diff --git a/.agents/skills/vortex-review/SKILL.md b/.agents/skills/vortex-review/SKILL.md index c2315032e..2bfc5ab58 100644 --- a/.agents/skills/vortex-review/SKILL.md +++ b/.agents/skills/vortex-review/SKILL.md @@ -39,9 +39,10 @@ lens. Spawn the selected lenses as parallel read-only subagents (use the Workflow tool if available, otherwise parallel Agent tasks). Finders are recall-oriented — precision comes -from verification in step 4 — so run them on a cheaper model than the session default: in -Claude Code pass `model: "sonnet"` (Workflow tool: `opts.model: 'sonnet'`, -`opts.effort: 'low'`); in Codex use GPT-5.6-Luna instead of GPT-5.6-Sol. Every finder +from verification in step 4, which cannot recover what a finder missed — so in Claude Code +run them on Opus at medium effort: pass `model: "opus"` (Workflow tool: `opts.effort: +'medium'`; it has no model option, so agents inherit the session's Opus model); in Codex +use GPT-5.6-Luna instead of GPT-5.6-Sol. Every finder gets: the diff, the change inventory, its lens brief below, and these standing rules: - Verify every claim against the actual code — read whole functions/files, never judge diff --git a/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts index 0eb48b308..2f27fb5df 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts @@ -263,6 +263,46 @@ describe("SquidRouterPayExecutor reliability", () => { expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); }); + it("triggers confirm recovery on confirm_failed whatever the status, until the call is approved", async () => { + // Live shape on 2026-10-06 (Base -> BSC): the failed poll left status "confirmed", not "called". + const failedPoll = { + ...FEE_STATUS, + call: { chain: "base" }, + confirm_failed: true, + is_insufficient_fee: false, + status: "confirmed" + }; + const pollUntilExecuted = async (axelarStatus: Record) => { + getStatusAxelarScan + .mockImplementationOnce(async () => axelarStatus as never) + .mockImplementationOnce(async () => ({ ...axelarStatus, status: "executed" }) as never); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.initialDelayMs = 0; + handler.pollIntervalMs = 0; + handler.stuckAlertThresholdMs = Number.POSITIVE_INFINITY; + handler.patchStateKey = mock(async (target: RampState, key: string, value: string) => { + target.state = { ...target.state, [key]: value }; + return 1; + }); + const state = makeState(); + await handler.checkBridgeStatus(state, SWAP_HASH, makeQuote(), 1000); + return state; + }; + + const recovered = await pollUntilExecuted(failedPoll); + expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); + expect(recoverAxelarStuckConfirm.mock.calls[0]?.slice(0, 2)).toEqual([SWAP_HASH, "base"]); + expect(recovered.state.axelarConfirmRecoveryAt).toBeString(); + + const approved = await pollUntilExecuted({ + ...failedPoll, + approved: { block_timestamp: 1_791_295_300 }, + status: "approved" + }); + expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); + expect(approved.state.axelarConfirmRecoveryAt).toBeUndefined(); + }); + it("alerts once with block context and the current stuck classification", async () => { const state = makeState(); state.phaseHistory = [{ phase: "squidRouterPay", timestamp: new Date(Date.now() - 30 * 60 * 1000) }]; diff --git a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts index ead419959..03960eb03 100644 --- a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts @@ -574,7 +574,7 @@ export class SquidRouterPayExecutor extends BasePhaseHandler { await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); await this.patchStateKey(state, "squidRouterPayTxHash", payTxHash); - } else if (axelarScanStatus.status === "called" && axelarScanStatus.confirm_failed) { + } else if (axelarScanStatus.confirm_failed && !axelarScanStatus.approved && !axelarScanStatus.executed) { recoveryOutcome = await this.maybeRecoverStuckConfirm(state, swapHash, axelarScanStatus.call?.chain, signal); } diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index 52c9feb76..cc9685267 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -82,7 +82,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is | Threat | Mitigation | |---|---| | **Bridge funds stuck in transit** | Dual monitoring (Squid + Axelar scan). Arrival polling is bounded at 80% of the phase processor timeout (8 minutes by default) and stops on processor abort. Phase retries on failure. Gas proactively funded via `addNativeGas`. Past the 20-minute stuck threshold the handler classifies the GMP, attempts the safe recovery for that state, and alerts ops with an actionability classification (see phase-handler description). | -| **Axelar validator confirm poll fails (transfer stuck at "called")** | Auto-recovery: broadcast a fresh `ConfirmGatewayTx` obtained from Axelar's recovery signing service (public tx hash only, no Vortex keys). Cooldown of 10 minutes between attempts, persisted in ramp state. Recovery failures are swallowed; the status loop keeps polling and retries after the cooldown. Past the stuck threshold the same recovery is also attempted for transfers lingering in "called"/"confirming" without `confirm_failed` (axelarscan does not always flag a stalled poll). | +| **Axelar validator confirm poll fails (`confirm_failed` without `approved`)** | Triggered by Axelarscan's `confirm_failed` flag while no `approved`/`executed` object exists, whatever the status: a failed poll can still read "confirmed" because Axelarscan also records its confirm event. Auto-recovery: broadcast a fresh `ConfirmGatewayTx` obtained from Axelar's recovery signing service (public tx hash only, no Vortex keys). Cooldown of 10 minutes between attempts, persisted in ramp state. Recovery failures are swallowed; the status loop keeps polling and retries after the cooldown. Past the stuck threshold the same recovery is also attempted for transfers lingering in "called"/"confirming" without `confirm_failed` (axelarscan does not always flag a stalled poll). | | **Insufficient Axelar gas after payment** | Stuck monitor sends at most one supplemental `addNativeGas` top-up: the `"pending"` sentinel on `squidRouterExtraGasTxHash` is claimed atomically (conditional UPDATE requiring the marker to still be absent) before the broadcast and reconciled to the tx hash after, so neither a crash nor a concurrent execution can re-pay. Aborted executions take no recovery actions. Skipped in the iteration that made the initial payment (the fetched status predates it). Excess gas is refunded to the funding wallet by the gas service. | | **Axelar relayer approves but never executes the destination call** | Past the stuck threshold and 2 minutes after approval, the funding wallet sends the permissionless gateway-validated execute itself (invariant 19). The gateway marks the command executed, so a race with the relayer reverts the second execute and only costs gas. The payload pays the ramp's ephemeral; Vortex never transfers to the customer directly. Outcome is reported in the stuck alert's "action taken"; an unknown outcome leaves the `"pending"` marker for manual reconciliation. | | **Malicious or wrong Axelarscan data drives the destination execute** | The target, command id, and payload come from Axelarscan. The transaction carries zero value and a gas limit capped at `DEFAULT_SQUIDROUTER_GAS_ESTIMATE`, the payload must hash to the approved `payloadHash`, `estimateGas` must succeed, and the per-ramp claim allows one send. Blast radius: one capped-gas call per ramp from the funding wallet; it cannot move the wallet's tokens without prior approvals. | diff --git a/packages/shared/src/services/squidrouter/axelar.test.ts b/packages/shared/src/services/squidrouter/axelar.test.ts index b813a65b3..ecb817c97 100644 --- a/packages/shared/src/services/squidrouter/axelar.test.ts +++ b/packages/shared/src/services/squidrouter/axelar.test.ts @@ -82,6 +82,21 @@ describe("classifyGmpStatus", () => { expect(classifyGmpStatus({ ...stuck, executed: {}, status: "executed" })).toBe("executed"); expect(classifyGmpStatus({ ...stuck, is_insufficient_fee: true })).toBe("insufficient_gas"); }); + + it("treats a failed confirm poll as stuck whatever the status, until the call is approved", () => { + // Shape seen on 2026-10-06 (Base -> BSC, poll 3258866): the failed poll recorded both a + // confirm and a confirm_failed event, so axelarscan reports "confirmed", not "called". + const failedPoll = status({ call: { chain: "base" }, confirm_failed: true, status: "confirmed" }); + + expect(classifyGmpStatus(failedPoll)).toBe("source_confirmation_stuck"); + expect(classifyGmpStatus({ ...failedPoll, status: "confirming" })).toBe("source_confirmation_stuck"); + expect(classifyGmpStatus({ ...failedPoll, approved: { block_timestamp: 1 } })).toBe("relayer_pending"); + expect(classifyGmpStatus({ ...failedPoll, approved: { block_timestamp: 1 }, status: "approved" })).toBe( + "approved_not_executed" + ); + expect(classifyGmpStatus({ ...failedPoll, executed: {}, status: "executed" })).toBe("executed"); + expect(classifyGmpStatus({ ...failedPoll, is_insufficient_fee: true })).toBe("insufficient_gas"); + }); }); describe("getStatusAxelarScan", () => { diff --git a/packages/shared/src/services/squidrouter/axelar.ts b/packages/shared/src/services/squidrouter/axelar.ts index fc7232dc9..b0eb65ffe 100644 --- a/packages/shared/src/services/squidrouter/axelar.ts +++ b/packages/shared/src/services/squidrouter/axelar.ts @@ -28,8 +28,9 @@ export interface AxelarScanStatusResponse { fees: AxelarScanStatusFees; id: string; // the id of the swap. // Set by axelarscan when the validator poll confirming the source event failed. - // Axelar's own relayer does not retry a failed poll, so the transfer stays in - // status "called" until a new ConfirmGatewayTx is broadcast. + // Axelar's own relayer does not retry a failed poll, so the transfer stalls until a + // new ConfirmGatewayTx is broadcast. The status can still read "confirmed": axelarscan + // records the failed poll's confirm event too (poll 3258866, 2026-10-06). confirm_failed?: boolean; call?: { chain: string; // source chain in Axelar naming, e.g. "base" @@ -88,7 +89,8 @@ export function classifyGmpStatus(status: AxelarScanStatusResponse | undefined | ) { return "insufficient_gas"; } - if (status.status === "called") return status.confirm_failed ? "source_confirmation_stuck" : "waiting_source_confirmation"; + if (status.confirm_failed && !status.approved && !status.executed) return "source_confirmation_stuck"; + if (status.status === "called") return "waiting_source_confirmation"; if (status.status === "confirming" || status.status === "confirmable") return "waiting_source_confirmation"; // Approved but no relayer tx after the grace period. no_gas_remain is not used: axelarscan // sets it on healthy executed calls too, and alongside a positive gas_remain_amount. @@ -101,8 +103,8 @@ export function classifyGmpStatus(status: AxelarScanStatusResponse | undefined | ) { return "approved_not_executed"; } - // "confirmed" means the source confirmation already succeeded — the transfer is - // waiting on approval/execution, so another ConfirmGatewayTx would be irrelevant. + // Without confirm_failed, "confirmed" means the source confirmation succeeded — the + // transfer is waiting on approval/execution, so another ConfirmGatewayTx would be irrelevant. if ( ["confirmed", "approving", "approvable", "approved", "executing", "executable", "express_executable"].includes( status.status @@ -117,8 +119,8 @@ const AXELAR_SIGNING_RELAYER_URL = "https://axelar-signing-relayer-mainnet.axela const AXELAR_RPC_URL = "https://mainnet.rpc.axelar.dev/chain/axelar"; /** - * Recovers a GMP transfer stuck at the confirmation step (status "called" with - * confirm_failed) by asking Axelar's recovery signing service for a signed + * Recovers a GMP transfer stuck at the confirmation step (confirm_failed and not + * yet approved) by asking Axelar's recovery signing service for a signed * ConfirmGatewayTx and broadcasting it to the Axelar network. This restarts the * validator poll; once it passes, approval and execution proceed automatically. *