Skip to content

Commit

Permalink
Implement staking rewards fetching
Browse files Browse the repository at this point in the history
  • Loading branch information
jmrossy committed Dec 21, 2023
1 parent 109b23e commit 415b44d
Show file tree
Hide file tree
Showing 26 changed files with 556 additions and 44 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
Expand Down
2 changes: 1 addition & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const securityHeaders = [
key: 'Content-Security-Policy',
value: `default-src 'self'; script-src 'self'${
isDev ? " 'unsafe-eval' 'unsafe-inline'" : ''
}; connect-src 'self' https://*.celo.org https://*.celo-testnet.org https://*.walletconnect.com wss://walletconnect.celo.org wss://*.walletconnect.com wss://*.walletconnect.org https://raw.githubusercontent.com; img-src 'self' data: https://raw.githubusercontent.com https://*.walletconnect.com; style-src 'self' 'unsafe-inline' https://*.googleapis.com; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-src 'self' https://*.walletconnect.com https://*.walletconnect.org;`,
}; connect-src 'self' https://*.celo.org https://*.celoscan.io https://*.walletconnect.com wss://walletconnect.celo.org wss://*.walletconnect.com wss://*.walletconnect.org https://raw.githubusercontent.com; img-src 'self' data: https://raw.githubusercontent.com https://*.walletconnect.com; style-src 'self' 'unsafe-inline' https://*.googleapis.com; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-src 'self' https://*.walletconnect.com https://*.walletconnect.org;`,
},
]

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"typecheck": "tsc",
"lint": "next lint",
"start": "next start",
"test": "jest --passWithNoTests",
"test": "jest",
"prettier": "prettier --write ./src",
"checks": "yarn typecheck && yarn lint && yarn test && yarn prettier && yarn build"
},
Expand Down
6 changes: 6 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ interface Config {
debug: boolean;
version: string | null;
walletConnectProjectId: string;
fornoApiKey: string;
celoscanApiKey: string;
}

const isDevMode = process?.env?.NODE_ENV === 'development';
const version = process?.env?.NEXT_PUBLIC_VERSION ?? null;
const walletConnectProjectId = process?.env?.NEXT_PUBLIC_WALLET_CONNECT_ID || '';
const fornoApiKey = process?.env?.NEXT_PUBLIC_FORNO_API_KEY || '';
const celoscanApiKey = process?.env?.NEXT_PUBLIC_CELOSCAN_API_KEY || '';

export const config: Config = Object.freeze({
debug: isDevMode,
version,
walletConnectProjectId,
fornoApiKey,
celoscanApiKey,
});
5 changes: 3 additions & 2 deletions src/config/links.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export const links = {
home: 'https://celostation.org',
celo: 'https://celo.org',
blockscoutUrl: 'https://explorer.celo.org',
celoscanUrl: 'https://celoscan.io',
blockscout: 'https://explorer.celo.org',
celoscan: 'https://celoscan.io',
celoscanApi: 'https://api.celoscan.io',
discord: 'https://discord.gg/celo',
github: 'https://github.com/jmrossy/celo-station',
twitter: 'https://twitter.com/CeloOrg',
Expand Down
4 changes: 2 additions & 2 deletions src/config/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChainId } from 'src/config/chains';
import { Color } from 'src/styles/Color';
import { areAddressesEqual } from 'src/utils/addresses';
import { eqAddress } from 'src/utils/addresses';

export interface Token {
id: string;
Expand Down Expand Up @@ -104,7 +104,7 @@ export function getTokenByAddress(address: Address): Token {
.flat();
// This assumes no clashes btwn different tokens on diff chains
for (const [id, tokenAddr] of idAddressTuples) {
if (areAddressesEqual(address, tokenAddr)) {
if (eqAddress(address, tokenAddr)) {
return Tokens[id as TokenId];
}
}
Expand Down
13 changes: 12 additions & 1 deletion src/config/wagmi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,19 @@ import { config } from 'src/config/config';
import { Color } from 'src/styles/Color';
import { WagmiConfig, configureChains, createConfig } from 'wagmi';

const fornoRpc = 'https://forno.celo.org?apikey=${config.fornoApiKey}';
const infuraRpc = 'https://mainnet.infura.io/v3';
const { chains, publicClient } = configureChains(
[Celo],
[
{
...Celo,
rpcUrls: {
default: { http: [fornoRpc] },
infura: { http: [infuraRpc] },
public: { http: [fornoRpc] },
},
},
],
[jsonRpcProvider({ rpc: (chain) => ({ http: chain.rpcUrls.default.http[0] }) })],
);

Expand Down
20 changes: 9 additions & 11 deletions src/features/account/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,26 @@ import { ZERO_ADDRESS } from 'src/config/consts';
import { Addresses } from 'src/config/contracts';
import { CELO } from 'src/config/tokens';
import { formatUnits } from 'viem';
import { useAccount, useBalance, useContractRead } from 'wagmi';
import { useBalance as _useBalance, useContractRead } from 'wagmi';

export function useAccountBalance(tokenAddress?: Address) {
const account = useAccount();
const { data, isError, isLoading, error } = useBalance({
address: account?.address,
export function useBalance(address?: Address, tokenAddress?: Address) {
const { data, isError, isLoading, error } = _useBalance({
address: address,
token: tokenAddress,
});

useToastError(error, 'Error fetching account balance');

return { address: account?.address, balance: data, isError, isLoading };
return { balance: data, isError, isLoading };
}

export function useLockedBalance() {
const account = useAccount();
export function useLockedBalance(address?: Address) {
const { data, isError, isLoading, error } = useContractRead({
address: Addresses.LockedGold,
abi: lockedGoldABI,
functionName: 'getAccountTotalLockedGold',
args: [account?.address || ZERO_ADDRESS],
enabled: !!account?.address,
args: [address || ZERO_ADDRESS],
enabled: !!address,
});

const balance: FetchBalanceResult | undefined = data
Expand All @@ -40,5 +38,5 @@ export function useLockedBalance() {

useToastError(error, 'Error fetching locked balance');

return { address: account?.address, balance, isError, isLoading };
return { balance, isError, isLoading };
}
22 changes: 22 additions & 0 deletions src/features/explorers/celoscan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { fetchWithTimeout, retryAsync } from 'src/utils/async';
import { ExplorerResponse } from './types';

export async function queryCeloscan<R>(url: string) {
const result = await retryAsync(() => executeQuery<R>(url));
return result;
}

async function executeQuery<R>(url: string) {
const response = await fetchWithTimeout(url);
if (!response.ok) {
throw new Error(`Fetch response not okay: ${response.status}`);
}
const json = (await response.json()) as ExplorerResponse<R>;

if (!json.result) {
const responseText = await response.text();
throw new Error(`Invalid result format: ${responseText}`);
}

return json.result;
}
19 changes: 19 additions & 0 deletions src/features/explorers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface ExplorerResponse<R> {
status: string;
message: string;
result: R;
}

export interface TransactionLog {
address: Address;
blockNumber: HexString;
blockHash: HexString;
data: HexString;
gasUsed: HexString;
gasPrice: HexString;
logIndex: HexString;
transactionIndex: HexString;
transactionHash: HexString;
topics: Array<HexString>;
timeStamp: HexString;
}
112 changes: 112 additions & 0 deletions src/features/staking/rewards/computeRewards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
computeStakingRewards,
getTimeWeightedAverageActive,
} from 'src/features/staking/rewards/computeRewards';
import { GroupVotes, StakeEvent, StakeEventType } from 'src/features/staking/types';
import { nowMinusDays } from 'src/test/time';
import { toWei } from 'src/utils/amount';

function groupVotes(activeAmount: number, group = '0x1'): GroupVotes {
return {
[group]: { active: toWei(activeAmount), pending: 0n },
};
}

const activate1: StakeEvent = {
type: StakeEventType.Activate,
group: '0x1',
value: toWei(1),
units: 0n,
blockNumber: 0,
timestamp: nowMinusDays(30),
txHash: '0x2',
};
const revoke1: StakeEvent = {
...activate1,
type: StakeEventType.Revoke,
value: toWei(0.5),
timestamp: nowMinusDays(20),
};
const activate2: StakeEvent = {
...activate1,
value: toWei(2),
timestamp: nowMinusDays(10),
};
const revoke2: StakeEvent = {
...revoke1,
value: toWei(2.55),
timestamp: nowMinusDays(5),
};

const activate3: StakeEvent = {
...activate1,
group: '0x2',
value: toWei(1000),
};

describe('Computes reward amounts correctly', () => {
it('For a simple activation', () => {
const rewards = computeStakingRewards([activate1], groupVotes(1.1));
expect(rewards).toEqual({ '0x1': 0.1 });
});
it('For a simple activation and revoke', () => {
const rewards = computeStakingRewards([activate1, revoke1], groupVotes(0.6));
expect(rewards).toEqual({ '0x1': 0.1 });
});
it('For a complex activation and revoke', () => {
const rewards = computeStakingRewards([activate1, revoke1, activate2, revoke2], groupVotes(0));
expect(rewards).toEqual({ '0x1': 0.05 });
});
it('For a multiple groups', () => {
const votes = { ...groupVotes(0.55), ...groupVotes(1005, '0x2') };
const rewards = computeStakingRewards([activate1, revoke1, activate3], votes);
expect(rewards).toEqual({ '0x1': 0.05, '0x2': 5 });
});
});

describe('Computes time-weighted avgs correctly', () => {
it('For a single event', () => {
const { avgActive, totalDays } = getTimeWeightedAverageActive([activate1]);
expect(avgActive).toEqual(1);
expect(totalDays).toEqual(30);
});
it('For a multiple events', () => {
const { avgActive, totalDays } = getTimeWeightedAverageActive([activate1, revoke1, activate2]);
expect(avgActive.toFixed(2)).toEqual('1.33');
expect(totalDays).toEqual(30);
});
it('For a events with gap', () => {
const { avgActive, totalDays } = getTimeWeightedAverageActive([
activate1,
revoke1,
activate2,
revoke2,
]);
expect(avgActive.toFixed(2)).toEqual('1.10');
expect(totalDays).toEqual(25);
});
});

describe('Computes reward APYs correctly', () => {
it('For a simple activation', () => {
const rewards = computeStakingRewards([activate1], groupVotes(1.01), 'apy');
expect(rewards).toEqual({ '0x1': 12.94 });
});
it('For a simple activation and revoke', () => {
const rewards = computeStakingRewards([activate1, revoke1], groupVotes(0.51), 'apy');
expect(rewards).toEqual({ '0x1': 20.02 });
});
it('For a complex activation and revoke', () => {
const rewards = computeStakingRewards(
[activate1, revoke1, activate2, revoke2],
groupVotes(0),
'apy',
);
expect(rewards).toEqual({ '0x1': 94.07 });
});
it('For a multiple groups', () => {
const votes = { ...groupVotes(0.51), ...groupVotes(1005, '0x2') };
const rewards = computeStakingRewards([activate1, revoke1, activate3], votes, 'apy');
expect(rewards).toEqual({ '0x1': 20.02, '0x2': 6.27 });
});
});
94 changes: 94 additions & 0 deletions src/features/staking/rewards/computeRewards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { GroupVotes, StakeEvent, StakeEventType } from 'src/features/staking/types';
import { fromWei } from 'src/utils/amount';
import { logger } from 'src/utils/logger';
import { objKeys } from 'src/utils/objects';
import { getDaysBetween } from 'src/utils/time';

export function computeStakingRewards(
stakeEvents: StakeEvent[],
groupVotes: GroupVotes,
mode: 'amount' | 'apy' = 'amount',
): Record<Address, number> {
return mode === 'amount'
? computeRewardAmount(stakeEvents, groupVotes)
: computeRewardApy(stakeEvents, groupVotes);
}

function computeRewardAmount(stakeEvents: StakeEvent[], groupVotes: GroupVotes) {
const groupTotals: Record<Address, bigint> = {}; // group addr to sum votes
for (const event of stakeEvents) {
const { group, type, value } = event;
if (!groupTotals[group]) groupTotals[group] = 0n;
if (type === StakeEventType.Activate) {
groupTotals[group] -= value;
} else if (type === StakeEventType.Revoke) {
groupTotals[group] += value;
}
}

const groupRewards: Record<Address, number> = {}; // group addr to rewards in wei
for (const group of objKeys(groupTotals)) {
const currentVotes = groupVotes[group]?.active || 0n;
const totalVoted = groupTotals[group];
const rewardWei = currentVotes + totalVoted;
if (rewardWei > 0n) {
groupRewards[group] = fromWei(rewardWei);
} else {
logger.warn('Reward for group < 0, should never happen', rewardWei.toString(), group);
groupRewards[group] = 0;
}
}
return groupRewards;
}

function computeRewardApy(stakeEvents: StakeEvent[], groupVotes: GroupVotes) {
// First get total reward amounts per group
const groupRewardAmounts = computeRewardAmount(stakeEvents, groupVotes);

// Next, gather events by group
const groupEvents: Record<Address, StakeEvent[]> = {}; // group addr to events
for (const event of stakeEvents) {
const group = event.group;
if (!groupEvents[group]) groupEvents[group] = [];
groupEvents[group].push(event);
}

// Finally, use avg active amounts to compute APR and APY
const groupApy: Record<Address, number> = {}; // weighted avgs of active votes
for (const group of objKeys(groupEvents)) {
const { avgActive, totalDays } = getTimeWeightedAverageActive(groupEvents[group]);
const rewardAmount = groupRewardAmounts[group];
const apr = (rewardAmount / avgActive / totalDays) * 365;
const apy = (1 + apr / 365) ** 365 - 1;
groupApy[group] = Math.round(apy * 10000) / 100;
}

return groupApy;
}

export function getTimeWeightedAverageActive(events: StakeEvent[]) {
const numEvents = events.length;
if (numEvents === 0) throw new Error('Expected at least 1 stake event');
const sortedEvents = events.sort((a, b) => a.timestamp - b.timestamp);
let activeVotes = 0;
let sum = 0;
let totalDays = 0;
for (let i = 0; i < numEvents; i++) {
const { type, value: valueInWei, timestamp } = sortedEvents[i];
const value = fromWei(valueInWei);
// has next event ? its timestamp : today
const nextTimestamp = i < numEvents - 1 ? sortedEvents[i + 1].timestamp : Date.now();
const numDays = getDaysBetween(timestamp, nextTimestamp);
if (type === StakeEventType.Activate) {
activeVotes += value;
} else {
activeVotes -= value;
}
// ignore periods where nothing was staked
if (activeVotes < 0.01) continue;
sum += activeVotes * numDays;
totalDays += numDays;
}

return { avgActive: sum / totalDays, totalDays };
}
Loading

0 comments on commit 415b44d

Please sign in to comment.