@agirails/sdk 3.3.0 → 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 (130) hide show
  1. package/dist/api/agirailsApp.d.ts +21 -1
  2. package/dist/api/agirailsApp.d.ts.map +1 -1
  3. package/dist/api/agirailsApp.js.map +1 -1
  4. package/dist/builders/CounterAcceptBuilder.d.ts +96 -0
  5. package/dist/builders/CounterAcceptBuilder.d.ts.map +1 -0
  6. package/dist/builders/CounterAcceptBuilder.js +226 -0
  7. package/dist/builders/CounterAcceptBuilder.js.map +1 -0
  8. package/dist/builders/CounterOfferBuilder.d.ts +143 -0
  9. package/dist/builders/CounterOfferBuilder.d.ts.map +1 -0
  10. package/dist/builders/CounterOfferBuilder.js +329 -0
  11. package/dist/builders/CounterOfferBuilder.js.map +1 -0
  12. package/dist/builders/QuoteBuilder.d.ts +9 -3
  13. package/dist/builders/QuoteBuilder.d.ts.map +1 -1
  14. package/dist/builders/QuoteBuilder.js +22 -6
  15. package/dist/builders/QuoteBuilder.js.map +1 -1
  16. package/dist/builders/index.d.ts +2 -0
  17. package/dist/builders/index.d.ts.map +1 -1
  18. package/dist/builders/index.js +7 -1
  19. package/dist/builders/index.js.map +1 -1
  20. package/dist/cli/agirails.js +22 -2
  21. package/dist/cli/agirails.js.map +1 -1
  22. package/dist/cli/commands/agent.d.ts +22 -0
  23. package/dist/cli/commands/agent.d.ts.map +1 -0
  24. package/dist/cli/commands/agent.js +209 -0
  25. package/dist/cli/commands/agent.js.map +1 -0
  26. package/dist/cli/commands/health.js +21 -5
  27. package/dist/cli/commands/health.js.map +1 -1
  28. package/dist/cli/commands/init.d.ts.map +1 -1
  29. package/dist/cli/commands/init.js +25 -5
  30. package/dist/cli/commands/init.js.map +1 -1
  31. package/dist/cli/commands/publish.d.ts +34 -0
  32. package/dist/cli/commands/publish.d.ts.map +1 -1
  33. package/dist/cli/commands/publish.js +256 -80
  34. package/dist/cli/commands/publish.js.map +1 -1
  35. package/dist/cli/commands/repair.d.ts +23 -0
  36. package/dist/cli/commands/repair.d.ts.map +1 -0
  37. package/dist/cli/commands/repair.js +210 -0
  38. package/dist/cli/commands/repair.js.map +1 -0
  39. package/dist/cli/commands/serve.d.ts +38 -0
  40. package/dist/cli/commands/serve.d.ts.map +1 -0
  41. package/dist/cli/commands/serve.js +308 -0
  42. package/dist/cli/commands/serve.js.map +1 -0
  43. package/dist/cli/commands/test.js +2 -2
  44. package/dist/cli/commands/test.js.map +1 -1
  45. package/dist/cli/index.js +10 -0
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/config/agirailsmdV4.d.ts +46 -1
  48. package/dist/config/agirailsmdV4.d.ts.map +1 -1
  49. package/dist/config/agirailsmdV4.js +65 -8
  50. package/dist/config/agirailsmdV4.js.map +1 -1
  51. package/dist/config/defaults.d.ts +10 -0
  52. package/dist/config/defaults.d.ts.map +1 -1
  53. package/dist/config/defaults.js +10 -0
  54. package/dist/config/defaults.js.map +1 -1
  55. package/dist/config/networks.d.ts.map +1 -1
  56. package/dist/config/networks.js +7 -1
  57. package/dist/config/networks.js.map +1 -1
  58. package/dist/config/publishPipeline.d.ts +23 -1
  59. package/dist/config/publishPipeline.d.ts.map +1 -1
  60. package/dist/config/publishPipeline.js +70 -15
  61. package/dist/config/publishPipeline.js.map +1 -1
  62. package/dist/index.d.ts +26 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +39 -3
  65. package/dist/index.js.map +1 -1
  66. package/dist/level1/Agent.d.ts +27 -0
  67. package/dist/level1/Agent.d.ts.map +1 -1
  68. package/dist/level1/Agent.js +77 -6
  69. package/dist/level1/Agent.js.map +1 -1
  70. package/dist/negotiation/BuyerOrchestrator.d.ts +115 -1
  71. package/dist/negotiation/BuyerOrchestrator.d.ts.map +1 -1
  72. package/dist/negotiation/BuyerOrchestrator.js +530 -4
  73. package/dist/negotiation/BuyerOrchestrator.js.map +1 -1
  74. package/dist/negotiation/DecisionEngine.d.ts +69 -1
  75. package/dist/negotiation/DecisionEngine.d.ts.map +1 -1
  76. package/dist/negotiation/DecisionEngine.js +140 -1
  77. package/dist/negotiation/DecisionEngine.js.map +1 -1
  78. package/dist/negotiation/MockChannel.d.ts +63 -0
  79. package/dist/negotiation/MockChannel.d.ts.map +1 -0
  80. package/dist/negotiation/MockChannel.js +175 -0
  81. package/dist/negotiation/MockChannel.js.map +1 -0
  82. package/dist/negotiation/NegotiationChannel.d.ts +142 -0
  83. package/dist/negotiation/NegotiationChannel.d.ts.map +1 -0
  84. package/dist/negotiation/NegotiationChannel.js +59 -0
  85. package/dist/negotiation/NegotiationChannel.js.map +1 -0
  86. package/dist/negotiation/PolicyEngine.d.ts +32 -0
  87. package/dist/negotiation/PolicyEngine.d.ts.map +1 -1
  88. package/dist/negotiation/PolicyEngine.js.map +1 -1
  89. package/dist/negotiation/ProviderOrchestrator.d.ts +158 -0
  90. package/dist/negotiation/ProviderOrchestrator.d.ts.map +1 -0
  91. package/dist/negotiation/ProviderOrchestrator.js +286 -0
  92. package/dist/negotiation/ProviderOrchestrator.js.map +1 -0
  93. package/dist/negotiation/ProviderPolicy.d.ts +188 -0
  94. package/dist/negotiation/ProviderPolicy.d.ts.map +1 -0
  95. package/dist/negotiation/ProviderPolicy.js +259 -0
  96. package/dist/negotiation/ProviderPolicy.js.map +1 -0
  97. package/dist/negotiation/RelayChannel.d.ts +59 -0
  98. package/dist/negotiation/RelayChannel.d.ts.map +1 -0
  99. package/dist/negotiation/RelayChannel.js +208 -0
  100. package/dist/negotiation/RelayChannel.js.map +1 -0
  101. package/dist/negotiation/index.d.ts +8 -1
  102. package/dist/negotiation/index.d.ts.map +1 -1
  103. package/dist/negotiation/index.js +8 -1
  104. package/dist/negotiation/index.js.map +1 -1
  105. package/dist/negotiation/verifyQuoteOnChain.d.ts +58 -0
  106. package/dist/negotiation/verifyQuoteOnChain.d.ts.map +1 -0
  107. package/dist/negotiation/verifyQuoteOnChain.js +83 -0
  108. package/dist/negotiation/verifyQuoteOnChain.js.map +1 -0
  109. package/dist/protocol/ACTPKernel.d.ts.map +1 -1
  110. package/dist/protocol/ACTPKernel.js +51 -1
  111. package/dist/protocol/ACTPKernel.js.map +1 -1
  112. package/dist/runtime/BlockchainRuntime.d.ts +13 -0
  113. package/dist/runtime/BlockchainRuntime.d.ts.map +1 -1
  114. package/dist/runtime/BlockchainRuntime.js +33 -2
  115. package/dist/runtime/BlockchainRuntime.js.map +1 -1
  116. package/dist/runtime/IACTPRuntime.d.ts +35 -0
  117. package/dist/runtime/IACTPRuntime.d.ts.map +1 -1
  118. package/dist/runtime/MockRuntime.d.ts +11 -0
  119. package/dist/runtime/MockRuntime.d.ts.map +1 -1
  120. package/dist/runtime/MockRuntime.js +39 -0
  121. package/dist/runtime/MockRuntime.js.map +1 -1
  122. package/dist/runtime/types/MockState.d.ts +10 -0
  123. package/dist/runtime/types/MockState.d.ts.map +1 -1
  124. package/dist/runtime/types/MockState.js.map +1 -1
  125. package/dist/transport/QuoteChannel.d.ts +201 -0
  126. package/dist/transport/QuoteChannel.d.ts.map +1 -0
  127. package/dist/transport/QuoteChannel.js +358 -0
  128. package/dist/transport/QuoteChannel.js.map +1 -0
  129. package/dist/types/adapter.d.ts +24 -24
  130. package/package.json +16 -1
@@ -22,17 +22,142 @@ 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
- // ============================================================================
26
- // BuyerOrchestrator
27
- // ============================================================================
25
+ const QuoteBuilder_1 = require("../builders/QuoteBuilder");
26
+ const CounterOfferBuilder_1 = require("../builders/CounterOfferBuilder");
27
+ const NonceManager_1 = require("../utils/NonceManager");
28
+ const verifyQuoteOnChain_1 = require("./verifyQuoteOnChain");
29
+ const NegotiationChannel_1 = require("./NegotiationChannel");
28
30
  class BuyerOrchestrator {
29
- constructor(policy, runtime, requesterAddress, actpDir) {
31
+ constructor(policy, runtime, requesterAddress, actpDir, negotiation = {}) {
32
+ /**
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.
36
+ */
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
+ }
30
68
  this.policy = policy;
31
69
  this.runtime = runtime;
32
70
  this.requesterAddress = requesterAddress;
33
71
  this.policyEngine = new PolicyEngine_1.PolicyEngine(policy, actpDir);
34
72
  this.decisionEngine = new DecisionEngine_1.DecisionEngine(policy.selection.weights);
35
73
  this.sessionStore = new SessionStore_1.SessionStore(actpDir);
74
+ this.negotiation = negotiation;
75
+ if (negotiation.signer) {
76
+ this.counterBuilder = new CounterOfferBuilder_1.CounterOfferBuilder(negotiation.signer, negotiation.nonceManager ?? new NonceManager_1.InMemoryNonceManager());
77
+ }
78
+ }
79
+ // --------------------------------------------------------------------------
80
+ // Channel inbound dispatch
81
+ // --------------------------------------------------------------------------
82
+ /**
83
+ * Channel delivered a verified message for `txId`. If a round is
84
+ * awaiting the next message, hand it directly; otherwise queue.
85
+ *
86
+ * NOTE: the channel has already verified EIP-712 signature + chainId
87
+ * before invoking us. This handler is concerned only with routing.
88
+ */
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);
99
+ }
100
+ /**
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).
104
+ *
105
+ * Drains the queue first so messages buffered while we were busy
106
+ * processing the previous round are picked up immediately.
107
+ */
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
+ });
36
161
  }
37
162
  /**
38
163
  * Execute the full negotiation flow.
@@ -169,6 +294,14 @@ class BuyerOrchestrator {
169
294
  emit({ type: 'round_end', round: round + 1, action: 'error', reason });
170
295
  continue;
171
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
+ }
172
305
  // 3c. Wait for quote or direct commit (ACTP allows INITIATED → COMMITTED fast path)
173
306
  emit({ type: 'waiting_quote', txId, ttlSeconds: quoteTtlSeconds });
174
307
  const reachedState = await this.waitForState(txId, ['QUOTED', 'COMMITTED'], quoteTtlSeconds * 1000, pollInterval);
@@ -189,6 +322,10 @@ class BuyerOrchestrator {
189
322
  tx_id: txId,
190
323
  });
191
324
  emit({ type: 'round_end', round: round + 1, action: 'timeout', reason: 'Quote TTL expired' });
325
+ // External caller may have pushed a quote between createTransaction
326
+ // and timeout — clear so a long-running daemon doesn't accumulate
327
+ // entries in receivedQuotes / sentCounters.
328
+ this._cleanupTxState(txId);
192
329
  continue;
193
330
  }
194
331
  emit({ type: 'quote_received', txId });
@@ -213,6 +350,48 @@ class BuyerOrchestrator {
213
350
  catch {
214
351
  // Non-fatal — price tracking is best-effort
215
352
  }
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
+ };
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;
393
+ }
394
+ }
216
395
  // 3e. Reserve budget and link escrow (or recognize already-committed).
217
396
  // ACTP invariant: tx.amount is immutable (set at createTransaction).
218
397
  // Policy was already validated pre-round, so offer.unit_price
@@ -239,6 +418,10 @@ class BuyerOrchestrator {
239
418
  });
240
419
  emit({ type: 'round_end', round: round + 1, action: 'accepted', reason });
241
420
  emit({ type: 'complete', success: true, reason: 'Negotiation complete' });
421
+ // COMMITTED fast-path bypassed _runNegotiationRound (which is
422
+ // the usual cleanup site) — drop any stashed quote/counter
423
+ // state so daemon callers don't leak across negotiations.
424
+ this._cleanupTxState(txId);
242
425
  return {
243
426
  success: true,
244
427
  commerce_session_id: session.commerce_session_id,
@@ -269,6 +452,9 @@ class BuyerOrchestrator {
269
452
  });
270
453
  emit({ type: 'round_end', round: round + 1, action: 'accepted', reason });
271
454
  emit({ type: 'complete', success: true, reason: 'Negotiation complete' });
455
+ // Symmetric to the COMMITTED fast-path above — this success exit
456
+ // also bypassed _runNegotiationRound's cleanup site.
457
+ this._cleanupTxState(txId);
272
458
  return {
273
459
  success: true,
274
460
  commerce_session_id: session.commerce_session_id,
@@ -294,6 +480,8 @@ class BuyerOrchestrator {
294
480
  quoted_price: quotedPrice,
295
481
  });
296
482
  emit({ type: 'round_end', round: round + 1, action: 'error', reason });
483
+ // Same daemon-leak rationale as the timeout `continue` above.
484
+ this._cleanupTxState(txId);
297
485
  continue;
298
486
  }
299
487
  }
@@ -313,6 +501,344 @@ class BuyerOrchestrator {
313
501
  };
314
502
  }
315
503
  // ============================================================================
504
+ // AIP-2.1 negotiation round
505
+ // ============================================================================
506
+ /**
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).
512
+ *
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.
530
+ */
531
+ async _runNegotiationRound(args) {
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.
535
+ const terminate = (result) => {
536
+ this._cleanupTxState(txId);
537
+ return result;
538
+ };
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.
542
+ this._cleanupTxState(txId);
543
+ return { done: false };
544
+ }
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 };
560
+ }
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;
597
+ }
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) {
614
+ try {
615
+ await this.runtime.transitionState(txId, 'CANCELLED');
616
+ }
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');
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' });
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 */ }
654
+ rounds.push({
655
+ round: round + 1,
656
+ provider_slug: candidateSlug,
657
+ provider_address: providerAddress,
658
+ action: 'rejected',
659
+ reason: `${evaluation.reason} (round ${counterRound + 1}/${roundsBudget}, source: ${hashSource})`,
660
+ tx_id: txId,
661
+ quoted_price: this._baseUnitsForLog(currentQuote.quotedAmount),
662
+ });
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);
670
+ }
671
+ // ----- counter -----
672
+ // Build counter, post on channel, await next message.
673
+ let counter;
674
+ try {
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
+ });
693
+ }
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 });
699
+ }
700
+ try {
701
+ await this.negotiation.negotiationChannel.post(txId, {
702
+ type: 'agirails.counteroffer.v1', message: counter,
703
+ });
704
+ }
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 });
710
+ }
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 });
722
+ }
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;
743
+ }
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.
749
+ try {
750
+ await this.runtime.transitionState(txId, 'CANCELLED');
751
+ }
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) {
763
+ let acceptQuoteSucceeded = false;
764
+ try {
765
+ await this.runtime.acceptQuote(txId, amountBaseUnits);
766
+ acceptQuoteSucceeded = true;
767
+ await this.runtime.linkEscrow(txId, amountBaseUnits);
768
+ }
769
+ catch (err) {
770
+ const reason = err instanceof Error ? err.message : String(err);
771
+ if (acceptQuoteSucceeded) {
772
+ try {
773
+ await this.runtime.transitionState(txId, 'CANCELLED');
774
+ }
775
+ catch { /* best-effort */ }
776
+ }
777
+ rounds.push({
778
+ round: round + 1,
779
+ provider_slug: candidateSlug,
780
+ provider_address: providerAddress,
781
+ action: 'error',
782
+ reason: `Commit failed (round ${counterRound + 1}): ${reason}`,
783
+ tx_id: txId,
784
+ });
785
+ emit({ type: 'round_end', round: round + 1, action: 'error', reason });
786
+ return { done: true, success: false, reason };
787
+ }
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})`;
793
+ rounds.push({
794
+ round: round + 1,
795
+ provider_slug: candidateSlug,
796
+ provider_address: providerAddress,
797
+ action: 'accepted',
798
+ reason,
799
+ tx_id: txId,
800
+ quoted_price: this._baseUnitsForLog(amountBaseUnits),
801
+ });
802
+ emit({ type: 'round_end', round: round + 1, action: 'accepted', reason });
803
+ return { done: true, success: true, reason };
804
+ }
805
+ /**
806
+ * Free per-tx negotiation state at terminal outcomes (accept commits,
807
+ * reject CANCELLED, timeout). Closes the channel subscription too so
808
+ * long-running daemon callers don't leak inbound-message resolvers.
809
+ *
810
+ * Idempotent — safe to call from multiple cleanup sites.
811
+ */
812
+ _cleanupTxState(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
+ }
830
+ }
831
+ /**
832
+ * Display-only downcast: USDC base-units string → Number for the
833
+ * RoundResult.quoted_price log field. Loses precision above
834
+ * Number.MAX_SAFE_INTEGER / 1e6 (~$9 quadrillion) but every
835
+ * comparison the orchestrator actually MAKES uses the bigint
836
+ * string. The on-chain tx.amount is the source of truth.
837
+ */
838
+ _baseUnitsForLog(baseUnitsStr) {
839
+ return Number(BigInt(baseUnitsStr)) / 1000000;
840
+ }
841
+ // ============================================================================
316
842
  // Helpers
317
843
  // ============================================================================
318
844
  /**