diff --git a/packages/account-sdk/src/sign/base-account/Signer.test.ts b/packages/account-sdk/src/sign/base-account/Signer.test.ts index b8e206279..92f0e4934 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.test.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.test.ts @@ -17,6 +17,7 @@ import { } from ':util/cipher.js'; import { fetchRPCRequest } from ':util/provider.js'; import { HttpRequestError, numberToHex } from 'viem'; +import { waitForCallsStatus } from 'viem/actions'; import { SCWKeyManager } from './SCWKeyManager.js'; import { Signer } from './Signer.js'; import { createSubAccountSigner } from './utils/createSubAccountSigner.js'; @@ -64,6 +65,12 @@ vi.mock('../../kms/crypto-key/index.js', () => ({ vi.mock(':util/provider'); vi.mock(':store/chain-clients/utils'); +vi.mock('viem/actions', () => ({ + waitForCallsStatus: vi.fn().mockResolvedValue({ + status: 'success', + receipts: [{ transactionHash: `0x${'a'.repeat(64)}` }], + }), +})); vi.mock('./SCWKeyManager'); vi.mock(':core/communicator/Communicator', () => ({ Communicator: vi.fn(() => ({ @@ -190,6 +197,21 @@ describe('Signer', () => { communicator: mockCommunicator, callback: mockCallback, }); + + (getClient as Mock).mockImplementation((chainId) => { + if (chainId === 84532 || chainId === 1) { + return { + request: vi.fn(), + chain: { + id: chainId, + }, + waitForTransaction: vi.fn().mockResolvedValue({ + status: 'success', + }), + }; + } + return null; + }); }); afterEach(async () => { @@ -415,7 +437,6 @@ describe('Signer', () => { 'wallet_sign', 'personal_ecRecover', 'eth_signTransaction', - 'eth_sendTransaction', 'eth_signTypedData_v1', 'eth_signTypedData_v3', 'eth_signTypedData_v4', @@ -447,6 +468,105 @@ describe('Signer', () => { ); }); + it('should convert eth_sendTransaction to wallet_sendCalls and wait for transaction hash', async () => { + const txHash = `0x${'b'.repeat(64)}`; + const mockRequest: RequestArguments = { + method: 'eth_sendTransaction', + params: [ + { + to: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + }, + ], + }; + + (decryptContent as Mock).mockResolvedValueOnce({ + result: { + value: { id: '0x1234ca11' }, + }, + }); + (waitForCallsStatus as Mock).mockResolvedValueOnce({ + status: 'success', + receipts: [{ transactionHash: txHash }], + }); + + const result = await signer.request(mockRequest); + + expect(encryptContent).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ + method: 'wallet_sendCalls', + params: [ + expect.objectContaining({ + from: '0xAddress', + chainId: '0x1', + calls: [{ to: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', data: '0x', value: '0x0' }], + }), + ], + }), + }), + expect.anything() + ); + expect(waitForCallsStatus).toHaveBeenCalledWith(expect.any(Object), { id: '0x1234ca11' }); + expect(result).toEqual(txHash); + }); + + it('should handle legacy wallet_sendCalls string responses for eth_sendTransaction', async () => { + const txHash = `0x${'c'.repeat(64)}`; + const mockRequest: RequestArguments = { + method: 'eth_sendTransaction', + params: [ + { + to: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + from: '0xAddress', + }, + ], + }; + + (decryptContent as Mock).mockResolvedValueOnce({ + result: { + value: '0xlegacycallsid', + }, + }); + (waitForCallsStatus as Mock).mockResolvedValueOnce({ + status: 'success', + receipts: [{ transactionHash: txHash }], + }); + + const result = await signer.request(mockRequest); + + expect(waitForCallsStatus).toHaveBeenCalledWith(expect.any(Object), { id: '0xlegacycallsid' }); + expect(result).toEqual(txHash); + }); + + it('should support contract deployment transactions without to', async () => { + const txHash = `0x${'d'.repeat(64)}`; + const mockRequest: RequestArguments = { + method: 'eth_sendTransaction', + params: [ + { + data: '0x60006000', + }, + ], + }; + + (decryptContent as Mock).mockResolvedValueOnce({ + result: { + value: { id: '0xdeploycallsid' }, + }, + }); + (waitForCallsStatus as Mock).mockResolvedValueOnce({ + status: 'success', + receipts: [{ transactionHash: txHash }], + }); + + const result = await signer.request(mockRequest); + const sentAction = (encryptContent as Mock).mock.calls[0][0].action; + + expect(sentAction.params[0].calls[0]).toEqual({ data: '0x60006000', value: '0x0' }); + expect(waitForCallsStatus).toHaveBeenCalledWith(expect.any(Object), { id: '0xdeploycallsid' }); + expect(result).toEqual(txHash); + }); + it.each([ 'wallet_prepareCalls', 'wallet_sendPreparedCalls', diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index d5a5ad1d6..53ca60a74 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -1,5 +1,12 @@ import { CB_WALLET_RPC_URL } from ':core/constants.js'; -import { Hex, WalletSendCallsParameters, hexToNumber, isAddressEqual, numberToHex } from 'viem'; +import { + Hex, + SendCallsReturnType, + WalletSendCallsParameters, + hexToNumber, + isAddressEqual, + numberToHex, +} from 'viem'; import { Communicator } from ':core/communicator/Communicator.js'; import { isActionableHttpRequestError, isViemError, standardErrors } from ':core/error/errors.js'; @@ -34,7 +41,7 @@ import { } from ':core/telemetry/events/scw-sub-account.js'; import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; import { Address } from ':core/type/index.js'; -import { ensureIntNumber, hexStringFromNumber } from ':core/type/util.js'; +import { ensureHexString, ensureIntNumber, hexStringFromNumber } from ':core/type/util.js'; import { SDKChain, createClients, getClient } from ':store/chain-clients/utils.js'; import { correlationIds } from ':store/correlation-ids/store.js'; import { spendPermissions, store } from ':store/store.js'; @@ -55,12 +62,15 @@ import { assertFetchPermissionsRequest, assertGetCapabilitiesParams, assertParamsChainId, + createWalletSendCallsRequest, fillMissingParamsForFetchPermissions, getSenderFromRequest, initSubAccountConfig, injectRequestCapabilities, + isEthSendTransactionParams, makeDataSuffix, prependWithoutDuplicates, + waitForCallsTransactionHash, } from './utils.js'; import { createSubAccountSigner } from './utils/createSubAccountSigner.js'; import { findOwnerIndex } from './utils/findOwnerIndex.js'; @@ -245,12 +255,13 @@ export class Signer { return this.handleGetCapabilitiesRequest(request); case 'wallet_switchEthereumChain': return this.handleSwitchChainRequest(request); + case 'eth_sendTransaction': + return this.handleSendTransaction(request); case 'eth_ecRecover': case 'personal_sign': case 'wallet_sign': case 'personal_ecRecover': case 'eth_signTransaction': - case 'eth_sendTransaction': case 'eth_signTypedData_v1': case 'eth_signTypedData_v3': case 'eth_signTypedData_v4': @@ -431,6 +442,56 @@ export class Signer { return result.value; } + /** + * Handles eth_sendTransaction by converting to wallet_sendCalls and + * waiting for the actual on-chain transaction hash. + * + * Without this, eth_sendTransaction sent via the popup returns the raw + * UserOperation signature (65 bytes) instead of a 32-byte tx hash, + * causing waitForTransactionReceipt to fail with InvalidParamsRpcError. + */ + private async handleSendTransaction(request: RequestArguments) { + if (!isEthSendTransactionParams(request.params)) { + throw standardErrors.rpc.invalidParams('Invalid eth_sendTransaction params'); + } + + const txParams = request.params[0]; + const from = txParams.from ?? this.accounts[0]; + + if (!from) { + throw standardErrors.rpc.invalidParams('No sender address available'); + } + + const normalizedTxParams = { + ...(txParams.to ? { to: txParams.to } : {}), + data: ensureHexString(txParams.data ?? '0x', true) as Hex, + value: ensureHexString(txParams.value ?? '0x0', true) as Hex, + }; + + const sendCallsRequest = createWalletSendCallsRequest({ + calls: [normalizedTxParams], + chainId: this.chain.id, + from, + }); + + const result = (await this.sendRequestToPopup(sendCallsRequest)) as SendCallsReturnType | string; + const callsId = typeof result === 'string' ? result : result.id; + + if (!callsId) { + throw standardErrors.rpc.internal('wallet_sendCalls response is missing id'); + } + + const client = getClient(this.chain.id); + if (!client) { + throw standardErrors.rpc.internal(`No client found for chain ${this.chain.id}`); + } + + return waitForCallsTransactionHash({ + client, + id: callsId, + }); + } + async cleanup() { const metadata = store.config.get().metadata; await this.keyManager.clear(); diff --git a/packages/account-sdk/src/sign/base-account/utils.test.ts b/packages/account-sdk/src/sign/base-account/utils.test.ts index e4dcdebfc..23c843845 100644 --- a/packages/account-sdk/src/sign/base-account/utils.test.ts +++ b/packages/account-sdk/src/sign/base-account/utils.test.ts @@ -14,6 +14,7 @@ import { getSenderFromRequest, initSubAccountConfig, injectRequestCapabilities, + isEthSendTransactionParams, isSendCallsParams, prependWithoutDuplicates, requestHasCapability, @@ -628,6 +629,37 @@ describe('isSendCallsParams', () => { }); }); +describe('isEthSendTransactionParams', () => { + it('should return true for contract deployments without to', () => { + expect( + isEthSendTransactionParams([ + { + data: '0x60006000', + }, + ]) + ).toBe(true); + }); + + it('should return true for standard transaction params', () => { + expect( + isEthSendTransactionParams([ + { + from: VALID_ADDRESS_1, + to: VALID_ADDRESS_2, + value: '0x1', + data: '0x', + }, + ]) + ).toBe(true); + }); + + it('should return false for invalid params', () => { + expect(isEthSendTransactionParams([])).toBe(false); + expect(isEthSendTransactionParams({})).toBe(false); + expect(isEthSendTransactionParams(null)).toBe(false); + }); +}); + describe('getCachedWalletConnectResponse', () => { beforeEach(() => { vi.spyOn(store.spendPermissions, 'get').mockReturnValue([]); diff --git a/packages/account-sdk/src/sign/base-account/utils.ts b/packages/account-sdk/src/sign/base-account/utils.ts index cb63babba..e38513319 100644 --- a/packages/account-sdk/src/sign/base-account/utils.ts +++ b/packages/account-sdk/src/sign/base-account/utils.ts @@ -23,6 +23,17 @@ import { waitForCallsStatus } from 'viem/actions'; import { getCryptoKeyAccount } from '../../kms/crypto-key/index.js'; import { spendPermissionManagerAddress } from './utils/constants.js'; +type WalletSendCall = NonNullable[number]; + +export type EthSendTransactionParams = [ + { + from?: Address; + to?: Address; + data?: Hex; + value?: Hex; + }, +]; + // *************************************************************** // Utility // *************************************************************** @@ -391,7 +402,7 @@ export function createWalletSendCallsRequest({ chainId, capabilities, }: { - calls: { to: Address; data: Hex; value: Hex }[]; + calls: WalletSendCall[]; from: Address; chainId: number; capabilities?: Record; @@ -503,20 +514,12 @@ export function isSendCallsParams(params: unknown): params is WalletSendCallsPar 'calls' in params[0] ); } -export function isEthSendTransactionParams(params: unknown): params is [ - { - to: Address; - data: Hex; - from: Address; - value: Hex; - }, -] { +export function isEthSendTransactionParams(params: unknown): params is EthSendTransactionParams { return ( Array.isArray(params) && params.length === 1 && typeof params[0] === 'object' && - params[0] !== null && - 'to' in params[0] + params[0] !== null ); } export function compute16ByteHash(input: string): Hex { diff --git a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts index 029bf2206..dd36f3973 100644 --- a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts +++ b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts @@ -57,10 +57,26 @@ export async function routeThroughGlobalAccount({ request.method === 'eth_sendTransaction' && isEthSendTransactionParams(request.params) ) { + const from = request.params[0].from; + if (!from) { + throw new Error('Could not get sender from eth_sendTransaction request'); + } + + const to = request.params[0].to; + if (!to) { + throw new Error('Could not route contract deployment through global account'); + } + const sendCallsRequest = createWalletSendCallsRequest({ - calls: [request.params[0]], + calls: [ + { + to, + data: request.params[0].data ?? '0x', + value: request.params[0].value ?? '0x0', + }, + ], chainId, - from: request.params[0].from, + from, }); originalSendCallsParams = sendCallsRequest.params[0]; @@ -108,17 +124,20 @@ export async function routeThroughGlobalAccount({ } ); - const result = (await globalAccountRequest(requestToParent)) as SendCallsReturnType; - - let callsId = result.id; + const result = (await globalAccountRequest(requestToParent)) as SendCallsReturnType | string; + const callsId = typeof result === 'string' ? result : result.id; // Cache returned spend permissions - if (result.capabilities?.spendPermissions) { + if (typeof result !== 'string' && result.capabilities?.spendPermissions) { spendPermissions.set(result.capabilities.spendPermissions.permissions); } // Wait for transaction hash if sending a transaction if (request.method === 'eth_sendTransaction') { + if (!callsId) { + throw new Error('wallet_sendCalls response is missing id'); + } + return waitForCallsTransactionHash({ client, id: callsId,