fix(arch): backend broadcast proxy — closes #297#298
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced May 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
POST /api/tx/broadcast— a JWT-authenticated backend endpoint that broadcasts signed Solana transactions via the agent's Helius-keyedConnectioninstead 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) returnedgetTransaction → nullseconds 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.tsfor verify); routing broadcasts through it is the architectural fix.What
POST /api/tx/broadcast— JWT-auth'd; takes{ serializedTx (base64), blockhash, lastValidBlockHeight }; returns{ signature }afterconfirmTransactionat'confirmed'.sendAndConfirmWithRetryported fromapp/src/lib/sendWithRetry.tstopackages/agent/src/lib/sendWithRetry.ts. Same resubmit-while-confirming logic, Node-side Connection.useTransactionSignercallsbroadcastViaBackend(new helper inapp/src/lib/broadcast.ts) instead ofconnection.sendRawTransaction+ local resubmit. External hook shape unchanged.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):
Error contract
Test plan
Out of scope (follow-ups)
Related