Skip to content

Add local pre-broadcast outcome variant to NodeBroadcastOutcomeSchema (or split into a sibling LocalBroadcastFailureSchema) #32

Description

@whoabuddy

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

  • Schema variant added (Option A or B — preference B documented above).
  • decideBroadcastAction updated with new branches mapping all local failures to responsible: \"relay\" + action: \"retire\".
  • parseLocalBroadcastFailure(err) helper exported and unit-tested for the three known patterns + null pass-through.
  • release-please bumps minor (additive change, no breaking API).
  • Relay PR follows up: replace string-match catch block with the new classifier (planned scope, no behavior change).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions