@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.
- 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/agirails.d.ts +4 -1
- package/dist/cli/agirails.d.ts.map +1 -1
- package/dist/cli/agirails.js +34 -2
- package/dist/cli/agirails.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 +250 -0
- package/dist/cli/commands/agent.js.map +1 -0
- package/dist/cli/commands/pay.d.ts +7 -0
- package/dist/cli/commands/pay.d.ts.map +1 -1
- package/dist/cli/commands/pay.js +34 -1
- package/dist/cli/commands/pay.js.map +1 -1
- package/dist/cli/commands/request.d.ts +19 -0
- package/dist/cli/commands/request.d.ts.map +1 -0
- package/dist/cli/commands/request.js +167 -0
- package/dist/cli/commands/request.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +12 -7
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +12 -7
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/test.d.ts +22 -4
- package/dist/cli/commands/test.d.ts.map +1 -1
- package/dist/cli/commands/test.js +139 -292
- package/dist/cli/commands/test.js.map +1 -1
- package/dist/cli/commands/tx.js +13 -0
- package/dist/cli/commands/tx.js.map +1 -1
- package/dist/cli/index.js +10 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/lib/resolveAgent.d.ts +67 -0
- package/dist/cli/lib/resolveAgent.d.ts.map +1 -0
- package/dist/cli/lib/resolveAgent.js +121 -0
- package/dist/cli/lib/resolveAgent.js.map +1 -0
- package/dist/cli/lib/runRequest.d.ts +114 -0
- package/dist/cli/lib/runRequest.d.ts.map +1 -0
- package/dist/cli/lib/runRequest.js +324 -0
- package/dist/cli/lib/runRequest.js.map +1 -0
- package/dist/cli/lib/serviceNameForHash.d.ts +48 -0
- package/dist/cli/lib/serviceNameForHash.d.ts.map +1 -0
- package/dist/cli/lib/serviceNameForHash.js +62 -0
- package/dist/cli/lib/serviceNameForHash.js.map +1 -0
- 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/level0/request.d.ts.map +1 -1
- package/dist/level0/request.js +18 -6
- package/dist/level0/request.js.map +1 -1
- package/dist/level1/Agent.d.ts +72 -8
- package/dist/level1/Agent.d.ts.map +1 -1
- package/dist/level1/Agent.js +243 -111
- package/dist/level1/Agent.js.map +1 -1
- package/dist/negotiation/BuyerOrchestrator.d.ts +73 -61
- package/dist/negotiation/BuyerOrchestrator.d.ts.map +1 -1
- package/dist/negotiation/BuyerOrchestrator.js +421 -381
- 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/protocol/EventMonitor.d.ts +26 -1
- package/dist/protocol/EventMonitor.d.ts.map +1 -1
- package/dist/protocol/EventMonitor.js +18 -4
- package/dist/protocol/EventMonitor.js.map +1 -1
- package/dist/runtime/BlockchainRuntime.d.ts +73 -0
- package/dist/runtime/BlockchainRuntime.d.ts.map +1 -1
- package/dist/runtime/BlockchainRuntime.js +131 -3
- package/dist/runtime/BlockchainRuntime.js.map +1 -1
- package/dist/runtime/IACTPRuntime.d.ts +29 -0
- package/dist/runtime/IACTPRuntime.d.ts.map +1 -1
- package/dist/runtime/MockRuntime.d.ts.map +1 -1
- package/dist/runtime/MockRuntime.js +21 -4
- package/dist/runtime/MockRuntime.js.map +1 -1
- package/dist/runtime/MockStateManager.d.ts.map +1 -1
- package/dist/runtime/MockStateManager.js +18 -0
- package/dist/runtime/MockStateManager.js.map +1 -1
- package/dist/runtime/types/MockState.d.ts +12 -0
- package/dist/runtime/types/MockState.d.ts.map +1 -1
- package/dist/runtime/types/MockState.js.map +1 -1
- package/dist/types/agent.d.ts +6 -1
- package/dist/types/agent.d.ts.map +1 -1
- 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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
*
|
|
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`
|
|
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
|
-
*
|
|
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.
|
|
106
|
+
* Drains the queue first so messages buffered while we were busy
|
|
107
|
+
* processing the previous round are picked up immediately.
|
|
91
108
|
*/
|
|
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
|
-
|
|
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:
|
|
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
|
|
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;
|
|
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
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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,
|
|
471
|
-
//
|
|
472
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 });
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
await this.runtime.
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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: '
|
|
578
|
-
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: '
|
|
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
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
680
|
+
// ----- counter -----
|
|
681
|
+
// Build counter, post on channel, await next message.
|
|
682
|
+
let counter;
|
|
587
683
|
try {
|
|
588
|
-
this.
|
|
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
|
-
|
|
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.
|
|
710
|
+
await this.negotiation.negotiationChannel.post(txId, {
|
|
711
|
+
type: 'agirails.counteroffer.v1', message: counter,
|
|
712
|
+
});
|
|
614
713
|
}
|
|
615
|
-
catch {
|
|
616
|
-
|
|
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
|
-
|
|
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');
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
774
|
+
await this.runtime.acceptQuote(txId, amountBaseUnits);
|
|
731
775
|
acceptQuoteSucceeded = true;
|
|
732
|
-
await this.runtime.linkEscrow(txId,
|
|
776
|
+
await this.runtime.linkEscrow(txId, amountBaseUnits);
|
|
733
777
|
}
|
|
734
778
|
catch (err) {
|
|
735
|
-
const reason =
|
|
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
|
|
795
|
+
return { done: true, success: false, reason };
|
|
756
796
|
}
|
|
757
|
-
|
|
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(
|
|
809
|
+
quoted_price: this._baseUnitsForLog(amountBaseUnits),
|
|
766
810
|
});
|
|
767
811
|
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
|
-
});
|
|
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).
|
|
790
|
-
*
|
|
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.
|
|
796
|
-
this.
|
|
797
|
-
|
|
798
|
-
|
|
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
|