Skip to content

fix(api): auto-recover Axelar stuck-confirm and stop leaked squidRouterPay polling loops#1276

Merged
ebma merged 3 commits into
stagingfrom
fix/squidrouter-pay-abort-and-axelar-recovery
Jul 20, 2026
Merged

fix(api): auto-recover Axelar stuck-confirm and stop leaked squidRouterPay polling loops#1276
ebma merged 3 commits into
stagingfrom
fix/squidrouter-pay-abort-and-axelar-recovery

Conversation

@ebma

@ebma ebma commented Jul 19, 2026

Copy link
Copy Markdown
Member

Context

A Base → BNB onramp transfer (0x31365ff4…236f49, ramp e6f7eb01-…) sat in Axelar status called for ~14 hours because its validator confirmation poll failed — and Axelar's relayer never retries a failed poll. It was recovered manually by broadcasting a fresh ConfirmGatewayTx. This PR automates that recovery and fixes the retry storm the incident exposed in production logs.

Fix 1: honor the processor's AbortSignal (retry-storm / 429s)

PhaseProcessor times out a phase execution after 10 minutes and aborts it, but SquidRouterPayPhaseHandler ignored the signal: every timed-out execution left an immortal 10s polling loop behind. Retries (8×) plus the every-5-min recovery worker kept spawning executions, each leaking another loop — production showed ~336 handler log lines per 2-minute window (one healthy loop produces ~12–24) and HTTP 429 rate-limiting from the SquidRouter status API.

The handler now threads the signal through the initial delay, the status polling loop, and the destination balance check (via the existing signal-aware sleep), so abandoned executions unwind immediately.

Fix 2: auto-recover a failed confirmation poll

When axelarscan reports status: "called" with confirm_failed, the handler now calls the new recoverAxelarStuckConfirm (shared): fetch a signed ConfirmGatewayTx from Axelar's public recovery signing service and broadcast it to the Axelar RPC, restarting the validator poll. Notes:

  • Uses only the public tx hash — no Vortex keys sign anything, no funds move.
  • Attempts are rate-limited by a 10-minute cooldown persisted in ramp state (axelarConfirmRecoveryAt), so retried/concurrent executions don't re-broadcast.
  • Recovery failures are swallowed; the status loop keeps polling and retries after the cooldown.
  • The byte handling is manual because axelarjs-sdk's manualRelayToDestChain mangles the relayer's numeric-keyed JSON byte response and broadcasts an empty tx (must contain at least one message); verified working against the real services during the incident.

Tests

  • packages/shared axelar.test.ts: numeric-keyed + array byte decode, empty-tx rejection, rejected-broadcast rejection.
  • apps/api squid-router-pay-phase-handler.test.ts: recovery triggered with timestamp persisted, cooldown respected, no recovery on healthy statuses, and a regression test asserting polling stops after abort.

Security spec updated: docs/security-spec/05-integrations/squid-router.md (handler behavior + new failure-mode row).

Verification

  • apps/api: 585 pass / 0 fail (28 skips = RUN_LIVE_TESTS-gated)
  • packages/shared: 69 pass
  • bun typecheck clean across the monorepo, Biome clean, bun build:shared rebuilt

…erPay polling loops

Two fixes for the squidRouterPay phase, prompted by a Base->BNB transfer that
sat in Axelar status "called" for 14h after its validator confirmation poll
failed (Axelar's relayer never retries a failed poll):

- Honor the phase processor's AbortSignal in the status polling loop and
  balance check. Previously every timed-out execution left an immortal 10s
  polling loop behind; retries plus the recovery worker piled up dozens of
  them per stuck ramp (~336 log lines/2min observed in production) until the
  SquidRouter status API rate-limited us with 429s.

- Detect a failed confirmation poll (status "called" + confirm_failed from
  axelarscan) and auto-recover: fetch a signed ConfirmGatewayTx from Axelar's
  public recovery signing service and broadcast it to the Axelar RPC, which
  restarts the validator poll. Uses only the public tx hash - no keys, no
  funds. Attempts are rate-limited via a cooldown timestamp persisted in ramp
  state. The byte handling is done manually because axelarjs-sdk's
  manualRelayToDestChain mangles the relayer's numeric-keyed byte response
  and broadcasts an empty tx ("must contain at least one message").
@netlify

netlify Bot commented Jul 19, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 597c0e3
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6a5ce34769a67f00082dfcd5
😎 Deploy Preview https://deploy-preview-1276--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 19, 2026

Copy link
Copy Markdown

Deploy Preview for vortexfi ready!

Name Link
🔨 Latest commit 597c0e3
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6a5ce347a263400008f701ee
😎 Deploy Preview https://deploy-preview-1276--vortexfi.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 19, 2026

Copy link
Copy Markdown

Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit 597c0e3
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6a5ce3474f71ab0008c278e5

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens SquidRouter/Axelar on-ramp processing after a production incident by (1) ensuring phase executions honor the processor’s AbortSignal to prevent leaked polling loops, and (2) adding an automated recovery path for Axelar GMP transfers stuck at status: "called" with confirm_failed by re-broadcasting a ConfirmGatewayTx via Axelar’s public recovery relayer + RPC.

Changes:

  • Add recoverAxelarStuckConfirm in @vortexfi/shared and extend AxelarScan status typing to surface confirm_failed + source chain metadata.
  • Thread AbortSignal through initial delay/polling in SquidRouterPayPhaseHandler, and implement rate-limited stuck-confirm recovery persisted via axelarConfirmRecoveryAt.
  • Add unit tests for relayer byte decoding/broadcast behavior and API handler integration tests (including an abort regression test), plus update the security spec.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/shared/src/services/squidrouter/axelar.ts Exposes confirm_failed info and adds recoverAxelarStuckConfirm to broadcast a relayer-signed ConfirmGatewayTx.
packages/shared/src/services/squidrouter/axelar.test.ts Tests relayer byte-shape decoding and broadcast error handling for recovery.
apps/api/src/api/services/phases/meta-state-types.ts Adds axelarConfirmRecoveryAt to persist recovery cooldown timestamp in ramp state metadata.
apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts Honors AbortSignal for sleeps, triggers rate-limited stuck-confirm recovery, and persists recovery attempt timestamps.
apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts Verifies recovery trigger/cooldown and asserts polling stops after abort.
docs/security-spec/05-integrations/squid-router.md Documents abort-aware polling and the new Axelar stuck-confirm recovery failure mode + mitigation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/shared/src/services/squidrouter/axelar.ts Outdated
Comment thread apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts Outdated
Comment thread apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts Outdated
Comment thread packages/shared/src/services/squidrouter/axelar.ts
Addresses Copilot review on #1276:

- recoverAxelarStuckConfirm: validate the relayer response shape (reject
  null/non-object data, ignore non-numeric keys, require integer bytes in
  0-255) instead of silently coercing garbage into a corrupted broadcast.
- Treat a successful broadcast without a returned tx hash as an error
  rather than returning an empty string.
- Thread the phase AbortSignal into the recovery fetches so aborted
  executions stop promptly instead of finishing network I/O after timeout.
- Normalize an unparseable persisted axelarConfirmRecoveryAt to "never
  attempted" so the cooldown comparison stays well-defined.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread packages/shared/src/services/squidrouter/axelar.ts Outdated
Addresses Copilot follow-up on #1276: byte-by-byte string concatenation
before btoa is quadratic; encode in 8KiB String.fromCharCode chunks
instead (spreading the full array at once could hit the argument-count
limit).
@ebma
ebma merged commit b6af8ed into staging Jul 20, 2026
8 checks passed
@ebma
ebma deleted the fix/squidrouter-pay-abort-and-axelar-recovery branch July 20, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants