diff --git a/packages/account-sdk/src/core/rpc/wallet_prepareCalls.ts b/packages/account-sdk/src/core/rpc/wallet_prepareCalls.ts index bbc2d8b18..df26738ce 100644 --- a/packages/account-sdk/src/core/rpc/wallet_prepareCalls.ts +++ b/packages/account-sdk/src/core/rpc/wallet_prepareCalls.ts @@ -1,14 +1,19 @@ import { Hex } from 'viem'; +import type { CallCapabilities } from './wallet_sendCalls.js'; + +export type PrepareCallsCall = { + to: Hex; + data: Hex; + value: Hex; + capabilities?: CallCapabilities; +}; + export type PrepareCallsParams = [ { from: Hex; chainId: Hex; - calls: { - to: Hex; - data: Hex; - value: Hex; - }[]; + calls: PrepareCallsCall[]; capabilities: Record; }, ]; diff --git a/packages/account-sdk/src/core/rpc/wallet_sendCalls.ts b/packages/account-sdk/src/core/rpc/wallet_sendCalls.ts new file mode 100644 index 000000000..633f21d88 --- /dev/null +++ b/packages/account-sdk/src/core/rpc/wallet_sendCalls.ts @@ -0,0 +1,38 @@ +import { Hex } from 'viem'; + +export type GasLimitOverrideCallCapability = { + value: Hex; +}; + +export type GasLimitOverrideCapability = { + supported: boolean; +}; + +export type CallCapabilities = { + gasLimitOverride?: GasLimitOverrideCallCapability; + [key: string]: unknown; +}; + +export type WalletSendCallsCall = { + to: Hex; + data?: Hex; + value?: Hex; + capabilities?: CallCapabilities; +}; + +export type WalletSendCallsParams = [ + { + version: string; + chainId: Hex; + from: Hex; + calls: WalletSendCallsCall[]; + atomicRequired?: boolean; + capabilities?: Record; + }, +]; + +export type WalletSendCallsSchema = { + Method: 'wallet_sendCalls'; + Parameters: WalletSendCallsParams; + ReturnType: Hex; +}; diff --git a/packages/account-sdk/src/sign/base-account/Signer.test.ts b/packages/account-sdk/src/sign/base-account/Signer.test.ts index b8e206279..785702256 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.test.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.test.ts @@ -1764,6 +1764,9 @@ describe('Signer', () => { const result = await signer.request(request); expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, + }, '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, @@ -1786,6 +1789,9 @@ describe('Signer', () => { const result = await signer.request(request); expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, + }, '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, @@ -1806,6 +1812,9 @@ describe('Signer', () => { const result = await signer.request(request); expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, + }, '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, @@ -1816,7 +1825,7 @@ describe('Signer', () => { }); }); - it('should return empty object when filter matches no capabilities', async () => { + it('should return 0x0 capability when filter matches no chain-specific capabilities', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, ['0x99', '0x100']], @@ -1824,36 +1833,15 @@ describe('Signer', () => { const result = await signer.request(request); - expect(result).toEqual({}); - }); - - it('should return empty object when capabilities is undefined', async () => { - stateSpy.mockImplementation(() => ({ - account: { - accounts: [globalAccountAddress], - capabilities: undefined, - }, - chains: [], - keys: {}, - spendPermissions: [], - config: { - metadata: mockMetadata, - preference: { walletUrl: CB_KEYS_URL, options: 'all' }, - version: '1.0.0', + // 0x0 (all chains) is always included even when no chain-specific capabilities match + expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, }, - })); - - const request = { - method: 'wallet_getCapabilities', - params: [globalAccountAddress], - }; - - const result = await signer.request(request); - - expect(result).toEqual({}); + }); }); - it('should return empty object when empty filter array is provided', async () => { + it('should return all capabilities including 0x0 when empty filter array is provided', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, []], @@ -1862,6 +1850,9 @@ describe('Signer', () => { const result = await signer.request(request); expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, + }, '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, @@ -1903,6 +1894,7 @@ describe('Signer', () => { const result = await signer.request(request); expect(result).toEqual({ + '0x0': { gasLimitOverride: { supported: true } }, '0x1': { atomicBatch: { supported: true } }, }); }); @@ -1933,6 +1925,136 @@ describe('Signer', () => { await expect(signer.request(request)).rejects.toThrow(); }); + + // ERC-8132: gasLimitOverride capability tests + it('should include gasLimitOverride capability under 0x0 (all chains)', async () => { + const request = { + method: 'wallet_getCapabilities', + params: [globalAccountAddress], + }; + + const result = await signer.request(request); + + expect(result).toHaveProperty('0x0'); + expect(result['0x0']).toEqual({ + gasLimitOverride: { supported: true }, + }); + }); + + it('should always include 0x0 gasLimitOverride even when filtering by chain', async () => { + const request = { + method: 'wallet_getCapabilities', + params: [globalAccountAddress, ['0x1']], + }; + + const result = await signer.request(request); + + // Should include both the filtered chain and 0x0 + expect(result).toHaveProperty('0x0'); + expect(result['0x0']).toEqual({ + gasLimitOverride: { supported: true }, + }); + expect(result).toHaveProperty('0x1'); + }); + + it('should include gasLimitOverride when capabilities is empty', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + capabilities: {}, + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + })); + + const request = { + method: 'wallet_getCapabilities', + params: [globalAccountAddress], + }; + + const result = await signer.request(request); + + expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, + }, + }); + }); + + it('should include gasLimitOverride when capabilities is undefined', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + capabilities: undefined, + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + })); + + const request = { + method: 'wallet_getCapabilities', + params: [globalAccountAddress], + }; + + const result = await signer.request(request); + + expect(result).toEqual({ + '0x0': { + gasLimitOverride: { supported: true }, + }, + }); + }); + + it('should preserve gasLimitOverride when storedCapabilities has 0x0 key', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + capabilities: { + '0x0': { + atomicBatch: { supported: true }, + }, + '0x14a34': { + paymasterService: { supported: true }, + }, + }, + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + })); + + const request = { + method: 'wallet_getCapabilities', + params: [globalAccountAddress], + }; + + const result = await signer.request(request); + + expect(result['0x0']).toEqual({ + atomicBatch: { supported: true }, + gasLimitOverride: { supported: true }, + }); + expect(result['0x14a34']).toEqual({ + paymasterService: { supported: true }, + }); + }); }); describe('coinbase_fetchPermissions', () => { diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index d5a5ad1d6..6ccbb8f24 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -68,6 +68,9 @@ import { handleAddSubAccountOwner } from './utils/handleAddSubAccountOwner.js'; import { handleInsufficientBalanceError } from './utils/handleInsufficientBalance.js'; import { routeThroughGlobalAccount } from './utils/routeThroughGlobalAccount.js'; +/** ERC-5792 wildcard chain ID — capabilities under this key apply to all chains. */ +const ALL_CHAINS_KEY = '0x0'; + type ConstructorOptions = { metadata: AppMetadata; communicator: Communicator; @@ -470,7 +473,7 @@ export class Signer { assertGetCapabilitiesParams(request.params); const requestedAccount = request.params[0]; - const filterChainIds = request.params[1]; // Optional second parameter + const filterChainIds = request.params[1]; if (!this.accounts.some((account) => isAddressEqual(account, requestedAccount))) { throw standardErrors.provider.unauthorized( @@ -478,29 +481,37 @@ export class Signer { ); } - const capabilities = store.getState().account.capabilities; + const storedCapabilities = store.getState().account.capabilities ?? {}; - // Return empty object if capabilities is undefined - if (!capabilities) { - return {}; - } + const sdkWildcardCapabilities = { + gasLimitOverride: { supported: true }, + }; + + const mergedWildcard = { + ...(storedCapabilities[ALL_CHAINS_KEY] ?? {}), + ...sdkWildcardCapabilities, + }; + + const capabilities = { + ...storedCapabilities, + [ALL_CHAINS_KEY]: mergedWildcard, + }; - // If no filter is provided, return all capabilities if (!filterChainIds || filterChainIds.length === 0) { return capabilities; } - // Convert filter chain IDs to numbers once for efficient lookup const filterChainNumbers = new Set(filterChainIds.map((chainId) => hexToNumber(chainId))); - // Filter capabilities const filteredCapabilities = Object.fromEntries( Object.entries(capabilities).filter(([capabilityKey]) => { + if (capabilityKey === ALL_CHAINS_KEY) { + return true; + } try { const capabilityChainNumber = hexToNumber(capabilityKey as `0x${string}`); return filterChainNumbers.has(capabilityChainNumber); } catch { - // If capabilityKey is not a valid hex string, exclude it return false; } }) diff --git a/packages/account-sdk/src/sign/base-account/utils.ts b/packages/account-sdk/src/sign/base-account/utils.ts index cb63babba..7bc0ec035 100644 --- a/packages/account-sdk/src/sign/base-account/utils.ts +++ b/packages/account-sdk/src/sign/base-account/utils.ts @@ -391,7 +391,7 @@ export function createWalletSendCallsRequest({ chainId, capabilities, }: { - calls: { to: Address; data: Hex; value: Hex }[]; + calls: { to: Address; data: Hex; value: Hex; capabilities?: Record }[]; from: Address; chainId: number; capabilities?: Record; diff --git a/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.test.ts b/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.test.ts index f9ae8c4ba..2defe16e7 100644 --- a/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.test.ts +++ b/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.test.ts @@ -464,4 +464,216 @@ describe('createSubAccountSigner', () => { ], }); }); + + // ERC-8132: gasLimitOverride capability tests + describe('gasLimitOverride validation', () => { + it('should pass through valid gasLimitOverride capabilities', async () => { + const request = vi.fn((args) => { + if (args.method === 'wallet_prepareCalls') { + return { + signatureRequest: { + hash: '0x', + }, + type: '0x', + userOp: '0x', + chainId: numberToHex(84532), + }; + } + + if (args.method === 'wallet_sendPreparedCalls') { + return ['0x']; + } + return undefined; + }); + + (getClient as any).mockReturnValue({ + request, + chain: { + id: 84532, + }, + }); + + const signer = await createSubAccountSigner({ + address: '0x', + client: getClient(84532)!, + owner, + }); + + await signer.request({ + method: 'wallet_sendCalls', + params: [ + { + chainId: numberToHex(84532), + calls: [ + { + to: '0x', + data: '0x', + capabilities: { + gasLimitOverride: { value: '0x5208' }, // 21000 gas + }, + }, + ], + from: '0x', + version: '1.0', + }, + ], + }); + + expect(request).toHaveBeenCalledWith({ + method: 'wallet_prepareCalls', + params: [ + expect.objectContaining({ + calls: [ + { + to: '0x', + data: '0x', + capabilities: { + gasLimitOverride: { value: '0x5208' }, + }, + }, + ], + }), + ], + }); + }); + + it('should throw error for zero gas limit', async () => { + const request = vi.fn(); + + (getClient as any).mockReturnValue({ + request, + chain: { + id: 84532, + }, + }); + + const signer = await createSubAccountSigner({ + address: '0x', + client: getClient(84532)!, + owner, + }); + + await expect( + signer.request({ + method: 'wallet_sendCalls', + params: [ + { + chainId: numberToHex(84532), + calls: [ + { + to: '0x', + data: '0x', + capabilities: { + gasLimitOverride: { value: '0x0' }, // Zero gas + }, + }, + ], + from: '0x', + version: '1.0', + }, + ], + }) + ).rejects.toThrow('gasLimitOverride.value cannot be zero'); + }); + + it('should allow calls without gasLimitOverride', async () => { + const request = vi.fn((args) => { + if (args.method === 'wallet_prepareCalls') { + return { + signatureRequest: { + hash: '0x', + }, + type: '0x', + userOp: '0x', + chainId: numberToHex(84532), + }; + } + + if (args.method === 'wallet_sendPreparedCalls') { + return ['0x']; + } + return undefined; + }); + + (getClient as any).mockReturnValue({ + request, + chain: { + id: 84532, + }, + }); + + const signer = await createSubAccountSigner({ + address: '0x', + client: getClient(84532)!, + owner, + }); + + // Should not throw - calls without gasLimitOverride are allowed + await signer.request({ + method: 'wallet_sendCalls', + params: [ + { + chainId: numberToHex(84532), + calls: [ + { + to: '0x', + data: '0x', + }, + ], + from: '0x', + version: '1.0', + }, + ], + }); + + expect(request).toHaveBeenCalled(); + }); + + it('should validate gasLimitOverride for each call in a batch', async () => { + const request = vi.fn(); + + (getClient as any).mockReturnValue({ + request, + chain: { + id: 84532, + }, + }); + + const signer = await createSubAccountSigner({ + address: '0x', + client: getClient(84532)!, + owner, + }); + + // First call is valid, second call has zero gas + await expect( + signer.request({ + method: 'wallet_sendCalls', + params: [ + { + chainId: numberToHex(84532), + calls: [ + { + to: '0x', + data: '0x', + capabilities: { + gasLimitOverride: { value: '0x5208' }, // Valid + }, + }, + { + to: '0x', + data: '0x', + capabilities: { + gasLimitOverride: { value: '0x0' }, // Invalid - zero + }, + }, + ], + from: '0x', + version: '1.0', + }, + ], + }) + ).rejects.toThrow('gasLimitOverride.value cannot be zero at call index 1'); + }); + }); }); diff --git a/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.ts b/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.ts index b2b80607e..52d2450be 100644 --- a/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.ts +++ b/packages/account-sdk/src/sign/base-account/utils/createSubAccountSigner.ts @@ -2,6 +2,7 @@ import { isViemError, standardErrors, viemHttpErrorToProviderError } from ':core import { RequestArguments } from ':core/provider/interface.js'; import { PrepareCallsSchema } from ':core/rpc/wallet_prepareCalls.js'; import { SendPreparedCallsSchema } from ':core/rpc/wallet_sendPreparedCalls.js'; +import type { CallCapabilities } from ':core/rpc/wallet_sendCalls.js'; import { OwnerAccount } from ':core/type/index.js'; import { ensureHexString } from ':core/type/util.js'; import { SubAccount } from ':store/store.js'; @@ -13,6 +14,7 @@ import { Hex, PublicClient, TypedDataDefinition, + hexToBigInt, hexToString, isHex, numberToHex, @@ -24,6 +26,26 @@ import { } from '../utils.js'; import { createSmartAccount } from './createSmartAccount.js'; +function validateGasLimitOverrides(calls: { capabilities?: CallCapabilities }[]): void { + for (let i = 0; i < calls.length; i++) { + const gasLimitOverride = calls[i].capabilities?.gasLimitOverride; + if (gasLimitOverride) { + const { value } = gasLimitOverride; + if (!value || !isHex(value)) { + throw standardErrors.rpc.invalidParams( + `gasLimitOverride.value must be a hex string at call index ${i}` + ); + } + const gasLimit = hexToBigInt(value); + if (gasLimit === 0n) { + throw standardErrors.rpc.invalidParams( + `gasLimitOverride.value cannot be zero at call index ${i}` + ); + } + } + } +} + type CreateSubAccountSignerParams = { address: Address; owner: OwnerAccount; @@ -118,7 +140,6 @@ export async function createSubAccountSigner({ } case 'wallet_sendCalls': { assertArrayPresence(args.params); - // Get the client for the chain const chainId = get(args.params[0], 'chainId'); if (!chainId) { throw standardErrors.rpc.invalidParams('chainId is required'); @@ -136,16 +157,21 @@ export async function createSubAccountSigner({ throw standardErrors.rpc.invalidParams('calls are required'); } + const calls = args.params[0].calls as { + to: Address; + data: Hex; + value: Hex; + capabilities?: CallCapabilities; + }[]; + + validateGasLimitOverrides(calls); + let prepareCallsRequest: RequestArguments = { method: 'wallet_prepareCalls', params: [ { version: '1.0', - calls: args.params[0].calls as { - to: Address; - data: Hex; - value: Hex; - }[], + calls, chainId: chainId, from: subAccount.address, capabilities: diff --git a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.test.ts b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.test.ts index 1ef001005..82c428b1c 100644 --- a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.test.ts +++ b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.test.ts @@ -1,5 +1,5 @@ import { spendPermissions } from ':store/store.js'; -import { encodeFunctionData, hexToBigInt } from 'viem'; +import { encodeFunctionData, hexToBigInt, numberToHex } from 'viem'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createWalletSendCallsRequest, @@ -21,6 +21,7 @@ vi.mock(':store/store.js', () => ({ vi.mock('viem', () => ({ encodeFunctionData: vi.fn(), hexToBigInt: vi.fn(), + numberToHex: vi.fn(), })); vi.mock('../utils.js', () => ({ @@ -53,6 +54,7 @@ describe('routeThroughGlobalAccount', () => { beforeEach(() => { mockClient = { chain: { id: chainId }, + estimateGas: vi.fn(), }; mockGlobalAccountRequest = vi.fn(); @@ -418,4 +420,164 @@ describe('routeThroughGlobalAccount', () => { }); }); }); + + describe('gasLimitOverride aggregation', () => { + beforeEach(() => { + // Use real hexToBigInt for gas limit tests + vi.mocked(hexToBigInt).mockImplementation((hex) => BigInt(hex)); + vi.mocked(numberToHex).mockImplementation((n) => `0x${(n as bigint).toString(16)}` as any); + }); + + it('should not set gasLimitOverride on batch call when no calls have overrides', async () => { + // @ts-ignore - testing with mock args + args.request.params[0].calls = [ + { to: '0xaaaa', data: '0x', value: '0x1' }, + { to: '0xbbbb', data: '0x', value: '0x0' }, + ]; + mockGlobalAccountRequest.mockResolvedValue('0x1234ca11'); + + await routeThroughGlobalAccount(args); + + // The batch call should not have capabilities + expect(injectRequestCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + params: [ + expect.objectContaining({ + calls: [expect.not.objectContaining({ capabilities: expect.anything() })], + }), + ], + }), + expect.anything() + ); + expect(mockClient.estimateGas).not.toHaveBeenCalled(); + }); + + it('should aggregate gasLimitOverride when all calls have overrides', async () => { + // @ts-ignore - testing with mock args + args.request.params[0].calls = [ + { + to: '0xaaaa', + data: '0x', + value: '0x0', + capabilities: { gasLimitOverride: { value: '0x5208' } }, // 21000 + }, + { + to: '0xbbbb', + data: '0x', + value: '0x0', + capabilities: { gasLimitOverride: { value: '0x7530' } }, // 30000 + }, + ]; + mockGlobalAccountRequest.mockResolvedValue('0x1234ca11'); + + await routeThroughGlobalAccount(args); + + // 21000 + 30000 = 51000, + overhead (2 * 500 safety + 0 input data) = 52000 + expect(numberToHex).toHaveBeenCalledWith(52000n); + expect(mockClient.estimateGas).not.toHaveBeenCalled(); + }); + + it('should estimate gas for calls without overrides and aggregate', async () => { + // @ts-ignore - testing with mock args + args.request.params[0].calls = [ + { + to: '0xaaaa', + data: '0x', + value: '0x0', + capabilities: { gasLimitOverride: { value: '0x5208' } }, // 21000 + }, + { + to: '0xbbbb', + data: '0xabcd', + value: '0x1', + // No gasLimitOverride — needs estimation + }, + ]; + mockClient.estimateGas.mockResolvedValue(50000n); + mockGlobalAccountRequest.mockResolvedValue('0x1234ca11'); + + await routeThroughGlobalAccount(args); + + // Should estimate gas for the second call + expect(mockClient.estimateGas).toHaveBeenCalledTimes(1); + expect(mockClient.estimateGas).toHaveBeenCalledWith({ + account: subAccountAddress, + to: '0xbbbb', + data: '0xabcd', + value: BigInt('0x1'), + }); + + // 21000 + 50000 = 71000, + overhead (2 * 500 safety + 2 bytes * 2 input cost) = 72004 + expect(numberToHex).toHaveBeenCalledWith(72004n); + }); + + it('should estimate gas for all calls when only one has override', async () => { + // @ts-ignore - testing with mock args + args.request.params[0].calls = [ + { + to: '0xaaaa', + data: '0x', + value: '0x0', + // No gasLimitOverride + }, + { + to: '0xbbbb', + data: '0x', + value: '0x0', + capabilities: { gasLimitOverride: { value: '0x2710' } }, // 10000 + }, + { + to: '0xcccc', + data: '0x', + value: '0x0', + // No gasLimitOverride + }, + ]; + mockClient.estimateGas.mockResolvedValue(30000n); + mockGlobalAccountRequest.mockResolvedValue('0x1234ca11'); + + await routeThroughGlobalAccount(args); + + // Should estimate for call 0 and call 2 + expect(mockClient.estimateGas).toHaveBeenCalledTimes(2); + + // 30000 + 10000 + 30000 = 70000, + overhead (3 * 500 safety + 0 input data) = 71500 + expect(numberToHex).toHaveBeenCalledWith(71500n); + }); + + it('should pass aggregated gasLimitOverride on the executeBatch call', async () => { + // @ts-ignore - testing with mock args + args.request.params[0].calls = [ + { + to: '0xaaaa', + data: '0x', + value: '0x0', + capabilities: { gasLimitOverride: { value: '0x5208' } }, + }, + ]; + mockGlobalAccountRequest.mockResolvedValue('0x1234ca11'); + + await routeThroughGlobalAccount(args); + + expect(injectRequestCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + params: [ + expect.objectContaining({ + calls: [ + expect.objectContaining({ + to: subAccountAddress, + capabilities: { + gasLimitOverride: { + value: expect.any(String), + }, + }, + }), + ], + }), + ], + }), + expect.anything() + ); + }); + }); }); diff --git a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts index 029bf2206..fa1a67434 100644 --- a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts +++ b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts @@ -1,4 +1,5 @@ import { RequestArguments } from ':core/provider/interface.js'; +import type { CallCapabilities } from ':core/rpc/wallet_sendCalls.js'; import { spendPermissions } from ':store/store.js'; import { Address, @@ -8,6 +9,7 @@ import { WalletSendCallsParameters, encodeFunctionData, hexToBigInt, + numberToHex, } from 'viem'; import { @@ -44,7 +46,9 @@ export async function routeThroughGlobalAccount({ /** The chain id to use to send the request. */ chainId: number; /** Optional calls to prepend to the request. */ - prependCalls?: { to: Address; data: Hex; value: Hex }[] | undefined; + prependCalls?: + | { to: Address; data: Hex; value: Hex; capabilities?: CallCapabilities }[] + | undefined; /** The function to use to send the request to the global account. */ globalAccountRequest: (request: RequestArguments) => Promise; }) { @@ -80,10 +84,27 @@ export async function routeThroughGlobalAccount({ ], }); + // Aggregate per-call gas limit overrides onto the executeBatch call. + // When any original call has a gasLimitOverride, we estimate gas for calls + // without one, sum everything, and set the total on the batch call sent to + // the global account popup. + const batchCallCapabilities = await aggregateGasLimitOverrides({ + calls: originalSendCallsParams.calls, + client, + subAccountAddress, + }); + // Send using wallet_sendCalls - const calls: { to: Address; data: Hex; value: Hex }[] = [ + const batchCall: { to: Address; data: Hex; value: Hex; capabilities?: CallCapabilities } = { + data: subAccountCallData, + to: subAccountAddress, + value: '0x0', + ...(batchCallCapabilities ? { capabilities: batchCallCapabilities } : {}), + }; + + const calls: { to: Address; data: Hex; value: Hex; capabilities?: CallCapabilities }[] = [ ...(prependCalls ?? []), - { data: subAccountCallData, to: subAccountAddress, value: '0x0' }, + batchCall, ]; const requestToParent = injectRequestCapabilities( @@ -127,3 +148,81 @@ export async function routeThroughGlobalAccount({ return result; } + +/** + * Per-call safety buffer (gas). Matches the backend config + * `safety_buffer_per_call`. + */ +const SAFETY_BUFFER_PER_CALL = 500n; + +/** + * Input data cost per byte (gas). Matches the backend config + * `proportional_input_cost_per_byte`. + */ +const PROPORTIONAL_INPUT_COST_PER_BYTE = 2n; + +/** + * Aggregates per-call gasLimitOverride values from the original calls into a + * single gasLimitOverride for the executeBatch call. For calls without an + * override, gas is estimated via eth_estimateGas. The total includes batch + * processing overhead. + * + * Returns undefined if no original calls have gasLimitOverride set. + */ +async function aggregateGasLimitOverrides({ + calls, + client, + subAccountAddress, +}: { + calls: WalletSendCallsParameters[0]['calls']; + client: PublicClient; + subAccountAddress: Address; +}): Promise { + const hasAnyOverride = calls.some( + (call) => + call.capabilities && + 'gasLimitOverride' in call.capabilities && + (call.capabilities as { gasLimitOverride?: { value?: Hex } }).gasLimitOverride?.value + ); + + if (!hasAnyOverride) { + return undefined; + } + + const gasLimits = await Promise.all( + calls.map(async (call) => { + const override = (call.capabilities as { gasLimitOverride?: { value?: Hex } } | undefined) + ?.gasLimitOverride?.value; + + if (override) { + return hexToBigInt(override); + } + + // Estimate gas for calls without an explicit override + return client.estimateGas({ + account: subAccountAddress, + to: call.to!, + data: call.data ?? '0x', + value: hexToBigInt(call.value ?? '0x0'), + }); + }) + ); + + const totalGas = gasLimits.reduce((sum, gas) => sum + gas, 0n); + + // Calculate input data overhead: 2 gas per byte of calldata per call + const inputDataOverhead = calls.reduce((sum, call) => { + const dataLength = call.data ? BigInt((call.data.length - 2) / 2) : 0n; // hex string minus 0x prefix, 2 chars per byte + return sum + dataLength * PROPORTIONAL_INPUT_COST_PER_BYTE; + }, 0n); + + // Per-call safety buffer (500 gas per call) + input data overhead + const batchOverhead = BigInt(calls.length) * SAFETY_BUFFER_PER_CALL + inputDataOverhead; + const totalWithOverhead = totalGas + batchOverhead; + + return { + gasLimitOverride: { + value: numberToHex(totalWithOverhead), + }, + }; +}