From 8f55fa15d65e10d484d3492d3ffe3514eee1cfe5 Mon Sep 17 00:00:00 2001 From: Nexory Date: Sat, 18 Jul 2026 09:11:43 +0200 Subject: [PATCH] fix(spend-permission): return the wallet-substituted account in requestSpendPermission When capabilities are provided, requestSpendPermission sends mutableData: { fields: ['message.account'] }, allowing the wallet to substitute account with a smart-wallet address. permissionHash is already derived from the wallet-returned (post-substitution) message, but the returned permission object used the original pre-substitution typedData.message, so result.permission.account and result.permissionHash described different permissions. Hoist the returned message into permissionMessage, set from signResult.signedData.message in the wallet_sign branch and from typedData.message in the eth_signTypedData_v4 branch, so permission and permissionHash stay consistent. Adds a test where the wallet substitutes account with a different address; it fails before this change and passes after. --- .../methods/requestSpendPermission.test.ts | 47 +++++++++++++++++++ .../methods/requestSpendPermission.ts | 9 +++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.test.ts index 3f63970d..19ad2df6 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.test.ts @@ -327,6 +327,53 @@ describe('requestSpendPermission', () => { }); }); + it('should return the wallet-substituted account in permission when mutableData substitution occurs', async () => { + const capabilities: WalletSignCapabilities = { + spendPermission: { + requireBalance: true, + }, + }; + + // The request declares mutableData: { fields: ['message.account'] }, so the + // wallet is allowed to substitute account with a different (smart-wallet) + // address than the one originally requested. + const substitutedAccount = '0x00000000000000000000000000000000000000ab' as `0x${string}`; + const substitutedTypedData: SpendPermissionTypedData = { + ...mockTypedData, + message: { + ...mockTypedData.message, + account: substitutedAccount, + }, + }; + + const mockWalletSignResponse = { + signature: mockSignature, + signedData: substitutedTypedData, + }; + + // createSpendPermissionTypedData returns the pre-substitution request data. + (createSpendPermissionTypedData as Mock).mockReturnValue(mockTypedData); + // The wallet returns the post-substitution signed data. + (mockProviderRequest as Mock).mockResolvedValue(mockWalletSignResponse); + (getHash as Mock).mockResolvedValue(mockPermissionHash); + + const result = await requestSpendPermission({ + ...mockRequestData, + capabilities, + }); + + // permissionHash is derived from the post-substitution message, ... + expect(getHash).toHaveBeenCalledWith({ + permission: substitutedTypedData.message, + chainId: mockRequestData.chainId, + }); + // ... so the returned permission must describe the same (substituted) + // account rather than the original pre-substitution request, otherwise + // permission and permissionHash describe different permissions. + expect(result.permission).toEqual(substitutedTypedData.message); + expect(result.permission.account).toBe(substitutedAccount); + }); + it('should handle invalid wallet_sign response', async () => { const capabilities: WalletSignCapabilities = { spendPermission: { diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts index 850262b3..3f3da40e 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts @@ -78,6 +78,11 @@ const requestSpendPermissionFn = async ( // Check if we should use wallet_sign (when capabilities are provided) or eth_signTypedData_v4 let signature: string; let permissionHash: string; + // The permission message actually returned to the caller. It must match the + // data that permissionHash is derived from: when the wallet substitutes + // message.account via mutableData, this is the wallet-returned (post- + // substitution) message, not the original request. + let permissionMessage: typeof typedData.message; if (capabilities) { // Use wallet_sign with capabilities @@ -126,6 +131,7 @@ const requestSpendPermissionFn = async ( permission: signResult.signedData.message, chainId, }); + permissionMessage = signResult.signedData.message; } else { // Use the original eth_signTypedData_v4 method [signature, permissionHash] = await Promise.all([ @@ -135,6 +141,7 @@ const requestSpendPermissionFn = async ( }) as Promise, getHash({ permission: typedData.message, chainId }), ]); + permissionMessage = typedData.message; } const permission: SpendPermission = { @@ -142,7 +149,7 @@ const requestSpendPermissionFn = async ( permissionHash, signature, chainId, - permission: typedData.message, + permission: permissionMessage, }; return permission;