Skip to content

Commit 8f4ec8a

Browse files
authored
Merge pull request #1713 from KeeperHub/fix/turnkey-sponsored-double-send
fix(web3): don't double-send sponsored transactions on slow Turnkey confirmations
2 parents 3664aae + 318ba80 commit 8f4ec8a

10 files changed

Lines changed: 375 additions & 152 deletions

lib/web3/sponsored-send-error.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import "server-only";
2+
3+
import { ErrorCategory, logSystemWarn, logUserError } from "@/lib/logging";
4+
import {
5+
isSponsoredTxPendingError,
6+
isSponsoredTxRevertError,
7+
} from "@/lib/web3/turnkey-revert";
8+
9+
export type SponsoredSendDecision =
10+
| { fallback: false; error: string }
11+
| { fallback: true };
12+
13+
/**
14+
* Classify a throw from the Turnkey-sponsored send path and decide whether the
15+
* caller may retry the transaction via direct signing.
16+
*
17+
* Returns `fallback: false` (a terminal error result) whenever the sponsored
18+
* transaction is already committed on Turnkey's side -- it either reverted
19+
* on-chain, or was accepted but not yet confirmed. Re-sending in those cases
20+
* would broadcast a second transaction from the same wallet and race the
21+
* sponsored one. Returns `fallback: true` only for genuine pre-broadcast
22+
* failures, where direct signing is safe.
23+
*
24+
* Shared by every web3 write step so the "never double-send" rule stays
25+
* identical across write-contract, transfer-token, approve-token, and
26+
* transfer-funds.
27+
*/
28+
export function resolveSponsoredSendError(
29+
error: unknown,
30+
ctx: { logPrefix: string; actionName: string; chainId: number }
31+
): SponsoredSendDecision {
32+
const { logPrefix, actionName, chainId } = ctx;
33+
34+
if (isSponsoredTxRevertError(error)) {
35+
logUserError(
36+
ErrorCategory.TRANSACTION,
37+
`${logPrefix} Sponsored transaction reverted on-chain`,
38+
error,
39+
{
40+
plugin_name: "web3",
41+
action_name: actionName,
42+
chain_id: String(chainId),
43+
tx_hash: error.txHash,
44+
send_transaction_status_id: error.sendTransactionStatusId,
45+
revert_chain_depth: String(error.revertChain.length),
46+
}
47+
);
48+
return {
49+
fallback: false,
50+
error: `Transaction reverted: ${error.message} (tx ${error.txHash})`,
51+
};
52+
}
53+
54+
if (isSponsoredTxPendingError(error)) {
55+
logSystemWarn(
56+
ErrorCategory.TRANSACTION,
57+
`${logPrefix} Sponsored transaction unconfirmed; not falling back to direct signing`,
58+
error,
59+
{
60+
plugin_name: "web3",
61+
action_name: actionName,
62+
chain_id: String(chainId),
63+
send_transaction_status_id: error.sendTransactionStatusId,
64+
}
65+
);
66+
return {
67+
fallback: false,
68+
error:
69+
"Sponsored transaction was submitted but not confirmed in time. It may still complete; not retrying to avoid a duplicate transaction.",
70+
};
71+
}
72+
73+
logUserError(
74+
ErrorCategory.TRANSACTION,
75+
`${logPrefix} Sponsorship attempted but failed, falling back to direct signing`,
76+
error,
77+
{
78+
plugin_name: "web3",
79+
action_name: actionName,
80+
chain_id: String(chainId),
81+
}
82+
);
83+
return { fallback: true };
84+
}

lib/web3/sponsored-transaction-manager.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "server-only";
22
import type { Hex } from "viem";
3-
import { createPublicClient, encodeFunctionData, http } from "viem";
3+
import { BaseError, createPublicClient, encodeFunctionData, http } from "viem";
44
import {
55
checkGasCredits,
66
getGasTokenPriceUsd,
@@ -12,6 +12,7 @@ import { MetricNames } from "@/lib/metrics/types";
1212
import { isTestnetChain } from "@/lib/web3/chainlink-feeds";
1313
import { createSponsoredClient } from "@/lib/web3/sponsored-client";
1414
import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag";
15+
import { SponsoredTxRevertError } from "@/lib/web3/turnkey-revert";
1516
import { submitTurnkeySponsoredTransaction } from "@/lib/web3/turnkey-sponsored-tx";
1617
import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config";
1718

@@ -97,6 +98,7 @@ export async function executeSponsoredTransaction(
9798

9899
return await finalizeSponsoredTx(
99100
submitResult.txHash,
101+
submitResult.sendTransactionStatusId,
100102
params.rpcUrl,
101103
params.organizationId,
102104
params.chainId,
@@ -156,13 +158,58 @@ export async function executeSponsoredContractTransaction(
156158

157159
return await finalizeSponsoredTx(
158160
submitResult.txHash,
161+
submitResult.sendTransactionStatusId,
159162
params.rpcUrl,
160163
params.organizationId,
161164
params.chainId,
162165
params.executionId
163166
);
164167
}
165168

169+
// viem renders an Error(string) revert as
170+
// "Execution reverted with reason: <reason>." -- pull <reason> back out.
171+
const VIEM_REVERT_REASON_RE = /reverted with reason:\s*(.+?)\.?\s*$/i;
172+
173+
/**
174+
* Recover the revert reason of an already-mined, reverted sponsored tx.
175+
*
176+
* This is a read-only `eth_call` replay against the block the tx landed in --
177+
* it never broadcasts a second transaction, so it does NOT double-send. Returns
178+
* undefined when the reason cannot be decoded (custom error without an ABI, or
179+
* state drift so the replay no longer reverts); callers fall back to a generic
180+
* message.
181+
*/
182+
async function decodeSponsoredRevertReason(
183+
publicClient: ReturnType<typeof createPublicClient>,
184+
txHash: Hex,
185+
blockNumber: bigint
186+
): Promise<string | undefined> {
187+
let tx: Awaited<ReturnType<typeof publicClient.getTransaction>>;
188+
try {
189+
tx = await publicClient.getTransaction({ hash: txHash });
190+
} catch {
191+
return;
192+
}
193+
if (!tx.to) {
194+
return;
195+
}
196+
try {
197+
await publicClient.call({
198+
account: tx.from,
199+
to: tx.to,
200+
data: tx.input,
201+
value: tx.value,
202+
blockNumber,
203+
});
204+
return;
205+
} catch (replayError) {
206+
if (replayError instanceof BaseError) {
207+
return VIEM_REVERT_REASON_RE.exec(replayError.shortMessage)?.[1]?.trim();
208+
}
209+
return;
210+
}
211+
}
212+
166213
/**
167214
* Wait for receipt, record gas usage, and build the result.
168215
*
@@ -173,6 +220,7 @@ export async function executeSponsoredContractTransaction(
173220
*/
174221
async function finalizeSponsoredTx(
175222
txHash: Hex,
223+
sendTransactionStatusId: string,
176224
rpcUrl: string,
177225
organizationId: string,
178226
chainId: number,
@@ -187,7 +235,21 @@ async function finalizeSponsoredTx(
187235
});
188236

189237
if (receipt.status !== "success") {
190-
throw new Error(`Sponsored transaction reverted: ${txHash}`);
238+
// The sponsored tx is on-chain and reverted. Read-only replay to recover
239+
// the reason (Turnkey's early hash meant we never saw its FAILED status),
240+
// then raise the typed revert error -- not a generic Error -- so the caller
241+
// reports it as the node result instead of falling back and duplicating.
242+
const reason = await decodeSponsoredRevertReason(
243+
publicClient,
244+
txHash,
245+
receipt.blockNumber
246+
);
247+
throw new SponsoredTxRevertError({
248+
message: reason ?? "reason unavailable",
249+
txHash,
250+
sendTransactionStatusId,
251+
revertChain: [],
252+
});
191253
}
192254

193255
const gasUsed = receipt.gasUsed;

lib/web3/turnkey-revert.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,34 @@ export function isSponsoredTxRevertError(
6565
);
6666
}
6767

68+
/**
69+
* Thrown when a sponsored transaction was accepted by Turnkey (we hold a
70+
* `sendTransactionStatusId`) but we could not confirm its terminal outcome
71+
* within the wait window, or Turnkey's status API kept failing. The send may
72+
* still broadcast and mine, so callers MUST NOT fall back to direct signing --
73+
* re-sending would put a second transaction from the same wallet on-chain and
74+
* race the sponsored one.
75+
*/
76+
export class SponsoredTxPendingError extends Error {
77+
readonly kind = "sponsored-tx-pending" as const;
78+
readonly sendTransactionStatusId: string;
79+
80+
constructor(opts: { message: string; sendTransactionStatusId: string }) {
81+
super(opts.message);
82+
this.name = "SponsoredTxPendingError";
83+
this.sendTransactionStatusId = opts.sendTransactionStatusId;
84+
}
85+
}
86+
87+
export function isSponsoredTxPendingError(
88+
err: unknown
89+
): err is SponsoredTxPendingError {
90+
return (
91+
err instanceof Error &&
92+
(err as { kind?: string }).kind === "sponsored-tx-pending"
93+
);
94+
}
95+
6896
/**
6997
* Render a Turnkey revert chain into a single human-readable string.
7098
*

0 commit comments

Comments
 (0)