@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.
- package/dist/builders/QuoteBuilder.d.ts +9 -3
- package/dist/builders/QuoteBuilder.d.ts.map +1 -1
- package/dist/builders/QuoteBuilder.js +14 -3
- package/dist/builders/QuoteBuilder.js.map +1 -1
- package/dist/cli/commands/agent.d.ts +22 -0
- package/dist/cli/commands/agent.d.ts.map +1 -0
- package/dist/cli/commands/agent.js +209 -0
- package/dist/cli/commands/agent.js.map +1 -0
- package/dist/cli/index.js +5 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/config/networks.d.ts.map +1 -1
- package/dist/config/networks.js +7 -1
- package/dist/config/networks.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/level1/Agent.d.ts.map +1 -1
- package/dist/level1/Agent.js +1 -1
- package/dist/level1/Agent.js.map +1 -1
- package/dist/negotiation/BuyerOrchestrator.d.ts +72 -60
- package/dist/negotiation/BuyerOrchestrator.d.ts.map +1 -1
- package/dist/negotiation/BuyerOrchestrator.js +411 -380
- package/dist/negotiation/BuyerOrchestrator.js.map +1 -1
- package/dist/negotiation/MockChannel.d.ts +63 -0
- package/dist/negotiation/MockChannel.d.ts.map +1 -0
- package/dist/negotiation/MockChannel.js +175 -0
- package/dist/negotiation/MockChannel.js.map +1 -0
- package/dist/negotiation/NegotiationChannel.d.ts +142 -0
- package/dist/negotiation/NegotiationChannel.d.ts.map +1 -0
- package/dist/negotiation/NegotiationChannel.js +59 -0
- package/dist/negotiation/NegotiationChannel.js.map +1 -0
- package/dist/negotiation/ProviderOrchestrator.d.ts +85 -35
- package/dist/negotiation/ProviderOrchestrator.d.ts.map +1 -1
- package/dist/negotiation/ProviderOrchestrator.js +199 -49
- package/dist/negotiation/ProviderOrchestrator.js.map +1 -1
- package/dist/negotiation/ProviderPolicy.d.ts +51 -6
- package/dist/negotiation/ProviderPolicy.d.ts.map +1 -1
- package/dist/negotiation/ProviderPolicy.js +61 -9
- package/dist/negotiation/ProviderPolicy.js.map +1 -1
- package/dist/negotiation/RelayChannel.d.ts +59 -0
- package/dist/negotiation/RelayChannel.d.ts.map +1 -0
- package/dist/negotiation/RelayChannel.js +208 -0
- package/dist/negotiation/RelayChannel.js.map +1 -0
- package/dist/protocol/ACTPKernel.d.ts.map +1 -1
- package/dist/protocol/ACTPKernel.js +51 -1
- package/dist/protocol/ACTPKernel.js.map +1 -1
- package/dist/runtime/BlockchainRuntime.d.ts.map +1 -1
- package/dist/runtime/BlockchainRuntime.js +10 -2
- package/dist/runtime/BlockchainRuntime.js.map +1 -1
- 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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// The branch ONLY triggers when reachedState
|
|
306
|
-
// COMMITTED fast-path below bypasses negotiation
|
|
307
|
-
// the provider already locked the deal at buyer's
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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,
|
|
471
|
-
//
|
|
472
|
-
//
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
return
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
await this.runtime.
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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: '
|
|
578
|
-
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: '
|
|
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
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
671
|
+
// ----- counter -----
|
|
672
|
+
// Build counter, post on channel, await next message.
|
|
673
|
+
let counter;
|
|
587
674
|
try {
|
|
588
|
-
this.
|
|
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
|
-
|
|
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.
|
|
701
|
+
await this.negotiation.negotiationChannel.post(txId, {
|
|
702
|
+
type: 'agirails.counteroffer.v1', message: counter,
|
|
703
|
+
});
|
|
614
704
|
}
|
|
615
|
-
catch {
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
765
|
+
await this.runtime.acceptQuote(txId, amountBaseUnits);
|
|
731
766
|
acceptQuoteSucceeded = true;
|
|
732
|
-
await this.runtime.linkEscrow(txId,
|
|
767
|
+
await this.runtime.linkEscrow(txId, amountBaseUnits);
|
|
733
768
|
}
|
|
734
769
|
catch (err) {
|
|
735
|
-
const reason =
|
|
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
|
|
786
|
+
return { done: true, success: false, reason };
|
|
756
787
|
}
|
|
757
|
-
|
|
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(
|
|
800
|
+
quoted_price: this._baseUnitsForLog(amountBaseUnits),
|
|
766
801
|
});
|
|
767
802
|
emit({ type: 'round_end', round: round + 1, action: 'accepted', reason });
|
|
768
|
-
return
|
|
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).
|
|
790
|
-
*
|
|
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.
|
|
796
|
-
this.
|
|
797
|
-
|
|
798
|
-
|
|
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
|