moltspay 1.6.0 → 2.4.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/.env.example +23 -0
- package/CHANGELOG.md +551 -0
- package/README.md +466 -7
- package/dist/cdp/index.js.map +1 -1
- package/dist/cdp/index.mjs.map +1 -1
- package/dist/{cdp-DeohBe1o.d.ts → cdp-B5PhDUTC.d.ts} +1 -1
- package/dist/{cdp-p_eHuQpb.d.mts → cdp-D-5Hg3y_.d.mts} +1 -1
- package/dist/chains/index.d.mts +102 -1
- package/dist/chains/index.d.ts +102 -1
- package/dist/chains/index.js +66 -0
- package/dist/chains/index.js.map +1 -1
- package/dist/chains/index.mjs +57 -0
- package/dist/chains/index.mjs.map +1 -1
- package/dist/cli/index.js +6850 -2127
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +6857 -2128
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/index.d.mts +348 -1
- package/dist/client/index.d.ts +348 -1
- package/dist/client/index.js +1462 -43
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +1469 -51
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/web/index.d.mts +17 -1
- package/dist/client/web/index.mjs +11 -0
- package/dist/client/web/index.mjs.map +1 -1
- package/dist/facilitators/index.d.mts +663 -4
- package/dist/facilitators/index.d.ts +663 -4
- package/dist/facilitators/index.js +1292 -10
- package/dist/facilitators/index.js.map +1 -1
- package/dist/facilitators/index.mjs +1268 -9
- package/dist/facilitators/index.mjs.map +1 -1
- package/dist/index.d.mts +78 -3
- package/dist/index.d.ts +78 -3
- package/dist/index.js +4229 -556
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4214 -548
- package/dist/index.mjs.map +1 -1
- package/dist/mcp/index.js +1462 -45
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/index.mjs +1473 -56
- package/dist/mcp/index.mjs.map +1 -1
- package/dist/{registry-OsEO2dOu.d.mts → registry-DIo0WNoO.d.mts} +8 -1
- package/dist/{registry-OsEO2dOu.d.ts → registry-DIo0WNoO.d.ts} +8 -1
- package/dist/server/index.d.mts +336 -2
- package/dist/server/index.d.ts +336 -2
- package/dist/server/index.js +2516 -188
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +2516 -188
- package/dist/server/index.mjs.map +1 -1
- package/dist/verify/index.js.map +1 -1
- package/dist/verify/index.mjs.map +1 -1
- package/dist/wallet/index.js.map +1 -1
- package/dist/wallet/index.mjs.map +1 -1
- package/package.json +20 -2
- package/schemas/moltspay.services.schema.json +211 -6
- package/scripts/postinstall.js +91 -0
package/dist/server/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/server/index.ts
|
|
2
|
-
import { readFileSync as
|
|
2
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
3
3
|
import { createServer } from "http";
|
|
4
|
-
import * as
|
|
4
|
+
import * as path3 from "path";
|
|
5
|
+
import crypto6 from "crypto";
|
|
5
6
|
|
|
6
7
|
// src/facilitators/interface.ts
|
|
7
8
|
var BaseFacilitator = class {
|
|
@@ -398,6 +399,28 @@ var CHAINS = {
|
|
|
398
399
|
requiresApproval: true
|
|
399
400
|
}
|
|
400
401
|
};
|
|
402
|
+
function getChain(name) {
|
|
403
|
+
const config = CHAINS[name];
|
|
404
|
+
if (!config) {
|
|
405
|
+
throw new Error(`Unsupported chain: ${name}. Supported: ${Object.keys(CHAINS).join(", ")}`);
|
|
406
|
+
}
|
|
407
|
+
return config;
|
|
408
|
+
}
|
|
409
|
+
function getChainById(chainId) {
|
|
410
|
+
return Object.values(CHAINS).find((c) => c.chainId === chainId);
|
|
411
|
+
}
|
|
412
|
+
var ALIPAY_CHAIN_ID = "alipay";
|
|
413
|
+
function isAlipayChainId(id) {
|
|
414
|
+
return id === ALIPAY_CHAIN_ID;
|
|
415
|
+
}
|
|
416
|
+
var WECHAT_CHAIN_ID = "wechat";
|
|
417
|
+
function isWechatChainId(id) {
|
|
418
|
+
return id === WECHAT_CHAIN_ID;
|
|
419
|
+
}
|
|
420
|
+
var BALANCE_CHAIN_ID = "balance";
|
|
421
|
+
function isBalanceChainId(id) {
|
|
422
|
+
return id === BALANCE_CHAIN_ID;
|
|
423
|
+
}
|
|
401
424
|
|
|
402
425
|
// src/facilitators/tempo.ts
|
|
403
426
|
var TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
@@ -856,12 +879,12 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
856
879
|
return this.spenderAddress;
|
|
857
880
|
}
|
|
858
881
|
async getServerAddress() {
|
|
859
|
-
const { ethers:
|
|
860
|
-
const wallet = new
|
|
882
|
+
const { ethers: ethers4 } = await import("ethers");
|
|
883
|
+
const wallet = new ethers4.Wallet(this.serverPrivateKey);
|
|
861
884
|
return wallet.address;
|
|
862
885
|
}
|
|
863
886
|
async recoverIntentSigner(intent, chainId) {
|
|
864
|
-
const { ethers:
|
|
887
|
+
const { ethers: ethers4 } = await import("ethers");
|
|
865
888
|
const domain = {
|
|
866
889
|
...EIP712_DOMAIN,
|
|
867
890
|
chainId
|
|
@@ -875,7 +898,7 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
875
898
|
nonce: intent.nonce,
|
|
876
899
|
deadline: intent.deadline
|
|
877
900
|
};
|
|
878
|
-
const recoveredAddress =
|
|
901
|
+
const recoveredAddress = ethers4.verifyTypedData(
|
|
879
902
|
domain,
|
|
880
903
|
INTENT_TYPES,
|
|
881
904
|
message,
|
|
@@ -919,10 +942,10 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
919
942
|
return result.result || "0x0";
|
|
920
943
|
}
|
|
921
944
|
async executeTransferFrom(from, to, amount, token, rpcUrl) {
|
|
922
|
-
const { ethers:
|
|
923
|
-
const provider = new
|
|
924
|
-
const wallet = new
|
|
925
|
-
const tokenContract = new
|
|
945
|
+
const { ethers: ethers4 } = await import("ethers");
|
|
946
|
+
const provider = new ethers4.JsonRpcProvider(rpcUrl);
|
|
947
|
+
const wallet = new ethers4.Wallet(this.serverPrivateKey, provider);
|
|
948
|
+
const tokenContract = new ethers4.Contract(token, [
|
|
926
949
|
"function transferFrom(address from, address to, uint256 amount) returns (bool)"
|
|
927
950
|
], wallet);
|
|
928
951
|
const tx = await tokenContract.transferFrom(from, to, amount);
|
|
@@ -1159,166 +1182,1411 @@ var SolanaFacilitator = class extends BaseFacilitator {
|
|
|
1159
1182
|
}
|
|
1160
1183
|
};
|
|
1161
1184
|
|
|
1162
|
-
// src/facilitators/
|
|
1163
|
-
import
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1185
|
+
// src/facilitators/alipay.ts
|
|
1186
|
+
import crypto2 from "crypto";
|
|
1187
|
+
|
|
1188
|
+
// src/facilitators/alipay/rsa2.ts
|
|
1189
|
+
import crypto from "crypto";
|
|
1190
|
+
function rsa2Sign(data, privateKeyPem) {
|
|
1191
|
+
const signer = crypto.createSign("RSA-SHA256");
|
|
1192
|
+
signer.update(data, "utf-8");
|
|
1193
|
+
signer.end();
|
|
1194
|
+
return signer.sign(privateKeyPem, "base64");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// src/facilitators/alipay/encoding.ts
|
|
1198
|
+
function base64url(input) {
|
|
1199
|
+
return Buffer.from(input, "utf-8").toString("base64url");
|
|
1200
|
+
}
|
|
1201
|
+
function decodeBase64UrlWithPadFix(input) {
|
|
1202
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
1203
|
+
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
1204
|
+
return Buffer.from(padded, "base64").toString("utf-8");
|
|
1205
|
+
}
|
|
1206
|
+
function toPem(key, kind) {
|
|
1207
|
+
const trimmed = key.trim();
|
|
1208
|
+
if (trimmed.includes("-----BEGIN")) return trimmed;
|
|
1209
|
+
const label = kind === "PRIVATE" ? "PRIVATE KEY" : "PUBLIC KEY";
|
|
1210
|
+
const body = trimmed.replace(/\s+/g, "").match(/.{1,64}/g)?.join("\n") ?? "";
|
|
1211
|
+
return `-----BEGIN ${label}-----
|
|
1212
|
+
${body}
|
|
1213
|
+
-----END ${label}-----
|
|
1214
|
+
`;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// src/facilitators/alipay/openapi.ts
|
|
1218
|
+
function formatAlipayTimestamp(d = /* @__PURE__ */ new Date()) {
|
|
1219
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1220
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
1221
|
+
}
|
|
1222
|
+
function buildSigningString(params) {
|
|
1223
|
+
return Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join("&");
|
|
1224
|
+
}
|
|
1225
|
+
function responseWrapperKey(method) {
|
|
1226
|
+
return `${method.replace(/\./g, "_")}_response`;
|
|
1227
|
+
}
|
|
1228
|
+
async function alipayOpenApiCall(method, bizContent, config) {
|
|
1229
|
+
const publicParams = {
|
|
1230
|
+
app_id: config.app_id,
|
|
1231
|
+
method,
|
|
1232
|
+
format: "JSON",
|
|
1233
|
+
charset: "utf-8",
|
|
1234
|
+
sign_type: config.sign_type ?? "RSA2",
|
|
1235
|
+
timestamp: formatAlipayTimestamp(),
|
|
1236
|
+
version: "1.0",
|
|
1237
|
+
biz_content: JSON.stringify(bizContent)
|
|
1238
|
+
};
|
|
1239
|
+
const signingString = buildSigningString(publicParams);
|
|
1240
|
+
const sign = rsa2Sign(signingString, config.private_key_pem);
|
|
1241
|
+
const body = new URLSearchParams({ ...publicParams, sign }).toString();
|
|
1242
|
+
const response = await fetch(config.gateway_url, {
|
|
1243
|
+
method: "POST",
|
|
1244
|
+
headers: {
|
|
1245
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"
|
|
1246
|
+
},
|
|
1247
|
+
body
|
|
1248
|
+
});
|
|
1249
|
+
if (!response.ok) {
|
|
1250
|
+
const text = await response.text().catch(() => "<unreadable>");
|
|
1251
|
+
throw new Error(
|
|
1252
|
+
`Alipay Open API HTTP ${response.status} for ${method}: ${text.slice(0, 500)}`
|
|
1253
|
+
);
|
|
1187
1254
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1255
|
+
const json = await response.json();
|
|
1256
|
+
const wrapperKey = responseWrapperKey(method);
|
|
1257
|
+
const business = json[wrapperKey];
|
|
1258
|
+
if (business === void 0 || business === null || typeof business !== "object") {
|
|
1259
|
+
throw new Error(
|
|
1260
|
+
`Alipay Open API response missing "${wrapperKey}" wrapper for ${method}: ${JSON.stringify(json).slice(0, 500)}`
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
return business;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/facilitators/alipay.ts
|
|
1267
|
+
var ALIPAY_NETWORK = "alipay";
|
|
1268
|
+
var ALIPAY_SCHEME = "alipay-aipay";
|
|
1269
|
+
var ALIPAY_GATEWAY_PROD = "https://openapi.alipay.com/gateway.do";
|
|
1270
|
+
var ALIPAY_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
|
|
1271
|
+
var ALIPAY_PAY_BEFORE_MS = 30 * 60 * 1e3;
|
|
1272
|
+
var ALIPAY_SIGNING_FIELDS = [
|
|
1273
|
+
"amount",
|
|
1274
|
+
"currency",
|
|
1275
|
+
"goods_name",
|
|
1276
|
+
"out_trade_no",
|
|
1277
|
+
"pay_before",
|
|
1278
|
+
"resource_id",
|
|
1279
|
+
"seller_id",
|
|
1280
|
+
"service_id"
|
|
1281
|
+
];
|
|
1282
|
+
var AlipayFacilitator = class extends BaseFacilitator {
|
|
1283
|
+
name = "alipay";
|
|
1284
|
+
displayName = "Alipay AI Pay";
|
|
1285
|
+
supportedNetworks = [ALIPAY_NETWORK];
|
|
1286
|
+
config;
|
|
1287
|
+
constructor(config) {
|
|
1288
|
+
super();
|
|
1289
|
+
this.config = {
|
|
1290
|
+
gateway_url: ALIPAY_GATEWAY_PROD,
|
|
1291
|
+
sign_type: "RSA2",
|
|
1292
|
+
...config
|
|
1293
|
+
};
|
|
1193
1294
|
}
|
|
1194
1295
|
/**
|
|
1195
|
-
*
|
|
1296
|
+
* Build the 402 challenge for a service: signs the 8-field payload with
|
|
1297
|
+
* RSA2, packages the nested `{protocol, method}` JSON as Base64URL for
|
|
1298
|
+
* `Payment-Needed`, and emits the parallel x402 `accepts[]` entry.
|
|
1196
1299
|
*/
|
|
1197
|
-
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
if (!factory) {
|
|
1203
|
-
throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
|
|
1300
|
+
async createPaymentRequirements(opts) {
|
|
1301
|
+
if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
|
|
1302
|
+
throw new Error(
|
|
1303
|
+
`AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
|
|
1304
|
+
);
|
|
1204
1305
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1306
|
+
const now = /* @__PURE__ */ new Date();
|
|
1307
|
+
const outTradeNo = opts.outTradeNo ?? generateOutTradeNo();
|
|
1308
|
+
const payBefore = formatPayBefore(now);
|
|
1309
|
+
const signedFields = {
|
|
1310
|
+
amount: opts.priceCny,
|
|
1311
|
+
currency: "CNY",
|
|
1312
|
+
goods_name: opts.goodsName,
|
|
1313
|
+
out_trade_no: outTradeNo,
|
|
1314
|
+
pay_before: payBefore,
|
|
1315
|
+
resource_id: opts.resourceId,
|
|
1316
|
+
seller_id: this.config.seller_id,
|
|
1317
|
+
service_id: opts.serviceId
|
|
1208
1318
|
};
|
|
1209
|
-
const
|
|
1210
|
-
this.
|
|
1211
|
-
|
|
1319
|
+
const signingString = ALIPAY_SIGNING_FIELDS.map((k) => `${k}=${signedFields[k]}`).join("&");
|
|
1320
|
+
const seller_signature = rsa2Sign(signingString, this.config.private_key_pem);
|
|
1321
|
+
const challenge = {
|
|
1322
|
+
protocol: {
|
|
1323
|
+
out_trade_no: outTradeNo,
|
|
1324
|
+
amount: signedFields.amount,
|
|
1325
|
+
currency: signedFields.currency,
|
|
1326
|
+
resource_id: signedFields.resource_id,
|
|
1327
|
+
pay_before: payBefore,
|
|
1328
|
+
seller_signature,
|
|
1329
|
+
seller_sign_type: this.config.sign_type ?? "RSA2",
|
|
1330
|
+
seller_unique_id: this.config.seller_id
|
|
1331
|
+
},
|
|
1332
|
+
method: {
|
|
1333
|
+
seller_name: this.config.seller_name,
|
|
1334
|
+
seller_id: this.config.seller_id,
|
|
1335
|
+
seller_app_id: this.config.app_id,
|
|
1336
|
+
goods_name: signedFields.goods_name,
|
|
1337
|
+
seller_unique_id_key: "seller_id",
|
|
1338
|
+
service_id: signedFields.service_id
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
const paymentNeededHeader = base64url(JSON.stringify(challenge));
|
|
1342
|
+
const x402Accepts = {
|
|
1343
|
+
scheme: ALIPAY_SCHEME,
|
|
1344
|
+
network: ALIPAY_NETWORK,
|
|
1345
|
+
asset: "CNY",
|
|
1346
|
+
amount: opts.priceCny,
|
|
1347
|
+
payTo: this.config.seller_id,
|
|
1348
|
+
maxTimeoutSeconds: ALIPAY_PAY_BEFORE_MS / 1e3,
|
|
1349
|
+
extra: {
|
|
1350
|
+
payment_needed_header: paymentNeededHeader,
|
|
1351
|
+
out_trade_no: outTradeNo,
|
|
1352
|
+
pay_before: payBefore,
|
|
1353
|
+
service_id: signedFields.service_id
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
return { x402Accepts, paymentNeededHeader };
|
|
1212
1357
|
}
|
|
1213
1358
|
/**
|
|
1214
|
-
*
|
|
1359
|
+
* Verify a `Payment-Proof` by calling
|
|
1360
|
+
* `alipay.aipay.agent.payment.verify` against the Alipay Open API.
|
|
1361
|
+
*
|
|
1362
|
+
* The proof is conveyed via `paymentPayload.payload`, which may be:
|
|
1363
|
+
* - a raw Base64URL string (the `Payment-Proof` header value), or
|
|
1364
|
+
* - an object `{ paymentProof: string }` / `{ proofHeader: string }`.
|
|
1365
|
+
*
|
|
1366
|
+
* All failure modes (malformed payload, network errors, Alipay
|
|
1367
|
+
* `code != 10000`) return `{ valid: false, error }`. No exception
|
|
1368
|
+
* escapes, regardless of client-supplied input.
|
|
1215
1369
|
*/
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1370
|
+
async verify(paymentPayload, _requirements) {
|
|
1371
|
+
try {
|
|
1372
|
+
const proofHeader = extractProofHeader(paymentPayload.payload);
|
|
1373
|
+
const decoded = decodeProof(proofHeader);
|
|
1374
|
+
const response = await alipayOpenApiCall(
|
|
1375
|
+
"alipay.aipay.agent.payment.verify",
|
|
1376
|
+
{
|
|
1377
|
+
payment_proof: decoded.protocol.payment_proof,
|
|
1378
|
+
trade_no: decoded.protocol.trade_no,
|
|
1379
|
+
client_session: decoded.method.client_session
|
|
1380
|
+
},
|
|
1381
|
+
this.getOpenApiConfig()
|
|
1382
|
+
);
|
|
1383
|
+
if (response.code !== "10000") {
|
|
1384
|
+
return {
|
|
1385
|
+
valid: false,
|
|
1386
|
+
error: `alipay verify ${response.code}: ${response.sub_msg ?? response.msg ?? "unknown"}`,
|
|
1387
|
+
details: {
|
|
1388
|
+
code: response.code,
|
|
1389
|
+
sub_code: response.sub_code,
|
|
1390
|
+
sub_msg: response.sub_msg
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
return {
|
|
1395
|
+
valid: true,
|
|
1396
|
+
details: {
|
|
1397
|
+
trade_no: response.trade_no ?? decoded.protocol.trade_no,
|
|
1398
|
+
amount: response.amount,
|
|
1399
|
+
out_trade_no: response.out_trade_no,
|
|
1400
|
+
resource_id: response.resource_id,
|
|
1401
|
+
active: response.active
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
return {
|
|
1406
|
+
valid: false,
|
|
1407
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1408
|
+
};
|
|
1220
1409
|
}
|
|
1221
|
-
return names;
|
|
1222
1410
|
}
|
|
1223
1411
|
/**
|
|
1224
|
-
*
|
|
1412
|
+
* Settle by calling `alipay.aipay.agent.fulfillment.confirm` after the
|
|
1413
|
+
* service resource has been returned to the buyer.
|
|
1414
|
+
*
|
|
1415
|
+
* Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
|
|
1416
|
+
* (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
|
|
1417
|
+
* server) is expected to log fulfillment failures but NOT roll back
|
|
1418
|
+
* the already-delivered resource.
|
|
1419
|
+
*
|
|
1420
|
+
* Re-decodes `paymentPayload.payload` to extract `trade_no` (verify
|
|
1421
|
+
* does the same; the redundant Base64URL decode is negligible).
|
|
1225
1422
|
*/
|
|
1226
|
-
async
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1423
|
+
async settle(paymentPayload, _requirements) {
|
|
1424
|
+
try {
|
|
1425
|
+
const proofHeader = extractProofHeader(paymentPayload.payload);
|
|
1426
|
+
const decoded = decodeProof(proofHeader);
|
|
1427
|
+
const tradeNo = decoded.protocol.trade_no;
|
|
1428
|
+
const response = await alipayOpenApiCall(
|
|
1429
|
+
"alipay.aipay.agent.fulfillment.confirm",
|
|
1430
|
+
{ trade_no: tradeNo },
|
|
1431
|
+
this.getOpenApiConfig()
|
|
1432
|
+
);
|
|
1433
|
+
if (response.code !== "10000") {
|
|
1434
|
+
return {
|
|
1435
|
+
success: false,
|
|
1436
|
+
transaction: tradeNo,
|
|
1437
|
+
error: `alipay fulfillment ${response.code}: ${response.sub_msg ?? response.msg ?? "unknown"}`,
|
|
1438
|
+
status: "fulfillment_failed"
|
|
1439
|
+
};
|
|
1237
1440
|
}
|
|
1441
|
+
return {
|
|
1442
|
+
success: true,
|
|
1443
|
+
transaction: tradeNo,
|
|
1444
|
+
status: "fulfilled"
|
|
1445
|
+
};
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
return {
|
|
1448
|
+
success: false,
|
|
1449
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1450
|
+
};
|
|
1238
1451
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Validate that the configured RSA keys parse and that the Open API
|
|
1455
|
+
* gateway is reachable. Does NOT make a real business API call (would
|
|
1456
|
+
* burn quota and could appear as a legitimate verify attempt in logs).
|
|
1457
|
+
*/
|
|
1458
|
+
async healthCheck() {
|
|
1459
|
+
const start = Date.now();
|
|
1460
|
+
try {
|
|
1461
|
+
crypto2.createPrivateKey(this.config.private_key_pem);
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
return {
|
|
1464
|
+
healthy: false,
|
|
1465
|
+
error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1466
|
+
};
|
|
1241
1467
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1468
|
+
try {
|
|
1469
|
+
crypto2.createPublicKey(this.config.alipay_public_key_pem);
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
return {
|
|
1472
|
+
healthy: false,
|
|
1473
|
+
error: `alipay_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
const gatewayUrl = this.config.gateway_url ?? ALIPAY_GATEWAY_PROD;
|
|
1477
|
+
const controller = new AbortController();
|
|
1478
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
1479
|
+
const response = await fetch(gatewayUrl, {
|
|
1480
|
+
method: "HEAD",
|
|
1481
|
+
signal: controller.signal
|
|
1482
|
+
}).catch(() => null);
|
|
1483
|
+
clearTimeout(timeout);
|
|
1484
|
+
const latencyMs = Date.now() - start;
|
|
1485
|
+
if (!response) {
|
|
1486
|
+
return { healthy: false, error: `gateway unreachable: ${gatewayUrl}`, latencyMs };
|
|
1258
1487
|
}
|
|
1488
|
+
return { healthy: true, latencyMs };
|
|
1259
1489
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1490
|
+
/** Bundle the facilitator config into the shape openapi.ts wants. */
|
|
1491
|
+
getOpenApiConfig() {
|
|
1492
|
+
return {
|
|
1493
|
+
gateway_url: this.config.gateway_url ?? ALIPAY_GATEWAY_PROD,
|
|
1494
|
+
app_id: this.config.app_id,
|
|
1495
|
+
private_key_pem: this.config.private_key_pem,
|
|
1496
|
+
alipay_public_key_pem: this.config.alipay_public_key_pem,
|
|
1497
|
+
sign_type: this.config.sign_type
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
function generateOutTradeNo() {
|
|
1502
|
+
return "VID" + crypto2.randomBytes(22).toString("base64url").slice(0, 29);
|
|
1503
|
+
}
|
|
1504
|
+
function formatPayBefore(now) {
|
|
1505
|
+
const expiry = new Date(now.getTime() + ALIPAY_PAY_BEFORE_MS);
|
|
1506
|
+
return expiry.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1507
|
+
}
|
|
1508
|
+
function extractProofHeader(payload) {
|
|
1509
|
+
if (typeof payload === "string") {
|
|
1510
|
+
if (payload.length === 0) {
|
|
1511
|
+
throw new Error("alipay Payment-Proof is empty");
|
|
1265
1512
|
}
|
|
1266
|
-
return
|
|
1513
|
+
return payload;
|
|
1267
1514
|
}
|
|
1268
|
-
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1515
|
+
if (payload !== null && typeof payload === "object") {
|
|
1516
|
+
const obj = payload;
|
|
1517
|
+
const candidate = obj.paymentProof ?? obj.proofHeader;
|
|
1518
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
1519
|
+
return candidate;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
throw new Error(
|
|
1523
|
+
"alipay payment payload must be a Base64URL string or {paymentProof: string} / {proofHeader: string}"
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
function decodeProof(proofHeader) {
|
|
1527
|
+
let parsed;
|
|
1528
|
+
try {
|
|
1529
|
+
parsed = JSON.parse(decodeBase64UrlWithPadFix(proofHeader));
|
|
1530
|
+
} catch (e) {
|
|
1531
|
+
throw new Error(
|
|
1532
|
+
`failed to decode Payment-Proof: ${e instanceof Error ? e.message : String(e)}`
|
|
1278
1533
|
);
|
|
1279
|
-
withFees.sort((a, b) => a.perTx - b.perTx);
|
|
1280
|
-
return withFees.map((w) => w.facilitator);
|
|
1281
1534
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1535
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
1536
|
+
throw new Error("decoded Payment-Proof is not an object");
|
|
1537
|
+
}
|
|
1538
|
+
const obj = parsed;
|
|
1539
|
+
const protocol = obj.protocol;
|
|
1540
|
+
const method = obj.method;
|
|
1541
|
+
if (!protocol || typeof protocol.payment_proof !== "string") {
|
|
1542
|
+
throw new Error("decoded Payment-Proof missing protocol.payment_proof");
|
|
1543
|
+
}
|
|
1544
|
+
if (typeof protocol.trade_no !== "string") {
|
|
1545
|
+
throw new Error("decoded Payment-Proof missing protocol.trade_no");
|
|
1546
|
+
}
|
|
1547
|
+
if (!method || typeof method.client_session !== "string") {
|
|
1548
|
+
throw new Error("decoded Payment-Proof missing method.client_session");
|
|
1549
|
+
}
|
|
1550
|
+
return parsed;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// src/facilitators/wechat.ts
|
|
1554
|
+
import crypto4 from "crypto";
|
|
1555
|
+
|
|
1556
|
+
// src/facilitators/wechat/sign.ts
|
|
1557
|
+
import crypto3 from "crypto";
|
|
1558
|
+
var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
|
|
1559
|
+
function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
|
|
1560
|
+
return `${method.toUpperCase()}
|
|
1561
|
+
${urlPath}
|
|
1562
|
+
${timestamp}
|
|
1563
|
+
${nonce}
|
|
1564
|
+
${body}
|
|
1565
|
+
`;
|
|
1566
|
+
}
|
|
1567
|
+
function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
|
|
1568
|
+
const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
|
|
1569
|
+
const signer = crypto3.createSign("RSA-SHA256");
|
|
1570
|
+
signer.update(message, "utf-8");
|
|
1571
|
+
signer.end();
|
|
1572
|
+
return signer.sign(privateKeyPem, "base64");
|
|
1573
|
+
}
|
|
1574
|
+
function buildAuthorizationToken(args) {
|
|
1575
|
+
const fields = [
|
|
1576
|
+
`mchid="${args.mchid}"`,
|
|
1577
|
+
`nonce_str="${args.nonce}"`,
|
|
1578
|
+
`signature="${args.signature}"`,
|
|
1579
|
+
`timestamp="${args.timestamp}"`,
|
|
1580
|
+
`serial_no="${args.serialNo}"`
|
|
1581
|
+
].join(",");
|
|
1582
|
+
return `${WECHAT_AUTH_SCHEMA} ${fields}`;
|
|
1583
|
+
}
|
|
1584
|
+
function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
|
|
1585
|
+
try {
|
|
1586
|
+
const message = `${timestamp}
|
|
1587
|
+
${nonce}
|
|
1588
|
+
${body}
|
|
1589
|
+
`;
|
|
1590
|
+
const verifier = crypto3.createVerify("RSA-SHA256");
|
|
1591
|
+
verifier.update(message, "utf-8");
|
|
1592
|
+
verifier.end();
|
|
1593
|
+
return verifier.verify(platformPublicKeyPem, signature, "base64");
|
|
1594
|
+
} catch {
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
function generateNonce() {
|
|
1599
|
+
return crypto3.randomBytes(16).toString("hex");
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// src/facilitators/wechat/api.ts
|
|
1603
|
+
var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
|
|
1604
|
+
var WechatApiError = class extends Error {
|
|
1605
|
+
status;
|
|
1606
|
+
code;
|
|
1607
|
+
constructor(message, status, code) {
|
|
1608
|
+
super(message);
|
|
1609
|
+
this.name = "WechatApiError";
|
|
1610
|
+
this.status = status;
|
|
1611
|
+
this.code = code;
|
|
1612
|
+
}
|
|
1613
|
+
};
|
|
1614
|
+
async function wechatV3Call(method, urlPath, body, config) {
|
|
1615
|
+
const base = config.api_base ?? WECHAT_API_BASE;
|
|
1616
|
+
const bodyStr = body === null ? "" : JSON.stringify(body);
|
|
1617
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
1618
|
+
const nonce = generateNonce();
|
|
1619
|
+
const signature = wechatV3Sign(
|
|
1620
|
+
method,
|
|
1621
|
+
urlPath,
|
|
1622
|
+
timestamp,
|
|
1623
|
+
nonce,
|
|
1624
|
+
bodyStr,
|
|
1625
|
+
config.private_key_pem
|
|
1626
|
+
);
|
|
1627
|
+
const authorization = buildAuthorizationToken({
|
|
1628
|
+
mchid: config.mchid,
|
|
1629
|
+
serialNo: config.serial_no,
|
|
1630
|
+
nonce,
|
|
1631
|
+
timestamp,
|
|
1632
|
+
signature
|
|
1633
|
+
});
|
|
1634
|
+
const response = await fetch(`${base}${urlPath}`, {
|
|
1635
|
+
method,
|
|
1636
|
+
headers: {
|
|
1637
|
+
Authorization: authorization,
|
|
1638
|
+
Accept: "application/json",
|
|
1639
|
+
"Content-Type": "application/json",
|
|
1640
|
+
// WeChat requires a non-empty UA; some edge nodes 403 a blank one.
|
|
1641
|
+
"User-Agent": "moltspay-wechat/1.0"
|
|
1642
|
+
},
|
|
1643
|
+
body: method === "GET" ? void 0 : bodyStr
|
|
1644
|
+
});
|
|
1645
|
+
const text = await response.text();
|
|
1646
|
+
if (config.platform_public_key_pem && text.length > 0) {
|
|
1647
|
+
const ts = response.headers.get("Wechatpay-Timestamp");
|
|
1648
|
+
const nc = response.headers.get("Wechatpay-Nonce");
|
|
1649
|
+
const sig = response.headers.get("Wechatpay-Signature");
|
|
1650
|
+
if (!ts || !nc || !sig) {
|
|
1651
|
+
throw new Error(
|
|
1652
|
+
`WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
|
|
1656
|
+
throw new Error(
|
|
1657
|
+
`WeChat v3 ${method} ${urlPath}: response signature verification failed`
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
let json = {};
|
|
1662
|
+
if (text.length > 0) {
|
|
1663
|
+
try {
|
|
1664
|
+
json = JSON.parse(text);
|
|
1665
|
+
} catch {
|
|
1666
|
+
throw new Error(
|
|
1667
|
+
`WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
if (!response.ok) {
|
|
1672
|
+
const code = typeof json.code === "string" ? json.code : void 0;
|
|
1673
|
+
const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
|
|
1674
|
+
throw new WechatApiError(
|
|
1675
|
+
`WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
|
|
1676
|
+
response.status,
|
|
1677
|
+
code
|
|
1292
1678
|
);
|
|
1293
|
-
|
|
1294
|
-
|
|
1679
|
+
}
|
|
1680
|
+
return { status: response.status, body: json };
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// src/facilitators/wechat.ts
|
|
1684
|
+
var WECHAT_NETWORK = "wechat";
|
|
1685
|
+
var WECHAT_SCHEME = "wechatpay-native";
|
|
1686
|
+
var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
|
|
1687
|
+
var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
|
|
1688
|
+
var WechatFacilitator = class extends BaseFacilitator {
|
|
1689
|
+
name = "wechat";
|
|
1690
|
+
displayName = "WeChat Pay";
|
|
1691
|
+
supportedNetworks = [WECHAT_NETWORK];
|
|
1692
|
+
config;
|
|
1693
|
+
constructor(config) {
|
|
1694
|
+
super();
|
|
1695
|
+
this.config = { api_base: WECHAT_API_BASE, ...config };
|
|
1295
1696
|
}
|
|
1296
1697
|
/**
|
|
1297
|
-
*
|
|
1698
|
+
* Place a Native order and build the 402 challenge. The returned
|
|
1699
|
+
* `code_url` is payer-agnostic — any WeChat user may scan it.
|
|
1298
1700
|
*/
|
|
1299
|
-
async
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
try {
|
|
1305
|
-
console.log(`[Registry] Trying ${f.name} for verify...`);
|
|
1306
|
-
const result = await f.verify(paymentPayload, requirements);
|
|
1307
|
-
if (result.valid) {
|
|
1308
|
-
console.log(`[Registry] ${f.name} verify succeeded`);
|
|
1309
|
-
return { ...result, facilitator: f.name };
|
|
1310
|
-
}
|
|
1311
|
-
lastError = result.error;
|
|
1312
|
-
console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
|
|
1313
|
-
if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
|
|
1314
|
-
break;
|
|
1315
|
-
}
|
|
1316
|
-
} catch (err) {
|
|
1317
|
-
lastError = err.message;
|
|
1318
|
-
console.error(`[Registry] ${f.name} error:`, err.message);
|
|
1319
|
-
}
|
|
1701
|
+
async createPaymentRequirements(opts) {
|
|
1702
|
+
if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
|
|
1703
|
+
throw new Error(
|
|
1704
|
+
`WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
|
|
1705
|
+
);
|
|
1320
1706
|
}
|
|
1321
|
-
|
|
1707
|
+
const total = cnyToFen(opts.priceCny);
|
|
1708
|
+
if (total < 1) {
|
|
1709
|
+
throw new Error(
|
|
1710
|
+
`WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
|
|
1714
|
+
const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
|
|
1715
|
+
const body = {
|
|
1716
|
+
appid: this.config.appid,
|
|
1717
|
+
mchid: this.config.mchid,
|
|
1718
|
+
description: opts.description,
|
|
1719
|
+
out_trade_no: outTradeNo,
|
|
1720
|
+
notify_url: this.config.notify_url,
|
|
1721
|
+
time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
|
|
1722
|
+
amount: { total, currency: "CNY" }
|
|
1723
|
+
};
|
|
1724
|
+
if (opts.attach) {
|
|
1725
|
+
const attachStr = JSON.stringify(opts.attach);
|
|
1726
|
+
if (Buffer.byteLength(attachStr, "utf8") > 128) {
|
|
1727
|
+
throw new Error(
|
|
1728
|
+
`WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
body.attach = attachStr;
|
|
1732
|
+
}
|
|
1733
|
+
const { body: resp } = await wechatV3Call(
|
|
1734
|
+
"POST",
|
|
1735
|
+
"/v3/pay/transactions/native",
|
|
1736
|
+
body,
|
|
1737
|
+
this.getApiConfig()
|
|
1738
|
+
);
|
|
1739
|
+
const codeUrl = resp.code_url;
|
|
1740
|
+
if (typeof codeUrl !== "string" || codeUrl.length === 0) {
|
|
1741
|
+
throw new Error(
|
|
1742
|
+
`WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
const x402Accepts = {
|
|
1746
|
+
scheme: WECHAT_SCHEME,
|
|
1747
|
+
network: WECHAT_NETWORK,
|
|
1748
|
+
asset: "CNY",
|
|
1749
|
+
amount: opts.priceCny,
|
|
1750
|
+
payTo: this.config.mchid,
|
|
1751
|
+
maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
|
|
1752
|
+
extra: {
|
|
1753
|
+
code_url: codeUrl,
|
|
1754
|
+
out_trade_no: outTradeNo
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
return { x402Accepts, codeUrl, outTradeNo };
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
|
|
1761
|
+
* (missing out_trade_no, gateway error, not-yet-paid) return
|
|
1762
|
+
* `{ valid: false, error }`; no exception escapes.
|
|
1763
|
+
*/
|
|
1764
|
+
async verify(paymentPayload, requirements) {
|
|
1765
|
+
try {
|
|
1766
|
+
const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
|
|
1767
|
+
const resp = await this.queryOrder(outTradeNo);
|
|
1768
|
+
const tradeState = resp.trade_state;
|
|
1769
|
+
if (tradeState !== "SUCCESS") {
|
|
1770
|
+
return {
|
|
1771
|
+
valid: false,
|
|
1772
|
+
error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
|
|
1773
|
+
details: { trade_state: tradeState, out_trade_no: outTradeNo }
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
valid: true,
|
|
1778
|
+
details: {
|
|
1779
|
+
trade_state: tradeState,
|
|
1780
|
+
transaction_id: resp.transaction_id,
|
|
1781
|
+
out_trade_no: resp.out_trade_no ?? outTradeNo,
|
|
1782
|
+
amount: resp.amount,
|
|
1783
|
+
attach: resp.attach,
|
|
1784
|
+
// Payer identity, gateway-attested. Present on a SUCCESS Native
|
|
1785
|
+
// order even though order creation was payer-agnostic; anchors the
|
|
1786
|
+
// custodial balance to a real WeChat user. @see WECHAT fiat auth design.
|
|
1787
|
+
openid: resp.payer?.openid
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
return { valid: false, error: e instanceof Error ? e.message : String(e) };
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Confirm settlement. Native captures funds at SUCCESS, so this is an
|
|
1796
|
+
* idempotent re-confirm that returns the `transaction_id`. Like Alipay's
|
|
1797
|
+
* fulfillment confirm, failures are surfaced but non-fatal to an
|
|
1798
|
+
* already-delivered resource (caller logs, does not roll back).
|
|
1799
|
+
*/
|
|
1800
|
+
async settle(paymentPayload, requirements) {
|
|
1801
|
+
try {
|
|
1802
|
+
const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
|
|
1803
|
+
const resp = await this.queryOrder(outTradeNo);
|
|
1804
|
+
const tradeState = resp.trade_state;
|
|
1805
|
+
const transactionId = resp.transaction_id;
|
|
1806
|
+
if (tradeState !== "SUCCESS") {
|
|
1807
|
+
return {
|
|
1808
|
+
success: false,
|
|
1809
|
+
transaction: transactionId,
|
|
1810
|
+
error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
|
|
1811
|
+
status: tradeState
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
return { success: true, transaction: transactionId, status: "fulfilled" };
|
|
1815
|
+
} catch (e) {
|
|
1816
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Validate keys parse, apiv3 key length, and gateway reachability. Does
|
|
1821
|
+
* NOT make a business API call.
|
|
1822
|
+
*/
|
|
1823
|
+
async healthCheck() {
|
|
1824
|
+
const start = Date.now();
|
|
1825
|
+
try {
|
|
1826
|
+
crypto4.createPrivateKey(this.config.private_key_pem);
|
|
1827
|
+
} catch (e) {
|
|
1828
|
+
return {
|
|
1829
|
+
healthy: false,
|
|
1830
|
+
error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
if (this.config.platform_public_key_pem) {
|
|
1834
|
+
try {
|
|
1835
|
+
crypto4.createPublicKey(this.config.platform_public_key_pem);
|
|
1836
|
+
} catch (e) {
|
|
1837
|
+
return {
|
|
1838
|
+
healthy: false,
|
|
1839
|
+
error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
|
|
1844
|
+
return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
|
|
1845
|
+
}
|
|
1846
|
+
const base = this.config.api_base ?? WECHAT_API_BASE;
|
|
1847
|
+
const controller = new AbortController();
|
|
1848
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
1849
|
+
const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
|
|
1850
|
+
clearTimeout(timeout);
|
|
1851
|
+
const latencyMs = Date.now() - start;
|
|
1852
|
+
if (!response) {
|
|
1853
|
+
return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
|
|
1854
|
+
}
|
|
1855
|
+
return { healthy: true, latencyMs };
|
|
1856
|
+
}
|
|
1857
|
+
/** Query a Native order by out_trade_no. The query string is part of the signed path. */
|
|
1858
|
+
async queryOrder(outTradeNo) {
|
|
1859
|
+
const path4 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
|
|
1860
|
+
const { body } = await wechatV3Call("GET", path4, null, this.getApiConfig());
|
|
1861
|
+
return body;
|
|
1862
|
+
}
|
|
1863
|
+
/** Project the facilitator config down to what api.ts needs. */
|
|
1864
|
+
getApiConfig() {
|
|
1865
|
+
return {
|
|
1866
|
+
mchid: this.config.mchid,
|
|
1867
|
+
serial_no: this.config.serial_no,
|
|
1868
|
+
private_key_pem: this.config.private_key_pem,
|
|
1869
|
+
platform_public_key_pem: this.config.platform_public_key_pem,
|
|
1870
|
+
api_base: this.config.api_base
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
function cnyToFen(cny) {
|
|
1875
|
+
return Math.round(parseFloat(cny) * 100);
|
|
1876
|
+
}
|
|
1877
|
+
function generateOutTradeNo2() {
|
|
1878
|
+
return "WX" + crypto4.randomBytes(15).toString("hex");
|
|
1879
|
+
}
|
|
1880
|
+
function parseWechatAttach(attach) {
|
|
1881
|
+
if (typeof attach !== "string" || attach.length === 0) return null;
|
|
1882
|
+
try {
|
|
1883
|
+
const parsed = JSON.parse(attach);
|
|
1884
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1885
|
+
} catch {
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
function formatTimeExpire(d) {
|
|
1890
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1891
|
+
const offMin = -d.getTimezoneOffset();
|
|
1892
|
+
const sign = offMin >= 0 ? "+" : "-";
|
|
1893
|
+
const abs = Math.abs(offMin);
|
|
1894
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
1895
|
+
}
|
|
1896
|
+
function extractOutTradeNo(paymentPayload, requirements) {
|
|
1897
|
+
const p = paymentPayload?.payload;
|
|
1898
|
+
if (typeof p === "string" && p.length > 0) return p;
|
|
1899
|
+
if (p !== null && typeof p === "object") {
|
|
1900
|
+
const obj = p;
|
|
1901
|
+
const cand = obj.out_trade_no ?? obj.outTradeNo;
|
|
1902
|
+
if (typeof cand === "string" && cand.length > 0) return cand;
|
|
1903
|
+
}
|
|
1904
|
+
const fromReq = requirements?.extra?.out_trade_no;
|
|
1905
|
+
if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
|
|
1906
|
+
throw new Error(
|
|
1907
|
+
"wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/facilitators/balance/ledger.ts
|
|
1912
|
+
import { randomUUID } from "crypto";
|
|
1913
|
+
var DEFAULT_SINGLE_LIMIT_SAT = 500;
|
|
1914
|
+
var DEFAULT_DAILY_LIMIT_SAT = 1e3;
|
|
1915
|
+
function toSat(amount) {
|
|
1916
|
+
const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
|
|
1917
|
+
if (!/^\d+(\.\d{1,2})?$/.test(s)) {
|
|
1918
|
+
throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
|
|
1919
|
+
}
|
|
1920
|
+
const [whole, frac = ""] = s.split(".");
|
|
1921
|
+
return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
|
|
1922
|
+
}
|
|
1923
|
+
function fromSat(sat) {
|
|
1924
|
+
return (sat / 100).toFixed(2);
|
|
1925
|
+
}
|
|
1926
|
+
var BalanceLedger = class {
|
|
1927
|
+
db;
|
|
1928
|
+
defaultSingleLimitSat;
|
|
1929
|
+
defaultDailyLimitSat;
|
|
1930
|
+
constructor(config) {
|
|
1931
|
+
const getBuiltin = process.getBuiltinModule;
|
|
1932
|
+
const sqlite = getBuiltin?.("node:sqlite");
|
|
1933
|
+
if (!sqlite?.DatabaseSync) {
|
|
1934
|
+
throw new Error(
|
|
1935
|
+
`The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
const { DatabaseSync } = sqlite;
|
|
1939
|
+
this.db = new DatabaseSync(config.dbPath);
|
|
1940
|
+
this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
|
|
1941
|
+
this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
|
|
1942
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
1943
|
+
this.db.exec(`
|
|
1944
|
+
CREATE TABLE IF NOT EXISTS buyers (
|
|
1945
|
+
buyer_id TEXT PRIMARY KEY,
|
|
1946
|
+
display_name TEXT,
|
|
1947
|
+
balance_sat INTEGER NOT NULL DEFAULT 0,
|
|
1948
|
+
total_topup_sat INTEGER NOT NULL DEFAULT 0,
|
|
1949
|
+
total_spent_sat INTEGER NOT NULL DEFAULT 0,
|
|
1950
|
+
daily_limit_sat INTEGER NOT NULL,
|
|
1951
|
+
single_limit_sat INTEGER NOT NULL,
|
|
1952
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
1953
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1954
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1955
|
+
);
|
|
1956
|
+
CREATE TABLE IF NOT EXISTS ledger_transactions (
|
|
1957
|
+
id TEXT PRIMARY KEY,
|
|
1958
|
+
buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
|
|
1959
|
+
type TEXT NOT NULL,
|
|
1960
|
+
amount_sat INTEGER NOT NULL,
|
|
1961
|
+
service TEXT,
|
|
1962
|
+
description TEXT,
|
|
1963
|
+
request_id TEXT,
|
|
1964
|
+
external_ref TEXT,
|
|
1965
|
+
refunds_tx_id TEXT,
|
|
1966
|
+
status TEXT NOT NULL DEFAULT 'completed',
|
|
1967
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1968
|
+
);
|
|
1969
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
|
|
1970
|
+
ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
|
|
1971
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
|
|
1972
|
+
ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
|
|
1973
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
|
|
1974
|
+
ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
|
|
1975
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
|
|
1976
|
+
ON ledger_transactions(buyer_id, created_at);
|
|
1977
|
+
CREATE TABLE IF NOT EXISTS ledger_meta (
|
|
1978
|
+
key TEXT PRIMARY KEY,
|
|
1979
|
+
value TEXT NOT NULL
|
|
1980
|
+
);
|
|
1981
|
+
`);
|
|
1982
|
+
const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
|
|
1983
|
+
if (!cols.includes("signer_address")) {
|
|
1984
|
+
this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
|
|
1985
|
+
}
|
|
1986
|
+
if (!cols.includes("wechat_openid")) {
|
|
1987
|
+
this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
|
|
1988
|
+
}
|
|
1989
|
+
const currency = config.currency ?? "USD";
|
|
1990
|
+
const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
|
|
1991
|
+
if (existingCurrency) {
|
|
1992
|
+
if (existingCurrency.value !== currency) {
|
|
1993
|
+
throw new Error(
|
|
1994
|
+
`Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
} else {
|
|
1998
|
+
this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
/** Fetch a buyer, or null. */
|
|
2002
|
+
getBuyer(buyerId) {
|
|
2003
|
+
const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
|
|
2004
|
+
return row ?? null;
|
|
2005
|
+
}
|
|
2006
|
+
/** Fetch a buyer, creating an empty active account on first sight. */
|
|
2007
|
+
getOrCreateBuyer(buyerId, displayName) {
|
|
2008
|
+
const existing = this.getBuyer(buyerId);
|
|
2009
|
+
if (existing) return existing;
|
|
2010
|
+
this.db.prepare(
|
|
2011
|
+
`INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
|
|
2012
|
+
VALUES (?, ?, ?, ?)`
|
|
2013
|
+
).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
|
|
2014
|
+
return this.getBuyer(buyerId);
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Record the gateway-attested WeChat payer openid that funded a buyer, on
|
|
2018
|
+
* top-up confirm. Idempotent and observational (stage 1a): it never
|
|
2019
|
+
* overwrites an existing binding with a *different* openid — a conflict is
|
|
2020
|
+
* reported to the caller (possible account sharing / spoof) but not
|
|
2021
|
+
* enforced here. Creates the buyer if absent.
|
|
2022
|
+
*/
|
|
2023
|
+
bindOpenid(buyerId, openid) {
|
|
2024
|
+
const buyer = this.getOrCreateBuyer(buyerId);
|
|
2025
|
+
if (buyer.wechat_openid === openid) {
|
|
2026
|
+
return { bound: true, conflict: false, existing: openid };
|
|
2027
|
+
}
|
|
2028
|
+
if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
|
|
2029
|
+
return { bound: false, conflict: true, existing: buyer.wechat_openid };
|
|
2030
|
+
}
|
|
2031
|
+
this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
|
|
2032
|
+
return { bound: true, conflict: false, existing: null };
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* TOFU-bind the account's spending signer address (EVM, lowercase). First
|
|
2036
|
+
* signed request records it; later requests must match. A mismatch is
|
|
2037
|
+
* reported (`conflict`) so the caller can reject under `enforce` — it is
|
|
2038
|
+
* never silently overwritten. Creates the buyer if absent.
|
|
2039
|
+
*/
|
|
2040
|
+
bindSigner(buyerId, address) {
|
|
2041
|
+
const a = address.toLowerCase();
|
|
2042
|
+
const buyer = this.getOrCreateBuyer(buyerId);
|
|
2043
|
+
if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
|
|
2044
|
+
if (buyer.signer_address && buyer.signer_address !== a) {
|
|
2045
|
+
return { bound: false, conflict: true, existing: buyer.signer_address };
|
|
2046
|
+
}
|
|
2047
|
+
this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
|
|
2048
|
+
return { bound: true, conflict: false, existing: null };
|
|
2049
|
+
}
|
|
2050
|
+
/** Sum of today's (UTC) completed deducts minus refunds issued against them. */
|
|
2051
|
+
spentTodaySat(buyerId) {
|
|
2052
|
+
const row = this.db.prepare(
|
|
2053
|
+
`SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
|
|
2054
|
+
FROM ledger_transactions
|
|
2055
|
+
WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
|
|
2056
|
+
).get(buyerId);
|
|
2057
|
+
return Math.max(0, row.spent);
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Read-only deduction precheck (the rail's `verify`). Never mutates.
|
|
2061
|
+
* Returns the same error codes `deduct` would.
|
|
2062
|
+
*/
|
|
2063
|
+
checkDeduct(buyerId, amountSat) {
|
|
2064
|
+
const buyer = this.getBuyer(buyerId);
|
|
2065
|
+
if (!buyer) return { success: false, error: "buyer_not_found" };
|
|
2066
|
+
if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
|
|
2067
|
+
if (amountSat > buyer.single_limit_sat) {
|
|
2068
|
+
return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
|
|
2069
|
+
}
|
|
2070
|
+
const spent = this.spentTodaySat(buyerId);
|
|
2071
|
+
if (spent + amountSat > buyer.daily_limit_sat) {
|
|
2072
|
+
return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
|
|
2073
|
+
}
|
|
2074
|
+
if (buyer.balance_sat < amountSat) {
|
|
2075
|
+
return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
|
|
2076
|
+
}
|
|
2077
|
+
return { success: true, balanceSat: buyer.balance_sat };
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
|
|
2081
|
+
* SQLite transaction; a `request_id` replay returns the original tx
|
|
2082
|
+
* without charging again.
|
|
2083
|
+
*/
|
|
2084
|
+
deduct(opts) {
|
|
2085
|
+
if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
|
|
2086
|
+
throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
|
|
2087
|
+
}
|
|
2088
|
+
if (opts.requestId) {
|
|
2089
|
+
const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
|
|
2090
|
+
if (prior) {
|
|
2091
|
+
const buyer = this.getBuyer(prior.buyer_id);
|
|
2092
|
+
return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2096
|
+
try {
|
|
2097
|
+
const check = this.checkDeduct(opts.buyerId, opts.amountSat);
|
|
2098
|
+
if (!check.success) {
|
|
2099
|
+
this.db.exec("ROLLBACK");
|
|
2100
|
+
return check;
|
|
2101
|
+
}
|
|
2102
|
+
const updated = this.db.prepare(
|
|
2103
|
+
`UPDATE buyers SET
|
|
2104
|
+
balance_sat = balance_sat - ?,
|
|
2105
|
+
total_spent_sat = total_spent_sat + ?,
|
|
2106
|
+
updated_at = datetime('now')
|
|
2107
|
+
WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
|
|
2108
|
+
).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
|
|
2109
|
+
if (Number(updated.changes) !== 1) {
|
|
2110
|
+
this.db.exec("ROLLBACK");
|
|
2111
|
+
return { success: false, error: "insufficient_balance" };
|
|
2112
|
+
}
|
|
2113
|
+
const txId = `btx_${randomUUID()}`;
|
|
2114
|
+
this.db.prepare(
|
|
2115
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
|
|
2116
|
+
VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
|
|
2117
|
+
).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
|
|
2118
|
+
this.db.exec("COMMIT");
|
|
2119
|
+
const buyer = this.getBuyer(opts.buyerId);
|
|
2120
|
+
return { success: true, txId, balanceSat: buyer.balance_sat };
|
|
2121
|
+
} catch (err) {
|
|
2122
|
+
try {
|
|
2123
|
+
this.db.exec("ROLLBACK");
|
|
2124
|
+
} catch {
|
|
2125
|
+
}
|
|
2126
|
+
throw err;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Credit a top-up. `externalRef` is the settlement proof reference
|
|
2131
|
+
* (on-chain tx hash / fiat trade number) and is unique: replaying the same
|
|
2132
|
+
* reference returns the original row without crediting twice.
|
|
2133
|
+
*/
|
|
2134
|
+
topup(opts) {
|
|
2135
|
+
if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
|
|
2136
|
+
throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
|
|
2137
|
+
}
|
|
2138
|
+
const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
|
|
2139
|
+
if (prior) {
|
|
2140
|
+
const buyer2 = this.getBuyer(prior.buyer_id);
|
|
2141
|
+
return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
|
|
2142
|
+
}
|
|
2143
|
+
this.getOrCreateBuyer(opts.buyerId);
|
|
2144
|
+
const txId = `btx_${randomUUID()}`;
|
|
2145
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2146
|
+
try {
|
|
2147
|
+
this.db.prepare(
|
|
2148
|
+
`UPDATE buyers SET
|
|
2149
|
+
balance_sat = balance_sat + ?,
|
|
2150
|
+
total_topup_sat = total_topup_sat + ?,
|
|
2151
|
+
updated_at = datetime('now')
|
|
2152
|
+
WHERE buyer_id = ?`
|
|
2153
|
+
).run(opts.amountSat, opts.amountSat, opts.buyerId);
|
|
2154
|
+
this.db.prepare(
|
|
2155
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
|
|
2156
|
+
VALUES (?, ?, 'topup', ?, ?, ?)`
|
|
2157
|
+
).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
|
|
2158
|
+
this.db.exec("COMMIT");
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
try {
|
|
2161
|
+
this.db.exec("ROLLBACK");
|
|
2162
|
+
} catch {
|
|
2163
|
+
}
|
|
2164
|
+
throw err;
|
|
2165
|
+
}
|
|
2166
|
+
const buyer = this.getBuyer(opts.buyerId);
|
|
2167
|
+
return { txId, balanceSat: buyer.balance_sat };
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Reverse a deduct (service failed after the charge). Idempotent: the
|
|
2171
|
+
* unique index on `refunds_tx_id` means a second refund of the same deduct
|
|
2172
|
+
* returns the original refund row.
|
|
2173
|
+
*/
|
|
2174
|
+
refund(deductTxId, reason) {
|
|
2175
|
+
const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
|
|
2176
|
+
if (!deductRow) return { success: false, error: "tx_not_found" };
|
|
2177
|
+
if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
|
|
2178
|
+
const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
|
|
2179
|
+
if (priorRefund) {
|
|
2180
|
+
const buyer = this.getBuyer(deductRow.buyer_id);
|
|
2181
|
+
return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
|
|
2182
|
+
}
|
|
2183
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2184
|
+
try {
|
|
2185
|
+
this.db.prepare(
|
|
2186
|
+
`UPDATE buyers SET
|
|
2187
|
+
balance_sat = balance_sat + ?,
|
|
2188
|
+
total_spent_sat = total_spent_sat - ?,
|
|
2189
|
+
updated_at = datetime('now')
|
|
2190
|
+
WHERE buyer_id = ?`
|
|
2191
|
+
).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
|
|
2192
|
+
this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
|
|
2193
|
+
const txId = `btx_${randomUUID()}`;
|
|
2194
|
+
this.db.prepare(
|
|
2195
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
|
|
2196
|
+
VALUES (?, ?, 'refund', ?, ?, ?)`
|
|
2197
|
+
).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
|
|
2198
|
+
this.db.exec("COMMIT");
|
|
2199
|
+
const buyer = this.getBuyer(deductRow.buyer_id);
|
|
2200
|
+
return { success: true, txId, balanceSat: buyer.balance_sat };
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
try {
|
|
2203
|
+
this.db.exec("ROLLBACK");
|
|
2204
|
+
} catch {
|
|
2205
|
+
}
|
|
2206
|
+
throw err;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
/** Paged transaction history, newest first (rowid breaks same-second ties). */
|
|
2210
|
+
listTransactions(buyerId, limit = 20, offset = 0) {
|
|
2211
|
+
return this.db.prepare(
|
|
2212
|
+
`SELECT * FROM ledger_transactions WHERE buyer_id = ?
|
|
2213
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
|
|
2214
|
+
).all(buyerId, limit, offset);
|
|
2215
|
+
}
|
|
2216
|
+
/** Quick integrity probe for healthCheck(). */
|
|
2217
|
+
integrityOk() {
|
|
2218
|
+
const row = this.db.prepare(`PRAGMA quick_check`).get();
|
|
2219
|
+
return row.quick_check === "ok";
|
|
2220
|
+
}
|
|
2221
|
+
close() {
|
|
2222
|
+
this.db.close();
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
|
|
2226
|
+
// src/facilitators/balance/auth.ts
|
|
2227
|
+
import { ethers as ethers2 } from "ethers";
|
|
2228
|
+
var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
|
|
2229
|
+
var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
2230
|
+
function extractBalanceAuth(raw) {
|
|
2231
|
+
if (!raw || typeof raw !== "object") return null;
|
|
2232
|
+
const a = raw;
|
|
2233
|
+
if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
|
|
2234
|
+
return { timestamp: a.timestamp, signature: a.signature };
|
|
2235
|
+
}
|
|
2236
|
+
return null;
|
|
2237
|
+
}
|
|
2238
|
+
function buildDeductMessage(f) {
|
|
2239
|
+
return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
|
|
2240
|
+
}
|
|
2241
|
+
function verifyDeductAuth(opts) {
|
|
2242
|
+
const { auth } = opts;
|
|
2243
|
+
if (!auth) return { ok: false, reason: "no_signature" };
|
|
2244
|
+
if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
|
|
2245
|
+
if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
|
|
2246
|
+
return { ok: false, reason: "timestamp_skew" };
|
|
2247
|
+
}
|
|
2248
|
+
const message = buildDeductMessage({
|
|
2249
|
+
buyerId: opts.buyerId,
|
|
2250
|
+
requestId: opts.requestId,
|
|
2251
|
+
service: opts.service,
|
|
2252
|
+
timestamp: auth.timestamp
|
|
2253
|
+
});
|
|
2254
|
+
try {
|
|
2255
|
+
const recovered = ethers2.verifyMessage(message, auth.signature).toLowerCase();
|
|
2256
|
+
return { ok: true, recovered };
|
|
2257
|
+
} catch {
|
|
2258
|
+
return { ok: false, reason: "bad_signature" };
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// src/facilitators/balance.ts
|
|
2263
|
+
var BALANCE_NETWORK = "balance";
|
|
2264
|
+
var BALANCE_SCHEME = "balance";
|
|
2265
|
+
function extractBalancePayload(payment) {
|
|
2266
|
+
const p = payment.payload;
|
|
2267
|
+
if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
|
|
2268
|
+
return {
|
|
2269
|
+
buyer_id: p.buyer_id,
|
|
2270
|
+
request_id: typeof p.request_id === "string" ? p.request_id : void 0,
|
|
2271
|
+
auth: extractBalanceAuth(p.auth) ?? void 0
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
return null;
|
|
2275
|
+
}
|
|
2276
|
+
var BalanceFacilitator = class extends BaseFacilitator {
|
|
2277
|
+
name = "balance";
|
|
2278
|
+
displayName = "Custodial Balance";
|
|
2279
|
+
supportedNetworks = [BALANCE_NETWORK];
|
|
2280
|
+
currency;
|
|
2281
|
+
/** User-auth rollout gate for deductions (off | shadow | enforce). */
|
|
2282
|
+
authMode;
|
|
2283
|
+
ledger;
|
|
2284
|
+
constructor(config) {
|
|
2285
|
+
super();
|
|
2286
|
+
this.currency = config.currency ?? "USD";
|
|
2287
|
+
this.authMode = config.auth_mode ?? "off";
|
|
2288
|
+
const ledgerConfig = {
|
|
2289
|
+
dbPath: config.db_path,
|
|
2290
|
+
defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
|
|
2291
|
+
defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
|
|
2292
|
+
currency: this.currency
|
|
2293
|
+
};
|
|
2294
|
+
this.ledger = new BalanceLedger(ledgerConfig);
|
|
2295
|
+
}
|
|
2296
|
+
/** Direct ledger access for the server's balance-management endpoints. */
|
|
2297
|
+
getLedger() {
|
|
2298
|
+
return this.ledger;
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
|
|
2302
|
+
*/
|
|
2303
|
+
createPaymentRequirements(opts) {
|
|
2304
|
+
toSat(opts.price);
|
|
2305
|
+
return {
|
|
2306
|
+
scheme: BALANCE_SCHEME,
|
|
2307
|
+
network: BALANCE_NETWORK,
|
|
2308
|
+
asset: this.currency,
|
|
2309
|
+
amount: opts.price,
|
|
2310
|
+
payTo: "custodial",
|
|
2311
|
+
maxTimeoutSeconds: 30,
|
|
2312
|
+
extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
/** Read-only funds/limits precheck. Never mutates the ledger. */
|
|
2316
|
+
async verify(paymentPayload, requirements) {
|
|
2317
|
+
const payload = extractBalancePayload(paymentPayload);
|
|
2318
|
+
if (!payload) {
|
|
2319
|
+
return { valid: false, error: "Missing buyer_id in balance payment payload" };
|
|
2320
|
+
}
|
|
2321
|
+
let amountSat;
|
|
2322
|
+
try {
|
|
2323
|
+
amountSat = toSat(requirements.amount);
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
return { valid: false, error: err.message };
|
|
2326
|
+
}
|
|
2327
|
+
const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
|
|
2328
|
+
if (!check.success) {
|
|
2329
|
+
return {
|
|
2330
|
+
valid: false,
|
|
2331
|
+
error: this.describeDeductError(check),
|
|
2332
|
+
details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
return { valid: true, details: { balance: fromSat(check.balanceSat) } };
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* The atomic deduction. Idempotent on `request_id`; the returned
|
|
2339
|
+
* `transaction` is the ledger tx id (usable for `refund`).
|
|
2340
|
+
*/
|
|
2341
|
+
async settle(paymentPayload, requirements) {
|
|
2342
|
+
const payload = extractBalancePayload(paymentPayload);
|
|
2343
|
+
if (!payload) {
|
|
2344
|
+
return { success: false, error: "Missing buyer_id in balance payment payload" };
|
|
2345
|
+
}
|
|
2346
|
+
let amountSat;
|
|
2347
|
+
try {
|
|
2348
|
+
amountSat = toSat(requirements.amount);
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
return { success: false, error: err.message };
|
|
2351
|
+
}
|
|
2352
|
+
const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
|
|
2353
|
+
let result;
|
|
2354
|
+
try {
|
|
2355
|
+
result = this.ledger.deduct({
|
|
2356
|
+
buyerId: payload.buyer_id,
|
|
2357
|
+
amountSat,
|
|
2358
|
+
requestId: payload.request_id,
|
|
2359
|
+
service: serviceId
|
|
2360
|
+
});
|
|
2361
|
+
} catch (err) {
|
|
2362
|
+
return { success: false, error: `Ledger deduct failed: ${err.message}` };
|
|
2363
|
+
}
|
|
2364
|
+
if (!result.success) {
|
|
2365
|
+
return { success: false, error: this.describeDeductError(result), status: result.error };
|
|
2366
|
+
}
|
|
2367
|
+
return {
|
|
2368
|
+
success: true,
|
|
2369
|
+
transaction: result.txId,
|
|
2370
|
+
status: result.replayed ? "replayed" : "deducted"
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
/** Reverse a deduct after a downstream failure. Idempotent per deduct. */
|
|
2374
|
+
refund(deductTxId, reason) {
|
|
2375
|
+
return this.ledger.refund(deductTxId, reason);
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Credit a gateway-verified external settlement to a buyer's balance. The
|
|
2379
|
+
* single entry point for every funding path -- WeChat callback, WeChat
|
|
2380
|
+
* polling, and the operator `/balance/topup` endpoint -- so they share one
|
|
2381
|
+
* idempotency boundary: `externalRef` is unique in the ledger, so a replay
|
|
2382
|
+
* credits nothing and returns the original transaction (`replayed: true`).
|
|
2383
|
+
* Callers must pass a gateway-verified `amountSat`, never a client-declared
|
|
2384
|
+
* amount.
|
|
2385
|
+
*/
|
|
2386
|
+
credit(opts) {
|
|
2387
|
+
const result = this.ledger.topup({
|
|
2388
|
+
buyerId: opts.buyerId,
|
|
2389
|
+
amountSat: opts.amountSat,
|
|
2390
|
+
externalRef: opts.externalRef,
|
|
2391
|
+
description: opts.description
|
|
2392
|
+
});
|
|
2393
|
+
return {
|
|
2394
|
+
txId: result.txId,
|
|
2395
|
+
balance: fromSat(result.balanceSat),
|
|
2396
|
+
balanceSat: result.balanceSat,
|
|
2397
|
+
replayed: result.replayed ?? false
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
async healthCheck() {
|
|
2401
|
+
const start = Date.now();
|
|
2402
|
+
try {
|
|
2403
|
+
const ok = this.ledger.integrityOk();
|
|
2404
|
+
return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
return { healthy: false, error: err.message };
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
describeDeductError(result) {
|
|
2410
|
+
switch (result.error) {
|
|
2411
|
+
case "buyer_not_found":
|
|
2412
|
+
return "Unknown buyer: top up first to create an account";
|
|
2413
|
+
case "buyer_not_active":
|
|
2414
|
+
return "Buyer account is frozen or banned";
|
|
2415
|
+
case "insufficient_balance":
|
|
2416
|
+
return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
|
|
2417
|
+
case "exceeds_single_limit":
|
|
2418
|
+
return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
|
|
2419
|
+
case "exceeds_daily_limit":
|
|
2420
|
+
return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
|
|
2421
|
+
default:
|
|
2422
|
+
return "Deduction failed";
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
// src/facilitators/registry.ts
|
|
2428
|
+
import { Keypair as Keypair2 } from "@solana/web3.js";
|
|
2429
|
+
import bs58 from "bs58";
|
|
2430
|
+
var FacilitatorRegistry = class {
|
|
2431
|
+
factories = /* @__PURE__ */ new Map();
|
|
2432
|
+
instances = /* @__PURE__ */ new Map();
|
|
2433
|
+
selection;
|
|
2434
|
+
roundRobinIndex = 0;
|
|
2435
|
+
constructor(selection) {
|
|
2436
|
+
this.registerFactory("cdp", (config) => new CDPFacilitator(config));
|
|
2437
|
+
this.registerFactory("tempo", () => new TempoFacilitator());
|
|
2438
|
+
this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
|
|
2439
|
+
this.registerFactory("solana", (config) => {
|
|
2440
|
+
let feePayerKeypair;
|
|
2441
|
+
const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
|
|
2442
|
+
if (feePayerKey) {
|
|
2443
|
+
try {
|
|
2444
|
+
feePayerKeypair = Keypair2.fromSecretKey(bs58.decode(feePayerKey));
|
|
2445
|
+
} catch (e) {
|
|
2446
|
+
console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
return new SolanaFacilitator({ feePayerKeypair });
|
|
2450
|
+
});
|
|
2451
|
+
this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
|
|
2452
|
+
this.registerFactory("wechat", (config) => new WechatFacilitator(config));
|
|
2453
|
+
this.registerFactory("balance", (config) => new BalanceFacilitator(config));
|
|
2454
|
+
this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* Register a new facilitator factory
|
|
2458
|
+
*/
|
|
2459
|
+
registerFactory(name, factory) {
|
|
2460
|
+
this.factories.set(name, factory);
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Get or create a facilitator instance
|
|
2464
|
+
*/
|
|
2465
|
+
get(name, config) {
|
|
2466
|
+
if (this.instances.has(name)) {
|
|
2467
|
+
return this.instances.get(name);
|
|
2468
|
+
}
|
|
2469
|
+
const factory = this.factories.get(name);
|
|
2470
|
+
if (!factory) {
|
|
2471
|
+
throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
|
|
2472
|
+
}
|
|
2473
|
+
const mergedConfig = {
|
|
2474
|
+
...this.selection.config?.[name],
|
|
2475
|
+
...config
|
|
2476
|
+
};
|
|
2477
|
+
const instance = factory(mergedConfig);
|
|
2478
|
+
this.instances.set(name, instance);
|
|
2479
|
+
return instance;
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* Get all configured facilitator names
|
|
2483
|
+
*/
|
|
2484
|
+
getConfiguredNames() {
|
|
2485
|
+
const names = [this.selection.primary];
|
|
2486
|
+
if (this.selection.fallback) {
|
|
2487
|
+
names.push(...this.selection.fallback);
|
|
2488
|
+
}
|
|
2489
|
+
return names;
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Get list of facilitators based on selection strategy
|
|
2493
|
+
*/
|
|
2494
|
+
async getOrderedFacilitators(network) {
|
|
2495
|
+
const names = this.getConfiguredNames();
|
|
2496
|
+
const facilitators = [];
|
|
2497
|
+
for (const name of names) {
|
|
2498
|
+
try {
|
|
2499
|
+
const f = this.get(name);
|
|
2500
|
+
if (f.supportsNetwork(network)) {
|
|
2501
|
+
facilitators.push(f);
|
|
2502
|
+
}
|
|
2503
|
+
} catch (err) {
|
|
2504
|
+
console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
if (facilitators.length === 0) {
|
|
2508
|
+
throw new Error(`No facilitators available for network: ${network}`);
|
|
2509
|
+
}
|
|
2510
|
+
switch (this.selection.strategy) {
|
|
2511
|
+
case "random":
|
|
2512
|
+
return this.shuffle(facilitators);
|
|
2513
|
+
case "roundrobin":
|
|
2514
|
+
this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
|
|
2515
|
+
return [
|
|
2516
|
+
...facilitators.slice(this.roundRobinIndex),
|
|
2517
|
+
...facilitators.slice(0, this.roundRobinIndex)
|
|
2518
|
+
];
|
|
2519
|
+
case "cheapest":
|
|
2520
|
+
return this.sortByCheapest(facilitators);
|
|
2521
|
+
case "fastest":
|
|
2522
|
+
return this.sortByFastest(facilitators);
|
|
2523
|
+
case "failover":
|
|
2524
|
+
default:
|
|
2525
|
+
return facilitators;
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
shuffle(array) {
|
|
2529
|
+
const result = [...array];
|
|
2530
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
2531
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2532
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
2533
|
+
}
|
|
2534
|
+
return result;
|
|
2535
|
+
}
|
|
2536
|
+
async sortByCheapest(facilitators) {
|
|
2537
|
+
const withFees = await Promise.all(
|
|
2538
|
+
facilitators.map(async (f) => {
|
|
2539
|
+
try {
|
|
2540
|
+
const fee = await f.getFee?.();
|
|
2541
|
+
return { facilitator: f, perTx: fee?.perTx ?? Infinity };
|
|
2542
|
+
} catch {
|
|
2543
|
+
return { facilitator: f, perTx: Infinity };
|
|
2544
|
+
}
|
|
2545
|
+
})
|
|
2546
|
+
);
|
|
2547
|
+
withFees.sort((a, b) => a.perTx - b.perTx);
|
|
2548
|
+
return withFees.map((w) => w.facilitator);
|
|
2549
|
+
}
|
|
2550
|
+
async sortByFastest(facilitators) {
|
|
2551
|
+
const withLatency = await Promise.all(
|
|
2552
|
+
facilitators.map(async (f) => {
|
|
2553
|
+
try {
|
|
2554
|
+
const health = await f.healthCheck();
|
|
2555
|
+
return { facilitator: f, latency: health.latencyMs ?? Infinity };
|
|
2556
|
+
} catch {
|
|
2557
|
+
return { facilitator: f, latency: Infinity };
|
|
2558
|
+
}
|
|
2559
|
+
})
|
|
2560
|
+
);
|
|
2561
|
+
withLatency.sort((a, b) => a.latency - b.latency);
|
|
2562
|
+
return withLatency.map((w) => w.facilitator);
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Verify payment using configured facilitators
|
|
2566
|
+
*/
|
|
2567
|
+
async verify(paymentPayload, requirements) {
|
|
2568
|
+
const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
|
|
2569
|
+
const facilitators = await this.getOrderedFacilitators(network);
|
|
2570
|
+
let lastError;
|
|
2571
|
+
for (const f of facilitators) {
|
|
2572
|
+
try {
|
|
2573
|
+
console.log(`[Registry] Trying ${f.name} for verify...`);
|
|
2574
|
+
const result = await f.verify(paymentPayload, requirements);
|
|
2575
|
+
if (result.valid) {
|
|
2576
|
+
console.log(`[Registry] ${f.name} verify succeeded`);
|
|
2577
|
+
return { ...result, facilitator: f.name };
|
|
2578
|
+
}
|
|
2579
|
+
lastError = result.error;
|
|
2580
|
+
console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
|
|
2581
|
+
if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2584
|
+
} catch (err) {
|
|
2585
|
+
lastError = err.message;
|
|
2586
|
+
console.error(`[Registry] ${f.name} error:`, err.message);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
return {
|
|
1322
2590
|
valid: false,
|
|
1323
2591
|
error: lastError || "All facilitators failed",
|
|
1324
2592
|
facilitator: "none"
|
|
@@ -1399,7 +2667,95 @@ var FacilitatorRegistry = class {
|
|
|
1399
2667
|
}
|
|
1400
2668
|
};
|
|
1401
2669
|
|
|
1402
|
-
// src/server/
|
|
2670
|
+
// src/server/balance-endpoints.ts
|
|
2671
|
+
import crypto5 from "crypto";
|
|
2672
|
+
|
|
2673
|
+
// src/verify/index.ts
|
|
2674
|
+
import { ethers as ethers3 } from "ethers";
|
|
2675
|
+
var TRANSFER_EVENT_TOPIC3 = ethers3.id("Transfer(address,address,uint256)");
|
|
2676
|
+
async function verifyPayment(params) {
|
|
2677
|
+
const { txHash, expectedAmount, expectedTo, expectedToken } = params;
|
|
2678
|
+
let chain;
|
|
2679
|
+
try {
|
|
2680
|
+
if (typeof params.chain === "number") {
|
|
2681
|
+
chain = getChainById(params.chain);
|
|
2682
|
+
} else {
|
|
2683
|
+
chain = getChain(params.chain || "base");
|
|
2684
|
+
}
|
|
2685
|
+
if (!chain) {
|
|
2686
|
+
return { verified: false, error: `Unsupported chain: ${params.chain}` };
|
|
2687
|
+
}
|
|
2688
|
+
} catch (e) {
|
|
2689
|
+
return { verified: false, error: `Unsupported chain: ${params.chain}` };
|
|
2690
|
+
}
|
|
2691
|
+
try {
|
|
2692
|
+
const provider = new ethers3.JsonRpcProvider(chain.rpc);
|
|
2693
|
+
const receipt = await provider.getTransactionReceipt(txHash);
|
|
2694
|
+
if (!receipt) {
|
|
2695
|
+
return { verified: false, error: "Transaction not found or not confirmed" };
|
|
2696
|
+
}
|
|
2697
|
+
if (receipt.status !== 1) {
|
|
2698
|
+
return { verified: false, error: "Transaction failed" };
|
|
2699
|
+
}
|
|
2700
|
+
const tokenAddresses = {};
|
|
2701
|
+
if (!expectedToken || expectedToken === "USDC") {
|
|
2702
|
+
tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
|
|
2703
|
+
}
|
|
2704
|
+
if (!expectedToken || expectedToken === "USDT") {
|
|
2705
|
+
tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
|
|
2706
|
+
}
|
|
2707
|
+
if (Object.keys(tokenAddresses).length === 0) {
|
|
2708
|
+
return { verified: false, error: `No token addresses configured for ${chain.name}` };
|
|
2709
|
+
}
|
|
2710
|
+
for (const log of receipt.logs) {
|
|
2711
|
+
const logAddress = log.address.toLowerCase();
|
|
2712
|
+
const detectedToken = tokenAddresses[logAddress];
|
|
2713
|
+
if (!detectedToken) {
|
|
2714
|
+
continue;
|
|
2715
|
+
}
|
|
2716
|
+
if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
const from = "0x" + log.topics[1].slice(-40);
|
|
2720
|
+
const to = "0x" + log.topics[2].slice(-40);
|
|
2721
|
+
const amountRaw = BigInt(log.data);
|
|
2722
|
+
const tokenConfig = chain.tokens[detectedToken];
|
|
2723
|
+
const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
|
|
2724
|
+
if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
if (amount < expectedAmount) {
|
|
2728
|
+
return {
|
|
2729
|
+
verified: false,
|
|
2730
|
+
error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
|
|
2731
|
+
amount,
|
|
2732
|
+
token: detectedToken,
|
|
2733
|
+
from,
|
|
2734
|
+
to,
|
|
2735
|
+
txHash,
|
|
2736
|
+
blockNumber: receipt.blockNumber
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
return {
|
|
2740
|
+
verified: true,
|
|
2741
|
+
amount,
|
|
2742
|
+
token: detectedToken,
|
|
2743
|
+
from,
|
|
2744
|
+
to,
|
|
2745
|
+
txHash,
|
|
2746
|
+
blockNumber: receipt.blockNumber
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
const tokenList = expectedToken ? expectedToken : "USDC/USDT";
|
|
2750
|
+
return { verified: false, error: `No ${tokenList} transfer found` };
|
|
2751
|
+
} catch (e) {
|
|
2752
|
+
return { verified: false, error: e.message || String(e) };
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// src/server/internal.ts
|
|
2757
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
2758
|
+
import * as path2 from "path";
|
|
1403
2759
|
var X402_VERSION2 = 2;
|
|
1404
2760
|
var PAYMENT_REQUIRED_HEADER = "x-payment-required";
|
|
1405
2761
|
var PAYMENT_HEADER = "x-payment";
|
|
@@ -1407,6 +2763,20 @@ var PAYMENT_RESPONSE_HEADER = "x-payment-response";
|
|
|
1407
2763
|
var MPP_AUTH_HEADER = "authorization";
|
|
1408
2764
|
var MPP_WWW_AUTH_HEADER = "www-authenticate";
|
|
1409
2765
|
var MPP_RECEIPT_HEADER = "payment-receipt";
|
|
2766
|
+
var ALIPAY_PAYMENT_NEEDED_HEADER = "payment-needed";
|
|
2767
|
+
var ALIPAY_PAYMENT_PROOF_HEADER = "payment-proof";
|
|
2768
|
+
function headerSafe(value) {
|
|
2769
|
+
return String(value ?? "").replace(/[^\x20-\x7E]/g, (ch) => encodeURIComponent(ch)).replace(/"/g, "%22");
|
|
2770
|
+
}
|
|
2771
|
+
function canonicalJson(value) {
|
|
2772
|
+
if (value === null || typeof value !== "object") {
|
|
2773
|
+
return JSON.stringify(value) ?? "null";
|
|
2774
|
+
}
|
|
2775
|
+
if (Array.isArray(value)) {
|
|
2776
|
+
return "[" + value.map(canonicalJson).join(",") + "]";
|
|
2777
|
+
}
|
|
2778
|
+
return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
|
|
2779
|
+
}
|
|
1410
2780
|
var TOKEN_ADDRESSES = {
|
|
1411
2781
|
"eip155:8453": {
|
|
1412
2782
|
USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
@@ -1534,7 +2904,340 @@ function loadEnvFile2() {
|
|
|
1534
2904
|
}
|
|
1535
2905
|
}
|
|
1536
2906
|
}
|
|
1537
|
-
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// src/server/balance-endpoints.ts
|
|
2910
|
+
var BalanceEndpoints = class {
|
|
2911
|
+
constructor(deps) {
|
|
2912
|
+
this.deps = deps;
|
|
2913
|
+
}
|
|
2914
|
+
/** GET /balance?buyer_id= -- balance, limits, and today's spend. */
|
|
2915
|
+
handleQuery(url, res) {
|
|
2916
|
+
const { balance, sendJson } = this.deps;
|
|
2917
|
+
const buyerId = url.searchParams.get("buyer_id");
|
|
2918
|
+
if (!buyerId) {
|
|
2919
|
+
return sendJson(res, 400, { error: "buyer_id query parameter is required" });
|
|
2920
|
+
}
|
|
2921
|
+
const ledger = balance.getLedger();
|
|
2922
|
+
const buyer = ledger.getBuyer(buyerId);
|
|
2923
|
+
if (!buyer) {
|
|
2924
|
+
return sendJson(res, 200, {
|
|
2925
|
+
buyer_id: buyerId,
|
|
2926
|
+
balance: "0.00",
|
|
2927
|
+
currency: balance.currency,
|
|
2928
|
+
exists: false
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
sendJson(res, 200, {
|
|
2932
|
+
buyer_id: buyerId,
|
|
2933
|
+
balance: fromSat(buyer.balance_sat),
|
|
2934
|
+
currency: balance.currency,
|
|
2935
|
+
single_limit: fromSat(buyer.single_limit_sat),
|
|
2936
|
+
daily_limit: fromSat(buyer.daily_limit_sat),
|
|
2937
|
+
today_spent: fromSat(ledger.spentTodaySat(buyerId)),
|
|
2938
|
+
status: buyer.status,
|
|
2939
|
+
wechat_openid: buyer.wechat_openid ?? null,
|
|
2940
|
+
signer_address: buyer.signer_address ?? null,
|
|
2941
|
+
exists: true
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
/**
|
|
2945
|
+
* Extract the gateway-confirmed paid amount (fen) from a WeChat verify
|
|
2946
|
+
* result. `payer_total` is what the buyer actually paid; falls back to
|
|
2947
|
+
* `total`. Returns null if no usable positive integer is present, so the
|
|
2948
|
+
* client-declared amount can never be trusted for crediting.
|
|
2949
|
+
*/
|
|
2950
|
+
wechatPaidFen(check) {
|
|
2951
|
+
const amount = check.details?.amount;
|
|
2952
|
+
const paid = amount?.payer_total ?? amount?.total;
|
|
2953
|
+
return typeof paid === "number" && Number.isFinite(paid) && paid > 0 ? paid : null;
|
|
2954
|
+
}
|
|
2955
|
+
/**
|
|
2956
|
+
* POST /balance/topup/order -- mint a buyer-bound WeChat Native order for a
|
|
2957
|
+
* configured top-up pack. The buyer_id rides in the WeChat `attach` so the
|
|
2958
|
+
* later confirm/callback credits the correct balance. Reuses the pending
|
|
2959
|
+
* order cache (keyed by buyer_id + pack) so concurrent requests share one
|
|
2960
|
+
* order. Body: `{ buyer_id, pack? }`. Returns `{ code_url, out_trade_no,
|
|
2961
|
+
* pack, max_timeout_seconds }`.
|
|
2962
|
+
*/
|
|
2963
|
+
async handleTopupOrder(body, res) {
|
|
2964
|
+
const { manifest, wechat, sendJson, getOrCreatePendingWechatOrder } = this.deps;
|
|
2965
|
+
const { buyer_id, pack } = body || {};
|
|
2966
|
+
if (typeof buyer_id !== "string" || !buyer_id) {
|
|
2967
|
+
return sendJson(res, 400, { error: "buyer_id is required" });
|
|
2968
|
+
}
|
|
2969
|
+
const signerAddress = typeof body?.signer_address === "string" && /^0x[0-9a-fA-F]{40}$/.test(body.signer_address) ? body.signer_address.toLowerCase() : void 0;
|
|
2970
|
+
if (!wechat) {
|
|
2971
|
+
return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
|
|
2972
|
+
}
|
|
2973
|
+
const balCfg = manifest.provider.balance;
|
|
2974
|
+
const packs = balCfg?.topup_packs ?? [];
|
|
2975
|
+
const chosen = typeof pack === "string" && pack ? pack : balCfg?.default_pack;
|
|
2976
|
+
if (!chosen) {
|
|
2977
|
+
return sendJson(res, 400, { error: "no pack specified and no default_pack configured" });
|
|
2978
|
+
}
|
|
2979
|
+
let chosenSat;
|
|
2980
|
+
try {
|
|
2981
|
+
chosenSat = toSat(chosen);
|
|
2982
|
+
if (chosenSat <= 0) throw new Error("pack must be positive");
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
return sendJson(res, 400, { error: `Invalid pack: ${err.message}` });
|
|
2985
|
+
}
|
|
2986
|
+
const withinMax = balCfg?.auto_topup_max ? chosenSat <= toSat(balCfg.auto_topup_max) : false;
|
|
2987
|
+
if (!packs.includes(chosen) && !withinMax) {
|
|
2988
|
+
return sendJson(res, 400, {
|
|
2989
|
+
error: `pack "${chosen}" is not an offered top-up pack and exceeds auto_topup_max`
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
const nonce = crypto5.randomBytes(8).toString("hex");
|
|
2993
|
+
const attach = signerAddress ? { buyer_id, signer: signerAddress } : { buyer_id, nonce };
|
|
2994
|
+
const cacheKey = crypto5.createHash("sha256").update(`topup|${buyer_id}|${chosen}|${signerAddress ?? ""}`).digest("hex");
|
|
2995
|
+
const result = await getOrCreatePendingWechatOrder(
|
|
2996
|
+
cacheKey,
|
|
2997
|
+
`topup:${buyer_id}`,
|
|
2998
|
+
() => wechat.createPaymentRequirements({
|
|
2999
|
+
priceCny: chosen,
|
|
3000
|
+
description: `Balance top-up ${chosen}`,
|
|
3001
|
+
attach
|
|
3002
|
+
})
|
|
3003
|
+
);
|
|
3004
|
+
if (!result) {
|
|
3005
|
+
return sendJson(res, 502, { error: "failed to create WeChat top-up order" });
|
|
3006
|
+
}
|
|
3007
|
+
return sendJson(res, 200, {
|
|
3008
|
+
code_url: result.codeUrl,
|
|
3009
|
+
out_trade_no: result.outTradeNo,
|
|
3010
|
+
pack: chosen,
|
|
3011
|
+
max_timeout_seconds: result.accepts.maxTimeoutSeconds
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* POST /balance/topup/confirm -- the polling-fallback credit path. The client
|
|
3016
|
+
* polls this after a scan; the server verifies the order with the WeChat
|
|
3017
|
+
* gateway and, on SUCCESS, credits the buyer bound in `attach` with the
|
|
3018
|
+
* gateway-confirmed `payer_total` (never a client-declared amount).
|
|
3019
|
+
* Idempotent on `wechat:<out_trade_no>`. Body: `{ out_trade_no }`.
|
|
3020
|
+
*/
|
|
3021
|
+
async handleTopupConfirm(body, res) {
|
|
3022
|
+
const { manifest, balance, wechat, sendJson, invalidateWechatChallenge } = this.deps;
|
|
3023
|
+
const outTradeNo = body?.out_trade_no;
|
|
3024
|
+
if (typeof outTradeNo !== "string" || !outTradeNo) {
|
|
3025
|
+
return sendJson(res, 400, { error: "out_trade_no is required" });
|
|
3026
|
+
}
|
|
3027
|
+
if (!wechat) {
|
|
3028
|
+
return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
|
|
3029
|
+
}
|
|
3030
|
+
const wxPayload = {
|
|
3031
|
+
x402Version: X402_VERSION2,
|
|
3032
|
+
scheme: WECHAT_SCHEME,
|
|
3033
|
+
network: WECHAT_NETWORK,
|
|
3034
|
+
payload: { out_trade_no: outTradeNo }
|
|
3035
|
+
};
|
|
3036
|
+
const wxReqs = {
|
|
3037
|
+
scheme: WECHAT_SCHEME,
|
|
3038
|
+
network: WECHAT_NETWORK,
|
|
3039
|
+
asset: "CNY",
|
|
3040
|
+
amount: "0",
|
|
3041
|
+
payTo: manifest.provider.wechat?.mchid || "",
|
|
3042
|
+
maxTimeoutSeconds: 30,
|
|
3043
|
+
extra: { out_trade_no: outTradeNo }
|
|
3044
|
+
};
|
|
3045
|
+
const check = await wechat.verify(wxPayload, wxReqs);
|
|
3046
|
+
if (!check.valid) {
|
|
3047
|
+
return sendJson(res, 200, { credited: false, pending: true, reason: check.error });
|
|
3048
|
+
}
|
|
3049
|
+
const paidFen = this.wechatPaidFen(check);
|
|
3050
|
+
if (paidFen === null) {
|
|
3051
|
+
return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
|
|
3052
|
+
}
|
|
3053
|
+
const attach = parseWechatAttach(check.details?.attach);
|
|
3054
|
+
const buyerId = attach?.buyer_id;
|
|
3055
|
+
if (!buyerId) {
|
|
3056
|
+
return sendJson(res, 422, {
|
|
3057
|
+
error: "top-up order has no buyer binding (attach.buyer_id missing)"
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
const credited = balance.credit({
|
|
3061
|
+
buyerId,
|
|
3062
|
+
amountSat: paidFen,
|
|
3063
|
+
externalRef: `wechat:${outTradeNo}`,
|
|
3064
|
+
description: `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`
|
|
3065
|
+
});
|
|
3066
|
+
invalidateWechatChallenge(outTradeNo);
|
|
3067
|
+
const openid = check.details?.openid;
|
|
3068
|
+
let openidBinding;
|
|
3069
|
+
if (typeof openid === "string" && openid) {
|
|
3070
|
+
openidBinding = balance.getLedger().bindOpenid(buyerId, openid);
|
|
3071
|
+
if (openidBinding.conflict) {
|
|
3072
|
+
console.warn(
|
|
3073
|
+
`[MoltsPay] Balance top-up openid conflict for buyer=${buyerId}: already bound to ${openidBinding.existing}, this payment from ${openid} \u2014 recorded, not enforced`
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
const signer = attach?.signer;
|
|
3078
|
+
if (typeof signer === "string" && /^0x[0-9a-fA-F]{40}$/.test(signer)) {
|
|
3079
|
+
const sb = balance.getLedger().bindSigner(buyerId, signer);
|
|
3080
|
+
if (sb.conflict) {
|
|
3081
|
+
console.warn(
|
|
3082
|
+
`[MoltsPay] Balance top-up signer conflict for buyer=${buyerId}: already bound to ${sb.existing}, this order signed for ${signer.toLowerCase()} \u2014 recorded, not enforced`
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
console.log(
|
|
3087
|
+
`[MoltsPay] Balance top-up credited buyer=${buyerId} +${fromSat(paidFen)} (${outTradeNo})${credited.replayed ? " [replayed]" : ""}${typeof openid === "string" && openid ? ` openid=${openid}${openidBinding?.conflict ? " [CONFLICT]" : ""}` : ""}`
|
|
3088
|
+
);
|
|
3089
|
+
return sendJson(res, 200, {
|
|
3090
|
+
credited: true,
|
|
3091
|
+
buyer_id: buyerId,
|
|
3092
|
+
tx_id: credited.txId,
|
|
3093
|
+
balance: credited.balance,
|
|
3094
|
+
replayed: credited.replayed,
|
|
3095
|
+
openid_bound: openidBinding?.bound ?? false,
|
|
3096
|
+
openid_conflict: openidBinding?.conflict ?? false
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* POST /balance/topup -- verify an externally settled payment and credit the
|
|
3101
|
+
* buyer's ledger balance (operator/recovery path). Per rail: `crypto`
|
|
3102
|
+
* verifies the tx on-chain; `wechat` queries the order and credits the
|
|
3103
|
+
* gateway-confirmed payer_total (never the client-declared amount);
|
|
3104
|
+
* `alipay` is operator-trusted in this MVP. Idempotent on the external ref.
|
|
3105
|
+
*/
|
|
3106
|
+
async handleTopup(body, res) {
|
|
3107
|
+
const { manifest, balance, wechat, sendJson } = this.deps;
|
|
3108
|
+
const { buyer_id, rail, amount } = body || {};
|
|
3109
|
+
if (typeof buyer_id !== "string" || !buyer_id) {
|
|
3110
|
+
return sendJson(res, 400, { error: "buyer_id is required" });
|
|
3111
|
+
}
|
|
3112
|
+
let amountSat;
|
|
3113
|
+
try {
|
|
3114
|
+
amountSat = toSat(String(amount));
|
|
3115
|
+
if (amountSat <= 0) throw new Error("amount must be positive");
|
|
3116
|
+
} catch (err) {
|
|
3117
|
+
return sendJson(res, 400, { error: `Invalid amount: ${err.message}` });
|
|
3118
|
+
}
|
|
3119
|
+
let externalRef;
|
|
3120
|
+
let description;
|
|
3121
|
+
if (rail === "crypto") {
|
|
3122
|
+
const txHash = body.tx_hash;
|
|
3123
|
+
if (typeof txHash !== "string" || !txHash) {
|
|
3124
|
+
return sendJson(res, 400, { error: 'tx_hash is required for rail "crypto"' });
|
|
3125
|
+
}
|
|
3126
|
+
const check = await verifyPayment({
|
|
3127
|
+
txHash,
|
|
3128
|
+
expectedAmount: amountSat / 100,
|
|
3129
|
+
expectedTo: manifest.provider.wallet,
|
|
3130
|
+
chain: body.chain || "base"
|
|
3131
|
+
});
|
|
3132
|
+
if (!check.verified) {
|
|
3133
|
+
return sendJson(res, 402, { error: `On-chain verification failed: ${check.error}` });
|
|
3134
|
+
}
|
|
3135
|
+
externalRef = txHash.toLowerCase();
|
|
3136
|
+
description = `crypto topup ${check.amount} ${check.token} on ${body.chain || "base"}`;
|
|
3137
|
+
} else if (rail === "wechat") {
|
|
3138
|
+
const outTradeNo = body.out_trade_no;
|
|
3139
|
+
if (typeof outTradeNo !== "string" || !outTradeNo) {
|
|
3140
|
+
return sendJson(res, 400, { error: 'out_trade_no is required for rail "wechat"' });
|
|
3141
|
+
}
|
|
3142
|
+
if (!wechat) {
|
|
3143
|
+
return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
|
|
3144
|
+
}
|
|
3145
|
+
const wxPayload = {
|
|
3146
|
+
x402Version: X402_VERSION2,
|
|
3147
|
+
scheme: WECHAT_SCHEME,
|
|
3148
|
+
network: WECHAT_NETWORK,
|
|
3149
|
+
payload: { out_trade_no: outTradeNo }
|
|
3150
|
+
};
|
|
3151
|
+
const wxReqs = {
|
|
3152
|
+
scheme: WECHAT_SCHEME,
|
|
3153
|
+
network: WECHAT_NETWORK,
|
|
3154
|
+
asset: "CNY",
|
|
3155
|
+
amount: "0",
|
|
3156
|
+
payTo: manifest.provider.wechat?.mchid || "",
|
|
3157
|
+
maxTimeoutSeconds: 30,
|
|
3158
|
+
extra: { out_trade_no: outTradeNo }
|
|
3159
|
+
};
|
|
3160
|
+
const check = await wechat.verify(wxPayload, wxReqs);
|
|
3161
|
+
if (!check.valid) {
|
|
3162
|
+
return sendJson(res, 402, { error: `WeChat order verification failed: ${check.error}` });
|
|
3163
|
+
}
|
|
3164
|
+
const paidFen = this.wechatPaidFen(check);
|
|
3165
|
+
if (paidFen === null) {
|
|
3166
|
+
return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
|
|
3167
|
+
}
|
|
3168
|
+
if (paidFen !== amountSat) {
|
|
3169
|
+
console.warn(
|
|
3170
|
+
`[MoltsPay] WeChat topup amount mismatch for ${outTradeNo}: client declared ${fromSat(amountSat)}, gateway confirms ${fromSat(paidFen)} paid -- crediting the verified amount`
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
3173
|
+
amountSat = paidFen;
|
|
3174
|
+
externalRef = `wechat:${outTradeNo}`;
|
|
3175
|
+
description = `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`;
|
|
3176
|
+
console.log(`[MoltsPay] WeChat topup verified: ${description}`);
|
|
3177
|
+
} else if (rail === "alipay") {
|
|
3178
|
+
const tradeNo = body.trade_no;
|
|
3179
|
+
if (typeof tradeNo !== "string" || !tradeNo) {
|
|
3180
|
+
return sendJson(res, 400, { error: 'trade_no is required for rail "alipay"' });
|
|
3181
|
+
}
|
|
3182
|
+
externalRef = `alipay:${tradeNo}`;
|
|
3183
|
+
description = `alipay topup trade_no=${tradeNo} (operator-confirmed, unverified)`;
|
|
3184
|
+
console.warn(`[MoltsPay] Alipay topup credited without gateway verification: ${tradeNo}`);
|
|
3185
|
+
} else {
|
|
3186
|
+
return sendJson(res, 400, { error: 'rail must be one of "crypto" | "alipay" | "wechat"' });
|
|
3187
|
+
}
|
|
3188
|
+
const result = balance.getLedger().topup({ buyerId: buyer_id, amountSat, externalRef, description });
|
|
3189
|
+
sendJson(res, 200, {
|
|
3190
|
+
success: true,
|
|
3191
|
+
tx_id: result.txId,
|
|
3192
|
+
balance: fromSat(result.balanceSat),
|
|
3193
|
+
replayed: result.replayed ?? false
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
/** POST /balance/refund -- reverse a deduct (operator/agent use). */
|
|
3197
|
+
handleRefund(body, res) {
|
|
3198
|
+
const { balance, sendJson } = this.deps;
|
|
3199
|
+
const { tx_id, reason } = body || {};
|
|
3200
|
+
if (typeof tx_id !== "string" || !tx_id) {
|
|
3201
|
+
return sendJson(res, 400, { error: "tx_id is required" });
|
|
3202
|
+
}
|
|
3203
|
+
const result = balance.refund(tx_id, typeof reason === "string" ? reason : void 0);
|
|
3204
|
+
if (!result.success) {
|
|
3205
|
+
return sendJson(res, result.error === "tx_not_found" ? 404 : 400, { error: result.error });
|
|
3206
|
+
}
|
|
3207
|
+
sendJson(res, 200, {
|
|
3208
|
+
success: true,
|
|
3209
|
+
tx_id: result.txId,
|
|
3210
|
+
balance: fromSat(result.balanceSat),
|
|
3211
|
+
replayed: result.replayed ?? false
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3214
|
+
/** GET /balance/transactions?buyer_id=&limit=&offset= -- history, newest first. */
|
|
3215
|
+
handleTransactions(url, res) {
|
|
3216
|
+
const { balance, sendJson } = this.deps;
|
|
3217
|
+
const buyerId = url.searchParams.get("buyer_id");
|
|
3218
|
+
if (!buyerId) {
|
|
3219
|
+
return sendJson(res, 400, { error: "buyer_id query parameter is required" });
|
|
3220
|
+
}
|
|
3221
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10) || 20, 100);
|
|
3222
|
+
const offset = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
|
|
3223
|
+
const rows = balance.getLedger().listTransactions(buyerId, limit, offset);
|
|
3224
|
+
sendJson(res, 200, {
|
|
3225
|
+
buyer_id: buyerId,
|
|
3226
|
+
transactions: rows.map((r) => ({
|
|
3227
|
+
tx_id: r.id,
|
|
3228
|
+
type: r.type,
|
|
3229
|
+
amount: fromSat(r.amount_sat),
|
|
3230
|
+
service: r.service,
|
|
3231
|
+
description: r.description,
|
|
3232
|
+
external_ref: r.external_ref,
|
|
3233
|
+
status: r.status,
|
|
3234
|
+
created_at: r.created_at
|
|
3235
|
+
}))
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
|
|
3240
|
+
// src/server/index.ts
|
|
1538
3241
|
var MoltsPayServer = class {
|
|
1539
3242
|
manifest;
|
|
1540
3243
|
skills = /* @__PURE__ */ new Map();
|
|
@@ -1542,9 +3245,32 @@ var MoltsPayServer = class {
|
|
|
1542
3245
|
registry;
|
|
1543
3246
|
networkId;
|
|
1544
3247
|
useMainnet;
|
|
3248
|
+
/** Alipay AI Pay facilitator instance, set when `provider.alipay` is configured (2.0.0). */
|
|
3249
|
+
alipayFacilitator = null;
|
|
3250
|
+
/** WeChat Pay Native facilitator instance, set when `provider.wechat` is configured (2.1.0). */
|
|
3251
|
+
wechatFacilitator = null;
|
|
3252
|
+
/** Custodial balance facilitator instance, set when `provider.balance` is configured (2.2.0). */
|
|
3253
|
+
balanceFacilitator = null;
|
|
3254
|
+
balanceEndpoints = null;
|
|
3255
|
+
/**
|
|
3256
|
+
* Pending WeChat Native order cache — the double-charge fix.
|
|
3257
|
+
*
|
|
3258
|
+
* Every `buildWechatChallenge` used to place a NEW Native order, so a client
|
|
3259
|
+
* that received two 402s (e.g. initial challenge + one poll re-request that
|
|
3260
|
+
* raced ahead of payment) could surface two live QRs and a buyer could pay
|
|
3261
|
+
* both (confirmed ¥0.07×2 on 2026-07-02). Now the unpaid order is cached
|
|
3262
|
+
* under a content-derived key — sha256(service id | canonical params |
|
|
3263
|
+
* price_cny) — and reused until it is paid or its `time_expire` window
|
|
3264
|
+
* nears expiry, so any number of 402 emits for the same purchase intent
|
|
3265
|
+
* share ONE order, even across separate client processes.
|
|
3266
|
+
* Storing the in-flight promise also dedupes concurrent 402 builds.
|
|
3267
|
+
* In-memory by design: on restart the worst case is one extra unpaid order,
|
|
3268
|
+
* which expires server-side per `time_expire` — never a double charge.
|
|
3269
|
+
*/
|
|
3270
|
+
wechatPendingChallenges = /* @__PURE__ */ new Map();
|
|
1545
3271
|
constructor(servicesPath, options = {}) {
|
|
1546
3272
|
loadEnvFile2();
|
|
1547
|
-
const content =
|
|
3273
|
+
const content = readFileSync3(servicesPath, "utf-8");
|
|
1548
3274
|
this.manifest = JSON.parse(content);
|
|
1549
3275
|
this.options = {
|
|
1550
3276
|
port: options.port || 3e3,
|
|
@@ -1563,7 +3289,101 @@ var MoltsPayServer = class {
|
|
|
1563
3289
|
cdp: { useMainnet: this.useMainnet }
|
|
1564
3290
|
}
|
|
1565
3291
|
};
|
|
3292
|
+
const providerAlipay = this.manifest.provider.alipay;
|
|
3293
|
+
if (providerAlipay) {
|
|
3294
|
+
try {
|
|
3295
|
+
const baseDir = path3.dirname(servicesPath);
|
|
3296
|
+
const resolvePem = (p, kind) => toPem(readFileSync3(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8"), kind);
|
|
3297
|
+
const alipayFacilitatorConfig = {
|
|
3298
|
+
seller_id: providerAlipay.seller_id,
|
|
3299
|
+
app_id: providerAlipay.app_id,
|
|
3300
|
+
seller_name: providerAlipay.seller_name,
|
|
3301
|
+
service_id_default: providerAlipay.service_id_default,
|
|
3302
|
+
private_key_pem: resolvePem(providerAlipay.private_key_path, "PRIVATE"),
|
|
3303
|
+
alipay_public_key_pem: resolvePem(providerAlipay.alipay_public_key_path, "PUBLIC"),
|
|
3304
|
+
gateway_url: providerAlipay.gateway_url,
|
|
3305
|
+
sign_type: providerAlipay.sign_type
|
|
3306
|
+
};
|
|
3307
|
+
facilitatorConfig.config = {
|
|
3308
|
+
...facilitatorConfig.config,
|
|
3309
|
+
alipay: alipayFacilitatorConfig
|
|
3310
|
+
};
|
|
3311
|
+
facilitatorConfig.fallback = facilitatorConfig.fallback || [];
|
|
3312
|
+
if (facilitatorConfig.primary !== "alipay" && !facilitatorConfig.fallback.includes("alipay")) {
|
|
3313
|
+
facilitatorConfig.fallback.push("alipay");
|
|
3314
|
+
}
|
|
3315
|
+
} catch (err) {
|
|
3316
|
+
throw new Error(`[MoltsPay] Alipay rail configured but key load failed: ${err.message}`);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
const providerWechat = this.manifest.provider.wechat;
|
|
3320
|
+
if (providerWechat) {
|
|
3321
|
+
try {
|
|
3322
|
+
const baseDir = path3.dirname(servicesPath);
|
|
3323
|
+
const readPem = (p) => readFileSync3(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8");
|
|
3324
|
+
const toPublicKeyPem = (pem) => pem.includes("BEGIN CERTIFICATE") ? new crypto6.X509Certificate(pem).publicKey.export({ type: "spki", format: "pem" }).toString() : pem;
|
|
3325
|
+
const wechatFacilitatorConfig = {
|
|
3326
|
+
mchid: providerWechat.mchid,
|
|
3327
|
+
appid: providerWechat.appid,
|
|
3328
|
+
serial_no: providerWechat.serial_no,
|
|
3329
|
+
private_key_pem: readPem(providerWechat.private_key_path),
|
|
3330
|
+
platform_public_key_pem: providerWechat.platform_public_key_path ? toPublicKeyPem(readPem(providerWechat.platform_public_key_path)) : void 0,
|
|
3331
|
+
apiv3_key: providerWechat.apiv3_key,
|
|
3332
|
+
notify_url: providerWechat.notify_url,
|
|
3333
|
+
api_base: providerWechat.api_base
|
|
3334
|
+
};
|
|
3335
|
+
facilitatorConfig.config = {
|
|
3336
|
+
...facilitatorConfig.config,
|
|
3337
|
+
wechat: wechatFacilitatorConfig
|
|
3338
|
+
};
|
|
3339
|
+
facilitatorConfig.fallback = facilitatorConfig.fallback || [];
|
|
3340
|
+
if (facilitatorConfig.primary !== "wechat" && !facilitatorConfig.fallback.includes("wechat")) {
|
|
3341
|
+
facilitatorConfig.fallback.push("wechat");
|
|
3342
|
+
}
|
|
3343
|
+
} catch (err) {
|
|
3344
|
+
throw new Error(`[MoltsPay] WeChat rail configured but key load failed: ${err.message}`);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
const providerBalance = this.manifest.provider.balance;
|
|
3348
|
+
if (providerBalance) {
|
|
3349
|
+
const baseDir = path3.dirname(servicesPath);
|
|
3350
|
+
const balanceFacilitatorConfig = {
|
|
3351
|
+
db_path: providerBalance.db_path === ":memory:" ? providerBalance.db_path : path3.isAbsolute(providerBalance.db_path) ? providerBalance.db_path : path3.resolve(baseDir, providerBalance.db_path),
|
|
3352
|
+
currency: providerBalance.currency,
|
|
3353
|
+
single_limit: providerBalance.single_limit,
|
|
3354
|
+
daily_limit: providerBalance.daily_limit,
|
|
3355
|
+
auth_mode: providerBalance.auth_mode
|
|
3356
|
+
};
|
|
3357
|
+
facilitatorConfig.config = {
|
|
3358
|
+
...facilitatorConfig.config,
|
|
3359
|
+
balance: balanceFacilitatorConfig
|
|
3360
|
+
};
|
|
3361
|
+
facilitatorConfig.fallback = facilitatorConfig.fallback || [];
|
|
3362
|
+
if (facilitatorConfig.primary !== "balance" && !facilitatorConfig.fallback.includes("balance")) {
|
|
3363
|
+
facilitatorConfig.fallback.push("balance");
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
1566
3366
|
this.registry = new FacilitatorRegistry(facilitatorConfig);
|
|
3367
|
+
if (providerAlipay) {
|
|
3368
|
+
this.alipayFacilitator = this.registry.get("alipay");
|
|
3369
|
+
console.log(`[MoltsPay] Alipay AI Pay rail enabled (seller ${providerAlipay.seller_id})`);
|
|
3370
|
+
}
|
|
3371
|
+
if (providerWechat) {
|
|
3372
|
+
this.wechatFacilitator = this.registry.get("wechat");
|
|
3373
|
+
console.log(`[MoltsPay] WeChat Pay rail enabled (mchid ${providerWechat.mchid})`);
|
|
3374
|
+
}
|
|
3375
|
+
if (providerBalance) {
|
|
3376
|
+
this.balanceFacilitator = this.registry.get("balance");
|
|
3377
|
+
this.balanceEndpoints = new BalanceEndpoints({
|
|
3378
|
+
manifest: this.manifest,
|
|
3379
|
+
balance: this.balanceFacilitator,
|
|
3380
|
+
wechat: this.wechatFacilitator,
|
|
3381
|
+
sendJson: (res, status, data) => this.sendJson(res, status, data),
|
|
3382
|
+
getOrCreatePendingWechatOrder: (cacheKey, logLabel, create) => this.getOrCreatePendingWechatOrder(cacheKey, logLabel, create),
|
|
3383
|
+
invalidateWechatChallenge: (outTradeNo) => this.invalidateWechatChallenge(outTradeNo)
|
|
3384
|
+
});
|
|
3385
|
+
console.log(`[MoltsPay] Custodial balance rail enabled (ledger ${providerBalance.db_path})`);
|
|
3386
|
+
}
|
|
1567
3387
|
const primaryFacilitator = this.registry.get(facilitatorConfig.primary);
|
|
1568
3388
|
console.log(`[MoltsPay] Loaded ${this.manifest.services.length} services from ${servicesPath}`);
|
|
1569
3389
|
console.log(`[MoltsPay] Provider: ${this.manifest.provider.name}`);
|
|
@@ -1604,7 +3424,10 @@ var MoltsPayServer = class {
|
|
|
1604
3424
|
return provider.wallet;
|
|
1605
3425
|
};
|
|
1606
3426
|
if (provider.chains && provider.chains.length > 0) {
|
|
1607
|
-
return provider.chains.
|
|
3427
|
+
return provider.chains.filter((c) => {
|
|
3428
|
+
const chainName = typeof c === "string" ? c : c.chain;
|
|
3429
|
+
return !isAlipayChainId(chainName) && !isWechatChainId(chainName) && !isBalanceChainId(chainName);
|
|
3430
|
+
}).map((c) => {
|
|
1608
3431
|
const chainName = typeof c === "string" ? c : c.chain;
|
|
1609
3432
|
const explicitWallet = typeof c === "object" ? c.wallet : null;
|
|
1610
3433
|
return {
|
|
@@ -1699,10 +3522,10 @@ var MoltsPayServer = class {
|
|
|
1699
3522
|
writeCorsHeaders(res, origin) {
|
|
1700
3523
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1701
3524
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1702
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Payment, Authorization");
|
|
3525
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Payment, Authorization, Payment-Proof");
|
|
1703
3526
|
res.setHeader(
|
|
1704
3527
|
"Access-Control-Expose-Headers",
|
|
1705
|
-
"X-Payment-Required, X-Payment-Response, WWW-Authenticate, Payment-Receipt"
|
|
3528
|
+
"X-Payment-Required, X-Payment-Response, WWW-Authenticate, Payment-Receipt, Payment-Needed"
|
|
1706
3529
|
);
|
|
1707
3530
|
}
|
|
1708
3531
|
/**
|
|
@@ -1723,13 +3546,41 @@ var MoltsPayServer = class {
|
|
|
1723
3546
|
if (url.pathname === "/.well-known/agent-services.json" && req.method === "GET") {
|
|
1724
3547
|
return this.handleAgentServicesDiscovery(res);
|
|
1725
3548
|
}
|
|
3549
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
3550
|
+
return this.handleAgentServicesDiscovery(res);
|
|
3551
|
+
}
|
|
1726
3552
|
if (url.pathname === "/health" && req.method === "GET") {
|
|
1727
3553
|
return await this.handleHealthCheck(res);
|
|
1728
3554
|
}
|
|
3555
|
+
if (url.pathname.startsWith("/balance") && this.balanceEndpoints) {
|
|
3556
|
+
if (url.pathname === "/balance" && req.method === "GET") {
|
|
3557
|
+
return this.balanceEndpoints.handleQuery(url, res);
|
|
3558
|
+
}
|
|
3559
|
+
if (url.pathname === "/balance/topup/order" && req.method === "POST") {
|
|
3560
|
+
const body = await this.readBody(req);
|
|
3561
|
+
return await this.balanceEndpoints.handleTopupOrder(body, res);
|
|
3562
|
+
}
|
|
3563
|
+
if (url.pathname === "/balance/topup/confirm" && req.method === "POST") {
|
|
3564
|
+
const body = await this.readBody(req);
|
|
3565
|
+
return await this.balanceEndpoints.handleTopupConfirm(body, res);
|
|
3566
|
+
}
|
|
3567
|
+
if (url.pathname === "/balance/topup" && req.method === "POST") {
|
|
3568
|
+
const body = await this.readBody(req);
|
|
3569
|
+
return await this.balanceEndpoints.handleTopup(body, res);
|
|
3570
|
+
}
|
|
3571
|
+
if (url.pathname === "/balance/refund" && req.method === "POST") {
|
|
3572
|
+
const body = await this.readBody(req);
|
|
3573
|
+
return this.balanceEndpoints.handleRefund(body, res);
|
|
3574
|
+
}
|
|
3575
|
+
if (url.pathname === "/balance/transactions" && req.method === "GET") {
|
|
3576
|
+
return this.balanceEndpoints.handleTransactions(url, res);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
1729
3579
|
if (url.pathname === "/execute" && req.method === "POST") {
|
|
1730
3580
|
const body = await this.readBody(req);
|
|
1731
3581
|
const paymentHeader = req.headers[PAYMENT_HEADER];
|
|
1732
|
-
|
|
3582
|
+
const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
|
|
3583
|
+
return await this.handleExecute(body, paymentHeader, res, proofHeader);
|
|
1733
3584
|
}
|
|
1734
3585
|
if (url.pathname === "/proxy" && req.method === "POST") {
|
|
1735
3586
|
const clientIP = req.headers["x-real-ip"] || req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "";
|
|
@@ -1747,29 +3598,81 @@ var MoltsPayServer = class {
|
|
|
1747
3598
|
const body = req.method === "POST" ? await this.readBody(req) : {};
|
|
1748
3599
|
const authHeader = req.headers[MPP_AUTH_HEADER];
|
|
1749
3600
|
const x402Header = req.headers[PAYMENT_HEADER];
|
|
1750
|
-
|
|
3601
|
+
const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
|
|
3602
|
+
return await this.handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader);
|
|
1751
3603
|
}
|
|
1752
|
-
this.sendJson(res, 404, {
|
|
3604
|
+
this.sendJson(res, 404, {
|
|
3605
|
+
error: "Not found",
|
|
3606
|
+
discovery: `${this.publicBase}/.well-known/agent-services.json`,
|
|
3607
|
+
endpoints: [`${this.publicBase}/health`, `${this.publicBase}/services`, `${this.publicBase}/execute`]
|
|
3608
|
+
});
|
|
1753
3609
|
} catch (err) {
|
|
1754
3610
|
console.error("[MoltsPay] Error:", err);
|
|
1755
3611
|
this.sendJson(res, 500, { error: err.message || "Internal error" });
|
|
1756
3612
|
}
|
|
1757
3613
|
}
|
|
1758
3614
|
/**
|
|
1759
|
-
*
|
|
3615
|
+
* Public base URL prefix for self-describing links, from PUBLIC_BASE_URL
|
|
3616
|
+
* (trailing slash stripped). Empty when unset, so emitted paths stay
|
|
3617
|
+
* root-relative — behavior is unchanged for local / no-prefix deploys.
|
|
3618
|
+
*
|
|
3619
|
+
* Needed because nginx rewrites the deployment prefix (e.g.
|
|
3620
|
+
* `/t/moltspay-server`) away before proxying, so the process cannot infer
|
|
3621
|
+
* its own public prefix; a root-relative `/services` would otherwise
|
|
3622
|
+
* resolve against the domain root and hit the wrong backend.
|
|
1760
3623
|
*/
|
|
1761
|
-
|
|
1762
|
-
|
|
3624
|
+
get publicBase() {
|
|
3625
|
+
return (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Per-service pricing across every configured rail, for the discovery
|
|
3629
|
+
* payloads. The top-level `price`/`currency` (crypto/USDC) stay unchanged
|
|
3630
|
+
* for back-compat; this surfaces the fiat + balance rails (CNY) that were
|
|
3631
|
+
* previously invisible in discovery even though the manifest defines them
|
|
3632
|
+
* and the 402 challenge already quotes them. `acceptedCurrencies` becomes
|
|
3633
|
+
* the union across rails so a client can see CNY is accepted without
|
|
3634
|
+
* first triggering a 402.
|
|
3635
|
+
*/
|
|
3636
|
+
describeServicePricing(s) {
|
|
3637
|
+
const pricing = [];
|
|
3638
|
+
if (this.getProviderChains().length > 0) {
|
|
3639
|
+
for (const currency of getAcceptedCurrencies(s)) {
|
|
3640
|
+
pricing.push({ rail: "crypto", currency, amount: String(s.price) });
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
if (s.alipay) pricing.push({ rail: "alipay", currency: "CNY", amount: s.alipay.price_cny });
|
|
3644
|
+
if (s.wechat) pricing.push({ rail: "wechat", currency: "CNY", amount: s.wechat.price_cny });
|
|
3645
|
+
if (s.balance) {
|
|
3646
|
+
pricing.push({
|
|
3647
|
+
rail: "balance",
|
|
3648
|
+
currency: this.manifest.provider.balance?.currency ?? "CNY",
|
|
3649
|
+
amount: s.balance.price ?? s.price.toFixed(2)
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
return { acceptedCurrencies: [...new Set(pricing.map((p) => p.currency))], pricing };
|
|
3653
|
+
}
|
|
3654
|
+
/** Shared service-list entry for the discovery and /services endpoints. */
|
|
3655
|
+
buildDiscoveryService(s) {
|
|
3656
|
+
const { acceptedCurrencies, pricing } = this.describeServicePricing(s);
|
|
3657
|
+
const headline = pricing.find((p) => p.rail === "crypto") ?? pricing[0];
|
|
3658
|
+
return {
|
|
1763
3659
|
id: s.id,
|
|
1764
3660
|
name: s.name,
|
|
1765
3661
|
description: s.description,
|
|
1766
|
-
price: s.price,
|
|
1767
|
-
currency: s.currency,
|
|
1768
|
-
acceptedCurrencies
|
|
3662
|
+
price: headline ? Number(headline.amount) : s.price,
|
|
3663
|
+
currency: headline ? headline.currency : s.currency,
|
|
3664
|
+
acceptedCurrencies,
|
|
3665
|
+
pricing,
|
|
1769
3666
|
input: s.input,
|
|
1770
3667
|
output: s.output,
|
|
1771
3668
|
available: this.skills.has(s.id)
|
|
1772
|
-
}
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
/**
|
|
3672
|
+
* GET /.well-known/agent-services.json - Standard discovery endpoint
|
|
3673
|
+
*/
|
|
3674
|
+
handleAgentServicesDiscovery(res) {
|
|
3675
|
+
const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
|
|
1773
3676
|
this.sendJson(res, 200, {
|
|
1774
3677
|
version: "1.0",
|
|
1775
3678
|
provider: {
|
|
@@ -1782,9 +3685,9 @@ var MoltsPayServer = class {
|
|
|
1782
3685
|
},
|
|
1783
3686
|
services,
|
|
1784
3687
|
endpoints: {
|
|
1785
|
-
services:
|
|
1786
|
-
execute:
|
|
1787
|
-
health:
|
|
3688
|
+
services: `${this.publicBase}/services`,
|
|
3689
|
+
execute: `${this.publicBase}/execute`,
|
|
3690
|
+
health: `${this.publicBase}/health`
|
|
1788
3691
|
},
|
|
1789
3692
|
payment: {
|
|
1790
3693
|
protocol: "x402",
|
|
@@ -1799,17 +3702,7 @@ var MoltsPayServer = class {
|
|
|
1799
3702
|
* GET /services - List available services
|
|
1800
3703
|
*/
|
|
1801
3704
|
handleGetServices(res) {
|
|
1802
|
-
const services = this.manifest.services.map((s) => (
|
|
1803
|
-
id: s.id,
|
|
1804
|
-
name: s.name,
|
|
1805
|
-
description: s.description,
|
|
1806
|
-
price: s.price,
|
|
1807
|
-
currency: s.currency,
|
|
1808
|
-
acceptedCurrencies: getAcceptedCurrencies(s),
|
|
1809
|
-
input: s.input,
|
|
1810
|
-
output: s.output,
|
|
1811
|
-
available: this.skills.has(s.id)
|
|
1812
|
-
}));
|
|
3705
|
+
const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
|
|
1813
3706
|
const selection = this.registry.getSelection();
|
|
1814
3707
|
this.sendJson(res, 200, {
|
|
1815
3708
|
provider: this.manifest.provider,
|
|
@@ -1844,7 +3737,7 @@ var MoltsPayServer = class {
|
|
|
1844
3737
|
/**
|
|
1845
3738
|
* POST /execute - Execute service with x402 payment
|
|
1846
3739
|
*/
|
|
1847
|
-
async handleExecute(body, paymentHeader, res) {
|
|
3740
|
+
async handleExecute(body, paymentHeader, res, proofHeader) {
|
|
1848
3741
|
const { service, params } = body;
|
|
1849
3742
|
if (!service) {
|
|
1850
3743
|
return this.sendJson(res, 400, { error: "Missing service" });
|
|
@@ -1858,8 +3751,17 @@ var MoltsPayServer = class {
|
|
|
1858
3751
|
return this.sendJson(res, 400, { error: `Missing required param: ${key}` });
|
|
1859
3752
|
}
|
|
1860
3753
|
}
|
|
3754
|
+
if (proofHeader) {
|
|
3755
|
+
const alipayPayment = {
|
|
3756
|
+
x402Version: X402_VERSION2,
|
|
3757
|
+
scheme: ALIPAY_SCHEME,
|
|
3758
|
+
network: ALIPAY_NETWORK,
|
|
3759
|
+
payload: proofHeader
|
|
3760
|
+
};
|
|
3761
|
+
return this.handleAlipayExecute(skill, params || {}, alipayPayment, res);
|
|
3762
|
+
}
|
|
1861
3763
|
if (!paymentHeader) {
|
|
1862
|
-
return this.sendPaymentRequired(skill.config, res);
|
|
3764
|
+
return this.sendPaymentRequired(skill.config, res, params || {});
|
|
1863
3765
|
}
|
|
1864
3766
|
let payment;
|
|
1865
3767
|
try {
|
|
@@ -1868,6 +3770,17 @@ var MoltsPayServer = class {
|
|
|
1868
3770
|
} catch {
|
|
1869
3771
|
return this.sendJson(res, 400, { error: "Invalid X-Payment header" });
|
|
1870
3772
|
}
|
|
3773
|
+
const payScheme = payment.accepted?.scheme || payment.scheme;
|
|
3774
|
+
const payNetwork = payment.accepted?.network || payment.network;
|
|
3775
|
+
if (payScheme === ALIPAY_SCHEME || (payNetwork ? isAlipayChainId(payNetwork) : false)) {
|
|
3776
|
+
return this.handleAlipayExecute(skill, params || {}, payment, res);
|
|
3777
|
+
}
|
|
3778
|
+
if (payScheme === WECHAT_SCHEME || (payNetwork ? isWechatChainId(payNetwork) : false)) {
|
|
3779
|
+
return this.handleWechatExecute(skill, params || {}, payment, res);
|
|
3780
|
+
}
|
|
3781
|
+
if (payScheme === BALANCE_SCHEME || (payNetwork ? isBalanceChainId(payNetwork) : false)) {
|
|
3782
|
+
return this.handleBalanceExecute(skill, params || {}, payment, res);
|
|
3783
|
+
}
|
|
1871
3784
|
const validation = this.validatePayment(payment, skill.config);
|
|
1872
3785
|
if (!validation.valid) {
|
|
1873
3786
|
return this.sendJson(res, 402, { error: validation.error });
|
|
@@ -1960,20 +3873,402 @@ var MoltsPayServer = class {
|
|
|
1960
3873
|
payment: settlement?.success ? { transaction: settlement.transaction, status: "settled", facilitator: settlement.facilitator } : { status: "pending" }
|
|
1961
3874
|
}, responseHeaders);
|
|
1962
3875
|
}
|
|
3876
|
+
/**
|
|
3877
|
+
* Execute a service paid via the Alipay AI Pay fiat rail (2.0.0).
|
|
3878
|
+
*
|
|
3879
|
+
* Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
|
|
3880
|
+
* validation. Verify hits the Alipay Open API (`payment.verify`). Settlement
|
|
3881
|
+
* (`fulfillment.confirm`) is FIRE-AND-FORGET per ALIPAY-INTEGRATION-DESIGN
|
|
3882
|
+
* §5.1: a confirm failure is logged but does NOT fail the already-delivered
|
|
3883
|
+
* response (the buyer's payment proof was already verified).
|
|
3884
|
+
*/
|
|
3885
|
+
async handleAlipayExecute(skill, params, payment, res) {
|
|
3886
|
+
if (!this.alipayFacilitator) {
|
|
3887
|
+
return this.sendJson(res, 402, { error: "Alipay rail not configured on this server" });
|
|
3888
|
+
}
|
|
3889
|
+
const requirements = {
|
|
3890
|
+
scheme: ALIPAY_SCHEME,
|
|
3891
|
+
network: ALIPAY_NETWORK,
|
|
3892
|
+
asset: "CNY",
|
|
3893
|
+
amount: skill.config.alipay?.price_cny || "0",
|
|
3894
|
+
payTo: this.manifest.provider.alipay?.seller_id || "",
|
|
3895
|
+
maxTimeoutSeconds: 1800
|
|
3896
|
+
};
|
|
3897
|
+
console.log(`[MoltsPay] Verifying Alipay payment...`);
|
|
3898
|
+
const verifyResult = await this.registry.verify(payment, requirements);
|
|
3899
|
+
if (!verifyResult.valid) {
|
|
3900
|
+
return this.sendJson(res, 402, {
|
|
3901
|
+
error: `Payment verification failed: ${verifyResult.error}`,
|
|
3902
|
+
facilitator: verifyResult.facilitator
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
3905
|
+
console.log(`[MoltsPay] Alipay payment verified by ${verifyResult.facilitator}`);
|
|
3906
|
+
const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
|
|
3907
|
+
console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
|
|
3908
|
+
let result;
|
|
3909
|
+
try {
|
|
3910
|
+
result = await Promise.race([
|
|
3911
|
+
skill.handler(params),
|
|
3912
|
+
new Promise(
|
|
3913
|
+
(_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
|
|
3914
|
+
)
|
|
3915
|
+
]);
|
|
3916
|
+
} catch (err) {
|
|
3917
|
+
console.error("[MoltsPay] Skill execution failed:", err.message);
|
|
3918
|
+
return this.sendJson(res, 500, {
|
|
3919
|
+
error: "Service execution failed",
|
|
3920
|
+
message: err.message
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3923
|
+
let settlement;
|
|
3924
|
+
try {
|
|
3925
|
+
settlement = await this.registry.settle(payment, requirements);
|
|
3926
|
+
if (settlement.success) {
|
|
3927
|
+
console.log(`[MoltsPay] Alipay fulfillment confirmed: ${settlement.transaction}`);
|
|
3928
|
+
} else {
|
|
3929
|
+
console.error(`[MoltsPay] Alipay fulfillment confirm failed (non-fatal): ${settlement.error}`);
|
|
3930
|
+
}
|
|
3931
|
+
} catch (err) {
|
|
3932
|
+
console.error(`[MoltsPay] Alipay fulfillment confirm threw (non-fatal): ${err.message}`);
|
|
3933
|
+
settlement = { success: false, error: err.message, facilitator: "alipay" };
|
|
3934
|
+
}
|
|
3935
|
+
const responseHeaders = {};
|
|
3936
|
+
if (settlement.success) {
|
|
3937
|
+
responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
|
|
3938
|
+
success: true,
|
|
3939
|
+
transaction: settlement.transaction,
|
|
3940
|
+
network: ALIPAY_NETWORK,
|
|
3941
|
+
facilitator: settlement.facilitator
|
|
3942
|
+
})).toString("base64");
|
|
3943
|
+
}
|
|
3944
|
+
this.sendJson(res, 200, {
|
|
3945
|
+
success: true,
|
|
3946
|
+
result,
|
|
3947
|
+
payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
|
|
3948
|
+
}, responseHeaders);
|
|
3949
|
+
}
|
|
3950
|
+
/**
|
|
3951
|
+
* Build the Alipay 402 challenge for a service, or null when the alipay rail
|
|
3952
|
+
* isn't configured for this server or this service. Returns the x402
|
|
3953
|
+
* `accepts[]` entry plus the Base64URL `Payment-Needed` header value so the
|
|
3954
|
+
* 402 responders can dual-emit both the x402 and legacy alipay-bot formats.
|
|
3955
|
+
*/
|
|
3956
|
+
async buildAlipayChallenge(config) {
|
|
3957
|
+
if (!this.alipayFacilitator || !config.alipay) return null;
|
|
3958
|
+
try {
|
|
3959
|
+
const req = await this.alipayFacilitator.createPaymentRequirements({
|
|
3960
|
+
serviceId: config.alipay.service_id || this.manifest.provider.alipay.service_id_default,
|
|
3961
|
+
priceCny: config.alipay.price_cny,
|
|
3962
|
+
goodsName: config.alipay.goods_name,
|
|
3963
|
+
resourceId: `/execute?service=${config.id}`
|
|
3964
|
+
});
|
|
3965
|
+
return { accepts: req.x402Accepts, paymentNeededHeader: req.paymentNeededHeader };
|
|
3966
|
+
} catch (err) {
|
|
3967
|
+
console.error(`[MoltsPay] Alipay challenge build failed for ${config.id}: ${err.message}`);
|
|
3968
|
+
return null;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
/**
|
|
3972
|
+
* Execute a service paid via the WeChat Pay v3 Native fiat rail (2.1.0).
|
|
3973
|
+
*
|
|
3974
|
+
* Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
|
|
3975
|
+
* validation. The buyer (a human) scanned the Native QR and paid; the
|
|
3976
|
+
* client re-requests carrying `out_trade_no` in the X-Payment payload.
|
|
3977
|
+
* Verify queries the order (`trade_state === SUCCESS`). Settlement is an
|
|
3978
|
+
* idempotent re-confirm and is FIRE-AND-FORGET (mirrors the Alipay path):
|
|
3979
|
+
* a confirm failure is logged but does NOT fail the delivered response —
|
|
3980
|
+
* the order was already verified SUCCESS.
|
|
3981
|
+
*/
|
|
3982
|
+
async handleWechatExecute(skill, params, payment, res) {
|
|
3983
|
+
if (!this.wechatFacilitator) {
|
|
3984
|
+
return this.sendJson(res, 402, { error: "WeChat rail not configured on this server" });
|
|
3985
|
+
}
|
|
3986
|
+
const outTradeNo = typeof payment.accepted?.extra?.out_trade_no === "string" ? payment.accepted.extra.out_trade_no : void 0;
|
|
3987
|
+
const requirements = {
|
|
3988
|
+
scheme: WECHAT_SCHEME,
|
|
3989
|
+
network: WECHAT_NETWORK,
|
|
3990
|
+
asset: "CNY",
|
|
3991
|
+
amount: skill.config.wechat?.price_cny || "0",
|
|
3992
|
+
payTo: this.manifest.provider.wechat?.mchid || "",
|
|
3993
|
+
maxTimeoutSeconds: 300,
|
|
3994
|
+
extra: outTradeNo ? { out_trade_no: outTradeNo } : void 0
|
|
3995
|
+
};
|
|
3996
|
+
console.log(`[MoltsPay] Verifying WeChat payment...`);
|
|
3997
|
+
const verifyResult = await this.registry.verify(payment, requirements);
|
|
3998
|
+
if (!verifyResult.valid) {
|
|
3999
|
+
return this.sendJson(res, 402, {
|
|
4000
|
+
error: `Payment verification failed: ${verifyResult.error}`,
|
|
4001
|
+
facilitator: verifyResult.facilitator
|
|
4002
|
+
});
|
|
4003
|
+
}
|
|
4004
|
+
console.log(`[MoltsPay] WeChat payment verified by ${verifyResult.facilitator}`);
|
|
4005
|
+
if (outTradeNo) {
|
|
4006
|
+
this.invalidateWechatChallenge(outTradeNo);
|
|
4007
|
+
}
|
|
4008
|
+
const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
|
|
4009
|
+
console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
|
|
4010
|
+
let result;
|
|
4011
|
+
try {
|
|
4012
|
+
result = await Promise.race([
|
|
4013
|
+
skill.handler(params),
|
|
4014
|
+
new Promise(
|
|
4015
|
+
(_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
|
|
4016
|
+
)
|
|
4017
|
+
]);
|
|
4018
|
+
} catch (err) {
|
|
4019
|
+
console.error("[MoltsPay] Skill execution failed:", err.message);
|
|
4020
|
+
return this.sendJson(res, 500, {
|
|
4021
|
+
error: "Service execution failed",
|
|
4022
|
+
message: err.message
|
|
4023
|
+
});
|
|
4024
|
+
}
|
|
4025
|
+
let settlement;
|
|
4026
|
+
try {
|
|
4027
|
+
settlement = await this.registry.settle(payment, requirements);
|
|
4028
|
+
if (settlement.success) {
|
|
4029
|
+
console.log(`[MoltsPay] WeChat settlement confirmed: ${settlement.transaction}`);
|
|
4030
|
+
} else {
|
|
4031
|
+
console.error(`[MoltsPay] WeChat settlement confirm failed (non-fatal): ${settlement.error}`);
|
|
4032
|
+
}
|
|
4033
|
+
} catch (err) {
|
|
4034
|
+
console.error(`[MoltsPay] WeChat settlement confirm threw (non-fatal): ${err.message}`);
|
|
4035
|
+
settlement = { success: false, error: err.message, facilitator: "wechat" };
|
|
4036
|
+
}
|
|
4037
|
+
const responseHeaders = {};
|
|
4038
|
+
if (settlement.success) {
|
|
4039
|
+
responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
|
|
4040
|
+
success: true,
|
|
4041
|
+
transaction: settlement.transaction,
|
|
4042
|
+
network: WECHAT_NETWORK,
|
|
4043
|
+
facilitator: settlement.facilitator
|
|
4044
|
+
})).toString("base64");
|
|
4045
|
+
}
|
|
4046
|
+
this.sendJson(res, 200, {
|
|
4047
|
+
success: true,
|
|
4048
|
+
result,
|
|
4049
|
+
payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
|
|
4050
|
+
}, responseHeaders);
|
|
4051
|
+
}
|
|
4052
|
+
/**
|
|
4053
|
+
* Build the WeChat 402 challenge for a service, or null when the wechat rail
|
|
4054
|
+
* isn't configured for this server or this service. Placing a Native order
|
|
4055
|
+
* is a network call that returns a fresh `code_url` + `out_trade_no`; the
|
|
4056
|
+
* x402 `accepts[]` entry carries both in `extra` so the client can render
|
|
4057
|
+
* the QR and later echo `out_trade_no` back for verification.
|
|
4058
|
+
*
|
|
4059
|
+
* DOUBLE-CHARGE FIX: the unpaid order is cached per service id (see
|
|
4060
|
+
* `wechatPendingChallenges`), so repeated 402 emits within the order's
|
|
4061
|
+
* `time_expire` window return the SAME `code_url`/`out_trade_no` instead of
|
|
4062
|
+
* minting a new payable order each time. The entry is dropped once the
|
|
4063
|
+
* order is paid (`invalidateWechatChallenge`) or shortly before it expires
|
|
4064
|
+
* (refresh margin, so clients never receive a nearly-dead QR). A build
|
|
4065
|
+
* failure is not cached and degrades gracefully (the other rails'
|
|
4066
|
+
* accepts[] still ship).
|
|
4067
|
+
*/
|
|
4068
|
+
async buildWechatChallenge(config, params) {
|
|
4069
|
+
if (!this.wechatFacilitator || !config.wechat) return null;
|
|
4070
|
+
const cacheKey = crypto6.createHash("sha256").update(`${config.id}|${canonicalJson(params ?? {})}|${config.wechat.price_cny}`).digest("hex");
|
|
4071
|
+
const result = await this.getOrCreatePendingWechatOrder(
|
|
4072
|
+
cacheKey,
|
|
4073
|
+
config.id,
|
|
4074
|
+
() => this.wechatFacilitator.createPaymentRequirements({
|
|
4075
|
+
priceCny: config.wechat.price_cny,
|
|
4076
|
+
description: config.wechat.description
|
|
4077
|
+
})
|
|
4078
|
+
);
|
|
4079
|
+
return result ? { accepts: result.accepts } : null;
|
|
4080
|
+
}
|
|
4081
|
+
/**
|
|
4082
|
+
* Get-or-create a pending WeChat Native order under `cacheKey`, deduping
|
|
4083
|
+
* concurrent builds and reusing an unpaid order until it nears expiry.
|
|
4084
|
+
* Shared by the 402 challenge path ({@link buildWechatChallenge}) and the
|
|
4085
|
+
* balance top-up order path ({@link handleBalanceTopupOrder}). See the
|
|
4086
|
+
* `wechatPendingChallenges` doc for the double-charge rationale.
|
|
4087
|
+
*/
|
|
4088
|
+
async getOrCreatePendingWechatOrder(cacheKey, logLabel, create) {
|
|
4089
|
+
const now = Date.now();
|
|
4090
|
+
const cached = this.wechatPendingChallenges.get(cacheKey);
|
|
4091
|
+
if (cached) {
|
|
4092
|
+
if (now < cached.expiresAtMs) {
|
|
4093
|
+
const hit = await cached.promise;
|
|
4094
|
+
if (hit) {
|
|
4095
|
+
console.log(`[MoltsPay] Reusing pending WeChat order ${hit.outTradeNo} for ${logLabel}`);
|
|
4096
|
+
return hit;
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
this.wechatPendingChallenges.delete(cacheKey);
|
|
4100
|
+
}
|
|
4101
|
+
const orderTtlMs = WECHAT_TIME_EXPIRE_MS;
|
|
4102
|
+
const cacheTtlMs = Math.max(orderTtlMs - 3e4, Math.floor(orderTtlMs / 2));
|
|
4103
|
+
const entry = {
|
|
4104
|
+
expiresAtMs: now + cacheTtlMs,
|
|
4105
|
+
promise: Promise.resolve(null)
|
|
4106
|
+
};
|
|
4107
|
+
entry.promise = (async () => {
|
|
4108
|
+
try {
|
|
4109
|
+
const req = await create();
|
|
4110
|
+
entry.outTradeNo = req.outTradeNo;
|
|
4111
|
+
return { accepts: req.x402Accepts, codeUrl: req.codeUrl, outTradeNo: req.outTradeNo };
|
|
4112
|
+
} catch (err) {
|
|
4113
|
+
console.error(`[MoltsPay] WeChat order build failed for ${logLabel}: ${err.message}`);
|
|
4114
|
+
this.wechatPendingChallenges.delete(cacheKey);
|
|
4115
|
+
return null;
|
|
4116
|
+
}
|
|
4117
|
+
})();
|
|
4118
|
+
this.wechatPendingChallenges.set(cacheKey, entry);
|
|
4119
|
+
return entry.promise;
|
|
4120
|
+
}
|
|
4121
|
+
/**
|
|
4122
|
+
* Drop the cached pending WeChat order that matches a paid `out_trade_no`,
|
|
4123
|
+
* so the next 402 mints a fresh order instead of re-serving a consumed one
|
|
4124
|
+
* (Native is one-code-one-payment).
|
|
4125
|
+
*/
|
|
4126
|
+
invalidateWechatChallenge(outTradeNo) {
|
|
4127
|
+
for (const [cacheKey, entry] of this.wechatPendingChallenges) {
|
|
4128
|
+
if (entry.outTradeNo === outTradeNo) {
|
|
4129
|
+
this.wechatPendingChallenges.delete(cacheKey);
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
/**
|
|
4134
|
+
* Handle /execute for the custodial balance rail (2.2.0).
|
|
4135
|
+
*
|
|
4136
|
+
* Execution order is INVERTED relative to the QR rails: the deduction IS
|
|
4137
|
+
* the settlement, so it must land before the skill runs, and a skill
|
|
4138
|
+
* failure refunds it. `settle()` is idempotent on the client's
|
|
4139
|
+
* `request_id`, so a retried request never double-charges.
|
|
4140
|
+
*
|
|
4141
|
+
* QR rails: verify(paid?) → run skill → settle (confirm, fire-and-forget)
|
|
4142
|
+
* balance: verify implicit in settle (atomic deduct) → run skill → [fail → refund]
|
|
4143
|
+
*/
|
|
4144
|
+
async handleBalanceExecute(skill, params, payment, res) {
|
|
4145
|
+
if (!this.balanceFacilitator) {
|
|
4146
|
+
return this.sendJson(res, 402, { error: "Balance rail not configured on this server" });
|
|
4147
|
+
}
|
|
4148
|
+
const requirements = this.balanceRequirementsFor(skill.config);
|
|
4149
|
+
const authMode = this.balanceFacilitator.authMode;
|
|
4150
|
+
if (authMode !== "off") {
|
|
4151
|
+
const bp = extractBalancePayload(payment);
|
|
4152
|
+
const buyerId = bp?.buyer_id ?? "";
|
|
4153
|
+
const requestId = bp?.request_id ?? "";
|
|
4154
|
+
const av = verifyDeductAuth({
|
|
4155
|
+
auth: bp?.auth ?? null,
|
|
4156
|
+
buyerId,
|
|
4157
|
+
requestId,
|
|
4158
|
+
service: skill.id,
|
|
4159
|
+
nowMs: Date.now()
|
|
4160
|
+
});
|
|
4161
|
+
const ledger = this.balanceFacilitator.getLedger();
|
|
4162
|
+
let denyReason;
|
|
4163
|
+
if (av.ok && av.recovered) {
|
|
4164
|
+
const bind = ledger.bindSigner(buyerId, av.recovered);
|
|
4165
|
+
if (bind.conflict) denyReason = `wrong signer (account bound to ${bind.existing}, got ${av.recovered})`;
|
|
4166
|
+
} else {
|
|
4167
|
+
denyReason = `signature ${av.reason}`;
|
|
4168
|
+
}
|
|
4169
|
+
if (denyReason) {
|
|
4170
|
+
if (authMode === "enforce") {
|
|
4171
|
+
console.warn(`[MoltsPay] Balance auth DENY (enforce) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
|
|
4172
|
+
return this.sendJson(res, 401, { error: `Balance auth failed: ${denyReason}`, facilitator: "balance" });
|
|
4173
|
+
}
|
|
4174
|
+
console.warn(`[MoltsPay] Balance auth would-deny (shadow) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
|
|
4175
|
+
} else {
|
|
4176
|
+
console.log(`[MoltsPay] Balance auth ok (${authMode}) buyer=${buyerId} signer=${av.recovered}`);
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
console.log(`[MoltsPay] Deducting balance for ${skill.id}...`);
|
|
4180
|
+
const settlement = await this.balanceFacilitator.settle(payment, requirements);
|
|
4181
|
+
if (!settlement.success) {
|
|
4182
|
+
return this.sendJson(res, 402, {
|
|
4183
|
+
error: `Balance deduction failed: ${settlement.error}`,
|
|
4184
|
+
code: settlement.status,
|
|
4185
|
+
facilitator: "balance"
|
|
4186
|
+
});
|
|
4187
|
+
}
|
|
4188
|
+
console.log(`[MoltsPay] Balance deducted (tx ${settlement.transaction}${settlement.status === "replayed" ? ", replayed" : ""})`);
|
|
4189
|
+
const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
|
|
4190
|
+
console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
|
|
4191
|
+
let result;
|
|
4192
|
+
try {
|
|
4193
|
+
result = await Promise.race([
|
|
4194
|
+
skill.handler(params),
|
|
4195
|
+
new Promise(
|
|
4196
|
+
(_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
|
|
4197
|
+
)
|
|
4198
|
+
]);
|
|
4199
|
+
} catch (err) {
|
|
4200
|
+
console.error("[MoltsPay] Skill execution failed:", err.message);
|
|
4201
|
+
const refund = this.balanceFacilitator.refund(settlement.transaction, `skill_failed: ${err.message}`.slice(0, 200));
|
|
4202
|
+
if (!refund.success) {
|
|
4203
|
+
console.error(`[MoltsPay] Balance refund FAILED for ${settlement.transaction}: ${refund.error} \u2014 manual reconciliation needed`);
|
|
4204
|
+
} else {
|
|
4205
|
+
console.log(`[MoltsPay] Balance refunded (tx ${refund.txId})`);
|
|
4206
|
+
}
|
|
4207
|
+
return this.sendJson(res, 500, {
|
|
4208
|
+
error: "Service execution failed",
|
|
4209
|
+
message: err.message,
|
|
4210
|
+
refunded: refund.success
|
|
4211
|
+
});
|
|
4212
|
+
}
|
|
4213
|
+
const responseHeaders = {
|
|
4214
|
+
[PAYMENT_RESPONSE_HEADER]: Buffer.from(JSON.stringify({
|
|
4215
|
+
success: true,
|
|
4216
|
+
transaction: settlement.transaction,
|
|
4217
|
+
network: "balance",
|
|
4218
|
+
facilitator: "balance"
|
|
4219
|
+
})).toString("base64")
|
|
4220
|
+
};
|
|
4221
|
+
this.sendJson(res, 200, {
|
|
4222
|
+
success: true,
|
|
4223
|
+
result,
|
|
4224
|
+
payment: { transaction: settlement.transaction, status: "fulfilled", facilitator: "balance" }
|
|
4225
|
+
}, responseHeaders);
|
|
4226
|
+
}
|
|
4227
|
+
/** The balance rail's requirements for a service (price defaults to `config.price`). */
|
|
4228
|
+
balanceRequirementsFor(config) {
|
|
4229
|
+
const price = config.balance?.price ?? config.price.toFixed(2);
|
|
4230
|
+
return {
|
|
4231
|
+
scheme: BALANCE_SCHEME,
|
|
4232
|
+
network: "balance",
|
|
4233
|
+
asset: this.balanceFacilitator?.currency ?? "USD",
|
|
4234
|
+
amount: price,
|
|
4235
|
+
payTo: "custodial",
|
|
4236
|
+
maxTimeoutSeconds: 30,
|
|
4237
|
+
extra: { service_id: config.id }
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
/**
|
|
4241
|
+
* Build the balance 402 challenge for a service, or null when the rail
|
|
4242
|
+
* isn't configured for this server or this service. Pure — nothing is
|
|
4243
|
+
* minted, so unlike the QR rails a 402 emit has no side effects.
|
|
4244
|
+
*/
|
|
4245
|
+
buildBalanceChallenge(config) {
|
|
4246
|
+
if (!this.balanceFacilitator || !config.balance) return null;
|
|
4247
|
+
try {
|
|
4248
|
+
return { accepts: this.balanceRequirementsFor(config) };
|
|
4249
|
+
} catch (err) {
|
|
4250
|
+
console.error(`[MoltsPay] Balance challenge build failed for ${config.id}: ${err.message}`);
|
|
4251
|
+
return null;
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
/** GET /balance?buyer_id= — balance, limits, and today's spend. */
|
|
1963
4255
|
/**
|
|
1964
4256
|
* Handle MPP (Machine Payments Protocol) request
|
|
1965
4257
|
* Supports both x402 and MPP protocols on service endpoints
|
|
1966
4258
|
*/
|
|
1967
|
-
async handleMPPRequest(skill, body, authHeader, x402Header, res) {
|
|
4259
|
+
async handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader) {
|
|
1968
4260
|
const config = skill.config;
|
|
1969
4261
|
const params = body || {};
|
|
4262
|
+
if (proofHeader) {
|
|
4263
|
+
return await this.handleExecute({ service: config.id, params }, void 0, res, proofHeader);
|
|
4264
|
+
}
|
|
1970
4265
|
if (x402Header) {
|
|
1971
4266
|
return await this.handleExecute({ service: config.id, params }, x402Header, res);
|
|
1972
4267
|
}
|
|
1973
4268
|
if (authHeader && authHeader.toLowerCase().startsWith("payment ")) {
|
|
1974
4269
|
return await this.handleMPPPayment(skill, params, authHeader, res);
|
|
1975
4270
|
}
|
|
1976
|
-
return this.sendMPPPaymentRequired(config, res);
|
|
4271
|
+
return this.sendMPPPaymentRequired(config, res, params);
|
|
1977
4272
|
}
|
|
1978
4273
|
/**
|
|
1979
4274
|
* Handle MPP payment verification and service execution
|
|
@@ -2072,7 +4367,7 @@ var MoltsPayServer = class {
|
|
|
2072
4367
|
/**
|
|
2073
4368
|
* Return 402 with both x402 and MPP payment requirements
|
|
2074
4369
|
*/
|
|
2075
|
-
sendMPPPaymentRequired(config, res) {
|
|
4370
|
+
async sendMPPPaymentRequired(config, res, params) {
|
|
2076
4371
|
const acceptedTokens = getAcceptedCurrencies(config);
|
|
2077
4372
|
const providerChains = this.getProviderChains();
|
|
2078
4373
|
const accepts = [];
|
|
@@ -2083,6 +4378,18 @@ var MoltsPayServer = class {
|
|
|
2083
4378
|
}
|
|
2084
4379
|
}
|
|
2085
4380
|
}
|
|
4381
|
+
const alipayChallenge = await this.buildAlipayChallenge(config);
|
|
4382
|
+
if (alipayChallenge) {
|
|
4383
|
+
accepts.push(alipayChallenge.accepts);
|
|
4384
|
+
}
|
|
4385
|
+
const wechatChallenge = await this.buildWechatChallenge(config, params);
|
|
4386
|
+
if (wechatChallenge) {
|
|
4387
|
+
accepts.push(wechatChallenge.accepts);
|
|
4388
|
+
}
|
|
4389
|
+
const balanceChallenge = this.buildBalanceChallenge(config);
|
|
4390
|
+
if (balanceChallenge) {
|
|
4391
|
+
accepts.push(balanceChallenge.accepts);
|
|
4392
|
+
}
|
|
2086
4393
|
const x402PaymentRequired = {
|
|
2087
4394
|
x402Version: X402_VERSION2,
|
|
2088
4395
|
accepts,
|
|
@@ -2110,7 +4417,7 @@ var MoltsPayServer = class {
|
|
|
2110
4417
|
};
|
|
2111
4418
|
const mppRequestEncoded = Buffer.from(JSON.stringify(mppRequest)).toString("base64");
|
|
2112
4419
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1e3).toISOString();
|
|
2113
|
-
mppWwwAuth = `Payment id="${challengeId}", realm="${this.manifest.provider.name}", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${config.name}", expires="${expiresAt}"`;
|
|
4420
|
+
mppWwwAuth = `Payment id="${challengeId}", realm="${headerSafe(this.manifest.provider.name)}", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${headerSafe(config.name)}", expires="${expiresAt}"`;
|
|
2114
4421
|
}
|
|
2115
4422
|
const headers = {
|
|
2116
4423
|
"Content-Type": "application/problem+json",
|
|
@@ -2119,6 +4426,9 @@ var MoltsPayServer = class {
|
|
|
2119
4426
|
if (mppWwwAuth) {
|
|
2120
4427
|
headers[MPP_WWW_AUTH_HEADER] = mppWwwAuth;
|
|
2121
4428
|
}
|
|
4429
|
+
if (alipayChallenge) {
|
|
4430
|
+
headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
|
|
4431
|
+
}
|
|
2122
4432
|
res.writeHead(402, headers);
|
|
2123
4433
|
res.end(JSON.stringify({
|
|
2124
4434
|
type: "https://paymentauth.org/problems/payment-required",
|
|
@@ -2145,7 +4455,7 @@ var MoltsPayServer = class {
|
|
|
2145
4455
|
* Return 402 with x402 payment requirements (v2 format)
|
|
2146
4456
|
* Includes requirements for all chains and all accepted currencies
|
|
2147
4457
|
*/
|
|
2148
|
-
sendPaymentRequired(config, res) {
|
|
4458
|
+
async sendPaymentRequired(config, res, params) {
|
|
2149
4459
|
const acceptedTokens = getAcceptedCurrencies(config);
|
|
2150
4460
|
const providerChains = this.getProviderChains();
|
|
2151
4461
|
const accepts = [];
|
|
@@ -2156,6 +4466,18 @@ var MoltsPayServer = class {
|
|
|
2156
4466
|
}
|
|
2157
4467
|
}
|
|
2158
4468
|
}
|
|
4469
|
+
const alipayChallenge = await this.buildAlipayChallenge(config);
|
|
4470
|
+
if (alipayChallenge) {
|
|
4471
|
+
accepts.push(alipayChallenge.accepts);
|
|
4472
|
+
}
|
|
4473
|
+
const wechatChallenge = await this.buildWechatChallenge(config, params);
|
|
4474
|
+
if (wechatChallenge) {
|
|
4475
|
+
accepts.push(wechatChallenge.accepts);
|
|
4476
|
+
}
|
|
4477
|
+
const balanceChallenge = this.buildBalanceChallenge(config);
|
|
4478
|
+
if (balanceChallenge) {
|
|
4479
|
+
accepts.push(balanceChallenge.accepts);
|
|
4480
|
+
}
|
|
2159
4481
|
const acceptedChains = providerChains.map((c) => {
|
|
2160
4482
|
if (c.network === "eip155:8453") return "base";
|
|
2161
4483
|
if (c.network === "eip155:137") return "polygon";
|
|
@@ -2167,20 +4489,26 @@ var MoltsPayServer = class {
|
|
|
2167
4489
|
acceptedCurrencies: acceptedTokens,
|
|
2168
4490
|
acceptedChains,
|
|
2169
4491
|
resource: {
|
|
2170
|
-
url:
|
|
4492
|
+
url: `${this.publicBase}/execute?service=${config.id}`,
|
|
2171
4493
|
description: `${config.name} - $${config.price} ${config.currency}`,
|
|
2172
4494
|
mimeType: "application/json"
|
|
2173
4495
|
}
|
|
2174
4496
|
};
|
|
2175
4497
|
const encoded = Buffer.from(JSON.stringify(paymentRequired)).toString("base64");
|
|
2176
|
-
|
|
4498
|
+
const headers = {
|
|
2177
4499
|
"Content-Type": "application/json",
|
|
2178
4500
|
[PAYMENT_REQUIRED_HEADER]: encoded
|
|
2179
|
-
}
|
|
4501
|
+
};
|
|
4502
|
+
if (alipayChallenge) {
|
|
4503
|
+
headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
|
|
4504
|
+
}
|
|
4505
|
+
const offered = this.describeServicePricing(config);
|
|
4506
|
+
const message = offered.pricing.length ? `Payment required \u2014 ${offered.pricing.map((p) => `${p.rail}: ${p.amount} ${p.currency}`).join(" | ")}` : `Service requires $${config.price} ${config.currency}`;
|
|
4507
|
+
res.writeHead(402, headers);
|
|
2180
4508
|
res.end(JSON.stringify({
|
|
2181
4509
|
error: "Payment required",
|
|
2182
|
-
message
|
|
2183
|
-
acceptedCurrencies:
|
|
4510
|
+
message,
|
|
4511
|
+
acceptedCurrencies: offered.acceptedCurrencies,
|
|
2184
4512
|
acceptedChains,
|
|
2185
4513
|
x402: paymentRequired
|
|
2186
4514
|
}, null, 2));
|
|
@@ -2283,12 +4611,12 @@ var MoltsPayServer = class {
|
|
|
2283
4611
|
return accepted.includes(token);
|
|
2284
4612
|
}
|
|
2285
4613
|
async readBody(req) {
|
|
2286
|
-
return new Promise((
|
|
4614
|
+
return new Promise((resolve2, reject) => {
|
|
2287
4615
|
let body = "";
|
|
2288
4616
|
req.on("data", (chunk) => body += chunk);
|
|
2289
4617
|
req.on("end", () => {
|
|
2290
4618
|
try {
|
|
2291
|
-
|
|
4619
|
+
resolve2(body ? JSON.parse(body) : {});
|
|
2292
4620
|
} catch {
|
|
2293
4621
|
reject(new Error("Invalid JSON"));
|
|
2294
4622
|
}
|
|
@@ -2546,7 +4874,7 @@ var MoltsPayServer = class {
|
|
|
2546
4874
|
};
|
|
2547
4875
|
const mppRequestEncoded = Buffer.from(JSON.stringify(mppRequest)).toString("base64");
|
|
2548
4876
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1e3).toISOString();
|
|
2549
|
-
const wwwAuth = `Payment id="${challengeId}", realm="MoltsPay Proxy", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${config.name}", expires="${expiresAt}"`;
|
|
4877
|
+
const wwwAuth = `Payment id="${challengeId}", realm="MoltsPay Proxy", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${headerSafe(config.name)}", expires="${expiresAt}"`;
|
|
2550
4878
|
res.writeHead(402, {
|
|
2551
4879
|
"Content-Type": "application/problem+json",
|
|
2552
4880
|
[MPP_WWW_AUTH_HEADER]: wwwAuth
|