diff --git a/.changeset/safe-access-key-fallback.md b/.changeset/safe-access-key-fallback.md new file mode 100644 index 00000000..7ab94287 --- /dev/null +++ b/.changeset/safe-access-key-fallback.md @@ -0,0 +1,5 @@ +--- +'accounts': patch +--- + +Prevent access-key transaction failures after dispatch from falling back to a duplicate wallet send. diff --git a/src/core/Provider.localnet.test.ts b/src/core/Provider.localnet.test.ts index 68a99131..104d7c3a 100644 --- a/src/core/Provider.localnet.test.ts +++ b/src/core/Provider.localnet.test.ts @@ -1097,6 +1097,49 @@ describe.each(adapters)('$name', ({ adapter, name }: (typeof adapters)[number]) } `) }) + + test('behavior: does not fall back after an access-key transaction is dispatched', async () => { + let failAfterDispatch = false + let sends = 0 + const transport: Transport = (options) => { + const base = http()(options) + return { + ...base, + async request(request: Parameters[0]) { + if (request.method !== 'eth_sendRawTransactionSync') return await base.request(request) + sends++ + const result = await base.request(request) + if (!failAfterDispatch) return result + failAfterDispatch = false + throw new Error('receipt response lost') + }, + } as never + } + const provider = Provider.create({ + adapter: adapter(), + chains: [chain], + transports: { [chain.id]: transport }, + }) + const connected = await connect(provider) + await fund(connected) + await provider.request({ + method: 'wallet_authorizeAccessKey', + params: [{ expiry: Expiry.days(1) }], + }) + sends = 0 + failAfterDispatch = true + + await expect( + provider.request({ + method: 'eth_sendTransactionSync', + params: [{ calls: [transferCall] }], + }), + ).rejects.toMatchObject({ + name: 'AccessKeyTransaction.DispatchedError', + transactionHash: expect.stringMatching(/^0x[0-9a-f]{64}$/), + }) + expect(sends).toMatchInlineSnapshot(`1`) + }) }) describe('eth_signTransaction', () => { diff --git a/src/core/Provider.ts b/src/core/Provider.ts index 48cdc790..bdc11972 100644 --- a/src/core/Provider.ts +++ b/src/core/Provider.ts @@ -286,7 +286,9 @@ export function create(options: create.Options = {}): create.ReturnType { ...(feePayer ? { feePayer: true as never } : {}), }) return await prepared.sign() - } catch {} + } catch (error) { + fallbackFromAccessKey(error) + } } const { client, request, selected } = await prepareRootTransaction(parameters) @@ -325,7 +327,9 @@ export function create(options: create.Options = {}): create.ReturnType { ...(feePayer ? { feePayer: true as never } : {}), }) return await prepared.send() - } catch {} + } catch (error) { + fallbackFromAccessKey(error) + } } const { feePayer, ...rest } = parameters @@ -372,7 +376,9 @@ export function create(options: create.Options = {}): create.ReturnType { ...(feePayer ? { feePayer: true as never } : {}), }) return await prepared.sendSync() - } catch {} + } catch (error) { + fallbackFromAccessKey(error) + } } const { feePayer, ...rest } = parameters @@ -399,6 +405,11 @@ export function create(options: create.Options = {}): create.ReturnType { return z.encode(Rpc.receipt, receipt as never) as Rpc.eth_sendTransactionSync.Encoded['returns'] } + function fallbackFromAccessKey(error: unknown): void { + if (AccessKeyTransaction.isDispatchedError(error)) throw error + console.warn('[accounts] Access-key transaction failed; falling back to wallet.', error) + } + async function signPersonalMessage(parameters: { address: Address.Address; data: Hex.Hex }) { const selected = await getAdapterAccount({ address: parameters.address }) const client = getWalletClient({ account: selected.account, transport: selected.transport }) diff --git a/src/core/internal/AccessKeyTransaction.ts b/src/core/internal/AccessKeyTransaction.ts index 8bfebeb4..5253ca87 100644 --- a/src/core/internal/AccessKeyTransaction.ts +++ b/src/core/internal/AccessKeyTransaction.ts @@ -1,4 +1,4 @@ -import { Address, Hex } from 'ox' +import { Address, Hash, Hex } from 'ox' import type { Client, Transport } from 'viem' import { prepareTransactionRequest } from 'viem/actions' import type { PrepareTransactionRequestReturnType } from 'viem/actions' @@ -23,6 +23,26 @@ const removalErrorNames = new Set([ 'SignatureTypeMismatch', ]) +/** Signals that a signed transaction was handed to the transport before an error was returned. */ +export class DispatchedError extends Error { + /** Hash of the signed transaction that may still be pending. */ + transactionHash: Hex.Hex + + constructor(error: unknown, options: { transactionHash: Hex.Hex }) { + super(error instanceof Error ? error.message : 'Access-key transaction dispatch failed.', { + cause: error, + }) + this.name = 'AccessKeyTransaction.DispatchedError' + this.transactionHash = options.transactionHash + } +} + +/** Returns whether an error occurred after a signed transaction was handed to the transport. */ +export function isDispatchedError(error: unknown): error is DispatchedError { + if (error instanceof DispatchedError) return true + return error instanceof Error && error.name === 'AccessKeyTransaction.DispatchedError' +} + /** Creates a transaction helper for a matching locally-signable access key. */ export async function create(options: create.Options): Promise { const { address, calls, chainId, client, store } = options @@ -163,27 +183,27 @@ function createPreparedTransaction(options: { request, sign, async send() { + const signed = await sign() try { - const signed = await sign() return (await client.request({ method: 'eth_sendRawTransaction' as never, params: [signed], })) as Hex.Hex } catch (error) { removeForError(error, { account, address, chainId, store }) - throw error + throw new DispatchedError(error, { transactionHash: Hash.keccak256(signed) }) } }, async sendSync() { + const signed = await sign() try { - const signed = await sign() return (await client.request({ method: 'eth_sendRawTransactionSync' as never, params: [signed], })) as create.SendSyncReturnType } catch (error) { removeForError(error, { account, address, chainId, store }) - throw error + throw new DispatchedError(error, { transactionHash: Hash.keccak256(signed) }) } }, }