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
7 changes: 4 additions & 3 deletions .agents/skills/vortex-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
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) }];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion docs/security-spec/05-integrations/squid-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/services/squidrouter/axelar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
16 changes: 9 additions & 7 deletions packages/shared/src/services/squidrouter/axelar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
*
Expand Down
Loading