diff --git a/src/strategies/fountainhead/README.md b/src/strategies/fountainhead/README.md new file mode 100644 index 000000000..286071d8f --- /dev/null +++ b/src/strategies/fountainhead/README.md @@ -0,0 +1,42 @@ +# Fountainhead + +Calulates the amount of tokens which are locked, staked, unlocked or in transition (stream-unlock). + +A _Locker_ is a contract holding tokens on behalf of users. Each account can have zero or one Locker. +A _Fontaine_ is a contract stream-unlocking tokens. Each Locker can have zero or more Fontaines. + +In order to calculate the amount of tokens belonging to an address, the strategy first checks if the account has a locker, using `ILockerFactory.getLockerAddress()`. +Then the Locker balance is calculated using `ILocker.getStakedBalance()` and `ILocker.getAvailableBalance()`. +Then the addresses of all Fontaines created by the Locker are fetched, and their token balances queried. + +Note: the Locker contract puts no limit on the number of Fontaines which can be created for a Locker (other than the data type of its counter). +In practive, we don't expect Lockers to have any Fontaines. But since it's theoretically possible, the strategy limits the number of Fontaines per Locker it will query. This limit is defined in `MAX_FONTAINES_PER_LOCKER`. By iterating from most to least recently created Fontaine, the probability of omitting Fontaines which are still active is minimized. + +## Dev + +Run test with +``` +yarn test --strategy=fountainhead +``` + +**cli helper commands during development using foundry's cast** + +get the owner of a locker: +``` +cast call --rpc-url $RPC "lockerOwner()" +``` + +get the locker address for an account (zero if not exists): +``` +cast call --rpc-url $RPC $LOCKER_FACTORY "getLockerAddress(address)" +``` + +create locker: +``` +cast send --account --rpc-url $RPC $LOCKER_FACTORY "createLockerContract()" +``` + +unlock with 7 days unlock period (creates a Fontaine): +``` +cast send --account --rpc-url $RPC "unlock(uint128,address)" 604800 +``` diff --git a/src/strategies/fountainhead/examples.json b/src/strategies/fountainhead/examples.json new file mode 100644 index 000000000..fd4034788 --- /dev/null +++ b/src/strategies/fountainhead/examples.json @@ -0,0 +1,21 @@ +[ + { + "name": "Example query", + "strategy": { + "name": "fountainhead", + "params": { + "tokenAddress": "0x3A193aC8FcaCCDa817c174D04081C105154a8441", + "lockerFactoryAddress": "0x8DaF7BF1a2052B6BDA0eC46619855Cec77DfbC76" + } + }, + "network": "84532", + "addresses": [ + "0x7269B0c7C831598465a9EB17F6c5a03331353dAF", + "0x6e7A82059a9D58B4D603706D478d04D1f961107a", + "0x264Ff25e609363cf738e238CBc7B680300509BED", + "0x5782BD439d3019F61bFac53f6358C30c3566737C", + "0x4ee5D45eB79aEa04C02961a2e543bbAf5cec81B3" + ], + "snapshot": 17304060 + } +] diff --git a/src/strategies/fountainhead/index.ts b/src/strategies/fountainhead/index.ts new file mode 100644 index 000000000..a9699d459 --- /dev/null +++ b/src/strategies/fountainhead/index.ts @@ -0,0 +1,152 @@ +import { BigNumberish, BigNumber } from '@ethersproject/bignumber'; +import { formatUnits } from '@ethersproject/units'; +import { Multicaller } from '../../utils'; + +export const author = 'd10r'; +export const version = '0.1.0'; + +// signatures of the methods we need +const abi = [ + // LockerFactory + 'function getUserLocker(address user) external view returns (bool isCreated, address lockerAddress)', + // Locker + 'function getAvailableBalance() external view returns(uint256)', + 'function getStakedBalance() external view returns(uint256)', + 'function fontaineCount() external view returns(uint16)', + 'function fontaines(uint256 unlockId) external view returns(address)', + // Token + 'function balanceOf(address account) external view returns (uint256)' +]; + +// Super Tokens always have 18 decimals +const DECIMALS = 18; + +// we must bound the number of fontaines per locker to avoid RPC timeouts +const MAX_FONTAINES_PER_LOCKER = 100; + +interface LockerState { + availableBalance: BigNumber; + stakedBalance: BigNumber; + fontaineCount: number; +} + +export async function strategy( + space, + network, + provider, + addresses, + options, + snapshot +): Promise> { + const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; + + // 1. GET UNLOCKED BALANCES + const mCall1 = new Multicaller(network, provider, abi, { blockTag }); + addresses.forEach((address) => + mCall1.call(address, options.tokenAddress, 'balanceOf', [address]) + ); + const unlockedBalances: Record = await mCall1.execute(); + + // 2. GET LOCKER ADDRESSES + const mCall2 = new Multicaller(network, provider, abi, { blockTag }); + // lockerFactory.getUserLocker(). Returns the deterministic address and a bool "exists". + addresses.forEach((address) => + mCall2.call(address, options.lockerFactoryAddress, 'getUserLocker', [address]) + ); + const mCall2Result: Record = await mCall2.execute(); + const lockerByAddress = Object.fromEntries( + Object.entries(mCall2Result) + .filter(([_, { isCreated }]) => isCreated) + .map(([addr, { lockerAddress }]) => [addr, lockerAddress]) + ); + const existingLockers = Object.values(lockerByAddress); + + // 3. GET LOCKER STATE (available balance, staked balance, fontaine count) + const mCall3 = new Multicaller(network, provider, abi, { blockTag }); + existingLockers.forEach((lockerAddress) => { + mCall3.call(`available-${lockerAddress}`, lockerAddress, 'getAvailableBalance', []); + mCall3.call(`staked-${lockerAddress}`, lockerAddress, 'getStakedBalance', []); + mCall3.call(`fontaineCount-${lockerAddress}`, lockerAddress, 'fontaineCount', []); + }); + const mCall3Result: Record = await mCall3.execute(); + // Transform raw results into structured data + const lockerStates: Record = {}; + existingLockers.forEach((lockerAddress) => { + lockerStates[lockerAddress] = { + availableBalance: BigNumber.from(mCall3Result[`available-${lockerAddress}`] || 0), + stakedBalance: BigNumber.from(mCall3Result[`staked-${lockerAddress}`] || 0), + fontaineCount: Number(mCall3Result[`fontaineCount-${lockerAddress}`]) + }; + }); + + // 4. GET ALL THE FONTAINES + const mCall4 = new Multicaller(network, provider, abi, { blockTag }); + existingLockers.forEach((lockerAddress) => { + const fontaineCount = lockerStates[lockerAddress].fontaineCount; + // iterate backwards, so we have fontaines ordered by creation time (most recent first). + // this makes it unlikely to miss fontaines which are still active. + for (let i = fontaineCount-1; i >= 0 && i >= fontaineCount-MAX_FONTAINES_PER_LOCKER; i--) { + mCall4.call(`${lockerAddress}-${i}`, lockerAddress, 'fontaines', [i]) + } + }); + const fontaineAddrs: Record = await mCall4.execute(); + + // 5. GET THE FONTAINE'S BALANCES + const mCall5 = new Multicaller(network, provider, abi, { blockTag }); + existingLockers.forEach((lockerAddress) => { + for (let i = 0; i < lockerStates[lockerAddress].fontaineCount; i++) { + const fontaineAddress = fontaineAddrs[`${lockerAddress}-${i}`]; + mCall5.call(`${lockerAddress}-${i}`, options.tokenAddress, 'balanceOf', [fontaineAddress]); + } + }); + const fontaineBalances: Record = await mCall5.execute(); + + // Note: all 5 allowed multicalls are "used". + // If needed we could "free" one by combining the balance queries of mCall1 and mCall5 + + // SUM UP ALL THE BALANCES + const balances = Object.fromEntries( + addresses.map(address => { + const lockerAddress: string = lockerByAddress[address]; + const unlockedBalance = BigNumber.from(unlockedBalances[address]); + + // if no locker -> return unlocked balance + if (!lockerAddress) return [address, unlockedBalance]; + + // else add all balances in locker and related fontaines + const availableBalance = lockerStates[lockerAddress].availableBalance; + const stakedBalance = lockerStates[lockerAddress].stakedBalance; + const fontaineBalanceSum = getFontaineBalancesForLocker( + lockerAddress, lockerStates[lockerAddress].fontaineCount, fontaineBalances); + + const totalBalance = unlockedBalance + .add(availableBalance) + .add(stakedBalance) + .add(fontaineBalanceSum); + + return [address, totalBalance]; + }) + ); + + // Return in the required format + return Object.fromEntries( + Object.entries(balances).map(([address, balance]) => [ + address, + parseFloat(formatUnits(balance, DECIMALS)) + ]) + ); +} + +// helper function to sum up the fontaine balances for a given locker +function getFontaineBalancesForLocker( + lockerAddress: string, + fontaineCount: number, + balances: Record +): BigNumber { + return Array.from({ length: fontaineCount }) + .map((_, i) => BigNumber.from(balances[`balance-${lockerAddress}-${i}`] || 0)) + .reduce( + (sum, balance) => sum.add(balance), + BigNumber.from(0) + ); +} \ No newline at end of file diff --git a/src/strategies/fountainhead/schema.json b/src/strategies/fountainhead/schema.json new file mode 100644 index 000000000..4e1d3dc5c --- /dev/null +++ b/src/strategies/fountainhead/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/Strategy", + "definitions": { + "Strategy": { + "title": "Strategy", + "type": "object", + "properties": { + "tokenAddress": { + "type": "string", + "title": "Token contract address", + "examples": ["e.g. 0x6C210F071c7246C452CAC7F8BaA6dA53907BbaE1"], + "pattern": "^0x[a-fA-F0-9]{40}$", + "minLength": 42, + "maxLength": 42 + }, + "lockerFactoryAddress": { + "type": "string", + "title": "Locker factory contract address", + "examples": ["e.g. 0xAcA744453C178F3D651e06A3459E2F242aa01789"], + "pattern": "^0x[a-fA-F0-9]{40}$", + "minLength": 42, + "maxLength": 42 + } + }, + "required": ["tokenAddress", "lockerFactoryAddress"], + "additionalProperties": false + } + } +} diff --git a/src/strategies/index.ts b/src/strategies/index.ts index 5a3ac613a..d189cae6d 100644 --- a/src/strategies/index.ts +++ b/src/strategies/index.ts @@ -462,6 +462,7 @@ import * as moxie from './moxie'; import * as stakingAmountDurationLinear from './staking-amount-duration-linear'; import * as stakingAmountDurationExponential from './staking-amount-duration-exponential'; import * as sacraSubgraph from './sacra-subgraph'; +import * as fountainhead from './fountainhead'; const strategies = { 'delegatexyz-erc721-balance-of': delegatexyzErc721BalanceOf, @@ -934,7 +935,8 @@ const strategies = { moxie: moxie, 'staking-amount-duration-linear': stakingAmountDurationLinear, 'staking-amount-duration-exponential': stakingAmountDurationExponential, - 'sacra-subgraph': sacraSubgraph + 'sacra-subgraph': sacraSubgraph, + fountainhead }; Object.keys(strategies).forEach(function (strategyName) {