@routedock/routedock 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +227 -4
- package/dist/MppSessionHandler-B4TyD7Xg.d.cts +121 -0
- package/dist/MppSessionHandler-CLB5SzgN.d.ts +121 -0
- package/dist/ProviderRegistry-BJ9oJLVz.d.cts +142 -0
- package/dist/ProviderRegistry-C50nyhaF.d.ts +142 -0
- package/dist/RouteDockClient-B1roCa8h.d.cts +455 -0
- package/dist/RouteDockClient-BbkqjhMG.d.ts +455 -0
- package/dist/SessionReconciler-Dscw43F3.d.ts +125 -0
- package/dist/SessionReconciler-bi0z2qtN.d.cts +125 -0
- package/dist/SessionStore-BpK8vv0L.d.cts +111 -0
- package/dist/SessionStore-DvkZv8-E.d.ts +111 -0
- package/dist/client.cjs +2419 -202
- package/dist/client.d.cts +50 -81
- package/dist/client.d.ts +50 -81
- package/dist/client.js +2373 -203
- package/dist/index.cjs +3080 -298
- package/dist/index.d.cts +12 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.js +3026 -298
- package/dist/provider/fastify.cjs +882 -0
- package/dist/provider/fastify.d.cts +35 -0
- package/dist/provider/fastify.d.ts +35 -0
- package/dist/provider/fastify.js +880 -0
- package/dist/provider/hono.cjs +948 -0
- package/dist/provider/hono.d.cts +99 -0
- package/dist/provider/hono.d.ts +99 -0
- package/dist/provider/hono.js +938 -0
- package/dist/provider.cjs +827 -97
- package/dist/provider.d.cts +32 -25
- package/dist/provider.d.ts +32 -25
- package/dist/provider.js +810 -99
- package/dist/react.cjs +2641 -0
- package/dist/react.d.cts +107 -0
- package/dist/react.d.ts +107 -0
- package/dist/react.js +2634 -0
- package/dist/routedockMiddleware-BlDsHuPl.d.ts +59 -0
- package/dist/routedockMiddleware-D97A5ptQ.d.cts +59 -0
- package/dist/schema.cjs +364 -0
- package/dist/schema.d.cts +3 -0
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +359 -0
- package/dist/sign-CUf3rhTx.d.cts +53 -0
- package/dist/sign-Dpj2Mp7k.d.ts +53 -0
- package/dist/testing.cjs +57 -0
- package/dist/testing.d.cts +95 -0
- package/dist/testing.d.ts +95 -0
- package/dist/testing.js +54 -0
- package/dist/types-DmhmfLzP.d.cts +410 -0
- package/dist/types-DmhmfLzP.d.ts +410 -0
- package/package.json +143 -23
- package/PUBLISH_CHECKLIST.md +0 -70
- package/dist/SessionStore-CCVO0PHM.d.cts +0 -152
- package/dist/SessionStore-CCVO0PHM.d.ts +0 -152
- package/dist/routedockMiddleware-B7WTB3LT.d.cts +0 -53
- package/dist/routedockMiddleware-DRgdrycG.d.ts +0 -53
- package/src/client/ModeRouter.ts +0 -104
- package/src/client/MppChargeClient.ts +0 -44
- package/src/client/MppSessionClient.ts +0 -144
- package/src/client/RouteDockClient.ts +0 -124
- package/src/client/index.ts +0 -8
- package/src/client/x402Client.ts +0 -84
- package/src/index.ts +0 -9
- package/src/provider/MppChargeHandler.ts +0 -90
- package/src/provider/MppSessionHandler.ts +0 -157
- package/src/provider/index.ts +0 -7
- package/src/provider/routedockMiddleware.ts +0 -154
- package/src/provider/x402Handler.ts +0 -164
- package/src/schemas/routedock.schema.json +0 -159
- package/src/store/SessionStore.ts +0 -90
- package/src/tests/smoke.test.ts +0 -228
- package/src/types.ts +0 -165
- package/tsconfig.json +0 -14
- package/tsup.config.ts +0 -15
package/dist/index.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { usdcToStroops } from '@routedock/nulth-sdk';
|
|
2
|
+
export { USDC_DECIMALS, usdcToStroops } from '@routedock/nulth-sdk';
|
|
3
|
+
import { Keypair, rpc, Horizon, xdr, Address, scValToNative, hash, Networks, StrKey, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } from '@stellar/stellar-sdk';
|
|
4
|
+
import { Validator } from '@cfworker/json-schema';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
3
6
|
import { createEd25519Signer } from '@x402/stellar';
|
|
4
7
|
import { ExactStellarScheme } from '@x402/stellar/exact/client';
|
|
5
8
|
import { x402Client, x402HTTPClient } from '@x402/core/client';
|
|
6
9
|
import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentRequiredHeader, decodePaymentSignatureHeader, encodePaymentResponseHeader } from '@x402/core/http';
|
|
7
10
|
import { stellar } from '@stellar/mpp/charge/client';
|
|
8
11
|
import { Mppx } from 'mppx/client';
|
|
12
|
+
import { STELLAR_PUBNET, STELLAR_TESTNET } from '@stellar/mpp';
|
|
9
13
|
import { stellar as stellar$1 } from '@stellar/mpp/channel/client';
|
|
14
|
+
import { Challenge, Errors } from 'mppx';
|
|
15
|
+
import fs from 'fs/promises';
|
|
16
|
+
import path from 'path';
|
|
10
17
|
import { ExactStellarScheme as ExactStellarScheme$1 } from '@x402/stellar/exact/facilitator';
|
|
11
18
|
import { ExactStellarScheme as ExactStellarScheme$2 } from '@x402/stellar/exact/server';
|
|
12
19
|
import { HTTPFacilitatorClient, x402ResourceServer } from '@x402/core/server';
|
|
@@ -14,44 +21,207 @@ import { stellar as stellar$2 } from '@stellar/mpp/charge/server';
|
|
|
14
21
|
import { Mppx as Mppx$1, Request } from 'mppx/server';
|
|
15
22
|
import { Store, stellar as stellar$3, close } from '@stellar/mpp/channel/server';
|
|
16
23
|
|
|
17
|
-
// src/
|
|
24
|
+
// src/errors.ts
|
|
18
25
|
var RouteDockError = class extends Error {
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
code;
|
|
27
|
+
retryable;
|
|
28
|
+
retryAfterMs;
|
|
29
|
+
constructor(message, code, retryable, options = {}) {
|
|
30
|
+
super(message, { cause: options.cause });
|
|
21
31
|
this.name = "RouteDockError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.retryable = retryable;
|
|
34
|
+
if (options.retryAfterMs !== void 0) {
|
|
35
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
36
|
+
}
|
|
22
37
|
}
|
|
23
38
|
};
|
|
24
39
|
var RouteDockManifestError = class extends RouteDockError {
|
|
25
|
-
constructor(message) {
|
|
26
|
-
super(message);
|
|
40
|
+
constructor(message, options = {}) {
|
|
41
|
+
super(message, "MANIFEST", false, options);
|
|
27
42
|
this.name = "RouteDockManifestError";
|
|
28
43
|
}
|
|
29
44
|
};
|
|
45
|
+
var RouteDockManifestTimeoutError = class extends RouteDockError {
|
|
46
|
+
constructor(message, options = {}) {
|
|
47
|
+
super(message, "MANIFEST_TIMEOUT", true, options);
|
|
48
|
+
this.name = "RouteDockManifestTimeoutError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var RouteDockManifestSunsetError = class extends RouteDockError {
|
|
52
|
+
constructor(message, options = {}) {
|
|
53
|
+
super(message, "MANIFEST_SUNSET", false, options);
|
|
54
|
+
this.name = "RouteDockManifestSunsetError";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
30
57
|
var RouteDockNoSupportedModeError = class extends RouteDockError {
|
|
31
|
-
constructor(message) {
|
|
32
|
-
super(message);
|
|
58
|
+
constructor(message, options = {}) {
|
|
59
|
+
super(message, "NO_SUPPORTED_MODE", false, options);
|
|
33
60
|
this.name = "RouteDockNoSupportedModeError";
|
|
34
61
|
}
|
|
35
62
|
};
|
|
36
|
-
var
|
|
37
|
-
constructor(message) {
|
|
38
|
-
super(message);
|
|
39
|
-
this.name = "
|
|
63
|
+
var RouteDockClientVersionError = class extends RouteDockError {
|
|
64
|
+
constructor(message, options = {}) {
|
|
65
|
+
super(message, "CLIENT_VERSION_TOO_OLD", false, options);
|
|
66
|
+
this.name = "RouteDockClientVersionError";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var RouteDockTrustlineError = class extends RouteDockError {
|
|
70
|
+
asset;
|
|
71
|
+
issuer;
|
|
72
|
+
remediation;
|
|
73
|
+
constructor(asset, issuer, remediation, options = {}) {
|
|
74
|
+
super(
|
|
75
|
+
`Missing trustline for ${asset} (issuer: ${issuer}). ${remediation}`,
|
|
76
|
+
"TRUSTLINE",
|
|
77
|
+
false,
|
|
78
|
+
options
|
|
79
|
+
);
|
|
80
|
+
this.name = "RouteDockTrustlineError";
|
|
81
|
+
this.asset = asset;
|
|
82
|
+
this.issuer = issuer;
|
|
83
|
+
this.remediation = remediation;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var RouteDockFacilitatorError = class extends RouteDockError {
|
|
87
|
+
status;
|
|
88
|
+
constructor(message, status, options = {}) {
|
|
89
|
+
super(message, "FACILITATOR", true, options);
|
|
90
|
+
this.name = "RouteDockFacilitatorError";
|
|
91
|
+
this.status = status;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var RouteDockNetworkError = class extends RouteDockError {
|
|
95
|
+
constructor(message, options = {}) {
|
|
96
|
+
super(message, "NETWORK", true, options);
|
|
97
|
+
this.name = "RouteDockNetworkError";
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
var RouteDockSignatureError = class extends RouteDockError {
|
|
101
|
+
constructor(message, options = {}) {
|
|
102
|
+
super(message, "SIGNATURE", false, options);
|
|
103
|
+
this.name = "RouteDockSignatureError";
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var RouteDockVoucherMonotonicityError = class extends RouteDockError {
|
|
107
|
+
constructor(message, options = {}) {
|
|
108
|
+
super(message, "VOUCHER_MONOTONICITY", false, options);
|
|
109
|
+
this.name = "RouteDockVoucherMonotonicityError";
|
|
40
110
|
}
|
|
41
111
|
};
|
|
42
|
-
var
|
|
112
|
+
var RouteDockPolicyRejectError = class extends RouteDockError {
|
|
43
113
|
reason;
|
|
44
|
-
constructor(reason) {
|
|
45
|
-
super(`Payment rejected by policy: ${reason}
|
|
46
|
-
this.name = "
|
|
114
|
+
constructor(reason, options = {}) {
|
|
115
|
+
super(`Payment rejected by policy: ${reason}`, "POLICY_REJECT", false, options);
|
|
116
|
+
this.name = "RouteDockPolicyRejectError";
|
|
47
117
|
this.reason = reason;
|
|
48
118
|
}
|
|
49
119
|
};
|
|
120
|
+
var RouteDockChannelStateError = class extends RouteDockError {
|
|
121
|
+
constructor(message, options = {}) {
|
|
122
|
+
super(message, "CHANNEL_STATE", false, options);
|
|
123
|
+
this.name = "RouteDockChannelStateError";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
var RouteDockDisputeError = class extends RouteDockError {
|
|
127
|
+
constructor(message, options = {}) {
|
|
128
|
+
super(message, "DISPUTE", false, options);
|
|
129
|
+
this.name = "RouteDockDisputeError";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
var RouteDockRefundWindowError = class extends RouteDockError {
|
|
133
|
+
constructor(message, options = {}) {
|
|
134
|
+
super(message, "REFUND_WINDOW", false, options);
|
|
135
|
+
this.name = "RouteDockRefundWindowError";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var RouteDockPolicyRejectedError = RouteDockPolicyRejectError;
|
|
139
|
+
var RouteDockSessionError = RouteDockVoucherMonotonicityError;
|
|
140
|
+
function parseRetryAfterMs(response) {
|
|
141
|
+
const header = response.headers.get("Retry-After");
|
|
142
|
+
if (!header) return void 0;
|
|
143
|
+
const seconds = Number(header);
|
|
144
|
+
if (!Number.isNaN(seconds) && seconds >= 0) {
|
|
145
|
+
return seconds * 1e3;
|
|
146
|
+
}
|
|
147
|
+
const date = Date.parse(header);
|
|
148
|
+
if (!Number.isNaN(date)) {
|
|
149
|
+
return Math.max(0, date - Date.now());
|
|
150
|
+
}
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
function httpStatusToError(message, status, response) {
|
|
154
|
+
const retryAfterMs = response ? parseRetryAfterMs(response) : void 0;
|
|
155
|
+
const opts = retryAfterMs !== void 0 ? { retryAfterMs } : {};
|
|
156
|
+
if (status === 429 || status === 503 || status >= 500 && status < 600) {
|
|
157
|
+
return new RouteDockFacilitatorError(message, status, opts);
|
|
158
|
+
}
|
|
159
|
+
if (status >= 400) {
|
|
160
|
+
return new RouteDockManifestError(message, opts);
|
|
161
|
+
}
|
|
162
|
+
return new RouteDockNetworkError(message, opts);
|
|
163
|
+
}
|
|
164
|
+
function wrapFetchError(err, context) {
|
|
165
|
+
if (err instanceof RouteDockError) return err;
|
|
166
|
+
return new RouteDockNetworkError(`${context}: ${String(err)}`, { cause: err });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/types.ts
|
|
170
|
+
var DEFAULT_MAX_SESSION_DURATION_MS = 36e5;
|
|
171
|
+
|
|
172
|
+
// src/internal/retry.ts
|
|
173
|
+
var DEFAULT_RETRY_POLICY = {
|
|
174
|
+
maxAttempts: 4,
|
|
175
|
+
baseDelayMs: 250,
|
|
176
|
+
maxDelayMs: 3e4
|
|
177
|
+
};
|
|
178
|
+
function backoffDelayMs(attempt, baseDelayMs, maxDelayMs = DEFAULT_RETRY_POLICY.maxDelayMs) {
|
|
179
|
+
const cappedDelay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
|
180
|
+
return Math.floor(Math.random() * (cappedDelay + 1));
|
|
181
|
+
}
|
|
182
|
+
function sleep(ms) {
|
|
183
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
184
|
+
}
|
|
185
|
+
async function withRetry(fn, policy = {}) {
|
|
186
|
+
const { maxAttempts, baseDelayMs, maxDelayMs, onRetry } = {
|
|
187
|
+
...DEFAULT_RETRY_POLICY,
|
|
188
|
+
...policy
|
|
189
|
+
};
|
|
190
|
+
let lastError;
|
|
191
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
192
|
+
try {
|
|
193
|
+
return await fn();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (!(err instanceof RouteDockError)) {
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
lastError = err;
|
|
199
|
+
if (!err.retryable || attempt >= maxAttempts - 1) {
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
const delay = err.retryAfterMs !== void 0 ? err.retryAfterMs : backoffDelayMs(attempt, baseDelayMs, maxDelayMs);
|
|
203
|
+
if (onRetry) {
|
|
204
|
+
try {
|
|
205
|
+
await onRetry(attempt, err, delay);
|
|
206
|
+
} catch (cbErr) {
|
|
207
|
+
console.error("[withRetry] onRetry callback threw:", cbErr);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await sleep(delay);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw lastError ?? new RouteDockError("Retry exhausted", "NETWORK", false);
|
|
214
|
+
}
|
|
215
|
+
var USDC_ISSUERS = {
|
|
216
|
+
testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
|
|
217
|
+
mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
|
|
218
|
+
};
|
|
219
|
+
var usdcToUnits = usdcToStroops;
|
|
50
220
|
|
|
51
221
|
// src/schemas/routedock.schema.json
|
|
52
222
|
var routedock_schema_default = {
|
|
53
223
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
54
|
-
$id: "https://routedock.
|
|
224
|
+
$id: "https://routedock.xyz/schemas/routedock.schema.json",
|
|
55
225
|
title: "RouteDock Manifest",
|
|
56
226
|
description: "Discovery manifest served at /.well-known/routedock.json on every RouteDock provider endpoint",
|
|
57
227
|
type: "object",
|
|
@@ -72,8 +242,26 @@ var routedock_schema_default = {
|
|
|
72
242
|
properties: {
|
|
73
243
|
routedock: {
|
|
74
244
|
type: "string",
|
|
75
|
-
|
|
76
|
-
description: "Manifest schema version.
|
|
245
|
+
pattern: "^[0-9]+\\.[0-9]+$",
|
|
246
|
+
description: "Manifest schema version, e.g. '1.0', '1.1', '2.0'."
|
|
247
|
+
},
|
|
248
|
+
min_client_version: {
|
|
249
|
+
type: "string",
|
|
250
|
+
pattern: "^[0-9]+\\.[0-9]+$",
|
|
251
|
+
description: "Minimum SDK client version (major.minor) required to use this manifest. Clients below this version must refuse to pay."
|
|
252
|
+
},
|
|
253
|
+
deprecated_modes: {
|
|
254
|
+
type: "array",
|
|
255
|
+
items: {
|
|
256
|
+
$ref: "#/definitions/PaymentMode"
|
|
257
|
+
},
|
|
258
|
+
uniqueItems: true,
|
|
259
|
+
description: "Payment modes still listed in 'modes' for backwards compatibility but deprecated for new integrations."
|
|
260
|
+
},
|
|
261
|
+
sunset_at: {
|
|
262
|
+
type: "string",
|
|
263
|
+
format: "date-time",
|
|
264
|
+
description: "ISO 8601 timestamp after which this manifest version is no longer supported by the provider."
|
|
77
265
|
},
|
|
78
266
|
name: {
|
|
79
267
|
type: "string",
|
|
@@ -126,18 +314,25 @@ var routedock_schema_default = {
|
|
|
126
314
|
},
|
|
127
315
|
"mpp-session": {
|
|
128
316
|
$ref: "#/definitions/SessionPricingConfig"
|
|
317
|
+
},
|
|
318
|
+
"mpp-session-ws": {
|
|
319
|
+
$ref: "#/definitions/SessionPricingConfig",
|
|
320
|
+
description: "WebSocket transport variant of mpp-session: channel opened and first voucher negotiated over HTTP, then the connection upgrades to WebSocket for push-based streaming."
|
|
129
321
|
}
|
|
130
322
|
},
|
|
131
323
|
minProperties: 1
|
|
132
324
|
},
|
|
133
325
|
endpoints: {
|
|
134
326
|
type: "object",
|
|
135
|
-
description: "Map of endpoint name to
|
|
327
|
+
description: "Map of endpoint name to endpoint descriptors",
|
|
136
328
|
additionalProperties: {
|
|
137
|
-
|
|
329
|
+
$ref: "#/definitions/EndpointDescriptor"
|
|
138
330
|
},
|
|
139
331
|
minProperties: 1
|
|
140
332
|
},
|
|
333
|
+
sla: {
|
|
334
|
+
$ref: "#/definitions/SLAConfig"
|
|
335
|
+
},
|
|
141
336
|
tags: {
|
|
142
337
|
type: "array",
|
|
143
338
|
items: {
|
|
@@ -147,12 +342,91 @@ var routedock_schema_default = {
|
|
|
147
342
|
},
|
|
148
343
|
uniqueItems: true,
|
|
149
344
|
description: "Capability tags indexed with trigram search in the provider registry"
|
|
345
|
+
},
|
|
346
|
+
vault: {
|
|
347
|
+
$ref: "#/definitions/VaultMode"
|
|
348
|
+
},
|
|
349
|
+
nulth_account: {
|
|
350
|
+
type: "string",
|
|
351
|
+
description: "Optional Nulth smart account address associated with this provider"
|
|
352
|
+
},
|
|
353
|
+
capabilities: {
|
|
354
|
+
type: "object",
|
|
355
|
+
description: "Optional protocol features this provider supports",
|
|
356
|
+
properties: {
|
|
357
|
+
streaming: {
|
|
358
|
+
type: "array",
|
|
359
|
+
items: {
|
|
360
|
+
type: "string",
|
|
361
|
+
enum: ["sse", "websocket"]
|
|
362
|
+
},
|
|
363
|
+
description: "Supported streaming transports"
|
|
364
|
+
},
|
|
365
|
+
webhooks: {
|
|
366
|
+
type: "boolean",
|
|
367
|
+
description: "Whether webhook callbacks are supported"
|
|
368
|
+
},
|
|
369
|
+
idempotency_keys: {
|
|
370
|
+
type: "boolean",
|
|
371
|
+
description: "Whether idempotency keys are supported"
|
|
372
|
+
},
|
|
373
|
+
content_types: {
|
|
374
|
+
type: "array",
|
|
375
|
+
items: {
|
|
376
|
+
type: "string"
|
|
377
|
+
},
|
|
378
|
+
description: "Supported content types for request/response"
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
additionalProperties: false
|
|
382
|
+
},
|
|
383
|
+
regions: {
|
|
384
|
+
type: "array",
|
|
385
|
+
items: {
|
|
386
|
+
type: "string",
|
|
387
|
+
minLength: 2,
|
|
388
|
+
maxLength: 10
|
|
389
|
+
},
|
|
390
|
+
uniqueItems: true,
|
|
391
|
+
description: "IATA airport codes or UN/LOCODE identifiers for regions where this provider has infrastructure (e.g. ['IAD', 'AMS']). Used by agents to select the nearest provider."
|
|
392
|
+
},
|
|
393
|
+
latency_hints: {
|
|
394
|
+
type: "object",
|
|
395
|
+
additionalProperties: {
|
|
396
|
+
type: "number",
|
|
397
|
+
minimum: 0
|
|
398
|
+
},
|
|
399
|
+
description: "Provider-declared p50 latency in milliseconds per region code. Keys must be a subset of 'regions'. Example: { 'IAD': 14, 'AMS': 22 }."
|
|
400
|
+
},
|
|
401
|
+
signature: {
|
|
402
|
+
type: "string",
|
|
403
|
+
description: "Base64 Ed25519 signature (payee keypair) over the SHA-256 hash of this manifest with the signature field omitted. Clients must verify before trusting any routing field."
|
|
404
|
+
},
|
|
405
|
+
signature_version: {
|
|
406
|
+
type: "string",
|
|
407
|
+
const: "2",
|
|
408
|
+
description: "Manifest signature format. Version 2 recursively includes every nested manifest field in the signed digest."
|
|
409
|
+
},
|
|
410
|
+
categories: {
|
|
411
|
+
type: "array",
|
|
412
|
+
items: {
|
|
413
|
+
type: "string",
|
|
414
|
+
minLength: 1,
|
|
415
|
+
maxLength: 128
|
|
416
|
+
},
|
|
417
|
+
uniqueItems: true,
|
|
418
|
+
description: "Optional hierarchical categories from a published taxonomy (e.g. data/price/crypto)"
|
|
150
419
|
}
|
|
151
420
|
},
|
|
152
421
|
definitions: {
|
|
422
|
+
VaultMode: {
|
|
423
|
+
type: "string",
|
|
424
|
+
enum: ["local-key", "agent-vault", "nulth"],
|
|
425
|
+
description: "Agent vault / custody mode for payments from this provider"
|
|
426
|
+
},
|
|
153
427
|
PaymentMode: {
|
|
154
428
|
type: "string",
|
|
155
|
-
enum: ["x402", "mpp-charge", "mpp-session"],
|
|
429
|
+
enum: ["x402", "mpp-charge", "mpp-session", "mpp-session-ws"],
|
|
156
430
|
description: "A supported RouteDock payment mode"
|
|
157
431
|
},
|
|
158
432
|
PricingConfig: {
|
|
@@ -173,13 +447,17 @@ var routedock_schema_default = {
|
|
|
173
447
|
facilitator: {
|
|
174
448
|
type: "string",
|
|
175
449
|
description: "x402 facilitator URL (required for x402 mode). OpenZeppelin Channels endpoint."
|
|
450
|
+
},
|
|
451
|
+
payee: {
|
|
452
|
+
type: "string",
|
|
453
|
+
description: "Optional per-mode payee override (Stellar G... address). Directs settlements for this mode to a separate account for treasury separation. Falls back to the top-level 'payee' when omitted."
|
|
176
454
|
}
|
|
177
455
|
}
|
|
178
456
|
},
|
|
179
457
|
SessionPricingConfig: {
|
|
180
458
|
type: "object",
|
|
181
459
|
description: "Per-voucher pricing for mpp-session (one-way-channel) mode",
|
|
182
|
-
required: ["rate", "per", "
|
|
460
|
+
required: ["rate", "per", "channel_factory", "min_deposit", "refund_waiting_period_ledgers"],
|
|
183
461
|
additionalProperties: false,
|
|
184
462
|
properties: {
|
|
185
463
|
rate: {
|
|
@@ -191,9 +469,9 @@ var routedock_schema_default = {
|
|
|
191
469
|
const: "voucher",
|
|
192
470
|
description: "Billing unit \u2014 always 'voucher' for session mode"
|
|
193
471
|
},
|
|
194
|
-
|
|
472
|
+
channel_factory: {
|
|
195
473
|
type: "string",
|
|
196
|
-
description: "Soroban
|
|
474
|
+
description: "Stable Soroban factory address (C...) used to deploy a per-session channel"
|
|
197
475
|
},
|
|
198
476
|
min_deposit: {
|
|
199
477
|
type: "string",
|
|
@@ -205,88 +483,502 @@ var routedock_schema_default = {
|
|
|
205
483
|
description: "Ledgers the server has to settle before the funder can reclaim. Default: 17280 (~24h). Do not reduce in production."
|
|
206
484
|
}
|
|
207
485
|
}
|
|
486
|
+
},
|
|
487
|
+
SLAConfig: {
|
|
488
|
+
type: "object",
|
|
489
|
+
description: "Service Level Agreement for the provider",
|
|
490
|
+
required: ["uptime_30d_percent", "p95_latency_ms"],
|
|
491
|
+
additionalProperties: false,
|
|
492
|
+
properties: {
|
|
493
|
+
uptime_30d_percent: {
|
|
494
|
+
type: "number",
|
|
495
|
+
description: "Uptime percentage over the last 30 days"
|
|
496
|
+
},
|
|
497
|
+
p95_latency_ms: {
|
|
498
|
+
type: "number",
|
|
499
|
+
description: "P95 latency in milliseconds"
|
|
500
|
+
},
|
|
501
|
+
maintenance_windows: {
|
|
502
|
+
type: "array",
|
|
503
|
+
items: {
|
|
504
|
+
type: "object",
|
|
505
|
+
required: ["cron", "duration_minutes"],
|
|
506
|
+
additionalProperties: false,
|
|
507
|
+
properties: {
|
|
508
|
+
cron: {
|
|
509
|
+
type: "string",
|
|
510
|
+
description: "Cron expression for the maintenance window"
|
|
511
|
+
},
|
|
512
|
+
duration_minutes: {
|
|
513
|
+
type: "number",
|
|
514
|
+
description: "Duration of the maintenance window in minutes"
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
EndpointDescriptor: {
|
|
522
|
+
type: "object",
|
|
523
|
+
description: "Descriptor for a provider endpoint",
|
|
524
|
+
required: ["method", "path"],
|
|
525
|
+
additionalProperties: false,
|
|
526
|
+
properties: {
|
|
527
|
+
method: {
|
|
528
|
+
type: "string",
|
|
529
|
+
description: "HTTP method for the endpoint"
|
|
530
|
+
},
|
|
531
|
+
path: {
|
|
532
|
+
type: "string",
|
|
533
|
+
description: "Path for the endpoint"
|
|
534
|
+
},
|
|
535
|
+
deprecated: {
|
|
536
|
+
type: "boolean",
|
|
537
|
+
description: "Whether this endpoint is retained only for backwards compatibility"
|
|
538
|
+
},
|
|
539
|
+
sunset_at: {
|
|
540
|
+
type: "string",
|
|
541
|
+
format: "date-time",
|
|
542
|
+
description: "ISO 8601 timestamp after which callers should stop using this endpoint"
|
|
543
|
+
},
|
|
544
|
+
headers: {
|
|
545
|
+
type: "object",
|
|
546
|
+
additionalProperties: {
|
|
547
|
+
type: "string"
|
|
548
|
+
},
|
|
549
|
+
description: "Expected request headers"
|
|
550
|
+
},
|
|
551
|
+
request_schema: {
|
|
552
|
+
description: "JSON schema for the request body"
|
|
553
|
+
},
|
|
554
|
+
response_schema: {
|
|
555
|
+
description: "JSON schema for the response body"
|
|
556
|
+
},
|
|
557
|
+
rate_limit: {
|
|
558
|
+
type: "object",
|
|
559
|
+
required: ["requests", "window_seconds"],
|
|
560
|
+
additionalProperties: false,
|
|
561
|
+
properties: {
|
|
562
|
+
requests: {
|
|
563
|
+
type: "number"
|
|
564
|
+
},
|
|
565
|
+
window_seconds: {
|
|
566
|
+
type: "number"
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
208
571
|
}
|
|
209
572
|
}
|
|
210
573
|
};
|
|
211
574
|
|
|
575
|
+
// package.json
|
|
576
|
+
var package_default = {
|
|
577
|
+
version: "0.2.0"};
|
|
578
|
+
var MANIFEST_SIGNATURE_VERSION = "2";
|
|
579
|
+
function canonicalize(value) {
|
|
580
|
+
if (Array.isArray(value)) {
|
|
581
|
+
return value.map(canonicalize);
|
|
582
|
+
}
|
|
583
|
+
if (value !== null && typeof value === "object") {
|
|
584
|
+
return Object.fromEntries(
|
|
585
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return value;
|
|
589
|
+
}
|
|
590
|
+
function manifestDigest(manifest) {
|
|
591
|
+
const { signature: _omit, ...rest } = manifest;
|
|
592
|
+
const canonical = JSON.stringify(canonicalize(rest));
|
|
593
|
+
return createHash("sha256").update(canonical, "utf8").digest();
|
|
594
|
+
}
|
|
595
|
+
function signManifest(manifest, payeeSecretKey) {
|
|
596
|
+
const keypair = Keypair.fromSecret(payeeSecretKey);
|
|
597
|
+
const versionedManifest = {
|
|
598
|
+
...manifest,
|
|
599
|
+
signature_version: MANIFEST_SIGNATURE_VERSION
|
|
600
|
+
};
|
|
601
|
+
const digest = manifestDigest(versionedManifest);
|
|
602
|
+
const sig = keypair.sign(digest);
|
|
603
|
+
return { ...versionedManifest, signature: Buffer.from(sig).toString("base64") };
|
|
604
|
+
}
|
|
605
|
+
function verifyManifestSignature(manifest, expectedPayee) {
|
|
606
|
+
if (!manifest.signature) {
|
|
607
|
+
throw new RouteDockSignatureError(
|
|
608
|
+
"Manifest is missing a signature field \u2014 cannot verify payee authenticity"
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
|
|
612
|
+
throw new RouteDockSignatureError(
|
|
613
|
+
`Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (expectedPayee !== void 0 && manifest.payee !== expectedPayee) {
|
|
617
|
+
throw new RouteDockSignatureError(
|
|
618
|
+
`Manifest payee ${manifest.payee} does not match expected payee ${expectedPayee}`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const keypair = Keypair.fromPublicKey(manifest.payee);
|
|
622
|
+
const digest = manifestDigest(manifest);
|
|
623
|
+
let sigBytes;
|
|
624
|
+
try {
|
|
625
|
+
sigBytes = Buffer.from(manifest.signature, "base64");
|
|
626
|
+
} catch {
|
|
627
|
+
throw new RouteDockSignatureError("Manifest signature is not valid base64");
|
|
628
|
+
}
|
|
629
|
+
let valid = false;
|
|
630
|
+
try {
|
|
631
|
+
valid = keypair.verify(digest, sigBytes);
|
|
632
|
+
} catch {
|
|
633
|
+
throw new RouteDockSignatureError(
|
|
634
|
+
`Manifest signature verification failed for payee ${manifest.payee}`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (!valid) {
|
|
638
|
+
throw new RouteDockSignatureError(
|
|
639
|
+
`Manifest signature verification failed for payee ${manifest.payee}`
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
212
644
|
// src/client/ModeRouter.ts
|
|
213
|
-
var
|
|
214
|
-
var
|
|
645
|
+
var validator = new Validator(routedock_schema_default, "7");
|
|
646
|
+
var SDK_VERSION = package_default.version;
|
|
647
|
+
function parseMajorMinor(version) {
|
|
648
|
+
const parts = version.split(".");
|
|
649
|
+
return [Number.parseInt(parts[0] ?? "0", 10), Number.parseInt(parts[1] ?? "0", 10)];
|
|
650
|
+
}
|
|
651
|
+
function isVersionBelow(a, b) {
|
|
652
|
+
const [aMajor, aMinor] = parseMajorMinor(a);
|
|
653
|
+
const [bMajor, bMinor] = parseMajorMinor(b);
|
|
654
|
+
return aMajor < bMajor || aMajor === bMajor && aMinor < bMinor;
|
|
655
|
+
}
|
|
656
|
+
function assertClientVersionSupported(manifest, baseUrl) {
|
|
657
|
+
const minVersion = manifest.min_client_version;
|
|
658
|
+
if (minVersion && isVersionBelow(SDK_VERSION, minVersion)) {
|
|
659
|
+
throw new RouteDockClientVersionError(
|
|
660
|
+
`SDK version ${SDK_VERSION} is below the minimum required version ${minVersion} for provider at ${baseUrl}. Please upgrade the SDK.`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function assertManifestActive(manifest, baseUrl) {
|
|
665
|
+
const sunsetAt = manifest.sunset_at;
|
|
666
|
+
if (!sunsetAt) return;
|
|
667
|
+
const sunsetTime = Date.parse(sunsetAt);
|
|
668
|
+
if (!Number.isFinite(sunsetTime)) {
|
|
669
|
+
throw new RouteDockManifestError(
|
|
670
|
+
`Manifest at ${baseUrl} contains an invalid sunset_at timestamp: ${sunsetAt}`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (sunsetTime <= Date.now()) {
|
|
674
|
+
throw new RouteDockManifestSunsetError(
|
|
675
|
+
`Manifest for provider at ${baseUrl} sunset at ${sunsetAt} and can no longer be used`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function assertEndpointActive(manifest, url, logger) {
|
|
680
|
+
const pathname = new URL(url).pathname;
|
|
681
|
+
const matches = Object.entries(manifest.endpoints).filter(
|
|
682
|
+
([, descriptor]) => descriptor.path === pathname
|
|
683
|
+
);
|
|
684
|
+
if (matches.length === 0) return;
|
|
685
|
+
const now = Date.now();
|
|
686
|
+
const allSunset = matches.every(([, descriptor]) => {
|
|
687
|
+
const sunsetAt = descriptor.sunset_at;
|
|
688
|
+
return typeof sunsetAt === "string" && Date.parse(sunsetAt) <= now;
|
|
689
|
+
});
|
|
690
|
+
if (allSunset) {
|
|
691
|
+
const [key, descriptor] = matches[0];
|
|
692
|
+
throw new RouteDockManifestSunsetError(
|
|
693
|
+
`Endpoint '${key}' (${pathname}) sunset at ${descriptor.sunset_at} and can no longer be used`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const deprecated = matches.find(([, descriptor]) => descriptor.deprecated === true);
|
|
697
|
+
if (deprecated && logger) {
|
|
698
|
+
const [key, descriptor] = deprecated;
|
|
699
|
+
const sunsetSuffix = descriptor.sunset_at ? `; sunset_at ${descriptor.sunset_at}` : "";
|
|
700
|
+
logger(
|
|
701
|
+
`[RouteDock] WARNING: ${manifest.name} \u2192 ${pathname}; endpoint '${key}' is deprecated${sunsetSuffix}`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function assertManifestValid(manifest, baseUrl) {
|
|
706
|
+
const context = baseUrl ? ` at ${baseUrl}` : "";
|
|
707
|
+
if (manifest.latency_hints !== void 0) {
|
|
708
|
+
if (!Array.isArray(manifest.regions) || manifest.regions.length === 0) {
|
|
709
|
+
throw new RouteDockManifestError(
|
|
710
|
+
`Invalid manifest${context}: latency_hints defined without declaring regions`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const regionSet = new Set(manifest.regions);
|
|
714
|
+
for (const region of Object.keys(manifest.latency_hints)) {
|
|
715
|
+
if (!regionSet.has(region)) {
|
|
716
|
+
throw new RouteDockManifestError(
|
|
717
|
+
`Invalid manifest${context}: latency_hints key '${region}' is not declared in regions (${manifest.regions.join(", ")})`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
215
723
|
var CACHE_TTL_MS = 6e4;
|
|
216
|
-
var
|
|
217
|
-
|
|
724
|
+
var DEFAULT_MANIFEST_CACHE_MAX_SIZE = 512;
|
|
725
|
+
var DEFAULT_MANIFEST_TIMEOUT_MS = 5e3;
|
|
726
|
+
var LruCache = class {
|
|
727
|
+
constructor(maxSize) {
|
|
728
|
+
this.maxSize = maxSize;
|
|
729
|
+
}
|
|
730
|
+
maxSize;
|
|
731
|
+
map = /* @__PURE__ */ new Map();
|
|
732
|
+
get(key) {
|
|
733
|
+
if (!this.map.has(key)) return void 0;
|
|
734
|
+
const value = this.map.get(key);
|
|
735
|
+
this.map.delete(key);
|
|
736
|
+
this.map.set(key, value);
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
set(key, value) {
|
|
740
|
+
if (this.map.has(key)) {
|
|
741
|
+
this.map.delete(key);
|
|
742
|
+
} else if (this.map.size >= this.maxSize) {
|
|
743
|
+
const oldestKey = this.map.keys().next().value;
|
|
744
|
+
if (oldestKey !== void 0) {
|
|
745
|
+
this.map.delete(oldestKey);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
this.map.set(key, value);
|
|
749
|
+
}
|
|
750
|
+
setMaxSize(maxSize) {
|
|
751
|
+
this.maxSize = maxSize;
|
|
752
|
+
while (this.map.size > this.maxSize) {
|
|
753
|
+
const oldestKey = this.map.keys().next().value;
|
|
754
|
+
if (oldestKey === void 0) break;
|
|
755
|
+
this.map.delete(oldestKey);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
delete(key) {
|
|
759
|
+
this.map.delete(key);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
var manifestCache = new LruCache(DEFAULT_MANIFEST_CACHE_MAX_SIZE);
|
|
763
|
+
function configureManifestCache(maxSize) {
|
|
764
|
+
if (!Number.isInteger(maxSize) || maxSize <= 0) {
|
|
765
|
+
throw new RouteDockManifestError("Invalid manifest cache size: " + maxSize);
|
|
766
|
+
}
|
|
767
|
+
manifestCache.setMaxSize(maxSize);
|
|
768
|
+
}
|
|
769
|
+
function invalidateManifest(baseUrl) {
|
|
770
|
+
manifestCache.delete(baseUrl);
|
|
771
|
+
}
|
|
772
|
+
function ttlFromHeaders(headers, now) {
|
|
773
|
+
const cacheControl = headers.get("cache-control");
|
|
774
|
+
if (cacheControl) {
|
|
775
|
+
const directives = cacheControl.split(",").map((directive) => directive.trim().toLowerCase());
|
|
776
|
+
if (directives.includes("no-store") || directives.includes("no-cache")) {
|
|
777
|
+
return 0;
|
|
778
|
+
}
|
|
779
|
+
const maxAge = /max-age=(\d+)/i.exec(cacheControl);
|
|
780
|
+
if (maxAge) {
|
|
781
|
+
return Number.parseInt(maxAge[1], 10) * 1e3;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const expires = headers.get("expires");
|
|
785
|
+
if (expires) {
|
|
786
|
+
const expiresTime = Date.parse(expires);
|
|
787
|
+
if (Number.isFinite(expiresTime)) {
|
|
788
|
+
return expiresTime - now;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return CACHE_TTL_MS;
|
|
792
|
+
}
|
|
793
|
+
async function fetchManifest(baseUrl, retryPolicy, manifestTimeoutMs = DEFAULT_MANIFEST_TIMEOUT_MS, expectedPayee) {
|
|
218
794
|
const cached = manifestCache.get(baseUrl);
|
|
219
|
-
if (cached && Date.now()
|
|
795
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
796
|
+
verifyManifestSignature(cached.manifest, expectedPayee);
|
|
797
|
+
assertClientVersionSupported(cached.manifest, baseUrl);
|
|
798
|
+
assertManifestValid(cached.manifest, baseUrl);
|
|
799
|
+
assertManifestActive(cached.manifest, baseUrl);
|
|
220
800
|
return cached.manifest;
|
|
221
801
|
}
|
|
222
802
|
const url = baseUrl.replace(/\/$/, "") + "/.well-known/routedock.json";
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
803
|
+
return withRetry(async () => {
|
|
804
|
+
let raw;
|
|
805
|
+
let resp;
|
|
806
|
+
let expiresAt = 0;
|
|
807
|
+
try {
|
|
808
|
+
resp = await fetch(url, { signal: AbortSignal.timeout(manifestTimeoutMs) });
|
|
809
|
+
if (!resp.ok) {
|
|
810
|
+
if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
|
|
811
|
+
throw httpStatusToError(
|
|
812
|
+
`Manifest fetch failed: HTTP ${resp.status} from ${url}`,
|
|
813
|
+
resp.status,
|
|
814
|
+
resp
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
throw new RouteDockManifestError(
|
|
818
|
+
`Manifest fetch failed: HTTP ${resp.status} from ${url}`
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} catch (err) {
|
|
822
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
823
|
+
throw new RouteDockManifestTimeoutError(
|
|
824
|
+
`Manifest fetch timed out after ${manifestTimeoutMs}ms from ${url}`,
|
|
825
|
+
{ cause: err }
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (err instanceof RouteDockError) throw err;
|
|
829
|
+
throw wrapFetchError(err, `Manifest fetch error from ${url}`);
|
|
830
|
+
}
|
|
831
|
+
const now = Date.now();
|
|
832
|
+
expiresAt = now + ttlFromHeaders(resp.headers, now);
|
|
833
|
+
try {
|
|
834
|
+
raw = await resp.json();
|
|
835
|
+
} catch (err) {
|
|
836
|
+
throw new RouteDockManifestError(`Manifest at ${url} is not valid JSON`, { cause: err });
|
|
837
|
+
}
|
|
838
|
+
const result = validator.validate(raw);
|
|
839
|
+
if (!result.valid) {
|
|
840
|
+
const msgs = result.errors.map((err) => err.error).join("; ");
|
|
841
|
+
throw new RouteDockManifestError(`Invalid manifest at ${url}: ${msgs}`);
|
|
842
|
+
}
|
|
843
|
+
const manifest = raw;
|
|
844
|
+
assertManifestValid(manifest, baseUrl);
|
|
845
|
+
verifyManifestSignature(manifest, expectedPayee);
|
|
846
|
+
assertClientVersionSupported(manifest, baseUrl);
|
|
847
|
+
assertManifestActive(manifest, baseUrl);
|
|
848
|
+
manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now(), expiresAt });
|
|
849
|
+
return manifest;
|
|
850
|
+
}, retryPolicy);
|
|
851
|
+
}
|
|
852
|
+
function selectFromModes(modes, manifest, options) {
|
|
853
|
+
if (options.sustained || options.session) {
|
|
854
|
+
if (options.transport === "websocket") {
|
|
855
|
+
if (modes.includes("mpp-session-ws")) return { mode: "mpp-session-ws" };
|
|
856
|
+
if (modes.includes("mpp-session")) return { mode: "mpp-session" };
|
|
857
|
+
} else {
|
|
858
|
+
if (modes.includes("mpp-session")) return { mode: "mpp-session" };
|
|
859
|
+
if (modes.includes("mpp-session-ws")) return { mode: "mpp-session-ws" };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (options.optimize === "cost") {
|
|
863
|
+
let budgetStroops = null;
|
|
864
|
+
if (options.budget_per_request !== void 0) {
|
|
865
|
+
try {
|
|
866
|
+
budgetStroops = usdcToStroops(options.budget_per_request);
|
|
867
|
+
} catch {
|
|
868
|
+
throw new RouteDockPolicyRejectError("invalid_budget_per_request");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const candidates = ["x402", "mpp-charge"].filter((mode) => modes.includes(mode)).flatMap((mode) => {
|
|
872
|
+
const amount = manifest.pricing[mode]?.amount;
|
|
873
|
+
if (typeof amount !== "string") return [];
|
|
874
|
+
try {
|
|
875
|
+
return [{ mode, amountStroops: usdcToStroops(amount) }];
|
|
876
|
+
} catch {
|
|
877
|
+
return [];
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
if (candidates.length > 0) {
|
|
881
|
+
const affordableCandidates = budgetStroops === null ? candidates : candidates.filter((candidate) => candidate.amountStroops <= budgetStroops);
|
|
882
|
+
const cheapestCandidate = [...affordableCandidates].sort(
|
|
883
|
+
(a, b) => a.amountStroops < b.amountStroops ? -1 : a.amountStroops > b.amountStroops ? 1 : 0
|
|
884
|
+
)[0];
|
|
885
|
+
if (cheapestCandidate) {
|
|
886
|
+
return { mode: cheapestCandidate.mode, reason: "cost-optimized" };
|
|
887
|
+
}
|
|
888
|
+
if (budgetStroops !== null) {
|
|
889
|
+
throw new RouteDockPolicyRejectError("budget_per_request_exceeded");
|
|
890
|
+
}
|
|
228
891
|
}
|
|
229
|
-
raw = await resp.json();
|
|
230
|
-
} catch (err) {
|
|
231
|
-
if (err instanceof RouteDockManifestError) throw err;
|
|
232
|
-
throw new RouteDockManifestError(`Manifest fetch error: ${String(err)}`);
|
|
233
892
|
}
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
893
|
+
if (modes.includes("mpp-charge")) return { mode: "mpp-charge" };
|
|
894
|
+
if (modes.includes("x402")) return { mode: "x402" };
|
|
895
|
+
return void 0;
|
|
896
|
+
}
|
|
897
|
+
function logSelection(manifest, selection, log, deprecated) {
|
|
898
|
+
const reason = selection.reason ? ` (${selection.reason})` : "";
|
|
899
|
+
if (deprecated) {
|
|
900
|
+
log(
|
|
901
|
+
`[RouteDock] WARNING: ${manifest.name} \u2192 ${selection.mode}${reason}; selected deprecated mode because no active supported mode is available`
|
|
902
|
+
);
|
|
903
|
+
return;
|
|
237
904
|
}
|
|
238
|
-
|
|
239
|
-
manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now() });
|
|
240
|
-
return manifest;
|
|
905
|
+
log(`[RouteDock] ${manifest.name} \u2192 ${selection.mode}${reason}`);
|
|
241
906
|
}
|
|
242
907
|
function selectMode(manifest, options = {}) {
|
|
243
908
|
const modes = manifest.modes;
|
|
909
|
+
const log = options.logger ?? (() => {
|
|
910
|
+
});
|
|
911
|
+
const deprecatedSet = new Set(manifest.deprecated_modes ?? []);
|
|
244
912
|
if (options.forceMode) {
|
|
245
913
|
if (!modes.includes(options.forceMode)) {
|
|
246
914
|
throw new RouteDockNoSupportedModeError(
|
|
247
915
|
`Provider does not support forced mode: ${options.forceMode} (available: ${modes.join(", ")})`
|
|
248
916
|
);
|
|
249
917
|
}
|
|
250
|
-
|
|
918
|
+
if (deprecatedSet.has(options.forceMode)) {
|
|
919
|
+
log(
|
|
920
|
+
`[RouteDock] WARNING: ${manifest.name} \u2192 ${options.forceMode} (forced); selected mode is deprecated`
|
|
921
|
+
);
|
|
922
|
+
} else {
|
|
923
|
+
log(`[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
|
|
924
|
+
}
|
|
251
925
|
return options.forceMode;
|
|
252
926
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const mode = "mpp-charge";
|
|
260
|
-
console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
|
|
261
|
-
return mode;
|
|
927
|
+
const activeModes = modes.filter((mode) => !deprecatedSet.has(mode));
|
|
928
|
+
const deprecatedModes = modes.filter((mode) => deprecatedSet.has(mode));
|
|
929
|
+
const activeSelection = selectFromModes(activeModes, manifest, options);
|
|
930
|
+
if (activeSelection) {
|
|
931
|
+
logSelection(manifest, activeSelection, log, false);
|
|
932
|
+
return activeSelection.mode;
|
|
262
933
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return mode;
|
|
934
|
+
const deprecatedSelection = selectFromModes(deprecatedModes, manifest, options);
|
|
935
|
+
if (deprecatedSelection) {
|
|
936
|
+
logSelection(manifest, deprecatedSelection, log, true);
|
|
937
|
+
return deprecatedSelection.mode;
|
|
267
938
|
}
|
|
268
939
|
throw new RouteDockNoSupportedModeError(
|
|
269
940
|
`No supported payment mode found in manifest (modes: ${modes.join(", ")})`
|
|
270
941
|
);
|
|
271
942
|
}
|
|
943
|
+
function rankProvidersByLatency(manifests, targetRegion) {
|
|
944
|
+
return [...manifests].sort((a, b) => {
|
|
945
|
+
const latA = a.latency_hints?.[targetRegion];
|
|
946
|
+
const latB = b.latency_hints?.[targetRegion];
|
|
947
|
+
const inRegionsA = a.regions?.includes(targetRegion) ?? false;
|
|
948
|
+
const inRegionsB = b.regions?.includes(targetRegion) ?? false;
|
|
949
|
+
if (latA !== void 0 && latB !== void 0) {
|
|
950
|
+
return latA - latB;
|
|
951
|
+
}
|
|
952
|
+
if (latA !== void 0) return -1;
|
|
953
|
+
if (latB !== void 0) return 1;
|
|
954
|
+
if (inRegionsA && !inRegionsB) return -1;
|
|
955
|
+
if (!inRegionsA && inRegionsB) return 1;
|
|
956
|
+
return 0;
|
|
957
|
+
});
|
|
958
|
+
}
|
|
272
959
|
var CAIP2 = {
|
|
273
960
|
testnet: "stellar:testnet",
|
|
274
961
|
mainnet: "stellar:pubnet"
|
|
275
962
|
};
|
|
276
|
-
var X402Client = class {
|
|
277
|
-
constructor(
|
|
278
|
-
this.secretKey = secretKey;
|
|
963
|
+
var X402Client = class _X402Client {
|
|
964
|
+
constructor(secretKeyOrSigner, network, retryPolicy) {
|
|
279
965
|
this.network = network;
|
|
966
|
+
this.retryPolicy = retryPolicy;
|
|
280
967
|
const caip2 = CAIP2[network];
|
|
281
|
-
|
|
282
|
-
const scheme = new ExactStellarScheme(signer);
|
|
968
|
+
this.signer = typeof secretKeyOrSigner === "string" ? createEd25519Signer(secretKeyOrSigner, caip2) : secretKeyOrSigner;
|
|
969
|
+
const scheme = new ExactStellarScheme(this.signer);
|
|
283
970
|
const core = new x402Client();
|
|
284
971
|
core.register(caip2, scheme);
|
|
285
972
|
this.httpClient = new x402HTTPClient(core);
|
|
286
973
|
}
|
|
287
|
-
secretKey;
|
|
288
974
|
network;
|
|
975
|
+
retryPolicy;
|
|
289
976
|
httpClient;
|
|
977
|
+
signer;
|
|
978
|
+
/** Replace signer (e.g. swap to Nulth ZK account payer before pay) */
|
|
979
|
+
withSigner(signer) {
|
|
980
|
+
return new _X402Client(signer, this.network, this.retryPolicy);
|
|
981
|
+
}
|
|
290
982
|
async pay(url, manifest) {
|
|
291
983
|
const pricing = manifest.pricing.x402;
|
|
292
984
|
if (!pricing) {
|
|
@@ -295,82 +987,194 @@ var X402Client = class {
|
|
|
295
987
|
if (!pricing.facilitator) {
|
|
296
988
|
throw new RouteDockManifestError("manifest.pricing.x402.facilitator missing");
|
|
297
989
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const data2 = await init.json();
|
|
301
|
-
return { data: data2, txHash: null, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
|
|
302
|
-
}
|
|
303
|
-
const reqHeader = init.headers.get("X-Payment-Requirements");
|
|
304
|
-
if (!reqHeader) {
|
|
305
|
-
throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
|
|
306
|
-
}
|
|
307
|
-
const paymentRequired = decodePaymentRequiredHeader(reqHeader);
|
|
308
|
-
const paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
|
|
309
|
-
const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
|
|
310
|
-
const settled = await fetch(url, { headers: paymentHeaders });
|
|
311
|
-
if (!settled.ok) {
|
|
312
|
-
throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
|
|
313
|
-
}
|
|
314
|
-
let txHash = null;
|
|
315
|
-
const responseHeader = settled.headers.get("X-Payment-Response");
|
|
316
|
-
if (responseHeader) {
|
|
990
|
+
return withRetry(async () => {
|
|
991
|
+
let init;
|
|
317
992
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
993
|
+
init = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
|
|
994
|
+
} catch (err) {
|
|
995
|
+
throw wrapFetchError(err, "x402 initial request");
|
|
321
996
|
}
|
|
997
|
+
if (init.status !== 402) {
|
|
998
|
+
if (!init.ok) {
|
|
999
|
+
throw httpStatusToError(
|
|
1000
|
+
`x402 initial request failed: HTTP ${init.status}`,
|
|
1001
|
+
init.status,
|
|
1002
|
+
init
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
let data2;
|
|
1006
|
+
try {
|
|
1007
|
+
data2 = await init.json();
|
|
1008
|
+
} catch {
|
|
1009
|
+
throw new RouteDockManifestError(`Failed to parse JSON from non-402 response (HTTP ${init.status})`);
|
|
1010
|
+
}
|
|
1011
|
+
return { data: data2, txHash: null, mode: "x402", amount: "0", timestamp: Date.now() };
|
|
1012
|
+
}
|
|
1013
|
+
const reqHeader = init.headers.get("X-Payment-Requirements");
|
|
1014
|
+
if (!reqHeader) {
|
|
1015
|
+
throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
|
|
1016
|
+
}
|
|
1017
|
+
let paymentRequired;
|
|
1018
|
+
try {
|
|
1019
|
+
paymentRequired = decodePaymentRequiredHeader(reqHeader);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
throw new RouteDockManifestError("402 X-Payment-Requirements header is malformed", {
|
|
1022
|
+
cause: err
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
let paymentPayload;
|
|
1026
|
+
try {
|
|
1027
|
+
paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
throw new RouteDockSignatureError(`x402 payment signing failed: ${String(err)}`, {
|
|
1030
|
+
cause: err
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
|
|
1034
|
+
let settled;
|
|
1035
|
+
try {
|
|
1036
|
+
settled = await fetch(url, { headers: paymentHeaders });
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
throw wrapFetchError(err, "x402 settlement request");
|
|
1039
|
+
}
|
|
1040
|
+
if (!settled.ok) {
|
|
1041
|
+
if (settled.status >= 500 || settled.status === 429 || settled.status === 503) {
|
|
1042
|
+
throw httpStatusToError(
|
|
1043
|
+
`x402 payment failed: HTTP ${settled.status}`,
|
|
1044
|
+
settled.status,
|
|
1045
|
+
settled
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
|
|
1049
|
+
}
|
|
1050
|
+
let txHash = null;
|
|
1051
|
+
const responseHeader = settled.headers.get("X-Payment-Response");
|
|
1052
|
+
if (responseHeader) {
|
|
1053
|
+
try {
|
|
1054
|
+
const settleResponse = decodePaymentResponseHeader(responseHeader);
|
|
1055
|
+
txHash = settleResponse.transaction ?? null;
|
|
1056
|
+
} catch {
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
let data;
|
|
1060
|
+
try {
|
|
1061
|
+
data = await settled.json();
|
|
1062
|
+
} catch {
|
|
1063
|
+
throw new RouteDockManifestError("Failed to parse JSON from settled response");
|
|
1064
|
+
}
|
|
1065
|
+
return { data, txHash, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
|
|
1066
|
+
}, this.retryPolicy);
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
var MppChargeClient = class {
|
|
1070
|
+
constructor(keypair, network, retryPolicy) {
|
|
1071
|
+
this.keypair = keypair;
|
|
1072
|
+
this.network = network;
|
|
1073
|
+
this.retryPolicy = retryPolicy;
|
|
1074
|
+
}
|
|
1075
|
+
keypair;
|
|
1076
|
+
network;
|
|
1077
|
+
retryPolicy;
|
|
1078
|
+
async pay(url, manifest) {
|
|
1079
|
+
const pricing = manifest.pricing["mpp-charge"];
|
|
1080
|
+
if (!pricing) {
|
|
1081
|
+
throw new RouteDockManifestError("manifest.pricing.mpp-charge missing");
|
|
322
1082
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
1083
|
+
return withRetry(async () => {
|
|
1084
|
+
let txHash = null;
|
|
1085
|
+
const mppx = Mppx.create({
|
|
1086
|
+
polyfill: false,
|
|
1087
|
+
methods: [
|
|
1088
|
+
stellar.charge({
|
|
1089
|
+
keypair: this.keypair,
|
|
1090
|
+
mode: "pull",
|
|
1091
|
+
onProgress(event) {
|
|
1092
|
+
if (event.type === "paid") {
|
|
1093
|
+
txHash = event.hash;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
})
|
|
1097
|
+
]
|
|
1098
|
+
});
|
|
1099
|
+
let response;
|
|
1100
|
+
try {
|
|
1101
|
+
response = await mppx.fetch(url);
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
throw wrapFetchError(err, "MPP charge request");
|
|
1104
|
+
}
|
|
1105
|
+
if (!response.ok) {
|
|
1106
|
+
if (response.status >= 500 || response.status === 429 || response.status === 503) {
|
|
1107
|
+
throw httpStatusToError(
|
|
1108
|
+
`MPP charge failed: HTTP ${response.status}`,
|
|
1109
|
+
response.status,
|
|
1110
|
+
response
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`);
|
|
1114
|
+
}
|
|
1115
|
+
let data;
|
|
1116
|
+
try {
|
|
1117
|
+
data = await response.json();
|
|
1118
|
+
} catch (cause) {
|
|
1119
|
+
throw new RouteDockManifestError(
|
|
1120
|
+
`Failed to parse JSON from response (HTTP ${response.status})`,
|
|
1121
|
+
{ cause }
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
return { data, txHash, mode: "mpp-charge", amount: pricing.amount, timestamp: Date.now() };
|
|
1125
|
+
}, this.retryPolicy);
|
|
360
1126
|
}
|
|
361
1127
|
};
|
|
362
1128
|
var MIN_REFUND_WAITING_PERIOD = 17280;
|
|
1129
|
+
var STREAM_CLEANUP_TIMEOUT_MS = 3e3;
|
|
1130
|
+
var WS_CONNECTING = 0;
|
|
1131
|
+
var WS_OPEN = 1;
|
|
1132
|
+
function defaultWebSocketFactory(url, init, handlers) {
|
|
1133
|
+
const socket = new WebSocket(
|
|
1134
|
+
url,
|
|
1135
|
+
{ headers: init.headers }
|
|
1136
|
+
);
|
|
1137
|
+
socket.onopen = () => handlers.onOpen();
|
|
1138
|
+
socket.onmessage = (event) => {
|
|
1139
|
+
const data = event.data;
|
|
1140
|
+
if (typeof data === "string") {
|
|
1141
|
+
handlers.onMessage(data);
|
|
1142
|
+
} else if (data instanceof ArrayBuffer) {
|
|
1143
|
+
handlers.onMessage(Buffer.from(data).toString("utf8"));
|
|
1144
|
+
} else {
|
|
1145
|
+
void data.arrayBuffer().then((buffer) => handlers.onMessage(Buffer.from(buffer).toString("utf8"))).catch(() => {
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
socket.onclose = (event) => handlers.onClose(event.code, event.reason);
|
|
1150
|
+
socket.onerror = () => handlers.onError();
|
|
1151
|
+
return socket;
|
|
1152
|
+
}
|
|
1153
|
+
function parseIntegerString(value) {
|
|
1154
|
+
return /^\d+$/.test(value) ? BigInt(value) : null;
|
|
1155
|
+
}
|
|
1156
|
+
function clientCumulativeStoreKey(networkId, channel) {
|
|
1157
|
+
return `stellar:channel:client:${networkId}:${channel}:cumulative`;
|
|
1158
|
+
}
|
|
1159
|
+
function resolveStoreNetworkId(network) {
|
|
1160
|
+
return network === STELLAR_PUBNET ? STELLAR_PUBNET : STELLAR_TESTNET;
|
|
1161
|
+
}
|
|
363
1162
|
var MppSessionClient = class {
|
|
364
|
-
constructor(keypair, network) {
|
|
1163
|
+
constructor(keypair, network, retryPolicy, webSocketFactory = defaultWebSocketFactory) {
|
|
365
1164
|
this.keypair = keypair;
|
|
366
1165
|
this.network = network;
|
|
1166
|
+
this.retryPolicy = retryPolicy;
|
|
1167
|
+
this.webSocketFactory = webSocketFactory;
|
|
367
1168
|
}
|
|
368
1169
|
keypair;
|
|
369
1170
|
network;
|
|
370
|
-
|
|
371
|
-
|
|
1171
|
+
retryPolicy;
|
|
1172
|
+
webSocketFactory;
|
|
1173
|
+
async openSession(url, manifest, commitmentSecret, options, onSpend) {
|
|
1174
|
+
const mode = options?.mode ?? "mpp-session";
|
|
1175
|
+
const pricing = manifest.pricing[mode];
|
|
372
1176
|
if (!pricing) {
|
|
373
|
-
throw new RouteDockManifestError(
|
|
1177
|
+
throw new RouteDockManifestError(`manifest.pricing.${mode} missing`);
|
|
374
1178
|
}
|
|
375
1179
|
const refundPeriod = pricing.refund_waiting_period_ledgers;
|
|
376
1180
|
if (refundPeriod < MIN_REFUND_WAITING_PERIOD) {
|
|
@@ -379,166 +1183,1383 @@ var MppSessionClient = class {
|
|
|
379
1183
|
);
|
|
380
1184
|
}
|
|
381
1185
|
const commitmentKey = Keypair.fromSecret(commitmentSecret);
|
|
382
|
-
const
|
|
1186
|
+
const channelFactory = pricing.channel_factory;
|
|
383
1187
|
const agentPublicKey = this.keypair.publicKey();
|
|
1188
|
+
const agentKeypair = this.keypair;
|
|
384
1189
|
let currentCumulative = 0n;
|
|
385
1190
|
let vouchersIssued = 0;
|
|
1191
|
+
let rateStroops;
|
|
1192
|
+
try {
|
|
1193
|
+
rateStroops = usdcToStroops(pricing.rate);
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
throw new RouteDockManifestError(
|
|
1196
|
+
`manifest.pricing.${mode}.rate is not a valid USDC amount: "${pricing.rate}"`,
|
|
1197
|
+
{ cause: err }
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
const store = options?.store;
|
|
1201
|
+
let reservedCumulative = null;
|
|
1202
|
+
let storeSeed;
|
|
1203
|
+
const seedFromStore = (networkId, channel) => {
|
|
1204
|
+
storeSeed ??= Promise.resolve(
|
|
1205
|
+
store.get(clientCumulativeStoreKey(networkId, channel))
|
|
1206
|
+
).then((stored) => {
|
|
1207
|
+
if (stored && typeof stored === "object" && "amount" in stored) {
|
|
1208
|
+
const raw = stored.amount;
|
|
1209
|
+
const amount = typeof raw === "string" ? parseIntegerString(raw) : null;
|
|
1210
|
+
if (amount === null) {
|
|
1211
|
+
throw new RouteDockChannelStateError(
|
|
1212
|
+
`Stored cumulative for channel ${channel} is not an integer string: ${JSON.stringify(raw)}`
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
return amount;
|
|
1216
|
+
}
|
|
1217
|
+
return 0n;
|
|
1218
|
+
});
|
|
1219
|
+
return storeSeed;
|
|
1220
|
+
};
|
|
1221
|
+
const reserveNextCumulative = async (challenge) => {
|
|
1222
|
+
const request = challenge.request;
|
|
1223
|
+
const channelValue = request["channel"];
|
|
1224
|
+
if (typeof channelValue !== "string" || channelValue !== channelFactory) {
|
|
1225
|
+
throw new RouteDockChannelStateError(
|
|
1226
|
+
`Challenge names channel ${String(channelValue)}, but the manifest channel_factory is ${channelFactory}`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
const amountValue = request["amount"];
|
|
1230
|
+
const amount = typeof amountValue === "string" ? parseIntegerString(amountValue) : null;
|
|
1231
|
+
if (amount === null) {
|
|
1232
|
+
throw new RouteDockChannelStateError(
|
|
1233
|
+
`Challenge request.amount is not a base-10 integer string: ${JSON.stringify(amountValue)}`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
if (amount !== rateStroops) {
|
|
1237
|
+
throw new RouteDockChannelStateError(
|
|
1238
|
+
`Challenge request.amount ${amount} does not match the manifest rate ${rateStroops} stroops per voucher`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
const methodDetailsValue = request["methodDetails"];
|
|
1242
|
+
const methodDetails = methodDetailsValue !== null && typeof methodDetailsValue === "object" ? methodDetailsValue : void 0;
|
|
1243
|
+
let serverReported = 0n;
|
|
1244
|
+
if (methodDetails && "cumulativeAmount" in methodDetails) {
|
|
1245
|
+
const rawCumulative = methodDetails["cumulativeAmount"];
|
|
1246
|
+
const parsed = typeof rawCumulative === "string" ? parseIntegerString(rawCumulative) : null;
|
|
1247
|
+
if (parsed === null) {
|
|
1248
|
+
throw new RouteDockChannelStateError(
|
|
1249
|
+
`Challenge methodDetails.cumulativeAmount is not a base-10 integer string: ${JSON.stringify(rawCumulative)}`
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
serverReported = parsed;
|
|
1253
|
+
}
|
|
1254
|
+
let baseline = reservedCumulative;
|
|
1255
|
+
if (baseline === null) {
|
|
1256
|
+
if (store) {
|
|
1257
|
+
const challengedNetwork = methodDetails?.["network"];
|
|
1258
|
+
const seeded = await seedFromStore(
|
|
1259
|
+
resolveStoreNetworkId(
|
|
1260
|
+
typeof challengedNetwork === "string" ? challengedNetwork : void 0
|
|
1261
|
+
),
|
|
1262
|
+
channelValue
|
|
1263
|
+
);
|
|
1264
|
+
baseline = reservedCumulative ?? seeded;
|
|
1265
|
+
} else if (serverReported === 0n) {
|
|
1266
|
+
baseline = 0n;
|
|
1267
|
+
} else {
|
|
1268
|
+
throw new RouteDockChannelStateError(
|
|
1269
|
+
`Challenge reports cumulative ${serverReported} for a channel this client has no baseline for. Pass a \`store\` in SessionOptions so the signed cumulative survives restarts, or close the channel before reusing it.`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (serverReported > baseline) {
|
|
1274
|
+
throw new RouteDockChannelStateError(
|
|
1275
|
+
`Challenge reports cumulative ${serverReported}, above the locally reserved baseline ${baseline}`
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
const next = baseline + rateStroops;
|
|
1279
|
+
reservedCumulative = next;
|
|
1280
|
+
return next;
|
|
1281
|
+
};
|
|
1282
|
+
const releaseReservation = (reserved) => {
|
|
1283
|
+
if (reservedCumulative === reserved) {
|
|
1284
|
+
reservedCumulative = reserved - rateStroops;
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
const createGuardedCredential = async (challenge, create) => {
|
|
1288
|
+
const next = await reserveNextCumulative(challenge);
|
|
1289
|
+
try {
|
|
1290
|
+
return await create(next.toString());
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
releaseReservation(next);
|
|
1293
|
+
throw err;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
const streamWs = (u, m, createCredential, onSigned) => this.streamWebSocket(u, m, createCredential, onSigned);
|
|
386
1297
|
const mppx = Mppx.create({
|
|
387
1298
|
polyfill: false,
|
|
388
1299
|
methods: [
|
|
389
1300
|
stellar$1.channel({
|
|
390
1301
|
commitmentKey,
|
|
391
1302
|
sourceAccount: this.keypair.publicKey(),
|
|
1303
|
+
...store ? { store } : {},
|
|
392
1304
|
onProgress(event) {
|
|
393
1305
|
if (event.type === "signed") {
|
|
394
1306
|
currentCumulative = BigInt(event.cumulativeAmount);
|
|
395
1307
|
}
|
|
396
1308
|
}
|
|
397
1309
|
})
|
|
398
|
-
]
|
|
1310
|
+
],
|
|
1311
|
+
// mppx runs onChallenge on the HTTP path only (createCredential skips it),
|
|
1312
|
+
// so the WebSocket path runs the same guard inside streamWebSocket.
|
|
1313
|
+
onChallenge: (challenge, helpers) => createGuardedCredential(
|
|
1314
|
+
challenge,
|
|
1315
|
+
(cumulativeAmount) => helpers.createCredential({ cumulativeAmount })
|
|
1316
|
+
)
|
|
399
1317
|
});
|
|
400
|
-
const
|
|
1318
|
+
const retryPolicy = this.retryPolicy;
|
|
1319
|
+
const maxDurationMs = options?.maxDurationMs ?? DEFAULT_MAX_SESSION_DURATION_MS;
|
|
1320
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1321
|
+
let timeoutId;
|
|
1322
|
+
let closed = false;
|
|
1323
|
+
const emit = (event, payload) => {
|
|
1324
|
+
const set = listeners.get(event);
|
|
1325
|
+
if (!set) return;
|
|
1326
|
+
for (const listener of set) {
|
|
1327
|
+
try {
|
|
1328
|
+
const returned = listener(payload);
|
|
1329
|
+
if (returned && typeof returned.then === "function") {
|
|
1330
|
+
void Promise.resolve(returned).catch(() => {
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
} catch {
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
const clearSessionTimer = () => {
|
|
1338
|
+
if (timeoutId !== void 0) {
|
|
1339
|
+
clearTimeout(timeoutId);
|
|
1340
|
+
timeoutId = void 0;
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
const network = this.network;
|
|
401
1344
|
const handle = {
|
|
402
|
-
channelId:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1345
|
+
channelId: channelFactory,
|
|
1346
|
+
// The channel is pre-deployed and funded before the agent runs, so the
|
|
1347
|
+
// client never issues the channel-open transaction and has no hash for
|
|
1348
|
+
// it. Report null rather than the contract address (a non-transaction
|
|
1349
|
+
// identifier that produces broken explorer links downstream).
|
|
1350
|
+
openTxHash: null,
|
|
1351
|
+
/**
|
|
1352
|
+
* Live session snapshot. vouchersIssued increments once per signed voucher
|
|
1353
|
+
* and currentCumulative is updated by the channel client's onProgress when
|
|
1354
|
+
* a voucher is signed, so a stats() call immediately after a yield always
|
|
1355
|
+
* reflects everything consumed so far.
|
|
1356
|
+
*/
|
|
1357
|
+
stats() {
|
|
1358
|
+
return {
|
|
1359
|
+
vouchersIssued,
|
|
1360
|
+
// Same conversion as SessionCloseResult.totalPaid so a per-session
|
|
1361
|
+
// sub-cap can be compared against stats().currentCumulative directly.
|
|
1362
|
+
currentCumulative: (Number(currentCumulative) / 1e7).toFixed(7),
|
|
1363
|
+
channelId: channelFactory,
|
|
1364
|
+
openTxHash: null
|
|
1365
|
+
};
|
|
1366
|
+
},
|
|
1367
|
+
async *stream(options2) {
|
|
1368
|
+
const checkSpend = () => {
|
|
1369
|
+
if (!onSpend) return Promise.resolve();
|
|
1370
|
+
return onSpend(pricing.rate);
|
|
1371
|
+
};
|
|
1372
|
+
if (mode === "mpp-session-ws") {
|
|
1373
|
+
await checkSpend();
|
|
1374
|
+
const onSigned = () => {
|
|
1375
|
+
vouchersIssued++;
|
|
1376
|
+
};
|
|
1377
|
+
for await (const item of streamWs(url, mppx, createGuardedCredential, onSigned)) {
|
|
1378
|
+
yield item;
|
|
1379
|
+
}
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const concurrency = Math.max(1, options2?.concurrency ?? 1);
|
|
1383
|
+
const assertOpen = () => {
|
|
1384
|
+
if (closed) {
|
|
1385
|
+
throw new RouteDockChannelStateError("session closed");
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
const abortController = new AbortController();
|
|
1389
|
+
const doFetch = () => withRetry(async () => {
|
|
1390
|
+
let resp;
|
|
1391
|
+
try {
|
|
1392
|
+
resp = await mppx.fetch(url, { signal: abortController.signal });
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
if (abortController.signal.aborted) {
|
|
1395
|
+
throw err;
|
|
415
1396
|
}
|
|
1397
|
+
throw wrapFetchError(err, "Voucher request");
|
|
416
1398
|
}
|
|
417
|
-
if (!resp
|
|
418
|
-
|
|
1399
|
+
if (!resp.ok) {
|
|
1400
|
+
if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
|
|
1401
|
+
throw httpStatusToError(
|
|
1402
|
+
`Voucher request failed: HTTP ${resp.status}`,
|
|
1403
|
+
resp.status,
|
|
1404
|
+
resp
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
throw new RouteDockChannelStateError(
|
|
1408
|
+
`Voucher request failed: HTTP ${resp.status}`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
try {
|
|
1412
|
+
return await resp.json();
|
|
1413
|
+
} catch (cause) {
|
|
1414
|
+
throw new RouteDockChannelStateError(
|
|
1415
|
+
`Voucher response was not valid JSON (HTTP ${resp.status})`,
|
|
1416
|
+
{ cause }
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
}, retryPolicy);
|
|
1420
|
+
if (concurrency === 1) {
|
|
1421
|
+
try {
|
|
1422
|
+
while (true) {
|
|
1423
|
+
assertOpen();
|
|
1424
|
+
await checkSpend();
|
|
1425
|
+
const data = await doFetch();
|
|
1426
|
+
vouchersIssued++;
|
|
1427
|
+
yield data;
|
|
1428
|
+
}
|
|
1429
|
+
} finally {
|
|
1430
|
+
abortController.abort();
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
const queue = [];
|
|
1434
|
+
try {
|
|
1435
|
+
for (let i = 0; i < concurrency; i++) {
|
|
1436
|
+
assertOpen();
|
|
1437
|
+
await checkSpend();
|
|
1438
|
+
const p = doFetch();
|
|
1439
|
+
p.catch(() => {
|
|
1440
|
+
});
|
|
1441
|
+
queue.push(p);
|
|
1442
|
+
}
|
|
1443
|
+
while (true) {
|
|
1444
|
+
assertOpen();
|
|
1445
|
+
const data = await queue.shift();
|
|
1446
|
+
await checkSpend();
|
|
1447
|
+
const p = doFetch();
|
|
1448
|
+
p.catch(() => {
|
|
1449
|
+
});
|
|
1450
|
+
queue.push(p);
|
|
1451
|
+
vouchersIssued++;
|
|
1452
|
+
yield data;
|
|
1453
|
+
}
|
|
1454
|
+
} finally {
|
|
1455
|
+
abortController.abort();
|
|
1456
|
+
let timer;
|
|
1457
|
+
try {
|
|
1458
|
+
await Promise.race([
|
|
1459
|
+
Promise.allSettled(queue),
|
|
1460
|
+
new Promise((resolve) => {
|
|
1461
|
+
timer = setTimeout(resolve, STREAM_CLEANUP_TIMEOUT_MS);
|
|
1462
|
+
timer.unref?.();
|
|
1463
|
+
})
|
|
1464
|
+
]);
|
|
1465
|
+
} finally {
|
|
1466
|
+
if (timer) {
|
|
1467
|
+
clearTimeout(timer);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
queue.length = 0;
|
|
419
1471
|
}
|
|
420
|
-
vouchersIssued++;
|
|
421
|
-
yield await resp.json();
|
|
422
1472
|
}
|
|
423
1473
|
},
|
|
424
1474
|
async close() {
|
|
425
|
-
|
|
426
|
-
|
|
1475
|
+
clearSessionTimer();
|
|
1476
|
+
closed = true;
|
|
1477
|
+
const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
|
|
1478
|
+
const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
|
|
427
1479
|
const server = new rpcMod.Server(rpcUrl);
|
|
428
|
-
const contract = new
|
|
429
|
-
const passphrase = "
|
|
430
|
-
const account = await
|
|
431
|
-
|
|
432
|
-
|
|
1480
|
+
const contract = new Contract2(channelFactory);
|
|
1481
|
+
const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
|
|
1482
|
+
const account = await withRetry(async () => {
|
|
1483
|
+
try {
|
|
1484
|
+
return await server.getAccount(agentPublicKey);
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
throw wrapFetchError(err, "Horizon getAccount");
|
|
1487
|
+
}
|
|
1488
|
+
}, retryPolicy);
|
|
1489
|
+
const simResult = await withRetry(async () => {
|
|
1490
|
+
try {
|
|
1491
|
+
const simTx = new TransactionBuilder2(account, {
|
|
1492
|
+
fee: BASE_FEE2,
|
|
1493
|
+
networkPassphrase: passphrase
|
|
1494
|
+
}).addOperation(
|
|
1495
|
+
contract.call(
|
|
1496
|
+
"prepare_commitment",
|
|
1497
|
+
nativeToScVal2(currentCumulative, { type: "i128" })
|
|
1498
|
+
)
|
|
1499
|
+
).setTimeout(30).build();
|
|
1500
|
+
return await server.simulateTransaction(simTx);
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
throw wrapFetchError(err, "prepare_commitment RPC");
|
|
1503
|
+
}
|
|
1504
|
+
}, retryPolicy);
|
|
433
1505
|
if (rpcMod.Api.isSimulationError(simResult)) {
|
|
434
|
-
throw new
|
|
1506
|
+
throw new RouteDockChannelStateError(
|
|
1507
|
+
`prepare_commitment simulation failed: ${simResult.error}`
|
|
1508
|
+
);
|
|
435
1509
|
}
|
|
436
1510
|
const commitmentBytes = simResult.result?.retval?.bytes();
|
|
437
|
-
if (!commitmentBytes)
|
|
438
|
-
|
|
439
|
-
const closeResp = await fetch(url, {
|
|
440
|
-
method: "DELETE",
|
|
441
|
-
headers: { "Content-Type": "application/json" },
|
|
442
|
-
body: JSON.stringify({
|
|
443
|
-
amount: currentCumulative.toString(),
|
|
444
|
-
signature: Buffer.from(signature).toString("hex")
|
|
445
|
-
})
|
|
446
|
-
});
|
|
447
|
-
if (!closeResp.ok) {
|
|
448
|
-
throw new RouteDockSessionError(`Channel close failed: HTTP ${closeResp.status}`);
|
|
1511
|
+
if (!commitmentBytes) {
|
|
1512
|
+
throw new RouteDockChannelStateError("prepare_commitment returned no bytes");
|
|
449
1513
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1514
|
+
let signature;
|
|
1515
|
+
try {
|
|
1516
|
+
signature = commitmentKey.sign(Buffer.from(commitmentBytes));
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
throw new RouteDockSignatureError("Channel close commitment signing failed", {
|
|
1519
|
+
cause: err
|
|
1520
|
+
});
|
|
454
1521
|
}
|
|
1522
|
+
const closeData = await withRetry(async () => {
|
|
1523
|
+
let closeResp;
|
|
1524
|
+
try {
|
|
1525
|
+
closeResp = await fetch(url, {
|
|
1526
|
+
method: "DELETE",
|
|
1527
|
+
headers: { "Content-Type": "application/json" },
|
|
1528
|
+
body: JSON.stringify({
|
|
1529
|
+
amount: currentCumulative.toString(),
|
|
1530
|
+
signature: Buffer.from(signature).toString("hex")
|
|
1531
|
+
})
|
|
1532
|
+
});
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
throw wrapFetchError(err, "Channel close request");
|
|
1535
|
+
}
|
|
1536
|
+
if (!closeResp.ok) {
|
|
1537
|
+
if (closeResp.status >= 500 || closeResp.status === 429 || closeResp.status === 503) {
|
|
1538
|
+
throw httpStatusToError(
|
|
1539
|
+
`Channel close failed: HTTP ${closeResp.status}`,
|
|
1540
|
+
closeResp.status,
|
|
1541
|
+
closeResp
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
throw new RouteDockChannelStateError(
|
|
1545
|
+
`Channel close failed: HTTP ${closeResp.status}`
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
let body;
|
|
1549
|
+
try {
|
|
1550
|
+
body = await closeResp.json();
|
|
1551
|
+
} catch (cause) {
|
|
1552
|
+
throw new RouteDockChannelStateError(
|
|
1553
|
+
`Channel close response was not valid JSON (HTTP ${closeResp.status})`,
|
|
1554
|
+
{ cause }
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
const closeTxHash = body.closeTxHash ?? null;
|
|
1558
|
+
if (!closeTxHash) {
|
|
1559
|
+
throw new RouteDockChannelStateError(
|
|
1560
|
+
"Channel close response missing closeTxHash"
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
return { closeTxHash, body };
|
|
1564
|
+
}, retryPolicy);
|
|
455
1565
|
const totalPaid = (Number(currentCumulative) / 1e7).toFixed(7);
|
|
456
|
-
return {
|
|
1566
|
+
return {
|
|
1567
|
+
closeTxHash: closeData.closeTxHash,
|
|
1568
|
+
totalPaid,
|
|
1569
|
+
vouchersIssued
|
|
1570
|
+
};
|
|
1571
|
+
},
|
|
1572
|
+
async requestRefund() {
|
|
1573
|
+
const { rpc: rpcMod, Contract: Contract2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
|
|
1574
|
+
const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
|
|
1575
|
+
const server = new rpcMod.Server(rpcUrl);
|
|
1576
|
+
const contract = new Contract2(channelFactory);
|
|
1577
|
+
const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
|
|
1578
|
+
try {
|
|
1579
|
+
const account = await server.getAccount(agentPublicKey);
|
|
1580
|
+
const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("request_refund")).setTimeout(30).build();
|
|
1581
|
+
const simResult = await server.simulateTransaction(tx);
|
|
1582
|
+
if (rpcMod.Api.isSimulationError(simResult)) {
|
|
1583
|
+
throw new RouteDockDisputeError(`request_refund simulation failed: ${simResult.error}`);
|
|
1584
|
+
}
|
|
1585
|
+
const preparedTx = await server.prepareTransaction(tx);
|
|
1586
|
+
preparedTx.sign(agentKeypair);
|
|
1587
|
+
const result = await server.sendTransaction(preparedTx);
|
|
1588
|
+
if (result.status === "ERROR") {
|
|
1589
|
+
const errDetail = result.errorResult ? JSON.stringify(result.errorResult) : "status ERROR";
|
|
1590
|
+
throw new RouteDockDisputeError(`Refund request transaction failed: ${errDetail}`);
|
|
1591
|
+
}
|
|
1592
|
+
if (!result.hash) {
|
|
1593
|
+
throw new RouteDockDisputeError("Refund request transaction not sent");
|
|
1594
|
+
}
|
|
1595
|
+
return result.hash;
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
if (err instanceof RouteDockDisputeError) throw err;
|
|
1598
|
+
throw new RouteDockDisputeError(`Failed to request refund: ${err instanceof Error ? err.message : String(err)}`);
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
async settleWithLatestVoucher() {
|
|
1602
|
+
const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
|
|
1603
|
+
const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
|
|
1604
|
+
const server = new rpcMod.Server(rpcUrl);
|
|
1605
|
+
const contract = new Contract2(channelFactory);
|
|
1606
|
+
const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
|
|
1607
|
+
try {
|
|
1608
|
+
const account = await server.getAccount(agentPublicKey);
|
|
1609
|
+
const simTx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("prepare_commitment", nativeToScVal2(currentCumulative, { type: "i128" }))).setTimeout(30).build();
|
|
1610
|
+
const simResult = await server.simulateTransaction(simTx);
|
|
1611
|
+
if (rpcMod.Api.isSimulationError(simResult)) {
|
|
1612
|
+
throw new RouteDockDisputeError(`prepare_commitment simulation failed: ${simResult.error}`);
|
|
1613
|
+
}
|
|
1614
|
+
const commitmentBytes = simResult.result?.retval?.bytes();
|
|
1615
|
+
if (!commitmentBytes) throw new RouteDockDisputeError("prepare_commitment returned no bytes");
|
|
1616
|
+
const signature = commitmentKey.sign(Buffer.from(commitmentBytes));
|
|
1617
|
+
const settleAccount = await server.getAccount(agentPublicKey);
|
|
1618
|
+
const settleTx = new TransactionBuilder2(settleAccount, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(
|
|
1619
|
+
contract.call(
|
|
1620
|
+
"settle_with_signature",
|
|
1621
|
+
nativeToScVal2(currentCumulative, { type: "i128" }),
|
|
1622
|
+
nativeToScVal2(Buffer.from(signature))
|
|
1623
|
+
)
|
|
1624
|
+
).setTimeout(30).build();
|
|
1625
|
+
const preparedSettleTx = await server.prepareTransaction(settleTx);
|
|
1626
|
+
preparedSettleTx.sign(agentKeypair);
|
|
1627
|
+
const settleResult = await server.sendTransaction(preparedSettleTx);
|
|
1628
|
+
if (settleResult.status === "ERROR") {
|
|
1629
|
+
const errDetail = settleResult.errorResult ? JSON.stringify(settleResult.errorResult) : "status ERROR";
|
|
1630
|
+
throw new RouteDockDisputeError(`Settlement transaction failed: ${errDetail}`);
|
|
1631
|
+
}
|
|
1632
|
+
if (!settleResult.hash) {
|
|
1633
|
+
throw new RouteDockDisputeError("Settlement transaction not sent");
|
|
1634
|
+
}
|
|
1635
|
+
return settleResult.hash;
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
if (err instanceof RouteDockDisputeError) throw err;
|
|
1638
|
+
throw new RouteDockDisputeError(`Failed to settle with latest voucher: ${err instanceof Error ? err.message : String(err)}`);
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
async getDisputeStatus() {
|
|
1642
|
+
const { rpc: rpcMod, Contract: Contract2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
|
|
1643
|
+
const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
|
|
1644
|
+
const server = new rpcMod.Server(rpcUrl);
|
|
1645
|
+
const contract = new Contract2(channelFactory);
|
|
1646
|
+
const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
|
|
1647
|
+
try {
|
|
1648
|
+
const account = await server.getAccount(agentPublicKey);
|
|
1649
|
+
const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("get_channel_state")).setTimeout(30).build();
|
|
1650
|
+
const simResult = await server.simulateTransaction(tx);
|
|
1651
|
+
if (rpcMod.Api.isSimulationError(simResult)) {
|
|
1652
|
+
throw new RouteDockChannelStateError(`Failed to query channel state: ${simResult.error}`);
|
|
1653
|
+
}
|
|
1654
|
+
const retval = simResult.result?.retval;
|
|
1655
|
+
if (!retval) {
|
|
1656
|
+
throw new RouteDockChannelStateError("No channel state returned");
|
|
1657
|
+
}
|
|
1658
|
+
if (typeof retval === "object" && retval !== null) {
|
|
1659
|
+
const status = retval.status;
|
|
1660
|
+
if (status === "open") return "open";
|
|
1661
|
+
if (status === "in_refund_window") return "in-refund-window";
|
|
1662
|
+
if (status === "refundable") return "refundable";
|
|
1663
|
+
if (status === "settled") return "settled";
|
|
1664
|
+
}
|
|
1665
|
+
return "open";
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
if (err instanceof RouteDockChannelStateError) throw err;
|
|
1668
|
+
throw new RouteDockChannelStateError(`Failed to get dispute status: ${err instanceof Error ? err.message : String(err)}`);
|
|
1669
|
+
}
|
|
1670
|
+
},
|
|
1671
|
+
on(event, listener) {
|
|
1672
|
+
let set = listeners.get(event);
|
|
1673
|
+
if (!set) {
|
|
1674
|
+
set = /* @__PURE__ */ new Set();
|
|
1675
|
+
listeners.set(event, set);
|
|
1676
|
+
}
|
|
1677
|
+
const stored = listener;
|
|
1678
|
+
set.add(stored);
|
|
1679
|
+
return () => {
|
|
1680
|
+
set?.delete(stored);
|
|
1681
|
+
};
|
|
457
1682
|
}
|
|
458
1683
|
};
|
|
1684
|
+
if (Number.isFinite(maxDurationMs) && maxDurationMs > 0) {
|
|
1685
|
+
timeoutId = setTimeout(() => {
|
|
1686
|
+
if (closed) return;
|
|
1687
|
+
emit("session:timeout", { maxDurationMs });
|
|
1688
|
+
void handle.close().catch((error) => {
|
|
1689
|
+
emit("session:close-failed", { maxDurationMs, error });
|
|
1690
|
+
console.warn(
|
|
1691
|
+
`RouteDock: maxDuration auto-close failed after ${maxDurationMs}ms \u2014 the channel may still hold collateral; retry close() or call requestRefund().`,
|
|
1692
|
+
error
|
|
1693
|
+
);
|
|
1694
|
+
});
|
|
1695
|
+
}, maxDurationMs);
|
|
1696
|
+
timeoutId.unref?.();
|
|
1697
|
+
}
|
|
459
1698
|
return handle;
|
|
460
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* mpp-session-ws streaming transport.
|
|
1702
|
+
*
|
|
1703
|
+
* Mirrors the issue's three-step sequence:
|
|
1704
|
+
* 1. open the channel — probe the provider URL over HTTP;
|
|
1705
|
+
* 2. negotiate the voucher — sign the mppx 402 challenge into a credential;
|
|
1706
|
+
* 3. upgrade the HTTP connection to WebSocket — hand the signed voucher to
|
|
1707
|
+
* the provider as the Authorization header of the WebSocket handshake.
|
|
1708
|
+
*
|
|
1709
|
+
* Each server frame is yielded (JSON frames parsed; raw strings yielded
|
|
1710
|
+
* as-is). The stream ends when the server closes the socket with a normal
|
|
1711
|
+
* close code (1000/1001); abnormal closes and connection failures surface as
|
|
1712
|
+
* RouteDockChannelStateError.
|
|
1713
|
+
*/
|
|
1714
|
+
async *streamWebSocket(url, mppx, createCredential, onSigned) {
|
|
1715
|
+
const request = { method: "GET" };
|
|
1716
|
+
let probe;
|
|
1717
|
+
try {
|
|
1718
|
+
probe = await mppx.rawFetch(url, request);
|
|
1719
|
+
} catch (err) {
|
|
1720
|
+
throw wrapFetchError(err, "Voucher request");
|
|
1721
|
+
}
|
|
1722
|
+
if (probe.status !== 402) {
|
|
1723
|
+
if (probe.status >= 500 || probe.status === 429 || probe.status === 503) {
|
|
1724
|
+
throw httpStatusToError(
|
|
1725
|
+
`Voucher request failed: HTTP ${probe.status}`,
|
|
1726
|
+
probe.status,
|
|
1727
|
+
probe
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
throw new RouteDockChannelStateError(
|
|
1731
|
+
`Voucher request failed: expected HTTP 402 challenge, got HTTP ${probe.status}`
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
let credential;
|
|
1735
|
+
try {
|
|
1736
|
+
credential = await createCredential(
|
|
1737
|
+
Challenge.fromResponse(probe),
|
|
1738
|
+
(cumulativeAmount) => mppx.createCredential(probe, { cumulativeAmount })
|
|
1739
|
+
);
|
|
1740
|
+
onSigned?.();
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
throw wrapFetchError(err, "Voucher credential");
|
|
1743
|
+
}
|
|
1744
|
+
const wsUrl = url.replace(/^http/, "ws");
|
|
1745
|
+
const queue = [];
|
|
1746
|
+
const waiters = [];
|
|
1747
|
+
const drain = () => {
|
|
1748
|
+
while (queue.length > 0 && waiters.length > 0) {
|
|
1749
|
+
const waiter = waiters.shift();
|
|
1750
|
+
waiter(queue.shift());
|
|
1751
|
+
}
|
|
1752
|
+
};
|
|
1753
|
+
const nextEvent = () => queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((resolve) => {
|
|
1754
|
+
waiters.push(resolve);
|
|
1755
|
+
});
|
|
1756
|
+
const socket = this.webSocketFactory(
|
|
1757
|
+
wsUrl,
|
|
1758
|
+
{ headers: { authorization: credential } },
|
|
1759
|
+
{
|
|
1760
|
+
onOpen: () => {
|
|
1761
|
+
queue.push({ type: "open" });
|
|
1762
|
+
drain();
|
|
1763
|
+
},
|
|
1764
|
+
onMessage: (data) => {
|
|
1765
|
+
queue.push({ type: "message", data });
|
|
1766
|
+
drain();
|
|
1767
|
+
},
|
|
1768
|
+
onClose: (code, reason) => {
|
|
1769
|
+
queue.push({ type: "close", code, reason });
|
|
1770
|
+
drain();
|
|
1771
|
+
},
|
|
1772
|
+
onError: () => {
|
|
1773
|
+
queue.push({ type: "error" });
|
|
1774
|
+
drain();
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
);
|
|
1778
|
+
try {
|
|
1779
|
+
for (; ; ) {
|
|
1780
|
+
const event = await nextEvent();
|
|
1781
|
+
if (event.type === "open") break;
|
|
1782
|
+
if (event.type === "error") {
|
|
1783
|
+
throw new RouteDockChannelStateError(
|
|
1784
|
+
"WebSocket upgrade failed: connection error"
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
if (event.type === "close") {
|
|
1788
|
+
throw new RouteDockChannelStateError(
|
|
1789
|
+
`WebSocket upgrade failed: server closed the connection before upgrading (code ${event.code})`
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
for (; ; ) {
|
|
1794
|
+
const event = await nextEvent();
|
|
1795
|
+
if (event.type === "message") {
|
|
1796
|
+
let parsed = event.data;
|
|
1797
|
+
try {
|
|
1798
|
+
parsed = JSON.parse(event.data);
|
|
1799
|
+
} catch {
|
|
1800
|
+
}
|
|
1801
|
+
yield parsed;
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
if (event.type === "error") {
|
|
1805
|
+
throw new RouteDockChannelStateError("WebSocket connection error");
|
|
1806
|
+
}
|
|
1807
|
+
if (event.type === "open") continue;
|
|
1808
|
+
if (event.code === 1e3 || event.code === 1001) {
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
throw new RouteDockChannelStateError(
|
|
1812
|
+
`WebSocket stream ended abnormally (code ${event.code}${event.reason ? `: ${event.reason}` : ""})`
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
} finally {
|
|
1816
|
+
if (socket.readyState === WS_CONNECTING || socket.readyState === WS_OPEN) {
|
|
1817
|
+
socket.close(1e3, "stream ended");
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
var NulthPolicyError = class extends Error {
|
|
1823
|
+
code;
|
|
1824
|
+
constructor(code, message) {
|
|
1825
|
+
super(message);
|
|
1826
|
+
this.name = "NulthPolicyError";
|
|
1827
|
+
this.code = code;
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
function sha256Hex(data) {
|
|
1831
|
+
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
|
|
1832
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
1833
|
+
}
|
|
1834
|
+
function commitDailyCap(dailyCapStroops, witnessSecret) {
|
|
1835
|
+
return sha256Hex(`${witnessSecret}:cap:${dailyCapStroops.toString()}`);
|
|
1836
|
+
}
|
|
1837
|
+
function commitAllowlist(payees, witnessSecret) {
|
|
1838
|
+
const sorted = [...payees].sort().join(",");
|
|
1839
|
+
return sha256Hex(`${witnessSecret}:allowlist:${sorted}`);
|
|
1840
|
+
}
|
|
1841
|
+
function hashPayee(payee) {
|
|
1842
|
+
return sha256Hex(`payee:${payee}`);
|
|
1843
|
+
}
|
|
1844
|
+
function dayBucketFromLedger(ledgerSequence) {
|
|
1845
|
+
return Math.floor(ledgerSequence / 17280);
|
|
1846
|
+
}
|
|
1847
|
+
function authDigestFromEntry(authEntryBase64) {
|
|
1848
|
+
const entryHash = hash(Buffer.from(authEntryBase64, "base64"));
|
|
1849
|
+
return sha256Hex(entryHash);
|
|
1850
|
+
}
|
|
1851
|
+
function assertAuthEntryMatchesContext(authEntry, from, ctx) {
|
|
1852
|
+
let preimage;
|
|
1853
|
+
try {
|
|
1854
|
+
preimage = xdr.HashIdPreimage.fromXDR(authEntry, "base64");
|
|
1855
|
+
} catch {
|
|
1856
|
+
throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not decode as a HashIdPreimage");
|
|
1857
|
+
}
|
|
1858
|
+
if (preimage.switch() !== xdr.EnvelopeType.envelopeTypeSorobanAuthorization()) {
|
|
1859
|
+
throw new NulthPolicyError(
|
|
1860
|
+
"auth_entry_mismatch",
|
|
1861
|
+
"authEntry is not a Soroban authorization preimage"
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
const inv = preimage.sorobanAuthorization().invocation();
|
|
1865
|
+
if (inv.subInvocations().length !== 0) {
|
|
1866
|
+
throw new NulthPolicyError("auth_entry_mismatch", "authEntry has unexpected sub-invocations");
|
|
1867
|
+
}
|
|
1868
|
+
if (inv.function().switch() !== xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn()) {
|
|
1869
|
+
throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not invoke a contract function");
|
|
1870
|
+
}
|
|
1871
|
+
const fn = inv.function().contractFn();
|
|
1872
|
+
const contractAddress = Address.fromScAddress(fn.contractAddress()).toString();
|
|
1873
|
+
if (contractAddress !== ctx.assetContract) {
|
|
1874
|
+
throw new NulthPolicyError(
|
|
1875
|
+
"auth_entry_mismatch",
|
|
1876
|
+
`authEntry contract ${contractAddress} does not match assetContract ${ctx.assetContract}`
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
const functionName = fn.functionName().toString();
|
|
1880
|
+
if (functionName !== "transfer") {
|
|
1881
|
+
throw new NulthPolicyError(
|
|
1882
|
+
"auth_entry_mismatch",
|
|
1883
|
+
`authEntry function ${functionName} does not match expected transfer`
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
const args = fn.args();
|
|
1887
|
+
if (args.length !== 3) {
|
|
1888
|
+
throw new NulthPolicyError("auth_entry_mismatch", `authEntry transfer has ${args.length} args, expected 3`);
|
|
1889
|
+
}
|
|
1890
|
+
const entryFrom = scValToNative(args[0]);
|
|
1891
|
+
if (entryFrom !== from) {
|
|
1892
|
+
throw new NulthPolicyError(
|
|
1893
|
+
"auth_entry_mismatch",
|
|
1894
|
+
`authEntry from ${entryFrom} does not match nulthAccount ${from}`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
const entryTo = scValToNative(args[1]);
|
|
1898
|
+
if (entryTo !== ctx.payee) {
|
|
1899
|
+
throw new NulthPolicyError(
|
|
1900
|
+
"auth_entry_mismatch",
|
|
1901
|
+
`authEntry to ${entryTo} does not match paymentContext.payee ${ctx.payee}`
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
const entryAmount = scValToNative(args[2]);
|
|
1905
|
+
if (entryAmount !== ctx.amountStroops) {
|
|
1906
|
+
throw new NulthPolicyError(
|
|
1907
|
+
"auth_entry_mismatch",
|
|
1908
|
+
`authEntry amount ${entryAmount} does not match paymentContext.amountStroops ${ctx.amountStroops}`
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
function insecureMockProof(preimage) {
|
|
1913
|
+
return createHash("sha512").update(`nulth:${preimage}`).digest("base64");
|
|
1914
|
+
}
|
|
1915
|
+
function encodeAuthSignature(proof) {
|
|
1916
|
+
const payload = { nulth: "zk-v1", proof };
|
|
1917
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
1918
|
+
}
|
|
1919
|
+
function usdcToStroops2(amount) {
|
|
1920
|
+
const [whole = "0", frac = ""] = amount.split(".");
|
|
1921
|
+
const padded = (frac + "0000000").slice(0, 7);
|
|
1922
|
+
return BigInt(whole) * 10000000n + BigInt(padded);
|
|
1923
|
+
}
|
|
1924
|
+
var NulthClient = class {
|
|
1925
|
+
constructor(config) {
|
|
1926
|
+
this.config = config;
|
|
1927
|
+
this.policy = { ...config.policy };
|
|
1928
|
+
this.prover = config.prover ?? "mock";
|
|
1929
|
+
if (this.prover !== "mock") {
|
|
1930
|
+
throw new RouteDockManifestError(
|
|
1931
|
+
`NulthClient: unknown prover backend ${String(config.prover)}; only 'mock' is implemented`
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
if (config.network !== "testnet") {
|
|
1935
|
+
throw new RouteDockManifestError(
|
|
1936
|
+
`NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
console.warn("NulthClient is using the insecure mock prover; proofs are not cryptographically sound");
|
|
1940
|
+
}
|
|
1941
|
+
config;
|
|
1942
|
+
policy;
|
|
1943
|
+
prover;
|
|
1944
|
+
get proverBackend() {
|
|
1945
|
+
return this.prover;
|
|
1946
|
+
}
|
|
1947
|
+
get nulthAccount() {
|
|
1948
|
+
return this.config.nulthAccount;
|
|
1949
|
+
}
|
|
1950
|
+
get allowlistCommitment() {
|
|
1951
|
+
return this.policy.allowlistCommitment;
|
|
1952
|
+
}
|
|
1953
|
+
get capCommitment() {
|
|
1954
|
+
return commitDailyCap(this.policy.dailyCapStroops, this.policy.witnessSecret);
|
|
1955
|
+
}
|
|
1956
|
+
buildPaymentAuthProof(context) {
|
|
1957
|
+
this.enforcePolicy(context);
|
|
1958
|
+
const bucket = dayBucketFromLedger(context.ledgerSequence);
|
|
1959
|
+
if (bucket !== this.policy.dayBucket) {
|
|
1960
|
+
this.policy.dayBucket = bucket;
|
|
1961
|
+
this.policy.dailySpendStroops = 0n;
|
|
1962
|
+
}
|
|
1963
|
+
const authDigest = authDigestFromEntry(context.authEntry);
|
|
1964
|
+
const capCommitment = this.capCommitment;
|
|
1965
|
+
const payeeHash = hashPayee(context.payee);
|
|
1966
|
+
const preimage = [
|
|
1967
|
+
authDigest,
|
|
1968
|
+
this.policy.allowlistCommitment,
|
|
1969
|
+
capCommitment,
|
|
1970
|
+
payeeHash,
|
|
1971
|
+
context.amountStroops.toString(),
|
|
1972
|
+
bucket.toString(),
|
|
1973
|
+
this.config.nulthAccount
|
|
1974
|
+
].join("|");
|
|
1975
|
+
const proof = {
|
|
1976
|
+
version: 1,
|
|
1977
|
+
proof: insecureMockProof(preimage),
|
|
1978
|
+
publicInputs: {
|
|
1979
|
+
authDigest,
|
|
1980
|
+
allowlistCommitment: this.policy.allowlistCommitment,
|
|
1981
|
+
capCommitment,
|
|
1982
|
+
payeeHash,
|
|
1983
|
+
amountStroops: context.amountStroops.toString(),
|
|
1984
|
+
dayBucket: bucket.toString()
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
this.policy.dailySpendStroops += context.amountStroops;
|
|
1988
|
+
return proof;
|
|
1989
|
+
}
|
|
1990
|
+
enforcePolicy(context) {
|
|
1991
|
+
if (this.policy.expiryLedger !== void 0 && context.ledgerSequence > this.policy.expiryLedger) {
|
|
1992
|
+
throw new NulthPolicyError("session_expired", "Nulth session expired");
|
|
1993
|
+
}
|
|
1994
|
+
if (!this.policy.allowedPayees.includes(context.payee)) {
|
|
1995
|
+
throw new NulthPolicyError("payee_not_allowed", `Payee ${context.payee} not in off-chain allowlist`);
|
|
1996
|
+
}
|
|
1997
|
+
const bucket = dayBucketFromLedger(context.ledgerSequence);
|
|
1998
|
+
const spend = bucket === this.policy.dayBucket ? this.policy.dailySpendStroops : 0n;
|
|
1999
|
+
if (spend + context.amountStroops > this.policy.dailyCapStroops) {
|
|
2000
|
+
throw new NulthPolicyError("daily_cap_exceeded", "Nulth daily cap exceeded");
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
function createNulthSigner(config) {
|
|
2005
|
+
const client = new NulthClient(config);
|
|
2006
|
+
const nulthAccount = config.nulthAccount;
|
|
2007
|
+
return {
|
|
2008
|
+
address: nulthAccount,
|
|
2009
|
+
signAuthEntry: async (authEntry) => {
|
|
2010
|
+
const ctx = config.paymentContext;
|
|
2011
|
+
if (!ctx) throw new Error("Nulth paymentContext must be set before signing");
|
|
2012
|
+
assertAuthEntryMatchesContext(authEntry, nulthAccount, ctx);
|
|
2013
|
+
const proof = client.buildPaymentAuthProof({ authEntry, ...ctx });
|
|
2014
|
+
return { signedAuthEntry: encodeAuthSignature(proof), signerAddress: nulthAccount };
|
|
2015
|
+
}
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
function setNulthPaymentContext(config, context) {
|
|
2019
|
+
config.paymentContext = context;
|
|
2020
|
+
}
|
|
2021
|
+
function paymentContextFromManifest(manifest, mode, ledgerSequence) {
|
|
2022
|
+
const pricing = manifest.pricing[mode];
|
|
2023
|
+
if (!pricing) throw new Error(`manifest.pricing.${mode} missing`);
|
|
2024
|
+
return {
|
|
2025
|
+
payee: manifest.payee,
|
|
2026
|
+
amountStroops: usdcToStroops2(pricing.amount),
|
|
2027
|
+
assetContract: manifest.asset_contract,
|
|
2028
|
+
ledgerSequence
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
function createPolicyState(input) {
|
|
2032
|
+
const dailyCapStroops = usdcToStroops2(input.dailyCapUsdc);
|
|
2033
|
+
return {
|
|
2034
|
+
dailyCapStroops,
|
|
2035
|
+
allowlistCommitment: commitAllowlist(input.allowedPayees, input.witnessSecret),
|
|
2036
|
+
allowedPayees: input.allowedPayees,
|
|
2037
|
+
dailySpendStroops: 0n,
|
|
2038
|
+
dayBucket: dayBucketFromLedger(input.ledgerSequence ?? 0),
|
|
2039
|
+
witnessSecret: input.witnessSecret,
|
|
2040
|
+
...input.expiryLedger !== void 0 ? { expiryLedger: input.expiryLedger } : {}
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
function assertNulthVaultManifest(manifest, nulthAccount) {
|
|
2044
|
+
if (manifest.vault !== void 0 && manifest.vault !== "nulth") {
|
|
2045
|
+
throw new RouteDockManifestError(`Provider vault mode ${manifest.vault} does not match client nulth vault`);
|
|
2046
|
+
}
|
|
2047
|
+
if (manifest.nulth_account && manifest.nulth_account !== nulthAccount) {
|
|
2048
|
+
throw new RouteDockManifestError(`Manifest nulth_account ${manifest.nulth_account} does not match configured ${nulthAccount}`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
async function fetchLedgerSequence(network) {
|
|
2052
|
+
const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
|
|
2053
|
+
const server = new rpc.Server(rpcUrl);
|
|
2054
|
+
const latest = await server.getLatestLedger();
|
|
2055
|
+
return latest.sequence;
|
|
2056
|
+
}
|
|
2057
|
+
async function prepareNulthSigner(vault, manifest, mode, network, ledgerSequenceOverride) {
|
|
2058
|
+
assertNulthVaultManifest(manifest, vault.nulthAccount);
|
|
2059
|
+
const prover = vault.prover ?? "mock";
|
|
2060
|
+
if (network === "mainnet" && prover === "mock") {
|
|
2061
|
+
throw new RouteDockManifestError(
|
|
2062
|
+
"nulth vault uses the insecure mock prover and cannot be used on mainnet"
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
const ledgerSequence = ledgerSequenceOverride ?? await fetchLedgerSequence(network);
|
|
2066
|
+
const policy = createPolicyState({
|
|
2067
|
+
dailyCapUsdc: vault.dailyCapUsdc,
|
|
2068
|
+
allowedPayees: vault.allowedPayees,
|
|
2069
|
+
witnessSecret: vault.witnessSecret,
|
|
2070
|
+
ledgerSequence,
|
|
2071
|
+
...vault.expiryLedger !== void 0 ? { expiryLedger: vault.expiryLedger } : {}
|
|
2072
|
+
});
|
|
2073
|
+
const config = {
|
|
2074
|
+
nulthAccount: vault.nulthAccount,
|
|
2075
|
+
network,
|
|
2076
|
+
prover,
|
|
2077
|
+
policy,
|
|
2078
|
+
...vault.verifierContract !== void 0 ? { verifierContract: vault.verifierContract } : {}
|
|
2079
|
+
};
|
|
2080
|
+
if (mode === "x402" || mode === "mpp-charge") {
|
|
2081
|
+
setNulthPaymentContext(config, paymentContextFromManifest(manifest, mode, ledgerSequence));
|
|
2082
|
+
} else {
|
|
2083
|
+
throw new RouteDockManifestError(`Nulth ZK vault does not support payment mode: ${mode}`);
|
|
2084
|
+
}
|
|
2085
|
+
const signer = createNulthSigner(config);
|
|
2086
|
+
return { signer, config };
|
|
2087
|
+
}
|
|
2088
|
+
function decodeAuthSignature(encoded) {
|
|
2089
|
+
const raw = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
|
2090
|
+
if (typeof raw !== "object" || raw === null || raw.nulth !== "zk-v1") {
|
|
2091
|
+
throw new Error("Invalid Nulth ZK auth signature");
|
|
2092
|
+
}
|
|
2093
|
+
return raw;
|
|
2094
|
+
}
|
|
2095
|
+
var FileSpendStore = class {
|
|
2096
|
+
filePath;
|
|
2097
|
+
constructor(filePath) {
|
|
2098
|
+
this.filePath = filePath;
|
|
2099
|
+
}
|
|
2100
|
+
async read() {
|
|
2101
|
+
let data;
|
|
2102
|
+
try {
|
|
2103
|
+
data = await fs.readFile(this.filePath, "utf-8");
|
|
2104
|
+
} catch (err) {
|
|
2105
|
+
if (err.code === "ENOENT") {
|
|
2106
|
+
return null;
|
|
2107
|
+
}
|
|
2108
|
+
throw new Error(
|
|
2109
|
+
`[FileSpendStore] Failed to read spend file at ${this.filePath}: ${err.message}`
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
try {
|
|
2113
|
+
return JSON.parse(data);
|
|
2114
|
+
} catch {
|
|
2115
|
+
throw new Error(
|
|
2116
|
+
`[FileSpendStore] Spend file at ${this.filePath} contains invalid JSON. Delete or repair it before resuming. Raw content: ${data.slice(0, 120)}`
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
async write(state) {
|
|
2121
|
+
const dir = path.dirname(this.filePath);
|
|
2122
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2123
|
+
const tmpPath = `${this.filePath}.tmp`;
|
|
2124
|
+
await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
|
|
2125
|
+
await fs.rename(tmpPath, this.filePath);
|
|
2126
|
+
}
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
// src/store/SpendStore.ts
|
|
2130
|
+
var InMemorySpendStore = class {
|
|
2131
|
+
state = null;
|
|
2132
|
+
constructor(options = {}) {
|
|
2133
|
+
if (options.warn !== false) {
|
|
2134
|
+
console.warn(
|
|
2135
|
+
"[RouteDock] Using in-memory SpendStore: the daily spend cap is NOT durable and resets on every process restart. Inject a persistent SpendStore via RouteDockClientConfig.spendStore for production safety."
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
async read() {
|
|
2140
|
+
return this.state ? { ...this.state, endpoints: { ...this.state.endpoints } } : null;
|
|
2141
|
+
}
|
|
2142
|
+
async write(state) {
|
|
2143
|
+
this.state = { ...state, endpoints: { ...state.endpoints } };
|
|
2144
|
+
}
|
|
461
2145
|
};
|
|
462
2146
|
|
|
463
2147
|
// src/client/RouteDockClient.ts
|
|
464
|
-
var
|
|
2148
|
+
var _secrets = /* @__PURE__ */ new WeakMap();
|
|
2149
|
+
function normalizeEndpointCaps(endpointCaps) {
|
|
2150
|
+
if (!endpointCaps) return void 0;
|
|
2151
|
+
const normalized = {};
|
|
2152
|
+
for (const [key, value] of Object.entries(endpointCaps)) {
|
|
2153
|
+
let url;
|
|
2154
|
+
try {
|
|
2155
|
+
url = new URL(key);
|
|
2156
|
+
} catch {
|
|
2157
|
+
throw new RouteDockManifestError(
|
|
2158
|
+
`spendCap.endpointCaps key "${key}" is not a valid URL \u2014 use an origin such as "https://api.example.com"`
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
2162
|
+
throw new RouteDockManifestError(
|
|
2163
|
+
`spendCap.endpointCaps key "${key}" must be an origin only (no path, query, or hash) \u2014 use "${url.origin}"`
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
const origin = url.origin;
|
|
2167
|
+
if (Object.prototype.hasOwnProperty.call(normalized, origin)) {
|
|
2168
|
+
throw new RouteDockManifestError(
|
|
2169
|
+
`spendCap.endpointCaps has two keys that both normalize to origin "${origin}" \u2014 remove the duplicate`
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
normalized[origin] = value;
|
|
2173
|
+
}
|
|
2174
|
+
return normalized;
|
|
2175
|
+
}
|
|
2176
|
+
function normalizeSpendCap(spendCap) {
|
|
2177
|
+
const endpointCaps = normalizeEndpointCaps(spendCap.endpointCaps);
|
|
2178
|
+
return endpointCaps === void 0 ? spendCap : { ...spendCap, endpointCaps };
|
|
2179
|
+
}
|
|
2180
|
+
var usdcToMicros = usdcToStroops;
|
|
2181
|
+
var ASSET_ISSUERS = {
|
|
2182
|
+
USDC: USDC_ISSUERS
|
|
2183
|
+
};
|
|
2184
|
+
function getAssetIssuer(asset, network) {
|
|
2185
|
+
return ASSET_ISSUERS[asset]?.[network] ?? "";
|
|
2186
|
+
}
|
|
2187
|
+
var TRUSTLINE_CACHE_TTL_MS = 3e5;
|
|
2188
|
+
var RouteDockClient = class _RouteDockClient {
|
|
2189
|
+
/** Per-(account,asset) trustline existence cache keyed by `${network}:${pubkey}:${asset}` */
|
|
2190
|
+
static _trustlineCache = /* @__PURE__ */ new Map();
|
|
465
2191
|
keypair;
|
|
466
2192
|
network;
|
|
467
2193
|
spendCap;
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
2194
|
+
retryPolicy;
|
|
2195
|
+
logger;
|
|
2196
|
+
manifestTimeoutMs;
|
|
2197
|
+
expectedPayee;
|
|
2198
|
+
vault;
|
|
2199
|
+
/**
|
|
2200
|
+
* Durable backing store for the local daily spend accumulator (keyed by
|
|
2201
|
+
* YYYY-MM-DD). Totals are persisted as decimal strings of microUSDC
|
|
2202
|
+
* (1 USDC = 10^7) so stores stay JSON-safe with no precision loss.
|
|
2203
|
+
*/
|
|
2204
|
+
spendStore;
|
|
471
2205
|
x402;
|
|
472
2206
|
charge;
|
|
473
2207
|
session;
|
|
2208
|
+
/**
|
|
2209
|
+
* Promise-chain mutex for serializing read-modify-write on the spend
|
|
2210
|
+
* accumulator. Each pay() call awaits the previous one, preventing
|
|
2211
|
+
* concurrent read-modify-write races that could overrun the cap.
|
|
2212
|
+
*/
|
|
2213
|
+
_spendMutex = Promise.resolve();
|
|
2214
|
+
/**
|
|
2215
|
+
* Pending spend reservations keyed by reserveId. Used by _rollbackSpend
|
|
2216
|
+
* to revert the accumulator when a payment fails after reservation.
|
|
2217
|
+
*/
|
|
2218
|
+
_pendingReserves = /* @__PURE__ */ new Map();
|
|
474
2219
|
constructor(config) {
|
|
475
2220
|
this.keypair = typeof config.wallet === "string" ? Keypair.fromSecret(config.wallet) : config.wallet;
|
|
476
2221
|
this.network = config.network;
|
|
477
|
-
this.spendCap = config.spendCap;
|
|
478
|
-
this.
|
|
2222
|
+
this.spendCap = config.spendCap ? normalizeSpendCap(config.spendCap) : void 0;
|
|
2223
|
+
this.retryPolicy = config.retryPolicy;
|
|
2224
|
+
this.spendStore = config.spendStore ?? new InMemorySpendStore({ warn: !!config.spendCap });
|
|
2225
|
+
this.logger = config.logger;
|
|
2226
|
+
this.manifestTimeoutMs = config.manifestTimeoutMs;
|
|
2227
|
+
this.expectedPayee = config.expectedPayee;
|
|
2228
|
+
if (config.commitmentSecret) {
|
|
2229
|
+
_secrets.set(this, config.commitmentSecret);
|
|
2230
|
+
}
|
|
2231
|
+
this.vault = config.vault;
|
|
2232
|
+
if (config.commitmentSecret) {
|
|
2233
|
+
_secrets.set(this, config.commitmentSecret);
|
|
2234
|
+
}
|
|
479
2235
|
const secretKey = this.keypair.secret();
|
|
480
|
-
this.x402 = new X402Client(secretKey, this.network);
|
|
481
|
-
this.charge = new MppChargeClient(this.keypair, this.network);
|
|
482
|
-
this.session = new MppSessionClient(this.keypair, this.network);
|
|
2236
|
+
this.x402 = new X402Client(secretKey, this.network, this.retryPolicy);
|
|
2237
|
+
this.charge = new MppChargeClient(this.keypair, this.network, this.retryPolicy);
|
|
2238
|
+
this.session = new MppSessionClient(this.keypair, this.network, this.retryPolicy);
|
|
2239
|
+
}
|
|
2240
|
+
/** Fetch manifest and select mode — shared by pay() and estimateCost(). */
|
|
2241
|
+
async _resolveManifest(url, options) {
|
|
2242
|
+
const baseUrl = new URL(url).origin;
|
|
2243
|
+
const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
|
|
2244
|
+
assertEndpointActive(manifest, url, this.logger);
|
|
2245
|
+
const mode = selectMode(manifest, options);
|
|
2246
|
+
return { manifest, mode };
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Serialize async work through the per-instance spend mutex so concurrent
|
|
2250
|
+
* pay() calls cannot interleave read-modify-write on the accumulator.
|
|
2251
|
+
*/
|
|
2252
|
+
_withSpendMutex(fn) {
|
|
2253
|
+
const prev = this._spendMutex;
|
|
2254
|
+
let next;
|
|
2255
|
+
next = prev.then(fn, fn);
|
|
2256
|
+
this._spendMutex = next.then(() => {
|
|
2257
|
+
}, () => {
|
|
2258
|
+
});
|
|
2259
|
+
return next;
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Check that the payer has a trustline for the payment asset and surface
|
|
2263
|
+
* manifest metadata (regions, latency hints, capabilities). Safe to
|
|
2264
|
+
* call before committing to a payment — for approval gates and manual
|
|
2265
|
+
* trustline remediation.
|
|
2266
|
+
*/
|
|
2267
|
+
async preflight(manifest) {
|
|
2268
|
+
assertManifestValid(manifest);
|
|
2269
|
+
await this._checkTrustline(manifest);
|
|
2270
|
+
return {
|
|
2271
|
+
hasTrustline: true,
|
|
2272
|
+
asset: manifest.asset,
|
|
2273
|
+
modes: manifest.modes,
|
|
2274
|
+
...manifest.regions && { regions: manifest.regions },
|
|
2275
|
+
...manifest.latency_hints && { latency_hints: manifest.latency_hints },
|
|
2276
|
+
...manifest.capabilities && { capabilities: manifest.capabilities }
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Verify via Horizon that the payer account has a trustline for the
|
|
2281
|
+
* payment asset declared in the manifest. Throws RouteDockTrustlineError
|
|
2282
|
+
* with an exact Stellar CLI change-trust remediation command when the
|
|
2283
|
+
* trustline is missing. Results are cached per (account, asset) so the
|
|
2284
|
+
* per-payment overhead is one RPC call amortized to zero after the first.
|
|
2285
|
+
*/
|
|
2286
|
+
async _checkTrustline(manifest) {
|
|
2287
|
+
const cacheKey = `${this.network}:${this.keypair.publicKey()}:${manifest.asset}`;
|
|
2288
|
+
const cached = _RouteDockClient._trustlineCache.get(cacheKey);
|
|
2289
|
+
if (cached && Date.now() < cached.expiresAt) return;
|
|
2290
|
+
const horizonUrl = this.network === "testnet" ? "https://horizon-testnet.stellar.org" : "https://horizon.stellar.org";
|
|
2291
|
+
const server = new Horizon.Server(horizonUrl);
|
|
2292
|
+
try {
|
|
2293
|
+
const account = await server.loadAccount(this.keypair.publicKey());
|
|
2294
|
+
const balances = account.balances;
|
|
2295
|
+
const expectedIssuer = getAssetIssuer(manifest.asset, this.network);
|
|
2296
|
+
const hasTrustline = balances.some(
|
|
2297
|
+
(b) => typeof b === "object" && b !== null && "asset_code" in b && b.asset_code === manifest.asset && (!expectedIssuer || b.asset_issuer === expectedIssuer)
|
|
2298
|
+
);
|
|
2299
|
+
if (!hasTrustline) {
|
|
2300
|
+
const remediation = expectedIssuer ? `Run: stellar tx new --source ${this.keypair.publicKey()} --network ${this.network} change-trust --asset ${manifest.asset}:${expectedIssuer} --limit 100000` : `Establish a trustline for ${manifest.asset} with the appropriate issuer on ${this.network}`;
|
|
2301
|
+
throw new RouteDockTrustlineError(manifest.asset, expectedIssuer || "unknown", remediation);
|
|
2302
|
+
}
|
|
2303
|
+
_RouteDockClient._trustlineCache.set(cacheKey, {
|
|
2304
|
+
exists: true,
|
|
2305
|
+
expiresAt: Date.now() + TRUSTLINE_CACHE_TTL_MS
|
|
2306
|
+
});
|
|
2307
|
+
} catch (err) {
|
|
2308
|
+
if (err instanceof RouteDockTrustlineError) throw err;
|
|
2309
|
+
this.logger?.(
|
|
2310
|
+
`[RouteDock] Trustline preflight: could not verify trustline for ${manifest.asset} \u2014 continuing`
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Pay for one request at `url`. Fetches manifest, selects payment mode,
|
|
2316
|
+
* runs trustline preflight, reserves local spend cap BEFORE executing the
|
|
2317
|
+
* payment, then commits the spend record on success. Rolls back the
|
|
2318
|
+
* reservation if the on-chain payment fails. Concurrent pay() calls are
|
|
2319
|
+
* serialized through a per-instance mutex to prevent spend-cap overrun.
|
|
2320
|
+
*/
|
|
2321
|
+
async pay(url, options) {
|
|
2322
|
+
const baseUrl = new URL(url).origin;
|
|
2323
|
+
const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
|
|
2324
|
+
assertEndpointActive(manifest, url, this.logger);
|
|
2325
|
+
const mode = selectMode(manifest, { ...options, ...this.logger && { logger: this.logger } });
|
|
2326
|
+
await this._checkTrustline(manifest);
|
|
2327
|
+
if (this.vault?.mode === "nulth") {
|
|
2328
|
+
return this._payWithNulthVault(url, manifest, mode);
|
|
2329
|
+
}
|
|
2330
|
+
let amount;
|
|
2331
|
+
switch (mode) {
|
|
2332
|
+
case "x402":
|
|
2333
|
+
amount = manifest.pricing.x402.amount;
|
|
2334
|
+
break;
|
|
2335
|
+
case "mpp-charge":
|
|
2336
|
+
amount = manifest.pricing["mpp-charge"].amount;
|
|
2337
|
+
break;
|
|
2338
|
+
case "mpp-session":
|
|
2339
|
+
case "mpp-session-ws":
|
|
2340
|
+
throw new RouteDockManifestError(
|
|
2341
|
+
`Use client.openSession() for ${mode} mode \u2014 client.pay() only handles discrete payments`
|
|
2342
|
+
);
|
|
2343
|
+
default:
|
|
2344
|
+
throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
|
|
2345
|
+
}
|
|
2346
|
+
const reserveId = await this._checkAndReserveSpend(amount, baseUrl);
|
|
2347
|
+
let result;
|
|
2348
|
+
try {
|
|
2349
|
+
switch (mode) {
|
|
2350
|
+
case "x402":
|
|
2351
|
+
result = await this.x402.pay(url, manifest);
|
|
2352
|
+
break;
|
|
2353
|
+
case "mpp-charge":
|
|
2354
|
+
result = await this.charge.pay(url, manifest);
|
|
2355
|
+
break;
|
|
2356
|
+
default:
|
|
2357
|
+
throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
|
|
2358
|
+
}
|
|
2359
|
+
} catch (err) {
|
|
2360
|
+
await this._rollbackSpend(reserveId).catch(() => {
|
|
2361
|
+
});
|
|
2362
|
+
throw err;
|
|
2363
|
+
}
|
|
2364
|
+
await this._commitSpend(reserveId);
|
|
2365
|
+
return result;
|
|
2366
|
+
}
|
|
2367
|
+
/** Nulth ZK vault path — proof built off-chain, attached as auth signature */
|
|
2368
|
+
async _payWithNulthVault(url, manifest, mode) {
|
|
2369
|
+
const prover = this.vault?.prover ?? "mock";
|
|
2370
|
+
if (this.network === "mainnet" && prover === "mock") {
|
|
2371
|
+
throw new RouteDockManifestError("nulth vault uses a MOCK Groth16 prover and cannot be used on mainnet");
|
|
2372
|
+
}
|
|
2373
|
+
if (mode !== "x402") {
|
|
2374
|
+
throw new RouteDockManifestError(
|
|
2375
|
+
'nulth vault currently supports x402 mode \u2014 force x402 via { forceMode: "x402" }'
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
try {
|
|
2379
|
+
const { signer } = await prepareNulthSigner(this.vault, manifest, mode, this.network);
|
|
2380
|
+
const x402 = this.x402.withSigner(signer);
|
|
2381
|
+
const result = await x402.pay(url, manifest);
|
|
2382
|
+
return result;
|
|
2383
|
+
} catch (err) {
|
|
2384
|
+
if (err instanceof NulthPolicyError) {
|
|
2385
|
+
throw new RouteDockPolicyRejectError(err.code);
|
|
2386
|
+
}
|
|
2387
|
+
throw err;
|
|
2388
|
+
}
|
|
483
2389
|
}
|
|
484
2390
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
2391
|
+
* Resolve manifest and compute the expected charge WITHOUT submitting any
|
|
2392
|
+
* transaction. Safe to call before committing — for approval gates and
|
|
2393
|
+
* budget-aware routing.
|
|
487
2394
|
*/
|
|
488
|
-
async
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
const mode = selectMode(manifest, options);
|
|
492
|
-
let result;
|
|
2395
|
+
async estimateCost(url, options) {
|
|
2396
|
+
const { manifest, mode } = await this._resolveManifest(url, options);
|
|
2397
|
+
let amount;
|
|
493
2398
|
switch (mode) {
|
|
494
2399
|
case "x402":
|
|
495
|
-
|
|
2400
|
+
amount = manifest.pricing.x402.amount;
|
|
496
2401
|
break;
|
|
497
2402
|
case "mpp-charge":
|
|
498
|
-
|
|
2403
|
+
amount = manifest.pricing["mpp-charge"].amount;
|
|
499
2404
|
break;
|
|
500
2405
|
case "mpp-session":
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
2406
|
+
amount = manifest.pricing["mpp-session"].rate;
|
|
2407
|
+
break;
|
|
2408
|
+
case "mpp-session-ws":
|
|
2409
|
+
amount = manifest.pricing["mpp-session-ws"].rate;
|
|
2410
|
+
break;
|
|
504
2411
|
default:
|
|
505
2412
|
throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
|
|
506
2413
|
}
|
|
507
|
-
|
|
508
|
-
|
|
2414
|
+
return {
|
|
2415
|
+
amount,
|
|
2416
|
+
asset: manifest.asset,
|
|
2417
|
+
mode,
|
|
2418
|
+
manifest,
|
|
2419
|
+
...manifest.regions && { regions: manifest.regions },
|
|
2420
|
+
...manifest.latency_hints && { latency_hints: manifest.latency_hints },
|
|
2421
|
+
...manifest.capabilities && { capabilities: manifest.capabilities }
|
|
2422
|
+
};
|
|
509
2423
|
}
|
|
510
2424
|
/**
|
|
511
2425
|
* Open a sustained MPP session at `url`. Verifies mpp-session is supported
|
|
512
2426
|
* before opening a channel. Returns a SessionHandle for streaming + closing.
|
|
2427
|
+
*
|
|
2428
|
+
* By default the session auto-closes after a wall-clock timeout (1h) so an
|
|
2429
|
+
* orphaned channel cannot keep collateral locked on-chain — override or
|
|
2430
|
+
* disable via `options.maxDurationMs`.
|
|
513
2431
|
*/
|
|
514
|
-
async openSession(url) {
|
|
2432
|
+
async openSession(url, options) {
|
|
515
2433
|
const baseUrl = new URL(url).origin;
|
|
516
|
-
const manifest = await fetchManifest(baseUrl);
|
|
517
|
-
|
|
2434
|
+
const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
|
|
2435
|
+
assertEndpointActive(manifest, url, this.logger);
|
|
2436
|
+
const mode = options?.mode ?? "mpp-session";
|
|
2437
|
+
if (!manifest.modes.includes(mode)) {
|
|
518
2438
|
throw new RouteDockManifestError(
|
|
519
|
-
`Provider at ${baseUrl} does not support
|
|
2439
|
+
`Provider at ${baseUrl} does not support ${mode} mode`
|
|
520
2440
|
);
|
|
521
2441
|
}
|
|
522
|
-
|
|
2442
|
+
const secret = _secrets.get(this);
|
|
2443
|
+
if (!secret) {
|
|
523
2444
|
throw new RouteDockManifestError(
|
|
524
2445
|
"commitmentSecret is required in RouteDockClientConfig for mpp-session mode"
|
|
525
2446
|
);
|
|
526
2447
|
}
|
|
527
|
-
|
|
2448
|
+
const endpointKey = new URL(url).origin;
|
|
2449
|
+
const onSpend = this.spendCap ? (amount) => this._recordSessionSpend(amount, endpointKey) : void 0;
|
|
2450
|
+
return this.session.openSession(url, manifest, secret, options, onSpend);
|
|
528
2451
|
}
|
|
529
|
-
/**
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
throw new RouteDockPolicyRejectedError("local_daily_cap_exceeded");
|
|
2452
|
+
/**
|
|
2453
|
+
* Record spend for an MPP session voucher. Called before each voucher
|
|
2454
|
+
* fetch in stream(). Enforces the same local daily and per-endpoint caps
|
|
2455
|
+
* as discrete pay() calls. Returns a commit function to clean up the
|
|
2456
|
+
* reservation after the voucher fetch succeeds.
|
|
2457
|
+
*/
|
|
2458
|
+
async _recordSessionSpend(amount, endpointKey) {
|
|
2459
|
+
const reserveId = await this._checkAndReserveSpend(amount, endpointKey);
|
|
2460
|
+
if (reserveId) {
|
|
2461
|
+
await this._commitSpend(reserveId);
|
|
540
2462
|
}
|
|
541
|
-
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Remove the commitment secret from memory and make it eligible for GC.
|
|
2466
|
+
* Call when this client instance is no longer needed — particularly in
|
|
2467
|
+
* long-lived processes or Worker threads.
|
|
2468
|
+
*/
|
|
2469
|
+
dispose() {
|
|
2470
|
+
_secrets.delete(this);
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Evict the cached manifest for `url` immediately, so the next pay() /
|
|
2474
|
+
* estimateCost() / openSession() call re-fetches it instead of routing on
|
|
2475
|
+
* the stale cached copy. Use when a provider signals their manifest
|
|
2476
|
+
* changed (e.g. after a config update) and the change must take effect
|
|
2477
|
+
* before the cache TTL expires.
|
|
2478
|
+
*/
|
|
2479
|
+
invalidateManifest(url) {
|
|
2480
|
+
invalidateManifest(new URL(url).origin);
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Reserve spend against local daily caps BEFORE dispatching payment.
|
|
2484
|
+
* Returns a reserveId that must be passed to _commitSpend() on success
|
|
2485
|
+
* or _rollbackSpend() on failure.
|
|
2486
|
+
*
|
|
2487
|
+
* The entire read-check-write is serialized through the per-instance
|
|
2488
|
+
* mutex (_withSpendMutex) so concurrent pay() calls cannot interleave
|
|
2489
|
+
* and overrun the cap.
|
|
2490
|
+
*
|
|
2491
|
+
* Enforcement order:
|
|
2492
|
+
* 1. Per-endpoint cap (if `endpointCaps[endpointKey]` is set) — checked first.
|
|
2493
|
+
* 2. Global daily cap — checked second.
|
|
2494
|
+
*
|
|
2495
|
+
* Both limits are independent: a payment is only reserved after **both** pass.
|
|
2496
|
+
* All spend (regardless of endpoint) counts toward the global accumulator.
|
|
2497
|
+
*/
|
|
2498
|
+
async _checkAndReserveSpend(amount, endpointKey) {
|
|
2499
|
+
if (!this.spendCap) return "";
|
|
2500
|
+
return this._withSpendMutex(async () => {
|
|
2501
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2502
|
+
const persisted = await this.spendStore.read();
|
|
2503
|
+
const current = persisted && persisted.date === today ? persisted : { date: today, totalMicros: "0", endpoints: {} };
|
|
2504
|
+
const amountMicros = usdcToStroops(amount);
|
|
2505
|
+
const total = BigInt(current.totalMicros);
|
|
2506
|
+
const endpointCapStr = this.spendCap.endpointCaps?.[endpointKey];
|
|
2507
|
+
if (endpointCapStr !== void 0) {
|
|
2508
|
+
const endpointCapMicros = usdcToStroops(endpointCapStr);
|
|
2509
|
+
const endpointTotal = BigInt(current.endpoints[endpointKey] ?? "0");
|
|
2510
|
+
if (endpointTotal + amountMicros > endpointCapMicros) {
|
|
2511
|
+
throw new RouteDockPolicyRejectError("local_endpoint_cap_exceeded");
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
const globalCapMicros = usdcToStroops(this.spendCap.daily);
|
|
2515
|
+
if (total + amountMicros > globalCapMicros) {
|
|
2516
|
+
throw new RouteDockPolicyRejectError("local_daily_cap_exceeded");
|
|
2517
|
+
}
|
|
2518
|
+
const reserveId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2519
|
+
this._pendingReserves.set(reserveId, { endpointKey, amountMicros });
|
|
2520
|
+
current.totalMicros = (total + amountMicros).toString();
|
|
2521
|
+
if (endpointCapStr !== void 0) {
|
|
2522
|
+
current.endpoints[endpointKey] = (BigInt(current.endpoints[endpointKey] ?? "0") + amountMicros).toString();
|
|
2523
|
+
}
|
|
2524
|
+
await this.spendStore.write(current);
|
|
2525
|
+
return reserveId;
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* Commit a previously reserved spend after payment succeeds. For discrete
|
|
2530
|
+
* pay() calls this is a no-op (the spend was already recorded by reserve).
|
|
2531
|
+
* Reserved for future use if the committed amount differs from the reserved.
|
|
2532
|
+
*/
|
|
2533
|
+
async _commitSpend(reserveId) {
|
|
2534
|
+
this._pendingReserves.delete(reserveId);
|
|
2535
|
+
}
|
|
2536
|
+
/**
|
|
2537
|
+
* Rollback a previously reserved spend when payment fails. Reverts the
|
|
2538
|
+
* tentative accumulator write done by _checkAndReserveSpend.
|
|
2539
|
+
*/
|
|
2540
|
+
async _rollbackSpend(reserveId) {
|
|
2541
|
+
if (!this.spendCap || !reserveId) return;
|
|
2542
|
+
const reserve = this._pendingReserves.get(reserveId);
|
|
2543
|
+
if (!reserve) return;
|
|
2544
|
+
this._pendingReserves.delete(reserveId);
|
|
2545
|
+
const { endpointKey, amountMicros } = reserve;
|
|
2546
|
+
await this._withSpendMutex(async () => {
|
|
2547
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2548
|
+
const persisted = await this.spendStore.read();
|
|
2549
|
+
if (!persisted || persisted.date !== today) return;
|
|
2550
|
+
const currentTotal = BigInt(persisted.totalMicros);
|
|
2551
|
+
persisted.totalMicros = (currentTotal - amountMicros).toString();
|
|
2552
|
+
if (persisted.endpoints[endpointKey]) {
|
|
2553
|
+
const endpointTotal = BigInt(persisted.endpoints[endpointKey]);
|
|
2554
|
+
const newEndpointTotal = endpointTotal - amountMicros;
|
|
2555
|
+
if (newEndpointTotal <= 0n) {
|
|
2556
|
+
delete persisted.endpoints[endpointKey];
|
|
2557
|
+
} else {
|
|
2558
|
+
persisted.endpoints[endpointKey] = newEndpointTotal.toString();
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
await this.spendStore.write(persisted);
|
|
2562
|
+
});
|
|
542
2563
|
}
|
|
543
2564
|
};
|
|
544
2565
|
|
|
@@ -550,12 +2571,16 @@ var SupabaseSessionStore = class {
|
|
|
550
2571
|
supabase;
|
|
551
2572
|
async get(channelId) {
|
|
552
2573
|
const { data, error } = await this.supabase.from("sessions").select("*").eq("channel_id", channelId).maybeSingle();
|
|
553
|
-
if (error)
|
|
2574
|
+
if (error) {
|
|
2575
|
+
throw new RouteDockNetworkError(`SessionStore.get failed: ${error.message}`);
|
|
2576
|
+
}
|
|
554
2577
|
if (!data) return null;
|
|
555
2578
|
return {
|
|
556
2579
|
channel_id: data.channel_id,
|
|
557
2580
|
payee: data.payee,
|
|
558
2581
|
payer: data.payer,
|
|
2582
|
+
channel_contract: data.channel_contract,
|
|
2583
|
+
network: data.network,
|
|
559
2584
|
cumulative_amount: String(data.cumulative_amount),
|
|
560
2585
|
last_signature: data.last_signature,
|
|
561
2586
|
status: data.status,
|
|
@@ -570,7 +2595,7 @@ var SupabaseSessionStore = class {
|
|
|
570
2595
|
const prev = parseFloat(existing.cumulative_amount);
|
|
571
2596
|
const next = parseFloat(state.cumulative_amount);
|
|
572
2597
|
if (next <= prev) {
|
|
573
|
-
throw new
|
|
2598
|
+
throw new RouteDockVoucherMonotonicityError(
|
|
574
2599
|
`cumulative_amount must be strictly increasing: ${next} <= ${prev}`
|
|
575
2600
|
);
|
|
576
2601
|
}
|
|
@@ -580,6 +2605,8 @@ var SupabaseSessionStore = class {
|
|
|
580
2605
|
channel_id: state.channel_id,
|
|
581
2606
|
payee: state.payee,
|
|
582
2607
|
payer: state.payer,
|
|
2608
|
+
channel_contract: state.channel_contract,
|
|
2609
|
+
network: state.network,
|
|
583
2610
|
cumulative_amount: state.cumulative_amount,
|
|
584
2611
|
last_signature: state.last_signature,
|
|
585
2612
|
status: state.status,
|
|
@@ -591,18 +2618,117 @@ var SupabaseSessionStore = class {
|
|
|
591
2618
|
);
|
|
592
2619
|
if (error) {
|
|
593
2620
|
if (error.message.includes("strictly increasing")) {
|
|
594
|
-
throw new
|
|
2621
|
+
throw new RouteDockVoucherMonotonicityError(
|
|
595
2622
|
`cumulative_amount must be strictly increasing (DB): ${error.message}`
|
|
596
2623
|
);
|
|
597
2624
|
}
|
|
598
|
-
throw new
|
|
2625
|
+
throw new RouteDockNetworkError(`SessionStore.upsert failed: ${error.message}`);
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
async setStatus(channelId, status, settlementTxHash) {
|
|
2629
|
+
const payload = {
|
|
2630
|
+
status,
|
|
2631
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2632
|
+
};
|
|
2633
|
+
if (settlementTxHash !== void 0) {
|
|
2634
|
+
payload.settlement_tx_hash = settlementTxHash;
|
|
2635
|
+
}
|
|
2636
|
+
const { error } = await this.supabase.from("sessions").update(payload).eq("channel_id", channelId);
|
|
2637
|
+
if (error) {
|
|
2638
|
+
throw new RouteDockNetworkError(`SessionStore.setStatus failed: ${error.message}`);
|
|
599
2639
|
}
|
|
600
2640
|
}
|
|
601
2641
|
async close(channelId) {
|
|
602
|
-
|
|
603
|
-
|
|
2642
|
+
await this.setStatus(channelId, "closed");
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
|
|
2646
|
+
// src/store/SupabaseSeenTxStore.ts
|
|
2647
|
+
var SupabaseSeenTxStore = class {
|
|
2648
|
+
constructor(supabase) {
|
|
2649
|
+
this.supabase = supabase;
|
|
2650
|
+
}
|
|
2651
|
+
supabase;
|
|
2652
|
+
async get(key) {
|
|
2653
|
+
const { data, error } = await this.supabase.from("settlements").select("tx_hash, headers").eq("key", key).maybeSingle();
|
|
2654
|
+
if (error) {
|
|
2655
|
+
throw new RouteDockNetworkError(`SupabaseSeenTxStore.get failed: ${error.message}`);
|
|
2656
|
+
}
|
|
2657
|
+
if (!data) return void 0;
|
|
2658
|
+
const headers = data.headers;
|
|
2659
|
+
return {
|
|
2660
|
+
txHash: data.tx_hash ?? null,
|
|
2661
|
+
...headers ? { headers } : {}
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
async set(key, record) {
|
|
2665
|
+
const { error } = await this.supabase.from("settlements").upsert(
|
|
2666
|
+
{
|
|
2667
|
+
key,
|
|
2668
|
+
tx_hash: record.txHash,
|
|
2669
|
+
headers: record.headers ?? null
|
|
2670
|
+
},
|
|
2671
|
+
{ onConflict: "key" }
|
|
2672
|
+
);
|
|
2673
|
+
if (error) {
|
|
2674
|
+
throw new RouteDockNetworkError(`SupabaseSeenTxStore.set failed: ${error.message}`);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
};
|
|
2678
|
+
|
|
2679
|
+
// src/provider/payee.ts
|
|
2680
|
+
function resolvePayee(manifest, mode) {
|
|
2681
|
+
const override = manifest.pricing[mode]?.payee;
|
|
2682
|
+
return override ?? manifest.payee;
|
|
2683
|
+
}
|
|
2684
|
+
function extractPayerAddress(key) {
|
|
2685
|
+
if (typeof key !== "string") return null;
|
|
2686
|
+
return StrKey.isValidEd25519PublicKey(key) || StrKey.isValidMed25519PublicKey(key) ? key : null;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// src/provider/SeenTxStore.ts
|
|
2690
|
+
var InMemorySeenTxStore = class {
|
|
2691
|
+
map = /* @__PURE__ */ new Map();
|
|
2692
|
+
order = [];
|
|
2693
|
+
constructor(maxEntriesOrOptions = 1e4) {
|
|
2694
|
+
const options = typeof maxEntriesOrOptions === "number" ? { maxEntries: maxEntriesOrOptions } : maxEntriesOrOptions;
|
|
2695
|
+
this.maxEntries = options.maxEntries ?? 1e4;
|
|
2696
|
+
if (options.warn !== false) {
|
|
2697
|
+
const isServerless = typeof globalThis.navigator !== "undefined" && globalThis.navigator.userAgent === "Cloudflare-Workers";
|
|
2698
|
+
if (isServerless) {
|
|
2699
|
+
console.warn(
|
|
2700
|
+
"[RouteDock] Using in-memory SeenTxStore: settlement deduplication is NOT durable and resets on every isolate restart. Supply a SupabaseSeenTxStore (or other durable SeenTxStore) for production safety."
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
maxEntries;
|
|
2706
|
+
get(key) {
|
|
2707
|
+
return this.map.get(key);
|
|
2708
|
+
}
|
|
2709
|
+
set(key, record) {
|
|
2710
|
+
if (!this.map.has(key)) {
|
|
2711
|
+
this.order.push(key);
|
|
2712
|
+
if (this.order.length > this.maxEntries) {
|
|
2713
|
+
const evicted = this.order.shift();
|
|
2714
|
+
if (evicted !== void 0) this.map.delete(evicted);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
this.map.set(key, record);
|
|
604
2718
|
}
|
|
605
2719
|
};
|
|
2720
|
+
async function sha256Hex2(input) {
|
|
2721
|
+
const buf = new TextEncoder().encode(input);
|
|
2722
|
+
const digest = await crypto.subtle.digest("SHA-256", buf);
|
|
2723
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2724
|
+
}
|
|
2725
|
+
async function paymentIdempotencyKey(getHeader) {
|
|
2726
|
+
const material = getHeader("payment-signature") ?? getHeader("x-payment") ?? getHeader("authorization");
|
|
2727
|
+
if (!material) return null;
|
|
2728
|
+
return sha256Hex2(material);
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// src/provider/x402Handler.ts
|
|
606
2732
|
var CAIP22 = {
|
|
607
2733
|
testnet: "stellar:testnet",
|
|
608
2734
|
mainnet: "stellar:pubnet"
|
|
@@ -612,6 +2738,7 @@ function createX402Handler(opts) {
|
|
|
612
2738
|
const caip2 = CAIP22[opts.network];
|
|
613
2739
|
const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
|
|
614
2740
|
const signer = createEd25519Signer(opts.payeeSecretKey, caip2);
|
|
2741
|
+
const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore();
|
|
615
2742
|
const useOzFacilitator = opts.network === "mainnet" && opts.facilitatorApiKey;
|
|
616
2743
|
const localFacilitator = new ExactStellarScheme$1([signer], {
|
|
617
2744
|
areFeesSponsored: true
|
|
@@ -630,13 +2757,14 @@ function createX402Handler(opts) {
|
|
|
630
2757
|
ozServer = new x402ResourceServer(facilitator);
|
|
631
2758
|
ozServer.register(caip2, new ExactStellarScheme$2());
|
|
632
2759
|
}
|
|
633
|
-
const amountInBaseUnits = String(
|
|
2760
|
+
const amountInBaseUnits = String(usdcToUnits(opts.amount));
|
|
2761
|
+
const payTo = resolvePayee(opts.manifest, "x402");
|
|
634
2762
|
const requirements = {
|
|
635
2763
|
scheme: "exact",
|
|
636
2764
|
network: caip2,
|
|
637
2765
|
asset: opts.assetContract,
|
|
638
2766
|
amount: amountInBaseUnits,
|
|
639
|
-
payTo
|
|
2767
|
+
payTo,
|
|
640
2768
|
maxTimeoutSeconds: 60,
|
|
641
2769
|
extra: {
|
|
642
2770
|
areFeesSponsored: true,
|
|
@@ -670,8 +2798,31 @@ function createX402Handler(opts) {
|
|
|
670
2798
|
}
|
|
671
2799
|
return;
|
|
672
2800
|
}
|
|
2801
|
+
const idempotencyKey = await paymentIdempotencyKey((name) => {
|
|
2802
|
+
const v = req.headers[name.toLowerCase()];
|
|
2803
|
+
return Array.isArray(v) ? v[0] : v;
|
|
2804
|
+
});
|
|
2805
|
+
if (idempotencyKey) {
|
|
2806
|
+
const cached = await seenTxStore.get(idempotencyKey);
|
|
2807
|
+
if (cached) {
|
|
2808
|
+
if (cached.headers) {
|
|
2809
|
+
for (const [k, val] of Object.entries(cached.headers)) {
|
|
2810
|
+
res.setHeader(k, val);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
next();
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
673
2817
|
const payload = decodePaymentSignatureHeader(paymentHeader);
|
|
674
2818
|
let txHash = null;
|
|
2819
|
+
let payerAddress = null;
|
|
2820
|
+
try {
|
|
2821
|
+
const creds = payload.authorization?.credentials;
|
|
2822
|
+
const key = Array.isArray(creds) ? creds[0]?.publicKey : void 0;
|
|
2823
|
+
payerAddress = extractPayerAddress(key);
|
|
2824
|
+
} catch {
|
|
2825
|
+
}
|
|
675
2826
|
if (ozServer) {
|
|
676
2827
|
const settleResult = await ozServer.settlePayment(payload, requirements);
|
|
677
2828
|
txHash = settleResult.transaction ?? null;
|
|
@@ -709,8 +2860,19 @@ function createX402Handler(opts) {
|
|
|
709
2860
|
);
|
|
710
2861
|
}
|
|
711
2862
|
}
|
|
2863
|
+
if (idempotencyKey) {
|
|
2864
|
+
const headers = {};
|
|
2865
|
+
const paymentResponse = res.getHeader("X-Payment-Response");
|
|
2866
|
+
if (typeof paymentResponse === "string") {
|
|
2867
|
+
headers["X-Payment-Response"] = paymentResponse;
|
|
2868
|
+
}
|
|
2869
|
+
await seenTxStore.set(idempotencyKey, { txHash, headers });
|
|
2870
|
+
}
|
|
712
2871
|
if (txHash && opts.onSettled) {
|
|
713
|
-
|
|
2872
|
+
Promise.resolve().then(() => opts.onSettled(txHash, opts.amount, "x402", payerAddress)).catch((err) => {
|
|
2873
|
+
console.error("[x402] onSettled callback error:", err);
|
|
2874
|
+
opts.onCallbackError?.(err, "onSettled");
|
|
2875
|
+
});
|
|
714
2876
|
}
|
|
715
2877
|
next();
|
|
716
2878
|
} catch (err) {
|
|
@@ -726,11 +2888,13 @@ var MPP_NETWORK = {
|
|
|
726
2888
|
function createMppChargeHandler(opts) {
|
|
727
2889
|
const networkId = MPP_NETWORK[opts.network];
|
|
728
2890
|
const amountHumanReadable = opts.amount;
|
|
2891
|
+
const recipient = resolvePayee(opts.manifest, "mpp-charge");
|
|
2892
|
+
const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore();
|
|
729
2893
|
const mppx = Mppx$1.create({
|
|
730
2894
|
secretKey: opts.payeeSecretKey,
|
|
731
2895
|
methods: [
|
|
732
2896
|
stellar$2.charge({
|
|
733
|
-
recipient
|
|
2897
|
+
recipient,
|
|
734
2898
|
currency: opts.assetContract,
|
|
735
2899
|
network: networkId,
|
|
736
2900
|
feePayer: { envelopeSigner: opts.payeeSecretKey }
|
|
@@ -739,12 +2903,43 @@ function createMppChargeHandler(opts) {
|
|
|
739
2903
|
});
|
|
740
2904
|
return async (req, res, next) => {
|
|
741
2905
|
try {
|
|
2906
|
+
let payerAddress = null;
|
|
2907
|
+
try {
|
|
2908
|
+
const authHeader = req.headers["authorization"];
|
|
2909
|
+
if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
|
|
2910
|
+
const credPart = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
|
|
2911
|
+
if (credPart) {
|
|
2912
|
+
const b64 = credPart.split("=").slice(1).join("=").replace(/^"|"$/g, "");
|
|
2913
|
+
const credJson = Buffer.from(b64, "base64").toString("utf8");
|
|
2914
|
+
const cred = JSON.parse(credJson);
|
|
2915
|
+
const key = cred.sender ?? cred.payload?.sender ?? cred.payload?.from;
|
|
2916
|
+
payerAddress = extractPayerAddress(key);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
} catch {
|
|
2920
|
+
}
|
|
2921
|
+
const idempotencyKey = await paymentIdempotencyKey((name) => {
|
|
2922
|
+
const v = req.headers[name.toLowerCase()];
|
|
2923
|
+
return Array.isArray(v) ? v[0] : v;
|
|
2924
|
+
});
|
|
2925
|
+
if (idempotencyKey) {
|
|
2926
|
+
const cached = await seenTxStore.get(idempotencyKey);
|
|
2927
|
+
if (cached) {
|
|
2928
|
+
if (cached.headers) {
|
|
2929
|
+
for (const [k, val] of Object.entries(cached.headers)) {
|
|
2930
|
+
res.setHeader(k, val);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
next();
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
742
2937
|
const fetchReq = Request.fromNodeListener(req, res);
|
|
743
2938
|
const handler = mppx["stellar/charge"];
|
|
744
2939
|
const result = await handler({
|
|
745
2940
|
amount: amountHumanReadable,
|
|
746
2941
|
currency: opts.assetContract,
|
|
747
|
-
recipient
|
|
2942
|
+
recipient,
|
|
748
2943
|
description: opts.manifest.name
|
|
749
2944
|
})(fetchReq);
|
|
750
2945
|
if (result.status === 402) {
|
|
@@ -755,25 +2950,123 @@ function createMppChargeHandler(opts) {
|
|
|
755
2950
|
return;
|
|
756
2951
|
}
|
|
757
2952
|
const receipt = result.withReceipt(new Response(""));
|
|
758
|
-
|
|
2953
|
+
const receiptHeaders = {};
|
|
2954
|
+
receipt.headers.forEach((v, k) => {
|
|
2955
|
+
res.setHeader(k, v);
|
|
2956
|
+
receiptHeaders[k] = v;
|
|
2957
|
+
});
|
|
759
2958
|
const receiptHeader = receipt.headers.get("payment-receipt");
|
|
760
|
-
|
|
2959
|
+
let reference;
|
|
2960
|
+
if (receiptHeader) {
|
|
761
2961
|
try {
|
|
762
2962
|
const parsed = JSON.parse(
|
|
763
2963
|
Buffer.from(receiptHeader, "base64").toString("utf8")
|
|
764
2964
|
);
|
|
765
|
-
|
|
766
|
-
await opts.onSettled(parsed.reference, opts.amount, "mpp-charge");
|
|
767
|
-
}
|
|
2965
|
+
reference = parsed.reference;
|
|
768
2966
|
} catch {
|
|
769
2967
|
}
|
|
770
2968
|
}
|
|
2969
|
+
if (idempotencyKey) {
|
|
2970
|
+
await seenTxStore.set(idempotencyKey, {
|
|
2971
|
+
txHash: reference ?? null,
|
|
2972
|
+
headers: receiptHeaders
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
if (reference && opts.onSettled) {
|
|
2976
|
+
Promise.resolve().then(() => opts.onSettled(reference, opts.amount, "mpp-charge", payerAddress)).catch((err) => {
|
|
2977
|
+
console.error("[mpp-charge] onSettled callback error:", err);
|
|
2978
|
+
opts.onCallbackError?.(err, "onSettled");
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
771
2981
|
next();
|
|
772
2982
|
} catch (err) {
|
|
773
2983
|
next(err);
|
|
774
2984
|
}
|
|
775
2985
|
};
|
|
776
2986
|
}
|
|
2987
|
+
function resolveVaultSettlementAddresses(payer, payee) {
|
|
2988
|
+
if (!payer) return null;
|
|
2989
|
+
return { payer, payee };
|
|
2990
|
+
}
|
|
2991
|
+
function buildSessionSettlementTransaction(source, vaultContractId, channelId, addresses, amount, voucherCount, networkPassphrase) {
|
|
2992
|
+
const vault = new Contract(vaultContractId);
|
|
2993
|
+
const op = vault.call(
|
|
2994
|
+
"record_session_settlement",
|
|
2995
|
+
nativeToScVal(channelId, { type: "address" }),
|
|
2996
|
+
nativeToScVal(addresses.payer, { type: "address" }),
|
|
2997
|
+
nativeToScVal(addresses.payee, { type: "address" }),
|
|
2998
|
+
nativeToScVal(amount, { type: "i128" }),
|
|
2999
|
+
nativeToScVal(voucherCount, { type: "u32" })
|
|
3000
|
+
);
|
|
3001
|
+
return new TransactionBuilder(source, {
|
|
3002
|
+
fee: BASE_FEE,
|
|
3003
|
+
networkPassphrase
|
|
3004
|
+
}).addOperation(op).setTimeout(30).build();
|
|
3005
|
+
}
|
|
3006
|
+
function channelAuthorizer(envelopeSigner) {
|
|
3007
|
+
return { envelopeSigner };
|
|
3008
|
+
}
|
|
3009
|
+
function formatMppError(error) {
|
|
3010
|
+
if (error instanceof Error) return error.message;
|
|
3011
|
+
if (typeof error === "string") return error;
|
|
3012
|
+
try {
|
|
3013
|
+
const serialized = JSON.stringify(error);
|
|
3014
|
+
return serialized === void 0 ? String(error) : serialized;
|
|
3015
|
+
} catch {
|
|
3016
|
+
return String(error);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
function withTypedChannelErrors(method) {
|
|
3020
|
+
return {
|
|
3021
|
+
...method,
|
|
3022
|
+
async verify(parameters) {
|
|
3023
|
+
try {
|
|
3024
|
+
return await method.verify(parameters);
|
|
3025
|
+
} catch (error) {
|
|
3026
|
+
throw mapChannelError(error);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
function onVerifiedCredential(method, commit) {
|
|
3032
|
+
return {
|
|
3033
|
+
...method,
|
|
3034
|
+
async verify(parameters) {
|
|
3035
|
+
const receipt = await method.verify(parameters);
|
|
3036
|
+
await commit(parameters.credential);
|
|
3037
|
+
return receipt;
|
|
3038
|
+
}
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
function mapChannelError(error) {
|
|
3042
|
+
const message = formatMppError(error);
|
|
3043
|
+
const details = error && typeof error === "object" && "details" in error ? error.details : void 0;
|
|
3044
|
+
const reason = details && Object.keys(details).length > 0 ? `${message} (${JSON.stringify(details)})` : message;
|
|
3045
|
+
if (/signer mismatch|authori[sz]er|unauthori[sz]ed/i.test(message)) {
|
|
3046
|
+
return new Errors.SignerMismatchError({ reason });
|
|
3047
|
+
}
|
|
3048
|
+
if (/already used|replay|challenge/i.test(message)) {
|
|
3049
|
+
return new Errors.InvalidChallengeError({ reason });
|
|
3050
|
+
}
|
|
3051
|
+
if (/channel has been closed|channel is closed|finalized/i.test(message)) {
|
|
3052
|
+
return new Errors.ChannelClosedError({ reason });
|
|
3053
|
+
}
|
|
3054
|
+
if (/exceeds channel balance|exceeds.*deposit/i.test(message)) {
|
|
3055
|
+
return new Errors.AmountExceedsDepositError({ reason });
|
|
3056
|
+
}
|
|
3057
|
+
if (/channel not found|no channel/i.test(message)) {
|
|
3058
|
+
return new Errors.ChannelNotFoundError({ reason });
|
|
3059
|
+
}
|
|
3060
|
+
if (/must be greater|does not cover|below.*minimum/i.test(message)) {
|
|
3061
|
+
return new Errors.DeltaTooSmallError({ reason });
|
|
3062
|
+
}
|
|
3063
|
+
if (/signature/i.test(message)) {
|
|
3064
|
+
return new Errors.InvalidSignatureError({ reason });
|
|
3065
|
+
}
|
|
3066
|
+
return new Errors.VerificationFailedError({ reason });
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// src/provider/MppSessionHandler.ts
|
|
777
3070
|
var MPP_NETWORK2 = {
|
|
778
3071
|
testnet: "stellar:testnet",
|
|
779
3072
|
mainnet: "stellar:pubnet"
|
|
@@ -782,67 +3075,202 @@ function createMppSessionHandler(opts) {
|
|
|
782
3075
|
const networkId = MPP_NETWORK2[opts.network];
|
|
783
3076
|
const rateHuman = opts.rate;
|
|
784
3077
|
const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
|
|
785
|
-
const
|
|
3078
|
+
const voucherRecordKey = `routedock:session:voucher:${opts.channelFactory}`;
|
|
786
3079
|
const innerStore = Store.memory();
|
|
787
|
-
let
|
|
3080
|
+
let record = null;
|
|
788
3081
|
let voucherCount = 0;
|
|
789
3082
|
let sessionOpened = false;
|
|
3083
|
+
let idleTimer = null;
|
|
3084
|
+
let settledCleanly = false;
|
|
3085
|
+
let closeListenerArmed = false;
|
|
3086
|
+
function clearIdleTimer() {
|
|
3087
|
+
if (idleTimer) {
|
|
3088
|
+
clearTimeout(idleTimer);
|
|
3089
|
+
idleTimer = null;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
function armIdleTimer() {
|
|
3093
|
+
if (!opts.idleTimeoutMs) return;
|
|
3094
|
+
clearIdleTimer();
|
|
3095
|
+
idleTimer = setTimeout(() => {
|
|
3096
|
+
void flagOrphan("idle-timeout");
|
|
3097
|
+
}, opts.idleTimeoutMs);
|
|
3098
|
+
if (typeof idleTimer.unref === "function") idleTimer.unref();
|
|
3099
|
+
}
|
|
3100
|
+
async function loadPersistedRecord() {
|
|
3101
|
+
if (record) return record;
|
|
3102
|
+
try {
|
|
3103
|
+
const stored = await innerStore.get(voucherRecordKey);
|
|
3104
|
+
if (!stored || typeof stored.amount !== "string" || typeof stored.signature !== "string") {
|
|
3105
|
+
return null;
|
|
3106
|
+
}
|
|
3107
|
+
record = { amount: BigInt(stored.amount), signature: stored.signature, payer: stored.payer ?? null };
|
|
3108
|
+
} catch {
|
|
3109
|
+
return null;
|
|
3110
|
+
}
|
|
3111
|
+
return record;
|
|
3112
|
+
}
|
|
3113
|
+
async function flagOrphan(reason) {
|
|
3114
|
+
await loadPersistedRecord();
|
|
3115
|
+
if (!sessionOpened || settledCleanly) return;
|
|
3116
|
+
sessionOpened = false;
|
|
3117
|
+
closeListenerArmed = false;
|
|
3118
|
+
clearIdleTimer();
|
|
3119
|
+
const cumulativeAmount = record ? (Number(record.amount) / 1e7).toFixed(7) : "0.0000000";
|
|
3120
|
+
if (opts.onOrphaned) {
|
|
3121
|
+
try {
|
|
3122
|
+
await opts.onOrphaned(opts.channelFactory, {
|
|
3123
|
+
cumulativeAmount,
|
|
3124
|
+
lastSignature: record?.signature ?? "",
|
|
3125
|
+
voucherCount,
|
|
3126
|
+
reason
|
|
3127
|
+
});
|
|
3128
|
+
} catch (err) {
|
|
3129
|
+
console.error("[mpp-session] onOrphaned handler failed:", err);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
790
3133
|
const wrappedStore = {
|
|
791
3134
|
async get(key) {
|
|
792
3135
|
return innerStore.get(key);
|
|
793
3136
|
},
|
|
794
3137
|
async put(key, value) {
|
|
795
|
-
|
|
796
|
-
if (key === cumulativeKey && value && typeof value === "object" && "amount" in value) {
|
|
797
|
-
lastCumulativeAmount = BigInt(value.amount);
|
|
798
|
-
voucherCount++;
|
|
799
|
-
if (!sessionOpened) {
|
|
800
|
-
sessionOpened = true;
|
|
801
|
-
if (opts.onSessionOpen) {
|
|
802
|
-
await opts.onSessionOpen(opts.channelContract);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
if (opts.onVoucher) {
|
|
806
|
-
const humanAmount = (Number(lastCumulativeAmount) / 1e7).toFixed(7);
|
|
807
|
-
await opts.onVoucher(voucherCount, humanAmount);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
3138
|
+
return innerStore.put(key, value);
|
|
810
3139
|
},
|
|
811
3140
|
async delete(key) {
|
|
812
3141
|
return innerStore.delete(key);
|
|
3142
|
+
},
|
|
3143
|
+
async update(key, fn) {
|
|
3144
|
+
const storeWithUpdate = innerStore;
|
|
3145
|
+
if (typeof storeWithUpdate.update === "function") {
|
|
3146
|
+
return storeWithUpdate.update(key, fn);
|
|
3147
|
+
}
|
|
3148
|
+
throw new Error("Store does not support atomic update operations");
|
|
813
3149
|
}
|
|
814
3150
|
};
|
|
3151
|
+
async function commit(credential) {
|
|
3152
|
+
await loadPersistedRecord();
|
|
3153
|
+
const payload = credential.payload;
|
|
3154
|
+
if (typeof payload?.amount !== "string" || typeof payload.signature !== "string") return;
|
|
3155
|
+
let amount;
|
|
3156
|
+
try {
|
|
3157
|
+
amount = BigInt(payload.amount);
|
|
3158
|
+
} catch {
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
if (record && amount < record.amount) return;
|
|
3162
|
+
const payer = record?.payer ?? extractPayerAddress(credential.source);
|
|
3163
|
+
record = { amount, signature: payload.signature, payer };
|
|
3164
|
+
await innerStore.put(voucherRecordKey, {
|
|
3165
|
+
amount: record.amount.toString(),
|
|
3166
|
+
signature: record.signature,
|
|
3167
|
+
payer: record.payer
|
|
3168
|
+
});
|
|
3169
|
+
voucherCount++;
|
|
3170
|
+
settledCleanly = false;
|
|
3171
|
+
armIdleTimer();
|
|
3172
|
+
if (!sessionOpened) {
|
|
3173
|
+
sessionOpened = true;
|
|
3174
|
+
if (opts.onSessionOpen) {
|
|
3175
|
+
Promise.resolve().then(() => opts.onSessionOpen(opts.channelFactory, record.payer)).catch((err) => {
|
|
3176
|
+
console.error("[mpp-session] onSessionOpen callback error:", err);
|
|
3177
|
+
opts.onCallbackError?.(err, "onSessionOpen");
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
if (opts.onVoucher) {
|
|
3182
|
+
const humanAmount = (Number(record.amount) / 1e7).toFixed(7);
|
|
3183
|
+
Promise.resolve().then(() => opts.onVoucher(opts.channelFactory, voucherCount, humanAmount, record.signature)).catch((err) => {
|
|
3184
|
+
console.error("[mpp-session] onVoucher callback error:", err);
|
|
3185
|
+
opts.onCallbackError?.(err, "onVoucher");
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
815
3189
|
const mppx = Mppx$1.create({
|
|
816
3190
|
secretKey: opts.payeeSecretKey,
|
|
817
3191
|
methods: [
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
3192
|
+
withTypedChannelErrors(
|
|
3193
|
+
onVerifiedCredential(
|
|
3194
|
+
stellar$3.channel({
|
|
3195
|
+
channel: opts.channelFactory,
|
|
3196
|
+
commitmentKey: opts.commitmentPublicKey,
|
|
3197
|
+
network: networkId,
|
|
3198
|
+
store: wrappedStore,
|
|
3199
|
+
sourceAccount: payeeKeypair.publicKey(),
|
|
3200
|
+
feePayer: channelAuthorizer(payeeKeypair)
|
|
3201
|
+
}),
|
|
3202
|
+
commit
|
|
3203
|
+
)
|
|
3204
|
+
)
|
|
826
3205
|
]
|
|
827
3206
|
});
|
|
828
|
-
let lastSignatureHex = "";
|
|
829
3207
|
return async (req, res, next) => {
|
|
830
3208
|
try {
|
|
831
3209
|
if (req.method === "DELETE") {
|
|
3210
|
+
await loadPersistedRecord();
|
|
832
3211
|
const body = req.body;
|
|
833
|
-
const
|
|
834
|
-
const
|
|
3212
|
+
const bodyAmount = body?.amount ? BigInt(body.amount) : 0n;
|
|
3213
|
+
const recordAmount = record?.amount ?? 0n;
|
|
3214
|
+
let closeAmount;
|
|
3215
|
+
let closeSig;
|
|
3216
|
+
if (bodyAmount > recordAmount) {
|
|
3217
|
+
closeAmount = bodyAmount;
|
|
3218
|
+
closeSig = body?.signature ?? record?.signature ?? "";
|
|
3219
|
+
} else {
|
|
3220
|
+
closeAmount = recordAmount;
|
|
3221
|
+
closeSig = record?.signature ?? "";
|
|
3222
|
+
}
|
|
835
3223
|
if (closeAmount > 0n && closeSig) {
|
|
3224
|
+
const closePayer = record?.payer ?? null;
|
|
836
3225
|
const closeTxHash = await close({
|
|
837
|
-
channel: opts.
|
|
3226
|
+
channel: opts.channelFactory,
|
|
838
3227
|
amount: closeAmount,
|
|
839
3228
|
signature: Buffer.from(closeSig, "hex"),
|
|
840
|
-
feePayer:
|
|
3229
|
+
feePayer: channelAuthorizer(payeeKeypair),
|
|
841
3230
|
network: networkId
|
|
842
3231
|
});
|
|
3232
|
+
settledCleanly = true;
|
|
3233
|
+
clearIdleTimer();
|
|
843
3234
|
if (opts.onSettled) {
|
|
844
|
-
const totalPaid = (Number(
|
|
845
|
-
|
|
3235
|
+
const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
|
|
3236
|
+
Promise.resolve().then(() => opts.onSettled(closeTxHash, totalPaid, "mpp-session", closePayer)).catch((err) => {
|
|
3237
|
+
console.error("[mpp-session] onSettled callback error:", err);
|
|
3238
|
+
opts.onCallbackError?.(err, "onSettled");
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
const vaultContract = process.env.AGENT_VAULT_CONTRACT;
|
|
3242
|
+
if (vaultContract) {
|
|
3243
|
+
const settlementAddresses = resolveVaultSettlementAddresses(
|
|
3244
|
+
closePayer,
|
|
3245
|
+
payeeKeypair.publicKey()
|
|
3246
|
+
);
|
|
3247
|
+
if (!settlementAddresses) {
|
|
3248
|
+
console.error("[mpp-session] skipped session_settled vault record: payer address unavailable");
|
|
3249
|
+
} else {
|
|
3250
|
+
try {
|
|
3251
|
+
const { Networks: Networks2 } = await import('@stellar/stellar-sdk');
|
|
3252
|
+
const { Server } = await import('@stellar/stellar-sdk/rpc');
|
|
3253
|
+
const networkPassphrase = opts.network === "mainnet" ? Networks2.PUBLIC : Networks2.TESTNET;
|
|
3254
|
+
const rpcUrl = opts.network === "mainnet" ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org";
|
|
3255
|
+
const server = new Server(rpcUrl);
|
|
3256
|
+
const payeePublicKey = payeeKeypair.publicKey();
|
|
3257
|
+
const sourceAccount = await server.getAccount(payeePublicKey);
|
|
3258
|
+
const tx = buildSessionSettlementTransaction(
|
|
3259
|
+
sourceAccount,
|
|
3260
|
+
vaultContract,
|
|
3261
|
+
opts.channelFactory,
|
|
3262
|
+
settlementAddresses,
|
|
3263
|
+
closeAmount,
|
|
3264
|
+
voucherCount,
|
|
3265
|
+
networkPassphrase
|
|
3266
|
+
);
|
|
3267
|
+
const preparedTx = await server.prepareTransaction(tx);
|
|
3268
|
+
preparedTx.sign(payeeKeypair);
|
|
3269
|
+
await server.sendTransaction(preparedTx);
|
|
3270
|
+
} catch (recordErr) {
|
|
3271
|
+
console.error("[mpp-session] failed to record session_settled on vault:", recordErr);
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
846
3274
|
}
|
|
847
3275
|
res.json({ closeTxHash });
|
|
848
3276
|
} else {
|
|
@@ -850,23 +3278,12 @@ function createMppSessionHandler(opts) {
|
|
|
850
3278
|
}
|
|
851
3279
|
sessionOpened = false;
|
|
852
3280
|
voucherCount = 0;
|
|
853
|
-
|
|
3281
|
+
record = null;
|
|
3282
|
+
await innerStore.delete(voucherRecordKey);
|
|
3283
|
+
closeListenerArmed = false;
|
|
3284
|
+
clearIdleTimer();
|
|
854
3285
|
return;
|
|
855
3286
|
}
|
|
856
|
-
const authHeader = req.headers["authorization"];
|
|
857
|
-
if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
|
|
858
|
-
try {
|
|
859
|
-
const credB64 = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
|
|
860
|
-
if (credB64) {
|
|
861
|
-
const credJson = Buffer.from(credB64.split("=").slice(1).join("=").replace(/^"|"$/g, ""), "base64").toString("utf8");
|
|
862
|
-
const cred = JSON.parse(credJson);
|
|
863
|
-
if (cred.payload?.signature) {
|
|
864
|
-
lastSignatureHex = cred.payload.signature;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
} catch {
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
3287
|
const fetchReq = Request.fromNodeListener(req, res);
|
|
871
3288
|
const result = await mppx.channel({
|
|
872
3289
|
amount: rateHuman,
|
|
@@ -880,6 +3297,12 @@ function createMppSessionHandler(opts) {
|
|
|
880
3297
|
res.send(body);
|
|
881
3298
|
return;
|
|
882
3299
|
}
|
|
3300
|
+
if (!closeListenerArmed) {
|
|
3301
|
+
closeListenerArmed = true;
|
|
3302
|
+
req.on("close", () => {
|
|
3303
|
+
void flagOrphan("connection-closed");
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
883
3306
|
next();
|
|
884
3307
|
} catch (err) {
|
|
885
3308
|
next(err);
|
|
@@ -888,20 +3311,26 @@ function createMppSessionHandler(opts) {
|
|
|
888
3311
|
}
|
|
889
3312
|
|
|
890
3313
|
// src/provider/routedockMiddleware.ts
|
|
3314
|
+
var MPP_MODES = ["mpp-session", "mpp-charge"];
|
|
891
3315
|
function routedock(opts) {
|
|
892
|
-
const
|
|
3316
|
+
const startTime = Date.now();
|
|
3317
|
+
const signedManifest = signManifest(opts.manifest, opts.payeeSecretKey);
|
|
3318
|
+
const handlerMap = /* @__PURE__ */ new Map();
|
|
893
3319
|
if (opts.modes.includes("x402")) {
|
|
894
3320
|
const x402Price = opts.pricing.x402;
|
|
895
3321
|
if (x402Price) {
|
|
896
|
-
|
|
3322
|
+
handlerMap.set(
|
|
3323
|
+
"x402",
|
|
897
3324
|
createX402Handler({
|
|
898
3325
|
payeeSecretKey: opts.payeeSecretKey,
|
|
899
3326
|
network: opts.network,
|
|
900
3327
|
amount: x402Price,
|
|
901
3328
|
assetContract: opts.assetContract,
|
|
902
3329
|
...opts.facilitatorApiKey ? { facilitatorApiKey: opts.facilitatorApiKey } : {},
|
|
903
|
-
manifest:
|
|
904
|
-
...opts.onSettled ? { onSettled: opts.onSettled } : {}
|
|
3330
|
+
manifest: signedManifest,
|
|
3331
|
+
...opts.onSettled ? { onSettled: opts.onSettled } : {},
|
|
3332
|
+
...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
|
|
3333
|
+
...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {}
|
|
905
3334
|
})
|
|
906
3335
|
);
|
|
907
3336
|
}
|
|
@@ -909,14 +3338,17 @@ function routedock(opts) {
|
|
|
909
3338
|
if (opts.modes.includes("mpp-charge")) {
|
|
910
3339
|
const chargePrice = opts.pricing["mpp-charge"];
|
|
911
3340
|
if (chargePrice) {
|
|
912
|
-
|
|
3341
|
+
handlerMap.set(
|
|
3342
|
+
"mpp-charge",
|
|
913
3343
|
createMppChargeHandler({
|
|
914
3344
|
payeeSecretKey: opts.payeeSecretKey,
|
|
915
3345
|
network: opts.network,
|
|
916
3346
|
amount: chargePrice,
|
|
917
3347
|
assetContract: opts.assetContract,
|
|
918
|
-
manifest:
|
|
919
|
-
...opts.onSettled ? { onSettled: opts.onSettled } : {}
|
|
3348
|
+
manifest: signedManifest,
|
|
3349
|
+
...opts.onSettled ? { onSettled: opts.onSettled } : {},
|
|
3350
|
+
...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
|
|
3351
|
+
...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {}
|
|
920
3352
|
})
|
|
921
3353
|
);
|
|
922
3354
|
}
|
|
@@ -927,39 +3359,52 @@ function routedock(opts) {
|
|
|
927
3359
|
if (!opts.commitmentPublicKey) {
|
|
928
3360
|
throw new Error("routedock: mpp-session mode requires commitmentPublicKey");
|
|
929
3361
|
}
|
|
930
|
-
|
|
3362
|
+
handlerMap.set(
|
|
3363
|
+
"mpp-session",
|
|
931
3364
|
createMppSessionHandler({
|
|
932
3365
|
payeeSecretKey: opts.payeeSecretKey,
|
|
933
3366
|
network: opts.network,
|
|
934
|
-
|
|
3367
|
+
channelFactory: sessionPricing.channelFactory,
|
|
935
3368
|
rate: sessionPricing.rate,
|
|
936
3369
|
assetContract: opts.assetContract,
|
|
937
|
-
manifest:
|
|
3370
|
+
manifest: signedManifest,
|
|
938
3371
|
commitmentPublicKey: opts.commitmentPublicKey,
|
|
939
3372
|
...opts.onSettled ? { onSettled: opts.onSettled } : {},
|
|
940
3373
|
...opts.onSessionOpen ? { onSessionOpen: opts.onSessionOpen } : {},
|
|
941
|
-
...opts.onVoucher ? { onVoucher: opts.onVoucher } : {}
|
|
3374
|
+
...opts.onVoucher ? { onVoucher: opts.onVoucher } : {},
|
|
3375
|
+
...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
|
|
3376
|
+
...opts.onOrphaned ? { onOrphaned: opts.onOrphaned } : {},
|
|
3377
|
+
...opts.idleTimeoutMs != null ? { idleTimeoutMs: opts.idleTimeoutMs } : {}
|
|
942
3378
|
})
|
|
943
3379
|
);
|
|
944
3380
|
}
|
|
945
3381
|
}
|
|
3382
|
+
const defaultMode = MPP_MODES.find((mode) => handlerMap.has(mode)) ?? (handlerMap.has("x402") ? "x402" : void 0);
|
|
946
3383
|
return (req, res, next) => {
|
|
947
3384
|
if (req.path === "/.well-known/routedock.json") {
|
|
948
|
-
res.json(
|
|
3385
|
+
res.json(signedManifest);
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
if (req.path === "/health") {
|
|
3389
|
+
res.json({
|
|
3390
|
+
status: "ok",
|
|
3391
|
+
routedock: opts.manifest.routedock,
|
|
3392
|
+
network: opts.network,
|
|
3393
|
+
payee: opts.payee,
|
|
3394
|
+
modesActive: opts.modes,
|
|
3395
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3)
|
|
3396
|
+
});
|
|
949
3397
|
return;
|
|
950
3398
|
}
|
|
951
|
-
if (
|
|
3399
|
+
if (handlerMap.size === 0) {
|
|
952
3400
|
next();
|
|
953
3401
|
return;
|
|
954
3402
|
}
|
|
955
3403
|
const hasX402Header = !!(req.headers["payment-signature"] || req.headers["x-payment"]);
|
|
956
3404
|
const prefersX402 = req.headers["x-preferred-mode"] === "x402";
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
} else {
|
|
961
|
-
handler = handlers[handlers.length - 1];
|
|
962
|
-
}
|
|
3405
|
+
const x402Handler = handlerMap.get("x402");
|
|
3406
|
+
const defaultHandler = defaultMode ? handlerMap.get(defaultMode) : void 0;
|
|
3407
|
+
const handler = hasX402Header || prefersX402 ? x402Handler ?? defaultHandler : defaultHandler;
|
|
963
3408
|
if (!handler) {
|
|
964
3409
|
next();
|
|
965
3410
|
return;
|
|
@@ -973,4 +3418,287 @@ function routedock(opts) {
|
|
|
973
3418
|
};
|
|
974
3419
|
}
|
|
975
3420
|
|
|
976
|
-
|
|
3421
|
+
// src/provider/registerProvider.ts
|
|
3422
|
+
async function registerProvider(optionsOrSupabase, manifestArg, baseUrlArg, verifiedArg) {
|
|
3423
|
+
let supabase;
|
|
3424
|
+
let manifest;
|
|
3425
|
+
let baseUrl;
|
|
3426
|
+
let verified;
|
|
3427
|
+
if ("supabase" in optionsOrSupabase) {
|
|
3428
|
+
supabase = optionsOrSupabase.supabase;
|
|
3429
|
+
manifest = optionsOrSupabase.manifest;
|
|
3430
|
+
baseUrl = optionsOrSupabase.baseUrl;
|
|
3431
|
+
verified = optionsOrSupabase.verified ?? true;
|
|
3432
|
+
} else {
|
|
3433
|
+
supabase = optionsOrSupabase;
|
|
3434
|
+
if (!manifestArg || !baseUrlArg) {
|
|
3435
|
+
throw new RouteDockManifestError("manifest and baseUrl are required for registerProvider");
|
|
3436
|
+
}
|
|
3437
|
+
manifest = manifestArg;
|
|
3438
|
+
baseUrl = baseUrlArg;
|
|
3439
|
+
verified = verifiedArg ?? true;
|
|
3440
|
+
}
|
|
3441
|
+
verifyManifestSignature(manifest);
|
|
3442
|
+
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
|
3443
|
+
const { error } = await supabase.from("providers").upsert(
|
|
3444
|
+
{
|
|
3445
|
+
name: manifest.name,
|
|
3446
|
+
description: manifest.description ?? null,
|
|
3447
|
+
base_url: normalizedBaseUrl,
|
|
3448
|
+
modes: manifest.modes,
|
|
3449
|
+
tags: manifest.tags ?? [],
|
|
3450
|
+
categories: manifest.categories ?? [],
|
|
3451
|
+
network: manifest.network,
|
|
3452
|
+
payee: manifest.payee,
|
|
3453
|
+
manifest,
|
|
3454
|
+
verified,
|
|
3455
|
+
registered_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3456
|
+
},
|
|
3457
|
+
{ onConflict: "base_url" }
|
|
3458
|
+
);
|
|
3459
|
+
if (error) {
|
|
3460
|
+
throw new Error(`Failed to register provider in Supabase: ${error.message}`);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
var MPP_NETWORK3 = {
|
|
3464
|
+
testnet: "stellar:testnet",
|
|
3465
|
+
mainnet: "stellar:pubnet"
|
|
3466
|
+
};
|
|
3467
|
+
function decimalToStroops(amountStr) {
|
|
3468
|
+
return usdcToStroops(amountStr);
|
|
3469
|
+
}
|
|
3470
|
+
async function reconcileAbandonedSessions(opts) {
|
|
3471
|
+
const stats = {
|
|
3472
|
+
orphanedCount: 0,
|
|
3473
|
+
recoveredCount: 0,
|
|
3474
|
+
skippedCount: 0,
|
|
3475
|
+
failedCount: 0,
|
|
3476
|
+
errors: []
|
|
3477
|
+
};
|
|
3478
|
+
const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
|
|
3479
|
+
const networkId = MPP_NETWORK3[opts.network];
|
|
3480
|
+
try {
|
|
3481
|
+
const { data: closingSessions, error } = await opts.supabase.from("sessions").select("channel_id, channel_contract, cumulative_amount, last_signature, settlement_tx_hash").eq("status", "closing").eq("network", opts.network).eq("payee", payeeKeypair.publicKey()).is("settlement_tx_hash", null).not("last_signature", "is", null).order("updated_at", { ascending: true }).limit(100);
|
|
3482
|
+
if (error) {
|
|
3483
|
+
throw new Error(`Failed to query sessions: ${error.message}`);
|
|
3484
|
+
}
|
|
3485
|
+
stats.orphanedCount = closingSessions?.length ?? 0;
|
|
3486
|
+
if (!closingSessions || closingSessions.length === 0) {
|
|
3487
|
+
return stats;
|
|
3488
|
+
}
|
|
3489
|
+
for (const session of closingSessions) {
|
|
3490
|
+
try {
|
|
3491
|
+
const {
|
|
3492
|
+
channel_id: sessionId,
|
|
3493
|
+
channel_contract: channelContract,
|
|
3494
|
+
cumulative_amount,
|
|
3495
|
+
last_signature
|
|
3496
|
+
} = session;
|
|
3497
|
+
const channelId = channelContract || sessionId;
|
|
3498
|
+
if (!channelId || !cumulative_amount || !last_signature) {
|
|
3499
|
+
stats.skippedCount++;
|
|
3500
|
+
continue;
|
|
3501
|
+
}
|
|
3502
|
+
const closeAmount = decimalToStroops(cumulative_amount);
|
|
3503
|
+
const closeSig = Buffer.from(last_signature, "hex");
|
|
3504
|
+
const closeFn = opts.channelClose ?? (await import('@stellar/mpp/channel/server')).close;
|
|
3505
|
+
const closeTxHash = await closeFn({
|
|
3506
|
+
channel: channelId,
|
|
3507
|
+
amount: closeAmount,
|
|
3508
|
+
signature: closeSig,
|
|
3509
|
+
feePayer: channelAuthorizer(payeeKeypair),
|
|
3510
|
+
network: networkId
|
|
3511
|
+
});
|
|
3512
|
+
const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
|
|
3513
|
+
const { error: updateError } = await opts.supabase.from("sessions").update({
|
|
3514
|
+
status: "closed",
|
|
3515
|
+
settlement_tx_hash: closeTxHash,
|
|
3516
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3517
|
+
}).eq("channel_id", sessionId);
|
|
3518
|
+
if (updateError) {
|
|
3519
|
+
throw new Error(`Failed to update session: ${updateError.message}`);
|
|
3520
|
+
}
|
|
3521
|
+
stats.recoveredCount++;
|
|
3522
|
+
if (opts.onRecovered) {
|
|
3523
|
+
await opts.onRecovered(channelId, closeTxHash, totalPaid);
|
|
3524
|
+
}
|
|
3525
|
+
} catch (err) {
|
|
3526
|
+
stats.failedCount++;
|
|
3527
|
+
stats.errors.push({
|
|
3528
|
+
channelId: session.channel_id,
|
|
3529
|
+
reason: formatMppError(err)
|
|
3530
|
+
});
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
return stats;
|
|
3534
|
+
} catch (err) {
|
|
3535
|
+
throw new Error(
|
|
3536
|
+
`Session reconciliation failed: ${formatMppError(err)}`
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
async function runStartupReconciliation(opts, logger) {
|
|
3541
|
+
const log = logger || console.log;
|
|
3542
|
+
log("[SessionReconciler] Running startup reconciliation...");
|
|
3543
|
+
try {
|
|
3544
|
+
const stats = await reconcileAbandonedSessions(opts);
|
|
3545
|
+
if (stats.orphanedCount === 0) {
|
|
3546
|
+
log("[SessionReconciler] No orphaned sessions found");
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
log(
|
|
3550
|
+
`[SessionReconciler] Found ${stats.orphanedCount} orphaned session(s). Recovered: ${stats.recoveredCount}, Skipped: ${stats.skippedCount}, Failed: ${stats.failedCount}`
|
|
3551
|
+
);
|
|
3552
|
+
if (stats.errors.length > 0) {
|
|
3553
|
+
log("[SessionReconciler] Reconciliation errors:");
|
|
3554
|
+
for (const err of stats.errors) {
|
|
3555
|
+
log(` - ${err.channelId}: ${err.reason}`);
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
} catch (err) {
|
|
3559
|
+
log(`[SessionReconciler] Startup reconciliation failed: ${formatMppError(err)}`);
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
function tryDecodeBase64(value) {
|
|
3563
|
+
if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("/") || value.startsWith("[")) {
|
|
3564
|
+
return value;
|
|
3565
|
+
}
|
|
3566
|
+
try {
|
|
3567
|
+
const decoded = typeof Buffer !== "undefined" ? Buffer.from(value, "base64").toString("utf-8") : atob(value);
|
|
3568
|
+
if (decoded.length > 0 && decoded !== value) return decoded;
|
|
3569
|
+
} catch {
|
|
3570
|
+
}
|
|
3571
|
+
return value;
|
|
3572
|
+
}
|
|
3573
|
+
var OnChainRegistry = class {
|
|
3574
|
+
horizon;
|
|
3575
|
+
knownAccounts;
|
|
3576
|
+
timeoutMs;
|
|
3577
|
+
constructor(options) {
|
|
3578
|
+
this.horizon = new Horizon.Server(options.horizonUrl);
|
|
3579
|
+
this.knownAccounts = options.knownAccounts;
|
|
3580
|
+
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
3581
|
+
}
|
|
3582
|
+
/**
|
|
3583
|
+
* stellar-sdk 14.6.1 has no way to cancel the underlying Horizon request, so
|
|
3584
|
+
* we stop waiting on it instead: the promise returned here rejects once
|
|
3585
|
+
* `timeoutMs` elapses, even though the request may still be in flight. The
|
|
3586
|
+
* timer is always cleared, including on the error path.
|
|
3587
|
+
*/
|
|
3588
|
+
loadAccountWithTimeout(accountId) {
|
|
3589
|
+
let timer;
|
|
3590
|
+
const timeout = new Promise((_, reject) => {
|
|
3591
|
+
timer = setTimeout(
|
|
3592
|
+
() => reject(new Error(`Horizon loadAccount timed out after ${this.timeoutMs}ms`)),
|
|
3593
|
+
this.timeoutMs
|
|
3594
|
+
);
|
|
3595
|
+
});
|
|
3596
|
+
return Promise.race([this.horizon.loadAccount(accountId), timeout]).finally(() => {
|
|
3597
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
async listProviders() {
|
|
3601
|
+
const settled = await Promise.allSettled(
|
|
3602
|
+
this.knownAccounts.map((accountId) => this.loadAccountWithTimeout(accountId))
|
|
3603
|
+
);
|
|
3604
|
+
const results = [];
|
|
3605
|
+
for (let i = 0; i < settled.length; i++) {
|
|
3606
|
+
const outcome = settled[i];
|
|
3607
|
+
if (outcome.status !== "fulfilled") continue;
|
|
3608
|
+
const accountId = this.knownAccounts[i];
|
|
3609
|
+
const account = outcome.value;
|
|
3610
|
+
const dataEntries = account.data_attr;
|
|
3611
|
+
if (!dataEntries) continue;
|
|
3612
|
+
const rawEndpoint = dataEntries["routedock_endpoint"];
|
|
3613
|
+
if (!rawEndpoint) continue;
|
|
3614
|
+
const endpoint = tryDecodeBase64(rawEndpoint);
|
|
3615
|
+
if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) continue;
|
|
3616
|
+
let tags = [];
|
|
3617
|
+
const rawTags = dataEntries["routedock_tags"];
|
|
3618
|
+
if (rawTags) {
|
|
3619
|
+
const decoded = tryDecodeBase64(rawTags);
|
|
3620
|
+
try {
|
|
3621
|
+
const parsed = JSON.parse(decoded);
|
|
3622
|
+
if (Array.isArray(parsed)) tags = parsed;
|
|
3623
|
+
} catch {
|
|
3624
|
+
tags = decoded.split(",").map((t) => t.trim()).filter(Boolean);
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
results.push({ account: accountId, endpoint, tags });
|
|
3628
|
+
}
|
|
3629
|
+
return results;
|
|
3630
|
+
}
|
|
3631
|
+
};
|
|
3632
|
+
|
|
3633
|
+
// src/registry/ProviderRegistry.ts
|
|
3634
|
+
function deriveNetwork(horizonUrl) {
|
|
3635
|
+
return horizonUrl.includes("testnet") ? "testnet" : "mainnet";
|
|
3636
|
+
}
|
|
3637
|
+
var ProviderRegistry = class {
|
|
3638
|
+
supabase;
|
|
3639
|
+
onChain;
|
|
3640
|
+
network;
|
|
3641
|
+
constructor(config) {
|
|
3642
|
+
this.supabase = config.supabase;
|
|
3643
|
+
this.network = config.onChain.network ?? deriveNetwork(config.onChain.horizonUrl);
|
|
3644
|
+
this.onChain = new OnChainRegistry({
|
|
3645
|
+
horizonUrl: config.onChain.horizonUrl,
|
|
3646
|
+
knownAccounts: config.onChain.knownAccounts,
|
|
3647
|
+
...config.onChain.timeoutMs !== void 0 ? { timeoutMs: config.onChain.timeoutMs } : {}
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
async listProviders() {
|
|
3651
|
+
const supabaseProviders = await this.trySupabase();
|
|
3652
|
+
if (supabaseProviders.length > 0) return supabaseProviders;
|
|
3653
|
+
return this.tryOnChain();
|
|
3654
|
+
}
|
|
3655
|
+
async trySupabase() {
|
|
3656
|
+
if (!this.supabase) return [];
|
|
3657
|
+
try {
|
|
3658
|
+
const { data, error } = await this.supabase.from("providers").select("*").eq("verified", true).eq("network", this.network).limit(100);
|
|
3659
|
+
if (error) return [];
|
|
3660
|
+
if (!data || data.length === 0) return [];
|
|
3661
|
+
return data.map((r) => ({
|
|
3662
|
+
name: r.name,
|
|
3663
|
+
description: r.description ?? void 0,
|
|
3664
|
+
base_url: r.base_url,
|
|
3665
|
+
modes: r.modes,
|
|
3666
|
+
tags: r.tags,
|
|
3667
|
+
network: r.network,
|
|
3668
|
+
payee: r.payee,
|
|
3669
|
+
source: "supabase",
|
|
3670
|
+
verified: r.verified === true
|
|
3671
|
+
}));
|
|
3672
|
+
} catch {
|
|
3673
|
+
return [];
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
async tryOnChain() {
|
|
3677
|
+
const providers = await this.onChain.listProviders();
|
|
3678
|
+
return providers.map((p) => ({
|
|
3679
|
+
name: `On-chain provider (${p.account.slice(0, 8)}...)`,
|
|
3680
|
+
description: `Provider registered on Stellar account ${p.account}`,
|
|
3681
|
+
base_url: p.endpoint,
|
|
3682
|
+
modes: [],
|
|
3683
|
+
tags: p.tags,
|
|
3684
|
+
network: this.network,
|
|
3685
|
+
payee: p.account,
|
|
3686
|
+
source: "onchain",
|
|
3687
|
+
verified: true
|
|
3688
|
+
}));
|
|
3689
|
+
}
|
|
3690
|
+
/**
|
|
3691
|
+
* Fetch a provider's manifest bound to the record's payee. The record's
|
|
3692
|
+
* `payee` (from supabase `verified` rows or the on-chain registry account)
|
|
3693
|
+
* is the out-of-band trust anchor: the fetched manifest's `payee` must
|
|
3694
|
+
* equal it, otherwise `RouteDockSignatureError` is thrown. This closes the
|
|
3695
|
+
* gap where the registry never cross-checked the on-chain payee against
|
|
3696
|
+
* the manifest payee.
|
|
3697
|
+
*/
|
|
3698
|
+
async fetchProviderManifest(record) {
|
|
3699
|
+
const baseUrl = new URL(record.base_url).origin;
|
|
3700
|
+
return fetchManifest(baseUrl, void 0, void 0, record.payee);
|
|
3701
|
+
}
|
|
3702
|
+
};
|
|
3703
|
+
|
|
3704
|
+
export { DEFAULT_MAX_SESSION_DURATION_MS, DEFAULT_RETRY_POLICY, FileSpendStore, InMemorySpendStore, MppChargeClient, NulthPolicyError, OnChainRegistry, ProviderRegistry, RouteDockChannelStateError, RouteDockClient, RouteDockClientVersionError, RouteDockDisputeError, RouteDockError, RouteDockFacilitatorError, RouteDockManifestError, RouteDockManifestSunsetError, RouteDockManifestTimeoutError, RouteDockNetworkError, RouteDockNoSupportedModeError, RouteDockPolicyRejectError, RouteDockPolicyRejectedError, RouteDockRefundWindowError, RouteDockSessionError, RouteDockSignatureError, RouteDockTrustlineError, RouteDockVoucherMonotonicityError, SupabaseSeenTxStore, SupabaseSessionStore, X402Client, assertEndpointActive, assertManifestValid, assertNulthVaultManifest, backoffDelayMs, configureManifestCache, createNulthSigner, createPolicyState, decimalToStroops, decodeAuthSignature, fetchLedgerSequence, fetchManifest, httpStatusToError, invalidateManifest, manifestDigest, parseRetryAfterMs, paymentContextFromManifest, prepareNulthSigner, rankProvidersByLatency, reconcileAbandonedSessions, registerProvider, routedock, runStartupReconciliation, selectMode, setNulthPaymentContext, signManifest, usdcToMicros, usdcToUnits, verifyManifestSignature, withRetry, wrapFetchError };
|