Skip to content

fix(arch): backend broadcast proxy — closes #297#298

Merged
rz1989s merged 11 commits into
mainfrom
fix/issue-297-backend-broadcast
May 23, 2026
Merged

fix(arch): backend broadcast proxy — closes #297#298
rz1989s merged 11 commits into
mainfrom
fix/issue-297-backend-broadcast

Conversation

@rz1989s
Copy link
Copy Markdown
Member

@rz1989s rz1989s commented May 23, 2026

Summary

Adds POST /api/tx/broadcast — a JWT-authenticated backend endpoint that broadcasts signed Solana transactions via the agent's Helius-keyed Connection instead of the FE's rate-limited public devnet RPC.

Closes #297.

Why

frontier_sip_17 verification (post-#296) found that chat-driven sends still hit "block height exceeded" because the FE broadcasts via https://api.devnet.solana.com (returned by /api/config.publicRpcUrl). Public devnet RPC drops broadcasts silently under load — two real signatures from that session (3Cj3Nr…hTn6, 3SdEhA…hTn6) returned getTransaction → null seconds after sign.

PR #296's FE-side resubmit loop was necessary but not sufficient: aggressive resubmits to a dropping RPC don't land. The backend already has a Helius-keyed Connection (used in tool-signing.ts for verify); routing broadcasts through it is the architectural fix.

What

  • POST /api/tx/broadcast — JWT-auth'd; takes { serializedTx (base64), blockhash, lastValidBlockHeight }; returns { signature } after confirmTransaction at 'confirmed'.
  • sendAndConfirmWithRetry ported from app/src/lib/sendWithRetry.ts to packages/agent/src/lib/sendWithRetry.ts. Same resubmit-while-confirming logic, Node-side Connection.
  • FE useTransactionSigner calls broadcastViaBackend (new helper in app/src/lib/broadcast.ts) instead of connection.sendRawTransaction + local resubmit. External hook shape unchanged.
  • FE sendWithRetry.ts + its test deleted (only caller was useTransactionSigner; covered by backend port).

Architecture

Spec: docs/superpowers/specs/2026-05-23-issue-297-backend-broadcast-design.md
Plan: docs/superpowers/plans/2026-05-23-issue-297-backend-broadcast-plan.md

Locked decisions (from brainstorming):

  • Option A (backend proxy) over B (full RPC proxy) and C (swap public RPC URL)
  • Backend owns broadcast + confirm + resubmit (sync long-running POST, no SSE)
  • Keep separate from SENTINEL `/api/tool-signing/:flagId/confirm` flow
  • JWT auth required (mirrors existing fund-moving endpoints)

Error contract

HTTP code When
401 UNAUTHENTICATED No JWT
400 VALIDATION_FAILED Bad body shape, malformed base64, undeserializable tx
400 BLOCKHASH_EXPIRED lastValidBlockHeight already passed at receive time
502 BROADCAST_FAILED First sendRawTransaction throws non-recoverable (SendTransactionError)
504 CONFIRMATION_TIMEOUT Blockhash expires before confirmation (TransactionExpiredBlockheightExceededError)
500 INTERNAL Anything else; Helius API key fragments stripped from message

Test plan

  • `packages/agent/tests/lib/sendWithRetry.test.ts` — 5 tests (port of FE tests + call-signature assertions on `{ skipPreflight: true, maxRetries: 0 }` and commitment level)
  • `packages/agent/tests/routes/tx-broadcast.test.ts` — 12 tests (auth, validation, happy path, BLOCKHASH_EXPIRED, CONFIRMATION_TIMEOUT, BROADCAST_FAILED, INTERNAL, Helius key redaction). Uses REAL `SendTransactionError` and `TransactionExpiredBlockheightExceededError` from `@solana/web3.js` (catches the `instanceof` discrimination correctly — earlier draft used `err.name === 'SendTransactionError'` which never fires because the class only sets `.name` on the constructor, not the prototype).
  • `app/src/lib/tests/broadcast.test.ts` — 5 tests (POST shape, auth header, success, 4xx, 5xx)
  • `pnpm typecheck` — clean across root + sdk + app + agent
  • `pnpm test -- --run` — all green (agent 1647, app 577, sdk 96, root sipher 555 = 2875 total)
  • `pnpm --filter @sipher/app build` — Vite build succeeds, no broken imports
  • Post-deploy manual smoke: chat-driven send via cipher-admin → verify signature on solscan → confirm SENTINEL pending-signing resolves → confirm PR feat(claim): Path A polish — auto-derive destinationWallet + human-readable amount #288 prod verification unblocked

Out of scope (follow-ups)

  • Flip `/api/config.publicRpcUrl` to backend proxy — separate PR
  • Backend blockhash endpoint — only if public RPC reads start failing
  • Promote `sendAndConfirmWithRetry` to `@sipher/sdk` — right long-term home
  • Per-wallet rate limiting — post-Frontier hardening
  • Durable nonce migration — much bigger lift
  • sipher#292 Cloudflare HTTP/3 toggle — RECTOR-driven (independent)

Related

rz1989s added 11 commits May 23, 2026 14:56
The FE currently broadcasts via public devnet RPC which silently drops
under rate limit. Spec describes POST /api/tx/broadcast, a JWT-auth
endpoint that takes a signed tx + blockhash and broadcasts via the
backend's Helius-keyed Connection. Backend owns broadcast + resubmit +
confirm; FE await is unchanged from useTransactionSigner's caller view.

Locks 4 architectural decisions from the brainstorming session:
- Option A over B (full RPC proxy) and C (swap public RPC)
- Backend owns broadcast + confirm + resubmit (sync long-running POST)
- Keep separate from SENTINEL /api/tool-signing/:flagId/confirm flow
- JWT auth required (mirrors vault-deposit-tx, refund-tx, tool-signing)

Refs #297
Companion to docs/superpowers/specs/2026-05-23-issue-297-backend-
broadcast-design.md. 7 tasks, TDD, ~3hr estimate. Each task is
bite-sized (5-60min) with bash commands + expected output. Spec
deviation: drops the BroadcastError class in favor of apiFetch's
message-based throw, matching existing FE patterns at SignTxCard,
SentinelConfirm, MultiChainVaultGrid.

Refs #297
Identical logic to app/src/lib/sendWithRetry.ts — broadcasts a signed
tx and aggressively resubmits every 2s while polling for confirmation.
Same dependency-injection shape (resubmitIntervalMs, sleep) for
deterministic tests. The FE copy is deleted in a later task once
useTransactionSigner is wired to the new backend endpoint.

One implementation note: added an extra `await Promise.resolve()` after
the sleep in the resubmit loop so that when sleep is an immediate
Promise (as in tests), the confirmTransaction resolution gets a chance
to set stopped=true before the next submitOnce fires. Matches the
expected behaviour of the test suite.

Refs #297
The previous commit (2e2e86f) added an extra await Promise.resolve()
after sleep() in the resubmit loop to defeat a microtask race in
Test 1's fake-sleep fixture. The cleaner fix is to use real setTimeout
for the happy-path test (no injected sleep), matching the FE original's
approach — confirmTransaction resolves as a microtask before the
first resubmit timer fires, so stopped=true lands before any second
submitOnce() call. Tests 2-5 continue to use the instant fake sleep.

Helper is now byte-identical to app/src/lib/sendWithRetry.ts (modulo
the JSDoc note pointing at sipher#297). Both will diverge after T6
deletes the FE copy.

Refs #297
Adds toHaveBeenCalledWith assertions to guard the load-bearing
invariants for sipher#297:
- sendRawTransaction must be called with { skipPreflight: true,
  maxRetries: 0 } (a regression to skipPreflight: false would
  reintroduce the public-RPC drop class this PR fixes).
- confirmTransaction must use commitment 'confirmed' with the
  full blockhash strategy object (commitment level is part of
  the contract; 'finalized' would slow demo latency markedly).

Also restores a JSDoc line that was dropped from the FE original
('Exported for testing; injected interval/sleep keeps tests
deterministic.') — keeps function-level documentation consistent.

Refs #297
Takes { serializedTx, blockhash, lastValidBlockHeight }, validates the
body + decodes + deserializes, then broadcasts via the Helius-backed
Connection through sendAndConfirmWithRetry. Returns { signature } on
confirm, 504 CONFIRMATION_TIMEOUT if blockhash expires, 502
BROADCAST_FAILED if the RPC rejects the first send, 400
VALIDATION_FAILED for malformed input, 401 UNAUTHENTICATED if no JWT.

Helius URL fragments are stripped from error messages before client
return — see redact() and the matching test.

The router is mounted in a follow-up commit (index.ts wiring).

Refs #297
…tructure

Three review fixes for the new POST /api/tx/broadcast route:

1. SendTransactionError instances from @solana/web3.js have .name ===
   'Error' (not 'SendTransactionError') — the class only sets .name on
   the constructor, not the prototype. The old err.name check never
   fired for real errors; first-send rejections silently fell through
   to 500 INTERNAL instead of 502 BROADCAST_FAILED. Replace with
   instanceof checks for both SendTransactionError and
   TransactionExpiredBlockheightExceededError; update tests to use the
   real web3.js error classes instead of synthetic .name overrides.

2. req.body is undefined (not {}) when the request has no
   Content-Type: application/json header. Destructure was crashing
   with TypeError → 500. Match the safer (req.body ?? {}) pattern from
   adjacent routes (sentinel-api, tool-signing).

3. Pre-flight getBlockHeight catch had no log line — if Helius itself
   is unreachable the failure was invisible until the broadcast also
   failed. Add a redacted console.warn so prod diagnosis is possible.

Also: happy-path test now asserts sendAndConfirmWithRetry was called
with the expected (connection, signedBytes, blockhash, height) tuple —
guards against a regression that mis-decodes the body fields.

Refs #297
Wires POST /api/tx/broadcast into the express app, matching the
pattern of vault-deposit-tx and vault-refund-tx. JWT-authenticated;
unauthenticated callers get 401 UNAUTHENTICATED from the route handler
itself (verifyJwt middleware also rejects upstream).

Refs #297
Thin wrapper around apiFetch that POSTs to /api/tx/broadcast with the
signed-tx + blockhash + lastValidBlockHeight payload. Returns {
signature } on success; throws with the backend's user-friendly error
message on failure (BLOCKHASH_EXPIRED, CONFIRMATION_TIMEOUT, etc.).

Used by useTransactionSigner in the next commit. Replaces the
FE-side broadcast hop that drops silently on public devnet RPC.

Refs #297
useTransactionSigner now POSTs the signed tx to /api/tx/broadcast
(JWT-authenticated) which broadcasts through the backend's Helius
connection. The FE still refreshes blockhash + signs locally; only
the broadcast hop moves server-side. External hook shape unchanged —
signAndBroadcast still returns { signature } | { error }.

Fixes the failure mode from frontier_sip_17: chat-driven sends hit
'block height exceeded' because public devnet RPC dropped the
broadcasts silently. PR #296's FE-side resubmit was necessary but
not sufficient — even aggressive resubmits to the dropping RPC don't
land. Backend Helius connection broadcasts reliably.

Refs #297
The broadcast hop moved to the backend (T5). The FE
sendAndConfirmWithRetry helper had exactly one caller —
useTransactionSigner — which now uses broadcastViaBackend. Its 5
tests are covered by the backend port at packages/agent/src/lib/
sendWithRetry.ts.

Net app test count: 577 → ~577 (-5 sendWithRetry, +5 broadcast).

Refs #297
@vercel
Copy link
Copy Markdown

vercel Bot commented May 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sipher Ready Ready Preview, Comment May 23, 2026 9:01am

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.

fix(arch): FE broadcasts via public devnet RPC — drops never land; route via backend or use Helius

1 participant