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
|
@@ -30,16 +30,39 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/facilitators/index.ts
|
|
31
31
|
var facilitators_exports = {};
|
|
32
32
|
__export(facilitators_exports, {
|
|
33
|
+
ALIPAY_GATEWAY_PROD: () => ALIPAY_GATEWAY_PROD,
|
|
34
|
+
ALIPAY_GATEWAY_SANDBOX: () => ALIPAY_GATEWAY_SANDBOX,
|
|
35
|
+
ALIPAY_NETWORK: () => ALIPAY_NETWORK,
|
|
36
|
+
ALIPAY_SCHEME: () => ALIPAY_SCHEME,
|
|
37
|
+
AlipayFacilitator: () => AlipayFacilitator,
|
|
38
|
+
BALANCE_AUTH_DOMAIN: () => BALANCE_AUTH_DOMAIN,
|
|
39
|
+
BALANCE_AUTH_MAX_SKEW_MS: () => BALANCE_AUTH_MAX_SKEW_MS,
|
|
40
|
+
BALANCE_NETWORK: () => BALANCE_NETWORK,
|
|
41
|
+
BALANCE_SCHEME: () => BALANCE_SCHEME,
|
|
33
42
|
BNBFacilitator: () => BNBFacilitator,
|
|
43
|
+
BalanceFacilitator: () => BalanceFacilitator,
|
|
44
|
+
BalanceLedger: () => BalanceLedger,
|
|
34
45
|
BaseFacilitator: () => BaseFacilitator,
|
|
35
46
|
CDPFacilitator: () => CDPFacilitator,
|
|
36
47
|
FacilitatorRegistry: () => FacilitatorRegistry,
|
|
37
48
|
SolanaFacilitator: () => SolanaFacilitator,
|
|
38
49
|
TempoFacilitator: () => TempoFacilitator,
|
|
50
|
+
WECHAT_AMOUNT_REGEX: () => WECHAT_AMOUNT_REGEX,
|
|
51
|
+
WECHAT_API_BASE: () => WECHAT_API_BASE,
|
|
52
|
+
WECHAT_NETWORK: () => WECHAT_NETWORK,
|
|
53
|
+
WECHAT_SCHEME: () => WECHAT_SCHEME,
|
|
54
|
+
WECHAT_TIME_EXPIRE_MS: () => WECHAT_TIME_EXPIRE_MS,
|
|
55
|
+
WechatFacilitator: () => WechatFacilitator,
|
|
56
|
+
buildDeductMessage: () => buildDeductMessage,
|
|
39
57
|
createIntentTypedData: () => createIntentTypedData,
|
|
40
58
|
createRegistry: () => createRegistry,
|
|
41
59
|
createSolanaPaymentTransaction: () => createSolanaPaymentTransaction,
|
|
42
|
-
|
|
60
|
+
extractBalancePayload: () => extractBalancePayload,
|
|
61
|
+
fromSat: () => fromSat,
|
|
62
|
+
getDefaultRegistry: () => getDefaultRegistry,
|
|
63
|
+
parseWechatAttach: () => parseWechatAttach,
|
|
64
|
+
toSat: () => toSat,
|
|
65
|
+
verifyDeductAuth: () => verifyDeductAuth
|
|
43
66
|
});
|
|
44
67
|
module.exports = __toCommonJS(facilitators_exports);
|
|
45
68
|
|
|
@@ -896,12 +919,12 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
896
919
|
return this.spenderAddress;
|
|
897
920
|
}
|
|
898
921
|
async getServerAddress() {
|
|
899
|
-
const { ethers:
|
|
900
|
-
const wallet = new
|
|
922
|
+
const { ethers: ethers3 } = await import("ethers");
|
|
923
|
+
const wallet = new ethers3.Wallet(this.serverPrivateKey);
|
|
901
924
|
return wallet.address;
|
|
902
925
|
}
|
|
903
926
|
async recoverIntentSigner(intent, chainId) {
|
|
904
|
-
const { ethers:
|
|
927
|
+
const { ethers: ethers3 } = await import("ethers");
|
|
905
928
|
const domain = {
|
|
906
929
|
...EIP712_DOMAIN,
|
|
907
930
|
chainId
|
|
@@ -915,7 +938,7 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
915
938
|
nonce: intent.nonce,
|
|
916
939
|
deadline: intent.deadline
|
|
917
940
|
};
|
|
918
|
-
const recoveredAddress =
|
|
941
|
+
const recoveredAddress = ethers3.verifyTypedData(
|
|
919
942
|
domain,
|
|
920
943
|
INTENT_TYPES,
|
|
921
944
|
message,
|
|
@@ -959,10 +982,10 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
959
982
|
return result.result || "0x0";
|
|
960
983
|
}
|
|
961
984
|
async executeTransferFrom(from, to, amount, token, rpcUrl) {
|
|
962
|
-
const { ethers:
|
|
963
|
-
const provider = new
|
|
964
|
-
const wallet = new
|
|
965
|
-
const tokenContract = new
|
|
985
|
+
const { ethers: ethers3 } = await import("ethers");
|
|
986
|
+
const provider = new ethers3.JsonRpcProvider(rpcUrl);
|
|
987
|
+
const wallet = new ethers3.Wallet(this.serverPrivateKey, provider);
|
|
988
|
+
const tokenContract = new ethers3.Contract(token, [
|
|
966
989
|
"function transferFrom(address from, address to, uint256 amount) returns (bool)"
|
|
967
990
|
], wallet);
|
|
968
991
|
const tx = await tokenContract.transferFrom(from, to, amount);
|
|
@@ -1253,6 +1276,1239 @@ async function createSolanaPaymentTransaction(senderPubkey, recipientPubkey, amo
|
|
|
1253
1276
|
return transaction;
|
|
1254
1277
|
}
|
|
1255
1278
|
|
|
1279
|
+
// src/facilitators/alipay.ts
|
|
1280
|
+
var import_node_crypto2 = __toESM(require("crypto"));
|
|
1281
|
+
|
|
1282
|
+
// src/facilitators/alipay/rsa2.ts
|
|
1283
|
+
var import_node_crypto = __toESM(require("crypto"));
|
|
1284
|
+
function rsa2Sign(data, privateKeyPem) {
|
|
1285
|
+
const signer = import_node_crypto.default.createSign("RSA-SHA256");
|
|
1286
|
+
signer.update(data, "utf-8");
|
|
1287
|
+
signer.end();
|
|
1288
|
+
return signer.sign(privateKeyPem, "base64");
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/facilitators/alipay/encoding.ts
|
|
1292
|
+
function base64url(input) {
|
|
1293
|
+
return Buffer.from(input, "utf-8").toString("base64url");
|
|
1294
|
+
}
|
|
1295
|
+
function decodeBase64UrlWithPadFix(input) {
|
|
1296
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
1297
|
+
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
1298
|
+
return Buffer.from(padded, "base64").toString("utf-8");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/facilitators/alipay/openapi.ts
|
|
1302
|
+
function formatAlipayTimestamp(d = /* @__PURE__ */ new Date()) {
|
|
1303
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1304
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
1305
|
+
}
|
|
1306
|
+
function buildSigningString(params) {
|
|
1307
|
+
return Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join("&");
|
|
1308
|
+
}
|
|
1309
|
+
function responseWrapperKey(method) {
|
|
1310
|
+
return `${method.replace(/\./g, "_")}_response`;
|
|
1311
|
+
}
|
|
1312
|
+
async function alipayOpenApiCall(method, bizContent, config) {
|
|
1313
|
+
const publicParams = {
|
|
1314
|
+
app_id: config.app_id,
|
|
1315
|
+
method,
|
|
1316
|
+
format: "JSON",
|
|
1317
|
+
charset: "utf-8",
|
|
1318
|
+
sign_type: config.sign_type ?? "RSA2",
|
|
1319
|
+
timestamp: formatAlipayTimestamp(),
|
|
1320
|
+
version: "1.0",
|
|
1321
|
+
biz_content: JSON.stringify(bizContent)
|
|
1322
|
+
};
|
|
1323
|
+
const signingString = buildSigningString(publicParams);
|
|
1324
|
+
const sign = rsa2Sign(signingString, config.private_key_pem);
|
|
1325
|
+
const body = new URLSearchParams({ ...publicParams, sign }).toString();
|
|
1326
|
+
const response = await fetch(config.gateway_url, {
|
|
1327
|
+
method: "POST",
|
|
1328
|
+
headers: {
|
|
1329
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"
|
|
1330
|
+
},
|
|
1331
|
+
body
|
|
1332
|
+
});
|
|
1333
|
+
if (!response.ok) {
|
|
1334
|
+
const text = await response.text().catch(() => "<unreadable>");
|
|
1335
|
+
throw new Error(
|
|
1336
|
+
`Alipay Open API HTTP ${response.status} for ${method}: ${text.slice(0, 500)}`
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const json = await response.json();
|
|
1340
|
+
const wrapperKey = responseWrapperKey(method);
|
|
1341
|
+
const business = json[wrapperKey];
|
|
1342
|
+
if (business === void 0 || business === null || typeof business !== "object") {
|
|
1343
|
+
throw new Error(
|
|
1344
|
+
`Alipay Open API response missing "${wrapperKey}" wrapper for ${method}: ${JSON.stringify(json).slice(0, 500)}`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
return business;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// src/facilitators/alipay.ts
|
|
1351
|
+
var ALIPAY_NETWORK = "alipay";
|
|
1352
|
+
var ALIPAY_SCHEME = "alipay-aipay";
|
|
1353
|
+
var ALIPAY_GATEWAY_PROD = "https://openapi.alipay.com/gateway.do";
|
|
1354
|
+
var ALIPAY_GATEWAY_SANDBOX = "https://openapi.alipaydev.com/gateway.do";
|
|
1355
|
+
var ALIPAY_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
|
|
1356
|
+
var ALIPAY_PAY_BEFORE_MS = 30 * 60 * 1e3;
|
|
1357
|
+
var ALIPAY_SIGNING_FIELDS = [
|
|
1358
|
+
"amount",
|
|
1359
|
+
"currency",
|
|
1360
|
+
"goods_name",
|
|
1361
|
+
"out_trade_no",
|
|
1362
|
+
"pay_before",
|
|
1363
|
+
"resource_id",
|
|
1364
|
+
"seller_id",
|
|
1365
|
+
"service_id"
|
|
1366
|
+
];
|
|
1367
|
+
var AlipayFacilitator = class extends BaseFacilitator {
|
|
1368
|
+
name = "alipay";
|
|
1369
|
+
displayName = "Alipay AI Pay";
|
|
1370
|
+
supportedNetworks = [ALIPAY_NETWORK];
|
|
1371
|
+
config;
|
|
1372
|
+
constructor(config) {
|
|
1373
|
+
super();
|
|
1374
|
+
this.config = {
|
|
1375
|
+
gateway_url: ALIPAY_GATEWAY_PROD,
|
|
1376
|
+
sign_type: "RSA2",
|
|
1377
|
+
...config
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Build the 402 challenge for a service: signs the 8-field payload with
|
|
1382
|
+
* RSA2, packages the nested `{protocol, method}` JSON as Base64URL for
|
|
1383
|
+
* `Payment-Needed`, and emits the parallel x402 `accepts[]` entry.
|
|
1384
|
+
*/
|
|
1385
|
+
async createPaymentRequirements(opts) {
|
|
1386
|
+
if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
|
|
1387
|
+
throw new Error(
|
|
1388
|
+
`AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
const now = /* @__PURE__ */ new Date();
|
|
1392
|
+
const outTradeNo = opts.outTradeNo ?? generateOutTradeNo();
|
|
1393
|
+
const payBefore = formatPayBefore(now);
|
|
1394
|
+
const signedFields = {
|
|
1395
|
+
amount: opts.priceCny,
|
|
1396
|
+
currency: "CNY",
|
|
1397
|
+
goods_name: opts.goodsName,
|
|
1398
|
+
out_trade_no: outTradeNo,
|
|
1399
|
+
pay_before: payBefore,
|
|
1400
|
+
resource_id: opts.resourceId,
|
|
1401
|
+
seller_id: this.config.seller_id,
|
|
1402
|
+
service_id: opts.serviceId
|
|
1403
|
+
};
|
|
1404
|
+
const signingString = ALIPAY_SIGNING_FIELDS.map((k) => `${k}=${signedFields[k]}`).join("&");
|
|
1405
|
+
const seller_signature = rsa2Sign(signingString, this.config.private_key_pem);
|
|
1406
|
+
const challenge = {
|
|
1407
|
+
protocol: {
|
|
1408
|
+
out_trade_no: outTradeNo,
|
|
1409
|
+
amount: signedFields.amount,
|
|
1410
|
+
currency: signedFields.currency,
|
|
1411
|
+
resource_id: signedFields.resource_id,
|
|
1412
|
+
pay_before: payBefore,
|
|
1413
|
+
seller_signature,
|
|
1414
|
+
seller_sign_type: this.config.sign_type ?? "RSA2",
|
|
1415
|
+
seller_unique_id: this.config.seller_id
|
|
1416
|
+
},
|
|
1417
|
+
method: {
|
|
1418
|
+
seller_name: this.config.seller_name,
|
|
1419
|
+
seller_id: this.config.seller_id,
|
|
1420
|
+
seller_app_id: this.config.app_id,
|
|
1421
|
+
goods_name: signedFields.goods_name,
|
|
1422
|
+
seller_unique_id_key: "seller_id",
|
|
1423
|
+
service_id: signedFields.service_id
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
const paymentNeededHeader = base64url(JSON.stringify(challenge));
|
|
1427
|
+
const x402Accepts = {
|
|
1428
|
+
scheme: ALIPAY_SCHEME,
|
|
1429
|
+
network: ALIPAY_NETWORK,
|
|
1430
|
+
asset: "CNY",
|
|
1431
|
+
amount: opts.priceCny,
|
|
1432
|
+
payTo: this.config.seller_id,
|
|
1433
|
+
maxTimeoutSeconds: ALIPAY_PAY_BEFORE_MS / 1e3,
|
|
1434
|
+
extra: {
|
|
1435
|
+
payment_needed_header: paymentNeededHeader,
|
|
1436
|
+
out_trade_no: outTradeNo,
|
|
1437
|
+
pay_before: payBefore,
|
|
1438
|
+
service_id: signedFields.service_id
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
return { x402Accepts, paymentNeededHeader };
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Verify a `Payment-Proof` by calling
|
|
1445
|
+
* `alipay.aipay.agent.payment.verify` against the Alipay Open API.
|
|
1446
|
+
*
|
|
1447
|
+
* The proof is conveyed via `paymentPayload.payload`, which may be:
|
|
1448
|
+
* - a raw Base64URL string (the `Payment-Proof` header value), or
|
|
1449
|
+
* - an object `{ paymentProof: string }` / `{ proofHeader: string }`.
|
|
1450
|
+
*
|
|
1451
|
+
* All failure modes (malformed payload, network errors, Alipay
|
|
1452
|
+
* `code != 10000`) return `{ valid: false, error }`. No exception
|
|
1453
|
+
* escapes, regardless of client-supplied input.
|
|
1454
|
+
*/
|
|
1455
|
+
async verify(paymentPayload, _requirements) {
|
|
1456
|
+
try {
|
|
1457
|
+
const proofHeader = extractProofHeader(paymentPayload.payload);
|
|
1458
|
+
const decoded = decodeProof(proofHeader);
|
|
1459
|
+
const response = await alipayOpenApiCall(
|
|
1460
|
+
"alipay.aipay.agent.payment.verify",
|
|
1461
|
+
{
|
|
1462
|
+
payment_proof: decoded.protocol.payment_proof,
|
|
1463
|
+
trade_no: decoded.protocol.trade_no,
|
|
1464
|
+
client_session: decoded.method.client_session
|
|
1465
|
+
},
|
|
1466
|
+
this.getOpenApiConfig()
|
|
1467
|
+
);
|
|
1468
|
+
if (response.code !== "10000") {
|
|
1469
|
+
return {
|
|
1470
|
+
valid: false,
|
|
1471
|
+
error: `alipay verify ${response.code}: ${response.sub_msg ?? response.msg ?? "unknown"}`,
|
|
1472
|
+
details: {
|
|
1473
|
+
code: response.code,
|
|
1474
|
+
sub_code: response.sub_code,
|
|
1475
|
+
sub_msg: response.sub_msg
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
return {
|
|
1480
|
+
valid: true,
|
|
1481
|
+
details: {
|
|
1482
|
+
trade_no: response.trade_no ?? decoded.protocol.trade_no,
|
|
1483
|
+
amount: response.amount,
|
|
1484
|
+
out_trade_no: response.out_trade_no,
|
|
1485
|
+
resource_id: response.resource_id,
|
|
1486
|
+
active: response.active
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
} catch (e) {
|
|
1490
|
+
return {
|
|
1491
|
+
valid: false,
|
|
1492
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Settle by calling `alipay.aipay.agent.fulfillment.confirm` after the
|
|
1498
|
+
* service resource has been returned to the buyer.
|
|
1499
|
+
*
|
|
1500
|
+
* Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
|
|
1501
|
+
* (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
|
|
1502
|
+
* server) is expected to log fulfillment failures but NOT roll back
|
|
1503
|
+
* the already-delivered resource.
|
|
1504
|
+
*
|
|
1505
|
+
* Re-decodes `paymentPayload.payload` to extract `trade_no` (verify
|
|
1506
|
+
* does the same; the redundant Base64URL decode is negligible).
|
|
1507
|
+
*/
|
|
1508
|
+
async settle(paymentPayload, _requirements) {
|
|
1509
|
+
try {
|
|
1510
|
+
const proofHeader = extractProofHeader(paymentPayload.payload);
|
|
1511
|
+
const decoded = decodeProof(proofHeader);
|
|
1512
|
+
const tradeNo = decoded.protocol.trade_no;
|
|
1513
|
+
const response = await alipayOpenApiCall(
|
|
1514
|
+
"alipay.aipay.agent.fulfillment.confirm",
|
|
1515
|
+
{ trade_no: tradeNo },
|
|
1516
|
+
this.getOpenApiConfig()
|
|
1517
|
+
);
|
|
1518
|
+
if (response.code !== "10000") {
|
|
1519
|
+
return {
|
|
1520
|
+
success: false,
|
|
1521
|
+
transaction: tradeNo,
|
|
1522
|
+
error: `alipay fulfillment ${response.code}: ${response.sub_msg ?? response.msg ?? "unknown"}`,
|
|
1523
|
+
status: "fulfillment_failed"
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
return {
|
|
1527
|
+
success: true,
|
|
1528
|
+
transaction: tradeNo,
|
|
1529
|
+
status: "fulfilled"
|
|
1530
|
+
};
|
|
1531
|
+
} catch (e) {
|
|
1532
|
+
return {
|
|
1533
|
+
success: false,
|
|
1534
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Validate that the configured RSA keys parse and that the Open API
|
|
1540
|
+
* gateway is reachable. Does NOT make a real business API call (would
|
|
1541
|
+
* burn quota and could appear as a legitimate verify attempt in logs).
|
|
1542
|
+
*/
|
|
1543
|
+
async healthCheck() {
|
|
1544
|
+
const start = Date.now();
|
|
1545
|
+
try {
|
|
1546
|
+
import_node_crypto2.default.createPrivateKey(this.config.private_key_pem);
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
return {
|
|
1549
|
+
healthy: false,
|
|
1550
|
+
error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
try {
|
|
1554
|
+
import_node_crypto2.default.createPublicKey(this.config.alipay_public_key_pem);
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
return {
|
|
1557
|
+
healthy: false,
|
|
1558
|
+
error: `alipay_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
const gatewayUrl = this.config.gateway_url ?? ALIPAY_GATEWAY_PROD;
|
|
1562
|
+
const controller = new AbortController();
|
|
1563
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
1564
|
+
const response = await fetch(gatewayUrl, {
|
|
1565
|
+
method: "HEAD",
|
|
1566
|
+
signal: controller.signal
|
|
1567
|
+
}).catch(() => null);
|
|
1568
|
+
clearTimeout(timeout);
|
|
1569
|
+
const latencyMs = Date.now() - start;
|
|
1570
|
+
if (!response) {
|
|
1571
|
+
return { healthy: false, error: `gateway unreachable: ${gatewayUrl}`, latencyMs };
|
|
1572
|
+
}
|
|
1573
|
+
return { healthy: true, latencyMs };
|
|
1574
|
+
}
|
|
1575
|
+
/** Bundle the facilitator config into the shape openapi.ts wants. */
|
|
1576
|
+
getOpenApiConfig() {
|
|
1577
|
+
return {
|
|
1578
|
+
gateway_url: this.config.gateway_url ?? ALIPAY_GATEWAY_PROD,
|
|
1579
|
+
app_id: this.config.app_id,
|
|
1580
|
+
private_key_pem: this.config.private_key_pem,
|
|
1581
|
+
alipay_public_key_pem: this.config.alipay_public_key_pem,
|
|
1582
|
+
sign_type: this.config.sign_type
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
function generateOutTradeNo() {
|
|
1587
|
+
return "VID" + import_node_crypto2.default.randomBytes(22).toString("base64url").slice(0, 29);
|
|
1588
|
+
}
|
|
1589
|
+
function formatPayBefore(now) {
|
|
1590
|
+
const expiry = new Date(now.getTime() + ALIPAY_PAY_BEFORE_MS);
|
|
1591
|
+
return expiry.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1592
|
+
}
|
|
1593
|
+
function extractProofHeader(payload) {
|
|
1594
|
+
if (typeof payload === "string") {
|
|
1595
|
+
if (payload.length === 0) {
|
|
1596
|
+
throw new Error("alipay Payment-Proof is empty");
|
|
1597
|
+
}
|
|
1598
|
+
return payload;
|
|
1599
|
+
}
|
|
1600
|
+
if (payload !== null && typeof payload === "object") {
|
|
1601
|
+
const obj = payload;
|
|
1602
|
+
const candidate = obj.paymentProof ?? obj.proofHeader;
|
|
1603
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
1604
|
+
return candidate;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
"alipay payment payload must be a Base64URL string or {paymentProof: string} / {proofHeader: string}"
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
function decodeProof(proofHeader) {
|
|
1612
|
+
let parsed;
|
|
1613
|
+
try {
|
|
1614
|
+
parsed = JSON.parse(decodeBase64UrlWithPadFix(proofHeader));
|
|
1615
|
+
} catch (e) {
|
|
1616
|
+
throw new Error(
|
|
1617
|
+
`failed to decode Payment-Proof: ${e instanceof Error ? e.message : String(e)}`
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
1621
|
+
throw new Error("decoded Payment-Proof is not an object");
|
|
1622
|
+
}
|
|
1623
|
+
const obj = parsed;
|
|
1624
|
+
const protocol = obj.protocol;
|
|
1625
|
+
const method = obj.method;
|
|
1626
|
+
if (!protocol || typeof protocol.payment_proof !== "string") {
|
|
1627
|
+
throw new Error("decoded Payment-Proof missing protocol.payment_proof");
|
|
1628
|
+
}
|
|
1629
|
+
if (typeof protocol.trade_no !== "string") {
|
|
1630
|
+
throw new Error("decoded Payment-Proof missing protocol.trade_no");
|
|
1631
|
+
}
|
|
1632
|
+
if (!method || typeof method.client_session !== "string") {
|
|
1633
|
+
throw new Error("decoded Payment-Proof missing method.client_session");
|
|
1634
|
+
}
|
|
1635
|
+
return parsed;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/facilitators/wechat.ts
|
|
1639
|
+
var import_node_crypto4 = __toESM(require("crypto"));
|
|
1640
|
+
|
|
1641
|
+
// src/facilitators/wechat/sign.ts
|
|
1642
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
1643
|
+
var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
|
|
1644
|
+
function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
|
|
1645
|
+
return `${method.toUpperCase()}
|
|
1646
|
+
${urlPath}
|
|
1647
|
+
${timestamp}
|
|
1648
|
+
${nonce}
|
|
1649
|
+
${body}
|
|
1650
|
+
`;
|
|
1651
|
+
}
|
|
1652
|
+
function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
|
|
1653
|
+
const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
|
|
1654
|
+
const signer = import_node_crypto3.default.createSign("RSA-SHA256");
|
|
1655
|
+
signer.update(message, "utf-8");
|
|
1656
|
+
signer.end();
|
|
1657
|
+
return signer.sign(privateKeyPem, "base64");
|
|
1658
|
+
}
|
|
1659
|
+
function buildAuthorizationToken(args) {
|
|
1660
|
+
const fields = [
|
|
1661
|
+
`mchid="${args.mchid}"`,
|
|
1662
|
+
`nonce_str="${args.nonce}"`,
|
|
1663
|
+
`signature="${args.signature}"`,
|
|
1664
|
+
`timestamp="${args.timestamp}"`,
|
|
1665
|
+
`serial_no="${args.serialNo}"`
|
|
1666
|
+
].join(",");
|
|
1667
|
+
return `${WECHAT_AUTH_SCHEMA} ${fields}`;
|
|
1668
|
+
}
|
|
1669
|
+
function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
|
|
1670
|
+
try {
|
|
1671
|
+
const message = `${timestamp}
|
|
1672
|
+
${nonce}
|
|
1673
|
+
${body}
|
|
1674
|
+
`;
|
|
1675
|
+
const verifier = import_node_crypto3.default.createVerify("RSA-SHA256");
|
|
1676
|
+
verifier.update(message, "utf-8");
|
|
1677
|
+
verifier.end();
|
|
1678
|
+
return verifier.verify(platformPublicKeyPem, signature, "base64");
|
|
1679
|
+
} catch {
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
function generateNonce() {
|
|
1684
|
+
return import_node_crypto3.default.randomBytes(16).toString("hex");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/facilitators/wechat/api.ts
|
|
1688
|
+
var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
|
|
1689
|
+
var WechatApiError = class extends Error {
|
|
1690
|
+
status;
|
|
1691
|
+
code;
|
|
1692
|
+
constructor(message, status, code) {
|
|
1693
|
+
super(message);
|
|
1694
|
+
this.name = "WechatApiError";
|
|
1695
|
+
this.status = status;
|
|
1696
|
+
this.code = code;
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
async function wechatV3Call(method, urlPath, body, config) {
|
|
1700
|
+
const base = config.api_base ?? WECHAT_API_BASE;
|
|
1701
|
+
const bodyStr = body === null ? "" : JSON.stringify(body);
|
|
1702
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
1703
|
+
const nonce = generateNonce();
|
|
1704
|
+
const signature = wechatV3Sign(
|
|
1705
|
+
method,
|
|
1706
|
+
urlPath,
|
|
1707
|
+
timestamp,
|
|
1708
|
+
nonce,
|
|
1709
|
+
bodyStr,
|
|
1710
|
+
config.private_key_pem
|
|
1711
|
+
);
|
|
1712
|
+
const authorization = buildAuthorizationToken({
|
|
1713
|
+
mchid: config.mchid,
|
|
1714
|
+
serialNo: config.serial_no,
|
|
1715
|
+
nonce,
|
|
1716
|
+
timestamp,
|
|
1717
|
+
signature
|
|
1718
|
+
});
|
|
1719
|
+
const response = await fetch(`${base}${urlPath}`, {
|
|
1720
|
+
method,
|
|
1721
|
+
headers: {
|
|
1722
|
+
Authorization: authorization,
|
|
1723
|
+
Accept: "application/json",
|
|
1724
|
+
"Content-Type": "application/json",
|
|
1725
|
+
// WeChat requires a non-empty UA; some edge nodes 403 a blank one.
|
|
1726
|
+
"User-Agent": "moltspay-wechat/1.0"
|
|
1727
|
+
},
|
|
1728
|
+
body: method === "GET" ? void 0 : bodyStr
|
|
1729
|
+
});
|
|
1730
|
+
const text = await response.text();
|
|
1731
|
+
if (config.platform_public_key_pem && text.length > 0) {
|
|
1732
|
+
const ts = response.headers.get("Wechatpay-Timestamp");
|
|
1733
|
+
const nc = response.headers.get("Wechatpay-Nonce");
|
|
1734
|
+
const sig = response.headers.get("Wechatpay-Signature");
|
|
1735
|
+
if (!ts || !nc || !sig) {
|
|
1736
|
+
throw new Error(
|
|
1737
|
+
`WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
|
|
1741
|
+
throw new Error(
|
|
1742
|
+
`WeChat v3 ${method} ${urlPath}: response signature verification failed`
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
let json = {};
|
|
1747
|
+
if (text.length > 0) {
|
|
1748
|
+
try {
|
|
1749
|
+
json = JSON.parse(text);
|
|
1750
|
+
} catch {
|
|
1751
|
+
throw new Error(
|
|
1752
|
+
`WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
if (!response.ok) {
|
|
1757
|
+
const code = typeof json.code === "string" ? json.code : void 0;
|
|
1758
|
+
const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
|
|
1759
|
+
throw new WechatApiError(
|
|
1760
|
+
`WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
|
|
1761
|
+
response.status,
|
|
1762
|
+
code
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
return { status: response.status, body: json };
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// src/facilitators/wechat.ts
|
|
1769
|
+
var WECHAT_NETWORK = "wechat";
|
|
1770
|
+
var WECHAT_SCHEME = "wechatpay-native";
|
|
1771
|
+
var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
|
|
1772
|
+
var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
|
|
1773
|
+
var WechatFacilitator = class extends BaseFacilitator {
|
|
1774
|
+
name = "wechat";
|
|
1775
|
+
displayName = "WeChat Pay";
|
|
1776
|
+
supportedNetworks = [WECHAT_NETWORK];
|
|
1777
|
+
config;
|
|
1778
|
+
constructor(config) {
|
|
1779
|
+
super();
|
|
1780
|
+
this.config = { api_base: WECHAT_API_BASE, ...config };
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* Place a Native order and build the 402 challenge. The returned
|
|
1784
|
+
* `code_url` is payer-agnostic — any WeChat user may scan it.
|
|
1785
|
+
*/
|
|
1786
|
+
async createPaymentRequirements(opts) {
|
|
1787
|
+
if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
|
|
1788
|
+
throw new Error(
|
|
1789
|
+
`WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
const total = cnyToFen(opts.priceCny);
|
|
1793
|
+
if (total < 1) {
|
|
1794
|
+
throw new Error(
|
|
1795
|
+
`WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
|
|
1799
|
+
const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
|
|
1800
|
+
const body = {
|
|
1801
|
+
appid: this.config.appid,
|
|
1802
|
+
mchid: this.config.mchid,
|
|
1803
|
+
description: opts.description,
|
|
1804
|
+
out_trade_no: outTradeNo,
|
|
1805
|
+
notify_url: this.config.notify_url,
|
|
1806
|
+
time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
|
|
1807
|
+
amount: { total, currency: "CNY" }
|
|
1808
|
+
};
|
|
1809
|
+
if (opts.attach) {
|
|
1810
|
+
const attachStr = JSON.stringify(opts.attach);
|
|
1811
|
+
if (Buffer.byteLength(attachStr, "utf8") > 128) {
|
|
1812
|
+
throw new Error(
|
|
1813
|
+
`WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
body.attach = attachStr;
|
|
1817
|
+
}
|
|
1818
|
+
const { body: resp } = await wechatV3Call(
|
|
1819
|
+
"POST",
|
|
1820
|
+
"/v3/pay/transactions/native",
|
|
1821
|
+
body,
|
|
1822
|
+
this.getApiConfig()
|
|
1823
|
+
);
|
|
1824
|
+
const codeUrl = resp.code_url;
|
|
1825
|
+
if (typeof codeUrl !== "string" || codeUrl.length === 0) {
|
|
1826
|
+
throw new Error(
|
|
1827
|
+
`WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
const x402Accepts = {
|
|
1831
|
+
scheme: WECHAT_SCHEME,
|
|
1832
|
+
network: WECHAT_NETWORK,
|
|
1833
|
+
asset: "CNY",
|
|
1834
|
+
amount: opts.priceCny,
|
|
1835
|
+
payTo: this.config.mchid,
|
|
1836
|
+
maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
|
|
1837
|
+
extra: {
|
|
1838
|
+
code_url: codeUrl,
|
|
1839
|
+
out_trade_no: outTradeNo
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
return { x402Accepts, codeUrl, outTradeNo };
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
|
|
1846
|
+
* (missing out_trade_no, gateway error, not-yet-paid) return
|
|
1847
|
+
* `{ valid: false, error }`; no exception escapes.
|
|
1848
|
+
*/
|
|
1849
|
+
async verify(paymentPayload, requirements) {
|
|
1850
|
+
try {
|
|
1851
|
+
const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
|
|
1852
|
+
const resp = await this.queryOrder(outTradeNo);
|
|
1853
|
+
const tradeState = resp.trade_state;
|
|
1854
|
+
if (tradeState !== "SUCCESS") {
|
|
1855
|
+
return {
|
|
1856
|
+
valid: false,
|
|
1857
|
+
error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
|
|
1858
|
+
details: { trade_state: tradeState, out_trade_no: outTradeNo }
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
valid: true,
|
|
1863
|
+
details: {
|
|
1864
|
+
trade_state: tradeState,
|
|
1865
|
+
transaction_id: resp.transaction_id,
|
|
1866
|
+
out_trade_no: resp.out_trade_no ?? outTradeNo,
|
|
1867
|
+
amount: resp.amount,
|
|
1868
|
+
attach: resp.attach,
|
|
1869
|
+
// Payer identity, gateway-attested. Present on a SUCCESS Native
|
|
1870
|
+
// order even though order creation was payer-agnostic; anchors the
|
|
1871
|
+
// custodial balance to a real WeChat user. @see WECHAT fiat auth design.
|
|
1872
|
+
openid: resp.payer?.openid
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
} catch (e) {
|
|
1876
|
+
return { valid: false, error: e instanceof Error ? e.message : String(e) };
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Confirm settlement. Native captures funds at SUCCESS, so this is an
|
|
1881
|
+
* idempotent re-confirm that returns the `transaction_id`. Like Alipay's
|
|
1882
|
+
* fulfillment confirm, failures are surfaced but non-fatal to an
|
|
1883
|
+
* already-delivered resource (caller logs, does not roll back).
|
|
1884
|
+
*/
|
|
1885
|
+
async settle(paymentPayload, requirements) {
|
|
1886
|
+
try {
|
|
1887
|
+
const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
|
|
1888
|
+
const resp = await this.queryOrder(outTradeNo);
|
|
1889
|
+
const tradeState = resp.trade_state;
|
|
1890
|
+
const transactionId = resp.transaction_id;
|
|
1891
|
+
if (tradeState !== "SUCCESS") {
|
|
1892
|
+
return {
|
|
1893
|
+
success: false,
|
|
1894
|
+
transaction: transactionId,
|
|
1895
|
+
error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
|
|
1896
|
+
status: tradeState
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
return { success: true, transaction: transactionId, status: "fulfilled" };
|
|
1900
|
+
} catch (e) {
|
|
1901
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Validate keys parse, apiv3 key length, and gateway reachability. Does
|
|
1906
|
+
* NOT make a business API call.
|
|
1907
|
+
*/
|
|
1908
|
+
async healthCheck() {
|
|
1909
|
+
const start = Date.now();
|
|
1910
|
+
try {
|
|
1911
|
+
import_node_crypto4.default.createPrivateKey(this.config.private_key_pem);
|
|
1912
|
+
} catch (e) {
|
|
1913
|
+
return {
|
|
1914
|
+
healthy: false,
|
|
1915
|
+
error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
if (this.config.platform_public_key_pem) {
|
|
1919
|
+
try {
|
|
1920
|
+
import_node_crypto4.default.createPublicKey(this.config.platform_public_key_pem);
|
|
1921
|
+
} catch (e) {
|
|
1922
|
+
return {
|
|
1923
|
+
healthy: false,
|
|
1924
|
+
error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
|
|
1929
|
+
return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
|
|
1930
|
+
}
|
|
1931
|
+
const base = this.config.api_base ?? WECHAT_API_BASE;
|
|
1932
|
+
const controller = new AbortController();
|
|
1933
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
1934
|
+
const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
|
|
1935
|
+
clearTimeout(timeout);
|
|
1936
|
+
const latencyMs = Date.now() - start;
|
|
1937
|
+
if (!response) {
|
|
1938
|
+
return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
|
|
1939
|
+
}
|
|
1940
|
+
return { healthy: true, latencyMs };
|
|
1941
|
+
}
|
|
1942
|
+
/** Query a Native order by out_trade_no. The query string is part of the signed path. */
|
|
1943
|
+
async queryOrder(outTradeNo) {
|
|
1944
|
+
const path2 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
|
|
1945
|
+
const { body } = await wechatV3Call("GET", path2, null, this.getApiConfig());
|
|
1946
|
+
return body;
|
|
1947
|
+
}
|
|
1948
|
+
/** Project the facilitator config down to what api.ts needs. */
|
|
1949
|
+
getApiConfig() {
|
|
1950
|
+
return {
|
|
1951
|
+
mchid: this.config.mchid,
|
|
1952
|
+
serial_no: this.config.serial_no,
|
|
1953
|
+
private_key_pem: this.config.private_key_pem,
|
|
1954
|
+
platform_public_key_pem: this.config.platform_public_key_pem,
|
|
1955
|
+
api_base: this.config.api_base
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
function cnyToFen(cny) {
|
|
1960
|
+
return Math.round(parseFloat(cny) * 100);
|
|
1961
|
+
}
|
|
1962
|
+
function generateOutTradeNo2() {
|
|
1963
|
+
return "WX" + import_node_crypto4.default.randomBytes(15).toString("hex");
|
|
1964
|
+
}
|
|
1965
|
+
function parseWechatAttach(attach) {
|
|
1966
|
+
if (typeof attach !== "string" || attach.length === 0) return null;
|
|
1967
|
+
try {
|
|
1968
|
+
const parsed = JSON.parse(attach);
|
|
1969
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1970
|
+
} catch {
|
|
1971
|
+
return null;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function formatTimeExpire(d) {
|
|
1975
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1976
|
+
const offMin = -d.getTimezoneOffset();
|
|
1977
|
+
const sign = offMin >= 0 ? "+" : "-";
|
|
1978
|
+
const abs = Math.abs(offMin);
|
|
1979
|
+
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)}`;
|
|
1980
|
+
}
|
|
1981
|
+
function extractOutTradeNo(paymentPayload, requirements) {
|
|
1982
|
+
const p = paymentPayload?.payload;
|
|
1983
|
+
if (typeof p === "string" && p.length > 0) return p;
|
|
1984
|
+
if (p !== null && typeof p === "object") {
|
|
1985
|
+
const obj = p;
|
|
1986
|
+
const cand = obj.out_trade_no ?? obj.outTradeNo;
|
|
1987
|
+
if (typeof cand === "string" && cand.length > 0) return cand;
|
|
1988
|
+
}
|
|
1989
|
+
const fromReq = requirements?.extra?.out_trade_no;
|
|
1990
|
+
if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
|
|
1991
|
+
throw new Error(
|
|
1992
|
+
"wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// src/facilitators/balance/ledger.ts
|
|
1997
|
+
var import_node_crypto5 = require("crypto");
|
|
1998
|
+
var DEFAULT_SINGLE_LIMIT_SAT = 500;
|
|
1999
|
+
var DEFAULT_DAILY_LIMIT_SAT = 1e3;
|
|
2000
|
+
function toSat(amount) {
|
|
2001
|
+
const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
|
|
2002
|
+
if (!/^\d+(\.\d{1,2})?$/.test(s)) {
|
|
2003
|
+
throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
|
|
2004
|
+
}
|
|
2005
|
+
const [whole, frac = ""] = s.split(".");
|
|
2006
|
+
return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
|
|
2007
|
+
}
|
|
2008
|
+
function fromSat(sat) {
|
|
2009
|
+
return (sat / 100).toFixed(2);
|
|
2010
|
+
}
|
|
2011
|
+
var BalanceLedger = class {
|
|
2012
|
+
db;
|
|
2013
|
+
defaultSingleLimitSat;
|
|
2014
|
+
defaultDailyLimitSat;
|
|
2015
|
+
constructor(config) {
|
|
2016
|
+
const getBuiltin = process.getBuiltinModule;
|
|
2017
|
+
const sqlite = getBuiltin?.("node:sqlite");
|
|
2018
|
+
if (!sqlite?.DatabaseSync) {
|
|
2019
|
+
throw new Error(
|
|
2020
|
+
`The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
const { DatabaseSync } = sqlite;
|
|
2024
|
+
this.db = new DatabaseSync(config.dbPath);
|
|
2025
|
+
this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
|
|
2026
|
+
this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
|
|
2027
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
2028
|
+
this.db.exec(`
|
|
2029
|
+
CREATE TABLE IF NOT EXISTS buyers (
|
|
2030
|
+
buyer_id TEXT PRIMARY KEY,
|
|
2031
|
+
display_name TEXT,
|
|
2032
|
+
balance_sat INTEGER NOT NULL DEFAULT 0,
|
|
2033
|
+
total_topup_sat INTEGER NOT NULL DEFAULT 0,
|
|
2034
|
+
total_spent_sat INTEGER NOT NULL DEFAULT 0,
|
|
2035
|
+
daily_limit_sat INTEGER NOT NULL,
|
|
2036
|
+
single_limit_sat INTEGER NOT NULL,
|
|
2037
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
2038
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2039
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2040
|
+
);
|
|
2041
|
+
CREATE TABLE IF NOT EXISTS ledger_transactions (
|
|
2042
|
+
id TEXT PRIMARY KEY,
|
|
2043
|
+
buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
|
|
2044
|
+
type TEXT NOT NULL,
|
|
2045
|
+
amount_sat INTEGER NOT NULL,
|
|
2046
|
+
service TEXT,
|
|
2047
|
+
description TEXT,
|
|
2048
|
+
request_id TEXT,
|
|
2049
|
+
external_ref TEXT,
|
|
2050
|
+
refunds_tx_id TEXT,
|
|
2051
|
+
status TEXT NOT NULL DEFAULT 'completed',
|
|
2052
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2053
|
+
);
|
|
2054
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
|
|
2055
|
+
ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
|
|
2056
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
|
|
2057
|
+
ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
|
|
2058
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
|
|
2059
|
+
ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
|
|
2060
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
|
|
2061
|
+
ON ledger_transactions(buyer_id, created_at);
|
|
2062
|
+
CREATE TABLE IF NOT EXISTS ledger_meta (
|
|
2063
|
+
key TEXT PRIMARY KEY,
|
|
2064
|
+
value TEXT NOT NULL
|
|
2065
|
+
);
|
|
2066
|
+
`);
|
|
2067
|
+
const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
|
|
2068
|
+
if (!cols.includes("signer_address")) {
|
|
2069
|
+
this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
|
|
2070
|
+
}
|
|
2071
|
+
if (!cols.includes("wechat_openid")) {
|
|
2072
|
+
this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
|
|
2073
|
+
}
|
|
2074
|
+
const currency = config.currency ?? "USD";
|
|
2075
|
+
const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
|
|
2076
|
+
if (existingCurrency) {
|
|
2077
|
+
if (existingCurrency.value !== currency) {
|
|
2078
|
+
throw new Error(
|
|
2079
|
+
`Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
} else {
|
|
2083
|
+
this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/** Fetch a buyer, or null. */
|
|
2087
|
+
getBuyer(buyerId) {
|
|
2088
|
+
const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
|
|
2089
|
+
return row ?? null;
|
|
2090
|
+
}
|
|
2091
|
+
/** Fetch a buyer, creating an empty active account on first sight. */
|
|
2092
|
+
getOrCreateBuyer(buyerId, displayName) {
|
|
2093
|
+
const existing = this.getBuyer(buyerId);
|
|
2094
|
+
if (existing) return existing;
|
|
2095
|
+
this.db.prepare(
|
|
2096
|
+
`INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
|
|
2097
|
+
VALUES (?, ?, ?, ?)`
|
|
2098
|
+
).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
|
|
2099
|
+
return this.getBuyer(buyerId);
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Record the gateway-attested WeChat payer openid that funded a buyer, on
|
|
2103
|
+
* top-up confirm. Idempotent and observational (stage 1a): it never
|
|
2104
|
+
* overwrites an existing binding with a *different* openid — a conflict is
|
|
2105
|
+
* reported to the caller (possible account sharing / spoof) but not
|
|
2106
|
+
* enforced here. Creates the buyer if absent.
|
|
2107
|
+
*/
|
|
2108
|
+
bindOpenid(buyerId, openid) {
|
|
2109
|
+
const buyer = this.getOrCreateBuyer(buyerId);
|
|
2110
|
+
if (buyer.wechat_openid === openid) {
|
|
2111
|
+
return { bound: true, conflict: false, existing: openid };
|
|
2112
|
+
}
|
|
2113
|
+
if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
|
|
2114
|
+
return { bound: false, conflict: true, existing: buyer.wechat_openid };
|
|
2115
|
+
}
|
|
2116
|
+
this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
|
|
2117
|
+
return { bound: true, conflict: false, existing: null };
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* TOFU-bind the account's spending signer address (EVM, lowercase). First
|
|
2121
|
+
* signed request records it; later requests must match. A mismatch is
|
|
2122
|
+
* reported (`conflict`) so the caller can reject under `enforce` — it is
|
|
2123
|
+
* never silently overwritten. Creates the buyer if absent.
|
|
2124
|
+
*/
|
|
2125
|
+
bindSigner(buyerId, address) {
|
|
2126
|
+
const a = address.toLowerCase();
|
|
2127
|
+
const buyer = this.getOrCreateBuyer(buyerId);
|
|
2128
|
+
if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
|
|
2129
|
+
if (buyer.signer_address && buyer.signer_address !== a) {
|
|
2130
|
+
return { bound: false, conflict: true, existing: buyer.signer_address };
|
|
2131
|
+
}
|
|
2132
|
+
this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
|
|
2133
|
+
return { bound: true, conflict: false, existing: null };
|
|
2134
|
+
}
|
|
2135
|
+
/** Sum of today's (UTC) completed deducts minus refunds issued against them. */
|
|
2136
|
+
spentTodaySat(buyerId) {
|
|
2137
|
+
const row = this.db.prepare(
|
|
2138
|
+
`SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
|
|
2139
|
+
FROM ledger_transactions
|
|
2140
|
+
WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
|
|
2141
|
+
).get(buyerId);
|
|
2142
|
+
return Math.max(0, row.spent);
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Read-only deduction precheck (the rail's `verify`). Never mutates.
|
|
2146
|
+
* Returns the same error codes `deduct` would.
|
|
2147
|
+
*/
|
|
2148
|
+
checkDeduct(buyerId, amountSat) {
|
|
2149
|
+
const buyer = this.getBuyer(buyerId);
|
|
2150
|
+
if (!buyer) return { success: false, error: "buyer_not_found" };
|
|
2151
|
+
if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
|
|
2152
|
+
if (amountSat > buyer.single_limit_sat) {
|
|
2153
|
+
return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
|
|
2154
|
+
}
|
|
2155
|
+
const spent = this.spentTodaySat(buyerId);
|
|
2156
|
+
if (spent + amountSat > buyer.daily_limit_sat) {
|
|
2157
|
+
return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
|
|
2158
|
+
}
|
|
2159
|
+
if (buyer.balance_sat < amountSat) {
|
|
2160
|
+
return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
|
|
2161
|
+
}
|
|
2162
|
+
return { success: true, balanceSat: buyer.balance_sat };
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
|
|
2166
|
+
* SQLite transaction; a `request_id` replay returns the original tx
|
|
2167
|
+
* without charging again.
|
|
2168
|
+
*/
|
|
2169
|
+
deduct(opts) {
|
|
2170
|
+
if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
|
|
2171
|
+
throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
|
|
2172
|
+
}
|
|
2173
|
+
if (opts.requestId) {
|
|
2174
|
+
const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
|
|
2175
|
+
if (prior) {
|
|
2176
|
+
const buyer = this.getBuyer(prior.buyer_id);
|
|
2177
|
+
return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2181
|
+
try {
|
|
2182
|
+
const check = this.checkDeduct(opts.buyerId, opts.amountSat);
|
|
2183
|
+
if (!check.success) {
|
|
2184
|
+
this.db.exec("ROLLBACK");
|
|
2185
|
+
return check;
|
|
2186
|
+
}
|
|
2187
|
+
const updated = this.db.prepare(
|
|
2188
|
+
`UPDATE buyers SET
|
|
2189
|
+
balance_sat = balance_sat - ?,
|
|
2190
|
+
total_spent_sat = total_spent_sat + ?,
|
|
2191
|
+
updated_at = datetime('now')
|
|
2192
|
+
WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
|
|
2193
|
+
).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
|
|
2194
|
+
if (Number(updated.changes) !== 1) {
|
|
2195
|
+
this.db.exec("ROLLBACK");
|
|
2196
|
+
return { success: false, error: "insufficient_balance" };
|
|
2197
|
+
}
|
|
2198
|
+
const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
|
|
2199
|
+
this.db.prepare(
|
|
2200
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
|
|
2201
|
+
VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
|
|
2202
|
+
).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
|
|
2203
|
+
this.db.exec("COMMIT");
|
|
2204
|
+
const buyer = this.getBuyer(opts.buyerId);
|
|
2205
|
+
return { success: true, txId, balanceSat: buyer.balance_sat };
|
|
2206
|
+
} catch (err) {
|
|
2207
|
+
try {
|
|
2208
|
+
this.db.exec("ROLLBACK");
|
|
2209
|
+
} catch {
|
|
2210
|
+
}
|
|
2211
|
+
throw err;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Credit a top-up. `externalRef` is the settlement proof reference
|
|
2216
|
+
* (on-chain tx hash / fiat trade number) and is unique: replaying the same
|
|
2217
|
+
* reference returns the original row without crediting twice.
|
|
2218
|
+
*/
|
|
2219
|
+
topup(opts) {
|
|
2220
|
+
if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
|
|
2221
|
+
throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
|
|
2222
|
+
}
|
|
2223
|
+
const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
|
|
2224
|
+
if (prior) {
|
|
2225
|
+
const buyer2 = this.getBuyer(prior.buyer_id);
|
|
2226
|
+
return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
|
|
2227
|
+
}
|
|
2228
|
+
this.getOrCreateBuyer(opts.buyerId);
|
|
2229
|
+
const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
|
|
2230
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2231
|
+
try {
|
|
2232
|
+
this.db.prepare(
|
|
2233
|
+
`UPDATE buyers SET
|
|
2234
|
+
balance_sat = balance_sat + ?,
|
|
2235
|
+
total_topup_sat = total_topup_sat + ?,
|
|
2236
|
+
updated_at = datetime('now')
|
|
2237
|
+
WHERE buyer_id = ?`
|
|
2238
|
+
).run(opts.amountSat, opts.amountSat, opts.buyerId);
|
|
2239
|
+
this.db.prepare(
|
|
2240
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
|
|
2241
|
+
VALUES (?, ?, 'topup', ?, ?, ?)`
|
|
2242
|
+
).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
|
|
2243
|
+
this.db.exec("COMMIT");
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
try {
|
|
2246
|
+
this.db.exec("ROLLBACK");
|
|
2247
|
+
} catch {
|
|
2248
|
+
}
|
|
2249
|
+
throw err;
|
|
2250
|
+
}
|
|
2251
|
+
const buyer = this.getBuyer(opts.buyerId);
|
|
2252
|
+
return { txId, balanceSat: buyer.balance_sat };
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Reverse a deduct (service failed after the charge). Idempotent: the
|
|
2256
|
+
* unique index on `refunds_tx_id` means a second refund of the same deduct
|
|
2257
|
+
* returns the original refund row.
|
|
2258
|
+
*/
|
|
2259
|
+
refund(deductTxId, reason) {
|
|
2260
|
+
const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
|
|
2261
|
+
if (!deductRow) return { success: false, error: "tx_not_found" };
|
|
2262
|
+
if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
|
|
2263
|
+
const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
|
|
2264
|
+
if (priorRefund) {
|
|
2265
|
+
const buyer = this.getBuyer(deductRow.buyer_id);
|
|
2266
|
+
return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
|
|
2267
|
+
}
|
|
2268
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2269
|
+
try {
|
|
2270
|
+
this.db.prepare(
|
|
2271
|
+
`UPDATE buyers SET
|
|
2272
|
+
balance_sat = balance_sat + ?,
|
|
2273
|
+
total_spent_sat = total_spent_sat - ?,
|
|
2274
|
+
updated_at = datetime('now')
|
|
2275
|
+
WHERE buyer_id = ?`
|
|
2276
|
+
).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
|
|
2277
|
+
this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
|
|
2278
|
+
const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
|
|
2279
|
+
this.db.prepare(
|
|
2280
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
|
|
2281
|
+
VALUES (?, ?, 'refund', ?, ?, ?)`
|
|
2282
|
+
).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
|
|
2283
|
+
this.db.exec("COMMIT");
|
|
2284
|
+
const buyer = this.getBuyer(deductRow.buyer_id);
|
|
2285
|
+
return { success: true, txId, balanceSat: buyer.balance_sat };
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
try {
|
|
2288
|
+
this.db.exec("ROLLBACK");
|
|
2289
|
+
} catch {
|
|
2290
|
+
}
|
|
2291
|
+
throw err;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/** Paged transaction history, newest first (rowid breaks same-second ties). */
|
|
2295
|
+
listTransactions(buyerId, limit = 20, offset = 0) {
|
|
2296
|
+
return this.db.prepare(
|
|
2297
|
+
`SELECT * FROM ledger_transactions WHERE buyer_id = ?
|
|
2298
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
|
|
2299
|
+
).all(buyerId, limit, offset);
|
|
2300
|
+
}
|
|
2301
|
+
/** Quick integrity probe for healthCheck(). */
|
|
2302
|
+
integrityOk() {
|
|
2303
|
+
const row = this.db.prepare(`PRAGMA quick_check`).get();
|
|
2304
|
+
return row.quick_check === "ok";
|
|
2305
|
+
}
|
|
2306
|
+
close() {
|
|
2307
|
+
this.db.close();
|
|
2308
|
+
}
|
|
2309
|
+
};
|
|
2310
|
+
|
|
2311
|
+
// src/facilitators/balance/auth.ts
|
|
2312
|
+
var import_ethers2 = require("ethers");
|
|
2313
|
+
var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
|
|
2314
|
+
var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
2315
|
+
function extractBalanceAuth(raw) {
|
|
2316
|
+
if (!raw || typeof raw !== "object") return null;
|
|
2317
|
+
const a = raw;
|
|
2318
|
+
if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
|
|
2319
|
+
return { timestamp: a.timestamp, signature: a.signature };
|
|
2320
|
+
}
|
|
2321
|
+
return null;
|
|
2322
|
+
}
|
|
2323
|
+
function buildDeductMessage(f) {
|
|
2324
|
+
return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
|
|
2325
|
+
}
|
|
2326
|
+
function verifyDeductAuth(opts) {
|
|
2327
|
+
const { auth } = opts;
|
|
2328
|
+
if (!auth) return { ok: false, reason: "no_signature" };
|
|
2329
|
+
if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
|
|
2330
|
+
if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
|
|
2331
|
+
return { ok: false, reason: "timestamp_skew" };
|
|
2332
|
+
}
|
|
2333
|
+
const message = buildDeductMessage({
|
|
2334
|
+
buyerId: opts.buyerId,
|
|
2335
|
+
requestId: opts.requestId,
|
|
2336
|
+
service: opts.service,
|
|
2337
|
+
timestamp: auth.timestamp
|
|
2338
|
+
});
|
|
2339
|
+
try {
|
|
2340
|
+
const recovered = import_ethers2.ethers.verifyMessage(message, auth.signature).toLowerCase();
|
|
2341
|
+
return { ok: true, recovered };
|
|
2342
|
+
} catch {
|
|
2343
|
+
return { ok: false, reason: "bad_signature" };
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/facilitators/balance.ts
|
|
2348
|
+
var BALANCE_NETWORK = "balance";
|
|
2349
|
+
var BALANCE_SCHEME = "balance";
|
|
2350
|
+
function extractBalancePayload(payment) {
|
|
2351
|
+
const p = payment.payload;
|
|
2352
|
+
if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
|
|
2353
|
+
return {
|
|
2354
|
+
buyer_id: p.buyer_id,
|
|
2355
|
+
request_id: typeof p.request_id === "string" ? p.request_id : void 0,
|
|
2356
|
+
auth: extractBalanceAuth(p.auth) ?? void 0
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
return null;
|
|
2360
|
+
}
|
|
2361
|
+
var BalanceFacilitator = class extends BaseFacilitator {
|
|
2362
|
+
name = "balance";
|
|
2363
|
+
displayName = "Custodial Balance";
|
|
2364
|
+
supportedNetworks = [BALANCE_NETWORK];
|
|
2365
|
+
currency;
|
|
2366
|
+
/** User-auth rollout gate for deductions (off | shadow | enforce). */
|
|
2367
|
+
authMode;
|
|
2368
|
+
ledger;
|
|
2369
|
+
constructor(config) {
|
|
2370
|
+
super();
|
|
2371
|
+
this.currency = config.currency ?? "USD";
|
|
2372
|
+
this.authMode = config.auth_mode ?? "off";
|
|
2373
|
+
const ledgerConfig = {
|
|
2374
|
+
dbPath: config.db_path,
|
|
2375
|
+
defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
|
|
2376
|
+
defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
|
|
2377
|
+
currency: this.currency
|
|
2378
|
+
};
|
|
2379
|
+
this.ledger = new BalanceLedger(ledgerConfig);
|
|
2380
|
+
}
|
|
2381
|
+
/** Direct ledger access for the server's balance-management endpoints. */
|
|
2382
|
+
getLedger() {
|
|
2383
|
+
return this.ledger;
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
|
|
2387
|
+
*/
|
|
2388
|
+
createPaymentRequirements(opts) {
|
|
2389
|
+
toSat(opts.price);
|
|
2390
|
+
return {
|
|
2391
|
+
scheme: BALANCE_SCHEME,
|
|
2392
|
+
network: BALANCE_NETWORK,
|
|
2393
|
+
asset: this.currency,
|
|
2394
|
+
amount: opts.price,
|
|
2395
|
+
payTo: "custodial",
|
|
2396
|
+
maxTimeoutSeconds: 30,
|
|
2397
|
+
extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
/** Read-only funds/limits precheck. Never mutates the ledger. */
|
|
2401
|
+
async verify(paymentPayload, requirements) {
|
|
2402
|
+
const payload = extractBalancePayload(paymentPayload);
|
|
2403
|
+
if (!payload) {
|
|
2404
|
+
return { valid: false, error: "Missing buyer_id in balance payment payload" };
|
|
2405
|
+
}
|
|
2406
|
+
let amountSat;
|
|
2407
|
+
try {
|
|
2408
|
+
amountSat = toSat(requirements.amount);
|
|
2409
|
+
} catch (err) {
|
|
2410
|
+
return { valid: false, error: err.message };
|
|
2411
|
+
}
|
|
2412
|
+
const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
|
|
2413
|
+
if (!check.success) {
|
|
2414
|
+
return {
|
|
2415
|
+
valid: false,
|
|
2416
|
+
error: this.describeDeductError(check),
|
|
2417
|
+
details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
return { valid: true, details: { balance: fromSat(check.balanceSat) } };
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* The atomic deduction. Idempotent on `request_id`; the returned
|
|
2424
|
+
* `transaction` is the ledger tx id (usable for `refund`).
|
|
2425
|
+
*/
|
|
2426
|
+
async settle(paymentPayload, requirements) {
|
|
2427
|
+
const payload = extractBalancePayload(paymentPayload);
|
|
2428
|
+
if (!payload) {
|
|
2429
|
+
return { success: false, error: "Missing buyer_id in balance payment payload" };
|
|
2430
|
+
}
|
|
2431
|
+
let amountSat;
|
|
2432
|
+
try {
|
|
2433
|
+
amountSat = toSat(requirements.amount);
|
|
2434
|
+
} catch (err) {
|
|
2435
|
+
return { success: false, error: err.message };
|
|
2436
|
+
}
|
|
2437
|
+
const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
|
|
2438
|
+
let result;
|
|
2439
|
+
try {
|
|
2440
|
+
result = this.ledger.deduct({
|
|
2441
|
+
buyerId: payload.buyer_id,
|
|
2442
|
+
amountSat,
|
|
2443
|
+
requestId: payload.request_id,
|
|
2444
|
+
service: serviceId
|
|
2445
|
+
});
|
|
2446
|
+
} catch (err) {
|
|
2447
|
+
return { success: false, error: `Ledger deduct failed: ${err.message}` };
|
|
2448
|
+
}
|
|
2449
|
+
if (!result.success) {
|
|
2450
|
+
return { success: false, error: this.describeDeductError(result), status: result.error };
|
|
2451
|
+
}
|
|
2452
|
+
return {
|
|
2453
|
+
success: true,
|
|
2454
|
+
transaction: result.txId,
|
|
2455
|
+
status: result.replayed ? "replayed" : "deducted"
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
/** Reverse a deduct after a downstream failure. Idempotent per deduct. */
|
|
2459
|
+
refund(deductTxId, reason) {
|
|
2460
|
+
return this.ledger.refund(deductTxId, reason);
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Credit a gateway-verified external settlement to a buyer's balance. The
|
|
2464
|
+
* single entry point for every funding path -- WeChat callback, WeChat
|
|
2465
|
+
* polling, and the operator `/balance/topup` endpoint -- so they share one
|
|
2466
|
+
* idempotency boundary: `externalRef` is unique in the ledger, so a replay
|
|
2467
|
+
* credits nothing and returns the original transaction (`replayed: true`).
|
|
2468
|
+
* Callers must pass a gateway-verified `amountSat`, never a client-declared
|
|
2469
|
+
* amount.
|
|
2470
|
+
*/
|
|
2471
|
+
credit(opts) {
|
|
2472
|
+
const result = this.ledger.topup({
|
|
2473
|
+
buyerId: opts.buyerId,
|
|
2474
|
+
amountSat: opts.amountSat,
|
|
2475
|
+
externalRef: opts.externalRef,
|
|
2476
|
+
description: opts.description
|
|
2477
|
+
});
|
|
2478
|
+
return {
|
|
2479
|
+
txId: result.txId,
|
|
2480
|
+
balance: fromSat(result.balanceSat),
|
|
2481
|
+
balanceSat: result.balanceSat,
|
|
2482
|
+
replayed: result.replayed ?? false
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
async healthCheck() {
|
|
2486
|
+
const start = Date.now();
|
|
2487
|
+
try {
|
|
2488
|
+
const ok = this.ledger.integrityOk();
|
|
2489
|
+
return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
return { healthy: false, error: err.message };
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
describeDeductError(result) {
|
|
2495
|
+
switch (result.error) {
|
|
2496
|
+
case "buyer_not_found":
|
|
2497
|
+
return "Unknown buyer: top up first to create an account";
|
|
2498
|
+
case "buyer_not_active":
|
|
2499
|
+
return "Buyer account is frozen or banned";
|
|
2500
|
+
case "insufficient_balance":
|
|
2501
|
+
return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
|
|
2502
|
+
case "exceeds_single_limit":
|
|
2503
|
+
return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
|
|
2504
|
+
case "exceeds_daily_limit":
|
|
2505
|
+
return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
|
|
2506
|
+
default:
|
|
2507
|
+
return "Deduction failed";
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
};
|
|
2511
|
+
|
|
1256
2512
|
// src/facilitators/registry.ts
|
|
1257
2513
|
var import_web33 = require("@solana/web3.js");
|
|
1258
2514
|
var import_bs58 = __toESM(require("bs58"));
|
|
@@ -1277,6 +2533,9 @@ var FacilitatorRegistry = class {
|
|
|
1277
2533
|
}
|
|
1278
2534
|
return new SolanaFacilitator({ feePayerKeypair });
|
|
1279
2535
|
});
|
|
2536
|
+
this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
|
|
2537
|
+
this.registerFactory("wechat", (config) => new WechatFacilitator(config));
|
|
2538
|
+
this.registerFactory("balance", (config) => new BalanceFacilitator(config));
|
|
1280
2539
|
this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
|
|
1281
2540
|
}
|
|
1282
2541
|
/**
|
|
@@ -1504,15 +2763,38 @@ function createRegistry(selection) {
|
|
|
1504
2763
|
}
|
|
1505
2764
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1506
2765
|
0 && (module.exports = {
|
|
2766
|
+
ALIPAY_GATEWAY_PROD,
|
|
2767
|
+
ALIPAY_GATEWAY_SANDBOX,
|
|
2768
|
+
ALIPAY_NETWORK,
|
|
2769
|
+
ALIPAY_SCHEME,
|
|
2770
|
+
AlipayFacilitator,
|
|
2771
|
+
BALANCE_AUTH_DOMAIN,
|
|
2772
|
+
BALANCE_AUTH_MAX_SKEW_MS,
|
|
2773
|
+
BALANCE_NETWORK,
|
|
2774
|
+
BALANCE_SCHEME,
|
|
1507
2775
|
BNBFacilitator,
|
|
2776
|
+
BalanceFacilitator,
|
|
2777
|
+
BalanceLedger,
|
|
1508
2778
|
BaseFacilitator,
|
|
1509
2779
|
CDPFacilitator,
|
|
1510
2780
|
FacilitatorRegistry,
|
|
1511
2781
|
SolanaFacilitator,
|
|
1512
2782
|
TempoFacilitator,
|
|
2783
|
+
WECHAT_AMOUNT_REGEX,
|
|
2784
|
+
WECHAT_API_BASE,
|
|
2785
|
+
WECHAT_NETWORK,
|
|
2786
|
+
WECHAT_SCHEME,
|
|
2787
|
+
WECHAT_TIME_EXPIRE_MS,
|
|
2788
|
+
WechatFacilitator,
|
|
2789
|
+
buildDeductMessage,
|
|
1513
2790
|
createIntentTypedData,
|
|
1514
2791
|
createRegistry,
|
|
1515
2792
|
createSolanaPaymentTransaction,
|
|
1516
|
-
|
|
2793
|
+
extractBalancePayload,
|
|
2794
|
+
fromSat,
|
|
2795
|
+
getDefaultRegistry,
|
|
2796
|
+
parseWechatAttach,
|
|
2797
|
+
toSat,
|
|
2798
|
+
verifyDeductAuth
|
|
1517
2799
|
});
|
|
1518
2800
|
//# sourceMappingURL=index.js.map
|