Skip to content
This repository has been archived by the owner on Jan 24, 2024. It is now read-only.

Commit

Permalink
feat(spool): Added Spool staking, emits SPOOL and voSPOOL (#1275)
Browse files Browse the repository at this point in the history
  • Loading branch information
amikec authored Sep 1, 2022
1 parent 2a6f2a3 commit 1128a46
Show file tree
Hide file tree
Showing 16 changed files with 6,433 additions and 19 deletions.
810 changes: 810 additions & 0 deletions src/apps/spool/contracts/abis/spool-staking.json

Large diffs are not rendered by default.

1,173 changes: 1,173 additions & 0 deletions src/apps/spool/contracts/abis/spool-vospool.json

Large diffs are not rendered by default.

998 changes: 998 additions & 0 deletions src/apps/spool/contracts/ethers/SpoolStaking.ts

Large diffs are not rendered by default.

1,157 changes: 1,157 additions & 0 deletions src/apps/spool/contracts/ethers/SpoolVospool.ts

Large diffs are not rendered by default.

828 changes: 828 additions & 0 deletions src/apps/spool/contracts/ethers/factories/SpoolStaking__factory.ts

Large diffs are not rendered by default.

1,191 changes: 1,191 additions & 0 deletions src/apps/spool/contracts/ethers/factories/SpoolVospool__factory.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/apps/spool/contracts/ethers/factories/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
export { SpoolStaking__factory } from './SpoolStaking__factory';
export { SpoolVault__factory } from './SpoolVault__factory';
export { SpoolVospool__factory } from './SpoolVospool__factory';
4 changes: 4 additions & 0 deletions src/apps/spool/contracts/ethers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
export type { SpoolStaking } from './SpoolStaking';
export type { SpoolVault } from './SpoolVault';
export type { SpoolVospool } from './SpoolVospool';
export * as factories from './factories';
export { SpoolStaking__factory } from './factories/SpoolStaking__factory';
export { SpoolVault__factory } from './factories/SpoolVault__factory';
export { SpoolVospool__factory } from './factories/SpoolVospool__factory';
10 changes: 10 additions & 0 deletions src/apps/spool/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { IAppToolkit, APP_TOOLKIT } from '~app-toolkit/app-toolkit.interface';
import { ContractFactory } from '~contract/contracts';
import { Network } from '~types/network.interface';

import { SpoolStaking__factory } from './ethers';
import { SpoolVault__factory } from './ethers';
import { SpoolVospool__factory } from './ethers';

// eslint-disable-next-line
type ContractOpts = { address: string; network: Network };
Expand All @@ -15,9 +17,17 @@ export class SpoolContractFactory extends ContractFactory {
super((network: Network) => appToolkit.getNetworkProvider(network));
}

spoolStaking({ address, network }: ContractOpts) {
return SpoolStaking__factory.connect(address, this.appToolkit.getNetworkProvider(network));
}
spoolVault({ address, network }: ContractOpts) {
return SpoolVault__factory.connect(address, this.appToolkit.getNetworkProvider(network));
}
spoolVospool({ address, network }: ContractOpts) {
return SpoolVospool__factory.connect(address, this.appToolkit.getNetworkProvider(network));
}
}

export type { SpoolStaking } from './ethers';
export type { SpoolVault } from './ethers';
export type { SpoolVospool } from './ethers';
99 changes: 83 additions & 16 deletions src/apps/spool/ethereum/spool.balance-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import { Inject } from '@nestjs/common';
import { parseEther } from 'ethers/lib/utils';
import { gql } from 'graphql-request';

import { drillBalance } from '~app-toolkit';
import { APP_TOOLKIT, IAppToolkit } from '~app-toolkit/app-toolkit.interface';
import { Register } from '~app-toolkit/decorators';
import { presentBalanceFetcherResponse } from '~app-toolkit/helpers/presentation/balance-fetcher-response.present';
import { SpoolContractFactory } from '~apps/spool';
import { STAKING_ADDRESS, SUBGRAPH_API_BASE_URL, VOSPOOL_ADDRESS } from '~apps/spool/ethereum/spool.constants';
import { ResolveBalancesProps, UserSpoolStaking } from '~apps/spool/ethereum/spool.types';
import { BalanceFetcher } from '~balance/balance-fetcher.interface';
import { IMulticallWrapper } from '~multicall';
import { ContractPosition } from '~position/position.interface';
import { isSupplied } from '~position/position.utils';
import { isClaimable, isSupplied, isVesting } from '~position/position.utils';
import { Network } from '~types/network.interface';

import { SPOOL_DEFINITION } from '../spool.definition';

const network = Network.ETHEREUM_MAINNET;

type VaultDataProps = {
strategies: string[];
};

type VaultPosition = ContractPosition<VaultDataProps>;

type ResolveBalancesProps = { address: string; contractPosition: VaultPosition; multicall: IMulticallWrapper };
const spoolStakedQuery = gql`
query getStaking($address: String!) {
userSpoolStaking(id: $address) {
id
spoolStaked
}
}
`;

@Register.BalanceFetcher(SPOOL_DEFINITION.id, network)
export class EthereumSpoolBalanceFetcher implements BalanceFetcher {
Expand All @@ -31,7 +34,23 @@ export class EthereumSpoolBalanceFetcher implements BalanceFetcher {
private readonly spoolContractFactory: SpoolContractFactory;

async getBalances(address: string) {
const balances = await this.appToolkit.helpers.contractPositionBalanceHelper.getContractPositionBalances({
const vaultBalances = await this.getVaultBalances(address);
const stakingBalances = await this.getStakingBalances(address);

return presentBalanceFetcherResponse([
{
label: 'Vaults',
assets: vaultBalances,
},
{
label: 'Staking',
assets: stakingBalances,
},
]);
}

async getVaultBalances(address: string) {
return await this.appToolkit.helpers.contractPositionBalanceHelper.getContractPositionBalances({
address,
appId: SPOOL_DEFINITION.id,
groupId: SPOOL_DEFINITION.groups.vault.id,
Expand All @@ -54,12 +73,60 @@ export class EthereumSpoolBalanceFetcher implements BalanceFetcher {
return [drillBalance(suppliedToken, balance.toString())];
},
});
}

return presentBalanceFetcherResponse([
{
label: 'Vaults',
assets: balances,
/**
* Fetch staked amount, generated voting power and claimable rewards
* - SpoolStaking can emit arbitrary tokens
* - VoSpoolRewards emits SPOOL
* - VoSPOOL is gradually unlocked over X weeks, reset to 0 if un-staked
* @param address
*/
async getStakingBalances(address: string) {
return await this.appToolkit.helpers.contractPositionBalanceHelper.getContractPositionBalances({
address,
appId: SPOOL_DEFINITION.id,
groupId: SPOOL_DEFINITION.groups.staking.id,
network: Network.ETHEREUM_MAINNET,
resolveBalances: async ({ address, contractPosition, multicall }) => {
const graphHelper = this.appToolkit.helpers.theGraphHelper;
const staking = this.spoolContractFactory.spoolStaking({ network, address: STAKING_ADDRESS });

const suppliedToken = contractPosition.tokens.find(isSupplied)!;
const govToken = contractPosition.tokens.find(isVesting)!;
const rewardTokens = contractPosition.tokens.filter(isClaimable);

const voSpool = this.spoolContractFactory.spoolVospool({ network, address: VOSPOOL_ADDRESS });
const [votingPowerRaw, voSpoolRewards, ...tokenRewards] = await Promise.all([
multicall.wrap(voSpool).getUserGradualVotingPower(address),
multicall.wrap(staking).callStatic.getUpdatedVoSpoolRewardAmount({ from: address }),
...rewardTokens.map(reward => multicall.wrap(staking).earned(reward.address, address)),
]);

const stakedSpool = await graphHelper.requestGraph<UserSpoolStaking>({
endpoint: SUBGRAPH_API_BASE_URL,
query: spoolStakedQuery,
variables: { address },
});

const stakedAmount = parseEther(stakedSpool?.userSpoolStaking?.spoolStaked || '0').toString();

const rewardBalances = rewardTokens.map((token, idx) => {
// Add voSPOOL rewards (always in SPOOL)
const rewards =
token.address.toLowerCase() == suppliedToken.address.toLowerCase()
? tokenRewards[idx].add(voSpoolRewards)
: tokenRewards[idx];

return drillBalance(token, rewards.toString());
});

return [
drillBalance(suppliedToken, stakedAmount),
drillBalance(govToken, votingPowerRaw.toString()),
...rewardBalances,
];
},
]);
});
}
}
4 changes: 4 additions & 0 deletions src/apps/spool/ethereum/spool.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const VOSPOOL_ADDRESS = '0xaf56d16a7fe479f2fcd48ff567ff589cb2d2a0e9';
export const STAKING_ADDRESS = '0xc3160c5cc63b6116dd182faa8393d3ad9313e213';
export const SUBGRAPH_API_BASE_URL = 'https://api.thegraph.com/subgraphs/name/spoolfi/spool';
export const ANALYTICS_API_BASE_URL = 'https://analytics.spool.fi';
131 changes: 131 additions & 0 deletions src/apps/spool/ethereum/spool.staking.contract-position-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Inject } from '@nestjs/common';
import { BigNumber } from 'ethers';
import { gql } from 'graphql-request';
import _ from 'lodash';

import { APP_TOOLKIT, IAppToolkit } from '~app-toolkit/app-toolkit.interface';
import { Register } from '~app-toolkit/decorators';
import { buildDollarDisplayItem, buildNumberDisplayItem } from '~app-toolkit/helpers/presentation/display-item.present';
import { getImagesFromToken } from '~app-toolkit/helpers/presentation/image.present';
import { SpoolContractFactory } from '~apps/spool';
import { STAKING_ADDRESS, SUBGRAPH_API_BASE_URL, VOSPOOL_ADDRESS } from '~apps/spool/ethereum/spool.constants';
import { StakingReward } from '~apps/spool/ethereum/spool.types';
import { ContractType } from '~position/contract.interface';
import { PositionFetcher } from '~position/position-fetcher.interface';
import { ContractPosition } from '~position/position.interface';
import { claimable, supplied, vesting } from '~position/position.utils';
import { BaseToken } from '~position/token.interface';
import { Network } from '~types/network.interface';

import { SPOOL_DEFINITION } from '../spool.definition';

const appId = SPOOL_DEFINITION.id;
const groupId = SPOOL_DEFINITION.groups.staking.id;
const spoolTokenAddress = SPOOL_DEFINITION.token!.address;
const network = Network.ETHEREUM_MAINNET;
const big10 = BigNumber.from(10);

const rewardsQuery = gql`
query {
stakingRewardTokens {
id
token {
id
name
symbol
decimals
}
isRemoved
endTime
startTime
}
}
`;

@Register.ContractPositionFetcher({ appId, groupId, network })
export class EthereumSpoolStakingContractPositionFetcher implements PositionFetcher<ContractPosition> {
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(SpoolContractFactory) private readonly spoolContractFactory: SpoolContractFactory,
) {}

async getPositions() {
const multicall = this.appToolkit.getMulticall(network);
const stakingRewards = await this.appToolkit.helpers.theGraphHelper.requestGraph<StakingReward>({
endpoint: SUBGRAPH_API_BASE_URL,
query: rewardsQuery,
});

const now = parseInt(String(new Date().getTime() / 1000));
const voSpoolContract = this.spoolContractFactory.spoolVospool({ network, address: VOSPOOL_ADDRESS });
const [voSpoolSymbol, voSpoolDecimals, votingPowerRaw] = await Promise.all([
multicall.wrap(voSpoolContract).symbol(),
multicall.wrap(voSpoolContract).decimals(),
multicall.wrap(voSpoolContract).getTotalGradualVotingPower(),
]);

const stakedToken = (await this.appToolkit.getBaseTokenPrice({ network, address: spoolTokenAddress }))!;
const govToken: BaseToken = {
type: ContractType.BASE_TOKEN,
address: VOSPOOL_ADDRESS,
network,
price: 0,
symbol: voSpoolSymbol,
decimals: voSpoolDecimals,
};

// SpoolStaking can emit arbitrary tokens
// VoSpoolRewards always emits SPOOL
const rewardAddresses = _.uniq(
stakingRewards.stakingRewardTokens
.filter(reward => !reward.isRemoved && parseInt(reward.startTime) >= now && parseInt(reward.endTime) <= now)
.map(reward => reward.token.id)
.concat([spoolTokenAddress]),
);

const rewardTokens = await Promise.all(
rewardAddresses.map(address => this.appToolkit.getBaseTokenPrice({ network, address })),
);

const tokens = rewardTokens.filter((token): token is BaseToken => token !== null).map(claimable);
tokens.push(supplied(stakedToken!));
tokens.push(vesting(govToken));

const spoolContract = this.spoolContractFactory.erc20({ network, address: spoolTokenAddress });
const totalStaked = await spoolContract.balanceOf(STAKING_ADDRESS);
const spoolStaked = totalStaked.div(big10.pow(stakedToken!.decimals)).toNumber();
const totalAccVoSpool = votingPowerRaw.div(big10.pow(voSpoolDecimals)).toNumber();

const pricePrecision = 10 ** 10;
const tvl = BigNumber.from(pricePrecision * stakedToken!.price)
.mul(totalStaked)
.div(pricePrecision)
.div(big10.pow(stakedToken!.decimals))
.toNumber();

const position: ContractPosition = {
type: ContractType.POSITION,
appId,
groupId,
address: STAKING_ADDRESS,
network,
tokens,
dataProps: {
tvl,
spoolStaked,
totalAccVoSpool,
},
displayProps: {
label: 'SPOOL Staking',
images: getImagesFromToken(stakedToken),
statsItems: [
{ label: 'TVL', value: buildDollarDisplayItem(tvl) },
{ label: 'SPOOL Staked', value: buildNumberDisplayItem(spoolStaked) },
{ label: 'voSPOOL Accumulated', value: buildNumberDisplayItem(totalAccVoSpool) },
],
},
};

return _.compact([position]);
}
}
33 changes: 33 additions & 0 deletions src/apps/spool/ethereum/spool.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { IMulticallWrapper } from '~multicall';
import { ContractPosition } from '~position/position.interface';

export type SpoolVaults = {
spools: VaultDetails[];
};
Expand Down Expand Up @@ -65,3 +68,33 @@ export type Platform = {
ecosystemFeeSize: string;
};
};

export type StakingReward = {
stakingRewardTokens: {
id: string;
token: {
id: string;
name: string;
symbol: string;
decimals: number;
};
isRemoved: boolean;
endTime: string;
startTime: string;
}[];
};

export type UserSpoolStaking = {
userSpoolStaking: {
id: string;
spoolStaked: string;
};
};

export type VaultDataProps = {
strategies: string[];
};

export type VaultPosition = ContractPosition<VaultDataProps>;

export type ResolveBalancesProps = { address: string; contractPosition: VaultPosition; multicall: IMulticallWrapper };
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildStringDisplayItem,
} from '~app-toolkit/helpers/presentation/display-item.present';
import { SpoolContractFactory } from '~apps/spool';
import { ANALYTICS_API_BASE_URL, SUBGRAPH_API_BASE_URL } from '~apps/spool/ethereum/spool.constants';
import { ContractType } from '~position/contract.interface';
import { WithMetaType } from '~position/display.interface';
import { PositionFetcher } from '~position/position-fetcher.interface';
Expand All @@ -26,8 +27,6 @@ import { Platform, RewardAnalytics, VaultDetails, SpoolVaults, StrategyAnalytics
const appId = SPOOL_DEFINITION.id;
const groupId = SPOOL_DEFINITION.groups.vault.id;
const network = Network.ETHEREUM_MAINNET;
const ANALYTICS_API_BASE_URL = 'https://analytics.spool.fi';
const SUBGRAPH_API_BASE_URL = 'https://api.thegraph.com/subgraphs/name/spoolfi/spool';

const platformQuery = gql`
query {
Expand Down
7 changes: 6 additions & 1 deletion src/apps/spool/spool.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const SPOOL_DEFINITION = appDefinition({
id: 'spool',
name: 'Spool',
description:
'Spool is decentralized middleware that allows anyone to create custom, diversified, and automated DeFi meta-strategies called “Spools”.',
'Spool is decentralized middleware that allows anyone to create custom, diversified, and automated DeFi meta-strategies called “Smart Vaults”.',
url: 'https://app.spool.fi',

groups: {
Expand All @@ -16,6 +16,11 @@ export const SPOOL_DEFINITION = appDefinition({
type: GroupType.POSITION,
label: 'Spools',
},
staking: {
id: 'staking',
type: GroupType.POSITION,
label: 'Staking',
},
},

tags: [AppTag.INFRASTRUCTURE, AppTag.YIELD_AGGREGATOR],
Expand Down
Loading

0 comments on commit 1128a46

Please sign in to comment.