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;