diff --git a/.changeset/tempo-zone-sdk-helpers.md b/.changeset/tempo-zone-sdk-helpers.md new file mode 100644 index 0000000000..b01cbbeef6 --- /dev/null +++ b/.changeset/tempo-zone-sdk-helpers.md @@ -0,0 +1,9 @@ +--- +'viem': patch +--- + +`viem/tempo`: Added Zone helpers for preparing encrypted deposit recipients, waiting for processed deposits, and preparing withdrawals without broadcasting. + +`viem/tempo`: Deposit helpers now accept an explicit `portalAddress` for zones outside viem's built-in registry. Prepared encrypted deposits now honor their `portalAddress` when building calls (previously the portal was always re-derived from the registry). + +`viem/tempo`: Zone withdrawal helpers now use `callbackGas` for the callback gas limit, leaving `gas` for transaction gas overrides. `zone.getWithdrawalFee` keeps a deprecated `gas` alias. diff --git a/site/pages/tempo/actions/index.mdx b/site/pages/tempo/actions/index.mdx index 6419478e06..e88a88a750 100644 --- a/site/pages/tempo/actions/index.mdx +++ b/site/pages/tempo/actions/index.mdx @@ -135,8 +135,10 @@ | [`zone.encryptedDeposit`](/tempo/actions/zone.encryptedDeposit) | Deposits tokens into a zone with encrypted recipient and memo | | [`zone.getAuthorizationTokenInfo`](/tempo/actions/zone.getAuthorizationTokenInfo) | Gets the authenticated account and expiry from the current authorization token | | [`zone.getDepositStatus`](/tempo/actions/zone.getDepositStatus) | Gets deposit processing status for a given Tempo block number | -| [`zone.getWithdrawalFee`](/tempo/actions/zone.getWithdrawalFee) | Gets the withdrawal fee for a given gas limit | +| [`zone.getWithdrawalFee`](/tempo/actions/zone.getWithdrawalFee) | Gets the withdrawal fee for a given callback gas limit | | [`zone.getZoneInfo`](/tempo/actions/zone.getZoneInfo) | Gets metadata about the current zone | +| [`zone.prepareWithdrawal`](/tempo/actions/zone.prepareWithdrawal) | Prepares withdrawal calls and fee details without broadcasting | | [`zone.requestVerifiableWithdrawal`](/tempo/actions/zone.requestVerifiableWithdrawal) | Requests a verifiable withdrawal from a zone to the parent Tempo chain | | [`zone.requestWithdrawal`](/tempo/actions/zone.requestWithdrawal) | Requests a withdrawal from a zone to the parent Tempo chain | | [`zone.signAuthorizationToken`](/tempo/actions/zone.signAuthorizationToken) | Signs a zone authorization token for authenticating zone RPC requests | +| [`zone.waitForDepositProcessed`](/tempo/actions/zone.waitForDepositProcessed) | Waits for a Tempo block's deposits to be processed by the zone | diff --git a/site/pages/tempo/actions/zone.deposit.mdx b/site/pages/tempo/actions/zone.deposit.mdx index 81de500211..b61505ee16 100644 --- a/site/pages/tempo/actions/zone.deposit.mdx +++ b/site/pages/tempo/actions/zone.deposit.mdx @@ -77,6 +77,14 @@ Token address or ID to deposit. Zone ID to deposit into (e.g. `7`). +### portalAddress (optional) + +- **Type:** `Address` +- **Default:** viem's zone registry + +Zone portal address on the parent Tempo chain. Pass this for hosted or custom zones that are not in +viem's built-in registry. + ### memo (optional) - **Type:** `Hex` diff --git a/site/pages/tempo/actions/zone.encryptedDeposit.mdx b/site/pages/tempo/actions/zone.encryptedDeposit.mdx index 34faf3aa34..a6d97b743c 100644 --- a/site/pages/tempo/actions/zone.encryptedDeposit.mdx +++ b/site/pages/tempo/actions/zone.encryptedDeposit.mdx @@ -80,6 +80,21 @@ export const broadcaster = client ::: +## Recipient-Only Usage + +`zone.encryptedDeposit.prepare` returns a complete deposit intent with `token`, `amount`, and the +encrypted recipient instructions. Use `zone.encryptedDeposit.prepareRecipient` when you only need the +recipient-encryption piece because another contract or service controls the token movement. + +```ts twoslash +import { client } from './viem.config' + +const preparedRecipient = await client.zone.encryptedDeposit.prepareRecipient({ + recipient: '0x0000000000000000000000000000000000000001', + zoneId: 7, +}) +``` + ## Return Type ```ts @@ -109,6 +124,14 @@ Token address or ID to deposit. Zone ID to deposit into (e.g. `7`). +### portalAddress (optional) + +- **Type:** `Address` +- **Default:** viem's zone registry + +Zone portal address on the parent Tempo chain. Pass this for hosted or custom zones that are not in +viem's built-in registry. + ### memo (optional) - **Type:** `Hex` diff --git a/site/pages/tempo/actions/zone.getWithdrawalFee.mdx b/site/pages/tempo/actions/zone.getWithdrawalFee.mdx index 2894623a5a..232829cca4 100644 --- a/site/pages/tempo/actions/zone.getWithdrawalFee.mdx +++ b/site/pages/tempo/actions/zone.getWithdrawalFee.mdx @@ -23,14 +23,14 @@ console.log('Fee:', fee) ::: -### Custom Gas Limit +### Custom Callback Gas Limit ```ts twoslash // @twoslash-cache: {"v":2,"hash":"400184268ea2cd3a6cf88917d013ceefa81ebc1cd0cdb11c0add9fe278d3be2b","data":"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="} import { client } from './zones.config' const fee = await client.zone.getWithdrawalFee({ - gas: 100_000n, + callbackGas: 100_000n, }) ``` @@ -44,7 +44,7 @@ The withdrawal fee as a bigint. ## Parameters -### gas (optional) +### callbackGas (optional) - **Type:** `bigint` - **Default:** `0n` diff --git a/site/pages/tempo/actions/zone.prepareWithdrawal.mdx b/site/pages/tempo/actions/zone.prepareWithdrawal.mdx new file mode 100644 index 0000000000..04b8183a04 --- /dev/null +++ b/site/pages/tempo/actions/zone.prepareWithdrawal.mdx @@ -0,0 +1,73 @@ +# `zone.prepareWithdrawal` + +Prepares a zone withdrawal without broadcasting it. + +Use this to inspect the ZoneOutbox calls and fee details before submitting a withdrawal. The helper +returns the same approve/request calls used by +[`zone.requestWithdrawal`](/tempo/actions/zone.requestWithdrawal), the protocol withdrawal fee, and +optionally an estimated zone transaction fee. + +## Usage + +:::code-group + +```ts twoslash [example.ts] +import { client } from './zones.config' + +const prepared = await client.zone.prepareWithdrawal({ + token: '0x20c0000000000000000000000000000000000001', + amount: 1_000_000n, + to: '0x0000000000000000000000000000000000000001', + estimateTransactionFee: true, + transactionFeeScale: 1_000_000_000_000n, +}) + +console.log('Total fee:', prepared.totalFee) +``` + +```ts twoslash [zones.config.ts] filename="zones.config.ts" +// [!include ~/snippets/tempo/zones.config.ts:setup] +``` + +::: + +## Return Type + +```ts +type ReturnType = { + amount: bigint + callbackGas: bigint + calls: unknown[] + data: Hex + estimatedGas?: bigint + fallbackRecipient: Address + gasPrice?: bigint + memo: Hex + to: Address + token: TokenIdOrAddress + totalFee: bigint + transactionFee?: bigint + withdrawalFee: bigint +} +``` + +## Parameters + +Accepts the same withdrawal parameters as [`zone.requestWithdrawal`](/tempo/actions/zone.requestWithdrawal), +plus the options below. + +### estimateTransactionFee (optional) + +- **Type:** `boolean` +- **Default:** `false` + +Whether to estimate the zone transaction gas fee and include it in `transactionFee` and `totalFee`. +Requires `transactionFeeScale`. + +### transactionFeeScale + +- **Type:** `bigint` + +Divisor used to convert `estimatedGas * gasPrice` (18-decimal units) into the fee token's base +units: `10 ** (18 - decimals)`. For a 6-decimal fee token, pass `1_000_000_000_000n`. Required when +`estimateTransactionFee` is `true`; omit it otherwise. diff --git a/site/pages/tempo/actions/zone.requestVerifiableWithdrawal.mdx b/site/pages/tempo/actions/zone.requestVerifiableWithdrawal.mdx index 917c3eafb6..d6d30d13d9 100644 --- a/site/pages/tempo/actions/zone.requestVerifiableWithdrawal.mdx +++ b/site/pages/tempo/actions/zone.requestVerifiableWithdrawal.mdx @@ -108,7 +108,7 @@ Optional callback data for the recipient. Fallback address if callback fails. -### gas (optional) +### callbackGas (optional) - **Type:** `bigint` - **Default:** `0n` diff --git a/site/pages/tempo/actions/zone.requestWithdrawal.mdx b/site/pages/tempo/actions/zone.requestWithdrawal.mdx index 2d0308ac91..95f7983705 100644 --- a/site/pages/tempo/actions/zone.requestWithdrawal.mdx +++ b/site/pages/tempo/actions/zone.requestWithdrawal.mdx @@ -90,7 +90,7 @@ Optional callback data for the recipient. Fallback address if callback fails. -### gas (optional) +### callbackGas (optional) - **Type:** `bigint` - **Default:** `0n` diff --git a/site/pages/tempo/actions/zone.waitForDepositProcessed.mdx b/site/pages/tempo/actions/zone.waitForDepositProcessed.mdx new file mode 100644 index 0000000000..f218d8a72b --- /dev/null +++ b/site/pages/tempo/actions/zone.waitForDepositProcessed.mdx @@ -0,0 +1,59 @@ +# `zone.waitForDepositProcessed` + +Waits until deposits for a given Tempo block number have been processed by the zone. + +Use this after a parent-chain deposit transaction has been included and you want to wait for the +corresponding zone balance to be available. + +## Usage + +:::code-group + +```ts twoslash [example.ts] +import { client } from './zones.config' + +const status = await client.zone.waitForDepositProcessed({ + tempoBlockNumber: 123456n, +}) + +console.log('Processed:', status.processed) +``` + +```ts twoslash [zones.config.ts] filename="zones.config.ts" +// [!include ~/snippets/tempo/zones.config.ts:setup] +``` + +::: + +## Return Type + +```ts +type ReturnType = { + deposits: Deposit[] + processed: boolean + tempoBlockNumber: bigint + zoneProcessedThrough: bigint +} +``` + +## Parameters + +### tempoBlockNumber + +- **Type:** `bigint` + +The Tempo L1 block number to wait for. + +### pollingInterval (optional) + +- **Type:** `number` +- **Default:** `client.pollingInterval` + +Polling frequency, in milliseconds. + +### timeout (optional) + +- **Type:** `number` +- **Default:** `60_000` + +Maximum time to wait, in milliseconds. Set to `0` to disable the timeout. diff --git a/site/pages/tempo/guides/zones/deposit.mdx b/site/pages/tempo/guides/zones/deposit.mdx index c9d310ff8d..18c68f922c 100644 --- a/site/pages/tempo/guides/zones/deposit.mdx +++ b/site/pages/tempo/guides/zones/deposit.mdx @@ -78,11 +78,11 @@ const { receipt } = await client.zone.encryptedDepositSync({ ::: -### Check Deposit Status +### Wait for Zone Processing -After depositing, confirm the funds have landed inside the zone with -[`zone.getDepositStatus`](/tempo/actions/zone.getDepositStatus), querying by the Mainnet block number -of your deposit. This call runs against the zone client. +After depositing, wait for the funds to land inside the zone with +[`zone.waitForDepositProcessed`](/tempo/actions/zone.waitForDepositProcessed), querying by the Mainnet +block number of your deposit. This call runs against the zone client. :::code-group @@ -90,7 +90,7 @@ of your deposit. This call runs against the zone client. // @twoslash-cache: {"v":2,"hash":"1291cbf697bb60877644df577f40983e152248fa587857cb3771f0b2aa87db0f","data":"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"} import { client } from './zones.config' -const status = await client.zone.getDepositStatus({ +const status = await client.zone.waitForDepositProcessed({ tempoBlockNumber: 123456n, }) ``` @@ -125,8 +125,16 @@ Validate the public transaction details before broadcasting a prepared payload f ### Track the Deposit Block Number -`zone.getDepositStatus` is keyed by the Mainnet block number of the deposit. Capture it from the -deposit receipt so you can reliably poll for processing. +`zone.waitForDepositProcessed` is keyed by the Mainnet block number of the deposit. Capture it from +the deposit receipt so you can reliably wait for processing. + +### Use a Custom Portal for Hosted Zones + +If your zone's portal is not in viem's built-in registry, pass `portalAddress` to +[`zone.deposit`](/tempo/actions/zone.deposit), [`zone.encryptedDeposit`](/tempo/actions/zone.encryptedDeposit), +`zone.encryptedDeposit.prepare`, or `zone.encryptedDeposit.prepareRecipient`. +This keeps hosted-zone integrations from needing to fork the Zone helpers while the registry catches +up. ## See More diff --git a/site/vocs.config.ts b/site/vocs.config.ts index e40f29d42f..b10da28f54 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -3047,6 +3047,10 @@ export default defineConfig({ text: 'getZoneInfo', link: '/tempo/actions/zone.getZoneInfo', }, + { + text: 'prepareWithdrawal', + link: '/tempo/actions/zone.prepareWithdrawal', + }, { text: 'requestVerifiableWithdrawal', link: '/tempo/actions/zone.requestVerifiableWithdrawal', @@ -3059,6 +3063,10 @@ export default defineConfig({ text: 'signAuthorizationToken', link: '/tempo/actions/zone.signAuthorizationToken', }, + { + text: 'waitForDepositProcessed', + link: '/tempo/actions/zone.waitForDepositProcessed', + }, ], }, ], diff --git a/src/chains/index.ts b/src/chains/index.ts index ff920955f9..b5fdf12234 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -152,9 +152,9 @@ export { datahavenTestnet } from './definitions/datahavenTestnet.js' export { dbkchain } from './definitions/dbkchain.js' export { dchain } from './definitions/dchain.js' export { dchainTestnet } from './definitions/dchainTestnet.js' -export { defiOracleMetaMainnet } from './definitions/defiOracleMetaMainnet.js' export { defichainEvm } from './definitions/defichainEvm.js' export { defichainEvmTestnet } from './definitions/defichainEvmTestnet.js' +export { defiOracleMetaMainnet } from './definitions/defiOracleMetaMainnet.js' export { degen } from './definitions/degen.js' export { dfk } from './definitions/dfk.js' export { diode } from './definitions/diode.js' diff --git a/src/clients/createClient.ts b/src/clients/createClient.ts index 14b62e225a..f6e4af92df 100644 --- a/src/clients/createClient.ts +++ b/src/clients/createClient.ts @@ -350,8 +350,9 @@ function isPlainObject(value: unknown): value is Record { * Binds an action function to a `client`, returning a parameter-only version * along with any helpers the action exposes. Helpers that need a client * (`.call`, `.calls`, `.callWithPeriod`, `.estimateGas`, `.prepare`, - * `.simulate`) are bound to `client`; pure helpers (`.extractEvent`, - * `.extractEvents`) are copied as-is. Used by decorators that attach + * `.prepareRecipient`, `.simulate`) are bound to `client`; pure helpers + * (`.extractEvent`, `.extractEvents`) are copied as-is. Used by decorators + * that attach * namespaced actions to a Client. * @internal */ @@ -366,6 +367,7 @@ export function bindActionDecorators( 'callWithPeriod', 'estimateGas', 'prepare', + 'prepareRecipient', 'simulate', ] as const) if (Object.hasOwn(action, key)) { diff --git a/src/tempo/Decorator.test.ts b/src/tempo/Decorator.test.ts index e736c2fcfe..ea8f1c47ee 100644 --- a/src/tempo/Decorator.test.ts +++ b/src/tempo/Decorator.test.ts @@ -56,6 +56,11 @@ describe('decorator', () => { expect(typeof client2.token.transfer.estimateGas).toBe('function') expect(typeof client2.token.transfer.simulate).toBe('function') expect(typeof client2.zone.encryptedDeposit.prepare).toBe('function') + expect(typeof client2.zone.encryptedDeposit.prepareRecipient).toBe( + 'function', + ) + expect(typeof client2.zone.prepareWithdrawal).toBe('function') + expect(typeof client2.zone.waitForDepositProcessed).toBe('function') }) test('binds missing action entries', () => { diff --git a/src/tempo/Decorator.ts b/src/tempo/Decorator.ts index 4f24c22280..002eb25d91 100644 --- a/src/tempo/Decorator.ts +++ b/src/tempo/Decorator.ts @@ -4936,7 +4936,7 @@ type DecoratorBase< parameters: zoneActions.getDepositStatus.Parameters, ) => Promise /** - * Returns the withdrawal fee for a given gas limit. + * Returns the withdrawal fee for a given callback gas limit. * * @example * ```ts @@ -4978,6 +4978,24 @@ type DecoratorBase< * @returns The zone metadata. */ getZoneInfo: () => Promise + /** + * Prepares a zone withdrawal without broadcasting it. + * + * @example + * ```ts + * const prepared = await client.zone.prepareWithdrawal({ + * token: '0x20c0...0001', + * amount: 1_000_000n, + * to: '0x...', + * }) + * ``` + * + * @param parameters - Parameters. + * @returns Prepared calls and fee details. + */ + prepareWithdrawal: ( + parameters: zoneActions.prepareWithdrawal.Parameters, + ) => Promise /** * Requests a withdrawal from a zone to the parent Tempo chain. * Batches approve and withdrawal into a single transaction. @@ -5102,6 +5120,22 @@ type DecoratorBase< signAuthorizationToken: ( parameters?: zoneActions.signAuthorizationToken.Parameters, ) => Promise + /** + * Waits until deposits for a given Tempo block number have been processed. + * + * @example + * ```ts + * const status = await client.zone.waitForDepositProcessed({ + * tempoBlockNumber: 1n, + * }) + * ``` + * + * @param parameters - Parameters. + * @returns The deposit status. + */ + waitForDepositProcessed: ( + parameters: zoneActions.waitForDepositProcessed.Parameters, + ) => Promise } } @@ -5132,6 +5166,9 @@ type BoundActionHelpers = (action extends { call: infer helper } (action extends { prepare: infer helper } ? { prepare: BoundHelper } : {}) & + (action extends { prepareRecipient: infer helper } + ? { prepareRecipient: BoundHelper } + : {}) & (action extends { simulate: infer helper } ? { simulate: BoundHelper } : {}) & @@ -5472,11 +5509,13 @@ export function decorator() { 'getDepositStatus', 'getWithdrawalFee', 'getZoneInfo', + 'prepareWithdrawal', 'requestWithdrawal', 'requestWithdrawalSync', 'requestVerifiableWithdrawal', 'requestVerifiableWithdrawalSync', 'signAuthorizationToken', + 'waitForDepositProcessed', ]), } as Decorator } diff --git a/src/tempo/actions/zone.test-d.ts b/src/tempo/actions/zone.test-d.ts index a670d7eedb..bc6c868ecd 100644 --- a/src/tempo/actions/zone.test-d.ts +++ b/src/tempo/actions/zone.test-d.ts @@ -18,8 +18,9 @@ test('encryptedDeposit.prepare returns a reusable encrypted deposit payload', as const prepared = await zoneActions.encryptedDeposit.prepare(client, { token: '0x20c0000000000000000000000000000000000000', amount: 1n, + portalAddress: '0x0000000000000000000000000000000000000002', recipient: '0x0000000000000000000000000000000000000001', - zoneId: 7, + zoneId: 71, }) expectTypeOf(prepared).toEqualTypeOf() @@ -27,6 +28,11 @@ test('encryptedDeposit.prepare returns a reusable encrypted deposit payload', as zoneActions.encryptedDeposit.calls(prepared) await zoneActions.encryptedDeposit(client, prepared) await zoneActions.encryptedDepositSync(client, prepared) + await zoneActions.encryptedDepositSync(client, { + ...prepared, + pollingInterval: 100, + timeout: 1_000, + }) }) test('encryptedDeposit still accepts plaintext parameters', async () => { @@ -35,4 +41,104 @@ test('encryptedDeposit still accepts plaintext parameters', async () => { amount: 1n, zoneId: 7, }) + + await zoneActions.encryptedDeposit(client, { + token: '0x20c0000000000000000000000000000000000000', + amount: 1n, + zoneId: 7, + // @ts-expect-error wait options only apply to encryptedDepositSync. + timeout: 1_000, + }) +}) + +test('encryptedDeposit.prepareRecipient returns reusable encrypted recipient data', async () => { + const preparedRecipient = await zoneActions.encryptedDeposit.prepareRecipient( + client, + { + portalAddress: '0x0000000000000000000000000000000000000002', + recipient: '0x0000000000000000000000000000000000000001', + zoneId: 71, + }, + ) + + expectTypeOf( + preparedRecipient, + ).toEqualTypeOf() + + // @ts-expect-error plaintext recipient is encrypted and not returned. + preparedRecipient.recipient + + // @ts-expect-error plaintext memo is encrypted and not returned. + preparedRecipient.memo +}) + +test('waitForDepositProcessed returns deposit status', async () => { + const status = await zoneActions.waitForDepositProcessed(client, { + tempoBlockNumber: 1n, + }) + + expectTypeOf(status).toEqualTypeOf() +}) + +test('getWithdrawalFee accepts legacy gas alias', async () => { + const fee = await zoneActions.getWithdrawalFee(client, { + gas: 1n, + }) + + expectTypeOf(fee).toEqualTypeOf() +}) + +test('prepareWithdrawal returns calls and fee details', async () => { + const prepared = await zoneActions.prepareWithdrawal(client, { + token: '0x20c0000000000000000000000000000000000000', + amount: 1n, + to: '0x0000000000000000000000000000000000000001', + estimateTransactionFee: true, + transactionFeeScale: 1_000_000_000_000n, + }) + + const accountlessClient = createClient({ + chain: tempoModerato, + transport: custom({ + async request() { + return null + }, + }), + }) + + // `account` is only required for gas estimation or the `to` fallback. + await zoneActions.prepareWithdrawal(accountlessClient, { + token: '0x20c0000000000000000000000000000000000000', + amount: 1n, + to: '0x0000000000000000000000000000000000000001', + }) + + // @ts-expect-error `transactionFeeScale` is required when estimating. + await zoneActions.prepareWithdrawal(client, { + token: '0x20c0000000000000000000000000000000000000', + amount: 1n, + to: '0x0000000000000000000000000000000000000001', + estimateTransactionFee: true, + }) + + // @ts-expect-error `transactionFeeScale` only applies when estimating. + await zoneActions.prepareWithdrawal(client, { + token: '0x20c0000000000000000000000000000000000000', + amount: 1n, + to: '0x0000000000000000000000000000000000000001', + transactionFeeScale: 1_000_000_000_000n, + }) + + expectTypeOf( + prepared, + ).toEqualTypeOf() + zoneActions.requestWithdrawal.calls({ + amount: prepared.amount, + callbackGas: prepared.callbackGas, + data: prepared.data, + fallbackRecipient: prepared.fallbackRecipient, + memo: prepared.memo, + to: prepared.to, + token: prepared.token, + }) }) diff --git a/src/tempo/actions/zone.test.ts b/src/tempo/actions/zone.test.ts index 891b4cd103..867675b6c0 100644 --- a/src/tempo/actions/zone.test.ts +++ b/src/tempo/actions/zone.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test } from 'vitest' import { accounts } from '~test/constants.js' import { http } from '~test/tempo/config.js' import { getClient as getZoneClient } from '~test/tempo/zones.js' +import { custom } from '../../clients/transports/custom.js' import * as Storage from '../Storage.js' import * as zoneActions from './zone.js' @@ -110,6 +111,69 @@ describe('getDepositStatus', () => { }) }) +describe('waitForDepositProcessed', () => { + test('behavior: polls until the deposit is processed', async () => { + let calls = 0 + const client = createClient({ + chain: tempoModerato, + pollingInterval: 1, + transport: custom({ + async request({ method }) { + if (method !== 'zone_getDepositStatus') + throw new Error(`Unsupported method: ${method}`) + + calls += 1 + const processed = calls === 2 + return { + deposits: [], + processed, + tempoBlockNumber: '0x7b', + zoneProcessedThrough: processed ? '0x7b' : '0x0', + } + }, + }), + }) + + const status = await zoneActions.waitForDepositProcessed(client, { + tempoBlockNumber: 123n, + pollingInterval: 1, + timeout: 1_000, + }) + + expect(calls).toBe(2) + expect(status.processed).toBe(true) + expect(status.tempoBlockNumber).toBe(123n) + }) + + test('error: timeout', async () => { + const client = createClient({ + chain: tempoModerato, + pollingInterval: 1, + transport: custom({ + async request({ method }) { + if (method !== 'zone_getDepositStatus') + throw new Error(`Unsupported method: ${method}`) + + return { + deposits: [], + processed: false, + tempoBlockNumber: '0x7b', + zoneProcessedThrough: '0x0', + } + }, + }), + }) + + await expect( + zoneActions.waitForDepositProcessed(client, { + tempoBlockNumber: 123n, + pollingInterval: 1, + timeout: 1, + }), + ).rejects.toThrow('Timed out while waiting for deposits') + }) +}) + describe('getWithdrawalFee', () => { test('behavior: returns withdrawal fee', async () => { await zoneActions.signAuthorizationToken(zoneClient) @@ -120,16 +184,138 @@ describe('getWithdrawalFee', () => { expect(fee).toBeGreaterThanOrEqual(0n) }) - test('behavior: accepts custom gas limit', async () => { + test('behavior: accepts custom callback gas limit', async () => { await zoneActions.signAuthorizationToken(zoneClient) const fee = await zoneActions.getWithdrawalFee(zoneClient, { - gas: 100_000n, + callbackGas: 100_000n, }) expect(typeof fee).toBe('bigint') expect(fee).toBeGreaterThanOrEqual(0n) }) + + test('behavior: accepts legacy gas alias', async () => { + let data: string | undefined + const client = createClient({ + chain: tempoModerato, + transport: custom({ + async request({ method, params }) { + if (method === 'eth_call') { + data = (params as [{ data?: string }])[0]?.data + return `0x${7n.toString(16).padStart(64, '0')}` + } + throw new Error(`Unsupported method: ${method}`) + }, + }), + }) + + const fee = await zoneActions.getWithdrawalFee(client, { + gas: 100_000n, + }) + + expect(fee).toBe(7n) + expect(data).toMatch( + new RegExp(`${100_000n.toString(16).padStart(64, '0')}$`), + ) + }) +}) + +describe('prepareWithdrawal', () => { + test('behavior: prepares calls and fee details', async () => { + let estimateRequest: Record | undefined + const token = '0x20c0000000000000000000000000000000000000' + const client = createClient({ + account, + chain: tempoModerato, + transport: custom({ + async request({ method, params }) { + if (method === 'eth_call') + return `0x${7n.toString(16).padStart(64, '0')}` + if (method === 'eth_gasPrice') return '0x5' + if (method === 'eth_estimateGas') { + estimateRequest = params[0] + return '0xa' + } + throw new Error(`Unsupported method: ${method}`) + }, + }), + }) + + const prepared = await zoneActions.prepareWithdrawal(client, { + token, + amount: 10n, + callbackGas: 11n, + to: account.address, + estimateTransactionFee: true, + transactionFeeScale: 10n, + }) + + expect(prepared.calls).toHaveLength(2) + expect(prepared.calls[1].functionName).toBe('requestWithdrawal') + expect(prepared.calls[1].args[4]).toBe(11n) + expect(prepared.withdrawalFee).toBe(7n) + expect(prepared.callbackGas).toBe(11n) + expect(prepared.estimatedGas).toBe(10n) + expect(prepared.gasPrice).toBe(5n) + expect(prepared.transactionFee).toBe(5n) + expect(prepared.totalFee).toBe(12n) + expect(estimateRequest?.calls).toBeDefined() + }) + + test('behavior: skips gas estimation by default', async () => { + const requests: string[] = [] + const client = createClient({ + account, + chain: tempoModerato, + transport: custom({ + async request({ method }) { + requests.push(method) + if (method === 'eth_call') + return `0x${7n.toString(16).padStart(64, '0')}` + throw new Error(`Unsupported method: ${method}`) + }, + }), + }) + + const prepared = await zoneActions.prepareWithdrawal(client, { + token: '0x20c0000000000000000000000000000000000000', + amount: 10n, + to: account.address, + }) + + expect(requests).toEqual(['eth_call']) + expect(prepared.estimatedGas).toBeUndefined() + expect(prepared.gasPrice).toBeUndefined() + expect(prepared.transactionFee).toBeUndefined() + expect(prepared.totalFee).toBe(prepared.withdrawalFee) + }) + + test('error: validates transaction fee estimation parameters before reading withdrawal fee', async () => { + const requests: string[] = [] + const client = createClient({ + account, + chain: tempoModerato, + transport: custom({ + async request({ method }) { + requests.push(method) + throw new Error(`Unsupported method: ${method}`) + }, + }), + }) + + await expect( + zoneActions.prepareWithdrawal(client, { + token: '0x20c0000000000000000000000000000000000000', + amount: 10n, + to: account.address, + estimateTransactionFee: true, + } as any), + ).rejects.toThrow( + '`transactionFeeScale` is required when `estimateTransactionFee` is `true`.', + ) + expect(requests).toEqual([]) + }) }) describe('encryptedDeposit', () => { @@ -163,6 +349,7 @@ describe('encryptedDeposit', () => { }) test('behavior: prepared encrypted deposit payload', async () => { + const portalAddress = '0x788dCA3B7d32CcF4C29fb20C4D83160cEAA50aDB' const prepared = { amount: parseUnits('1', 6), chainId: tempoModerato.id, @@ -175,9 +362,9 @@ describe('encryptedDeposit', () => { tag: '0x00000000000000000000000000000000', }, keyIndex: 0n, - portalAddress: '0x3F5296303400B56271b476F5A0B9cBF74350D6Ac', + portalAddress, token: '0x20c0000000000000000000000000000000000000', - zoneId: 7, + zoneId: 71, } satisfies zoneActions.PreparedEncryptedDeposit const calls = zoneActions.encryptedDeposit.calls(prepared) @@ -200,6 +387,22 @@ describe('encryptedDeposit', () => { }) describe('deposit', () => { + test('behavior: accepts a custom portal address', () => { + const portalAddress = '0x788dCA3B7d32CcF4C29fb20C4D83160cEAA50aDB' + const calls = zoneActions.deposit.calls({ + amount: parseUnits('1', 6), + chainId: tempoModerato.id, + portalAddress, + recipient: account.address, + token: '0x20c0000000000000000000000000000000000000', + zoneId: 71, + }) + + expect(calls[0].args).toEqual([portalAddress, parseUnits('1', 6)]) + expect(calls[1].address).toBe(portalAddress) + expect(calls[1].functionName).toBe('deposit') + }) + test('behavior: deposits tokens into zone via parent chain', async () => { const result = await zoneActions.depositSync(mainnetClient, { token: '0x20c0000000000000000000000000000000000000', diff --git a/src/tempo/actions/zone.ts b/src/tempo/actions/zone.ts index 00d9544eac..8e420790ed 100644 --- a/src/tempo/actions/zone.ts +++ b/src/tempo/actions/zone.ts @@ -6,6 +6,14 @@ import * as Secp256k1 from 'ox/Secp256k1' import { TokenId, ZoneId, ZoneRpcAuthentication } from 'ox/tempo' import type { Account } from '../../accounts/types.js' import { parseAccount } from '../../accounts/utils/parseAccount.js' +import { + type EstimateGasErrorType, + estimateGas, +} from '../../actions/public/estimateGas.js' +import { + type GetGasPriceErrorType, + getGasPrice, +} from '../../actions/public/getGasPrice.js' import { readContract } from '../../actions/public/readContract.js' import { type SendTransactionReturnType, @@ -15,18 +23,25 @@ import { sendTransactionSync } from '../../actions/wallet/sendTransactionSync.js import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import { zeroHash } from '../../constants/bytes.js' -import type { BaseErrorType } from '../../errors/base.js' +import { BaseError, type BaseErrorType } from '../../errors/base.js' import type { Chain } from '../../types/chain.js' -import type { Compute } from '../../types/utils.js' +import type { Compute, UnionOmit } from '../../types/utils.js' import type { RequestErrorType } from '../../utils/buildRequest.js' +import { withTimeout } from '../../utils/promise/withTimeout.js' +import { wait } from '../../utils/wait.js' import * as Abis from '../Abis.js' import * as Addresses from '../Addresses.js' import type { GetAccountParameter, ReadParameters, WriteParameters, + WriteSyncParameters, } from '../internal/types.js' -import { defineCall, pickWriteParameters } from '../internal/utils.js' +import { + defineCall, + pickWriteParameters, + pickWriteSyncParameters, +} from '../internal/utils.js' import * as Storage from '../Storage.js' import type { TransactionReceipt } from '../Transaction.js' import * as ZoneAbis from '../zones/Abis.js' @@ -57,6 +72,19 @@ export type PreparedEncryptedDeposit = { zoneId: number } +export type PreparedEncryptedDepositRecipient = { + /** Parent chain ID (e.g. `42431` for moderato). */ + chainId: number + /** Encrypted deposit payload. */ + encrypted: EncryptedPayload + /** Encryption key index from the portal contract. */ + keyIndex: bigint + /** Zone portal address on the parent chain. */ + portalAddress: Address + /** Zone ID (e.g. `7`). */ + zoneId: number +} + /** * Deposits tokens into a zone on the parent Tempo chain. * Batches approve and deposit into a single transaction. @@ -95,7 +123,7 @@ export async function deposit< const chainId = client.chain?.id if (!chainId) throw new Error('`chain` is required.') - const { account = client.account, ...rest } = parameters + const { account = client.account } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -105,7 +133,7 @@ export async function deposit< const args = { ...parameters, chainId, recipient } return sendTransaction(client, { - ...rest, + ...pickWriteParameters(parameters as never), calls: deposit.calls(args), } as never) as never } @@ -127,6 +155,8 @@ export namespace deposit { chainId: number /** Optional deposit memo. @default `0x00...00` */ memo?: Hex.Hex | undefined + /** Zone portal address on the parent chain. Defaults to viem's zone registry. */ + portalAddress?: Address | undefined /** Recipient address in the zone. */ recipient: Address /** Token address or ID to deposit. */ @@ -147,8 +177,16 @@ export namespace deposit { * @returns The calls. */ export function calls(args: Args) { - const { amount, chainId, memo = zeroHash, recipient, token, zoneId } = args - const portalAddress = getPortalAddress(chainId, zoneId) + const { + amount, + chainId, + memo = zeroHash, + portalAddress: portalAddress_, + recipient, + token, + zoneId, + } = args + const portalAddress = getPortalAddress(chainId, zoneId, portalAddress_) return [ defineCall({ address: TokenId.toAddress(token), @@ -204,11 +242,7 @@ export async function depositSync< const chainId = client.chain?.id if (!chainId) throw new Error('`chain` is required.') - const { - account = client.account, - throwOnReceiptRevert = true, - ...rest - } = parameters + const { account = client.account, throwOnReceiptRevert = true } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -218,7 +252,8 @@ export async function depositSync< const args = { ...parameters, chainId, recipient } const receipt = await sendTransactionSync(client, { - ...rest, + ...pickWriteParameters(parameters as never), + ...pickWriteSyncParameters(parameters as never), throwOnReceiptRevert, calls: deposit.calls(args), } as never) @@ -229,7 +264,7 @@ export namespace depositSync { export type Parameters< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, - > = deposit.Parameters + > = deposit.Parameters & WriteSyncParameters export type Args = deposit.Args @@ -281,7 +316,7 @@ export async function encryptedDeposit< const chainId = client.chain?.id if (!chainId) throw new Error('`chain` is required.') - const { account = client.account, ...rest } = parameters + const { account = client.account } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -304,12 +339,13 @@ export async function encryptedDeposit< const prepared = await encryptedDeposit.prepare(client, { amount: parameters.amount, memo: parameters.memo, + portalAddress: parameters.portalAddress, recipient, token: parameters.token, zoneId: parameters.zoneId, }) return sendTransaction(client, { - ...rest, + ...pickWriteParameters(parameters as never), calls: encryptedDeposit.calls(prepared), } as never) as never } @@ -338,6 +374,8 @@ export namespace encryptedDeposit { keyIndex: bigint /** Optional deposit memo. @default `0x00...00` */ memo?: Hex.Hex | undefined + /** Zone portal address on the parent chain. Defaults to viem's zone registry. */ + portalAddress?: Address | undefined /** Recipient address in the zone. */ recipient: Address /** Token address or ID to deposit. */ @@ -387,8 +425,93 @@ export namespace encryptedDeposit { const chainId = client.chain?.id if (!chainId) throw new Error('`chain` is required.') - const { amount, memo, recipient, token, zoneId, ...rest } = parameters - const portalAddress = getPortalAddress(chainId, zoneId) + const { amount, token, ...recipientParameters } = parameters + const recipient = await prepareRecipient(client, { + ...recipientParameters, + }) + + return { + amount, + chainId, + encrypted: recipient.encrypted, + keyIndex: recipient.keyIndex, + portalAddress: recipient.portalAddress, + token, + zoneId: recipient.zoneId, + } + } + + export namespace prepare { + export type Parameters = ReadParameters & Args + + export type Args = { + /** Amount of tokens to deposit. */ + amount: bigint + /** Optional deposit memo. @default `0x00...00` */ + memo?: Hex.Hex | undefined + /** Zone portal address on the parent chain. Defaults to viem's zone registry. */ + portalAddress?: Address | undefined + /** Recipient address in the zone. */ + recipient: Address + /** Token address or ID to deposit. */ + token: TokenId.TokenIdOrAddress + /** Zone ID (e.g. `7`). */ + zoneId: number + } + + export type ReturnValue = PreparedEncryptedDeposit + + export type ErrorType = RequestErrorType | BaseErrorType + } + + /** + * Prepares encrypted Zone recipient instructions without constructing a token + * deposit. + * + * This is the recipient-encryption step used by + * `encryptedDeposit.prepare`, without `token` or `amount`. Use it when + * another contract or service already controls the token movement and only + * needs the ZonePortal `keyIndex` and encrypted recipient payload. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempoModerato } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempoModerato, + * transport: http(), + * }) + * + * const recipient = await Actions.zone.encryptedDeposit.prepareRecipient(client, { + * recipient: '0x...', + * zoneId: 7, + * }) + * ``` + * + * @param client - Public client connected to the parent Tempo chain. + * @param parameters - Encrypted recipient preparation parameters. + * @returns Prepared encrypted recipient instructions. + */ + export async function prepareRecipient< + chain extends Chain | undefined, + account extends Account | undefined, + >( + client: Client, + parameters: prepareRecipient.Parameters, + ): Promise { + const chainId = client.chain?.id + if (!chainId) throw new Error('`chain` is required.') + + const { + memo, + portalAddress: portalAddress_, + recipient, + zoneId, + ...rest + } = parameters + const portalAddress = getPortalAddress(chainId, zoneId, portalAddress_) const [publicKey, keyIndex] = await Promise.all([ readContract(client, { @@ -405,9 +528,8 @@ export namespace encryptedDeposit { }), ]) - if (keyIndex === 0n) { + if (keyIndex === 0n) throw new Error('No sequencer encryption key configured.') - } const encrypted = await encryptDepositPayload( { x: publicKey[0], yParity: publicKey[1] }, @@ -418,33 +540,29 @@ export namespace encryptedDeposit { ) return { - amount, chainId, encrypted, keyIndex: keyIndex - 1n, portalAddress, - token, zoneId, } } - export namespace prepare { + export namespace prepareRecipient { export type Parameters = ReadParameters & Args export type Args = { - /** Amount of tokens to deposit. */ - amount: bigint /** Optional deposit memo. @default `0x00...00` */ memo?: Hex.Hex | undefined + /** Zone portal address on the parent chain. Defaults to viem's zone registry. */ + portalAddress?: Address | undefined /** Recipient address in the zone. */ recipient: Address - /** Token address or ID to deposit. */ - token: TokenId.TokenIdOrAddress /** Zone ID (e.g. `7`). */ zoneId: number } - export type ReturnValue = PreparedEncryptedDeposit + export type ReturnValue = PreparedEncryptedDepositRecipient export type ErrorType = RequestErrorType | BaseErrorType } @@ -456,8 +574,16 @@ export namespace encryptedDeposit { * @returns The calls. */ export function calls(args: Args | PreparedEncryptedDeposit) { - const { amount, chainId, encrypted, keyIndex, token, zoneId } = args - const portalAddress = getPortalAddress(chainId, zoneId) + const { + amount, + chainId, + encrypted, + keyIndex, + portalAddress: portalAddress_, + token, + zoneId, + } = args + const portalAddress = getPortalAddress(chainId, zoneId, portalAddress_) return [ defineCall({ address: TokenId.toAddress(token), @@ -524,11 +650,7 @@ export async function encryptedDepositSync< const chainId = client.chain?.id if (!chainId) throw new Error('`chain` is required.') - const { - account = client.account, - throwOnReceiptRevert = true, - ...rest - } = parameters + const { account = client.account, throwOnReceiptRevert = true } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -541,6 +663,7 @@ export async function encryptedDepositSync< } const receipt = await sendTransactionSync(client, { ...pickWriteParameters(parameters as never), + ...pickWriteSyncParameters(parameters as never), throwOnReceiptRevert, calls: encryptedDeposit.calls(parameters), } as never) @@ -553,12 +676,14 @@ export async function encryptedDepositSync< const prepared = await encryptedDeposit.prepare(client, { amount: parameters.amount, memo: parameters.memo, + portalAddress: parameters.portalAddress, recipient, token: parameters.token, zoneId: parameters.zoneId, }) const receipt = await sendTransactionSync(client, { - ...rest, + ...pickWriteParameters(parameters as never), + ...pickWriteSyncParameters(parameters as never), throwOnReceiptRevert, calls: encryptedDeposit.calls(prepared), } as never) @@ -569,7 +694,8 @@ export namespace encryptedDepositSync { export type Parameters< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, - > = encryptedDeposit.Parameters + > = encryptedDeposit.Parameters & + WriteSyncParameters export type Args = encryptedDeposit.Args @@ -742,7 +868,101 @@ export namespace getDepositStatus { } /** - * Returns the fee required for a withdrawal from a zone, given a gas limit. + * Waits until deposits for a given Tempo block number have been processed by + * the zone. + * + * @example + * ```ts + * import { createClient } from 'viem' + * import { http, zoneModerato } from 'viem/tempo/zones' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: zoneModerato(7), + * transport: http(), + * }) + * + * const status = await Actions.zone.waitForDepositProcessed(client, { + * tempoBlockNumber: 42n, + * }) + * ``` + * + * @param client - Zone client. + * @param parameters - Parameters including the Tempo block number. + * @returns Deposit status. + */ +export async function waitForDepositProcessed< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: waitForDepositProcessed.Parameters, +): Promise { + const { + pollingInterval = client.pollingInterval, + timeout = 60_000, + ...statusParameters + } = parameters + + // Deadline check so the polling loop stops shortly after `withTimeout` + // rejects (the loop itself cannot be cancelled mid-request). + const timeoutAt = timeout > 0 ? Date.now() + timeout : undefined + const poll = async () => { + while (true) { + const status = await getDepositStatus(client, statusParameters) + if (status.processed) return status + if (timeoutAt && Date.now() >= timeoutAt) + throw new WaitForDepositProcessedTimeoutError(statusParameters) + await wait(pollingInterval) + } + } + + if (!timeoutAt) return poll() + return withTimeout(poll, { + errorInstance: new WaitForDepositProcessedTimeoutError(statusParameters), + timeout, + }) +} + +export namespace waitForDepositProcessed { + export type Parameters = getDepositStatus.Parameters & { + /** + * Polling frequency (in ms). Defaults to the client's pollingInterval config. + * + * @default client.pollingInterval + */ + pollingInterval?: number | undefined + /** + * Optional timeout (in milliseconds) to wait before stopping polling. + * + * @default 60_000 + */ + timeout?: number | undefined + } + + export type ReturnType = getDepositStatus.ReturnType + + export type ErrorType = + | getDepositStatus.ErrorType + | WaitForDepositProcessedTimeoutErrorType +} + +export type WaitForDepositProcessedTimeoutErrorType = + WaitForDepositProcessedTimeoutError & { + name: 'WaitForDepositProcessedTimeoutError' + } +export class WaitForDepositProcessedTimeoutError extends BaseError { + constructor({ tempoBlockNumber }: { tempoBlockNumber: bigint }) { + super( + `Timed out while waiting for deposits from Tempo block ${tempoBlockNumber} to be processed.`, + { name: 'WaitForDepositProcessedTimeoutError' }, + ) + } +} + +/** + * Returns the fee required for a withdrawal from a zone, given a callback gas + * limit. * * The client must be connected to the **zone chain**. * @@ -761,7 +981,7 @@ export namespace getDepositStatus { * ``` * * @param client - Zone client. - * @param parameters - Optional gas limit parameter. + * @param parameters - Optional callback gas limit parameter. * @returns The withdrawal fee as a bigint. */ export async function getWithdrawalFee< @@ -771,19 +991,26 @@ export async function getWithdrawalFee< client: Client, parameters: getWithdrawalFee.Parameters = {}, ): Promise { - const { gas = 0n, ...rest } = parameters + const { callbackGas, gas, ...rest } = parameters + const callbackGas_ = callbackGas ?? gas ?? 0n return readContract(client, { ...rest, address: Addresses.zoneOutbox, abi: ZoneAbis.zoneOutbox, functionName: 'calculateWithdrawalFee', - args: [gas], + args: [callbackGas_], }) } export namespace getWithdrawalFee { export type Parameters = ReadParameters & { - /** Gas limit for the withdrawal callback. @default `0n` */ + /** Gas limit reserved for the withdrawal callback on the parent chain. @default `0n` */ + callbackGas?: bigint | undefined + /** + * Gas limit reserved for the withdrawal callback on the parent chain. + * + * @deprecated Use `callbackGas`. + */ gas?: bigint | undefined } @@ -887,7 +1114,7 @@ export async function requestWithdrawal< client: Client, parameters: requestWithdrawal.Parameters, ): Promise { - const { account = client.account, ...rest } = parameters + const { account = client.account } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -897,7 +1124,7 @@ export async function requestWithdrawal< const args = { ...parameters, to } return sendTransaction(client, { - ...rest, + ...pickWriteParameters(parameters as never), calls: requestWithdrawal.calls(args), } as never) as never } @@ -915,12 +1142,12 @@ export namespace requestWithdrawal { export type Args = { /** Amount of tokens to withdraw. */ amount: bigint + /** Gas limit reserved for the withdrawal callback on the parent chain. @default `0n` */ + callbackGas?: bigint | undefined /** Optional callback data for the recipient. @default `'0x'` */ data?: Hex.Hex | undefined /** Fallback address if callback fails. @default `to` */ fallbackRecipient?: Address | undefined - /** Gas limit reserved for the withdrawal callback on the parent chain. @default `0n` */ - gas?: bigint | undefined /** Optional withdrawal memo. @default `0x00...00` */ memo?: Hex.Hex | undefined /** Recipient address on the parent Tempo chain. */ @@ -943,9 +1170,9 @@ export namespace requestWithdrawal { export function calls(args: Args) { const { amount, + callbackGas = 0n, data = '0x', fallbackRecipient = args.to, - gas = 0n, memo = zeroHash, to, token, @@ -966,7 +1193,7 @@ export namespace requestWithdrawal { to, amount, memo, - gas, + callbackGas, fallbackRecipient, data, '0x', @@ -976,6 +1203,207 @@ export namespace requestWithdrawal { } } +type WithdrawalCalls = ReturnType + +/** + * Prepares a zone withdrawal without broadcasting it. + * + * Use this to inspect the ZoneOutbox calls and fees before submitting a + * withdrawal transaction. When `estimateTransactionFee` is enabled, viem also + * estimates the zone transaction gas and converts it with `transactionFeeScale`. + * + * @example + * ```ts + * import { createClient } from 'viem' + * import { http, zoneModerato } from 'viem/tempo/zones' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: zoneModerato(7), + * transport: http(), + * }) + * + * const prepared = await Actions.zone.prepareWithdrawal(client, { + * token: '0x20c0...0001', + * amount: 1_000_000n, + * to: '0x...', + * }) + * ``` + * + * @param client - Zone client. + * @param parameters - Withdrawal preparation parameters. + * @returns Prepared calls and fee details. + */ +export async function prepareWithdrawal< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: prepareWithdrawal.Parameters, +): Promise { + const { + account = client.account, + amount, + callbackGas = 0n, + data = '0x', + estimateTransactionFee = false, + fallbackRecipient, + memo = zeroHash, + to: to_, + token, + transactionFeeScale, + ...transactionRequest + } = parameters + + const account_ = account ? parseAccount(account) : undefined + const to = to_ ?? account_?.address + if (!to) throw new Error('`to` is required.') + + const args = { + amount, + callbackGas, + data, + fallbackRecipient, + memo, + to, + token, + } + const calls = requestWithdrawal.calls(args) + + let estimateTransactionFeePromise: Promise< + | { + estimatedGas: bigint + gasPrice: bigint + transactionFee: bigint + } + | undefined + > + if (estimateTransactionFee) { + if (!account) throw new Error('`account` is required.') + if (transactionFeeScale === undefined) + throw new Error( + '`transactionFeeScale` is required when `estimateTransactionFee` is `true`.', + ) + + estimateTransactionFeePromise = (async () => { + const [estimatedGas, gasPrice] = await Promise.all([ + estimateGas(client, { + ...transactionRequest, + account, + calls, + prepare: false, + } as never), + getGasPrice(client), + ]) + return { + estimatedGas, + gasPrice, + transactionFee: ceilDiv(estimatedGas * gasPrice, transactionFeeScale), + } + })() + } else { + estimateTransactionFeePromise = Promise.resolve(undefined) + } + + const [withdrawalFee, estimated] = await Promise.all([ + getWithdrawalFee(client, { callbackGas }), + estimateTransactionFeePromise, + ]) + const { estimatedGas, gasPrice, transactionFee } = estimated ?? {} + + return { + amount, + callbackGas, + calls, + data, + estimatedGas, + fallbackRecipient: fallbackRecipient ?? to, + gasPrice, + memo, + to, + token, + totalFee: withdrawalFee + (transactionFee ?? 0n), + transactionFee, + withdrawalFee, + } +} + +export namespace prepareWithdrawal { + export type Parameters< + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + > = UnionOmit< + WriteParameters, + 'account' | 'throwOnReceiptRevert' + > & + GetAccountParameter & + Args + + export type Args = Omit & { + /** Recipient address on the parent Tempo chain. @default `account.address` */ + to?: Address | undefined + } & ( + | { + /** + * Whether to estimate the zone transaction gas fee and include it in + * `transactionFee` and `totalFee`. + * + * @default false + */ + estimateTransactionFee?: false | undefined + transactionFeeScale?: undefined + } + | { + /** + * Whether to estimate the zone transaction gas fee and include it in + * `transactionFee` and `totalFee`. + */ + estimateTransactionFee: true + /** + * Divisor used to convert `estimatedGas * gasPrice` (18-decimal + * units) into the fee token's base units: `10 ** (18 - decimals)`. + * For a 6-decimal fee token, pass `1_000_000_000_000n`. + */ + transactionFeeScale: bigint + } + ) + + export type ReturnType = Compute<{ + /** Amount of tokens to withdraw. */ + amount: bigint + /** Gas limit reserved for the withdrawal callback on the parent chain. */ + callbackGas: bigint + /** Calls required to approve and request the withdrawal. */ + calls: WithdrawalCalls + /** Callback data for the recipient. */ + data: Hex.Hex + /** Estimated gas for the zone transaction. */ + estimatedGas?: bigint | undefined + /** Fallback address if callback processing fails. */ + fallbackRecipient: Address + /** Gas price used for transaction fee estimation. */ + gasPrice?: bigint | undefined + /** Withdrawal memo. */ + memo: Hex.Hex + /** Recipient address on the parent Tempo chain. */ + to: Address + /** Token address or ID to withdraw. */ + token: TokenId.TokenIdOrAddress + /** Withdrawal fee plus estimated transaction fee, when estimated. */ + totalFee: bigint + /** Estimated zone transaction fee in fee-token base units. */ + transactionFee?: bigint | undefined + /** Protocol withdrawal fee for the callback gas limit. */ + withdrawalFee: bigint + }> + + export type ErrorType = + | EstimateGasErrorType + | GetGasPriceErrorType + | getWithdrawalFee.ErrorType + | BaseErrorType +} + /** * Requests a withdrawal from a zone to the parent Tempo chain and waits for * the transaction receipt. @@ -1010,11 +1438,7 @@ export async function requestWithdrawalSync< client: Client, parameters: requestWithdrawalSync.Parameters, ): Promise { - const { - account = client.account, - throwOnReceiptRevert = true, - ...rest - } = parameters + const { account = client.account, throwOnReceiptRevert = true } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -1024,7 +1448,8 @@ export async function requestWithdrawalSync< const args = { ...parameters, to } const receipt = await sendTransactionSync(client, { - ...rest, + ...pickWriteParameters(parameters as never), + ...pickWriteSyncParameters(parameters as never), calls: requestWithdrawal.calls(args), throwOnReceiptRevert, } as never) @@ -1035,7 +1460,8 @@ export namespace requestWithdrawalSync { export type Parameters< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, - > = requestWithdrawal.Parameters + > = requestWithdrawal.Parameters & + WriteSyncParameters export type Args = requestWithdrawal.Args @@ -1086,7 +1512,7 @@ export async function requestVerifiableWithdrawal< client: Client, parameters: requestVerifiableWithdrawal.Parameters, ): Promise { - const { account = client.account, ...rest } = parameters + const { account = client.account } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -1096,7 +1522,7 @@ export async function requestVerifiableWithdrawal< const args = { ...parameters, to } return sendTransaction(client, { - ...rest, + ...pickWriteParameters(parameters as never), calls: requestVerifiableWithdrawal.calls(args), } as never) as never } @@ -1130,9 +1556,9 @@ export namespace requestVerifiableWithdrawal { export function calls(args: Args) { const { amount, + callbackGas = 0n, data = '0x', fallbackRecipient = args.to, - gas = 0n, memo = zeroHash, revealTo, to, @@ -1154,7 +1580,7 @@ export namespace requestVerifiableWithdrawal { to, amount, memo, - gas, + callbackGas, fallbackRecipient, data, revealTo, @@ -1199,11 +1625,7 @@ export async function requestVerifiableWithdrawalSync< client: Client, parameters: requestVerifiableWithdrawalSync.Parameters, ): Promise { - const { - account = client.account, - throwOnReceiptRevert = true, - ...rest - } = parameters + const { account = client.account, throwOnReceiptRevert = true } = parameters const account_ = account ? parseAccount(account) : undefined if (!account) throw new Error('`account` is required.') @@ -1213,7 +1635,8 @@ export async function requestVerifiableWithdrawalSync< const args = { ...parameters, to } const receipt = await sendTransactionSync(client, { - ...rest, + ...pickWriteParameters(parameters as never), + ...pickWriteSyncParameters(parameters as never), calls: requestVerifiableWithdrawal.calls(args), throwOnReceiptRevert, } as never) @@ -1224,7 +1647,8 @@ export namespace requestVerifiableWithdrawalSync { export type Parameters< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, - > = requestVerifiableWithdrawal.Parameters + > = requestVerifiableWithdrawal.Parameters & + WriteSyncParameters export type Args = requestVerifiableWithdrawal.Args @@ -1336,6 +1760,11 @@ export namespace signAuthorizationToken { export type ErrorType = BaseErrorType } +function ceilDiv(numerator: bigint, denominator: bigint) { + if (denominator <= 0n) throw new Error('`denominator` must be positive.') + return (numerator + denominator - 1n) / denominator +} + /** * Encrypts a deposit payload (recipient + memo) using ECIES with AES-256-GCM. * diff --git a/src/tempo/internal/types.ts b/src/tempo/internal/types.ts index 1c89a4fea0..1750dc0e3c 100644 --- a/src/tempo/internal/types.ts +++ b/src/tempo/internal/types.ts @@ -77,3 +77,11 @@ export type WriteParameters< | 'validAfter' | 'validBefore' > + +export type WriteSyncParameters< + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, +> = UnionPick< + viem_WriteContractSyncParameters, + 'pollingInterval' | 'timeout' +> diff --git a/src/tempo/internal/utils.ts b/src/tempo/internal/utils.ts index c21395b0d9..bb0bc40e62 100644 --- a/src/tempo/internal/utils.ts +++ b/src/tempo/internal/utils.ts @@ -216,6 +216,17 @@ export function pickWriteParameters(parameters: Record) { } } +/** + * Picks the receipt-wait fields shared by Tempo sync write actions, so they + * can be forwarded to `sendTransactionSync` alongside + * {@link pickWriteParameters}. + * @internal + */ +export function pickWriteSyncParameters(parameters: Record) { + const { pollingInterval, timeout } = parameters + return { pollingInterval, timeout } +} + /** * Parameters of an action's `.call` builder: the call arguments, optionally * preceded by a Client. The Client is used to resolve token symbols declared diff --git a/src/tempo/zones/zone.ts b/src/tempo/zones/zone.ts index d4062a2a51..29e5040691 100644 --- a/src/tempo/zones/zone.ts +++ b/src/tempo/zones/zone.ts @@ -1,3 +1,4 @@ +import type { Address } from 'abitype' import { ZoneId } from 'ox/tempo' import { tempo } from '../../chains/definitions/tempo.js' import { tempoModerato } from '../../chains/definitions/tempoModerato.js' @@ -14,7 +15,9 @@ export const portalAddresses = { export function getPortalAddress( chainId: number, zoneId: number, + portalAddress?: Address | undefined, ): `0x${string}` { + if (portalAddress) return portalAddress const address = ( portalAddresses as Record> )[chainId]?.[zoneId] @@ -25,7 +28,7 @@ export function getPortalAddress( return address } -type Override = { +type ZoneConfig = { name: string rpcUrl: string } @@ -39,7 +42,7 @@ const overrides = { name: 'Zone B', rpcUrl: 'https://rpc-zone-b.testnet.tempo.xyz', }, -} as const satisfies Record +} as const satisfies Record export const zone = /*#__PURE__*/ from({ sourceId: tempo.id, @@ -57,7 +60,7 @@ export function from(options: from.Options) { const chainId = ZoneId.toChainId(id) const paddedId = String(id).padStart(3, '0') - const override = (overrides as Record)[id] + const override = (overrides as Record)[id] return defineChain({ ...chainConfig,