diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8c7397b9..76746033 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -40,4 +40,6 @@ jobs: TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }} TEST_CHAIN_ID: ${{ vars.TEST_CHAIN_ID }} TEST_RPC_URL: ${{ secrets.TEST_RPC_URL }} + TEST_L2_CHAIN_ID: ${{ vars.TEST_L2_CHAIN_ID }} + TEST_L2_RPC_URL: ${{ secrets.TEST_L2_RPC_URL }} TEST_SUBGRAPH_URL: ${{ secrets.TEST_SUBGRAPH_URL }} diff --git a/packages/sdk/src/common/constants.ts b/packages/sdk/src/common/constants.ts index d96e60f6..c5dac6c2 100644 --- a/packages/sdk/src/common/constants.ts +++ b/packages/sdk/src/common/constants.ts @@ -5,6 +5,7 @@ import { holesky, sepolia, optimismSepolia, + optimism, } from 'viem/chains'; export enum CHAINS { @@ -12,6 +13,7 @@ export enum CHAINS { Mainnet = 1, Holesky = 17000, Sepolia = 11155111, + Optimism = 10, OptimismSepolia = 11155420, } @@ -30,33 +32,28 @@ export const GAS_TRANSACTION_RATIO_PRECISION = 10 ** 7; export const ESTIMATE_ACCOUNT = '0x87c0e047F4e4D3e289A56a36570D4CB957A37Ef1'; export const LIDO_LOCATOR_BY_CHAIN: { - [key in CHAINS]: Address | null; + [key in CHAINS]?: Address; } = { [CHAINS.Mainnet]: '0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb', [CHAINS.Goerli]: '0x1eDf09b5023DC86737b59dE68a8130De878984f5', [CHAINS.Holesky]: '0x28FAB2059C713A7F9D8c86Db49f9bb0e96Af1ef8', [CHAINS.Sepolia]: '0x8f6254332f69557A72b0DA2D5F0Bc07d4CA991E7', - [CHAINS.OptimismSepolia]: null, }; export const SUBRGRAPH_ID_BY_CHAIN: { - [key in CHAINS]: string | null; + [key in CHAINS]?: string; } = { [CHAINS.Mainnet]: 'Sxx812XgeKyzQPaBpR5YZWmGV5fZuBaPdh7DFhzSwiQ', [CHAINS.Goerli]: 'QmeDfGTuNbSoZ71zi3Ch4WNRbzALfiFPnJMYUFPinLiFNa', - [CHAINS.Holesky]: null, - [CHAINS.Sepolia]: null, - [CHAINS.OptimismSepolia]: null, }; export const EARLIEST_TOKEN_REBASED_EVENT: { - [key in CHAINS]: bigint; + [key in CHAINS]?: bigint; } = { [CHAINS.Mainnet]: 17272708n, [CHAINS.Goerli]: 8712039n, [CHAINS.Holesky]: 52174n, [CHAINS.Sepolia]: 5434668n, - [CHAINS.OptimismSepolia]: 0n, } as const; export const LIDO_TOKENS = { @@ -89,15 +86,17 @@ export const enum LIDO_L2_CONTRACT_NAMES { steth = 'steth', } -export const LIDO_L2_CONTRACT_ADDRESSES: Record< - number, - Record -> = { +export const LIDO_L2_CONTRACT_ADDRESSES: { + [key in CHAINS]?: { [key2 in LIDO_L2_CONTRACT_NAMES]?: Address }; +} = { [CHAINS.OptimismSepolia]: { wsteth: '0x24B47cd3A74f1799b32B2de11073764Cb1bb318B', steth: '0xf49d208b5c7b10415c7beafe9e656f2df9edfe3b', }, -} as const; + [CHAINS.Optimism]: { + wsteth: '0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb', + }, +}; export const CONTRACTS_BY_TOKENS = { [LIDO_TOKENS.steth]: LIDO_CONTRACT_NAMES.lido, @@ -136,13 +135,12 @@ export const VIEM_CHAINS: { [key in CHAINS]: Chain } = { [CHAINS.Goerli]: goerli, [CHAINS.Holesky]: holesky, [CHAINS.Sepolia]: sepolia, + [CHAINS.Optimism]: optimism, [CHAINS.OptimismSepolia]: optimismSepolia, }; -export const WQ_API_URLS: { [key in CHAINS]: string | null } = { +export const WQ_API_URLS: { [key in CHAINS]?: string } = { [CHAINS.Mainnet]: 'https://wq-api.lido.fi', [CHAINS.Goerli]: 'https://wq-api.testnet.fi', [CHAINS.Holesky]: 'https://wq-api-holesky.testnet.fi', - [CHAINS.Sepolia]: null, - [CHAINS.OptimismSepolia]: null, }; diff --git a/packages/sdk/src/core/core.ts b/packages/sdk/src/core/core.ts index cb73b325..8aeb0b18 100644 --- a/packages/sdk/src/core/core.ts +++ b/packages/sdk/src/core/core.ts @@ -390,7 +390,7 @@ export default class LidoSDKCore extends LidoSDKCacheable { @Logger('Utils:') @Cache(30 * 60 * 1000, ['chain.id']) public getL2ContractAddress(contract: LIDO_L2_CONTRACT_NAMES): Address { - const chainConfig = LIDO_L2_CONTRACT_ADDRESSES[this.chain.id]; + const chainConfig = LIDO_L2_CONTRACT_ADDRESSES[this.chain.id as CHAINS]; invariant( chainConfig, `Lido L2 contracts are not supported for ${this.chain.name}(${this.chain.id})`, @@ -408,7 +408,7 @@ export default class LidoSDKCore extends LidoSDKCacheable { @Logger('Utils:') @Cache(30 * 60 * 1000, ['chain.id']) public getSubgraphId(): string | null { - const id = SUBRGRAPH_ID_BY_CHAIN[this.chainId]; + const id = SUBRGRAPH_ID_BY_CHAIN[this.chainId] ?? null; return id; } diff --git a/packages/sdk/src/l2/__test__/l2.test.ts b/packages/sdk/src/l2/__test__/l2.test.ts new file mode 100644 index 00000000..1df82a94 --- /dev/null +++ b/packages/sdk/src/l2/__test__/l2.test.ts @@ -0,0 +1,341 @@ +import { beforeAll, describe, expect, jest, test } from '@jest/globals'; +import { expectSDKModule } from '../../../tests/utils/expect/expect-sdk-module.js'; +import { + useL2, + useL2Rpc, + useTestL2RpcProvider, +} from '../../../tests/utils/fixtures/use-l2.js'; +import { LidoSDKL2 } from '../l2.js'; +import { LidoSDKL2Steth, LidoSDKL2Wsteth } from '../tokens.js'; +import { expectERC20 } from '../../../tests/utils/expect/expect-erc20.js'; +import { LIDO_L2_CONTRACT_NAMES } from '../../common/constants.js'; +import { expectERC20Wallet } from '../../../tests/utils/expect/expect-erc20-wallet.js'; +import { + useAccount, + useAltAccount, +} from '../../../tests/utils/fixtures/use-wallet-client.js'; +import { getContract } from 'viem'; +import { bridgedWstethAbi } from '../abi/brigedWsteth.js'; +import { expectAddress } from '../../../tests/utils/expect/expect-address.js'; +import { expectContract } from '../../../tests/utils/expect/expect-contract.js'; +import { + expectAlmostEqualBn, + expectNonNegativeBn, +} from '../../../tests/utils/expect/expect-bn.js'; +import { + SPENDING_TIMEOUT, + testSpending, +} from '../../../tests/utils/test-spending.js'; +import { expectTxCallback } from '../../../tests/utils/expect/expect-tx-callback.js'; +import { + expectPopulatedTx, + expectPopulatedTxToRun, +} from '../../../tests/utils/expect/expect-populated-tx.js'; + +const prepareL2Wsteth = async () => { + const l2 = useL2(); + const account = useAccount(); + const { testClient } = useTestL2RpcProvider(); + const wstethAddress = await l2.wsteth.contractAddress(); + + const wstethImpersonated = getContract({ + abi: bridgedWstethAbi, + address: wstethAddress, + client: testClient, + }); + + const bridge = await wstethImpersonated.read.bridge(); + + await testClient.setBalance({ + address: account.address, + value: 100000000000000n, + }); + + await testClient.setBalance({ + address: bridge, + value: 100000000000000n, + }); + + await testClient.request({ + method: 'evm_addAccount' as any, + params: [bridge, 'pass'], + }); + + await testClient.request({ + method: 'personal_unlockAccount' as any, + params: [bridge, 'pass'], + }); + + await wstethImpersonated.write.bridgeMint([account.address, 2000n], { + account: bridge, + chain: testClient.chain, + }); +}; + +describe('LidoSDKL2', () => { + const l2 = useL2(); + const account = useAccount(); + beforeAll(async () => { + await prepareL2Wsteth(); + }); + + test('is correct module', () => { + expectSDKModule(LidoSDKL2); + }); + + test('has correct address', async () => { + const address = await l2.contractAddress(); + const stethAddress = l2.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + expectAddress(address, stethAddress); + }); + + test('has contract', async () => { + const stethAddress = l2.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + const contract = await l2.getContract(); + expectContract(contract, stethAddress); + }); + + test('get allowance', async () => { + const allowance = await l2.getWstethForWrapAllowance(account.address); + expectNonNegativeBn(allowance); + + const contractAddress = await l2.contractAddress(); + const altAllowance = await l2.wsteth.allowance({ + account: account.address, + to: contractAddress, + }); + expect(allowance).toEqual(altAllowance); + }); +}); + +describe('LidoSDKL2 wrap', () => { + const l2 = useL2(); + const account = useAccount(); + + const value = 100n; + + beforeAll(prepareL2Wsteth); + + testSpending('set allowance', async () => { + const mock = jest.fn(); + const tx = await l2.approveWstethForWrap({ value, callback: mock }); + expectTxCallback(mock, tx); + await expect(l2.getWstethForWrapAllowance(account)).resolves.toEqual(value); + }); + + testSpending('wrap populate', async () => { + const stethAddress = l2.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + const tx = await l2.wrapWstethToStethPopulateTx({ value }); + expectAddress(tx.to, stethAddress); + expectAddress(tx.from, account.address); + expectPopulatedTx(tx, undefined); + await expectPopulatedTxToRun(tx, l2.core.rpcProvider); + }); + + testSpending('wrap simulate', async () => { + const stethAddress = l2.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + const tx = await l2.wrapWstethToStethSimulateTx({ value }); + expectAddress(tx.address, stethAddress); + }); + + testSpending('wrap wsteth to steth', async () => { + const stethValue = await l2.steth.convertToSteth(value); + const stethBalanceBefore = await l2.steth.balance(account.address); + const wstethBalanceBefore = await l2.wsteth.balance(account.address); + const mock = jest.fn(); + const tx = await l2.wrapWstethToSteth({ value, callback: mock }); + expectTxCallback(mock, tx); + const stethBalanceAfter = await l2.steth.balance(account.address); + const wstethBalanceAfter = await l2.wsteth.balance(account.address); + + const stethDiff = stethBalanceAfter - stethBalanceBefore; + const wstethDiff = wstethBalanceAfter - wstethBalanceBefore; + + expectAlmostEqualBn(stethDiff, stethValue); + expectAlmostEqualBn(wstethDiff, -value); + + expect(tx.result).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = tx.result!; + expectAlmostEqualBn(result.stethReceived, stethDiff); + expect(result.wstethWrapped).toEqual(-wstethDiff); + + await expect( + l2.getWstethForWrapAllowance(account.address), + ).resolves.toEqual(0n); + }); + + testSpending('unwrap steth populate', async () => { + const stethAddress = l2.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + const tx = await l2.unwrapPopulateTx({ value }); + expectAddress(tx.to, stethAddress); + expectAddress(tx.from, account.address); + expectPopulatedTx(tx, undefined); + await expectPopulatedTxToRun(tx, l2.core.rpcProvider); + }); + + testSpending('unwrap steth simulate', async () => { + const stethAddress = l2.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + const tx = await l2.unwrapSimulateTx({ value }); + expectAddress(tx.address, stethAddress); + }); + + testSpending('unwrap', async () => { + const stethValue = await l2.steth.convertToSteth(value); + const stethBalanceBefore = await l2.steth.balance(account.address); + const wstethBalanceBefore = await l2.wsteth.balance(account.address); + const mock = jest.fn(); + const tx = await l2.unwrap({ value: stethValue, callback: mock }); + expectTxCallback(mock, tx); + const stethBalanceAfter = await l2.steth.balance(account.address); + const wstethBalanceAfter = await l2.wsteth.balance(account.address); + + const stethDiff = stethBalanceAfter - stethBalanceBefore; + const wstethDiff = wstethBalanceAfter - wstethBalanceBefore; + + expectAlmostEqualBn(stethDiff, -stethValue); + expectAlmostEqualBn(wstethDiff, value); + + expect(tx.result).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { stethUnwrapped, wstethReceived } = tx.result!; + expectAlmostEqualBn(stethUnwrapped, -stethDiff); + expect(wstethReceived).toEqual(wstethDiff); + }); +}); + +describe('LidoSDKL2Wsteth', () => { + const l2 = useL2(); + const l2Rpc = useL2Rpc(); + + beforeAll(async () => { + await prepareL2Wsteth(); + }); + + // wstETH erc20 tests + expectERC20({ + contractName: LIDO_L2_CONTRACT_NAMES.wsteth, + constructedWithWeb3Core: l2.wsteth, + isL2: true, + ModulePrototype: LidoSDKL2Wsteth, + constructedWithRpcCore: l2Rpc.wsteth, + }); + + expectERC20Wallet({ + contractName: LIDO_L2_CONTRACT_NAMES.wsteth, + constructedWithWeb3Core: l2.wsteth, + isL2: true, + constructedWithRpcCore: l2Rpc.wsteth, + }); +}); + +describe('LidoSDKL2Steth', () => { + const l2 = useL2(); + const l2Rpc = useL2Rpc(); + const account = useAccount(); + + beforeAll(async () => { + await prepareL2Wsteth(); + + await l2.approveWstethForWrap({ value: 1000n, account }); + await l2.wrapWstethToSteth({ value: 1000n, account }); + }); + + // stETH erc20 tests + expectERC20({ + ModulePrototype: LidoSDKL2Steth, + contractName: LIDO_L2_CONTRACT_NAMES.steth, + constructedWithWeb3Core: l2.steth, + isL2: true, + constructedWithRpcCore: l2Rpc.steth, + }); + + expectERC20Wallet({ + contractName: LIDO_L2_CONTRACT_NAMES.steth, + constructedWithWeb3Core: l2.steth, + isL2: true, + constructedWithRpcCore: l2Rpc.steth, + }); +}); + +describe('LidoSDKL2Steth shares', () => { + const l2 = useL2(); + const account = useAccount(); + const { address: altAddress } = useAltAccount(); + const value = 1000n; + + beforeAll(async () => { + await prepareL2Wsteth(); + + await l2.approveWstethForWrap({ value, account }); + await l2.wrapWstethToSteth({ value, account }); + }); + + test('shares balance and conversions', async () => { + const balanceSteth = await l2.steth.balance(); + const shares = await l2.steth.balanceShares(account); + + const convertedToShares = await l2.steth.convertToShares(balanceSteth); + expectAlmostEqualBn(shares, convertedToShares); + const convertedToSteth = await l2.steth.convertToSteth(shares); + expectAlmostEqualBn(balanceSteth, convertedToSteth); + }); + + test('populate transfer', async () => { + const tx = await l2.steth.populateTransferShares({ + to: altAddress, + amount: 100n, + }); + expectPopulatedTx(tx, undefined, true); + await expectPopulatedTxToRun(tx, l2.core.rpcProvider); + }); + + test('simulate transfer', async () => { + const contractAddressSteth = l2.steth.core.getL2ContractAddress( + LIDO_L2_CONTRACT_NAMES.steth, + ); + const tx = await l2.steth.simulateTransferShares({ + to: altAddress, + amount: 100n, + }); + expectAddress(tx.request.address, contractAddressSteth); + expectAddress(tx.request.functionName, 'transferShares'); + }); + + testSpending( + 'can transfer shares', + async () => { + const amount = 100n; + const amountSteth = await l2.steth.convertToSteth(amount); + const balanceStethBefore = await l2.steth.balance(account.address); + const balanceSharesBefore = await l2.steth.balanceShares(account.address); + const mockTxCallback = jest.fn(); + + const tx = await l2.steth.transferShares({ + amount, + to: altAddress, + callback: mockTxCallback, + }); + expectTxCallback(mockTxCallback, tx); + + const balanceStethAfter = await l2.steth.balance(account.address); + const balanceSharesAfter = await l2.steth.balanceShares(account.address); + expect(balanceSharesAfter - balanceSharesBefore).toEqual(-amount); + // due to protocol rounding error this can happen + expectAlmostEqualBn(balanceStethAfter - balanceStethBefore, -amountSteth); + }, + SPENDING_TIMEOUT, + ); +}); diff --git a/packages/sdk/src/l2/l2.ts b/packages/sdk/src/l2/l2.ts index 174ee5a7..7d454045 100644 --- a/packages/sdk/src/l2/l2.ts +++ b/packages/sdk/src/l2/l2.ts @@ -16,7 +16,12 @@ import { type TransactionReceipt, } from 'viem'; -import { LIDO_L2_CONTRACT_NAMES, NOOP } from '../common/constants.js'; +import { + CHAINS, + LIDO_L2_CONTRACT_ADDRESSES, + LIDO_L2_CONTRACT_NAMES, + NOOP, +} from '../common/constants.js'; import { Cache, Logger, ErrorHandler } from '../common/decorators/index.js'; import { rebasableL2StethAbi } from './abi/rebasableL2Steth.js'; @@ -36,6 +41,13 @@ export class LidoSDKL2 extends LidoSDKModule { getAbiItem({ abi: rebasableL2StethAbi, name: 'Transfer' }), ); + public static isContractAvailableOn( + contract: LIDO_L2_CONTRACT_NAMES, + chain: CHAINS, + ) { + return !!LIDO_L2_CONTRACT_ADDRESSES[chain]?.[contract]; + } + public readonly wsteth: LidoSDKL2Wsteth; public readonly steth: LidoSDKL2Steth; diff --git a/packages/sdk/src/l2/tokens.ts b/packages/sdk/src/l2/tokens.ts index 57e7039d..d2ff086b 100644 --- a/packages/sdk/src/l2/tokens.ts +++ b/packages/sdk/src/l2/tokens.ts @@ -8,7 +8,7 @@ import { Hash, } from 'viem'; -import { LIDO_L2_CONTRACT_NAMES, NOOP } from '../common/constants.js'; +import { CHAINS, LIDO_L2_CONTRACT_NAMES, NOOP } from '../common/constants.js'; import { parseValue } from '../common/utils/parse-value.js'; import { Cache, ErrorHandler, Logger } from '../common/decorators/index.js'; import { AbstractLidoSDKErc20 } from '../erc20/erc20.js'; @@ -48,25 +48,39 @@ export class LidoSDKL2Wsteth extends AbstractLidoSDKErc20 { }); } - @Cache(30 * 60 * 1000, ['core.chain.id']) - public async contractVersion(): Promise { - const contract = await this.getL2Contract(); - return contract.read.getContractVersion(); - } - @Cache(30 * 60 * 1000, ['core.chain.id']) public override async erc721Domain(): Promise<{ name: string; version: string; chainId: bigint; - verifyingContract: `0x${string}`; + verifyingContract: Address; + fields: Hash; + salt: Hash; + extensions: readonly bigint[]; }> { - const { name } = await this.erc20Metadata(); + const contract = await this.getL2Contract(); + const [ + fields, + name, + version, + chainId, + verifyingContract, + salt, + extensions, + ] = await contract.read.eip712Domain(); + + // Testnet misconfiguration + const fixedVersion = + this.core.chainId === CHAINS.OptimismSepolia ? '1' : version; + return { - name: name, - version: (await this.contractVersion()).toString(), - chainId: BigInt(this.core.chain.id), - verifyingContract: await this.contractAddress(), + fields, + name, + version: fixedVersion, + chainId, + verifyingContract, + salt, + extensions, }; } } @@ -92,12 +106,6 @@ export class LidoSDKL2Steth extends AbstractLidoSDKErc20 { }); } - @Cache(30 * 60 * 1000, ['core.chain.id']) - public async contractVersion(): Promise { - const contract = await this.getL2Contract(); - return contract.read.getContractVersion(); - } - @Cache(30 * 60 * 1000, ['core.chain.id']) public override async erc721Domain(): Promise<{ name: string; diff --git a/packages/sdk/src/rewards/rewards.ts b/packages/sdk/src/rewards/rewards.ts index ab76db91..83baeda1 100644 --- a/packages/sdk/src/rewards/rewards.ts +++ b/packages/sdk/src/rewards/rewards.ts @@ -66,7 +66,13 @@ export class LidoSDKRewards extends LidoSDKModule { @Logger('Contracts:') @Cache(30 * 60 * 1000, ['core.chain.id']) private earliestRebaseEventBlock(): bigint { - return EARLIEST_TOKEN_REBASED_EVENT[this.core.chainId]; + const block = EARLIEST_TOKEN_REBASED_EVENT[this.core.chainId]; + invariant( + block, + `No rebase event for chain:${this.core.chainId}`, + ERROR_CODE.NOT_SUPPORTED, + ); + return block; } @Logger('Contracts:') diff --git a/packages/sdk/src/withdraw/withdraw-waiting-time.ts b/packages/sdk/src/withdraw/withdraw-waiting-time.ts index 4727674f..7ef8a829 100644 --- a/packages/sdk/src/withdraw/withdraw-waiting-time.ts +++ b/packages/sdk/src/withdraw/withdraw-waiting-time.ts @@ -97,7 +97,7 @@ export class LidoSDKWithdrawWaitingTime extends BusModule { const baseUrl = getCustomApiUrl && typeof getCustomApiUrl === 'function' - ? getCustomApiUrl(defaultUrl, this.bus.core.chainId) + ? getCustomApiUrl(defaultUrl ?? null, this.bus.core.chainId) : defaultUrl; if (!baseUrl) { diff --git a/packages/sdk/tests/global-setup.cjs b/packages/sdk/tests/global-setup.cjs index 3ce86ca9..fc42f123 100644 --- a/packages/sdk/tests/global-setup.cjs +++ b/packages/sdk/tests/global-setup.cjs @@ -2,49 +2,64 @@ const path = require('path'); const dotenv = require('dotenv'); const ganache = require('ganache'); -module.exports = async function () { - dotenv.config({ - path: path.resolve(process.cwd(), '.env'), - }); - - const rpcUrl = process.env.TEST_RPC_URL; - const chainId = Number(process.env.TEST_CHAIN_ID); - +const setupGanacheProvider = async (chainId, rpcUrl) => { const ganacheProvider = ganache.provider({ fork: { url: rpcUrl }, logging: { quiet: true }, chain: { chainId, asyncRequestProcessing: true }, }); - - console.debug('\nInitializing ganache provider...'); + console.debug(`\n[${chainId}]Initializing ganache provider...`); await ganacheProvider.initialize(); - console.debug('Initialized ganache provider OK'); + console.debug(`[${chainId}]Initialized ganache provider OK`); - console.debug('Testing direct RPC provider...'); + console.debug(`[${chainId}]Testing direct RPC provider...`); const { result } = await fetch(rpcUrl, { method: 'POST', body: JSON.stringify({ method: 'eth_chainId', params: [], + id: 1, + jsonrpc: '2.0', }), headers: { 'Content-Type': 'application/json', }, }).then((response) => response.json()); - if (Number(result) !== chainId) { - throw new Error(`Invalid direct RPC provider response: ${result}`); + if (parseInt(result, 16) !== chainId) { + throw new Error( + `[${chainId}]Invalid direct RPC provider response: ${result}`, + ); } - console.debug('Direct RPC provider OK'); + console.debug(`[${chainId}]Direct RPC provider OK`); - console.debug('Testing ganache fork RPC provider...'); + console.debug(`[${chainId}]Testing ganache fork RPC provider...`); const testRequest = await ganacheProvider.request({ method: 'eth_chainId', params: [], }); - if (Number(testRequest) !== chainId) { - throw new Error(`Invalid ganache response: ${testRequest}`); + if (parseInt(testRequest, 16) !== chainId) { + throw new Error(`[${chainId}]Invalid ganache response: ${testRequest}`); } - console.debug('Ganache fork RPC provider OK'); + console.debug(`[${chainId}]Ganache fork RPC provider OK`); + return ganacheProvider; +}; + +module.exports = async function () { + dotenv.config({ + path: path.resolve(process.cwd(), '.env'), + }); + + // L1 + const chainId = Number(process.env.TEST_CHAIN_ID); + const rpcUrl = process.env.TEST_RPC_URL; + globalThis.__ganache_provider__ = await setupGanacheProvider(chainId, rpcUrl); + + // L2 + const l2RpcUrl = process.env.TEST_L2_RPC_URL; + const l2ChainId = Number(process.env.TEST_L2_CHAIN_ID); - globalThis.__ganache_provider__ = ganacheProvider; + globalThis.__l2_ganache_provider__ = await setupGanacheProvider( + l2ChainId, + l2RpcUrl, + ); }; diff --git a/packages/sdk/tests/global-teardown.cjs b/packages/sdk/tests/global-teardown.cjs index 2413a5b6..460e8d3c 100644 --- a/packages/sdk/tests/global-teardown.cjs +++ b/packages/sdk/tests/global-teardown.cjs @@ -4,4 +4,9 @@ module.exports = async function () { await globalThis.__ganache_provider__.disconnect(); console.debug('Disconnected ganache provider'); } + if (globalThis.__l2_ganache_provider__) { + console.debug('Disconnecting L2 ganache provider...'); + await globalThis.__l2_ganache_provider__.disconnect(); + console.debug('Disconnected L2 ganache provider'); + } }; diff --git a/packages/sdk/tests/utils/expect/expect-erc20-wallet.ts b/packages/sdk/tests/utils/expect/expect-erc20-wallet.ts index bb6d703a..e4e039e4 100644 --- a/packages/sdk/tests/utils/expect/expect-erc20-wallet.ts +++ b/packages/sdk/tests/utils/expect/expect-erc20-wallet.ts @@ -1,8 +1,9 @@ -import { encodeFunctionData, getContract } from 'viem'; +import { encodeFunctionData, getContract, maxUint256 } from 'viem'; import { expect, describe, test, jest } from '@jest/globals'; import { AbstractLidoSDKErc20 } from '../../../src/erc20/erc20.js'; import { LIDO_CONTRACT_NAMES, + LIDO_L2_CONTRACT_NAMES, PERMIT_MESSAGE_TYPES, } from '../../../src/index.js'; import { @@ -51,10 +52,12 @@ export const expectERC20Wallet = ({ contractName, constructedWithRpcCore, constructedWithWeb3Core, + isL2 = false, }: { - contractName: LIDO_CONTRACT_NAMES; + contractName: LIDO_CONTRACT_NAMES | LIDO_L2_CONTRACT_NAMES; constructedWithRpcCore: I; constructedWithWeb3Core: I; + isL2?: boolean; }) => { const token = constructedWithWeb3Core; const tokenRpc = constructedWithRpcCore; @@ -62,11 +65,15 @@ export const expectERC20Wallet = ({ const rpcCore = tokenRpc.core; const getTokenAddress = async () => { - const address = - await constructedWithRpcCore.core.getContractAddress(contractName); + const address = await (isL2 + ? constructedWithRpcCore.core.getL2ContractAddress( + contractName as LIDO_L2_CONTRACT_NAMES, + ) + : constructedWithRpcCore.core.getContractAddress( + contractName as LIDO_CONTRACT_NAMES, + )); return address; }; - const getTokenContract = async () => { const address = await getTokenAddress(); return getContract({ @@ -268,37 +275,51 @@ export const expectERC20Wallet = ({ describe('permit', () => { test('signPermit', async () => { const { address } = useAccount(); + const altAccount = useAltAccount(); const contract = await getTokenContract(); const nonce = await contract.read.nonces([address]); const chainId = token.core.chainId; const params = { amount: 100n, - spender: address, - deadline: 86400n, + spender: altAccount.address, + deadline: maxUint256, }; - const tx = await token.signPermit(params); + const signedPermit = await token.signPermit(params); - expect(tx).toHaveProperty('v'); - expect(tx).toHaveProperty('r'); - expect(tx).toHaveProperty('s'); - expect(tx).toHaveProperty('chainId'); + expect(signedPermit).toHaveProperty('v'); + expect(signedPermit).toHaveProperty('r'); + expect(signedPermit).toHaveProperty('s'); + expect(signedPermit).toHaveProperty('chainId'); - expect(typeof tx.v).toBe('number'); - expect(typeof tx.r).toBe('string'); - expect(typeof tx.s).toBe('string'); - expect(tx.chainId).toBe(BigInt(chainId)); + expect(typeof signedPermit.v).toBe('number'); + expect(typeof signedPermit.r).toBe('string'); + expect(typeof signedPermit.s).toBe('string'); + expect(signedPermit.chainId).toBe(BigInt(chainId)); - const { v, r, s, chainId: _, ...permitMessage } = tx; + const { v, r, s, chainId: _, ...permitMessage } = signedPermit; expectPermitMessage(permitMessage, { address: address, - spender: address, + spender: altAccount.address, amount: params.amount, nonce, deadline: params.deadline, }); + + await contract.simulate.permit( + [ + permitMessage.owner, + permitMessage.spender, + permitMessage.value, + permitMessage.deadline, + v, + r, + s, + ], + { account: altAccount, maxFeePerGas: 0n, maxPriorityFeePerGas: 0n }, + ); }); testSpending('populatePermit', async () => { @@ -327,6 +348,7 @@ export const expectERC20Wallet = ({ expect(tx.domain).toMatchObject(domain); expect(tx.types).toBe(PERMIT_MESSAGE_TYPES); expect(tx.primaryType).toBe('Permit'); + expectPermitMessage(tx.message, { address: address, spender: address, diff --git a/packages/sdk/tests/utils/expect/expect-erc20.ts b/packages/sdk/tests/utils/expect/expect-erc20.ts index ea80600a..20a098f8 100644 --- a/packages/sdk/tests/utils/expect/expect-erc20.ts +++ b/packages/sdk/tests/utils/expect/expect-erc20.ts @@ -1,7 +1,10 @@ import { getContract } from 'viem'; import { expect, describe, test } from '@jest/globals'; import { AbstractLidoSDKErc20 } from '../../../src/erc20/erc20.js'; -import { LIDO_CONTRACT_NAMES } from '../../../src/index.js'; +import { + LIDO_CONTRACT_NAMES, + LIDO_L2_CONTRACT_NAMES, +} from '../../../src/index.js'; import { expectAddress } from '../../../tests/utils/expect/expect-address.js'; import { expectContract } from '../../../tests/utils/expect/expect-contract.js'; import { useAccount } from '../../../tests/utils/fixtures/use-wallet-client.js'; @@ -10,25 +13,34 @@ import { expectBn } from '../../../tests/utils/expect/expect-bn.js'; import { expectSDKModule } from '../../../tests/utils/expect/expect-sdk-module.js'; import { LidoSDKCommonProps } from '../../../src/core/types.js'; +type expectERC20Options = { + contractName: LIDO_CONTRACT_NAMES | LIDO_L2_CONTRACT_NAMES; + constructedWithRpcCore: I; + constructedWithWeb3Core: I; + ModulePrototype: new (props: LidoSDKCommonProps) => I; + isL2?: boolean; +}; + export const expectERC20 = ({ contractName, constructedWithRpcCore, constructedWithWeb3Core, ModulePrototype, -}: { - contractName: LIDO_CONTRACT_NAMES; - constructedWithRpcCore: I; - constructedWithWeb3Core: I; - ModulePrototype: new (props: LidoSDKCommonProps) => I; -}) => { + isL2 = false, +}: expectERC20Options) => { const token = constructedWithWeb3Core; const tokenRpc = constructedWithRpcCore; const web3Core = token.core; const rpcCore = tokenRpc.core; const getTokenAddress = async () => { - const address = - await constructedWithRpcCore.core.getContractAddress(contractName); + const address = await (isL2 + ? constructedWithRpcCore.core.getL2ContractAddress( + contractName as LIDO_L2_CONTRACT_NAMES, + ) + : constructedWithRpcCore.core.getContractAddress( + contractName as LIDO_CONTRACT_NAMES, + )); return address; }; diff --git a/packages/sdk/tests/utils/fixtures/use-l2.ts b/packages/sdk/tests/utils/fixtures/use-l2.ts new file mode 100644 index 00000000..9230d7d9 --- /dev/null +++ b/packages/sdk/tests/utils/fixtures/use-l2.ts @@ -0,0 +1,110 @@ +import type { EthereumProvider } from 'ganache'; +import { + createTestClient, + createWalletClient, + custom, + PrivateKeyAccount, + publicActions, + PublicClient, + TestClient, +} from 'viem'; +import { useTestsEnvs } from './use-test-envs.js'; +import { CHAINS, LidoSDKCore, VIEM_CHAINS } from '../../../src/index.js'; +import { useAccount } from './use-wallet-client.js'; +import { LidoSDKL2 } from '../../../src/l2/l2.js'; + +let cached: { + testClient: TestClient<'ganache'>; + ganacheProvider: EthereumProvider; +} | null = null; + +export const useTestL2RpcProvider = () => { + if (cached) return cached; + const { l2ChainId } = useTestsEnvs(); + + const ganacheProvider = (globalThis as any) + .__l2_ganache_provider__ as EthereumProvider; + + const testClient = createTestClient({ + mode: 'ganache', + transport: custom({ + async request(args) { + if (args.method === 'eth_estimateGas') { + delete args.params[0].gas; + } + return ganacheProvider.request(args); + }, + }), + name: 'testClient', + chain: VIEM_CHAINS[l2ChainId as CHAINS], + }); + + cached = { ganacheProvider, testClient }; + return cached; +}; + +let cachedPublicProvider: PublicClient | null = null; + +export const usePublicL2RpcProvider = () => { + if (cachedPublicProvider) return cachedPublicProvider; + const { testClient } = useTestL2RpcProvider(); + const rpcProvider = testClient.extend(publicActions) as PublicClient; + cachedPublicProvider = rpcProvider; + return rpcProvider; +}; + +export const useL2WalletClient = (_account?: PrivateKeyAccount) => { + const { l2ChainId } = useTestsEnvs(); + const { testClient } = useTestL2RpcProvider(); + const account = _account ?? useAccount(); + + const chain = VIEM_CHAINS[l2ChainId as CHAINS]; + + return createWalletClient({ + account, + chain, + transport: custom({ request: testClient.request }), + }); +}; + +let cachedWeb3Core: LidoSDKCore | null = null; + +export const useL2Web3Core = () => { + if (!cachedWeb3Core) { + const walletClient = useL2WalletClient(); + const { l2ChainId } = useTestsEnvs(); + const rpcProvider = usePublicL2RpcProvider(); + cachedWeb3Core = new LidoSDKCore({ + chainId: l2ChainId, + rpcProvider: rpcProvider, + logMode: 'none', + web3Provider: walletClient, + }); + } + return cachedWeb3Core; +}; + +let cachedRpcCore: LidoSDKCore | null = null; + +export const useL2RpcCore = () => { + if (!cachedRpcCore) { + const { l2ChainId } = useTestsEnvs(); + const rpcProvider = usePublicL2RpcProvider(); + cachedRpcCore = new LidoSDKCore({ + chainId: l2ChainId, + rpcProvider: rpcProvider, + logMode: 'none', + }); + } + return cachedRpcCore; +}; + +export const useL2Rpc = () => { + const rpcCore = useL2RpcCore(); + return new LidoSDKL2({ core: rpcCore }); +}; + +export const useL2 = () => { + const web3Core = useL2Web3Core(); + return new LidoSDKL2({ core: web3Core }); +}; diff --git a/packages/sdk/tests/utils/fixtures/use-test-envs.ts b/packages/sdk/tests/utils/fixtures/use-test-envs.ts index a01827de..4d0b968e 100644 --- a/packages/sdk/tests/utils/fixtures/use-test-envs.ts +++ b/packages/sdk/tests/utils/fixtures/use-test-envs.ts @@ -3,6 +3,8 @@ export const useTestsEnvs = () => { privateKey: process.env.TEST_PRIVATE_KEY as string, rpcUrl: process.env.TEST_RPC_URL as string, chainId: Number(process.env.TEST_CHAIN_ID), + l2RpcUrl: process.env.TEST_L2_RPC_URL as string, + l2ChainId: Number(process.env.TEST_L2_CHAIN_ID), skipSpendingTests: process.env.TEST_SKIP_SPENDING_TESTS == 'true', subgraphUrl: process.env.TEST_SUBGRAPH_URL, };