xytara 3.1.4 → 3.1.6
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 +4 -0
- package/REAL_PAYMENT_SETUP.md +12 -8
- package/RELEASE_NOTES.md +6 -5
- package/examples/generated.carried-handoff.json +3 -3
- package/lib/cdp_jwt.js +57 -0
- package/lib/payment_config.js +253 -242
- package/lib/payment_verification.js +313 -312
- package/lib/x402_facilitator.js +181 -155
- package/package.json +4 -3
- package/scripts/verify_cdp_facilitator.js +110 -0
- package/scripts/verify_x402_facilitator.js +202 -194
- package/server.js +11227 -11226
package/.env.example
CHANGED
|
@@ -13,6 +13,10 @@ XYTARA_X402_SETTLE=true
|
|
|
13
13
|
# credit units a single self-serve /x402/credits/topup grants (default 1000);
|
|
14
14
|
# an accepts entry may override per-payment via its extra.credits
|
|
15
15
|
XYTARA_X402_TOPUP_UNITS=1000
|
|
16
|
+
# Coinbase CDP auth for the CDP-hosted facilitator (Base mainnet); Ed25519 Secret API Key.
|
|
17
|
+
# The runtime mints a fresh CDP Bearer JWT per request when both are set.
|
|
18
|
+
CDP_API_KEY_ID=
|
|
19
|
+
CDP_API_KEY_SECRET=
|
|
16
20
|
XYTARA_PAYMENT_MAX_AGE_SECONDS=300
|
|
17
21
|
XYTARA_WALLET_REGISTRY_JSON=[]
|
|
18
22
|
XYTARA_TREASURY_DESTINATION_ID=
|
package/REAL_PAYMENT_SETUP.md
CHANGED
|
@@ -72,21 +72,25 @@ settle it through a mainnet-capable facilitator.
|
|
|
72
72
|
|
|
73
73
|
Important: the default `https://x402.org/facilitator` settles **Base Sepolia only** (its
|
|
74
74
|
`/supported` list has no `eip155:8453`). Pointing a mainnet `accepts` entry at it fails
|
|
75
|
-
closed and the route is unpayable. For mainnet
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
closed and the route is unpayable. For mainnet use a mainnet-capable facilitator, such as
|
|
76
|
+
Coinbase CDP (`https://api.cdp.coinbase.com/platform/v2/x402`), which is supported natively:
|
|
77
|
+
create a CDP Secret API Key (Ed25519) and set its id and secret, and the runtime mints a
|
|
78
|
+
fresh short-lived CDP Bearer JWT per facilitator call (CDP rejects static tokens).
|
|
78
79
|
|
|
79
80
|
```env
|
|
80
81
|
# advertise mainnet (real USDC on Base); you can keep a Sepolia entry in the array too
|
|
81
82
|
XYTARA_X402_ACCEPTS_JSON=[{"scheme":"exact","network":"eip155:8453","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","amount":"10000","payTo":"<your-evm-address>","extra":{"name":"USDC","version":"2"},"maxTimeoutSeconds":60}]
|
|
82
|
-
#
|
|
83
|
+
# point at the CDP-hosted facilitator and supply the CDP Secret API Key:
|
|
83
84
|
XYTARA_X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
|
|
84
|
-
|
|
85
|
+
CDP_API_KEY_ID=<your-cdp-key-id>
|
|
86
|
+
CDP_API_KEY_SECRET=<your-cdp-ed25519-secret-base64>
|
|
85
87
|
```
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
The CDP key must use the **Ed25519** signature algorithm (not ECDSA). `CDP_API_KEY_ID` /
|
|
90
|
+
`CDP_API_KEY_SECRET` are the CDP-standard names; `XYTARA_X402_CDP_KEY_ID` /
|
|
91
|
+
`XYTARA_X402_CDP_KEY_SECRET` are accepted as overrides. `amount` is in the asset's base units
|
|
92
|
+
(USDC has 6 decimals, so `10000` = 0.01 USDC). Verify the CDP auth path locally with
|
|
93
|
+
`npm run verify:cdp-facilitator`. Once mainnet is live, buyers spend real USDC.
|
|
90
94
|
|
|
91
95
|
## Minimum Production-Oriented Configuration
|
|
92
96
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
`xytara` 3.1.
|
|
1
|
+
`xytara` 3.1.6 fixes the x402 facilitator request so strict facilitators like Coinbase CDP accept it.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The facilitator client was adding a `resource` field to the `paymentRequirements` it sends to `/verify` and `/settle`. The x402 v2 `PaymentRequirements` shape has no `resource` field (resource belongs on the payload), so strict facilitators such as the CDP-hosted facilitator rejected the request with HTTP 400, which blocked Base mainnet settlement through CDP. Lenient facilitators ignored the extra field, so the default Base Sepolia path was unaffected.
|
|
4
4
|
|
|
5
5
|
Highlights:
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- sends a clean x402 v2 `paymentRequirements` (`scheme`, `network`, `asset`, `amount`, `payTo`, `maxTimeoutSeconds`, `extra`) with no extra `resource` field, so it matches `paymentPayload.accepted` and passes strict facilitator validation
|
|
8
|
+
- this unblocks the CDP facilitator path added in the previous release, including Base mainnet
|
|
9
|
+
- adds a regression check that the facilitator receives `paymentRequirements` without a `resource` field and equal to the payment payload's `accepted`
|
|
10
|
+
- no change to payment semantics, settlement, or any route; lenient-facilitator and Base Sepolia behavior is unchanged
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
"source_ref": "spine.job.commitment",
|
|
7
7
|
"committed_ref": "transaction.job.commitment",
|
|
8
8
|
"precommit_pack_ref": "precommit.job.commitment.readiness",
|
|
9
|
-
"transaction_id": "
|
|
10
|
-
"receipt_id": "
|
|
11
|
-
"proof_ref": "
|
|
9
|
+
"transaction_id": "txn_2061435e6b7b",
|
|
10
|
+
"receipt_id": "rcpt_4451d9ab898f",
|
|
11
|
+
"proof_ref": "proof_rcpt_4451d9ab898f",
|
|
12
12
|
"payment_protocol": "x402",
|
|
13
13
|
"settlement_mode": "evm_payment",
|
|
14
14
|
"treasury_destination_id": "treasury_acct_demo",
|
package/lib/cdp_jwt.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Zero-dependency Coinbase CDP Bearer JWT signer, used to authenticate requests to the
|
|
4
|
+
// CDP-hosted x402 facilitator (https://api.cdp.coinbase.com/platform/v2/x402), which
|
|
5
|
+
// settles Base mainnet. CDP authenticates each request with a short-lived (<=120s) EdDSA
|
|
6
|
+
// (Ed25519) JWT generated from a CDP Secret API Key, so a static Authorization header is
|
|
7
|
+
// not usable; a fresh token is minted per request with a `uri` claim bound to that request.
|
|
8
|
+
//
|
|
9
|
+
// Secret format: base64-encoded Ed25519, 64 bytes (first 32 = seed, last 32 = public key).
|
|
10
|
+
// Reference: https://docs.cdp.coinbase.com/get-started/docs/cdp-api-keys
|
|
11
|
+
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
|
|
14
|
+
// DER PKCS#8 prefix for an Ed25519 private key carrying a 32-byte seed.
|
|
15
|
+
const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
16
|
+
|
|
17
|
+
function base64url(input) {
|
|
18
|
+
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
19
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function privateKeyFromSecret(keySecret) {
|
|
23
|
+
const decoded = Buffer.from(String(keySecret || "").trim(), "base64");
|
|
24
|
+
if (decoded.length !== 64) {
|
|
25
|
+
throw new Error("cdp_key_secret_invalid: expected a base64 Ed25519 key of 64 bytes");
|
|
26
|
+
}
|
|
27
|
+
const seed = decoded.subarray(0, 32);
|
|
28
|
+
const pkcs8 = Buffer.concat([ED25519_PKCS8_PREFIX, seed]);
|
|
29
|
+
return crypto.createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build a CDP Bearer JWT for a single request.
|
|
33
|
+
// keyId CDP API key id (UUID)
|
|
34
|
+
// keySecret base64 Ed25519 secret (64 bytes)
|
|
35
|
+
// method HTTP method, e.g. "POST"
|
|
36
|
+
// host request host, e.g. "api.cdp.coinbase.com"
|
|
37
|
+
// path request path, e.g. "/platform/v2/x402/verify"
|
|
38
|
+
function buildCdpBearerToken({ keyId, keySecret, method, host, path }) {
|
|
39
|
+
const id = String(keyId || "").trim();
|
|
40
|
+
if (!id) throw new Error("cdp_key_id_missing");
|
|
41
|
+
const privateKey = privateKeyFromSecret(keySecret);
|
|
42
|
+
const now = Math.floor(Date.now() / 1000);
|
|
43
|
+
const header = { alg: "EdDSA", typ: "JWT", kid: id, nonce: crypto.randomBytes(16).toString("hex") };
|
|
44
|
+
const claims = {
|
|
45
|
+
sub: id,
|
|
46
|
+
iss: "cdp",
|
|
47
|
+
aud: ["cdp_service"],
|
|
48
|
+
nbf: now,
|
|
49
|
+
exp: now + 120,
|
|
50
|
+
uri: `${String(method || "POST").toUpperCase()} ${host}${path}`
|
|
51
|
+
};
|
|
52
|
+
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`;
|
|
53
|
+
const signature = crypto.sign(null, Buffer.from(signingInput), privateKey);
|
|
54
|
+
return `${signingInput}.${base64url(signature)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { buildCdpBearerToken, base64url };
|
package/lib/payment_config.js
CHANGED
|
@@ -1,242 +1,253 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const {
|
|
5
|
-
buildBsvTeranodeTreasuryDestination,
|
|
6
|
-
normalizeSettlementMode
|
|
7
|
-
} = require("./settlement_bsv_teranode");
|
|
8
|
-
|
|
9
|
-
function safeParseJson(text, fallback) {
|
|
10
|
-
try {
|
|
11
|
-
return JSON.parse(text);
|
|
12
|
-
} catch (_) {
|
|
13
|
-
return fallback;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function readJsonFromEnvOrPath(jsonValue, pathValue, fallback) {
|
|
18
|
-
if (typeof jsonValue === "string" && jsonValue.trim()) {
|
|
19
|
-
return safeParseJson(jsonValue, fallback);
|
|
20
|
-
}
|
|
21
|
-
if (typeof pathValue === "string" && pathValue.trim()) {
|
|
22
|
-
try {
|
|
23
|
-
return safeParseJson(fs.readFileSync(pathValue, "utf8"), fallback);
|
|
24
|
-
} catch (_) {
|
|
25
|
-
return fallback;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return fallback;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function loadWalletRegistry() {
|
|
32
|
-
const raw = readJsonFromEnvOrPath(
|
|
33
|
-
process.env.XYTARA_WALLET_REGISTRY_JSON,
|
|
34
|
-
process.env.XYTARA_WALLET_REGISTRY_PATH,
|
|
35
|
-
[]
|
|
36
|
-
);
|
|
37
|
-
const entries = Array.isArray(raw)
|
|
38
|
-
? raw
|
|
39
|
-
: Array.isArray(raw && raw.wallets) ? raw.wallets : [];
|
|
40
|
-
const byWalletId = new Map();
|
|
41
|
-
for (const entry of entries) {
|
|
42
|
-
if (!entry || typeof entry !== "object") continue;
|
|
43
|
-
const walletId = typeof entry.wallet_id === "string" ? entry.wallet_id.trim() : "";
|
|
44
|
-
const secret = typeof entry.secret === "string" ? entry.secret : "";
|
|
45
|
-
if (!walletId || !secret) continue;
|
|
46
|
-
byWalletId.set(walletId, {
|
|
47
|
-
wallet_id: walletId,
|
|
48
|
-
secret,
|
|
49
|
-
account_id: typeof entry.account_id === "string" && entry.account_id.trim() ? entry.account_id.trim() : null,
|
|
50
|
-
enabled: entry.enabled !== false
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
return byWalletId;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function loadTreasuryDestinationMap() {
|
|
57
|
-
const raw = readJsonFromEnvOrPath(
|
|
58
|
-
process.env.XYTARA_TREASURY_DESTINATIONS_JSON,
|
|
59
|
-
process.env.XYTARA_TREASURY_DESTINATIONS_PATH,
|
|
60
|
-
{}
|
|
61
|
-
);
|
|
62
|
-
const input = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
63
|
-
const normalized = {};
|
|
64
|
-
for (const [settlementMode, entry] of Object.entries(input)) {
|
|
65
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
66
|
-
normalized[normalizeSettlementMode(settlementMode)] = {
|
|
67
|
-
destination_id: typeof entry.destination_id === "string" && entry.destination_id.trim()
|
|
68
|
-
? entry.destination_id.trim()
|
|
69
|
-
: null,
|
|
70
|
-
destination_kind: typeof entry.destination_kind === "string" && entry.destination_kind.trim()
|
|
71
|
-
? entry.destination_kind.trim()
|
|
72
|
-
: null,
|
|
73
|
-
account_id: typeof entry.account_id === "string" && entry.account_id.trim()
|
|
74
|
-
? entry.account_id.trim()
|
|
75
|
-
: null,
|
|
76
|
-
external_ref: typeof entry.external_ref === "string" && entry.external_ref.trim()
|
|
77
|
-
? entry.external_ref.trim()
|
|
78
|
-
: null,
|
|
79
|
-
custody_ref: typeof entry.custody_ref === "string" && entry.custody_ref.trim()
|
|
80
|
-
? entry.custody_ref.trim()
|
|
81
|
-
: null,
|
|
82
|
-
reporting_ref: typeof entry.reporting_ref === "string" && entry.reporting_ref.trim()
|
|
83
|
-
? entry.reporting_ref.trim()
|
|
84
|
-
: null,
|
|
85
|
-
wallet_ref: typeof entry.wallet_ref === "string" && entry.wallet_ref.trim()
|
|
86
|
-
? entry.wallet_ref.trim()
|
|
87
|
-
: null,
|
|
88
|
-
provider_ref: typeof entry.provider_ref === "string" && entry.provider_ref.trim()
|
|
89
|
-
? entry.provider_ref.trim()
|
|
90
|
-
: null,
|
|
91
|
-
network: typeof entry.network === "string" && entry.network.trim()
|
|
92
|
-
? entry.network.trim()
|
|
93
|
-
: null,
|
|
94
|
-
chain_id: typeof entry.chain_id === "string" && entry.chain_id.trim()
|
|
95
|
-
? entry.chain_id.trim()
|
|
96
|
-
: null,
|
|
97
|
-
asset_symbol: typeof entry.asset_symbol === "string" && entry.asset_symbol.trim()
|
|
98
|
-
? entry.asset_symbol.trim()
|
|
99
|
-
: null,
|
|
100
|
-
policy_scope: typeof entry.policy_scope === "string" && entry.policy_scope.trim()
|
|
101
|
-
? entry.policy_scope.trim()
|
|
102
|
-
: null,
|
|
103
|
-
storage_class: typeof entry.storage_class === "string" && entry.storage_class.trim()
|
|
104
|
-
? entry.storage_class.trim()
|
|
105
|
-
: null,
|
|
106
|
-
enabled: entry.enabled !== false,
|
|
107
|
-
accept_inbound: entry.accept_inbound !== false
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
return normalized;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function loadPaymentConfig() {
|
|
114
|
-
// Default is fail-closed: signed payment acceptance. "presence" (accept any non-empty payment header)
|
|
115
|
-
// must now be opted into explicitly and is only appropriate for local development.
|
|
116
|
-
const verificationModeInput = String(process.env.XYTARA_PAYMENT_VERIFICATION_MODE || "local_signed").trim() || "local_signed";
|
|
117
|
-
const verificationMode = verificationModeInput === "signed" ? "local_signed" : verificationModeInput;
|
|
118
|
-
const maxAgeSeconds = Number(process.env.XYTARA_PAYMENT_MAX_AGE_SECONDS || 300);
|
|
119
|
-
return {
|
|
120
|
-
verification_mode: verificationMode,
|
|
121
|
-
verification_mode_input: verificationModeInput,
|
|
122
|
-
payment_max_age_seconds: Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 ? maxAgeSeconds : 300,
|
|
123
|
-
wallet_registry: loadWalletRegistry(),
|
|
124
|
-
x402: loadX402FacilitatorConfig(),
|
|
125
|
-
treasury_destination_id: String(process.env.XYTARA_TREASURY_DESTINATION_ID || "").trim() || null,
|
|
126
|
-
treasury_destination_kind: String(process.env.XYTARA_TREASURY_DESTINATION_KIND || "").trim() || null,
|
|
127
|
-
treasury_account_id: String(process.env.XYTARA_TREASURY_ACCOUNT_ID || "").trim() || null,
|
|
128
|
-
treasury_external_ref: String(process.env.XYTARA_TREASURY_EXTERNAL_REF || "").trim() || null,
|
|
129
|
-
treasury_destinations_by_settlement_mode: loadTreasuryDestinationMap()
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Configuration for the x402_facilitator verification mode: the on-chain, trustless
|
|
134
|
-
// pay-per-call path. The advertised accepts tell a buyer how to pay; the facilitator
|
|
135
|
-
// verifies and settles the signed payment before any work runs.
|
|
136
|
-
function loadX402FacilitatorConfig() {
|
|
137
|
-
let accepts = [];
|
|
138
|
-
const rawAccepts = String(process.env.XYTARA_X402_ACCEPTS_JSON || "").trim();
|
|
139
|
-
if (rawAccepts) {
|
|
140
|
-
try {
|
|
141
|
-
const parsed = JSON.parse(rawAccepts);
|
|
142
|
-
if (Array.isArray(parsed)) {
|
|
143
|
-
accepts = parsed
|
|
144
|
-
.map((entry) => ({
|
|
145
|
-
scheme: String((entry && entry.scheme) || "exact").trim(),
|
|
146
|
-
network: String((entry && entry.network) || "").trim(),
|
|
147
|
-
asset: String((entry && entry.asset) || "").trim(),
|
|
148
|
-
amount: String((entry && entry.amount) || "").trim(),
|
|
149
|
-
payTo: String((entry && entry.payTo) || "").trim(),
|
|
150
|
-
...(entry && entry.extra && typeof entry.extra === "object" ? { extra: entry.extra } : {}),
|
|
151
|
-
...(Number(entry && entry.maxTimeoutSeconds) > 0 ? { maxTimeoutSeconds: Number(entry.maxTimeoutSeconds) } : {})
|
|
152
|
-
}))
|
|
153
|
-
.filter((entry) => entry.network && entry.asset && entry.amount && entry.payTo);
|
|
154
|
-
}
|
|
155
|
-
} catch (_) {
|
|
156
|
-
accepts = [];
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
let headers = {};
|
|
160
|
-
const rawHeaders = String(process.env.XYTARA_X402_FACILITATOR_HEADERS_JSON || "").trim();
|
|
161
|
-
if (rawHeaders) {
|
|
162
|
-
try {
|
|
163
|
-
const parsed = JSON.parse(rawHeaders);
|
|
164
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) headers = parsed;
|
|
165
|
-
} catch (_) {
|
|
166
|
-
headers = {};
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return {
|
|
170
|
-
accepts,
|
|
171
|
-
facilitator_url: String(process.env.XYTARA_X402_FACILITATOR_URL || "https://x402.org/facilitator").trim().replace(/\/$/, ""),
|
|
172
|
-
facilitator_headers: headers,
|
|
173
|
-
facilitator_timeout_ms: Math.max(1000, Number(process.env.XYTARA_X402_FACILITATOR_TIMEOUT_MS || 15000) || 15000),
|
|
174
|
-
settle: String(process.env.XYTARA_X402_SETTLE || "true").trim().toLowerCase() !== "false",
|
|
175
|
-
// Credit units a single self-serve x402 top-up grants, unless the matched accepts
|
|
176
|
-
// entry carries its own `extra.credits`.
|
|
177
|
-
topup_units_default: Math.max(0, Number(process.env.XYTARA_X402_TOPUP_UNITS || 1000) || 0)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
||
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const {
|
|
5
|
+
buildBsvTeranodeTreasuryDestination,
|
|
6
|
+
normalizeSettlementMode
|
|
7
|
+
} = require("./settlement_bsv_teranode");
|
|
8
|
+
|
|
9
|
+
function safeParseJson(text, fallback) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
} catch (_) {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJsonFromEnvOrPath(jsonValue, pathValue, fallback) {
|
|
18
|
+
if (typeof jsonValue === "string" && jsonValue.trim()) {
|
|
19
|
+
return safeParseJson(jsonValue, fallback);
|
|
20
|
+
}
|
|
21
|
+
if (typeof pathValue === "string" && pathValue.trim()) {
|
|
22
|
+
try {
|
|
23
|
+
return safeParseJson(fs.readFileSync(pathValue, "utf8"), fallback);
|
|
24
|
+
} catch (_) {
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadWalletRegistry() {
|
|
32
|
+
const raw = readJsonFromEnvOrPath(
|
|
33
|
+
process.env.XYTARA_WALLET_REGISTRY_JSON,
|
|
34
|
+
process.env.XYTARA_WALLET_REGISTRY_PATH,
|
|
35
|
+
[]
|
|
36
|
+
);
|
|
37
|
+
const entries = Array.isArray(raw)
|
|
38
|
+
? raw
|
|
39
|
+
: Array.isArray(raw && raw.wallets) ? raw.wallets : [];
|
|
40
|
+
const byWalletId = new Map();
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry || typeof entry !== "object") continue;
|
|
43
|
+
const walletId = typeof entry.wallet_id === "string" ? entry.wallet_id.trim() : "";
|
|
44
|
+
const secret = typeof entry.secret === "string" ? entry.secret : "";
|
|
45
|
+
if (!walletId || !secret) continue;
|
|
46
|
+
byWalletId.set(walletId, {
|
|
47
|
+
wallet_id: walletId,
|
|
48
|
+
secret,
|
|
49
|
+
account_id: typeof entry.account_id === "string" && entry.account_id.trim() ? entry.account_id.trim() : null,
|
|
50
|
+
enabled: entry.enabled !== false
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return byWalletId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadTreasuryDestinationMap() {
|
|
57
|
+
const raw = readJsonFromEnvOrPath(
|
|
58
|
+
process.env.XYTARA_TREASURY_DESTINATIONS_JSON,
|
|
59
|
+
process.env.XYTARA_TREASURY_DESTINATIONS_PATH,
|
|
60
|
+
{}
|
|
61
|
+
);
|
|
62
|
+
const input = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
63
|
+
const normalized = {};
|
|
64
|
+
for (const [settlementMode, entry] of Object.entries(input)) {
|
|
65
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
66
|
+
normalized[normalizeSettlementMode(settlementMode)] = {
|
|
67
|
+
destination_id: typeof entry.destination_id === "string" && entry.destination_id.trim()
|
|
68
|
+
? entry.destination_id.trim()
|
|
69
|
+
: null,
|
|
70
|
+
destination_kind: typeof entry.destination_kind === "string" && entry.destination_kind.trim()
|
|
71
|
+
? entry.destination_kind.trim()
|
|
72
|
+
: null,
|
|
73
|
+
account_id: typeof entry.account_id === "string" && entry.account_id.trim()
|
|
74
|
+
? entry.account_id.trim()
|
|
75
|
+
: null,
|
|
76
|
+
external_ref: typeof entry.external_ref === "string" && entry.external_ref.trim()
|
|
77
|
+
? entry.external_ref.trim()
|
|
78
|
+
: null,
|
|
79
|
+
custody_ref: typeof entry.custody_ref === "string" && entry.custody_ref.trim()
|
|
80
|
+
? entry.custody_ref.trim()
|
|
81
|
+
: null,
|
|
82
|
+
reporting_ref: typeof entry.reporting_ref === "string" && entry.reporting_ref.trim()
|
|
83
|
+
? entry.reporting_ref.trim()
|
|
84
|
+
: null,
|
|
85
|
+
wallet_ref: typeof entry.wallet_ref === "string" && entry.wallet_ref.trim()
|
|
86
|
+
? entry.wallet_ref.trim()
|
|
87
|
+
: null,
|
|
88
|
+
provider_ref: typeof entry.provider_ref === "string" && entry.provider_ref.trim()
|
|
89
|
+
? entry.provider_ref.trim()
|
|
90
|
+
: null,
|
|
91
|
+
network: typeof entry.network === "string" && entry.network.trim()
|
|
92
|
+
? entry.network.trim()
|
|
93
|
+
: null,
|
|
94
|
+
chain_id: typeof entry.chain_id === "string" && entry.chain_id.trim()
|
|
95
|
+
? entry.chain_id.trim()
|
|
96
|
+
: null,
|
|
97
|
+
asset_symbol: typeof entry.asset_symbol === "string" && entry.asset_symbol.trim()
|
|
98
|
+
? entry.asset_symbol.trim()
|
|
99
|
+
: null,
|
|
100
|
+
policy_scope: typeof entry.policy_scope === "string" && entry.policy_scope.trim()
|
|
101
|
+
? entry.policy_scope.trim()
|
|
102
|
+
: null,
|
|
103
|
+
storage_class: typeof entry.storage_class === "string" && entry.storage_class.trim()
|
|
104
|
+
? entry.storage_class.trim()
|
|
105
|
+
: null,
|
|
106
|
+
enabled: entry.enabled !== false,
|
|
107
|
+
accept_inbound: entry.accept_inbound !== false
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadPaymentConfig() {
|
|
114
|
+
// Default is fail-closed: signed payment acceptance. "presence" (accept any non-empty payment header)
|
|
115
|
+
// must now be opted into explicitly and is only appropriate for local development.
|
|
116
|
+
const verificationModeInput = String(process.env.XYTARA_PAYMENT_VERIFICATION_MODE || "local_signed").trim() || "local_signed";
|
|
117
|
+
const verificationMode = verificationModeInput === "signed" ? "local_signed" : verificationModeInput;
|
|
118
|
+
const maxAgeSeconds = Number(process.env.XYTARA_PAYMENT_MAX_AGE_SECONDS || 300);
|
|
119
|
+
return {
|
|
120
|
+
verification_mode: verificationMode,
|
|
121
|
+
verification_mode_input: verificationModeInput,
|
|
122
|
+
payment_max_age_seconds: Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 ? maxAgeSeconds : 300,
|
|
123
|
+
wallet_registry: loadWalletRegistry(),
|
|
124
|
+
x402: loadX402FacilitatorConfig(),
|
|
125
|
+
treasury_destination_id: String(process.env.XYTARA_TREASURY_DESTINATION_ID || "").trim() || null,
|
|
126
|
+
treasury_destination_kind: String(process.env.XYTARA_TREASURY_DESTINATION_KIND || "").trim() || null,
|
|
127
|
+
treasury_account_id: String(process.env.XYTARA_TREASURY_ACCOUNT_ID || "").trim() || null,
|
|
128
|
+
treasury_external_ref: String(process.env.XYTARA_TREASURY_EXTERNAL_REF || "").trim() || null,
|
|
129
|
+
treasury_destinations_by_settlement_mode: loadTreasuryDestinationMap()
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Configuration for the x402_facilitator verification mode: the on-chain, trustless
|
|
134
|
+
// pay-per-call path. The advertised accepts tell a buyer how to pay; the facilitator
|
|
135
|
+
// verifies and settles the signed payment before any work runs.
|
|
136
|
+
function loadX402FacilitatorConfig() {
|
|
137
|
+
let accepts = [];
|
|
138
|
+
const rawAccepts = String(process.env.XYTARA_X402_ACCEPTS_JSON || "").trim();
|
|
139
|
+
if (rawAccepts) {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(rawAccepts);
|
|
142
|
+
if (Array.isArray(parsed)) {
|
|
143
|
+
accepts = parsed
|
|
144
|
+
.map((entry) => ({
|
|
145
|
+
scheme: String((entry && entry.scheme) || "exact").trim(),
|
|
146
|
+
network: String((entry && entry.network) || "").trim(),
|
|
147
|
+
asset: String((entry && entry.asset) || "").trim(),
|
|
148
|
+
amount: String((entry && entry.amount) || "").trim(),
|
|
149
|
+
payTo: String((entry && entry.payTo) || "").trim(),
|
|
150
|
+
...(entry && entry.extra && typeof entry.extra === "object" ? { extra: entry.extra } : {}),
|
|
151
|
+
...(Number(entry && entry.maxTimeoutSeconds) > 0 ? { maxTimeoutSeconds: Number(entry.maxTimeoutSeconds) } : {})
|
|
152
|
+
}))
|
|
153
|
+
.filter((entry) => entry.network && entry.asset && entry.amount && entry.payTo);
|
|
154
|
+
}
|
|
155
|
+
} catch (_) {
|
|
156
|
+
accepts = [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let headers = {};
|
|
160
|
+
const rawHeaders = String(process.env.XYTARA_X402_FACILITATOR_HEADERS_JSON || "").trim();
|
|
161
|
+
if (rawHeaders) {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(rawHeaders);
|
|
164
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) headers = parsed;
|
|
165
|
+
} catch (_) {
|
|
166
|
+
headers = {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
accepts,
|
|
171
|
+
facilitator_url: String(process.env.XYTARA_X402_FACILITATOR_URL || "https://x402.org/facilitator").trim().replace(/\/$/, ""),
|
|
172
|
+
facilitator_headers: headers,
|
|
173
|
+
facilitator_timeout_ms: Math.max(1000, Number(process.env.XYTARA_X402_FACILITATOR_TIMEOUT_MS || 15000) || 15000),
|
|
174
|
+
settle: String(process.env.XYTARA_X402_SETTLE || "true").trim().toLowerCase() !== "false",
|
|
175
|
+
// Credit units a single self-serve x402 top-up grants, unless the matched accepts
|
|
176
|
+
// entry carries its own `extra.credits`.
|
|
177
|
+
topup_units_default: Math.max(0, Number(process.env.XYTARA_X402_TOPUP_UNITS || 1000) || 0),
|
|
178
|
+
// Optional Coinbase CDP auth for the CDP-hosted facilitator (Base mainnet). When both a
|
|
179
|
+
// key id and secret are present, the facilitator client mints a fresh EdDSA Bearer JWT
|
|
180
|
+
// per request. Accepts the CDP-standard env names or XYTARA_X402_CDP_* overrides.
|
|
181
|
+
cdp: loadCdpFacilitatorAuth()
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function loadCdpFacilitatorAuth() {
|
|
186
|
+
const keyId = String(process.env.XYTARA_X402_CDP_KEY_ID || process.env.CDP_API_KEY_ID || "").trim();
|
|
187
|
+
const keySecret = String(process.env.XYTARA_X402_CDP_KEY_SECRET || process.env.CDP_API_KEY_SECRET || "").trim();
|
|
188
|
+
if (!keyId || !keySecret) return null;
|
|
189
|
+
return { key_id: keyId, key_secret: keySecret };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildConfiguredTreasuryDestination(accountId, settlementMode) {
|
|
193
|
+
const config = loadPaymentConfig();
|
|
194
|
+
const normalizedSettlementMode = normalizeSettlementMode(settlementMode);
|
|
195
|
+
const mapEntry = normalizedSettlementMode && config.treasury_destinations_by_settlement_mode[normalizedSettlementMode]
|
|
196
|
+
? config.treasury_destinations_by_settlement_mode[normalizedSettlementMode]
|
|
197
|
+
: null;
|
|
198
|
+
const destinationId = mapEntry && typeof mapEntry.destination_id === "string" && mapEntry.destination_id.trim()
|
|
199
|
+
? mapEntry.destination_id.trim()
|
|
200
|
+
: config.treasury_destination_id;
|
|
201
|
+
if (!destinationId) {
|
|
202
|
+
return buildBsvTeranodeTreasuryDestination(accountId, normalizedSettlementMode);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
destination_id: destinationId,
|
|
206
|
+
destination_kind:
|
|
207
|
+
(mapEntry && typeof mapEntry.destination_kind === "string" && mapEntry.destination_kind.trim())
|
|
208
|
+
|| config.treasury_destination_kind
|
|
209
|
+
|| "merchant_treasury",
|
|
210
|
+
account_id:
|
|
211
|
+
(mapEntry && typeof mapEntry.account_id === "string" && mapEntry.account_id.trim())
|
|
212
|
+
|| config.treasury_account_id
|
|
213
|
+
|| accountId
|
|
214
|
+
|| null,
|
|
215
|
+
settlement_mode: normalizedSettlementMode || null,
|
|
216
|
+
external_ref:
|
|
217
|
+
(mapEntry && typeof mapEntry.external_ref === "string" && mapEntry.external_ref.trim())
|
|
218
|
+
|| config.treasury_external_ref
|
|
219
|
+
|| null,
|
|
220
|
+
custody_ref: mapEntry && typeof mapEntry.custody_ref === "string" && mapEntry.custody_ref.trim()
|
|
221
|
+
? mapEntry.custody_ref.trim()
|
|
222
|
+
: null,
|
|
223
|
+
reporting_ref: mapEntry && typeof mapEntry.reporting_ref === "string" && mapEntry.reporting_ref.trim()
|
|
224
|
+
? mapEntry.reporting_ref.trim()
|
|
225
|
+
: null,
|
|
226
|
+
wallet_ref: mapEntry && typeof mapEntry.wallet_ref === "string" && mapEntry.wallet_ref.trim()
|
|
227
|
+
? mapEntry.wallet_ref.trim()
|
|
228
|
+
: null,
|
|
229
|
+
provider_ref: mapEntry && typeof mapEntry.provider_ref === "string" && mapEntry.provider_ref.trim()
|
|
230
|
+
? mapEntry.provider_ref.trim()
|
|
231
|
+
: null,
|
|
232
|
+
network: mapEntry && typeof mapEntry.network === "string" && mapEntry.network.trim()
|
|
233
|
+
? mapEntry.network.trim()
|
|
234
|
+
: null,
|
|
235
|
+
chain_id: mapEntry && typeof mapEntry.chain_id === "string" && mapEntry.chain_id.trim()
|
|
236
|
+
? mapEntry.chain_id.trim()
|
|
237
|
+
: null,
|
|
238
|
+
asset_symbol: mapEntry && typeof mapEntry.asset_symbol === "string" && mapEntry.asset_symbol.trim()
|
|
239
|
+
? mapEntry.asset_symbol.trim()
|
|
240
|
+
: null,
|
|
241
|
+
policy_scope: mapEntry && typeof mapEntry.policy_scope === "string" && mapEntry.policy_scope.trim()
|
|
242
|
+
? mapEntry.policy_scope.trim()
|
|
243
|
+
: null,
|
|
244
|
+
storage_class: mapEntry && typeof mapEntry.storage_class === "string" && mapEntry.storage_class.trim()
|
|
245
|
+
? mapEntry.storage_class.trim()
|
|
246
|
+
: null
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
buildConfiguredTreasuryDestination,
|
|
252
|
+
loadPaymentConfig
|
|
253
|
+
};
|