moltspay 2.0.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 -8
- package/dist/cdp/index.js.map +1 -1
- package/dist/cdp/index.mjs.map +1 -1
- package/dist/chains/index.d.mts +69 -4
- package/dist/chains/index.d.ts +69 -4
- package/dist/chains/index.js +45 -1
- package/dist/chains/index.js.map +1 -1
- package/dist/chains/index.mjs +39 -1
- package/dist/chains/index.mjs.map +1 -1
- package/dist/cli/index.js +5444 -2126
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +5457 -2133
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/index.d.mts +307 -1
- package/dist/client/index.d.ts +307 -1
- package/dist/client/index.js +639 -34
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +656 -52
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/web/index.mjs.map +1 -1
- package/dist/facilitators/index.d.mts +512 -10
- package/dist/facilitators/index.d.ts +512 -10
- package/dist/facilitators/index.js +925 -13
- package/dist/facilitators/index.js.map +1 -1
- package/dist/facilitators/index.mjs +906 -12
- package/dist/facilitators/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2843 -551
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2849 -558
- package/dist/index.mjs.map +1 -1
- package/dist/mcp/index.js +635 -32
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/index.mjs +660 -57
- package/dist/mcp/index.mjs.map +1 -1
- package/dist/server/index.d.mts +252 -11
- package/dist/server/index.d.ts +252 -11
- package/dist/server/index.js +2049 -261
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +2049 -261
- 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 +14 -2
- package/schemas/moltspay.services.schema.json +127 -16
package/dist/server/index.js
CHANGED
|
@@ -33,9 +33,10 @@ __export(server_exports, {
|
|
|
33
33
|
MoltsPayServer: () => MoltsPayServer
|
|
34
34
|
});
|
|
35
35
|
module.exports = __toCommonJS(server_exports);
|
|
36
|
-
var
|
|
36
|
+
var import_fs3 = require("fs");
|
|
37
37
|
var import_http = require("http");
|
|
38
|
-
var
|
|
38
|
+
var path3 = __toESM(require("path"));
|
|
39
|
+
var import_node_crypto7 = __toESM(require("crypto"));
|
|
39
40
|
|
|
40
41
|
// src/facilitators/interface.ts
|
|
41
42
|
var BaseFacilitator = class {
|
|
@@ -432,10 +433,28 @@ var CHAINS = {
|
|
|
432
433
|
requiresApproval: true
|
|
433
434
|
}
|
|
434
435
|
};
|
|
436
|
+
function getChain(name) {
|
|
437
|
+
const config = CHAINS[name];
|
|
438
|
+
if (!config) {
|
|
439
|
+
throw new Error(`Unsupported chain: ${name}. Supported: ${Object.keys(CHAINS).join(", ")}`);
|
|
440
|
+
}
|
|
441
|
+
return config;
|
|
442
|
+
}
|
|
443
|
+
function getChainById(chainId) {
|
|
444
|
+
return Object.values(CHAINS).find((c) => c.chainId === chainId);
|
|
445
|
+
}
|
|
435
446
|
var ALIPAY_CHAIN_ID = "alipay";
|
|
436
447
|
function isAlipayChainId(id) {
|
|
437
448
|
return id === ALIPAY_CHAIN_ID;
|
|
438
449
|
}
|
|
450
|
+
var WECHAT_CHAIN_ID = "wechat";
|
|
451
|
+
function isWechatChainId(id) {
|
|
452
|
+
return id === WECHAT_CHAIN_ID;
|
|
453
|
+
}
|
|
454
|
+
var BALANCE_CHAIN_ID = "balance";
|
|
455
|
+
function isBalanceChainId(id) {
|
|
456
|
+
return id === BALANCE_CHAIN_ID;
|
|
457
|
+
}
|
|
439
458
|
|
|
440
459
|
// src/facilitators/tempo.ts
|
|
441
460
|
var TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
@@ -894,12 +913,12 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
894
913
|
return this.spenderAddress;
|
|
895
914
|
}
|
|
896
915
|
async getServerAddress() {
|
|
897
|
-
const { ethers:
|
|
898
|
-
const wallet = new
|
|
916
|
+
const { ethers: ethers4 } = await import("ethers");
|
|
917
|
+
const wallet = new ethers4.Wallet(this.serverPrivateKey);
|
|
899
918
|
return wallet.address;
|
|
900
919
|
}
|
|
901
920
|
async recoverIntentSigner(intent, chainId) {
|
|
902
|
-
const { ethers:
|
|
921
|
+
const { ethers: ethers4 } = await import("ethers");
|
|
903
922
|
const domain = {
|
|
904
923
|
...EIP712_DOMAIN,
|
|
905
924
|
chainId
|
|
@@ -913,7 +932,7 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
913
932
|
nonce: intent.nonce,
|
|
914
933
|
deadline: intent.deadline
|
|
915
934
|
};
|
|
916
|
-
const recoveredAddress =
|
|
935
|
+
const recoveredAddress = ethers4.verifyTypedData(
|
|
917
936
|
domain,
|
|
918
937
|
INTENT_TYPES,
|
|
919
938
|
message,
|
|
@@ -957,10 +976,10 @@ var BNBFacilitator = class extends BaseFacilitator {
|
|
|
957
976
|
return result.result || "0x0";
|
|
958
977
|
}
|
|
959
978
|
async executeTransferFrom(from, to, amount, token, rpcUrl) {
|
|
960
|
-
const { ethers:
|
|
961
|
-
const provider = new
|
|
962
|
-
const wallet = new
|
|
963
|
-
const tokenContract = new
|
|
979
|
+
const { ethers: ethers4 } = await import("ethers");
|
|
980
|
+
const provider = new ethers4.JsonRpcProvider(rpcUrl);
|
|
981
|
+
const wallet = new ethers4.Wallet(this.serverPrivateKey, provider);
|
|
982
|
+
const tokenContract = new ethers4.Contract(token, [
|
|
964
983
|
"function transferFrom(address from, address to, uint256 amount) returns (bool)"
|
|
965
984
|
], wallet);
|
|
966
985
|
const tx = await tokenContract.transferFrom(from, to, amount);
|
|
@@ -1286,7 +1305,7 @@ var ALIPAY_SIGNING_FIELDS = [
|
|
|
1286
1305
|
];
|
|
1287
1306
|
var AlipayFacilitator = class extends BaseFacilitator {
|
|
1288
1307
|
name = "alipay";
|
|
1289
|
-
displayName = "Alipay AI
|
|
1308
|
+
displayName = "Alipay AI Pay";
|
|
1290
1309
|
supportedNetworks = [ALIPAY_NETWORK];
|
|
1291
1310
|
config;
|
|
1292
1311
|
constructor(config) {
|
|
@@ -1305,7 +1324,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
|
|
|
1305
1324
|
async createPaymentRequirements(opts) {
|
|
1306
1325
|
if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
|
|
1307
1326
|
throw new Error(
|
|
1308
|
-
`AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is
|
|
1327
|
+
`AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
|
|
1309
1328
|
);
|
|
1310
1329
|
}
|
|
1311
1330
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1418,7 +1437,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
|
|
|
1418
1437
|
* service resource has been returned to the buyer.
|
|
1419
1438
|
*
|
|
1420
1439
|
* Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
|
|
1421
|
-
*
|
|
1440
|
+
* (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
|
|
1422
1441
|
* server) is expected to log fulfillment failures but NOT roll back
|
|
1423
1442
|
* the already-delivered resource.
|
|
1424
1443
|
*
|
|
@@ -1555,210 +1574,1086 @@ function decodeProof(proofHeader) {
|
|
|
1555
1574
|
return parsed;
|
|
1556
1575
|
}
|
|
1557
1576
|
|
|
1558
|
-
// src/facilitators/
|
|
1559
|
-
var
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1577
|
+
// src/facilitators/wechat.ts
|
|
1578
|
+
var import_node_crypto4 = __toESM(require("crypto"));
|
|
1579
|
+
|
|
1580
|
+
// src/facilitators/wechat/sign.ts
|
|
1581
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
1582
|
+
var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
|
|
1583
|
+
function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
|
|
1584
|
+
return `${method.toUpperCase()}
|
|
1585
|
+
${urlPath}
|
|
1586
|
+
${timestamp}
|
|
1587
|
+
${nonce}
|
|
1588
|
+
${body}
|
|
1589
|
+
`;
|
|
1590
|
+
}
|
|
1591
|
+
function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
|
|
1592
|
+
const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
|
|
1593
|
+
const signer = import_node_crypto3.default.createSign("RSA-SHA256");
|
|
1594
|
+
signer.update(message, "utf-8");
|
|
1595
|
+
signer.end();
|
|
1596
|
+
return signer.sign(privateKeyPem, "base64");
|
|
1597
|
+
}
|
|
1598
|
+
function buildAuthorizationToken(args) {
|
|
1599
|
+
const fields = [
|
|
1600
|
+
`mchid="${args.mchid}"`,
|
|
1601
|
+
`nonce_str="${args.nonce}"`,
|
|
1602
|
+
`signature="${args.signature}"`,
|
|
1603
|
+
`timestamp="${args.timestamp}"`,
|
|
1604
|
+
`serial_no="${args.serialNo}"`
|
|
1605
|
+
].join(",");
|
|
1606
|
+
return `${WECHAT_AUTH_SCHEMA} ${fields}`;
|
|
1607
|
+
}
|
|
1608
|
+
function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
|
|
1609
|
+
try {
|
|
1610
|
+
const message = `${timestamp}
|
|
1611
|
+
${nonce}
|
|
1612
|
+
${body}
|
|
1613
|
+
`;
|
|
1614
|
+
const verifier = import_node_crypto3.default.createVerify("RSA-SHA256");
|
|
1615
|
+
verifier.update(message, "utf-8");
|
|
1616
|
+
verifier.end();
|
|
1617
|
+
return verifier.verify(platformPublicKeyPem, signature, "base64");
|
|
1618
|
+
} catch {
|
|
1619
|
+
return false;
|
|
1584
1620
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1621
|
+
}
|
|
1622
|
+
function generateNonce() {
|
|
1623
|
+
return import_node_crypto3.default.randomBytes(16).toString("hex");
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// src/facilitators/wechat/api.ts
|
|
1627
|
+
var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
|
|
1628
|
+
var WechatApiError = class extends Error {
|
|
1629
|
+
status;
|
|
1630
|
+
code;
|
|
1631
|
+
constructor(message, status, code) {
|
|
1632
|
+
super(message);
|
|
1633
|
+
this.name = "WechatApiError";
|
|
1634
|
+
this.status = status;
|
|
1635
|
+
this.code = code;
|
|
1590
1636
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1637
|
+
};
|
|
1638
|
+
async function wechatV3Call(method, urlPath, body, config) {
|
|
1639
|
+
const base = config.api_base ?? WECHAT_API_BASE;
|
|
1640
|
+
const bodyStr = body === null ? "" : JSON.stringify(body);
|
|
1641
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
1642
|
+
const nonce = generateNonce();
|
|
1643
|
+
const signature = wechatV3Sign(
|
|
1644
|
+
method,
|
|
1645
|
+
urlPath,
|
|
1646
|
+
timestamp,
|
|
1647
|
+
nonce,
|
|
1648
|
+
bodyStr,
|
|
1649
|
+
config.private_key_pem
|
|
1650
|
+
);
|
|
1651
|
+
const authorization = buildAuthorizationToken({
|
|
1652
|
+
mchid: config.mchid,
|
|
1653
|
+
serialNo: config.serial_no,
|
|
1654
|
+
nonce,
|
|
1655
|
+
timestamp,
|
|
1656
|
+
signature
|
|
1657
|
+
});
|
|
1658
|
+
const response = await fetch(`${base}${urlPath}`, {
|
|
1659
|
+
method,
|
|
1660
|
+
headers: {
|
|
1661
|
+
Authorization: authorization,
|
|
1662
|
+
Accept: "application/json",
|
|
1663
|
+
"Content-Type": "application/json",
|
|
1664
|
+
// WeChat requires a non-empty UA; some edge nodes 403 a blank one.
|
|
1665
|
+
"User-Agent": "moltspay-wechat/1.0"
|
|
1666
|
+
},
|
|
1667
|
+
body: method === "GET" ? void 0 : bodyStr
|
|
1668
|
+
});
|
|
1669
|
+
const text = await response.text();
|
|
1670
|
+
if (config.platform_public_key_pem && text.length > 0) {
|
|
1671
|
+
const ts = response.headers.get("Wechatpay-Timestamp");
|
|
1672
|
+
const nc = response.headers.get("Wechatpay-Nonce");
|
|
1673
|
+
const sig = response.headers.get("Wechatpay-Signature");
|
|
1674
|
+
if (!ts || !nc || !sig) {
|
|
1675
|
+
throw new Error(
|
|
1676
|
+
`WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
|
|
1677
|
+
);
|
|
1597
1678
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1679
|
+
if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
|
|
1680
|
+
throw new Error(
|
|
1681
|
+
`WeChat v3 ${method} ${urlPath}: response signature verification failed`
|
|
1682
|
+
);
|
|
1601
1683
|
}
|
|
1602
|
-
const mergedConfig = {
|
|
1603
|
-
...this.selection.config?.[name],
|
|
1604
|
-
...config
|
|
1605
|
-
};
|
|
1606
|
-
const instance = factory(mergedConfig);
|
|
1607
|
-
this.instances.set(name, instance);
|
|
1608
|
-
return instance;
|
|
1609
1684
|
}
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1685
|
+
let json = {};
|
|
1686
|
+
if (text.length > 0) {
|
|
1687
|
+
try {
|
|
1688
|
+
json = JSON.parse(text);
|
|
1689
|
+
} catch {
|
|
1690
|
+
throw new Error(
|
|
1691
|
+
`WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
|
|
1692
|
+
);
|
|
1617
1693
|
}
|
|
1618
|
-
|
|
1694
|
+
}
|
|
1695
|
+
if (!response.ok) {
|
|
1696
|
+
const code = typeof json.code === "string" ? json.code : void 0;
|
|
1697
|
+
const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
|
|
1698
|
+
throw new WechatApiError(
|
|
1699
|
+
`WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
|
|
1700
|
+
response.status,
|
|
1701
|
+
code
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
return { status: response.status, body: json };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/facilitators/wechat.ts
|
|
1708
|
+
var WECHAT_NETWORK = "wechat";
|
|
1709
|
+
var WECHAT_SCHEME = "wechatpay-native";
|
|
1710
|
+
var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
|
|
1711
|
+
var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
|
|
1712
|
+
var WechatFacilitator = class extends BaseFacilitator {
|
|
1713
|
+
name = "wechat";
|
|
1714
|
+
displayName = "WeChat Pay";
|
|
1715
|
+
supportedNetworks = [WECHAT_NETWORK];
|
|
1716
|
+
config;
|
|
1717
|
+
constructor(config) {
|
|
1718
|
+
super();
|
|
1719
|
+
this.config = { api_base: WECHAT_API_BASE, ...config };
|
|
1619
1720
|
}
|
|
1620
1721
|
/**
|
|
1621
|
-
*
|
|
1722
|
+
* Place a Native order and build the 402 challenge. The returned
|
|
1723
|
+
* `code_url` is payer-agnostic — any WeChat user may scan it.
|
|
1622
1724
|
*/
|
|
1623
|
-
async
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
const f = this.get(name);
|
|
1629
|
-
if (f.supportsNetwork(network)) {
|
|
1630
|
-
facilitators.push(f);
|
|
1631
|
-
}
|
|
1632
|
-
} catch (err) {
|
|
1633
|
-
console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
if (facilitators.length === 0) {
|
|
1637
|
-
throw new Error(`No facilitators available for network: ${network}`);
|
|
1725
|
+
async createPaymentRequirements(opts) {
|
|
1726
|
+
if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
|
|
1727
|
+
throw new Error(
|
|
1728
|
+
`WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
|
|
1729
|
+
);
|
|
1638
1730
|
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
return [
|
|
1645
|
-
...facilitators.slice(this.roundRobinIndex),
|
|
1646
|
-
...facilitators.slice(0, this.roundRobinIndex)
|
|
1647
|
-
];
|
|
1648
|
-
case "cheapest":
|
|
1649
|
-
return this.sortByCheapest(facilitators);
|
|
1650
|
-
case "fastest":
|
|
1651
|
-
return this.sortByFastest(facilitators);
|
|
1652
|
-
case "failover":
|
|
1653
|
-
default:
|
|
1654
|
-
return facilitators;
|
|
1731
|
+
const total = cnyToFen(opts.priceCny);
|
|
1732
|
+
if (total < 1) {
|
|
1733
|
+
throw new Error(
|
|
1734
|
+
`WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
|
|
1735
|
+
);
|
|
1655
1736
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1737
|
+
const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
|
|
1738
|
+
const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
|
|
1739
|
+
const body = {
|
|
1740
|
+
appid: this.config.appid,
|
|
1741
|
+
mchid: this.config.mchid,
|
|
1742
|
+
description: opts.description,
|
|
1743
|
+
out_trade_no: outTradeNo,
|
|
1744
|
+
notify_url: this.config.notify_url,
|
|
1745
|
+
time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
|
|
1746
|
+
amount: { total, currency: "CNY" }
|
|
1747
|
+
};
|
|
1748
|
+
if (opts.attach) {
|
|
1749
|
+
const attachStr = JSON.stringify(opts.attach);
|
|
1750
|
+
if (Buffer.byteLength(attachStr, "utf8") > 128) {
|
|
1751
|
+
throw new Error(
|
|
1752
|
+
`WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
body.attach = attachStr;
|
|
1662
1756
|
}
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
try {
|
|
1669
|
-
const fee = await f.getFee?.();
|
|
1670
|
-
return { facilitator: f, perTx: fee?.perTx ?? Infinity };
|
|
1671
|
-
} catch {
|
|
1672
|
-
return { facilitator: f, perTx: Infinity };
|
|
1673
|
-
}
|
|
1674
|
-
})
|
|
1675
|
-
);
|
|
1676
|
-
withFees.sort((a, b) => a.perTx - b.perTx);
|
|
1677
|
-
return withFees.map((w) => w.facilitator);
|
|
1678
|
-
}
|
|
1679
|
-
async sortByFastest(facilitators) {
|
|
1680
|
-
const withLatency = await Promise.all(
|
|
1681
|
-
facilitators.map(async (f) => {
|
|
1682
|
-
try {
|
|
1683
|
-
const health = await f.healthCheck();
|
|
1684
|
-
return { facilitator: f, latency: health.latencyMs ?? Infinity };
|
|
1685
|
-
} catch {
|
|
1686
|
-
return { facilitator: f, latency: Infinity };
|
|
1687
|
-
}
|
|
1688
|
-
})
|
|
1757
|
+
const { body: resp } = await wechatV3Call(
|
|
1758
|
+
"POST",
|
|
1759
|
+
"/v3/pay/transactions/native",
|
|
1760
|
+
body,
|
|
1761
|
+
this.getApiConfig()
|
|
1689
1762
|
);
|
|
1690
|
-
|
|
1691
|
-
|
|
1763
|
+
const codeUrl = resp.code_url;
|
|
1764
|
+
if (typeof codeUrl !== "string" || codeUrl.length === 0) {
|
|
1765
|
+
throw new Error(
|
|
1766
|
+
`WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
const x402Accepts = {
|
|
1770
|
+
scheme: WECHAT_SCHEME,
|
|
1771
|
+
network: WECHAT_NETWORK,
|
|
1772
|
+
asset: "CNY",
|
|
1773
|
+
amount: opts.priceCny,
|
|
1774
|
+
payTo: this.config.mchid,
|
|
1775
|
+
maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
|
|
1776
|
+
extra: {
|
|
1777
|
+
code_url: codeUrl,
|
|
1778
|
+
out_trade_no: outTradeNo
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
return { x402Accepts, codeUrl, outTradeNo };
|
|
1692
1782
|
}
|
|
1693
1783
|
/**
|
|
1694
|
-
*
|
|
1784
|
+
* Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
|
|
1785
|
+
* (missing out_trade_no, gateway error, not-yet-paid) return
|
|
1786
|
+
* `{ valid: false, error }`; no exception escapes.
|
|
1695
1787
|
*/
|
|
1696
1788
|
async verify(paymentPayload, requirements) {
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
}
|
|
1708
|
-
lastError = result.error;
|
|
1709
|
-
console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
|
|
1710
|
-
if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
|
|
1711
|
-
break;
|
|
1712
|
-
}
|
|
1713
|
-
} catch (err) {
|
|
1714
|
-
lastError = err.message;
|
|
1715
|
-
console.error(`[Registry] ${f.name} error:`, err.message);
|
|
1789
|
+
try {
|
|
1790
|
+
const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
|
|
1791
|
+
const resp = await this.queryOrder(outTradeNo);
|
|
1792
|
+
const tradeState = resp.trade_state;
|
|
1793
|
+
if (tradeState !== "SUCCESS") {
|
|
1794
|
+
return {
|
|
1795
|
+
valid: false,
|
|
1796
|
+
error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
|
|
1797
|
+
details: { trade_state: tradeState, out_trade_no: outTradeNo }
|
|
1798
|
+
};
|
|
1716
1799
|
}
|
|
1800
|
+
return {
|
|
1801
|
+
valid: true,
|
|
1802
|
+
details: {
|
|
1803
|
+
trade_state: tradeState,
|
|
1804
|
+
transaction_id: resp.transaction_id,
|
|
1805
|
+
out_trade_no: resp.out_trade_no ?? outTradeNo,
|
|
1806
|
+
amount: resp.amount,
|
|
1807
|
+
attach: resp.attach,
|
|
1808
|
+
// Payer identity, gateway-attested. Present on a SUCCESS Native
|
|
1809
|
+
// order even though order creation was payer-agnostic; anchors the
|
|
1810
|
+
// custodial balance to a real WeChat user. @see WECHAT fiat auth design.
|
|
1811
|
+
openid: resp.payer?.openid
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
} catch (e) {
|
|
1815
|
+
return { valid: false, error: e instanceof Error ? e.message : String(e) };
|
|
1717
1816
|
}
|
|
1718
|
-
return {
|
|
1719
|
-
valid: false,
|
|
1720
|
-
error: lastError || "All facilitators failed",
|
|
1721
|
-
facilitator: "none"
|
|
1722
|
-
};
|
|
1723
1817
|
}
|
|
1724
1818
|
/**
|
|
1725
|
-
*
|
|
1819
|
+
* Confirm settlement. Native captures funds at SUCCESS, so this is an
|
|
1820
|
+
* idempotent re-confirm that returns the `transaction_id`. Like Alipay's
|
|
1821
|
+
* fulfillment confirm, failures are surfaced but non-fatal to an
|
|
1822
|
+
* already-delivered resource (caller logs, does not roll back).
|
|
1726
1823
|
*/
|
|
1727
1824
|
async settle(paymentPayload, requirements) {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
|
|
1741
|
-
} catch (err) {
|
|
1742
|
-
lastError = err.message;
|
|
1743
|
-
console.error(`[Registry] ${f.name} error:`, err.message);
|
|
1825
|
+
try {
|
|
1826
|
+
const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
|
|
1827
|
+
const resp = await this.queryOrder(outTradeNo);
|
|
1828
|
+
const tradeState = resp.trade_state;
|
|
1829
|
+
const transactionId = resp.transaction_id;
|
|
1830
|
+
if (tradeState !== "SUCCESS") {
|
|
1831
|
+
return {
|
|
1832
|
+
success: false,
|
|
1833
|
+
transaction: transactionId,
|
|
1834
|
+
error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
|
|
1835
|
+
status: tradeState
|
|
1836
|
+
};
|
|
1744
1837
|
}
|
|
1838
|
+
return { success: true, transaction: transactionId, status: "fulfilled" };
|
|
1839
|
+
} catch (e) {
|
|
1840
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
1745
1841
|
}
|
|
1746
|
-
return {
|
|
1747
|
-
success: false,
|
|
1748
|
-
error: lastError || "All facilitators failed",
|
|
1749
|
-
facilitator: "none"
|
|
1750
|
-
};
|
|
1751
1842
|
}
|
|
1752
1843
|
/**
|
|
1753
|
-
*
|
|
1844
|
+
* Validate keys parse, apiv3 key length, and gateway reachability. Does
|
|
1845
|
+
* NOT make a business API call.
|
|
1754
1846
|
*/
|
|
1755
|
-
async
|
|
1756
|
-
const
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1847
|
+
async healthCheck() {
|
|
1848
|
+
const start = Date.now();
|
|
1849
|
+
try {
|
|
1850
|
+
import_node_crypto4.default.createPrivateKey(this.config.private_key_pem);
|
|
1851
|
+
} catch (e) {
|
|
1852
|
+
return {
|
|
1853
|
+
healthy: false,
|
|
1854
|
+
error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
if (this.config.platform_public_key_pem) {
|
|
1858
|
+
try {
|
|
1859
|
+
import_node_crypto4.default.createPublicKey(this.config.platform_public_key_pem);
|
|
1860
|
+
} catch (e) {
|
|
1861
|
+
return {
|
|
1862
|
+
healthy: false,
|
|
1863
|
+
error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
|
|
1868
|
+
return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
|
|
1869
|
+
}
|
|
1870
|
+
const base = this.config.api_base ?? WECHAT_API_BASE;
|
|
1871
|
+
const controller = new AbortController();
|
|
1872
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
1873
|
+
const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
|
|
1874
|
+
clearTimeout(timeout);
|
|
1875
|
+
const latencyMs = Date.now() - start;
|
|
1876
|
+
if (!response) {
|
|
1877
|
+
return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
|
|
1878
|
+
}
|
|
1879
|
+
return { healthy: true, latencyMs };
|
|
1880
|
+
}
|
|
1881
|
+
/** Query a Native order by out_trade_no. The query string is part of the signed path. */
|
|
1882
|
+
async queryOrder(outTradeNo) {
|
|
1883
|
+
const path4 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
|
|
1884
|
+
const { body } = await wechatV3Call("GET", path4, null, this.getApiConfig());
|
|
1885
|
+
return body;
|
|
1886
|
+
}
|
|
1887
|
+
/** Project the facilitator config down to what api.ts needs. */
|
|
1888
|
+
getApiConfig() {
|
|
1889
|
+
return {
|
|
1890
|
+
mchid: this.config.mchid,
|
|
1891
|
+
serial_no: this.config.serial_no,
|
|
1892
|
+
private_key_pem: this.config.private_key_pem,
|
|
1893
|
+
platform_public_key_pem: this.config.platform_public_key_pem,
|
|
1894
|
+
api_base: this.config.api_base
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
function cnyToFen(cny) {
|
|
1899
|
+
return Math.round(parseFloat(cny) * 100);
|
|
1900
|
+
}
|
|
1901
|
+
function generateOutTradeNo2() {
|
|
1902
|
+
return "WX" + import_node_crypto4.default.randomBytes(15).toString("hex");
|
|
1903
|
+
}
|
|
1904
|
+
function parseWechatAttach(attach) {
|
|
1905
|
+
if (typeof attach !== "string" || attach.length === 0) return null;
|
|
1906
|
+
try {
|
|
1907
|
+
const parsed = JSON.parse(attach);
|
|
1908
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1909
|
+
} catch {
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
function formatTimeExpire(d) {
|
|
1914
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1915
|
+
const offMin = -d.getTimezoneOffset();
|
|
1916
|
+
const sign = offMin >= 0 ? "+" : "-";
|
|
1917
|
+
const abs = Math.abs(offMin);
|
|
1918
|
+
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)}`;
|
|
1919
|
+
}
|
|
1920
|
+
function extractOutTradeNo(paymentPayload, requirements) {
|
|
1921
|
+
const p = paymentPayload?.payload;
|
|
1922
|
+
if (typeof p === "string" && p.length > 0) return p;
|
|
1923
|
+
if (p !== null && typeof p === "object") {
|
|
1924
|
+
const obj = p;
|
|
1925
|
+
const cand = obj.out_trade_no ?? obj.outTradeNo;
|
|
1926
|
+
if (typeof cand === "string" && cand.length > 0) return cand;
|
|
1927
|
+
}
|
|
1928
|
+
const fromReq = requirements?.extra?.out_trade_no;
|
|
1929
|
+
if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
|
|
1930
|
+
throw new Error(
|
|
1931
|
+
"wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// src/facilitators/balance/ledger.ts
|
|
1936
|
+
var import_node_crypto5 = require("crypto");
|
|
1937
|
+
var DEFAULT_SINGLE_LIMIT_SAT = 500;
|
|
1938
|
+
var DEFAULT_DAILY_LIMIT_SAT = 1e3;
|
|
1939
|
+
function toSat(amount) {
|
|
1940
|
+
const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
|
|
1941
|
+
if (!/^\d+(\.\d{1,2})?$/.test(s)) {
|
|
1942
|
+
throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
|
|
1943
|
+
}
|
|
1944
|
+
const [whole, frac = ""] = s.split(".");
|
|
1945
|
+
return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
|
|
1946
|
+
}
|
|
1947
|
+
function fromSat(sat) {
|
|
1948
|
+
return (sat / 100).toFixed(2);
|
|
1949
|
+
}
|
|
1950
|
+
var BalanceLedger = class {
|
|
1951
|
+
db;
|
|
1952
|
+
defaultSingleLimitSat;
|
|
1953
|
+
defaultDailyLimitSat;
|
|
1954
|
+
constructor(config) {
|
|
1955
|
+
const getBuiltin = process.getBuiltinModule;
|
|
1956
|
+
const sqlite = getBuiltin?.("node:sqlite");
|
|
1957
|
+
if (!sqlite?.DatabaseSync) {
|
|
1958
|
+
throw new Error(
|
|
1959
|
+
`The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
const { DatabaseSync } = sqlite;
|
|
1963
|
+
this.db = new DatabaseSync(config.dbPath);
|
|
1964
|
+
this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
|
|
1965
|
+
this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
|
|
1966
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
1967
|
+
this.db.exec(`
|
|
1968
|
+
CREATE TABLE IF NOT EXISTS buyers (
|
|
1969
|
+
buyer_id TEXT PRIMARY KEY,
|
|
1970
|
+
display_name TEXT,
|
|
1971
|
+
balance_sat INTEGER NOT NULL DEFAULT 0,
|
|
1972
|
+
total_topup_sat INTEGER NOT NULL DEFAULT 0,
|
|
1973
|
+
total_spent_sat INTEGER NOT NULL DEFAULT 0,
|
|
1974
|
+
daily_limit_sat INTEGER NOT NULL,
|
|
1975
|
+
single_limit_sat INTEGER NOT NULL,
|
|
1976
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
1977
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1978
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1979
|
+
);
|
|
1980
|
+
CREATE TABLE IF NOT EXISTS ledger_transactions (
|
|
1981
|
+
id TEXT PRIMARY KEY,
|
|
1982
|
+
buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
|
|
1983
|
+
type TEXT NOT NULL,
|
|
1984
|
+
amount_sat INTEGER NOT NULL,
|
|
1985
|
+
service TEXT,
|
|
1986
|
+
description TEXT,
|
|
1987
|
+
request_id TEXT,
|
|
1988
|
+
external_ref TEXT,
|
|
1989
|
+
refunds_tx_id TEXT,
|
|
1990
|
+
status TEXT NOT NULL DEFAULT 'completed',
|
|
1991
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1992
|
+
);
|
|
1993
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
|
|
1994
|
+
ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
|
|
1995
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
|
|
1996
|
+
ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
|
|
1997
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
|
|
1998
|
+
ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
|
|
1999
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
|
|
2000
|
+
ON ledger_transactions(buyer_id, created_at);
|
|
2001
|
+
CREATE TABLE IF NOT EXISTS ledger_meta (
|
|
2002
|
+
key TEXT PRIMARY KEY,
|
|
2003
|
+
value TEXT NOT NULL
|
|
2004
|
+
);
|
|
2005
|
+
`);
|
|
2006
|
+
const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
|
|
2007
|
+
if (!cols.includes("signer_address")) {
|
|
2008
|
+
this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
|
|
2009
|
+
}
|
|
2010
|
+
if (!cols.includes("wechat_openid")) {
|
|
2011
|
+
this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
|
|
2012
|
+
}
|
|
2013
|
+
const currency = config.currency ?? "USD";
|
|
2014
|
+
const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
|
|
2015
|
+
if (existingCurrency) {
|
|
2016
|
+
if (existingCurrency.value !== currency) {
|
|
2017
|
+
throw new Error(
|
|
2018
|
+
`Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
} else {
|
|
2022
|
+
this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
/** Fetch a buyer, or null. */
|
|
2026
|
+
getBuyer(buyerId) {
|
|
2027
|
+
const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
|
|
2028
|
+
return row ?? null;
|
|
2029
|
+
}
|
|
2030
|
+
/** Fetch a buyer, creating an empty active account on first sight. */
|
|
2031
|
+
getOrCreateBuyer(buyerId, displayName) {
|
|
2032
|
+
const existing = this.getBuyer(buyerId);
|
|
2033
|
+
if (existing) return existing;
|
|
2034
|
+
this.db.prepare(
|
|
2035
|
+
`INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
|
|
2036
|
+
VALUES (?, ?, ?, ?)`
|
|
2037
|
+
).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
|
|
2038
|
+
return this.getBuyer(buyerId);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Record the gateway-attested WeChat payer openid that funded a buyer, on
|
|
2042
|
+
* top-up confirm. Idempotent and observational (stage 1a): it never
|
|
2043
|
+
* overwrites an existing binding with a *different* openid — a conflict is
|
|
2044
|
+
* reported to the caller (possible account sharing / spoof) but not
|
|
2045
|
+
* enforced here. Creates the buyer if absent.
|
|
2046
|
+
*/
|
|
2047
|
+
bindOpenid(buyerId, openid) {
|
|
2048
|
+
const buyer = this.getOrCreateBuyer(buyerId);
|
|
2049
|
+
if (buyer.wechat_openid === openid) {
|
|
2050
|
+
return { bound: true, conflict: false, existing: openid };
|
|
2051
|
+
}
|
|
2052
|
+
if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
|
|
2053
|
+
return { bound: false, conflict: true, existing: buyer.wechat_openid };
|
|
2054
|
+
}
|
|
2055
|
+
this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
|
|
2056
|
+
return { bound: true, conflict: false, existing: null };
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* TOFU-bind the account's spending signer address (EVM, lowercase). First
|
|
2060
|
+
* signed request records it; later requests must match. A mismatch is
|
|
2061
|
+
* reported (`conflict`) so the caller can reject under `enforce` — it is
|
|
2062
|
+
* never silently overwritten. Creates the buyer if absent.
|
|
2063
|
+
*/
|
|
2064
|
+
bindSigner(buyerId, address) {
|
|
2065
|
+
const a = address.toLowerCase();
|
|
2066
|
+
const buyer = this.getOrCreateBuyer(buyerId);
|
|
2067
|
+
if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
|
|
2068
|
+
if (buyer.signer_address && buyer.signer_address !== a) {
|
|
2069
|
+
return { bound: false, conflict: true, existing: buyer.signer_address };
|
|
2070
|
+
}
|
|
2071
|
+
this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
|
|
2072
|
+
return { bound: true, conflict: false, existing: null };
|
|
2073
|
+
}
|
|
2074
|
+
/** Sum of today's (UTC) completed deducts minus refunds issued against them. */
|
|
2075
|
+
spentTodaySat(buyerId) {
|
|
2076
|
+
const row = this.db.prepare(
|
|
2077
|
+
`SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
|
|
2078
|
+
FROM ledger_transactions
|
|
2079
|
+
WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
|
|
2080
|
+
).get(buyerId);
|
|
2081
|
+
return Math.max(0, row.spent);
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Read-only deduction precheck (the rail's `verify`). Never mutates.
|
|
2085
|
+
* Returns the same error codes `deduct` would.
|
|
2086
|
+
*/
|
|
2087
|
+
checkDeduct(buyerId, amountSat) {
|
|
2088
|
+
const buyer = this.getBuyer(buyerId);
|
|
2089
|
+
if (!buyer) return { success: false, error: "buyer_not_found" };
|
|
2090
|
+
if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
|
|
2091
|
+
if (amountSat > buyer.single_limit_sat) {
|
|
2092
|
+
return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
|
|
2093
|
+
}
|
|
2094
|
+
const spent = this.spentTodaySat(buyerId);
|
|
2095
|
+
if (spent + amountSat > buyer.daily_limit_sat) {
|
|
2096
|
+
return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
|
|
2097
|
+
}
|
|
2098
|
+
if (buyer.balance_sat < amountSat) {
|
|
2099
|
+
return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
|
|
2100
|
+
}
|
|
2101
|
+
return { success: true, balanceSat: buyer.balance_sat };
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
|
|
2105
|
+
* SQLite transaction; a `request_id` replay returns the original tx
|
|
2106
|
+
* without charging again.
|
|
2107
|
+
*/
|
|
2108
|
+
deduct(opts) {
|
|
2109
|
+
if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
|
|
2110
|
+
throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
|
|
2111
|
+
}
|
|
2112
|
+
if (opts.requestId) {
|
|
2113
|
+
const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
|
|
2114
|
+
if (prior) {
|
|
2115
|
+
const buyer = this.getBuyer(prior.buyer_id);
|
|
2116
|
+
return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2120
|
+
try {
|
|
2121
|
+
const check = this.checkDeduct(opts.buyerId, opts.amountSat);
|
|
2122
|
+
if (!check.success) {
|
|
2123
|
+
this.db.exec("ROLLBACK");
|
|
2124
|
+
return check;
|
|
2125
|
+
}
|
|
2126
|
+
const updated = this.db.prepare(
|
|
2127
|
+
`UPDATE buyers SET
|
|
2128
|
+
balance_sat = balance_sat - ?,
|
|
2129
|
+
total_spent_sat = total_spent_sat + ?,
|
|
2130
|
+
updated_at = datetime('now')
|
|
2131
|
+
WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
|
|
2132
|
+
).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
|
|
2133
|
+
if (Number(updated.changes) !== 1) {
|
|
2134
|
+
this.db.exec("ROLLBACK");
|
|
2135
|
+
return { success: false, error: "insufficient_balance" };
|
|
2136
|
+
}
|
|
2137
|
+
const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
|
|
2138
|
+
this.db.prepare(
|
|
2139
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
|
|
2140
|
+
VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
|
|
2141
|
+
).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
|
|
2142
|
+
this.db.exec("COMMIT");
|
|
2143
|
+
const buyer = this.getBuyer(opts.buyerId);
|
|
2144
|
+
return { success: true, txId, balanceSat: buyer.balance_sat };
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
try {
|
|
2147
|
+
this.db.exec("ROLLBACK");
|
|
2148
|
+
} catch {
|
|
2149
|
+
}
|
|
2150
|
+
throw err;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Credit a top-up. `externalRef` is the settlement proof reference
|
|
2155
|
+
* (on-chain tx hash / fiat trade number) and is unique: replaying the same
|
|
2156
|
+
* reference returns the original row without crediting twice.
|
|
2157
|
+
*/
|
|
2158
|
+
topup(opts) {
|
|
2159
|
+
if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
|
|
2160
|
+
throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
|
|
2161
|
+
}
|
|
2162
|
+
const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
|
|
2163
|
+
if (prior) {
|
|
2164
|
+
const buyer2 = this.getBuyer(prior.buyer_id);
|
|
2165
|
+
return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
|
|
2166
|
+
}
|
|
2167
|
+
this.getOrCreateBuyer(opts.buyerId);
|
|
2168
|
+
const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
|
|
2169
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2170
|
+
try {
|
|
2171
|
+
this.db.prepare(
|
|
2172
|
+
`UPDATE buyers SET
|
|
2173
|
+
balance_sat = balance_sat + ?,
|
|
2174
|
+
total_topup_sat = total_topup_sat + ?,
|
|
2175
|
+
updated_at = datetime('now')
|
|
2176
|
+
WHERE buyer_id = ?`
|
|
2177
|
+
).run(opts.amountSat, opts.amountSat, opts.buyerId);
|
|
2178
|
+
this.db.prepare(
|
|
2179
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
|
|
2180
|
+
VALUES (?, ?, 'topup', ?, ?, ?)`
|
|
2181
|
+
).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
|
|
2182
|
+
this.db.exec("COMMIT");
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
try {
|
|
2185
|
+
this.db.exec("ROLLBACK");
|
|
2186
|
+
} catch {
|
|
2187
|
+
}
|
|
2188
|
+
throw err;
|
|
2189
|
+
}
|
|
2190
|
+
const buyer = this.getBuyer(opts.buyerId);
|
|
2191
|
+
return { txId, balanceSat: buyer.balance_sat };
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Reverse a deduct (service failed after the charge). Idempotent: the
|
|
2195
|
+
* unique index on `refunds_tx_id` means a second refund of the same deduct
|
|
2196
|
+
* returns the original refund row.
|
|
2197
|
+
*/
|
|
2198
|
+
refund(deductTxId, reason) {
|
|
2199
|
+
const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
|
|
2200
|
+
if (!deductRow) return { success: false, error: "tx_not_found" };
|
|
2201
|
+
if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
|
|
2202
|
+
const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
|
|
2203
|
+
if (priorRefund) {
|
|
2204
|
+
const buyer = this.getBuyer(deductRow.buyer_id);
|
|
2205
|
+
return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
|
|
2206
|
+
}
|
|
2207
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2208
|
+
try {
|
|
2209
|
+
this.db.prepare(
|
|
2210
|
+
`UPDATE buyers SET
|
|
2211
|
+
balance_sat = balance_sat + ?,
|
|
2212
|
+
total_spent_sat = total_spent_sat - ?,
|
|
2213
|
+
updated_at = datetime('now')
|
|
2214
|
+
WHERE buyer_id = ?`
|
|
2215
|
+
).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
|
|
2216
|
+
this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
|
|
2217
|
+
const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
|
|
2218
|
+
this.db.prepare(
|
|
2219
|
+
`INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
|
|
2220
|
+
VALUES (?, ?, 'refund', ?, ?, ?)`
|
|
2221
|
+
).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
|
|
2222
|
+
this.db.exec("COMMIT");
|
|
2223
|
+
const buyer = this.getBuyer(deductRow.buyer_id);
|
|
2224
|
+
return { success: true, txId, balanceSat: buyer.balance_sat };
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
try {
|
|
2227
|
+
this.db.exec("ROLLBACK");
|
|
2228
|
+
} catch {
|
|
2229
|
+
}
|
|
2230
|
+
throw err;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
/** Paged transaction history, newest first (rowid breaks same-second ties). */
|
|
2234
|
+
listTransactions(buyerId, limit = 20, offset = 0) {
|
|
2235
|
+
return this.db.prepare(
|
|
2236
|
+
`SELECT * FROM ledger_transactions WHERE buyer_id = ?
|
|
2237
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
|
|
2238
|
+
).all(buyerId, limit, offset);
|
|
2239
|
+
}
|
|
2240
|
+
/** Quick integrity probe for healthCheck(). */
|
|
2241
|
+
integrityOk() {
|
|
2242
|
+
const row = this.db.prepare(`PRAGMA quick_check`).get();
|
|
2243
|
+
return row.quick_check === "ok";
|
|
2244
|
+
}
|
|
2245
|
+
close() {
|
|
2246
|
+
this.db.close();
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
// src/facilitators/balance/auth.ts
|
|
2251
|
+
var import_ethers2 = require("ethers");
|
|
2252
|
+
var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
|
|
2253
|
+
var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
2254
|
+
function extractBalanceAuth(raw) {
|
|
2255
|
+
if (!raw || typeof raw !== "object") return null;
|
|
2256
|
+
const a = raw;
|
|
2257
|
+
if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
|
|
2258
|
+
return { timestamp: a.timestamp, signature: a.signature };
|
|
2259
|
+
}
|
|
2260
|
+
return null;
|
|
2261
|
+
}
|
|
2262
|
+
function buildDeductMessage(f) {
|
|
2263
|
+
return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
|
|
2264
|
+
}
|
|
2265
|
+
function verifyDeductAuth(opts) {
|
|
2266
|
+
const { auth } = opts;
|
|
2267
|
+
if (!auth) return { ok: false, reason: "no_signature" };
|
|
2268
|
+
if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
|
|
2269
|
+
if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
|
|
2270
|
+
return { ok: false, reason: "timestamp_skew" };
|
|
2271
|
+
}
|
|
2272
|
+
const message = buildDeductMessage({
|
|
2273
|
+
buyerId: opts.buyerId,
|
|
2274
|
+
requestId: opts.requestId,
|
|
2275
|
+
service: opts.service,
|
|
2276
|
+
timestamp: auth.timestamp
|
|
2277
|
+
});
|
|
2278
|
+
try {
|
|
2279
|
+
const recovered = import_ethers2.ethers.verifyMessage(message, auth.signature).toLowerCase();
|
|
2280
|
+
return { ok: true, recovered };
|
|
2281
|
+
} catch {
|
|
2282
|
+
return { ok: false, reason: "bad_signature" };
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// src/facilitators/balance.ts
|
|
2287
|
+
var BALANCE_NETWORK = "balance";
|
|
2288
|
+
var BALANCE_SCHEME = "balance";
|
|
2289
|
+
function extractBalancePayload(payment) {
|
|
2290
|
+
const p = payment.payload;
|
|
2291
|
+
if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
|
|
2292
|
+
return {
|
|
2293
|
+
buyer_id: p.buyer_id,
|
|
2294
|
+
request_id: typeof p.request_id === "string" ? p.request_id : void 0,
|
|
2295
|
+
auth: extractBalanceAuth(p.auth) ?? void 0
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
return null;
|
|
2299
|
+
}
|
|
2300
|
+
var BalanceFacilitator = class extends BaseFacilitator {
|
|
2301
|
+
name = "balance";
|
|
2302
|
+
displayName = "Custodial Balance";
|
|
2303
|
+
supportedNetworks = [BALANCE_NETWORK];
|
|
2304
|
+
currency;
|
|
2305
|
+
/** User-auth rollout gate for deductions (off | shadow | enforce). */
|
|
2306
|
+
authMode;
|
|
2307
|
+
ledger;
|
|
2308
|
+
constructor(config) {
|
|
2309
|
+
super();
|
|
2310
|
+
this.currency = config.currency ?? "USD";
|
|
2311
|
+
this.authMode = config.auth_mode ?? "off";
|
|
2312
|
+
const ledgerConfig = {
|
|
2313
|
+
dbPath: config.db_path,
|
|
2314
|
+
defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
|
|
2315
|
+
defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
|
|
2316
|
+
currency: this.currency
|
|
2317
|
+
};
|
|
2318
|
+
this.ledger = new BalanceLedger(ledgerConfig);
|
|
2319
|
+
}
|
|
2320
|
+
/** Direct ledger access for the server's balance-management endpoints. */
|
|
2321
|
+
getLedger() {
|
|
2322
|
+
return this.ledger;
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
|
|
2326
|
+
*/
|
|
2327
|
+
createPaymentRequirements(opts) {
|
|
2328
|
+
toSat(opts.price);
|
|
2329
|
+
return {
|
|
2330
|
+
scheme: BALANCE_SCHEME,
|
|
2331
|
+
network: BALANCE_NETWORK,
|
|
2332
|
+
asset: this.currency,
|
|
2333
|
+
amount: opts.price,
|
|
2334
|
+
payTo: "custodial",
|
|
2335
|
+
maxTimeoutSeconds: 30,
|
|
2336
|
+
extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
/** Read-only funds/limits precheck. Never mutates the ledger. */
|
|
2340
|
+
async verify(paymentPayload, requirements) {
|
|
2341
|
+
const payload = extractBalancePayload(paymentPayload);
|
|
2342
|
+
if (!payload) {
|
|
2343
|
+
return { valid: false, error: "Missing buyer_id in balance payment payload" };
|
|
2344
|
+
}
|
|
2345
|
+
let amountSat;
|
|
2346
|
+
try {
|
|
2347
|
+
amountSat = toSat(requirements.amount);
|
|
2348
|
+
} catch (err) {
|
|
2349
|
+
return { valid: false, error: err.message };
|
|
2350
|
+
}
|
|
2351
|
+
const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
|
|
2352
|
+
if (!check.success) {
|
|
2353
|
+
return {
|
|
2354
|
+
valid: false,
|
|
2355
|
+
error: this.describeDeductError(check),
|
|
2356
|
+
details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
return { valid: true, details: { balance: fromSat(check.balanceSat) } };
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* The atomic deduction. Idempotent on `request_id`; the returned
|
|
2363
|
+
* `transaction` is the ledger tx id (usable for `refund`).
|
|
2364
|
+
*/
|
|
2365
|
+
async settle(paymentPayload, requirements) {
|
|
2366
|
+
const payload = extractBalancePayload(paymentPayload);
|
|
2367
|
+
if (!payload) {
|
|
2368
|
+
return { success: false, error: "Missing buyer_id in balance payment payload" };
|
|
2369
|
+
}
|
|
2370
|
+
let amountSat;
|
|
2371
|
+
try {
|
|
2372
|
+
amountSat = toSat(requirements.amount);
|
|
2373
|
+
} catch (err) {
|
|
2374
|
+
return { success: false, error: err.message };
|
|
2375
|
+
}
|
|
2376
|
+
const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
|
|
2377
|
+
let result;
|
|
2378
|
+
try {
|
|
2379
|
+
result = this.ledger.deduct({
|
|
2380
|
+
buyerId: payload.buyer_id,
|
|
2381
|
+
amountSat,
|
|
2382
|
+
requestId: payload.request_id,
|
|
2383
|
+
service: serviceId
|
|
2384
|
+
});
|
|
2385
|
+
} catch (err) {
|
|
2386
|
+
return { success: false, error: `Ledger deduct failed: ${err.message}` };
|
|
2387
|
+
}
|
|
2388
|
+
if (!result.success) {
|
|
2389
|
+
return { success: false, error: this.describeDeductError(result), status: result.error };
|
|
2390
|
+
}
|
|
2391
|
+
return {
|
|
2392
|
+
success: true,
|
|
2393
|
+
transaction: result.txId,
|
|
2394
|
+
status: result.replayed ? "replayed" : "deducted"
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
/** Reverse a deduct after a downstream failure. Idempotent per deduct. */
|
|
2398
|
+
refund(deductTxId, reason) {
|
|
2399
|
+
return this.ledger.refund(deductTxId, reason);
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Credit a gateway-verified external settlement to a buyer's balance. The
|
|
2403
|
+
* single entry point for every funding path -- WeChat callback, WeChat
|
|
2404
|
+
* polling, and the operator `/balance/topup` endpoint -- so they share one
|
|
2405
|
+
* idempotency boundary: `externalRef` is unique in the ledger, so a replay
|
|
2406
|
+
* credits nothing and returns the original transaction (`replayed: true`).
|
|
2407
|
+
* Callers must pass a gateway-verified `amountSat`, never a client-declared
|
|
2408
|
+
* amount.
|
|
2409
|
+
*/
|
|
2410
|
+
credit(opts) {
|
|
2411
|
+
const result = this.ledger.topup({
|
|
2412
|
+
buyerId: opts.buyerId,
|
|
2413
|
+
amountSat: opts.amountSat,
|
|
2414
|
+
externalRef: opts.externalRef,
|
|
2415
|
+
description: opts.description
|
|
2416
|
+
});
|
|
2417
|
+
return {
|
|
2418
|
+
txId: result.txId,
|
|
2419
|
+
balance: fromSat(result.balanceSat),
|
|
2420
|
+
balanceSat: result.balanceSat,
|
|
2421
|
+
replayed: result.replayed ?? false
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
async healthCheck() {
|
|
2425
|
+
const start = Date.now();
|
|
2426
|
+
try {
|
|
2427
|
+
const ok = this.ledger.integrityOk();
|
|
2428
|
+
return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
|
|
2429
|
+
} catch (err) {
|
|
2430
|
+
return { healthy: false, error: err.message };
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
describeDeductError(result) {
|
|
2434
|
+
switch (result.error) {
|
|
2435
|
+
case "buyer_not_found":
|
|
2436
|
+
return "Unknown buyer: top up first to create an account";
|
|
2437
|
+
case "buyer_not_active":
|
|
2438
|
+
return "Buyer account is frozen or banned";
|
|
2439
|
+
case "insufficient_balance":
|
|
2440
|
+
return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
|
|
2441
|
+
case "exceeds_single_limit":
|
|
2442
|
+
return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
|
|
2443
|
+
case "exceeds_daily_limit":
|
|
2444
|
+
return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
|
|
2445
|
+
default:
|
|
2446
|
+
return "Deduction failed";
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
// src/facilitators/registry.ts
|
|
2452
|
+
var import_web33 = require("@solana/web3.js");
|
|
2453
|
+
var import_bs58 = __toESM(require("bs58"));
|
|
2454
|
+
var FacilitatorRegistry = class {
|
|
2455
|
+
factories = /* @__PURE__ */ new Map();
|
|
2456
|
+
instances = /* @__PURE__ */ new Map();
|
|
2457
|
+
selection;
|
|
2458
|
+
roundRobinIndex = 0;
|
|
2459
|
+
constructor(selection) {
|
|
2460
|
+
this.registerFactory("cdp", (config) => new CDPFacilitator(config));
|
|
2461
|
+
this.registerFactory("tempo", () => new TempoFacilitator());
|
|
2462
|
+
this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
|
|
2463
|
+
this.registerFactory("solana", (config) => {
|
|
2464
|
+
let feePayerKeypair;
|
|
2465
|
+
const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
|
|
2466
|
+
if (feePayerKey) {
|
|
2467
|
+
try {
|
|
2468
|
+
feePayerKeypair = import_web33.Keypair.fromSecretKey(import_bs58.default.decode(feePayerKey));
|
|
2469
|
+
} catch (e) {
|
|
2470
|
+
console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return new SolanaFacilitator({ feePayerKeypair });
|
|
2474
|
+
});
|
|
2475
|
+
this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
|
|
2476
|
+
this.registerFactory("wechat", (config) => new WechatFacilitator(config));
|
|
2477
|
+
this.registerFactory("balance", (config) => new BalanceFacilitator(config));
|
|
2478
|
+
this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Register a new facilitator factory
|
|
2482
|
+
*/
|
|
2483
|
+
registerFactory(name, factory) {
|
|
2484
|
+
this.factories.set(name, factory);
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Get or create a facilitator instance
|
|
2488
|
+
*/
|
|
2489
|
+
get(name, config) {
|
|
2490
|
+
if (this.instances.has(name)) {
|
|
2491
|
+
return this.instances.get(name);
|
|
2492
|
+
}
|
|
2493
|
+
const factory = this.factories.get(name);
|
|
2494
|
+
if (!factory) {
|
|
2495
|
+
throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
|
|
2496
|
+
}
|
|
2497
|
+
const mergedConfig = {
|
|
2498
|
+
...this.selection.config?.[name],
|
|
2499
|
+
...config
|
|
2500
|
+
};
|
|
2501
|
+
const instance = factory(mergedConfig);
|
|
2502
|
+
this.instances.set(name, instance);
|
|
2503
|
+
return instance;
|
|
2504
|
+
}
|
|
2505
|
+
/**
|
|
2506
|
+
* Get all configured facilitator names
|
|
2507
|
+
*/
|
|
2508
|
+
getConfiguredNames() {
|
|
2509
|
+
const names = [this.selection.primary];
|
|
2510
|
+
if (this.selection.fallback) {
|
|
2511
|
+
names.push(...this.selection.fallback);
|
|
2512
|
+
}
|
|
2513
|
+
return names;
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Get list of facilitators based on selection strategy
|
|
2517
|
+
*/
|
|
2518
|
+
async getOrderedFacilitators(network) {
|
|
2519
|
+
const names = this.getConfiguredNames();
|
|
2520
|
+
const facilitators = [];
|
|
2521
|
+
for (const name of names) {
|
|
2522
|
+
try {
|
|
2523
|
+
const f = this.get(name);
|
|
2524
|
+
if (f.supportsNetwork(network)) {
|
|
2525
|
+
facilitators.push(f);
|
|
2526
|
+
}
|
|
2527
|
+
} catch (err) {
|
|
2528
|
+
console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
if (facilitators.length === 0) {
|
|
2532
|
+
throw new Error(`No facilitators available for network: ${network}`);
|
|
2533
|
+
}
|
|
2534
|
+
switch (this.selection.strategy) {
|
|
2535
|
+
case "random":
|
|
2536
|
+
return this.shuffle(facilitators);
|
|
2537
|
+
case "roundrobin":
|
|
2538
|
+
this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
|
|
2539
|
+
return [
|
|
2540
|
+
...facilitators.slice(this.roundRobinIndex),
|
|
2541
|
+
...facilitators.slice(0, this.roundRobinIndex)
|
|
2542
|
+
];
|
|
2543
|
+
case "cheapest":
|
|
2544
|
+
return this.sortByCheapest(facilitators);
|
|
2545
|
+
case "fastest":
|
|
2546
|
+
return this.sortByFastest(facilitators);
|
|
2547
|
+
case "failover":
|
|
2548
|
+
default:
|
|
2549
|
+
return facilitators;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
shuffle(array) {
|
|
2553
|
+
const result = [...array];
|
|
2554
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
2555
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2556
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
2557
|
+
}
|
|
2558
|
+
return result;
|
|
2559
|
+
}
|
|
2560
|
+
async sortByCheapest(facilitators) {
|
|
2561
|
+
const withFees = await Promise.all(
|
|
2562
|
+
facilitators.map(async (f) => {
|
|
2563
|
+
try {
|
|
2564
|
+
const fee = await f.getFee?.();
|
|
2565
|
+
return { facilitator: f, perTx: fee?.perTx ?? Infinity };
|
|
2566
|
+
} catch {
|
|
2567
|
+
return { facilitator: f, perTx: Infinity };
|
|
2568
|
+
}
|
|
2569
|
+
})
|
|
2570
|
+
);
|
|
2571
|
+
withFees.sort((a, b) => a.perTx - b.perTx);
|
|
2572
|
+
return withFees.map((w) => w.facilitator);
|
|
2573
|
+
}
|
|
2574
|
+
async sortByFastest(facilitators) {
|
|
2575
|
+
const withLatency = await Promise.all(
|
|
2576
|
+
facilitators.map(async (f) => {
|
|
2577
|
+
try {
|
|
2578
|
+
const health = await f.healthCheck();
|
|
2579
|
+
return { facilitator: f, latency: health.latencyMs ?? Infinity };
|
|
2580
|
+
} catch {
|
|
2581
|
+
return { facilitator: f, latency: Infinity };
|
|
2582
|
+
}
|
|
2583
|
+
})
|
|
2584
|
+
);
|
|
2585
|
+
withLatency.sort((a, b) => a.latency - b.latency);
|
|
2586
|
+
return withLatency.map((w) => w.facilitator);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Verify payment using configured facilitators
|
|
2590
|
+
*/
|
|
2591
|
+
async verify(paymentPayload, requirements) {
|
|
2592
|
+
const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
|
|
2593
|
+
const facilitators = await this.getOrderedFacilitators(network);
|
|
2594
|
+
let lastError;
|
|
2595
|
+
for (const f of facilitators) {
|
|
2596
|
+
try {
|
|
2597
|
+
console.log(`[Registry] Trying ${f.name} for verify...`);
|
|
2598
|
+
const result = await f.verify(paymentPayload, requirements);
|
|
2599
|
+
if (result.valid) {
|
|
2600
|
+
console.log(`[Registry] ${f.name} verify succeeded`);
|
|
2601
|
+
return { ...result, facilitator: f.name };
|
|
2602
|
+
}
|
|
2603
|
+
lastError = result.error;
|
|
2604
|
+
console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
|
|
2605
|
+
if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
|
|
2606
|
+
break;
|
|
2607
|
+
}
|
|
2608
|
+
} catch (err) {
|
|
2609
|
+
lastError = err.message;
|
|
2610
|
+
console.error(`[Registry] ${f.name} error:`, err.message);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
return {
|
|
2614
|
+
valid: false,
|
|
2615
|
+
error: lastError || "All facilitators failed",
|
|
2616
|
+
facilitator: "none"
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Settle payment using configured facilitators
|
|
2621
|
+
*/
|
|
2622
|
+
async settle(paymentPayload, requirements) {
|
|
2623
|
+
const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
|
|
2624
|
+
const facilitators = await this.getOrderedFacilitators(network);
|
|
2625
|
+
let lastError;
|
|
2626
|
+
for (const f of facilitators) {
|
|
2627
|
+
try {
|
|
2628
|
+
console.log(`[Registry] Trying ${f.name} for settle...`);
|
|
2629
|
+
const result = await f.settle(paymentPayload, requirements);
|
|
2630
|
+
if (result.success) {
|
|
2631
|
+
console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
|
|
2632
|
+
return { ...result, facilitator: f.name };
|
|
2633
|
+
}
|
|
2634
|
+
lastError = result.error;
|
|
2635
|
+
console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
|
|
2636
|
+
} catch (err) {
|
|
2637
|
+
lastError = err.message;
|
|
2638
|
+
console.error(`[Registry] ${f.name} error:`, err.message);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
return {
|
|
2642
|
+
success: false,
|
|
2643
|
+
error: lastError || "All facilitators failed",
|
|
2644
|
+
facilitator: "none"
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Check health of all configured facilitators
|
|
2649
|
+
*/
|
|
2650
|
+
async healthCheckAll() {
|
|
2651
|
+
const results = {};
|
|
2652
|
+
for (const name of this.getConfiguredNames()) {
|
|
2653
|
+
try {
|
|
2654
|
+
const f = this.get(name);
|
|
2655
|
+
results[name] = await f.healthCheck();
|
|
2656
|
+
} catch (err) {
|
|
1762
2657
|
results[name] = { healthy: false, error: err.message };
|
|
1763
2658
|
}
|
|
1764
2659
|
}
|
|
@@ -1796,7 +2691,95 @@ var FacilitatorRegistry = class {
|
|
|
1796
2691
|
}
|
|
1797
2692
|
};
|
|
1798
2693
|
|
|
1799
|
-
// src/server/
|
|
2694
|
+
// src/server/balance-endpoints.ts
|
|
2695
|
+
var import_node_crypto6 = __toESM(require("crypto"));
|
|
2696
|
+
|
|
2697
|
+
// src/verify/index.ts
|
|
2698
|
+
var import_ethers3 = require("ethers");
|
|
2699
|
+
var TRANSFER_EVENT_TOPIC3 = import_ethers3.ethers.id("Transfer(address,address,uint256)");
|
|
2700
|
+
async function verifyPayment(params) {
|
|
2701
|
+
const { txHash, expectedAmount, expectedTo, expectedToken } = params;
|
|
2702
|
+
let chain;
|
|
2703
|
+
try {
|
|
2704
|
+
if (typeof params.chain === "number") {
|
|
2705
|
+
chain = getChainById(params.chain);
|
|
2706
|
+
} else {
|
|
2707
|
+
chain = getChain(params.chain || "base");
|
|
2708
|
+
}
|
|
2709
|
+
if (!chain) {
|
|
2710
|
+
return { verified: false, error: `Unsupported chain: ${params.chain}` };
|
|
2711
|
+
}
|
|
2712
|
+
} catch (e) {
|
|
2713
|
+
return { verified: false, error: `Unsupported chain: ${params.chain}` };
|
|
2714
|
+
}
|
|
2715
|
+
try {
|
|
2716
|
+
const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
|
|
2717
|
+
const receipt = await provider.getTransactionReceipt(txHash);
|
|
2718
|
+
if (!receipt) {
|
|
2719
|
+
return { verified: false, error: "Transaction not found or not confirmed" };
|
|
2720
|
+
}
|
|
2721
|
+
if (receipt.status !== 1) {
|
|
2722
|
+
return { verified: false, error: "Transaction failed" };
|
|
2723
|
+
}
|
|
2724
|
+
const tokenAddresses = {};
|
|
2725
|
+
if (!expectedToken || expectedToken === "USDC") {
|
|
2726
|
+
tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
|
|
2727
|
+
}
|
|
2728
|
+
if (!expectedToken || expectedToken === "USDT") {
|
|
2729
|
+
tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
|
|
2730
|
+
}
|
|
2731
|
+
if (Object.keys(tokenAddresses).length === 0) {
|
|
2732
|
+
return { verified: false, error: `No token addresses configured for ${chain.name}` };
|
|
2733
|
+
}
|
|
2734
|
+
for (const log of receipt.logs) {
|
|
2735
|
+
const logAddress = log.address.toLowerCase();
|
|
2736
|
+
const detectedToken = tokenAddresses[logAddress];
|
|
2737
|
+
if (!detectedToken) {
|
|
2738
|
+
continue;
|
|
2739
|
+
}
|
|
2740
|
+
if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
|
|
2741
|
+
continue;
|
|
2742
|
+
}
|
|
2743
|
+
const from = "0x" + log.topics[1].slice(-40);
|
|
2744
|
+
const to = "0x" + log.topics[2].slice(-40);
|
|
2745
|
+
const amountRaw = BigInt(log.data);
|
|
2746
|
+
const tokenConfig = chain.tokens[detectedToken];
|
|
2747
|
+
const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
|
|
2748
|
+
if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
if (amount < expectedAmount) {
|
|
2752
|
+
return {
|
|
2753
|
+
verified: false,
|
|
2754
|
+
error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
|
|
2755
|
+
amount,
|
|
2756
|
+
token: detectedToken,
|
|
2757
|
+
from,
|
|
2758
|
+
to,
|
|
2759
|
+
txHash,
|
|
2760
|
+
blockNumber: receipt.blockNumber
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
return {
|
|
2764
|
+
verified: true,
|
|
2765
|
+
amount,
|
|
2766
|
+
token: detectedToken,
|
|
2767
|
+
from,
|
|
2768
|
+
to,
|
|
2769
|
+
txHash,
|
|
2770
|
+
blockNumber: receipt.blockNumber
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
const tokenList = expectedToken ? expectedToken : "USDC/USDT";
|
|
2774
|
+
return { verified: false, error: `No ${tokenList} transfer found` };
|
|
2775
|
+
} catch (e) {
|
|
2776
|
+
return { verified: false, error: e.message || String(e) };
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// src/server/internal.ts
|
|
2781
|
+
var import_fs2 = require("fs");
|
|
2782
|
+
var path2 = __toESM(require("path"));
|
|
1800
2783
|
var X402_VERSION2 = 2;
|
|
1801
2784
|
var PAYMENT_REQUIRED_HEADER = "x-payment-required";
|
|
1802
2785
|
var PAYMENT_HEADER = "x-payment";
|
|
@@ -1809,6 +2792,15 @@ var ALIPAY_PAYMENT_PROOF_HEADER = "payment-proof";
|
|
|
1809
2792
|
function headerSafe(value) {
|
|
1810
2793
|
return String(value ?? "").replace(/[^\x20-\x7E]/g, (ch) => encodeURIComponent(ch)).replace(/"/g, "%22");
|
|
1811
2794
|
}
|
|
2795
|
+
function canonicalJson(value) {
|
|
2796
|
+
if (value === null || typeof value !== "object") {
|
|
2797
|
+
return JSON.stringify(value) ?? "null";
|
|
2798
|
+
}
|
|
2799
|
+
if (Array.isArray(value)) {
|
|
2800
|
+
return "[" + value.map(canonicalJson).join(",") + "]";
|
|
2801
|
+
}
|
|
2802
|
+
return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
|
|
2803
|
+
}
|
|
1812
2804
|
var TOKEN_ADDRESSES = {
|
|
1813
2805
|
"eip155:8453": {
|
|
1814
2806
|
USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
@@ -1899,44 +2891,377 @@ var TOKEN_DOMAINS = {
|
|
|
1899
2891
|
USDC: { name: "USD Coin", version: "1" },
|
|
1900
2892
|
USDT: { name: "Tether USD", version: "1" }
|
|
1901
2893
|
}
|
|
1902
|
-
};
|
|
1903
|
-
function getTokenDomain(network, token) {
|
|
1904
|
-
const networkDomains = TOKEN_DOMAINS[network] || TOKEN_DOMAINS["eip155:8453"];
|
|
1905
|
-
return networkDomains[token] || { name: "USD Coin", version: "2" };
|
|
1906
|
-
}
|
|
1907
|
-
function getAcceptedCurrencies(config) {
|
|
1908
|
-
return config.acceptedCurrencies ?? [config.currency];
|
|
1909
|
-
}
|
|
1910
|
-
function loadEnvFile2() {
|
|
1911
|
-
const envPaths = [
|
|
1912
|
-
path2.join(process.cwd(), ".env"),
|
|
1913
|
-
path2.join(process.env.HOME || "", ".moltspay", ".env")
|
|
1914
|
-
];
|
|
1915
|
-
for (const envPath of envPaths) {
|
|
1916
|
-
if ((0, import_fs2.existsSync)(envPath)) {
|
|
1917
|
-
try {
|
|
1918
|
-
const content = (0, import_fs2.readFileSync)(envPath, "utf-8");
|
|
1919
|
-
for (const line of content.split("\n")) {
|
|
1920
|
-
const trimmed = line.trim();
|
|
1921
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1922
|
-
const eqIndex = trimmed.indexOf("=");
|
|
1923
|
-
if (eqIndex === -1) continue;
|
|
1924
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
1925
|
-
let value = trimmed.slice(eqIndex + 1).trim();
|
|
1926
|
-
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
1927
|
-
value = value.slice(1, -1);
|
|
1928
|
-
}
|
|
1929
|
-
if (!process.env[key]) {
|
|
1930
|
-
process.env[key] = value;
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
console.log(`[MoltsPay] Loaded config from ${envPath}`);
|
|
1934
|
-
break;
|
|
1935
|
-
} catch {
|
|
2894
|
+
};
|
|
2895
|
+
function getTokenDomain(network, token) {
|
|
2896
|
+
const networkDomains = TOKEN_DOMAINS[network] || TOKEN_DOMAINS["eip155:8453"];
|
|
2897
|
+
return networkDomains[token] || { name: "USD Coin", version: "2" };
|
|
2898
|
+
}
|
|
2899
|
+
function getAcceptedCurrencies(config) {
|
|
2900
|
+
return config.acceptedCurrencies ?? [config.currency];
|
|
2901
|
+
}
|
|
2902
|
+
function loadEnvFile2() {
|
|
2903
|
+
const envPaths = [
|
|
2904
|
+
path2.join(process.cwd(), ".env"),
|
|
2905
|
+
path2.join(process.env.HOME || "", ".moltspay", ".env")
|
|
2906
|
+
];
|
|
2907
|
+
for (const envPath of envPaths) {
|
|
2908
|
+
if ((0, import_fs2.existsSync)(envPath)) {
|
|
2909
|
+
try {
|
|
2910
|
+
const content = (0, import_fs2.readFileSync)(envPath, "utf-8");
|
|
2911
|
+
for (const line of content.split("\n")) {
|
|
2912
|
+
const trimmed = line.trim();
|
|
2913
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
2914
|
+
const eqIndex = trimmed.indexOf("=");
|
|
2915
|
+
if (eqIndex === -1) continue;
|
|
2916
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
2917
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
2918
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2919
|
+
value = value.slice(1, -1);
|
|
2920
|
+
}
|
|
2921
|
+
if (!process.env[key]) {
|
|
2922
|
+
process.env[key] = value;
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
console.log(`[MoltsPay] Loaded config from ${envPath}`);
|
|
2926
|
+
break;
|
|
2927
|
+
} catch {
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
// src/server/balance-endpoints.ts
|
|
2934
|
+
var BalanceEndpoints = class {
|
|
2935
|
+
constructor(deps) {
|
|
2936
|
+
this.deps = deps;
|
|
2937
|
+
}
|
|
2938
|
+
/** GET /balance?buyer_id= -- balance, limits, and today's spend. */
|
|
2939
|
+
handleQuery(url, res) {
|
|
2940
|
+
const { balance, sendJson } = this.deps;
|
|
2941
|
+
const buyerId = url.searchParams.get("buyer_id");
|
|
2942
|
+
if (!buyerId) {
|
|
2943
|
+
return sendJson(res, 400, { error: "buyer_id query parameter is required" });
|
|
2944
|
+
}
|
|
2945
|
+
const ledger = balance.getLedger();
|
|
2946
|
+
const buyer = ledger.getBuyer(buyerId);
|
|
2947
|
+
if (!buyer) {
|
|
2948
|
+
return sendJson(res, 200, {
|
|
2949
|
+
buyer_id: buyerId,
|
|
2950
|
+
balance: "0.00",
|
|
2951
|
+
currency: balance.currency,
|
|
2952
|
+
exists: false
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
sendJson(res, 200, {
|
|
2956
|
+
buyer_id: buyerId,
|
|
2957
|
+
balance: fromSat(buyer.balance_sat),
|
|
2958
|
+
currency: balance.currency,
|
|
2959
|
+
single_limit: fromSat(buyer.single_limit_sat),
|
|
2960
|
+
daily_limit: fromSat(buyer.daily_limit_sat),
|
|
2961
|
+
today_spent: fromSat(ledger.spentTodaySat(buyerId)),
|
|
2962
|
+
status: buyer.status,
|
|
2963
|
+
wechat_openid: buyer.wechat_openid ?? null,
|
|
2964
|
+
signer_address: buyer.signer_address ?? null,
|
|
2965
|
+
exists: true
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Extract the gateway-confirmed paid amount (fen) from a WeChat verify
|
|
2970
|
+
* result. `payer_total` is what the buyer actually paid; falls back to
|
|
2971
|
+
* `total`. Returns null if no usable positive integer is present, so the
|
|
2972
|
+
* client-declared amount can never be trusted for crediting.
|
|
2973
|
+
*/
|
|
2974
|
+
wechatPaidFen(check) {
|
|
2975
|
+
const amount = check.details?.amount;
|
|
2976
|
+
const paid = amount?.payer_total ?? amount?.total;
|
|
2977
|
+
return typeof paid === "number" && Number.isFinite(paid) && paid > 0 ? paid : null;
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* POST /balance/topup/order -- mint a buyer-bound WeChat Native order for a
|
|
2981
|
+
* configured top-up pack. The buyer_id rides in the WeChat `attach` so the
|
|
2982
|
+
* later confirm/callback credits the correct balance. Reuses the pending
|
|
2983
|
+
* order cache (keyed by buyer_id + pack) so concurrent requests share one
|
|
2984
|
+
* order. Body: `{ buyer_id, pack? }`. Returns `{ code_url, out_trade_no,
|
|
2985
|
+
* pack, max_timeout_seconds }`.
|
|
2986
|
+
*/
|
|
2987
|
+
async handleTopupOrder(body, res) {
|
|
2988
|
+
const { manifest, wechat, sendJson, getOrCreatePendingWechatOrder } = this.deps;
|
|
2989
|
+
const { buyer_id, pack } = body || {};
|
|
2990
|
+
if (typeof buyer_id !== "string" || !buyer_id) {
|
|
2991
|
+
return sendJson(res, 400, { error: "buyer_id is required" });
|
|
2992
|
+
}
|
|
2993
|
+
const signerAddress = typeof body?.signer_address === "string" && /^0x[0-9a-fA-F]{40}$/.test(body.signer_address) ? body.signer_address.toLowerCase() : void 0;
|
|
2994
|
+
if (!wechat) {
|
|
2995
|
+
return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
|
|
2996
|
+
}
|
|
2997
|
+
const balCfg = manifest.provider.balance;
|
|
2998
|
+
const packs = balCfg?.topup_packs ?? [];
|
|
2999
|
+
const chosen = typeof pack === "string" && pack ? pack : balCfg?.default_pack;
|
|
3000
|
+
if (!chosen) {
|
|
3001
|
+
return sendJson(res, 400, { error: "no pack specified and no default_pack configured" });
|
|
3002
|
+
}
|
|
3003
|
+
let chosenSat;
|
|
3004
|
+
try {
|
|
3005
|
+
chosenSat = toSat(chosen);
|
|
3006
|
+
if (chosenSat <= 0) throw new Error("pack must be positive");
|
|
3007
|
+
} catch (err) {
|
|
3008
|
+
return sendJson(res, 400, { error: `Invalid pack: ${err.message}` });
|
|
3009
|
+
}
|
|
3010
|
+
const withinMax = balCfg?.auto_topup_max ? chosenSat <= toSat(balCfg.auto_topup_max) : false;
|
|
3011
|
+
if (!packs.includes(chosen) && !withinMax) {
|
|
3012
|
+
return sendJson(res, 400, {
|
|
3013
|
+
error: `pack "${chosen}" is not an offered top-up pack and exceeds auto_topup_max`
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
const nonce = import_node_crypto6.default.randomBytes(8).toString("hex");
|
|
3017
|
+
const attach = signerAddress ? { buyer_id, signer: signerAddress } : { buyer_id, nonce };
|
|
3018
|
+
const cacheKey = import_node_crypto6.default.createHash("sha256").update(`topup|${buyer_id}|${chosen}|${signerAddress ?? ""}`).digest("hex");
|
|
3019
|
+
const result = await getOrCreatePendingWechatOrder(
|
|
3020
|
+
cacheKey,
|
|
3021
|
+
`topup:${buyer_id}`,
|
|
3022
|
+
() => wechat.createPaymentRequirements({
|
|
3023
|
+
priceCny: chosen,
|
|
3024
|
+
description: `Balance top-up ${chosen}`,
|
|
3025
|
+
attach
|
|
3026
|
+
})
|
|
3027
|
+
);
|
|
3028
|
+
if (!result) {
|
|
3029
|
+
return sendJson(res, 502, { error: "failed to create WeChat top-up order" });
|
|
3030
|
+
}
|
|
3031
|
+
return sendJson(res, 200, {
|
|
3032
|
+
code_url: result.codeUrl,
|
|
3033
|
+
out_trade_no: result.outTradeNo,
|
|
3034
|
+
pack: chosen,
|
|
3035
|
+
max_timeout_seconds: result.accepts.maxTimeoutSeconds
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* POST /balance/topup/confirm -- the polling-fallback credit path. The client
|
|
3040
|
+
* polls this after a scan; the server verifies the order with the WeChat
|
|
3041
|
+
* gateway and, on SUCCESS, credits the buyer bound in `attach` with the
|
|
3042
|
+
* gateway-confirmed `payer_total` (never a client-declared amount).
|
|
3043
|
+
* Idempotent on `wechat:<out_trade_no>`. Body: `{ out_trade_no }`.
|
|
3044
|
+
*/
|
|
3045
|
+
async handleTopupConfirm(body, res) {
|
|
3046
|
+
const { manifest, balance, wechat, sendJson, invalidateWechatChallenge } = this.deps;
|
|
3047
|
+
const outTradeNo = body?.out_trade_no;
|
|
3048
|
+
if (typeof outTradeNo !== "string" || !outTradeNo) {
|
|
3049
|
+
return sendJson(res, 400, { error: "out_trade_no is required" });
|
|
3050
|
+
}
|
|
3051
|
+
if (!wechat) {
|
|
3052
|
+
return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
|
|
3053
|
+
}
|
|
3054
|
+
const wxPayload = {
|
|
3055
|
+
x402Version: X402_VERSION2,
|
|
3056
|
+
scheme: WECHAT_SCHEME,
|
|
3057
|
+
network: WECHAT_NETWORK,
|
|
3058
|
+
payload: { out_trade_no: outTradeNo }
|
|
3059
|
+
};
|
|
3060
|
+
const wxReqs = {
|
|
3061
|
+
scheme: WECHAT_SCHEME,
|
|
3062
|
+
network: WECHAT_NETWORK,
|
|
3063
|
+
asset: "CNY",
|
|
3064
|
+
amount: "0",
|
|
3065
|
+
payTo: manifest.provider.wechat?.mchid || "",
|
|
3066
|
+
maxTimeoutSeconds: 30,
|
|
3067
|
+
extra: { out_trade_no: outTradeNo }
|
|
3068
|
+
};
|
|
3069
|
+
const check = await wechat.verify(wxPayload, wxReqs);
|
|
3070
|
+
if (!check.valid) {
|
|
3071
|
+
return sendJson(res, 200, { credited: false, pending: true, reason: check.error });
|
|
3072
|
+
}
|
|
3073
|
+
const paidFen = this.wechatPaidFen(check);
|
|
3074
|
+
if (paidFen === null) {
|
|
3075
|
+
return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
|
|
3076
|
+
}
|
|
3077
|
+
const attach = parseWechatAttach(check.details?.attach);
|
|
3078
|
+
const buyerId = attach?.buyer_id;
|
|
3079
|
+
if (!buyerId) {
|
|
3080
|
+
return sendJson(res, 422, {
|
|
3081
|
+
error: "top-up order has no buyer binding (attach.buyer_id missing)"
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
const credited = balance.credit({
|
|
3085
|
+
buyerId,
|
|
3086
|
+
amountSat: paidFen,
|
|
3087
|
+
externalRef: `wechat:${outTradeNo}`,
|
|
3088
|
+
description: `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`
|
|
3089
|
+
});
|
|
3090
|
+
invalidateWechatChallenge(outTradeNo);
|
|
3091
|
+
const openid = check.details?.openid;
|
|
3092
|
+
let openidBinding;
|
|
3093
|
+
if (typeof openid === "string" && openid) {
|
|
3094
|
+
openidBinding = balance.getLedger().bindOpenid(buyerId, openid);
|
|
3095
|
+
if (openidBinding.conflict) {
|
|
3096
|
+
console.warn(
|
|
3097
|
+
`[MoltsPay] Balance top-up openid conflict for buyer=${buyerId}: already bound to ${openidBinding.existing}, this payment from ${openid} \u2014 recorded, not enforced`
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
const signer = attach?.signer;
|
|
3102
|
+
if (typeof signer === "string" && /^0x[0-9a-fA-F]{40}$/.test(signer)) {
|
|
3103
|
+
const sb = balance.getLedger().bindSigner(buyerId, signer);
|
|
3104
|
+
if (sb.conflict) {
|
|
3105
|
+
console.warn(
|
|
3106
|
+
`[MoltsPay] Balance top-up signer conflict for buyer=${buyerId}: already bound to ${sb.existing}, this order signed for ${signer.toLowerCase()} \u2014 recorded, not enforced`
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
console.log(
|
|
3111
|
+
`[MoltsPay] Balance top-up credited buyer=${buyerId} +${fromSat(paidFen)} (${outTradeNo})${credited.replayed ? " [replayed]" : ""}${typeof openid === "string" && openid ? ` openid=${openid}${openidBinding?.conflict ? " [CONFLICT]" : ""}` : ""}`
|
|
3112
|
+
);
|
|
3113
|
+
return sendJson(res, 200, {
|
|
3114
|
+
credited: true,
|
|
3115
|
+
buyer_id: buyerId,
|
|
3116
|
+
tx_id: credited.txId,
|
|
3117
|
+
balance: credited.balance,
|
|
3118
|
+
replayed: credited.replayed,
|
|
3119
|
+
openid_bound: openidBinding?.bound ?? false,
|
|
3120
|
+
openid_conflict: openidBinding?.conflict ?? false
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
/**
|
|
3124
|
+
* POST /balance/topup -- verify an externally settled payment and credit the
|
|
3125
|
+
* buyer's ledger balance (operator/recovery path). Per rail: `crypto`
|
|
3126
|
+
* verifies the tx on-chain; `wechat` queries the order and credits the
|
|
3127
|
+
* gateway-confirmed payer_total (never the client-declared amount);
|
|
3128
|
+
* `alipay` is operator-trusted in this MVP. Idempotent on the external ref.
|
|
3129
|
+
*/
|
|
3130
|
+
async handleTopup(body, res) {
|
|
3131
|
+
const { manifest, balance, wechat, sendJson } = this.deps;
|
|
3132
|
+
const { buyer_id, rail, amount } = body || {};
|
|
3133
|
+
if (typeof buyer_id !== "string" || !buyer_id) {
|
|
3134
|
+
return sendJson(res, 400, { error: "buyer_id is required" });
|
|
3135
|
+
}
|
|
3136
|
+
let amountSat;
|
|
3137
|
+
try {
|
|
3138
|
+
amountSat = toSat(String(amount));
|
|
3139
|
+
if (amountSat <= 0) throw new Error("amount must be positive");
|
|
3140
|
+
} catch (err) {
|
|
3141
|
+
return sendJson(res, 400, { error: `Invalid amount: ${err.message}` });
|
|
3142
|
+
}
|
|
3143
|
+
let externalRef;
|
|
3144
|
+
let description;
|
|
3145
|
+
if (rail === "crypto") {
|
|
3146
|
+
const txHash = body.tx_hash;
|
|
3147
|
+
if (typeof txHash !== "string" || !txHash) {
|
|
3148
|
+
return sendJson(res, 400, { error: 'tx_hash is required for rail "crypto"' });
|
|
3149
|
+
}
|
|
3150
|
+
const check = await verifyPayment({
|
|
3151
|
+
txHash,
|
|
3152
|
+
expectedAmount: amountSat / 100,
|
|
3153
|
+
expectedTo: manifest.provider.wallet,
|
|
3154
|
+
chain: body.chain || "base"
|
|
3155
|
+
});
|
|
3156
|
+
if (!check.verified) {
|
|
3157
|
+
return sendJson(res, 402, { error: `On-chain verification failed: ${check.error}` });
|
|
3158
|
+
}
|
|
3159
|
+
externalRef = txHash.toLowerCase();
|
|
3160
|
+
description = `crypto topup ${check.amount} ${check.token} on ${body.chain || "base"}`;
|
|
3161
|
+
} else if (rail === "wechat") {
|
|
3162
|
+
const outTradeNo = body.out_trade_no;
|
|
3163
|
+
if (typeof outTradeNo !== "string" || !outTradeNo) {
|
|
3164
|
+
return sendJson(res, 400, { error: 'out_trade_no is required for rail "wechat"' });
|
|
3165
|
+
}
|
|
3166
|
+
if (!wechat) {
|
|
3167
|
+
return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
|
|
3168
|
+
}
|
|
3169
|
+
const wxPayload = {
|
|
3170
|
+
x402Version: X402_VERSION2,
|
|
3171
|
+
scheme: WECHAT_SCHEME,
|
|
3172
|
+
network: WECHAT_NETWORK,
|
|
3173
|
+
payload: { out_trade_no: outTradeNo }
|
|
3174
|
+
};
|
|
3175
|
+
const wxReqs = {
|
|
3176
|
+
scheme: WECHAT_SCHEME,
|
|
3177
|
+
network: WECHAT_NETWORK,
|
|
3178
|
+
asset: "CNY",
|
|
3179
|
+
amount: "0",
|
|
3180
|
+
payTo: manifest.provider.wechat?.mchid || "",
|
|
3181
|
+
maxTimeoutSeconds: 30,
|
|
3182
|
+
extra: { out_trade_no: outTradeNo }
|
|
3183
|
+
};
|
|
3184
|
+
const check = await wechat.verify(wxPayload, wxReqs);
|
|
3185
|
+
if (!check.valid) {
|
|
3186
|
+
return sendJson(res, 402, { error: `WeChat order verification failed: ${check.error}` });
|
|
3187
|
+
}
|
|
3188
|
+
const paidFen = this.wechatPaidFen(check);
|
|
3189
|
+
if (paidFen === null) {
|
|
3190
|
+
return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
|
|
1936
3191
|
}
|
|
3192
|
+
if (paidFen !== amountSat) {
|
|
3193
|
+
console.warn(
|
|
3194
|
+
`[MoltsPay] WeChat topup amount mismatch for ${outTradeNo}: client declared ${fromSat(amountSat)}, gateway confirms ${fromSat(paidFen)} paid -- crediting the verified amount`
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
amountSat = paidFen;
|
|
3198
|
+
externalRef = `wechat:${outTradeNo}`;
|
|
3199
|
+
description = `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`;
|
|
3200
|
+
console.log(`[MoltsPay] WeChat topup verified: ${description}`);
|
|
3201
|
+
} else if (rail === "alipay") {
|
|
3202
|
+
const tradeNo = body.trade_no;
|
|
3203
|
+
if (typeof tradeNo !== "string" || !tradeNo) {
|
|
3204
|
+
return sendJson(res, 400, { error: 'trade_no is required for rail "alipay"' });
|
|
3205
|
+
}
|
|
3206
|
+
externalRef = `alipay:${tradeNo}`;
|
|
3207
|
+
description = `alipay topup trade_no=${tradeNo} (operator-confirmed, unverified)`;
|
|
3208
|
+
console.warn(`[MoltsPay] Alipay topup credited without gateway verification: ${tradeNo}`);
|
|
3209
|
+
} else {
|
|
3210
|
+
return sendJson(res, 400, { error: 'rail must be one of "crypto" | "alipay" | "wechat"' });
|
|
1937
3211
|
}
|
|
3212
|
+
const result = balance.getLedger().topup({ buyerId: buyer_id, amountSat, externalRef, description });
|
|
3213
|
+
sendJson(res, 200, {
|
|
3214
|
+
success: true,
|
|
3215
|
+
tx_id: result.txId,
|
|
3216
|
+
balance: fromSat(result.balanceSat),
|
|
3217
|
+
replayed: result.replayed ?? false
|
|
3218
|
+
});
|
|
1938
3219
|
}
|
|
1939
|
-
|
|
3220
|
+
/** POST /balance/refund -- reverse a deduct (operator/agent use). */
|
|
3221
|
+
handleRefund(body, res) {
|
|
3222
|
+
const { balance, sendJson } = this.deps;
|
|
3223
|
+
const { tx_id, reason } = body || {};
|
|
3224
|
+
if (typeof tx_id !== "string" || !tx_id) {
|
|
3225
|
+
return sendJson(res, 400, { error: "tx_id is required" });
|
|
3226
|
+
}
|
|
3227
|
+
const result = balance.refund(tx_id, typeof reason === "string" ? reason : void 0);
|
|
3228
|
+
if (!result.success) {
|
|
3229
|
+
return sendJson(res, result.error === "tx_not_found" ? 404 : 400, { error: result.error });
|
|
3230
|
+
}
|
|
3231
|
+
sendJson(res, 200, {
|
|
3232
|
+
success: true,
|
|
3233
|
+
tx_id: result.txId,
|
|
3234
|
+
balance: fromSat(result.balanceSat),
|
|
3235
|
+
replayed: result.replayed ?? false
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
/** GET /balance/transactions?buyer_id=&limit=&offset= -- history, newest first. */
|
|
3239
|
+
handleTransactions(url, res) {
|
|
3240
|
+
const { balance, sendJson } = this.deps;
|
|
3241
|
+
const buyerId = url.searchParams.get("buyer_id");
|
|
3242
|
+
if (!buyerId) {
|
|
3243
|
+
return sendJson(res, 400, { error: "buyer_id query parameter is required" });
|
|
3244
|
+
}
|
|
3245
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10) || 20, 100);
|
|
3246
|
+
const offset = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
|
|
3247
|
+
const rows = balance.getLedger().listTransactions(buyerId, limit, offset);
|
|
3248
|
+
sendJson(res, 200, {
|
|
3249
|
+
buyer_id: buyerId,
|
|
3250
|
+
transactions: rows.map((r) => ({
|
|
3251
|
+
tx_id: r.id,
|
|
3252
|
+
type: r.type,
|
|
3253
|
+
amount: fromSat(r.amount_sat),
|
|
3254
|
+
service: r.service,
|
|
3255
|
+
description: r.description,
|
|
3256
|
+
external_ref: r.external_ref,
|
|
3257
|
+
status: r.status,
|
|
3258
|
+
created_at: r.created_at
|
|
3259
|
+
}))
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3262
|
+
};
|
|
3263
|
+
|
|
3264
|
+
// src/server/index.ts
|
|
1940
3265
|
var MoltsPayServer = class {
|
|
1941
3266
|
manifest;
|
|
1942
3267
|
skills = /* @__PURE__ */ new Map();
|
|
@@ -1944,11 +3269,32 @@ var MoltsPayServer = class {
|
|
|
1944
3269
|
registry;
|
|
1945
3270
|
networkId;
|
|
1946
3271
|
useMainnet;
|
|
1947
|
-
/** Alipay AI
|
|
3272
|
+
/** Alipay AI Pay facilitator instance, set when `provider.alipay` is configured (2.0.0). */
|
|
1948
3273
|
alipayFacilitator = null;
|
|
3274
|
+
/** WeChat Pay Native facilitator instance, set when `provider.wechat` is configured (2.1.0). */
|
|
3275
|
+
wechatFacilitator = null;
|
|
3276
|
+
/** Custodial balance facilitator instance, set when `provider.balance` is configured (2.2.0). */
|
|
3277
|
+
balanceFacilitator = null;
|
|
3278
|
+
balanceEndpoints = null;
|
|
3279
|
+
/**
|
|
3280
|
+
* Pending WeChat Native order cache — the double-charge fix.
|
|
3281
|
+
*
|
|
3282
|
+
* Every `buildWechatChallenge` used to place a NEW Native order, so a client
|
|
3283
|
+
* that received two 402s (e.g. initial challenge + one poll re-request that
|
|
3284
|
+
* raced ahead of payment) could surface two live QRs and a buyer could pay
|
|
3285
|
+
* both (confirmed ¥0.07×2 on 2026-07-02). Now the unpaid order is cached
|
|
3286
|
+
* under a content-derived key — sha256(service id | canonical params |
|
|
3287
|
+
* price_cny) — and reused until it is paid or its `time_expire` window
|
|
3288
|
+
* nears expiry, so any number of 402 emits for the same purchase intent
|
|
3289
|
+
* share ONE order, even across separate client processes.
|
|
3290
|
+
* Storing the in-flight promise also dedupes concurrent 402 builds.
|
|
3291
|
+
* In-memory by design: on restart the worst case is one extra unpaid order,
|
|
3292
|
+
* which expires server-side per `time_expire` — never a double charge.
|
|
3293
|
+
*/
|
|
3294
|
+
wechatPendingChallenges = /* @__PURE__ */ new Map();
|
|
1949
3295
|
constructor(servicesPath, options = {}) {
|
|
1950
3296
|
loadEnvFile2();
|
|
1951
|
-
const content = (0,
|
|
3297
|
+
const content = (0, import_fs3.readFileSync)(servicesPath, "utf-8");
|
|
1952
3298
|
this.manifest = JSON.parse(content);
|
|
1953
3299
|
this.options = {
|
|
1954
3300
|
port: options.port || 3e3,
|
|
@@ -1970,8 +3316,8 @@ var MoltsPayServer = class {
|
|
|
1970
3316
|
const providerAlipay = this.manifest.provider.alipay;
|
|
1971
3317
|
if (providerAlipay) {
|
|
1972
3318
|
try {
|
|
1973
|
-
const baseDir =
|
|
1974
|
-
const resolvePem = (p, kind) => toPem((0,
|
|
3319
|
+
const baseDir = path3.dirname(servicesPath);
|
|
3320
|
+
const resolvePem = (p, kind) => toPem((0, import_fs3.readFileSync)(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8"), kind);
|
|
1975
3321
|
const alipayFacilitatorConfig = {
|
|
1976
3322
|
seller_id: providerAlipay.seller_id,
|
|
1977
3323
|
app_id: providerAlipay.app_id,
|
|
@@ -1994,10 +3340,73 @@ var MoltsPayServer = class {
|
|
|
1994
3340
|
throw new Error(`[MoltsPay] Alipay rail configured but key load failed: ${err.message}`);
|
|
1995
3341
|
}
|
|
1996
3342
|
}
|
|
3343
|
+
const providerWechat = this.manifest.provider.wechat;
|
|
3344
|
+
if (providerWechat) {
|
|
3345
|
+
try {
|
|
3346
|
+
const baseDir = path3.dirname(servicesPath);
|
|
3347
|
+
const readPem = (p) => (0, import_fs3.readFileSync)(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8");
|
|
3348
|
+
const toPublicKeyPem = (pem) => pem.includes("BEGIN CERTIFICATE") ? new import_node_crypto7.default.X509Certificate(pem).publicKey.export({ type: "spki", format: "pem" }).toString() : pem;
|
|
3349
|
+
const wechatFacilitatorConfig = {
|
|
3350
|
+
mchid: providerWechat.mchid,
|
|
3351
|
+
appid: providerWechat.appid,
|
|
3352
|
+
serial_no: providerWechat.serial_no,
|
|
3353
|
+
private_key_pem: readPem(providerWechat.private_key_path),
|
|
3354
|
+
platform_public_key_pem: providerWechat.platform_public_key_path ? toPublicKeyPem(readPem(providerWechat.platform_public_key_path)) : void 0,
|
|
3355
|
+
apiv3_key: providerWechat.apiv3_key,
|
|
3356
|
+
notify_url: providerWechat.notify_url,
|
|
3357
|
+
api_base: providerWechat.api_base
|
|
3358
|
+
};
|
|
3359
|
+
facilitatorConfig.config = {
|
|
3360
|
+
...facilitatorConfig.config,
|
|
3361
|
+
wechat: wechatFacilitatorConfig
|
|
3362
|
+
};
|
|
3363
|
+
facilitatorConfig.fallback = facilitatorConfig.fallback || [];
|
|
3364
|
+
if (facilitatorConfig.primary !== "wechat" && !facilitatorConfig.fallback.includes("wechat")) {
|
|
3365
|
+
facilitatorConfig.fallback.push("wechat");
|
|
3366
|
+
}
|
|
3367
|
+
} catch (err) {
|
|
3368
|
+
throw new Error(`[MoltsPay] WeChat rail configured but key load failed: ${err.message}`);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
const providerBalance = this.manifest.provider.balance;
|
|
3372
|
+
if (providerBalance) {
|
|
3373
|
+
const baseDir = path3.dirname(servicesPath);
|
|
3374
|
+
const balanceFacilitatorConfig = {
|
|
3375
|
+
db_path: providerBalance.db_path === ":memory:" ? providerBalance.db_path : path3.isAbsolute(providerBalance.db_path) ? providerBalance.db_path : path3.resolve(baseDir, providerBalance.db_path),
|
|
3376
|
+
currency: providerBalance.currency,
|
|
3377
|
+
single_limit: providerBalance.single_limit,
|
|
3378
|
+
daily_limit: providerBalance.daily_limit,
|
|
3379
|
+
auth_mode: providerBalance.auth_mode
|
|
3380
|
+
};
|
|
3381
|
+
facilitatorConfig.config = {
|
|
3382
|
+
...facilitatorConfig.config,
|
|
3383
|
+
balance: balanceFacilitatorConfig
|
|
3384
|
+
};
|
|
3385
|
+
facilitatorConfig.fallback = facilitatorConfig.fallback || [];
|
|
3386
|
+
if (facilitatorConfig.primary !== "balance" && !facilitatorConfig.fallback.includes("balance")) {
|
|
3387
|
+
facilitatorConfig.fallback.push("balance");
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
1997
3390
|
this.registry = new FacilitatorRegistry(facilitatorConfig);
|
|
1998
3391
|
if (providerAlipay) {
|
|
1999
3392
|
this.alipayFacilitator = this.registry.get("alipay");
|
|
2000
|
-
console.log(`[MoltsPay] Alipay AI
|
|
3393
|
+
console.log(`[MoltsPay] Alipay AI Pay rail enabled (seller ${providerAlipay.seller_id})`);
|
|
3394
|
+
}
|
|
3395
|
+
if (providerWechat) {
|
|
3396
|
+
this.wechatFacilitator = this.registry.get("wechat");
|
|
3397
|
+
console.log(`[MoltsPay] WeChat Pay rail enabled (mchid ${providerWechat.mchid})`);
|
|
3398
|
+
}
|
|
3399
|
+
if (providerBalance) {
|
|
3400
|
+
this.balanceFacilitator = this.registry.get("balance");
|
|
3401
|
+
this.balanceEndpoints = new BalanceEndpoints({
|
|
3402
|
+
manifest: this.manifest,
|
|
3403
|
+
balance: this.balanceFacilitator,
|
|
3404
|
+
wechat: this.wechatFacilitator,
|
|
3405
|
+
sendJson: (res, status, data) => this.sendJson(res, status, data),
|
|
3406
|
+
getOrCreatePendingWechatOrder: (cacheKey, logLabel, create) => this.getOrCreatePendingWechatOrder(cacheKey, logLabel, create),
|
|
3407
|
+
invalidateWechatChallenge: (outTradeNo) => this.invalidateWechatChallenge(outTradeNo)
|
|
3408
|
+
});
|
|
3409
|
+
console.log(`[MoltsPay] Custodial balance rail enabled (ledger ${providerBalance.db_path})`);
|
|
2001
3410
|
}
|
|
2002
3411
|
const primaryFacilitator = this.registry.get(facilitatorConfig.primary);
|
|
2003
3412
|
console.log(`[MoltsPay] Loaded ${this.manifest.services.length} services from ${servicesPath}`);
|
|
@@ -2039,7 +3448,10 @@ var MoltsPayServer = class {
|
|
|
2039
3448
|
return provider.wallet;
|
|
2040
3449
|
};
|
|
2041
3450
|
if (provider.chains && provider.chains.length > 0) {
|
|
2042
|
-
return provider.chains.
|
|
3451
|
+
return provider.chains.filter((c) => {
|
|
3452
|
+
const chainName = typeof c === "string" ? c : c.chain;
|
|
3453
|
+
return !isAlipayChainId(chainName) && !isWechatChainId(chainName) && !isBalanceChainId(chainName);
|
|
3454
|
+
}).map((c) => {
|
|
2043
3455
|
const chainName = typeof c === "string" ? c : c.chain;
|
|
2044
3456
|
const explicitWallet = typeof c === "object" ? c.wallet : null;
|
|
2045
3457
|
return {
|
|
@@ -2158,9 +3570,36 @@ var MoltsPayServer = class {
|
|
|
2158
3570
|
if (url.pathname === "/.well-known/agent-services.json" && req.method === "GET") {
|
|
2159
3571
|
return this.handleAgentServicesDiscovery(res);
|
|
2160
3572
|
}
|
|
3573
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
3574
|
+
return this.handleAgentServicesDiscovery(res);
|
|
3575
|
+
}
|
|
2161
3576
|
if (url.pathname === "/health" && req.method === "GET") {
|
|
2162
3577
|
return await this.handleHealthCheck(res);
|
|
2163
3578
|
}
|
|
3579
|
+
if (url.pathname.startsWith("/balance") && this.balanceEndpoints) {
|
|
3580
|
+
if (url.pathname === "/balance" && req.method === "GET") {
|
|
3581
|
+
return this.balanceEndpoints.handleQuery(url, res);
|
|
3582
|
+
}
|
|
3583
|
+
if (url.pathname === "/balance/topup/order" && req.method === "POST") {
|
|
3584
|
+
const body = await this.readBody(req);
|
|
3585
|
+
return await this.balanceEndpoints.handleTopupOrder(body, res);
|
|
3586
|
+
}
|
|
3587
|
+
if (url.pathname === "/balance/topup/confirm" && req.method === "POST") {
|
|
3588
|
+
const body = await this.readBody(req);
|
|
3589
|
+
return await this.balanceEndpoints.handleTopupConfirm(body, res);
|
|
3590
|
+
}
|
|
3591
|
+
if (url.pathname === "/balance/topup" && req.method === "POST") {
|
|
3592
|
+
const body = await this.readBody(req);
|
|
3593
|
+
return await this.balanceEndpoints.handleTopup(body, res);
|
|
3594
|
+
}
|
|
3595
|
+
if (url.pathname === "/balance/refund" && req.method === "POST") {
|
|
3596
|
+
const body = await this.readBody(req);
|
|
3597
|
+
return this.balanceEndpoints.handleRefund(body, res);
|
|
3598
|
+
}
|
|
3599
|
+
if (url.pathname === "/balance/transactions" && req.method === "GET") {
|
|
3600
|
+
return this.balanceEndpoints.handleTransactions(url, res);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
2164
3603
|
if (url.pathname === "/execute" && req.method === "POST") {
|
|
2165
3604
|
const body = await this.readBody(req);
|
|
2166
3605
|
const paymentHeader = req.headers[PAYMENT_HEADER];
|
|
@@ -2186,27 +3625,78 @@ var MoltsPayServer = class {
|
|
|
2186
3625
|
const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
|
|
2187
3626
|
return await this.handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader);
|
|
2188
3627
|
}
|
|
2189
|
-
this.sendJson(res, 404, {
|
|
3628
|
+
this.sendJson(res, 404, {
|
|
3629
|
+
error: "Not found",
|
|
3630
|
+
discovery: `${this.publicBase}/.well-known/agent-services.json`,
|
|
3631
|
+
endpoints: [`${this.publicBase}/health`, `${this.publicBase}/services`, `${this.publicBase}/execute`]
|
|
3632
|
+
});
|
|
2190
3633
|
} catch (err) {
|
|
2191
3634
|
console.error("[MoltsPay] Error:", err);
|
|
2192
3635
|
this.sendJson(res, 500, { error: err.message || "Internal error" });
|
|
2193
3636
|
}
|
|
2194
3637
|
}
|
|
2195
3638
|
/**
|
|
2196
|
-
*
|
|
3639
|
+
* Public base URL prefix for self-describing links, from PUBLIC_BASE_URL
|
|
3640
|
+
* (trailing slash stripped). Empty when unset, so emitted paths stay
|
|
3641
|
+
* root-relative — behavior is unchanged for local / no-prefix deploys.
|
|
3642
|
+
*
|
|
3643
|
+
* Needed because nginx rewrites the deployment prefix (e.g.
|
|
3644
|
+
* `/t/moltspay-server`) away before proxying, so the process cannot infer
|
|
3645
|
+
* its own public prefix; a root-relative `/services` would otherwise
|
|
3646
|
+
* resolve against the domain root and hit the wrong backend.
|
|
2197
3647
|
*/
|
|
2198
|
-
|
|
2199
|
-
|
|
3648
|
+
get publicBase() {
|
|
3649
|
+
return (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
|
|
3650
|
+
}
|
|
3651
|
+
/**
|
|
3652
|
+
* Per-service pricing across every configured rail, for the discovery
|
|
3653
|
+
* payloads. The top-level `price`/`currency` (crypto/USDC) stay unchanged
|
|
3654
|
+
* for back-compat; this surfaces the fiat + balance rails (CNY) that were
|
|
3655
|
+
* previously invisible in discovery even though the manifest defines them
|
|
3656
|
+
* and the 402 challenge already quotes them. `acceptedCurrencies` becomes
|
|
3657
|
+
* the union across rails so a client can see CNY is accepted without
|
|
3658
|
+
* first triggering a 402.
|
|
3659
|
+
*/
|
|
3660
|
+
describeServicePricing(s) {
|
|
3661
|
+
const pricing = [];
|
|
3662
|
+
if (this.getProviderChains().length > 0) {
|
|
3663
|
+
for (const currency of getAcceptedCurrencies(s)) {
|
|
3664
|
+
pricing.push({ rail: "crypto", currency, amount: String(s.price) });
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
if (s.alipay) pricing.push({ rail: "alipay", currency: "CNY", amount: s.alipay.price_cny });
|
|
3668
|
+
if (s.wechat) pricing.push({ rail: "wechat", currency: "CNY", amount: s.wechat.price_cny });
|
|
3669
|
+
if (s.balance) {
|
|
3670
|
+
pricing.push({
|
|
3671
|
+
rail: "balance",
|
|
3672
|
+
currency: this.manifest.provider.balance?.currency ?? "CNY",
|
|
3673
|
+
amount: s.balance.price ?? s.price.toFixed(2)
|
|
3674
|
+
});
|
|
3675
|
+
}
|
|
3676
|
+
return { acceptedCurrencies: [...new Set(pricing.map((p) => p.currency))], pricing };
|
|
3677
|
+
}
|
|
3678
|
+
/** Shared service-list entry for the discovery and /services endpoints. */
|
|
3679
|
+
buildDiscoveryService(s) {
|
|
3680
|
+
const { acceptedCurrencies, pricing } = this.describeServicePricing(s);
|
|
3681
|
+
const headline = pricing.find((p) => p.rail === "crypto") ?? pricing[0];
|
|
3682
|
+
return {
|
|
2200
3683
|
id: s.id,
|
|
2201
3684
|
name: s.name,
|
|
2202
3685
|
description: s.description,
|
|
2203
|
-
price: s.price,
|
|
2204
|
-
currency: s.currency,
|
|
2205
|
-
acceptedCurrencies
|
|
3686
|
+
price: headline ? Number(headline.amount) : s.price,
|
|
3687
|
+
currency: headline ? headline.currency : s.currency,
|
|
3688
|
+
acceptedCurrencies,
|
|
3689
|
+
pricing,
|
|
2206
3690
|
input: s.input,
|
|
2207
3691
|
output: s.output,
|
|
2208
3692
|
available: this.skills.has(s.id)
|
|
2209
|
-
}
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* GET /.well-known/agent-services.json - Standard discovery endpoint
|
|
3697
|
+
*/
|
|
3698
|
+
handleAgentServicesDiscovery(res) {
|
|
3699
|
+
const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
|
|
2210
3700
|
this.sendJson(res, 200, {
|
|
2211
3701
|
version: "1.0",
|
|
2212
3702
|
provider: {
|
|
@@ -2219,9 +3709,9 @@ var MoltsPayServer = class {
|
|
|
2219
3709
|
},
|
|
2220
3710
|
services,
|
|
2221
3711
|
endpoints: {
|
|
2222
|
-
services:
|
|
2223
|
-
execute:
|
|
2224
|
-
health:
|
|
3712
|
+
services: `${this.publicBase}/services`,
|
|
3713
|
+
execute: `${this.publicBase}/execute`,
|
|
3714
|
+
health: `${this.publicBase}/health`
|
|
2225
3715
|
},
|
|
2226
3716
|
payment: {
|
|
2227
3717
|
protocol: "x402",
|
|
@@ -2236,17 +3726,7 @@ var MoltsPayServer = class {
|
|
|
2236
3726
|
* GET /services - List available services
|
|
2237
3727
|
*/
|
|
2238
3728
|
handleGetServices(res) {
|
|
2239
|
-
const services = this.manifest.services.map((s) => (
|
|
2240
|
-
id: s.id,
|
|
2241
|
-
name: s.name,
|
|
2242
|
-
description: s.description,
|
|
2243
|
-
price: s.price,
|
|
2244
|
-
currency: s.currency,
|
|
2245
|
-
acceptedCurrencies: getAcceptedCurrencies(s),
|
|
2246
|
-
input: s.input,
|
|
2247
|
-
output: s.output,
|
|
2248
|
-
available: this.skills.has(s.id)
|
|
2249
|
-
}));
|
|
3729
|
+
const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
|
|
2250
3730
|
const selection = this.registry.getSelection();
|
|
2251
3731
|
this.sendJson(res, 200, {
|
|
2252
3732
|
provider: this.manifest.provider,
|
|
@@ -2305,7 +3785,7 @@ var MoltsPayServer = class {
|
|
|
2305
3785
|
return this.handleAlipayExecute(skill, params || {}, alipayPayment, res);
|
|
2306
3786
|
}
|
|
2307
3787
|
if (!paymentHeader) {
|
|
2308
|
-
return this.sendPaymentRequired(skill.config, res);
|
|
3788
|
+
return this.sendPaymentRequired(skill.config, res, params || {});
|
|
2309
3789
|
}
|
|
2310
3790
|
let payment;
|
|
2311
3791
|
try {
|
|
@@ -2319,6 +3799,12 @@ var MoltsPayServer = class {
|
|
|
2319
3799
|
if (payScheme === ALIPAY_SCHEME || (payNetwork ? isAlipayChainId(payNetwork) : false)) {
|
|
2320
3800
|
return this.handleAlipayExecute(skill, params || {}, payment, res);
|
|
2321
3801
|
}
|
|
3802
|
+
if (payScheme === WECHAT_SCHEME || (payNetwork ? isWechatChainId(payNetwork) : false)) {
|
|
3803
|
+
return this.handleWechatExecute(skill, params || {}, payment, res);
|
|
3804
|
+
}
|
|
3805
|
+
if (payScheme === BALANCE_SCHEME || (payNetwork ? isBalanceChainId(payNetwork) : false)) {
|
|
3806
|
+
return this.handleBalanceExecute(skill, params || {}, payment, res);
|
|
3807
|
+
}
|
|
2322
3808
|
const validation = this.validatePayment(payment, skill.config);
|
|
2323
3809
|
if (!validation.valid) {
|
|
2324
3810
|
return this.sendJson(res, 402, { error: validation.error });
|
|
@@ -2412,7 +3898,7 @@ var MoltsPayServer = class {
|
|
|
2412
3898
|
}, responseHeaders);
|
|
2413
3899
|
}
|
|
2414
3900
|
/**
|
|
2415
|
-
* Execute a service paid via the Alipay AI
|
|
3901
|
+
* Execute a service paid via the Alipay AI Pay fiat rail (2.0.0).
|
|
2416
3902
|
*
|
|
2417
3903
|
* Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
|
|
2418
3904
|
* validation. Verify hits the Alipay Open API (`payment.verify`). Settlement
|
|
@@ -2506,6 +3992,290 @@ var MoltsPayServer = class {
|
|
|
2506
3992
|
return null;
|
|
2507
3993
|
}
|
|
2508
3994
|
}
|
|
3995
|
+
/**
|
|
3996
|
+
* Execute a service paid via the WeChat Pay v3 Native fiat rail (2.1.0).
|
|
3997
|
+
*
|
|
3998
|
+
* Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
|
|
3999
|
+
* validation. The buyer (a human) scanned the Native QR and paid; the
|
|
4000
|
+
* client re-requests carrying `out_trade_no` in the X-Payment payload.
|
|
4001
|
+
* Verify queries the order (`trade_state === SUCCESS`). Settlement is an
|
|
4002
|
+
* idempotent re-confirm and is FIRE-AND-FORGET (mirrors the Alipay path):
|
|
4003
|
+
* a confirm failure is logged but does NOT fail the delivered response —
|
|
4004
|
+
* the order was already verified SUCCESS.
|
|
4005
|
+
*/
|
|
4006
|
+
async handleWechatExecute(skill, params, payment, res) {
|
|
4007
|
+
if (!this.wechatFacilitator) {
|
|
4008
|
+
return this.sendJson(res, 402, { error: "WeChat rail not configured on this server" });
|
|
4009
|
+
}
|
|
4010
|
+
const outTradeNo = typeof payment.accepted?.extra?.out_trade_no === "string" ? payment.accepted.extra.out_trade_no : void 0;
|
|
4011
|
+
const requirements = {
|
|
4012
|
+
scheme: WECHAT_SCHEME,
|
|
4013
|
+
network: WECHAT_NETWORK,
|
|
4014
|
+
asset: "CNY",
|
|
4015
|
+
amount: skill.config.wechat?.price_cny || "0",
|
|
4016
|
+
payTo: this.manifest.provider.wechat?.mchid || "",
|
|
4017
|
+
maxTimeoutSeconds: 300,
|
|
4018
|
+
extra: outTradeNo ? { out_trade_no: outTradeNo } : void 0
|
|
4019
|
+
};
|
|
4020
|
+
console.log(`[MoltsPay] Verifying WeChat payment...`);
|
|
4021
|
+
const verifyResult = await this.registry.verify(payment, requirements);
|
|
4022
|
+
if (!verifyResult.valid) {
|
|
4023
|
+
return this.sendJson(res, 402, {
|
|
4024
|
+
error: `Payment verification failed: ${verifyResult.error}`,
|
|
4025
|
+
facilitator: verifyResult.facilitator
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
console.log(`[MoltsPay] WeChat payment verified by ${verifyResult.facilitator}`);
|
|
4029
|
+
if (outTradeNo) {
|
|
4030
|
+
this.invalidateWechatChallenge(outTradeNo);
|
|
4031
|
+
}
|
|
4032
|
+
const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
|
|
4033
|
+
console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
|
|
4034
|
+
let result;
|
|
4035
|
+
try {
|
|
4036
|
+
result = await Promise.race([
|
|
4037
|
+
skill.handler(params),
|
|
4038
|
+
new Promise(
|
|
4039
|
+
(_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
|
|
4040
|
+
)
|
|
4041
|
+
]);
|
|
4042
|
+
} catch (err) {
|
|
4043
|
+
console.error("[MoltsPay] Skill execution failed:", err.message);
|
|
4044
|
+
return this.sendJson(res, 500, {
|
|
4045
|
+
error: "Service execution failed",
|
|
4046
|
+
message: err.message
|
|
4047
|
+
});
|
|
4048
|
+
}
|
|
4049
|
+
let settlement;
|
|
4050
|
+
try {
|
|
4051
|
+
settlement = await this.registry.settle(payment, requirements);
|
|
4052
|
+
if (settlement.success) {
|
|
4053
|
+
console.log(`[MoltsPay] WeChat settlement confirmed: ${settlement.transaction}`);
|
|
4054
|
+
} else {
|
|
4055
|
+
console.error(`[MoltsPay] WeChat settlement confirm failed (non-fatal): ${settlement.error}`);
|
|
4056
|
+
}
|
|
4057
|
+
} catch (err) {
|
|
4058
|
+
console.error(`[MoltsPay] WeChat settlement confirm threw (non-fatal): ${err.message}`);
|
|
4059
|
+
settlement = { success: false, error: err.message, facilitator: "wechat" };
|
|
4060
|
+
}
|
|
4061
|
+
const responseHeaders = {};
|
|
4062
|
+
if (settlement.success) {
|
|
4063
|
+
responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
|
|
4064
|
+
success: true,
|
|
4065
|
+
transaction: settlement.transaction,
|
|
4066
|
+
network: WECHAT_NETWORK,
|
|
4067
|
+
facilitator: settlement.facilitator
|
|
4068
|
+
})).toString("base64");
|
|
4069
|
+
}
|
|
4070
|
+
this.sendJson(res, 200, {
|
|
4071
|
+
success: true,
|
|
4072
|
+
result,
|
|
4073
|
+
payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
|
|
4074
|
+
}, responseHeaders);
|
|
4075
|
+
}
|
|
4076
|
+
/**
|
|
4077
|
+
* Build the WeChat 402 challenge for a service, or null when the wechat rail
|
|
4078
|
+
* isn't configured for this server or this service. Placing a Native order
|
|
4079
|
+
* is a network call that returns a fresh `code_url` + `out_trade_no`; the
|
|
4080
|
+
* x402 `accepts[]` entry carries both in `extra` so the client can render
|
|
4081
|
+
* the QR and later echo `out_trade_no` back for verification.
|
|
4082
|
+
*
|
|
4083
|
+
* DOUBLE-CHARGE FIX: the unpaid order is cached per service id (see
|
|
4084
|
+
* `wechatPendingChallenges`), so repeated 402 emits within the order's
|
|
4085
|
+
* `time_expire` window return the SAME `code_url`/`out_trade_no` instead of
|
|
4086
|
+
* minting a new payable order each time. The entry is dropped once the
|
|
4087
|
+
* order is paid (`invalidateWechatChallenge`) or shortly before it expires
|
|
4088
|
+
* (refresh margin, so clients never receive a nearly-dead QR). A build
|
|
4089
|
+
* failure is not cached and degrades gracefully (the other rails'
|
|
4090
|
+
* accepts[] still ship).
|
|
4091
|
+
*/
|
|
4092
|
+
async buildWechatChallenge(config, params) {
|
|
4093
|
+
if (!this.wechatFacilitator || !config.wechat) return null;
|
|
4094
|
+
const cacheKey = import_node_crypto7.default.createHash("sha256").update(`${config.id}|${canonicalJson(params ?? {})}|${config.wechat.price_cny}`).digest("hex");
|
|
4095
|
+
const result = await this.getOrCreatePendingWechatOrder(
|
|
4096
|
+
cacheKey,
|
|
4097
|
+
config.id,
|
|
4098
|
+
() => this.wechatFacilitator.createPaymentRequirements({
|
|
4099
|
+
priceCny: config.wechat.price_cny,
|
|
4100
|
+
description: config.wechat.description
|
|
4101
|
+
})
|
|
4102
|
+
);
|
|
4103
|
+
return result ? { accepts: result.accepts } : null;
|
|
4104
|
+
}
|
|
4105
|
+
/**
|
|
4106
|
+
* Get-or-create a pending WeChat Native order under `cacheKey`, deduping
|
|
4107
|
+
* concurrent builds and reusing an unpaid order until it nears expiry.
|
|
4108
|
+
* Shared by the 402 challenge path ({@link buildWechatChallenge}) and the
|
|
4109
|
+
* balance top-up order path ({@link handleBalanceTopupOrder}). See the
|
|
4110
|
+
* `wechatPendingChallenges` doc for the double-charge rationale.
|
|
4111
|
+
*/
|
|
4112
|
+
async getOrCreatePendingWechatOrder(cacheKey, logLabel, create) {
|
|
4113
|
+
const now = Date.now();
|
|
4114
|
+
const cached = this.wechatPendingChallenges.get(cacheKey);
|
|
4115
|
+
if (cached) {
|
|
4116
|
+
if (now < cached.expiresAtMs) {
|
|
4117
|
+
const hit = await cached.promise;
|
|
4118
|
+
if (hit) {
|
|
4119
|
+
console.log(`[MoltsPay] Reusing pending WeChat order ${hit.outTradeNo} for ${logLabel}`);
|
|
4120
|
+
return hit;
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
this.wechatPendingChallenges.delete(cacheKey);
|
|
4124
|
+
}
|
|
4125
|
+
const orderTtlMs = WECHAT_TIME_EXPIRE_MS;
|
|
4126
|
+
const cacheTtlMs = Math.max(orderTtlMs - 3e4, Math.floor(orderTtlMs / 2));
|
|
4127
|
+
const entry = {
|
|
4128
|
+
expiresAtMs: now + cacheTtlMs,
|
|
4129
|
+
promise: Promise.resolve(null)
|
|
4130
|
+
};
|
|
4131
|
+
entry.promise = (async () => {
|
|
4132
|
+
try {
|
|
4133
|
+
const req = await create();
|
|
4134
|
+
entry.outTradeNo = req.outTradeNo;
|
|
4135
|
+
return { accepts: req.x402Accepts, codeUrl: req.codeUrl, outTradeNo: req.outTradeNo };
|
|
4136
|
+
} catch (err) {
|
|
4137
|
+
console.error(`[MoltsPay] WeChat order build failed for ${logLabel}: ${err.message}`);
|
|
4138
|
+
this.wechatPendingChallenges.delete(cacheKey);
|
|
4139
|
+
return null;
|
|
4140
|
+
}
|
|
4141
|
+
})();
|
|
4142
|
+
this.wechatPendingChallenges.set(cacheKey, entry);
|
|
4143
|
+
return entry.promise;
|
|
4144
|
+
}
|
|
4145
|
+
/**
|
|
4146
|
+
* Drop the cached pending WeChat order that matches a paid `out_trade_no`,
|
|
4147
|
+
* so the next 402 mints a fresh order instead of re-serving a consumed one
|
|
4148
|
+
* (Native is one-code-one-payment).
|
|
4149
|
+
*/
|
|
4150
|
+
invalidateWechatChallenge(outTradeNo) {
|
|
4151
|
+
for (const [cacheKey, entry] of this.wechatPendingChallenges) {
|
|
4152
|
+
if (entry.outTradeNo === outTradeNo) {
|
|
4153
|
+
this.wechatPendingChallenges.delete(cacheKey);
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
/**
|
|
4158
|
+
* Handle /execute for the custodial balance rail (2.2.0).
|
|
4159
|
+
*
|
|
4160
|
+
* Execution order is INVERTED relative to the QR rails: the deduction IS
|
|
4161
|
+
* the settlement, so it must land before the skill runs, and a skill
|
|
4162
|
+
* failure refunds it. `settle()` is idempotent on the client's
|
|
4163
|
+
* `request_id`, so a retried request never double-charges.
|
|
4164
|
+
*
|
|
4165
|
+
* QR rails: verify(paid?) → run skill → settle (confirm, fire-and-forget)
|
|
4166
|
+
* balance: verify implicit in settle (atomic deduct) → run skill → [fail → refund]
|
|
4167
|
+
*/
|
|
4168
|
+
async handleBalanceExecute(skill, params, payment, res) {
|
|
4169
|
+
if (!this.balanceFacilitator) {
|
|
4170
|
+
return this.sendJson(res, 402, { error: "Balance rail not configured on this server" });
|
|
4171
|
+
}
|
|
4172
|
+
const requirements = this.balanceRequirementsFor(skill.config);
|
|
4173
|
+
const authMode = this.balanceFacilitator.authMode;
|
|
4174
|
+
if (authMode !== "off") {
|
|
4175
|
+
const bp = extractBalancePayload(payment);
|
|
4176
|
+
const buyerId = bp?.buyer_id ?? "";
|
|
4177
|
+
const requestId = bp?.request_id ?? "";
|
|
4178
|
+
const av = verifyDeductAuth({
|
|
4179
|
+
auth: bp?.auth ?? null,
|
|
4180
|
+
buyerId,
|
|
4181
|
+
requestId,
|
|
4182
|
+
service: skill.id,
|
|
4183
|
+
nowMs: Date.now()
|
|
4184
|
+
});
|
|
4185
|
+
const ledger = this.balanceFacilitator.getLedger();
|
|
4186
|
+
let denyReason;
|
|
4187
|
+
if (av.ok && av.recovered) {
|
|
4188
|
+
const bind = ledger.bindSigner(buyerId, av.recovered);
|
|
4189
|
+
if (bind.conflict) denyReason = `wrong signer (account bound to ${bind.existing}, got ${av.recovered})`;
|
|
4190
|
+
} else {
|
|
4191
|
+
denyReason = `signature ${av.reason}`;
|
|
4192
|
+
}
|
|
4193
|
+
if (denyReason) {
|
|
4194
|
+
if (authMode === "enforce") {
|
|
4195
|
+
console.warn(`[MoltsPay] Balance auth DENY (enforce) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
|
|
4196
|
+
return this.sendJson(res, 401, { error: `Balance auth failed: ${denyReason}`, facilitator: "balance" });
|
|
4197
|
+
}
|
|
4198
|
+
console.warn(`[MoltsPay] Balance auth would-deny (shadow) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
|
|
4199
|
+
} else {
|
|
4200
|
+
console.log(`[MoltsPay] Balance auth ok (${authMode}) buyer=${buyerId} signer=${av.recovered}`);
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
console.log(`[MoltsPay] Deducting balance for ${skill.id}...`);
|
|
4204
|
+
const settlement = await this.balanceFacilitator.settle(payment, requirements);
|
|
4205
|
+
if (!settlement.success) {
|
|
4206
|
+
return this.sendJson(res, 402, {
|
|
4207
|
+
error: `Balance deduction failed: ${settlement.error}`,
|
|
4208
|
+
code: settlement.status,
|
|
4209
|
+
facilitator: "balance"
|
|
4210
|
+
});
|
|
4211
|
+
}
|
|
4212
|
+
console.log(`[MoltsPay] Balance deducted (tx ${settlement.transaction}${settlement.status === "replayed" ? ", replayed" : ""})`);
|
|
4213
|
+
const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
|
|
4214
|
+
console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
|
|
4215
|
+
let result;
|
|
4216
|
+
try {
|
|
4217
|
+
result = await Promise.race([
|
|
4218
|
+
skill.handler(params),
|
|
4219
|
+
new Promise(
|
|
4220
|
+
(_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
|
|
4221
|
+
)
|
|
4222
|
+
]);
|
|
4223
|
+
} catch (err) {
|
|
4224
|
+
console.error("[MoltsPay] Skill execution failed:", err.message);
|
|
4225
|
+
const refund = this.balanceFacilitator.refund(settlement.transaction, `skill_failed: ${err.message}`.slice(0, 200));
|
|
4226
|
+
if (!refund.success) {
|
|
4227
|
+
console.error(`[MoltsPay] Balance refund FAILED for ${settlement.transaction}: ${refund.error} \u2014 manual reconciliation needed`);
|
|
4228
|
+
} else {
|
|
4229
|
+
console.log(`[MoltsPay] Balance refunded (tx ${refund.txId})`);
|
|
4230
|
+
}
|
|
4231
|
+
return this.sendJson(res, 500, {
|
|
4232
|
+
error: "Service execution failed",
|
|
4233
|
+
message: err.message,
|
|
4234
|
+
refunded: refund.success
|
|
4235
|
+
});
|
|
4236
|
+
}
|
|
4237
|
+
const responseHeaders = {
|
|
4238
|
+
[PAYMENT_RESPONSE_HEADER]: Buffer.from(JSON.stringify({
|
|
4239
|
+
success: true,
|
|
4240
|
+
transaction: settlement.transaction,
|
|
4241
|
+
network: "balance",
|
|
4242
|
+
facilitator: "balance"
|
|
4243
|
+
})).toString("base64")
|
|
4244
|
+
};
|
|
4245
|
+
this.sendJson(res, 200, {
|
|
4246
|
+
success: true,
|
|
4247
|
+
result,
|
|
4248
|
+
payment: { transaction: settlement.transaction, status: "fulfilled", facilitator: "balance" }
|
|
4249
|
+
}, responseHeaders);
|
|
4250
|
+
}
|
|
4251
|
+
/** The balance rail's requirements for a service (price defaults to `config.price`). */
|
|
4252
|
+
balanceRequirementsFor(config) {
|
|
4253
|
+
const price = config.balance?.price ?? config.price.toFixed(2);
|
|
4254
|
+
return {
|
|
4255
|
+
scheme: BALANCE_SCHEME,
|
|
4256
|
+
network: "balance",
|
|
4257
|
+
asset: this.balanceFacilitator?.currency ?? "USD",
|
|
4258
|
+
amount: price,
|
|
4259
|
+
payTo: "custodial",
|
|
4260
|
+
maxTimeoutSeconds: 30,
|
|
4261
|
+
extra: { service_id: config.id }
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
/**
|
|
4265
|
+
* Build the balance 402 challenge for a service, or null when the rail
|
|
4266
|
+
* isn't configured for this server or this service. Pure — nothing is
|
|
4267
|
+
* minted, so unlike the QR rails a 402 emit has no side effects.
|
|
4268
|
+
*/
|
|
4269
|
+
buildBalanceChallenge(config) {
|
|
4270
|
+
if (!this.balanceFacilitator || !config.balance) return null;
|
|
4271
|
+
try {
|
|
4272
|
+
return { accepts: this.balanceRequirementsFor(config) };
|
|
4273
|
+
} catch (err) {
|
|
4274
|
+
console.error(`[MoltsPay] Balance challenge build failed for ${config.id}: ${err.message}`);
|
|
4275
|
+
return null;
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
/** GET /balance?buyer_id= — balance, limits, and today's spend. */
|
|
2509
4279
|
/**
|
|
2510
4280
|
* Handle MPP (Machine Payments Protocol) request
|
|
2511
4281
|
* Supports both x402 and MPP protocols on service endpoints
|
|
@@ -2522,7 +4292,7 @@ var MoltsPayServer = class {
|
|
|
2522
4292
|
if (authHeader && authHeader.toLowerCase().startsWith("payment ")) {
|
|
2523
4293
|
return await this.handleMPPPayment(skill, params, authHeader, res);
|
|
2524
4294
|
}
|
|
2525
|
-
return this.sendMPPPaymentRequired(config, res);
|
|
4295
|
+
return this.sendMPPPaymentRequired(config, res, params);
|
|
2526
4296
|
}
|
|
2527
4297
|
/**
|
|
2528
4298
|
* Handle MPP payment verification and service execution
|
|
@@ -2621,7 +4391,7 @@ var MoltsPayServer = class {
|
|
|
2621
4391
|
/**
|
|
2622
4392
|
* Return 402 with both x402 and MPP payment requirements
|
|
2623
4393
|
*/
|
|
2624
|
-
async sendMPPPaymentRequired(config, res) {
|
|
4394
|
+
async sendMPPPaymentRequired(config, res, params) {
|
|
2625
4395
|
const acceptedTokens = getAcceptedCurrencies(config);
|
|
2626
4396
|
const providerChains = this.getProviderChains();
|
|
2627
4397
|
const accepts = [];
|
|
@@ -2636,6 +4406,14 @@ var MoltsPayServer = class {
|
|
|
2636
4406
|
if (alipayChallenge) {
|
|
2637
4407
|
accepts.push(alipayChallenge.accepts);
|
|
2638
4408
|
}
|
|
4409
|
+
const wechatChallenge = await this.buildWechatChallenge(config, params);
|
|
4410
|
+
if (wechatChallenge) {
|
|
4411
|
+
accepts.push(wechatChallenge.accepts);
|
|
4412
|
+
}
|
|
4413
|
+
const balanceChallenge = this.buildBalanceChallenge(config);
|
|
4414
|
+
if (balanceChallenge) {
|
|
4415
|
+
accepts.push(balanceChallenge.accepts);
|
|
4416
|
+
}
|
|
2639
4417
|
const x402PaymentRequired = {
|
|
2640
4418
|
x402Version: X402_VERSION2,
|
|
2641
4419
|
accepts,
|
|
@@ -2701,7 +4479,7 @@ var MoltsPayServer = class {
|
|
|
2701
4479
|
* Return 402 with x402 payment requirements (v2 format)
|
|
2702
4480
|
* Includes requirements for all chains and all accepted currencies
|
|
2703
4481
|
*/
|
|
2704
|
-
async sendPaymentRequired(config, res) {
|
|
4482
|
+
async sendPaymentRequired(config, res, params) {
|
|
2705
4483
|
const acceptedTokens = getAcceptedCurrencies(config);
|
|
2706
4484
|
const providerChains = this.getProviderChains();
|
|
2707
4485
|
const accepts = [];
|
|
@@ -2716,6 +4494,14 @@ var MoltsPayServer = class {
|
|
|
2716
4494
|
if (alipayChallenge) {
|
|
2717
4495
|
accepts.push(alipayChallenge.accepts);
|
|
2718
4496
|
}
|
|
4497
|
+
const wechatChallenge = await this.buildWechatChallenge(config, params);
|
|
4498
|
+
if (wechatChallenge) {
|
|
4499
|
+
accepts.push(wechatChallenge.accepts);
|
|
4500
|
+
}
|
|
4501
|
+
const balanceChallenge = this.buildBalanceChallenge(config);
|
|
4502
|
+
if (balanceChallenge) {
|
|
4503
|
+
accepts.push(balanceChallenge.accepts);
|
|
4504
|
+
}
|
|
2719
4505
|
const acceptedChains = providerChains.map((c) => {
|
|
2720
4506
|
if (c.network === "eip155:8453") return "base";
|
|
2721
4507
|
if (c.network === "eip155:137") return "polygon";
|
|
@@ -2727,7 +4513,7 @@ var MoltsPayServer = class {
|
|
|
2727
4513
|
acceptedCurrencies: acceptedTokens,
|
|
2728
4514
|
acceptedChains,
|
|
2729
4515
|
resource: {
|
|
2730
|
-
url:
|
|
4516
|
+
url: `${this.publicBase}/execute?service=${config.id}`,
|
|
2731
4517
|
description: `${config.name} - $${config.price} ${config.currency}`,
|
|
2732
4518
|
mimeType: "application/json"
|
|
2733
4519
|
}
|
|
@@ -2740,11 +4526,13 @@ var MoltsPayServer = class {
|
|
|
2740
4526
|
if (alipayChallenge) {
|
|
2741
4527
|
headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
|
|
2742
4528
|
}
|
|
4529
|
+
const offered = this.describeServicePricing(config);
|
|
4530
|
+
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}`;
|
|
2743
4531
|
res.writeHead(402, headers);
|
|
2744
4532
|
res.end(JSON.stringify({
|
|
2745
4533
|
error: "Payment required",
|
|
2746
|
-
message
|
|
2747
|
-
acceptedCurrencies:
|
|
4534
|
+
message,
|
|
4535
|
+
acceptedCurrencies: offered.acceptedCurrencies,
|
|
2748
4536
|
acceptedChains,
|
|
2749
4537
|
x402: paymentRequired
|
|
2750
4538
|
}, null, 2));
|