diff --git a/.changeset/khaki-lemons-mix.md b/.changeset/khaki-lemons-mix.md new file mode 100644 index 000000000..96782daba --- /dev/null +++ b/.changeset/khaki-lemons-mix.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-sdk': minor +--- + +Add executeGaslessBatch action to support 7702 gasless flow diff --git a/packages/sdk/scripts/test-gasless-batch.mts b/packages/sdk/scripts/test-gasless-batch.mts new file mode 100644 index 000000000..61ae50a09 --- /dev/null +++ b/packages/sdk/scripts/test-gasless-batch.mts @@ -0,0 +1,99 @@ +/** + * Integration test for executeGaslessBatch + * + * Swaps Pudgy Penguins (PENGU) on Base → USDC on Optimism + * + * Usage: + * PRIVATE_KEY=0x... RELAY_API_KEY=... npx tsx scripts/test-gasless-batch.mts + */ + +import { createWalletClient, http, type Hex } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { base } from 'viem/chains' +import { + createClient, + getQuote, + executeGaslessBatch, + convertViemChainToRelayChain, + LogLevel +} from '../src/index.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as Hex | undefined +const RELAY_API_KEY = process.env.RELAY_API_KEY + +if (!PRIVATE_KEY) { + console.error('Missing PRIVATE_KEY env var') + process.exit(1) +} + +if (!RELAY_API_KEY) { + console.error('Missing RELAY_API_KEY env var') + process.exit(1) +} + +// Pudgy Penguins (PENGU) on Base +const PENGU_BASE = '0x01e6bd233f7021e4f5698a3ae44242b76a246c0a' +// USDC on Optimism +const USDC_OPTIMISM = '0x0b2c639c533813f4aa9d7837caf62653d097ff85' + +const account = privateKeyToAccount(PRIVATE_KEY) + +const walletClient = createWalletClient({ + account, + chain: base, + transport: http() +}) + +createClient({ + baseApiUrl: 'https://api.relay.link', + apiKey: RELAY_API_KEY, + source: 'relay.link', + logLevel: LogLevel.Verbose, + chains: [convertViemChainToRelayChain(base)] +}) + +console.log(`\n── Gasless Batch Test ──`) +console.log(`Account: ${account.address}`) +console.log(`Swap: PENGU (Base) → USDC (Optimism)`) +console.log(`Amount: 1000000000000000000 (1 PENGU)\n`) + +// ── 1. Get Quote ───────────────────────────────────────────────────────────── + +console.log('→ Getting quote...') + +const quote = await getQuote({ + chainId: 8453, + currency: PENGU_BASE, + toChainId: 10, + toCurrency: USDC_OPTIMISM, + amount: '10000000000000000000000', // 10000 PENGU (18 decimals) + tradeType: 'EXACT_INPUT', + user: account.address, + recipient: account.address, + options: { + subsidizeFees: true + } +}) + +console.log(`✓ Quote received — ${quote.steps.length} step(s)`) + +for (const step of quote.steps) { + const items = step.items?.length ?? 0 + console.log(` step: kind=${step.kind}, items=${items}, requestId=${step.requestId ?? 'none'}`) +} + +// ── 2. Execute Gasless Batch ───────────────────────────────────────────────── + +console.log('\n→ Executing gasless batch...') + +const result = await executeGaslessBatch({ + quote, + walletClient, + //Subsidize the origin tx fees + subsidizeFees: true, + onProgress: (progress) => { + console.log(` [progress] ${progress.status}${progress.requestId ? ` (${progress.requestId})` : ''}`) + } +}) + +console.log(`\n✓ Done — requestId: ${result.requestId}`) diff --git a/packages/sdk/src/actions/gaslessBatch.test.ts b/packages/sdk/src/actions/gaslessBatch.test.ts new file mode 100644 index 000000000..b912d8550 --- /dev/null +++ b/packages/sdk/src/actions/gaslessBatch.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createClient } from '../client.js' +import { MAINNET_RELAY_API } from '../constants/index.js' +import { axios } from '../utils/index.js' +import { + createCaliburExecutor, + CALIBUR_ORIGIN_GAS_OVERHEAD +} from '../utils/caliburExecutor.js' +import type { BatchExecutorConfig } from '../types/BatchExecutor.js' +import type { Execute } from '../types/Execute.js' +import { zeroAddress, type Address } from 'viem' + +// ── helpers ────────────────────────────────────────────────────────────────── + +const MOCK_USER = '0x1111111111111111111111111111111111111111' as Address + +/** Minimal quote fixture with a single transaction step */ +const createMockQuote = (chainId = 8453): Execute => + ({ + steps: [ + { + kind: 'transaction', + requestId: 'req-abc', + items: [ + { + data: { + to: '0x2222222222222222222222222222222222222222', + value: '0', + data: '0xdeadbeef', + chainId + } + } + ] + } + ] + }) as unknown as Execute + +/** Build a mock executor based on Calibur but with overridable fields */ +const createMockExecutor = ( + overrides: Partial = {} +): BatchExecutorConfig => { + const calibur = createCaliburExecutor() + return { + ...calibur, + ...overrides + } +} + +const mockWalletClient = () => ({ + account: { address: MOCK_USER }, + signAuthorization: vi.fn().mockResolvedValue({ + chainId: 8453, + address: zeroAddress, + nonce: 0, + yParity: 0, + r: '0x' + '00'.repeat(32), + s: '0x' + '00'.repeat(32) + }), + signTypedData: vi.fn().mockResolvedValue('0x' + 'ab'.repeat(65)) +}) + +// ── mocks ──────────────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let axiosRequestSpy: any + +/** + * Mock axios so all HTTP calls resolve. + * – POST /execute → returns a requestId + * – GET /intents/status/v3 → returns success immediately + */ +const mockAxios = () => + vi.spyOn(axios, 'request').mockImplementation((config: any) => { + if (config?.url?.includes('/execute')) { + return Promise.resolve({ + data: { requestId: 'req-mock-123', message: 'ok' }, + status: 200 + }) + } + // status polling + return Promise.resolve({ + data: { status: 'success' }, + status: 200 + }) + }) + +// Mock viem's createPublicClient to avoid real RPC calls +vi.mock('viem', async () => { + const actual = await vi.importActual('viem') + return { + ...(actual as object), + createPublicClient: () => ({ + getCode: vi.fn().mockResolvedValue('0x'), + getTransactionCount: vi.fn().mockResolvedValue(0), + readContract: vi.fn().mockResolvedValue(0n) + }) + } +}) + +/** Extract the request body sent to /execute from the axios spy calls */ +const getExecuteBody = (): Record => { + const call = axiosRequestSpy.mock.calls.find((c: any) => + (c[0] as any)?.url?.includes('/execute') + ) + if (!call) throw new Error('No /execute call found') + return (call[0] as any).data as Record +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +describe('executeGaslessBatch', () => { + beforeEach(() => { + vi.clearAllMocks() + axiosRequestSpy = mockAxios() + }) + + // ---------- basic validation ---------- + + it('should throw when client has no API url', async () => { + createClient({ baseApiUrl: '' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + await expect( + executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any + }) + ).rejects.toThrow('RelayClient missing api url configuration') + }) + + it('should throw when client has no API key', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + await expect( + executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any + }) + ).rejects.toThrow('API key is required') + }) + + it('should throw when walletClient has no account', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + await expect( + executeGaslessBatch({ + quote: createMockQuote(), + walletClient: {} as any + }) + ).rejects.toThrow('WalletClient must have an account') + }) + + // ---------- originGasOverhead: default (Calibur) ---------- + + it('should include Calibur default originGasOverhead (80k) in /execute body', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + await executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any + }) + + const body = getExecuteBody() + expect(body.originGasOverhead).toBe(CALIBUR_ORIGIN_GAS_OVERHEAD) + expect(body.originGasOverhead).toBe(80_000) + }) + + // ---------- originGasOverhead: custom executor ---------- + + it('should use custom executor originGasOverhead when provided', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + const customExecutor = createMockExecutor({ originGasOverhead: 120_000 }) + + await executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any, + executor: customExecutor + }) + + const body = getExecuteBody() + expect(body.originGasOverhead).toBe(120_000) + }) + + // ---------- originGasOverhead: explicit override ---------- + + it('should let originGasOverhead parameter override the executor default', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + await executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any, + originGasOverhead: 200_000 + }) + + const body = getExecuteBody() + expect(body.originGasOverhead).toBe(200_000) + }) + + it('should let originGasOverhead parameter override a custom executor default', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + const customExecutor = createMockExecutor({ originGasOverhead: 120_000 }) + + await executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any, + executor: customExecutor, + originGasOverhead: 50_000 + }) + + const body = getExecuteBody() + expect(body.originGasOverhead).toBe(50_000) + }) + + // ---------- originGasOverhead: executor with no default ---------- + + it('should omit originGasOverhead when executor has none and no override given', async () => { + createClient({ baseApiUrl: MAINNET_RELAY_API, apiKey: 'test-key' }) + + const { executeGaslessBatch } = await import('./gaslessBatch.js') + + const executorWithoutGas = createMockExecutor({ + originGasOverhead: undefined + }) + + await executeGaslessBatch({ + quote: createMockQuote(), + walletClient: mockWalletClient() as any, + executor: executorWithoutGas + }) + + const body = getExecuteBody() + expect(body).not.toHaveProperty('originGasOverhead') + }) +}) diff --git a/packages/sdk/src/actions/gaslessBatch.ts b/packages/sdk/src/actions/gaslessBatch.ts new file mode 100644 index 000000000..8f8d64d94 --- /dev/null +++ b/packages/sdk/src/actions/gaslessBatch.ts @@ -0,0 +1,367 @@ +import { + createPublicClient, + encodeAbiParameters, + http, + type Account, + type Address, + type Hex, + type WalletClient +} from 'viem' +import type { AxiosRequestConfig } from 'axios' +import type { Execute } from '../types/Execute.js' +import type { BatchCall, BatchExecutorConfig } from '../types/BatchExecutor.js' +import type { paths } from '../types/api.js' +import { getClient } from '../client.js' +import { axios } from '../utils/axios.js' +import { APIError, getApiKeyHeader, LogLevel } from '../utils/index.js' +import { createCaliburExecutor } from '../utils/caliburExecutor.js' + +type ExecuteBody = NonNullable< + paths['/execute']['post']['requestBody']['content']['application/json'] +> + +type ExecuteResponse = NonNullable< + paths['/execute']['post']['responses']['200']['content']['application/json'] +> + +export type GaslessBatchProgress = { + status: + | 'signing_authorization' + | 'signing_batch' + | 'submitting' + | 'polling' + | 'success' + | 'failure' + requestId?: string + details?: any +} + +export type ExecuteGaslessBatchParameters = { + /** The quote obtained from getQuote() */ + quote: Execute + /** viem WalletClient with an account attached */ + walletClient: WalletClient + /** Batch executor config — defaults to Calibur */ + executor?: BatchExecutorConfig + /** Whether the sponsor pays all fees (default: false) */ + subsidizeFees?: boolean + /** Gas overhead for the origin chain gasless transaction. + * Overrides the executor's default if provided (Calibur default: 80,000). */ + originGasOverhead?: number + /** Progress callback for each stage of the flow */ + onProgress?: (data: GaslessBatchProgress) => void +} + +export type GaslessBatchResult = { + requestId: string +} + +/** + * Execute a gasless batch swap using EIP-7702 delegation. + * + * Takes a quote from getQuote(), delegates the user's EOA to a batch executor + * (defaults to Calibur), batches the quote's transaction steps atomically, + * and submits via Relay's /execute API with the sponsor covering gas costs. + * + * @example + * ```ts + * const quote = await getQuote({ ... }) + * const result = await executeGaslessBatch({ quote, walletClient }) + * console.log(result.requestId) + * ``` + * + * @param parameters - {@link ExecuteGaslessBatchParameters} + */ +export async function executeGaslessBatch( + parameters: ExecuteGaslessBatchParameters +): Promise { + const { + quote, + walletClient, + executor: executorConfig, + subsidizeFees = false, + originGasOverhead: originGasOverheadOverride, + onProgress + } = parameters + + const client = getClient() + + if (!client.baseApiUrl || !client.baseApiUrl.length) { + throw new ReferenceError('RelayClient missing api url configuration') + } + + if (!client.apiKey) { + throw new Error( + 'API key is required for gasless batch execution. Configure it via createClient({ apiKey: "..." })' + ) + } + + const account = walletClient.account + if (!account) { + throw new Error( + 'WalletClient must have an account. Create it with createWalletClient({ account, ... })' + ) + } + + const userAddress = account.address + const executor = executorConfig ?? createCaliburExecutor() + const originGasOverhead = + originGasOverheadOverride ?? executor.originGasOverhead + + client.log( + ['Gasless Batch: starting', { user: userAddress, executor: executor.address }], + LogLevel.Info + ) + + // ── 1. Extract calls and chainId from quote steps ──────────────────── + + const calls: BatchCall[] = [] + let requestId: string | undefined + let chainId: number | undefined + + for (const step of quote.steps) { + if (step.kind !== 'transaction') continue + for (const item of step.items) { + if (!item.data) continue + calls.push({ + to: item.data.to as Address, + value: BigInt(item.data.value || '0'), + data: item.data.data as Hex + }) + if (!chainId && item.data.chainId) { + chainId = item.data.chainId + } + } + if (step.requestId) requestId = step.requestId + } + + if (calls.length === 0) { + throw new Error('No transaction steps found in quote') + } + + client.log( + [`Gasless Batch: extracted ${calls.length} calls from quote on chain ${chainId}`, { requestId }], + LogLevel.Verbose + ) + + if (!chainId) { + throw new Error( + 'Could not determine origin chainId from quote steps' + ) + } + + // Create a public client for on-chain reads + const chain = client.chains.find((c) => c.id === chainId) + const rpcUrl = chain?.httpRpcUrl + const publicClient = createPublicClient({ + chain: chain?.viemChain, + transport: rpcUrl ? http(rpcUrl) : http() + }) + + // ── 2. Check delegation ────────────────────────────────────────────── + + onProgress?.({ status: 'signing_authorization', requestId }) + + const code = await publicClient.getCode({ address: userAddress }) + const isDelegated = + code?.toLowerCase().startsWith('0xef0100') && + code.slice(8).toLowerCase() === + executor.address.slice(2).toLowerCase() + + client.log( + [`Gasless Batch: delegation status — ${isDelegated ? 'already delegated' : 'needs delegation'}`], + LogLevel.Info + ) + + // ── 3. Sign EIP-7702 authorization (if needed) ────────────────────── + + let authorization: + | ExecuteBody['data']['authorizationList'] + | undefined + + if (!isDelegated) { + const currentNonce = await publicClient.getTransactionCount({ + address: userAddress + }) + + const signedAuth = await walletClient.signAuthorization({ + account: account as Account, + contractAddress: executor.address, + chainId, + nonce: currentNonce + }) + + authorization = [ + { + chainId: Number(signedAuth.chainId), + address: signedAuth.address, + nonce: signedAuth.nonce, + yParity: signedAuth.yParity ?? 0, + r: signedAuth.r, + s: signedAuth.s + } + ] + + client.log( + ['Gasless Batch: signed 7702 authorization'], + LogLevel.Verbose + ) + } + + // ── 4. Sign batch via EIP-712 ──────────────────────────────────────── + + onProgress?.({ status: 'signing_batch', requestId }) + + const nonce = await executor.getNonce(publicClient, userAddress) + + client.log( + [`Gasless Batch: executor nonce ${nonce}`], + LogLevel.Verbose + ) + + const signedMessage = executor.buildSignMessage(calls, nonce) + const domain = executor.buildSignDomain(chainId, userAddress) + + const signature = await walletClient.signTypedData({ + account: account as Account, + domain, + types: executor.eip712Types, + primaryType: executor.eip712PrimaryType, + message: signedMessage + }) + + // Wrap signature: abi.encode(signature, hookData) — empty hookData + const wrappedSignature = encodeAbiParameters( + [{ type: 'bytes' }, { type: 'bytes' }], + [signature, '0x'] + ) + + const batchCallData = executor.encodeExecute( + signedMessage, + wrappedSignature + ) + + client.log( + ['Gasless Batch: signed EIP-712 batch'], + LogLevel.Verbose + ) + + // ── 5. Submit via /execute ─────────────────────────────────────────── + + onProgress?.({ status: 'submitting', requestId }) + + const executeBody: ExecuteBody = { + executionKind: 'rawCalls', + data: { + chainId, + to: userAddress, + data: batchCallData, + value: '0', + ...(authorization ? { authorizationList: authorization } : {}) + }, + executionOptions: { + referrer: client.source || '', + subsidizeFees + }, + ...(originGasOverhead != null ? { originGasOverhead } : {}), + ...(requestId ? { requestId } : {}) + } + + const executeRequest: AxiosRequestConfig = { + url: `${client.baseApiUrl}/execute`, + method: 'post', + data: executeBody, + headers: { + ...getApiKeyHeader(client), + 'relay-sdk-version': client.version ?? 'unknown' + } + } + + client.log( + ['Gasless Batch: submitting to /execute', { chainId, requestId, subsidizeFees }], + LogLevel.Info + ) + + let executeResult: ExecuteResponse + try { + const res = await axios.request(executeRequest) + executeResult = res.data + } catch (error: any) { + throw new APIError( + error?.response?.data?.error || + error?.message || + 'Gasless batch execution failed', + error?.response?.status || 500, + error?.response?.data || error + ) + } + + const finalRequestId = + executeResult.requestId || requestId || '' + + client.log( + [`Gasless Batch: submitted, requestId ${finalRequestId}`], + LogLevel.Info + ) + + // ── 6. Poll for completion ─────────────────────────────────────────── + + onProgress?.({ status: 'polling', requestId: finalRequestId }) + + const maxAttempts = client.maxPollingAttemptsBeforeTimeout ?? 60 + const pollingInterval = client.pollingInterval ?? 5000 + + for (let i = 0; i < maxAttempts; i++) { + const statusRequest: AxiosRequestConfig = { + url: `${client.baseApiUrl}/intents/status/v3`, + method: 'get', + params: { requestId: finalRequestId }, + headers: getApiKeyHeader(client) + } + + try { + const res = await axios.request(statusRequest) + const status = res.data + + client.log( + [`Gasless Batch: poll [${i + 1}/${maxAttempts}] status=${status.status}`], + LogLevel.Verbose + ) + + if (status.status === 'success') { + client.log( + [`Gasless Batch: complete — requestId ${finalRequestId}`], + LogLevel.Info + ) + onProgress?.({ + status: 'success', + requestId: finalRequestId, + details: status + }) + return { requestId: finalRequestId } + } + + if (status.status === 'failure' || status.status === 'refund') { + onProgress?.({ + status: 'failure', + requestId: finalRequestId, + details: status + }) + throw new Error( + `Gasless batch request failed with status: ${status.status}` + ) + } + } catch (error: any) { + if (error.message?.startsWith('Gasless batch request failed')) { + throw error + } + // Transient polling error — continue + } + + await new Promise((resolve) => setTimeout(resolve, pollingInterval)) + } + + throw new Error( + `Polling timed out after ${maxAttempts} attempts for request ${finalRequestId}` + ) +} diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index 8b2a57eab..30bfa38f6 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -3,3 +3,4 @@ export * from './getQuote.js' export * from './getAppFees.js' export * from './claimAppFees.js' export * from './fastFill.js' +export * from './gaslessBatch.js' diff --git a/packages/sdk/src/constants/calibur.ts b/packages/sdk/src/constants/calibur.ts new file mode 100644 index 000000000..59abe4694 --- /dev/null +++ b/packages/sdk/src/constants/calibur.ts @@ -0,0 +1,87 @@ +import type { Hex, Address } from 'viem' +import { pad } from 'viem' + +/** + * Calibur — Uniswap's minimal batch executor for EIP-7702 delegated EOAs. + * Deployed at the same address on all supported chains. + * https://github.com/Uniswap/calibur + */ +export const CALIBUR_ADDRESS = + '0x000000009B1D0aF20D8C6d0A44e162d11F9b8f00' as Address + +export const CALIBUR_ABI = [ + { + name: 'execute', + type: 'function', + stateMutability: 'payable', + inputs: [ + { + name: 'signedBatchedCall', + type: 'tuple', + components: [ + { + name: 'batchedCall', + type: 'tuple', + components: [ + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' } + ] + }, + { name: 'revertOnFailure', type: 'bool' } + ] + }, + { name: 'nonce', type: 'uint256' }, + { name: 'keyHash', type: 'bytes32' }, + { name: 'executor', type: 'address' }, + { name: 'deadline', type: 'uint256' } + ] + }, + { name: 'wrappedSignature', type: 'bytes' } + ], + outputs: [] + }, + { + name: 'getSeq', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'key', type: 'uint256' }], + outputs: [{ name: '', type: 'uint256' }] + } +] as const + +export const CALIBUR_EIP712_TYPES = { + SignedBatchedCall: [ + { name: 'batchedCall', type: 'BatchedCall' }, + { name: 'nonce', type: 'uint256' }, + { name: 'keyHash', type: 'bytes32' }, + { name: 'executor', type: 'address' }, + { name: 'deadline', type: 'uint256' } + ], + BatchedCall: [ + { name: 'calls', type: 'Call[]' }, + { name: 'revertOnFailure', type: 'bool' } + ], + Call: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' } + ] +} as const + +/** bytes32(0) — the root key hash, meaning the EOA owner's key */ +export const ROOT_KEY_HASH = + '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex + +/** + * EIP-712 domain salt for Calibur. + * salt = pad(implementationAddress) with left-padding to 32 bytes. + */ +export const CALIBUR_SALT = pad(CALIBUR_ADDRESS, { + dir: 'left', + size: 32 +}) diff --git a/packages/sdk/src/constants/index.ts b/packages/sdk/src/constants/index.ts index cf9763193..8f724f7da 100644 --- a/packages/sdk/src/constants/index.ts +++ b/packages/sdk/src/constants/index.ts @@ -1,2 +1,3 @@ export * from './servers.js' export * from './address.js' +export * from './calibur.js' diff --git a/packages/sdk/src/types/BatchExecutor.ts b/packages/sdk/src/types/BatchExecutor.ts new file mode 100644 index 000000000..4a80ed5e9 --- /dev/null +++ b/packages/sdk/src/types/BatchExecutor.ts @@ -0,0 +1,58 @@ +import type { Address, Hex, PublicClient } from 'viem' + +export type BatchCall = { + to: Address + value: bigint + data: Hex +} + +export type BatchExecutorConfig = { + /** Contract address of the batch executor (e.g., Calibur) */ + address: Address + + /** ABI for the batch executor contract */ + abi: readonly Record[] + + /** Default gas overhead for origin chain gasless transactions (e.g., 80_000 for Calibur) */ + originGasOverhead?: number + + /** EIP-712 types for the signed batch call */ + eip712Types: Record< + string, + | Array<{ name: string; type: string }> + | readonly { readonly name: string; readonly type: string }[] + > + + /** EIP-712 primary type name */ + eip712PrimaryType: string + + /** EIP-712 domain salt */ + salt: Hex + + /** Build the EIP-712 domain for signing */ + buildSignDomain: ( + chainId: number, + verifyingContract: Address + ) => { + name: string + version: string + chainId: bigint + verifyingContract: Address + salt: Hex + } + + /** Build the EIP-712 message to sign */ + buildSignMessage: ( + calls: BatchCall[], + nonce: bigint + ) => Record + + /** Encode the execute calldata from the signed message + wrapped signature */ + encodeExecute: ( + signedMessage: Record, + wrappedSignature: Hex + ) => Hex + + /** Read the current nonce from the executor contract */ + getNonce: (publicClient: PublicClient, userAddress: Address) => Promise +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 74ed31814..417eb3251 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -5,3 +5,4 @@ export * from './TransactionStepItem.js' export * from './AdaptedWallet.js' export * from './RelayChain.js' export * from './Progress.js' +export * from './BatchExecutor.js' diff --git a/packages/sdk/src/utils/caliburExecutor.ts b/packages/sdk/src/utils/caliburExecutor.ts new file mode 100644 index 000000000..1130462af --- /dev/null +++ b/packages/sdk/src/utils/caliburExecutor.ts @@ -0,0 +1,80 @@ +import { + encodeFunctionData, + type Address, + type Hex, + type PublicClient +} from 'viem' +import type { BatchExecutorConfig, BatchCall } from '../types/BatchExecutor.js' +import { + CALIBUR_ADDRESS, + CALIBUR_ABI, + CALIBUR_EIP712_TYPES, + CALIBUR_SALT, + ROOT_KEY_HASH +} from '../constants/calibur.js' + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address + +export const CALIBUR_ORIGIN_GAS_OVERHEAD = 80_000 + +export function createCaliburExecutor(): BatchExecutorConfig { + return { + address: CALIBUR_ADDRESS, + abi: CALIBUR_ABI, + eip712Types: CALIBUR_EIP712_TYPES, + eip712PrimaryType: 'SignedBatchedCall', + salt: CALIBUR_SALT, + originGasOverhead: CALIBUR_ORIGIN_GAS_OVERHEAD, + + buildSignDomain(chainId: number, verifyingContract: Address) { + return { + name: 'Calibur', + version: '1.0.0', + chainId: BigInt(chainId), + verifyingContract, + salt: CALIBUR_SALT + } + }, + + buildSignMessage(calls: BatchCall[], nonce: bigint) { + return { + batchedCall: { + calls: calls.map((c) => ({ to: c.to, value: c.value, data: c.data })), + revertOnFailure: true + }, + nonce, + keyHash: ROOT_KEY_HASH, + executor: ZERO_ADDRESS, + deadline: 0n + } + }, + + encodeExecute( + signedMessage: Record, + wrappedSignature: Hex + ) { + return encodeFunctionData({ + abi: CALIBUR_ABI, + functionName: 'execute', + args: [signedMessage as any, wrappedSignature] + }) + }, + + async getNonce( + publicClient: PublicClient, + userAddress: Address + ): Promise { + try { + const seq = await publicClient.readContract({ + address: userAddress, + abi: CALIBUR_ABI, + functionName: 'getSeq', + args: [0n] + }) + return BigInt(seq as bigint) + } catch { + return 0n + } + } + } +} diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 1b78a458d..e44fb72f9 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -16,3 +16,7 @@ export { safeStructuredClone } from './structuredClone.js' export { repeatUntilOk } from './repeatUntilOk.js' export { prepareHyperliquidSteps } from './hyperliquid.js' export { isRelayApiUrl, getApiKeyHeader } from './apiKey.js' +export { + createCaliburExecutor, + CALIBUR_ORIGIN_GAS_OVERHEAD +} from './caliburExecutor.js'