-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
26 changed files
with
556 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
Oops, something went wrong.