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