@agirails/sdk 3.4.1 → 3.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/builders/QuoteBuilder.d.ts +9 -3
  2. package/dist/builders/QuoteBuilder.d.ts.map +1 -1
  3. package/dist/builders/QuoteBuilder.js +14 -3
  4. package/dist/builders/QuoteBuilder.js.map +1 -1
  5. package/dist/cli/commands/agent.d.ts +22 -0
  6. package/dist/cli/commands/agent.d.ts.map +1 -0
  7. package/dist/cli/commands/agent.js +209 -0
  8. package/dist/cli/commands/agent.js.map +1 -0
  9. package/dist/cli/index.js +5 -1
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/config/networks.d.ts.map +1 -1
  12. package/dist/config/networks.js +7 -1
  13. package/dist/config/networks.js.map +1 -1
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +12 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/level1/Agent.d.ts.map +1 -1
  19. package/dist/level1/Agent.js +1 -1
  20. package/dist/level1/Agent.js.map +1 -1
  21. package/dist/negotiation/BuyerOrchestrator.d.ts +72 -60
  22. package/dist/negotiation/BuyerOrchestrator.d.ts.map +1 -1
  23. package/dist/negotiation/BuyerOrchestrator.js +411 -380
  24. package/dist/negotiation/BuyerOrchestrator.js.map +1 -1
  25. package/dist/negotiation/MockChannel.d.ts +63 -0
  26. package/dist/negotiation/MockChannel.d.ts.map +1 -0
  27. package/dist/negotiation/MockChannel.js +175 -0
  28. package/dist/negotiation/MockChannel.js.map +1 -0
  29. package/dist/negotiation/NegotiationChannel.d.ts +142 -0
  30. package/dist/negotiation/NegotiationChannel.d.ts.map +1 -0
  31. package/dist/negotiation/NegotiationChannel.js +59 -0
  32. package/dist/negotiation/NegotiationChannel.js.map +1 -0
  33. package/dist/negotiation/ProviderOrchestrator.d.ts +85 -35
  34. package/dist/negotiation/ProviderOrchestrator.d.ts.map +1 -1
  35. package/dist/negotiation/ProviderOrchestrator.js +199 -49
  36. package/dist/negotiation/ProviderOrchestrator.js.map +1 -1
  37. package/dist/negotiation/ProviderPolicy.d.ts +51 -6
  38. package/dist/negotiation/ProviderPolicy.d.ts.map +1 -1
  39. package/dist/negotiation/ProviderPolicy.js +61 -9
  40. package/dist/negotiation/ProviderPolicy.js.map +1 -1
  41. package/dist/negotiation/RelayChannel.d.ts +59 -0
  42. package/dist/negotiation/RelayChannel.d.ts.map +1 -0
  43. package/dist/negotiation/RelayChannel.js +208 -0
  44. package/dist/negotiation/RelayChannel.js.map +1 -0
  45. package/dist/protocol/ACTPKernel.d.ts.map +1 -1
  46. package/dist/protocol/ACTPKernel.js +51 -1
  47. package/dist/protocol/ACTPKernel.js.map +1 -1
  48. package/dist/runtime/BlockchainRuntime.d.ts.map +1 -1
  49. package/dist/runtime/BlockchainRuntime.js +10 -2
  50. package/dist/runtime/BlockchainRuntime.js.map +1 -1
  51. package/package.json +1 -1
@@ -22,26 +22,49 @@ const agirailsApp_1 = require("../api/agirailsApp");
22
22
  const PolicyEngine_1 = require("./PolicyEngine");
23
23
  const DecisionEngine_1 = require("./DecisionEngine");
24
24
  const SessionStore_1 = require("./SessionStore");
25
+ const QuoteBuilder_1 = require("../builders/QuoteBuilder");
25
26
  const CounterOfferBuilder_1 = require("../builders/CounterOfferBuilder");
26
- const CounterAcceptBuilder_1 = require("../builders/CounterAcceptBuilder");
27
- const QuoteChannel_1 = require("../transport/QuoteChannel");
28
27
  const NonceManager_1 = require("../utils/NonceManager");
29
28
  const verifyQuoteOnChain_1 = require("./verifyQuoteOnChain");
29
+ const NegotiationChannel_1 = require("./NegotiationChannel");
30
30
  class BuyerOrchestrator {
31
31
  constructor(policy, runtime, requesterAddress, actpDir, negotiation = {}) {
32
- /** Quotes pushed in by the caller's quote-channel handler, keyed by txId. */
33
- this.receivedQuotes = new Map();
34
- /** Provider-acceptance-of-counter signal, keyed by txId. */
35
- this.counterAccepted = new Map();
36
- /** Resolvers waiting on counter acceptance — woken by setCounterAccepted. */
37
- this.counterWaiters = new Map();
38
32
  /**
39
- * Counters this orchestrator sent (one per round, last write wins),
40
- * keyed by txId. Stored so `setCounterAccepted` can cryptographically
41
- * bind a provider's acceptance message to the exact counter we sent
42
- * (acceptedAmount === counter.counterAmount, inReplyTo === counterHash).
33
+ * Per-txId inbound message queue. Channel callbacks push here; the
34
+ * orchestrator's negotiation loop drains via `_waitForNextMessage`.
35
+ * Bounded implicitly by `_cleanupTxState` at terminal outcomes.
43
36
  */
44
- this.sentCounters = new Map();
37
+ this.inboundQueues = new Map();
38
+ /**
39
+ * Per-txId resolver for the orchestrator's "wait for next message"
40
+ * await. When a message arrives and a resolver is set, the message
41
+ * is delivered immediately instead of queued.
42
+ */
43
+ this.inboundResolvers = new Map();
44
+ /**
45
+ * Active subscriptions opened by `negotiate()`. Closed at end of
46
+ * each negotiation. Multiple concurrent calls are supported.
47
+ */
48
+ this.activeSubscriptions = new Map();
49
+ // Fail-fast on partial negotiation context. Pre-fix bug: a developer
50
+ // who set `negotiationChannel: new RelayChannel(...)` but forgot
51
+ // `signer` or `chainId` got NO error — every tx silently fell
52
+ // through to fixed-price flow with the channel subscription
53
+ // opened-and-immediately-closed for nothing. (P1 audit finding: G.)
54
+ if (negotiation.negotiationChannel) {
55
+ const missing = [];
56
+ if (!negotiation.signer)
57
+ missing.push('signer');
58
+ if (!negotiation.kernelAddress)
59
+ missing.push('kernelAddress');
60
+ if (!negotiation.chainId)
61
+ missing.push('chainId');
62
+ if (missing.length > 0) {
63
+ throw new Error(`BuyerNegotiationContext: negotiationChannel was provided but the following required field(s) are missing: ${missing.join(', ')}. ` +
64
+ `Multi-round negotiation needs all of: signer, kernelAddress, chainId, negotiationChannel. ` +
65
+ `Omit negotiationChannel for fixed-price-only flow.`);
66
+ }
67
+ }
45
68
  this.policy = policy;
46
69
  this.runtime = runtime;
47
70
  this.requesterAddress = requesterAddress;
@@ -53,68 +76,88 @@ class BuyerOrchestrator {
53
76
  this.counterBuilder = new CounterOfferBuilder_1.CounterOfferBuilder(negotiation.signer, negotiation.nonceManager ?? new NonceManager_1.InMemoryNonceManager());
54
77
  }
55
78
  }
79
+ // --------------------------------------------------------------------------
80
+ // Channel inbound dispatch
81
+ // --------------------------------------------------------------------------
56
82
  /**
57
- * Push a verified QuoteMessage into the orchestrator. Callers wire
58
- * this from their QuoteChannel handler so the negotiation loop can
59
- * pick it up on the next poll tick.
83
+ * Channel delivered a verified message for `txId`. If a round is
84
+ * awaiting the next message, hand it directly; otherwise queue.
60
85
  *
61
- * Caller is responsible for validating the signature before pushing
62
- * (the QuoteChannel handler already does this); the orchestrator
63
- * layers on top by cross-referencing against on-chain hash (with
64
- * legacy fallback, §3.6).
86
+ * NOTE: the channel has already verified EIP-712 signature + chainId
87
+ * before invoking us. This handler is concerned only with routing.
65
88
  */
66
- setReceivedQuote(txId, quote, opts = {}) {
67
- this.receivedQuotes.set(txId, {
68
- quote,
69
- providerEndpoint: opts.providerEndpoint,
70
- providerAddress: opts.providerAddress,
71
- actualEscrow: opts.actualEscrow,
72
- });
89
+ _onChannelMessage(txId, delivered) {
90
+ const resolver = this.inboundResolvers.get(txId);
91
+ if (resolver) {
92
+ this.inboundResolvers.delete(txId);
93
+ resolver(delivered.envelope);
94
+ return;
95
+ }
96
+ const queue = this.inboundQueues.get(txId) ?? [];
97
+ queue.push(delivered.envelope);
98
+ this.inboundQueues.set(txId, queue);
73
99
  }
74
100
  /**
75
- * Signal that the provider has accepted our counter-offer off-chain.
76
- * Callers wire this from their quote-channel handler when an
77
- * `agirails.counteraccept.v1` notification arrives.
78
- *
79
- * Verifies the acceptance is cryptographically bound to the counter
80
- * THIS orchestrator sent for THIS txId:
81
- * 1. EIP-712 signature recovers to provider DID
82
- * 2. `acceptMessage.txId === txId`
83
- * 3. `acceptMessage.inReplyTo === keccak(stored counter)`
84
- * 4. `acceptMessage.acceptedAmount === storedCounter.counterAmount`
101
+ * Await the next inbound message matching one of `acceptedTypes`.
102
+ * Returns null on timeout (caller decides how to handle: cancel,
103
+ * accept-if-affordable, etc).
85
104
  *
86
- * Throws on any mismatch. Without these checks a malicious peer could
87
- * pose as the provider and trick the buyer into committing at any
88
- * price, since the previous (string-only) API trusted the caller.
89
- *
90
- * Wakes any negotiation round waiting on counter acceptance for this txId.
105
+ * Drains the queue first so messages buffered while we were busy
106
+ * processing the previous round are picked up immediately.
91
107
  */
92
- async setCounterAccepted(txId, acceptMessage) {
93
- if (!this.negotiation.kernelAddress) {
94
- throw new Error('setCounterAccepted requires negotiation.kernelAddress in BuyerNegotiationContext');
95
- }
96
- const sentCounter = this.sentCounters.get(txId);
97
- if (!sentCounter) {
98
- throw new Error(`No counter-offer sent for txId ${txId} — cannot verify provider acceptance`);
99
- }
100
- if (acceptMessage.txId !== txId) {
101
- throw new Error(`CounterAccept.txId mismatch: expected ${txId}, got ${acceptMessage.txId}`);
102
- }
103
- const counterHash = new CounterOfferBuilder_1.CounterOfferBuilder().computeHash(sentCounter);
104
- if (acceptMessage.inReplyTo !== counterHash) {
105
- throw new Error(`CounterAccept.inReplyTo (${acceptMessage.inReplyTo}) does not match sent counter hash (${counterHash})`);
106
- }
107
- if (acceptMessage.acceptedAmount !== sentCounter.counterAmount) {
108
- throw new Error(`CounterAccept.acceptedAmount (${acceptMessage.acceptedAmount}) does not match counter.counterAmount (${sentCounter.counterAmount})`);
109
- }
110
- const verifier = new CounterAcceptBuilder_1.CounterAcceptBuilder();
111
- await verifier.verify(acceptMessage, this.negotiation.kernelAddress);
112
- this.counterAccepted.set(txId, { amountBaseUnits: acceptMessage.acceptedAmount });
113
- const waiter = this.counterWaiters.get(txId);
114
- if (waiter) {
115
- waiter();
116
- this.counterWaiters.delete(txId);
117
- }
108
+ _waitForNextMessage(txId, acceptedTypes, timeoutMs) {
109
+ return new Promise((resolve) => {
110
+ // Drain queue first non-matching types stay queued for later.
111
+ const queue = this.inboundQueues.get(txId) ?? [];
112
+ const idx = queue.findIndex((m) => acceptedTypes.includes(m.type));
113
+ if (idx >= 0) {
114
+ const [msg] = queue.splice(idx, 1);
115
+ if (queue.length === 0)
116
+ this.inboundQueues.delete(txId);
117
+ else
118
+ this.inboundQueues.set(txId, queue);
119
+ resolve(msg);
120
+ return;
121
+ }
122
+ const timer = setTimeout(() => {
123
+ if (this.inboundResolvers.get(txId) === filteredResolver) {
124
+ this.inboundResolvers.delete(txId);
125
+ }
126
+ resolve(null);
127
+ }, timeoutMs);
128
+ const filteredResolver = (msg) => {
129
+ if (acceptedTypes.includes(msg.type)) {
130
+ clearTimeout(timer);
131
+ resolve(msg);
132
+ }
133
+ else {
134
+ // Wrong type — push back to queue, then re-drain queue
135
+ // BEFORE re-registering as resolver. Pre-fix race (P1
136
+ // audit finding: H): another correct-type message could
137
+ // arrive in the same microtask between the resolver being
138
+ // detached at _onChannelMessage and re-registered here,
139
+ // landing in the queue and never waking us — we'd time out
140
+ // with a correct-type message sitting unread.
141
+ const q = this.inboundQueues.get(txId) ?? [];
142
+ q.push(msg);
143
+ const correctIdx = q.findIndex((m) => acceptedTypes.includes(m.type));
144
+ if (correctIdx >= 0) {
145
+ const [found] = q.splice(correctIdx, 1);
146
+ if (q.length === 0)
147
+ this.inboundQueues.delete(txId);
148
+ else
149
+ this.inboundQueues.set(txId, q);
150
+ clearTimeout(timer);
151
+ resolve(found);
152
+ }
153
+ else {
154
+ this.inboundQueues.set(txId, q);
155
+ this.inboundResolvers.set(txId, filteredResolver);
156
+ }
157
+ }
158
+ };
159
+ this.inboundResolvers.set(txId, filteredResolver);
160
+ });
118
161
  }
119
162
  /**
120
163
  * Execute the full negotiation flow.
@@ -251,6 +294,14 @@ class BuyerOrchestrator {
251
294
  emit({ type: 'round_end', round: round + 1, action: 'error', reason });
252
295
  continue;
253
296
  }
297
+ // Open negotiation channel subscription for this txId. All inbound
298
+ // quote / counteraccept messages from the provider will land in
299
+ // our internal queue (via _onChannelMessage) for the negotiation
300
+ // round loop to consume. Subscription is closed in _cleanupTxState.
301
+ if (this.negotiation.negotiationChannel) {
302
+ const sub = this.negotiation.negotiationChannel.subscribeTxId(txId, (delivered) => this._onChannelMessage(txId, delivered));
303
+ this.activeSubscriptions.set(txId, sub);
304
+ }
254
305
  // 3c. Wait for quote or direct commit (ACTP allows INITIATED → COMMITTED fast path)
255
306
  emit({ type: 'waiting_quote', txId, ttlSeconds: quoteTtlSeconds });
256
307
  const reachedState = await this.waitForState(txId, ['QUOTED', 'COMMITTED'], quoteTtlSeconds * 1000, pollInterval);
@@ -299,49 +350,46 @@ class BuyerOrchestrator {
299
350
  catch {
300
351
  // Non-fatal — price tracking is best-effort
301
352
  }
302
- // 3d-bis. AIP-2.1 negotiation branch: if the caller pushed in a
303
- // signed QuoteMessage via setReceivedQuote, verify it against
304
- // on-chain hash (with legacy fallback) and run evaluateQuote.
305
- // The branch ONLY triggers when reachedState === 'QUOTED' — the
306
- // COMMITTED fast-path below bypasses negotiation entirely because
307
- // the provider already locked the deal at buyer's offered amount.
308
- if (reachedState === 'QUOTED') {
309
- const received = this.receivedQuotes.get(txId);
310
- if (received) {
311
- const negResult = await this._runNegotiationRound({
312
- txId,
313
- received,
314
- candidateSlug: candidate.slug,
315
- providerAddress,
316
- offer,
317
- round,
318
- rounds,
319
- emit,
320
- });
321
- if (negResult.done) {
322
- // Negotiation reached a terminal decision (accept or
323
- // reject) — short-circuit the existing escrow logic below.
324
- if (negResult.success) {
325
- this.sessionStore.linkTransaction(session.commerce_session_id, txId, candidate.slug);
326
- const negReason = negResult.reason ?? 'Negotiation complete';
327
- emit({ type: 'complete', success: true, reason: negReason });
328
- return {
329
- success: true,
330
- commerce_session_id: session.commerce_session_id,
331
- actp_tx_id: txId,
332
- selected_provider: candidate.slug,
333
- rounds_used: round + 1,
334
- reason: negReason,
335
- rounds,
336
- deadlock_detected: deadlockDetected,
337
- };
338
- }
339
- // negResult.success === false → candidate rejected; continue
340
- // outer loop to try the next one. The existing code below
341
- // would attempt linkEscrow at buyer's offered price, which
342
- // would happily succeed and silently ignore our rejection.
343
- continue;
353
+ // 3d-bis. AIP-2.1 negotiation branch: if the orchestrator has a
354
+ // negotiationChannel configured, drain the inbound queue for any
355
+ // quote that arrived via the channel and run the multi-round
356
+ // counter-offer loop. The branch ONLY triggers when reachedState
357
+ // === 'QUOTED' — the COMMITTED fast-path below bypasses negotiation
358
+ // entirely because the provider already locked the deal at buyer's
359
+ // offered amount.
360
+ if (reachedState === 'QUOTED' && this.negotiation.negotiationChannel) {
361
+ const negResult = await this._runNegotiationRound({
362
+ txId,
363
+ candidateSlug: candidate.slug,
364
+ providerAddress,
365
+ offer,
366
+ round,
367
+ rounds,
368
+ emit,
369
+ });
370
+ if (negResult.done) {
371
+ // Negotiation reached a terminal decision (accept or
372
+ // reject) — short-circuit the existing escrow logic below.
373
+ if (negResult.success) {
374
+ this.sessionStore.linkTransaction(session.commerce_session_id, txId, candidate.slug);
375
+ const negReason = negResult.reason ?? 'Negotiation complete';
376
+ emit({ type: 'complete', success: true, reason: negReason });
377
+ return {
378
+ success: true,
379
+ commerce_session_id: session.commerce_session_id,
380
+ actp_tx_id: txId,
381
+ selected_provider: candidate.slug,
382
+ rounds_used: round + 1,
383
+ reason: negReason,
384
+ rounds,
385
+ deadlock_detected: deadlockDetected,
386
+ };
344
387
  }
388
+ // negResult.success === false → candidate rejected; continue
389
+ // outer loop to try the next one. The existing code below
390
+ // would attempt linkEscrow at buyer's offered price, which
391
+ // would happily succeed and silently ignore our rejection.
392
+ continue;
345
393
  }
346
394
  }
347
395
  // 3e. Reserve budget and link escrow (or recognize already-committed).
@@ -456,305 +504,292 @@ class BuyerOrchestrator {
456
504
  // AIP-2.1 negotiation round
457
505
  // ============================================================================
458
506
  /**
459
- * Run a negotiation decision for a single incoming provider quote.
460
- * Called ONLY when the caller has pushed in a signed QuoteMessage via
461
- * setReceivedQuote and the tx has reached QUOTED on-chain.
507
+ * Run the multi-round AIP-2.1 negotiation flow for one provider/txId.
508
+ *
509
+ * Channel-driven: this method never reads `setReceivedQuote` state;
510
+ * all inbound messages flow through the orchestrator's NegotiationChannel
511
+ * subscription (opened in `_negotiate` after createTransaction).
462
512
  *
463
- * Returns `{ done: true, success: bool, reason }` when a terminal
464
- * decision is reached (accept landed COMMITTED, or candidate rejected).
465
- * Returns `{ done: false }` when the caller should fall through to the
466
- * existing fixed-price flow (verification failed, missing negotiation
467
- * context, etc).
513
+ * Inner loop walks up to `policy.negotiation.rounds_per_provider`
514
+ * counter exchanges:
515
+ *
516
+ * await first quote (channel)
517
+ * for round in 0..rounds_per_provider:
518
+ * evaluate(currentQuote, roundsUsedSoFar = round)
519
+ * accept → on-chain acceptQuote+linkEscrow, return success
520
+ * reject → on-chain CANCELLED, return failure
521
+ * counter → channel.post(counter), await NEXT message:
522
+ * counteraccept → bind to last counter, on-chain accept+link, return success
523
+ * new quote → currentQuote = new quote, loop
524
+ * timeout → on-chain CANCELLED, return failure
525
+ *
526
+ * Returns `{done: false}` when the channel has no quote but the tx
527
+ * reached QUOTED via raw transitionState (legacy flow caller wants
528
+ * to fall through to fixed-price). Returns `{done: true, success?,
529
+ * reason?}` for any terminal outcome.
468
530
  */
469
531
  async _runNegotiationRound(args) {
470
- const { txId, received, candidateSlug, providerAddress, offer, round, rounds, emit } = args;
471
- // Any `done: true` return from this method means the tx reached a
472
- // terminal decision cleanup per-tx state before returning so
473
- // long-running daemon callers don't accumulate entries in
474
- // receivedQuotes / counterAccepted over thousands of negotiations.
532
+ const { txId, candidateSlug, providerAddress, offer, round, rounds, emit } = args;
533
+ // Cleanup hook fires on any `done: true` return closes the channel
534
+ // subscription opened in _negotiate so daemon callers don't leak.
475
535
  const terminate = (result) => {
476
536
  this._cleanupTxState(txId);
477
537
  return result;
478
538
  };
479
- // 1. Cross-reference the off-chain quote with the on-chain hash.
480
- // Without a match we cannot trust the pushed message, so fall
481
- // through to the existing fixed-price flow rather than negotiate
482
- // on unverified data.
483
- const onChainTx = await this.runtime.getTransaction(txId);
484
- const onChainHash = onChainTx && onChainTx.quoteHash;
485
- if (!onChainHash) {
486
- // Drop the pushed quote even though we're falling through to the
487
- // fixed-price flow — keeping it would leak memory in daemon-style
488
- // runners. The pushed quote was specific to THIS round; if a new
489
- // (real) quote arrives later for the same txId, callers must push
490
- // again. Targeted cleanup here pairs with terminate() on the
491
- // done:true paths.
539
+ if (!this.counterBuilder || !this.negotiation.kernelAddress || !this.negotiation.chainId) {
540
+ // Channel was provided but not the rest of the negotiation context.
541
+ // Fall through to fixed-price flow rather than try to negotiate.
492
542
  this._cleanupTxState(txId);
493
543
  return { done: false };
494
544
  }
495
- const verify = (0, verifyQuoteOnChain_1.verifyQuoteHashOnChain)(received.quote, onChainHash, {
496
- providerAddress: received.providerAddress,
497
- actualEscrow: received.actualEscrow,
498
- });
499
- if (!verify.match) {
500
- rounds.push({
501
- round: round + 1,
502
- provider_slug: candidateSlug,
503
- provider_address: providerAddress,
504
- action: 'error',
505
- reason: `Quote hash mismatch: expected ${verify.canonicalHash}, on-chain ${onChainHash}`,
506
- tx_id: txId,
507
- });
508
- emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'Quote hash mismatch' });
509
- return terminate({ done: true, success: false, reason: 'hash mismatch' });
510
- }
511
- // Attach source tag onto the round record for observability.
512
- const hashSource = verify.source;
513
- // 2. Decide accept / counter / reject.
514
- //
515
- // We pass `roundsUsedSoFar = 0` (the default) deliberately:
516
- // _runNegotiationRound runs at most ONCE per provider in the outer
517
- // `_negotiate` loop (one call site at line ~459, one quote per
518
- // candidate). Multi-round counter-counter exchange against the same
519
- // provider is a future feature (tracked in PRD as 3.4.1) — when it
520
- // lands the caller MUST start passing the actual count of prior
521
- // evaluations for this txId so the budget guard
522
- // (`roundsUsedSoFar + 1 >= rounds_per_provider`) advances correctly.
523
- const evaluation = this.decisionEngine.evaluateQuote(received.quote, this.policy);
524
- if (evaluation.action === 'reject') {
525
- try {
526
- await this.runtime.transitionState(txId, 'CANCELLED');
527
- }
528
- catch {
529
- // Best-effort; if the tx is already terminal the cancel call
530
- // may revert and that's fine.
531
- }
532
- rounds.push({
533
- round: round + 1,
534
- provider_slug: candidateSlug,
535
- provider_address: providerAddress,
536
- action: 'rejected',
537
- reason: `${evaluation.reason} (quote source: ${hashSource})`,
538
- tx_id: txId,
539
- quoted_price: this._baseUnitsForLog(received.quote.quotedAmount),
540
- });
541
- emit({ type: 'round_end', round: round + 1, action: 'rejected', reason: evaluation.reason });
542
- return terminate({ done: true, success: false, reason: evaluation.reason });
545
+ const counterTtlSec = this.policy.negotiation.counter_response_ttl_seconds
546
+ ?? PolicyEngine_1.PolicyEngine.parseTtl(this.policy.negotiation.quote_ttl);
547
+ const counterTtlMs = counterTtlSec * 1000;
548
+ const roundsBudget = this.policy.negotiation.rounds_per_provider ?? 1;
549
+ // Wait for the FIRST quote on the channel. The provider posts it
550
+ // after on-chain submitQuote — channel + chain may race so we
551
+ // also tolerate the quote arriving slightly before the kernel
552
+ // hash is readable (we re-read on-chain inside the loop).
553
+ const firstQuoteEnv = await this._waitForNextMessage(txId, ['agirails.quote.v1'], counterTtlMs);
554
+ if (!firstQuoteEnv || !(0, NegotiationChannel_1.isQuoteEnvelope)(firstQuoteEnv)) {
555
+ // No quote arrived on the channel within TTL — fall through to
556
+ // fixed-price (the on-chain hash + waitForState already proved
557
+ // the tx hit QUOTED, so this is a legacy-provider scenario).
558
+ this._cleanupTxState(txId);
559
+ return { done: false };
543
560
  }
544
- if (evaluation.action === 'accept') {
545
- // Commit at provider's quoted amount. AIP-2 sequence:
546
- // acceptQuote(txId, quotedAmount) updates tx.amount (still QUOTED),
547
- // linkEscrow(txId, quotedAmount) COMMITTED.
548
- // The two calls are NOT atomic on-chain. If linkEscrow fails after
549
- // acceptQuote succeeded, the tx is stuck in QUOTED with a finalized
550
- // amount but no escrow lock best-effort CANCELLED keeps state
551
- // machine consistent rather than leaving a zombie row.
552
- const amountBaseUnits = received.quote.quotedAmount;
553
- let acceptQuoteSucceeded = false;
554
- try {
555
- await this.runtime.acceptQuote(txId, amountBaseUnits);
556
- acceptQuoteSucceeded = true;
557
- await this.runtime.linkEscrow(txId, amountBaseUnits);
561
+ let currentQuote = firstQuoteEnv.message;
562
+ // Multi-round inner loop. Each iteration:
563
+ // - on FIRST round: cross-check on-chain hash (anchored proof that
564
+ // the off-chain quote we're seeing matches what provider committed
565
+ // on-chain via submitQuote defense against MITM substitution).
566
+ // - on subsequent rounds: trust the channel's EIP-712 verify alone.
567
+ // Re-quotes are off-chain only by design (kernel forbids QUOTED
568
+ // QUOTED on-chain transitions per Q4 audit). The provider DID is
569
+ // already authenticated by the channel's signature recovery, and
570
+ // the FINAL agreed amount is anchored on acceptQuote+linkEscrow.
571
+ let hashSource = 'aip2';
572
+ for (let counterRound = 0; counterRound < roundsBudget; counterRound++) {
573
+ if (counterRound === 0) {
574
+ const onChainTx = await this.runtime.getTransaction(txId);
575
+ const onChainHash = onChainTx && onChainTx.quoteHash;
576
+ if (!onChainHash) {
577
+ // No anchored quote — fall through to fixed-price.
578
+ this._cleanupTxState(txId);
579
+ return { done: false };
580
+ }
581
+ const verify = (0, verifyQuoteOnChain_1.verifyQuoteHashOnChain)(currentQuote, onChainHash, {
582
+ providerAddress: providerAddress,
583
+ });
584
+ if (!verify.match) {
585
+ rounds.push({
586
+ round: round + 1,
587
+ provider_slug: candidateSlug,
588
+ provider_address: providerAddress,
589
+ action: 'error',
590
+ reason: `Quote hash mismatch: expected ${verify.canonicalHash}, on-chain ${onChainHash}`,
591
+ tx_id: txId,
592
+ });
593
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'Quote hash mismatch' });
594
+ return terminate({ done: true, success: false, reason: 'hash mismatch' });
595
+ }
596
+ hashSource = verify.source;
558
597
  }
559
- catch (err) {
560
- const reason = err instanceof Error ? err.message : String(err);
561
- if (acceptQuoteSucceeded) {
562
- // linkEscrow failed after accept roll back to CANCELLED so
563
- // the tx doesn't sit in QUOTED with a price set but no commit.
598
+ else {
599
+ // Subsequent re-quotes: guard against two attacker-controlled
600
+ // mutations the channel-level EIP-712 verify cannot catch on
601
+ // its own (same provider can sign anything, including poisoned
602
+ // re-quotes):
603
+ //
604
+ // (a) provider DID switched mid-negotiation
605
+ // (b) maxPrice inflated mid-negotiation — without this
606
+ // guard, the buyer's `evaluateQuote` accept-if-affordable
607
+ // branch on the last round would compare against the
608
+ // attacker's inflated max, committing the buyer above
609
+ // its own policy ceiling. (P0 audit finding.)
610
+ //
611
+ // We anchor BOTH provider and maxPrice to the FIRST quote
612
+ // (which already cross-checked on-chain hash on round 0).
613
+ if (currentQuote.provider !== firstQuoteEnv.message.provider) {
564
614
  try {
565
615
  await this.runtime.transitionState(txId, 'CANCELLED');
566
616
  }
567
- catch {
568
- // Best-effort: if the chain itself is unreachable or the
569
- // tx already moved (stale read race), the operator gets
570
- // the original failure reason in the round log.
617
+ catch { /* best-effort */ }
618
+ rounds.push({
619
+ round: round + 1,
620
+ provider_slug: candidateSlug,
621
+ provider_address: providerAddress,
622
+ action: 'error',
623
+ reason: `Re-quote provider mismatch: ${currentQuote.provider} vs original ${firstQuoteEnv.message.provider}`,
624
+ tx_id: txId,
625
+ });
626
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'provider mismatch on re-quote' });
627
+ return terminate({ done: true, success: false, reason: 'provider mismatch' });
628
+ }
629
+ if (currentQuote.maxPrice !== firstQuoteEnv.message.maxPrice) {
630
+ try {
631
+ await this.runtime.transitionState(txId, 'CANCELLED');
571
632
  }
633
+ catch { /* best-effort */ }
634
+ rounds.push({
635
+ round: round + 1,
636
+ provider_slug: candidateSlug,
637
+ provider_address: providerAddress,
638
+ action: 'error',
639
+ reason: `Re-quote maxPrice mismatch: ${currentQuote.maxPrice} vs original ${firstQuoteEnv.message.maxPrice} — provider may not raise the ceiling mid-negotiation`,
640
+ tx_id: txId,
641
+ });
642
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'maxPrice substitution attempt on re-quote' });
643
+ return terminate({ done: true, success: false, reason: 'maxPrice substitution' });
572
644
  }
645
+ hashSource = 'aip2';
646
+ }
647
+ const evaluation = this.decisionEngine.evaluateQuote(currentQuote, this.policy, counterRound);
648
+ // ----- reject -----
649
+ if (evaluation.action === 'reject') {
650
+ try {
651
+ await this.runtime.transitionState(txId, 'CANCELLED');
652
+ }
653
+ catch { /* best-effort */ }
573
654
  rounds.push({
574
655
  round: round + 1,
575
656
  provider_slug: candidateSlug,
576
657
  provider_address: providerAddress,
577
- action: 'error',
578
- reason: `Accept flow failed: ${reason}`,
658
+ action: 'rejected',
659
+ reason: `${evaluation.reason} (round ${counterRound + 1}/${roundsBudget}, source: ${hashSource})`,
579
660
  tx_id: txId,
661
+ quoted_price: this._baseUnitsForLog(currentQuote.quotedAmount),
580
662
  });
581
- emit({ type: 'round_end', round: round + 1, action: 'error', reason });
582
- return terminate({ done: true, success: false, reason });
663
+ emit({ type: 'round_end', round: round + 1, action: 'rejected', reason: evaluation.reason });
664
+ return terminate({ done: true, success: false, reason: evaluation.reason });
665
+ }
666
+ // ----- accept (at provider's quoted amount) -----
667
+ if (evaluation.action === 'accept') {
668
+ const result = await this._commitAtAmount(txId, currentQuote.quotedAmount, candidateSlug, providerAddress, offer, round, rounds, emit, hashSource, counterRound);
669
+ return terminate(result);
583
670
  }
584
- // Update local ledger for daily-spend accounting; tolerate
585
- // reservation failure (it's bookkeeping the on-chain escrow
586
- // is already locked).
671
+ // ----- counter -----
672
+ // Build counter, post on channel, await next message.
673
+ let counter;
587
674
  try {
588
- this.policyEngine.reserve(offer.commerce_session_id || '', this._baseUnitsForLog(amountBaseUnits), offer.currency);
675
+ const consumerDID = `did:ethr:${this.negotiation.chainId}:${(await this.negotiation.signer.getAddress()).toLowerCase()}`;
676
+ const now = Math.floor(Date.now() / 1000);
677
+ // inReplyTo is the canonical hash of the quote we're countering
678
+ // — recompute on every round (subsequent re-quotes have their
679
+ // own hash, distinct from the on-chain anchored first quote).
680
+ const currentQuoteHash = new QuoteBuilder_1.QuoteBuilder().computeHash(currentQuote);
681
+ counter = await this.counterBuilder.build({
682
+ txId,
683
+ consumer: consumerDID,
684
+ provider: currentQuote.provider,
685
+ quoteAmount: currentQuote.quotedAmount,
686
+ counterAmount: evaluation.amountBaseUnits,
687
+ maxPrice: currentQuote.maxPrice,
688
+ inReplyTo: currentQuoteHash,
689
+ chainId: this.negotiation.chainId,
690
+ kernelAddress: this.negotiation.kernelAddress,
691
+ expiresAt: now + counterTtlSec,
692
+ });
589
693
  }
590
- catch {
591
- // swallow
694
+ catch (err) {
695
+ const reason = `Counter build failed on round ${counterRound + 1}: ${err instanceof Error ? err.message : String(err)}`;
696
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'error', reason, tx_id: txId });
697
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
698
+ return terminate({ done: true, success: false, reason });
592
699
  }
593
- const reason = `Quote accepted at ${amountBaseUnits} base units (source: ${hashSource})`;
594
- rounds.push({
595
- round: round + 1,
596
- provider_slug: candidateSlug,
597
- provider_address: providerAddress,
598
- action: 'accepted',
599
- reason,
600
- tx_id: txId,
601
- quoted_price: this._baseUnitsForLog(amountBaseUnits),
602
- });
603
- emit({ type: 'round_end', round: round + 1, action: 'accepted', reason });
604
- return terminate({ done: true, success: true, reason });
605
- }
606
- // evaluation.action === 'counter'.
607
- if (!this.counterBuilder || !this.negotiation.kernelAddress || !this.negotiation.chainId) {
608
- // Caller requested counter-offers but didn't wire the signer.
609
- // Log + fall through to accept/reject at max guard rails
610
- // (treating as if counter_strategy were 'walk' would be less
611
- // surprising than silently accepting above target).
612
700
  try {
613
- await this.runtime.transitionState(txId, 'CANCELLED');
701
+ await this.negotiation.negotiationChannel.post(txId, {
702
+ type: 'agirails.counteroffer.v1', message: counter,
703
+ });
614
704
  }
615
- catch {
616
- /* best-effort */
705
+ catch (err) {
706
+ const reason = `Counter post failed on round ${counterRound + 1}: ${err instanceof Error ? err.message : String(err)}`;
707
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'error', reason, tx_id: txId });
708
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
709
+ return terminate({ done: true, success: false, reason });
617
710
  }
618
- const reason = 'counter_strategy set but no signer/kernelAddress/chainId in BuyerNegotiationContext';
619
- rounds.push({
620
- round: round + 1,
621
- provider_slug: candidateSlug,
622
- provider_address: providerAddress,
623
- action: 'error',
624
- reason,
625
- tx_id: txId,
626
- });
627
- emit({ type: 'round_end', round: round + 1, action: 'error', reason });
628
- return terminate({ done: true, success: false, reason });
629
- }
630
- // Build + send counter-offer. Then wait for provider's off-chain
631
- // acceptance (setCounterAccepted) or timeout.
632
- const channel = this.negotiation.channel ?? new QuoteChannel_1.QuoteChannelClient();
633
- const counterTtlSec = this.policy.negotiation.counter_response_ttl_seconds
634
- ?? PolicyEngine_1.PolicyEngine.parseTtl(this.policy.negotiation.quote_ttl);
635
- const now = Math.floor(Date.now() / 1000);
636
- let counter;
637
- try {
638
- counter = await this.counterBuilder.build({
639
- txId,
640
- consumer: `did:ethr:${this.negotiation.chainId}:${(await this.negotiation.signer.getAddress()).toLowerCase()}`,
641
- provider: received.quote.provider,
642
- quoteAmount: received.quote.quotedAmount,
643
- counterAmount: evaluation.amountBaseUnits,
644
- maxPrice: received.quote.maxPrice,
645
- inReplyTo: verify.canonicalHash,
646
- chainId: this.negotiation.chainId,
647
- kernelAddress: this.negotiation.kernelAddress,
648
- expiresAt: now + counterTtlSec,
649
- });
650
- // Stash the counter we just built so setCounterAccepted can
651
- // cryptographically bind the provider's acceptance message to
652
- // the exact counter we sent (acceptedAmount + inReplyTo checks).
653
- this.sentCounters.set(txId, counter);
654
- }
655
- catch (err) {
656
- const reason = `Counter build failed: ${err instanceof Error ? err.message : String(err)}`;
657
- rounds.push({
658
- round: round + 1,
659
- provider_slug: candidateSlug,
660
- provider_address: providerAddress,
661
- action: 'error',
662
- reason,
663
- tx_id: txId,
664
- });
665
- emit({ type: 'round_end', round: round + 1, action: 'error', reason });
666
- return terminate({ done: true, success: false, reason });
667
- }
668
- // Without an endpoint we have nowhere to deliver the signed counter.
669
- // Cancel immediately rather than wait counter_response_ttl seconds
670
- // for a provider that can't possibly hear from us.
671
- if (!received.providerEndpoint) {
672
- try {
673
- await this.runtime.transitionState(txId, 'CANCELLED');
711
+ // Await provider's response: counteraccept (deal closed) or new quote (provider re-quote → next round).
712
+ const next = await this._waitForNextMessage(txId, ['agirails.counteraccept.v1', 'agirails.quote.v1'], counterTtlMs);
713
+ if (!next) {
714
+ try {
715
+ await this.runtime.transitionState(txId, 'CANCELLED');
716
+ }
717
+ catch { /* best-effort */ }
718
+ const reason = `No response within ${counterTtlSec}s on round ${counterRound + 1}`;
719
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'timeout', reason, tx_id: txId });
720
+ emit({ type: 'round_end', round: round + 1, action: 'timeout', reason });
721
+ return terminate({ done: true, success: false, reason });
674
722
  }
675
- catch {
676
- /* best-effort */
723
+ if ((0, NegotiationChannel_1.isCounterAcceptEnvelope)(next)) {
724
+ // Provider accepted our counter — verify binding (channel already
725
+ // verified EIP-712, here we bind to the counter WE sent).
726
+ const accept = next.message;
727
+ const counterHash = new CounterOfferBuilder_1.CounterOfferBuilder().computeHash(counter);
728
+ if (accept.txId !== txId ||
729
+ accept.inReplyTo !== counterHash ||
730
+ accept.acceptedAmount !== counter.counterAmount) {
731
+ const reason = `CounterAccept binding mismatch on round ${counterRound + 1}`;
732
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'error', reason, tx_id: txId });
733
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
734
+ return terminate({ done: true, success: false, reason });
735
+ }
736
+ const result = await this._commitAtAmount(txId, accept.acceptedAmount, candidateSlug, providerAddress, offer, round, rounds, emit, 'counteraccept', counterRound);
737
+ return terminate(result);
738
+ }
739
+ if ((0, NegotiationChannel_1.isQuoteEnvelope)(next)) {
740
+ // Provider re-quoted — replace currentQuote and loop.
741
+ currentQuote = next.message;
742
+ continue;
677
743
  }
678
- const reason = 'Cannot deliver counter — no providerEndpoint set on received quote';
679
- rounds.push({
680
- round: round + 1,
681
- provider_slug: candidateSlug,
682
- provider_address: providerAddress,
683
- action: 'error',
684
- reason,
685
- tx_id: txId,
686
- });
687
- emit({ type: 'round_end', round: round + 1, action: 'error', reason });
688
- return terminate({ done: true, success: false, reason });
689
744
  }
745
+ // Budget exhausted without accept — DecisionEngine's last-round
746
+ // branch should have triggered accept-if-affordable; reaching here
747
+ // implies provider re-quoted to the very last round and we still
748
+ // saw 'counter'. Cancel.
690
749
  try {
691
- await channel.sendCounter(received.providerEndpoint, counter);
692
- }
693
- catch (err) {
694
- const reason = `Counter channel POST failed: ${err instanceof Error ? err.message : String(err)}`;
695
- rounds.push({
696
- round: round + 1,
697
- provider_slug: candidateSlug,
698
- provider_address: providerAddress,
699
- action: 'error',
700
- reason,
701
- tx_id: txId,
702
- });
703
- emit({ type: 'round_end', round: round + 1, action: 'error', reason });
704
- return terminate({ done: true, success: false, reason });
705
- }
706
- // Wait for provider's acceptance signal or timeout.
707
- const accepted = await this._waitForCounterAcceptance(txId, counterTtlSec * 1000);
708
- if (!accepted) {
709
- try {
710
- await this.runtime.transitionState(txId, 'CANCELLED');
711
- }
712
- catch {
713
- /* best-effort */
714
- }
715
- const reason = `Counter acceptance not received within ${counterTtlSec}s`;
716
- rounds.push({
717
- round: round + 1,
718
- provider_slug: candidateSlug,
719
- provider_address: providerAddress,
720
- action: 'timeout',
721
- reason,
722
- tx_id: txId,
723
- });
724
- emit({ type: 'round_end', round: round + 1, action: 'timeout', reason });
725
- return terminate({ done: true, success: false, reason });
750
+ await this.runtime.transitionState(txId, 'CANCELLED');
726
751
  }
727
- const finalAmount = accepted.amountBaseUnits;
752
+ catch { /* best-effort */ }
753
+ const reason = `Negotiation budget (${roundsBudget} rounds) exhausted without accept`;
754
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'timeout', reason, tx_id: txId });
755
+ emit({ type: 'round_end', round: round + 1, action: 'timeout', reason });
756
+ return terminate({ done: true, success: false, reason });
757
+ }
758
+ /**
759
+ * Shared accept+linkEscrow with atomic rollback. Used by both the
760
+ * "accept the quote" and "accept the counter" terminal branches.
761
+ */
762
+ async _commitAtAmount(txId, amountBaseUnits, candidateSlug, providerAddress, offer, round, rounds, emit, sourceTag, counterRound) {
728
763
  let acceptQuoteSucceeded = false;
729
764
  try {
730
- await this.runtime.acceptQuote(txId, finalAmount);
765
+ await this.runtime.acceptQuote(txId, amountBaseUnits);
731
766
  acceptQuoteSucceeded = true;
732
- await this.runtime.linkEscrow(txId, finalAmount);
767
+ await this.runtime.linkEscrow(txId, amountBaseUnits);
733
768
  }
734
769
  catch (err) {
735
- const reason = `Accept-counter flow failed: ${err instanceof Error ? err.message : String(err)}`;
770
+ const reason = err instanceof Error ? err.message : String(err);
736
771
  if (acceptQuoteSucceeded) {
737
- // Mirror the symmetric path above: roll back to CANCELLED so
738
- // the tx doesn't sit QUOTED with a finalized counter price.
739
772
  try {
740
773
  await this.runtime.transitionState(txId, 'CANCELLED');
741
774
  }
742
- catch {
743
- /* best-effort */
744
- }
775
+ catch { /* best-effort */ }
745
776
  }
746
777
  rounds.push({
747
778
  round: round + 1,
748
779
  provider_slug: candidateSlug,
749
780
  provider_address: providerAddress,
750
781
  action: 'error',
751
- reason,
782
+ reason: `Commit failed (round ${counterRound + 1}): ${reason}`,
752
783
  tx_id: txId,
753
784
  });
754
785
  emit({ type: 'round_end', round: round + 1, action: 'error', reason });
755
- return terminate({ done: true, success: false, reason });
786
+ return { done: true, success: false, reason };
756
787
  }
757
- const reason = `Counter accepted at ${finalAmount} base units (source: ${hashSource})`;
788
+ try {
789
+ this.policyEngine.reserve(offer.commerce_session_id || '', this._baseUnitsForLog(amountBaseUnits), offer.currency);
790
+ }
791
+ catch { /* best-effort budget bookkeeping */ }
792
+ const reason = `Committed at ${amountBaseUnits} base units (round ${counterRound + 1}, source: ${sourceTag})`;
758
793
  rounds.push({
759
794
  round: round + 1,
760
795
  provider_slug: candidateSlug,
@@ -762,40 +797,36 @@ class BuyerOrchestrator {
762
797
  action: 'accepted',
763
798
  reason,
764
799
  tx_id: txId,
765
- quoted_price: this._baseUnitsForLog(finalAmount),
800
+ quoted_price: this._baseUnitsForLog(amountBaseUnits),
766
801
  });
767
802
  emit({ type: 'round_end', round: round + 1, action: 'accepted', reason });
768
- return terminate({ done: true, success: true, reason });
769
- }
770
- /** Resolve when setCounterAccepted(txId, …) is called, or on timeout. */
771
- async _waitForCounterAcceptance(txId, timeoutMs) {
772
- // Already accepted before we started waiting? Return immediately.
773
- const existing = this.counterAccepted.get(txId);
774
- if (existing)
775
- return existing;
776
- return new Promise((resolve) => {
777
- const timer = setTimeout(() => {
778
- this.counterWaiters.delete(txId);
779
- resolve(null);
780
- }, timeoutMs);
781
- this.counterWaiters.set(txId, () => {
782
- clearTimeout(timer);
783
- resolve(this.counterAccepted.get(txId) ?? null);
784
- });
785
- });
803
+ return { done: true, success: true, reason };
786
804
  }
787
805
  /**
788
806
  * Free per-tx negotiation state at terminal outcomes (accept commits,
789
- * reject CANCELLED, timeout). Long-running daemon-style runners would
790
- * otherwise leak both maps unbounded as txIds accumulate.
807
+ * reject CANCELLED, timeout). Closes the channel subscription too so
808
+ * long-running daemon callers don't leak inbound-message resolvers.
791
809
  *
792
810
  * Idempotent — safe to call from multiple cleanup sites.
793
811
  */
794
812
  _cleanupTxState(txId) {
795
- this.receivedQuotes.delete(txId);
796
- this.counterAccepted.delete(txId);
797
- this.counterWaiters.delete(txId);
798
- this.sentCounters.delete(txId);
813
+ this.inboundQueues.delete(txId);
814
+ const pending = this.inboundResolvers.get(txId);
815
+ if (pending) {
816
+ // No more messages will arrive for this tx; resolve waiter as
817
+ // a timeout-equivalent so awaiters don't hang.
818
+ this.inboundResolvers.delete(txId);
819
+ // Note: pending resolver expects a NegotiationMessage; we let
820
+ // the timeout in _waitForNextMessage fire instead by NOT
821
+ // calling pending here. The resolver is removed; setTimeout
822
+ // wins on its own clock.
823
+ void pending;
824
+ }
825
+ const sub = this.activeSubscriptions.get(txId);
826
+ if (sub) {
827
+ sub.unsubscribe();
828
+ this.activeSubscriptions.delete(txId);
829
+ }
799
830
  }
800
831
  /**
801
832
  * Display-only downcast: USDC base-units string → Number for the