Skip to content

Commit

Permalink
add lido
Browse files Browse the repository at this point in the history
Co-Authored-By: Edouard Bougon <[email protected]>
  • Loading branch information
chakra-guy and EdouardBougon committed Sep 4, 2024
1 parent d4e8937 commit cae476c
Show file tree
Hide file tree
Showing 3 changed files with 556 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type * as viem from "viem";
import * as aave_v3_health_factor from "#/adapters/aave/aave_v3_health_factor";
import * as ens_expiration from "#/adapters/ens/expiration";
import * as lido_staking_rewards from "#/adapters/lido/staking_rewards";
import * as test_is_active_user from "#/adapters/test/is_active_user";
import * as test_is_matching from "#/adapters/test/is_matching";
import * as test_is_not_active_user from "#/adapters/test/is_not_active_user";
Expand All @@ -12,6 +13,7 @@ export const CONTRACT_ADAPTERS: ContractAdapters = {
// Actual adapters
[domain.Kind.AaveV3HealthFactor]: new aave_v3_health_factor.Adapter(),
[domain.Kind.EnsExpiration]: new ens_expiration.Adapter(),
[domain.Kind.LidoStakingRewards]: new lido_staking_rewards.Adapter(),

// Test adapters
[domain.Kind.TestIsMatching]: new test_is_matching.Adapter(),
Expand Down
316 changes: 316 additions & 0 deletions src/adapters/lido/staking_rewards/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
import * as t from "bun:test";
import * as uuid from "uuid";
import * as adapters from "#/adapters";
import * as lido_staking_rewards from "#/adapters/lido/staking_rewards";
import * as domain from "#/domain";
import * as testutils from "#/testutils";

t.describe("lido_staking_rewards adapter", () => {
const adapter = new lido_staking_rewards.Adapter();
const client = testutils.createRPCClient();

const trigger: domain.Trigger<lido_staking_rewards.UserSettings, lido_staking_rewards.State> = {
id: uuid.v4(),
chainId: domain.Chain.Ethereum,
kind: domain.Kind.LidoStakingRewards,
address: "0x1234567890123456789012345678901234567890",
userSettings: { notificationIntervalDays: 30 },
state: null as unknown as lido_staking_rewards.State,
matchDedupKey: null,
scheduledAt: new Date(),
};

const mocks = {
readContract: t.spyOn(client, "readContract"),
getBlockNumber: t.spyOn(client, "getBlockNumber"),
getLogs: t.spyOn(client, "getLogs"),
};

t.beforeEach(() => {
mocks.readContract.mockClear();
mocks.getBlockNumber.mockClear();
mocks.getLogs.mockClear();
});

t.describe("check user", () => {
t.test("should handle not supported chain", async () => {
const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.None, client);
t.expect(result).toEqual({ active: false, error: new adapters.NotSupportedChainError() });
});

t.test("should call the right function with the right args", async () => {
mocks.readContract.mockResolvedValueOnce(0n);

await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, client);

t.expect(mocks.readContract).toHaveBeenCalledWith({
address: adapter["STETH_TOKEN_ADDRESS"],
abi: adapter["STETH_ABI"],
functionName: "balanceOf",
args: ["0x12Dec026d5826F95bA23957529B36a386E085583"],
});
});

t.test("should handle not active user", async () => {
mocks.readContract.mockResolvedValueOnce(0n);
const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, client);
t.expect(result).toEqual({ active: false, error: new adapters.NotActiveUserError() });
});

t.test("should handle when user doesn't have enough balance", async () => {
mocks.readContract.mockResolvedValueOnce(adapter["STETH_DUST_THRESHOLD"] - 1n);
const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, client);
t.expect(result).toEqual({ active: false, error: new adapters.NotActiveUserError() });
});

t.test("should handle active user", async () => {
mocks.readContract.mockResolvedValueOnce(adapter["STETH_DUST_THRESHOLD"] + 1n);
const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, client);
t.expect(result).toEqual({ active: true, userSettings: { notificationIntervalDays: 30 } });
});
});

t.describe("matching", () => {
t.test("should return NotActiveUserError for balance below dust threshold", async () => {
mocks.readContract.mockResolvedValueOnce(adapter["STETH_DUST_THRESHOLD"] - 1n);

const result = await adapter.matchTrigger(trigger, client);

t.expect(result.matched).toBe(false);
t.expect(result.error).toBeInstanceOf(adapters.NotActiveUserError);
});

t.test("should initialize state on first run", async () => {
mocks.readContract
.mockResolvedValueOnce(BigInt(10 * 1e18)) // currentStethBalance
.mockResolvedValueOnce(BigInt(1.05 * 1e18)); // stETH exchange rate

mocks.getBlockNumber.mockResolvedValueOnce(BigInt(1000));

const result = await adapter.matchTrigger(trigger, client);

t.expect(result.matched).toBe(false);
t.expect(result.state).toBeDefined();
t.expect(result.state?.lastBlockNumber).toBe(BigInt(1000));
t.expect(result.state?.startBalance).toBe(BigInt(10 * 1e18));
t.expect(result.state?.startRate).toBe(BigInt(1.05 * 1e18));
t.expect(result.state?.deposits).toBe(0n);
t.expect(result.state?.withdrawals).toBe(0n);
});

t.test("should not trigger notification when interval not reached", async () => {
mocks.readContract
.mockResolvedValueOnce(BigInt(10 * 1e18)) // currentStethBalance
.mockResolvedValueOnce(BigInt(1.05 * 1e18)); // stETH exchange rate

mocks.getBlockNumber.mockResolvedValueOnce(BigInt(1000));

mocks.getLogs
.mockResolvedValueOnce([]) // deposits
.mockResolvedValueOnce([]); // withdrawals

const state: lido_staking_rewards.State = {
lastBlockNumber: BigInt(900),
lastNotificationTimestamp: Math.floor(Date.now() / 1000) - 15 * 24 * 60 * 60, // 15 days ago
startBalance: BigInt(9 * 1e18),
startRate: BigInt(1.04 * 1e18),
deposits: 1n,
withdrawals: 1n,
};

const result = await adapter.matchTrigger({ ...trigger, state }, client);

t.expect(result.matched).toBe(false);
t.expect(result.state).toBeDefined();
t.expect(result.state?.lastBlockNumber).toBe(BigInt(1000));
t.expect(result.state?.deposits).toBe(1n);
t.expect(result.state?.withdrawals).toBe(1n);
});

t.test("should trigger notification when interval is reached", async () => {
mocks.readContract
.mockResolvedValueOnce(BigInt(10 * 1e18)) // currentStethBalance
.mockResolvedValueOnce(BigInt(1.05 * 1e18)); // stETH exchange rate

mocks.getBlockNumber.mockResolvedValueOnce(BigInt(1000));

mocks.getLogs
.mockResolvedValueOnce([]) // deposits
.mockResolvedValueOnce([]); // withdrawals

const initialState: lido_staking_rewards.State = {
lastBlockNumber: BigInt(700),
lastNotificationTimestamp: Math.floor(Date.now() / 1000) - 31 * 24 * 60 * 60, // 31 days ago
startBalance: BigInt(9 * 1e18),
startRate: BigInt(1.04 * 1e18),
deposits: 1n,
withdrawals: 1n,
};

const result = await adapter.matchTrigger({ ...trigger, state: initialState }, client);

t.expect(result.matched).toBe(true);
t.expect(result.state).toBeDefined();
t.expect(result.state?.lastBlockNumber).toBe(BigInt(1000));
t.expect(result.state?.deposits).toBe(0n);
t.expect(result.state?.withdrawals).toBe(0n);
t.expect(result.context).toBeDefined();
t.expect(result.context?.daysSinceLastNotification).toBe(31);
});

t.test("should handle different notification interval settings", async () => {
mocks.readContract
.mockResolvedValueOnce(BigInt(10 * 1e18)) // currentStethBalance
.mockResolvedValueOnce(BigInt(1.05 * 1e18)); // stETH exchange rate

mocks.getBlockNumber.mockResolvedValueOnce(BigInt(1000));

mocks.getLogs
.mockResolvedValueOnce([]) // deposits
.mockResolvedValueOnce([]); // withdrawals

const initialState: lido_staking_rewards.State = {
lastBlockNumber: BigInt(900),
lastNotificationTimestamp: Math.floor(Date.now() / 1000) - 8 * 24 * 60 * 60, // 8 days ago
startBalance: BigInt(9 * 1e18),
startRate: BigInt(1.04 * 1e18),
deposits: 0n,
withdrawals: 0n,
};

const result = await adapter.matchTrigger({ ...trigger, userSettings: { notificationIntervalDays: 7 }, state: initialState }, client);

t.expect(result.matched).toBe(true);
t.expect(result.context?.daysSinceLastNotification).toBe(8);
t.expect(result.state?.lastBlockNumber).toBe(BigInt(1000));
});
});

t.describe("exchange rate", () => {
t.test("should get stETH exchange rate with real RPC call", async () => {
const exchangeRate = await adapter["getStethExchangeRate"](client, BigInt(20626033));
t.expect(exchangeRate).toBe(1177101458282319168n);
});
});

t.describe("deposit and withdrawal tracking", () => {
t.test("should track deposits correctly", async () => {
const deposits = await adapter["trackDeposits"](client, "0xddBEfaA6EEBD13ACA8613018501eEF8247287C16", BigInt(20625938), BigInt(20626033));
t.expect(deposits).toBe(588550729141159584n);
});

t.test("should track withdrawals correctly", async () => {
const withdrawals = await adapter["trackWithdrawals"](client, "0xddBEfaA6EEBD13ACA8613018501eEF8247287C16", BigInt(20625938), BigInt(20626033));
t.expect(withdrawals).toBe(588550729141159584n);
});
});

t.describe("reward calculation", () => {
t.test("should calculate rewards correctly when stETH value increases due to exchange rate", () => {
const startBalance = BigInt(10 * 1e18);
const startRate = BigInt(1 * 1e18);
const endBalance = BigInt(10 * 1e18);
const endRate = BigInt(1.05 * 1e18);
const deposits = BigInt(0);
const withdrawals = BigInt(0);

const reward = adapter["calculateRewards"](startBalance, startRate, endBalance, endRate, deposits, withdrawals);

// (10 * 1.05) - (10 * 1) = 0.5
t.expect(reward).toBe(BigInt(0.5 * 1e18));
});

t.test("should calculate rewards correctly with a single deposit", () => {
const startBalance = BigInt(10 * 1e18);
const startRate = BigInt(1 * 1e18);
const endBalance = BigInt(15 * 1e18);
const endRate = BigInt(1.05 * 1e18);
const deposits = BigInt(5 * 1e18);
const withdrawals = BigInt(0);

const reward = adapter["calculateRewards"](startBalance, startRate, endBalance, endRate, deposits, withdrawals);

// (15 * 1.05) - (10 * 1) - 5 = 0.75
t.expect(reward).toBe(BigInt(0.75 * 1e18));
});

t.test("should calculate rewards correctly with a single withdrawal", () => {
const startBalance = BigInt(10 * 1e18);
const startRate = BigInt(1 * 1e18);
const endBalance = BigInt(8 * 1e18);
const endRate = BigInt(1.05 * 1e18);
const deposits = BigInt(0);
const withdrawals = BigInt(2 * 1e18);

const reward = adapter["calculateRewards"](startBalance, startRate, endBalance, endRate, deposits, withdrawals);

// (8 * 1.05) - (10 * 1) + 2 = 0.4
t.expect(reward).toBe(BigInt(0.4 * 1e18));
});

t.test("should calculate rewards correctly with multiple deposits and withdrawals", () => {
const startBalance = BigInt(10 * 1e18);
const startRate = BigInt(1 * 1e18);
const endBalance = BigInt(12 * 1e18);
const endRate = BigInt(1.05 * 1e18);
const deposits = BigInt(5 * 1e18);
const withdrawals = BigInt(3 * 1e18);

const reward = adapter["calculateRewards"](startBalance, startRate, endBalance, endRate, deposits, withdrawals);

// (12 * 1.05) - (10 * 1) - 5 + 3 = 0.6
t.expect(reward).toBe(BigInt(0.6 * 1e18));
});

t.test("should handle zero rewards correctly", () => {
const startBalance = BigInt(10 * 1e18);
const startRate = BigInt(1 * 1e18);
const endBalance = BigInt(15 * 1e18);
const endRate = BigInt(1 * 1e18);
const deposits = BigInt(5 * 1e18);
const withdrawals = BigInt(0);

const reward = adapter["calculateRewards"](startBalance, startRate, endBalance, endRate, deposits, withdrawals);

// (15 * 1) - (10 * 1) - 5 = 0
t.expect(reward).toBe(BigInt(0));
});

t.test("should handle negative rewards correctly (loss scenario)", () => {
const startBalance = BigInt(10 * 1e18);
const startRate = BigInt(1 * 1e18);
const endBalance = BigInt(10 * 1e18);
const endRate = BigInt(0.95 * 1e18);
const deposits = BigInt(0);
const withdrawals = BigInt(0);

const reward = adapter["calculateRewards"](startBalance, startRate, endBalance, endRate, deposits, withdrawals);

// (10 * 0.95) - (10 * 1) = -0.5
t.expect(reward).toBe(BigInt(-0.5 * 1e18));
});
});

t.describe("mapping", () => {
t.test("should map into notification data", async () => {
const context: lido_staking_rewards.Context = {
currentStethBalance: BigInt(10 * 1e18),
currentExchangeRate: BigInt(1.05 * 1e18),
currentEthValue: BigInt(10.5 * 1e18),
estimatedTotalRewardInPeriod: BigInt(0.5 * 1e18),
daysSinceLastNotification: 30,
};

const data = await adapter.mapIntoNotificationData(trigger, context);

t.expect(data).toEqual({
chainId: domain.Chain.Ethereum,
currentStethBalance: "10",
currentEthValue: "10.5",
estimatedTotalRewardInPeriod: "0.5",
daysSinceLastNotification: 30,
notificationIntervalDays: 30,
});
});
});
});
Loading

0 comments on commit cae476c

Please sign in to comment.