Skip to content

feat: sub accounts data suffix support #1613

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 25, 2025
Merged
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
61 changes: 59 additions & 2 deletions examples/testapp/src/pages/auto-sub-account/index.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
FormControl,
FormLabel,
HStack,
Input,
Radio,
RadioGroup,
Stack,
VStack,
} from '@chakra-ui/react';
import { getCryptoKeyAccount } from '@coinbase/wallet-sdk';
import { SpendLimitConfig } from '@coinbase/wallet-sdk/dist/core/provider/interface';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { numberToHex, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { baseSepolia } from 'viem/chains';
Expand All @@ -27,7 +28,7 @@ export default function AutoSubAccount() {
const [lastResult, setLastResult] = useState<string>();
const [sendingAmounts, setSendingAmounts] = useState<Record<number, boolean>>({});
const [signerType, setSignerType] = useState<SignerType>('cryptokey');
const { subAccountsConfig, setSubAccountsConfig } = useConfig();
const { subAccountsConfig, setSubAccountsConfig, config, setConfig } = useConfig();
const { provider } = useEIP1193Provider();

useEffect(() => {
Expand Down Expand Up @@ -185,6 +186,42 @@ export default function AutoSubAccount() {
}
};

const handleAttributionDataSuffixChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value) {
setConfig({
...config,
attribution: { dataSuffix: value as `0x${string}` },
});
} else {
const { attribution, ...rest } = config;
setConfig(rest);
}
};

const handleAttributionModeChange = (value: string) => {
if (value === 'auto') {
setConfig({
...config,
attribution: { auto: true },
});
} else if (value === 'manual') {
setConfig({
...config,
attribution: { dataSuffix: '0x' as `0x${string}` },
});
} else {
const { attribution, ...restConfig } = config;
setConfig(restConfig);
}
};

const getAttributionMode = () => {
if (!config.attribution) return 'none';
if (config.attribution.auto) return 'auto';
return 'manual';
};

return (
<Container mb={16}>
<VStack w="full" spacing={4}>
Expand Down Expand Up @@ -238,6 +275,26 @@ export default function AutoSubAccount() {
</Stack>
</RadioGroup>
</FormControl>
<FormControl>
<FormLabel>Attribution</FormLabel>
<RadioGroup value={getAttributionMode()} onChange={handleAttributionModeChange}>
<Stack direction="row">
<Radio value="none">None</Radio>
<Radio value="auto">Auto</Radio>
<Radio value="manual">Manual</Radio>
</Stack>
</RadioGroup>
</FormControl>
{getAttributionMode() === 'manual' && (
<FormControl>
<FormLabel>Attribution Data Suffix (hex)</FormLabel>
<Input
placeholder="0x..."
value={config.attribution?.dataSuffix || ''}
onChange={handleAttributionDataSuffixChange}
/>
</FormControl>
)}
<Button w="full" onClick={handleRequestAccounts}>
eth_requestAccounts
</Button>
Expand Down
17 changes: 12 additions & 5 deletions packages/wallet-sdk/src/sign/scw/SCWSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getSenderFromRequest,
initSubAccountConfig,
injectRequestCapabilities,
makeDataSuffix,
} from './utils.js';
import { createSubAccountSigner } from './utils/createSubAccountSigner.js';
import { handleInsufficientBalanceError } from './utils/handleInsufficientBalance.js';
Expand Down Expand Up @@ -471,16 +472,17 @@ export class SCWSigner implements Signer {

private async sendRequestToSubAccountSigner(request: RequestArguments) {
const subAccount = store.subAccounts.get();
const config = store.subAccountsConfig.get() ?? {};
const subAccountsConfig = store.subAccountsConfig.get();
const config = store.config.get();

assertPresence(
subAccount?.address,
standardErrors.provider.unauthorized('no active sub account')
);

// Get the owner account from the config
const ownerAccount = config?.toOwnerAccount
? await config.toOwnerAccount()
const ownerAccount = subAccountsConfig?.toOwnerAccount
? await subAccountsConfig.toOwnerAccount()
: await getCryptoKeyAccount();

assertPresence(
Expand Down Expand Up @@ -509,6 +511,10 @@ export class SCWSigner implements Signer {
globalAccountAddress,
standardErrors.provider.unauthorized('no global account found')
);
const dataSuffix = makeDataSuffix({
attribution: config.preference?.attribution,
dappOrigin: window.location.origin,
});

const { request: subAccountRequest } = await createSubAccountSigner({
address: subAccount.address,
Expand All @@ -517,6 +523,7 @@ export class SCWSigner implements Signer {
factory: subAccount.factory,
factoryData: subAccount.factoryData,
parentAddress: globalAccountAddress,
attribution: dataSuffix ? { suffix: dataSuffix } : undefined,
});

try {
Expand All @@ -536,8 +543,8 @@ export class SCWSigner implements Signer {
if (
!(
isActionableHttpRequestError(errorObject) &&
config?.dynamicSpendLimits &&
config.enableAutoSubAccounts &&
subAccountsConfig?.dynamicSpendLimits &&
subAccountsConfig?.enableAutoSubAccounts &&
errorObject.data
)
) {
Expand Down
31 changes: 28 additions & 3 deletions packages/wallet-sdk/src/sign/scw/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Hex, PublicClient, WalletSendCallsParameters, hexToBigInt, numberToHex } from 'viem';
import { PublicClient, WalletSendCallsParameters, hexToBigInt } from 'viem';

import { InsufficientBalanceErrorData, standardErrors } from ':core/error/errors.js';
import { RequestArguments } from ':core/provider/interface.js';
import { InsufficientBalanceErrorData } from ':core/error/errors.js';
import { Hex, keccak256, numberToHex, slice, toHex } from 'viem';

import { standardErrors } from ':core/error/errors.js';
import { Attribution, RequestArguments } from ':core/provider/interface.js';
import {
EmptyFetchPermissionsRequest,
FetchPermissionsRequest,
Expand Down Expand Up @@ -488,3 +491,25 @@ export function isEthSendTransactionParams(params: unknown): params is [
'to' in params[0]
);
}
export function compute16ByteHash(input: string): Hex {
return slice(keccak256(toHex(input)), 0, 16);
}

export function makeDataSuffix({
attribution,
dappOrigin,
}: { attribution?: Attribution; dappOrigin: string }): Hex | undefined {
if (!attribution) {
return;
}

if ('auto' in attribution && attribution.auto && dappOrigin) {
return compute16ByteHash(dappOrigin);
}

if ('dataSuffix' in attribution) {
return attribution.dataSuffix;
}

return;
}
Original file line number Diff line number Diff line change
Expand Up @@ -384,4 +384,88 @@ describe('createSubAccountSigner', () => {
})
);
});

it('handles attribution', 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'];
}

if (args.method === 'wallet_getCallsStatus') {
return {
status: 'CONFIRMED',
receipts: [
{
logs: [],
status: 1,
blockHash: '0x',
blockNumber: 1,
gasUsed: 130161,
transactionHash: '0x',
},
],
};
}

return undefined;
});
(getClient as any).mockReturnValue({
request,
getChainId: vi.fn().mockResolvedValue(84532),
chain: {
id: 84532,
},
});

const signer = await createSubAccountSigner({
address: '0x',
client: getClient(84532)!,
owner,
attribution: {
suffix: '0x7890',
},
});

// Typecheck for appOrigin option
await createSubAccountSigner({
address: '0x',
client: getClient(84532)!,
owner,
attribution: {
appOrigin: 'https://app.com',
},
});

await signer.request({
method: 'wallet_sendCalls',
params: [
{
calls: [{ to: '0x', data: '0x123456' }],
chainId: numberToHex(84532),
from: '0x',
version: '1.0',
},
],
});

expect(request).toHaveBeenCalledWith({
method: 'wallet_prepareCalls',
params: [
expect.objectContaining({
capabilities: expect.objectContaining({ attribution: { suffix: '0x7890' } }),
}),
],
});
});
});
20 changes: 19 additions & 1 deletion packages/wallet-sdk/src/sign/scw/utils/createSubAccountSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ export async function createSubAccountSigner({
factory,
factoryData,
parentAddress,
attribution,
}: {
address: Address;
owner: OwnerAccount;
client: PublicClient;
parentAddress?: Address;
factoryData?: Hex;
factory?: Address;
attribution?:
| {
suffix: Hex;
}
| {
appOrigin: string;
};
}) {
const code = await getCode(client, {
address,
Expand Down Expand Up @@ -160,7 +168,7 @@ export async function createSubAccountSigner({
from: subAccount.address,
capabilities:
'capabilities' in args.params[0]
? (args.params[0].capabilities as Record<string, any>)
? (args.params[0].capabilities as Record<string, unknown>)
: {},
},
],
Expand Down Expand Up @@ -273,6 +281,16 @@ export async function createSubAccountSigner({
throw standardErrors.rpc.invalidParams('calls are required');
}

const prepareCallsParams = args.params[0] as PrepareCallsSchema['Parameters'][0];

if (
attribution &&
prepareCallsParams.capabilities &&
!('attribution' in prepareCallsParams.capabilities)
) {
prepareCallsParams.capabilities.attribution = attribution;
}

const prepareCallsResponse = await client.request<PrepareCallsSchema>({
method: 'wallet_prepareCalls',
params: [{ ...args.params[0], chainId: chainId }] as PrepareCallsSchema['Parameters'],
Expand Down