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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions