@agirails/sdk 3.4.1 → 4.0.0-beta.0

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 (107) 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/agirails.d.ts +4 -1
  6. package/dist/cli/agirails.d.ts.map +1 -1
  7. package/dist/cli/agirails.js +34 -2
  8. package/dist/cli/agirails.js.map +1 -1
  9. package/dist/cli/commands/agent.d.ts +22 -0
  10. package/dist/cli/commands/agent.d.ts.map +1 -0
  11. package/dist/cli/commands/agent.js +250 -0
  12. package/dist/cli/commands/agent.js.map +1 -0
  13. package/dist/cli/commands/pay.d.ts +7 -0
  14. package/dist/cli/commands/pay.d.ts.map +1 -1
  15. package/dist/cli/commands/pay.js +34 -1
  16. package/dist/cli/commands/pay.js.map +1 -1
  17. package/dist/cli/commands/request.d.ts +19 -0
  18. package/dist/cli/commands/request.d.ts.map +1 -0
  19. package/dist/cli/commands/request.js +167 -0
  20. package/dist/cli/commands/request.js.map +1 -0
  21. package/dist/cli/commands/serve.d.ts +12 -7
  22. package/dist/cli/commands/serve.d.ts.map +1 -1
  23. package/dist/cli/commands/serve.js +12 -7
  24. package/dist/cli/commands/serve.js.map +1 -1
  25. package/dist/cli/commands/test.d.ts +22 -4
  26. package/dist/cli/commands/test.d.ts.map +1 -1
  27. package/dist/cli/commands/test.js +139 -292
  28. package/dist/cli/commands/test.js.map +1 -1
  29. package/dist/cli/commands/tx.js +13 -0
  30. package/dist/cli/commands/tx.js.map +1 -1
  31. package/dist/cli/index.js +10 -1
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/lib/resolveAgent.d.ts +67 -0
  34. package/dist/cli/lib/resolveAgent.d.ts.map +1 -0
  35. package/dist/cli/lib/resolveAgent.js +121 -0
  36. package/dist/cli/lib/resolveAgent.js.map +1 -0
  37. package/dist/cli/lib/runRequest.d.ts +114 -0
  38. package/dist/cli/lib/runRequest.d.ts.map +1 -0
  39. package/dist/cli/lib/runRequest.js +324 -0
  40. package/dist/cli/lib/runRequest.js.map +1 -0
  41. package/dist/cli/lib/serviceNameForHash.d.ts +48 -0
  42. package/dist/cli/lib/serviceNameForHash.d.ts.map +1 -0
  43. package/dist/cli/lib/serviceNameForHash.js +62 -0
  44. package/dist/cli/lib/serviceNameForHash.js.map +1 -0
  45. package/dist/config/networks.d.ts.map +1 -1
  46. package/dist/config/networks.js +7 -1
  47. package/dist/config/networks.js.map +1 -1
  48. package/dist/index.d.ts +6 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +12 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/level0/request.d.ts.map +1 -1
  53. package/dist/level0/request.js +18 -6
  54. package/dist/level0/request.js.map +1 -1
  55. package/dist/level1/Agent.d.ts +72 -8
  56. package/dist/level1/Agent.d.ts.map +1 -1
  57. package/dist/level1/Agent.js +243 -111
  58. package/dist/level1/Agent.js.map +1 -1
  59. package/dist/negotiation/BuyerOrchestrator.d.ts +73 -61
  60. package/dist/negotiation/BuyerOrchestrator.d.ts.map +1 -1
  61. package/dist/negotiation/BuyerOrchestrator.js +421 -381
  62. package/dist/negotiation/BuyerOrchestrator.js.map +1 -1
  63. package/dist/negotiation/MockChannel.d.ts +63 -0
  64. package/dist/negotiation/MockChannel.d.ts.map +1 -0
  65. package/dist/negotiation/MockChannel.js +175 -0
  66. package/dist/negotiation/MockChannel.js.map +1 -0
  67. package/dist/negotiation/NegotiationChannel.d.ts +142 -0
  68. package/dist/negotiation/NegotiationChannel.d.ts.map +1 -0
  69. package/dist/negotiation/NegotiationChannel.js +59 -0
  70. package/dist/negotiation/NegotiationChannel.js.map +1 -0
  71. package/dist/negotiation/ProviderOrchestrator.d.ts +85 -35
  72. package/dist/negotiation/ProviderOrchestrator.d.ts.map +1 -1
  73. package/dist/negotiation/ProviderOrchestrator.js +199 -49
  74. package/dist/negotiation/ProviderOrchestrator.js.map +1 -1
  75. package/dist/negotiation/ProviderPolicy.d.ts +51 -6
  76. package/dist/negotiation/ProviderPolicy.d.ts.map +1 -1
  77. package/dist/negotiation/ProviderPolicy.js +61 -9
  78. package/dist/negotiation/ProviderPolicy.js.map +1 -1
  79. package/dist/negotiation/RelayChannel.d.ts +59 -0
  80. package/dist/negotiation/RelayChannel.d.ts.map +1 -0
  81. package/dist/negotiation/RelayChannel.js +208 -0
  82. package/dist/negotiation/RelayChannel.js.map +1 -0
  83. package/dist/protocol/ACTPKernel.d.ts.map +1 -1
  84. package/dist/protocol/ACTPKernel.js +51 -1
  85. package/dist/protocol/ACTPKernel.js.map +1 -1
  86. package/dist/protocol/EventMonitor.d.ts +26 -1
  87. package/dist/protocol/EventMonitor.d.ts.map +1 -1
  88. package/dist/protocol/EventMonitor.js +18 -4
  89. package/dist/protocol/EventMonitor.js.map +1 -1
  90. package/dist/runtime/BlockchainRuntime.d.ts +73 -0
  91. package/dist/runtime/BlockchainRuntime.d.ts.map +1 -1
  92. package/dist/runtime/BlockchainRuntime.js +131 -3
  93. package/dist/runtime/BlockchainRuntime.js.map +1 -1
  94. package/dist/runtime/IACTPRuntime.d.ts +29 -0
  95. package/dist/runtime/IACTPRuntime.d.ts.map +1 -1
  96. package/dist/runtime/MockRuntime.d.ts.map +1 -1
  97. package/dist/runtime/MockRuntime.js +21 -4
  98. package/dist/runtime/MockRuntime.js.map +1 -1
  99. package/dist/runtime/MockStateManager.d.ts.map +1 -1
  100. package/dist/runtime/MockStateManager.js +18 -0
  101. package/dist/runtime/MockStateManager.js.map +1 -1
  102. package/dist/runtime/types/MockState.d.ts +12 -0
  103. package/dist/runtime/types/MockState.d.ts.map +1 -1
  104. package/dist/runtime/types/MockState.js.map +1 -1
  105. package/dist/types/agent.d.ts +6 -1
  106. package/dist/types/agent.d.ts.map +1 -1
  107. package/package.json +2 -1
@@ -18,30 +18,54 @@
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.BuyerOrchestrator = void 0;
21
+ const ethers_1 = require("ethers");
21
22
  const agirailsApp_1 = require("../api/agirailsApp");
22
23
  const PolicyEngine_1 = require("./PolicyEngine");
23
24
  const DecisionEngine_1 = require("./DecisionEngine");
24
25
  const SessionStore_1 = require("./SessionStore");
26
+ const QuoteBuilder_1 = require("../builders/QuoteBuilder");
25
27
  const CounterOfferBuilder_1 = require("../builders/CounterOfferBuilder");
26
- const CounterAcceptBuilder_1 = require("../builders/CounterAcceptBuilder");
27
- const QuoteChannel_1 = require("../transport/QuoteChannel");
28
28
  const NonceManager_1 = require("../utils/NonceManager");
29
29
  const verifyQuoteOnChain_1 = require("./verifyQuoteOnChain");
30
+ const NegotiationChannel_1 = require("./NegotiationChannel");
30
31
  class BuyerOrchestrator {
31
32
  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
33
  /**
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).
34
+ * Per-txId inbound message queue. Channel callbacks push here; the
35
+ * orchestrator's negotiation loop drains via `_waitForNextMessage`.
36
+ * Bounded implicitly by `_cleanupTxState` at terminal outcomes.
43
37
  */
44
- this.sentCounters = new Map();
38
+ this.inboundQueues = new Map();
39
+ /**
40
+ * Per-txId resolver for the orchestrator's "wait for next message"
41
+ * await. When a message arrives and a resolver is set, the message
42
+ * is delivered immediately instead of queued.
43
+ */
44
+ this.inboundResolvers = new Map();
45
+ /**
46
+ * Active subscriptions opened by `negotiate()`. Closed at end of
47
+ * each negotiation. Multiple concurrent calls are supported.
48
+ */
49
+ this.activeSubscriptions = new Map();
50
+ // Fail-fast on partial negotiation context. Pre-fix bug: a developer
51
+ // who set `negotiationChannel: new RelayChannel(...)` but forgot
52
+ // `signer` or `chainId` got NO error — every tx silently fell
53
+ // through to fixed-price flow with the channel subscription
54
+ // opened-and-immediately-closed for nothing. (P1 audit finding: G.)
55
+ if (negotiation.negotiationChannel) {
56
+ const missing = [];
57
+ if (!negotiation.signer)
58
+ missing.push('signer');
59
+ if (!negotiation.kernelAddress)
60
+ missing.push('kernelAddress');
61
+ if (!negotiation.chainId)
62
+ missing.push('chainId');
63
+ if (missing.length > 0) {
64
+ throw new Error(`BuyerNegotiationContext: negotiationChannel was provided but the following required field(s) are missing: ${missing.join(', ')}. ` +
65
+ `Multi-round negotiation needs all of: signer, kernelAddress, chainId, negotiationChannel. ` +
66
+ `Omit negotiationChannel for fixed-price-only flow.`);
67
+ }
68
+ }
45
69
  this.policy = policy;
46
70
  this.runtime = runtime;
47
71
  this.requesterAddress = requesterAddress;
@@ -53,68 +77,88 @@ class BuyerOrchestrator {
53
77
  this.counterBuilder = new CounterOfferBuilder_1.CounterOfferBuilder(negotiation.signer, negotiation.nonceManager ?? new NonceManager_1.InMemoryNonceManager());
54
78
  }
55
79
  }
80
+ // --------------------------------------------------------------------------
81
+ // Channel inbound dispatch
82
+ // --------------------------------------------------------------------------
56
83
  /**
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.
84
+ * Channel delivered a verified message for `txId`. If a round is
85
+ * awaiting the next message, hand it directly; otherwise queue.
60
86
  *
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).
87
+ * NOTE: the channel has already verified EIP-712 signature + chainId
88
+ * before invoking us. This handler is concerned only with routing.
65
89
  */
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
- });
90
+ _onChannelMessage(txId, delivered) {
91
+ const resolver = this.inboundResolvers.get(txId);
92
+ if (resolver) {
93
+ this.inboundResolvers.delete(txId);
94
+ resolver(delivered.envelope);
95
+ return;
96
+ }
97
+ const queue = this.inboundQueues.get(txId) ?? [];
98
+ queue.push(delivered.envelope);
99
+ this.inboundQueues.set(txId, queue);
73
100
  }
74
101
  /**
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`
102
+ * Await the next inbound message matching one of `acceptedTypes`.
103
+ * Returns null on timeout (caller decides how to handle: cancel,
104
+ * accept-if-affordable, etc).
85
105
  *
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.
106
+ * Drains the queue first so messages buffered while we were busy
107
+ * processing the previous round are picked up immediately.
91
108
  */
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
- }
109
+ _waitForNextMessage(txId, acceptedTypes, timeoutMs) {
110
+ return new Promise((resolve) => {
111
+ // Drain queue first non-matching types stay queued for later.
112
+ const queue = this.inboundQueues.get(txId) ?? [];
113
+ const idx = queue.findIndex((m) => acceptedTypes.includes(m.type));
114
+ if (idx >= 0) {
115
+ const [msg] = queue.splice(idx, 1);
116
+ if (queue.length === 0)
117
+ this.inboundQueues.delete(txId);
118
+ else
119
+ this.inboundQueues.set(txId, queue);
120
+ resolve(msg);
121
+ return;
122
+ }
123
+ const timer = setTimeout(() => {
124
+ if (this.inboundResolvers.get(txId) === filteredResolver) {
125
+ this.inboundResolvers.delete(txId);
126
+ }
127
+ resolve(null);
128
+ }, timeoutMs);
129
+ const filteredResolver = (msg) => {
130
+ if (acceptedTypes.includes(msg.type)) {
131
+ clearTimeout(timer);
132
+ resolve(msg);
133
+ }
134
+ else {
135
+ // Wrong type — push back to queue, then re-drain queue
136
+ // BEFORE re-registering as resolver. Pre-fix race (P1
137
+ // audit finding: H): another correct-type message could
138
+ // arrive in the same microtask between the resolver being
139
+ // detached at _onChannelMessage and re-registered here,
140
+ // landing in the queue and never waking us — we'd time out
141
+ // with a correct-type message sitting unread.
142
+ const q = this.inboundQueues.get(txId) ?? [];
143
+ q.push(msg);
144
+ const correctIdx = q.findIndex((m) => acceptedTypes.includes(m.type));
145
+ if (correctIdx >= 0) {
146
+ const [found] = q.splice(correctIdx, 1);
147
+ if (q.length === 0)
148
+ this.inboundQueues.delete(txId);
149
+ else
150
+ this.inboundQueues.set(txId, q);
151
+ clearTimeout(timer);
152
+ resolve(found);
153
+ }
154
+ else {
155
+ this.inboundQueues.set(txId, q);
156
+ this.inboundResolvers.set(txId, filteredResolver);
157
+ }
158
+ }
159
+ };
160
+ this.inboundResolvers.set(txId, filteredResolver);
161
+ });
118
162
  }
119
163
  /**
120
164
  * Execute the full negotiation flow.
@@ -231,12 +275,20 @@ class BuyerOrchestrator {
231
275
  let txId;
232
276
  try {
233
277
  const amount = this.toBaseUnits(offer.unit_price);
278
+ // PRD §5.6: put the bytes32 routing key on-chain (matches what
279
+ // Agent.provide(name) registers in handlersByHash). Pre-4.0.0 this
280
+ // site passed JSON.stringify({ service, session }), which
281
+ // BlockchainRuntime.validateServiceHash then hashed wholesale — the
282
+ // resulting on-chain serviceHash could never match
283
+ // keccak256(toUtf8Bytes(taskName)) so provider routing silently
284
+ // missed. The session_id is no longer carried on-chain; subscription
285
+ // tracking still uses txId as the correlation key.
234
286
  txId = await this.runtime.createTransaction({
235
287
  provider: providerAddress,
236
288
  requester: this.requesterAddress,
237
289
  amount,
238
290
  deadline: Math.floor(Date.now() / 1000) + quoteTtlSeconds + 3600, // quote TTL + 1h buffer
239
- serviceDescription: JSON.stringify({ service: this.policy.task, session: session.commerce_session_id }),
291
+ serviceDescription: (0, ethers_1.keccak256)((0, ethers_1.toUtf8Bytes)(this.policy.task)),
240
292
  });
241
293
  }
242
294
  catch (err) {
@@ -251,6 +303,14 @@ class BuyerOrchestrator {
251
303
  emit({ type: 'round_end', round: round + 1, action: 'error', reason });
252
304
  continue;
253
305
  }
306
+ // Open negotiation channel subscription for this txId. All inbound
307
+ // quote / counteraccept messages from the provider will land in
308
+ // our internal queue (via _onChannelMessage) for the negotiation
309
+ // round loop to consume. Subscription is closed in _cleanupTxState.
310
+ if (this.negotiation.negotiationChannel) {
311
+ const sub = this.negotiation.negotiationChannel.subscribeTxId(txId, (delivered) => this._onChannelMessage(txId, delivered));
312
+ this.activeSubscriptions.set(txId, sub);
313
+ }
254
314
  // 3c. Wait for quote or direct commit (ACTP allows INITIATED → COMMITTED fast path)
255
315
  emit({ type: 'waiting_quote', txId, ttlSeconds: quoteTtlSeconds });
256
316
  const reachedState = await this.waitForState(txId, ['QUOTED', 'COMMITTED'], quoteTtlSeconds * 1000, pollInterval);
@@ -299,49 +359,46 @@ class BuyerOrchestrator {
299
359
  catch {
300
360
  // Non-fatal — price tracking is best-effort
301
361
  }
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;
362
+ // 3d-bis. AIP-2.1 negotiation branch: if the orchestrator has a
363
+ // negotiationChannel configured, drain the inbound queue for any
364
+ // quote that arrived via the channel and run the multi-round
365
+ // counter-offer loop. The branch ONLY triggers when reachedState
366
+ // === 'QUOTED' — the COMMITTED fast-path below bypasses negotiation
367
+ // entirely because the provider already locked the deal at buyer's
368
+ // offered amount.
369
+ if (reachedState === 'QUOTED' && this.negotiation.negotiationChannel) {
370
+ const negResult = await this._runNegotiationRound({
371
+ txId,
372
+ candidateSlug: candidate.slug,
373
+ providerAddress,
374
+ offer,
375
+ round,
376
+ rounds,
377
+ emit,
378
+ });
379
+ if (negResult.done) {
380
+ // Negotiation reached a terminal decision (accept or
381
+ // reject) — short-circuit the existing escrow logic below.
382
+ if (negResult.success) {
383
+ this.sessionStore.linkTransaction(session.commerce_session_id, txId, candidate.slug);
384
+ const negReason = negResult.reason ?? 'Negotiation complete';
385
+ emit({ type: 'complete', success: true, reason: negReason });
386
+ return {
387
+ success: true,
388
+ commerce_session_id: session.commerce_session_id,
389
+ actp_tx_id: txId,
390
+ selected_provider: candidate.slug,
391
+ rounds_used: round + 1,
392
+ reason: negReason,
393
+ rounds,
394
+ deadlock_detected: deadlockDetected,
395
+ };
344
396
  }
397
+ // negResult.success === false → candidate rejected; continue
398
+ // outer loop to try the next one. The existing code below
399
+ // would attempt linkEscrow at buyer's offered price, which
400
+ // would happily succeed and silently ignore our rejection.
401
+ continue;
345
402
  }
346
403
  }
347
404
  // 3e. Reserve budget and link escrow (or recognize already-committed).
@@ -456,305 +513,292 @@ class BuyerOrchestrator {
456
513
  // AIP-2.1 negotiation round
457
514
  // ============================================================================
458
515
  /**
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.
516
+ * Run the multi-round AIP-2.1 negotiation flow for one provider/txId.
517
+ *
518
+ * Channel-driven: this method never reads `setReceivedQuote` state;
519
+ * all inbound messages flow through the orchestrator's NegotiationChannel
520
+ * subscription (opened in `_negotiate` after createTransaction).
462
521
  *
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).
522
+ * Inner loop walks up to `policy.negotiation.rounds_per_provider`
523
+ * counter exchanges:
524
+ *
525
+ * await first quote (channel)
526
+ * for round in 0..rounds_per_provider:
527
+ * evaluate(currentQuote, roundsUsedSoFar = round)
528
+ * accept → on-chain acceptQuote+linkEscrow, return success
529
+ * reject → on-chain CANCELLED, return failure
530
+ * counter → channel.post(counter), await NEXT message:
531
+ * counteraccept → bind to last counter, on-chain accept+link, return success
532
+ * new quote → currentQuote = new quote, loop
533
+ * timeout → on-chain CANCELLED, return failure
534
+ *
535
+ * Returns `{done: false}` when the channel has no quote but the tx
536
+ * reached QUOTED via raw transitionState (legacy flow caller wants
537
+ * to fall through to fixed-price). Returns `{done: true, success?,
538
+ * reason?}` for any terminal outcome.
468
539
  */
469
540
  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.
541
+ const { txId, candidateSlug, providerAddress, offer, round, rounds, emit } = args;
542
+ // Cleanup hook fires on any `done: true` return closes the channel
543
+ // subscription opened in _negotiate so daemon callers don't leak.
475
544
  const terminate = (result) => {
476
545
  this._cleanupTxState(txId);
477
546
  return result;
478
547
  };
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.
548
+ if (!this.counterBuilder || !this.negotiation.kernelAddress || !this.negotiation.chainId) {
549
+ // Channel was provided but not the rest of the negotiation context.
550
+ // Fall through to fixed-price flow rather than try to negotiate.
492
551
  this._cleanupTxState(txId);
493
552
  return { done: false };
494
553
  }
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 });
554
+ const counterTtlSec = this.policy.negotiation.counter_response_ttl_seconds
555
+ ?? PolicyEngine_1.PolicyEngine.parseTtl(this.policy.negotiation.quote_ttl);
556
+ const counterTtlMs = counterTtlSec * 1000;
557
+ const roundsBudget = this.policy.negotiation.rounds_per_provider ?? 1;
558
+ // Wait for the FIRST quote on the channel. The provider posts it
559
+ // after on-chain submitQuote — channel + chain may race so we
560
+ // also tolerate the quote arriving slightly before the kernel
561
+ // hash is readable (we re-read on-chain inside the loop).
562
+ const firstQuoteEnv = await this._waitForNextMessage(txId, ['agirails.quote.v1'], counterTtlMs);
563
+ if (!firstQuoteEnv || !(0, NegotiationChannel_1.isQuoteEnvelope)(firstQuoteEnv)) {
564
+ // No quote arrived on the channel within TTL — fall through to
565
+ // fixed-price (the on-chain hash + waitForState already proved
566
+ // the tx hit QUOTED, so this is a legacy-provider scenario).
567
+ this._cleanupTxState(txId);
568
+ return { done: false };
543
569
  }
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);
570
+ let currentQuote = firstQuoteEnv.message;
571
+ // Multi-round inner loop. Each iteration:
572
+ // - on FIRST round: cross-check on-chain hash (anchored proof that
573
+ // the off-chain quote we're seeing matches what provider committed
574
+ // on-chain via submitQuote defense against MITM substitution).
575
+ // - on subsequent rounds: trust the channel's EIP-712 verify alone.
576
+ // Re-quotes are off-chain only by design (kernel forbids QUOTED
577
+ // QUOTED on-chain transitions per Q4 audit). The provider DID is
578
+ // already authenticated by the channel's signature recovery, and
579
+ // the FINAL agreed amount is anchored on acceptQuote+linkEscrow.
580
+ let hashSource = 'aip2';
581
+ for (let counterRound = 0; counterRound < roundsBudget; counterRound++) {
582
+ if (counterRound === 0) {
583
+ const onChainTx = await this.runtime.getTransaction(txId);
584
+ const onChainHash = onChainTx && onChainTx.quoteHash;
585
+ if (!onChainHash) {
586
+ // No anchored quote — fall through to fixed-price.
587
+ this._cleanupTxState(txId);
588
+ return { done: false };
589
+ }
590
+ const verify = (0, verifyQuoteOnChain_1.verifyQuoteHashOnChain)(currentQuote, onChainHash, {
591
+ providerAddress: providerAddress,
592
+ });
593
+ if (!verify.match) {
594
+ rounds.push({
595
+ round: round + 1,
596
+ provider_slug: candidateSlug,
597
+ provider_address: providerAddress,
598
+ action: 'error',
599
+ reason: `Quote hash mismatch: expected ${verify.canonicalHash}, on-chain ${onChainHash}`,
600
+ tx_id: txId,
601
+ });
602
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'Quote hash mismatch' });
603
+ return terminate({ done: true, success: false, reason: 'hash mismatch' });
604
+ }
605
+ hashSource = verify.source;
558
606
  }
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.
607
+ else {
608
+ // Subsequent re-quotes: guard against two attacker-controlled
609
+ // mutations the channel-level EIP-712 verify cannot catch on
610
+ // its own (same provider can sign anything, including poisoned
611
+ // re-quotes):
612
+ //
613
+ // (a) provider DID switched mid-negotiation
614
+ // (b) maxPrice inflated mid-negotiation — without this
615
+ // guard, the buyer's `evaluateQuote` accept-if-affordable
616
+ // branch on the last round would compare against the
617
+ // attacker's inflated max, committing the buyer above
618
+ // its own policy ceiling. (P0 audit finding.)
619
+ //
620
+ // We anchor BOTH provider and maxPrice to the FIRST quote
621
+ // (which already cross-checked on-chain hash on round 0).
622
+ if (currentQuote.provider !== firstQuoteEnv.message.provider) {
564
623
  try {
565
624
  await this.runtime.transitionState(txId, 'CANCELLED');
566
625
  }
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.
626
+ catch { /* best-effort */ }
627
+ rounds.push({
628
+ round: round + 1,
629
+ provider_slug: candidateSlug,
630
+ provider_address: providerAddress,
631
+ action: 'error',
632
+ reason: `Re-quote provider mismatch: ${currentQuote.provider} vs original ${firstQuoteEnv.message.provider}`,
633
+ tx_id: txId,
634
+ });
635
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'provider mismatch on re-quote' });
636
+ return terminate({ done: true, success: false, reason: 'provider mismatch' });
637
+ }
638
+ if (currentQuote.maxPrice !== firstQuoteEnv.message.maxPrice) {
639
+ try {
640
+ await this.runtime.transitionState(txId, 'CANCELLED');
571
641
  }
642
+ catch { /* best-effort */ }
643
+ rounds.push({
644
+ round: round + 1,
645
+ provider_slug: candidateSlug,
646
+ provider_address: providerAddress,
647
+ action: 'error',
648
+ reason: `Re-quote maxPrice mismatch: ${currentQuote.maxPrice} vs original ${firstQuoteEnv.message.maxPrice} — provider may not raise the ceiling mid-negotiation`,
649
+ tx_id: txId,
650
+ });
651
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason: 'maxPrice substitution attempt on re-quote' });
652
+ return terminate({ done: true, success: false, reason: 'maxPrice substitution' });
572
653
  }
654
+ hashSource = 'aip2';
655
+ }
656
+ const evaluation = this.decisionEngine.evaluateQuote(currentQuote, this.policy, counterRound);
657
+ // ----- reject -----
658
+ if (evaluation.action === 'reject') {
659
+ try {
660
+ await this.runtime.transitionState(txId, 'CANCELLED');
661
+ }
662
+ catch { /* best-effort */ }
573
663
  rounds.push({
574
664
  round: round + 1,
575
665
  provider_slug: candidateSlug,
576
666
  provider_address: providerAddress,
577
- action: 'error',
578
- reason: `Accept flow failed: ${reason}`,
667
+ action: 'rejected',
668
+ reason: `${evaluation.reason} (round ${counterRound + 1}/${roundsBudget}, source: ${hashSource})`,
579
669
  tx_id: txId,
670
+ quoted_price: this._baseUnitsForLog(currentQuote.quotedAmount),
580
671
  });
581
- emit({ type: 'round_end', round: round + 1, action: 'error', reason });
582
- return terminate({ done: true, success: false, reason });
672
+ emit({ type: 'round_end', round: round + 1, action: 'rejected', reason: evaluation.reason });
673
+ return terminate({ done: true, success: false, reason: evaluation.reason });
674
+ }
675
+ // ----- accept (at provider's quoted amount) -----
676
+ if (evaluation.action === 'accept') {
677
+ const result = await this._commitAtAmount(txId, currentQuote.quotedAmount, candidateSlug, providerAddress, offer, round, rounds, emit, hashSource, counterRound);
678
+ return terminate(result);
583
679
  }
584
- // Update local ledger for daily-spend accounting; tolerate
585
- // reservation failure (it's bookkeeping the on-chain escrow
586
- // is already locked).
680
+ // ----- counter -----
681
+ // Build counter, post on channel, await next message.
682
+ let counter;
587
683
  try {
588
- this.policyEngine.reserve(offer.commerce_session_id || '', this._baseUnitsForLog(amountBaseUnits), offer.currency);
684
+ const consumerDID = `did:ethr:${this.negotiation.chainId}:${(await this.negotiation.signer.getAddress()).toLowerCase()}`;
685
+ const now = Math.floor(Date.now() / 1000);
686
+ // inReplyTo is the canonical hash of the quote we're countering
687
+ // — recompute on every round (subsequent re-quotes have their
688
+ // own hash, distinct from the on-chain anchored first quote).
689
+ const currentQuoteHash = new QuoteBuilder_1.QuoteBuilder().computeHash(currentQuote);
690
+ counter = await this.counterBuilder.build({
691
+ txId,
692
+ consumer: consumerDID,
693
+ provider: currentQuote.provider,
694
+ quoteAmount: currentQuote.quotedAmount,
695
+ counterAmount: evaluation.amountBaseUnits,
696
+ maxPrice: currentQuote.maxPrice,
697
+ inReplyTo: currentQuoteHash,
698
+ chainId: this.negotiation.chainId,
699
+ kernelAddress: this.negotiation.kernelAddress,
700
+ expiresAt: now + counterTtlSec,
701
+ });
589
702
  }
590
- catch {
591
- // swallow
703
+ catch (err) {
704
+ const reason = `Counter build failed on round ${counterRound + 1}: ${err instanceof Error ? err.message : String(err)}`;
705
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'error', reason, tx_id: txId });
706
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
707
+ return terminate({ done: true, success: false, reason });
592
708
  }
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
709
  try {
613
- await this.runtime.transitionState(txId, 'CANCELLED');
710
+ await this.negotiation.negotiationChannel.post(txId, {
711
+ type: 'agirails.counteroffer.v1', message: counter,
712
+ });
614
713
  }
615
- catch {
616
- /* best-effort */
714
+ catch (err) {
715
+ const reason = `Counter post failed on round ${counterRound + 1}: ${err instanceof Error ? err.message : String(err)}`;
716
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'error', reason, tx_id: txId });
717
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
718
+ return terminate({ done: true, success: false, reason });
617
719
  }
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');
720
+ // Await provider's response: counteraccept (deal closed) or new quote (provider re-quote → next round).
721
+ const next = await this._waitForNextMessage(txId, ['agirails.counteraccept.v1', 'agirails.quote.v1'], counterTtlMs);
722
+ if (!next) {
723
+ try {
724
+ await this.runtime.transitionState(txId, 'CANCELLED');
725
+ }
726
+ catch { /* best-effort */ }
727
+ const reason = `No response within ${counterTtlSec}s on round ${counterRound + 1}`;
728
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'timeout', reason, tx_id: txId });
729
+ emit({ type: 'round_end', round: round + 1, action: 'timeout', reason });
730
+ return terminate({ done: true, success: false, reason });
674
731
  }
675
- catch {
676
- /* best-effort */
732
+ if ((0, NegotiationChannel_1.isCounterAcceptEnvelope)(next)) {
733
+ // Provider accepted our counter — verify binding (channel already
734
+ // verified EIP-712, here we bind to the counter WE sent).
735
+ const accept = next.message;
736
+ const counterHash = new CounterOfferBuilder_1.CounterOfferBuilder().computeHash(counter);
737
+ if (accept.txId !== txId ||
738
+ accept.inReplyTo !== counterHash ||
739
+ accept.acceptedAmount !== counter.counterAmount) {
740
+ const reason = `CounterAccept binding mismatch on round ${counterRound + 1}`;
741
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'error', reason, tx_id: txId });
742
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
743
+ return terminate({ done: true, success: false, reason });
744
+ }
745
+ const result = await this._commitAtAmount(txId, accept.acceptedAmount, candidateSlug, providerAddress, offer, round, rounds, emit, 'counteraccept', counterRound);
746
+ return terminate(result);
747
+ }
748
+ if ((0, NegotiationChannel_1.isQuoteEnvelope)(next)) {
749
+ // Provider re-quoted — replace currentQuote and loop.
750
+ currentQuote = next.message;
751
+ continue;
677
752
  }
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
753
  }
754
+ // Budget exhausted without accept — DecisionEngine's last-round
755
+ // branch should have triggered accept-if-affordable; reaching here
756
+ // implies provider re-quoted to the very last round and we still
757
+ // saw 'counter'. Cancel.
690
758
  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 });
759
+ await this.runtime.transitionState(txId, 'CANCELLED');
726
760
  }
727
- const finalAmount = accepted.amountBaseUnits;
761
+ catch { /* best-effort */ }
762
+ const reason = `Negotiation budget (${roundsBudget} rounds) exhausted without accept`;
763
+ rounds.push({ round: round + 1, provider_slug: candidateSlug, provider_address: providerAddress, action: 'timeout', reason, tx_id: txId });
764
+ emit({ type: 'round_end', round: round + 1, action: 'timeout', reason });
765
+ return terminate({ done: true, success: false, reason });
766
+ }
767
+ /**
768
+ * Shared accept+linkEscrow with atomic rollback. Used by both the
769
+ * "accept the quote" and "accept the counter" terminal branches.
770
+ */
771
+ async _commitAtAmount(txId, amountBaseUnits, candidateSlug, providerAddress, offer, round, rounds, emit, sourceTag, counterRound) {
728
772
  let acceptQuoteSucceeded = false;
729
773
  try {
730
- await this.runtime.acceptQuote(txId, finalAmount);
774
+ await this.runtime.acceptQuote(txId, amountBaseUnits);
731
775
  acceptQuoteSucceeded = true;
732
- await this.runtime.linkEscrow(txId, finalAmount);
776
+ await this.runtime.linkEscrow(txId, amountBaseUnits);
733
777
  }
734
778
  catch (err) {
735
- const reason = `Accept-counter flow failed: ${err instanceof Error ? err.message : String(err)}`;
779
+ const reason = err instanceof Error ? err.message : String(err);
736
780
  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
781
  try {
740
782
  await this.runtime.transitionState(txId, 'CANCELLED');
741
783
  }
742
- catch {
743
- /* best-effort */
744
- }
784
+ catch { /* best-effort */ }
745
785
  }
746
786
  rounds.push({
747
787
  round: round + 1,
748
788
  provider_slug: candidateSlug,
749
789
  provider_address: providerAddress,
750
790
  action: 'error',
751
- reason,
791
+ reason: `Commit failed (round ${counterRound + 1}): ${reason}`,
752
792
  tx_id: txId,
753
793
  });
754
794
  emit({ type: 'round_end', round: round + 1, action: 'error', reason });
755
- return terminate({ done: true, success: false, reason });
795
+ return { done: true, success: false, reason };
756
796
  }
757
- const reason = `Counter accepted at ${finalAmount} base units (source: ${hashSource})`;
797
+ try {
798
+ this.policyEngine.reserve(offer.commerce_session_id || '', this._baseUnitsForLog(amountBaseUnits), offer.currency);
799
+ }
800
+ catch { /* best-effort budget bookkeeping */ }
801
+ const reason = `Committed at ${amountBaseUnits} base units (round ${counterRound + 1}, source: ${sourceTag})`;
758
802
  rounds.push({
759
803
  round: round + 1,
760
804
  provider_slug: candidateSlug,
@@ -762,40 +806,36 @@ class BuyerOrchestrator {
762
806
  action: 'accepted',
763
807
  reason,
764
808
  tx_id: txId,
765
- quoted_price: this._baseUnitsForLog(finalAmount),
809
+ quoted_price: this._baseUnitsForLog(amountBaseUnits),
766
810
  });
767
811
  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
- });
812
+ return { done: true, success: true, reason };
786
813
  }
787
814
  /**
788
815
  * 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.
816
+ * reject CANCELLED, timeout). Closes the channel subscription too so
817
+ * long-running daemon callers don't leak inbound-message resolvers.
791
818
  *
792
819
  * Idempotent — safe to call from multiple cleanup sites.
793
820
  */
794
821
  _cleanupTxState(txId) {
795
- this.receivedQuotes.delete(txId);
796
- this.counterAccepted.delete(txId);
797
- this.counterWaiters.delete(txId);
798
- this.sentCounters.delete(txId);
822
+ this.inboundQueues.delete(txId);
823
+ const pending = this.inboundResolvers.get(txId);
824
+ if (pending) {
825
+ // No more messages will arrive for this tx; resolve waiter as
826
+ // a timeout-equivalent so awaiters don't hang.
827
+ this.inboundResolvers.delete(txId);
828
+ // Note: pending resolver expects a NegotiationMessage; we let
829
+ // the timeout in _waitForNextMessage fire instead by NOT
830
+ // calling pending here. The resolver is removed; setTimeout
831
+ // wins on its own clock.
832
+ void pending;
833
+ }
834
+ const sub = this.activeSubscriptions.get(txId);
835
+ if (sub) {
836
+ sub.unsubscribe();
837
+ this.activeSubscriptions.delete(txId);
838
+ }
799
839
  }
800
840
  /**
801
841
  * Display-only downcast: USDC base-units string → Number for the