diff --git a/.gitignore b/.gitignore index b77330c..522f118 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dist .turbo packages/ethereum/cache -packages/ethereum/hardhat.log \ No newline at end of file +packages/ethereum/hardhat.log +packages/ethereum/hardhat.pid \ No newline at end of file diff --git a/packages/ethereum/package.json b/packages/ethereum/package.json index b201122..160771a 100644 --- a/packages/ethereum/package.json +++ b/packages/ethereum/package.json @@ -4,10 +4,11 @@ "description": "All-in-one toolkit for building staking dApps on Ethereum network", "scripts": { "build": "rm -fr dist/* && tsc -p tsconfig.mjs.json --outDir dist/mjs && tsc -p tsconfig.cjs.json --outDir dist/cjs && bash ../../scripts/fix-package-json", - "hardhat:start": "hardhat node", - "hardhat:stop": "kill $(lsof -t -i:8545) > /dev/null 2>&1", - "test:integration": "mocha --timeout 20000 '**/*.spec.ts'", - "test": "npm run hardhat:start > hardhat.log & (sleep 5; npm run test:integration; status=$?; npm run hardhat:stop; exit $status)" + "hardhat:start": "hardhat node & echo $! > hardhat.pid;", + "hardhat:stop": "kill $(cat hardhat.pid) && rm hardhat.pid", + "test:integration": "mocha --timeout 30000 '**/*.spec.ts'", + "test": "npm run hardhat:start > hardhat.log & sleep 5; npm run test:integration; status=$?; npm run hardhat:stop; exit $status" + }, "main": "dist/cjs/index.js", "module": "dist/mjs/index.js", diff --git a/packages/ethereum/test/buildStakeTx.spec.ts b/packages/ethereum/test/buildStakeTx.spec.ts index b792715..a0ec3ad 100644 --- a/packages/ethereum/test/buildStakeTx.spec.ts +++ b/packages/ethereum/test/buildStakeTx.spec.ts @@ -1,6 +1,6 @@ import { EthereumStaker } from '@chorus-one/ethereum' import { Hex, PublicClient, WalletClient, parseEther } from 'viem' -import { assert, assert } from 'chai' +import { assert } from 'chai' import { prepareTests, stake } from './lib/utils' const amountToStake = parseEther('2') diff --git a/packages/ethereum/test/getRewardsHistory.spec.ts b/packages/ethereum/test/getRewardsHistory.spec.ts new file mode 100644 index 0000000..f5079ba --- /dev/null +++ b/packages/ethereum/test/getRewardsHistory.spec.ts @@ -0,0 +1,59 @@ +import { EthereumStaker } from '@chorus-one/ethereum' +import { assert } from 'chai' +import { Hex } from 'viem' +import { prepareTests } from './lib/utils' + +describe('EthereumStaker.getRewards', () => { + let validatorAddress: Hex + let delegatorAddress: Hex + let staker: EthereumStaker + + beforeEach(async () => { + const setup = await prepareTests() + validatorAddress = setup.validatorAddress + delegatorAddress = '0x2dF83a340D5067751e8045cCe90764B19D9e7A4D' + staker = setup.staker + }) + + it('returns correct rewards history for given period of time for Chorus mainnet stakers', async () => { + const rewards = await staker.getRewardsHistory({ + startTime: new Date('2024-02-01').getTime(), + endTime: new Date('2024-03-01').getTime(), + validatorAddress, + delegatorAddress + }) + + assert.deepEqual(rewards, [ + { timestamp: 1706745600000, amount: '0.000024786824502065' }, + { timestamp: 1706832000000, amount: '0.000044119038630881' }, + { timestamp: 1706918400000, amount: '0.000062876329371494' }, + { timestamp: 1707004800000, amount: '0.00008190429845996' }, + { timestamp: 1707091200000, amount: '0.0001097109140602' }, + { timestamp: 1707177600000, amount: '0.000153434503075382' }, + { timestamp: 1707264000000, amount: '0.000146997696335134' }, + { timestamp: 1707350400000, amount: '0.000146832073871066' }, + { timestamp: 1707436800000, amount: '0.000146482684232609' }, + { timestamp: 1707523200000, amount: '0.000146408328074781' }, + { timestamp: 1707609600000, amount: '0.000146334140031532' }, + { timestamp: 1707696000000, amount: '0.000146312956091355' }, + { timestamp: 1707782400000, amount: '0.00014640161118171' }, + { timestamp: 1707868800000, amount: '0.000146491769380892' }, + { timestamp: 1707955200000, amount: '0.000146580473685824' }, + { timestamp: 1708041600000, amount: '0.000146669140376886' }, + { timestamp: 1708128000000, amount: '0.000146755869058696' }, + { timestamp: 1708214400000, amount: '0.000146842138530308' }, + { timestamp: 1708300800000, amount: '0.000146883811424052' }, + { timestamp: 1708387200000, amount: '0.000146968064066342' }, + { timestamp: 1708473600000, amount: '0.000147054516896928' }, + { timestamp: 1708560000000, amount: '0.000147133777127185' }, + { timestamp: 1708646400000, amount: '0.000147208399117727' }, + { timestamp: 1708732800000, amount: '0.000147289422275367' }, + { timestamp: 1708819200000, amount: '0.000147352436930246' }, + { timestamp: 1708905600000, amount: '0.000147420173096827' }, + { timestamp: 1708992000000, amount: '0.000147500180062545' }, + { timestamp: 1709078400000, amount: '0.000147585418073838' }, + { timestamp: 1709164800000, amount: '0.000147671364763405' }, + { timestamp: 1709251200000, amount: '0.000148768196229641' } + ]) + }) +}) diff --git a/packages/ethereum/test/getTxHistory.spec.ts b/packages/ethereum/test/getTxHistory.spec.ts new file mode 100644 index 0000000..250004a --- /dev/null +++ b/packages/ethereum/test/getTxHistory.spec.ts @@ -0,0 +1,35 @@ +import { EthereumStaker } from '@chorus-one/ethereum' +import { assert } from 'chai' +import { Hex } from 'viem' +import { prepareTests } from './lib/utils' + +describe('EthereumStaker.getTxHistory', () => { + let validatorAddress: Hex + let delegatorAddress: Hex + let staker: EthereumStaker + + beforeEach(async () => { + const setup = await prepareTests() + validatorAddress = setup.validatorAddress + delegatorAddress = '0x2dF83a340D5067751e8045cCe90764B19D9e7A4D' + staker = setup.staker + }) + + it('returns correct interaction history for given period of time for Chorus mainnet stakers', async () => { + const txHistory = await staker.getTxHistory({ + validatorAddress, + delegatorAddress + }) + + const expectedTx = { + timestamp: 1705042416000, + type: 'Deposited', + amount: '0.01', + txHash: '0xd2d3c10b5e4dde53afe9cede8d10a961c357f574324599e9d78467f6c811afcf' + } + + const tx = txHistory.find((tx) => tx.txHash === expectedTx.txHash) + + assert.deepEqual(tx, expectedTx) + }) +}) diff --git a/packages/ethereum/test/getUnstakeQueue.spec.ts b/packages/ethereum/test/getUnstakeQueue.spec.ts new file mode 100644 index 0000000..9047e29 --- /dev/null +++ b/packages/ethereum/test/getUnstakeQueue.spec.ts @@ -0,0 +1,177 @@ +import { Hex, PublicClient, WalletClient, decodeEventLog, formatEther, parseEther } from 'viem' +import { assert } from 'chai' +import { EthereumStaker } from '../dist/mjs' +import { prepareTests, stake } from './lib/utils' +import { VaultABI } from '../src/lib/contracts/vaultAbi' +const amountToStake = parseEther('5') +const amountToUnstake = parseEther('1') + +const originalFetch = global.fetch + +// https://github.com/tc39/proposal-promise-with-resolvers/blob/main/polyfills.js +const withResolvers = () => { + const out: { + resolve: (value: V) => void + reject: (reason: Err) => void + promise: Promise + } = { + resolve: () => {}, + reject: () => {}, + promise: Promise.resolve() as Promise + } + + out.promise = new Promise((resolve, reject) => { + out.resolve = resolve + out.reject = reject + }) + + return out +} + +type VaultEvent = ReturnType> + +describe('EthereumStaker.getUnstakeQueue', () => { + let delegatorAddress: Hex + let validatorAddress: Hex + let walletClient: WalletClient + let publicClient: PublicClient + let staker: EthereumStaker + let unwatch: () => void = () => {} + + const unstake = async (amount: string) => { + const { tx } = await staker.buildUnstakeTx({ + delegatorAddress, + validatorAddress, + amount + }) + + const request = await walletClient.prepareTransactionRequest({ + ...tx, + chain: undefined + }) + const hash = await walletClient.sendTransaction({ + ...request, + account: delegatorAddress + }) + + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + assert.equal(receipt.status, 'success') + } + + beforeEach(async () => { + const setup = await prepareTests() + + delegatorAddress = setup.walletClient.account.address + validatorAddress = setup.validatorAddress + publicClient = setup.publicClient + walletClient = setup.walletClient + staker = setup.staker + + await stake({ + delegatorAddress, + validatorAddress, + amountToStake, + publicClient, + walletClient, + staker + }) + }) + + afterEach(() => { + unwatch() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + global.fetch = originalFetch + }) + + it('should return the unstake queue', async () => { + // Subscribe to the ExitQueueEntered events + const { resolve: eventsResolve, promise: eventsPromise } = withResolvers() + const passedEvents: VaultEvent[] = [] + + unwatch = publicClient.watchEvent({ + onLogs: (logs) => { + const nextEvents = logs + .map((l) => + decodeEventLog({ + abi: VaultABI, + data: l.data, + topics: l.topics + }) + ) + .filter((e): e is VaultEvent => e.eventName === 'ExitQueueEntered') + passedEvents.push(...nextEvents) + if (passedEvents.length === 2) { + eventsResolve(passedEvents.sort((a, b) => Number(a.args.shares) - Number(b.args.shares))) + } + } + }) + + // Unstake + + await unstake(formatEther(amountToUnstake)) + await unstake('2') + + // Wait for the events to be processed + + const events = await eventsPromise + + assert.strictEqual(events.length, 2) + // The shares are not exactly the same as the amount to unstake + assert.closeTo(Number(events[0].args.shares), Number(parseEther('1')), Number(parseEther('0.1'))) + assert.isTrue(typeof events[0].args.positionTicket === 'bigint') + + // mock the request to Stakewise with positionTicket and totalShares from the events + + const day = 24 * 60 * 60 + const mockExitRequests = [ + { + positionTicket: events[0].args.positionTicket.toString(), + totalShares: events[0].args.shares.toString(), + // earlier + timestamp: Math.round((new Date().getTime() - 60000) / 1000 - day * 2).toString() + }, + { + positionTicket: events[1].args.positionTicket.toString(), + totalShares: events[1].args.shares.toString(), + // later + timestamp: Math.round(new Date().getTime() / 1000 - day * 2).toString() + } + ] + + const mockFetch = (input, init) => { + if (input === 'https://holesky-graph.stakewise.io/subgraphs/name/stakewise/stakewise?opName=exitQueue') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { + exitRequests: mockExitRequests + } + }) + }) + } else { + return originalFetch(input, init) // Fallback to the original fetch for other URLs + } + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + global.fetch = mockFetch + + const unstakeQueue = await staker.getUnstakeQueue({ + validatorAddress, + delegatorAddress + }) + + assert.strictEqual(unstakeQueue.length, 2) + // The queue is sorted by the timestamp from latest to earliest + const earlierItem = unstakeQueue[1] + const earlierMock = mockExitRequests[0] + + assert.equal(earlierItem.timestamp, new Date(Number(earlierMock.timestamp) * 1000).getTime()) + // Take into account 1 wei assets conversion issues on the contract + assert.closeTo(Number(parseEther(earlierItem.totalAmount)), Number(amountToUnstake), 1) + + assert.isFalse(earlierItem.isWithdrawable) + }) +}) diff --git a/packages/ethereum/test/getVault.spec.ts b/packages/ethereum/test/getVault.spec.ts new file mode 100644 index 0000000..f7e2960 --- /dev/null +++ b/packages/ethereum/test/getVault.spec.ts @@ -0,0 +1,26 @@ +import { EthereumStaker } from '@chorus-one/ethereum' +import { assert } from 'chai' +import { Hex } from 'viem' +import { prepareTests } from './lib/utils' + +describe('EthereumStaker.getVault', () => { + let validatorAddress: Hex + let staker: EthereumStaker + + beforeEach(async () => { + const setup = await prepareTests() + validatorAddress = setup.validatorAddress + staker = setup.staker + }) + + it('returns correct details and stake for Chorus mainnet wallet by default', async () => { + const { vault } = await staker.getVault({ + validatorAddress + }) + + assert.equal(vault.name, 'Chorus One Test Wallet') + assert.isTrue(Number(vault.tvl) > 1000 * 10 ** 18) + assert.isTrue(vault.description === 'Test wallet for Chorus') + assert.isTrue(/^https?:\/\/.*.png$/.test(vault.logoUrl)) + }) +})