@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/provider.js
CHANGED
|
@@ -1,46 +1,311 @@
|
|
|
1
|
-
import { Keypair } from '@stellar/stellar-sdk';
|
|
1
|
+
import { Keypair, StrKey, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } from '@stellar/stellar-sdk';
|
|
2
2
|
import { ExactStellarScheme } from '@x402/stellar/exact/facilitator';
|
|
3
3
|
import { ExactStellarScheme as ExactStellarScheme$1 } from '@x402/stellar/exact/server';
|
|
4
4
|
import { HTTPFacilitatorClient, x402ResourceServer } from '@x402/core/server';
|
|
5
5
|
import { createEd25519Signer } from '@x402/stellar';
|
|
6
6
|
import { encodePaymentRequiredHeader, decodePaymentSignatureHeader, encodePaymentResponseHeader } from '@x402/core/http';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { usdcToStroops } from '@routedock/nulth-sdk';
|
|
8
|
+
import { stellar as stellar$1 } from '@stellar/mpp/charge/server';
|
|
9
|
+
import { Request, Mppx } from 'mppx/server';
|
|
10
|
+
import { Store, stellar, close } from '@stellar/mpp/channel/server';
|
|
11
|
+
import { Errors } from 'mppx';
|
|
12
|
+
import { createHash } from 'crypto';
|
|
10
13
|
|
|
11
|
-
// src/
|
|
14
|
+
// src/errors.ts
|
|
12
15
|
var RouteDockError = class extends Error {
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
code;
|
|
17
|
+
retryable;
|
|
18
|
+
retryAfterMs;
|
|
19
|
+
constructor(message, code, retryable, options = {}) {
|
|
20
|
+
super(message, { cause: options.cause });
|
|
15
21
|
this.name = "RouteDockError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.retryable = retryable;
|
|
24
|
+
if (options.retryAfterMs !== void 0) {
|
|
25
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
26
|
+
}
|
|
16
27
|
}
|
|
17
28
|
};
|
|
18
29
|
var RouteDockManifestError = class extends RouteDockError {
|
|
19
|
-
constructor(message) {
|
|
20
|
-
super(message);
|
|
30
|
+
constructor(message, options = {}) {
|
|
31
|
+
super(message, "MANIFEST", false, options);
|
|
21
32
|
this.name = "RouteDockManifestError";
|
|
22
33
|
}
|
|
23
34
|
};
|
|
24
35
|
var RouteDockNoSupportedModeError = class extends RouteDockError {
|
|
25
|
-
constructor(message) {
|
|
26
|
-
super(message);
|
|
36
|
+
constructor(message, options = {}) {
|
|
37
|
+
super(message, "NO_SUPPORTED_MODE", false, options);
|
|
27
38
|
this.name = "RouteDockNoSupportedModeError";
|
|
28
39
|
}
|
|
29
40
|
};
|
|
30
|
-
var
|
|
31
|
-
constructor(message) {
|
|
32
|
-
super(message);
|
|
33
|
-
this.name = "
|
|
41
|
+
var RouteDockClientVersionError = class extends RouteDockError {
|
|
42
|
+
constructor(message, options = {}) {
|
|
43
|
+
super(message, "CLIENT_VERSION_TOO_OLD", false, options);
|
|
44
|
+
this.name = "RouteDockClientVersionError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var RouteDockTrustlineError = class extends RouteDockError {
|
|
48
|
+
asset;
|
|
49
|
+
issuer;
|
|
50
|
+
remediation;
|
|
51
|
+
constructor(asset, issuer, remediation, options = {}) {
|
|
52
|
+
super(
|
|
53
|
+
`Missing trustline for ${asset} (issuer: ${issuer}). ${remediation}`,
|
|
54
|
+
"TRUSTLINE",
|
|
55
|
+
false,
|
|
56
|
+
options
|
|
57
|
+
);
|
|
58
|
+
this.name = "RouteDockTrustlineError";
|
|
59
|
+
this.asset = asset;
|
|
60
|
+
this.issuer = issuer;
|
|
61
|
+
this.remediation = remediation;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var RouteDockFacilitatorError = class extends RouteDockError {
|
|
65
|
+
status;
|
|
66
|
+
constructor(message, status, options = {}) {
|
|
67
|
+
super(message, "FACILITATOR", true, options);
|
|
68
|
+
this.name = "RouteDockFacilitatorError";
|
|
69
|
+
this.status = status;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var RouteDockNetworkError = class extends RouteDockError {
|
|
73
|
+
constructor(message, options = {}) {
|
|
74
|
+
super(message, "NETWORK", true, options);
|
|
75
|
+
this.name = "RouteDockNetworkError";
|
|
34
76
|
}
|
|
35
77
|
};
|
|
36
|
-
var
|
|
78
|
+
var RouteDockSignatureError = class extends RouteDockError {
|
|
79
|
+
constructor(message, options = {}) {
|
|
80
|
+
super(message, "SIGNATURE", false, options);
|
|
81
|
+
this.name = "RouteDockSignatureError";
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var RouteDockVoucherMonotonicityError = class extends RouteDockError {
|
|
85
|
+
constructor(message, options = {}) {
|
|
86
|
+
super(message, "VOUCHER_MONOTONICITY", false, options);
|
|
87
|
+
this.name = "RouteDockVoucherMonotonicityError";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var RouteDockPolicyRejectError = class extends RouteDockError {
|
|
37
91
|
reason;
|
|
38
|
-
constructor(reason) {
|
|
39
|
-
super(`Payment rejected by policy: ${reason}
|
|
40
|
-
this.name = "
|
|
92
|
+
constructor(reason, options = {}) {
|
|
93
|
+
super(`Payment rejected by policy: ${reason}`, "POLICY_REJECT", false, options);
|
|
94
|
+
this.name = "RouteDockPolicyRejectError";
|
|
41
95
|
this.reason = reason;
|
|
42
96
|
}
|
|
43
97
|
};
|
|
98
|
+
var RouteDockChannelStateError = class extends RouteDockError {
|
|
99
|
+
constructor(message, options = {}) {
|
|
100
|
+
super(message, "CHANNEL_STATE", false, options);
|
|
101
|
+
this.name = "RouteDockChannelStateError";
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
var RouteDockDisputeError = class extends RouteDockError {
|
|
105
|
+
constructor(message, options = {}) {
|
|
106
|
+
super(message, "DISPUTE", false, options);
|
|
107
|
+
this.name = "RouteDockDisputeError";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
var RouteDockRefundWindowError = class extends RouteDockError {
|
|
111
|
+
constructor(message, options = {}) {
|
|
112
|
+
super(message, "REFUND_WINDOW", false, options);
|
|
113
|
+
this.name = "RouteDockRefundWindowError";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
var RouteDockPolicyRejectedError = RouteDockPolicyRejectError;
|
|
117
|
+
var RouteDockSessionError = RouteDockVoucherMonotonicityError;
|
|
118
|
+
|
|
119
|
+
// src/types.ts
|
|
120
|
+
var DEFAULT_MAX_SESSION_DURATION_MS = 36e5;
|
|
121
|
+
|
|
122
|
+
// src/internal/assetUtils.ts
|
|
123
|
+
function normalizeManifestAssets(manifest) {
|
|
124
|
+
if (manifest.assets && manifest.assets.length > 0) {
|
|
125
|
+
return manifest.assets;
|
|
126
|
+
}
|
|
127
|
+
if (manifest.asset && manifest.asset_contract) {
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
asset: manifest.asset,
|
|
131
|
+
asset_contract: manifest.asset_contract
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
throw new RouteDockManifestError(
|
|
136
|
+
"Manifest must define either assets[] or root asset/asset_contract fields"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
function getEligibleAssets(manifest, mode, endpoint) {
|
|
140
|
+
const assets = normalizeManifestAssets(manifest);
|
|
141
|
+
return assets.filter((asset) => {
|
|
142
|
+
if (asset.modes && !asset.modes.includes(mode)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (endpoint && asset.endpoints && asset.endpoints.length > 0) {
|
|
146
|
+
const trimmedEndpoint = endpoint.replace(/^\//, "");
|
|
147
|
+
if (asset.endpoints.includes(endpoint) || asset.endpoints.includes(trimmedEndpoint)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (manifest.endpoints) {
|
|
151
|
+
let pathname = endpoint;
|
|
152
|
+
if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
|
|
153
|
+
try {
|
|
154
|
+
pathname = new URL(endpoint).pathname;
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const trimmedPathname = pathname.replace(/^\//, "");
|
|
159
|
+
const matchesEndpoint = Object.entries(manifest.endpoints).some(([key, desc]) => {
|
|
160
|
+
const pathMatches = desc.path === pathname || desc.path === endpoint || desc.path.replace(/\/+$/, "") === pathname.replace(/\/+$/, "") || desc.path.replace(/^\//, "") === trimmedPathname;
|
|
161
|
+
const keyMatches = key === endpoint || key === trimmedEndpoint;
|
|
162
|
+
if (pathMatches || keyMatches) {
|
|
163
|
+
return asset.endpoints.includes(key) || asset.endpoints.includes(desc.path) || asset.endpoints.includes(desc.path.replace(/^\//, ""));
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
});
|
|
167
|
+
if (!matchesEndpoint) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function resolveAssetContract(manifest, mode, endpoint, explicitContract) {
|
|
178
|
+
if (manifest.assets && manifest.assets.length > 0) {
|
|
179
|
+
return selectAsset(manifest, mode, endpoint).asset_contract;
|
|
180
|
+
}
|
|
181
|
+
return explicitContract ?? selectAsset(manifest, mode, endpoint).asset_contract;
|
|
182
|
+
}
|
|
183
|
+
function selectAsset(manifest, mode, endpoint) {
|
|
184
|
+
const eligible = getEligibleAssets(manifest, mode, endpoint);
|
|
185
|
+
if (eligible.length === 0) {
|
|
186
|
+
const endpointMsg = endpoint ? ` for endpoint '${endpoint}'` : "";
|
|
187
|
+
throw new RouteDockManifestError(
|
|
188
|
+
`No eligible assets found for mode '${mode}'${endpointMsg}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return eligible[0];
|
|
192
|
+
}
|
|
193
|
+
function isAssetEligible(manifest, assetIdentifier, mode, endpoint) {
|
|
194
|
+
const eligible = getEligibleAssets(manifest, mode, endpoint);
|
|
195
|
+
return eligible.some(
|
|
196
|
+
(asset) => asset.asset === assetIdentifier || asset.asset_contract === assetIdentifier
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/internal/payee.ts
|
|
201
|
+
function resolvePayee(manifest, mode) {
|
|
202
|
+
const override = manifest.pricing[mode]?.payee;
|
|
203
|
+
return override ?? manifest.payee;
|
|
204
|
+
}
|
|
205
|
+
var usdcToUnits = usdcToStroops;
|
|
206
|
+
function extractPayerAddress(key) {
|
|
207
|
+
if (typeof key !== "string") return null;
|
|
208
|
+
return StrKey.isValidEd25519PublicKey(key) || StrKey.isValidMed25519PublicKey(key) ? key : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/internal/logger.ts
|
|
212
|
+
function createConsoleLogger() {
|
|
213
|
+
return (level, message, fields) => {
|
|
214
|
+
const values = fields ? Object.values(fields) : [];
|
|
215
|
+
if (values.length > 0) {
|
|
216
|
+
console[level](message, ...values);
|
|
217
|
+
} else {
|
|
218
|
+
console[level](message);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
var consoleLogger = createConsoleLogger();
|
|
223
|
+
function resolveLogger(logger, fallback = consoleLogger) {
|
|
224
|
+
return logger ?? fallback;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/provider/SeenTxStore.ts
|
|
228
|
+
var SETTLEMENT_REPLAY_WINDOW_MS = 6e4;
|
|
229
|
+
var MAX_SETTLEMENT_REPLAYS = 1;
|
|
230
|
+
async function checkSettlementReplay(store, key, now = Date.now()) {
|
|
231
|
+
const record = await store.get(key);
|
|
232
|
+
if (!record) return { kind: "miss" };
|
|
233
|
+
const { createdAt } = record;
|
|
234
|
+
if (createdAt !== void 0 && now - createdAt > SETTLEMENT_REPLAY_WINDOW_MS) {
|
|
235
|
+
return { kind: "spent" };
|
|
236
|
+
}
|
|
237
|
+
if (store.claimReplay) {
|
|
238
|
+
const replayed = await store.claimReplay(key);
|
|
239
|
+
if (replayed > MAX_SETTLEMENT_REPLAYS) return { kind: "spent" };
|
|
240
|
+
}
|
|
241
|
+
return { kind: "replay", record };
|
|
242
|
+
}
|
|
243
|
+
var InMemorySeenTxStore = class {
|
|
244
|
+
map = /* @__PURE__ */ new Map();
|
|
245
|
+
replayCounts = /* @__PURE__ */ new Map();
|
|
246
|
+
order = [];
|
|
247
|
+
constructor(maxEntriesOrOptions = 1e4) {
|
|
248
|
+
const options = typeof maxEntriesOrOptions === "number" ? { maxEntries: maxEntriesOrOptions } : maxEntriesOrOptions;
|
|
249
|
+
this.maxEntries = options.maxEntries ?? 1e4;
|
|
250
|
+
if (options.warn !== false) {
|
|
251
|
+
const isServerless = typeof globalThis.navigator !== "undefined" && globalThis.navigator.userAgent === "Cloudflare-Workers";
|
|
252
|
+
if (isServerless) {
|
|
253
|
+
resolveLogger(options.logger)(
|
|
254
|
+
"warn",
|
|
255
|
+
"[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."
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
maxEntries;
|
|
261
|
+
get(key) {
|
|
262
|
+
return this.map.get(key);
|
|
263
|
+
}
|
|
264
|
+
set(key, record) {
|
|
265
|
+
if (!this.map.has(key)) {
|
|
266
|
+
this.order.push(key);
|
|
267
|
+
if (this.order.length > this.maxEntries) {
|
|
268
|
+
const evicted = this.order.shift();
|
|
269
|
+
if (evicted !== void 0) {
|
|
270
|
+
this.map.delete(evicted);
|
|
271
|
+
this.replayCounts.delete(evicted);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
this.map.set(key, record);
|
|
276
|
+
}
|
|
277
|
+
claimReplay(key) {
|
|
278
|
+
if (!this.map.has(key)) return Number.POSITIVE_INFINITY;
|
|
279
|
+
const next = (this.replayCounts.get(key) ?? 0) + 1;
|
|
280
|
+
this.replayCounts.set(key, next);
|
|
281
|
+
return next;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
async function sha256Hex(input) {
|
|
285
|
+
const buf = new TextEncoder().encode(input);
|
|
286
|
+
const digest = await crypto.subtle.digest("SHA-256", buf);
|
|
287
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
288
|
+
}
|
|
289
|
+
async function paymentIdempotencyKey(getHeader, scope) {
|
|
290
|
+
const material = getHeader("payment-signature") ?? getHeader("x-payment") ?? getHeader("authorization");
|
|
291
|
+
if (!material) return null;
|
|
292
|
+
return sha256Hex(
|
|
293
|
+
[scope.method, scope.path, scope.amount, scope.payTo, material].join("\n")
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/provider/settleResult.ts
|
|
298
|
+
function readSettleResult(result) {
|
|
299
|
+
const settle = result;
|
|
300
|
+
const failed = (reason) => reason ? { ok: false, reason } : { ok: false };
|
|
301
|
+
if (!settle || settle.success !== true) return failed(settle?.errorReason);
|
|
302
|
+
if (typeof settle.transaction !== "string" || settle.transaction.length === 0) {
|
|
303
|
+
return failed(settle.errorReason);
|
|
304
|
+
}
|
|
305
|
+
return { ok: true, txHash: settle.transaction };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/provider/x402Handler.ts
|
|
44
309
|
var CAIP2 = {
|
|
45
310
|
testnet: "stellar:testnet",
|
|
46
311
|
mainnet: "stellar:pubnet"
|
|
@@ -50,6 +315,8 @@ function createX402Handler(opts) {
|
|
|
50
315
|
const caip2 = CAIP2[opts.network];
|
|
51
316
|
const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
|
|
52
317
|
const signer = createEd25519Signer(opts.payeeSecretKey, caip2);
|
|
318
|
+
const logger = resolveLogger(opts.logger);
|
|
319
|
+
const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore({ logger });
|
|
53
320
|
const useOzFacilitator = opts.network === "mainnet" && opts.facilitatorApiKey;
|
|
54
321
|
const localFacilitator = new ExactStellarScheme([signer], {
|
|
55
322
|
areFeesSponsored: true
|
|
@@ -68,23 +335,31 @@ function createX402Handler(opts) {
|
|
|
68
335
|
ozServer = new x402ResourceServer(facilitator);
|
|
69
336
|
ozServer.register(caip2, new ExactStellarScheme$1());
|
|
70
337
|
}
|
|
71
|
-
const amountInBaseUnits = String(
|
|
72
|
-
const
|
|
73
|
-
scheme: "exact",
|
|
74
|
-
network: caip2,
|
|
75
|
-
asset: opts.assetContract,
|
|
76
|
-
amount: amountInBaseUnits,
|
|
77
|
-
payTo: opts.manifest.payee,
|
|
78
|
-
maxTimeoutSeconds: 60,
|
|
79
|
-
extra: {
|
|
80
|
-
areFeesSponsored: true,
|
|
81
|
-
...useOzFacilitator ? {} : { facilitatorAddresses: [payeeKeypair.publicKey()] }
|
|
82
|
-
}
|
|
83
|
-
};
|
|
338
|
+
const amountInBaseUnits = String(usdcToUnits(opts.amount));
|
|
339
|
+
const payTo = resolvePayee(opts.manifest, "x402");
|
|
84
340
|
return async (req, res, next) => {
|
|
85
341
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
342
|
+
const endpoint = req.path || req.originalUrl;
|
|
343
|
+
const assetContract = resolveAssetContract(
|
|
344
|
+
opts.manifest,
|
|
345
|
+
"x402",
|
|
346
|
+
endpoint,
|
|
347
|
+
opts.assetContract
|
|
348
|
+
);
|
|
349
|
+
const requirements = {
|
|
350
|
+
scheme: "exact",
|
|
351
|
+
network: caip2,
|
|
352
|
+
asset: assetContract,
|
|
353
|
+
amount: amountInBaseUnits,
|
|
354
|
+
payTo,
|
|
355
|
+
maxTimeoutSeconds: 60,
|
|
356
|
+
extra: {
|
|
357
|
+
areFeesSponsored: true,
|
|
358
|
+
...useOzFacilitator ? {} : { facilitatorAddresses: [payeeKeypair.publicKey()] }
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const respondPaymentRequired = async (error, reason) => {
|
|
362
|
+
const body = { error, ...reason ? { reason } : {} };
|
|
88
363
|
if (ozServer) {
|
|
89
364
|
const resourceInfo = {
|
|
90
365
|
url: `${req.protocol}://${req.get("host") ?? ""}${req.originalUrl}`,
|
|
@@ -94,7 +369,7 @@ function createX402Handler(opts) {
|
|
|
94
369
|
[requirements],
|
|
95
370
|
resourceInfo
|
|
96
371
|
);
|
|
97
|
-
res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", encodePaymentRequiredHeader(paymentRequired)).json(
|
|
372
|
+
res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", encodePaymentRequiredHeader(paymentRequired)).json(body);
|
|
98
373
|
} else {
|
|
99
374
|
const x402Response = {
|
|
100
375
|
x402Version: 2,
|
|
@@ -104,23 +379,65 @@ function createX402Handler(opts) {
|
|
|
104
379
|
},
|
|
105
380
|
accepts: [requirements]
|
|
106
381
|
};
|
|
107
|
-
res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", encodePaymentRequiredHeader(x402Response)).json(
|
|
382
|
+
res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", encodePaymentRequiredHeader(x402Response)).json(body);
|
|
108
383
|
}
|
|
384
|
+
};
|
|
385
|
+
const paymentHeader = req.headers["payment-signature"] ?? req.headers["x-payment"];
|
|
386
|
+
if (!paymentHeader) {
|
|
387
|
+
await respondPaymentRequired("Payment Required");
|
|
109
388
|
return;
|
|
110
389
|
}
|
|
390
|
+
const idempotencyKey = await paymentIdempotencyKey(
|
|
391
|
+
(name) => {
|
|
392
|
+
const v = req.headers[name.toLowerCase()];
|
|
393
|
+
return Array.isArray(v) ? v[0] : v;
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
method: req.method,
|
|
397
|
+
path: req.originalUrl.split("?")[0],
|
|
398
|
+
amount: requirements.amount,
|
|
399
|
+
payTo: requirements.payTo
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
if (idempotencyKey) {
|
|
403
|
+
const replayCheck = await checkSettlementReplay(seenTxStore, idempotencyKey);
|
|
404
|
+
if (replayCheck.kind === "spent") {
|
|
405
|
+
res.status(402).json({ error: "Payment already used" });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (replayCheck.kind === "replay") {
|
|
409
|
+
if (replayCheck.record.headers) {
|
|
410
|
+
for (const [k, val] of Object.entries(replayCheck.record.headers)) {
|
|
411
|
+
res.setHeader(k, val);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
next();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
111
418
|
const payload = decodePaymentSignatureHeader(paymentHeader);
|
|
112
419
|
let txHash = null;
|
|
420
|
+
let payerAddress = null;
|
|
421
|
+
try {
|
|
422
|
+
const creds = payload.authorization?.credentials;
|
|
423
|
+
const key = Array.isArray(creds) ? creds[0]?.publicKey : void 0;
|
|
424
|
+
payerAddress = extractPayerAddress(key);
|
|
425
|
+
} catch {
|
|
426
|
+
}
|
|
113
427
|
if (ozServer) {
|
|
114
428
|
const settleResult = await ozServer.settlePayment(payload, requirements);
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
encodePaymentResponseHeader(
|
|
120
|
-
settleResult
|
|
121
|
-
)
|
|
122
|
-
);
|
|
429
|
+
const outcome = readSettleResult(settleResult);
|
|
430
|
+
if (!outcome.ok) {
|
|
431
|
+
await respondPaymentRequired("Payment settlement failed", outcome.reason);
|
|
432
|
+
return;
|
|
123
433
|
}
|
|
434
|
+
txHash = outcome.txHash;
|
|
435
|
+
res.setHeader(
|
|
436
|
+
"X-Payment-Response",
|
|
437
|
+
encodePaymentResponseHeader(
|
|
438
|
+
settleResult
|
|
439
|
+
)
|
|
440
|
+
);
|
|
124
441
|
} else {
|
|
125
442
|
const verifyResult = await localFacilitator.verify(
|
|
126
443
|
payload,
|
|
@@ -137,22 +454,40 @@ function createX402Handler(opts) {
|
|
|
137
454
|
payload,
|
|
138
455
|
requirements
|
|
139
456
|
);
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
457
|
+
const outcome = readSettleResult(settleResult);
|
|
458
|
+
if (!outcome.ok) {
|
|
459
|
+
await respondPaymentRequired("Payment settlement failed", outcome.reason);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
txHash = outcome.txHash;
|
|
463
|
+
res.setHeader(
|
|
464
|
+
"X-Payment-Response",
|
|
465
|
+
encodePaymentResponseHeader(
|
|
466
|
+
settleResult
|
|
467
|
+
)
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (idempotencyKey) {
|
|
471
|
+
const headers = {};
|
|
472
|
+
const paymentResponse = res.getHeader("X-Payment-Response");
|
|
473
|
+
if (typeof paymentResponse === "string") {
|
|
474
|
+
headers["X-Payment-Response"] = paymentResponse;
|
|
148
475
|
}
|
|
476
|
+
await seenTxStore.set(idempotencyKey, { txHash, headers, createdAt: Date.now() });
|
|
149
477
|
}
|
|
150
478
|
if (txHash && opts.onSettled) {
|
|
151
|
-
|
|
479
|
+
Promise.resolve().then(() => opts.onSettled(txHash, opts.amount, "x402", payerAddress)).catch((err) => {
|
|
480
|
+
logger("error", "[x402] onSettled callback error", { error: err });
|
|
481
|
+
opts.onCallbackError?.(err, "onSettled");
|
|
482
|
+
});
|
|
152
483
|
}
|
|
153
484
|
next();
|
|
154
485
|
} catch (err) {
|
|
155
|
-
|
|
486
|
+
if (err instanceof RouteDockManifestError) {
|
|
487
|
+
next(err);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
logger("error", "[x402] Settlement error", { error: err });
|
|
156
491
|
res.status(500).json({ error: "Payment settlement failed" });
|
|
157
492
|
}
|
|
158
493
|
};
|
|
@@ -164,25 +499,87 @@ var MPP_NETWORK = {
|
|
|
164
499
|
function createMppChargeHandler(opts) {
|
|
165
500
|
const networkId = MPP_NETWORK[opts.network];
|
|
166
501
|
const amountHumanReadable = opts.amount;
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
502
|
+
const recipient = resolvePayee(opts.manifest, "mpp-charge");
|
|
503
|
+
const logger = resolveLogger(opts.logger);
|
|
504
|
+
const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore({ logger });
|
|
505
|
+
const mppxInstances = /* @__PURE__ */ new Map();
|
|
506
|
+
function getMppx(contract) {
|
|
507
|
+
let instance = mppxInstances.get(contract);
|
|
508
|
+
if (!instance) {
|
|
509
|
+
instance = Mppx.create({
|
|
510
|
+
secretKey: opts.payeeSecretKey,
|
|
511
|
+
methods: [
|
|
512
|
+
stellar$1.charge({
|
|
513
|
+
recipient,
|
|
514
|
+
currency: contract,
|
|
515
|
+
network: networkId,
|
|
516
|
+
feePayer: { envelopeSigner: opts.payeeSecretKey }
|
|
517
|
+
})
|
|
518
|
+
]
|
|
519
|
+
});
|
|
520
|
+
mppxInstances.set(contract, instance);
|
|
521
|
+
}
|
|
522
|
+
return instance;
|
|
523
|
+
}
|
|
178
524
|
return async (req, res, next) => {
|
|
179
525
|
try {
|
|
526
|
+
const endpoint = req.path || req.originalUrl;
|
|
527
|
+
const assetContract = resolveAssetContract(
|
|
528
|
+
opts.manifest,
|
|
529
|
+
"mpp-charge",
|
|
530
|
+
endpoint,
|
|
531
|
+
opts.assetContract
|
|
532
|
+
);
|
|
533
|
+
const mppx = getMppx(assetContract);
|
|
534
|
+
let payerAddress = null;
|
|
535
|
+
try {
|
|
536
|
+
const authHeader = req.headers["authorization"];
|
|
537
|
+
if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
|
|
538
|
+
const credPart = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
|
|
539
|
+
if (credPart) {
|
|
540
|
+
const b64 = credPart.split("=").slice(1).join("=").replace(/^"|"$/g, "");
|
|
541
|
+
const credJson = Buffer.from(b64, "base64").toString("utf8");
|
|
542
|
+
const cred = JSON.parse(credJson);
|
|
543
|
+
const key = cred.sender ?? cred.payload?.sender ?? cred.payload?.from;
|
|
544
|
+
payerAddress = extractPayerAddress(key);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
}
|
|
549
|
+
const idempotencyKey = await paymentIdempotencyKey(
|
|
550
|
+
(name) => {
|
|
551
|
+
const v = req.headers[name.toLowerCase()];
|
|
552
|
+
return Array.isArray(v) ? v[0] : v;
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
method: req.method,
|
|
556
|
+
path: req.originalUrl.split("?")[0],
|
|
557
|
+
amount: amountHumanReadable,
|
|
558
|
+
payTo: recipient
|
|
559
|
+
}
|
|
560
|
+
);
|
|
561
|
+
if (idempotencyKey) {
|
|
562
|
+
const replayCheck = await checkSettlementReplay(seenTxStore, idempotencyKey);
|
|
563
|
+
if (replayCheck.kind === "spent") {
|
|
564
|
+
res.status(402).json({ error: "Payment already used" });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (replayCheck.kind === "replay") {
|
|
568
|
+
if (replayCheck.record.headers) {
|
|
569
|
+
for (const [k, val] of Object.entries(replayCheck.record.headers)) {
|
|
570
|
+
res.setHeader(k, val);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
next();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
180
577
|
const fetchReq = Request.fromNodeListener(req, res);
|
|
181
578
|
const handler = mppx["stellar/charge"];
|
|
182
579
|
const result = await handler({
|
|
183
580
|
amount: amountHumanReadable,
|
|
184
|
-
currency:
|
|
185
|
-
recipient
|
|
581
|
+
currency: assetContract,
|
|
582
|
+
recipient,
|
|
186
583
|
description: opts.manifest.name
|
|
187
584
|
})(fetchReq);
|
|
188
585
|
if (result.status === 402) {
|
|
@@ -193,25 +590,131 @@ function createMppChargeHandler(opts) {
|
|
|
193
590
|
return;
|
|
194
591
|
}
|
|
195
592
|
const receipt = result.withReceipt(new Response(""));
|
|
196
|
-
|
|
593
|
+
const receiptHeaders = {};
|
|
594
|
+
receipt.headers.forEach((v, k) => {
|
|
595
|
+
res.setHeader(k, v);
|
|
596
|
+
receiptHeaders[k] = v;
|
|
597
|
+
});
|
|
197
598
|
const receiptHeader = receipt.headers.get("payment-receipt");
|
|
198
|
-
|
|
599
|
+
let reference;
|
|
600
|
+
if (receiptHeader) {
|
|
199
601
|
try {
|
|
200
602
|
const parsed = JSON.parse(
|
|
201
603
|
Buffer.from(receiptHeader, "base64").toString("utf8")
|
|
202
604
|
);
|
|
203
|
-
|
|
204
|
-
await opts.onSettled(parsed.reference, opts.amount, "mpp-charge");
|
|
205
|
-
}
|
|
605
|
+
reference = parsed.reference;
|
|
206
606
|
} catch {
|
|
207
607
|
}
|
|
208
608
|
}
|
|
609
|
+
if (idempotencyKey) {
|
|
610
|
+
await seenTxStore.set(idempotencyKey, {
|
|
611
|
+
txHash: reference ?? null,
|
|
612
|
+
headers: receiptHeaders,
|
|
613
|
+
createdAt: Date.now()
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (reference && opts.onSettled) {
|
|
617
|
+
Promise.resolve().then(() => opts.onSettled(reference, opts.amount, "mpp-charge", payerAddress)).catch((err) => {
|
|
618
|
+
logger("error", "[mpp-charge] onSettled callback error", { error: err });
|
|
619
|
+
opts.onCallbackError?.(err, "onSettled");
|
|
620
|
+
});
|
|
621
|
+
}
|
|
209
622
|
next();
|
|
210
623
|
} catch (err) {
|
|
211
624
|
next(err);
|
|
212
625
|
}
|
|
213
626
|
};
|
|
214
627
|
}
|
|
628
|
+
function resolveVaultSettlementAddresses(payer, payee) {
|
|
629
|
+
if (!payer) return null;
|
|
630
|
+
return { payer, payee };
|
|
631
|
+
}
|
|
632
|
+
function buildSessionSettlementTransaction(source, vaultContractId, channelId, addresses, amount, voucherCount, networkPassphrase) {
|
|
633
|
+
const vault = new Contract(vaultContractId);
|
|
634
|
+
const op = vault.call(
|
|
635
|
+
"record_session_settlement",
|
|
636
|
+
nativeToScVal(channelId, { type: "address" }),
|
|
637
|
+
nativeToScVal(addresses.payer, { type: "address" }),
|
|
638
|
+
nativeToScVal(addresses.payee, { type: "address" }),
|
|
639
|
+
nativeToScVal(amount, { type: "i128" }),
|
|
640
|
+
nativeToScVal(voucherCount, { type: "u32" })
|
|
641
|
+
);
|
|
642
|
+
return new TransactionBuilder(source, {
|
|
643
|
+
fee: BASE_FEE,
|
|
644
|
+
networkPassphrase
|
|
645
|
+
}).addOperation(op).setTimeout(30).build();
|
|
646
|
+
}
|
|
647
|
+
function channelAuthorizer(envelopeSigner) {
|
|
648
|
+
return { envelopeSigner };
|
|
649
|
+
}
|
|
650
|
+
function formatMppError(error) {
|
|
651
|
+
if (error instanceof Error) return error.message;
|
|
652
|
+
if (typeof error === "string") return error;
|
|
653
|
+
try {
|
|
654
|
+
const serialized = JSON.stringify(error);
|
|
655
|
+
return serialized === void 0 ? String(error) : serialized;
|
|
656
|
+
} catch {
|
|
657
|
+
return String(error);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function withTypedChannelErrors(method) {
|
|
661
|
+
return {
|
|
662
|
+
...method,
|
|
663
|
+
async verify(parameters) {
|
|
664
|
+
try {
|
|
665
|
+
return await method.verify(parameters);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
throw mapChannelError(error);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function onVerifiedCredential(method, commit) {
|
|
673
|
+
return {
|
|
674
|
+
...method,
|
|
675
|
+
async verify(parameters) {
|
|
676
|
+
const receipt = await method.verify(parameters);
|
|
677
|
+
await commit(parameters.credential);
|
|
678
|
+
return receipt;
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function mapChannelError(error) {
|
|
683
|
+
const message = formatMppError(error);
|
|
684
|
+
const details = error && typeof error === "object" && "details" in error ? error.details : void 0;
|
|
685
|
+
const reason = details && Object.keys(details).length > 0 ? `${message} (${JSON.stringify(details)})` : message;
|
|
686
|
+
if (/signer mismatch|authori[sz]er|unauthori[sz]ed/i.test(message)) {
|
|
687
|
+
return new Errors.SignerMismatchError({ reason });
|
|
688
|
+
}
|
|
689
|
+
if (/already used|replay|challenge/i.test(message)) {
|
|
690
|
+
return new Errors.InvalidChallengeError({ reason });
|
|
691
|
+
}
|
|
692
|
+
if (/channel has been closed|channel is closed|finalized/i.test(message)) {
|
|
693
|
+
return new Errors.ChannelClosedError({ reason });
|
|
694
|
+
}
|
|
695
|
+
if (/exceeds channel balance|exceeds.*deposit/i.test(message)) {
|
|
696
|
+
return new Errors.AmountExceedsDepositError({ reason });
|
|
697
|
+
}
|
|
698
|
+
if (/channel not found|no channel/i.test(message)) {
|
|
699
|
+
return new Errors.ChannelNotFoundError({ reason });
|
|
700
|
+
}
|
|
701
|
+
if (/must be greater|does not cover|below.*minimum/i.test(message)) {
|
|
702
|
+
return new Errors.DeltaTooSmallError({ reason });
|
|
703
|
+
}
|
|
704
|
+
if (/signature/i.test(message)) {
|
|
705
|
+
return new Errors.InvalidSignatureError({ reason });
|
|
706
|
+
}
|
|
707
|
+
return new Errors.VerificationFailedError({ reason });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/provider/MppSessionHandler.ts
|
|
711
|
+
function isVoucherStoreValue(value) {
|
|
712
|
+
if (typeof value !== "object" || value === null) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
const candidate = value;
|
|
716
|
+
return typeof candidate.amount === "string" && /^\d+$/.test(candidate.amount);
|
|
717
|
+
}
|
|
215
718
|
var MPP_NETWORK2 = {
|
|
216
719
|
testnet: "stellar:testnet",
|
|
217
720
|
mainnet: "stellar:pubnet"
|
|
@@ -220,67 +723,203 @@ function createMppSessionHandler(opts) {
|
|
|
220
723
|
const networkId = MPP_NETWORK2[opts.network];
|
|
221
724
|
const rateHuman = opts.rate;
|
|
222
725
|
const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
|
|
223
|
-
const
|
|
726
|
+
const logger = resolveLogger(opts.logger);
|
|
727
|
+
const voucherRecordKey = `routedock:session:voucher:${opts.channelFactory}`;
|
|
224
728
|
const innerStore = Store.memory();
|
|
225
|
-
let
|
|
729
|
+
let record = null;
|
|
226
730
|
let voucherCount = 0;
|
|
227
731
|
let sessionOpened = false;
|
|
732
|
+
let idleTimer = null;
|
|
733
|
+
let settledCleanly = false;
|
|
734
|
+
let closeListenerArmed = false;
|
|
735
|
+
function clearIdleTimer() {
|
|
736
|
+
if (idleTimer) {
|
|
737
|
+
clearTimeout(idleTimer);
|
|
738
|
+
idleTimer = null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function armIdleTimer() {
|
|
742
|
+
if (!opts.idleTimeoutMs) return;
|
|
743
|
+
clearIdleTimer();
|
|
744
|
+
idleTimer = setTimeout(() => {
|
|
745
|
+
void flagOrphan("idle-timeout");
|
|
746
|
+
}, opts.idleTimeoutMs);
|
|
747
|
+
if (typeof idleTimer.unref === "function") idleTimer.unref();
|
|
748
|
+
}
|
|
749
|
+
async function loadPersistedRecord() {
|
|
750
|
+
if (record) return record;
|
|
751
|
+
try {
|
|
752
|
+
const stored = await innerStore.get(voucherRecordKey);
|
|
753
|
+
if (!stored || typeof stored.amount !== "string" || typeof stored.signature !== "string") {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
record = { amount: BigInt(stored.amount), signature: stored.signature, payer: stored.payer ?? null };
|
|
757
|
+
} catch {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
return record;
|
|
761
|
+
}
|
|
762
|
+
async function flagOrphan(reason) {
|
|
763
|
+
await loadPersistedRecord();
|
|
764
|
+
if (!sessionOpened || settledCleanly) return;
|
|
765
|
+
sessionOpened = false;
|
|
766
|
+
closeListenerArmed = false;
|
|
767
|
+
clearIdleTimer();
|
|
768
|
+
const cumulativeAmount = record ? (Number(record.amount) / 1e7).toFixed(7) : "0.0000000";
|
|
769
|
+
if (opts.onOrphaned) {
|
|
770
|
+
try {
|
|
771
|
+
await opts.onOrphaned(opts.channelFactory, {
|
|
772
|
+
cumulativeAmount,
|
|
773
|
+
lastSignature: record?.signature ?? "",
|
|
774
|
+
voucherCount,
|
|
775
|
+
reason
|
|
776
|
+
});
|
|
777
|
+
} catch (err) {
|
|
778
|
+
logger("error", "[mpp-session] onOrphaned handler failed", { error: err });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
228
782
|
const wrappedStore = {
|
|
229
783
|
async get(key) {
|
|
230
784
|
return innerStore.get(key);
|
|
231
785
|
},
|
|
232
786
|
async put(key, value) {
|
|
233
|
-
|
|
234
|
-
if (key === cumulativeKey && value && typeof value === "object" && "amount" in value) {
|
|
235
|
-
lastCumulativeAmount = BigInt(value.amount);
|
|
236
|
-
voucherCount++;
|
|
237
|
-
if (!sessionOpened) {
|
|
238
|
-
sessionOpened = true;
|
|
239
|
-
if (opts.onSessionOpen) {
|
|
240
|
-
await opts.onSessionOpen(opts.channelContract);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
if (opts.onVoucher) {
|
|
244
|
-
const humanAmount = (Number(lastCumulativeAmount) / 1e7).toFixed(7);
|
|
245
|
-
await opts.onVoucher(voucherCount, humanAmount);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
787
|
+
return innerStore.put(key, value);
|
|
248
788
|
},
|
|
249
789
|
async delete(key) {
|
|
250
790
|
return innerStore.delete(key);
|
|
791
|
+
},
|
|
792
|
+
async update(key, fn) {
|
|
793
|
+
const storeWithUpdate = innerStore;
|
|
794
|
+
if (typeof storeWithUpdate.update === "function") {
|
|
795
|
+
return storeWithUpdate.update(key, fn);
|
|
796
|
+
}
|
|
797
|
+
throw new Error("Store does not support atomic update operations");
|
|
251
798
|
}
|
|
252
799
|
};
|
|
800
|
+
async function commit(credential) {
|
|
801
|
+
await loadPersistedRecord();
|
|
802
|
+
const payload = credential.payload;
|
|
803
|
+
if (typeof payload?.amount !== "string" || typeof payload.signature !== "string") return;
|
|
804
|
+
let amount;
|
|
805
|
+
try {
|
|
806
|
+
amount = BigInt(payload.amount);
|
|
807
|
+
} catch {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (record && amount < record.amount) return;
|
|
811
|
+
const payer = record?.payer ?? extractPayerAddress(credential.source);
|
|
812
|
+
record = { amount, signature: payload.signature, payer };
|
|
813
|
+
await innerStore.put(voucherRecordKey, {
|
|
814
|
+
amount: record.amount.toString(),
|
|
815
|
+
signature: record.signature,
|
|
816
|
+
payer: record.payer
|
|
817
|
+
});
|
|
818
|
+
voucherCount++;
|
|
819
|
+
settledCleanly = false;
|
|
820
|
+
armIdleTimer();
|
|
821
|
+
if (!sessionOpened) {
|
|
822
|
+
sessionOpened = true;
|
|
823
|
+
if (opts.onSessionOpen) {
|
|
824
|
+
Promise.resolve().then(() => opts.onSessionOpen(opts.channelFactory, record.payer)).catch((err) => {
|
|
825
|
+
logger("error", "[mpp-session] onSessionOpen callback error", { error: err });
|
|
826
|
+
opts.onCallbackError?.(err, "onSessionOpen");
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (opts.onVoucher) {
|
|
831
|
+
const humanAmount = (Number(record.amount) / 1e7).toFixed(7);
|
|
832
|
+
Promise.resolve().then(() => opts.onVoucher(opts.channelFactory, voucherCount, humanAmount, record.signature)).catch((err) => {
|
|
833
|
+
logger("error", "[mpp-session] onVoucher callback error", { error: err });
|
|
834
|
+
opts.onCallbackError?.(err, "onVoucher");
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
253
838
|
const mppx = Mppx.create({
|
|
254
839
|
secretKey: opts.payeeSecretKey,
|
|
255
840
|
methods: [
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
841
|
+
withTypedChannelErrors(
|
|
842
|
+
onVerifiedCredential(
|
|
843
|
+
stellar.channel({
|
|
844
|
+
channel: opts.channelFactory,
|
|
845
|
+
commitmentKey: opts.commitmentPublicKey,
|
|
846
|
+
network: networkId,
|
|
847
|
+
store: wrappedStore,
|
|
848
|
+
sourceAccount: payeeKeypair.publicKey(),
|
|
849
|
+
feePayer: channelAuthorizer(payeeKeypair)
|
|
850
|
+
}),
|
|
851
|
+
commit
|
|
852
|
+
)
|
|
853
|
+
)
|
|
264
854
|
]
|
|
265
855
|
});
|
|
266
|
-
let lastSignatureHex = "";
|
|
267
856
|
return async (req, res, next) => {
|
|
268
857
|
try {
|
|
269
858
|
if (req.method === "DELETE") {
|
|
859
|
+
await loadPersistedRecord();
|
|
270
860
|
const body = req.body;
|
|
271
|
-
const
|
|
272
|
-
const
|
|
861
|
+
const bodyAmount = body?.amount ? BigInt(body.amount) : 0n;
|
|
862
|
+
const recordAmount = record?.amount ?? 0n;
|
|
863
|
+
let closeAmount;
|
|
864
|
+
let closeSig;
|
|
865
|
+
if (bodyAmount > recordAmount) {
|
|
866
|
+
closeAmount = bodyAmount;
|
|
867
|
+
closeSig = body?.signature ?? record?.signature ?? "";
|
|
868
|
+
} else {
|
|
869
|
+
closeAmount = recordAmount;
|
|
870
|
+
closeSig = record?.signature ?? "";
|
|
871
|
+
}
|
|
273
872
|
if (closeAmount > 0n && closeSig) {
|
|
873
|
+
const closePayer = record?.payer ?? null;
|
|
274
874
|
const closeTxHash = await close({
|
|
275
|
-
channel: opts.
|
|
875
|
+
channel: opts.channelFactory,
|
|
276
876
|
amount: closeAmount,
|
|
277
877
|
signature: Buffer.from(closeSig, "hex"),
|
|
278
|
-
feePayer:
|
|
878
|
+
feePayer: channelAuthorizer(payeeKeypair),
|
|
279
879
|
network: networkId
|
|
280
880
|
});
|
|
881
|
+
settledCleanly = true;
|
|
882
|
+
clearIdleTimer();
|
|
281
883
|
if (opts.onSettled) {
|
|
282
|
-
const totalPaid = (Number(
|
|
283
|
-
|
|
884
|
+
const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
|
|
885
|
+
Promise.resolve().then(() => opts.onSettled(closeTxHash, totalPaid, "mpp-session", closePayer)).catch((err) => {
|
|
886
|
+
logger("error", "[mpp-session] onSettled callback error", { error: err });
|
|
887
|
+
opts.onCallbackError?.(err, "onSettled");
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
const vaultContract = process.env.AGENT_VAULT_CONTRACT;
|
|
891
|
+
if (vaultContract) {
|
|
892
|
+
const settlementAddresses = resolveVaultSettlementAddresses(
|
|
893
|
+
closePayer,
|
|
894
|
+
payeeKeypair.publicKey()
|
|
895
|
+
);
|
|
896
|
+
if (!settlementAddresses) {
|
|
897
|
+
logger("warn", "[mpp-session] skipped session_settled vault record: payer address unavailable");
|
|
898
|
+
} else {
|
|
899
|
+
try {
|
|
900
|
+
const { Networks } = await import('@stellar/stellar-sdk');
|
|
901
|
+
const { Server } = await import('@stellar/stellar-sdk/rpc');
|
|
902
|
+
const networkPassphrase = opts.network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
|
|
903
|
+
const rpcUrl = opts.network === "mainnet" ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org";
|
|
904
|
+
const server = new Server(rpcUrl);
|
|
905
|
+
const payeePublicKey = payeeKeypair.publicKey();
|
|
906
|
+
const sourceAccount = await server.getAccount(payeePublicKey);
|
|
907
|
+
const tx = buildSessionSettlementTransaction(
|
|
908
|
+
sourceAccount,
|
|
909
|
+
vaultContract,
|
|
910
|
+
opts.channelFactory,
|
|
911
|
+
settlementAddresses,
|
|
912
|
+
closeAmount,
|
|
913
|
+
voucherCount,
|
|
914
|
+
networkPassphrase
|
|
915
|
+
);
|
|
916
|
+
const preparedTx = await server.prepareTransaction(tx);
|
|
917
|
+
preparedTx.sign(payeeKeypair);
|
|
918
|
+
await server.sendTransaction(preparedTx);
|
|
919
|
+
} catch (recordErr) {
|
|
920
|
+
logger("error", "[mpp-session] failed to record session_settled on vault", { error: recordErr });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
284
923
|
}
|
|
285
924
|
res.json({ closeTxHash });
|
|
286
925
|
} else {
|
|
@@ -288,23 +927,12 @@ function createMppSessionHandler(opts) {
|
|
|
288
927
|
}
|
|
289
928
|
sessionOpened = false;
|
|
290
929
|
voucherCount = 0;
|
|
291
|
-
|
|
930
|
+
record = null;
|
|
931
|
+
await innerStore.delete(voucherRecordKey);
|
|
932
|
+
closeListenerArmed = false;
|
|
933
|
+
clearIdleTimer();
|
|
292
934
|
return;
|
|
293
935
|
}
|
|
294
|
-
const authHeader = req.headers["authorization"];
|
|
295
|
-
if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
|
|
296
|
-
try {
|
|
297
|
-
const credB64 = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
|
|
298
|
-
if (credB64) {
|
|
299
|
-
const credJson = Buffer.from(credB64.split("=").slice(1).join("=").replace(/^"|"$/g, ""), "base64").toString("utf8");
|
|
300
|
-
const cred = JSON.parse(credJson);
|
|
301
|
-
if (cred.payload?.signature) {
|
|
302
|
-
lastSignatureHex = cred.payload.signature;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
} catch {
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
936
|
const fetchReq = Request.fromNodeListener(req, res);
|
|
309
937
|
const result = await mppx.channel({
|
|
310
938
|
amount: rateHuman,
|
|
@@ -318,28 +946,101 @@ function createMppSessionHandler(opts) {
|
|
|
318
946
|
res.send(body);
|
|
319
947
|
return;
|
|
320
948
|
}
|
|
949
|
+
if (!closeListenerArmed) {
|
|
950
|
+
closeListenerArmed = true;
|
|
951
|
+
req.on("close", () => {
|
|
952
|
+
void flagOrphan("connection-closed");
|
|
953
|
+
});
|
|
954
|
+
}
|
|
321
955
|
next();
|
|
322
956
|
} catch (err) {
|
|
323
957
|
next(err);
|
|
324
958
|
}
|
|
325
959
|
};
|
|
326
960
|
}
|
|
961
|
+
var MANIFEST_SIGNATURE_VERSION = "2";
|
|
962
|
+
function canonicalize(value) {
|
|
963
|
+
if (Array.isArray(value)) {
|
|
964
|
+
return value.map(canonicalize);
|
|
965
|
+
}
|
|
966
|
+
if (value !== null && typeof value === "object") {
|
|
967
|
+
return Object.fromEntries(
|
|
968
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
return value;
|
|
972
|
+
}
|
|
973
|
+
function manifestDigest(manifest) {
|
|
974
|
+
const { signature: _omit, ...rest } = manifest;
|
|
975
|
+
const canonical = JSON.stringify(canonicalize(rest));
|
|
976
|
+
return createHash("sha256").update(canonical, "utf8").digest();
|
|
977
|
+
}
|
|
978
|
+
function signManifest(manifest, payeeSecretKey) {
|
|
979
|
+
const keypair = Keypair.fromSecret(payeeSecretKey);
|
|
980
|
+
const versionedManifest = {
|
|
981
|
+
...manifest,
|
|
982
|
+
signature_version: MANIFEST_SIGNATURE_VERSION
|
|
983
|
+
};
|
|
984
|
+
const digest = manifestDigest(versionedManifest);
|
|
985
|
+
const sig = keypair.sign(digest);
|
|
986
|
+
return { ...versionedManifest, signature: Buffer.from(sig).toString("base64") };
|
|
987
|
+
}
|
|
988
|
+
function verifyManifestSignature(manifest, expectedPayee) {
|
|
989
|
+
if (!manifest.signature) {
|
|
990
|
+
throw new RouteDockSignatureError(
|
|
991
|
+
"Manifest is missing a signature field \u2014 cannot verify payee authenticity"
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
|
|
995
|
+
throw new RouteDockSignatureError(
|
|
996
|
+
`Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
const keypair = Keypair.fromPublicKey(manifest.payee);
|
|
1000
|
+
const digest = manifestDigest(manifest);
|
|
1001
|
+
let sigBytes;
|
|
1002
|
+
try {
|
|
1003
|
+
sigBytes = Buffer.from(manifest.signature, "base64");
|
|
1004
|
+
} catch {
|
|
1005
|
+
throw new RouteDockSignatureError("Manifest signature is not valid base64");
|
|
1006
|
+
}
|
|
1007
|
+
let valid = false;
|
|
1008
|
+
try {
|
|
1009
|
+
valid = keypair.verify(digest, sigBytes);
|
|
1010
|
+
} catch {
|
|
1011
|
+
throw new RouteDockSignatureError(
|
|
1012
|
+
`Manifest signature verification failed for payee ${manifest.payee}`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
if (!valid) {
|
|
1016
|
+
throw new RouteDockSignatureError(
|
|
1017
|
+
`Manifest signature verification failed for payee ${manifest.payee}`
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
327
1021
|
|
|
328
1022
|
// src/provider/routedockMiddleware.ts
|
|
1023
|
+
var MPP_MODES = ["mpp-session", "mpp-charge"];
|
|
329
1024
|
function routedock(opts) {
|
|
330
|
-
const
|
|
1025
|
+
const startTime = Date.now();
|
|
1026
|
+
const signedManifest = signManifest(opts.manifest, opts.payeeSecretKey);
|
|
1027
|
+
const handlerMap = /* @__PURE__ */ new Map();
|
|
331
1028
|
if (opts.modes.includes("x402")) {
|
|
332
1029
|
const x402Price = opts.pricing.x402;
|
|
333
1030
|
if (x402Price) {
|
|
334
|
-
|
|
1031
|
+
handlerMap.set(
|
|
1032
|
+
"x402",
|
|
335
1033
|
createX402Handler({
|
|
336
1034
|
payeeSecretKey: opts.payeeSecretKey,
|
|
337
1035
|
network: opts.network,
|
|
338
1036
|
amount: x402Price,
|
|
339
|
-
assetContract: opts.assetContract,
|
|
1037
|
+
...opts.assetContract ? { assetContract: opts.assetContract } : {},
|
|
340
1038
|
...opts.facilitatorApiKey ? { facilitatorApiKey: opts.facilitatorApiKey } : {},
|
|
341
|
-
manifest:
|
|
342
|
-
...opts.onSettled ? { onSettled: opts.onSettled } : {}
|
|
1039
|
+
manifest: signedManifest,
|
|
1040
|
+
...opts.onSettled ? { onSettled: opts.onSettled } : {},
|
|
1041
|
+
...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
|
|
1042
|
+
...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {},
|
|
1043
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
343
1044
|
})
|
|
344
1045
|
);
|
|
345
1046
|
}
|
|
@@ -347,14 +1048,18 @@ function routedock(opts) {
|
|
|
347
1048
|
if (opts.modes.includes("mpp-charge")) {
|
|
348
1049
|
const chargePrice = opts.pricing["mpp-charge"];
|
|
349
1050
|
if (chargePrice) {
|
|
350
|
-
|
|
1051
|
+
handlerMap.set(
|
|
1052
|
+
"mpp-charge",
|
|
351
1053
|
createMppChargeHandler({
|
|
352
1054
|
payeeSecretKey: opts.payeeSecretKey,
|
|
353
1055
|
network: opts.network,
|
|
354
1056
|
amount: chargePrice,
|
|
355
|
-
assetContract: opts.assetContract,
|
|
356
|
-
manifest:
|
|
357
|
-
...opts.onSettled ? { onSettled: opts.onSettled } : {}
|
|
1057
|
+
...opts.assetContract ? { assetContract: opts.assetContract } : {},
|
|
1058
|
+
manifest: signedManifest,
|
|
1059
|
+
...opts.onSettled ? { onSettled: opts.onSettled } : {},
|
|
1060
|
+
...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
|
|
1061
|
+
...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {},
|
|
1062
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
358
1063
|
})
|
|
359
1064
|
);
|
|
360
1065
|
}
|
|
@@ -365,39 +1070,53 @@ function routedock(opts) {
|
|
|
365
1070
|
if (!opts.commitmentPublicKey) {
|
|
366
1071
|
throw new Error("routedock: mpp-session mode requires commitmentPublicKey");
|
|
367
1072
|
}
|
|
368
|
-
|
|
1073
|
+
handlerMap.set(
|
|
1074
|
+
"mpp-session",
|
|
369
1075
|
createMppSessionHandler({
|
|
370
1076
|
payeeSecretKey: opts.payeeSecretKey,
|
|
371
1077
|
network: opts.network,
|
|
372
|
-
|
|
1078
|
+
channelFactory: sessionPricing.channelFactory,
|
|
373
1079
|
rate: sessionPricing.rate,
|
|
374
|
-
assetContract: opts.assetContract,
|
|
375
|
-
manifest:
|
|
1080
|
+
...opts.assetContract ? { assetContract: opts.assetContract } : {},
|
|
1081
|
+
manifest: signedManifest,
|
|
376
1082
|
commitmentPublicKey: opts.commitmentPublicKey,
|
|
377
1083
|
...opts.onSettled ? { onSettled: opts.onSettled } : {},
|
|
378
1084
|
...opts.onSessionOpen ? { onSessionOpen: opts.onSessionOpen } : {},
|
|
379
|
-
...opts.onVoucher ? { onVoucher: opts.onVoucher } : {}
|
|
1085
|
+
...opts.onVoucher ? { onVoucher: opts.onVoucher } : {},
|
|
1086
|
+
...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
|
|
1087
|
+
...opts.onOrphaned ? { onOrphaned: opts.onOrphaned } : {},
|
|
1088
|
+
...opts.idleTimeoutMs != null ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
|
|
1089
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
380
1090
|
})
|
|
381
1091
|
);
|
|
382
1092
|
}
|
|
383
1093
|
}
|
|
1094
|
+
const defaultMode = MPP_MODES.find((mode) => handlerMap.has(mode)) ?? (handlerMap.has("x402") ? "x402" : void 0);
|
|
384
1095
|
return (req, res, next) => {
|
|
385
1096
|
if (req.path === "/.well-known/routedock.json") {
|
|
386
|
-
res.json(
|
|
1097
|
+
res.json(signedManifest);
|
|
387
1098
|
return;
|
|
388
1099
|
}
|
|
389
|
-
if (
|
|
1100
|
+
if (req.path === "/health") {
|
|
1101
|
+
res.json({
|
|
1102
|
+
status: "ok",
|
|
1103
|
+
routedock: opts.manifest.routedock,
|
|
1104
|
+
network: opts.network,
|
|
1105
|
+
payee: opts.payee,
|
|
1106
|
+
modesActive: opts.modes,
|
|
1107
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3)
|
|
1108
|
+
});
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (handlerMap.size === 0) {
|
|
390
1112
|
next();
|
|
391
1113
|
return;
|
|
392
1114
|
}
|
|
393
1115
|
const hasX402Header = !!(req.headers["payment-signature"] || req.headers["x-payment"]);
|
|
394
1116
|
const prefersX402 = req.headers["x-preferred-mode"] === "x402";
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
} else {
|
|
399
|
-
handler = handlers[handlers.length - 1];
|
|
400
|
-
}
|
|
1117
|
+
const x402Handler = handlerMap.get("x402");
|
|
1118
|
+
const defaultHandler = defaultMode ? handlerMap.get(defaultMode) : void 0;
|
|
1119
|
+
const handler = hasX402Header || prefersX402 ? x402Handler ?? defaultHandler : defaultHandler;
|
|
401
1120
|
if (!handler) {
|
|
402
1121
|
next();
|
|
403
1122
|
return;
|
|
@@ -410,6 +1129,149 @@ function routedock(opts) {
|
|
|
410
1129
|
})).then(() => next()).catch((err) => next(err));
|
|
411
1130
|
};
|
|
412
1131
|
}
|
|
1132
|
+
var MPP_NETWORK3 = {
|
|
1133
|
+
testnet: "stellar:testnet",
|
|
1134
|
+
mainnet: "stellar:pubnet"
|
|
1135
|
+
};
|
|
1136
|
+
function decimalToStroops(amountStr) {
|
|
1137
|
+
return usdcToStroops(amountStr);
|
|
1138
|
+
}
|
|
1139
|
+
async function reconcileAbandonedSessions(opts) {
|
|
1140
|
+
const stats = {
|
|
1141
|
+
orphanedCount: 0,
|
|
1142
|
+
recoveredCount: 0,
|
|
1143
|
+
skippedCount: 0,
|
|
1144
|
+
failedCount: 0,
|
|
1145
|
+
errors: []
|
|
1146
|
+
};
|
|
1147
|
+
const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
|
|
1148
|
+
const networkId = MPP_NETWORK3[opts.network];
|
|
1149
|
+
try {
|
|
1150
|
+
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);
|
|
1151
|
+
if (error) {
|
|
1152
|
+
throw new Error(`Failed to query sessions: ${error.message}`);
|
|
1153
|
+
}
|
|
1154
|
+
stats.orphanedCount = closingSessions?.length ?? 0;
|
|
1155
|
+
if (!closingSessions || closingSessions.length === 0) {
|
|
1156
|
+
return stats;
|
|
1157
|
+
}
|
|
1158
|
+
for (const session of closingSessions) {
|
|
1159
|
+
try {
|
|
1160
|
+
const {
|
|
1161
|
+
channel_id: sessionId,
|
|
1162
|
+
channel_contract: channelContract,
|
|
1163
|
+
cumulative_amount,
|
|
1164
|
+
last_signature
|
|
1165
|
+
} = session;
|
|
1166
|
+
const channelId = channelContract || sessionId;
|
|
1167
|
+
if (!channelId || !cumulative_amount || !last_signature) {
|
|
1168
|
+
stats.skippedCount++;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
const closeAmount = decimalToStroops(cumulative_amount);
|
|
1172
|
+
const closeSig = Buffer.from(last_signature, "hex");
|
|
1173
|
+
const closeFn = opts.channelClose ?? (await import('@stellar/mpp/channel/server')).close;
|
|
1174
|
+
const closeTxHash = await closeFn({
|
|
1175
|
+
channel: channelId,
|
|
1176
|
+
amount: closeAmount,
|
|
1177
|
+
signature: closeSig,
|
|
1178
|
+
feePayer: channelAuthorizer(payeeKeypair),
|
|
1179
|
+
network: networkId
|
|
1180
|
+
});
|
|
1181
|
+
const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
|
|
1182
|
+
const { error: updateError } = await opts.supabase.from("sessions").update({
|
|
1183
|
+
status: "closed",
|
|
1184
|
+
settlement_tx_hash: closeTxHash,
|
|
1185
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1186
|
+
}).eq("channel_id", sessionId);
|
|
1187
|
+
if (updateError) {
|
|
1188
|
+
throw new Error(`Failed to update session: ${updateError.message}`);
|
|
1189
|
+
}
|
|
1190
|
+
stats.recoveredCount++;
|
|
1191
|
+
if (opts.onRecovered) {
|
|
1192
|
+
await opts.onRecovered(channelId, closeTxHash, totalPaid);
|
|
1193
|
+
}
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
stats.failedCount++;
|
|
1196
|
+
stats.errors.push({
|
|
1197
|
+
channelId: session.channel_id,
|
|
1198
|
+
reason: formatMppError(err)
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return stats;
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
throw new Error(
|
|
1205
|
+
`Session reconciliation failed: ${formatMppError(err)}`
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
async function runStartupReconciliation(opts, logger) {
|
|
1210
|
+
const log = resolveLogger(logger);
|
|
1211
|
+
log("info", "[SessionReconciler] Running startup reconciliation...");
|
|
1212
|
+
try {
|
|
1213
|
+
const stats = await reconcileAbandonedSessions(opts);
|
|
1214
|
+
if (stats.orphanedCount === 0) {
|
|
1215
|
+
log("info", "[SessionReconciler] No orphaned sessions found");
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
log(
|
|
1219
|
+
"info",
|
|
1220
|
+
`[SessionReconciler] Found ${stats.orphanedCount} orphaned session(s). Recovered: ${stats.recoveredCount}, Skipped: ${stats.skippedCount}, Failed: ${stats.failedCount}`
|
|
1221
|
+
);
|
|
1222
|
+
if (stats.errors.length > 0) {
|
|
1223
|
+
log("warn", "[SessionReconciler] Reconciliation errors:");
|
|
1224
|
+
for (const err of stats.errors) {
|
|
1225
|
+
log("warn", ` - ${err.channelId}: ${err.reason}`);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
log("error", `[SessionReconciler] Startup reconciliation failed: ${formatMppError(err)}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/provider/registerProvider.ts
|
|
1234
|
+
async function registerProvider(optionsOrSupabase, manifestArg, baseUrlArg, verifiedArg) {
|
|
1235
|
+
let supabase;
|
|
1236
|
+
let manifest;
|
|
1237
|
+
let baseUrl;
|
|
1238
|
+
let verified;
|
|
1239
|
+
if ("supabase" in optionsOrSupabase) {
|
|
1240
|
+
supabase = optionsOrSupabase.supabase;
|
|
1241
|
+
manifest = optionsOrSupabase.manifest;
|
|
1242
|
+
baseUrl = optionsOrSupabase.baseUrl;
|
|
1243
|
+
verified = optionsOrSupabase.verified ?? true;
|
|
1244
|
+
} else {
|
|
1245
|
+
supabase = optionsOrSupabase;
|
|
1246
|
+
if (!manifestArg || !baseUrlArg) {
|
|
1247
|
+
throw new RouteDockManifestError("manifest and baseUrl are required for registerProvider");
|
|
1248
|
+
}
|
|
1249
|
+
manifest = manifestArg;
|
|
1250
|
+
baseUrl = baseUrlArg;
|
|
1251
|
+
verified = verifiedArg ?? true;
|
|
1252
|
+
}
|
|
1253
|
+
verifyManifestSignature(manifest);
|
|
1254
|
+
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
|
1255
|
+
const { error } = await supabase.from("providers").upsert(
|
|
1256
|
+
{
|
|
1257
|
+
name: manifest.name,
|
|
1258
|
+
description: manifest.description ?? null,
|
|
1259
|
+
base_url: normalizedBaseUrl,
|
|
1260
|
+
modes: manifest.modes,
|
|
1261
|
+
tags: manifest.tags ?? [],
|
|
1262
|
+
categories: manifest.categories ?? [],
|
|
1263
|
+
network: manifest.network,
|
|
1264
|
+
payee: manifest.payee,
|
|
1265
|
+
manifest,
|
|
1266
|
+
verified,
|
|
1267
|
+
registered_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1268
|
+
},
|
|
1269
|
+
{ onConflict: "base_url" }
|
|
1270
|
+
);
|
|
1271
|
+
if (error) {
|
|
1272
|
+
throw new Error(`Failed to register provider in Supabase: ${error.message}`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
413
1275
|
|
|
414
1276
|
// src/store/SessionStore.ts
|
|
415
1277
|
var SupabaseSessionStore = class {
|
|
@@ -419,12 +1281,16 @@ var SupabaseSessionStore = class {
|
|
|
419
1281
|
supabase;
|
|
420
1282
|
async get(channelId) {
|
|
421
1283
|
const { data, error } = await this.supabase.from("sessions").select("*").eq("channel_id", channelId).maybeSingle();
|
|
422
|
-
if (error)
|
|
1284
|
+
if (error) {
|
|
1285
|
+
throw new RouteDockNetworkError(`SessionStore.get failed: ${error.message}`);
|
|
1286
|
+
}
|
|
423
1287
|
if (!data) return null;
|
|
424
1288
|
return {
|
|
425
1289
|
channel_id: data.channel_id,
|
|
426
1290
|
payee: data.payee,
|
|
427
1291
|
payer: data.payer,
|
|
1292
|
+
channel_contract: data.channel_contract,
|
|
1293
|
+
network: data.network,
|
|
428
1294
|
cumulative_amount: String(data.cumulative_amount),
|
|
429
1295
|
last_signature: data.last_signature,
|
|
430
1296
|
status: data.status,
|
|
@@ -439,7 +1305,7 @@ var SupabaseSessionStore = class {
|
|
|
439
1305
|
const prev = parseFloat(existing.cumulative_amount);
|
|
440
1306
|
const next = parseFloat(state.cumulative_amount);
|
|
441
1307
|
if (next <= prev) {
|
|
442
|
-
throw new
|
|
1308
|
+
throw new RouteDockVoucherMonotonicityError(
|
|
443
1309
|
`cumulative_amount must be strictly increasing: ${next} <= ${prev}`
|
|
444
1310
|
);
|
|
445
1311
|
}
|
|
@@ -449,6 +1315,8 @@ var SupabaseSessionStore = class {
|
|
|
449
1315
|
channel_id: state.channel_id,
|
|
450
1316
|
payee: state.payee,
|
|
451
1317
|
payer: state.payer,
|
|
1318
|
+
channel_contract: state.channel_contract,
|
|
1319
|
+
network: state.network,
|
|
452
1320
|
cumulative_amount: state.cumulative_amount,
|
|
453
1321
|
last_signature: state.last_signature,
|
|
454
1322
|
status: state.status,
|
|
@@ -460,17 +1328,80 @@ var SupabaseSessionStore = class {
|
|
|
460
1328
|
);
|
|
461
1329
|
if (error) {
|
|
462
1330
|
if (error.message.includes("strictly increasing")) {
|
|
463
|
-
throw new
|
|
1331
|
+
throw new RouteDockVoucherMonotonicityError(
|
|
464
1332
|
`cumulative_amount must be strictly increasing (DB): ${error.message}`
|
|
465
1333
|
);
|
|
466
1334
|
}
|
|
467
|
-
throw new
|
|
1335
|
+
throw new RouteDockNetworkError(`SessionStore.upsert failed: ${error.message}`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
async setStatus(channelId, status, settlementTxHash) {
|
|
1339
|
+
const payload = {
|
|
1340
|
+
status,
|
|
1341
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1342
|
+
};
|
|
1343
|
+
if (settlementTxHash !== void 0) {
|
|
1344
|
+
payload.settlement_tx_hash = settlementTxHash;
|
|
1345
|
+
}
|
|
1346
|
+
const { error } = await this.supabase.from("sessions").update(payload).eq("channel_id", channelId);
|
|
1347
|
+
if (error) {
|
|
1348
|
+
throw new RouteDockNetworkError(`SessionStore.setStatus failed: ${error.message}`);
|
|
468
1349
|
}
|
|
469
1350
|
}
|
|
470
1351
|
async close(channelId) {
|
|
471
|
-
|
|
472
|
-
|
|
1352
|
+
await this.setStatus(channelId, "closed");
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
// src/store/SupabaseSeenTxStore.ts
|
|
1357
|
+
var SupabaseSeenTxStore = class {
|
|
1358
|
+
constructor(supabase) {
|
|
1359
|
+
this.supabase = supabase;
|
|
1360
|
+
}
|
|
1361
|
+
supabase;
|
|
1362
|
+
async get(key) {
|
|
1363
|
+
const { data, error } = await this.supabase.from("settlements").select("tx_hash, headers, created_at").eq("key", key).maybeSingle();
|
|
1364
|
+
if (error) {
|
|
1365
|
+
throw new RouteDockNetworkError(`SupabaseSeenTxStore.get failed: ${error.message}`);
|
|
1366
|
+
}
|
|
1367
|
+
if (!data) return void 0;
|
|
1368
|
+
const headers = data.headers;
|
|
1369
|
+
return {
|
|
1370
|
+
txHash: data.tx_hash ?? null,
|
|
1371
|
+
...headers ? { headers } : {},
|
|
1372
|
+
createdAt: Date.parse(data.created_at)
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
async set(key, record) {
|
|
1376
|
+
const { error } = await this.supabase.from("settlements").upsert(
|
|
1377
|
+
{
|
|
1378
|
+
key,
|
|
1379
|
+
tx_hash: record.txHash,
|
|
1380
|
+
headers: record.headers ?? null,
|
|
1381
|
+
created_at: new Date(record.createdAt ?? Date.now()).toISOString()
|
|
1382
|
+
},
|
|
1383
|
+
{ onConflict: "key" }
|
|
1384
|
+
);
|
|
1385
|
+
if (error) {
|
|
1386
|
+
throw new RouteDockNetworkError(`SupabaseSeenTxStore.set failed: ${error.message}`);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Atomically increment the settlement's replay counter through the
|
|
1391
|
+
* `claim_settlement_replay` Postgres function (migration 006). The UPDATE's
|
|
1392
|
+
* row lock gives concurrent claims distinct counts. Returns `Infinity` when
|
|
1393
|
+
* the key no longer exists, so the caller treats it as spent.
|
|
1394
|
+
*/
|
|
1395
|
+
async claimReplay(key) {
|
|
1396
|
+
const { data, error } = await this.supabase.rpc("claim_settlement_replay", {
|
|
1397
|
+
p_key: key
|
|
1398
|
+
});
|
|
1399
|
+
if (error) {
|
|
1400
|
+
throw new RouteDockNetworkError(`SupabaseSeenTxStore.claimReplay failed: ${error.message}`);
|
|
1401
|
+
}
|
|
1402
|
+
if (data == null) return Number.POSITIVE_INFINITY;
|
|
1403
|
+
return data;
|
|
473
1404
|
}
|
|
474
1405
|
};
|
|
475
1406
|
|
|
476
|
-
export { RouteDockError, RouteDockManifestError, RouteDockNoSupportedModeError, RouteDockPolicyRejectedError, RouteDockSessionError, SupabaseSessionStore, createMppChargeHandler, createMppSessionHandler, createX402Handler, routedock };
|
|
1407
|
+
export { DEFAULT_MAX_SESSION_DURATION_MS, InMemorySeenTxStore, MAX_SETTLEMENT_REPLAYS, RouteDockChannelStateError, RouteDockClientVersionError, RouteDockDisputeError, RouteDockError, RouteDockFacilitatorError, RouteDockManifestError, RouteDockNetworkError, RouteDockNoSupportedModeError, RouteDockPolicyRejectError, RouteDockPolicyRejectedError, RouteDockRefundWindowError, RouteDockSessionError, RouteDockSignatureError, RouteDockTrustlineError, RouteDockVoucherMonotonicityError, SETTLEMENT_REPLAY_WINDOW_MS, SupabaseSeenTxStore, SupabaseSessionStore, checkSettlementReplay, createMppChargeHandler, createMppSessionHandler, createX402Handler, decimalToStroops, getEligibleAssets, isAssetEligible, isVoucherStoreValue, normalizeManifestAssets, paymentIdempotencyKey, reconcileAbandonedSessions, registerProvider, resolveAssetContract, routedock, runStartupReconciliation, selectAsset };
|