From 7b8c250bd46c403bde680a7d33a95c9342c715e9 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 31 Oct 2024 12:43:27 +0100 Subject: [PATCH 1/6] added fountainhead strategy --- src/strategies/fountainhead/README.md | 40 +++++++++++ src/strategies/fountainhead/examples.json | 20 ++++++ src/strategies/fountainhead/index.ts | 84 +++++++++++++++++++++++ src/strategies/fountainhead/schema.json | 30 ++++++++ src/strategies/index.ts | 4 +- 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/strategies/fountainhead/README.md create mode 100644 src/strategies/fountainhead/examples.json create mode 100644 src/strategies/fountainhead/index.ts create mode 100644 src/strategies/fountainhead/schema.json diff --git a/src/strategies/fountainhead/README.md b/src/strategies/fountainhead/README.md new file mode 100644 index 000000000..86a4c5937 --- /dev/null +++ b/src/strategies/fountainhead/README.md @@ -0,0 +1,40 @@ +# Fountainhead + +Calulates the amount of tokens which are locked, staked, unlocked or in transition (stream-unlock). + +The _Locker__ is a contract holding tokens on behalf of users. Each account can have zero or one Locker. + +In order to calculate the amount of staked tokens, the strategy first checks if the account has a locker, using `ILockerFactory.getLockerAddress()`. +Then the amount is calculated using `ILocker.getStakedBalance()` and `ILocker.getAvailableBalance()`. + +Here is an example of parameters for Base Sepolia: + +```json +{ + "tokenAddress": "0x3A193aC8FcaCCDa817c174D04081C105154a8441", + "lockerFactoryAddress": "0xeFE0b1044c26b8050F94A73B7213394D2E0aa504" +} +``` + +## Dev + +Run test with +``` +yarn test --strategy=fountainhead +``` + +0x7269B0c7C831598465a9EB17F6c5a03331353dAF has locker 0x37db1380669155d6080c04a5e6db029e306cd964 +0x6e7A82059a9D58B4D603706D478d04D1f961107a has locker 0x56ba69c4fb8d62ed5a067d79cee01fec0a023c0a +0x264Ff25e609363cf738e238CBc7B680300509BED has locker 0x664409c2bb818f7ccfb015891f789b4b52e94129 + +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_FACTOR "getLockerAddress(address)" +``` diff --git a/src/strategies/fountainhead/examples.json b/src/strategies/fountainhead/examples.json new file mode 100644 index 000000000..d6a67c006 --- /dev/null +++ b/src/strategies/fountainhead/examples.json @@ -0,0 +1,20 @@ +[ + { + "name": "Example query", + "strategy": { + "name": "fountainhead", + "params": { + "tokenAddress": "0x3A193aC8FcaCCDa817c174D04081C105154a8441", + "lockerFactoryAddress": "0xeFE0b1044c26b8050F94A73B7213394D2E0aa504" + } + }, + "network": "84532", + "addresses": [ + "0x7269B0c7C831598465a9EB17F6c5a03331353dAF", + "0x6e7A82059a9D58B4D603706D478d04D1f961107a", + "0x264Ff25e609363cf738e238CBc7B680300509BED", + "0x5782bd439d3019f61bfac53f6358c30c3566737c" + ], + "snapshot": 17001580 + } +] diff --git a/src/strategies/fountainhead/index.ts b/src/strategies/fountainhead/index.ts new file mode 100644 index 000000000..716a85d26 --- /dev/null +++ b/src/strategies/fountainhead/index.ts @@ -0,0 +1,84 @@ +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 getLockerAddress(address user) external view returns (address)', + 'function isLockerCreated(address locker) external view returns (bool isCreated)', + // Locker + 'function getAvailableBalance() external view returns(uint256)', + 'function getStakedBalance() external view returns(uint256)', + // Token + 'function balanceOf(address account) external view returns (uint256)' +]; + +// Super Tokens always have 18 decimals +const DECIMALS = 18; + +export async function strategy( + space, + network, + provider, + addresses, + options, + snapshot +): Promise> { + const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; + + const mCall1 = new Multicaller(network, provider, abi, { blockTag }); + + // lockerFactory.getLockerAddress(). Returns the deterministic address. + // TODO: here it would be good to also get a bool "exists" + addresses.forEach((address) => + mCall1.call(address, options.lockerFactoryAddress, 'getLockerAddress', [address]) + ); + const mc1Result: Record = await mCall1.execute(); + + const lockerAddresses = Object.values(mc1Result); + + // check if they exist (can't yet do that in a single call) + const mCall2 = new Multicaller(network, provider, abi, { blockTag }); + lockerAddresses.forEach((lockerAddress) => + mCall2.call(lockerAddress, options.lockerFactoryAddress, 'isLockerCreated', [lockerAddress]) + ); + const mc2Result: Record = await mCall2.execute(); + const existingLockers = Object.keys(mc2Result).filter(key => mc2Result[key] === true); + + // Now we have all locker adresses and can get the amounts for each user + const mCall3 = new Multicaller(network, provider, abi, { blockTag }); + existingLockers.forEach((lockerAddress) => + mCall3.call(`available-${lockerAddress}`, lockerAddress, 'getAvailableBalance', []) + ); + existingLockers.forEach((lockerAddress) => + mCall3.call(`staked-${lockerAddress}`, lockerAddress, 'getStakedBalance', []) + ); + const mc3Result: Record = await mCall3.execute(); + + // Create a map for each address to the sum of available and staked balance + const balanceMap = Object.fromEntries( + addresses.map(address => { + const lockerAddress = mc1Result[address]; + if (lockerAddress) { + const availableBalance = mc3Result[`available-${lockerAddress}`] || BigNumber.from(0); + const stakedBalance = mc3Result[`staked-${lockerAddress}`] || BigNumber.from(0); + const totalBalance = BigNumber.from(availableBalance).add(BigNumber.from(stakedBalance)); + return [address, totalBalance]; + } else { + return [address, BigNumber.from(0)]; + } + }) + ); + + // Return in the required format + return Object.fromEntries( + Object.entries(balanceMap).map(([address, balance]) => [ + address, + parseFloat(formatUnits(balance, DECIMALS)) + ]) + ); +} diff --git a/src/strategies/fountainhead/schema.json b/src/strategies/fountainhead/schema.json new file mode 100644 index 000000000..ec085f104 --- /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 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) { From 6d56dc56da08bb4cc915fe8986c425b574cb8a95 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 4 Nov 2024 15:32:56 +0100 Subject: [PATCH 2/6] include unlocked balance, use new method getUserLocker() --- src/strategies/fountainhead/examples.json | 6 ++-- src/strategies/fountainhead/index.ts | 41 ++++++++++++----------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/strategies/fountainhead/examples.json b/src/strategies/fountainhead/examples.json index d6a67c006..1c84863cc 100644 --- a/src/strategies/fountainhead/examples.json +++ b/src/strategies/fountainhead/examples.json @@ -5,7 +5,7 @@ "name": "fountainhead", "params": { "tokenAddress": "0x3A193aC8FcaCCDa817c174D04081C105154a8441", - "lockerFactoryAddress": "0xeFE0b1044c26b8050F94A73B7213394D2E0aa504" + "lockerFactoryAddress": "0x8DaF7BF1a2052B6BDA0eC46619855Cec77DfbC76" } }, "network": "84532", @@ -13,8 +13,8 @@ "0x7269B0c7C831598465a9EB17F6c5a03331353dAF", "0x6e7A82059a9D58B4D603706D478d04D1f961107a", "0x264Ff25e609363cf738e238CBc7B680300509BED", - "0x5782bd439d3019f61bfac53f6358c30c3566737c" + "0x5782BD439d3019F61bFac53f6358C30c3566737C" ], - "snapshot": 17001580 + "snapshot": 17304060 } ] diff --git a/src/strategies/fountainhead/index.ts b/src/strategies/fountainhead/index.ts index 716a85d26..718f7ec02 100644 --- a/src/strategies/fountainhead/index.ts +++ b/src/strategies/fountainhead/index.ts @@ -8,8 +8,7 @@ export const version = '0.1.0'; // signatures of the methods we need const abi = [ // LockerFactory - 'function getLockerAddress(address user) external view returns (address)', - 'function isLockerCreated(address locker) external view returns (bool isCreated)', + 'function getUserLocker(address user) external view returns (bool isCreated, address lockerAddress)', // Locker 'function getAvailableBalance() external view returns(uint256)', 'function getStakedBalance() external view returns(uint256)', @@ -30,26 +29,27 @@ export async function strategy( ): Promise> { const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; - const mCall1 = new Multicaller(network, provider, abi, { blockTag }); - - // lockerFactory.getLockerAddress(). Returns the deterministic address. - // TODO: here it would be good to also get a bool "exists" + // get unlocked amounts held by address itself + const mCall0 = new Multicaller(network, provider, abi, { blockTag }); addresses.forEach((address) => - mCall1.call(address, options.lockerFactoryAddress, 'getLockerAddress', [address]) + mCall0.call(address, options.tokenAddress, 'balanceOf', [address]) ); - const mc1Result: Record = await mCall1.execute(); + const mc0Result: Record = await mCall0.execute(); - const lockerAddresses = Object.values(mc1Result); + const mCall1 = new Multicaller(network, provider, abi, { blockTag }); - // check if they exist (can't yet do that in a single call) - const mCall2 = new Multicaller(network, provider, abi, { blockTag }); - lockerAddresses.forEach((lockerAddress) => - mCall2.call(lockerAddress, options.lockerFactoryAddress, 'isLockerCreated', [lockerAddress]) + // lockerFactory.getUserLocker(). Returns the deterministic address and a bool "exists". + addresses.forEach((address) => + mCall1.call(address, options.lockerFactoryAddress, 'getUserLocker', [address]) ); - const mc2Result: Record = await mCall2.execute(); - const existingLockers = Object.keys(mc2Result).filter(key => mc2Result[key] === true); + const mc1Result: Record = await mCall1.execute(); + + // Filter out addresses of existing lockers + const existingLockers = Object.values(mc1Result) + .filter(result => result.isCreated === true) + .map(result => result.lockerAddress); - // Now we have all locker adresses and can get the amounts for each user + // Now we have all locker adresses and can get the staked and unstaked amounts for each address const mCall3 = new Multicaller(network, provider, abi, { blockTag }); existingLockers.forEach((lockerAddress) => mCall3.call(`available-${lockerAddress}`, lockerAddress, 'getAvailableBalance', []) @@ -59,14 +59,15 @@ export async function strategy( ); const mc3Result: Record = await mCall3.execute(); - // Create a map for each address to the sum of available and staked balance + // Create a map for each address to the cumulated balance const balanceMap = Object.fromEntries( addresses.map(address => { const lockerAddress = mc1Result[address]; if (lockerAddress) { - const availableBalance = mc3Result[`available-${lockerAddress}`] || BigNumber.from(0); - const stakedBalance = mc3Result[`staked-${lockerAddress}`] || BigNumber.from(0); - const totalBalance = BigNumber.from(availableBalance).add(BigNumber.from(stakedBalance)); + const unlockedBalance = BigNumber.from(mc0Result[address]); + const availableBalance = BigNumber.from(mc3Result[`available-${lockerAddress}`] || 0); + const stakedBalance = BigNumber.from(mc3Result[`staked-${lockerAddress}`] || 0); + const totalBalance = unlockedBalance.add(availableBalance).add(stakedBalance); return [address, totalBalance]; } else { return [address, BigNumber.from(0)]; From a79a32d92d4e81b53d8125acadd469c436714152 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 4 Nov 2024 18:21:59 +0100 Subject: [PATCH 3/6] add fontaine balances --- src/strategies/fountainhead/README.md | 16 +++- src/strategies/fountainhead/examples.json | 3 +- src/strategies/fountainhead/index.ts | 94 +++++++++++++++++++---- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/strategies/fountainhead/README.md b/src/strategies/fountainhead/README.md index 86a4c5937..e13ff93ca 100644 --- a/src/strategies/fountainhead/README.md +++ b/src/strategies/fountainhead/README.md @@ -6,6 +6,10 @@ The _Locker__ is a contract holding tokens on behalf of users. Each account can In order to calculate the amount of staked tokens, the strategy first checks if the account has a locker, using `ILockerFactory.getLockerAddress()`. Then the amount is calculated using `ILocker.getStakedBalance()` and `ILocker.getAvailableBalance()`. +Then the addresses of all fontaines created by the Locker are fetched, and their token balances. + +Note: the Locker contract puts no limit on the number of fontaines (other than the data type of its counter). +In practice we don't expect a high number of fontaines per locker. But in order to avoid a DoS vector, the strategy anyway limits the number of fontaines iterated. It does so by going from most recently created backwards in order to minimize the probability of missing active lockers. Here is an example of parameters for Base Sepolia: @@ -36,5 +40,15 @@ cast call --rpc-url $RPC "lockerOwner()" get the locker address for an account (zero if not exists): ``` -cast call --rpc-url $RPC $LOCKER_FACTOR "getLockerAddress(address)" +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: +``` +cast send --account testnet --rpc-url $RPC "unlock(uint128,address)" 604800 ``` diff --git a/src/strategies/fountainhead/examples.json b/src/strategies/fountainhead/examples.json index 1c84863cc..fd4034788 100644 --- a/src/strategies/fountainhead/examples.json +++ b/src/strategies/fountainhead/examples.json @@ -13,7 +13,8 @@ "0x7269B0c7C831598465a9EB17F6c5a03331353dAF", "0x6e7A82059a9D58B4D603706D478d04D1f961107a", "0x264Ff25e609363cf738e238CBc7B680300509BED", - "0x5782BD439d3019F61bFac53f6358C30c3566737C" + "0x5782BD439d3019F61bFac53f6358C30c3566737C", + "0x4ee5D45eB79aEa04C02961a2e543bbAf5cec81B3" ], "snapshot": 17304060 } diff --git a/src/strategies/fountainhead/index.ts b/src/strategies/fountainhead/index.ts index 718f7ec02..0d0a6d25f 100644 --- a/src/strategies/fountainhead/index.ts +++ b/src/strategies/fountainhead/index.ts @@ -12,6 +12,8 @@ const abi = [ // 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)' ]; @@ -19,6 +21,9 @@ const abi = [ // 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; + export async function strategy( space, network, @@ -30,26 +35,26 @@ export async function strategy( const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; // get unlocked amounts held by address itself - const mCall0 = new Multicaller(network, provider, abi, { blockTag }); + const mCall1 = new Multicaller(network, provider, abi, { blockTag }); addresses.forEach((address) => - mCall0.call(address, options.tokenAddress, 'balanceOf', [address]) + mCall1.call(address, options.tokenAddress, 'balanceOf', [address]) ); - const mc0Result: Record = await mCall0.execute(); + const mc0Result: Record = await mCall1.execute(); - const mCall1 = new Multicaller(network, provider, abi, { blockTag }); + const mCall2 = new Multicaller(network, provider, abi, { blockTag }); // lockerFactory.getUserLocker(). Returns the deterministic address and a bool "exists". addresses.forEach((address) => - mCall1.call(address, options.lockerFactoryAddress, 'getUserLocker', [address]) + mCall2.call(address, options.lockerFactoryAddress, 'getUserLocker', [address]) ); - const mc1Result: Record = await mCall1.execute(); + const mc1Result: Record = await mCall2.execute(); // Filter out addresses of existing lockers const existingLockers = Object.values(mc1Result) .filter(result => result.isCreated === true) .map(result => result.lockerAddress); - // Now we have all locker adresses and can get the staked and unstaked amounts for each address + // Now we have all locker adresses and can get the staked and unstaked amounts for each address... const mCall3 = new Multicaller(network, provider, abi, { blockTag }); existingLockers.forEach((lockerAddress) => mCall3.call(`available-${lockerAddress}`, lockerAddress, 'getAvailableBalance', []) @@ -57,21 +62,64 @@ export async function strategy( existingLockers.forEach((lockerAddress) => mCall3.call(`staked-${lockerAddress}`, lockerAddress, 'getStakedBalance', []) ); + // and the fontaineCount + existingLockers.forEach((lockerAddress) => + mCall3.call(`fontaineCount-${lockerAddress}`, lockerAddress, 'fontaineCount', []) + ); const mc3Result: Record = await mCall3.execute(); + // now get all the fontaine addresses + const mCall4 = new Multicaller(network, provider, abi, { blockTag }); + existingLockers.forEach((lockerAddress) => { + const fontaineCount = Number(mc3Result[`fontaineCount-${lockerAddress}`]); + // 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 mc4Result: Record = await mCall4.execute(); + + // compile a map of locker -> fontaineCount + const fontainesCountMap = Object.fromEntries( + existingLockers.map((lockerAddress) => [lockerAddress, Number(mc3Result[`fontaineCount-${lockerAddress}`])]) + ); + + // now we have all the fontaines, we can get the balance for each fontaine + const mCall5 = new Multicaller(network, provider, abi, { blockTag }); + existingLockers.forEach((lockerAddress) => { + const fontaineCount = fontainesCountMap[lockerAddress]; + // iterate over each fontaine index + for (let i = 0; i < fontaineCount; i++) { + const fontaineAddress = mc4Result[`${lockerAddress}-${i}`]; + // Get the token balance of the fontaine contract + mCall5.call(`${lockerAddress}-${i}`, options.tokenAddress, 'balanceOf', [fontaineAddress]); + } + }); + const mc5Result: Record = await mCall5.execute(); + + // Note: all 5 allowed multicalls are "used". We could however "free" one by combining mCall1 and mCall5 (balance queries). + // Create a map for each address to the cumulated balance const balanceMap = Object.fromEntries( addresses.map(address => { const lockerAddress = mc1Result[address]; - if (lockerAddress) { - const unlockedBalance = BigNumber.from(mc0Result[address]); - const availableBalance = BigNumber.from(mc3Result[`available-${lockerAddress}`] || 0); - const stakedBalance = BigNumber.from(mc3Result[`staked-${lockerAddress}`] || 0); - const totalBalance = unlockedBalance.add(availableBalance).add(stakedBalance); - return [address, totalBalance]; - } else { - return [address, BigNumber.from(0)]; - } + const unlockedBalance = BigNumber.from(mc0Result[address]); + + // if no locker -> return unlocked balance + if (!lockerAddress) return [address, unlockedBalance]; + + // else add all balances in locker and related fontaines + const availableBalance = BigNumber.from(mc3Result[`available-${lockerAddress}`] || 0); + const stakedBalance = BigNumber.from(mc3Result[`staked-${lockerAddress}`] || 0); + const fontaineBalances = getFontaineBalancesForLocker(lockerAddress, fontainesCountMap[lockerAddress], mc5Result); + + const totalBalance = unlockedBalance + .add(availableBalance) + .add(stakedBalance) + .add(fontaineBalances); + + return [address, totalBalance]; }) ); @@ -83,3 +131,17 @@ export async function strategy( ]) ); } + +// 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 From 4580b0c48ba3ee80bc3b2262efd536b8ca0a8ce5 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 5 Nov 2024 12:23:40 +0100 Subject: [PATCH 4/6] simpify code --- src/strategies/fountainhead/index.ts | 97 +++++++++++++++------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/src/strategies/fountainhead/index.ts b/src/strategies/fountainhead/index.ts index 0d0a6d25f..a9699d459 100644 --- a/src/strategies/fountainhead/index.ts +++ b/src/strategies/fountainhead/index.ts @@ -24,6 +24,12 @@ 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, @@ -34,90 +40,89 @@ export async function strategy( ): Promise> { const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; - // get unlocked amounts held by address itself + // 1. GET UNLOCKED BALANCES const mCall1 = new Multicaller(network, provider, abi, { blockTag }); addresses.forEach((address) => mCall1.call(address, options.tokenAddress, 'balanceOf', [address]) ); - const mc0Result: Record = await mCall1.execute(); + 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 mc1Result: Record = await mCall2.execute(); - - // Filter out addresses of existing lockers - const existingLockers = Object.values(mc1Result) - .filter(result => result.isCreated === true) - .map(result => result.lockerAddress); + 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); - // Now we have all locker adresses and can get the staked and unstaked amounts for each address... + // 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', []) - ); - existingLockers.forEach((lockerAddress) => - mCall3.call(`staked-${lockerAddress}`, lockerAddress, 'getStakedBalance', []) - ); - // and the fontaineCount - existingLockers.forEach((lockerAddress) => - mCall3.call(`fontaineCount-${lockerAddress}`, lockerAddress, 'fontaineCount', []) - ); - const mc3Result: Record = await mCall3.execute(); + 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}`]) + }; + }); - // now get all the fontaine addresses + // 4. GET ALL THE FONTAINES const mCall4 = new Multicaller(network, provider, abi, { blockTag }); existingLockers.forEach((lockerAddress) => { - const fontaineCount = Number(mc3Result[`fontaineCount-${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 mc4Result: Record = await mCall4.execute(); - - // compile a map of locker -> fontaineCount - const fontainesCountMap = Object.fromEntries( - existingLockers.map((lockerAddress) => [lockerAddress, Number(mc3Result[`fontaineCount-${lockerAddress}`])]) - ); + const fontaineAddrs: Record = await mCall4.execute(); - // now we have all the fontaines, we can get the balance for each fontaine + // 5. GET THE FONTAINE'S BALANCES const mCall5 = new Multicaller(network, provider, abi, { blockTag }); existingLockers.forEach((lockerAddress) => { - const fontaineCount = fontainesCountMap[lockerAddress]; - // iterate over each fontaine index - for (let i = 0; i < fontaineCount; i++) { - const fontaineAddress = mc4Result[`${lockerAddress}-${i}`]; - // Get the token balance of the fontaine contract + for (let i = 0; i < lockerStates[lockerAddress].fontaineCount; i++) { + const fontaineAddress = fontaineAddrs[`${lockerAddress}-${i}`]; mCall5.call(`${lockerAddress}-${i}`, options.tokenAddress, 'balanceOf', [fontaineAddress]); } }); - const mc5Result: Record = await mCall5.execute(); + const fontaineBalances: Record = await mCall5.execute(); - // Note: all 5 allowed multicalls are "used". We could however "free" one by combining mCall1 and mCall5 (balance queries). + // Note: all 5 allowed multicalls are "used". + // If needed we could "free" one by combining the balance queries of mCall1 and mCall5 - // Create a map for each address to the cumulated balance - const balanceMap = Object.fromEntries( + // SUM UP ALL THE BALANCES + const balances = Object.fromEntries( addresses.map(address => { - const lockerAddress = mc1Result[address]; - const unlockedBalance = BigNumber.from(mc0Result[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 = BigNumber.from(mc3Result[`available-${lockerAddress}`] || 0); - const stakedBalance = BigNumber.from(mc3Result[`staked-${lockerAddress}`] || 0); - const fontaineBalances = getFontaineBalancesForLocker(lockerAddress, fontainesCountMap[lockerAddress], mc5Result); + 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(fontaineBalances); + .add(fontaineBalanceSum); return [address, totalBalance]; }) @@ -125,7 +130,7 @@ export async function strategy( // Return in the required format return Object.fromEntries( - Object.entries(balanceMap).map(([address, balance]) => [ + Object.entries(balances).map(([address, balance]) => [ address, parseFloat(formatUnits(balance, DECIMALS)) ]) From 69bf8de8b709fb0d442d116d3eb967ac7722d7ca Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 5 Nov 2024 12:34:33 +0100 Subject: [PATCH 5/6] update README --- src/strategies/fountainhead/README.md | 32 +++++++++------------------ 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/strategies/fountainhead/README.md b/src/strategies/fountainhead/README.md index e13ff93ca..286071d8f 100644 --- a/src/strategies/fountainhead/README.md +++ b/src/strategies/fountainhead/README.md @@ -2,23 +2,15 @@ Calulates the amount of tokens which are locked, staked, unlocked or in transition (stream-unlock). -The _Locker__ is a contract holding tokens on behalf of users. Each account can have zero or one Locker. +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 staked tokens, the strategy first checks if the account has a locker, using `ILockerFactory.getLockerAddress()`. -Then the amount is calculated using `ILocker.getStakedBalance()` and `ILocker.getAvailableBalance()`. -Then the addresses of all fontaines created by the Locker are fetched, and their token balances. +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 (other than the data type of its counter). -In practice we don't expect a high number of fontaines per locker. But in order to avoid a DoS vector, the strategy anyway limits the number of fontaines iterated. It does so by going from most recently created backwards in order to minimize the probability of missing active lockers. - -Here is an example of parameters for Base Sepolia: - -```json -{ - "tokenAddress": "0x3A193aC8FcaCCDa817c174D04081C105154a8441", - "lockerFactoryAddress": "0xeFE0b1044c26b8050F94A73B7213394D2E0aa504" -} -``` +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 @@ -27,11 +19,7 @@ Run test with yarn test --strategy=fountainhead ``` -0x7269B0c7C831598465a9EB17F6c5a03331353dAF has locker 0x37db1380669155d6080c04a5e6db029e306cd964 -0x6e7A82059a9D58B4D603706D478d04D1f961107a has locker 0x56ba69c4fb8d62ed5a067d79cee01fec0a023c0a -0x264Ff25e609363cf738e238CBc7B680300509BED has locker 0x664409c2bb818f7ccfb015891f789b4b52e94129 - -cli helper commands during development using foundry's cast: +**cli helper commands during development using foundry's cast** get the owner of a locker: ``` @@ -48,7 +36,7 @@ create locker: cast send --account --rpc-url $RPC $LOCKER_FACTORY "createLockerContract()" ``` -unlock with 7 days unlock period: +unlock with 7 days unlock period (creates a Fontaine): ``` -cast send --account testnet --rpc-url $RPC "unlock(uint128,address)" 604800 +cast send --account --rpc-url $RPC "unlock(uint128,address)" 604800 ``` From 5192143dcd53db782adf24986e39240f84e87c90 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 5 Nov 2024 12:43:30 +0100 Subject: [PATCH 6/6] smol doc fix --- src/strategies/fountainhead/schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strategies/fountainhead/schema.json b/src/strategies/fountainhead/schema.json index ec085f104..4e1d3dc5c 100644 --- a/src/strategies/fountainhead/schema.json +++ b/src/strategies/fountainhead/schema.json @@ -16,7 +16,7 @@ }, "lockerFactoryAddress": { "type": "string", - "title": "Locker contract address", + "title": "Locker factory contract address", "examples": ["e.g. 0xAcA744453C178F3D651e06A3459E2F242aa01789"], "pattern": "^0x[a-fA-F0-9]{40}$", "minLength": 42,