Context
NodeBroadcastOutcomeSchema in src/core/nonce-outcome.ts is a discriminated union covering everything stacks-core can return from a broadcast attempt: accepted, nonce_conflict, chaining_limit, nonce_too_low, fee_too_low, insufficient_funds, invalid_transaction, rate_limited, server_error, temporarily_blacklisted. decideBroadcastAction then classifies each into a (responsible, action) pair so consumers can act consistently.
The implicit assumption: every broadcast attempt makes it as far as a node response. But there's a category of failures that happens before the HTTP call, in the local @stacks/transactions library itself:
Cannot sponsor sign a non-sponsored transaction — thrown when tx.auth.authType !== AuthType.Sponsored
Failed to deserialize transaction — thrown on malformed hex
- Various
assert / invariant style throws on bad SpendingConditionSpec, etc.
These bypass the classifier entirely and have to be handled with ad-hoc string matching in consumer catch blocks, which violates the whole point of the typed pipeline.
Production incident that motivated this
aibtcdev/x402-sponsor-relay 2026-05-19 13:00 UTC: a buggy /settle re-sponsor branch (PR aibtcdev/x402-sponsor-relay#382) enqueued a structurally-invalid hex into the bounded-broadcast queue. On every alarm tick (~5 min), the worker tried to sponsor-sign the queued tx, threw "Cannot sponsor sign a non-sponsored transaction", got caught by a generic catch block that logged the warning and incremented an error counter — and retried indefinitely.
Result: 3 wallet-0 sponsor nonces wedged for hours generating perpetual log spam until natural cleanup mechanisms (dispatch_flush_start: dispatched_entry_stuck) eventually fired.
Two stopgap fixes shipped on the relay side:
The #392 change is the part that belongs in tx-schemas. String-matching in a catch block is not the right home for a classification decision — it should be in the discriminated-union schema, alongside the existing node outcomes.
What we need
Option A — extend NodeBroadcastOutcomeSchema
Add a new variant:
const LocalStructuralFailureSchema = z.object({
outcome: z.literal(\"local_structural_failure\"),
kind: z.enum([\"auth_type_mismatch\", \"deserialize_failed\", \"malformed_bytes\", \"unknown\"]),
reason: z.string().min(1),
});
Pros: single union, callers only need to handle one discriminated type.
Cons: muddles the "this is what the node said" semantics — the name NodeBroadcastOutcomeSchema implies a node response.
Option B — sibling LocalBroadcastFailureSchema (preferred)
Keep NodeBroadcastOutcomeSchema strictly for node responses. Add a sibling:
export const LocalBroadcastFailureSchema = z.discriminatedUnion(\"kind\", [
z.object({ kind: z.literal(\"auth_type_mismatch\"), reason: z.string() }),
z.object({ kind: z.literal(\"deserialize_failed\"), reason: z.string() }),
z.object({ kind: z.literal(\"malformed_bytes\"), reason: z.string() }),
z.object({ kind: z.literal(\"unknown_local\"), reason: z.string() }),
]);
export type LocalBroadcastFailure = z.infer<typeof LocalBroadcastFailureSchema>;
And a wrapper union for full broadcast attempt outcomes:
export const BroadcastAttemptOutcomeSchema = z.discriminatedUnion(\"source\", [
z.object({ source: z.literal(\"node\"), node: NodeBroadcastOutcomeSchema }),
z.object({ source: z.literal(\"local\"), local: LocalBroadcastFailureSchema }),
]);
Pros: keeps node semantics clean, lets callers branch on source first.
Companion: extend decideBroadcastAction
Either way, decideBroadcastAction (src/core/... — wherever it lives) needs to gain branches that:
- Map all
local_structural_failure / LocalBroadcastFailure variants to responsible: \"relay\" + action: \"retire\" (do not retry — the hex is broken, not the transient world).
- Provide a
parseLocalBroadcastFailure(err: unknown) helper that takes a caught Error and classifies it (or returns null for non-structural throws that should propagate).
export function parseLocalBroadcastFailure(err: unknown): LocalBroadcastFailure | null {
const msg = err instanceof Error ? err.message : String(err);
if (/non-sponsored transaction/i.test(msg)) return { kind: \"auth_type_mismatch\", reason: msg };
if (/failed to deserialize/i.test(msg)) return { kind: \"deserialize_failed\", reason: msg };
if (/malformed/i.test(msg)) return { kind: \"malformed_bytes\", reason: msg };
return null;
}
Consumer rewrite (relay)
Once landed, aibtcdev/x402-sponsor-relay/src/durable-objects/nonce-do.ts bounded_broadcast catch block stops doing its own regex and routes through the schema:
} catch (e) {
const local = parseLocalBroadcastFailure(e);
if (local) {
const action = decideBroadcastAction({ source: \"local\", local });
if (action.action === \"retire\") {
this.retireQueuedEntry(entry.wallet_index, entry.sponsor_nonce, local.kind);
this.log(\"warn\", \"bounded_broadcast_local_failure_retired\", { ...local });
errors++;
continue;
}
}
// genuinely unexpected — keep current catch behavior
this.log(\"warn\", \"bounded_broadcast_error\", { error: e instanceof Error ? e.message : String(e) });
errors++;
}
Acceptance criteria
Out of scope
- Re-enabling
ENABLE_SETTLE_RESPONSOR on the relay. That needs a separate rewrite of the re-sponsor path to bypass the dispatch queue entirely (not enqueue and sign in two steps) — tracked separately.
Refs
Context
NodeBroadcastOutcomeSchemainsrc/core/nonce-outcome.tsis a discriminated union covering everythingstacks-corecan return from a broadcast attempt:accepted,nonce_conflict,chaining_limit,nonce_too_low,fee_too_low,insufficient_funds,invalid_transaction,rate_limited,server_error,temporarily_blacklisted.decideBroadcastActionthen classifies each into a(responsible, action)pair so consumers can act consistently.The implicit assumption: every broadcast attempt makes it as far as a node response. But there's a category of failures that happens before the HTTP call, in the local
@stacks/transactionslibrary itself:Cannot sponsor sign a non-sponsored transaction— thrown whentx.auth.authType !== AuthType.SponsoredFailed to deserialize transaction— thrown on malformed hexassert/invariantstyle throws on bad SpendingConditionSpec, etc.These bypass the classifier entirely and have to be handled with ad-hoc string matching in consumer catch blocks, which violates the whole point of the typed pipeline.
Production incident that motivated this
aibtcdev/x402-sponsor-relay2026-05-19 13:00 UTC: a buggy/settlere-sponsor branch (PR aibtcdev/x402-sponsor-relay#382) enqueued a structurally-invalid hex into the bounded-broadcast queue. On every alarm tick (~5 min), the worker tried to sponsor-sign the queued tx, threw "Cannot sponsor sign a non-sponsored transaction", got caught by a generic catch block that logged the warning and incremented an error counter — and retried indefinitely.Result: 3 wallet-0 sponsor nonces wedged for hours generating perpetual log spam until natural cleanup mechanisms (
dispatch_flush_start: dispatched_entry_stuck) eventually fired.Two stopgap fixes shipped on the relay side:
ENABLE_SETTLE_RESPONSOR=false(prevents new bad entries)non-sponsored transaction/failed to deserialize/malformedin the catch block (cleans up zombies)The #392 change is the part that belongs in tx-schemas. String-matching in a catch block is not the right home for a classification decision — it should be in the discriminated-union schema, alongside the existing node outcomes.
What we need
Option A — extend
NodeBroadcastOutcomeSchemaAdd a new variant:
Pros: single union, callers only need to handle one discriminated type.
Cons: muddles the "this is what the node said" semantics — the name
NodeBroadcastOutcomeSchemaimplies a node response.Option B — sibling
LocalBroadcastFailureSchema(preferred)Keep
NodeBroadcastOutcomeSchemastrictly for node responses. Add a sibling:And a wrapper union for full broadcast attempt outcomes:
Pros: keeps node semantics clean, lets callers branch on
sourcefirst.Companion: extend
decideBroadcastActionEither way,
decideBroadcastAction(src/core/...— wherever it lives) needs to gain branches that:local_structural_failure/LocalBroadcastFailurevariants toresponsible: \"relay\"+action: \"retire\"(do not retry — the hex is broken, not the transient world).parseLocalBroadcastFailure(err: unknown)helper that takes a caughtErrorand classifies it (or returnsnullfor non-structural throws that should propagate).Consumer rewrite (relay)
Once landed,
aibtcdev/x402-sponsor-relay/src/durable-objects/nonce-do.tsbounded_broadcast catch block stops doing its own regex and routes through the schema:Acceptance criteria
decideBroadcastActionupdated with new branches mapping all local failures toresponsible: \"relay\"+action: \"retire\".parseLocalBroadcastFailure(err)helper exported and unit-tested for the three known patterns + null pass-through.Out of scope
ENABLE_SETTLE_RESPONSORon the relay. That needs a separate rewrite of the re-sponsor path to bypass the dispatch queue entirely (not enqueue and sign in two steps) — tracked separately.Refs