Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions packages/account-sdk/src/core/rpc/wallet_prepareCalls.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
},
];
Expand Down
38 changes: 38 additions & 0 deletions packages/account-sdk/src/core/rpc/wallet_sendCalls.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
},
];

export type WalletSendCallsSchema = {
Method: 'wallet_sendCalls';
Parameters: WalletSendCallsParams;
ReturnType: Hex;
};
178 changes: 150 additions & 28 deletions packages/account-sdk/src/sign/base-account/Signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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 },
Expand All @@ -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 },
Expand All @@ -1816,44 +1825,23 @@ 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']],
};

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, []],
Expand All @@ -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 },
Expand Down Expand Up @@ -1903,6 +1894,7 @@ describe('Signer', () => {
const result = await signer.request(request);

expect(result).toEqual({
'0x0': { gasLimitOverride: { supported: true } },
'0x1': { atomicBatch: { supported: true } },
});
});
Expand Down Expand Up @@ -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', () => {
Expand Down
31 changes: 21 additions & 10 deletions packages/account-sdk/src/sign/base-account/Signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -470,37 +473,45 @@ 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(
'no active account found when getting capabilities'
);
}

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;
}
})
Expand Down
2 changes: 1 addition & 1 deletion packages/account-sdk/src/sign/base-account/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }[];
from: Address;
chainId: number;
capabilities?: Record<string, unknown>;
Expand Down
Loading
Loading