@lukasrosario/account
Version:
Base Account SDK
707 lines (641 loc) • 22.3 kB
text/typescript
import { store } from ':store/store.js';
import { hashTypedData, hexToBigInt, numberToHex } from 'viem';
import {
SpendPermissionBatch,
addSenderToRequest,
appendWithoutDuplicates,
assertFetchPermissionsRequest,
assertGetCapabilitiesParams,
assertParamsChainId,
createSpendPermissionBatchMessage,
createWalletSendCallsRequest,
fillMissingParamsForFetchPermissions,
getCachedWalletConnectResponse,
getSenderFromRequest,
initSubAccountConfig,
injectRequestCapabilities,
prependWithoutDuplicates,
requestHasCapability,
} from './utils.js';
// Valid Ethereum addresses for testing
const VALID_ADDRESS_1 = '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54';
const VALID_ADDRESS_2 = '0x7838d2724FC686813CAf81d4429beff1110c739a';
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
describe('utils', () => {
describe('getSenderFromRequest', () => {
const sender = '0x123';
it.each([
['eth_signTransaction', [{ from: sender }], sender],
['eth_sendTransaction', [{ from: sender }], sender],
['wallet_sendCalls', [{ from: sender }], sender],
['eth_signTypedData_v4', [sender, {}], sender],
['personal_sign', ['message', sender], sender],
])('should return the sender from the request for %s', (method, params, sender) => {
const request = { method, params };
expect(getSenderFromRequest(request)).toBe(sender);
});
});
describe('addSenderToRequest', () => {
it.each([
['eth_signTransaction', [{}], [{ from: '0x123' }]],
['eth_sendTransaction', [{}], [{ from: '0x123' }]],
['wallet_sendCalls', [{}], [{ from: '0x123' }]],
['eth_signTypedData_v4', [undefined, {}], ['0x123', {}]],
['personal_sign', ['hello', undefined], ['hello', '0x123']],
])('should enhance the request params for %s', (method, params, expectedParams) => {
const request = { method, params };
const sender = '0x123';
expect(addSenderToRequest(request, sender)).toEqual({
method,
params: expectedParams,
});
});
});
});
describe('assertParamsChainId', () => {
it('should throw if the params are not an array', () => {
expect(() => assertParamsChainId({})).toThrow();
});
it('should throw if the params array is empty', () => {
expect(() => assertParamsChainId([])).toThrow();
});
it('should throw if the first param does not have a chainId property', () => {
expect(() => assertParamsChainId([{}])).toThrow();
});
it('should throw if the chainId is not a string or number', () => {
expect(() => assertParamsChainId([{ chainId: true }])).toThrow();
expect(() => assertParamsChainId([{ chainId: null }])).toThrow();
expect(() => assertParamsChainId([{ chainId: undefined }])).toThrow();
expect(() => assertParamsChainId([{ chainId: {} }])).toThrow();
});
it('should not throw if the chainId is a string', () => {
expect(() => assertParamsChainId([{ chainId: '0x1' }])).not.toThrow();
});
it('should not throw if the chainId is a number', () => {
expect(() => assertParamsChainId([{ chainId: 1 }])).not.toThrow();
});
it('should throw if params is null or undefined', () => {
expect(() => assertParamsChainId(null)).toThrow();
expect(() => assertParamsChainId(undefined)).toThrow();
});
});
describe('assertGetCapabilitiesParams', () => {
it('should throw if params is null or undefined', () => {
expect(() => assertGetCapabilitiesParams(null)).toThrow();
expect(() => assertGetCapabilitiesParams(undefined)).toThrow();
});
it('should throw if params is not an array', () => {
expect(() => assertGetCapabilitiesParams({})).toThrow();
expect(() => assertGetCapabilitiesParams('0x123')).toThrow();
expect(() => assertGetCapabilitiesParams(123)).toThrow();
});
it('should throw if params array is empty', () => {
expect(() => assertGetCapabilitiesParams([])).toThrow();
});
it('should throw if params array has more than 2 elements', () => {
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1'], 'extra'])).toThrow();
});
it('should throw if first param is not a string', () => {
expect(() => assertGetCapabilitiesParams([123])).toThrow();
expect(() => assertGetCapabilitiesParams([null])).toThrow();
expect(() => assertGetCapabilitiesParams([{}])).toThrow();
});
it('should throw if first param is not a valid Ethereum address', () => {
expect(() => assertGetCapabilitiesParams(['123'])).toThrow();
expect(() => assertGetCapabilitiesParams(['0x123'])).toThrow(); // Too short
expect(() => assertGetCapabilitiesParams(['0x123abc'])).toThrow(); // Too short
expect(() => assertGetCapabilitiesParams(['xyz123'])).toThrow(); // No 0x prefix
expect(() =>
assertGetCapabilitiesParams(['0x12345678901234567890123456789012345678gg'])
).toThrow(); // Invalid hex characters
expect(() =>
assertGetCapabilitiesParams(['0x123456789012345678901234567890123456789'])
).toThrow(); // Too short (39 chars)
expect(() =>
assertGetCapabilitiesParams(['0x12345678901234567890123456789012345678901'])
).toThrow(); // Too long (41 chars)
});
it('should not throw for valid single parameter (valid Ethereum address)', () => {
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1])).not.toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_2])).not.toThrow();
expect(() => assertGetCapabilitiesParams([ZERO_ADDRESS])).not.toThrow();
});
it('should throw if second param is not an array when present', () => {
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, '0x1'])).toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, 123])).toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, null])).toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, {}])).toThrow();
});
it('should throw if second param array contains non-hex strings', () => {
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', '123']])).toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', 123]])).toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', null]])).toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['abc123']])).toThrow();
});
it('should not throw for valid parameters with filter array', () => {
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, []])).not.toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1']])).not.toThrow();
expect(() =>
assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0x1', '0x2', '0x3']])
).not.toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_1, ['0xabcdef', '0x0']])).not.toThrow();
expect(() => assertGetCapabilitiesParams([VALID_ADDRESS_2, ['0x1', '0xa']])).not.toThrow();
});
});
describe('injectRequestCapabilities', () => {
const capabilities = {
addSubAccount: {
account: {
type: 'create',
keys: [
{
type: 'address',
publicKey: '0x123',
},
],
},
},
};
it('should merge capabilities for wallet_connect method', () => {
const siweCapability = {
chainId: 84532,
nonce: Math.random().toString(36).substring(2, 15),
};
const request = {
method: 'wallet_connect',
params: [
{
someParam: 'value',
capabilities: {
signInWithEthereum: siweCapability,
},
},
],
};
const result = injectRequestCapabilities(request, capabilities);
expect(result).toEqual({
method: 'wallet_connect',
params: [
{
someParam: 'value',
capabilities: {
...capabilities,
signInWithEthereum: siweCapability,
},
},
],
});
});
it('should merge capabilities for wallet_sendCalls method', () => {
const paymasterCapability = {
url: 'https://paymaster.example.com',
};
const request = {
method: 'wallet_sendCalls',
params: [
{
version: '1.0',
existingParam: 'test',
capabilities: {
paymasterService: paymasterCapability,
},
},
],
};
const result = injectRequestCapabilities(request, capabilities);
expect(result).toEqual({
method: 'wallet_sendCalls',
params: [
{
version: '1.0',
existingParam: 'test',
capabilities: {
...capabilities,
paymasterService: paymasterCapability,
},
},
],
});
});
it('should not modify request for other methods', () => {
const request = {
method: 'eth_sendTransaction',
params: [{ someParam: 'value' }],
};
const result = injectRequestCapabilities(request, capabilities);
expect(result).toEqual(request);
});
it('should throw if capabilities is not an object', () => {
const request = {
method: 'wallet_sendCalls',
params: [
{
capabilities: 'invalid',
},
],
};
expect(() => injectRequestCapabilities(request, capabilities)).toThrow();
});
});
describe('initSubAccountConfig', () => {
it('should initialize the sub account config', async () => {
store.subAccountsConfig.set({
enableAutoSubAccounts: true,
toOwnerAccount: vi.fn().mockResolvedValue({
account: {
address: '0x123',
type: 'local',
},
}),
});
await initSubAccountConfig();
const config = store.subAccountsConfig.get();
expect(config?.capabilities?.addSubAccount).toBeDefined();
});
});
describe('assertFetchPermissionsRequest', () => {
it('should throw if the request is not a fetch permissions request', () => {
expect(() => assertFetchPermissionsRequest({ method: 'eth_sendTransaction' })).toThrow();
});
it('should throw if the request params are the correct shape', () => {
expect(() =>
assertFetchPermissionsRequest({
method: 'coinbase_fetchPermissions',
params: [{ account: 'non-hex-string' }],
})
).toThrow();
});
it('should pass if the params is omitted completely', () => {
expect(() =>
assertFetchPermissionsRequest({ method: 'coinbase_fetchPermissions' })
).not.toThrow();
});
it('should pass if the params is correct shape', () => {
expect(() =>
assertFetchPermissionsRequest({
method: 'coinbase_fetchPermissions',
params: [{ account: '0x123', chainId: '0x123', spender: '0x123' }],
})
).not.toThrow();
});
});
describe('fillMissingParamsForFetchPermissions', () => {
it('should return the request if the params are present', () => {
const request = {
method: 'coinbase_fetchPermissions',
params: [{ account: '0x123', chainId: '0x123', spender: '0x123' }],
};
assertFetchPermissionsRequest(request);
expect(fillMissingParamsForFetchPermissions(request)).toEqual(request);
});
it('should fill in the missing params if the params are not present', () => {
vi.spyOn(store, 'getState').mockImplementation(() => ({
account: {
accounts: ['0x123'],
chain: { id: 1 },
},
subAccount: { address: '0x456' },
chains: [],
keys: {},
spendPermissions: [],
config: {
version: '1.0.0',
},
}));
const request = {
method: 'coinbase_fetchPermissions',
};
assertFetchPermissionsRequest(request);
expect(fillMissingParamsForFetchPermissions(request)).toEqual({
method: 'coinbase_fetchPermissions',
params: [{ account: '0x123', chainId: '0x1', spender: '0x456' }],
});
});
});
describe('createSpendPermissionBatchMessage', () => {
it('should create a correctly structured batch message that produces the expected hash', () => {
const spendPermissionBatch: SpendPermissionBatch = {
account: '0x1234567890123456789012345678901234567890' as `0x${string}`,
period: 86400,
start: 1745516872,
end: 1748108872,
permissions: [
{
spender: '0xabcdef0123456789abcdef0123456789abcdef01' as `0x${string}`,
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as `0x${string}`,
allowance: numberToHex(BigInt('1000000000000')),
salt: '0x1',
extraData: '0x',
},
{
spender: '0x2222222222222222222222222222222222222222' as `0x${string}`,
token: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as `0x${string}`,
allowance: numberToHex(BigInt('1000000000000000000')),
salt: '0x2',
extraData: '0x1234',
},
],
} as const;
const message = createSpendPermissionBatchMessage({
spendPermissionBatch,
chainId: 8453, // Using mainnet chain ID
});
// Convert hex values to bigint for EIP-712 typed data
const typedDataMessage = {
...message.message,
permissions: message.message.permissions.map((p) => ({
...p,
allowance: hexToBigInt(p.allowance),
salt: hexToBigInt(p.salt),
})),
};
const hash = hashTypedData({
domain: message.domain,
types: message.types,
primaryType: message.primaryType,
message: typedDataMessage,
});
expect(hash).toEqual('0x010415415ed40b5f566f89869e2aa4cd26c6af3b22710dda12c6bd1c906095d3');
});
});
describe('createWalletSendCallsRequest', () => {
it('should inject paymaster url if provided', () => {
// mock store config
vi.spyOn(store.config, 'get').mockReturnValue({
paymasterUrls: {
1: 'https://paymaster.example.com',
},
version: '1.0.0',
});
const request = createWalletSendCallsRequest({
calls: [
{
to: '0x123',
data: '0x123',
value: '0x123',
},
],
from: '0x123',
chainId: 1,
});
expect(request).toEqual({
method: 'wallet_sendCalls',
params: [
expect.objectContaining({
capabilities: {
paymasterService: { url: 'https://paymaster.example.com' },
},
}),
],
});
});
});
describe('requestCapabilities', () => {
describe('requestHasCapability', () => {
it('returns false for requests without params', () => {
expect(requestHasCapability({} as any, 'test')).toBe(false);
expect(requestHasCapability({ params: null } as any, 'test')).toBe(false);
});
it('returns false for requests without capabilities', () => {
expect(requestHasCapability({ params: [] } as any, 'test')).toBe(false);
expect(requestHasCapability({ params: [{}] } as any, 'test')).toBe(false);
});
it('returns false when capabilities is not an object', () => {
expect(
requestHasCapability({ params: [{ capabilities: 'not-an-object' }] } as any, 'test')
).toBe(false);
});
it('returns true when capability exists', () => {
expect(
requestHasCapability(
{ params: [{ capabilities: { testCapability: true } }] } as any,
'testCapability'
)
).toBe(true);
});
it('returns false when capability does not exist', () => {
expect(
requestHasCapability(
{ params: [{ capabilities: { otherCapability: true } }] } as any,
'testCapability'
)
).toBe(false);
});
});
});
describe('prependWithoutDuplicates', () => {
it('should prepend an item to an array without duplicates', () => {
expect(prependWithoutDuplicates(['1', '2', '3'], '4')).toEqual(['4', '1', '2', '3']);
});
it('should not prepend an item to an array if it is already present', () => {
expect(prependWithoutDuplicates(['1', '2', '3'], '2')).toEqual(['2', '1', '3']);
});
});
describe('appendWithoutDuplicates', () => {
it('should append an item to an array without duplicates', () => {
expect(appendWithoutDuplicates(['1', '2', '3'], '4')).toEqual(['1', '2', '3', '4']);
});
it('should move an existing item to the end of the array', () => {
expect(appendWithoutDuplicates(['1', '2', '3'], '2')).toEqual(['1', '3', '2']);
});
});
describe('getCachedWalletConnectResponse', () => {
beforeEach(() => {
vi.spyOn(store.spendPermissions, 'get').mockReturnValue([]);
vi.spyOn(store.subAccounts, 'get').mockReturnValue(undefined);
vi.spyOn(store.account, 'get').mockReturnValue({ accounts: undefined });
});
it('should return null if no accounts exist', async () => {
const result = await getCachedWalletConnectResponse();
expect(result).toBeNull();
});
it('should return accounts with no capabilities if no spend permissions or sub accounts', async () => {
vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123', '0x456'] });
const result = await getCachedWalletConnectResponse();
expect(result).toEqual({
accounts: [
{
address: '0x123',
capabilities: {
subAccounts: undefined,
spendPermissions: undefined,
},
},
{
address: '0x456',
capabilities: {
subAccounts: undefined,
spendPermissions: undefined,
},
},
],
});
});
it('should include sub account capability if sub account exists', async () => {
vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] });
vi.spyOn(store.subAccounts, 'get').mockReturnValue({
address: '0xsub',
factory: '0xfactory',
factoryData: '0xdata',
});
const result = await getCachedWalletConnectResponse();
expect(result).toEqual({
accounts: [
{
address: '0x123',
capabilities: {
subAccounts: [
{
address: '0xsub',
factory: '0xfactory',
factoryData: '0xdata',
},
],
spendPermissions: undefined,
},
},
],
});
});
it('should include spend permissions capability if spend permissions exist', async () => {
vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] });
vi.spyOn(store.spendPermissions, 'get').mockReturnValue([
{
signature: '0xsig1',
chainId: 1,
permission: {
account: '0x123',
spender: '0xspender1',
token: '0xtoken1',
allowance: '1000000',
period: 86400,
start: 1234567890,
end: 1234567890 + 86400,
salt: '0xsalt1',
extraData: '0x',
},
},
{
signature: '0xsig2',
chainId: 1,
permission: {
account: '0x123',
spender: '0xspender2',
token: '0xtoken2',
allowance: '2000000',
period: 86400,
start: 1234567890,
end: 1234567890 + 86400,
salt: '0xsalt2',
extraData: '0x',
},
},
]);
const result = await getCachedWalletConnectResponse();
expect(result).toEqual({
accounts: [
{
address: '0x123',
capabilities: {
subAccounts: undefined,
spendPermissions: {
permissions: [
{
signature: '0xsig1',
chainId: 1,
permission: {
account: '0x123',
spender: '0xspender1',
token: '0xtoken1',
allowance: '1000000',
period: 86400,
start: 1234567890,
end: 1234567890 + 86400,
salt: '0xsalt1',
extraData: '0x',
},
},
{
signature: '0xsig2',
chainId: 1,
permission: {
account: '0x123',
spender: '0xspender2',
token: '0xtoken2',
allowance: '2000000',
period: 86400,
start: 1234567890,
end: 1234567890 + 86400,
salt: '0xsalt2',
extraData: '0x',
},
},
],
},
},
},
],
});
});
it('should include both sub account and spend permissions capabilities if both exist', async () => {
vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] });
vi.spyOn(store.subAccounts, 'get').mockReturnValue({
address: '0xsub',
factory: '0xfactory',
factoryData: '0xdata',
});
vi.spyOn(store.spendPermissions, 'get').mockReturnValue([
{
signature: '0xsig1',
chainId: 1,
permission: {
account: '0x123',
spender: '0xspender1',
token: '0xtoken1',
allowance: '1000000',
period: 86400,
start: 1234567890,
end: 1234567890 + 86400,
salt: '0xsalt1',
extraData: '0x',
},
},
]);
const result = await getCachedWalletConnectResponse();
expect(result).toEqual({
accounts: [
{
address: '0x123',
capabilities: {
subAccounts: [
{
address: '0xsub',
factory: '0xfactory',
factoryData: '0xdata',
},
],
spendPermissions: {
permissions: [
{
signature: '0xsig1',
chainId: 1,
permission: {
account: '0x123',
spender: '0xspender1',
token: '0xtoken1',
allowance: '1000000',
period: 86400,
start: 1234567890,
end: 1234567890 + 86400,
salt: '0xsalt1',
extraData: '0x',
},
},
],
},
},
},
],
});
});
});