Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[fountainhead] Add Fountainhead strategy #1624

Merged
merged 7 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/strategies/fountainhead/README.md
Original file line number Diff line number Diff line change
@@ -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 <LOCKER_ADDRESS> "lockerOwner()"
```

get the locker address for an account (zero if not exists):
```
cast call --rpc-url $RPC $LOCKER_FACTORY "getLockerAddress(address)" <ACCOUNT_ADDRESS>
```

create locker:
```
cast send --account <ACCOUNT_NAME>--rpc-url $RPC $LOCKER_FACTORY "createLockerContract()"
```

unlock with 7 days unlock period (creates a Fontaine):
```
cast send --account <ACCOUNT_NAME> --rpc-url $RPC <LOCKER_ADDRESS> "unlock(uint128,address)" 604800 <RECEIVER_ADDRESS>
```
21 changes: 21 additions & 0 deletions src/strategies/fountainhead/examples.json
Original file line number Diff line number Diff line change
@@ -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
}
]
152 changes: 152 additions & 0 deletions src/strategies/fountainhead/index.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, number>> {
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<string, BigNumberish> = 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<string, any> = 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<string, BigNumberish> = await mCall3.execute();
// Transform raw results into structured data
const lockerStates: Record<string, LockerState> = {};
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<string, string> = 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<string, BigNumberish> = 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<string, BigNumberish>
): BigNumber {
return Array.from({ length: fontaineCount })
.map((_, i) => BigNumber.from(balances[`balance-${lockerAddress}-${i}`] || 0))
.reduce(
(sum, balance) => sum.add(balance),
BigNumber.from(0)
);
}
30 changes: 30 additions & 0 deletions src/strategies/fountainhead/schema.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
4 changes: 3 additions & 1 deletion src/strategies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading