You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
~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.constfailedCounterparties=newMap<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)
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".
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).
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:
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
np.propose_deal, awaitingnp.deal_acceptedThe 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:…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.onDealCancelledintrader-main.ts:720-737carries a deliberate comment explaining why we don't permanently blacklist on plainCANCELLED: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:negotiation-handler.ts:670)failedCounterpartiesfor this intent with a TTL (5–10 min). Increment a per-pubkey counter; after K=1–3 timeouts add to the set.AGENT_BUSYrejectionImplementation notes:
scan_interval_ms=30000).intent-engine.ts:433comment) — same Map, same cleanup.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_rejectedwith reason=AGENT_BUSYarriving from the counterparty — this implies they're alive but busy; mark less aggressively than a silent timeout.np.ping— distinguishes "intent stale on aggregator" from "agent went away".Related
Project convention reminder
Per the design guidance recorded in sphere-sdk#536 + trader-service#29:
Nothing in the proposed fix touches that seam — the tarpit is a metadata layer over the existing
failedCounterpartiesMap, which already operates in pubkey space (not units).