@antseed/node 0.2.25 → 0.2.27
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/README.md +74 -1
- package/dist/discovery/announcer.d.ts +2 -0
- package/dist/discovery/announcer.d.ts.map +1 -1
- package/dist/discovery/announcer.js +10 -6
- package/dist/discovery/announcer.js.map +1 -1
- package/dist/discovery/index.d.ts +0 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +0 -1
- package/dist/discovery/index.js.map +1 -1
- package/dist/discovery/reputation-verifier.d.ts +7 -4
- package/dist/discovery/reputation-verifier.d.ts.map +1 -1
- package/dist/discovery/reputation-verifier.js +33 -9
- package/dist/discovery/reputation-verifier.js.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +60 -33
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +640 -324
- package/dist/node.js.map +1 -1
- package/dist/p2p/connection-manager.d.ts.map +1 -1
- package/dist/p2p/connection-manager.js +6 -0
- package/dist/p2p/connection-manager.js.map +1 -1
- package/dist/p2p/identity.d.ts +27 -3
- package/dist/p2p/identity.d.ts.map +1 -1
- package/dist/p2p/identity.js +52 -13
- package/dist/p2p/identity.js.map +1 -1
- package/dist/p2p/index.d.ts +0 -2
- package/dist/p2p/index.d.ts.map +1 -1
- package/dist/p2p/index.js +0 -2
- package/dist/p2p/index.js.map +1 -1
- package/dist/p2p/payment-codec.d.ts +7 -13
- package/dist/p2p/payment-codec.d.ts.map +1 -1
- package/dist/p2p/payment-codec.js +32 -50
- package/dist/p2p/payment-codec.js.map +1 -1
- package/dist/p2p/payment-mux.d.ts +11 -20
- package/dist/p2p/payment-mux.d.ts.map +1 -1
- package/dist/p2p/payment-mux.js +36 -52
- package/dist/p2p/payment-mux.js.map +1 -1
- package/dist/payments/buyer-payment-manager.d.ts +34 -93
- package/dist/payments/buyer-payment-manager.d.ts.map +1 -1
- package/dist/payments/buyer-payment-manager.js +184 -189
- package/dist/payments/buyer-payment-manager.js.map +1 -1
- package/dist/payments/chain-config.d.ts +38 -0
- package/dist/payments/chain-config.d.ts.map +1 -0
- package/dist/payments/chain-config.js +60 -0
- package/dist/payments/chain-config.js.map +1 -0
- package/dist/payments/evm/ants-token-client.d.ts +16 -0
- package/dist/payments/evm/ants-token-client.d.ts.map +1 -0
- package/dist/payments/evm/ants-token-client.js +66 -0
- package/dist/payments/evm/ants-token-client.js.map +1 -0
- package/dist/payments/evm/base-evm-client.d.ts +13 -0
- package/dist/payments/evm/base-evm-client.d.ts.map +1 -0
- package/dist/payments/evm/base-evm-client.js +38 -0
- package/dist/payments/evm/base-evm-client.js.map +1 -0
- package/dist/payments/evm/emissions-client.d.ts +23 -0
- package/dist/payments/evm/emissions-client.d.ts.map +1 -0
- package/dist/payments/evm/emissions-client.js +84 -0
- package/dist/payments/evm/emissions-client.js.map +1 -0
- package/dist/payments/evm/escrow-client.d.ts +57 -36
- package/dist/payments/evm/escrow-client.d.ts.map +1 -1
- package/dist/payments/evm/escrow-client.js +200 -93
- package/dist/payments/evm/escrow-client.js.map +1 -1
- package/dist/payments/evm/identity-client.d.ts +34 -0
- package/dist/payments/evm/identity-client.d.ts.map +1 -0
- package/dist/payments/evm/identity-client.js +125 -0
- package/dist/payments/evm/identity-client.js.map +1 -0
- package/dist/payments/evm/signatures.d.ts +18 -5
- package/dist/payments/evm/signatures.d.ts.map +1 -1
- package/dist/payments/evm/signatures.js +21 -12
- package/dist/payments/evm/signatures.js.map +1 -1
- package/dist/payments/evm/subpool-client.d.ts +30 -0
- package/dist/payments/evm/subpool-client.d.ts.map +1 -0
- package/dist/payments/evm/subpool-client.js +158 -0
- package/dist/payments/evm/subpool-client.js.map +1 -0
- package/dist/payments/index.d.ts +20 -7
- package/dist/payments/index.d.ts.map +1 -1
- package/dist/payments/index.js +17 -6
- package/dist/payments/index.js.map +1 -1
- package/dist/payments/readiness.d.ts +12 -0
- package/dist/payments/readiness.d.ts.map +1 -0
- package/dist/payments/readiness.js +64 -0
- package/dist/payments/readiness.js.map +1 -0
- package/dist/payments/seller-payment-manager.d.ts +74 -34
- package/dist/payments/seller-payment-manager.d.ts.map +1 -1
- package/dist/payments/seller-payment-manager.js +327 -118
- package/dist/payments/seller-payment-manager.js.map +1 -1
- package/dist/payments/session-store.d.ts +65 -0
- package/dist/payments/session-store.d.ts.map +1 -0
- package/dist/payments/session-store.js +243 -0
- package/dist/payments/session-store.js.map +1 -0
- package/dist/payments/usdc-utils.d.ts +9 -0
- package/dist/payments/usdc-utils.d.ts.map +1 -0
- package/dist/payments/usdc-utils.js +17 -0
- package/dist/payments/usdc-utils.js.map +1 -0
- package/dist/types/http.d.ts +2 -0
- package/dist/types/http.d.ts.map +1 -1
- package/dist/types/http.js +2 -0
- package/dist/types/http.js.map +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/metering.d.ts +1 -1
- package/dist/types/metering.d.ts.map +1 -1
- package/dist/types/protocol.d.ts +43 -60
- package/dist/types/protocol.d.ts.map +1 -1
- package/dist/types/protocol.js +5 -8
- package/dist/types/protocol.js.map +1 -1
- package/package.json +3 -2
package/dist/node.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { loadOrCreateIdentity } from "./p2p/identity.js";
|
|
6
|
-
import { ANTSEED_STREAMING_RESPONSE_HEADER, } from "./types/http.js";
|
|
7
|
+
import { ANTSEED_STREAMING_RESPONSE_HEADER, ANTSEED_SPENDING_AUTH_HEADER, } from "./types/http.js";
|
|
7
8
|
import { MeteringStorage } from "./metering/storage.js";
|
|
8
9
|
import { ReceiptGenerator } from "./metering/receipt-generator.js";
|
|
9
10
|
import { ConnectionState } from "./types/connection.js";
|
|
@@ -20,13 +21,58 @@ import { KeepaliveManager, buildPongPayload } from "./p2p/keepalive.js";
|
|
|
20
21
|
import { MessageType } from "./types/protocol.js";
|
|
21
22
|
import { NatTraversal } from "./p2p/nat-traversal.js";
|
|
22
23
|
import { signUtf8Ed25519 } from "./p2p/identity.js";
|
|
23
|
-
|
|
24
|
-
import { BalanceManager, BaseEscrowClient,
|
|
25
|
-
import {
|
|
24
|
+
// verifyMessage/getBytes removed — no longer needed after SpendingAuth refactor
|
|
25
|
+
import { BalanceManager, BaseEscrowClient, SessionStore, } from "./payments/index.js";
|
|
26
|
+
import { parseJsonObject, extractUsage } from "@antseed/api-adapter";
|
|
26
27
|
import { debugLog, debugWarn } from "./utils/debug.js";
|
|
27
28
|
import { parsePublicAddress } from "./discovery/public-address.js";
|
|
28
29
|
import { BuyerPaymentManager } from "./payments/buyer-payment-manager.js";
|
|
30
|
+
import { SellerPaymentManager } from "./payments/seller-payment-manager.js";
|
|
29
31
|
import { identityToEvmAddress } from "./payments/evm/keypair.js";
|
|
32
|
+
import { IdentityClient } from "./payments/evm/identity-client.js";
|
|
33
|
+
import { verifyReputation } from "./discovery/reputation-verifier.js";
|
|
34
|
+
/**
|
|
35
|
+
* Extract actual token usage from an LLM provider response body.
|
|
36
|
+
* Handles both JSON and SSE (streaming) responses. Returns zeros
|
|
37
|
+
* if usage data is not found (caller should fall back to estimation).
|
|
38
|
+
*/
|
|
39
|
+
function parseResponseUsage(body) {
|
|
40
|
+
const parsed = parseJsonObject(body);
|
|
41
|
+
if (parsed) {
|
|
42
|
+
return extractUsage(parsed);
|
|
43
|
+
}
|
|
44
|
+
// SSE streaming: scan data lines for a usage object
|
|
45
|
+
const text = new TextDecoder().decode(body);
|
|
46
|
+
let inputTokens = 0;
|
|
47
|
+
let outputTokens = 0;
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (!trimmed.startsWith('data:'))
|
|
51
|
+
continue;
|
|
52
|
+
const payload = trimmed.slice(5).trim();
|
|
53
|
+
if (!payload || payload === '[DONE]')
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const event = JSON.parse(payload);
|
|
57
|
+
const usage = extractUsage(event);
|
|
58
|
+
if (usage.inputTokens > 0)
|
|
59
|
+
inputTokens = Math.max(inputTokens, usage.inputTokens);
|
|
60
|
+
if (usage.outputTokens > 0)
|
|
61
|
+
outputTokens = Math.max(outputTokens, usage.outputTokens);
|
|
62
|
+
}
|
|
63
|
+
catch { /* skip non-JSON lines */ }
|
|
64
|
+
}
|
|
65
|
+
return { inputTokens, outputTokens };
|
|
66
|
+
}
|
|
67
|
+
function parsePaymentRequiredBody(body) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(new TextDecoder().decode(body));
|
|
70
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
30
76
|
export class AntseedNode extends EventEmitter {
|
|
31
77
|
static _METADATA_REFRESH_DEBOUNCE_MS = 200;
|
|
32
78
|
_config;
|
|
@@ -46,6 +92,7 @@ export class AntseedNode extends EventEmitter {
|
|
|
46
92
|
_receiptGenerator = null;
|
|
47
93
|
_balanceManager = null;
|
|
48
94
|
_escrowClient = null;
|
|
95
|
+
_identityClient = null;
|
|
49
96
|
_paymentMuxes = new Map();
|
|
50
97
|
_providerLoadCounts = new Map();
|
|
51
98
|
_metadataRefreshTimer = null;
|
|
@@ -54,8 +101,23 @@ export class AntseedNode extends EventEmitter {
|
|
|
54
101
|
_settlementTimers = new Map();
|
|
55
102
|
/** Buyer-side payment manager (initialized when buyer has payment config). */
|
|
56
103
|
_buyerPaymentManager = null;
|
|
57
|
-
/**
|
|
104
|
+
/** Seller-side payment manager (initialized when seller has payment config). */
|
|
105
|
+
_sellerPaymentManager = null;
|
|
106
|
+
/** Shared session store for payment persistence. */
|
|
107
|
+
_sessionStore = null;
|
|
108
|
+
/** Periodic timeout checker interval. */
|
|
109
|
+
_timeoutCheckerInterval = null;
|
|
110
|
+
/** Tracks which seller peers the buyer has already negotiated payment for. */
|
|
58
111
|
_buyerLockedPeers = new Set();
|
|
112
|
+
/** Pending PaymentRequired payloads from sellers, keyed by peerId. Resolvers waiting for them. */
|
|
113
|
+
_pendingPaymentRequired = new Map();
|
|
114
|
+
_manualApprovalCache = null;
|
|
115
|
+
/** Buffered PaymentRequired that arrived before _doNegotiatePayment registered its listener.
|
|
116
|
+
* This handles the race where 402 + PaymentRequired arrive in the same I/O tick. */
|
|
117
|
+
_bufferedPaymentRequired = new Map();
|
|
118
|
+
/** Per-peer mutex to prevent concurrent payment negotiations. */
|
|
119
|
+
_paymentNegotiationLocks = new Map();
|
|
120
|
+
/** Peers the caller has manually approved by retrying after a 402. */
|
|
59
121
|
constructor(config) {
|
|
60
122
|
super();
|
|
61
123
|
this._config = config;
|
|
@@ -108,10 +170,6 @@ export class AntseedNode extends EventEmitter {
|
|
|
108
170
|
totalTokens: session.totalTokens,
|
|
109
171
|
avgLatencyMs: session.totalRequests > 0 ? session.totalLatencyMs / session.totalRequests : 0,
|
|
110
172
|
settling: Boolean(session.settling),
|
|
111
|
-
lockCommitted: session.lockCommitted,
|
|
112
|
-
lockedAmountUSDC: session.lockedAmount.toString(),
|
|
113
|
-
runningTotalUSDC: session.runningTotal.toString(),
|
|
114
|
-
ackedRequestCount: session.ackedRequestCount,
|
|
115
173
|
});
|
|
116
174
|
}
|
|
117
175
|
return snapshots;
|
|
@@ -132,7 +190,7 @@ export class AntseedNode extends EventEmitter {
|
|
|
132
190
|
}
|
|
133
191
|
const dataDir = this._config.dataDir ?? join(homedir(), ".antseed");
|
|
134
192
|
// Load or create identity
|
|
135
|
-
this._identity = await loadOrCreateIdentity(dataDir);
|
|
193
|
+
this._identity = await loadOrCreateIdentity(this._config.identityStore ?? dataDir);
|
|
136
194
|
debugLog(`[Node] Identity loaded: ${this._identity.peerId.slice(0, 12)}...`);
|
|
137
195
|
// Determine bootstrap nodes — merge official + any user-configured nodes unless
|
|
138
196
|
// noOfficialBootstrap is set (e.g. isolated local testing).
|
|
@@ -213,12 +271,35 @@ export class AntseedNode extends EventEmitter {
|
|
|
213
271
|
}
|
|
214
272
|
this._metering = null;
|
|
215
273
|
}
|
|
274
|
+
if (this._timeoutCheckerInterval) {
|
|
275
|
+
clearInterval(this._timeoutCheckerInterval);
|
|
276
|
+
this._timeoutCheckerInterval = null;
|
|
277
|
+
}
|
|
278
|
+
if (this._sessionStore) {
|
|
279
|
+
try {
|
|
280
|
+
this._sessionStore.close();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// ignore close errors
|
|
284
|
+
}
|
|
285
|
+
this._sessionStore = null;
|
|
286
|
+
}
|
|
216
287
|
this._peerLookup = null;
|
|
217
288
|
this._receiptGenerator = null;
|
|
218
289
|
this._balanceManager = null;
|
|
219
290
|
this._escrowClient = null;
|
|
291
|
+
this._identityClient = null;
|
|
220
292
|
this._buyerPaymentManager = null;
|
|
293
|
+
this._sellerPaymentManager = null;
|
|
221
294
|
this._buyerLockedPeers.clear();
|
|
295
|
+
// Clean up payment negotiation state
|
|
296
|
+
for (const [, pending] of this._pendingPaymentRequired) {
|
|
297
|
+
clearTimeout(pending.timer);
|
|
298
|
+
pending.reject(new Error('Node stopped'));
|
|
299
|
+
}
|
|
300
|
+
this._pendingPaymentRequired.clear();
|
|
301
|
+
this._bufferedPaymentRequired.clear();
|
|
302
|
+
this._paymentNegotiationLocks.clear();
|
|
222
303
|
this._started = false;
|
|
223
304
|
this.emit("stopped");
|
|
224
305
|
}
|
|
@@ -248,19 +329,30 @@ export class AntseedNode extends EventEmitter {
|
|
|
248
329
|
}
|
|
249
330
|
}
|
|
250
331
|
// Optional reputation verification: replace claimed data with verified on-chain data
|
|
251
|
-
if (this.
|
|
332
|
+
if (this._identityClient) {
|
|
252
333
|
for (const p of peers) {
|
|
253
|
-
if (p.evmAddress
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
p.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
334
|
+
if (!p.evmAddress)
|
|
335
|
+
continue;
|
|
336
|
+
try {
|
|
337
|
+
const metadata = {
|
|
338
|
+
peerId: p.peerId,
|
|
339
|
+
version: 0,
|
|
340
|
+
providers: [],
|
|
341
|
+
region: "",
|
|
342
|
+
timestamp: 0,
|
|
343
|
+
signature: "",
|
|
344
|
+
evmAddress: p.evmAddress,
|
|
345
|
+
onChainReputation: p.onChainReputation,
|
|
346
|
+
onChainSessionCount: p.onChainSessionCount,
|
|
347
|
+
onChainDisputeCount: p.onChainDisputeCount,
|
|
348
|
+
};
|
|
349
|
+
const result = await verifyReputation(this._identityClient, metadata);
|
|
350
|
+
p.onChainReputation = result.actualReputation;
|
|
351
|
+
p.onChainSessionCount = result.actualSessionCount;
|
|
352
|
+
p.onChainDisputeCount = result.actualDisputeCount;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// Identity contract lookup failed for this peer — keep claimed data
|
|
264
356
|
}
|
|
265
357
|
}
|
|
266
358
|
}
|
|
@@ -295,12 +387,22 @@ export class AntseedNode extends EventEmitter {
|
|
|
295
387
|
const conn = await this._getOrCreateConnection(peer);
|
|
296
388
|
debugLog(`[Node] Connection to ${peer.peerId.slice(0, 12)}... state=${conn.state}`);
|
|
297
389
|
const mux = this._getOrCreateMux(peer.peerId, conn);
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
390
|
+
// Extract and strip x-antseed-spending-auth header if present (manual approval flow)
|
|
391
|
+
const externalSpendingAuth = req.headers[ANTSEED_SPENDING_AUTH_HEADER] ?? null;
|
|
392
|
+
if (externalSpendingAuth) {
|
|
393
|
+
const { [ANTSEED_SPENDING_AUTH_HEADER]: _, ...cleanHeaders } = req.headers;
|
|
394
|
+
req = { ...req, headers: cleanHeaders };
|
|
395
|
+
}
|
|
396
|
+
// If we already have a payment session with this peer, skip negotiation.
|
|
397
|
+
const needsPaymentNegotiation = this._buyerPaymentManager
|
|
398
|
+
&& !this._buyerLockedPeers.has(peer.peerId);
|
|
399
|
+
// If an external spending auth was provided, apply it before sending the request.
|
|
400
|
+
if (externalSpendingAuth && needsPaymentNegotiation) {
|
|
401
|
+
debugLog(`[Node] Applying external spending auth for ${peer.peerId.slice(0, 12)}...`);
|
|
402
|
+
await this._applyExternalSpendingAuth(peer, conn, externalSpendingAuth);
|
|
301
403
|
}
|
|
302
404
|
const startTime = Date.now();
|
|
303
|
-
|
|
405
|
+
const executeRequest = () => new Promise((resolve, reject) => {
|
|
304
406
|
const timeoutMs = this._config.requestTimeoutMs ?? 30_000;
|
|
305
407
|
const maxStreamBufferBytes = Math.max(1, this._config.maxStreamBufferBytes ?? 16 * 1024 * 1024);
|
|
306
408
|
const maxStreamDurationMs = Math.max(1, this._config.maxStreamDurationMs ?? 5 * 60_000);
|
|
@@ -473,6 +575,78 @@ export class AntseedNode extends EventEmitter {
|
|
|
473
575
|
});
|
|
474
576
|
});
|
|
475
577
|
});
|
|
578
|
+
// Execute the request. If we get a 402 and payment negotiation is needed,
|
|
579
|
+
// wait for the seller's PaymentRequired message, negotiate, and retry.
|
|
580
|
+
const response = await executeRequest();
|
|
581
|
+
if (response.statusCode === 402 && needsPaymentNegotiation && !externalSpendingAuth) {
|
|
582
|
+
const manualApproval = await this._isManualApprovalEnabled();
|
|
583
|
+
const directPaymentBody = parsePaymentRequiredBody(response.body);
|
|
584
|
+
const responseAlreadyHasRequirements = Boolean(directPaymentBody?.sellerEvmAddr);
|
|
585
|
+
const waitMs = manualApproval ? 10_000 : 2_000;
|
|
586
|
+
const buffered = responseAlreadyHasRequirements
|
|
587
|
+
? null
|
|
588
|
+
: await this._awaitPaymentRequired(peer.peerId, conn, waitMs);
|
|
589
|
+
if (buffered)
|
|
590
|
+
this._bufferedPaymentRequired.delete(peer.peerId);
|
|
591
|
+
// Helper: return enriched 402 so the caller can show an approval / add-credits card
|
|
592
|
+
const returnPaymentRequired = (reason) => {
|
|
593
|
+
debugLog(`[Node] Got 402 from ${peer.peerId.slice(0, 12)}... — returning to caller (${reason})`);
|
|
594
|
+
if (responseAlreadyHasRequirements) {
|
|
595
|
+
return response;
|
|
596
|
+
}
|
|
597
|
+
if (buffered) {
|
|
598
|
+
const enrichedBody = JSON.stringify({
|
|
599
|
+
error: 'payment_required',
|
|
600
|
+
peerId: peer.peerId,
|
|
601
|
+
sellerEvmAddr: buffered.sellerEvmAddr,
|
|
602
|
+
tokenRate: buffered.tokenRate,
|
|
603
|
+
firstSignCap: buffered.firstSignCap,
|
|
604
|
+
suggestedAmount: buffered.suggestedAmount,
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
...response,
|
|
608
|
+
headers: { ...response.headers, 'content-type': 'application/json' },
|
|
609
|
+
body: new TextEncoder().encode(enrichedBody),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
return response;
|
|
613
|
+
};
|
|
614
|
+
// If manual approval is on, always return the 402 to the caller
|
|
615
|
+
if (manualApproval) {
|
|
616
|
+
return returnPaymentRequired(responseAlreadyHasRequirements ? 'manual approval (direct body)' : 'manual approval');
|
|
617
|
+
}
|
|
618
|
+
// Auto mode: check if we can actually pay before attempting negotiation
|
|
619
|
+
if (!this._escrowClient || !this._identity || !this._buyerPaymentManager) {
|
|
620
|
+
return returnPaymentRequired('no escrow configured');
|
|
621
|
+
}
|
|
622
|
+
// Check on-chain balance — if insufficient, return 402 instead of failing mid-negotiate
|
|
623
|
+
try {
|
|
624
|
+
const buyerAddr = identityToEvmAddress(this._identity);
|
|
625
|
+
const balance = await this._escrowClient.getBuyerBalance(buyerAddr);
|
|
626
|
+
if (balance.available <= 0n) {
|
|
627
|
+
return returnPaymentRequired('insufficient credits');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
debugWarn(`[Node] Failed to check buyer balance: ${err instanceof Error ? err.message : err}`);
|
|
632
|
+
// Fall through to negotiate — let it fail naturally if balance is truly insufficient
|
|
633
|
+
}
|
|
634
|
+
// Auto-negotiate: sign SpendingAuth internally and retry
|
|
635
|
+
// Re-buffer the PaymentRequired so _doNegotiatePayment can consume it
|
|
636
|
+
if (buffered)
|
|
637
|
+
this._bufferedPaymentRequired.set(peer.peerId, buffered);
|
|
638
|
+
debugLog(`[Node] Got 402 from ${peer.peerId.slice(0, 12)}... — auto-negotiating payment`);
|
|
639
|
+
try {
|
|
640
|
+
await this._negotiatePayment(peer, conn);
|
|
641
|
+
debugLog(`[Node] Payment negotiated with ${peer.peerId.slice(0, 12)}... — retrying request`);
|
|
642
|
+
return executeRequest();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
this._buyerLockedPeers.delete(peer.peerId);
|
|
646
|
+
throw err;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return response;
|
|
476
650
|
}
|
|
477
651
|
_createDHTConfig(port, bootstrapNodes) {
|
|
478
652
|
return {
|
|
@@ -545,8 +719,23 @@ export class AntseedNode extends EventEmitter {
|
|
|
545
719
|
this._muxes.get(peerId)?.abortPendingUploads();
|
|
546
720
|
this._muxes.delete(peerId);
|
|
547
721
|
this._paymentMuxes.delete(peerId);
|
|
722
|
+
this._bufferedPaymentRequired.delete(peerId);
|
|
723
|
+
// Cancel any in-flight PaymentRequired wait so _doNegotiatePayment
|
|
724
|
+
// fails immediately instead of blocking for 10s on a dead connection.
|
|
725
|
+
const pendingPR = this._pendingPaymentRequired.get(peerId);
|
|
726
|
+
if (pendingPR) {
|
|
727
|
+
clearTimeout(pendingPR.timer);
|
|
728
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
729
|
+
pendingPR.reject(new Error(`Peer ${peerId.slice(0, 12)}... disconnected during payment negotiation`));
|
|
730
|
+
}
|
|
731
|
+
// Don't delete _paymentNegotiationLocks here — the pending rejection
|
|
732
|
+
// causes _doNegotiatePayment to throw, and its finally block owns cleanup.
|
|
733
|
+
// Deleting here would race with a new negotiation started on reconnect.
|
|
548
734
|
this._decoders.delete(peerId);
|
|
549
|
-
// Handle buyer disconnect
|
|
735
|
+
// Handle buyer disconnect
|
|
736
|
+
if (this._sellerPaymentManager) {
|
|
737
|
+
this._sellerPaymentManager.onBuyerDisconnect(peerId);
|
|
738
|
+
}
|
|
550
739
|
void this._finalizeSession(peerId, "disconnect");
|
|
551
740
|
}
|
|
552
741
|
});
|
|
@@ -654,6 +843,7 @@ export class AntseedNode extends EventEmitter {
|
|
|
654
843
|
])),
|
|
655
844
|
reannounceIntervalMs: DEFAULT_DHT_CONFIG.reannounceIntervalMs,
|
|
656
845
|
signalingPort: actualSignalingPort,
|
|
846
|
+
...(this._identityClient ? { identityClient: this._identityClient } : {}),
|
|
657
847
|
};
|
|
658
848
|
this._announcer = new PeerAnnouncer(announcerConfig);
|
|
659
849
|
this._announcer.startPeriodicAnnounce();
|
|
@@ -670,6 +860,8 @@ export class AntseedNode extends EventEmitter {
|
|
|
670
860
|
const identity = this._identity;
|
|
671
861
|
const dhtPort = this._config.dhtPort ?? 0;
|
|
672
862
|
debugLog(`[Node] Starting buyer — DHT port=${dhtPort}`);
|
|
863
|
+
const dataDir = this._config.dataDir ?? join(homedir(), ".antseed");
|
|
864
|
+
await this._initializePayments(dataDir);
|
|
673
865
|
// Start DHT with ephemeral port
|
|
674
866
|
this._dht = new DHTNode(this._createDHTConfig(dhtPort, bootstrapNodes));
|
|
675
867
|
await this._dht.start();
|
|
@@ -693,14 +885,32 @@ export class AntseedNode extends EventEmitter {
|
|
|
693
885
|
// Initialize buyer-side payment manager if payments config is provided
|
|
694
886
|
const payments = this._config.payments;
|
|
695
887
|
if (payments?.enabled && payments.rpcUrl && payments.contractAddress && payments.usdcAddress) {
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
888
|
+
const paymentsDir = join(dataDir, "payments");
|
|
889
|
+
// Create shared SessionStore for both buyer and seller payment managers
|
|
890
|
+
if (!this._sessionStore) {
|
|
891
|
+
try {
|
|
892
|
+
this._sessionStore = new SessionStore(paymentsDir);
|
|
893
|
+
debugLog("[Node] SessionStore initialized (buyer)");
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
debugWarn(`[Node] SessionStore unavailable: ${err instanceof Error ? err.message : err}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (this._sessionStore) {
|
|
900
|
+
const buyerPaymentConfig = {
|
|
901
|
+
rpcUrl: payments.rpcUrl,
|
|
902
|
+
contractAddress: payments.contractAddress,
|
|
903
|
+
usdcAddress: payments.usdcAddress,
|
|
904
|
+
identityAddress: payments.identityAddress ?? '',
|
|
905
|
+
chainId: payments.chainId ?? 8453,
|
|
906
|
+
defaultMaxAmountUsdc: BigInt(payments.defaultMaxAmountUsdc ?? "1000000"),
|
|
907
|
+
defaultAuthDurationSecs: payments.defaultAuthDurationSecs ?? 90000, // Must exceed SETTLE_TIMEOUT (24h)
|
|
908
|
+
autoAck: payments.autoAck ?? true,
|
|
909
|
+
dataDir: paymentsDir,
|
|
910
|
+
};
|
|
911
|
+
this._buyerPaymentManager = new BuyerPaymentManager(identity, buyerPaymentConfig, this._sessionStore);
|
|
912
|
+
debugLog(`[Node] Buyer payment manager initialized (wallet=${identityToEvmAddress(identity).slice(0, 10)}... chainId=${buyerPaymentConfig.chainId} contract=${buyerPaymentConfig.contractAddress.slice(0, 10)}...)`);
|
|
913
|
+
}
|
|
704
914
|
}
|
|
705
915
|
debugLog(`[Node] Buyer ready — DHT running on port ${this._dht.getPort()}`);
|
|
706
916
|
}
|
|
@@ -710,32 +920,68 @@ export class AntseedNode extends EventEmitter {
|
|
|
710
920
|
const mux = new ProxyMux(conn);
|
|
711
921
|
// Create PaymentMux alongside ProxyMux (seller-side)
|
|
712
922
|
const paymentMux = new PaymentMux(conn);
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
923
|
+
if (this._sellerPaymentManager) {
|
|
924
|
+
const spm = this._sellerPaymentManager;
|
|
925
|
+
paymentMux.onSpendingAuth((payload) => {
|
|
926
|
+
void spm.handleSpendingAuth(buyerPeerId, payload.buyerEvmAddr, payload, paymentMux);
|
|
927
|
+
});
|
|
928
|
+
paymentMux.onBuyerAck((payload) => {
|
|
929
|
+
void spm.handleBuyerAck(buyerPeerId, payload);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
// No SellerPaymentManager — reject SpendingAuth to prevent
|
|
934
|
+
// accepting payment claims without EIP-712 signature verification
|
|
935
|
+
paymentMux.onSpendingAuth(() => {
|
|
936
|
+
debugWarn(`[Node] SpendingAuth rejected — SellerPaymentManager not configured`);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
725
939
|
this._paymentMuxes.set(buyerPeerId, paymentMux);
|
|
726
940
|
// Register the ProxyMux request handler that routes to providers
|
|
727
941
|
mux.onProxyRequest(async (request) => {
|
|
728
942
|
debugLog(`[Node] Seller received request: ${request.method} ${request.path} (reqId=${request.requestId.slice(0, 8)})`);
|
|
729
|
-
// Reject with 402 if
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
943
|
+
// Reject with 402 if no active payment session and escrow client is configured.
|
|
944
|
+
// Also send PaymentRequired via PaymentMux so the buyer knows what to sign.
|
|
945
|
+
const spmAuthorized = this._sellerPaymentManager?.hasSession(buyerPeerId) ?? false;
|
|
946
|
+
if (this._escrowClient && !spmAuthorized) {
|
|
947
|
+
// Pass buyerPeerId so seller can suggest higher amount for returning buyers,
|
|
948
|
+
// and include per-direction pricing from the first registered provider.
|
|
949
|
+
// Retry init if it failed at startup (e.g. RPC was unreachable)
|
|
950
|
+
await this._sellerPaymentManager?.ensureInitialized();
|
|
951
|
+
const firstProvider = this._providers[0];
|
|
952
|
+
const providerPricing = firstProvider?.pricing?.defaults;
|
|
953
|
+
const requirements = this._sellerPaymentManager?.getPaymentRequirements(request.requestId, buyerPeerId, providerPricing);
|
|
954
|
+
if (requirements) {
|
|
955
|
+
debugLog(`[Node] No payment session for ${buyerPeerId.slice(0, 12)}... — sending 402 + PaymentRequired`);
|
|
956
|
+
const paymentBody = JSON.stringify({
|
|
957
|
+
error: 'payment_required',
|
|
958
|
+
sellerEvmAddr: requirements.sellerEvmAddr,
|
|
959
|
+
tokenRate: requirements.tokenRate,
|
|
960
|
+
firstSignCap: requirements.firstSignCap,
|
|
961
|
+
suggestedAmount: requirements.suggestedAmount,
|
|
962
|
+
});
|
|
963
|
+
mux.sendProxyResponse({
|
|
964
|
+
requestId: request.requestId,
|
|
965
|
+
statusCode: 402,
|
|
966
|
+
headers: { "content-type": "application/json" },
|
|
967
|
+
body: new TextEncoder().encode(paymentBody),
|
|
968
|
+
});
|
|
969
|
+
paymentMux.sendPaymentRequired(requirements);
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
// init() failed — on-chain data not cached. Tell buyer to retry
|
|
973
|
+
// rather than letting them wait 10s for a PaymentRequired that won't come.
|
|
974
|
+
debugWarn(`[Node] No payment session and seller not ready (init failed?) — returning 402`);
|
|
975
|
+
mux.sendProxyResponse({
|
|
976
|
+
requestId: request.requestId,
|
|
977
|
+
statusCode: 402,
|
|
978
|
+
headers: { "content-type": "application/json" },
|
|
979
|
+
body: new TextEncoder().encode(JSON.stringify({
|
|
980
|
+
error: 'payment_required',
|
|
981
|
+
message: 'Seller not ready, try again later',
|
|
982
|
+
})),
|
|
983
|
+
});
|
|
984
|
+
}
|
|
739
985
|
return;
|
|
740
986
|
}
|
|
741
987
|
const requestedService = this._extractRequestedService(request);
|
|
@@ -820,11 +1066,13 @@ export class AntseedNode extends EventEmitter {
|
|
|
820
1066
|
// Record metering
|
|
821
1067
|
const latencyMs = Date.now() - startTime;
|
|
822
1068
|
const requestPricing = this._resolveProviderPricing(provider, request);
|
|
823
|
-
await this._recordMetering(buyerPeerId, provider.name, requestPricing, request, statusCode, latencyMs, request.body.length, responseBody.length);
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1069
|
+
await this._recordMetering(buyerPeerId, provider.name, requestPricing, request, statusCode, latencyMs, request.body.length, responseBody.length, responseBody);
|
|
1070
|
+
// Send receipt to buyer after each request
|
|
1071
|
+
if (this._sellerPaymentManager?.hasSession(buyerPeerId)) {
|
|
1072
|
+
const usage = parseResponseUsage(responseBody);
|
|
1073
|
+
const totalTokens = usage.inputTokens + usage.outputTokens;
|
|
1074
|
+
debugLog(`[Node] Sending receipt: buyer=${buyerPeerId.slice(0, 12)}... tokens=${totalTokens} (in=${usage.inputTokens} out=${usage.outputTokens})`);
|
|
1075
|
+
await this._sellerPaymentManager.sendReceipt(buyerPeerId, paymentMux, responseBody, BigInt(totalTokens));
|
|
828
1076
|
}
|
|
829
1077
|
}
|
|
830
1078
|
finally {
|
|
@@ -912,13 +1160,6 @@ export class AntseedNode extends EventEmitter {
|
|
|
912
1160
|
totalLatencyMs: 0,
|
|
913
1161
|
totalCostCents: 0,
|
|
914
1162
|
provider: providerName,
|
|
915
|
-
lockCommitted: false,
|
|
916
|
-
lockedAmount: 0n,
|
|
917
|
-
runningTotal: 0n,
|
|
918
|
-
ackedRequestCount: 0,
|
|
919
|
-
lastAckedTotal: 0n,
|
|
920
|
-
awaitingAck: false,
|
|
921
|
-
buyerEvmAddress: null,
|
|
922
1163
|
};
|
|
923
1164
|
this._sessions.set(buyerPeerId, session);
|
|
924
1165
|
}
|
|
@@ -941,17 +1182,36 @@ export class AntseedNode extends EventEmitter {
|
|
|
941
1182
|
});
|
|
942
1183
|
}
|
|
943
1184
|
/** Estimate tokens from byte lengths (rough: ~4 chars per token). */
|
|
1185
|
+
/** Fallback token estimation from byte lengths (~4 bytes per token). */
|
|
944
1186
|
_estimateTokens(inputBytes, outputBytes) {
|
|
945
1187
|
const inputTokens = Math.max(1, Math.round(inputBytes / 4));
|
|
946
1188
|
const outputTokens = Math.max(1, Math.round(outputBytes / 4));
|
|
947
|
-
return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
|
|
1189
|
+
return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, method: 'content-length', confidence: 'low' };
|
|
948
1190
|
}
|
|
949
|
-
async _recordMetering(buyerPeerId, providerName, providerPricingUsdPerMillion, request, statusCode, latencyMs, inputBytes, outputBytes) {
|
|
1191
|
+
async _recordMetering(buyerPeerId, providerName, providerPricingUsdPerMillion, request, statusCode, latencyMs, inputBytes, outputBytes, responseBody) {
|
|
950
1192
|
if (!this._identity)
|
|
951
1193
|
return;
|
|
952
1194
|
const sellerPeerId = this._identity.peerId;
|
|
953
1195
|
const isSSE = request.headers["accept"]?.includes("text/event-stream") ?? false;
|
|
954
|
-
|
|
1196
|
+
// Use actual token counts from provider response when available,
|
|
1197
|
+
// falling back to byte-based estimation.
|
|
1198
|
+
const providerUsage = parseResponseUsage(responseBody);
|
|
1199
|
+
let tokens;
|
|
1200
|
+
if (providerUsage.inputTokens > 0 || providerUsage.outputTokens > 0) {
|
|
1201
|
+
const totalTokens = providerUsage.inputTokens + providerUsage.outputTokens;
|
|
1202
|
+
tokens = {
|
|
1203
|
+
inputTokens: providerUsage.inputTokens,
|
|
1204
|
+
outputTokens: providerUsage.outputTokens,
|
|
1205
|
+
totalTokens,
|
|
1206
|
+
method: 'provider-usage',
|
|
1207
|
+
confidence: 'high',
|
|
1208
|
+
};
|
|
1209
|
+
debugLog(`[Node] Metering: provider-usage tokens=${totalTokens} (in=${providerUsage.inputTokens} out=${providerUsage.outputTokens})`);
|
|
1210
|
+
}
|
|
1211
|
+
else {
|
|
1212
|
+
tokens = this._estimateTokens(inputBytes, outputBytes);
|
|
1213
|
+
debugLog(`[Node] Metering: estimated tokens=${tokens.totalTokens} from ${inputBytes}+${outputBytes} bytes`);
|
|
1214
|
+
}
|
|
955
1215
|
// Get or create session for this buyer
|
|
956
1216
|
const session = this._getOrCreateSellerSession(buyerPeerId, providerName);
|
|
957
1217
|
if (!session)
|
|
@@ -1039,11 +1299,54 @@ export class AntseedNode extends EventEmitter {
|
|
|
1039
1299
|
});
|
|
1040
1300
|
debugLog(`[Node] BaseEscrowClient initialized (contract=${payments.contractAddress.slice(0, 10)}...)`);
|
|
1041
1301
|
}
|
|
1302
|
+
// Initialize IdentityClient if identity contract address is provided
|
|
1303
|
+
if (payments.rpcUrl && payments.identityAddress) {
|
|
1304
|
+
this._identityClient = new IdentityClient({
|
|
1305
|
+
rpcUrl: payments.rpcUrl,
|
|
1306
|
+
contractAddress: payments.identityAddress,
|
|
1307
|
+
});
|
|
1308
|
+
debugLog(`[Node] IdentityClient initialized (contract=${payments.identityAddress.slice(0, 10)}...)`);
|
|
1309
|
+
}
|
|
1310
|
+
// Initialize SessionStore for persistent payment sessions (shared instance)
|
|
1311
|
+
const paymentsDir = join(dataDir, "payments");
|
|
1312
|
+
if (!this._sessionStore) {
|
|
1313
|
+
try {
|
|
1314
|
+
this._sessionStore = new SessionStore(paymentsDir);
|
|
1315
|
+
debugLog("[Node] SessionStore initialized");
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
debugWarn(`[Node] SessionStore unavailable: ${err instanceof Error ? err.message : err}`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
// Initialize SellerPaymentManager for seller role
|
|
1322
|
+
if (this._config.role === 'seller' && this._identity && this._sessionStore &&
|
|
1323
|
+
payments.rpcUrl && payments.contractAddress && payments.usdcAddress) {
|
|
1324
|
+
const sellerConfig = {
|
|
1325
|
+
rpcUrl: payments.rpcUrl,
|
|
1326
|
+
contractAddress: payments.contractAddress,
|
|
1327
|
+
usdcAddress: payments.usdcAddress,
|
|
1328
|
+
chainId: payments.chainId ?? 8453,
|
|
1329
|
+
dataDir: paymentsDir,
|
|
1330
|
+
firstSignAmountUsdc: payments.firstSignAmountUsdc,
|
|
1331
|
+
provenSignAmountUsdc: payments.provenSignAmountUsdc,
|
|
1332
|
+
};
|
|
1333
|
+
this._sellerPaymentManager = new SellerPaymentManager(this._identity, sellerConfig, this._sessionStore);
|
|
1334
|
+
await this._sellerPaymentManager.init();
|
|
1335
|
+
debugLog(`[Node] SellerPaymentManager initialized`);
|
|
1336
|
+
// Startup recovery: check for timed-out sessions
|
|
1337
|
+
await this._sellerPaymentManager.checkTimeouts();
|
|
1338
|
+
// Start periodic timeout checker (every 60s)
|
|
1339
|
+
this._timeoutCheckerInterval = setInterval(() => {
|
|
1340
|
+
void this._sellerPaymentManager?.checkTimeouts();
|
|
1341
|
+
}, 60_000);
|
|
1342
|
+
if (typeof this._timeoutCheckerInterval.unref === "function") {
|
|
1343
|
+
this._timeoutCheckerInterval.unref();
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1042
1346
|
if (!this._metering) {
|
|
1043
1347
|
debugWarn("[Node] Payments enabled but metering storage is unavailable; skipping balance manager wiring");
|
|
1044
1348
|
return;
|
|
1045
1349
|
}
|
|
1046
|
-
const paymentsDir = join(dataDir, "payments");
|
|
1047
1350
|
this._balanceManager = new BalanceManager();
|
|
1048
1351
|
await this._balanceManager.load(paymentsDir).catch((err) => {
|
|
1049
1352
|
debugWarn(`[Node] Failed to load payment balances: ${err instanceof Error ? err.message : err}`);
|
|
@@ -1074,37 +1377,6 @@ export class AntseedNode extends EventEmitter {
|
|
|
1074
1377
|
clearTimeout(timer);
|
|
1075
1378
|
this._settlementTimers.delete(buyerPeerId);
|
|
1076
1379
|
}
|
|
1077
|
-
// Bilateral-aware disconnect handling (ghost scenario - Task 7)
|
|
1078
|
-
if (session.lockCommitted && this._escrowClient && this._identity && reason === "disconnect") {
|
|
1079
|
-
const sellerWallet = identityToEvmWallet(this._identity);
|
|
1080
|
-
const sessionIdHex = "0x" + bytesToHex(session.sessionIdBytes);
|
|
1081
|
-
try {
|
|
1082
|
-
if (session.lastAckedTotal > 0n) {
|
|
1083
|
-
// Buyer acked some work — open dispute with last acked total
|
|
1084
|
-
debugLog(`[Node] Ghost buyer — opening dispute with lastAckedTotal=${session.lastAckedTotal}`);
|
|
1085
|
-
await this._escrowClient.openDispute(sellerWallet, sessionIdHex, session.lastAckedTotal);
|
|
1086
|
-
}
|
|
1087
|
-
else if (session.runningTotal > 0n) {
|
|
1088
|
-
// No acks but work was done — open dispute with running total
|
|
1089
|
-
debugLog(`[Node] Ghost buyer — opening dispute with runningTotal=${session.runningTotal}`);
|
|
1090
|
-
await this._escrowClient.openDispute(sellerWallet, sessionIdHex, session.runningTotal);
|
|
1091
|
-
}
|
|
1092
|
-
else {
|
|
1093
|
-
// No work done — lock expires after 1 hour automatically
|
|
1094
|
-
debugLog(`[Node] Ghost buyer — no work done, lock will expire`);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
catch (err) {
|
|
1098
|
-
debugWarn(`[Node] Failed to handle ghost buyer for session ${session.sessionId}: ${err instanceof Error ? err.message : err}`);
|
|
1099
|
-
}
|
|
1100
|
-
this._sessions.delete(buyerPeerId);
|
|
1101
|
-
this.emit("session:finalized", {
|
|
1102
|
-
buyerPeerId,
|
|
1103
|
-
sessionId: session.sessionId,
|
|
1104
|
-
reason: "ghost-disconnect",
|
|
1105
|
-
});
|
|
1106
|
-
return;
|
|
1107
|
-
}
|
|
1108
1380
|
if (!this._metering || !this._identity) {
|
|
1109
1381
|
this._sessions.delete(buyerPeerId);
|
|
1110
1382
|
return;
|
|
@@ -1235,249 +1507,300 @@ export class AntseedNode extends EventEmitter {
|
|
|
1235
1507
|
this._muxes.set(peerId, mux);
|
|
1236
1508
|
return mux;
|
|
1237
1509
|
}
|
|
1238
|
-
// ──
|
|
1510
|
+
// ── Buyer-side payment helpers ─────────────────────────────────
|
|
1239
1511
|
/**
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1512
|
+
* Create a PaymentMux for a buyer-side outbound connection and register
|
|
1513
|
+
* buyer-side handlers (lock confirm, lock reject, seller receipt, top-up request).
|
|
1242
1514
|
*/
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1515
|
+
_getOrCreateBuyerPaymentMux(peerId, conn) {
|
|
1516
|
+
const existing = this._paymentMuxes.get(peerId);
|
|
1517
|
+
if (existing)
|
|
1518
|
+
return existing;
|
|
1519
|
+
const pmux = new PaymentMux(conn);
|
|
1520
|
+
this._paymentMuxes.set(peerId, pmux);
|
|
1521
|
+
const bpm = this._buyerPaymentManager;
|
|
1522
|
+
if (!bpm)
|
|
1523
|
+
return pmux;
|
|
1524
|
+
pmux.onAuthAck((payload) => {
|
|
1525
|
+
bpm.handleAuthAck(peerId, payload);
|
|
1526
|
+
});
|
|
1527
|
+
pmux.onSellerReceipt((receipt) => {
|
|
1528
|
+
void bpm.handleSellerReceipt(peerId, receipt, pmux);
|
|
1529
|
+
});
|
|
1530
|
+
pmux.onTopUpRequest((request) => {
|
|
1531
|
+
void bpm.handleTopUpRequest(peerId, request, pmux);
|
|
1532
|
+
});
|
|
1533
|
+
pmux.onPaymentRequired((payload) => {
|
|
1534
|
+
const pending = this._pendingPaymentRequired.get(peerId);
|
|
1535
|
+
if (pending) {
|
|
1536
|
+
clearTimeout(pending.timer);
|
|
1537
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1538
|
+
pending.resolve(payload);
|
|
1263
1539
|
}
|
|
1264
|
-
|
|
1265
|
-
//
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
session.lockedAmount = lockedAmount;
|
|
1270
|
-
session.runningTotal = 0n;
|
|
1271
|
-
session.ackedRequestCount = 0;
|
|
1272
|
-
session.lastAckedTotal = 0n;
|
|
1273
|
-
session.awaitingAck = false;
|
|
1274
|
-
session.buyerEvmAddress = buyerEvmAddress;
|
|
1540
|
+
else {
|
|
1541
|
+
// Buffer: 402 and PaymentRequired can arrive in the same I/O tick,
|
|
1542
|
+
// before _doNegotiatePayment registers its listener.
|
|
1543
|
+
this._bufferedPaymentRequired.set(peerId, payload);
|
|
1544
|
+
debugLog(`[Node] PaymentRequired from ${peerId.slice(0, 12)}... buffered (listener not yet registered)`);
|
|
1275
1545
|
}
|
|
1276
|
-
debugLog(`[Node] Lock committed for buyer ${buyerPeerId.slice(0, 12)}... amount=${lockedAmount} tx=${txHash.slice(0, 12)}...`);
|
|
1277
|
-
paymentMux.sendSessionLockConfirm({
|
|
1278
|
-
sessionId: payload.sessionId,
|
|
1279
|
-
txSignature: txHash,
|
|
1280
|
-
});
|
|
1281
|
-
}
|
|
1282
|
-
catch (err) {
|
|
1283
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
1284
|
-
debugWarn(`[Node] Failed to commit lock for ${buyerPeerId.slice(0, 12)}...: ${reason}`);
|
|
1285
|
-
paymentMux.sendSessionLockReject({
|
|
1286
|
-
sessionId: payload.sessionId,
|
|
1287
|
-
reason,
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Generate and send a bilateral receipt after processing a request (Task 3).
|
|
1293
|
-
*/
|
|
1294
|
-
async _sendBilateralReceipt(_buyerPeerId, session, providerPricingUsdPerMillion, responseBody, paymentMux) {
|
|
1295
|
-
if (!this._identity)
|
|
1296
|
-
return;
|
|
1297
|
-
// Calculate incremental cost in USDC base units (6 decimals)
|
|
1298
|
-
// Estimate tokens from response body size
|
|
1299
|
-
const tokens = this._estimateTokens(0, responseBody.length);
|
|
1300
|
-
const costUSD = (tokens.inputTokens * providerPricingUsdPerMillion.inputUsdPerMillion +
|
|
1301
|
-
tokens.outputTokens * providerPricingUsdPerMillion.outputUsdPerMillion) /
|
|
1302
|
-
1_000_000;
|
|
1303
|
-
const costBaseUnits = BigInt(Math.round(costUSD * 1_000_000));
|
|
1304
|
-
// Update running total
|
|
1305
|
-
session.runningTotal += costBaseUnits;
|
|
1306
|
-
// SHA-256 hash of response body for proof of work
|
|
1307
|
-
const responseHash = createHash("sha256").update(responseBody).digest();
|
|
1308
|
-
// Build receipt message and sign with Ed25519
|
|
1309
|
-
const receiptMsg = buildReceiptMessage(session.sessionIdBytes, session.runningTotal, session.totalRequests, new Uint8Array(responseHash));
|
|
1310
|
-
const sellerSig = await signMessageEd25519(this._identity, receiptMsg);
|
|
1311
|
-
paymentMux.sendSellerReceipt({
|
|
1312
|
-
sessionId: session.sessionId,
|
|
1313
|
-
runningTotal: session.runningTotal.toString(),
|
|
1314
|
-
requestCount: session.totalRequests,
|
|
1315
|
-
responseHash: bytesToHex(new Uint8Array(responseHash)),
|
|
1316
|
-
sellerSig: bytesToHex(sellerSig),
|
|
1317
1546
|
});
|
|
1318
|
-
|
|
1319
|
-
// Send TopUpRequest if running total > 80% of locked amount
|
|
1320
|
-
if (session.lockedAmount > 0n && session.runningTotal * 100n > session.lockedAmount * 80n) {
|
|
1321
|
-
const additionalAmount = session.lockedAmount; // Request same amount again
|
|
1322
|
-
paymentMux.sendTopUpRequest({
|
|
1323
|
-
sessionId: session.sessionId,
|
|
1324
|
-
additionalAmount: additionalAmount.toString(),
|
|
1325
|
-
currentRunningTotal: session.runningTotal.toString(),
|
|
1326
|
-
currentLockedAmount: session.lockedAmount.toString(),
|
|
1327
|
-
});
|
|
1328
|
-
debugLog(`[Node] TopUpRequest sent for session ${session.sessionId.slice(0, 8)}... (running=${session.runningTotal}, locked=${session.lockedAmount})`);
|
|
1329
|
-
}
|
|
1547
|
+
return pmux;
|
|
1330
1548
|
}
|
|
1331
1549
|
/**
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1550
|
+
* Wait for the seller's PaymentRequired message, sign a SpendingAuth with
|
|
1551
|
+
* the seller's real requirements, and wait for AuthAck.
|
|
1552
|
+
* Uses a per-peer mutex so concurrent requests wait for the first negotiation.
|
|
1334
1553
|
*/
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
return;
|
|
1554
|
+
/** Read requireManualApproval from config.json so changes take effect without restart. */
|
|
1555
|
+
async _isManualApprovalEnabled() {
|
|
1556
|
+
const now = Date.now();
|
|
1557
|
+
if (this._manualApprovalCache && now - this._manualApprovalCache.at < 5_000) {
|
|
1558
|
+
return this._manualApprovalCache.value;
|
|
1340
1559
|
}
|
|
1341
1560
|
try {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
const
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
}
|
|
1351
|
-
session.ackedRequestCount = payload.requestCount;
|
|
1352
|
-
session.lastAckedTotal = BigInt(payload.runningTotal);
|
|
1353
|
-
session.awaitingAck = false;
|
|
1354
|
-
debugLog(`[Node] BuyerAck received: requestCount=${payload.requestCount} runningTotal=${payload.runningTotal}`);
|
|
1561
|
+
const configPath = this._config.configPath
|
|
1562
|
+
?? join(this._config.dataDir ?? join(homedir(), '.antseed'), 'config.json');
|
|
1563
|
+
const raw = await readFile(configPath, 'utf-8');
|
|
1564
|
+
const parsed = JSON.parse(raw);
|
|
1565
|
+
const buyer = parsed.buyer && typeof parsed.buyer === 'object' ? parsed.buyer : {};
|
|
1566
|
+
const value = Boolean(buyer.requireManualApproval);
|
|
1567
|
+
this._manualApprovalCache = { value, at: now };
|
|
1568
|
+
return value;
|
|
1355
1569
|
}
|
|
1356
|
-
catch
|
|
1357
|
-
|
|
1570
|
+
catch {
|
|
1571
|
+
const value = Boolean(this._config.requireManualApproval);
|
|
1572
|
+
this._manualApprovalCache = { value, at: now };
|
|
1573
|
+
return value;
|
|
1358
1574
|
}
|
|
1359
1575
|
}
|
|
1360
1576
|
/**
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1577
|
+
* Wait for the seller's PaymentRequired payload for a given peer.
|
|
1578
|
+
* Returns a buffered payload immediately when available, otherwise waits up to timeoutMs.
|
|
1363
1579
|
*/
|
|
1364
|
-
async
|
|
1365
|
-
const
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
// Submit settlement on-chain with buyer's ECDSA signature and score
|
|
1378
|
-
const txHash = await this._escrowClient.settle(sellerWallet, sessionIdHex, BigInt(payload.runningTotal), payload.score, payload.buyerSig);
|
|
1379
|
-
debugLog(`[Node] Session settled on-chain: ${session.sessionId.slice(0, 8)}... tx=${txHash.slice(0, 12)}... score=${payload.score}`);
|
|
1380
|
-
// Clean up session
|
|
1381
|
-
this._sessions.delete(buyerPeerId);
|
|
1382
|
-
const timer = this._settlementTimers.get(buyerPeerId);
|
|
1383
|
-
if (timer) {
|
|
1384
|
-
clearTimeout(timer);
|
|
1385
|
-
this._settlementTimers.delete(buyerPeerId);
|
|
1580
|
+
async _awaitPaymentRequired(peerId, conn, timeoutMs) {
|
|
1581
|
+
const buffered = this._bufferedPaymentRequired.get(peerId);
|
|
1582
|
+
if (buffered) {
|
|
1583
|
+
return buffered;
|
|
1584
|
+
}
|
|
1585
|
+
// Ensure the buyer-side PaymentMux exists before waiting so incoming frames
|
|
1586
|
+
// are captured even when 402 and PaymentRequired arrive close together.
|
|
1587
|
+
this._getOrCreateBuyerPaymentMux(peerId, conn);
|
|
1588
|
+
return await new Promise((resolve) => {
|
|
1589
|
+
const already = this._bufferedPaymentRequired.get(peerId);
|
|
1590
|
+
if (already) {
|
|
1591
|
+
resolve(already);
|
|
1592
|
+
return;
|
|
1386
1593
|
}
|
|
1387
|
-
this.
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1594
|
+
const existing = this._pendingPaymentRequired.get(peerId);
|
|
1595
|
+
if (existing) {
|
|
1596
|
+
const wrapper = {
|
|
1597
|
+
resolve: (payload) => {
|
|
1598
|
+
clearTimeout(existing.timer);
|
|
1599
|
+
clearTimeout(wrapper.timer);
|
|
1600
|
+
if (this._pendingPaymentRequired.get(peerId) === wrapper) {
|
|
1601
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1602
|
+
}
|
|
1603
|
+
existing.resolve(payload);
|
|
1604
|
+
resolve(payload);
|
|
1605
|
+
},
|
|
1606
|
+
reject: (err) => {
|
|
1607
|
+
clearTimeout(existing.timer);
|
|
1608
|
+
clearTimeout(wrapper.timer);
|
|
1609
|
+
if (this._pendingPaymentRequired.get(peerId) === wrapper) {
|
|
1610
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1611
|
+
}
|
|
1612
|
+
existing.reject(err);
|
|
1613
|
+
resolve(null);
|
|
1614
|
+
},
|
|
1615
|
+
timer: setTimeout(() => {
|
|
1616
|
+
clearTimeout(existing.timer);
|
|
1617
|
+
if (this._pendingPaymentRequired.get(peerId) === wrapper) {
|
|
1618
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1619
|
+
}
|
|
1620
|
+
resolve(null);
|
|
1621
|
+
}, timeoutMs),
|
|
1622
|
+
};
|
|
1623
|
+
this._pendingPaymentRequired.set(peerId, wrapper);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const timer = setTimeout(() => {
|
|
1627
|
+
if (this._pendingPaymentRequired.get(peerId)?.timer === timer) {
|
|
1628
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1629
|
+
}
|
|
1630
|
+
resolve(null);
|
|
1631
|
+
}, timeoutMs);
|
|
1632
|
+
this._pendingPaymentRequired.set(peerId, {
|
|
1633
|
+
resolve: (payload) => {
|
|
1634
|
+
clearTimeout(timer);
|
|
1635
|
+
if (this._pendingPaymentRequired.get(peerId)?.timer === timer) {
|
|
1636
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1637
|
+
}
|
|
1638
|
+
resolve(payload);
|
|
1639
|
+
},
|
|
1640
|
+
reject: () => {
|
|
1641
|
+
clearTimeout(timer);
|
|
1642
|
+
if (this._pendingPaymentRequired.get(peerId)?.timer === timer) {
|
|
1643
|
+
this._pendingPaymentRequired.delete(peerId);
|
|
1644
|
+
}
|
|
1645
|
+
resolve(null);
|
|
1646
|
+
},
|
|
1647
|
+
timer,
|
|
1393
1648
|
});
|
|
1394
|
-
}
|
|
1395
|
-
catch (err) {
|
|
1396
|
-
debugWarn(`[Node] Failed to settle session ${session.sessionId}: ${err instanceof Error ? err.message : err}`);
|
|
1397
|
-
}
|
|
1649
|
+
});
|
|
1398
1650
|
}
|
|
1399
1651
|
/**
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1652
|
+
* Apply a pre-signed SpendingAuth from the x-antseed-spending-auth header.
|
|
1653
|
+
* Sends it to the seller via PaymentMux and waits for AuthAck.
|
|
1402
1654
|
*/
|
|
1403
|
-
async
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1655
|
+
async _applyExternalSpendingAuth(peer, conn, headerValue) {
|
|
1656
|
+
const pmux = this._getOrCreateBuyerPaymentMux(peer.peerId, conn);
|
|
1657
|
+
let payload;
|
|
1658
|
+
try {
|
|
1659
|
+
const decoded = Buffer.from(headerValue, 'base64').toString('utf-8');
|
|
1660
|
+
payload = JSON.parse(decoded);
|
|
1661
|
+
}
|
|
1662
|
+
catch {
|
|
1663
|
+
throw new Error('Invalid x-antseed-spending-auth header: failed to decode');
|
|
1408
1664
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1665
|
+
debugLog(`[Node] External SpendingAuth: session=${payload.sessionId.slice(0, 18)}... amount=${payload.maxAmountUsdc}`);
|
|
1666
|
+
// Store session so handleAuthAck can find it
|
|
1667
|
+
if (this._sessionStore) {
|
|
1668
|
+
const sellerEvmAddr = payload.sellerEvmAddr ?? '';
|
|
1669
|
+
this._sessionStore.upsertSession({
|
|
1670
|
+
sessionId: payload.sessionId,
|
|
1671
|
+
peerId: peer.peerId,
|
|
1672
|
+
role: 'buyer',
|
|
1673
|
+
sellerEvmAddr,
|
|
1674
|
+
buyerEvmAddr: payload.buyerEvmAddr,
|
|
1675
|
+
nonce: payload.nonce,
|
|
1676
|
+
authMax: payload.maxAmountUsdc,
|
|
1677
|
+
deadline: payload.deadline,
|
|
1678
|
+
previousSessionId: payload.previousSessionId,
|
|
1679
|
+
previousConsumption: payload.previousConsumption,
|
|
1680
|
+
tokensDelivered: '0',
|
|
1681
|
+
requestCount: 0,
|
|
1682
|
+
reservedAt: Date.now(),
|
|
1683
|
+
settledAt: null,
|
|
1684
|
+
settledAmount: null,
|
|
1685
|
+
status: 'active',
|
|
1686
|
+
createdAt: Date.now(),
|
|
1687
|
+
updatedAt: Date.now(),
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
// Send the pre-signed SpendingAuth to the seller
|
|
1691
|
+
pmux.sendSpendingAuth(payload);
|
|
1692
|
+
debugLog(`[Node] External SpendingAuth sent to seller ${peer.peerId.slice(0, 12)}..., waiting for AuthAck...`);
|
|
1693
|
+
await this._waitForLockConfirmation(peer.peerId);
|
|
1694
|
+
debugLog(`[Node] AuthAck received from seller ${peer.peerId.slice(0, 12)}...`);
|
|
1695
|
+
this._buyerLockedPeers.add(peer.peerId);
|
|
1696
|
+
this.emit('payment:signed', {
|
|
1697
|
+
peerId: peer.peerId,
|
|
1698
|
+
sellerEvmAddr: payload.sellerEvmAddr ?? '',
|
|
1699
|
+
amount: payload.maxAmountUsdc,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
async _negotiatePayment(peer, conn) {
|
|
1703
|
+
// Per-peer mutex: if another request is already negotiating, wait for it
|
|
1704
|
+
const existing = this._paymentNegotiationLocks.get(peer.peerId);
|
|
1705
|
+
if (existing) {
|
|
1706
|
+
await existing;
|
|
1411
1707
|
return;
|
|
1412
1708
|
}
|
|
1709
|
+
const negotiation = this._doNegotiatePayment(peer, conn);
|
|
1710
|
+
this._paymentNegotiationLocks.set(peer.peerId, negotiation);
|
|
1413
1711
|
try {
|
|
1414
|
-
|
|
1415
|
-
const sessionIdHex = "0x" + bytesToHex(session.sessionIdBytes);
|
|
1416
|
-
const additionalAmount = BigInt(payload.additionalAmount);
|
|
1417
|
-
const txHash = await this._escrowClient.extendLock(sellerWallet, sessionIdHex, additionalAmount, payload.buyerSig);
|
|
1418
|
-
session.lockedAmount += additionalAmount;
|
|
1419
|
-
debugLog(`[Node] TopUp committed: session=${session.sessionId.slice(0, 8)}... additional=${additionalAmount} newTotal=${session.lockedAmount} tx=${txHash.slice(0, 12)}...`);
|
|
1712
|
+
await negotiation;
|
|
1420
1713
|
}
|
|
1421
|
-
|
|
1422
|
-
|
|
1714
|
+
finally {
|
|
1715
|
+
this._paymentNegotiationLocks.delete(peer.peerId);
|
|
1423
1716
|
}
|
|
1424
1717
|
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* Create a PaymentMux for a buyer-side outbound connection and register
|
|
1428
|
-
* buyer-side handlers (lock confirm, lock reject, seller receipt, top-up request).
|
|
1429
|
-
*/
|
|
1430
|
-
_getOrCreateBuyerPaymentMux(peerId, conn) {
|
|
1431
|
-
const existing = this._paymentMuxes.get(peerId);
|
|
1432
|
-
if (existing)
|
|
1433
|
-
return existing;
|
|
1434
|
-
const pmux = new PaymentMux(conn);
|
|
1435
|
-
this._paymentMuxes.set(peerId, pmux);
|
|
1436
|
-
const bpm = this._buyerPaymentManager;
|
|
1437
|
-
if (!bpm)
|
|
1438
|
-
return pmux;
|
|
1439
|
-
pmux.onSessionLockConfirm((payload) => {
|
|
1440
|
-
bpm.handleLockConfirm(peerId, payload);
|
|
1441
|
-
});
|
|
1442
|
-
pmux.onSessionLockReject((payload) => {
|
|
1443
|
-
bpm.handleLockReject(peerId, payload);
|
|
1444
|
-
});
|
|
1445
|
-
pmux.onSellerReceipt((receipt) => {
|
|
1446
|
-
void bpm.handleSellerReceipt(peerId, receipt, pmux);
|
|
1447
|
-
});
|
|
1448
|
-
pmux.onTopUpRequest((request) => {
|
|
1449
|
-
void bpm.handleTopUpRequest(peerId, request, pmux);
|
|
1450
|
-
});
|
|
1451
|
-
return pmux;
|
|
1452
|
-
}
|
|
1453
|
-
/**
|
|
1454
|
-
* Initiate a lock with a seller peer. Creates PaymentMux, signs lock auth,
|
|
1455
|
-
* and waits for confirmation before returning.
|
|
1456
|
-
*/
|
|
1457
|
-
async _initiateBuyerLock(peer, conn) {
|
|
1718
|
+
async _doNegotiatePayment(peer, conn) {
|
|
1458
1719
|
const bpm = this._buyerPaymentManager;
|
|
1459
|
-
if (!bpm)
|
|
1720
|
+
if (!bpm) {
|
|
1721
|
+
throw new Error('Payment negotiation unavailable — no escrow contract configured');
|
|
1722
|
+
}
|
|
1723
|
+
// If already locked from a previous successful negotiation, skip
|
|
1724
|
+
if (this._buyerLockedPeers.has(peer.peerId))
|
|
1460
1725
|
return;
|
|
1461
|
-
// Mark as locked so we don't re-initiate
|
|
1462
|
-
this._buyerLockedPeers.add(peer.peerId);
|
|
1463
1726
|
const pmux = this._getOrCreateBuyerPaymentMux(peer.peerId, conn);
|
|
1464
|
-
//
|
|
1465
|
-
const
|
|
1466
|
-
if (
|
|
1467
|
-
|
|
1468
|
-
|
|
1727
|
+
// Check if PaymentRequired was already buffered (arrives in same I/O tick as 402)
|
|
1728
|
+
const buffered = this._bufferedPaymentRequired.get(peer.peerId);
|
|
1729
|
+
if (buffered) {
|
|
1730
|
+
this._bufferedPaymentRequired.delete(peer.peerId);
|
|
1731
|
+
debugLog(`[Node] Using buffered PaymentRequired from ${peer.peerId.slice(0, 12)}...`);
|
|
1732
|
+
}
|
|
1733
|
+
const PAYMENT_REQUIRED_TIMEOUT_MS = 10_000;
|
|
1734
|
+
const requirements = buffered ?? await new Promise((resolve, reject) => {
|
|
1735
|
+
const timer = setTimeout(() => {
|
|
1736
|
+
this._pendingPaymentRequired.delete(peer.peerId);
|
|
1737
|
+
reject(new Error(`PaymentRequired timeout from seller ${peer.peerId.slice(0, 12)}...`));
|
|
1738
|
+
}, PAYMENT_REQUIRED_TIMEOUT_MS);
|
|
1739
|
+
this._pendingPaymentRequired.set(peer.peerId, { resolve, reject, timer });
|
|
1740
|
+
});
|
|
1741
|
+
debugLog(`[Node] PaymentRequired from ${peer.peerId.slice(0, 12)}...: rate=${requirements.tokenRate} cap=${requirements.firstSignCap} suggested=${requirements.suggestedAmount}`);
|
|
1742
|
+
// Fetch on-chain context so the UI can show the buyer real data
|
|
1743
|
+
let approvalContext = null;
|
|
1744
|
+
if (this._escrowClient && this._identity) {
|
|
1745
|
+
try {
|
|
1746
|
+
const buyerAddr = identityToEvmAddress(this._identity);
|
|
1747
|
+
approvalContext = await this._escrowClient.getBuyerApprovalContext(buyerAddr, requirements.sellerEvmAddr);
|
|
1748
|
+
debugLog(`[Node] Approval context: balance=${approvalContext.buyerBalance.available} isFirstSign=${approvalContext.isFirstSign} cooldown=${approvalContext.cooldownRemainingSecs}s`);
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
debugWarn(`[Node] Failed to fetch approval context: ${err instanceof Error ? err.message : err}`);
|
|
1752
|
+
}
|
|
1469
1753
|
}
|
|
1754
|
+
// Cap amount before building approval info so the displayed amount
|
|
1755
|
+
// matches what will actually be signed via authorizeSpending.
|
|
1756
|
+
let amount;
|
|
1470
1757
|
try {
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1758
|
+
amount = BigInt(requirements.suggestedAmount);
|
|
1759
|
+
}
|
|
1760
|
+
catch {
|
|
1761
|
+
throw new Error(`Invalid suggestedAmount from seller ${peer.peerId.slice(0, 12)}...: "${requirements.suggestedAmount}"`);
|
|
1762
|
+
}
|
|
1763
|
+
if (approvalContext) {
|
|
1764
|
+
const available = approvalContext.buyerBalance.available;
|
|
1765
|
+
if (amount > available)
|
|
1766
|
+
amount = available;
|
|
1767
|
+
if (approvalContext.isFirstSign) {
|
|
1768
|
+
const cap = approvalContext.firstSignCap;
|
|
1769
|
+
if (amount > cap)
|
|
1770
|
+
amount = cap;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (amount <= 0n) {
|
|
1774
|
+
throw new Error(`Insufficient escrow balance to authorize payment to ${peer.peerId.slice(0, 12)}...`);
|
|
1775
|
+
}
|
|
1776
|
+
const approvalInfo = {
|
|
1777
|
+
peerId: peer.peerId,
|
|
1778
|
+
sellerEvmAddr: requirements.sellerEvmAddr,
|
|
1779
|
+
tokenRate: requirements.tokenRate,
|
|
1780
|
+
firstSignCap: requirements.firstSignCap,
|
|
1781
|
+
suggestedAmount: amount.toString(),
|
|
1782
|
+
buyerAvailableUsdc: approvalContext ? approvalContext.buyerBalance.available.toString() : null,
|
|
1783
|
+
isFirstSign: approvalContext?.isFirstSign ?? null,
|
|
1784
|
+
cooldownRemainingSecs: approvalContext?.cooldownRemainingSecs ?? null,
|
|
1785
|
+
};
|
|
1786
|
+
this.emit('payment:required', approvalInfo);
|
|
1787
|
+
try {
|
|
1788
|
+
await bpm.authorizeSpending(peer.peerId, requirements.sellerEvmAddr, pmux, amount);
|
|
1789
|
+
debugLog(`[Node] SpendingAuth sent to seller ${peer.peerId.slice(0, 12)}..., waiting for AuthAck...`);
|
|
1474
1790
|
await this._waitForLockConfirmation(peer.peerId);
|
|
1475
|
-
debugLog(`[Node]
|
|
1791
|
+
debugLog(`[Node] AuthAck received from seller ${peer.peerId.slice(0, 12)}...`);
|
|
1792
|
+
this._buyerLockedPeers.add(peer.peerId);
|
|
1793
|
+
// Notify listeners what was signed
|
|
1794
|
+
this.emit('payment:signed', {
|
|
1795
|
+
peerId: peer.peerId,
|
|
1796
|
+
sellerEvmAddr: requirements.sellerEvmAddr,
|
|
1797
|
+
amount: amount.toString(),
|
|
1798
|
+
tokenRate: requirements.tokenRate,
|
|
1799
|
+
});
|
|
1476
1800
|
}
|
|
1477
1801
|
catch (err) {
|
|
1478
|
-
debugWarn(`[Node]
|
|
1479
|
-
|
|
1480
|
-
this._buyerLockedPeers.delete(peer.peerId);
|
|
1802
|
+
debugWarn(`[Node] Payment negotiation failed for ${peer.peerId.slice(0, 12)}...: ${err instanceof Error ? err.message : err}`);
|
|
1803
|
+
throw err;
|
|
1481
1804
|
}
|
|
1482
1805
|
}
|
|
1483
1806
|
/**
|
|
@@ -1503,23 +1826,16 @@ export class AntseedNode extends EventEmitter {
|
|
|
1503
1826
|
throw new Error(`Lock confirmation timed out for seller ${sellerPeerId.slice(0, 12)}... (${timeoutMs}ms)`);
|
|
1504
1827
|
}
|
|
1505
1828
|
/**
|
|
1506
|
-
*
|
|
1829
|
+
* Clean up buyer payment sessions on shutdown.
|
|
1830
|
+
* Sessions are persisted in SessionStore and will be resumed on next connect.
|
|
1507
1831
|
*/
|
|
1508
1832
|
async _endAllBuyerSessions() {
|
|
1509
1833
|
const bpm = this._buyerPaymentManager;
|
|
1510
1834
|
if (!bpm)
|
|
1511
1835
|
return;
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
debugLog(`[Node] Ending ${sessions.length} buyer payment session(s)...`);
|
|
1516
|
-
await Promise.allSettled(sessions.map((session) => {
|
|
1517
|
-
const pmux = this._paymentMuxes.get(session.sellerPeerId);
|
|
1518
|
-
if (pmux) {
|
|
1519
|
-
return bpm.endSession(session.sellerPeerId, pmux, 80);
|
|
1520
|
-
}
|
|
1521
|
-
return Promise.resolve();
|
|
1522
|
-
}));
|
|
1836
|
+
// Sessions persist in SQLite; no explicit end needed.
|
|
1837
|
+
// The buyer will reference them as previousSession on next connect.
|
|
1838
|
+
debugLog(`[Node] Buyer sessions persisted for next reconnection`);
|
|
1523
1839
|
}
|
|
1524
1840
|
_resolvePublicAddress(result) {
|
|
1525
1841
|
const metadataPublicAddress = result.metadata.publicAddress?.trim();
|