diff --git a/packages/sdk-viem/src/actions.ts b/packages/sdk-viem/src/actions.ts index 1a0503ac8..a59e35a60 100644 --- a/packages/sdk-viem/src/actions.ts +++ b/packages/sdk-viem/src/actions.ts @@ -1,4 +1,6 @@ import { + ChildToParentMessageStatus, + ChildTransactionReceipt, EthBridger, ParentToChildMessageStatus, ParentTransactionReceipt, @@ -17,14 +19,10 @@ import { WalletClient, } from 'viem' -export type PrepareDepositEthParameters = { - amount: bigint - account: Account | Address -} - -const DEFAULT_CONFIRMATIONS = 1 -const DEFAULT_TIMEOUT = 1000 * 60 * 5 // 5 minutes +export const DEFAULT_CONFIRMATIONS = 1 +export const DEFAULT_TIMEOUT = 1000 * 60 * 5 // 5 minutes +// Cross-chain transaction types export type WaitForCrossChainTxParameters = { hash: Hash timeout?: number @@ -45,9 +43,13 @@ export type CrossChainTransactionStatus = { hash: Hash } -export type DepositEthParameters = { +// Deposit types +export type PrepareDepositEthParameters = { amount: bigint account: Account | Address +} + +export type DepositEthParameters = PrepareDepositEthParameters & { confirmations?: number timeout?: number } @@ -58,6 +60,32 @@ export type ArbitrumDepositActions = { ) => Promise } +// Withdraw types +export type PrepareWithdrawEthParameters = { + amount: bigint + destinationAddress: Address + account: Account | Address +} + +export type WithdrawEthParameters = PrepareWithdrawEthParameters & { + confirmations?: number + timeout?: number +} + +export type ArbitrumChildWalletActions = { + prepareWithdrawEthTransaction: ( + params: PrepareWithdrawEthParameters + ) => Promise<{ + request: TransactionRequest + l1GasEstimate: bigint + }> + + withdrawEth: ( + params: WithdrawEthParameters + ) => Promise +} + +// Parent wallet types export type ArbitrumParentWalletActions = { waitForCrossChainTransaction: ( params: WaitForCrossChainTxParameters @@ -72,24 +100,6 @@ export type ArbitrumParentWalletActions = { ) => Promise } -export async function prepareDepositEthTransaction( - client: PublicClient, - { amount, account }: PrepareDepositEthParameters -): Promise { - const provider = publicClientToProvider(client) - const ethBridger = await EthBridger.fromProvider(provider) - const request = await ethBridger.getDepositRequest({ - amount: BigNumber.from(amount), - from: typeof account === 'string' ? account : account.address, - }) - - return { - to: request.txRequest.to as Address, - value: BigNumber.from(request.txRequest.value).toBigInt(), - data: request.txRequest.data as Address, - } -} - export async function waitForCrossChainTransaction( parentClient: PublicClient, childClient: PublicClient, @@ -164,6 +174,7 @@ export async function sendCrossChainTransaction( ...request, chain: walletClient.chain, account: walletClient.account as Account, + kzg: undefined, }) return waitForCrossChainTransaction(parentClient, childClient, { @@ -173,6 +184,25 @@ export async function sendCrossChainTransaction( }) } +// Deposit functions +export async function prepareDepositEthTransaction( + client: PublicClient, + { amount, account }: PrepareDepositEthParameters +): Promise { + const provider = publicClientToProvider(client) + const ethBridger = await EthBridger.fromProvider(provider) + const request = await ethBridger.getDepositRequest({ + amount: BigNumber.from(amount), + from: typeof account === 'string' ? account : account.address, + }) + + return { + to: request.txRequest.to as Address, + value: BigNumber.from(request.txRequest.value).toBigInt(), + data: request.txRequest.data as Address, + } +} + export async function depositEth( parentClient: PublicClient, childClient: PublicClient, @@ -196,6 +226,97 @@ export async function depositEth( }) } +// Withdraw functions +export async function prepareWithdrawEthTransaction( + client: PublicClient, + { amount, destinationAddress, account }: PrepareWithdrawEthParameters +): Promise<{ + request: TransactionRequest + l1GasEstimate: bigint +}> { + const provider = publicClientToProvider(client) + const ethBridger = await EthBridger.fromProvider(provider) + const request = await ethBridger.getWithdrawalRequest({ + amount: BigNumber.from(amount), + destinationAddress, + from: typeof account === 'string' ? account : account.address, + }) + + const l1GasEstimate = await request.estimateParentGasLimit(provider) + + return { + request: { + to: request.txRequest.to as `0x${string}`, + value: BigNumber.from(request.txRequest.value).toBigInt(), + data: request.txRequest.data as `0x${string}`, + }, + l1GasEstimate: l1GasEstimate.toBigInt(), + } +} + +export async function withdrawEth( + parentClient: PublicClient, + childClient: PublicClient, + walletClient: WalletClient, + { + amount, + destinationAddress, + account, + confirmations = DEFAULT_CONFIRMATIONS, + }: WithdrawEthParameters +): Promise { + const { request } = await prepareWithdrawEthTransaction(childClient, { + amount, + destinationAddress, + account, + }) + + const hash = await walletClient.sendTransaction({ + ...request, + chain: walletClient.chain, + account: walletClient.account as Account, + kzg: undefined, + }) + + const childProvider = publicClientToProvider(childClient) + const parentProvider = publicClientToProvider(parentClient) + + const viemReceipt = await childClient.waitForTransactionReceipt({ + hash, + confirmations, + }) + + const ethersReceipt = + viemTransactionReceiptToEthersTransactionReceipt(viemReceipt) + + const childReceipt = new ChildTransactionReceipt(ethersReceipt) + + const messages = await childReceipt.getChildToParentMessages(parentProvider) + if (messages.length === 0) { + return { + status: 'failed', + complete: false, + hash, + message: undefined, + childTxReceipt: undefined, + } + } + + const message = messages[0] + const messageStatus = await message.status(childProvider) + + // For withdrawals, return early since it needs to wait for challenge period + const isUnconfirmed = messageStatus === ChildToParentMessageStatus.UNCONFIRMED + return { + status: isUnconfirmed ? 'success' : 'failed', + complete: false, // Not complete until executed after challenge period + message, + childTxReceipt: ethersReceipt, + hash, + } +} + +// Client action creators export function arbitrumParentClientActions() { return (client: PublicClient): ArbitrumDepositActions => ({ prepareDepositEthTransaction: params => @@ -221,3 +342,15 @@ export function arbitrumParentWalletActions( depositEth(parentClient, childClient, walletClient, params), }) } + +export function arbitrumChildWalletActions( + parentClient: PublicClient, + childClient: PublicClient +) { + return (walletClient: WalletClient): ArbitrumChildWalletActions => ({ + prepareWithdrawEthTransaction: (params: PrepareWithdrawEthParameters) => + prepareWithdrawEthTransaction(childClient, params), + withdrawEth: (params: WithdrawEthParameters) => + withdrawEth(parentClient, childClient, walletClient, params), + }) +} diff --git a/packages/sdk-viem/src/createArbitrumClient.ts b/packages/sdk-viem/src/createArbitrumClient.ts index a4551a7ca..437e475c0 100644 --- a/packages/sdk-viem/src/createArbitrumClient.ts +++ b/packages/sdk-viem/src/createArbitrumClient.ts @@ -6,6 +6,8 @@ import { http, } from 'viem' import { + ArbitrumChildWalletActions, + arbitrumChildWalletActions, ArbitrumParentWalletActions, arbitrumParentWalletActions, } from './actions' @@ -14,7 +16,7 @@ export type ArbitrumClients = { parentPublicClient: PublicClient childPublicClient: PublicClient parentWalletClient: WalletClient & ArbitrumParentWalletActions - childWalletClient?: WalletClient + childWalletClient?: WalletClient & ArbitrumChildWalletActions } export type CreateArbitrumClientParams = { @@ -48,10 +50,14 @@ export function createArbitrumClient({ arbitrumParentWalletActions(parentPublicClient, childPublicClient) ) + const extendedChildWalletClient = childWalletClient?.extend( + arbitrumChildWalletActions(parentPublicClient, childPublicClient) + ) + return { parentPublicClient, childPublicClient, parentWalletClient: extendedParentWalletClient, - childWalletClient, + childWalletClient: extendedChildWalletClient, } } diff --git a/packages/sdk-viem/tests/helpers.ts b/packages/sdk-viem/tests/helpers.ts new file mode 100644 index 000000000..65e8a5275 --- /dev/null +++ b/packages/sdk-viem/tests/helpers.ts @@ -0,0 +1,65 @@ +import { + ChildToParentMessage, + ChildToParentMessageStatus, + ChildTransactionReceipt, +} from '@arbitrum/sdk' +import { config } from '@arbitrum/sdk/tests/testSetup' +import { + publicClientToProvider, + viemTransactionReceiptToEthersTransactionReceipt, +} from '@offchainlabs/ethers-viem-compat' +import { Wallet } from 'ethers' +import { Hash, PublicClient, TransactionReceipt } from 'viem' + +/** + * Test utility to execute a withdrawal after it's been confirmed. + */ +export async function executeConfirmedWithdrawal( + viemReceipt: TransactionReceipt, + childClient: PublicClient, + parentClient: PublicClient, + confirmations = 1 +): Promise<{ status: boolean; hash: Hash }> { + const childProvider = publicClientToProvider(childClient) + const parentProvider = publicClientToProvider(parentClient) + + const ethersReceipt = + viemTransactionReceiptToEthersTransactionReceipt(viemReceipt) + + const childReceipt = new ChildTransactionReceipt(ethersReceipt) + + const messages = await childReceipt.getChildToParentMessages(parentProvider) + if (messages.length === 0) { + throw new Error('No messages found in receipt') + } + + const message = messages[0] + + // Wait for message to be ready to execute + await message.waitUntilReadyToExecute(childProvider) + + // Check if message has been confirmed + const status = await message.status(childProvider) + if (status !== ChildToParentMessageStatus.CONFIRMED) { + throw new Error('Message not confirmed after waiting') + } + + // Create a writer to execute the message + const parentSigner = new Wallet(`0x${config.ethKey}`, parentProvider) + const events = childReceipt.getChildToParentEvents() + const messageWriter = ChildToParentMessage.fromEvent(parentSigner, events[0]) + + // Execute the message + const execTx = await messageWriter.execute(childProvider) + const execHash = execTx.hash + + const execReceipt = await parentClient.waitForTransactionReceipt({ + hash: execHash as `0x${string}`, + confirmations, + }) + + return { + status: Boolean(execReceipt.status), + hash: execHash as Hash, + } +} diff --git a/packages/sdk-viem/tests/testSetup.ts b/packages/sdk-viem/tests/testSetup.ts index ed5811547..5f7a64d23 100644 --- a/packages/sdk-viem/tests/testSetup.ts +++ b/packages/sdk-viem/tests/testSetup.ts @@ -13,11 +13,13 @@ export type ViemTestSetup = { localEthChain: Chain localArbChain: Chain parentAccount: ReturnType - childPublicClient: ArbitrumClients['childPublicClient'] + parentPublicClient: ArbitrumClients['parentPublicClient'] parentWalletClient: ArbitrumClients['parentWalletClient'] + childPublicClient: ArbitrumClients['childPublicClient'] + childWalletClient: ArbitrumClients['childWalletClient'] childChain: Awaited>['childChain'] parentSigner: Awaited>['parentSigner'] -} +} & Awaited> function generateViemChain( networkData: { @@ -76,7 +78,12 @@ export async function testSetup(): Promise { transport: http(config.arbUrl), }) - const { childPublicClient, parentWalletClient } = createArbitrumClient({ + const { + childPublicClient, + childWalletClient, + parentWalletClient, + parentPublicClient, + } = createArbitrumClient({ parentChain: localEthChain, childChain: localArbChain, parentWalletClient: baseParentWalletClient, @@ -89,7 +96,9 @@ export async function testSetup(): Promise { localArbChain, parentAccount, childPublicClient, + childWalletClient, parentWalletClient, + parentPublicClient, } } diff --git a/packages/sdk-viem/tests/withdraw.test.ts b/packages/sdk-viem/tests/withdraw.test.ts new file mode 100644 index 000000000..b476eef63 --- /dev/null +++ b/packages/sdk-viem/tests/withdraw.test.ts @@ -0,0 +1,134 @@ +import { + approveCustomFeeTokenWithViem, + approveParentCustomFeeToken, + fundParentCustomFeeToken, + getAmountInEnvironmentDecimals, + isArbitrumNetworkWithCustomFeeToken, + normalizeBalanceDiffByDecimals, +} from '@arbitrum/sdk/tests/integration/custom-fee-token/customFeeTokenTestHelpers' +import { + fundChildSigner, + fundParentSigner, +} from '@arbitrum/sdk/tests/integration/testHelpers' +import { expect } from 'chai' +import { parseEther } from 'viem' +import { executeConfirmedWithdrawal } from './helpers' +import { testSetup } from './testSetup' + +describe('withdraw', function () { + this.timeout(300000) + + let setup: Awaited> + + before(async function () { + setup = await testSetup() + }) + + beforeEach(async function () { + await fundParentSigner(setup.parentSigner) + await fundChildSigner(setup.childSigner) + + if (isArbitrumNetworkWithCustomFeeToken()) { + await fundParentCustomFeeToken(setup.parentAccount.address) + await approveParentCustomFeeToken(setup.parentSigner) + } + }) + + it('withdraws ETH from child to parent using withdraw action', async function () { + const { + parentAccount, + childPublicClient, + childWalletClient, + parentWalletClient, + parentPublicClient, + localEthChain, + } = setup + + const [withdrawAmount, tokenDecimals] = + await getAmountInEnvironmentDecimals('0.01') + + const initialParentBalance = await parentPublicClient.getBalance({ + address: parentAccount.address as `0x${string}`, + }) + + const initialChildBalance = await childPublicClient.getBalance({ + address: parentAccount.address as `0x${string}`, + }) + + if (isArbitrumNetworkWithCustomFeeToken()) { + await approveCustomFeeTokenWithViem({ + parentAccount, + parentWalletClient, + chain: localEthChain, + }) + } + + // Start withdrawal + const result = await childWalletClient!.withdrawEth({ + amount: withdrawAmount, + destinationAddress: parentAccount.address, + account: parentAccount, + }) + + expect(result.status).to.equal('success') + expect(result.complete).to.equal(false) + + const receipt = await childPublicClient.waitForTransactionReceipt({ + hash: result.hash, + }) + + const { status } = await executeConfirmedWithdrawal( + receipt, + childPublicClient, + parentPublicClient + ) + + expect(status).to.be.true + + const finalParentBalance = await parentPublicClient.getBalance({ + address: parentAccount.address as `0x${string}`, + }) + + const finalChildBalance = await childPublicClient.getBalance({ + address: parentAccount.address as `0x${string}`, + }) + + // Check that balance decreased on child chain + const childBalanceDiff = finalChildBalance - initialChildBalance + const normalizedChildBalanceDiff = normalizeBalanceDiffByDecimals( + BigInt(childBalanceDiff), + tokenDecimals + ) + expect(normalizedChildBalanceDiff < BigInt(0)).to.be.true + + const parentBalanceDiff = finalParentBalance - initialParentBalance + const normalizedParentBalanceDiff = normalizeBalanceDiffByDecimals( + BigInt(parentBalanceDiff), + tokenDecimals + ) + + if (isArbitrumNetworkWithCustomFeeToken()) { + const maxExpectedDecrease = -withdrawAmount * BigInt(2) + expect(normalizedParentBalanceDiff >= maxExpectedDecrease).to.be.true + } else { + expect(normalizedParentBalanceDiff >= withdrawAmount).to.be.true + } + }) + + it('handles withdrawal failure gracefully', async function () { + const { parentAccount, childWalletClient } = setup + + const withdrawAmount = parseEther('999999999') + + try { + await childWalletClient!.withdrawEth({ + amount: withdrawAmount, + destinationAddress: parentAccount.address, + account: parentAccount, + }) + expect.fail('Should have thrown an error') + } catch (error) { + expect(error).to.exist + } + }) +})