Skip to content

Commit

Permalink
chore: initial rewards distribution cli scaffolding (#50)
Browse files Browse the repository at this point in the history
* feat: added input prompts for rewards distribution

* wip: rewards distribution cli

* wip: wiring up staking rewards calculation

* wip: staking rewards progression

* feat: staking rewards accounting with on-chain validation

* feat: add progress bar to rewards validation

* chore: remove unused rewards simulation
  • Loading branch information
woodenfurniture authored May 31, 2024
1 parent bd94f64 commit 1a1fa9c
Show file tree
Hide file tree
Showing 12 changed files with 971 additions and 278 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@
},
"simple-git-hooks": {
"pre-commit": "yarn lint:sol && yarn pretty-quick --staged"
},
"dependencies": {
"bignumber.js": "^9.1.2"
}
}
263 changes: 263 additions & 0 deletions scripts/rewards-distribution/calculateRewards/calculateRewards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { Address, Block } from "viem";
import { RFoxLog, StakeLog, UnstakeLog } from "../events";
import { getStakingAmount, isLogType } from "../helpers";
import { REWARD_RATE, WAD } from "../constants";
import assert from "assert";

type StakingInfo = {
stakingBalance: bigint;
earnedRewards: bigint;
rewardPerTokenStored: bigint;
runeAddress: String;
};

const getEmptyStakingInfo = () => {
return {
stakingBalance: 0n,
earnedRewards: 0n,
rewardPerTokenStored: 0n,
runeAddress: "",
};
};

const rewardPerToken = (
rewardPerTokenStored: bigint,
totalStaked: bigint,
currentTimestamp: bigint,
lastUpdateTimestamp: bigint,
) => {
if (totalStaked == 0n) {
return rewardPerTokenStored;
}
return (
rewardPerTokenStored +
((currentTimestamp - lastUpdateTimestamp) * REWARD_RATE * WAD) / totalStaked
);
};

const earned = (
stakingInfo: StakingInfo,
rewardPerTokenStored: bigint,
totalStaked: bigint,
currentTimestamp: bigint,
lastUpdateTimestamp: bigint,
) => {
return (
(stakingInfo.stakingBalance *
(rewardPerToken(
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
) -
stakingInfo.rewardPerTokenStored)) /
WAD +
stakingInfo.earnedRewards
);
};

const updateReward = (
stakingInfo: StakingInfo,
rewardPerTokenStored: bigint,
totalStaked: bigint,
currentTimestamp: bigint,
lastUpdateTimestamp: bigint,
) => {
rewardPerTokenStored = rewardPerToken(
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
);
lastUpdateTimestamp = currentTimestamp;
stakingInfo.earnedRewards = earned(
stakingInfo,
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
);
stakingInfo.rewardPerTokenStored = rewardPerTokenStored;
return { stakingInfo, rewardPerTokenStored, lastUpdateTimestamp };
};

const stake = (
amount: bigint,
runeAddress: String,
stakingInfo: StakingInfo,
rewardPerTokenStored: bigint,
totalStaked: bigint,
currentTimestamp: bigint,
lastUpdateTimestamp: bigint,
) => {
({ stakingInfo, rewardPerTokenStored, lastUpdateTimestamp } = updateReward(
stakingInfo,
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
));

stakingInfo.stakingBalance += amount;
stakingInfo.runeAddress = runeAddress;
totalStaked += amount;

return {
stakingInfo,
rewardPerTokenStored,
lastUpdateTimestamp,
totalStaked,
};
};

const unstake = (
amount: bigint,
stakingInfo: StakingInfo,
rewardPerTokenStored: bigint,
totalStaked: bigint,
currentTimestamp: bigint,
lastUpdateTimestamp: bigint,
) => {
({ stakingInfo, rewardPerTokenStored, lastUpdateTimestamp } = updateReward(
stakingInfo,
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
));

stakingInfo.stakingBalance -= amount;
totalStaked -= amount;

return {
stakingInfo,
rewardPerTokenStored,
lastUpdateTimestamp,
totalStaked,
};
};

export const calculateRewards = (
contractCreationBlock: Block,
// The reward is computed as an all-time value, so we need to subtract the rewards at the end of
// the previous epoch. This prevents us missing rewards for the first block in the epoch.
previousEpochEndBlock: Block,
epochEndBlock: Block,
logs: { log: RFoxLog; timestamp: bigint }[],
) => {
let totalStaked = 0n;
let rewardPerTokenStored = 0n;
let lastUpdateTimestamp = contractCreationBlock.timestamp;
const stakingInfoByAccount: Record<Address, StakingInfo> = {};

const stakingLogs = logs.filter(
(
logWithTimestamp,
): logWithTimestamp is { log: StakeLog | UnstakeLog; timestamp: bigint } =>
isLogType("Stake", logWithTimestamp.log) ||
isLogType("Unstake", logWithTimestamp.log),
);

const epochStartRewardsByAccount: Record<Address, bigint> = {};
const epochEndRewardsByAccount: Record<Address, bigint> = {};

const previousEpochEndBlockNumber = previousEpochEndBlock.number;
const epochEndBlockNumber = epochEndBlock.number;

assert(
previousEpochEndBlockNumber !== null,
"Epoch start block number is null",
);
assert(epochEndBlockNumber !== null, "Epoch end block number is null");

let hasCalcedStartRewards = false;

// process logs
for (const { log, timestamp: currentTimestamp } of stakingLogs) {
// Paranoia in case we get logs past the end of the epoch
assert(log.blockNumber <= epochEndBlockNumber);

// When the block number passes the start of the epoch, assign the reward values for the start of the epoch
if (
!hasCalcedStartRewards &&
log.blockNumber > previousEpochEndBlockNumber
) {
for (const [account, stakingInfo] of Object.entries(
stakingInfoByAccount,
)) {
epochStartRewardsByAccount[account as Address] = earned(
stakingInfo,
rewardPerTokenStored,
totalStaked,
previousEpochEndBlock.timestamp,
lastUpdateTimestamp,
);

hasCalcedStartRewards = true;
}
}

let stakingInfo =
stakingInfoByAccount[log.args.account] ?? getEmptyStakingInfo();

switch (true) {
case isLogType<StakeLog>("Stake", log):
({
stakingInfo,
rewardPerTokenStored,
lastUpdateTimestamp,
totalStaked,
} = stake(
log.args.amount,
log.args.runeAddress,
stakingInfo,
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
));
break;
case isLogType<UnstakeLog>("Unstake", log):
({
stakingInfo,
rewardPerTokenStored,
lastUpdateTimestamp,
totalStaked,
} = unstake(
log.args.amount,
stakingInfo,
rewardPerTokenStored,
totalStaked,
currentTimestamp,
lastUpdateTimestamp,
));
break;
default:
break;
}

stakingInfoByAccount[log.args.account] = stakingInfo;
}

// Grab the reward values for the end of the epoch
for (const [account, stakingInfo] of Object.entries(stakingInfoByAccount)) {
epochEndRewardsByAccount[account as Address] = earned(
stakingInfo,
rewardPerTokenStored,
totalStaked,
epochEndBlock.timestamp,
lastUpdateTimestamp,
);
}

const earnedRewardsByAccount: Record<Address, bigint> = {};

for (const [account, epochEndReward] of Object.entries(
epochEndRewardsByAccount,
)) {
earnedRewardsByAccount[account as Address] =
epochEndReward - (epochStartRewardsByAccount[account as Address] ?? 0n);
}

return earnedRewardsByAccount;
};
11 changes: 11 additions & 0 deletions scripts/rewards-distribution/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import assert from "assert";
import { createPublicClient, http } from "viem";
import { arbitrum } from "viem/chains";

// TODO: dotenv or similar
assert(process.env.ARBITRUM_JSON_RPC_URL, "ARBITRUM_JSON_RPC_URL is required");

export const publicClient = createPublicClient({
chain: arbitrum,
transport: http(process.env.ARBITRUM_JSON_RPC_URL),
});
30 changes: 9 additions & 21 deletions scripts/rewards-distribution/constants.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { localhost } from "viem/chains";
import { Address } from "viem";

export const localChain = {
...localhost,
id: 31337,
} as const;
export const RUNE_DECIMALS = 8;

export const localOwnerWalletClient = createWalletClient({
chain: localChain,
account: privateKeyToAccount(process.env.OWNER_PRIVATE_KEY as `0x${string}`),
transport: http(process.env.ANVIL_JSON_RPC_URL),
});
export const WAD = 1n * 10n ** 18n;
export const REWARD_RATE = 1_000_000_000n;

export const localUserWalletClient = createWalletClient({
chain: localChain,
account: privateKeyToAccount(process.env.USER_PRIVATE_KEY as `0x${string}`),
transport: http(process.env.ANVIL_JSON_RPC_URL),
});
// The number of blocks to query at a time, when fetching logs
export const GET_LOGS_BLOCK_STEP_SIZE = 20000n;

export const localPublicClient = createPublicClient({
chain: localChain,
transport: http(process.env.ANVIL_JSON_RPC_URL),
});
// RFOX on Arbitrum ERC1967Proxy contract address
export const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS: Address =
"0xd612B64A134f3D4830542B7463CE8ca8a29D7268";
Loading

0 comments on commit 1a1fa9c

Please sign in to comment.