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