Skip to content
Merged
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
84 changes: 84 additions & 0 deletions lib/web3/sponsored-send-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import "server-only";

import { ErrorCategory, logSystemWarn, logUserError } from "@/lib/logging";
import {
isSponsoredTxPendingError,
isSponsoredTxRevertError,
} from "@/lib/web3/turnkey-revert";

export type SponsoredSendDecision =
| { fallback: false; error: string }
| { fallback: true };

/**
* Classify a throw from the Turnkey-sponsored send path and decide whether the
* caller may retry the transaction via direct signing.
*
* Returns `fallback: false` (a terminal error result) whenever the sponsored
* transaction is already committed on Turnkey's side -- it either reverted
* on-chain, or was accepted but not yet confirmed. Re-sending in those cases
* would broadcast a second transaction from the same wallet and race the
* sponsored one. Returns `fallback: true` only for genuine pre-broadcast
* failures, where direct signing is safe.
*
* Shared by every web3 write step so the "never double-send" rule stays
* identical across write-contract, transfer-token, approve-token, and
* transfer-funds.
*/
export function resolveSponsoredSendError(
error: unknown,
ctx: { logPrefix: string; actionName: string; chainId: number }
): SponsoredSendDecision {
const { logPrefix, actionName, chainId } = ctx;

if (isSponsoredTxRevertError(error)) {
logUserError(
ErrorCategory.TRANSACTION,
`${logPrefix} Sponsored transaction reverted on-chain`,
error,
{
plugin_name: "web3",
action_name: actionName,
chain_id: String(chainId),
tx_hash: error.txHash,
send_transaction_status_id: error.sendTransactionStatusId,
revert_chain_depth: String(error.revertChain.length),
}
);
return {
fallback: false,
error: `Transaction reverted: ${error.message} (tx ${error.txHash})`,
};
}

if (isSponsoredTxPendingError(error)) {
logSystemWarn(
ErrorCategory.TRANSACTION,
`${logPrefix} Sponsored transaction unconfirmed; not falling back to direct signing`,
error,
{
plugin_name: "web3",
action_name: actionName,
chain_id: String(chainId),
send_transaction_status_id: error.sendTransactionStatusId,
}
);
return {
fallback: false,
error:
"Sponsored transaction was submitted but not confirmed in time. It may still complete; not retrying to avoid a duplicate transaction.",
};
}

logUserError(
ErrorCategory.TRANSACTION,
`${logPrefix} Sponsorship attempted but failed, falling back to direct signing`,
error,
{
plugin_name: "web3",
action_name: actionName,
chain_id: String(chainId),
}
);
return { fallback: true };
}
66 changes: 64 additions & 2 deletions lib/web3/sponsored-transaction-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "server-only";
import type { Hex } from "viem";
import { createPublicClient, encodeFunctionData, http } from "viem";
import { BaseError, createPublicClient, encodeFunctionData, http } from "viem";
import {
checkGasCredits,
getGasTokenPriceUsd,
Expand All @@ -12,6 +12,7 @@ import { MetricNames } from "@/lib/metrics/types";
import { isTestnetChain } from "@/lib/web3/chainlink-feeds";
import { createSponsoredClient } from "@/lib/web3/sponsored-client";
import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag";
import { SponsoredTxRevertError } from "@/lib/web3/turnkey-revert";
import { submitTurnkeySponsoredTransaction } from "@/lib/web3/turnkey-sponsored-tx";
import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config";

Expand Down Expand Up @@ -97,6 +98,7 @@ export async function executeSponsoredTransaction(

return await finalizeSponsoredTx(
submitResult.txHash,
submitResult.sendTransactionStatusId,
params.rpcUrl,
params.organizationId,
params.chainId,
Expand Down Expand Up @@ -156,13 +158,58 @@ export async function executeSponsoredContractTransaction(

return await finalizeSponsoredTx(
submitResult.txHash,
submitResult.sendTransactionStatusId,
params.rpcUrl,
params.organizationId,
params.chainId,
params.executionId
);
}

// viem renders an Error(string) revert as
// "Execution reverted with reason: <reason>." -- pull <reason> back out.
const VIEM_REVERT_REASON_RE = /reverted with reason:\s*(.+?)\.?\s*$/i;

/**
* Recover the revert reason of an already-mined, reverted sponsored tx.
*
* This is a read-only `eth_call` replay against the block the tx landed in --
* it never broadcasts a second transaction, so it does NOT double-send. Returns
* undefined when the reason cannot be decoded (custom error without an ABI, or
* state drift so the replay no longer reverts); callers fall back to a generic
* message.
*/
async function decodeSponsoredRevertReason(
publicClient: ReturnType<typeof createPublicClient>,
txHash: Hex,
blockNumber: bigint
): Promise<string | undefined> {
let tx: Awaited<ReturnType<typeof publicClient.getTransaction>>;
try {
tx = await publicClient.getTransaction({ hash: txHash });
} catch {
return;
}
if (!tx.to) {
return;
}
try {
await publicClient.call({
account: tx.from,
to: tx.to,
data: tx.input,
value: tx.value,
blockNumber,
});
return;
} catch (replayError) {
if (replayError instanceof BaseError) {
return VIEM_REVERT_REASON_RE.exec(replayError.shortMessage)?.[1]?.trim();
}
return;
}
}

/**
* Wait for receipt, record gas usage, and build the result.
*
Expand All @@ -173,6 +220,7 @@ export async function executeSponsoredContractTransaction(
*/
async function finalizeSponsoredTx(
txHash: Hex,
sendTransactionStatusId: string,
rpcUrl: string,
organizationId: string,
chainId: number,
Expand All @@ -187,7 +235,21 @@ async function finalizeSponsoredTx(
});

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

const gasUsed = receipt.gasUsed;
Expand Down
28 changes: 28 additions & 0 deletions lib/web3/turnkey-revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,34 @@ export function isSponsoredTxRevertError(
);
}

/**
* Thrown when a sponsored transaction was accepted by Turnkey (we hold a
* `sendTransactionStatusId`) but we could not confirm its terminal outcome
* within the wait window, or Turnkey's status API kept failing. The send may
* still broadcast and mine, so callers MUST NOT fall back to direct signing --
* re-sending would put a second transaction from the same wallet on-chain and
* race the sponsored one.
*/
export class SponsoredTxPendingError extends Error {
readonly kind = "sponsored-tx-pending" as const;
readonly sendTransactionStatusId: string;

constructor(opts: { message: string; sendTransactionStatusId: string }) {
super(opts.message);
this.name = "SponsoredTxPendingError";
this.sendTransactionStatusId = opts.sendTransactionStatusId;
}
}

export function isSponsoredTxPendingError(
err: unknown
): err is SponsoredTxPendingError {
return (
err instanceof Error &&
(err as { kind?: string }).kind === "sponsored-tx-pending"
);
}

/**
* Render a Turnkey revert chain into a single human-readable string.
*
Expand Down
Loading
Loading