From 0565484a584e4f4ebc67acbb420aa4a01dbd1d6b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 21 Jun 2024 21:16:32 -0300 Subject: [PATCH 1/4] RewardRatePool naming: interest -> payout I found the "interest" term a little confusing as the pool doesn't earn/pay interest, but rather pays a portion of its princpal. (No logic changes.) --- contracts/RewardRatePool.sol | 24 ++++++++++++------------ test/unit-js/RewardRatePool.js | 8 ++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/contracts/RewardRatePool.sol b/contracts/RewardRatePool.sol index 71eff0a..963efb7 100644 --- a/contracts/RewardRatePool.sol +++ b/contracts/RewardRatePool.sol @@ -7,7 +7,7 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; /** * @title Reward Rate Pool Contract - * @dev Implements reward distribution based on a fixed annual interest rate. + * @dev Implements reward distribution based on a fixed simple annual payout rate. */ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { using SafeERC20 for IERC20; @@ -18,12 +18,12 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { address public beneficiary; uint256 public totalPaidOut; uint256 public lastPaidOutTime; - uint64 public constant ANNUAL_INTEREST_RATE = 145; // 14.5% in tenths of a percent + uint64 public constant ANNUAL_SIMPLE_PAYOUT_RATE = 145; // 14.5% in tenths of a percent uint64 public constant BASIS_POINTS = 1000; // Basis points for percentage calculation /** * @dev Sets the initial beneficiary and SENT token address. - * @param _beneficiary Address that will receive the interest payouts. + * @param _beneficiary Address that will receive the payouts. * @param _sent Address of the SENT ERC20 token contract. */ function initialize(address _beneficiary, address _sent) public initializer { @@ -44,7 +44,7 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { ////////////////////////////////////////////////////////////// /** - * @dev Calculates and releases the due interest payout to the beneficiary. + * @dev Calculates and releases the due payout to the beneficiary. * Updates the total paid out and the last payout time. */ function payoutReleased() public { @@ -77,7 +77,7 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { function rewardRate(uint256 timestamp) public view returns (uint256) { uint256 alreadyReleased = calculateReleasedAmount(timestamp); uint256 totalDeposited = calculateTotalDeposited(); - return calculateInterestAmount(totalDeposited - alreadyReleased, 2 minutes); + return calculatePayoutAmount(totalDeposited - alreadyReleased, 2 minutes); } /** @@ -95,16 +95,16 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { */ function calculateReleasedAmount(uint256 timestamp) public view returns (uint256) { uint256 timeElapsed = timestamp - lastPaidOutTime; - return totalPaidOut + calculateInterestAmount(SENT.balanceOf(address(this)), timeElapsed); + return totalPaidOut + calculatePayoutAmount(SENT.balanceOf(address(this)), timeElapsed); } /** - * @dev Calculates 14.5% annual interest for a given balance and time period. - * @param balance The principal balance to calculate interest on. - * @param timeElapsed The time period over which to calculate interest. - * @return The calculated interest amount. + * @dev Calculates payout amount for a given balance and time period. + * @param balance The principal balance to calculate payout from. + * @param timeElapsed The time period over which to calculate payout. + * @return The calculated payout amount. */ - function calculateInterestAmount(uint256 balance, uint256 timeElapsed) public pure returns (uint256) { - return (balance * ANNUAL_INTEREST_RATE * timeElapsed) / (BASIS_POINTS * 365 days); + function calculatePayoutAmount(uint256 balance, uint256 timeElapsed) public pure returns (uint256) { + return (balance * ANNUAL_SIMPLE_PAYOUT_RATE * timeElapsed) / (BASIS_POINTS * 365 days); } } diff --git a/test/unit-js/RewardRatePool.js b/test/unit-js/RewardRatePool.js index 90f9166..1109ced 100644 --- a/test/unit-js/RewardRatePool.js +++ b/test/unit-js/RewardRatePool.js @@ -34,13 +34,13 @@ describe("RewardRatePool Contract Tests", function () { rewardRatePool = await upgrades.deployProxy(RewardRatePool, [await serviceNodeRewards.getAddress(), await mockERC20.getAddress()]); }); - it("Should have the correct interest rate", async function () { - await expect(await rewardRatePool.ANNUAL_INTEREST_RATE()) + it("Should have the correct payout rate", async function () { + await expect(await rewardRatePool.ANNUAL_SIMPLE_PAYOUT_RATE()) .to.equal(145); }); - it("should calculate 14.5% interest correctly", async function () { - await expect(await rewardRatePool.calculateInterestAmount(principal, seconds_in_year)) + it("should calculate 14.5% payout correctly", async function () { + await expect(await rewardRatePool.calculatePayoutAmount(principal, seconds_in_year)) .to.equal((principal * 0.145).toFixed(0)); }); From 2f2cf01d7aec9f0f256f775178b87c6a42aacc91 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 21 Jun 2024 21:20:14 -0300 Subject: [PATCH 2/4] Update simple payout rate to 15.1% instead of 14.5% Our announced reward payout is 14% annually of the pool, which works out to ~15.1% when compounded regularly. This updates the amount, and adds documentation of how that value is derived from the 14% target. --- contracts/RewardRatePool.sol | 24 +++++++++++++++++++++++- test/unit-js/RewardRatePool.js | 23 ++++++++++++----------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/contracts/RewardRatePool.sol b/contracts/RewardRatePool.sol index 963efb7..31ac23a 100644 --- a/contracts/RewardRatePool.sol +++ b/contracts/RewardRatePool.sol @@ -18,7 +18,29 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { address public beneficiary; uint256 public totalPaidOut; uint256 public lastPaidOutTime; - uint64 public constant ANNUAL_SIMPLE_PAYOUT_RATE = 145; // 14.5% in tenths of a percent + // The simple annual payout rate used for reward calculations. This 15.1% value is chosen so + // that, with daily payouts computed at this simple rate, the total (compounded) payout over a + // year will equal 14% of the amount that was in the pool at the beginning of the year. + // + // To elaborate where this comes from, with daily payout r (=R/365, that is, the annual payout + // divided by 365 days per year), with starting balance P, the payout on day 1 equals: + // rP + // leaving P-rP = (1-r)P in the pool, and so the day 2 payout equals: + // r(1-r)P + // leaving (1-r)P - r(1-r)P = (1-r)(1-r)P = (1-r)^2 P in the pool. And so on, so that + // after 365 days there will be (1-r)^365 P left in the pool. + // + // To hit a target of 14% over a year, then, we want to find r to solve: + // (1-r)^{365} P = (1-.14) P + // i.e. + // (1-r)^{365} = 0.86 + // and then we multiply the `r` solution by 365 to get the simple annual rate with daily + // payouts. Rounded to the nearest 10th of a percent, that value equals 0.151, i.e. 15.1%. + // + // There is, of course, some slight imprecision here from the rounding and because the precise + // payout frequency depends on the times between calling this smart contract, but the errors are + // expected to be small, keeping this close to the 14% target. + uint64 public constant ANNUAL_SIMPLE_PAYOUT_RATE = 151; // 15.1% in tenths of a percent uint64 public constant BASIS_POINTS = 1000; // Basis points for percentage calculation /** diff --git a/test/unit-js/RewardRatePool.js b/test/unit-js/RewardRatePool.js index 1109ced..c4db5f3 100644 --- a/test/unit-js/RewardRatePool.js +++ b/test/unit-js/RewardRatePool.js @@ -11,10 +11,11 @@ describe("RewardRatePool Contract Tests", function () { let serviceNodeRewards; let RewardRatePool; let rewardRatePool; - let principal = 100000; - let bigAtomicPrincipal = ethers.parseUnits(principal.toString(), 9); - let seconds_in_year = 365*24*60*60; - let seconds_in_2_minutes = 2*60; + const principal = 100000; + const bigAtomicPrincipal = ethers.parseUnits(principal.toString(), 9); + const seconds_in_day = 24*60*60; + const seconds_in_year = 365 * seconds_in_day; + const seconds_in_2_minutes = 2*60; beforeEach(async function () { // Deploy a mock ERC20 token @@ -36,22 +37,22 @@ describe("RewardRatePool Contract Tests", function () { it("Should have the correct payout rate", async function () { await expect(await rewardRatePool.ANNUAL_SIMPLE_PAYOUT_RATE()) - .to.equal(145); + .to.equal(151); }); - it("should calculate 14.5% payout correctly", async function () { + it("should calculate 15.1% payout correctly", async function () { await expect(await rewardRatePool.calculatePayoutAmount(principal, seconds_in_year)) - .to.equal((principal * 0.145).toFixed(0)); + .to.equal((principal * 0.151).toFixed(0)); }); - it("should calculate 14.5% released correctly", async function () { + it("should calculate 15.1% released correctly", async function () { await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); let last_paid = await rewardRatePool.lastPaidOutTime(); await expect(await rewardRatePool.calculateReleasedAmount(last_paid + BigInt(seconds_in_year))) - .to.equal(ethers.parseUnits((principal * 0.145).toFixed(0).toString(), 9)); + .to.equal(ethers.parseUnits((principal * 0.151).toFixed(0).toString(), 9)); }); - it("should calculate 14.5% released correctly", async function () { + it("should calculate 15.1% released correctly", async function () { await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); let last_paid = await rewardRatePool.lastPaidOutTime(); await expect(await rewardRatePool.calculateReleasedAmount(last_paid)) @@ -62,7 +63,7 @@ describe("RewardRatePool Contract Tests", function () { await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); let last_paid = await rewardRatePool.lastPaidOutTime(); await expect(await rewardRatePool.rewardRate(last_paid)) - .to.equal(ethers.parseUnits((principal * 0.145).toFixed().toString(), 9) * BigInt(seconds_in_2_minutes) / BigInt(seconds_in_year)); + .to.equal(ethers.parseUnits((principal * 0.151).toFixed().toString(), 9) * BigInt(seconds_in_2_minutes) / BigInt(seconds_in_year)); }); it("should be able to release funds to the rewards contract", async function () { From 6ccc331f47acc6383372fecdf089638c52cf2146 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 21 Jun 2024 21:21:50 -0300 Subject: [PATCH 3/4] Add repeated compounding payout tests Tests the the compounding effect on the simple payout rate achieves (within reasonable error) the intended 14% target when compounded daily or monthly. --- test/unit-js/RewardRatePool.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/unit-js/RewardRatePool.js b/test/unit-js/RewardRatePool.js index c4db5f3..b22fcfc 100644 --- a/test/unit-js/RewardRatePool.js +++ b/test/unit-js/RewardRatePool.js @@ -66,6 +66,32 @@ describe("RewardRatePool Contract Tests", function () { .to.equal(ethers.parseUnits((principal * 0.151).toFixed().toString(), 9) * BigInt(seconds_in_2_minutes) / BigInt(seconds_in_year)); }); + it("should should be ~14.017% with daily withdrawals", async function () { + await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); + let t = await rewardRatePool.lastPaidOutTime(); + let total_paid = 0; + for (let i = 0; i < 365; i++) { + t += BigInt(seconds_in_day); + await time.setNextBlockTimestamp(t); + await rewardRatePool.payoutReleased(); + } + await expect(await rewardRatePool.calculateReleasedAmount(t)) + .to.equal(bigAtomicPrincipal * BigInt("14017916502388") / BigInt("100000000000000")); + }); + + it("should should be ~14.098% with monthly withdrawals", async function () { + await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); + let t = await rewardRatePool.lastPaidOutTime(); + let total_paid = 0; + for (let i = 0; i < 12; i++) { + t += BigInt(seconds_in_year / 12); + await time.setNextBlockTimestamp(t); + await rewardRatePool.payoutReleased(); + } + await expect(await rewardRatePool.calculateReleasedAmount(t)) + .to.equal(bigAtomicPrincipal * BigInt("14097571610714") / BigInt("100000000000000")); + }); + it("should be able to release funds to the rewards contract", async function () { await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); expect(await mockERC20.balanceOf(rewardRatePool)).to.equal(bigAtomicPrincipal); From 5e464cf8165dd2a3096151e02db8fbfc71479f33 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 27 Jun 2024 22:08:40 -0300 Subject: [PATCH 4/4] Remove timestamp argument from RewardRatePool query functions Requiring the timestamp here causes problems with oxend because it means we can't realistically pre-fetch rewards amounts until we get the timestamp from oxen. That's a problem because we can't pre-fetch the reward data for verification (because it needs the timestamp that goes into the block), which means in order to validate blocks we'd have to stop during block processing and make (and wait for) a contract call to come back. Removing the timestamp makes this a lot easier because it lets us query the reward rate in advance by only querying every few minutes (at predefined height intervals the network agrees on) and because it now depends on the block timestamp, the value will be the same for anyone fetching it. --- contracts/RewardRatePool.sol | 16 ++++++------ test/unit-js/RewardRatePool.js | 46 +++++++++++----------------------- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/contracts/RewardRatePool.sol b/contracts/RewardRatePool.sol index 31ac23a..ab1e13c 100644 --- a/contracts/RewardRatePool.sol +++ b/contracts/RewardRatePool.sol @@ -70,7 +70,7 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { * Updates the total paid out and the last payout time. */ function payoutReleased() public { - uint256 newTotalPaidOut = calculateReleasedAmount(block.timestamp); + uint256 newTotalPaidOut = calculateReleasedAmount(); uint256 released = newTotalPaidOut - totalPaidOut; totalPaidOut = newTotalPaidOut; lastPaidOutTime = block.timestamp; @@ -92,12 +92,11 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { ////////////////////////////////////////////////////////////// /** - * @dev Returns the block reward for a 2 minutes block at a certain timestamp. - * @param timestamp The timestamp of the block. + * @dev Returns the current 2-minute block reward. * @return The calculated block reward. */ - function rewardRate(uint256 timestamp) public view returns (uint256) { - uint256 alreadyReleased = calculateReleasedAmount(timestamp); + function rewardRate() public view returns (uint256) { + uint256 alreadyReleased = calculateReleasedAmount(); uint256 totalDeposited = calculateTotalDeposited(); return calculatePayoutAmount(totalDeposited - alreadyReleased, 2 minutes); } @@ -111,12 +110,11 @@ contract RewardRatePool is Initializable, Ownable2StepUpgradeable { } /** - * @dev Calculates the amount of SENT tokens released up to a specific timestamp. - * @param timestamp The timestamp until which to calculate the released amount. + * @dev Calculates the amount of SENT tokens released up to the current time. * @return The calculated amount of SENT tokens released. */ - function calculateReleasedAmount(uint256 timestamp) public view returns (uint256) { - uint256 timeElapsed = timestamp - lastPaidOutTime; + function calculateReleasedAmount() public view returns (uint256) { + uint256 timeElapsed = block.timestamp - lastPaidOutTime; return totalPaidOut + calculatePayoutAmount(SENT.balanceOf(address(this)), timeElapsed); } diff --git a/test/unit-js/RewardRatePool.js b/test/unit-js/RewardRatePool.js index b22fcfc..24f7efe 100644 --- a/test/unit-js/RewardRatePool.js +++ b/test/unit-js/RewardRatePool.js @@ -46,24 +46,20 @@ describe("RewardRatePool Contract Tests", function () { }); it("should calculate 15.1% released correctly", async function () { + await time.setNextBlockTimestamp(await time.latest() + 42) await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); - let last_paid = await rewardRatePool.lastPaidOutTime(); - await expect(await rewardRatePool.calculateReleasedAmount(last_paid + BigInt(seconds_in_year))) - .to.equal(ethers.parseUnits((principal * 0.151).toFixed(0).toString(), 9)); - }); - - it("should calculate 15.1% released correctly", async function () { - await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); - let last_paid = await rewardRatePool.lastPaidOutTime(); - await expect(await rewardRatePool.calculateReleasedAmount(last_paid)) - .to.equal(0); + await expect(await rewardRatePool.calculateReleasedAmount()) + // Block timestamp has advanced by 42 seconds, so there will be a corresponding released amount: + .to.equal(ethers.parseUnits((principal * 0.151).toFixed().toString(), 9) * BigInt(42) / BigInt(seconds_in_year)); }); it("should calculate reward rate", async function () { + await time.setNextBlockTimestamp(await time.latest() + 1) await mockERC20.transfer(rewardRatePool, bigAtomicPrincipal); - let last_paid = await rewardRatePool.lastPaidOutTime(); - await expect(await rewardRatePool.rewardRate(last_paid)) - .to.equal(ethers.parseUnits((principal * 0.151).toFixed().toString(), 9) * BigInt(seconds_in_2_minutes) / BigInt(seconds_in_year)); + // The -1 here is because the block time advances by at least 1, and that's enough to just + // slightly reduce our reward by one atomic unit with the specific values we use here. + await expect(await rewardRatePool.rewardRate()) + .to.equal(ethers.parseUnits((principal * 0.151).toFixed().toString(), 9) * BigInt(seconds_in_2_minutes) / BigInt(seconds_in_year) - BigInt(1)); }); it("should should be ~14.017% with daily withdrawals", async function () { @@ -75,7 +71,7 @@ describe("RewardRatePool Contract Tests", function () { await time.setNextBlockTimestamp(t); await rewardRatePool.payoutReleased(); } - await expect(await rewardRatePool.calculateReleasedAmount(t)) + await expect(await rewardRatePool.calculateReleasedAmount()) .to.equal(bigAtomicPrincipal * BigInt("14017916502388") / BigInt("100000000000000")); }); @@ -88,7 +84,7 @@ describe("RewardRatePool Contract Tests", function () { await time.setNextBlockTimestamp(t); await rewardRatePool.payoutReleased(); } - await expect(await rewardRatePool.calculateReleasedAmount(t)) + await expect(await rewardRatePool.calculateReleasedAmount()) .to.equal(bigAtomicPrincipal * BigInt("14097571610714") / BigInt("100000000000000")); }); @@ -97,29 +93,17 @@ describe("RewardRatePool Contract Tests", function () { expect(await mockERC20.balanceOf(rewardRatePool)).to.equal(bigAtomicPrincipal); // NOTE: Advance time and test the payout release - let last_paid = await rewardRatePool.lastPaidOutTime(); - let total_payout = await rewardRatePool.calculateReleasedAmount(last_paid + BigInt(seconds_in_year)); - + let last_paid = await rewardRatePool.lastPaidOutTime(); await time.setNextBlockTimestamp(last_paid + BigInt(seconds_in_year)); await expect(await rewardRatePool.payoutReleased()).to .emit(rewardRatePool, 'FundsReleased') - .withArgs(total_payout); - - expect(await mockERC20.balanceOf(serviceNodeRewards)).to - .equal(total_payout); - + .withArgs(15100000000000); // NOTE: Advance time again and test the payout release - last_paid = await rewardRatePool.lastPaidOutTime(); - let next_total_payout = await rewardRatePool.calculateReleasedAmount(last_paid + BigInt(seconds_in_year)); - total_payout = next_total_payout - total_payout; - + last_paid = await rewardRatePool.lastPaidOutTime(); await time.setNextBlockTimestamp(last_paid + BigInt(seconds_in_year)); await expect(await rewardRatePool.payoutReleased()).to .emit(rewardRatePool, 'FundsReleased') - .withArgs(total_payout); - - expect(await mockERC20.balanceOf(serviceNodeRewards)).to - .equal(next_total_payout); + .withArgs(12819900000000); // (10000 - 15.1%) * 15.1% }); });