Skip to content

Commit c831acc

Browse files
authored
Add support for wallet_getCapabilities v2 (#1647)
* getCapabilities v2 * bump version * nvm * rm space
1 parent 0adcfd9 commit c831acc

File tree

4 files changed

+355
-3
lines changed

4 files changed

+355
-3
lines changed

packages/wallet-sdk/src/sign/scw/SCWSigner.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,223 @@ describe('SCWSigner', () => {
10701070
});
10711071
});
10721072

1073+
describe('wallet_getCapabilities', () => {
1074+
let stateSpy: MockInstance;
1075+
1076+
beforeEach(() => {
1077+
stateSpy = vi.spyOn(store, 'getState').mockImplementation(() => ({
1078+
account: {
1079+
accounts: [globalAccountAddress],
1080+
capabilities: {
1081+
'0x1': {
1082+
atomicBatch: { supported: true },
1083+
paymasterService: { supported: true }
1084+
},
1085+
'0x5': {
1086+
atomicBatch: { supported: false }
1087+
},
1088+
'0xa': {
1089+
paymasterService: { supported: true }
1090+
}
1091+
},
1092+
},
1093+
chains: [],
1094+
keys: {},
1095+
spendLimits: [],
1096+
config: {
1097+
metadata: mockMetadata,
1098+
preference: { keysUrl: CB_KEYS_URL, options: 'all' },
1099+
version: '1.0.0',
1100+
},
1101+
}));
1102+
1103+
signer['accounts'] = [globalAccountAddress];
1104+
});
1105+
1106+
afterEach(() => {
1107+
stateSpy.mockRestore();
1108+
});
1109+
1110+
it('should return all capabilities when no filter is provided', async () => {
1111+
const request = {
1112+
method: 'wallet_getCapabilities',
1113+
params: [globalAccountAddress],
1114+
};
1115+
1116+
const result = await signer.request(request);
1117+
1118+
expect(result).toEqual({
1119+
'0x1': {
1120+
atomicBatch: { supported: true },
1121+
paymasterService: { supported: true }
1122+
},
1123+
'0x5': {
1124+
atomicBatch: { supported: false }
1125+
},
1126+
'0xa': {
1127+
paymasterService: { supported: true }
1128+
}
1129+
});
1130+
});
1131+
1132+
it('should return filtered capabilities when chain filter is provided', async () => {
1133+
const request = {
1134+
method: 'wallet_getCapabilities',
1135+
params: [globalAccountAddress, ['0x1', '0xa']],
1136+
};
1137+
1138+
const result = await signer.request(request);
1139+
1140+
expect(result).toEqual({
1141+
'0x1': {
1142+
atomicBatch: { supported: true },
1143+
paymasterService: { supported: true }
1144+
},
1145+
'0xa': {
1146+
paymasterService: { supported: true }
1147+
}
1148+
});
1149+
});
1150+
1151+
it('should handle different hex formatting in filters', async () => {
1152+
// Test that '0x01' matches '0x1' capability
1153+
const request = {
1154+
method: 'wallet_getCapabilities',
1155+
params: [globalAccountAddress, ['0x01', '0x05']],
1156+
};
1157+
1158+
const result = await signer.request(request);
1159+
1160+
expect(result).toEqual({
1161+
'0x1': {
1162+
atomicBatch: { supported: true },
1163+
paymasterService: { supported: true }
1164+
},
1165+
'0x5': {
1166+
atomicBatch: { supported: false }
1167+
}
1168+
});
1169+
});
1170+
1171+
it('should return empty object when filter matches no capabilities', async () => {
1172+
const request = {
1173+
method: 'wallet_getCapabilities',
1174+
params: [globalAccountAddress, ['0x99', '0x100']],
1175+
};
1176+
1177+
const result = await signer.request(request);
1178+
1179+
expect(result).toEqual({});
1180+
});
1181+
1182+
it('should return empty object when capabilities is undefined', async () => {
1183+
stateSpy.mockImplementation(() => ({
1184+
account: {
1185+
accounts: [globalAccountAddress],
1186+
capabilities: undefined,
1187+
},
1188+
chains: [],
1189+
keys: {},
1190+
spendLimits: [],
1191+
config: {
1192+
metadata: mockMetadata,
1193+
preference: { keysUrl: CB_KEYS_URL, options: 'all' },
1194+
version: '1.0.0',
1195+
},
1196+
}));
1197+
1198+
const request = {
1199+
method: 'wallet_getCapabilities',
1200+
params: [globalAccountAddress],
1201+
};
1202+
1203+
const result = await signer.request(request);
1204+
1205+
expect(result).toEqual({});
1206+
});
1207+
1208+
it('should return empty object when empty filter array is provided', async () => {
1209+
const request = {
1210+
method: 'wallet_getCapabilities',
1211+
params: [globalAccountAddress, []],
1212+
};
1213+
1214+
const result = await signer.request(request);
1215+
1216+
expect(result).toEqual({
1217+
'0x1': {
1218+
atomicBatch: { supported: true },
1219+
paymasterService: { supported: true }
1220+
},
1221+
'0x5': {
1222+
atomicBatch: { supported: false }
1223+
},
1224+
'0xa': {
1225+
paymasterService: { supported: true }
1226+
}
1227+
});
1228+
});
1229+
1230+
it('should handle capabilities with non-hex keys gracefully', async () => {
1231+
stateSpy.mockImplementation(() => ({
1232+
account: {
1233+
accounts: [globalAccountAddress],
1234+
capabilities: {
1235+
'0x1': { atomicBatch: { supported: true } },
1236+
'invalid-key': { someFeature: true },
1237+
'0x5': { paymasterService: { supported: true } }
1238+
},
1239+
},
1240+
chains: [],
1241+
keys: {},
1242+
spendLimits: [],
1243+
config: {
1244+
metadata: mockMetadata,
1245+
preference: { keysUrl: CB_KEYS_URL, options: 'all' },
1246+
version: '1.0.0',
1247+
},
1248+
}));
1249+
1250+
const request = {
1251+
method: 'wallet_getCapabilities',
1252+
params: [globalAccountAddress, ['0x1']],
1253+
};
1254+
1255+
const result = await signer.request(request);
1256+
1257+
expect(result).toEqual({
1258+
'0x1': { atomicBatch: { supported: true } }
1259+
});
1260+
});
1261+
1262+
it('should throw error when account is not in accounts list', async () => {
1263+
const request = {
1264+
method: 'wallet_getCapabilities',
1265+
params: [subAccountAddress],
1266+
};
1267+
1268+
await expect(signer.request(request)).rejects.toThrow('no active account found');
1269+
});
1270+
1271+
it('should throw error when account parameter is invalid', async () => {
1272+
const request = {
1273+
method: 'wallet_getCapabilities',
1274+
params: ['invalid-address'],
1275+
};
1276+
1277+
await expect(signer.request(request)).rejects.toThrow();
1278+
});
1279+
1280+
it('should throw error when filter contains invalid hex strings', async () => {
1281+
const request = {
1282+
method: 'wallet_getCapabilities',
1283+
params: [globalAccountAddress, ['0x1', 'invalid-hex']],
1284+
};
1285+
1286+
await expect(signer.request(request)).rejects.toThrow();
1287+
});
1288+
});
1289+
10731290
describe('coinbase_fetchPermissions', () => {
10741291
const mockSpendLimits = [
10751292
{

packages/wallet-sdk/src/sign/scw/SCWSigner.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CB_WALLET_RPC_URL } from ':core/constants.js';
2-
import { Hex, hexToNumber, numberToHex } from 'viem';
2+
import { Hex, hexToNumber, isAddressEqual, numberToHex } from 'viem';
33

44
import { Communicator } from ':core/communicator/Communicator.js';
55
import { isActionableHttpRequestError, isViemError, standardErrors } from ':core/error/errors.js';
@@ -27,6 +27,7 @@ import { SCWKeyManager } from './SCWKeyManager.js';
2727
import {
2828
addSenderToRequest,
2929
assertFetchPermissionsRequest,
30+
assertGetCapabilitiesParams,
3031
assertParamsChainId,
3132
fillMissingParamsForFetchPermissions,
3233
getCachedWalletConnectResponse,
@@ -170,7 +171,7 @@ export class SCWSigner implements Signer {
170171
case 'eth_chainId':
171172
return numberToHex(this.chain.id);
172173
case 'wallet_getCapabilities':
173-
return store.getState().account.capabilities;
174+
return this.handleGetCapabilitiesRequest(request);
174175
case 'wallet_switchEthereumChain':
175176
return this.handleSwitchChainRequest(request);
176177
case 'eth_ecRecover':
@@ -343,6 +344,47 @@ export class SCWSigner implements Signer {
343344
return popupResult;
344345
}
345346

347+
private async handleGetCapabilitiesRequest(request: RequestArguments) {
348+
assertGetCapabilitiesParams(request.params);
349+
350+
const requestedAccount = request.params[0];
351+
const filterChainIds = request.params[1]; // Optional second parameter
352+
353+
if (!this.accounts.some((account) => isAddressEqual(account, requestedAccount))) {
354+
throw standardErrors.provider.unauthorized('no active account found');
355+
}
356+
357+
const capabilities = store.getState().account.capabilities;
358+
359+
// Return empty object if capabilities is undefined
360+
if (!capabilities) {
361+
return {};
362+
}
363+
364+
// If no filter is provided, return all capabilities
365+
if (!filterChainIds || filterChainIds.length === 0) {
366+
return capabilities;
367+
}
368+
369+
// Convert filter chain IDs to numbers once for efficient lookup
370+
const filterChainNumbers = new Set(filterChainIds.map(chainId => hexToNumber(chainId)));
371+
372+
// Filter capabilities
373+
const filteredCapabilities = Object.fromEntries(
374+
Object.entries(capabilities).filter(([capabilityKey]) => {
375+
try {
376+
const capabilityChainNumber = hexToNumber(capabilityKey as `0x${string}`);
377+
return filterChainNumbers.has(capabilityChainNumber);
378+
} catch {
379+
// If capabilityKey is not a valid hex string, exclude it
380+
return false;
381+
}
382+
})
383+
);
384+
385+
return filteredCapabilities;
386+
}
387+
346388
private async sendEncryptedRequest(request: RequestArguments): Promise<RPCResponseMessage> {
347389
const sharedSecret = await this.keyManager.getSharedSecret();
348390
if (!sharedSecret) {

packages/wallet-sdk/src/sign/scw/utils.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
SpendPermissionBatch,
55
addSenderToRequest,
66
assertFetchPermissionsRequest,
7+
assertGetCapabilitiesParams,
78
assertParamsChainId,
89
createSpendPermissionBatchMessage,
910
createWalletSendCallsRequest,
@@ -16,6 +17,11 @@ import {
1617
requestHasCapability,
1718
} from './utils.js';
1819

20+
// Valid Ethereum addresses for testing
21+
const VALID_ADDRESS_1 = '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54';
22+
const VALID_ADDRESS_2 = '0x7838d2724FC686813CAf81d4429beff1110c739a';
23+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
24+
1925
describe('utils', () => {
2026
describe('getSenderFromRequest', () => {
2127
const sender = '0x123';
@@ -83,6 +89,71 @@ describe('assertParamsChainId', () => {
8389
});
8490
});
8591

92+
describe('assertGetCapabilitiesParams', () => {
93+
it('should throw if params is null or undefined', () => {
94+
expect(() => assertGetCapabilitiesParams(null)).toThrow();
95+
expect(() => assertGetCapabilitiesParams(undefined)).toThrow();
96+
});
97+
98+
it('should throw if params is not an array', () => {
99+
expect(() => assertGetCapabilitiesParams({})).toThrow();
100+
expect(() => assertGetCapabilitiesParams('0x123')).toThrow();
101+
expect(() => assertGetCapabilitiesParams(123)).toThrow();
102+
});
103+
104+
it('should throw if params array is empty', () => {
105+
expect(() => assertGetCapabilitiesParams([])).toThrow();
106+
});
107+
108+
it('should throw if params array has more than 2 elements', () => {
109+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1'], 'extra'])).toThrow();
110+
});
111+
112+
it('should throw if first param is not a string', () => {
113+
expect(() => assertGetCapabilitiesParams([123])).toThrow();
114+
expect(() => assertGetCapabilitiesParams([null])).toThrow();
115+
expect(() => assertGetCapabilitiesParams([{}])).toThrow();
116+
});
117+
118+
it('should throw if first param is not a valid Ethereum address', () => {
119+
expect(() => assertGetCapabilitiesParams(['123'])).toThrow();
120+
expect(() => assertGetCapabilitiesParams(['0x123'])).toThrow(); // Too short
121+
expect(() => assertGetCapabilitiesParams(['0x123abc'])).toThrow(); // Too short
122+
expect(() => assertGetCapabilitiesParams(['xyz123'])).toThrow(); // No 0x prefix
123+
expect(() => assertGetCapabilitiesParams(['0x12345678901234567890123456789012345678gg'])).toThrow(); // Invalid hex characters
124+
expect(() => assertGetCapabilitiesParams(['0x123456789012345678901234567890123456789'])).toThrow(); // Too short (39 chars)
125+
expect(() => assertGetCapabilitiesParams(['0x12345678901234567890123456789012345678901'])).toThrow(); // Too long (41 chars)
126+
});
127+
128+
it('should not throw for valid single parameter (valid Ethereum address)', () => {
129+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1])).not.toThrow();
130+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_2])).not.toThrow();
131+
expect(() => assertGetCapabilitiesParams([ZERO_ADDRESS])).not.toThrow();
132+
});
133+
134+
it('should throw if second param is not an array when present', () => {
135+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, '0x1'])).toThrow();
136+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, 123])).toThrow();
137+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, null])).toThrow();
138+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, {}])).toThrow();
139+
});
140+
141+
it('should throw if second param array contains non-hex strings', () => {
142+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', '123']])).toThrow();
143+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', 123]])).toThrow();
144+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', null]])).toThrow();
145+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['abc123']])).toThrow();
146+
});
147+
148+
it('should not throw for valid parameters with filter array', () => {
149+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, []])).not.toThrow();
150+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1']])).not.toThrow();
151+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', '0x2', '0x3']])).not.toThrow();
152+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0xabcdef', '0x0']])).not.toThrow();
153+
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_2, ['0x1', '0xa']])).not.toThrow();
154+
});
155+
});
156+
86157
describe('injectRequestCapabilities', () => {
87158
const capabilities = {
88159
addSubAccount: {

0 commit comments

Comments
 (0)