From d10fd3130570de21c7546513b4bab832e7a98f0b Mon Sep 17 00:00:00 2001 From: Evgenii Zaitsev <97302011+EvgeniiZaitsevCW@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:38:14 +0700 Subject: [PATCH] test: improve existing tests and add new ones (#10) * test: create tests draft for `YieldStreamerPrimary` contract * test: fix test failures * test: reorganize and improve existing tests * style: fix formatting issues in the Hardhat config file * test: improve tests and add new ones * test: fix a strange Codacy issue * test: fix a strange Codacy issue once again --------- Co-authored-by: Igor Senych --- contracts/mocks/YieldStreamerV1Mock.sol | 72 + contracts/mocks/tokens/ERC20TokenMock.sol | 31 +- contracts/testable/YieldStreamerTestable.sol | 46 +- hardhat.config.ts | 8 +- test-utils/common.ts | 29 + test-utils/eth.ts | 46 +- test-utils/specific.ts | 163 ++ test/YieldStreamer.external.test.ts | 2271 +++++++++++++++++ ...test.ts => YieldStreamer.internal.test.ts} | 977 +++---- test/YieldStreamer.schedule.test.ts | 748 ------ test/YieldStreamerHarness.test.ts | 336 ++- 11 files changed, 3307 insertions(+), 1420 deletions(-) create mode 100644 contracts/mocks/YieldStreamerV1Mock.sol create mode 100644 test-utils/specific.ts create mode 100644 test/YieldStreamer.external.test.ts rename test/{YieldStreamerTestable.test.ts => YieldStreamer.internal.test.ts} (73%) delete mode 100644 test/YieldStreamer.schedule.test.ts diff --git a/contracts/mocks/YieldStreamerV1Mock.sol b/contracts/mocks/YieldStreamerV1Mock.sol new file mode 100644 index 0000000..3fd18d4 --- /dev/null +++ b/contracts/mocks/YieldStreamerV1Mock.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { IYieldStreamerV1 } from "../interfaces/IYieldStreamerV1.sol"; + +/** + * @title YieldStreamerV1Mock contract + * @author CloudWalk Inc. (See https://www.cloudwalk.io) + * @dev An implementation of the {YieldStreamerV1} contract for testing purposes. + */ +contract YieldStreamerV1Mock is IYieldStreamerV1 { + /** + * @dev Emitted when an blocklist function is called. + */ + event YieldStreamerV1Mock_BlocklistCalled(address account); + + // ------------------ Storage---- ----------------------------- // + + mapping(address => ClaimResult) private _claimAllPreview; + mapping(address => bool) private _isBlocklister; + + // ------------------ IYieldStreamerV1 ------------------------ // + + /** + * @inheritdoc IYieldStreamerV1 + */ + function claimAllPreview(address account) external view returns (ClaimResult memory) { + return _claimAllPreview[account]; + } + + /** + * @inheritdoc IYieldStreamerV1 + */ + function blocklist(address account) external { + emit YieldStreamerV1Mock_BlocklistCalled(account); + } + + /** + * @inheritdoc IYieldStreamerV1 + */ + function isBlocklister(address account) external view returns (bool) { + return _isBlocklister[account]; + } + + /** + * @inheritdoc IYieldStreamerV1 + */ + function getAccountGroup(address account) external view returns (bytes32) { + return bytes32(0); + } + + // ------------------ Functions ------------------------------- // + + /** + * @dev Sets the preview result for a given account. + * @param account The address of the account to set the preview for. + * @param preview The preview result to set for the account. + */ + function setClaimAllPreview(address account, ClaimResult memory preview) external { + _claimAllPreview[account] = preview; + } + + /** + * @dev Sets the blocklister status for a given account. + * @param account The address of the account to set the blocklister status for. + * @param isBlocklister_ The blocklister status to set for the account. + */ + function setBlocklister(address account, bool isBlocklister_) external { + _isBlocklister[account] = isBlocklister_; + } +} diff --git a/contracts/mocks/tokens/ERC20TokenMock.sol b/contracts/mocks/tokens/ERC20TokenMock.sol index 13b6259..6c21e37 100644 --- a/contracts/mocks/tokens/ERC20TokenMock.sol +++ b/contracts/mocks/tokens/ERC20TokenMock.sol @@ -31,14 +31,8 @@ contract ERC20TokenMock is ERC20 { * @param account The address of an account to mint for. * @param amount The amount of tokens to mint. */ - function mint(address account, uint256 amount) external returns (bool) { + function mint(address account, uint256 amount) external { _mint(account, amount); - - if (_hook != address(0)) { - IERC20Hook(_hook).afterTokenTransfer(address(0), account, amount); - } - - return true; } /** @@ -46,14 +40,8 @@ contract ERC20TokenMock is ERC20 { * @param account The address of an account to burn for. * @param amount The amount of tokens to burn. */ - function burn(address account, uint256 amount) external returns (bool) { + function burn(address account, uint256 amount) external { _burn(account, amount); - - if (_hook != address(0)) { - IERC20Hook(_hook).afterTokenTransfer(account, address(0), amount); - } - - return true; } /** @@ -64,4 +52,19 @@ contract ERC20TokenMock is ERC20 { _hook = hook; return true; } + + // ------------------ Internal functions ---------------------- // + + /** + * @dev Overrides the default implementation of the {ERC20} contract to call the hook after the transfer. + * @param from The address of the sender. + * @param to The address of the recipient. + * @param value The amount of tokens to transfer. + */ + function _update(address from, address to, uint256 value) internal virtual override { + super._update(from, to, value); + if (_hook != address(0)) { + IERC20Hook(_hook).afterTokenTransfer(from, to, value); + } + } } diff --git a/contracts/testable/YieldStreamerTestable.sol b/contracts/testable/YieldStreamerTestable.sol index 0b1b71e..0815b8a 100644 --- a/contracts/testable/YieldStreamerTestable.sol +++ b/contracts/testable/YieldStreamerTestable.sol @@ -3,13 +3,42 @@ pragma solidity 0.8.24; import { YieldStreamer } from "../YieldStreamer.sol"; +import { Bitwise } from "../libs/Bitwise.sol"; /** * @title YieldStreamerTestable contract * @author CloudWalk Inc. (See https://www.cloudwalk.io) - * @dev Implements additional functions to test private and internal functions of base contracts. + * @dev Implements additional functions to test internal functions of the yield streamer contract. */ contract YieldStreamerTestable is YieldStreamer { + // ------------------ Internal initializers ------------------- // + + function call_parent_initialize(address underlyingToken) external { + __YieldStreamer_init(underlyingToken); + } + + function call_parent_initialize_unchained(address underlyingToken) external { + __YieldStreamer_init_init_unchained(underlyingToken); + } + + // ------------------ Setters for storage structures ---------- // + + function setYieldState(address account, YieldState calldata newState) external { + _yieldStreamerStorage().yieldStates[account] = newState; + } + + // ------------------ Getters for storage structures ---------- // + + function getSourceGroupMapping(bytes32 groupKey) external view returns (uint256) { + return _yieldStreamerInitializationStorage().groupIds[groupKey]; + } + + // ------------------ Account initializers -------------------- // + + function initializeSingleAccount(address account) external { + _initializeSingleAccount(account); + } + // ------------------ Yield calculation ----------------------- // function getAccruePreview( @@ -47,7 +76,6 @@ contract YieldStreamerTestable is YieldStreamer { return _calculateSimpleYield(amount, rate, elapsedSeconds); } - function inRangeYieldRates( YieldRate[] memory rates, uint256 fromTimestamp, @@ -91,4 +119,18 @@ contract YieldStreamerTestable is YieldStreamer { function map(AccruePreview memory accrue) external pure returns (ClaimPreview memory) { return _map(accrue); } + + // ------------------ Bitwise functions ----------------------- // + + function setBit(uint8 flags, uint256 bitIndex) external pure returns (uint8) { + return Bitwise.setBit(flags, bitIndex); + } + + function clearBit(uint8 flags, uint256 bitIndex) external pure returns (uint8) { + return Bitwise.clearBit(flags, bitIndex); + } + + function isBitSet(uint8 flags, uint256 bitIndex) external pure returns (bool) { + return Bitwise.isBitSet(flags, bitIndex); + } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 434fc21..f170d99 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -35,16 +35,16 @@ const config: HardhatUserConfig = { accounts: process.env.CW_TESTNET_PK ? [process.env.CW_TESTNET_PK] : { - mnemonic: process.env.CW_TESTNET_MNEMONIC ?? "" - } + mnemonic: process.env.CW_TESTNET_MNEMONIC ?? "" + } }, cw_mainnet: { url: process.env.CW_MAINNET_RPC, accounts: process.env.CW_MAINNET_PK ? [process.env.CW_MAINNET_PK] : { - mnemonic: process.env.CW_MAINNET_MNEMONIC ?? "" - } + mnemonic: process.env.CW_MAINNET_MNEMONIC ?? "" + } } }, gasReporter: { diff --git a/test-utils/common.ts b/test-utils/common.ts index 2c47d5c..0776495 100644 --- a/test-utils/common.ts +++ b/test-utils/common.ts @@ -1,5 +1,6 @@ import { network } from "hardhat"; import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; export async function setUpFixture(func: () => Promise): Promise { if (network.name === "hardhat") { @@ -10,3 +11,31 @@ export async function setUpFixture(func: () => Promise): Promise { return func(); } } + +export function checkEquality>( + actualObject: T, + expectedObject: T, + index?: number, + props: { + ignoreObjects: boolean; + } = { ignoreObjects: false } +) { + const indexString = index == null ? "" : ` with index: ${index}`; + Object.keys(expectedObject).forEach(property => { + const value = actualObject[property]; + if (typeof value === "undefined" || typeof value === "function") { + throw Error(`Property "${property}" is not found in the actual object` + indexString); + } + if (typeof expectedObject[property] === "object" && props.ignoreObjects) { + return; + } + expect(value).to.eq( + expectedObject[property], + `Mismatch in the "${property}" property between the actual object and expected one` + indexString + ); + }); +} + +export function maxUintForBits(numberOfBits: number): bigint { + return 2n ** BigInt(numberOfBits) - 1n; +} diff --git a/test-utils/eth.ts b/test-utils/eth.ts index e8b9b37..1346c42 100644 --- a/test-utils/eth.ts +++ b/test-utils/eth.ts @@ -1,7 +1,17 @@ -import { ethers, upgrades } from "hardhat"; +import { ethers, network, upgrades } from "hardhat"; import { BaseContract, BlockTag, Contract, ContractFactory, TransactionReceipt, TransactionResponse } from "ethers"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { expect } from "chai"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; + +export async function proveTx(tx: Promise | TransactionResponse): Promise { + const txResponse = await tx; + const txReceipt = await txResponse.wait(); + if (!txReceipt) { + throw new Error("The transaction receipt is empty"); + } + return txReceipt; +} export async function checkContractUupsUpgrading( contract: Contract, @@ -37,6 +47,12 @@ export function getAddress(contract: Contract): string { return address; } +export async function getTxTimestamp(tx: Promise | TransactionResponse): Promise { + const receipt = await proveTx(tx); + const block = await ethers.provider.getBlock(receipt.blockNumber); + return Number(block?.timestamp ?? 0); +} + export async function getBlockTimestamp(blockTag: BlockTag): Promise { const block = await ethers.provider.getBlock(blockTag); return block?.timestamp ?? 0; @@ -46,25 +62,13 @@ export async function getLatestBlockTimestamp(): Promise { return getBlockTimestamp("latest"); } -export async function proveTx(txResponsePromise: Promise): Promise { - const txResponse = await txResponsePromise; - const txReceipt = await txResponse.wait(); - if (!txReceipt) { - throw new Error("The transaction receipt is empty"); +export async function increaseBlockTimestampTo(target: number) { + if (network.name === "hardhat") { + await time.increaseTo(target); + } else if (network.name === "stratus") { + await ethers.provider.send("evm_setNextBlockTimestamp", [target]); + await ethers.provider.send("evm_mine", []); + } else { + throw new Error(`Setting block timestamp for the current blockchain is not supported: ${network.name}`); } - return txReceipt as TransactionReceipt; -} - -export function checkEquality>(actualObject: T, expectedObject: T, index?: number) { - const indexString = !index ? "" : ` with index: ${index}`; - Object.keys(expectedObject).forEach(property => { - const value = actualObject[property]; - if (typeof value === "undefined" || typeof value === "function" || typeof value === "object") { - throw Error(`Property "${property}" is not found in the actual object` + indexString); - } - expect(value).to.eq( - expectedObject[property], - `Mismatch in the "${property}" property between the actual object and expected one` + indexString - ); - }); } diff --git a/test-utils/specific.ts b/test-utils/specific.ts new file mode 100644 index 0000000..9b12002 --- /dev/null +++ b/test-utils/specific.ts @@ -0,0 +1,163 @@ +export const ERRORS = { + AccessControlUnauthorizedAccount: "AccessControlUnauthorizedAccount", + Bitwise_BitIndexOutOfBounds: "Bitwise_BitIndexOutOfBounds", + ERC20InsufficientBalance: "ERC20InsufficientBalance", + InvalidInitialization: "InvalidInitialization", + NotInitializing: "NotInitializing", + SafeCastOverflowedUintDowncast: "SafeCastOverflowedUintDowncast", + YieldStreamer_AccountAlreadyInitialized: "YieldStreamer_AccountAlreadyInitialized", + YieldStreamer_AccountInitializationProhibited: "YieldStreamer_AccountInitializationProhibited", + YieldStreamer_AccountNotInitialized: "YieldStreamer_AccountNotInitialized", + YieldStreamer_ClaimAmountBelowMinimum: "YieldStreamer_ClaimAmountBelowMinimum", + YieldStreamer_ClaimAmountNonRounded: "YieldStreamer_ClaimAmountNonRounded", + YieldStreamer_EmptyArray: "YieldStreamer_EmptyArray", + YieldStreamer_FeeReceiverAlreadyConfigured: "YieldStreamer_FeeReceiverAlreadyConfigured", + YieldStreamer_GroupAlreadyAssigned: "YieldStreamer_GroupAlreadyAssigned", + YieldStreamer_HookCallerUnauthorized: "YieldStreamer_HookCallerUnauthorized", + YieldStreamer_ImplementationAddressInvalid: "YieldStreamer_ImplementationAddressInvalid", + YieldStreamer_SourceYieldStreamerAlreadyConfigured: "YieldStreamer_SourceYieldStreamerAlreadyConfigured", + YieldStreamer_SourceYieldStreamerGroupAlreadyMapped: "YieldStreamer_SourceYieldStreamerGroupAlreadyMapped", + YieldStreamer_SourceYieldStreamerNotConfigured: "YieldStreamer_SourceYieldStreamerNotConfigured", + YieldStreamer_SourceYieldStreamerUnauthorizedBlocklister: "YieldStreamer_SourceYieldStreamerUnauthorizedBlocklister", + YieldStreamer_TimeRangeInvalid: "YieldStreamer_TimeRangeInvalid", + YieldStreamer_TimeRangeIsInvalid: "YieldStreamer_TimeRangeIsInvalid", + YieldStreamer_TokenAddressZero: "YieldStreamer_TokenAddressZero", + YieldStreamer_YieldBalanceInsufficient: "YieldStreamer_YieldBalanceInsufficient", + YieldStreamer_YieldRateArrayIsEmpty: "YieldStreamer_YieldRateArrayIsEmpty", + YieldStreamer_YieldRateInvalidEffectiveDay: "YieldStreamer_YieldRateInvalidEffectiveDay", + YieldStreamer_YieldRateInvalidItemIndex: "YieldStreamer_YieldRateInvalidItemIndex" +}; + +export const HOUR = 3600n; // 1 hour (in seconds) +export const DAY = 24n * HOUR; // 1 day (in seconds) + +export const RATE_FACTOR = 1_000_000_000_000n; // 10^12 +export const ROUND_FACTOR = 10_000n; // 10^4 +export const FEE_RATE = 0n; +export const NEGATIVE_TIME_SHIFT = 3n * HOUR; // 3 hours +export const MIN_CLAIM_AMOUNT = 1_000_000n; // 1 BRLC +export const ENABLE_YIELD_STATE_AUTO_INITIALIZATION = false; + +export interface YieldState { + flags: bigint; + streamYield: bigint; + accruedYield: bigint; + lastUpdateTimestamp: bigint; + lastUpdateBalance: bigint; +} + +export interface RateTier { + rate: bigint; + cap: bigint; +} + +export interface YieldRate { + tiers: RateTier[]; + effectiveDay: bigint; +} + +export interface YieldResult { + partialFirstDayYield: bigint; + fullDaysYield: bigint; + partialLastDayYield: bigint; + partialFirstDayYieldTiered: bigint[]; + fullDaysYieldTiered: bigint[]; + partialLastDayYieldTiered: bigint[]; +} + +export interface AccruePreview { + fromTimestamp: bigint; + toTimestamp: bigint; + balance: bigint; + streamYieldBefore: bigint; + accruedYieldBefore: bigint; + streamYieldAfter: bigint; + accruedYieldAfter: bigint; + rates: YieldRate[]; + results: YieldResult[]; +} + +export interface ClaimPreview { + yieldExact: bigint; + yieldRounded: bigint; + feeExact: bigint; + feeRounded: bigint; + timestamp: bigint; + balance: bigint; + rates: bigint[]; + caps: bigint[]; +} + +export const defaultYieldState: YieldState = { + flags: 0n, + streamYield: 0n, + accruedYield: 0n, + lastUpdateTimestamp: 0n, + lastUpdateBalance: 0n +}; + +export function normalizeYieldRate(rate: YieldRate): YieldRate { + return { + effectiveDay: rate.effectiveDay, + tiers: rate.tiers.map((tier: RateTier) => ({ + rate: tier.rate, + cap: tier.cap + })) + }; +} + +export function normalizeYieldResult(result: YieldResult): YieldResult { + return { + partialFirstDayYield: result.partialFirstDayYield, + fullDaysYield: result.fullDaysYield, + partialLastDayYield: result.partialLastDayYield, + partialFirstDayYieldTiered: [...result.partialFirstDayYieldTiered], + fullDaysYieldTiered: [...result.fullDaysYieldTiered], + partialLastDayYieldTiered: [...result.partialLastDayYieldTiered] + }; +} + +export function normalizeAccruePreview(result: AccruePreview): AccruePreview { + return { + fromTimestamp: result.fromTimestamp, + toTimestamp: result.toTimestamp, + balance: result.balance, + streamYieldBefore: result.streamYieldBefore, + accruedYieldBefore: result.accruedYieldBefore, + streamYieldAfter: result.streamYieldAfter, + accruedYieldAfter: result.accruedYieldAfter, + rates: result.rates.map((r: YieldRate) => ({ + tiers: r.tiers.map((t: RateTier) => ({ + rate: t.rate, + cap: t.cap + })), + effectiveDay: r.effectiveDay + })), + results: result.results.map(normalizeYieldResult) + }; +} + +export function normalizeClaimPreview(claimPreview: ClaimPreview): ClaimPreview { + return { + yieldExact: claimPreview.yieldExact, + yieldRounded: claimPreview.yieldRounded, + feeExact: claimPreview.feeExact, + feeRounded: claimPreview.feeRounded, + timestamp: claimPreview.timestamp, + balance: claimPreview.balance, + rates: [...claimPreview.rates], + caps: [...claimPreview.caps] + }; +} + +export function roundDown(amount: bigint): bigint { + return (amount / ROUND_FACTOR) * ROUND_FACTOR; +} + +export function adjustTimestamp(timestamp: number | bigint): bigint { + return BigInt(timestamp) - NEGATIVE_TIME_SHIFT; +} + +export function normalizeTimestamp(timestamp: number | bigint): number { + return Number(BigInt(timestamp) + NEGATIVE_TIME_SHIFT); +} diff --git a/test/YieldStreamer.external.test.ts b/test/YieldStreamer.external.test.ts new file mode 100644 index 0000000..14bd2f8 --- /dev/null +++ b/test/YieldStreamer.external.test.ts @@ -0,0 +1,2271 @@ +import { expect } from "chai"; +import { ethers, upgrades } from "hardhat"; +import { Contract, ContractFactory, TransactionResponse } from "ethers"; +import { + checkContractUupsUpgrading, + connect, + getAddress, + getLatestBlockTimestamp, + getTxTimestamp, + increaseBlockTimestampTo, + proveTx +} from "../test-utils/eth"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { checkEquality, maxUintForBits, setUpFixture } from "../test-utils/common"; +import { + AccruePreview, + adjustTimestamp, + ClaimPreview, + DAY, + defaultYieldState, + ENABLE_YIELD_STATE_AUTO_INITIALIZATION, + ERRORS, + FEE_RATE, + HOUR, + MIN_CLAIM_AMOUNT, + NEGATIVE_TIME_SHIFT, + normalizeAccruePreview, + normalizeClaimPreview, + normalizeTimestamp, + normalizeYieldRate, + normalizeYieldResult, + RATE_FACTOR, + ROUND_FACTOR, + roundDown, + YieldRate, + YieldState +} from "../test-utils/specific"; + +const EVENTS = { + YieldStreamer_AccountInitialized: "YieldStreamer_AccountInitialized", + YieldStreamer_FeeReceiverChanged: "YieldStreamer_FeeReceiverChanged", + YieldStreamer_GroupAssigned: "YieldStreamer_GroupAssigned", + YieldStreamer_GroupMapped: "YieldStreamer_GroupMapped", + YieldStreamer_InitializedFlagSet: "YieldStreamer_InitializedFlagSet", + YieldStreamer_SourceYieldStreamerChanged: "YieldStreamer_SourceYieldStreamerChanged", + YieldStreamer_YieldAccrued: "YieldStreamer_YieldAccrued", + YieldStreamer_YieldRateAdded: "YieldStreamer_YieldRateAdded", + YieldStreamer_YieldRateUpdated: "YieldStreamer_YieldRateUpdated", + YieldStreamer_YieldTransferred: "YieldStreamer_YieldTransferred", + YieldStreamerV1Mock_BlocklistCalled: "YieldStreamerV1Mock_BlocklistCalled" +}; + +const ADDRESS_ZERO = ethers.ZeroAddress; +const DEFAULT_GROUP_ID = 0n; +const EFFECTIVE_DAY_ZERO = 0n; +const CAP_ZERO = 0n; +const RATE_ZERO = 0n; +const MAX_GROUP_ID = maxUintForBits(32); +const DEFAULT_SOURCE_GROUP_KEY = ethers.ZeroHash; +const STATE_FLAG_INITIALIZED = 1n; + +const INITIAL_YIELD_STREAMER_BALANCE = 1_000_000_000n; +const RATE_40 = (RATE_FACTOR * 40n) / 100n; // 40% +const RATE_80 = (RATE_FACTOR * 80n) / 100n; // 80% +const RATE = RATE_FACTOR / 100n; // 1% + +const OWNER_ROLE: string = ethers.id("OWNER_ROLE"); +const ADMIN_ROLE: string = ethers.id("ADMIN_ROLE"); +const PAUSER_ROLE: string = ethers.id("PAUSER_ROLE"); + +// The yield rates array for the default yield group +const YIELD_RATES1: YieldRate[] = [{ + effectiveDay: 0n, + tiers: [{ rate: RATE, cap: 0n }] +}]; + +// The yield rates array for the max yield group +const YIELD_RATES2: YieldRate[] = [ + { + tiers: [ + { + rate: maxUintForBits(48), + cap: maxUintForBits(64) + }, + { + rate: 0n, + cap: 0n + }, + { + rate: 123456789n, + cap: 987654321n + } + ], + effectiveDay: 0n + }, + { + tiers: [ + { + rate: maxUintForBits(48) - 1n, + cap: maxUintForBits(64) - 1n + }, + { + rate: 0n + 1n, + cap: 0n + 1n + }, + { + rate: 123456789n - 1n, + cap: 987654321n + 1n + } + ], + effectiveDay: 12345n + }, + { + tiers: [ + { + rate: maxUintForBits(48) - 2n, + cap: maxUintForBits(64) - 2n + }, + { + rate: 0n + 2n, + cap: 0n + 2n + }, + { + rate: 123456789n - 2n, + cap: 987654321n + 2n + } + ], + effectiveDay: maxUintForBits(16) + } +]; + +export interface YieldStreamerV1ClaimResult { + nextClaimDay: bigint; + nextClaimDebit: bigint; + firstYieldDay: bigint; + prevClaimDebit: bigint; + primaryYield: bigint; + streamYield: bigint; + lastDayPartialYield: bigint; + shortfall: bigint; + fee: bigint; + yield: bigint; +} + +interface Version { + major: number; + minor: number; + patch: number; + + [key: string]: number; // Indexing signature to ensure that fields are iterated over in a key-value style +} + +interface BalanceActionItem { + relativeTime: { day: bigint; hour: bigint }; // The action time relative to the scenario start time + balanceChange: bigint; // Amount to increase (positive) or decrease (negative) the balance + expectedYieldState: { // The expected yield state fields after the balance action + lastUpdateBalance: bigint; + accruedYield: bigint; + streamYield: bigint; + }; +} + +interface Fixture { + yieldStreamer: Contract; + yieldStreamerUnderAdmin: Contract; + yieldStreamerV1Mock: Contract; + tokenMock: Contract; + tokenMockAddress: string; +} + +const EXPECTED_VERSION: Version = { + major: 2, + minor: 2, + patch: 0 +}; + +function getTierRates(yieldRate: YieldRate): bigint[] { + return yieldRate.tiers.map(tier => tier.rate); +} + +function getTierCaps(yieldRate: YieldRate): bigint[] { + return yieldRate.tiers.map(tier => tier.cap); +} + +async function getGroupsForAccounts(yieldStreamer: Contract, accounts: string[]): Promise { + const actualGroups: bigint[] = []; + for (const account of accounts) { + actualGroups.push(await yieldStreamer.getAccountGroup(account)); + } + return actualGroups; +} + +export const yieldStreamerV1ClaimResult: YieldStreamerV1ClaimResult = { + nextClaimDay: 0n, + nextClaimDebit: 0n, + firstYieldDay: 0n, + prevClaimDebit: 0n, + primaryYield: 0n, + streamYield: 0n, + lastDayPartialYield: 0n, + shortfall: 0n, + fee: 0n, + yield: 0n +}; + +describe("Contract 'YieldStreamer' regarding external functions", async () => { + let yieldStreamerFactory: ContractFactory; + let yieldStreamerV1MockFactory: ContractFactory; + let tokenMockFactory: ContractFactory; + + let deployer: HardhatEthersSigner; + let admin: HardhatEthersSigner; + let feeReceiver: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let users: HardhatEthersSigner[]; + + before(async () => { + [deployer, admin, feeReceiver, stranger, ...users] = await ethers.getSigners(); + + // Factories with an explicitly specified deployer account + yieldStreamerFactory = await ethers.getContractFactory("YieldStreamerTestable"); + yieldStreamerFactory = yieldStreamerFactory.connect(deployer); + yieldStreamerV1MockFactory = await ethers.getContractFactory("YieldStreamerV1Mock"); + yieldStreamerV1MockFactory = yieldStreamerV1MockFactory.connect(deployer); + tokenMockFactory = await ethers.getContractFactory("ERC20TokenMock"); + tokenMockFactory = tokenMockFactory.connect(deployer); + }); + + async function getLatestBlockAdjustedTimestamp(): Promise { + return adjustTimestamp(await getLatestBlockTimestamp()); + } + + async function getNearestDayEndAdjustedTimestamp(): Promise { + const adjustedTimestamp = await getLatestBlockAdjustedTimestamp(); + let nearestDayEndAdjustedTimestamp = (adjustedTimestamp / DAY) * DAY + DAY - 1n; + // If the current timestamp is too close to the day end then the next day end must be taken to have time for tests + if (nearestDayEndAdjustedTimestamp - adjustedTimestamp <= 20 * 60) { + nearestDayEndAdjustedTimestamp += DAY; + } + return nearestDayEndAdjustedTimestamp; + } + + function calculateEffectiveDay(adjustedTimestamp: bigint, additionalNumberOfDays: bigint): bigint { + return (adjustedTimestamp + additionalNumberOfDays * DAY) / DAY; + } + + async function addYieldRates( + yieldStreamer: Contract, + yieldRates: YieldRate[], + groupId: number | bigint = DEFAULT_GROUP_ID + ): Promise { + for (const yieldRate of yieldRates) { + const tierRates = yieldRate.tiers.map(tier => tier.rate); + const tierCaps = yieldRate.tiers.map(tier => tier.cap); + await proveTx(yieldStreamer.addYieldRate(groupId, yieldRate.effectiveDay, tierRates, tierCaps)); + } + } + + async function executeBalanceActionsAndCheck( + fixture: Fixture, + yieldRates: YieldRate[], + balanceActions: BalanceActionItem[] + ) { + const { yieldStreamer, tokenMock } = fixture; + + await proveTx(tokenMock.setHook(getAddress(yieldStreamer))); + + // Set the initialized state for the user + await yieldStreamer.setInitializedFlag(users[0].address, true); + + // Add yield rates to the contract + await addYieldRates(yieldStreamer, yieldRates); + + // Set the current block timestamp to the needed start time + const adjustedStartTimestamp = await getNearestDayEndAdjustedTimestamp(); + await increaseBlockTimestampTo(normalizeTimestamp(adjustedStartTimestamp)); + + // Iterate over each action in the schedule + for (const [index, actionItem] of balanceActions.entries()) { + // Calculate the desired internal timestamp for the action based on day and hour offsets + const desiredInternalTimestamp = + adjustedStartTimestamp + actionItem.relativeTime.day * DAY + actionItem.relativeTime.hour * HOUR; + + // Adjust for NEGATIVE_TIME_SHIFT to set the block.timestamp + const normalizedTimestamp = normalizeTimestamp(desiredInternalTimestamp); + + // Ensure the timestamp is strictly greater than the current block timestamp + const currentTimestamp = await getLatestBlockTimestamp(); + const timestampToSet = normalizedTimestamp <= currentTimestamp ? currentTimestamp + 1 : normalizedTimestamp; + + // Increase the blockchain time to the desired adjusted timestamp + await increaseBlockTimestampTo(timestampToSet); + + // Perform the deposit or withdraw action based on the action type + let tx: Promise; + if (actionItem.balanceChange >= 0) { + // Perform a deposit action + tx = tokenMock.mint(users[0].address, actionItem.balanceChange); + } else { + // Perform a withdrawal action + tx = tokenMock.burn(users[0].address, -actionItem.balanceChange); + } + const txTimestamp = await getTxTimestamp(tx); + + // Fetch the actual yield state from the contract after the action + const actualYieldState = await yieldStreamer.getYieldState(users[0].address); + + // Update the expected lastUpdateTimestamp with the adjusted block timestamp and set the correct flags + const expectedYieldState: YieldState = { + ...defaultYieldState, + flags: STATE_FLAG_INITIALIZED, + streamYield: actionItem.expectedYieldState.streamYield, + accruedYield: actionItem.expectedYieldState.accruedYield, + lastUpdateTimestamp: adjustTimestamp(txTimestamp), + lastUpdateBalance: actionItem.expectedYieldState.lastUpdateBalance + }; + + // Assert that the actual yield state matches the expected state + checkEquality(actualYieldState, expectedYieldState, index); + } + } + + function calculateStreamYield( + yieldState: YieldState, + dailyRate: bigint, + timestamp: bigint + ) { + const dailyYieldWithRateFactor = (yieldState.lastUpdateBalance + yieldState.accruedYield) * dailyRate; + return dailyYieldWithRateFactor * (timestamp - yieldState.lastUpdateTimestamp) / (DAY * RATE_FACTOR); + } + + async function deployContracts(): Promise { + let tokenMock: Contract = (await tokenMockFactory.deploy("Mock Token", "MTK")) as Contract; + tokenMock = connect(tokenMock, deployer); // Explicitly specifying the initial account + await tokenMock.waitForDeployment(); + + let yieldStreamerV1Mock: Contract = (await yieldStreamerV1MockFactory.deploy()) as Contract; + yieldStreamerV1Mock = connect(yieldStreamerV1Mock, deployer); // Explicitly specifying the initial account + await yieldStreamerV1Mock.waitForDeployment(); + + const tokenMockAddress = getAddress(tokenMock); + let yieldStreamer: Contract = (await upgrades.deployProxy(yieldStreamerFactory, [tokenMockAddress])) as Contract; + yieldStreamer = connect(yieldStreamer, deployer); // Explicitly specifying the initial account + await yieldStreamer.waitForDeployment(); + const yieldStreamerUnderAdmin = connect(yieldStreamer, admin); + + return { yieldStreamer, yieldStreamerUnderAdmin, yieldStreamerV1Mock, tokenMock, tokenMockAddress }; + } + + async function deployAndConfigureContracts(): Promise { + const fixture = await deployContracts(); + const { yieldStreamer, yieldStreamerV1Mock, tokenMock } = fixture; + + await yieldStreamer.setSourceYieldStreamer(getAddress(yieldStreamerV1Mock)); + await yieldStreamerV1Mock.setBlocklister(getAddress(yieldStreamer), true); + await yieldStreamer.grantRole(ADMIN_ROLE, admin.address); + await addYieldRates(yieldStreamer, YIELD_RATES1, DEFAULT_GROUP_ID); + await addYieldRates(yieldStreamer, YIELD_RATES2, MAX_GROUP_ID); + + await proveTx(tokenMock.mint(getAddress(yieldStreamer), INITIAL_YIELD_STREAMER_BALANCE)); + + return fixture; + } + + describe("Function initialize()", async () => { + it("Configures the contract as expected", async () => { + const { yieldStreamer, tokenMockAddress } = await setUpFixture(deployContracts); + + // Role hashes + expect(await yieldStreamer.OWNER_ROLE()).to.equal(OWNER_ROLE); + expect(await yieldStreamer.ADMIN_ROLE()).to.equal(ADMIN_ROLE); + expect(await yieldStreamer.PAUSER_ROLE()).to.equal(PAUSER_ROLE); + + // The role admins + expect(await yieldStreamer.getRoleAdmin(OWNER_ROLE)).to.equal(OWNER_ROLE); + expect(await yieldStreamer.getRoleAdmin(ADMIN_ROLE)).to.equal(OWNER_ROLE); + expect(await yieldStreamer.getRoleAdmin(PAUSER_ROLE)).to.equal(OWNER_ROLE); + + // Roles + expect(await yieldStreamer.hasRole(OWNER_ROLE, deployer.address)).to.equal(true); + expect(await yieldStreamer.hasRole(ADMIN_ROLE, deployer.address)).to.equal(false); + expect(await yieldStreamer.hasRole(PAUSER_ROLE, deployer.address)).to.equal(false); + + // The initial contract state is unpaused + expect(await yieldStreamer.paused()).to.equal(false); + + // Public constants + expect(await yieldStreamer.RATE_FACTOR()).to.equal(RATE_FACTOR); + expect(await yieldStreamer.ROUND_FACTOR()).to.equal(ROUND_FACTOR); + expect(await yieldStreamer.FEE_RATE()).to.equal(FEE_RATE); + expect(await yieldStreamer.NEGATIVE_TIME_SHIFT()).to.equal(NEGATIVE_TIME_SHIFT); + expect(await yieldStreamer.MIN_CLAIM_AMOUNT()).to.equal(MIN_CLAIM_AMOUNT); + expect(await yieldStreamer.ENABLE_YIELD_STATE_AUTO_INITIALIZATION()).to.equal( + ENABLE_YIELD_STATE_AUTO_INITIALIZATION + ); + + // Default values of the internal structures, mappings and variables. Also checks the set of fields + expect(await yieldStreamer.underlyingToken()).to.equal(tokenMockAddress); + expect(await yieldStreamer.feeReceiver()).to.equal(ADDRESS_ZERO); + expect(await yieldStreamer.getAccountGroup(users[0].address)).to.equal(DEFAULT_GROUP_ID); + checkEquality(await yieldStreamer.getYieldState(users[0].address), defaultYieldState); + const actualYieldRates = await yieldStreamer.getGroupYieldRates(DEFAULT_GROUP_ID); + expect(actualYieldRates.length).to.equal(0); + + expect(await yieldStreamer.sourceYieldStreamer()).to.equal(ADDRESS_ZERO); + expect( + await yieldStreamer.getSourceGroupMapping(DEFAULT_SOURCE_GROUP_KEY) // Call via the testable version + ).to.equal(DEFAULT_GROUP_ID); + }); + + it("Is reverted if called a second time", async () => { + const { yieldStreamer, tokenMockAddress } = await setUpFixture(deployContracts); + + await expect(yieldStreamer.initialize(tokenMockAddress)).to.be.revertedWithCustomError( + yieldStreamer, + ERRORS.InvalidInitialization + ); + }); + + it("Is reverted if the underlying token address is zero", async () => { + const wrongTokenAddress = (ADDRESS_ZERO); + await expect(upgrades.deployProxy(yieldStreamerFactory, [wrongTokenAddress])) + .to.be.revertedWithCustomError(yieldStreamerFactory, ERRORS.YieldStreamer_TokenAddressZero); + }); + + it("Is reverted if the internal initializer is called outside the init process", async () => { + const { yieldStreamer, tokenMockAddress } = await setUpFixture(deployContracts); + await expect( + yieldStreamer.call_parent_initialize(tokenMockAddress) // Call via the testable version + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.NotInitializing); + }); + + it("Is reverted if the unchained internal initializer is called outside the init process", async () => { + const { yieldStreamer, tokenMockAddress } = await setUpFixture(deployContracts); + await expect( + yieldStreamer.call_parent_initialize_unchained(tokenMockAddress) // Call via the testable version + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.NotInitializing); + }); + }); + + describe("Function '$__VERSION()'", async () => { + it("Returns expected values", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const actualVersion = await yieldStreamer.$__VERSION(); + checkEquality(actualVersion, EXPECTED_VERSION); + }); + }); + + describe("Function 'upgradeToAndCall()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + await checkContractUupsUpgrading(yieldStreamer, yieldStreamerFactory); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamer, admin).upgradeToAndCall(yieldStreamer, "0x")) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(admin.address, OWNER_ROLE); + await expect(connect(yieldStreamer, stranger).upgradeToAndCall(yieldStreamer, "0x")) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + + it("Is reverted if the provided implementation address is not a yield streamer contract", async () => { + const { yieldStreamer, tokenMockAddress } = await setUpFixture(deployContracts); + + await expect(yieldStreamer.upgradeToAndCall(tokenMockAddress, "0x")) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_ImplementationAddressInvalid); + }); + }); + + describe("Function 'addYieldRate()'", async () => { + const groupId = (MAX_GROUP_ID); + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // Add first yield rate with the zero effective day + const tx1 = yieldStreamer.addYieldRate( + groupId, + YIELD_RATES2[0].effectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ); + + await proveTx(tx1); + const actualRates1: YieldRate[] = (await yieldStreamer.getGroupYieldRates(groupId)).map(normalizeYieldRate); + const expectedRates1: YieldRate[] = [YIELD_RATES2[0]]; + expect(actualRates1).to.deep.equal(expectedRates1); + await expect(tx1) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldRateAdded) + .withArgs( + groupId, + YIELD_RATES2[0].effectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ); + + // Add second yield rate with a non-zero effective day + const tx2 = yieldStreamer.addYieldRate( + groupId, + YIELD_RATES2[1].effectiveDay, + getTierRates(YIELD_RATES2[1]), + getTierCaps(YIELD_RATES2[1]) + ); + + await proveTx(tx2); + const actualRates2: YieldRate[] = (await yieldStreamer.getGroupYieldRates(groupId)).map(normalizeYieldRate); + const expectedRates2: YieldRate[] = [YIELD_RATES2[0], YIELD_RATES2[1]]; + expect(actualRates2).to.deep.equal(expectedRates2); + await expect(tx2) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldRateAdded) + .withArgs( + groupId, + YIELD_RATES2[1].effectiveDay, + getTierRates(YIELD_RATES2[1]), + getTierCaps(YIELD_RATES2[1]) + ); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + await expect( + connect(yieldStreamer, stranger).addYieldRate( + groupId, + YIELD_RATES2[0].effectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ) + ).to.be.revertedWithCustomError( + yieldStreamer, + ERRORS.AccessControlUnauthorizedAccount + ).withArgs(stranger.address, OWNER_ROLE); + }); + + // it("Is reverted if the provided tier rates and tier caps arrays are empty", async () => { + // const { yieldStreamer } = await setUpFixture(deployContracts); + // const emptyArray: bigint[] = []; + // + // await expect(yieldStreamer.addYieldRate(groupId, 0n, emptyArray, emptyArray)).to.be.reverted; + // }); + + it("Is reverted if the provided tier rates and caps arrays have different lengths", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const tierRates1 = [100n, 200n]; + const tierCaps1 = [300n]; + // const tierRates2 = [100n]; + // const tierCaps2 = [200n, 300n]; + + await expect(yieldStreamer.addYieldRate(groupId, 0n, tierRates1, tierCaps1)).to.be.revertedWithPanic(0x32); + // await expect(yieldStreamer.addYieldRate(groupId, 0n, tierRates2, tierCaps2)).to.be.revertedWithPanic(0x32); + }); + + it("Is reverted if the first added yield rate has a non-zero effective day", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const nonZeroEffectiveDay = 1n; + + await expect( + yieldStreamer.addYieldRate( + groupId, + nonZeroEffectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ) + ).revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidEffectiveDay); + }); + + it("Is reverted if the new eff. day is not greater than the eff. day of the preceding rate object", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // Add initial yield rates to the group + await addYieldRates(yieldStreamer, [YIELD_RATES2[0], YIELD_RATES2[1]], groupId); + + // Create new yield rate with same effective day as previous rate + const newYieldRate: YieldRate = { ...YIELD_RATES2[2] }; + newYieldRate.effectiveDay = YIELD_RATES2[1].effectiveDay; + + // Attempt to add yield rate - should revert since effective day is not greater + await expect( + yieldStreamer.addYieldRate( + groupId, + newYieldRate.effectiveDay, + getTierRates(newYieldRate), + getTierCaps(newYieldRate) + ) + ).revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidEffectiveDay); + }); + + it("Is reverted if the provided effective day is greater than uint16 max value", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const wrongEffectiveDay = maxUintForBits(16) + 1n; + + await proveTx(yieldStreamer.addYieldRate( + groupId, + EFFECTIVE_DAY_ZERO, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + )); + + await expect( + yieldStreamer.addYieldRate( + groupId, + wrongEffectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ) + ).revertedWithCustomError( + yieldStreamer, + ERRORS.SafeCastOverflowedUintDowncast + ).withArgs(16, wrongEffectiveDay); + }); + + it("Is reverted if the provided rate is greater than uint48 max value", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const wrongRate = maxUintForBits(48) + 1n; + + await expect( + yieldStreamer.addYieldRate( + groupId, + EFFECTIVE_DAY_ZERO, + [wrongRate], + [CAP_ZERO] + ) + ).revertedWithCustomError( + yieldStreamer, + ERRORS.SafeCastOverflowedUintDowncast + ).withArgs(48, wrongRate); + }); + + it("Is reverted if the provided cap is greater than uint64 max value", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const wrongCap = maxUintForBits(64) + 1n; + + await expect( + yieldStreamer.addYieldRate( + groupId, + EFFECTIVE_DAY_ZERO, + [RATE_ZERO], + [wrongCap] + ) + ).revertedWithCustomError( + yieldStreamer, + ERRORS.SafeCastOverflowedUintDowncast + ).withArgs(64, wrongCap); + }); + }); + + describe("Function 'updateYieldRate()'", async () => { + const groupId = (MAX_GROUP_ID); + + async function executeAndCheck(initialYieldRates: YieldRate[], props: { groupId: bigint; itemIndex: number }) { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + + // Set up test parameters + const { groupId, itemIndex } = props; + const yieldRateUpdated: YieldRate = { ...initialYieldRates[itemIndex] }; + yieldRateUpdated.tiers = [{ rate: 123456789n, cap: 987654321n }, { rate: 987654321n, cap: 0n }]; + if (initialYieldRates.length > 1 && itemIndex != 0) { + yieldRateUpdated.effectiveDay = + (YIELD_RATES2[itemIndex - 1].effectiveDay + YIELD_RATES2[itemIndex].effectiveDay) / 2n; + } + + const tx = yieldStreamer.updateYieldRate( + groupId, + itemIndex, + yieldRateUpdated.effectiveDay, + getTierRates(yieldRateUpdated), + getTierCaps(yieldRateUpdated) + ); + await proveTx(tx); + + const actualRates: YieldRate[] = (await yieldStreamer.getGroupYieldRates(groupId)).map(normalizeYieldRate); + const expectedRates: YieldRate[] = [...initialYieldRates]; + expectedRates[itemIndex] = yieldRateUpdated; + expect(actualRates).to.deep.equal(expectedRates); + + expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldRateUpdated) + .withArgs( + groupId, + itemIndex, + yieldRateUpdated.effectiveDay, + getTierRates(yieldRateUpdated), + getTierCaps(yieldRateUpdated) + ); + } + + describe("Executes as expected if there are several items in the yield rate array and", async () => { + it("The item index to update is zero", async () => { + expect(YIELD_RATES2.length).greaterThanOrEqual(3); + await executeAndCheck(YIELD_RATES2, { groupId, itemIndex: 0 }); + }); + + it("The item index to update is in the middle", async () => { + expect(YIELD_RATES2.length).greaterThanOrEqual(3); + await executeAndCheck(YIELD_RATES2, { groupId, itemIndex: 1 }); + }); + + it("The item index to update is the last one", async () => { + expect(YIELD_RATES2.length).greaterThanOrEqual(3); + await executeAndCheck(YIELD_RATES2, { groupId, itemIndex: YIELD_RATES2.length - 1 }); + }); + }); + + describe("Executes as expected if there is a single item in the yield rate array and", async () => { + it("The item index to update is zero", async () => { + expect(YIELD_RATES1.length).to.equal(1); + await executeAndCheck(YIELD_RATES1, { groupId: DEFAULT_GROUP_ID, itemIndex: 0 }); + }); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndex = 1; + + await expect( + connect(yieldStreamer, admin).updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex].effectiveDay, + getTierRates(YIELD_RATES2[itemIndex]), + getTierCaps(YIELD_RATES2[itemIndex]) + ) + ).to.be.revertedWithCustomError( + yieldStreamer, + ERRORS.AccessControlUnauthorizedAccount + ).withArgs(admin.address, OWNER_ROLE); + + await expect( + connect(yieldStreamer, stranger).updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex].effectiveDay, + getTierRates(YIELD_RATES2[itemIndex]), + getTierCaps(YIELD_RATES2[itemIndex]) + ) + ).to.be.revertedWithCustomError( + yieldStreamer, + ERRORS.AccessControlUnauthorizedAccount + ).withArgs(stranger.address, OWNER_ROLE); + }); + + it("Is reverted if the being updated yield rate has index 0 but the effective day is non-zero", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndex = 0; + const nonZeroEffectiveDay = 1n; + + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + nonZeroEffectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ) + ).revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidEffectiveDay); + }); + + it("Is reverted if the yield rate array to update is empty", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const emptyGroupId = 123n; + const itemIndex = 0; + + await expect( + yieldStreamer.updateYieldRate( + emptyGroupId, + itemIndex, + YIELD_RATES2[0].effectiveDay, + getTierRates(YIELD_RATES2[0]), + getTierCaps(YIELD_RATES2[0]) + ) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidItemIndex); + }); + + it("Is reverted if the provided item index is greater than the length of the yield rates array", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const lastIndex = YIELD_RATES2.length - 1; + const invalidItemIndex = lastIndex + 1; + + await expect( + yieldStreamer.updateYieldRate( + groupId, + invalidItemIndex, + YIELD_RATES2[lastIndex].effectiveDay, + getTierRates(YIELD_RATES2[lastIndex]), + getTierCaps(YIELD_RATES2[lastIndex]) + ) + ).revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidItemIndex); + }); + + it("Is reverted if the provided tier rates and tier caps arrays are empty", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const emptyArray: bigint[] = []; + const itemIndex = 1; + + await expect(yieldStreamer.updateYieldRate(groupId, itemIndex, 0n, emptyArray, emptyArray)).to.be.reverted; + }); + + it("Is reverted if the provided tier rates and caps arrays have different lengths", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const tierRates1 = [100n, 200n]; + const tierCaps1 = [300n]; + // const tierRates2 = [100n]; + // const tierCaps2 = [200n, 300n]; + const itemIndex = 1; + + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex].effectiveDay, + tierRates1, + tierCaps1 + ) + ).to.be.reverted; + // await expect( + // yieldStreamer.updateYieldRate( + // groupId, + // itemIndex, + // YIELD_RATES[itemIndex].effectiveDay, + // tierRates2, + // tierCaps2 + // ) + // ).to.be.reverted; + }); + + it("Is reverted if the new eff. day is not greater than the eff. day of the preceding rate object", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndexes = [1, 2]; + + for (const itemIndex of itemIndexes) { + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex - 1].effectiveDay, + getTierRates(YIELD_RATES2[itemIndex]), + getTierCaps(YIELD_RATES2[itemIndex]) + ) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidEffectiveDay); + } + }); + + it("Is reverted if the new eff. day is not less than the eff. day of the next rate object", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndexes = [0, 1]; + + for (const itemIndex of itemIndexes) { + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex + 1].effectiveDay, + getTierRates(YIELD_RATES2[itemIndex]), + getTierCaps(YIELD_RATES2[itemIndex]) + ) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateInvalidEffectiveDay); + } + }); + + it("Is reverted if the new eff. day is greater than uint16 max value", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndex = YIELD_RATES2.length - 1; + const wrongEffectiveDay = maxUintForBits(16) + 1n; + + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + wrongEffectiveDay, + getTierRates(YIELD_RATES2[itemIndex]), + getTierCaps(YIELD_RATES2[itemIndex]) + ) + ).revertedWithCustomError( + yieldStreamer, + ERRORS.SafeCastOverflowedUintDowncast + ).withArgs(16, wrongEffectiveDay); + }); + + it("Is reverted if the new rate is greater than uint48 max value", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndex = YIELD_RATES2.length - 1; + const wrongRate = maxUintForBits(48) + 1n; + + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex].effectiveDay, + [wrongRate], + [CAP_ZERO] + ) + ).revertedWithCustomError( + yieldStreamer, + ERRORS.SafeCastOverflowedUintDowncast + ).withArgs(48, wrongRate); + }); + + it("Is reverted if the new cap is greater than uint64 max value", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const itemIndex = YIELD_RATES2.length - 1; + const wrongCap = maxUintForBits(64) + 1n; + + await expect( + yieldStreamer.updateYieldRate( + groupId, + itemIndex, + YIELD_RATES2[itemIndex].effectiveDay, + [RATE_ZERO], + [wrongCap] + ) + ).revertedWithCustomError( + yieldStreamer, + ERRORS.SafeCastOverflowedUintDowncast + ).withArgs(64, wrongCap); + }); + }); + + describe("Function 'assignGroup()'", async () => { + const groupId1 = 1n; + const groupId2 = (MAX_GROUP_ID); + describe("Executes as expected if", async () => { + it("The provided account array is NOT empty", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + await addYieldRates(yieldStreamer, [{ effectiveDay: 0n, tiers: [{ rate: RATE_40, cap: 0n }] }], groupId1); + + // Assign one account to the group without yield accrual + { + const accounts = [users[0].address]; + const forceYieldAccrue = false; + const tx = yieldStreamer.assignGroup(groupId1, accounts, forceYieldAccrue); + await proveTx(tx); + + const actualGroups1 = await getGroupsForAccounts(yieldStreamer, accounts); + expect(actualGroups1).to.deep.equal([groupId1]); + + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_GroupAssigned) + .withArgs(users[0].address, groupId1, DEFAULT_GROUP_ID); + await expect(tx).not.to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldAccrued); + } + + // Assign two accounts to the new group with yield accrual + { + const accounts = [users[0].address, users[1].address]; + const forceYieldAccrue = true; + + const tx = yieldStreamer.assignGroup(groupId2, accounts, forceYieldAccrue); + await proveTx(tx); + + const actualGroups2 = await getGroupsForAccounts(yieldStreamer, accounts); + expect(actualGroups2).to.deep.equal([groupId2, groupId2]); + + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_GroupAssigned) + .withArgs(accounts[0], groupId2, groupId1); + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_GroupAssigned) + .withArgs(accounts[1], groupId2, DEFAULT_GROUP_ID); + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldAccrued) + .withArgs(accounts[0], anyValue, anyValue, anyValue, anyValue); + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldAccrued) + .withArgs(accounts[1], anyValue, anyValue, anyValue, anyValue); + } + }); + it("The provided account array is empty", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + await expect(yieldStreamer.assignGroup(groupId2, [], false)).not.to.be.reverted; + }); + }); + + describe("Is reverted if", async () => { + it("The caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const accounts = [users[0].address]; + const forceYieldAccrue = false; + + await expect(connect(yieldStreamer, admin).assignGroup(MAX_GROUP_ID, accounts, forceYieldAccrue)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(admin.address, OWNER_ROLE); + await expect(connect(yieldStreamer, stranger).assignGroup(MAX_GROUP_ID, accounts, forceYieldAccrue)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + + it("The group is already assigned", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const accounts = [users[0].address]; + const forceYieldAccrue = false; + + await expect( + yieldStreamer.assignGroup(DEFAULT_GROUP_ID, accounts, forceYieldAccrue) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_GroupAlreadyAssigned); + + await proveTx(yieldStreamer.assignGroup(MAX_GROUP_ID, accounts, forceYieldAccrue)); + + await expect( + yieldStreamer.assignGroup(MAX_GROUP_ID, accounts, forceYieldAccrue) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_GroupAlreadyAssigned); + }); + }); + }); + + describe("Function 'setFeeReceiver()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // Initial fee receiver setup + await expect(yieldStreamer.setFeeReceiver(feeReceiver.address)) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_FeeReceiverChanged) + .withArgs(feeReceiver.address, ADDRESS_ZERO); + + expect(await yieldStreamer.feeReceiver()).to.equal(feeReceiver.address); + + // Fee receiver reset + await expect(yieldStreamer.setFeeReceiver(ADDRESS_ZERO)) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_FeeReceiverChanged) + .withArgs(ADDRESS_ZERO, feeReceiver.address); + + expect(await yieldStreamer.feeReceiver()).to.equal(ADDRESS_ZERO); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamer, stranger).setFeeReceiver(feeReceiver.address)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + + it("Is reverted if provided receiver is the same as the current one", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // Set the receiver to zero + await expect(yieldStreamer.setFeeReceiver(ADDRESS_ZERO)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_FeeReceiverAlreadyConfigured); + + // Set the receiver to the non-zero address + await proveTx(yieldStreamer.setFeeReceiver(feeReceiver.address)); + + // Try to set the receiver to the same non-zero address + await expect(yieldStreamer.setFeeReceiver(feeReceiver.address)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_FeeReceiverAlreadyConfigured); + }); + }); + + describe("Function 'setSourceYieldStreamer()'", async () => { + const sourceYieldStreamerAddressStub = "0x0000000000000000000000000000000000000001"; + + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // Can be set to non-zero + await expect(yieldStreamer.setSourceYieldStreamer(sourceYieldStreamerAddressStub)) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_SourceYieldStreamerChanged) + .withArgs(ADDRESS_ZERO, sourceYieldStreamerAddressStub); + + expect(await yieldStreamer.sourceYieldStreamer()).to.equal(sourceYieldStreamerAddressStub); + + // Can be set to zero + await expect(yieldStreamer.setSourceYieldStreamer(ADDRESS_ZERO)) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_SourceYieldStreamerChanged) + .withArgs(sourceYieldStreamerAddressStub, ADDRESS_ZERO); + + expect(await yieldStreamer.sourceYieldStreamer()).to.equal(ADDRESS_ZERO); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamer, stranger).setSourceYieldStreamer(sourceYieldStreamerAddressStub)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + + it("Is reverted if the new source yield streamer is the same", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // Check for the zero initial address + await expect(yieldStreamer.setSourceYieldStreamer(ADDRESS_ZERO)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerAlreadyConfigured); + + // Check for a non-zero initial address + await proveTx(yieldStreamer.setSourceYieldStreamer(sourceYieldStreamerAddressStub)); + await expect(yieldStreamer.setSourceYieldStreamer(sourceYieldStreamerAddressStub)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerAlreadyConfigured); + }); + }); + + describe("Function 'mapSourceYieldStreamerGroup()'", async () => { + async function executeAndCheck( + yieldStreamer: Contract, + props: { groupKey: string; newGroupId: bigint; oldGroupId: bigint } + ) { + const { groupKey, newGroupId, oldGroupId } = props; + await expect(yieldStreamer.mapSourceYieldStreamerGroup(groupKey, newGroupId)) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_GroupMapped) + .withArgs(groupKey, newGroupId, oldGroupId); + // Call via the testable version + expect(await yieldStreamer.getSourceGroupMapping(groupKey)).to.equal(newGroupId); + } + + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + // The zero group key can be mapped to a non-zero group ID + await executeAndCheck( + yieldStreamer, + { groupKey: DEFAULT_SOURCE_GROUP_KEY, newGroupId: MAX_GROUP_ID, oldGroupId: DEFAULT_GROUP_ID } + ); + + // The zero group key can be mapped to the zero group ID + await executeAndCheck( + yieldStreamer, + { groupKey: DEFAULT_SOURCE_GROUP_KEY, newGroupId: DEFAULT_GROUP_ID, oldGroupId: MAX_GROUP_ID } + ); + + // The non-zero group key can be mapped to the non-zero group IDs + await executeAndCheck( + yieldStreamer, + { + groupKey: "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + newGroupId: MAX_GROUP_ID, + oldGroupId: DEFAULT_GROUP_ID + } + ); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const groupKey = (DEFAULT_SOURCE_GROUP_KEY); + + await expect(connect(yieldStreamer, stranger).mapSourceYieldStreamerGroup(groupKey, MAX_GROUP_ID)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + + it("Is reverted if source yield streamer group already mapped", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const groupKey = (DEFAULT_SOURCE_GROUP_KEY); + + // Check for the zero initial group ID + await expect( + yieldStreamer.mapSourceYieldStreamerGroup(groupKey, DEFAULT_GROUP_ID) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerGroupAlreadyMapped); + + // Check for a non-zero initial group ID + await proveTx(yieldStreamer.mapSourceYieldStreamerGroup(groupKey, MAX_GROUP_ID)); + await expect(yieldStreamer.mapSourceYieldStreamerGroup(groupKey, MAX_GROUP_ID)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerGroupAlreadyMapped); + }); + }); + + describe("Function 'setInitializedFlag()'", async () => { + async function executeAndCheck( + yieldStreamer: Contract, + props: { newFlagState: boolean; oldFlagState: boolean } + ) { + const { newFlagState, oldFlagState } = props; + const userAddress = users[0].address; + + const tx = yieldStreamer.setInitializedFlag(userAddress, newFlagState); + await proveTx(tx); + + const expectedYieldState: YieldState = { ...defaultYieldState }; + if (newFlagState) { + expectedYieldState.flags = 1n; + } + const actualYieldState = await yieldStreamer.getYieldState(userAddress); + checkEquality(actualYieldState, expectedYieldState); + + if (newFlagState !== oldFlagState) { + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_InitializedFlagSet) + .withArgs(userAddress, newFlagState); + } else { + await expect(tx).not.to.emit(yieldStreamer, EVENTS.YieldStreamer_InitializedFlagSet); + } + } + + it("Executes as expected in different cases", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + checkEquality(await yieldStreamer.getYieldState(users[0].address), defaultYieldState); + + // Check change: 0 => 1 + await executeAndCheck(yieldStreamer, { newFlagState: true, oldFlagState: false }); + + // Check change: 1 => 1 + await executeAndCheck(yieldStreamer, { newFlagState: true, oldFlagState: true }); + + // Check change: 1 => 0 + await executeAndCheck(yieldStreamer, { newFlagState: false, oldFlagState: true }); + + // Check change: 0 => 0 + await executeAndCheck(yieldStreamer, { newFlagState: false, oldFlagState: false }); + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamer, stranger).setInitializedFlag(users[0].address, true)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + }); + + describe("Function 'initializeAccounts()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer, yieldStreamerV1Mock, tokenMock } = await setUpFixture(deployAndConfigureContracts); + const accounts = [users[0].address, users[1].address, users[2].address]; + const balances = [maxUintForBits(64), 1n, 123n]; + const groupKey = (DEFAULT_SOURCE_GROUP_KEY); + const groupId = 123456789; + const claimPreviewResults: YieldStreamerV1ClaimResult[] = [ + { ...yieldStreamerV1ClaimResult, primaryYield: maxUintForBits(64) - 1n, lastDayPartialYield: 1n }, + { ...yieldStreamerV1ClaimResult, primaryYield: 1n, lastDayPartialYield: 2n }, + { ...yieldStreamerV1ClaimResult } + ]; + + await proveTx(yieldStreamer.mapSourceYieldStreamerGroup(groupKey, groupId)); + for (let i = 0; i < accounts.length; ++i) { + const actualYieldState = await yieldStreamer.getYieldState(accounts[i]); + checkEquality(actualYieldState, defaultYieldState, i); + await proveTx(yieldStreamerV1Mock.setClaimAllPreview(accounts[i], claimPreviewResults[i])); + await proveTx(tokenMock.mint(accounts[i], balances[i])); + } + + const tx = yieldStreamer.initializeAccounts(accounts); + const expectedBlockTimestamp = adjustTimestamp(await getTxTimestamp(tx)); + + const expectedYieldStates: YieldState[] = claimPreviewResults.map((res, i) => ({ + flags: 1n, + streamYield: 0n, + accruedYield: res.primaryYield + res.lastDayPartialYield, + lastUpdateTimestamp: expectedBlockTimestamp, + lastUpdateBalance: balances[i] + })); + + for (let i = 0; i < accounts.length; ++i) { + const account = accounts[i]; + const actualYieldState = await yieldStreamer.getYieldState(account); + checkEquality(actualYieldState, expectedYieldStates[i], i); + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_AccountInitialized) + .withArgs( + account, + groupId, + balances[i], + expectedYieldStates[i].accruedYield, + 0 // streamYield + ); + await expect(tx) + .to.emit(yieldStreamerV1Mock, EVENTS.YieldStreamerV1Mock_BlocklistCalled) + .withArgs(accounts[i]); + } + }); + + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + + await expect(connect(yieldStreamer, stranger).initializeAccounts([users[0].address])) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); + }); + + it("Is reverted if accounts array is empty", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + + await expect(yieldStreamer.initializeAccounts([])) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_EmptyArray); + }); + + it("Is reverted if the yield streamer source is not configured", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + await proveTx(yieldStreamer.setSourceYieldStreamer(ADDRESS_ZERO)); + + await expect(yieldStreamer.initializeAccounts([users[0].address])) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerNotConfigured); + }); + + it("Is reverted if the contract does not have the blocklister role in the source yield streamer", async () => { + const { yieldStreamer, yieldStreamerV1Mock } = await setUpFixture(deployAndConfigureContracts); + await proveTx(yieldStreamerV1Mock.setBlocklister(getAddress(yieldStreamer), false)); + + await expect(yieldStreamer.initializeAccounts([users[0].address])) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerUnauthorizedBlocklister); + }); + + it("Is reverted if an account is already initialized", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const accounts = [users[0].address, users[0].address]; + await proveTx(await yieldStreamer.initializeAccounts([accounts[1]])); + + await expect(yieldStreamer.initializeAccounts(accounts)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_AccountAlreadyInitialized); + }); + + it("Is reverted if one of provided account addresses is zero", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const accounts = [users[0].address, ADDRESS_ZERO]; + + await expect(yieldStreamer.initializeAccounts(accounts)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_AccountInitializationProhibited); + }); + }); + + describe("Function 'afterTokenTransfer()'", async () => { + describe("Executes as expected in the case of scenario with multiple balance changes when", async () => { + it("The balance mainly increases and there is a single yield rate and a cap", async () => { + const fixture = await setUpFixture(deployContracts); + + // Yield rates to be added to the contract + const yieldRates: YieldRate[] = [{ + effectiveDay: 0n, + tiers: [ + { rate: RATE_40, cap: 100n }, + { rate: RATE_40, cap: 0n } + ] + }]; + + const balanceActions: BalanceActionItem[] = [ + { + relativeTime: { day: 0n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 1000n, + accruedYield: 0n, + streamYield: 0n + } + }, + { + relativeTime: { day: 0n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 2000n, + accruedYield: 0n, + streamYield: 100n // Assuming yield accrual logic + } + }, + { + relativeTime: { day: 0n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 3000n, + accruedYield: 0n, + streamYield: 300n + } + }, + { + relativeTime: { day: 1n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 4000n, + accruedYield: 600n, + streamYield: 360n + } + }, + { + relativeTime: { day: 1n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 5000n, + accruedYield: 600n, + streamYield: 820n + } + }, + { + relativeTime: { day: 1n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 6000n, + accruedYield: 600n, + streamYield: 1380n + } + }, + { + relativeTime: { day: 4n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 7000n, + accruedYield: 10934n, + streamYield: 1693n + } + }, + { + relativeTime: { day: 4n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 8000n, + accruedYield: 10934n, + streamYield: 3486n + } + }, + { + relativeTime: { day: 4n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 9000n, + accruedYield: 10934n, + streamYield: 5379n + } + }, + { + relativeTime: { day: 5n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 10000n, + accruedYield: 18306n, + streamYield: 2730n + } + }, + { + relativeTime: { day: 5n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 11000n, + accruedYield: 18306n, + streamYield: 5560n + } + }, + { + relativeTime: { day: 5n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 12000n, + accruedYield: 18306n, + streamYield: 8490n + } + } + ]; + + await executeBalanceActionsAndCheck(fixture, yieldRates, balanceActions); + }); + + it("The balance mainly increases and there are multiple yield rates and caps", async () => { + const fixture = await setUpFixture(deployContracts); + + const adjustedBlockTime = await getNearestDayEndAdjustedTimestamp(); + + // Yield rates to be added to the contract + const yieldRates: YieldRate[] = [ + // 40% yield rate at day 0 + { + effectiveDay: 0n, + tiers: [ + { rate: RATE_40, cap: 100n }, + { rate: RATE_40, cap: 0n } + ] + }, + // 80% yield rate at day 3 + { + effectiveDay: calculateEffectiveDay(adjustedBlockTime, 3n), + tiers: [ + { rate: RATE_80, cap: 100n }, + { rate: RATE_80, cap: 0n } + ] + }, + // 40% yield rate at day 5 + { + effectiveDay: calculateEffectiveDay(adjustedBlockTime, 5n), + tiers: [ + { rate: RATE_40, cap: 100n }, + { rate: RATE_40, cap: 0n } + ] + } + ]; + + // Simulated deposit schedule + const balanceActions: BalanceActionItem[] = [ + { + relativeTime: { day: 0n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 1000n, + accruedYield: 0n, + streamYield: 0n + } + }, + { + relativeTime: { day: 0n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 2000n, + accruedYield: 0n, + streamYield: 100n + } + }, + { + relativeTime: { day: 0n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 3000n, + accruedYield: 0n, + streamYield: 300n + } + }, + { + relativeTime: { day: 1n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 4000n, + accruedYield: 600n, + streamYield: 360n + } + }, + { + relativeTime: { day: 1n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 5000n, + accruedYield: 600n, + streamYield: 820n + } + }, + { + relativeTime: { day: 1n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 6000n, + accruedYield: 600n, + streamYield: 1380n + } + }, + { + relativeTime: { day: 4n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 7000n, + accruedYield: 21993n, + streamYield: 2799n + } + }, + { + relativeTime: { day: 4n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 8000n, + accruedYield: 21993n, + streamYield: 5698n + } + }, + { + relativeTime: { day: 4n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 9000n, + accruedYield: 21993n, + streamYield: 8697n + } + }, + { + relativeTime: { day: 5n, hour: 6n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 10000n, + accruedYield: 33789n, + streamYield: 4278n + } + }, + { + relativeTime: { day: 5n, hour: 12n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 11000n, + accruedYield: 33789n, + streamYield: 8656n + } + }, + { + relativeTime: { day: 5n, hour: 18n }, + balanceChange: 1000n, + expectedYieldState: { + lastUpdateBalance: 12000n, + accruedYield: 33789n, + streamYield: 13134n + } + } + ]; + + await executeBalanceActionsAndCheck(fixture, yieldRates, balanceActions); + }); + + it("The balance mainly decreases and there is a single yield rate and a cap", async () => { + const fixture = await setUpFixture(deployContracts); + + // Yield rates to be added to the contract + const yieldRates: YieldRate[] = [{ + effectiveDay: 0n, + tiers: [ + { rate: RATE_40, cap: 100n }, + { rate: RATE_40, cap: 0n } + ] + }]; + + // Simulated action schedule of deposits and withdrawals + const balanceActions: BalanceActionItem[] = [ + { + relativeTime: { day: 0n, hour: 6n }, + balanceChange: 11000n, + expectedYieldState: { + lastUpdateBalance: 11000n, + accruedYield: 0n, + streamYield: 0n + } + }, + { + relativeTime: { day: 0n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 10000n, + accruedYield: 0n, + streamYield: 1100n + } + }, + { + relativeTime: { day: 0n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 9000n, + accruedYield: 0n, + streamYield: 2100n + } + }, + { + relativeTime: { day: 1n, hour: 6n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 8000n, + accruedYield: 3000n, + streamYield: 1200n + } + }, + { + relativeTime: { day: 1n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 7000n, + accruedYield: 3000n, + streamYield: 2300n + } + }, + { + relativeTime: { day: 1n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 6000n, + accruedYield: 3000n, + streamYield: 3300n + } + }, + { + relativeTime: { day: 4n, hour: 6n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 5000n, + accruedYield: 19872n, + streamYield: 2587n + } + }, + { + relativeTime: { day: 4n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 4000n, + accruedYield: 19872n, + streamYield: 5074n + } + }, + { + relativeTime: { day: 4n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 3000n, + accruedYield: 19872n, + streamYield: 7461n + } + }, + { + relativeTime: { day: 5n, hour: 6n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 2000n, + accruedYield: 29620n, + streamYield: 3262n + } + }, + { + relativeTime: { day: 5n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 1000n, + accruedYield: 29620n, + streamYield: 6424n + } + }, + { + relativeTime: { day: 5n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 0n, + accruedYield: 29620n, + streamYield: 9486n + } + } + ]; + + await executeBalanceActionsAndCheck(fixture, yieldRates, balanceActions); + }); + + it("The balance mainly decreases and there are multiple yield rates and caps", async () => { + const fixture = await setUpFixture(deployContracts); + + const adjustedBlockTime = await getNearestDayEndAdjustedTimestamp(); + + // Yield rates to be added to the contract + const yieldRates: YieldRate[] = [ + // 40% yield rate at day 0 + { + effectiveDay: 0n, + tiers: [ + { rate: RATE_40, cap: 100n }, + { rate: RATE_40, cap: 0n } + ] + }, + // 80% yield rate at day 3 + { + effectiveDay: calculateEffectiveDay(adjustedBlockTime, 3n), + tiers: [ + { rate: RATE_80, cap: 100n }, + { rate: RATE_80, cap: 0n } + ] + }, + // 40% yield rate at day 5 + { + effectiveDay: calculateEffectiveDay(adjustedBlockTime, 5n), + tiers: [ + { rate: RATE_40, cap: 100n }, + { rate: RATE_40, cap: 0n } + ] + } + ]; + + // Simulated action schedule + const balanceActions: BalanceActionItem[] = [ + { + relativeTime: { day: 0n, hour: 6n }, + balanceChange: 11000n, + expectedYieldState: { + lastUpdateBalance: 11000n, + accruedYield: 0n, + streamYield: 0n + } + }, + { + relativeTime: { day: 0n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 10000n, + accruedYield: 0n, + streamYield: 1100n + } + }, + { + relativeTime: { day: 0n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 9000n, + accruedYield: 0n, + streamYield: 2100n + } + }, + { + relativeTime: { day: 1n, hour: 6n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 8000n, + accruedYield: 3000n, + streamYield: 1200n + } + }, + { + relativeTime: { day: 1n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 7000n, + accruedYield: 3000n, + streamYield: 2300n + } + }, + { + relativeTime: { day: 1n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 6000n, + accruedYield: 3000n, + streamYield: 3300n + } + }, + { + relativeTime: { day: 4n, hour: 6n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 5000n, + accruedYield: 36768n, + streamYield: 4276n + } + }, + { + relativeTime: { day: 4n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 4000n, + accruedYield: 36768n, + streamYield: 8452n + } + }, + { + relativeTime: { day: 4n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 3000n, + accruedYield: 36768n, + streamYield: 12528n + } + }, + { + relativeTime: { day: 5n, hour: 6n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 2000n, + accruedYield: 53272n, + streamYield: 5627n + } + }, + { + relativeTime: { day: 5n, hour: 12n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 1000n, + accruedYield: 53272n, + streamYield: 11154n + } + }, + { + relativeTime: { day: 5n, hour: 18n }, + balanceChange: -1000n, + expectedYieldState: { + lastUpdateBalance: 0n, + accruedYield: 53272n, + streamYield: 16581n + } + } + ]; + // Run the action schedule and test the yield states + await executeBalanceActionsAndCheck(fixture, yieldRates, balanceActions); + }); + }); + + describe("Executes as expected when the account is not initialized and", async () => { + async function executeAndCheck(props: { balanceChange: bigint }) { + const { yieldStreamer, tokenMock } = await setUpFixture(deployAndConfigureContracts); + const startDayTimestamp = await getNearestDayEndAdjustedTimestamp() + 1n; + const accountAddress = users[0].address; + const expectedYieldState: YieldState = { + ...defaultYieldState, + flags: 0n, + accruedYield: 1234567n, + lastUpdateTimestamp: startDayTimestamp, + lastUpdateBalance: 987654321n + }; + // Call via the testable version + await proveTx(yieldStreamer.setYieldState(accountAddress, expectedYieldState)); + await proveTx(tokenMock.mint(accountAddress, expectedYieldState.lastUpdateBalance)); + await proveTx(tokenMock.setHook(getAddress(yieldStreamer))); + await increaseBlockTimestampTo(normalizeTimestamp(startDayTimestamp + 3n * HOUR)); + + let tx: Promise; + if (props.balanceChange >= 0) { + tx = tokenMock.mint(accountAddress, props.balanceChange); + } else { + tx = tokenMock.burn(accountAddress, -props.balanceChange); + } + + await expect(tx).not.to.emit(yieldStreamer, EVENTS.YieldStreamer_AccountInitialized); + await expect(tx).not.to.emit(yieldStreamer, EVENTS.YieldStreamer_YieldAccrued); + } + + it("The balance increases", async () => { + await executeAndCheck({ balanceChange: 123n }); + }); + + it("The balance increases", async () => { + await executeAndCheck({ balanceChange: -123n }); + }); + }); + + describe("Is reverted if", async () => { + it("It is called not by a token contract", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const fromAddress = (ADDRESS_ZERO); + const toAddress = users[0].address; + const amount = 1n; + + await expect( + connect(yieldStreamer, deployer).afterTokenTransfer(fromAddress, toAddress, amount) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_HookCallerUnauthorized); + + await expect( + connect(yieldStreamer, admin).afterTokenTransfer(fromAddress, toAddress, amount) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_HookCallerUnauthorized); + }); + }); + }); + + describe("Function 'beforeTokenTransfer()", async () => { + it("Executes by any account without any consequences", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + const from = (ADDRESS_ZERO); + const to = (users[0].address); + const amount = 123456n; + await expect(connect(yieldStreamer, stranger).beforeTokenTransfer(from, to, amount)).not.to.be.reverted; + }); + }); + + describe("Function 'claimAmountFor()'", async () => { + // Should handle claims for accounts with zero yielded balance gracefully by reverting appropriately. + // Should correctly handle claims immediately after yield accrual without delays. + // Should revert if the _accrueYield function fails during the claim process. + async function executeAndCheck(props: { + startDayBalance: bigint; + accruedYield: bigint; + claimAmount: bigint; + relativeClaimTimestamp: bigint; + }) { + const { yieldStreamerUnderAdmin, tokenMock } = await setUpFixture(deployAndConfigureContracts); + const startDayTimestamp = await getNearestDayEndAdjustedTimestamp() + 1n; + + // Set the yield state for the user with claimable yield + const account = users[0]; + const claimAmount = props.claimAmount; + const expectedYieldState: YieldState = { + ...defaultYieldState, + flags: STATE_FLAG_INITIALIZED, + streamYield: 0n, + accruedYield: props.accruedYield, + lastUpdateTimestamp: startDayTimestamp, + lastUpdateBalance: props.startDayBalance + }; + // Compute expected fee and net amount + const fee = (claimAmount * FEE_RATE) / ROUND_FACTOR; + const netAmount = claimAmount - fee; + + await proveTx( + yieldStreamerUnderAdmin.setYieldState(account.address, expectedYieldState) // Call via the testable version + ); + await tokenMock.setHook(getAddress(yieldStreamerUnderAdmin)); + await increaseBlockTimestampTo(normalizeTimestamp(startDayTimestamp + props.relativeClaimTimestamp)); + + // Execute yield claim + const tx = await yieldStreamerUnderAdmin.claimAmountFor(account.address, claimAmount); + const expectedTimestamp = adjustTimestamp(await getTxTimestamp(tx)); + + // Check that the yield state for the user is reset (accrued yield becomes 0) + const updatedYieldState = await yieldStreamerUnderAdmin.getYieldState(account.address); + expectedYieldState.streamYield = calculateStreamYield(expectedYieldState, RATE, expectedTimestamp); + if (claimAmount > expectedYieldState.accruedYield) { + expectedYieldState.streamYield -= claimAmount - expectedYieldState.accruedYield; + expectedYieldState.accruedYield = 0n; + } else { + expectedYieldState.accruedYield -= claimAmount; + } + expectedYieldState.lastUpdateBalance += claimAmount; + expectedYieldState.lastUpdateTimestamp = expectedTimestamp; + checkEquality(updatedYieldState, expectedYieldState); + + // Check balance changes + await expect(tx).to.changeTokenBalances( + tokenMock, + [yieldStreamerUnderAdmin, feeReceiver, account], + [-claimAmount, fee, netAmount] + ); + + // Check that the expected event was emitted + await expect(tx) + .to.emit(yieldStreamerUnderAdmin, EVENTS.YieldStreamer_YieldTransferred) + .withArgs(users[0].address, netAmount, fee); + } + + describe("Executes as expected if the last yield state change was exactly at the day start and", async () => { + it("The claim amount is less than the accrued yield", async () => { + await executeAndCheck({ + startDayBalance: MIN_CLAIM_AMOUNT * 1000n, + accruedYield: MIN_CLAIM_AMOUNT * 100n, + claimAmount: MIN_CLAIM_AMOUNT * 10n, + relativeClaimTimestamp: 3n * HOUR + }); + }); + + it("There is only the accrued yield non-zero and it matches the min claim amount", async () => { + await executeAndCheck({ + startDayBalance: 0n, + accruedYield: MIN_CLAIM_AMOUNT, + claimAmount: MIN_CLAIM_AMOUNT, + relativeClaimTimestamp: 0n + }); + }); + + it("The claim amount is greater than the accrued yield", async () => { + const accruedYield = MIN_CLAIM_AMOUNT * 100n; + await executeAndCheck({ + startDayBalance: 0n, + accruedYield, + claimAmount: accruedYield + accruedYield * RATE * 12n / (RATE_FACTOR * 24n), + relativeClaimTimestamp: 18n * HOUR + }); + }); + + it("The claim amount almost equals to the accrued yield and stream yield", async () => { + const accruedYield = MIN_CLAIM_AMOUNT * 100n; + await executeAndCheck({ + startDayBalance: 0n, + accruedYield, + claimAmount: accruedYield + accruedYield * RATE * 12n / (RATE_FACTOR * 24n), + relativeClaimTimestamp: 12n * HOUR + }); + }); + }); + + it("Is reverted if the caller does not have the admin role", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + + await expect(connect(yieldStreamer, deployer).claimAmountFor(users[0].address, 0)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(deployer.address, ADMIN_ROLE); + await expect(connect(yieldStreamer, stranger).claimAmountFor(users[0].address, 0)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, ADMIN_ROLE); + }); + + it("Is reverted if the amount is less than the minimum claim amount", async () => { + const { yieldStreamerUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const claimAmount = MIN_CLAIM_AMOUNT - 1n; + + await expect( + yieldStreamerUnderAdmin.claimAmountFor(users[0].address, claimAmount) + ).to.be.revertedWithCustomError(yieldStreamerUnderAdmin, ERRORS.YieldStreamer_ClaimAmountBelowMinimum); + }); + + it("Is reverted is the amount is not rounded down to the required precision", async () => { + const { yieldStreamerUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const claimAmount = MIN_CLAIM_AMOUNT + 1n; + + await expect( + yieldStreamerUnderAdmin.claimAmountFor(users[0].address, claimAmount) + ).to.be.revertedWithCustomError(yieldStreamerUnderAdmin, ERRORS.YieldStreamer_ClaimAmountNonRounded); + }); + + it("Is reverted if the account is not initialized", async () => { + const { yieldStreamerUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + + await expect( + yieldStreamerUnderAdmin.claimAmountFor(users[0].address, MIN_CLAIM_AMOUNT) + ).to.be.revertedWithCustomError(yieldStreamerUnderAdmin, ERRORS.YieldStreamer_AccountNotInitialized); + }); + + it("Is reverted if the claim amount exceeds the total available yield for the account", async () => { + const { yieldStreamerUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const yieldState: YieldState = { + ...defaultYieldState, + flags: STATE_FLAG_INITIALIZED, + accruedYield: MIN_CLAIM_AMOUNT, + lastUpdateTimestamp: await getLatestBlockAdjustedTimestamp() + }; + // Call via the testable version + await proveTx(yieldStreamerUnderAdmin.setYieldState(users[0].address, yieldState)); + + await expect( + yieldStreamerUnderAdmin.claimAmountFor(users[0].address, yieldState.accruedYield + ROUND_FACTOR) + ).to.be.revertedWithCustomError(yieldStreamerUnderAdmin, ERRORS.YieldStreamer_YieldBalanceInsufficient); + }); + + it("Is reverted if the underlying token transfer fails due to insufficient balance in the contract", async () => { + const { yieldStreamerUnderAdmin, tokenMock } = await setUpFixture(deployAndConfigureContracts); + const yieldState: YieldState = { + ...defaultYieldState, + flags: STATE_FLAG_INITIALIZED, + accruedYield: MIN_CLAIM_AMOUNT, + lastUpdateTimestamp: await getLatestBlockAdjustedTimestamp() + }; + // Call via the testable version + await proveTx(yieldStreamerUnderAdmin.setYieldState(users[0].address, yieldState)); + await proveTx( + tokenMock.burn(getAddress(yieldStreamerUnderAdmin), INITIAL_YIELD_STREAMER_BALANCE - MIN_CLAIM_AMOUNT + 1n) + ); + + await expect( + yieldStreamerUnderAdmin.claimAmountFor(users[0].address, MIN_CLAIM_AMOUNT) + ).to.be.revertedWithCustomError(tokenMock, ERRORS.ERC20InsufficientBalance); + }); + }); + + describe("Function 'getAccruePreview()'", async () => { + async function executeAndCheck(expectedYieldState: YieldState) { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + await proveTx(yieldStreamer.setYieldState(users[0].address, expectedYieldState)); // Call via the testable version + + let timestamp = expectedYieldState.lastUpdateTimestamp + 3n * HOUR; + await increaseBlockTimestampTo(normalizeTimestamp(timestamp)); + const actualAccruePreviewRaw = await yieldStreamer.getAccruePreview(users[0].address); + timestamp = await getLatestBlockAdjustedTimestamp(); + + const additionalYield = calculateStreamYield(expectedYieldState, RATE, timestamp); + const streamYieldAfter = expectedYieldState.streamYield + additionalYield; + const yieldBase = expectedYieldState.accruedYield + expectedYieldState.lastUpdateBalance; + const expectedAccruePreview: AccruePreview = { + fromTimestamp: expectedYieldState.lastUpdateTimestamp, + toTimestamp: timestamp, + balance: expectedYieldState.lastUpdateBalance, + streamYieldBefore: expectedYieldState.streamYield, + accruedYieldBefore: expectedYieldState.accruedYield, + streamYieldAfter: streamYieldAfter, + accruedYieldAfter: expectedYieldState.accruedYield, + rates: [{ tiers: [{ rate: RATE, cap: 0n }], effectiveDay: 0n }], + results: [{ + partialFirstDayYield: 0n, + fullDaysYield: 0n, + partialLastDayYield: streamYieldAfter, + partialFirstDayYieldTiered: yieldBase === 0n ? [] : [0n], + fullDaysYieldTiered: yieldBase === 0n ? [] : [0n], + partialLastDayYieldTiered: yieldBase === 0n ? [] : [additionalYield] + }] + }; + + checkEquality(actualAccruePreviewRaw, expectedAccruePreview, undefined, { ignoreObjects: true }); + expect(actualAccruePreviewRaw.results.length).to.equal(expectedAccruePreview.results.length); + for (let i = 0; i < expectedAccruePreview.results.length; ++i) { + const expectedResult = expectedAccruePreview.results[i]; + const actualResult = actualAccruePreviewRaw.results[i]; + checkEquality(actualResult, expectedResult, i, { ignoreObjects: true }); + expect(normalizeYieldResult(actualResult)).to.deep.equal(expectedResult); + } + const actualAccruePreview = normalizeAccruePreview(await yieldStreamer.getAccruePreview(users[0].address)); + expect(actualAccruePreview).to.deep.equal(expectedAccruePreview); + } + + it("Executes as expected for an initialised account in a simple case", async () => { + const startDayTimestamp = await getNearestDayEndAdjustedTimestamp() + 1n; + const startTimestamp = startDayTimestamp + 3n * HOUR; + + const yieldState: YieldState = { + ...defaultYieldState, + flags: STATE_FLAG_INITIALIZED, + streamYield: MIN_CLAIM_AMOUNT / 2n, + accruedYield: MIN_CLAIM_AMOUNT * 10n, + lastUpdateTimestamp: startTimestamp, + lastUpdateBalance: MIN_CLAIM_AMOUNT * 100n + }; + + await executeAndCheck(yieldState); + }); + + it("Executes as expected for a uninitialised account", async () => { + const startDayTimestamp = await getNearestDayEndAdjustedTimestamp() + 1n; + const startTimestamp = startDayTimestamp + 3n * HOUR; + + const yieldState: YieldState = { ...defaultYieldState, lastUpdateTimestamp: startTimestamp }; + + await executeAndCheck(yieldState); + }); + + it("Is reverted if the account is in a group with non-configured rates", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const groupId = 123456; + await proveTx(yieldStreamer.assignGroup(groupId, [users[0].address], false)); + + await expect(yieldStreamer.getAccruePreview(users[0].address)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateArrayIsEmpty); + }); + + // Other test cases are covered in tests for the internal "_getAccruePreview()" function + }); + + describe("Function 'getClaimPreview()'", async () => { + async function executeAndCheck(expectedYieldState: YieldState) { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + await proveTx(yieldStreamer.setYieldState(users[0].address, expectedYieldState)); // Call via the testable version + + let timestamp = expectedYieldState.lastUpdateTimestamp + 6n * HOUR; + await increaseBlockTimestampTo(normalizeTimestamp(timestamp)); + timestamp = await getLatestBlockAdjustedTimestamp(); + const actualClaimPreviewRaw = await yieldStreamer.getClaimPreview(users[0].address); + + const additionalYield = calculateStreamYield(expectedYieldState, RATE, timestamp); + const totalStreamYield = expectedYieldState.streamYield + additionalYield; + const totalClaimableYield = expectedYieldState.accruedYield + totalStreamYield; + + const expectedClaimPreview: ClaimPreview = { + yieldExact: totalClaimableYield, + yieldRounded: roundDown(totalClaimableYield), + feeExact: 0n, + feeRounded: 0n, + timestamp, + balance: expectedYieldState.lastUpdateBalance, + rates: [RATE], + caps: [0n] + }; + + checkEquality(actualClaimPreviewRaw, expectedClaimPreview, undefined, { ignoreObjects: true }); + const actualClaimPreview = normalizeClaimPreview(await yieldStreamer.getClaimPreview(users[0].address)); + expect(actualClaimPreview).to.deep.equal(expectedClaimPreview); + } + + it("Executes as expected for an initialised account in a simple case", async () => { + const startDayTimestamp = await getNearestDayEndAdjustedTimestamp() + 1n; + const startTimestamp = startDayTimestamp + 4n * HOUR; + + const yieldState: YieldState = { + ...defaultYieldState, + flags: STATE_FLAG_INITIALIZED, + streamYield: MIN_CLAIM_AMOUNT / 3n, + accruedYield: MIN_CLAIM_AMOUNT * 10n, + lastUpdateTimestamp: startTimestamp, + lastUpdateBalance: MIN_CLAIM_AMOUNT * 100n + }; + + await executeAndCheck(yieldState); + }); + + it("Executes as expected for a uninitialised account", async () => { + const startDayTimestamp = await getNearestDayEndAdjustedTimestamp() + 1n; + const startTimestamp = startDayTimestamp + 4n * HOUR; + + const yieldState: YieldState = { ...defaultYieldState, lastUpdateTimestamp: startTimestamp }; + + await executeAndCheck(yieldState); + }); + + it("Is reverted if the account is in a group with non-configured rates", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const groupId = 123456; + await proveTx(yieldStreamer.assignGroup(groupId, [users[0].address], false)); + + await expect(yieldStreamer.getClaimPreview(users[0].address)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateArrayIsEmpty); + }); + + // Other test cases are covered in tests for the internal "_getClaimPreview()" function + }); + + describe("Function 'blockTimestamp()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + const expectedTimestamp = await getLatestBlockAdjustedTimestamp(); + const actualTimestamp = await yieldStreamer.blockTimestamp(); + expect(actualTimestamp).to.equal(expectedTimestamp); + }); + }); + + describe("Function 'proveYieldStreamer()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployAndConfigureContracts); + await expect(yieldStreamer.proveYieldStreamer()).to.not.be.reverted; + }); + }); +}); diff --git a/test/YieldStreamerTestable.test.ts b/test/YieldStreamer.internal.test.ts similarity index 73% rename from test/YieldStreamerTestable.test.ts rename to test/YieldStreamer.internal.test.ts index 1a77ac3..df0985f 100644 --- a/test/YieldStreamerTestable.test.ts +++ b/test/YieldStreamer.internal.test.ts @@ -1,114 +1,96 @@ import { expect } from "chai"; import { ethers, upgrades } from "hardhat"; -import { Contract, ContractFactory } from "ethers"; -import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; -import { setUpFixture } from "../test-utils/common"; - -const NEGATIVE_TIME_SHIFT = 10800n; // 3 hours -const RATE_FACTOR = 1000000000000n; // 10^12 -const ROUND_FACTOR = 10000n; // 10^4 -const DAY = 86400n; // 1 day (in seconds) -const HOUR = 3600n; // 1 hour (in seconds) -const INITIAL_DAY_INDEX = 21000n; // 21000 days +import { Contract } from "ethers"; +import { + AccruePreview, + adjustTimestamp, + ClaimPreview, + DAY, + defaultYieldState, + ERRORS, + HOUR, + normalizeAccruePreview, + normalizeClaimPreview, + normalizeYieldRate, + normalizeYieldResult, + RATE_FACTOR, + RateTier, + roundDown, + YieldRate, + YieldResult, + YieldState +} from "../test-utils/specific"; +import { getAddress, getTxTimestamp, proveTx } from "../test-utils/eth"; +import { checkEquality, maxUintForBits, setUpFixture } from "../test-utils/common"; +import { yieldStreamerV1ClaimResult, YieldStreamerV1ClaimResult } from "./YieldStreamer.external.test"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +const EVENTS = { + YieldStreamer_AccountInitialized: "YieldStreamer_AccountInitialized", + YieldStreamerV1Mock_BlocklistCalled: "YieldStreamerV1Mock_BlocklistCalled" +}; + +const INITIAL_DAY_INDEX = 21_000n; // 21000 days const INITIAL_TIMESTAMP = INITIAL_DAY_INDEX * DAY; +const ADDRESS_ZERO = ethers.ZeroAddress; -const REVERT_ERROR_IF_YIELD_RATE_ARRAY_IS_EMPTY = "YieldStreamer_YieldRateArrayIsEmpty"; -const REVERT_ERROR_IF_TIME_RANGE_IS_INVALID = "YieldStreamer_TimeRangeIsInvalid"; - -interface YieldState { - flags: bigint; - streamYield: bigint; - accruedYield: bigint; - lastUpdateTimestamp: bigint; - lastUpdateBalance: bigint; -} - -interface RateTier { - rate: bigint; - cap: bigint; -} - -interface YieldRate { - tiers: RateTier[]; - effectiveDay: bigint; -} - -interface YieldResult { - partialFirstDayYield: bigint; - fullDaysYield: bigint; - partialLastDayYield: bigint; - partialFirstDayYieldTiered: bigint[]; - fullDaysYieldTiered: bigint[]; - partialLastDayYieldTiered: bigint[]; -} - -interface AccruePreview { +interface CompoundYieldParams { fromTimestamp: bigint; toTimestamp: bigint; + tiers: RateTier[]; balance: bigint; - streamYieldBefore: bigint; - accruedYieldBefore: bigint; - streamYieldAfter: bigint; - accruedYieldAfter: bigint; - rates: YieldRate[]; - results: YieldResult[]; + streamYield: bigint; } -interface ClaimPreview { - yieldExact: bigint; - yieldRounded: bigint; - feeExact: bigint; - feeRounded: bigint; - timestamp: bigint; - balance: bigint; - rates: bigint[]; - caps: bigint[]; +interface Fixture { + yieldStreamer: Contract; + yieldStreamerV1Mock: Contract; + tokenMock: Contract; } -describe("YieldStreamerTestable", async () => { - let yieldStreamerTestableFactory: ContractFactory; +/* + * Acronyms: + * - IB --- initial balance; + * - FDi -- full day number i, e.g. FD2 -- full day number 2; + * - PFD -- partial first day; + * - PLD -- partial last day; + * - SY --- stream yield. + */ + +describe("Contract 'YieldStreamer' regarding internal functions", async () => { + let user: HardhatEthersSigner; before(async () => { - yieldStreamerTestableFactory = await ethers.getContractFactory("YieldStreamerTestable"); + [/* skip deployer */, user] = await ethers.getSigners(); }); - async function deployContracts(): Promise<{ yieldStreamerTestable: Contract; tokenMock: Contract }> { + async function deployContracts(): Promise { const tokenMockFactory = await ethers.getContractFactory("ERC20TokenMock"); const tokenMock = await tokenMockFactory.deploy("Mock Token", "MTK"); await tokenMock.waitForDeployment(); + const tokenMockAddress = getAddress(tokenMock); - const yieldStreamerTestable: Contract = await upgrades.deployProxy(yieldStreamerTestableFactory, [ - tokenMock.target - ]); - await yieldStreamerTestable.waitForDeployment(); + const yieldStreamerV1MockFactory = await ethers.getContractFactory("YieldStreamerV1Mock"); + const yieldStreamerV1Mock: Contract = (await yieldStreamerV1MockFactory.deploy()) as Contract; + await yieldStreamerV1Mock.waitForDeployment(); - return { yieldStreamerTestable, tokenMock }; - } + const yieldStreamerFactory = await ethers.getContractFactory("YieldStreamerTestable"); + const yieldStreamer: Contract = await upgrades.deployProxy(yieldStreamerFactory, [tokenMockAddress]); + await yieldStreamer.waitForDeployment(); - function roundDown(amount: bigint): bigint { - return (amount / ROUND_FACTOR) * ROUND_FACTOR; + return { yieldStreamer, yieldStreamerV1Mock, tokenMock }; } - function roundUp(amount: bigint): bigint { - const roundedAmount = roundDown(amount); - if (roundedAmount < amount) { - return roundedAmount + ROUND_FACTOR; - } - return roundedAmount; - } - - function getSampleYieldRates(count: number): YieldRate[] { + function createSampleYieldRates(count: number): YieldRate[] { const rates: YieldRate[] = []; // Build the yield rates array. for (let i = 0n; i < count; i++) { rates.push({ - tiers: [ - { - rate: i, - cap: i - } - ], + tiers: [{ + rate: i, + cap: i + }], effectiveDay: i }); } @@ -116,59 +98,125 @@ describe("YieldStreamerTestable", async () => { return rates; } - function normalizeYieldRate(rate: YieldRate): YieldRate { - return { - effectiveDay: rate.effectiveDay, - tiers: rate.tiers.map((tier: RateTier) => ({ - rate: tier.rate, - cap: tier.cap - })) - }; + function simpleYield(amount: bigint, rate: bigint, elapsedSeconds: bigint): bigint { + return (amount * rate * elapsedSeconds) / (DAY * RATE_FACTOR); } - function normalizeYieldResult(result: YieldResult): YieldResult { - return { - partialFirstDayYield: result.partialFirstDayYield, - fullDaysYield: result.fullDaysYield, - partialLastDayYield: result.partialLastDayYield, - partialFirstDayYieldTiered: result.partialFirstDayYieldTiered.map((n: bigint) => n), - fullDaysYieldTiered: result.fullDaysYieldTiered.map((n: bigint) => n), - partialLastDayYieldTiered: result.partialLastDayYieldTiered.map((n: bigint) => n) + describe("Function '_initializeSingleAccount()'", async () => { + const userBalance = maxUintForBits(64); + const groupKey = ethers.ZeroHash; + const groupId = 123456789; + const claimPreviewResult: YieldStreamerV1ClaimResult = { + ...yieldStreamerV1ClaimResult, + primaryYield: maxUintForBits(64) - 1n, + lastDayPartialYield: 1n }; - } - function normalizeAccruePreview(result: AccruePreview): AccruePreview { - return { - fromTimestamp: result.fromTimestamp, - toTimestamp: result.toTimestamp, - balance: result.balance, - streamYieldBefore: result.streamYieldBefore, - accruedYieldBefore: result.accruedYieldBefore, - streamYieldAfter: result.streamYieldAfter, - accruedYieldAfter: result.accruedYieldAfter, - rates: result.rates.map((r: YieldRate) => ({ - tiers: r.tiers.map((t: RateTier) => ({ - rate: t.rate, - cap: t.cap - })), - effectiveDay: r.effectiveDay - })), - results: result.results.map((r: YieldResult) => ({ - partialFirstDayYield: r.partialFirstDayYield, - fullDaysYield: r.fullDaysYield, - partialLastDayYield: r.partialLastDayYield, - partialFirstDayYieldTiered: r.partialFirstDayYieldTiered.map((n: bigint) => n), - fullDaysYieldTiered: r.fullDaysYieldTiered.map((n: bigint) => n), - partialLastDayYieldTiered: r.partialLastDayYieldTiered.map((n: bigint) => n) - })) - }; - } + async function configureContracts(fixture: Fixture, accountAddress: string) { + const { yieldStreamer, yieldStreamerV1Mock, tokenMock } = fixture; + await yieldStreamer.setSourceYieldStreamer(getAddress(yieldStreamerV1Mock)); + await yieldStreamerV1Mock.setBlocklister(getAddress(yieldStreamer), true); + await proveTx(yieldStreamer.mapSourceYieldStreamerGroup(groupKey, groupId)); + await proveTx(yieldStreamerV1Mock.setClaimAllPreview(accountAddress, claimPreviewResult)); + if (accountAddress !== ADDRESS_ZERO) { + await proveTx(tokenMock.mint(accountAddress, userBalance)); + } + } - function simpleYield(amount: bigint, rate: bigint, elapsedSeconds: bigint): bigint { - return (amount * rate * elapsedSeconds) / (DAY * RATE_FACTOR); - } + it("Executes as expected if the account is not initialized", async () => { + const fixture = await setUpFixture(deployContracts); + const { yieldStreamer, yieldStreamerV1Mock } = fixture; + const accountAddress = user.address; + + await configureContracts(fixture, accountAddress); + { + const actualYieldState = await yieldStreamer.getYieldState(accountAddress); + checkEquality(actualYieldState, defaultYieldState); + } + + // Call the `_initializeSingleAccount()` function via the testable contract version + const tx = yieldStreamer.initializeSingleAccount(accountAddress); + const expectedBlockTimestamp = adjustTimestamp(await getTxTimestamp(tx)); + + const expectedYieldState: YieldState = { + flags: 1n, + streamYield: 0n, + accruedYield: claimPreviewResult.primaryYield + claimPreviewResult.lastDayPartialYield, + lastUpdateTimestamp: expectedBlockTimestamp, + lastUpdateBalance: userBalance + }; + + { + const actualYieldState = await yieldStreamer.getYieldState(accountAddress); + checkEquality(actualYieldState, expectedYieldState); + } + + await expect(tx) + .to.emit(yieldStreamer, EVENTS.YieldStreamer_AccountInitialized) + .withArgs( + accountAddress, + groupId, + userBalance, + expectedYieldState.accruedYield, + 0 // streamYield + ); + await expect(tx) + .to.emit(yieldStreamerV1Mock, EVENTS.YieldStreamerV1Mock_BlocklistCalled) + .withArgs(accountAddress); + }); - describe("Function 'getAccruePreview()'", async () => { + it("Executes as expected if the account is already initialized", async () => { + const fixture = await setUpFixture(deployContracts); + const { yieldStreamer, yieldStreamerV1Mock } = fixture; + const accountAddress = user.address; + + await configureContracts(fixture, accountAddress); + const expectedYieldState: YieldState = { + ...defaultYieldState, + flags: 1n + }; + await proveTx(yieldStreamer.setYieldState(accountAddress, expectedYieldState)); // Call via the testable version + + // Call the `_initializeSingleAccount()` function via the testable contract version + const tx = yieldStreamer.initializeSingleAccount(accountAddress); + await expect(tx).not.to.emit(yieldStreamer, EVENTS.YieldStreamer_AccountInitialized); + await expect(tx).not.to.emit(yieldStreamerV1Mock, EVENTS.YieldStreamerV1Mock_BlocklistCalled); + + { + const actualYieldState = await yieldStreamer.getYieldState(accountAddress); + checkEquality(actualYieldState, expectedYieldState); + } + }); + + it("Executes as expected even if the account address is zero", async () => { + const fixture = await setUpFixture(deployContracts); + const { yieldStreamer } = fixture; + const accountAddress = (ADDRESS_ZERO); + + await configureContracts(fixture, accountAddress); + + // Call the `_initializeSingleAccount()` function via the testable contract version + const tx = yieldStreamer.initializeSingleAccount(accountAddress); + await expect(tx).not.to.be.reverted; + }); + + it("Is reverted if the yield streamer source is not configured", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + await expect(yieldStreamer.initializeSingleAccount(user.address)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerNotConfigured); + }); + + it("Is reverted if the contract does not have the blocklister role in the source yield streamer", async () => { + const { yieldStreamer, yieldStreamerV1Mock } = await setUpFixture(deployContracts); + await yieldStreamer.setSourceYieldStreamer(getAddress(yieldStreamerV1Mock)); + + await expect(yieldStreamer.initializeSingleAccount(user.address)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_SourceYieldStreamerUnauthorizedBlocklister); + }); + }); + + describe("Function '_getAccruePreview()'", async () => { interface GetAccruePreviewTestCase { description: string; state: YieldState; @@ -179,7 +227,7 @@ describe("YieldStreamerTestable", async () => { const testCases: GetAccruePreviewTestCase[] = [ { - description: "one yield rate period", + description: "Single yield rate period", state: { lastUpdateTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, lastUpdateBalance: 3000000n, @@ -230,26 +278,26 @@ describe("YieldStreamerTestable", async () => { results: [ { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 1060000 + // PFD Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 40600n + // - T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + PFD yield) + 40600n + // - T3: 1% on 3000000 + 1000000 + 60000 for 1 day (IB + SY + PFD) // ------ // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + PFD yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + PFD + FD1) partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 6 hours (Initial balance + Stream yield + PFD yield + FD1 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 6 hours (IB + SY + PFD + FD1) partialFirstDayYieldTiered: [ // PFD Total: 60000 simpleYield(1000000n, (RATE_FACTOR / 100n) * 3n, HOUR * 18n), // - PFD T1: 22500 @@ -299,7 +347,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "two yield rate periods", + description: "Two yield rate periods", state: { lastUpdateTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, lastUpdateBalance: 3000000n, @@ -372,13 +420,13 @@ describe("YieldStreamerTestable", async () => { results: [ { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 1060000 + // PFD Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n, // -- T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + PFD yield) @@ -406,15 +454,15 @@ describe("YieldStreamerTestable", async () => { { partialFirstDayYield: 0n, fullDaysYield: - // FD2 Total: 91506 + // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + PFD yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + PFD + FD1) partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (Initial balance + Stream yield + PFD yield + FD1 yield + FD2 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (IB + SY + PFD + FD1 + FD2) partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [ // FD2 Total: 91506 @@ -448,7 +496,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "three yield rate periods", + description: "Three yield rate periods", state: { lastUpdateTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, lastUpdateBalance: 3000000n, @@ -537,13 +585,13 @@ describe("YieldStreamerTestable", async () => { results: [ { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 1060000 + // PFD Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n, // -- T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + PFD yield) @@ -571,10 +619,10 @@ describe("YieldStreamerTestable", async () => { { partialFirstDayYield: 0n, fullDaysYield: - // FD2 Total: 91506 + // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + PFD yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + PFD + FD1) partialLastDayYield: 0n, partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [ @@ -596,10 +644,10 @@ describe("YieldStreamerTestable", async () => { partialFirstDayYield: 0n, fullDaysYield: 0n, partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (Initial balance + Stream yield + PFD yield + FD1 yield + FD2 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (IB + SY + PFD + FD1 + FD2) partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [0n, 0n, 0n], partialLastDayYieldTiered: [ @@ -623,12 +671,12 @@ describe("YieldStreamerTestable", async () => { ]; for (const [index, testCase] of testCases.entries()) { - it(`Should handle test case ${index + 1}: ${testCase.description}`, async () => { - const { yieldStreamerTestable } = await loadFixture(deployContracts); + it(`Executes as expected for test case ${index + 1}: ${testCase.description}`, async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Add yield rates to contract for (let i = 0; i < testCase.rates.length; i++) { - await yieldStreamerTestable.addYieldRate( + await yieldStreamer.addYieldRate( 0, testCase.rates[i].effectiveDay, testCase.rates[i].tiers.map(tier => tier.rate), @@ -636,23 +684,23 @@ describe("YieldStreamerTestable", async () => { ); } - // Call the `getAccruePreview()` function - const accruePreviewRaw = await yieldStreamerTestable.getAccruePreview( + // Call the `_getAccruePreview()` function via the testable contract version + const actualAccruePreviewRaw = await yieldStreamer.getAccruePreview( testCase.state, testCase.rates, testCase.currentTimestamp ); // Convert the function result to a comparable format - const accruePreview = normalizeAccruePreview(accruePreviewRaw); + const actualAccruePreview = normalizeAccruePreview(actualAccruePreviewRaw); // Assertion - expect(accruePreview).to.deep.equal(testCase.expected); + expect(actualAccruePreview).to.deep.equal(testCase.expected); }); } }); - describe("Function 'calculateYield()'", async () => { + describe("Function '_calculateYield()'", async () => { interface CalculateYieldTestCase { description: string; params: { @@ -670,7 +718,7 @@ describe("YieldStreamerTestable", async () => { const testCases: CalculateYieldTestCase[] = [ { - description: "one yield rate period", + description: "Single yield rate period", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, toTimestamp: INITIAL_TIMESTAMP + DAY * 3n + HOUR * 6n, @@ -699,13 +747,13 @@ describe("YieldStreamerTestable", async () => { expected: [ { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 106000 + // PFD Total: 1000000 + 60000 = 106000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n + // - T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + PFD yield) @@ -713,12 +761,12 @@ describe("YieldStreamerTestable", async () => { // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + PFD yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + PFD + FD1) partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (Initial balance + Stream yield + PFD yield + FD1 yield + FD2 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (IB + SY + PFD + FD1 + FD2) partialFirstDayYieldTiered: [ // PFD Total: 60000 simpleYield(1000000n, (RATE_FACTOR / 100n) * 3n, HOUR * 18n), // - PFD T1: 22500 @@ -767,7 +815,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "two yield rate periods", + description: "Two yield rate periods", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, toTimestamp: INITIAL_TIMESTAMP + DAY * 3n + HOUR * 6n, @@ -810,13 +858,13 @@ describe("YieldStreamerTestable", async () => { expected: [ { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 1060000 + // PFD Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n, // -- T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + PFD yield) @@ -844,15 +892,15 @@ describe("YieldStreamerTestable", async () => { { partialFirstDayYield: 0n, fullDaysYield: - // FD2 Total: 91506 + // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + PFD yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + PFD + FD1) partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (Initial balance + Stream yield + PFD yield + FD1 yield + FD2 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (IB + SY + PFD + FD1 + FD2) partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [ // FD2 Total: 91506 @@ -885,7 +933,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "three yield rate periods", + description: "Three yield rate periods", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, toTimestamp: INITIAL_TIMESTAMP + DAY * 3n + HOUR * 6n, @@ -936,13 +984,13 @@ describe("YieldStreamerTestable", async () => { expected: [ { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 1060000 + // PFD Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n, // -- T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + PFD yield) @@ -970,10 +1018,10 @@ describe("YieldStreamerTestable", async () => { { partialFirstDayYield: 0n, fullDaysYield: - // FD2 Total: 91506 + // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + PFD yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + PFD + FD1) partialLastDayYield: 0n, partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [ @@ -995,10 +1043,10 @@ describe("YieldStreamerTestable", async () => { partialFirstDayYield: 0n, fullDaysYield: 0n, partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (Initial balance + Stream yield + PFD yield + FD1 yield + FD2 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (IB + SY + PFD + FD1 + FD2) partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [0n, 0n, 0n], partialLastDayYieldTiered: [ @@ -1021,12 +1069,12 @@ describe("YieldStreamerTestable", async () => { ]; for (const [index, testCase] of testCases.entries()) { - it(`Should handle test case ${index + 1}: ${testCase.description}`, async () => { - const { yieldStreamerTestable } = await loadFixture(deployContracts); + it(`Executes as expected for test case ${index + 1}: ${testCase.description}`, async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Add yield rates to contract for (let i = 0; i < testCase.rates.length; i++) { - await yieldStreamerTestable.addYieldRate( + await yieldStreamer.addYieldRate( 0, testCase.rates[i].effectiveDay, testCase.rates[i].tiers.map(tier => tier.rate), @@ -1034,8 +1082,8 @@ describe("YieldStreamerTestable", async () => { ); } - // Call the `calculateYield()` function - const yieldResultsRaw = await yieldStreamerTestable.calculateYield(testCase.params, testCase.rates); + // Call the `_calculateYield()` function via the testable contract version + const yieldResultsRaw = await yieldStreamer.calculateYield(testCase.params, testCase.rates); // Convert the function result to a comparable format const yieldResults = yieldResultsRaw.map(normalizeYieldResult); @@ -1046,16 +1094,10 @@ describe("YieldStreamerTestable", async () => { } }); - describe("Function 'compoundYield()'", async () => { + describe("Function '_compoundYield()'", async () => { interface CompoundYieldTestCase { description: string; - params: { - fromTimestamp: bigint; - toTimestamp: bigint; - tiers: RateTier[]; - balance: bigint; - streamYield: bigint; - }; + params: CompoundYieldParams; expected: { partialFirstDayYield: bigint; fullDaysYield: bigint; @@ -1070,7 +1112,7 @@ describe("YieldStreamerTestable", async () => { const testCases: CompoundYieldTestCase[] = [ { - description: "single partial day: D1:00:00:00 - D1:01:00:00", + description: "Single partial day: D1:00:00:00 - D1:01:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP, toTimestamp: INITIAL_TIMESTAMP + HOUR, @@ -1084,11 +1126,11 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // PFD Total: 1000000 + // PFD Total: 1000000 1000000n, // - Stream yield fullDaysYield: 0n, partialLastDayYield: - // PLD Total: 3749 + // PLD Total: 3749 1250n + // - T1: 3% on 1000000 for 1 hour (Initial balance) 833n + // -- T2: 2% on 1000000 for 1 hour (Initial balance) 1666n, // -- T3: 1% on (3000000 + 1000000) for 1 hour (Initial balance + Stream yield) @@ -1108,7 +1150,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "single partial day: D1:01:00:00 - D1:23:00:00", + description: "Single partial day: D1:01:00:00 - D1:23:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR, toTimestamp: INITIAL_TIMESTAMP + DAY - HOUR, @@ -1124,7 +1166,7 @@ describe("YieldStreamerTestable", async () => { partialFirstDayYield: 0n, fullDaysYield: 0n, partialLastDayYield: - // PLD Total: 1000000 + 73333 = 1073333 + // PLD Total: 1000000 + 73333 = 1073333 1000000n + // - Stream yield 27500n + // --- T1: 3% on 1000000 for 22 hours (Initial balance) 18333n + // --- T2: 2% on 1000000 for 22 hours (Initial balance) @@ -1140,7 +1182,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "single partial day: D1:23:00:00 - D2:00:00:00", + description: "Single partial day: D1:23:00:00 - D2:00:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP + DAY - HOUR, toTimestamp: INITIAL_TIMESTAMP + DAY, @@ -1156,7 +1198,7 @@ describe("YieldStreamerTestable", async () => { partialFirstDayYield: 0n, fullDaysYield: 0n, partialLastDayYield: - // PLD Total: 1000000 + 3333 = 1003333 + // PLD Total: 1000000 + 3333 = 1003333 1000000n + // - Stream yield 1250n + // ---- T1: 3% on 1000000 for 1 hour (Initial balance) 833n + // ----- T2: 2% on 1000000 for 1 hour (Initial balance) @@ -1172,7 +1214,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "single full day: D1:00:00:00 - D2:00:00:00", + description: "Single full day: D1:00:00:00 - D2:00:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP, toTimestamp: INITIAL_TIMESTAMP + DAY, @@ -1186,10 +1228,10 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // FDP Total: 1000000 + // FDP Total: 1000000 1000000n, // - Stream yield fullDaysYield: - // FD1 Total: 90000 + // FD1 Total: 90000 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40000n, // -- T3: 1% on 3000000 + 1000000 for 1 day (Initial balance + Stream yield) @@ -1210,7 +1252,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "two full days: D1:00:00:00 - D3:00:00:00", + description: "Two full days: D1:00:00:00 - D3:00:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP, toTimestamp: INITIAL_TIMESTAMP + DAY * 2n, @@ -1224,10 +1266,10 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // FDP Total: 1000000 + // FDP Total: 1000000 1000000n, // - Stream yield fullDaysYield: - // FD1 Total: 90000 + // FD1 Total: 90000 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40000n + // - T3: 1% on 3000000 + 1000000 for 1 day (Initial balance + Stream yield) @@ -1264,7 +1306,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "two full days AND first partial day: D1:06:00:00 - D4:00:00:00", + description: "Two full days AND first partial day: D1:06:00:00 - D4:00:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, toTimestamp: INITIAL_TIMESTAMP + DAY * 3n, @@ -1278,14 +1320,14 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // FDP Total: 1000000 + 60000 = 1060000 + // FDP Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) // PD0 Total: 60000 fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n + // - T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + FDP yield) @@ -1293,7 +1335,7 @@ describe("YieldStreamerTestable", async () => { // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + FDP yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + FDP + FD1) partialLastDayYield: 0n, partialFirstDayYieldTiered: [ // PFD Total: 60000 @@ -1329,7 +1371,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "two full days AND last partial day: D1:00:00:00 - D4:06:00:00", + description: "Two full days AND last partial day: D1:00:00:00 - D4:06:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP, toTimestamp: INITIAL_TIMESTAMP + DAY * 2n + HOUR * 6n, @@ -1343,10 +1385,10 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // FDP Total: 1000000 + // FDP Total: 1000000 1000000n, // - Stream yield fullDaysYield: - // FD1 Total: 90000 + // FD1 Total: 90000 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40000n + // - T3: 1% on 3000000 + 1000000 for 1 day (Initial balance + Stream yield) @@ -1356,10 +1398,10 @@ describe("YieldStreamerTestable", async () => { 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40900n, // -- T3: 1% on 3000000 + 1000000 + 90000 for 1 day (Initial balance + Stream yield + FD1 yield) partialLastDayYield: - // LDP Total: 22952 + // LDP Total: 22952 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10452n, // - T3: 1% on 3000000 + 1000000 + 90000 + 90900 for 6 hours (Initial balance + Stream yield + FD1 yield + FD2 yield) + 10452n, // - T3: 1% on 3000000 + 1000000 + 90000 + 90900 for 6 hours (IB + SY + FD1 + FD2) partialFirstDayYieldTiered: [0n, 0n, 0n], fullDaysYieldTiered: [ // FD1 + FD2 Total: 180900 @@ -1399,7 +1441,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "two full days AND first partial day AND last partial day: D1:06:00:00 - D4:06:00:00", + description: "Two full days AND first partial day AND last partial day: D1:06:00:00 - D4:06:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, toTimestamp: INITIAL_TIMESTAMP + DAY * 3n + HOUR * 6n, @@ -1413,13 +1455,13 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // PFD Total: 1000000 + 60000 = 1060000 + // PFD Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: - // FD1 Total: 90600 + // FD1 Total: 90600 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) 40600n + // - T3: 1% on 3000000 + 1000000 + 60000 for 1 day (Initial balance + Stream yield + FDP yield) @@ -1427,12 +1469,12 @@ describe("YieldStreamerTestable", async () => { // FD2 Total: 91506 30000n + // - T1: 3% on 1000000 for 1 day (Initial balance) 20000n + // - T2: 2% on 1000000 for 1 day (Initial balance) - 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (Initial balance + Stream yield + FDP yield + FD1 yield) + 41506n, // -- T3: 1% on 3000000 + 1000000 + 60000 + 90600 for 1 day (IB + SY + FDP + FD1) partialLastDayYield: - // PLD Total: 23105 + // PLD Total: 23105 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) - 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (Initial balance + Stream yield + FDP yield + FD1 yield + FD2 yield) + 10605n, // - T3: 1% on 3000000 + 1000000 + 60000 + 90600 + 91506 for 6 hours (IB + SY + FDP + FD1 + FD2) partialFirstDayYieldTiered: [ // PFD Total: 60000 simpleYield(1000000n, (RATE_FACTOR / 100n) * 3n, HOUR * 18n), // - PFD T1: 22500 @@ -1480,7 +1522,7 @@ describe("YieldStreamerTestable", async () => { } }, { - description: "two partial days: D1:06:00:00 - D2:06:00:00", + description: "Two partial days: D1:06:00:00 - D2:06:00:00", params: { fromTimestamp: INITIAL_TIMESTAMP + HOUR * 6n, toTimestamp: INITIAL_TIMESTAMP + DAY + HOUR * 6n, @@ -1494,14 +1536,14 @@ describe("YieldStreamerTestable", async () => { }, expected: { partialFirstDayYield: - // FDP Total: 1000000 + 60000 = 1060000 + // FDP Total: 1000000 + 60000 = 1060000 1000000n + // - Stream yield 22500n + // --- T1: 3% on 1000000 for 18 hours (Initial balance) 15000n + // --- T2: 2% on 2000000 for 18 hours (Initial balance) 22500n, // ---- T3: 1% on 3000000 for 18 hours (Initial balance) fullDaysYield: 0n, partialLastDayYield: - // PLD Total: 22650 + // PLD Total: 22650 7500n + // - T1: 3% on 1000000 for 6 hours (Initial balance) 5000n + // - T2: 2% on 1000000 for 6 hours (Initial balance) 10150n, // - T3: 1% on 3000000 + 1000000 + 60000 for 6 hours (Initial balance + Stream yield + FDP yield) @@ -1525,22 +1567,78 @@ describe("YieldStreamerTestable", async () => { ) // ------------------------------------------------------------ PLD T3: 10105 ] } + }, + { + description: "Same timestamps 'from' and 'to'", + params: { + fromTimestamp: INITIAL_TIMESTAMP, + toTimestamp: INITIAL_TIMESTAMP, + tiers: [{ rate: RATE_FACTOR / 10n, cap: 0n }], + balance: 12345678n, + streamYield: 87654321n + }, + expected: { + partialFirstDayYield: 0n, + fullDaysYield: 0n, + partialLastDayYield: 0n, + partialFirstDayYieldTiered: [], + fullDaysYieldTiered: [], + partialLastDayYieldTiered: [] + } + }, + { + description: "Zero balance for calculations", + params: { + fromTimestamp: INITIAL_TIMESTAMP, + toTimestamp: INITIAL_TIMESTAMP + DAY, + tiers: [{ rate: RATE_FACTOR / 10n, cap: 0n }], + balance: 0n, + streamYield: 87654321n + }, + expected: { + partialFirstDayYield: 0n, + fullDaysYield: 0n, + partialLastDayYield: 0n, + partialFirstDayYieldTiered: [], + fullDaysYieldTiered: [], + partialLastDayYieldTiered: [] + } + }, + { + description: "The 'from' timestamp is greater than the 'to' one", + params: { + fromTimestamp: INITIAL_TIMESTAMP + DAY, + toTimestamp: INITIAL_TIMESTAMP, + tiers: [{ rate: RATE_FACTOR / 10n, cap: 0n }], + balance: 0n, + streamYield: 87654321n + }, + expected: { + partialFirstDayYield: 0n, + fullDaysYield: 0n, + partialLastDayYield: 0n, + partialFirstDayYieldTiered: [], + fullDaysYieldTiered: [], + partialLastDayYieldTiered: [] + }, + revertMessage: ERRORS.YieldStreamer_TimeRangeInvalid, + shouldRevert: true } ]; for (const [index, testCase] of testCases.entries()) { - it(`Should handle test case ${index + 1}: ${testCase.description}`, async () => { - const { yieldStreamerTestable } = await loadFixture(deployContracts); + it(`Executes as expected for test case ${index + 1}: ${testCase.description}`, async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); if (testCase.shouldRevert) { - // Call the `compoundYield()` function and expect it to revert - await expect(yieldStreamerTestable.compoundYield(testCase.params)).to.be.revertedWithCustomError( - yieldStreamerTestable, + // Call the `_compoundYield()` function via the testable contract version and expect it to revert + await expect(yieldStreamer.compoundYield(testCase.params)).to.be.revertedWithCustomError( + yieldStreamer, testCase.revertMessage! ); } else { - // Call the `compoundYield()` function and expect it to return - const yieldResultRaw = await yieldStreamerTestable.compoundYield(testCase.params); + // Call the `_compoundYield()` function via the testable contract version and expect it to return + const yieldResultRaw = await yieldStreamer.compoundYield(testCase.params); // Convert the function result to a comparable format const yieldResult = normalizeYieldResult(yieldResultRaw); @@ -1552,7 +1650,7 @@ describe("YieldStreamerTestable", async () => { } }); - describe("Function 'calculateTieredYield()'", async () => { + describe("Function '_calculateTieredYield()'", async () => { interface CalculateTieredYieldTestCase { description: string; amount: bigint; @@ -1563,14 +1661,14 @@ describe("YieldStreamerTestable", async () => { const testCases: CalculateTieredYieldTestCase[] = [ { - description: "single tier - zero cap - 1 hour", + description: "Single tier - zero cap - 1 hour", amount: 650000000n, tiers: [{ rate: (RATE_FACTOR / 100n) * 5n, cap: 0n }], elapsedSeconds: HOUR, expectedTieredYield: [((RATE_FACTOR / 100n) * 5n * 650000000n * HOUR) / (DAY * RATE_FACTOR)] }, { - description: "multiple tiers - total cap less than amount - 1 hour", + description: "Multiple tiers - total cap less than amount - 1 hour", amount: 650000000n, tiers: [ { rate: (RATE_FACTOR / 100n) * 5n, cap: 300000000n }, @@ -1587,7 +1685,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "multiple tiers - total cap greater than amount - 1 hour", + description: "Multiple tiers - total cap greater than amount - 1 hour", amount: 450000000n, tiers: [ { rate: (RATE_FACTOR / 100n) * 5n, cap: 300000000n }, @@ -1604,7 +1702,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "multiple tiers - total cap greater than amount - 1 day + 3 hours", + description: "Multiple tiers - total cap greater than amount - 1 day + 3 hours", amount: 450000000n, tiers: [ { rate: (RATE_FACTOR / 100n) * 5n, cap: 300000000n }, @@ -1621,7 +1719,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "multiple tiers - total cap greater than amount - 2 days + 18 hours", + description: "Multiple tiers - total cap greater than amount - 2 days + 18 hours", amount: 450000000n, tiers: [ { rate: (RATE_FACTOR / 100n) * 5n, cap: 300000000n }, @@ -1638,7 +1736,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "multiple tiers - zero rates present in the tiers array", + description: "Multiple tiers - zero rates present in the tiers array", amount: 650000000n, tiers: [ { rate: 0n, cap: 300000000n }, @@ -1655,7 +1753,7 @@ describe("YieldStreamerTestable", async () => { ] }, { - description: "multiple tiers - zero elapsed seconds", + description: "Multiple tiers - zero elapsed seconds", amount: 650000000n, tiers: [ { rate: (RATE_FACTOR / 100n) * 5n, cap: 300000000n }, @@ -1667,7 +1765,7 @@ describe("YieldStreamerTestable", async () => { expectedTieredYield: [0n, 0n, 0n, 0n] }, { - description: "multiple tiers - zero amount", + description: "Nultiple tiers - zero amount", amount: 0n, tiers: [ { rate: (RATE_FACTOR / 100n) * 5n, cap: 300000000n }, @@ -1681,21 +1779,22 @@ describe("YieldStreamerTestable", async () => { ]; for (const [index, testCase] of testCases.entries()) { - it(`Should handle test case ${index + 1}: ${testCase.description}`, async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it(`Executes as expected for test case ${index + 1}: ${testCase.description}`, async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Calculate the expected yield const expectedTotalYield = testCase.expectedTieredYield.reduce((acc, curr) => acc + curr, 0n); - // Call the `calculateTieredYield` function - const resultRaw = await yieldStreamerTestable.calculateTieredYield( + // Call the `_calculateTieredYield()` function via the testable contract version + const resultRaw = await yieldStreamer.calculateTieredYield( testCase.amount, testCase.elapsedSeconds, testCase.tiers ); // Convert the function result to a comparable format - const [totalYield, tieredYield] = resultRaw.map((n: bigint) => n); + const totalYield: bigint = resultRaw[0]; + const tieredYield: bigint[] = resultRaw[1]; // Assertion expect(tieredYield).to.deep.equal(testCase.expectedTieredYield); @@ -1704,66 +1803,66 @@ describe("YieldStreamerTestable", async () => { } }); - describe("Function 'calculateSimpleYield()'", async () => { - it("Should return zero when rate is zero", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + describe("Function '_calculateSimpleYield()'", async () => { + it("Returns zero when the provided rate is zero", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); const amount = 1000n; const rate = 0n; // Zero rate - const elapsedSeconds = HOUR; + const elapsedSeconds = (HOUR); - // Call the `calculateSimpleYield` function - const yieldAmount = await yieldStreamerTestable.calculateSimpleYield(amount, rate, elapsedSeconds); + // Call the `_calculateSimpleYield()` function via the testable contract version + const yieldAmount = await yieldStreamer.calculateSimpleYield(amount, rate, elapsedSeconds); // Assertion expect(yieldAmount).to.equal(0); }); - it("Should return zero when amount is zero", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Returns zero when the provided amount is zero", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); const amount = 0n; // Zero amount const rate = 1000n; - const elapsedSeconds = HOUR; + const elapsedSeconds = (HOUR); - // Call the `calculateSimpleYield` function - const yieldAmount = await yieldStreamerTestable.calculateSimpleYield(amount, rate, elapsedSeconds); + // Call the `_calculateSimpleYield()` function via the testable contract version + const yieldAmount = await yieldStreamer.calculateSimpleYield(amount, rate, elapsedSeconds); // Assertion expect(yieldAmount).to.equal(0); }); - it("Should return zero when elapsedSeconds is zero", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Returns zero when the provided 'elapsedSeconds' parameter is zero", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); const amount = 1000n; const rate = 1000n; const elapsedSeconds = 0n; // Zero elapsed seconds - // Call the `calculateSimpleYield` function - const yieldAmount = await yieldStreamerTestable.calculateSimpleYield(amount, rate, elapsedSeconds); + // Call the `_calculateSimpleYield()` function via the testable contract version + const yieldAmount = await yieldStreamer.calculateSimpleYield(amount, rate, elapsedSeconds); // Assertion expect(yieldAmount).to.equal(0); }); - it("Should calculate the yield correctly when elapsed seconds is equal to 1 day", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Calculates the yield correctly when provided elapsed seconds are equal to 1 day", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); const amount = 123456789n; const rate = 123456789n; const elapsedSeconds = DAY; const expectedYield = (amount * rate * elapsedSeconds) / (DAY * RATE_FACTOR); - // Call the `calculateSimpleYield` function - const yieldAmount = await yieldStreamerTestable.calculateSimpleYield(amount, rate, elapsedSeconds); + // Call the `_calculateSimpleYield()` function via the testable contract version + const yieldAmount = await yieldStreamer.calculateSimpleYield(amount, rate, elapsedSeconds); // Assertion expect(yieldAmount).to.equal(expectedYield); }); - it("Should calculate the yield correctly when elapsed seconds is less than 1 day", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Сalculates the yield correctly when provided elapsed seconds are less than 1 day", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); const amount = 123456789n; const rate = 123456789n; @@ -1771,31 +1870,31 @@ describe("YieldStreamerTestable", async () => { const expectedYield = (amount * rate * elapsedSeconds) / (DAY * RATE_FACTOR); // Call the `calculateSimpleYield` function - const yieldAmount = await yieldStreamerTestable.calculateSimpleYield(amount, rate, elapsedSeconds); + const yieldAmount = await yieldStreamer.calculateSimpleYield(amount, rate, elapsedSeconds); // Assertion expect(yieldAmount).to.equal(expectedYield); }); - it("Should calculate the yield correctly when elapsed seconds is greater than 1 day", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Calculates the yield correctly when provided elapsed seconds are greater than 1 day", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); const amount = 123456789n; const rate = 123456789n; const elapsedSeconds = DAY * 2n + HOUR * 3n; const expectedYield = (amount * rate * elapsedSeconds) / (DAY * RATE_FACTOR); - // Call the `calculateSimpleYield` function - const yieldAmount = await yieldStreamerTestable.calculateSimpleYield(amount, rate, elapsedSeconds); + // Call the `_calculateSimpleYield()` function via the testable contract version + const yieldAmount = await yieldStreamer.calculateSimpleYield(amount, rate, elapsedSeconds); // Assertion expect(yieldAmount).to.equal(expectedYield); }); }); - describe("Function 'inRangeYieldRates()'", async () => { - it("Should return indices (0, 0) when there is only one yield rate in the array", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + describe("Function '_inRangeYieldRates()'", async () => { + it("Returns indices (0, 0) when there is only one yield rate in the array", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Set up `fromTimestamp` and `toTimestamp` const fromTimestamp = 100n; @@ -1810,8 +1909,8 @@ describe("YieldStreamerTestable", async () => { } ]; - // Call the `inRangeYieldRates` function - const [startIndex, endIndex] = await yieldStreamerTestable.inRangeYieldRates(rates, fromTimestamp, toTimestamp); + // Call the `_calculateSimpleYield()` function via the testable contract version + const [startIndex, endIndex] = await yieldStreamer.inRangeYieldRates(rates, fromTimestamp, toTimestamp); // Assertion expect(startIndex).to.equal(0); @@ -1853,7 +1952,7 @@ describe("YieldStreamerTestable", async () => { const testCases: InRangeYieldRatesTestCase[] = [ { description: - "`fromTimestamp` is 2s before the second rate effective day, `toTimestamp` is 1s before the second rate effective day", + "`fromTimestamp` is 2s before the second rate eff. day, `toTimestamp` is 1s before the second rate eff. day", fromTimestamp: -2n + secondRateEffectiveDay * DAY, toTimestamp: -1n + secondRateEffectiveDay * DAY, expectedStartIndex: 0, @@ -1861,7 +1960,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s before the second rate effective day, `toTimestamp` is exactly on the second rate effective day", + "`fromTimestamp` is 1s before the second rate eff. day, `toTimestamp` is exactly on the second rate eff. day", fromTimestamp: -1n + secondRateEffectiveDay * DAY, toTimestamp: 0n + secondRateEffectiveDay * DAY, expectedStartIndex: 0, @@ -1869,7 +1968,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s before the second rate effective day, `toTimestamp` is 1s after the second rate effective day", + "`fromTimestamp` is 1s before the second rate eff. day, `toTimestamp` is 1s after the second rate eff. day", fromTimestamp: -1n + secondRateEffectiveDay * DAY, toTimestamp: 1n + secondRateEffectiveDay * DAY, expectedStartIndex: 0, @@ -1877,7 +1976,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s before the second rate effective day, `toTimestamp` is 1s before the third rate effective day", + "`fromTimestamp` is 1s before the second rate eff. day, `toTimestamp` is 1s before the third rate eff. day", fromTimestamp: -1n + secondRateEffectiveDay * DAY, toTimestamp: -1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 0, @@ -1885,7 +1984,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s before the second rate effective day, `toTimestamp` is exactly on the third rate effective day", + "`fromTimestamp` is 1s before the second rate eff. day, `toTimestamp` is exactly on the third rate eff. day", fromTimestamp: -1n + secondRateEffectiveDay * DAY, toTimestamp: 0n + thirdRateEffectiveDay * DAY, expectedStartIndex: 0, @@ -1893,7 +1992,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s before the second rate effective day, `toTimestamp` is 1s after the third rate effective day", + "`fromTimestamp` is 1s before the second rate eff. day, `toTimestamp` is 1s after the third rate eff. day", fromTimestamp: -1n + secondRateEffectiveDay * DAY, toTimestamp: 1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 0, @@ -1901,7 +2000,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is exactly on the second rate effective day, `toTimestamp` is 1s after the third rate effective day", + "`fromTimestamp` is exactly on the second rate eff. day, `toTimestamp` is 1s after the third rate eff. day", fromTimestamp: 0n + secondRateEffectiveDay * DAY, toTimestamp: 1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 1, @@ -1909,7 +2008,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s after the second rate effective day, `toTimestamp` is 1s after the third rate effective day", + "`fromTimestamp` is 1s after the second rate eff. day, `toTimestamp` is 1s after the third rate eff. day", fromTimestamp: 1n + secondRateEffectiveDay * DAY, toTimestamp: 1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 1, @@ -1917,7 +2016,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s before the third rate effective day, `toTimestamp` is 1s after the third rate effective day", + "`fromTimestamp` is 1s before the third rate eff. day, `toTimestamp` is 1s after the third rate eff. day", fromTimestamp: -1n + thirdRateEffectiveDay * DAY, toTimestamp: 1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 1, @@ -1925,7 +2024,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is exactly on the third rate effective day, `toTimestamp` is 1s after the third rate effective day", + "`fromTimestamp` is exactly on the third rate eff. day, `toTimestamp` is 1s after the third rate eff. day", fromTimestamp: 0n + thirdRateEffectiveDay * DAY, toTimestamp: 1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 2, @@ -1933,7 +2032,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is 1s after the third rate effective day, `toTimestamp` is 2s after the third rate effective day", + "`fromTimestamp` is 1s after the third rate eff. day, `toTimestamp` is 2s after the third rate eff. day", fromTimestamp: 1n + thirdRateEffectiveDay * DAY, toTimestamp: 2n + thirdRateEffectiveDay * DAY, expectedStartIndex: 2, @@ -1941,7 +2040,7 @@ describe("YieldStreamerTestable", async () => { }, { description: - "`fromTimestamp` is exactly on the second rate effective day, `toTimestamp` is 1s before the third rate effective day", + "`fromTimestamp` is exactly on the second rate eff. day, `toTimestamp` is 1s before the third rate eff. day", fromTimestamp: 0n + secondRateEffectiveDay * DAY, toTimestamp: -1n + thirdRateEffectiveDay * DAY, expectedStartIndex: 1, @@ -1950,11 +2049,11 @@ describe("YieldStreamerTestable", async () => { ]; for (const [index, testCase] of testCases.entries()) { - it(`Should handle test case ${index + 1}: ${testCase.description}.`, async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it(`Executes as expected for test case ${index + 1}: ${testCase.description}.`, async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - // Call the `inRangeYieldRates` function for the given test case - const [startIndex, endIndex] = await yieldStreamerTestable.inRangeYieldRates( + // Call the `_inRangeYieldRates()` function via the testable contract version for the given test case + const [startIndex, endIndex] = await yieldStreamer.inRangeYieldRates( testRates, testCase.fromTimestamp, testCase.toTimestamp @@ -1966,49 +2065,49 @@ describe("YieldStreamerTestable", async () => { }); } - it("Should revert when the `fromTimestamp` is greater than the `toTimestamp`", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Is reverted when `fromTimestamp` is greater than `toTimestamp`", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Set up `fromTimestamp` and `toTimestamp` const fromTimestamp = 101n; const toTimestamp = 100n; - // Call the `inRangeYieldRates` function + // Call the `_inRangeYieldRates()` function via the testable contract version await expect( - yieldStreamerTestable.inRangeYieldRates(testRates, fromTimestamp, toTimestamp) - ).to.be.revertedWithCustomError(yieldStreamerTestable, REVERT_ERROR_IF_TIME_RANGE_IS_INVALID); + yieldStreamer.inRangeYieldRates(testRates, fromTimestamp, toTimestamp) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_TimeRangeIsInvalid); }); - it("Should revert when the `fromTimestamp` is equal to the `toTimestamp`", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Is reverted when `fromTimestamp` is equal to `toTimestamp`", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Set up `fromTimestamp` and `toTimestamp` const fromTimestamp = 100n; const toTimestamp = 100n; - // Call the `inRangeYieldRates` function + // Call the `_inRangeYieldRates()` function via the testable contract version await expect( - yieldStreamerTestable.inRangeYieldRates(testRates, fromTimestamp, toTimestamp) - ).to.be.revertedWithCustomError(yieldStreamerTestable, REVERT_ERROR_IF_TIME_RANGE_IS_INVALID); + yieldStreamer.inRangeYieldRates(testRates, fromTimestamp, toTimestamp) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_TimeRangeIsInvalid); }); - it("Should revert when there are no yield rates in the array", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Is reverted when there are no yield rates in the array", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Set up `fromTimestamp` and `toTimestamp` const fromTimestamp = 100n; const toTimestamp = 200n; - // Call the `inRangeYieldRates` function + // Call the `_inRangeYieldRates()` function via the testable contract version await expect( - yieldStreamerTestable.inRangeYieldRates([], fromTimestamp, toTimestamp) - ).to.be.revertedWithCustomError(yieldStreamerTestable, REVERT_ERROR_IF_YIELD_RATE_ARRAY_IS_EMPTY); + yieldStreamer.inRangeYieldRates([], fromTimestamp, toTimestamp) + ).to.be.revertedWithCustomError(yieldStreamer, ERRORS.YieldStreamer_YieldRateArrayIsEmpty); }); }); - describe("Function 'aggregateYield()'", async () => { - it("Should correctly aggregate a single yield result", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + describe("Function '_aggregateYield()'", async () => { + it("Correctly aggregates a single yield result", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Set up a single yield result with sample values const yieldResult: YieldResult = { @@ -2025,16 +2124,16 @@ describe("YieldStreamerTestable", async () => { const expectedAccruedYield = yieldResult.partialFirstDayYield + yieldResult.fullDaysYield; const expectedStreamYield = yieldResult.partialLastDayYield; - // Call the `aggregateYield` function with the yield results - const [accruedYield, streamYield] = await yieldStreamerTestable.aggregateYield(yieldResults); + // Call the `_aggregateYield()` function via the testable contract version with the yield results + const [accruedYield, streamYield] = await yieldStreamer.aggregateYield(yieldResults); // Assertion expect(accruedYield).to.equal(expectedAccruedYield); expect(streamYield).to.equal(expectedStreamYield); }); - it("Should correctly aggregate multiple yield results", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Correctly aggregates multiple yield results", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Set up multiple yield results with sample values const yieldResults: YieldResult[] = [ @@ -2080,20 +2179,20 @@ describe("YieldStreamerTestable", async () => { // Calculate expected `streamYield` according to the function logic const expectedStreamYield = yieldResults[yieldResults.length - 1].partialLastDayYield; - // Call the `aggregateYield` function with the yield results - const [accruedYield, streamYield] = await yieldStreamerTestable.aggregateYield(yieldResults); + // Call the `_aggregateYield()` function via the testable contract version with the yield results + const [accruedYield, streamYield] = await yieldStreamer.aggregateYield(yieldResults); // Assertion expect(accruedYield).to.equal(expectedAccruedYield); expect(streamYield).to.equal(expectedStreamYield); }); - it("Should correctly aggregate an empty yield results array", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Correctly aggregates an empty yield results array", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - // Call the `aggregateYield` function with an empty array + // Call the `_aggregateYield()` function via the testable contract version with an empty array const yieldResults: YieldResult[] = []; - const [accruedYield, streamYield] = await yieldStreamerTestable.aggregateYield(yieldResults); + const [accruedYield, streamYield] = await yieldStreamer.aggregateYield(yieldResults); // Assertion expect(accruedYield).to.equal(0); @@ -2101,102 +2200,80 @@ describe("YieldStreamerTestable", async () => { }); }); - describe("Function 'blockTimestamp()'", async () => { - it("Should return the adjusted timestamp as expected", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + describe("Function '_effectiveTimestamp()'", async () => { + it("Returns the effective timestamp as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const currentTimestamp = BigInt(await time.latest()); - const expectedBlockTimestamp = currentTimestamp - NEGATIVE_TIME_SHIFT; - const blockTimestamp = await yieldStreamerTestable.blockTimestamp(); - - expect(blockTimestamp).to.equal(expectedBlockTimestamp); - }); - }); - - describe("Function 'effectiveTimestamp()'", async () => { - it("Should return the effective timestamp as expected", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); - - const timestamps = [ - 0n, - 1n, - 50n, - 86399n, - 86400n, - 86401n, - 2n * 86400n, - 3n * 86400n + 12345n, - 1660135722n - ]; + const timestamps = [0n, 1n, 50n, 86399n, 86400n, 86401n, 2n * 86400n, 3n * 86400n + 12345n, 1660135722n]; for (const timestamp of timestamps) { - const effectiveTimestamp = await yieldStreamerTestable.effectiveTimestamp(timestamp); + const effectiveTimestamp = await yieldStreamer.effectiveTimestamp(timestamp); // Call via the testable version const expectedEffectiveTimestamp = (timestamp / DAY) * DAY; expect(effectiveTimestamp).to.equal(expectedEffectiveTimestamp); } }); }); - describe("Function 'truncateArray()'", async () => { - it("Should return the full array when `startIndex` is 0 and `endIndex` is `rates.length - 1`", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + describe("Function '_truncateArray()'", async () => { + it("Returns the full array when `startIndex` is 0 and `endIndex` is `rates.length - 1`", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); - // Call the `truncateArray` function - const yieldRatesRaw = await yieldStreamerTestable.truncateArray(0, rates.length - 1, rates); + // Call the `truncateArray` function via the testable contract version + const yieldRatesRaw = await yieldStreamer.truncateArray(0, rates.length - 1, rates); const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); // Assertion expect(yieldRates).to.deep.equal(rates); }); - it("Should return a truncated array when `startIndex` and `endIndex` are different (internal range)", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Returns a truncated array when `startIndex` and `endIndex` are different (internal range)", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); - // Call the `truncateArray` function - const yieldRatesRaw = await yieldStreamerTestable.truncateArray(1, 3, rates); - const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); + // Call the `_truncateArray()` function + const yieldRatesRaw = await yieldStreamer.truncateArray(1, 3, rates); + const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); // Call via the testable version // Assertion expect(yieldRates).to.deep.equal(rates.slice(1, 4)); }); - it("Should return a truncated array when `startIndex` and `endIndex` are different (include the first element)", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Return a truncated array when `startIndex` != `endIndex` (include the first element)", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); // Call the `truncateArray` function - const yieldRatesRaw = await yieldStreamerTestable.truncateArray(0, 3, rates); + const yieldRatesRaw = await yieldStreamer.truncateArray(0, 3, rates); // Call via the testable version const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); // Assertion expect(yieldRates).to.deep.equal(rates.slice(0, 4)); }); - it("Should return a truncated array when `startIndex` and `endIndex` are different (include the last element)", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Returns a truncated array when `startIndex` != `endIndex` (include the last element)", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); // Call the `truncateArray` function - const yieldRatesRaw = await yieldStreamerTestable.truncateArray(1, 4, rates); + const yieldRatesRaw = await yieldStreamer.truncateArray(1, 4, rates); // Call via the testable version const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); // Assertion expect(yieldRates).to.deep.equal(rates.slice(1, 5)); }); - it("Should return a single element when `startIndex` and `endIndex` are the same (multiple rates in array)", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Returns a single element when `startIndex` == `endIndex` (multiple rates in array)", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); - // Call the `truncateArray` function - const yieldRatesRaw = await yieldStreamerTestable.truncateArray(2, 2, rates); + // Call the `_truncateArray()` function via the testable contract version + const yieldRatesRaw = await yieldStreamer.truncateArray(2, 2, rates); const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); // Assertion @@ -2204,13 +2281,13 @@ describe("YieldStreamerTestable", async () => { expect(yieldRates[0]).to.deep.equal(rates[2]); }); - it("Should return a single element when `startIndex` and `endIndex` are the same (single rate in array)", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Returns a single element when `startIndex` == `endIndex` (single rate in array)", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(1); + const rates = createSampleYieldRates(1); - // Call the `truncateArray` function - const yieldRatesRaw = await yieldStreamerTestable.truncateArray(0, 0, rates); + // Call the `_truncateArray()` function via the testable contract version + const yieldRatesRaw = await yieldStreamer.truncateArray(0, 0, rates); const yieldRates: YieldRate[] = yieldRatesRaw.map(normalizeYieldRate); // Assertion @@ -2218,80 +2295,66 @@ describe("YieldStreamerTestable", async () => { expect(yieldRates[0]).to.deep.equal(rates[0]); }); - it("Should revert when `startIndex` is greater than `endIndex`", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Is reverted when `startIndex` is greater than `endIndex`", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); - // Arithmetic operation overflowed outside of an unchecked block - await expect(yieldStreamerTestable.truncateArray(3, 2, rates)).to.be.revertedWithPanic(0x11); + // Arithmetic operation overflowed outside an unchecked block + await expect(yieldStreamer.truncateArray(3, 2, rates)).to.be.revertedWithPanic(0x11); }); - it("Should revert when `endIndex` is out of bounds", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Is reverted when `endIndex` is out of bounds", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - const rates = getSampleYieldRates(5); + const rates = createSampleYieldRates(5); // Array accessed at an out-of-bounds or negative index - await expect(yieldStreamerTestable.truncateArray(5, 5, rates)).to.be.revertedWithPanic(0x32); + await expect(yieldStreamer.truncateArray(5, 5, rates)).to.be.revertedWithPanic(0x32); }); - it("Should revert when rates array is empty", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Is reverted when rates array is empty", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // Array accessed at an out-of-bounds or negative index - await expect(yieldStreamerTestable.truncateArray(0, 0, [])).to.be.revertedWithPanic(0x32); + await expect(yieldStreamer.truncateArray(0, 0, [])).to.be.revertedWithPanic(0x32); }); }); - describe("Function 'calculateFee()'", async () => { - it("Should calculate fee as expected", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + describe("Function '_calculateFee()'", async () => { + it("Calculates fee as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); // `FEE_RATE` is 0, so the fee should always be 0 - expect(await yieldStreamerTestable.calculateFee(0n)).to.equal(0n); - expect(await yieldStreamerTestable.calculateFee(1000000n)).to.equal(0n); - expect(await yieldStreamerTestable.calculateFee(1000000000000n)).to.equal(0n); + expect(await yieldStreamer.calculateFee(0n)).to.equal(0n); + expect(await yieldStreamer.calculateFee(1000000n)).to.equal(0n); + expect(await yieldStreamer.calculateFee(1000000000000n)).to.equal(0n); }); }); - describe("Function 'roundDown()'", async () => { - it("Should round down as expected", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); - - // Verify the function works as expected with hardcoded values - expect(await yieldStreamerTestable.roundDown(0n)).to.equal(0n); - expect(await yieldStreamerTestable.roundDown(10000000n)).to.equal(10000000n); - expect(await yieldStreamerTestable.roundDown(10000001n)).to.equal(10000000n); - expect(await yieldStreamerTestable.roundDown(10009999n)).to.equal(10000000n); - - // Verify the function works as expected with the `roundDown` utility function - expect(await yieldStreamerTestable.roundDown(0n)).to.equal(roundDown(0n)); - expect(await yieldStreamerTestable.roundDown(10000000n)).to.equal(roundDown(10000000n)); - expect(await yieldStreamerTestable.roundDown(10000001n)).to.equal(roundDown(10000001n)); - expect(await yieldStreamerTestable.roundDown(10009999n)).to.equal(roundDown(10009999n)); + describe("Function '_roundDown()'", async () => { + it("Rounds down as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + expect(await yieldStreamer.roundDown(0n)).to.equal(0n); + expect(await yieldStreamer.roundDown(10000000n)).to.equal(10000000n); + expect(await yieldStreamer.roundDown(10000001n)).to.equal(10000000n); + expect(await yieldStreamer.roundDown(10009999n)).to.equal(10000000n); }); }); - describe("Function 'roundUp()'", async () => { - it("Should round up as expected", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); - - // Verify the function works as expected with hardcoded values - expect(await yieldStreamerTestable.roundUp(0n)).to.equal(0n); - expect(await yieldStreamerTestable.roundUp(10000000n)).to.equal(10000000n); - expect(await yieldStreamerTestable.roundUp(10000001n)).to.equal(10010000n); - expect(await yieldStreamerTestable.roundUp(10009999n)).to.equal(10010000n); - - // Verify the function works as expected with the `roundUp` utility function - expect(await yieldStreamerTestable.roundUp(0n)).to.equal(roundUp(0n)); - expect(await yieldStreamerTestable.roundUp(10000000n)).to.equal(roundUp(10000000n)); - expect(await yieldStreamerTestable.roundUp(10000001n)).to.equal(roundUp(10000001n)); - expect(await yieldStreamerTestable.roundUp(10009999n)).to.equal(roundUp(10009999n)); + describe("Function '_roundUp()'", async () => { + it("Rounds up as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + + expect(await yieldStreamer.roundUp(0n)).to.equal(0n); + expect(await yieldStreamer.roundUp(10000000n)).to.equal(10000000n); + expect(await yieldStreamer.roundUp(10000001n)).to.equal(10010000n); + expect(await yieldStreamer.roundUp(10009999n)).to.equal(10010000n); }); }); - describe("Function 'map()'", async () => { + describe("Function '_map()'", async () => { // Create an `AccruePreview` struct with sample data const accruePreview: AccruePreview = { fromTimestamp: 10000000n, @@ -2337,11 +2400,11 @@ describe("YieldStreamerTestable", async () => { ] }; - it("Should map as expected", async () => { - const { yieldStreamerTestable } = await setUpFixture(deployContracts); + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); - // Call the `map` function - const claimPreviewRaw: ClaimPreview = await yieldStreamerTestable.map(accruePreview); + // Call the `_map()` function via the testable contract version + const actualClaimPreviewRaw: ClaimPreview = await yieldStreamer.map(accruePreview); // Create the `ClaimPreview` struct with expected values const expectedClaimPreview: ClaimPreview = { @@ -2355,18 +2418,52 @@ describe("YieldStreamerTestable", async () => { caps: accruePreview.rates[accruePreview.rates.length - 1].tiers.map(tier => tier.cap) }; + const actualClaimPreview = normalizeClaimPreview(actualClaimPreviewRaw); + // Assertion - expect(accruePreview.accruedYieldAfter + accruePreview.streamYieldAfter).not.to.equal( - roundDown(accruePreview.accruedYieldAfter + accruePreview.streamYieldAfter) - ); - expect(expectedClaimPreview.yieldExact).to.equal(claimPreviewRaw.yieldExact); - expect(expectedClaimPreview.yieldRounded).to.equal(claimPreviewRaw.yieldRounded); - expect(expectedClaimPreview.feeExact).to.equal(claimPreviewRaw.feeExact); - expect(expectedClaimPreview.feeRounded).to.equal(claimPreviewRaw.feeRounded); - expect(expectedClaimPreview.timestamp).to.equal(claimPreviewRaw.timestamp); - expect(expectedClaimPreview.balance).to.equal(claimPreviewRaw.balance); - expect(expectedClaimPreview.rates).to.deep.equal(claimPreviewRaw.rates); - expect(expectedClaimPreview.caps).to.deep.equal(claimPreviewRaw.caps); + expect(actualClaimPreview).to.deep.equal(expectedClaimPreview); + }); + }); + + describe("Function 'setBit()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + expect(await yieldStreamer.setBit(0, 0)).to.equal(0x01); + expect(await yieldStreamer.setBit(0, 7)).to.equal(0x80); + }); + + it("Is reverted if the bit index is greater than 7", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + await expect(yieldStreamer.setBit(0, 8)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.Bitwise_BitIndexOutOfBounds); + }); + }); + + describe("Function 'clearBit()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + expect(await yieldStreamer.clearBit(0xFF, 0)).to.equal(0xFE); + expect(await yieldStreamer.clearBit(0xFF, 7)).to.equal(0x7F); + }); + + it("Is reverted if the bit index is greater than 7", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + await expect(yieldStreamer.clearBit(0xFF, 8)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.Bitwise_BitIndexOutOfBounds); + }); + }); + + describe("Function 'isBitSet()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + expect(await yieldStreamer.isBitSet(0x01, 0)).to.equal(true); + expect(await yieldStreamer.isBitSet(0x7F, 7)).to.equal(false); + }); + + it("Is reverted if the bit index is greater than 7", async () => { + const { yieldStreamer } = await setUpFixture(deployContracts); + await expect(yieldStreamer.isBitSet(0xFF, 8)) + .to.be.revertedWithCustomError(yieldStreamer, ERRORS.Bitwise_BitIndexOutOfBounds); }); }); }); diff --git a/test/YieldStreamer.schedule.test.ts b/test/YieldStreamer.schedule.test.ts deleted file mode 100644 index 95eeeb3..0000000 --- a/test/YieldStreamer.schedule.test.ts +++ /dev/null @@ -1,748 +0,0 @@ -import { expect } from "chai"; -import { ethers, network, upgrades } from "hardhat"; -import { Contract } from "ethers"; -import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { checkEquality } from "../test-utils/eth"; - -// Constants for rate calculations and time units -const RATE_FACTOR = BigInt(1000000000000); // Factor used in yield rate calculations (10^12) -const DAY = 24 * 60 * 60; // Number of seconds in a day -const HOUR = 60 * 60; // Number of seconds in an hour -const NEGATIVE_TIME_SHIFT = 3 * HOUR; // Negative time shift in seconds (3 hours) - -// Interface representing an action (deposit or withdraw) in the test schedule -interface ActionItem { - day: number; // Day number relative to the start time - hour: number; // Hour of the day - amount: bigint; // Amount to deposit or withdraw - type: "deposit" | "withdraw"; // Type of action -} - -// Interface representing the yield state expected after each action -interface YieldState { - lastUpdateTimestamp: number; // Timestamp when the state was last updated - lastUpdateBalance: bigint; // Balance at the last update - accruedYield: bigint; // Total accrued yield up to the last update - streamYield: bigint; // Yield that is being streamed since the last update -} - -// Interface representing a yield rate change in the contract -interface YieldRate { - effectiveDay: number; // Day when the yield rate becomes effective - tierRates: bigint[]; // Array of yield rate value for each tier (expressed in RATE_FACTOR units) - tierCaps: bigint[]; // Array of balance cap for each tier -} - -interface Version { - major: number; - minor: number; - patch: number; - - [key: string]: number; // Indexing signature to ensure that fields are iterated over in a key-value style -} - -/** - * Calculates the adjusted block time aligned to the contract's internal time. - * @returns The adjusted block time. - */ -async function getAdjustedBlockTime(): Promise { - const currentBlockTime = Number(await time.latest()); - let adjustedBlockTime = currentBlockTime - NEGATIVE_TIME_SHIFT; - adjustedBlockTime = Math.floor(adjustedBlockTime / DAY) * DAY + DAY - 1; - return adjustedBlockTime; -} - -/** - * Fetches the yield state of a given account from the contract. - * @param yieldStreamer The YieldStreamer contract instance. - * @param account The address of the account. - * @returns The yield state of the account. - */ -async function getYieldState(yieldStreamer: Contract, account: string): Promise { - const state = await yieldStreamer.getYieldState(account); - return { - lastUpdateTimestamp: state.lastUpdateTimestamp, - lastUpdateBalance: state.lastUpdateBalance, - accruedYield: state.accruedYield, - streamYield: state.streamYield - }; -} - -/** - * Tests a schedule of deposit and withdraw actions against expected yield states. - * @param user The signer representing the user performing actions. - * @param yieldStreamer The YieldStreamer contract instance. - * @param actionItems The list of actions to perform in the test. - * @param expectedYieldStates The expected yield states after each action. - */ -async function testActionSchedule( - user: SignerWithAddress, - erc20Token: Contract, - yieldStreamer: Contract, - actionItems: ActionItem[], - expectedYieldStates: YieldState[] -): Promise { - // Get the adjusted block time aligned to the contract's internal time - const adjustedBlockTime = await getAdjustedBlockTime(); - - // Calculate the start time (actual block timestamp) - const startTime = adjustedBlockTime + NEGATIVE_TIME_SHIFT; - - // Set the block timestamp to the calculated start time - await time.setNextBlockTimestamp(startTime); - - // Iterate over each action in the schedule - for (const [index, actionItem] of actionItems.entries()) { - // Calculate the desired internal timestamp for the action based on day and hour offsets - const desiredInternalTimestamp = adjustedBlockTime + (actionItem.day - 1) * DAY + actionItem.hour * HOUR; - - // Adjust for NEGATIVE_TIME_SHIFT to set the block.timestamp - const adjustedTimestamp = desiredInternalTimestamp + NEGATIVE_TIME_SHIFT; - - // Ensure the timestamp is strictly greater than the current block timestamp - const currentBlockTimestamp = Number(await time.latest()); - const timestampToSet = adjustedTimestamp <= currentBlockTimestamp ? currentBlockTimestamp + 1 : adjustedTimestamp; - - // Increase the blockchain time to the desired adjusted timestamp - await time.increaseTo(timestampToSet); - - // Perform the deposit or withdraw action based on the action type - if (actionItem.type === "deposit") { - // Perform a deposit action - await erc20Token.connect(user).mint(user.address, actionItem.amount); - } else if (actionItem.type === "withdraw") { - // Perform a withdrawal action - await erc20Token.connect(user).burn(user.address, actionItem.amount); - } - - // Fetch the actual yield state from the contract after the action - const contractYieldState = await getYieldState(yieldStreamer, user.address); - - // Update the expected lastUpdateTimestamp with the adjusted block timestamp - const blockTimestamp = (await ethers.provider.getBlock("latest")).timestamp; - expectedYieldStates[index].lastUpdateTimestamp = blockTimestamp - NEGATIVE_TIME_SHIFT; - - // Assert that the actual yield state matches the expected state - expect(contractYieldState.lastUpdateTimestamp).to.equal(expectedYieldStates[index].lastUpdateTimestamp); - expect(contractYieldState.lastUpdateBalance).to.equal(expectedYieldStates[index].lastUpdateBalance); - expect(contractYieldState.accruedYield).to.equal(expectedYieldStates[index].accruedYield); - expect(contractYieldState.streamYield).to.equal(expectedYieldStates[index].streamYield); - } -} - -/** - * Adds yield rates to the contract's yield rate schedule. - * @param yieldStreamer The YieldStreamer contract instance. - * @param yieldRates The list of yield rates to add. - */ -async function addYieldRates(yieldStreamer: Contract, yieldRates: YieldRate[]): Promise { - const zeroBytes32 = ethers.ZeroHash; // Placeholder for the yield rate ID - for (const yieldRate of yieldRates) { - await yieldStreamer.addYieldRate(zeroBytes32, yieldRate.effectiveDay, yieldRate.tierRates, yieldRate.tierCaps); - } -} - -/** - * Calculates the effective day number for a yield rate based on the adjusted block time. - * @param adjustedBlockTime The adjusted block time. - * @param dayNumber The day number offset from the adjusted block time. - * @returns The effective day number for the yield rate. - */ -function calculateEffectiveDay(adjustedBlockTime: number, dayNumber: number): number { - return Math.floor((adjustedBlockTime + dayNumber * DAY) / DAY); -} - -/** - * Sets up a fixture for deploying contracts, using Hardhat's snapshot functionality. - * @param func The async function that deploys and sets up the contracts. - * @returns The deployed contracts. - */ -async function setUpFixture(func: () => Promise): Promise { - if (network.name === "hardhat") { - // Use Hardhat's snapshot functionality for faster test execution - return loadFixture(func); - } else { - // Directly execute the function if not on Hardhat network - return func(); - } -} - -describe("YieldStreamer - Deposit/Withdraw Simulation Tests", function () { - let user: SignerWithAddress; - let adjustedBlockTime: number; - const EXPECTED_VERSION: Version = { - major: 2, - minor: 2, - patch: 0 - }; - - // Get the signer representing the test user and adjusted block time before the tests run - before(async function () { - [user] = await ethers.getSigners(); - adjustedBlockTime = await getAdjustedBlockTime(); - }); - - /** - * Deploys the YieldStreamer contract for testing. - * @returns The deployed YieldStreamer contract instance. - */ - async function deployContracts(): Promise<{ erc20Token: Contract; yieldStreamer: Contract }> { - const ERC20TokenMock = await ethers.getContractFactory("ERC20TokenMock"); - const YieldStreamer = await ethers.getContractFactory("YieldStreamer"); - - const erc20Token = await ERC20TokenMock.deploy("Mock Token", "MTK"); - await erc20Token.waitForDeployment(); - - const yieldStreamer: Contract = await upgrades.deployProxy(YieldStreamer, [erc20Token.target]); - await yieldStreamer.waitForDeployment(); - - await erc20Token.setHook(yieldStreamer.target); - - return { erc20Token, yieldStreamer }; - } - - describe("Function 'deposit()'", function () { - it("Should correctly update state for Deposit Schedule 1", async function () { - const { erc20Token, yieldStreamer } = await setUpFixture(deployContracts); - - // Simulated action schedule of deposits - const actionSchedule: ActionItem[] = [ - { day: 1, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 1, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 1, hour: 18, amount: BigInt(1000), type: "deposit" }, - { day: 2, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 2, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 2, hour: 18, amount: BigInt(1000), type: "deposit" }, - { day: 5, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 5, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 5, hour: 18, amount: BigInt(1000), type: "deposit" }, - { day: 6, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 6, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 6, hour: 18, amount: BigInt(1000), type: "deposit" } - ]; - - // Expected yield states after each action - const expectedYieldStates: YieldState[] = [ - { - // Action 1: Deposit 1000 at Day 1, 6 AM - lastUpdateTimestamp: 0, // Will be updated during the test - lastUpdateBalance: BigInt(1000), - accruedYield: BigInt(0), - streamYield: BigInt(0) - }, - { - // Action 2: Deposit 1000 at Day 1, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(2000), - accruedYield: BigInt(0), - streamYield: BigInt(100) // Assuming yield accrual logic - }, - { - // Action 3, Deposit 1000 at Day 1, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(3000), - accruedYield: BigInt(0), - streamYield: BigInt(300) - }, - { - // Action 4, Deposit 1000 at Day 2, 6 AM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(4000), - accruedYield: BigInt(600), - streamYield: BigInt(360) - }, - { - // Action 5, Deposit 1000 at Day 2, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(5000), - accruedYield: BigInt(600), - streamYield: BigInt(820) - }, - { - // Action 6, Deposit 1000 at Day 2, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(6000), - accruedYield: BigInt(600), - streamYield: BigInt(1380) - }, - { - // Action 7, Deposit 1000 at Day 5, 6 AM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(7000), - accruedYield: BigInt(10934), - streamYield: BigInt(1693) - }, - { - // Action 8, Deposit 1000 at Day 5, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(8000), - accruedYield: BigInt(10934), - streamYield: BigInt(3486) - }, - { - // Action 9, Deposit 1000 at Day 5, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(9000), - accruedYield: BigInt(10934), - streamYield: BigInt(5379) - }, - { - // Action 10, Deposit 1000 at Day 6, 6 AM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(10000), - accruedYield: BigInt(18306), - streamYield: BigInt(2730) - }, - { - // Action 11, Deposit 1000 at Day 6, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(11000), - accruedYield: BigInt(18306), - streamYield: BigInt(5560) - }, - { - // Action 12, Deposit 1000 at Day 6, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(12000), - accruedYield: BigInt(18306), - streamYield: BigInt(8490) - } - ]; - - // Yield rates to be added to the contract - const yieldRates: YieldRate[] = [ - // 40% yield rate - { - effectiveDay: 0, - tierRates: [(RATE_FACTOR * BigInt(40000)) / BigInt(100000), (RATE_FACTOR * BigInt(40000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - } - ]; - - // Set the initialized state for the user - await yieldStreamer.setInitializedFlag(user.address, true); - - // Add yield rates to the contract - await addYieldRates(yieldStreamer, yieldRates); - - // Run the action schedule and test the yield states - await testActionSchedule(user, erc20Token, yieldStreamer, actionSchedule, expectedYieldStates); - }); - - it("Should correctly update state for Deposit Schedule 2", async () => { - const { erc20Token, yieldStreamer } = await setUpFixture(deployContracts); - - // Simulated deposit schedule - const actionSchedule: ActionItem[] = [ - { day: 1, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 1, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 1, hour: 18, amount: BigInt(1000), type: "deposit" }, - { day: 2, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 2, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 2, hour: 18, amount: BigInt(1000), type: "deposit" }, - { day: 5, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 5, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 5, hour: 18, amount: BigInt(1000), type: "deposit" }, - { day: 6, hour: 6, amount: BigInt(1000), type: "deposit" }, - { day: 6, hour: 12, amount: BigInt(1000), type: "deposit" }, - { day: 6, hour: 18, amount: BigInt(1000), type: "deposit" } - ]; - - // Expected YieldStates from the simulation - const expectedYieldStates: YieldState[] = [ - { - // Action 1, Deposit 1000 at Day 1, 6 AM - lastUpdateTimestamp: 0, // Will be updated during the test - lastUpdateBalance: BigInt(1000), - accruedYield: BigInt(0), - streamYield: BigInt(0) - }, - { - // Action 2, Deposit 1000 at Day 1, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(2000), - accruedYield: BigInt(0), - streamYield: BigInt(100) - }, - { - // Action 3, Deposit 1000 at Day 1, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(3000), - accruedYield: BigInt(0), - streamYield: BigInt(300) - }, - { - // Action 4, Deposit 1000 at Day 2, 6 AM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(4000), - accruedYield: BigInt(600), - streamYield: BigInt(360) - }, - { - // Action 5, Deposit 1000 at Day 2, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(5000), - accruedYield: BigInt(600), - streamYield: BigInt(820) - }, - { - // Action 6, Deposit 1000 at Day 2, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(6000), - accruedYield: BigInt(600), - streamYield: BigInt(1380) - }, - { - // Action 7, Deposit 1000 at Day 5, 6 AM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(7000), - accruedYield: BigInt(21993), - streamYield: BigInt(2799) - }, - { - // Action 8, Deposit 1000 at Day 5, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(8000), - accruedYield: BigInt(21993), - streamYield: BigInt(5698) - }, - { - // Action 9, Deposit 1000 at Day 5, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(9000), - accruedYield: BigInt(21993), - streamYield: BigInt(8697) - }, - { - // Action 10, Deposit 1000 at Day 6, 6 AM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(10000), - accruedYield: BigInt(33789), - streamYield: BigInt(4278) - }, - { - // Action 11, Deposit 1000 at Day 6, 12 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(11000), - accruedYield: BigInt(33789), - streamYield: BigInt(8656) - }, - { - // Action 12, Deposit 1000 at Day 6, 6 PM - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(12000), - accruedYield: BigInt(33789), - streamYield: BigInt(13134) - } - ]; - - // Yield rates to be added to the contract - const yieldRates: YieldRate[] = [ - // 40% yield rate - { - effectiveDay: 0, - tierRates: [(RATE_FACTOR * BigInt(40000)) / BigInt(100000), (RATE_FACTOR * BigInt(40000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - }, - // 80% yield rate - { - effectiveDay: calculateEffectiveDay(adjustedBlockTime, 3), - tierRates: [(RATE_FACTOR * BigInt(80000)) / BigInt(100000), (RATE_FACTOR * BigInt(80000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - }, - // 40% yield rate - { - effectiveDay: calculateEffectiveDay(adjustedBlockTime, 5), - tierRates: [(RATE_FACTOR * BigInt(40000)) / BigInt(100000), (RATE_FACTOR * BigInt(40000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - } - ]; - - // Set the initialized state for the user - await yieldStreamer.setInitializedFlag(user.address, true); - - // Add yield rates to the contract - await addYieldRates(yieldStreamer, yieldRates); - - // Run the action schedule and test the yield states - await testActionSchedule(user, erc20Token, yieldStreamer, actionSchedule, expectedYieldStates); - }); - }); - - describe("Function 'withdraw()'", function () { - it("Should correctly update state for Withdraw Schedule 1", async () => { - const { erc20Token, yieldStreamer } = await setUpFixture(deployContracts); - - // Simulated action schedule of deposits and withdrawals - const actionSchedule: ActionItem[] = [ - { day: 1, hour: 6, amount: BigInt(11000), type: "deposit" }, - { day: 1, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 1, hour: 18, amount: BigInt(1000), type: "withdraw" }, - { day: 2, hour: 6, amount: BigInt(1000), type: "withdraw" }, - { day: 2, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 2, hour: 18, amount: BigInt(1000), type: "withdraw" }, - { day: 5, hour: 6, amount: BigInt(1000), type: "withdraw" }, - { day: 5, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 5, hour: 18, amount: BigInt(1000), type: "withdraw" }, - { day: 6, hour: 6, amount: BigInt(1000), type: "withdraw" }, - { day: 6, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 6, hour: 18, amount: BigInt(1000), type: "withdraw" } - ]; - - // Expected yield states after each action - const expectedYieldStates: YieldState[] = [ - { - // Action 1, Day 1, 6 AM, Deposit 11000 - lastUpdateTimestamp: 0, // Will be updated during the test - lastUpdateBalance: BigInt(11000), - accruedYield: BigInt(0), - streamYield: BigInt(0) - }, - { - // Action 2, Day 1, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(10000), - accruedYield: BigInt(0), - streamYield: BigInt(1100) - }, - { - // Action 3, Day 1, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(9000), - accruedYield: BigInt(0), - streamYield: BigInt(2100) - }, - { - // Action 4, Day 2, 6 AM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(8000), - accruedYield: BigInt(3000), - streamYield: BigInt(1200) - }, - { - // Action 5, Day 2, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(7000), - accruedYield: BigInt(3000), - streamYield: BigInt(2300) - }, - { - // Action 6, Day 2, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(6000), - accruedYield: BigInt(3000), - streamYield: BigInt(3300) - }, - { - // Action 7, Day 5, 6 AM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(5000), - accruedYield: BigInt(19872), - streamYield: BigInt(2587) - }, - { - // Action 8, Day 5, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(4000), - accruedYield: BigInt(19872), - streamYield: BigInt(5074) - }, - { - // Action 9, Day 5, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(3000), - accruedYield: BigInt(19872), - streamYield: BigInt(7461) - }, - { - // Action 10, Day 6, 6 AM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(2000), - accruedYield: BigInt(29620), - streamYield: BigInt(3262) - }, - { - // Action 11, Day 6, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(1000), - accruedYield: BigInt(29620), - streamYield: BigInt(6424) - }, - { - // Action 12, Day 6, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(0), - accruedYield: BigInt(29620), - streamYield: BigInt(9486) - } - ]; - - // Yield rates to be added to the contract - const yieldRates: YieldRate[] = [ - // 40% yield rate - { - effectiveDay: 0, - tierRates: [(RATE_FACTOR * BigInt(40000)) / BigInt(100000), (RATE_FACTOR * BigInt(40000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - } - ]; - - // Set the initialized state for the user - await yieldStreamer.setInitializedFlag(user.address, true); - - // Add yield rates to the contract - await addYieldRates(yieldStreamer, yieldRates); - - // Run the action schedule and test the yield states - await testActionSchedule(user, erc20Token, yieldStreamer, actionSchedule, expectedYieldStates); - }); - - it("Should correctly update state for Withdraw Schedule 2", async () => { - const { erc20Token, yieldStreamer } = await setUpFixture(deployContracts); - - // Simulated action schedule - const actionSchedule: ActionItem[] = [ - { day: 1, hour: 6, amount: BigInt(11000), type: "deposit" }, - { day: 1, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 1, hour: 18, amount: BigInt(1000), type: "withdraw" }, - { day: 2, hour: 6, amount: BigInt(1000), type: "withdraw" }, - { day: 2, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 2, hour: 18, amount: BigInt(1000), type: "withdraw" }, - { day: 5, hour: 6, amount: BigInt(1000), type: "withdraw" }, - { day: 5, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 5, hour: 18, amount: BigInt(1000), type: "withdraw" }, - { day: 6, hour: 6, amount: BigInt(1000), type: "withdraw" }, - { day: 6, hour: 12, amount: BigInt(1000), type: "withdraw" }, - { day: 6, hour: 18, amount: BigInt(1000), type: "withdraw" } - ]; - - // Expected YieldStates from the simulation - const expectedYieldStates: YieldState[] = [ - { - // Action 1, Day 1, 6 AM, Deposit 11000 - lastUpdateTimestamp: 0, // Will be updated during the test - lastUpdateBalance: BigInt(11000), - accruedYield: BigInt(0), - streamYield: BigInt(0) - }, - { - // Action 2, Day 1, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(10000), - accruedYield: BigInt(0), - streamYield: BigInt(1100) - }, - { - // Action 3, Day 1, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(9000), - accruedYield: BigInt(0), - streamYield: BigInt(2100) - }, - { - // Action 4, Day 2, 6 AM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(8000), - accruedYield: BigInt(3000), - streamYield: BigInt(1200) - }, - { - // Action 5, Day 2, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(7000), - accruedYield: BigInt(3000), - streamYield: BigInt(2300) - }, - { - // Action 6, Day 2, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(6000), - accruedYield: BigInt(3000), - streamYield: BigInt(3300) - }, - { - // Action 7, Day 5, 6 AM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(5000), - accruedYield: BigInt(36768), - streamYield: BigInt(4276) - }, - { - // Action 8, Day 5, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(4000), - accruedYield: BigInt(36768), - streamYield: BigInt(8452) - }, - { - // Action 9, Day 5, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(3000), - accruedYield: BigInt(36768), - streamYield: BigInt(12528) - }, - { - // Action 10, Day 6, 6 AM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(2000), - accruedYield: BigInt(53272), - streamYield: BigInt(5627) - }, - { - // Action 11, Day 6, 12 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(1000), - accruedYield: BigInt(53272), - streamYield: BigInt(11154) - }, - { - // Action 12, Day 6, 6 PM, Withdraw 1000 - lastUpdateTimestamp: 0, - lastUpdateBalance: BigInt(0), - accruedYield: BigInt(53272), - streamYield: BigInt(16581) - } - ]; - - // Yield rates to be added to the contract - const yieldRates: YieldRate[] = [ - // 40% yield rate - { - effectiveDay: 0, - tierRates: [(RATE_FACTOR * BigInt(40000)) / BigInt(100000), (RATE_FACTOR * BigInt(40000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - }, - // 80% yield rate - { - effectiveDay: calculateEffectiveDay(adjustedBlockTime, 3), - tierRates: [(RATE_FACTOR * BigInt(80000)) / BigInt(100000), (RATE_FACTOR * BigInt(80000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - }, - // 40% yield rate - { - effectiveDay: calculateEffectiveDay(adjustedBlockTime, 5), - tierRates: [(RATE_FACTOR * BigInt(40000)) / BigInt(100000), (RATE_FACTOR * BigInt(40000)) / BigInt(100000)], - tierCaps: [BigInt(100), BigInt(0)] - } - ]; - - // Set the initialized state for the user - await yieldStreamer.setInitializedFlag(user.address, true); - - // Add yield rates to the contract - await addYieldRates(yieldStreamer, yieldRates); - - // Run the action schedule and test the yield states - await testActionSchedule(user, erc20Token, yieldStreamer, actionSchedule, expectedYieldStates); - }); - }); - - describe("Function '$__VERSION()'", async () => { - it("Returns expected values", async () => { - const { yieldStreamer} = await setUpFixture(deployContracts); - const yieldStreamerVersion = await yieldStreamer.$__VERSION(); - checkEquality(yieldStreamerVersion, EXPECTED_VERSION); - }); - }); -}); diff --git a/test/YieldStreamerHarness.test.ts b/test/YieldStreamerHarness.test.ts index 23201ce..625de76 100644 --- a/test/YieldStreamerHarness.test.ts +++ b/test/YieldStreamerHarness.test.ts @@ -2,94 +2,40 @@ import { expect } from "chai"; import { ethers, upgrades } from "hardhat"; import { Contract, ContractFactory } from "ethers"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { getAddress, getLatestBlockTimestamp, proveTx } from "../test-utils/eth"; -import { setUpFixture } from "../test-utils/common"; +import { connect, getLatestBlockTimestamp, proveTx } from "../test-utils/eth"; +import { checkEquality, setUpFixture } from "../test-utils/common"; +import { DAY, ERRORS, NEGATIVE_TIME_SHIFT, YieldRate, YieldState } from "../test-utils/specific"; -// Constants for rate calculations and time units -const SECONDS_IN_DAY = 24 * 60 * 60; // Number of seconds in a day - -const BYTES32_ZERO = ethers.ZeroHash; -const ADDRESS_ZERO = ethers.ZeroAddress; - -const RATE_FACTOR = 10 ** 12; -const ROUND_FACTOR = 10000; -const FEE_RATE = 0; -const NEGATIVE_TIME_SHIFT = 3 * 60 * 60; -const MIN_CLAIM_AMOUNT = 1000000; -const ENABLE_YIELD_STATE_AUTO_INITIALIZATION = false; - -interface RateTier { - rate: bigint; - cap: bigint; -} - -interface YieldRate { - tiers: RateTier[]; - effectiveDay: bigint; -} - -interface YieldState { - flags: number; - streamYield: bigint; - accruedYield: bigint; - lastUpdateTimestamp: bigint; - lastUpdateBalance: bigint; -} +const DEFAULT_ADMIN_ROLE: string = ethers.ZeroHash; +const OWNER_ROLE: string = ethers.id("OWNER_ROLE"); +const HARNESS_ADMIN_ROLE: string = ethers.id("HARNESS_ADMIN_ROLE"); interface YieldStreamerHarnessLayout { currentBlockTimestamp: bigint; usingSpecialBlockTimestamps: boolean; } -interface Version { - major: number; - minor: number; - patch: number; +interface Fixture { + yieldStreamerHarness: Contract; + yieldStreamerHarnessUnderAdmin: Contract; + tokenMock: Contract; } -function checkEquality>(actualObject: T, expectedObject: T, index?: number) { - const indexString = !index ? "" : ` with index: ${index}`; - Object.keys(expectedObject).forEach(property => { - const value = actualObject[property]; - if (typeof value === "undefined" || typeof value === "function" || typeof value === "object") { - throw Error(`Property "${property}" is not found in the actual object` + indexString); - } - expect(value).to.eq( - expectedObject[property], - `Mismatch in the "${property}" property between the actual object and expected one` + indexString - ); - }); -} - -describe("YieldStreamerHarness", function () { - // Errors of the lib contracts - const REVERT_ERROR_IF_CONTRACT_INITIALIZATION_IS_INVALID = "InvalidInitialization"; - - // Errors of the contracts under test - const REVERT_ERROR_IF_TOKEN_ADDRESS_IS_ZERO = "YieldStreamer_TokenAddressZero"; - +describe("contract 'YieldStreamerHarness'", async () => { let yieldStreamerHarnessFactory: ContractFactory; let deployer: HardhatEthersSigner; - - const ownerRole: string = ethers.id("OWNER_ROLE"); - const pauserRole: string = ethers.id("PAUSER_ROLE"); - const rescuerRole: string = ethers.id("RESCUER_ROLE"); - const harnessAdminRole: string = ethers.id("HARNESS_ADMIN_ROLE"); - const EXPECTED_VERSION: Version = { - major: 2, - minor: 2, - patch: 0 - }; + let harnessAdmin: HardhatEthersSigner; + let stranger: HardhatEthersSigner; // Get the signer representing the test user before the tests run - before(async function () { - [deployer] = await ethers.getSigners(); + before(async () => { + [deployer, harnessAdmin, stranger] = await ethers.getSigners(); // Contract factories with the explicitly specified deployer account yieldStreamerHarnessFactory = await ethers.getContractFactory("YieldStreamerHarness"); }); - async function deployContracts(): Promise<{ yieldStreamerHarness: Contract; tokenMock: Contract }> { + async function deployContracts(): Promise { const tokenMockFactory = await ethers.getContractFactory("ERC20TokenMock"); const tokenMock = await tokenMockFactory.deploy("Mock Token", "MTK"); @@ -97,97 +43,37 @@ describe("YieldStreamerHarness", function () { const yieldStreamerHarness: Contract = await upgrades.deployProxy(yieldStreamerHarnessFactory, [tokenMock.target]); await yieldStreamerHarness.waitForDeployment(); + const yieldStreamerHarnessUnderAdmin = connect(yieldStreamerHarness, harnessAdmin); - await tokenMock.setHook(yieldStreamerHarness.target); - - return { yieldStreamerHarness, tokenMock }; + return { yieldStreamerHarness, yieldStreamerHarnessUnderAdmin, tokenMock }; } - async function deployAndConfigureContracts(): Promise<{ yieldStreamerHarness: Contract; tokenMock: Contract }> { - const { yieldStreamerHarness, tokenMock } = await deployContracts(); - await proveTx(yieldStreamerHarness.initHarness()); - await proveTx(yieldStreamerHarness.grantRole(harnessAdminRole, deployer.address)); - return { yieldStreamerHarness, tokenMock }; - } - - describe("Function 'initialize()'", async () => { - it("Configures the contract as expected", async () => { - const { yieldStreamerHarness, tokenMock } = await setUpFixture(deployContracts); - - // The underlying token contract address - expect(await yieldStreamerHarness.underlyingToken()).to.equal(getAddress(tokenMock)); - - // Role hashes - expect(await yieldStreamerHarness.OWNER_ROLE()).to.equal(ownerRole); - expect(await yieldStreamerHarness.PAUSER_ROLE()).to.equal(pauserRole); - expect(await yieldStreamerHarness.RESCUER_ROLE()).to.equal(rescuerRole); - expect(await yieldStreamerHarness.HARNESS_ADMIN_ROLE()).to.equal(harnessAdminRole); - - // The role admins - expect(await yieldStreamerHarness.getRoleAdmin(ownerRole)).to.equal(ownerRole); - expect(await yieldStreamerHarness.getRoleAdmin(pauserRole)).to.equal(ownerRole); - expect(await yieldStreamerHarness.getRoleAdmin(rescuerRole)).to.equal(ownerRole); - expect(await yieldStreamerHarness.getRoleAdmin(harnessAdminRole)).to.equal(BYTES32_ZERO); - - // The deployer should have the owner role, but not the other roles - expect(await yieldStreamerHarness.hasRole(ownerRole, deployer.address)).to.equal(true); - expect(await yieldStreamerHarness.hasRole(pauserRole, deployer.address)).to.equal(false); - expect(await yieldStreamerHarness.hasRole(rescuerRole, deployer.address)).to.equal(false); - expect(await yieldStreamerHarness.hasRole(harnessAdminRole, deployer.address)).to.equal(false); - - // The initial contract state is unpaused - expect(await yieldStreamerHarness.paused()).to.equal(false); - - // Other parameters and constants - expect(await yieldStreamerHarness.RATE_FACTOR()).to.equal(RATE_FACTOR); - expect(await yieldStreamerHarness.ROUND_FACTOR()).to.equal(ROUND_FACTOR); - expect(await yieldStreamerHarness.FEE_RATE()).to.equal(FEE_RATE); - expect(await yieldStreamerHarness.NEGATIVE_TIME_SHIFT()).to.equal(NEGATIVE_TIME_SHIFT); - expect(await yieldStreamerHarness.MIN_CLAIM_AMOUNT()).to.equal(MIN_CLAIM_AMOUNT); - expect(await yieldStreamerHarness.ENABLE_YIELD_STATE_AUTO_INITIALIZATION()).to.equal( - ENABLE_YIELD_STATE_AUTO_INITIALIZATION - ); - }); + async function deployAndConfigureContracts(): Promise { + const fixture = await deployContracts(); + await proveTx(fixture.yieldStreamerHarness.initHarness()); + await proveTx(fixture.yieldStreamerHarness.grantRole(HARNESS_ADMIN_ROLE, harnessAdmin.address)); - it("Is reverted if it is called a second time ", async () => { - const { yieldStreamerHarness, tokenMock } = await setUpFixture(deployContracts); - await expect(yieldStreamerHarness.initialize(getAddress(tokenMock))).to.be.revertedWithCustomError( - yieldStreamerHarness, - REVERT_ERROR_IF_CONTRACT_INITIALIZATION_IS_INVALID - ); - }); - - it("Is reverted if the passed token address is zero", async () => { - const anotherFreezerRoot: Contract = await upgrades.deployProxy(yieldStreamerHarnessFactory, [], { - initializer: false - }); - - await expect(anotherFreezerRoot.initialize(ADDRESS_ZERO)).to.be.revertedWithCustomError( - yieldStreamerHarnessFactory, - REVERT_ERROR_IF_TOKEN_ADDRESS_IS_ZERO - ); - }); - }); - - describe("Function '$__VERSION()'", async () => { - it("Returns expected values", async () => { - const { yieldStreamerHarness } = await setUpFixture(deployContracts); - const yieldStreamerHarnessVersion = await yieldStreamerHarness.$__VERSION(); - checkEquality(yieldStreamerHarnessVersion, EXPECTED_VERSION); - }); - }); + return fixture; + } describe("Function 'initHarness()'", async () => { it("Executes as expected", async () => { const { yieldStreamerHarness } = await setUpFixture(deployContracts); + expect(await yieldStreamerHarness.getRoleAdmin(HARNESS_ADMIN_ROLE)).to.equal(DEFAULT_ADMIN_ROLE); await proveTx(yieldStreamerHarness.initHarness()); - expect(await yieldStreamerHarness.getRoleAdmin(harnessAdminRole)).to.equal(ownerRole); + expect(await yieldStreamerHarness.getRoleAdmin(HARNESS_ADMIN_ROLE)).to.equal(OWNER_ROLE); + }); + it("Is reverted if the caller does not have the owner role", async () => { + const { yieldStreamerHarness } = await setUpFixture(deployContracts); + await expect(connect(yieldStreamerHarness, stranger).initHarness()) + .to.be.revertedWithCustomError(yieldStreamerHarness, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(stranger.address, OWNER_ROLE); }); }); describe("Function 'deleteYieldRates()'", async () => { it("Executes as expected", async () => { - const { yieldStreamerHarness } = await setUpFixture(deployAndConfigureContracts); + const { yieldStreamerHarness, yieldStreamerHarnessUnderAdmin } = await setUpFixture(deployAndConfigureContracts); const groupId = 1; const yieldRate1: YieldRate = { effectiveDay: 0n, tiers: [{ rate: 123n, cap: 0n }] }; const yieldRate2: YieldRate = { effectiveDay: 1n, tiers: [{ rate: 456n, cap: 0n }] }; @@ -215,91 +101,159 @@ describe("YieldStreamerHarness", function () { const actualYieldRatesBefore2 = await yieldStreamerHarness.getGroupYieldRates(groupId); expect(actualYieldRatesBefore2.length).to.equal(2); - await proveTx(yieldStreamerHarness.deleteYieldRates(groupId)); + await proveTx(yieldStreamerHarnessUnderAdmin.deleteYieldRates(groupId)); const actualYieldRatesAfter = await yieldStreamerHarness.getGroupYieldRates(groupId); expect(actualYieldRatesAfter.length).to.equal(0); }); + + it("Is reverted if the caller does not have the harness admin role", async () => { + const { yieldStreamerHarness } = await setUpFixture(deployContracts); + const groupId = 1; + + await expect(connect(yieldStreamerHarness, deployer).deleteYieldRates(groupId)) + .to.be.revertedWithCustomError(yieldStreamerHarness, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(deployer.address, HARNESS_ADMIN_ROLE); + }); }); describe("Function 'setYieldState()'", async () => { + const expectedYieldState: YieldState = { + flags: 0xffn, + streamYield: 2n ** 64n - 1n, + accruedYield: 2n ** 64n - 1n, + lastUpdateTimestamp: 2n ** 40n - 1n, + lastUpdateBalance: 2n ** 64n - 1n + }; + const accountAddress = "0x0000000000000000000000000000000000000001"; + it("Executes as expected", async () => { - const { yieldStreamerHarness } = await setUpFixture(deployAndConfigureContracts); - const accountAddress = "0x0000000000000000000000000000000000000001"; - const yieldState: YieldState = { - flags: 0xff, - streamYield: 2n ** 64n - 1n, - accruedYield: 2n ** 64n - 1n, - lastUpdateTimestamp: 2n ** 40n - 1n, - lastUpdateBalance: 2n ** 64n - 1n - }; - await proveTx(yieldStreamerHarness.setYieldState(accountAddress, yieldState)); - const actualYieldState = await yieldStreamerHarness.getYieldState(accountAddress); - checkEquality(actualYieldState, yieldState); + const { yieldStreamerHarnessUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + + await proveTx(yieldStreamerHarnessUnderAdmin.setYieldState(accountAddress, expectedYieldState)); + const actualYieldState = await yieldStreamerHarnessUnderAdmin.getYieldState(accountAddress); + checkEquality(actualYieldState, expectedYieldState); + }); + + it("Is reverted if the caller does not have the harness admin role", async () => { + const { yieldStreamerHarness } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamerHarness, deployer).setYieldState(accountAddress, expectedYieldState)) + .to.be.revertedWithCustomError(yieldStreamerHarness, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(deployer.address, HARNESS_ADMIN_ROLE); }); }); describe("Function 'resetYieldState()'", async () => { + const accountAddress = "0x0000000000000000000000000000000000000001"; + it("Executes as expected", async () => { - const { yieldStreamerHarness } = await setUpFixture(deployAndConfigureContracts); - const accountAddress = "0x0000000000000000000000000000000000000001"; - const yieldStateBefore: YieldState = { - flags: 0xff, + const { yieldStreamerHarnessUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const expectedYieldStateBefore: YieldState = { + flags: 0xffn, streamYield: 2n ** 64n - 1n, accruedYield: 2n ** 64n - 1n, lastUpdateTimestamp: 2n ** 40n - 1n, lastUpdateBalance: 2n ** 64n - 1n }; - await proveTx(yieldStreamerHarness.setYieldState(accountAddress, yieldStateBefore)); - const actualYieldStateBefore = await yieldStreamerHarness.getYieldState(accountAddress); - checkEquality(actualYieldStateBefore, yieldStateBefore); + await proveTx(yieldStreamerHarnessUnderAdmin.setYieldState(accountAddress, expectedYieldStateBefore)); + const actualYieldStateBefore = await yieldStreamerHarnessUnderAdmin.getYieldState(accountAddress); + checkEquality(actualYieldStateBefore, expectedYieldStateBefore); - const yieldStateAfter: YieldState = { - flags: 0, + const expectedYieldStateAfter: YieldState = { + flags: 0n, streamYield: 0n, accruedYield: 0n, lastUpdateTimestamp: 0n, lastUpdateBalance: 0n }; - await proveTx(yieldStreamerHarness.resetYieldState(accountAddress)); - const actualYieldStateAfter = await yieldStreamerHarness.getYieldState(accountAddress); - checkEquality(actualYieldStateAfter, yieldStateAfter); + await proveTx(yieldStreamerHarnessUnderAdmin.resetYieldState(accountAddress)); + const actualYieldStateAfter = await yieldStreamerHarnessUnderAdmin.getYieldState(accountAddress); + checkEquality(actualYieldStateAfter, expectedYieldStateAfter); + }); + + it("Is reverted if the caller does not have the harness admin role", async () => { + const { yieldStreamerHarness } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamerHarness, deployer).resetYieldState(accountAddress)) + .to.be.revertedWithCustomError(yieldStreamerHarness, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(deployer.address, HARNESS_ADMIN_ROLE); }); }); describe("Function 'setBlockTimestamp()'", async () => { + const day = 123n; + const time = 456n; + it("Executes as expected", async () => { - const { yieldStreamerHarness } = await setUpFixture(deployAndConfigureContracts); - const day = 123; - const time = 456; - const expectedTimestamp = day * SECONDS_IN_DAY + time; + const { yieldStreamerHarnessUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const expectedTimestamp = day * DAY + time; const expectedYieldStreamerHarnessLayout: YieldStreamerHarnessLayout = { - currentBlockTimestamp: BigInt(expectedTimestamp), + currentBlockTimestamp: 0n, usingSpecialBlockTimestamps: false }; - await proveTx(yieldStreamerHarness.setBlockTimestamp(day, time)); + const actualYieldStreamerHarnessLayoutBefore = await yieldStreamerHarnessUnderAdmin.getHarnessStorageLayout(); + checkEquality(actualYieldStreamerHarnessLayoutBefore, expectedYieldStreamerHarnessLayout); + + await proveTx(yieldStreamerHarnessUnderAdmin.setBlockTimestamp(day, time)); + + expectedYieldStreamerHarnessLayout.currentBlockTimestamp = expectedTimestamp; + const actualYieldStreamerHarnessLayoutAfter = await yieldStreamerHarnessUnderAdmin.getHarnessStorageLayout(); + checkEquality(actualYieldStreamerHarnessLayoutAfter, expectedYieldStreamerHarnessLayout); + }); + + it("Is reverted if the caller does not have the harness admin role", async () => { + const { yieldStreamerHarness } = await setUpFixture(deployContracts); + + await expect(connect(yieldStreamerHarness, deployer).setBlockTimestamp(day, time)) + .to.be.revertedWithCustomError(yieldStreamerHarness, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(deployer.address, HARNESS_ADMIN_ROLE); + }); + }); + + describe("Function 'setUsingSpecialBlockTimestamps()'", async () => { + it("Executes as expected", async () => { + const { yieldStreamerHarnessUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const expectedYieldStreamerHarnessLayout: YieldStreamerHarnessLayout = { + currentBlockTimestamp: 0n, + usingSpecialBlockTimestamps: false + }; + + const actualYieldStreamerHarnessLayoutBefore = await yieldStreamerHarnessUnderAdmin.getHarnessStorageLayout(); + checkEquality(actualYieldStreamerHarnessLayoutBefore, expectedYieldStreamerHarnessLayout); + + await proveTx(yieldStreamerHarnessUnderAdmin.setUsingSpecialBlockTimestamps(true)); + + expectedYieldStreamerHarnessLayout.usingSpecialBlockTimestamps = true; + const actualYieldStreamerHarnessLayoutAfter = await yieldStreamerHarnessUnderAdmin.getHarnessStorageLayout(); + checkEquality(actualYieldStreamerHarnessLayoutAfter, expectedYieldStreamerHarnessLayout); + }); + + it("Is reverted if the caller does not have the harness admin role", async () => { + const { yieldStreamerHarness } = await setUpFixture(deployContracts); - const actualYieldStreamerHarnessLayout = await yieldStreamerHarness.getHarnessStorageLayout(); - checkEquality(actualYieldStreamerHarnessLayout, expectedYieldStreamerHarnessLayout); + await expect(connect(yieldStreamerHarness, deployer).setUsingSpecialBlockTimestamps(true)) + .to.be.revertedWithCustomError(yieldStreamerHarness, ERRORS.AccessControlUnauthorizedAccount) + .withArgs(deployer.address, HARNESS_ADMIN_ROLE); }); }); describe("Function 'blockTimestamp()'", async () => { it("Executes as expected", async () => { - const { yieldStreamerHarness } = await setUpFixture(deployAndConfigureContracts); - const day = 123; - const time = 456; + const { yieldStreamerHarnessUnderAdmin } = await setUpFixture(deployAndConfigureContracts); + const day = 123n; + const time = 456n; - let expectedBlockTimestamp = (await getLatestBlockTimestamp()) - NEGATIVE_TIME_SHIFT; - expect(await yieldStreamerHarness.blockTimestamp()).to.equal(expectedBlockTimestamp); + let expectedBlockTimestamp = BigInt(await getLatestBlockTimestamp()) - NEGATIVE_TIME_SHIFT; + expect(await yieldStreamerHarnessUnderAdmin.blockTimestamp()).to.equal(expectedBlockTimestamp); - await proveTx(yieldStreamerHarness.setUsingSpecialBlockTimestamps(true)); - expectedBlockTimestamp = 0; - expect(await yieldStreamerHarness.blockTimestamp()).to.equal(expectedBlockTimestamp); + await proveTx(yieldStreamerHarnessUnderAdmin.setUsingSpecialBlockTimestamps(true)); + expectedBlockTimestamp = 0n; + expect(await yieldStreamerHarnessUnderAdmin.blockTimestamp()).to.equal(expectedBlockTimestamp); - await proveTx(yieldStreamerHarness.setBlockTimestamp(day, time)); - expectedBlockTimestamp = day * SECONDS_IN_DAY + time - NEGATIVE_TIME_SHIFT; - expect(await yieldStreamerHarness.blockTimestamp()).to.equal(expectedBlockTimestamp); + await proveTx(yieldStreamerHarnessUnderAdmin.setBlockTimestamp(day, time)); + expectedBlockTimestamp = day * DAY + time - NEGATIVE_TIME_SHIFT; + expect(await yieldStreamerHarnessUnderAdmin.blockTimestamp()).to.equal(expectedBlockTimestamp); }); }); });