diff --git a/components/organization/member-sessions-dialog.tsx b/components/organization/member-sessions-dialog.tsx index 115d0cfed6..55766a22fd 100644 --- a/components/organization/member-sessions-dialog.tsx +++ b/components/organization/member-sessions-dialog.tsx @@ -58,6 +58,7 @@ type LoadState = // risk_flags_json onto labels an org admin can read at a glance. const REASON_LABELS: Record = { new_country: "New country", + unknown_country: "Unknown location", impossible_travel: "Impossible travel", first_geo_attestation: "First sign-in location", }; diff --git a/lib/auth.ts b/lib/auth.ts index 3a1daa3e68..cdf16c5512 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -846,6 +846,12 @@ export const auth = betterAuth({ // cookie expires before a legitimate user finishes the // /verify-mfa flow. const PRE_STEPUP_TTL_MS = 10 * 60 * 1000; + // Persist the risk blob whenever there is signal: a resolved + // country, or an anomaly with no country (unknown_country — a + // login we could no longer place). A null country with no anomaly + // is inconclusive (local dev / self-hosted) and stored as null. + const riskFlagsJson = + risk.country || risk.anomaly ? serializeRiskFlags(risk) : null; // IP-verification does not write to the session row. The // atomic flow in strict-signin / oauth-mfa-finalize / the // /verify-ip endpoint resolves IP trust BEFORE any session @@ -857,11 +863,11 @@ export const auth = betterAuth({ ? { requiresMfa: true, expiresAt: new Date(Date.now() + PRE_STEPUP_TTL_MS), - riskFlagsJson: risk.country ? serializeRiskFlags(risk) : null, + riskFlagsJson, } : { requiresMfa: false, - riskFlagsJson: risk.country ? serializeRiskFlags(risk) : null, + riskFlagsJson, }, }; }, diff --git a/lib/security/login-risk.ts b/lib/security/login-risk.ts index 982ebf278b..56765188cb 100644 --- a/lib/security/login-risk.ts +++ b/lib/security/login-risk.ts @@ -23,9 +23,12 @@ import { * * `country` is the resolved Cloudflare-attested country for the login. * Null when the request did not arrive via Cloudflare (local dev, direct - * origin access). When null, the geo check is treated as inconclusive - * (anomaly = false) — we do not flag based on missing signal, because - * doing so would trip every local-dev login and self-hosted setup. + * origin access). A null country with no prior country history is treated + * as inconclusive (anomaly = false) — we do not flag on a missing signal + * for users who have never been placed, because doing so would trip every + * local-dev login and self-hosted setup. But a null country for a user who + * DOES have a country history is itself the anomaly (unknown_country): a + * session we can no longer place is as suspicious as one from a new country. */ export type LoginRiskSignal = { anomaly: boolean; @@ -52,16 +55,6 @@ const MAX_PLAUSIBLE_VELOCITY_KMH = 800; // instead of dividing by zero. const MIN_ELAPSED_HOURS = 1 / 3600; const EARTH_RADIUS_KM = 6371; -const NULL_RISK: LoginRiskSignal = { - anomaly: false, - reasons: [], - country: null, - region: null, - city: null, - latitude: null, - longitude: null, - recentCountries: [], -}; /** * Resolves the geographic location of the current request. CF-IPCountry @@ -251,7 +244,9 @@ function isImpossibleTravel( * * Two independent signals are merged into one `anomaly`: * - Country: empty history -> first_geo_attestation; known country -> - * clean; unseen country -> new_country anomaly. + * clean; unseen country -> new_country anomaly; no country attested but + * a country history exists -> unknown_country anomaly (a login we can no + * longer place, treated the same as a change to a new country). * - Impossible travel: when the current login carries coordinates and a * prior located session exists, an implausible velocity between them * flags impossible_travel — even when the country is familiar, which @@ -262,21 +257,28 @@ export async function assessLoginRisk( ): Promise { const location = await resolveLoginLocation(); const { country, region, city, latitude, longitude } = location; - if (!country) { - return NULL_RISK; - } const priorCountries = await loadRecentCountries(userId); const reasons: string[] = []; let anomaly = false; let recentCountries: string[] = []; - if (priorCountries.length === 0) { - reasons.push("first_geo_attestation"); - } else if (priorCountries.includes(country)) { - recentCountries = priorCountries.filter((c) => c !== country); - } else { + if (country) { + if (priorCountries.length === 0) { + reasons.push("first_geo_attestation"); + } else if (priorCountries.includes(country)) { + recentCountries = priorCountries.filter((c) => c !== country); + } else { + anomaly = true; + reasons.push("new_country"); + recentCountries = priorCountries; + } + } else if (priorCountries.length > 0) { + // No country attested for this login, but the user has an established + // country history. Falling off the map is as suspicious as moving to a + // new one, so flag it. With no prior history (local dev, self-hosted, + // first login) a missing country stays inconclusive and never flags. anomaly = true; - reasons.push("new_country"); + reasons.push("unknown_country"); recentCountries = priorCountries; } diff --git a/lib/web3/sponsored-send-error.ts b/lib/web3/sponsored-send-error.ts new file mode 100644 index 0000000000..3f66ab6740 --- /dev/null +++ b/lib/web3/sponsored-send-error.ts @@ -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 }; +} diff --git a/lib/web3/sponsored-transaction-manager.ts b/lib/web3/sponsored-transaction-manager.ts index d334899fc4..13452ff3c8 100644 --- a/lib/web3/sponsored-transaction-manager.ts +++ b/lib/web3/sponsored-transaction-manager.ts @@ -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, @@ -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"; @@ -97,6 +98,7 @@ export async function executeSponsoredTransaction( return await finalizeSponsoredTx( submitResult.txHash, + submitResult.sendTransactionStatusId, params.rpcUrl, params.organizationId, params.chainId, @@ -156,6 +158,7 @@ export async function executeSponsoredContractTransaction( return await finalizeSponsoredTx( submitResult.txHash, + submitResult.sendTransactionStatusId, params.rpcUrl, params.organizationId, params.chainId, @@ -163,6 +166,50 @@ export async function executeSponsoredContractTransaction( ); } +// viem renders an Error(string) revert as +// "Execution reverted with reason: ." -- pull 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, + txHash: Hex, + blockNumber: bigint +): Promise { + let tx: Awaited>; + 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. * @@ -173,6 +220,7 @@ export async function executeSponsoredContractTransaction( */ async function finalizeSponsoredTx( txHash: Hex, + sendTransactionStatusId: string, rpcUrl: string, organizationId: string, chainId: number, @@ -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; diff --git a/lib/web3/turnkey-revert.ts b/lib/web3/turnkey-revert.ts index c490f7384e..5051cf0100 100644 --- a/lib/web3/turnkey-revert.ts +++ b/lib/web3/turnkey-revert.ts @@ -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. * diff --git a/lib/web3/turnkey-sponsored-tx.ts b/lib/web3/turnkey-sponsored-tx.ts index 982a60a2ab..c03ba31f3a 100644 --- a/lib/web3/turnkey-sponsored-tx.ts +++ b/lib/web3/turnkey-sponsored-tx.ts @@ -5,6 +5,7 @@ import { getTurnkeyClientForOrg } from "@/lib/turnkey/agentic-wallet"; import { formatRevertChain, type RevertChainEntry, + SponsoredTxPendingError, SponsoredTxRevertError, } from "@/lib/web3/turnkey-revert"; import { toCaip2 } from "@/lib/web3/turnkey-sponsorship-config"; @@ -18,20 +19,37 @@ import { toCaip2 } from "@/lib/web3/turnkey-sponsorship-config"; * Station, and broadcasts. The activity returns a `sendTransactionStatusId` * which we poll until the transaction is broadcast and a hash is available. * - * If the chain is not supported, or the polling deadline expires, callers - * receive null and should fall back to direct signing. + * Return contract: + * - `null` only for failures that happened BEFORE anything was broadcast + * (unsupported chain, `ethSendTransaction` rejected, or a terminal-failure + * status with no tx hash). Callers may safely fall back to direct signing. + * - `SponsoredTxRevertError` when the tx broadcast and reverted on-chain. + * - `SponsoredTxPendingError` when the send was accepted but we could not + * confirm its outcome within the wait window (Turnkey slow to broadcast, + * or its status API kept erroring). The tx may still land, so callers MUST + * NOT fall back -- doing so double-sends from the same wallet. */ const STATUS_POLL_INTERVAL_MS = 1000; -const STATUS_POLL_TIMEOUT_MS = 30_000; +// Turnkey's Gas Station queues, gas-funds, and broadcasts, then inclusion waits +// on the mempool -- routinely longer than 30s under load. A short deadline made +// the poll return null and the caller re-send via direct signing, so a slow +// sponsored tx and its direct-signed retry both landed a block apart and the +// second reverted. Wait longer so a normal-but-slow send resolves to a real +// hash (or a real revert) instead of an ambiguous timeout. +const STATUS_POLL_TIMEOUT_MS = 120_000; +// Tolerate transient Turnkey status-API blips before giving up on confirmation. +const MAX_CONSECUTIVE_STATUS_ERRORS = 3; + +type PollOptions = { + timeoutMs?: number; + intervalMs?: number; +}; // Turnkey's getSendTransactionStatus returns short status strings // (INITIALIZED, BROADCASTING, BROADCASTED, INCLUDED, CONFIRMED, FINALIZED, -// FAILED, DROPPED, REJECTED), NOT the `TRANSACTION_STATUS_*` enum the API uses -// elsewhere -- the original constants never matched, so the poll always timed -// out and callers fell back to direct signing. Turnkey reports INCLUDED only -// once the tx succeeded on-chain and FAILED for reverts, so matching the real -// terminal states keeps revert detection (SponsoredTxRevertError) intact. +// FAILED, DROPPED, REJECTED). We treat these as failures; any other status +// that carries a tx hash means the send is broadcast and we wait on that hash. const TERMINAL_FAILURE_STATUSES = new Set([ "FAILED", "DROPPED", @@ -40,13 +58,6 @@ const TERMINAL_FAILURE_STATUSES = new Set([ "REVERTED", ]); -const TERMINAL_SUCCESS_STATUSES = new Set([ - "BROADCASTED", - "INCLUDED", - "CONFIRMED", - "FINALIZED", -]); - export type TurnkeySponsoredTxParams = { subOrgId: string; walletAddress: string; @@ -73,7 +84,8 @@ function sleep(ms: number): Promise { * fall back to direct signing. */ export async function submitTurnkeySponsoredTransaction( - params: TurnkeySponsoredTxParams + params: TurnkeySponsoredTxParams, + pollOptions?: PollOptions ): Promise { const caip2 = toCaip2(params.chainId); if (caip2 === null) { @@ -118,7 +130,7 @@ export async function submitTurnkeySponsoredTransaction( return null; } - const txHash = await pollForTxHash(params.subOrgId, statusId); + const txHash = await pollForTxHash(params.subOrgId, statusId, pollOptions); if (txHash === null) { return null; } @@ -128,11 +140,16 @@ export async function submitTurnkeySponsoredTransaction( async function pollForTxHash( subOrgId: string, - sendTransactionStatusId: string + sendTransactionStatusId: string, + pollOptions?: PollOptions ): Promise { const turnkey = getTurnkeyClientForOrg(subOrgId); const client = turnkey.apiClient(); - const deadline = Date.now() + STATUS_POLL_TIMEOUT_MS; + const timeoutMs = pollOptions?.timeoutMs ?? STATUS_POLL_TIMEOUT_MS; + const intervalMs = pollOptions?.intervalMs ?? STATUS_POLL_INTERVAL_MS; + const deadline = Date.now() + timeoutMs; + + let consecutiveStatusErrors = 0; while (Date.now() < deadline) { let response: Awaited>; @@ -141,6 +158,7 @@ async function pollForTxHash( organizationId: subOrgId, sendTransactionStatusId, }); + consecutiveStatusErrors = 0; } catch (error) { logSystemError( ErrorCategory.EXTERNAL_SERVICE, @@ -151,7 +169,19 @@ async function pollForTxHash( send_transaction_status_id: sendTransactionStatusId, } ); - return null; + // The send was already accepted; a status blip does not mean it failed. + // Retry a few times, then surface a pending error rather than returning + // null (which would let the caller re-send and double-broadcast). + consecutiveStatusErrors++; + if (consecutiveStatusErrors >= MAX_CONSECUTIVE_STATUS_ERRORS) { + throw new SponsoredTxPendingError({ + message: + "Turnkey status API unavailable; sponsored transaction outcome unknown", + sendTransactionStatusId, + }); + } + await sleep(intervalMs); + continue; } const hash = response.eth?.txHash; @@ -193,25 +223,30 @@ async function pollForTxHash( return null; } - if ( - TERMINAL_SUCCESS_STATUSES.has(response.txStatus) && - hash !== undefined && - hash !== "" - ) { + // Turnkey assigned a hash -> the tx is broadcast and we own it. Return it + // so the caller waits for the receipt and reports the real on-chain outcome + // (included or reverted) as the node result, and never re-sends. + if (hash !== undefined && hash !== "") { return hash as Hex; } - await sleep(STATUS_POLL_INTERVAL_MS); + await sleep(intervalMs); } + // Deadline hit without a terminal status. The send was accepted and may + // still broadcast, so surface a pending error instead of null: the caller + // must fail the step rather than re-send via direct signing. logSystemError( ErrorCategory.EXTERNAL_SERVICE, "[Turnkey Sponsorship] Timed out waiting for tx hash", - new Error(`No txHash within ${STATUS_POLL_TIMEOUT_MS}ms`), + new Error(`No terminal status within ${timeoutMs}ms`), { service: "turnkey", send_transaction_status_id: sendTransactionStatusId, } ); - return null; + throw new SponsoredTxPendingError({ + message: `Sponsored transaction not confirmed within ${timeoutMs}ms; outcome unknown`, + sendTransactionStatusId, + }); } diff --git a/plugins/web3/steps/approve-token-core.ts b/plugins/web3/steps/approve-token-core.ts index 07386c0365..02c5122884 100644 --- a/plugins/web3/steps/approve-token-core.ts +++ b/plugins/web3/steps/approve-token-core.ts @@ -36,11 +36,11 @@ import { import { resolveGasLimitOverrides } from "@/lib/web3/gas-defaults"; import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config"; import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; +import { resolveSponsoredSendError } from "@/lib/web3/sponsored-send-error"; import { executeSponsoredContractTransaction } from "@/lib/web3/sponsored-transaction-manager"; import type { ExecutedCall } from "@/lib/web3/trace-decode"; import { traceExecutedCallWithFailover } from "@/lib/web3/trace-executed-call"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; -import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -341,35 +341,14 @@ export async function approveTokenCore( } ); } catch (error) { - if (isSponsoredTxRevertError(error)) { - logUserError( - ErrorCategory.TRANSACTION, - "[Approve Token] Sponsored transaction reverted on-chain", - error, - { - plugin_name: "web3", - action_name: "approve-token", - chain_id: String(chainId), - tx_hash: error.txHash, - send_transaction_status_id: error.sendTransactionStatusId, - revert_chain_depth: String(error.revertChain.length), - } - ); - return { - success: false, - error: `Transaction reverted: ${error.message}`, - }; + const decision = resolveSponsoredSendError(error, { + logPrefix: "[Approve Token]", + actionName: "approve-token", + chainId, + }); + if (!decision.fallback) { + return { success: false, error: decision.error }; } - logUserError( - ErrorCategory.TRANSACTION, - "[Approve Token] Sponsorship attempted but failed, falling back to direct signing", - error, - { - plugin_name: "web3", - action_name: "approve-token", - chain_id: String(chainId), - } - ); } } diff --git a/plugins/web3/steps/transfer-funds-core.ts b/plugins/web3/steps/transfer-funds-core.ts index c2ecd28b13..6a37044c5b 100644 --- a/plugins/web3/steps/transfer-funds-core.ts +++ b/plugins/web3/steps/transfer-funds-core.ts @@ -34,9 +34,9 @@ import { } from "@/lib/web3/decode-revert-error"; import { resolveGasLimitOverrides } from "@/lib/web3/gas-defaults"; import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; +import { resolveSponsoredSendError } from "@/lib/web3/sponsored-send-error"; import { executeSponsoredTransaction } from "@/lib/web3/sponsored-transaction-manager"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; -import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -266,38 +266,14 @@ export async function transferFundsCore( } ); } catch (error) { - if (isSponsoredTxRevertError(error)) { - // Sponsored tx was broadcast and reverted on-chain. Do NOT fall back - // to direct signing -- the underlying call would just revert again - // and the user would pay gas twice. - logUserError( - ErrorCategory.TRANSACTION, - "[Transfer Funds] Sponsored transaction reverted on-chain", - error, - { - plugin_name: "web3", - action_name: "transfer-funds", - chain_id: String(chainId), - tx_hash: error.txHash, - send_transaction_status_id: error.sendTransactionStatusId, - revert_chain_depth: String(error.revertChain.length), - } - ); - return { - success: false, - error: `Transaction reverted: ${error.message}`, - }; + const decision = resolveSponsoredSendError(error, { + logPrefix: "[Transfer Funds]", + actionName: "transfer-funds", + chainId, + }); + if (!decision.fallback) { + return { success: false, error: decision.error }; } - logUserError( - ErrorCategory.TRANSACTION, - "[Transfer Funds] Sponsorship attempted but failed, falling back to direct signing", - error, - { - plugin_name: "web3", - action_name: "transfer-funds", - chain_id: String(chainId), - } - ); } } diff --git a/plugins/web3/steps/transfer-token-core.ts b/plugins/web3/steps/transfer-token-core.ts index dc19e2a281..9bf1f8f461 100644 --- a/plugins/web3/steps/transfer-token-core.ts +++ b/plugins/web3/steps/transfer-token-core.ts @@ -40,11 +40,11 @@ import { import { resolveGasLimitOverrides } from "@/lib/web3/gas-defaults"; import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config"; import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; +import { resolveSponsoredSendError } from "@/lib/web3/sponsored-send-error"; import { executeSponsoredContractTransaction } from "@/lib/web3/sponsored-transaction-manager"; import type { ExecutedCall } from "@/lib/web3/trace-decode"; import { traceExecutedCallWithFailover } from "@/lib/web3/trace-executed-call"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; -import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -431,35 +431,14 @@ export async function transferTokenCore( } ); } catch (error) { - if (isSponsoredTxRevertError(error)) { - logUserError( - ErrorCategory.TRANSACTION, - "[Transfer Token] Sponsored transaction reverted on-chain", - error, - { - plugin_name: "web3", - action_name: "transfer-token", - chain_id: String(chainId), - tx_hash: error.txHash, - send_transaction_status_id: error.sendTransactionStatusId, - revert_chain_depth: String(error.revertChain.length), - } - ); - return { - success: false, - error: `Transaction reverted: ${error.message}`, - }; + const decision = resolveSponsoredSendError(error, { + logPrefix: "[Transfer Token]", + actionName: "transfer-token", + chainId, + }); + if (!decision.fallback) { + return { success: false, error: decision.error }; } - logUserError( - ErrorCategory.TRANSACTION, - "[Transfer Token] Sponsorship attempted but failed, falling back to direct signing", - error, - { - plugin_name: "web3", - action_name: "transfer-token", - chain_id: String(chainId), - } - ); } } diff --git a/plugins/web3/steps/write-contract-core.ts b/plugins/web3/steps/write-contract-core.ts index 8333fde36d..a75bc6d04b 100644 --- a/plugins/web3/steps/write-contract-core.ts +++ b/plugins/web3/steps/write-contract-core.ts @@ -44,8 +44,8 @@ import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; import { executeSponsoredContractTransaction } from "@/lib/web3/sponsored-transaction-manager"; import type { ExecutedCall } from "@/lib/web3/trace-decode"; import { traceExecutedCallWithFailover } from "@/lib/web3/trace-executed-call"; +import { resolveSponsoredSendError } from "@/lib/web3/sponsored-send-error"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; -import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -400,35 +400,14 @@ export async function writeContractCore( } ); } catch (error) { - if (isSponsoredTxRevertError(error)) { - logUserError( - ErrorCategory.TRANSACTION, - "[Write Contract] Sponsored transaction reverted on-chain", - error, - { - plugin_name: "web3", - action_name: "write-contract", - chain_id: String(chainId), - tx_hash: error.txHash, - send_transaction_status_id: error.sendTransactionStatusId, - revert_chain_depth: String(error.revertChain.length), - } - ); - return { - success: false, - error: `Transaction reverted: ${error.message}`, - }; + const decision = resolveSponsoredSendError(error, { + logPrefix: "[Write Contract]", + actionName: "write-contract", + chainId, + }); + if (!decision.fallback) { + return { success: false, error: decision.error }; } - logUserError( - ErrorCategory.TRANSACTION, - "[Write Contract] Sponsorship attempted but failed, falling back to direct signing", - error, - { - plugin_name: "web3", - action_name: "write-contract", - chain_id: String(chainId), - } - ); } } diff --git a/tests/e2e/vitest/protocol-simulation/_shared/simulate.ts b/tests/e2e/vitest/protocol-simulation/_shared/simulate.ts index 87dd0a7391..70579c8d62 100644 --- a/tests/e2e/vitest/protocol-simulation/_shared/simulate.ts +++ b/tests/e2e/vitest/protocol-simulation/_shared/simulate.ts @@ -46,7 +46,7 @@ export const SIM_WALLET = "0x5115000000000000000000000000000000000051"; const WRITE_TIMEOUT_MS = 120_000; const READ_TIMEOUT_MS = 30_000; -async function impersonatedSend( +async function sendImpersonatedOnce( provider: JsonRpcProvider, from: string, tx: { to: string; data?: string; value?: bigint } @@ -64,6 +64,24 @@ async function impersonatedSend( } } +async function impersonatedSend( + provider: JsonRpcProvider, + from: string, + tx: { to: string; data?: string; value?: bigint } +): Promise { + try { + await sendImpersonatedOnce(provider, from, tx); + } catch { + // A reverted send left no state behind, so one retry is safe. Public + // fork upstreams intermittently serve stale slots to mid-execution + // state fetches, producing reason-less reverts that replay green on + // the same fork moments later; the retry absorbs exactly that class, + // while a genuine revert fails again deterministically. + await new Promise((resolve) => setTimeout(resolve, 2000)); + await sendImpersonatedOnce(provider, from, tx); + } +} + async function provisionSetup( provider: JsonRpcProvider, protocolSlug: string, diff --git a/tests/unit/login-risk.test.ts b/tests/unit/login-risk.test.ts index 0bbd3537ad..28445cd1ae 100644 --- a/tests/unit/login-risk.test.ts +++ b/tests/unit/login-risk.test.ts @@ -98,8 +98,9 @@ beforeEach(() => { }); describe("assessLoginRisk", () => { - it("returns NULL_RISK when CF-Connecting-IP is absent (local dev / direct origin)", async () => { + it("stays inconclusive when CF-Connecting-IP is absent and there is no country history (local dev / direct origin)", async () => { setHeaders({ "cf-ipcountry": "AU" }); + setRecentSessionCountries([]); const result = await assessLoginRisk("user_1"); expect(result).toEqual({ anomaly: false, @@ -111,23 +112,48 @@ describe("assessLoginRisk", () => { longitude: null, recentCountries: [], }); - expect(mockSelect).not.toHaveBeenCalled(); }); - it("returns NULL_RISK when CF-IPCountry is missing despite CF-Connecting-IP", async () => { + it("stays inconclusive when CF-IPCountry is missing and there is no country history", async () => { setHeaders({ "cf-connecting-ip": "203.0.113.1" }); + setRecentSessionCountries([null, null]); const result = await assessLoginRisk("user_1"); expect(result.country).toBeNull(); expect(result.anomaly).toBe(false); }); - it("returns NULL_RISK when CF-IPCountry is the unknown sentinel XX", async () => { + it("stays inconclusive when CF-IPCountry is the unknown sentinel XX and there is no country history", async () => { setHeaders({ "cf-connecting-ip": "203.0.113.1", "cf-ipcountry": "XX", }); + setRecentSessionCountries([]); const result = await assessLoginRisk("user_1"); expect(result.country).toBeNull(); + expect(result.anomaly).toBe(false); + }); + + it("flags unknown_country when a login with no attested country follows a known-country history", async () => { + setHeaders({ + "cf-connecting-ip": "203.0.113.1", + "cf-ipcountry": "XX", + }); + setRecentSessionCountries(["AU", "AU"]); + const result = await assessLoginRisk("user_1"); + expect(result.country).toBeNull(); + expect(result.anomaly).toBe(true); + expect(result.reasons).toEqual(["unknown_country"]); + expect(result.recentCountries).toEqual(["AU"]); + }); + + it("flags unknown_country when CF is bypassed entirely for a user with country history", async () => { + setHeaders({ "cf-ipcountry": "AU" }); + setRecentSessionCountries(["KR"]); + const result = await assessLoginRisk("user_1"); + expect(result.country).toBeNull(); + expect(result.anomaly).toBe(true); + expect(result.reasons).toEqual(["unknown_country"]); + expect(result.recentCountries).toEqual(["KR"]); }); it("flags first_geo_attestation (not anomaly) for a user's first geo-attested session", async () => { diff --git a/tests/unit/sponsored-send-error.test.ts b/tests/unit/sponsored-send-error.test.ts new file mode 100644 index 0000000000..d238da6fd9 --- /dev/null +++ b/tests/unit/sponsored-send-error.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/logging", () => ({ + ErrorCategory: { TRANSACTION: "transaction" }, + logSystemWarn: vi.fn(), + logUserError: vi.fn(), +})); + +// Real error classes so the type guards inside the helper work. +import { resolveSponsoredSendError } from "@/lib/web3/sponsored-send-error"; +import { + SponsoredTxPendingError, + SponsoredTxRevertError, +} from "@/lib/web3/turnkey-revert"; + +const CTX = { logPrefix: "[Test]", actionName: "write-contract", chainId: 1 }; + +describe("resolveSponsoredSendError", () => { + it("does not fall back on an on-chain revert", () => { + const error = new SponsoredTxRevertError({ + message: "Guard/not-allowed", + txHash: "0xabc", + sendTransactionStatusId: "sid", + revertChain: [], + }); + + const decision = resolveSponsoredSendError(error, CTX); + + expect(decision.fallback).toBe(false); + expect(decision).toMatchObject({ + error: "Transaction reverted: Guard/not-allowed (tx 0xabc)", + }); + }); + + it("does not fall back when the sponsored send is submitted but unconfirmed", () => { + const error = new SponsoredTxPendingError({ + message: "outcome unknown", + sendTransactionStatusId: "sid", + }); + + const decision = resolveSponsoredSendError(error, CTX); + + expect(decision.fallback).toBe(false); + expect(decision).toMatchObject({ + error: expect.stringContaining("not confirmed"), + }); + }); + + it("falls back to direct signing on a generic pre-broadcast error", () => { + const decision = resolveSponsoredSendError(new Error("boom"), CTX); + + expect(decision.fallback).toBe(true); + }); +}); diff --git a/tests/unit/turnkey-sponsored-tx.test.ts b/tests/unit/turnkey-sponsored-tx.test.ts index 1a0a319c37..e1c201d02b 100644 --- a/tests/unit/turnkey-sponsored-tx.test.ts +++ b/tests/unit/turnkey-sponsored-tx.test.ts @@ -27,11 +27,17 @@ vi.mock("@/lib/web3/turnkey-sponsorship-config", () => ({ toCaip2: (chainId: number) => `eip155:${chainId}`, })); -// turnkey-revert is intentionally NOT mocked so `instanceof SponsoredTxRevertError` -// works against the real class. -import { SponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; +// turnkey-revert is intentionally NOT mocked so `instanceof` works against the +// real error classes. +import { + SponsoredTxPendingError, + SponsoredTxRevertError, +} from "@/lib/web3/turnkey-revert"; import { submitTurnkeySponsoredTransaction } from "@/lib/web3/turnkey-sponsored-tx"; +// Tight poll options so the timeout / retry paths resolve in milliseconds. +const FAST_POLL = { timeoutMs: 40, intervalMs: 5 }; + const SEPOLIA = 11_155_111; // Generic, lowercased test address (not a real wallet); getAddress() yields its // EIP-55 checksum, which is what Turnkey must receive. @@ -114,4 +120,43 @@ describe("submitTurnkeySponsoredTransaction", () => { expect(result).toBeNull(); }); + + it("returns the hash as soon as Turnkey assigns one, before a terminal-success status", async () => { + mockEthSend.mockResolvedValue({ sendTransactionStatusId: "sid-5" }); + // Non-terminal status, but Turnkey already has a tx hash -> the send is + // broadcast and we own it, so we must return it (not keep polling / re-send). + mockGetStatus.mockResolvedValue({ + txStatus: "BROADCASTING", + eth: { txHash: "0xbroadcasting" }, + }); + + const result = await submitTurnkeySponsoredTransaction( + baseParams(), + FAST_POLL + ); + + expect(result).toEqual({ + txHash: "0xbroadcasting", + sendTransactionStatusId: "sid-5", + }); + }); + + it("throws SponsoredTxPendingError when the wait elapses without a terminal status", async () => { + mockEthSend.mockResolvedValue({ sendTransactionStatusId: "sid-6" }); + // Stuck before broadcast: accepted, but never a hash or terminal status. + mockGetStatus.mockResolvedValue({ txStatus: "INITIALIZED", eth: {} }); + + await expect( + submitTurnkeySponsoredTransaction(baseParams(), FAST_POLL) + ).rejects.toBeInstanceOf(SponsoredTxPendingError); + }); + + it("throws SponsoredTxPendingError after repeated status-API failures instead of returning null", async () => { + mockEthSend.mockResolvedValue({ sendTransactionStatusId: "sid-7" }); + mockGetStatus.mockRejectedValue(new Error("Turnkey status API 503")); + + await expect( + submitTurnkeySponsoredTransaction(baseParams(), FAST_POLL) + ).rejects.toBeInstanceOf(SponsoredTxPendingError); + }); });