Skip to content
Open
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
122 changes: 121 additions & 1 deletion packages/account-sdk/src/sign/base-account/Signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -415,7 +437,6 @@ describe('Signer', () => {
'wallet_sign',
'personal_ecRecover',
'eth_signTransaction',
'eth_sendTransaction',
'eth_signTypedData_v1',
'eth_signTypedData_v3',
'eth_signTypedData_v4',
Expand Down Expand Up @@ -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',
Expand Down
67 changes: 64 additions & 3 deletions packages/account-sdk/src/sign/base-account/Signer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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();
Expand Down
32 changes: 32 additions & 0 deletions packages/account-sdk/src/sign/base-account/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getSenderFromRequest,
initSubAccountConfig,
injectRequestCapabilities,
isEthSendTransactionParams,
isSendCallsParams,
prependWithoutDuplicates,
requestHasCapability,
Expand Down Expand Up @@ -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([]);
Expand Down
25 changes: 14 additions & 11 deletions packages/account-sdk/src/sign/base-account/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletSendCallsParameters[0]['calls']>[number];

export type EthSendTransactionParams = [
{
from?: Address;
to?: Address;
data?: Hex;
value?: Hex;
},
];

// ***************************************************************
// Utility
// ***************************************************************
Expand Down Expand Up @@ -391,7 +402,7 @@ export function createWalletSendCallsRequest({
chainId,
capabilities,
}: {
calls: { to: Address; data: Hex; value: Hex }[];
calls: WalletSendCall[];
from: Address;
chainId: number;
capabilities?: Record<string, unknown>;
Expand Down Expand Up @@ -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 {
Expand Down
Loading