Skip to content
1 change: 1 addition & 0 deletions components/organization/member-sessions-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type LoadState =
// risk_flags_json onto labels an org admin can read at a glance.
const REASON_LABELS: Record<string, string> = {
new_country: "New country",
unknown_country: "Unknown location",
impossible_travel: "Impossible travel",
first_geo_attestation: "First sign-in location",
};
Expand Down
10 changes: 8 additions & 2 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
};
},
Expand Down
48 changes: 25 additions & 23 deletions lib/security/login-risk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -262,21 +257,28 @@ export async function assessLoginRisk(
): Promise<LoginRiskSignal> {
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;
}

Expand Down
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