Skip to content

Negotiation matcher reattempts unresponsive counterparty every scan tick — needs short-TTL tarpit on proposal timeout #30

Description

@vrogojin

Symptom

Observed during the trader-roundtrip soak (sphere-sdk#536 follow-up). Alice's intent matched against a stale BUY intent from a prior soak run whose tenant container had been torn down. Alice's trader spent the entire 15-min soak budget cycling the same dead counterparty in 30-second tarpit cycles:

11:43:06  np_deal_proposed  deal_id=3cea...  acceptor=02c00203...(dead)  rate=0.085 volume=50
11:43:36  deal_timeout      deal_id=3cea...  state=PROPOSED  timeout_ms=30000
11:43:41  np_deal_proposed  deal_id=53bb...  acceptor=02c00203...(STILL the same dead one)
11:44:11  deal_timeout      deal_id=53bb...
11:44:15  np_deal_proposed  deal_id=ed39...  acceptor=02c00203...(STILL)
11:44:50  np_deal_proposed  deal_id=ba22...  acceptor=02c00203...(STILL)

Each scan tick reconsiders the full match candidate set from scratch, picks the same "best" candidate, sends an np.propose_deal, waits 30s, times out, repeats. Other live candidates that arrived later in the feed (the actual current acceptor we wanted to match against) never get a turn.

Expected

Stage Right deadline
Sent np.propose_deal, awaiting np.deal_accepted 30-60s (current 30s ✓)
After proposal timeout — try the next candidate, not the same one missing
Deal accepted → settlement (deposits + escrow + payout chain) ~1h (matches user-level "swap session"; separate code path)

The proposal timeout itself is correctly tuned (30s). The missing piece is a per-counterparty negative-cache so the next scan picks a different candidate.

Diagnosis

The data structure for this already exists. intent-engine.ts:163:

// Per-intent set of counterparty pubkeys that failed (timed out / rejected).
// Prevents repeatedly matching against the same dead counterparty.
const failedCounterparties = new Map<string, Set<string>>();

…and the matcher already consults it at intent-engine.ts:336. But today it's only populated by the W2 yield-timeout fall-through path (intent-engine.ts:427-433). The much more common proposal-timeout-on-PROPOSED-state path doesn't add to it.

onDealCancelled in trader-main.ts:720-737 carries a deliberate comment explaining why we don't permanently blacklist on plain CANCELLED:

Cancellation reasons include:

  • proposal timeout (counterparty unreachable — could be transient)
  • AGENT_BUSY rejection (proposer-election race, fully recoverable)
  • sibling-cancellation when another deal won proposer-election
    None of these justify permanently blacklisting the counterparty…

That reasoning is correct for permanent blacklisting. But a short-TTL tarpit doesn't conflict with it — it gives the counterparty a chance to recover after a few minutes while preventing the alive-and-stuck case observed above.

Proposed fix

In onDealCancelled (trader-main.ts:720), differentiate by cancellation reason:

Reason Action
Proposal timeout (PROPOSED → CANCELLED via the 30s timer in negotiation-handler.ts:670) Add counterparty pubkey to failedCounterparties for this intent with a TTL (5–10 min). Increment a per-pubkey counter; after K=1–3 timeouts add to the set.
AGENT_BUSY rejection No action (existing behavior — recoverable race).
Sibling cancellation (lost proposer-election) No action (existing behavior — race-only).

Implementation notes:

  • The 5–10 min TTL is short enough that a counterparty coming back online recovers quickly, but long enough to skip them across the next ~10–20 scan cycles (scan_interval_ms=30000).
  • TTL semantics should match the existing failedCounterparties LRU/capacity bound (intent-engine.ts:433 comment) — same Map, same cleanup.
  • The "K consecutive timeouts before tarpit" threshold handles the single-bad-tick case (one Nostr DM lost) without penalizing the counterparty for an isolated transient.
  • The tarpit is per-intent, not global, matching the existing data shape. Two different intents with overlapping candidate sets each track their own tarpit.

Why this matters

Without this, a single stuck/dead acceptor monopolizes a busy intent's entire match cycle — every scan picks that one candidate, every proposal times out, and the other live candidates never get a turn. The soak failure mode is the loudest demonstration, but the same dynamic hits production any time a trader-tenant goes offline mid-session: nearby live agents waste their scan budget on the corpse instead of finding each other.

Two follow-up signals would make this even better but are out of scope here:

  • np.deal_rejected with reason=AGENT_BUSY arriving from the counterparty — this implies they're alive but busy; mark less aggressively than a silent timeout.
  • A "counterparty heartbeat" via the market-api or a periodic np.ping — distinguishes "intent stale on aggregator" from "agent went away".

Related

  • Trader posts intents it cannot honor — no pre-flight balance check vs max-rate exposure #29 — pre-flight balance check on intent creation. Tracks a different defense-in-depth (don't post intents you can't fund); orthogonal.
  • sphere-sdk#538 — soak rate-band fix that surfaced this behavior under a stale market-feed state.
  • sphere-sdk#473 — cross-process Nostr DM flakiness (the reason the dead counterparty's container teardown left an orphan intent on the market feed in the first place).

Project convention reminder

Per the design guidance recorded in sphere-sdk#536 + trader-service#29:

Intents work in human-readable decimal strings; actual swaps work in exact bigint smallest-units.

Nothing in the proposed fix touches that seam — the tarpit is a metadata layer over the existing failedCounterparties Map, which already operates in pubkey space (not units).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions