@cloudpayx/x402-pay 0.0.0-stage → 0.2.1
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/README.md +93 -2
- package/dist/src/assets.d.ts +53 -0
- package/dist/src/assets.js +36 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +85 -0
- package/dist/src/one_shot_guard.d.ts +52 -0
- package/dist/src/one_shot_guard.js +143 -0
- package/dist/src/policy.d.ts +74 -0
- package/dist/src/policy.js +103 -0
- package/dist/src/settlement_verifier.d.ts +24 -0
- package/dist/src/settlement_verifier.js +78 -0
- package/dist/src/xrpl_x402_wire.d.ts +1 -0
- package/dist/src/xrpl_x402_wire.js +69 -0
- package/package.json +45 -4
- package/scripts/durable-purchase-lifecycle.mjs +114 -0
- package/scripts/local-attempt-journal.mjs +82 -0
- package/scripts/persistent-spending-budget.mjs +137 -0
- package/scripts/public-node.mjs +10 -0
- package/scripts/safe-agent-fetch.mjs +184 -0
- package/scripts/secure-node-purchase.mjs +93 -0
- package/scripts/trusted-xrpl-primitives.mjs +46 -0
- package/scripts/trusted-xrpl-recovery.mjs +64 -0
- package/types/public-node.d.ts +55 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const DIGITS = /^(0|[1-9][0-9]*)$/;
|
|
2
|
+
const DECIMAL = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
|
|
3
|
+
// Syntax gate only, NOT XRPL address checksum verification.
|
|
4
|
+
const CLASSIC_ADDRESS_SHAPE = /^r[1-9A-HJ-NP-Za-km-z]{24,34}$/;
|
|
5
|
+
const ISSUED_CURRENCY_SHAPE = /^(?:[A-Za-z0-9]{3}|[A-Fa-f0-9]{40})$/;
|
|
6
|
+
/** Compare nonnegative decimal strings without Number / float rounding. */
|
|
7
|
+
export function compareAmount(a, b, units) {
|
|
8
|
+
if (a.length > 64 || b.length > 64)
|
|
9
|
+
return null;
|
|
10
|
+
const valid = units === 'drops' ? DIGITS : DECIMAL;
|
|
11
|
+
if (!valid.test(a) || !valid.test(b))
|
|
12
|
+
return null;
|
|
13
|
+
const [aw, af = ''] = a.split('.');
|
|
14
|
+
const [bw, bf = ''] = b.split('.');
|
|
15
|
+
const places = Math.max(af.length, bf.length);
|
|
16
|
+
const nA = BigInt(aw + af.padEnd(places, '0'));
|
|
17
|
+
const nB = BigInt(bw + bf.padEnd(places, '0'));
|
|
18
|
+
return nA < nB ? -1 : nA > nB ? 1 : 0;
|
|
19
|
+
}
|
|
20
|
+
function assetKey(asset) {
|
|
21
|
+
return asset.kind === 'xrp' ? 'XRP' : `${asset.asset}|${asset.issuer}`;
|
|
22
|
+
}
|
|
23
|
+
function isPositiveCap(asset) {
|
|
24
|
+
const value = asset.kind === 'xrp' ? asset.maxAmountDrops : asset.maxAmount;
|
|
25
|
+
const units = asset.kind === 'xrp' ? 'drops' : 'token';
|
|
26
|
+
return compareAmount(value, '0', units) === 1;
|
|
27
|
+
}
|
|
28
|
+
export function createAgentPaymentGate(config) {
|
|
29
|
+
if (!config.assets?.length || !config.assets.every(isPositiveCap)) {
|
|
30
|
+
throw new Error('CPX_INVALID_ASSET_CAP');
|
|
31
|
+
}
|
|
32
|
+
const names = config.assets.map(assetKey);
|
|
33
|
+
if (new Set(names).size !== names.length)
|
|
34
|
+
throw new Error('CPX_DUPLICATE_ASSET');
|
|
35
|
+
if (config.assets.some(a => (a.kind === 'issued' && (!CLASSIC_ADDRESS_SHAPE.test(a.issuer) ||
|
|
36
|
+
!ISSUED_CURRENCY_SHAPE.test(a.asset) || a.asset === 'XRP')) ||
|
|
37
|
+
(a.kind === 'xrp' && a.asset !== 'XRP')))
|
|
38
|
+
throw new Error('CPX_INVALID_ASSET_DEFINITION');
|
|
39
|
+
if (config.recipients?.mode !== 'any' && config.recipients?.mode !== 'allowlist')
|
|
40
|
+
throw new Error('CPX_RECIPIENT_POLICY_REQUIRED');
|
|
41
|
+
if (config.recipients.mode === 'allowlist' && config.recipients.addresses.length === 0)
|
|
42
|
+
throw new Error('CPX_EMPTY_RECIPIENT_ALLOWLIST');
|
|
43
|
+
if (config.approval?.mode !== 'autonomous' && config.approval?.mode !== 'callback')
|
|
44
|
+
throw new Error('CPX_APPROVAL_POLICY_REQUIRED');
|
|
45
|
+
if (config.approval.mode === 'callback' && typeof config.approval.authorize !== 'function')
|
|
46
|
+
throw new Error('CPX_APPROVAL_CALLBACK_REQUIRED');
|
|
47
|
+
const caps = new Map(config.assets.map(a => [assetKey(a), a]));
|
|
48
|
+
const recipientAllowlist = config.recipients.mode === 'allowlist' ?
|
|
49
|
+
new Set(config.recipients.addresses) : null;
|
|
50
|
+
return {
|
|
51
|
+
async evaluate(challenge) {
|
|
52
|
+
const req = challenge?.selectedRequirements;
|
|
53
|
+
if (!req || challenge?.paymentRequired?.x402Version !== 2 ||
|
|
54
|
+
req.network !== 'xrpl:0' || req.scheme !== 'exact') {
|
|
55
|
+
return { allowed: false, reason: 'CPX_UNSUPPORTED_PAYMENT' };
|
|
56
|
+
}
|
|
57
|
+
// The XRPL x402 exact scheme requires this to be explicitly false.
|
|
58
|
+
if (req.extra?.areFeesSponsored !== false &&
|
|
59
|
+
!(config.legacyT54Offer === true && req.extra?.areFeesSponsored === undefined)) {
|
|
60
|
+
return { allowed: false, reason: 'CPX_INVALID_FEE_TERMS' };
|
|
61
|
+
}
|
|
62
|
+
if (req.extra?.assetTransferMethod !== undefined &&
|
|
63
|
+
!['sequence', 'ticketSequence'].includes(req.extra?.assetTransferMethod)) {
|
|
64
|
+
return { allowed: false, reason: 'CPX_INVALID_TRANSFER_METHOD' };
|
|
65
|
+
}
|
|
66
|
+
if (!CLASSIC_ADDRESS_SHAPE.test(req.payTo ?? '')) {
|
|
67
|
+
return { allowed: false, reason: 'CPX_INVALID_RECIPIENT_SHAPE' };
|
|
68
|
+
}
|
|
69
|
+
if (recipientAllowlist && !recipientAllowlist.has(req.payTo)) {
|
|
70
|
+
return { allowed: false, reason: 'CPX_RECIPIENT_NOT_PERMITTED' };
|
|
71
|
+
}
|
|
72
|
+
const issuer = req.asset === 'XRP' ? undefined : req.extra?.issuer;
|
|
73
|
+
// Reject inconsistent or extra issuer data for XRP.
|
|
74
|
+
if (req.asset === 'XRP' && req.extra?.issuer !== undefined) {
|
|
75
|
+
return { allowed: false, reason: 'CPX_UNEXPECTED_XRP_ISSUER' };
|
|
76
|
+
}
|
|
77
|
+
const cap = caps.get(req.asset === 'XRP' ? 'XRP' : `${req.asset}|${issuer ?? ''}`);
|
|
78
|
+
if (!cap)
|
|
79
|
+
return { allowed: false, reason: 'CPX_ASSET_NOT_PERMITTED' };
|
|
80
|
+
const units = cap.kind === 'xrp' ? 'drops' : 'token';
|
|
81
|
+
const maximum = cap.kind === 'xrp' ? cap.maxAmountDrops : cap.maxAmount;
|
|
82
|
+
const cmp = compareAmount(req.amount, maximum, units);
|
|
83
|
+
if (cmp === null || compareAmount(req.amount, '0', units) !== 1 || cmp === 1) {
|
|
84
|
+
return { allowed: false, reason: 'CPX_AMOUNT_NOT_PERMITTED' };
|
|
85
|
+
}
|
|
86
|
+
const intent = {
|
|
87
|
+
network: 'xrpl:0', asset: req.asset, amount: req.amount,
|
|
88
|
+
amountUnit: units, issuer, recipient: req.payTo
|
|
89
|
+
};
|
|
90
|
+
if (config.approval.mode === 'callback') {
|
|
91
|
+
try {
|
|
92
|
+
if (!(await config.approval.authorize(intent))) {
|
|
93
|
+
return { allowed: false, reason: 'CPX_PAYMENT_DENIED' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { allowed: false, reason: 'CPX_AUTHORIZATION_UNAVAILABLE' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { allowed: true, intent };
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ExpectedXrplSettlement = {
|
|
2
|
+
/** SHA-512Half transaction hash as returned by the original signer, 64 hex chars. */
|
|
3
|
+
transactionHash: string;
|
|
4
|
+
payer: string;
|
|
5
|
+
recipient: string;
|
|
6
|
+
destinationTag?: number;
|
|
7
|
+
sourceTag?: number;
|
|
8
|
+
/** SHA-256(invoiceId string), upper-case 64 hex, bound by the signing guard. */
|
|
9
|
+
invoiceID: string;
|
|
10
|
+
asset: string;
|
|
11
|
+
issuer?: string;
|
|
12
|
+
amount: string;
|
|
13
|
+
maxFeeDrops: string;
|
|
14
|
+
};
|
|
15
|
+
/** Throws on any uncertain or unexpected result. Never authorizes retry. */
|
|
16
|
+
export declare function verifyValidatedXrplSettlement(payload: unknown, expected: ExpectedXrplSettlement): Readonly<{
|
|
17
|
+
status: "VALIDATED_SUCCESS";
|
|
18
|
+
transactionHash: string;
|
|
19
|
+
ledgerIndex: number;
|
|
20
|
+
deliveredAmount: string;
|
|
21
|
+
asset: string;
|
|
22
|
+
feeDrops: string;
|
|
23
|
+
invoiceID: string;
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cpX x402 Pay: XRPL validated-ledger settlement verifier.
|
|
3
|
+
* Pure data verifier: no wallet, signing, network calls, or transaction submission.
|
|
4
|
+
* Caller must fetch a transaction from a trusted XRPL mainnet node using `tx`.
|
|
5
|
+
* A seller's HTTP 200 or payment-response header does NOT replace this check.
|
|
6
|
+
*/
|
|
7
|
+
import { compareAmount } from './policy.js';
|
|
8
|
+
function record(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
function required(test, code) {
|
|
12
|
+
if (!test)
|
|
13
|
+
throw new Error(code);
|
|
14
|
+
}
|
|
15
|
+
const HASH = /^[0-9A-Fa-f]{64}$/;
|
|
16
|
+
const DROPS = /^(0|[1-9][0-9]*)$/;
|
|
17
|
+
const CURRENCY = /^[0-9A-F]{40}$/;
|
|
18
|
+
function equalAmount(a, b, unit) {
|
|
19
|
+
return typeof a === 'string' && compareAmount(a, b, unit) === 0;
|
|
20
|
+
}
|
|
21
|
+
/** Throws on any uncertain or unexpected result. Never authorizes retry. */
|
|
22
|
+
export function verifyValidatedXrplSettlement(payload, expected) {
|
|
23
|
+
const outer = record(payload);
|
|
24
|
+
const data = record(outer?.result) ?? outer;
|
|
25
|
+
required(data !== undefined, 'CPX_BAD_LEDGER_RESPONSE');
|
|
26
|
+
required(HASH.test(expected.transactionHash), 'CPX_BAD_EXPECTED_TX_HASH');
|
|
27
|
+
required(HASH.test(expected.invoiceID), 'CPX_BAD_EXPECTED_INVOICE');
|
|
28
|
+
required(typeof expected.payer === 'string' && expected.payer.length > 0 &&
|
|
29
|
+
typeof expected.recipient === 'string' && expected.recipient.length > 0, 'CPX_BAD_EXPECTED_ADDRESSES');
|
|
30
|
+
required(expected.destinationTag === undefined ||
|
|
31
|
+
(Number.isInteger(expected.destinationTag) && expected.destinationTag >= 0 &&
|
|
32
|
+
expected.destinationTag <= 0xffffffff), 'CPX_BAD_EXPECTED_TAG');
|
|
33
|
+
required(expected.sourceTag === undefined ||
|
|
34
|
+
(Number.isInteger(expected.sourceTag) && expected.sourceTag >= 0 &&
|
|
35
|
+
expected.sourceTag <= 0xffffffff), 'CPX_BAD_EXPECTED_SOURCE_TAG');
|
|
36
|
+
required(DROPS.test(expected.maxFeeDrops) && BigInt(expected.maxFeeDrops) > 0n, 'CPX_BAD_EXPECTED_FEE');
|
|
37
|
+
required(data.validated === true, 'CPX_TX_NOT_VALIDATED');
|
|
38
|
+
const meta = record(data.meta);
|
|
39
|
+
required(meta !== undefined && meta.TransactionResult === 'tesSUCCESS', 'CPX_TX_NOT_SUCCESSFUL');
|
|
40
|
+
const tx = record(data.tx_json) ?? record(data.tx) ?? data;
|
|
41
|
+
const actualHash = data.hash ?? tx.hash;
|
|
42
|
+
required(typeof actualHash === 'string' && actualHash.toUpperCase() === expected.transactionHash.toUpperCase(), 'CPX_TX_HASH_MISMATCH');
|
|
43
|
+
required(tx.TransactionType === 'Payment', 'CPX_NOT_A_PAYMENT');
|
|
44
|
+
required(tx.Account === expected.payer, 'CPX_WRONG_PAYER');
|
|
45
|
+
required(tx.Destination === expected.recipient, 'CPX_WRONG_RECIPIENT');
|
|
46
|
+
required(tx.DestinationTag === expected.destinationTag, 'CPX_WRONG_TAG');
|
|
47
|
+
required(tx.SourceTag === expected.sourceTag, 'CPX_WRONG_SOURCE_TAG');
|
|
48
|
+
required(typeof tx.InvoiceID === 'string' && tx.InvoiceID.toUpperCase() === expected.invoiceID.toUpperCase(), 'CPX_WRONG_INVOICE');
|
|
49
|
+
required(typeof tx.Fee === 'string' && DROPS.test(tx.Fee) &&
|
|
50
|
+
BigInt(tx.Fee) > 0n && BigInt(tx.Fee) <= BigInt(expected.maxFeeDrops), 'CPX_FEE_MISMATCH');
|
|
51
|
+
required(typeof data.ledger_index === 'number' && Number.isSafeInteger(data.ledger_index) &&
|
|
52
|
+
data.ledger_index > 0, 'CPX_MISSING_VALIDATED_LEDGER');
|
|
53
|
+
// XRPL v2 uses DeliverMax, v1 uses Amount; delivered_amount is authoritative.
|
|
54
|
+
const requested = tx.DeliverMax ?? tx.Amount;
|
|
55
|
+
const delivered = meta.delivered_amount;
|
|
56
|
+
const unit = expected.asset === 'XRP' ? 'drops' : 'token';
|
|
57
|
+
if (unit === 'drops') {
|
|
58
|
+
required(expected.issuer === undefined && equalAmount(requested, expected.amount, 'drops'), 'CPX_WRONG_XRP_REQUEST');
|
|
59
|
+
required(equalAmount(delivered, expected.amount, 'drops'), 'CPX_WRONG_XRP_DELIVERY');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
required(CURRENCY.test(expected.asset) && typeof expected.issuer === 'string' && expected.issuer.startsWith('r'), 'CPX_BAD_EXPECTED_ASSET');
|
|
63
|
+
for (const [label, candidate] of [['REQUEST', requested], ['DELIVERY', delivered]]) {
|
|
64
|
+
const value = record(candidate);
|
|
65
|
+
required(value !== undefined && value.currency === expected.asset &&
|
|
66
|
+
value.issuer === expected.issuer && equalAmount(value.value, expected.amount, 'token'), `CPX_WRONG_${label}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
status: 'VALIDATED_SUCCESS',
|
|
71
|
+
transactionHash: expected.transactionHash.toUpperCase(),
|
|
72
|
+
ledgerIndex: data.ledger_index,
|
|
73
|
+
deliveredAmount: expected.amount,
|
|
74
|
+
asset: expected.asset,
|
|
75
|
+
feeDrops: tx.Fee,
|
|
76
|
+
invoiceID: expected.invoiceID.toUpperCase()
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createCpXWireFetch(baseFetch: typeof globalThis.fetch): typeof globalThis.fetch;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function decode(header) {
|
|
2
|
+
if (header.length > 131072)
|
|
3
|
+
throw Error("CPX_PAYMENT_HEADER_TOO_LARGE");
|
|
4
|
+
try {
|
|
5
|
+
const binary = atob(header);
|
|
6
|
+
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
7
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw Error("CPX_INVALID_PAYMENT_ENVELOPE");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function encode(value) {
|
|
14
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
15
|
+
let binary = "";
|
|
16
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
17
|
+
binary += String.fromCharCode(...Array.from(bytes.subarray(i, i + 8192)));
|
|
18
|
+
}
|
|
19
|
+
return btoa(binary);
|
|
20
|
+
}
|
|
21
|
+
export function createCpXWireFetch(baseFetch) {
|
|
22
|
+
return async (input, init) => {
|
|
23
|
+
const request = new Request(input, init);
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
26
|
+
throw Error("CPX_UNSAFE_PAYMENT_URL");
|
|
27
|
+
const safe = new Request(request, { redirect: "error" });
|
|
28
|
+
const signature = safe.headers.get("PAYMENT-SIGNATURE");
|
|
29
|
+
if (!signature)
|
|
30
|
+
return baseFetch(safe);
|
|
31
|
+
const payment = decode(signature);
|
|
32
|
+
const accepted = payment?.accepted;
|
|
33
|
+
const extra = accepted?.extra;
|
|
34
|
+
const isT54Xrp = payment?.x402Version === 2 &&
|
|
35
|
+
accepted?.scheme === "exact" &&
|
|
36
|
+
accepted?.network === "xrpl:0" &&
|
|
37
|
+
accepted?.asset === "XRP" &&
|
|
38
|
+
extra?.sourceTag === 804681468;
|
|
39
|
+
// Existing RLUSD and USDC envelopes remain unchanged.
|
|
40
|
+
if (!isT54Xrp)
|
|
41
|
+
return baseFetch(safe);
|
|
42
|
+
const invoice = extra?.invoiceId;
|
|
43
|
+
const payload = payment.payload;
|
|
44
|
+
if (extra?.areFeesSponsored !== false ||
|
|
45
|
+
typeof invoice !== "string" ||
|
|
46
|
+
invoice.length < 1 ||
|
|
47
|
+
invoice.length > 128 ||
|
|
48
|
+
typeof accepted?.payTo !== "string" ||
|
|
49
|
+
!/^r[1-9A-HJ-NP-Za-km-z]{24,34}$/.test(accepted.payTo) ||
|
|
50
|
+
typeof accepted?.amount !== "string" ||
|
|
51
|
+
!/^[1-9][0-9]*$/.test(accepted.amount) ||
|
|
52
|
+
!payload ||
|
|
53
|
+
typeof payload.signedTxBlob !== "string" ||
|
|
54
|
+
!/^(?:[0-9a-fA-F]{2})+$/.test(payload.signedTxBlob) ||
|
|
55
|
+
(payload.invoiceId !== undefined &&
|
|
56
|
+
payload.invoiceId !== invoice)) {
|
|
57
|
+
throw Error("CPX_T54_WIRE_VALIDATION_FAILED");
|
|
58
|
+
}
|
|
59
|
+
const headers = new Headers(safe.headers);
|
|
60
|
+
headers.set("PAYMENT-SIGNATURE", encode({
|
|
61
|
+
...payment,
|
|
62
|
+
payload: { ...payload, invoiceId: invoice }
|
|
63
|
+
}));
|
|
64
|
+
return baseFetch(new Request(safe, {
|
|
65
|
+
headers,
|
|
66
|
+
redirect: "error"
|
|
67
|
+
}));
|
|
68
|
+
};
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudpayx/x402-pay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Experimental, wallet-agnostic, account-free XRP, RLUSD and USDC XRPL x402 buyer integration",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@x402/core": "2.28.0",
|
|
9
|
+
"@x402/fetch": "2.28.0",
|
|
10
|
+
"@x402/xrpl": "2.28.0",
|
|
11
|
+
"xrpl": "4.7.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "^5.8.3"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"test:policy": "tsc -p tsconfig.guard.json && node --test tests/*.test.mjs",
|
|
19
|
+
"demo:dry-run": "tsc -p tsconfig.policy.json && node examples/simulation-only.mjs",
|
|
20
|
+
"test:signing-guard": "tsc -p tsconfig.guard.json && node --test tests/guard.test.mjs"
|
|
21
|
+
},
|
|
22
|
+
"main": "./scripts/public-node.mjs",
|
|
23
|
+
"types": "./types/public-node.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./types/public-node.d.ts",
|
|
27
|
+
"node": "./scripts/public-node.mjs",
|
|
28
|
+
"default": "./scripts/public-node.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist/src/",
|
|
33
|
+
"scripts/public-node.mjs",
|
|
34
|
+
"scripts/secure-node-purchase.mjs",
|
|
35
|
+
"scripts/durable-purchase-lifecycle.mjs",
|
|
36
|
+
"scripts/local-attempt-journal.mjs",
|
|
37
|
+
"scripts/persistent-spending-budget.mjs",
|
|
38
|
+
"scripts/safe-agent-fetch.mjs",
|
|
39
|
+
"scripts/trusted-xrpl-primitives.mjs",
|
|
40
|
+
"scripts/trusted-xrpl-recovery.mjs",
|
|
41
|
+
"types/",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cpX x402 Pay: local, self-funded-agent purchase lifecycle.
|
|
3
|
+
* No network, wallet, transaction builder, or submission code is included here.
|
|
4
|
+
* IMPORTANT: call executeDurablePurchase with the signer factory INSIDE the
|
|
5
|
+
* exclusive journal reservation. The factory MUST apply createCpXX402AgentPay's
|
|
6
|
+
* oneShot transaction guard around the provided journaledSigner.
|
|
7
|
+
* Never use this local file journal for multi-host delegated/managed accounts.
|
|
8
|
+
*/
|
|
9
|
+
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
10
|
+
const DROPS = /^(0|[1-9][0-9]*)$/;
|
|
11
|
+
function requireCondition(ok, code) { if (!ok) throw new Error(code); }
|
|
12
|
+
const isRecord = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
13
|
+
function transactionExpectation(tx, hash, maxFeeDrops) {
|
|
14
|
+
requireCondition(isRecord(tx) && tx.TransactionType === 'Payment', 'CPX_JOURNAL_NOT_PAYMENT');
|
|
15
|
+
requireCondition(typeof tx.Account === 'string' && typeof tx.Destination === 'string', 'CPX_JOURNAL_BAD_ADDRESS');
|
|
16
|
+
requireCondition(tx.DestinationTag === undefined ||
|
|
17
|
+
(Number.isInteger(tx.DestinationTag) && tx.DestinationTag >= 0 && tx.DestinationTag <= 0xffffffff), 'CPX_JOURNAL_BAD_TAG');
|
|
18
|
+
requireCondition(tx.SourceTag === undefined ||
|
|
19
|
+
(Number.isInteger(tx.SourceTag) && tx.SourceTag >= 0 && tx.SourceTag <= 0xffffffff), 'CPX_JOURNAL_BAD_SOURCE_TAG');
|
|
20
|
+
requireCondition(typeof tx.InvoiceID === 'string' && HEX64.test(tx.InvoiceID), 'CPX_JOURNAL_BAD_INVOICE');
|
|
21
|
+
requireCondition(DROPS.test(maxFeeDrops) && BigInt(maxFeeDrops) > 0n, 'CPX_JOURNAL_BAD_FEE_CAP');
|
|
22
|
+
requireCondition(typeof tx.Fee === 'string' && DROPS.test(tx.Fee) && BigInt(tx.Fee) > 0n && BigInt(tx.Fee) <= BigInt(maxFeeDrops), 'CPX_JOURNAL_FEE_EXCEEDS_CAP');
|
|
23
|
+
requireCondition(
|
|
24
|
+
Number.isSafeInteger(tx.LastLedgerSequence) &&
|
|
25
|
+
tx.LastLedgerSequence > 0 &&
|
|
26
|
+
tx.LastLedgerSequence <= 0xffffffff,
|
|
27
|
+
'CPX_JOURNAL_LAST_LEDGER_REQUIRED'
|
|
28
|
+
);
|
|
29
|
+
const value = tx.DeliverMax ?? tx.Amount;
|
|
30
|
+
let asset, issuer, amount;
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
asset = 'XRP'; amount = value;
|
|
33
|
+
requireCondition(DROPS.test(amount) && BigInt(amount) > 0n, 'CPX_JOURNAL_BAD_XRP');
|
|
34
|
+
} else {
|
|
35
|
+
requireCondition(isRecord(value) && typeof value.currency === 'string' && typeof value.issuer === 'string' && typeof value.value === 'string', 'CPX_JOURNAL_BAD_TOKEN');
|
|
36
|
+
asset = value.currency; issuer = value.issuer; amount = value.value;
|
|
37
|
+
}
|
|
38
|
+
return {transactionHash: hash, payer: tx.Account, recipient: tx.Destination,
|
|
39
|
+
...(tx.DestinationTag === undefined ? {} : {destinationTag:tx.DestinationTag}),
|
|
40
|
+
...(tx.SourceTag === undefined ? {} : {sourceTag:tx.SourceTag}),
|
|
41
|
+
invoiceID: tx.InvoiceID.toUpperCase(),
|
|
42
|
+
lastLedgerSequence: tx.LastLedgerSequence,
|
|
43
|
+
asset, ...(issuer === undefined ? {} : {issuer}), amount, maxFeeDrops};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Persist the original signed transaction hash BEFORE returning the blob to x402. */
|
|
47
|
+
export function createJournaledSigner({signer, journal, intentId, hashSignedTx, maxFeeDrops}) {
|
|
48
|
+
requireCondition(typeof signer?.classicAddress === 'string' && typeof signer.sign === 'function', 'CPX_SIGNER_REQUIRED');
|
|
49
|
+
requireCondition(typeof journal?.signed === 'function', 'CPX_JOURNAL_REQUIRED');
|
|
50
|
+
requireCondition(typeof hashSignedTx === 'function', 'CPX_OFFICIAL_SIGNED_HASH_FUNCTION_REQUIRED');
|
|
51
|
+
let invoked = false;
|
|
52
|
+
return {
|
|
53
|
+
classicAddress: signer.classicAddress,
|
|
54
|
+
async sign(tx) {
|
|
55
|
+
requireCondition(!invoked, 'CPX_JOURNAL_SIGNER_ALREADY_USED');
|
|
56
|
+
invoked = true; // fail-closed even if an await fails or two calls overlap
|
|
57
|
+
const signed = await signer.sign(tx); // should be invoked THROUGH the official one-shot guard
|
|
58
|
+
requireCondition(typeof signed?.signedTxBlob === 'string' && signed.signedTxBlob.length > 0, 'CPX_SIGNER_NO_BLOB');
|
|
59
|
+
const hash = hashSignedTx(signed.signedTxBlob);
|
|
60
|
+
requireCondition(typeof hash === 'string' && HEX64.test(hash), 'CPX_INVALID_COMPUTED_HASH');
|
|
61
|
+
if (signed.hash !== undefined) {
|
|
62
|
+
requireCondition(typeof signed.hash === 'string' && signed.hash.toUpperCase() === hash.toUpperCase(), 'CPX_SIGNED_HASH_MISMATCH');
|
|
63
|
+
}
|
|
64
|
+
const expected = transactionExpectation(tx, hash.toUpperCase(), maxFeeDrops);
|
|
65
|
+
requireCondition(tx.Account === signer.classicAddress, 'CPX_JOURNAL_PAYER_MISMATCH');
|
|
66
|
+
await journal.signed(intentId, hash.toUpperCase(), expected); // fsync + atomic rename
|
|
67
|
+
return signed; // NEVER returned if durable persistence fails
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reserve a stable economic intent BEFORE requesting the x402 resource.
|
|
74
|
+
* createPaymentClient MUST return a guarded SDK instance whose oneShot is ON.
|
|
75
|
+
* Returning HTTP 200 is intentionally not treated as validated settlement.
|
|
76
|
+
*/
|
|
77
|
+
export async function executeDurablePurchase({journal, intent, signer, hashSignedTx, maxFeeDrops, createPaymentClient, request, reserveBudget}) {
|
|
78
|
+
requireCondition(typeof journal?.reserve === 'function', 'CPX_JOURNAL_REQUIRED');
|
|
79
|
+
requireCondition(typeof createPaymentClient === 'function', 'CPX_CLIENT_FACTORY_REQUIRED');
|
|
80
|
+
requireCondition(typeof reserveBudget === 'function', 'CPX_BUDGET_RESERVATION_REQUIRED');
|
|
81
|
+
requireCondition(typeof request?.url === 'string' && request.url === intent.resource, 'CPX_RESOURCE_MISMATCH');
|
|
82
|
+
await journal.reserve(intent); // duplicates and concurrent same-ID attempts fail here
|
|
83
|
+
// Must hold durable capacity before constructing any payment client, HTTP or signer.
|
|
84
|
+
// A crash between intent and budget reservation intentionally blocks retry.
|
|
85
|
+
await reserveBudget(intent);
|
|
86
|
+
const journaledSigner = createJournaledSigner({signer, journal, intentId: intent.intentId, hashSignedTx, maxFeeDrops});
|
|
87
|
+
// Caller must use createCpXX402AgentPay({signer:journaledSigner, policy, oneShot:{...}})
|
|
88
|
+
const buyer = await createPaymentClient(journaledSigner);
|
|
89
|
+
requireCondition(typeof buyer?.paidFetch === 'function' && typeof buyer?.oneShotStatus === 'function', 'CPX_ONE_SHOT_CLIENT_REQUIRED');
|
|
90
|
+
// Never retry automatically. If this throws or times out, the intent stays blocked.
|
|
91
|
+
const response = await buyer.paidFetch(request.url, request.init);
|
|
92
|
+
// Capture seller rejection text for diagnostics; never treat it as settlement proof.
|
|
93
|
+
let rejectionBody;
|
|
94
|
+
if (response.status !== 200) {
|
|
95
|
+
try { rejectionBody = (await response.clone().text()).slice(0, 2048); }
|
|
96
|
+
catch { rejectionBody = 'SELLER_RESPONSE_BODY_UNAVAILABLE'; }
|
|
97
|
+
}
|
|
98
|
+
return {status: 'HTTP_RESPONSE_LEDGER_UNVERIFIED',
|
|
99
|
+
...(rejectionBody === undefined ? {} : {rejectionBody}), httpStatus: response.status,
|
|
100
|
+
paymentResponseHeaderPresent: response.headers?.has?.('payment-response') === true,
|
|
101
|
+
journalStatus: (await journal.load(intent.intentId)).status};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Strict read-only reconciliation. No submission or retry is possible here. */
|
|
105
|
+
export async function reconcileDurablePurchase({journal, intentId, lookupValidatedTransaction, verifyValidatedXrplSettlement}) {
|
|
106
|
+
requireCondition(typeof lookupValidatedTransaction === 'function' && typeof verifyValidatedXrplSettlement === 'function', 'CPX_RECONCILER_REQUIRED');
|
|
107
|
+
const state = await journal.load(intentId);
|
|
108
|
+
requireCondition(state.status === 'SIGNED_SUBMISSION_UNKNOWN' && isRecord(state.expected), 'CPX_NO_SIGNED_EXPECTATION_TO_RECONCILE');
|
|
109
|
+
// The lookup must query an independently trusted XRPL mainnet node, NOT the seller.
|
|
110
|
+
const ledgerResponse = await lookupValidatedTransaction(state.transactionHash);
|
|
111
|
+
const receipt = verifyValidatedXrplSettlement(ledgerResponse, state.expected);
|
|
112
|
+
await journal.validated(intentId, receipt);
|
|
113
|
+
return receipt;
|
|
114
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local durable idempotency demonstration for independent agents.
|
|
3
|
+
* Each distinct economic purchase MUST have a stable application-provided intentId.
|
|
4
|
+
* Fail closed if same intentId is seen again, even with a new seller invoice.
|
|
5
|
+
* No secrets or signed transaction blobs are stored.
|
|
6
|
+
* Not a multi-host distributed lock, Cloudflare database, or account-wide budget.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const HASH = /^[0-9A-Fa-f]{64}$/;
|
|
13
|
+
function requireValue(condition, code) {
|
|
14
|
+
if (!condition) throw new Error(code);
|
|
15
|
+
}
|
|
16
|
+
export function createLocalAttemptJournal(folder) {
|
|
17
|
+
requireValue(typeof folder === 'string' && folder.length > 0, 'CPX_INVALID_JOURNAL_DIR');
|
|
18
|
+
const dir = resolve(folder);
|
|
19
|
+
const fileFor = intentId => {
|
|
20
|
+
requireValue(typeof intentId === 'string' && intentId.length >= 12 && intentId.length <= 200, 'CPX_BAD_INTENT_ID');
|
|
21
|
+
return join(dir, createHash('sha256').update(intentId).digest('hex') + '.json');
|
|
22
|
+
};
|
|
23
|
+
async function load(intentId) {
|
|
24
|
+
const raw = await readFile(fileFor(intentId), 'utf8');
|
|
25
|
+
const state = JSON.parse(raw);
|
|
26
|
+
requireValue(state.intentId === intentId, 'CPX_JOURNAL_ID_MISMATCH');
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
async function reserve({intentId, payer, seller, resource, asset, amount}) {
|
|
30
|
+
requireValue([payer,seller,resource,asset,amount].every(v => typeof v === 'string' && v.length > 0), 'CPX_BAD_INTENT');
|
|
31
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const file = fileFor(intentId);
|
|
33
|
+
// Open with O_EXCL: only one process can claim an intent at a time on local filesystem.
|
|
34
|
+
let fd;
|
|
35
|
+
try {
|
|
36
|
+
fd = await open(file, 'wx', 0o600);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err?.code === 'EEXIST') throw new Error('CPX_DUPLICATE_PURCHASE_INTENT_BLOCKED');
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const state = {intentId, payer, seller, resource, asset, amount,
|
|
42
|
+
status:'RESERVED_OUTCOME_UNKNOWN', createdAt:new Date().toISOString()};
|
|
43
|
+
try {
|
|
44
|
+
await fd.writeFile(JSON.stringify(state, null, 2));
|
|
45
|
+
await fd.sync();
|
|
46
|
+
} finally { await fd.close(); }
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
async function signed(intentId, hash, expected) {
|
|
50
|
+
requireValue(typeof hash === 'string' && HASH.test(hash), 'CPX_INVALID_SIGNED_HASH');
|
|
51
|
+
const state = await load(intentId);
|
|
52
|
+
requireValue(state.status === 'RESERVED_OUTCOME_UNKNOWN', 'CPX_SIGNED_STATE_CONFLICT');
|
|
53
|
+
if (expected !== undefined) {
|
|
54
|
+
requireValue(expected && typeof expected === 'object' && !Array.isArray(expected) &&
|
|
55
|
+
expected.transactionHash === hash.toUpperCase() &&
|
|
56
|
+
expected.payer === state.payer && expected.recipient === state.seller &&
|
|
57
|
+
expected.asset === state.asset && expected.amount === state.amount,
|
|
58
|
+
'CPX_EXPECTED_PAYMENT_MISMATCH');
|
|
59
|
+
}
|
|
60
|
+
return update(intentId, {...state, status:'SIGNED_SUBMISSION_UNKNOWN',
|
|
61
|
+
transactionHash:hash.toUpperCase(), ...(expected === undefined ? {} : {expected})});
|
|
62
|
+
}
|
|
63
|
+
async function validated(intentId, verifiedReceipt) {
|
|
64
|
+
const state = await load(intentId);
|
|
65
|
+
requireValue(state.status === 'SIGNED_SUBMISSION_UNKNOWN' &&
|
|
66
|
+
verifiedReceipt?.status === 'VALIDATED_SUCCESS' &&
|
|
67
|
+
verifiedReceipt.transactionHash === state.transactionHash,
|
|
68
|
+
'CPX_LEDGER_PROOF_REQUIRED');
|
|
69
|
+
return update(intentId, {...state, status:'VALIDATED_SUCCESS',
|
|
70
|
+
ledgerIndex:verifiedReceipt.ledgerIndex, verifiedReceipt, verifiedAt:new Date().toISOString()});
|
|
71
|
+
}
|
|
72
|
+
async function update(intentId, state) {
|
|
73
|
+
const dest = fileFor(intentId);
|
|
74
|
+
const temp = dest + '.' + randomUUID() + '.tmp';
|
|
75
|
+
const fd = await open(temp, 'wx', 0o600);
|
|
76
|
+
try { await fd.writeFile(JSON.stringify(state, null, 2)); await fd.sync(); }
|
|
77
|
+
finally { await fd.close(); }
|
|
78
|
+
await rename(temp, dest);
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
return { reserve, signed, validated, load };
|
|
82
|
+
}
|