@aifinpay/agent 2.3.2 → 2.5.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/PAYMENT_RECEIPTS.md +194 -19
- package/README.md +37 -17
- package/dist/agentHistory.d.ts +9 -0
- package/dist/agentHistory.d.ts.map +1 -1
- package/dist/agentHistory.js +110 -16
- package/dist/agentHistory.js.map +1 -1
- package/dist/aifp1.d.ts +124 -17
- package/dist/aifp1.d.ts.map +1 -1
- package/dist/aifp1.js +580 -82
- package/dist/aifp1.js.map +1 -1
- package/dist/deploymentErrors.d.ts +5 -0
- package/dist/deploymentErrors.d.ts.map +1 -0
- package/dist/deploymentErrors.js +8 -0
- package/dist/deploymentErrors.js.map +1 -0
- package/dist/deploymentResolver.d.ts +2 -3
- package/dist/deploymentResolver.d.ts.map +1 -1
- package/dist/deploymentResolver.js +2 -6
- package/dist/deploymentResolver.js.map +1 -1
- package/dist/generated/solanaV14Deployments.generated.d.ts +1 -0
- package/dist/generated/solanaV14Deployments.generated.d.ts.map +1 -1
- package/dist/generated/solanaV14Deployments.generated.js +4 -2
- package/dist/generated/solanaV14Deployments.generated.js.map +1 -1
- package/dist/generated/v14Deployments.generated.d.ts +1 -1
- package/dist/generated/v14Deployments.generated.js +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/paymentChains.d.ts +95 -0
- package/dist/paymentChains.d.ts.map +1 -0
- package/dist/paymentChains.js +116 -0
- package/dist/paymentChains.js.map +1 -0
- package/dist/settlement.d.ts +5 -5
- package/dist/settlementSolanaV14.d.ts +135 -0
- package/dist/settlementSolanaV14.d.ts.map +1 -0
- package/dist/settlementSolanaV14.js +733 -0
- package/dist/settlementSolanaV14.js.map +1 -0
- package/dist/settlementV14.d.ts +9 -2
- package/dist/settlementV14.d.ts.map +1 -1
- package/dist/settlementV14.js +81 -14
- package/dist/settlementV14.js.map +1 -1
- package/dist/solanaDeploymentResolver.d.ts +2 -1
- package/dist/solanaDeploymentResolver.d.ts.map +1 -1
- package/dist/solanaDeploymentResolver.js +1 -32
- package/dist/solanaDeploymentResolver.js.map +1 -1
- package/dist/spendLedger.d.ts +74 -33
- package/dist/spendLedger.d.ts.map +1 -1
- package/dist/spendLedger.js +523 -130
- package/dist/spendLedger.js.map +1 -1
- package/dist/unifiedAgent.d.ts +17 -8
- package/dist/unifiedAgent.d.ts.map +1 -1
- package/dist/unifiedAgent.js +222 -37
- package/dist/unifiedAgent.js.map +1 -1
- package/package.json +4 -4
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -127
- package/dist/cli.js.map +0 -1
- package/dist/solanaV14Deployments.generated.d.ts +0 -35
- package/dist/solanaV14Deployments.generated.d.ts.map +0 -1
- package/dist/solanaV14Deployments.generated.js +0 -61
- package/dist/solanaV14Deployments.generated.js.map +0 -1
- package/dist/splitterRoutes.generated.d.ts +0 -22
- package/dist/splitterRoutes.generated.d.ts.map +0 -1
- package/dist/splitterRoutes.generated.js +0 -459
- package/dist/splitterRoutes.generated.js.map +0 -1
- package/dist/v14Deployments.generated.d.ts +0 -53
- package/dist/v14Deployments.generated.d.ts.map +0 -1
- package/dist/v14Deployments.generated.js +0 -89
- package/dist/v14Deployments.generated.js.map +0 -1
package/dist/aifp1.js
CHANGED
|
@@ -33,13 +33,15 @@
|
|
|
33
33
|
// AiFinPayAgent, but nothing else here does, and a test should be able to drive
|
|
34
34
|
// the whole protocol without an RPC endpoint.
|
|
35
35
|
// ──────────────────────────────────────────────────────────────────────────
|
|
36
|
-
import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
|
|
36
|
+
import { createHash, createPublicKey, randomBytes, verify as verifySignature } from "node:crypto";
|
|
37
|
+
import bs58 from "bs58";
|
|
37
38
|
import { keccak256, parseUnits, stringToHex } from "viem";
|
|
38
|
-
import {
|
|
39
|
+
import { paymentChain, paymentStableAsset } from "./paymentChains.js";
|
|
39
40
|
import { AiFinPayError } from "./errors.js";
|
|
40
|
-
import { validateV14SettlementCall } from "./settlementV14.js";
|
|
41
|
+
import { validateV14SettlementCall, V14SettlementError } from "./settlementV14.js";
|
|
41
42
|
import { SettlementConfirmationPendingError } from "./settlement.js";
|
|
42
43
|
import { settlementHttp, SettlementHttpError } from "./settlementHttp.js";
|
|
44
|
+
import { authorizedSolanaV14Inventory, solanaV14Inventory, validateSolanaV14Call, solanaStableMint, assertPreparedSolanaV14Recovery, readSolanaV14FinalizedFailure, solanaLamportCostUsd, SolanaV14Error, } from "./settlementSolanaV14.js";
|
|
43
45
|
// ── Errors ────────────────────────────────────────────────────────────────
|
|
44
46
|
//
|
|
45
47
|
// Specific classes rather than one message, because the recoveries differ: a
|
|
@@ -74,12 +76,109 @@ export class Aifp1PayError extends Aifp1Error {
|
|
|
74
76
|
this.recovery = recovery;
|
|
75
77
|
}
|
|
76
78
|
}
|
|
79
|
+
/** Canonical failed original transaction reconciled to one fee-only debit. */
|
|
80
|
+
export class Aifp1FinalizedFailureError extends Aifp1PayError {
|
|
81
|
+
feeAmountUsd;
|
|
82
|
+
actualFeeLamports;
|
|
83
|
+
code = "AIFP1_FINALIZED_FAILURE";
|
|
84
|
+
constructor(recovery, feeAmountUsd, actualFeeLamports) {
|
|
85
|
+
super("Original Solana transaction finalized with failure; fee-only debit reconciled without resending", recovery.txRef, recovery.quote.quote_id, recovery);
|
|
86
|
+
this.feeAmountUsd = feeAmountUsd;
|
|
87
|
+
this.actualFeeLamports = actualFeeLamports;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Exact owner/quote binding for a locally selected capped v1.4 payment. */
|
|
91
|
+
export function paymentBudgetBinding(payment, payer, walletIdentity) {
|
|
92
|
+
const call = payment.quote.settlement_call;
|
|
93
|
+
if (call?.splitter_version !== "1.4")
|
|
94
|
+
throw new Aifp1QuoteError("Bound budgets require the original v1.4 call");
|
|
95
|
+
return {
|
|
96
|
+
apiBaseUrl: payment.apiBaseUrl.replace(/\/+$/, ""),
|
|
97
|
+
paymentIssuer: payment.paymentIssuer ?? "https://api.aifinpay.io",
|
|
98
|
+
payer: payment.chain === "solana" ? payer : payer.toLowerCase(),
|
|
99
|
+
...(walletIdentity ? { walletIdentity } : {}),
|
|
100
|
+
...(payment.chain === "solana" && payment.admissionSolUsdPrice !== undefined
|
|
101
|
+
? {
|
|
102
|
+
solanaAdmissionRateUsd: payment.admissionSolUsdPrice,
|
|
103
|
+
solanaMaxFeeLamports: payment.maxFeeLamports,
|
|
104
|
+
solanaTransactionFeeLamports: payment.transactionFeeLamports,
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
merchantId: payment.quote.merchant_id,
|
|
108
|
+
scope: payment.quote.scope,
|
|
109
|
+
resource: payment.quote.resource,
|
|
110
|
+
networkMode: payment.quote.network_mode ?? "live",
|
|
111
|
+
chain: payment.chain === "solana"
|
|
112
|
+
? `solana:${call.network}:${call.contract}:${call.idl_sha256}`
|
|
113
|
+
: authorizedV14Chain(payment.chain),
|
|
114
|
+
asset: payment.asset,
|
|
115
|
+
token: payment.chain === "solana" ? call.args.quote.token : call.args.quote.token.toLowerCase(),
|
|
116
|
+
grossAmount: call.args.quote.grossAmount,
|
|
117
|
+
quoteId: payment.quote.quote_id,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
77
120
|
/** Recover an already settled payment. Never sends an on-chain transaction. */
|
|
78
121
|
export function recoverAifp1Payment(recovery, signer, options = {}) {
|
|
122
|
+
if (recovery.family === "solana") {
|
|
123
|
+
const call = recovery.quote.settlement_call;
|
|
124
|
+
if (!options.solanaNetwork || !options.solanaEnvironment)
|
|
125
|
+
throw new Aifp1QuoteError("Solana recovery requires independent owner environment/network");
|
|
126
|
+
const d = solanaV14Inventory(options.solanaEnvironment, options.solanaNetwork);
|
|
127
|
+
assertPreparedSolanaV14Recovery(call, recovery.solana, d, signer.payerAddress, recovery.quote.quote_id);
|
|
128
|
+
if (recovery.chain !== "solana" ||
|
|
129
|
+
recovery.txRef !== recovery.solana.hash ||
|
|
130
|
+
call.asset !== recovery.asset ||
|
|
131
|
+
recovery.quote.payer !== signer.payerAddress ||
|
|
132
|
+
recovery.quote.accepted_chains.join() !== "solana" ||
|
|
133
|
+
recovery.quote.accepted_assets.join() !== recovery.asset)
|
|
134
|
+
throw new Aifp1QuoteError("Solana recovery purchase mismatch");
|
|
135
|
+
return (async () => {
|
|
136
|
+
const binding = paymentBudgetBinding(recovery, signer.payerAddress, recovery.budgetBindingVersion === 2 ? signer.walletIdentity : undefined);
|
|
137
|
+
if (recovery.budgetReservationId !== undefined) {
|
|
138
|
+
if (!signer.assertReservation || !signer.completeReservation)
|
|
139
|
+
throw new Aifp1QuoteError("Original bound ledger hooks required");
|
|
140
|
+
await signer.assertReservation(recovery.budgetReservationId, recovery.txRef, binding);
|
|
141
|
+
}
|
|
142
|
+
if (signer.solanaRpc && signer.finalizeFailedReservation && recovery.budgetReservationId !== undefined) {
|
|
143
|
+
let fee;
|
|
144
|
+
try {
|
|
145
|
+
solanaLamportCostUsd(0n, recovery.admissionSolUsdPrice);
|
|
146
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(recovery.transactionFeeLamports) ||
|
|
147
|
+
!/^[1-9][0-9]*$/.test(recovery.maxFeeLamports))
|
|
148
|
+
throw new Aifp1QuoteError("Original fee cap metadata is required");
|
|
149
|
+
fee = await readSolanaV14FinalizedFailure(call, recovery.solana, {
|
|
150
|
+
rpc: signer.solanaRpc,
|
|
151
|
+
deployment: d,
|
|
152
|
+
payer: signer.payerAddress,
|
|
153
|
+
orderId: recovery.quote.quote_id,
|
|
154
|
+
transactionFeeLamports: BigInt(recovery.transactionFeeLamports),
|
|
155
|
+
maxFeeLamports: BigInt(recovery.maxFeeLamports),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new Aifp1PayError(error instanceof Error ? error.message : "Failure proof is incomplete; retain original reservation", recovery.txRef, recovery.quote.quote_id, recovery);
|
|
160
|
+
}
|
|
161
|
+
if (fee !== null) {
|
|
162
|
+
const feeUsd = solanaLamportCostUsd(fee, recovery.admissionSolUsdPrice);
|
|
163
|
+
try {
|
|
164
|
+
await signer.finalizeFailedReservation(recovery.budgetReservationId, recovery.txRef, binding, feeUsd);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Aifp1PayError(error instanceof Error ? error.message : "Canonical failure fee journal requires reconciliation", recovery.txRef, recovery.quote.quote_id, recovery);
|
|
168
|
+
}
|
|
169
|
+
throw new Aifp1FinalizedFailureError(recovery, feeUsd, fee);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const paid = await submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, "solana", { paymentIssuer: recovery.paymentIssuer, ...options });
|
|
173
|
+
if (recovery.budgetReservationId !== undefined)
|
|
174
|
+
await signer.completeReservation(recovery.budgetReservationId, recovery.txRef, binding);
|
|
175
|
+
return paid;
|
|
176
|
+
})();
|
|
177
|
+
}
|
|
79
178
|
const chain = authorizedV14Chain(recovery.chain);
|
|
80
179
|
const call = recovery.quote.settlement_call;
|
|
81
180
|
if (chain !== "polygon" && call?.splitter_version !== "1.4") {
|
|
82
|
-
throw new Aifp1QuoteError(
|
|
181
|
+
throw new Aifp1QuoteError(`Recovery on ${chain} requires the original v1.4 settlement call`);
|
|
83
182
|
}
|
|
84
183
|
if (call?.splitter_version === "1.4" &&
|
|
85
184
|
(call.chain !== chain ||
|
|
@@ -89,6 +188,23 @@ export function recoverAifp1Payment(recovery, signer, options = {}) {
|
|
|
89
188
|
!recovery.quote.accepted_assets.includes(recovery.asset))) {
|
|
90
189
|
throw new Aifp1QuoteError("recovery chain or asset disagrees with the saved purchase");
|
|
91
190
|
}
|
|
191
|
+
if (recovery.budgetReservationId !== undefined) {
|
|
192
|
+
if (typeof recovery.budgetReservationId !== "string" ||
|
|
193
|
+
!recovery.budgetReservationId ||
|
|
194
|
+
!/^0x(?:[0-9a-fA-F]{2})+$/.test(recovery.serializedTransaction ?? "") ||
|
|
195
|
+
keccak256(recovery.serializedTransaction) !== recovery.txRef ||
|
|
196
|
+
!signer.assertReservation ||
|
|
197
|
+
!signer.completeReservation) {
|
|
198
|
+
throw new Aifp1QuoteError("Bound budget recovery requires the original signed bytes and ledger hooks");
|
|
199
|
+
}
|
|
200
|
+
const binding = paymentBudgetBinding(recovery, signer.payerAddress, recovery.budgetBindingVersion === 2 ? signer.walletIdentity : undefined);
|
|
201
|
+
return (async () => {
|
|
202
|
+
await signer.assertReservation(recovery.budgetReservationId, recovery.txRef, binding);
|
|
203
|
+
const paid = await submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, chain, { paymentIssuer: recovery.paymentIssuer, ...options });
|
|
204
|
+
await signer.completeReservation(recovery.budgetReservationId, recovery.txRef, binding);
|
|
205
|
+
return paid;
|
|
206
|
+
})();
|
|
207
|
+
}
|
|
92
208
|
return submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, chain, { paymentIssuer: recovery.paymentIssuer, ...options });
|
|
93
209
|
}
|
|
94
210
|
/** The gateway rejected a receipt we believed covered the request. */
|
|
@@ -96,7 +212,9 @@ export class Aifp1ReceiptRejectedError extends Aifp1Error {
|
|
|
96
212
|
}
|
|
97
213
|
export function describeQuote(q) {
|
|
98
214
|
const ns = q.native_settlement;
|
|
99
|
-
const pay = ns
|
|
215
|
+
const pay = ns
|
|
216
|
+
? { amount: formatUnits("total_lamports" in ns ? ns.total_lamports : ns.total_wei, ns.decimals), asset: ns.asset }
|
|
217
|
+
: null;
|
|
100
218
|
// Fee as basis points, from the split the quote already carries. Stated so the
|
|
101
219
|
// agent sees the rate, not just the total — the same reason the 402 does.
|
|
102
220
|
let feeBps = null;
|
|
@@ -485,6 +603,134 @@ function validateCanonicalQuoteEconomics(quote) {
|
|
|
485
603
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} does not implement canonical AIFP-1 gross economics (payer 100%, merchant 99%, protocol 1%, creator 0%)`);
|
|
486
604
|
}
|
|
487
605
|
}
|
|
606
|
+
function validateSolanaQuote(quote, auth, payer, expiryMs, d, historical = false) {
|
|
607
|
+
const call = quote.settlement_call;
|
|
608
|
+
validateSolanaV14Call(call, d, payer, quote.quote_id, historical);
|
|
609
|
+
const asset = auth.asset ?? "SOL", q = call.args.quote, gross = BigInt(q.grossAmount), treasury = gross / 100n;
|
|
610
|
+
if (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/.test(quote.amount) ||
|
|
611
|
+
quote.accepted_chains.join() !== "solana" ||
|
|
612
|
+
quote.accepted_assets.join() !== asset ||
|
|
613
|
+
call.asset !== asset ||
|
|
614
|
+
quote.payer !== payer ||
|
|
615
|
+
q.merchant !== quote.pay_to.solana ||
|
|
616
|
+
q.validUntil !== String(Math.floor(expiryMs / 1000)) ||
|
|
617
|
+
quote.settlement.total_units !== parseUnits(quote.amount, 6).toString())
|
|
618
|
+
throw new Aifp1QuoteError("Solana quote amount/payer/network/merchant binding mismatch");
|
|
619
|
+
if (asset === "SOL") {
|
|
620
|
+
const n = quote.native_settlement;
|
|
621
|
+
if (!n ||
|
|
622
|
+
n.asset !== "SOL" ||
|
|
623
|
+
n.decimals !== 9 ||
|
|
624
|
+
n.settlement_semantics !== "gross-inclusive" ||
|
|
625
|
+
n.total_lamports !== q.grossAmount ||
|
|
626
|
+
n.merchant_lamports !== String(gross - treasury) ||
|
|
627
|
+
n.treasury_lamports !== String(treasury) ||
|
|
628
|
+
n.creator_lamports !== "0" ||
|
|
629
|
+
quote.token_settlement !== undefined ||
|
|
630
|
+
(n.valid_until !== undefined && String(n.valid_until) !== q.validUntil))
|
|
631
|
+
throw new Aifp1QuoteError("SOL9 gross-inclusive amount mismatch");
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const t = quote.token_settlement;
|
|
635
|
+
if (quote.native_settlement !== undefined ||
|
|
636
|
+
!t ||
|
|
637
|
+
t.asset !== asset ||
|
|
638
|
+
t.token !== solanaStableMint(auth.network, asset) ||
|
|
639
|
+
t.decimals !== 6 ||
|
|
640
|
+
t.settlement_semantics !== "gross-inclusive" ||
|
|
641
|
+
t.total_units !== q.grossAmount ||
|
|
642
|
+
t.total_units !== parseUnits(quote.amount, 6).toString() ||
|
|
643
|
+
t.merchant_units !== String(gross - treasury) ||
|
|
644
|
+
t.protocol_fee_units !== String(treasury) ||
|
|
645
|
+
t.creator_units !== "0")
|
|
646
|
+
throw new Aifp1QuoteError("SPL6 token gross-inclusive amount mismatch");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async function settleSolanaBatch(deps, opts, quote, plan, amountUsd, binding, reservation, site, send, markSettled) {
|
|
650
|
+
const auth = opts.solanaV14;
|
|
651
|
+
if (typeof reservation === "string" &&
|
|
652
|
+
(!deps.prepareReservation || !deps.assertReservation || !deps.completeReservation))
|
|
653
|
+
throw new Aifp1QuoteError("Solana capped payments require durable bound-journal hooks");
|
|
654
|
+
let prepared;
|
|
655
|
+
const recoveryFor = (tx) => ({
|
|
656
|
+
family: "solana",
|
|
657
|
+
chain: "solana",
|
|
658
|
+
apiBaseUrl: opts.apiBaseUrl ?? DEFAULT_API_BASE,
|
|
659
|
+
paymentIssuer: opts.paymentIssuer,
|
|
660
|
+
...(deps.walletIdentity ? { budgetBindingVersion: 2 } : {}),
|
|
661
|
+
quote,
|
|
662
|
+
asset: auth.asset ?? "SOL",
|
|
663
|
+
txRef: tx.hash,
|
|
664
|
+
solana: tx,
|
|
665
|
+
reservedAmountUsd: amountUsd,
|
|
666
|
+
admissionSolUsdPrice: binding.solanaAdmissionRateUsd,
|
|
667
|
+
maxFeeLamports: binding.solanaMaxFeeLamports,
|
|
668
|
+
transactionFeeLamports: binding.solanaTransactionFeeLamports,
|
|
669
|
+
...(typeof reservation === "string" ? { budgetReservationId: reservation } : {}),
|
|
670
|
+
});
|
|
671
|
+
let result;
|
|
672
|
+
try {
|
|
673
|
+
result = await deps.settleSolana(plan, async (tx) => {
|
|
674
|
+
assertPreparedSolanaV14Recovery(quote.settlement_call, tx, plan.deployment, deps.payerAddress, quote.quote_id);
|
|
675
|
+
if (typeof reservation === "string")
|
|
676
|
+
await deps.prepareReservation(reservation, tx.hash, binding);
|
|
677
|
+
await auth.onPrepared(recoveryFor(tx));
|
|
678
|
+
prepared = tx;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
if (prepared) {
|
|
683
|
+
markSettled(); // Unknown funds remain reserved even after blockhash expiry.
|
|
684
|
+
if (error instanceof SolanaV14Error &&
|
|
685
|
+
error.code === "SOLANA_V14_TRANSACTION_REVERTED" &&
|
|
686
|
+
error.prepared?.hash === prepared.hash &&
|
|
687
|
+
error.actualFeeLamports !== undefined &&
|
|
688
|
+
error.actualFeeLamports <= BigInt(binding.solanaMaxFeeLamports) &&
|
|
689
|
+
typeof reservation === "string" &&
|
|
690
|
+
deps.finalizeFailedReservation) {
|
|
691
|
+
const feeUsd = solanaLamportCostUsd(error.actualFeeLamports, binding.solanaAdmissionRateUsd);
|
|
692
|
+
try {
|
|
693
|
+
await deps.finalizeFailedReservation(reservation, prepared.hash, binding, feeUsd);
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
throw new Aifp1PayError(error instanceof Error ? error.message : "Canonical failure fee journal requires reconciliation", prepared.hash, quote.quote_id, recoveryFor(prepared));
|
|
697
|
+
}
|
|
698
|
+
throw new Aifp1FinalizedFailureError(recoveryFor(prepared), feeUsd, error.actualFeeLamports);
|
|
699
|
+
}
|
|
700
|
+
throw new Aifp1PayError("Solana transaction prepared; recover its original signature without resending", prepared.hash, quote.quote_id, recoveryFor(prepared));
|
|
701
|
+
}
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
markSettled();
|
|
705
|
+
const recovery = recoveryFor(result);
|
|
706
|
+
try {
|
|
707
|
+
if (typeof reservation === "string")
|
|
708
|
+
await deps.commit(reservation, amountUsd);
|
|
709
|
+
const paid = await submitPayment(deps, recovery.apiBaseUrl, quote, result.hash, recovery.asset, "solana", opts);
|
|
710
|
+
if (typeof reservation === "string")
|
|
711
|
+
await deps.completeReservation(reservation, result.hash, binding);
|
|
712
|
+
deps.cache.put({
|
|
713
|
+
site,
|
|
714
|
+
merchantId: paid.merchant_id,
|
|
715
|
+
receiptId: paid.receipt_id,
|
|
716
|
+
jwt: paid.receipt,
|
|
717
|
+
scope: paid.scope,
|
|
718
|
+
resource: paid.resource,
|
|
719
|
+
unitQuota: paid.unit_quota,
|
|
720
|
+
remaining: paid.unit_quota,
|
|
721
|
+
expiresAt: Date.parse(paid.expires_at),
|
|
722
|
+
amountUsd,
|
|
723
|
+
});
|
|
724
|
+
const response = await send(paid.receipt);
|
|
725
|
+
if (response.status === 402)
|
|
726
|
+
throw new Aifp1ReceiptRejectedError("Gateway rejected paid Solana receipt; recover existing purchase");
|
|
727
|
+
deps.onPaid?.({ merchantId: paid.merchant_id, amountUsd, txRef: result.hash, receiptId: paid.receipt_id });
|
|
728
|
+
return response;
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
throw new Aifp1PayError(error instanceof Error ? error.message : "Solana receipt recovery required", result.hash, quote.quote_id, recovery);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
488
734
|
/** Is this response the AIFP-1 paywall asking for money? */
|
|
489
735
|
function isAifp1Challenge(body) {
|
|
490
736
|
const b = body;
|
|
@@ -510,8 +756,15 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
510
756
|
if (opts.maxAmountUsd !== undefined && (!Number.isFinite(opts.maxAmountUsd) || opts.maxAmountUsd < 0)) {
|
|
511
757
|
throw new Aifp1QuoteError("maxAmountUsd must be nonnegative and finite");
|
|
512
758
|
}
|
|
513
|
-
|
|
514
|
-
|
|
759
|
+
if (opts.v14 && opts.solanaV14)
|
|
760
|
+
throw new Aifp1QuoteError("Only one payment family may be authorized");
|
|
761
|
+
const sol = opts.solanaV14;
|
|
762
|
+
const evmChain = authorizedV14Chain(opts.v14?.chain);
|
|
763
|
+
const chain = sol ? "solana" : evmChain;
|
|
764
|
+
const solDeployment = sol ? authorizedSolanaV14Inventory(sol.environment, sol.network) : undefined;
|
|
765
|
+
if (sol && (typeof sol.maxFeeLamports !== "bigint" || sol.maxFeeLamports <= 0n))
|
|
766
|
+
throw new Aifp1QuoteError("Explicit positive Solana fee plus rent budget required");
|
|
767
|
+
const nativeAsset = sol ? "SOL" : paymentChain(evmChain).native;
|
|
515
768
|
const direct = opts.resourcePathMode === "direct";
|
|
516
769
|
const parsed = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS, opts.resourcePathMode);
|
|
517
770
|
let site = parsed.site;
|
|
@@ -641,36 +894,163 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
641
894
|
const buyBatch = async () => {
|
|
642
895
|
if (opts.maxAmountUsd === 0)
|
|
643
896
|
throw new Aifp1QuoteError("No payment budget remains; cached access can still be used");
|
|
644
|
-
|
|
897
|
+
let validPayer = /^0x[0-9a-fA-F]{40}$/.test(deps.payerAddress || "");
|
|
898
|
+
if (sol) {
|
|
899
|
+
try {
|
|
900
|
+
validPayer =
|
|
901
|
+
bs58.decode(deps.payerAddress).length === 32 &&
|
|
902
|
+
bs58.encode(bs58.decode(deps.payerAddress)) === deps.payerAddress;
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
validPayer = false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (typeof deps.signPaymentAuthorization !== "function" || !validPayer) {
|
|
645
909
|
throw new Aifp1QuoteError("a settlement wallet signer is required to receive a payment receipt");
|
|
646
910
|
}
|
|
647
911
|
// 2. Quote.
|
|
648
|
-
const stableAsset =
|
|
912
|
+
const stableAsset = sol
|
|
913
|
+
? sol.asset && sol.asset !== "SOL"
|
|
914
|
+
? (solanaStableMint(sol.network, sol.asset), sol.asset)
|
|
915
|
+
: null
|
|
916
|
+
: pinnedStableAsset(opts.v14?.asset, evmChain);
|
|
649
917
|
const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
650
918
|
const { scope, resource } = resolveScope(opts, challenge);
|
|
651
|
-
const
|
|
919
|
+
const quoteBody = {
|
|
652
920
|
merchant_id: challenge.merchant_id,
|
|
653
921
|
payer: deps.payerAddress,
|
|
654
922
|
...(resource !== undefined ? { resource } : {}),
|
|
655
923
|
scope,
|
|
656
924
|
units: opts.units ?? defaultUnitsFor(challenge),
|
|
657
925
|
agent_id: deps.agentId,
|
|
658
|
-
...(opts.v14 ? { asset: stableAsset ?? nativeAsset } : {}),
|
|
659
|
-
|
|
660
|
-
|
|
926
|
+
...(opts.v14 || sol ? { asset: stableAsset ?? nativeAsset } : {}),
|
|
927
|
+
...(sol || opts.v14?.chain !== undefined ? { settlement_chain: chain } : {}),
|
|
928
|
+
};
|
|
929
|
+
let admission;
|
|
930
|
+
let admissionBinding;
|
|
931
|
+
let admissionContext;
|
|
932
|
+
if (sol) {
|
|
933
|
+
if (!deps.beginQuoteAdmission ||
|
|
934
|
+
!deps.adoptQuoteAdmission ||
|
|
935
|
+
!deps.closeQuoteAdmission ||
|
|
936
|
+
!deps.quoteOwnerContext)
|
|
937
|
+
throw new Aifp1QuoteError("Solana requires a durable quote-admission ledger and independent owner context");
|
|
938
|
+
admissionBinding = {
|
|
939
|
+
apiBaseUrl: apiBase,
|
|
940
|
+
paymentIssuer: opts.paymentIssuer ?? DEFAULT_API_BASE,
|
|
941
|
+
payer: deps.payerAddress,
|
|
942
|
+
merchantId: challenge.merchant_id,
|
|
943
|
+
scope,
|
|
944
|
+
resource: resource ?? "*",
|
|
945
|
+
networkMode: sol.network === "devnet" ? "test" : "live",
|
|
946
|
+
chain: `solana:${sol.network}:${solDeployment.programId}:${solDeployment.idl.sha256}`,
|
|
947
|
+
asset: stableAsset ?? nativeAsset,
|
|
948
|
+
token: stableAsset ? solanaStableMint(sol.network, stableAsset) : "11111111111111111111111111111111",
|
|
949
|
+
grossAmount: "0",
|
|
950
|
+
quoteId: "quote-admission",
|
|
951
|
+
quoteAdmissionVersion: "1",
|
|
952
|
+
...(deps.walletIdentity ? { walletIdentity: deps.walletIdentity } : {}),
|
|
953
|
+
};
|
|
954
|
+
admissionContext = JSON.stringify([
|
|
955
|
+
deps.quoteOwnerContext,
|
|
956
|
+
sol.environment,
|
|
957
|
+
sol.network,
|
|
958
|
+
solDeployment.programId,
|
|
959
|
+
solDeployment.idl.sha256,
|
|
960
|
+
sol.maxFeeLamports.toString(),
|
|
961
|
+
opts.maxAmountUsd ?? null,
|
|
962
|
+
new URL(url).origin,
|
|
963
|
+
{ ...quoteBody, units: opts.units ?? null },
|
|
964
|
+
]);
|
|
965
|
+
admission = await deps.beginQuoteAdmission(admissionBinding, admissionContext, async () => {
|
|
966
|
+
const authorization = {
|
|
967
|
+
payer: deps.payerAddress,
|
|
968
|
+
network: sol.network,
|
|
969
|
+
network_mode: sol.network === "devnet" ? "test" : "live",
|
|
970
|
+
nonce: randomBytes(32).toString("hex"),
|
|
971
|
+
expires_at: Math.floor(Date.now() / 1000) + 240,
|
|
972
|
+
};
|
|
973
|
+
const statement = solanaQuoteAuthorizationMessage(quoteBody, authorization, opts.paymentIssuer ?? DEFAULT_API_BASE);
|
|
974
|
+
const signature = await deps.signPaymentAuthorization(statement);
|
|
975
|
+
return {
|
|
976
|
+
requestBody: JSON.stringify({ ...quoteBody, quote_authorization: { ...authorization, signature } }),
|
|
977
|
+
statement,
|
|
978
|
+
};
|
|
979
|
+
});
|
|
980
|
+
// The file contains no authority to change the owner-selected request or key.
|
|
981
|
+
try {
|
|
982
|
+
const body = JSON.parse(admission.requestBody), auth = body.quote_authorization;
|
|
983
|
+
const expected = { ...quoteBody, ...(opts.units === undefined ? { units: body.units } : {}) };
|
|
984
|
+
const { quote_authorization: _auth, ...originalBody } = body;
|
|
985
|
+
const statement = solanaQuoteAuthorizationMessage(body, auth, opts.paymentIssuer ?? DEFAULT_API_BASE);
|
|
986
|
+
const publicKey = createPublicKey({
|
|
987
|
+
key: Buffer.concat([
|
|
988
|
+
Buffer.from("302a300506032b6570032100", "hex"),
|
|
989
|
+
Buffer.from(bs58.decode(deps.payerAddress)),
|
|
990
|
+
]),
|
|
991
|
+
format: "der",
|
|
992
|
+
type: "spki",
|
|
993
|
+
});
|
|
994
|
+
if (admission.phase !== "pending" ||
|
|
995
|
+
JSON.stringify(originalBody) !== JSON.stringify(expected) ||
|
|
996
|
+
auth.payer !== deps.payerAddress ||
|
|
997
|
+
auth.network !== sol.network ||
|
|
998
|
+
auth.network_mode !== admissionBinding.networkMode ||
|
|
999
|
+
!/^[0-9a-f]{64}$/.test(auth.nonce) ||
|
|
1000
|
+
!Number.isSafeInteger(auth.expires_at) ||
|
|
1001
|
+
auth.expires_at <= 0 ||
|
|
1002
|
+
!Number.isSafeInteger(body.units) ||
|
|
1003
|
+
body.units <= 0 ||
|
|
1004
|
+
statement !== admission.statement ||
|
|
1005
|
+
bs58.decode(auth.signature).length !== 64 ||
|
|
1006
|
+
bs58.encode(bs58.decode(auth.signature)) !== auth.signature ||
|
|
1007
|
+
!verifySignature(null, Buffer.from(statement), publicKey, bs58.decode(auth.signature)))
|
|
1008
|
+
throw new Error("binding");
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
throw new Aifp1QuoteError("Stored quote admission does not match the exact owner-authorized request");
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const quote = admission?.quoteJson
|
|
1015
|
+
? JSON.parse(admission.quoteJson)
|
|
1016
|
+
: await requestQuote(deps, apiBase, admission?.requestBody ?? quoteBody, opts.apiTimeoutMs, admission
|
|
1017
|
+
? async (status, detail) => {
|
|
1018
|
+
const body = JSON.parse(admission.requestBody), auth = body.quote_authorization;
|
|
1019
|
+
if (status === 410 &&
|
|
1020
|
+
detail?.error === "AIFP-410-SOLANA" &&
|
|
1021
|
+
detail.reason === "solana_quote_authorization_expired" &&
|
|
1022
|
+
detail.admission_status === "not_admitted" &&
|
|
1023
|
+
auth.expires_at <= Math.floor(Date.now() / 1000) &&
|
|
1024
|
+
detail.authorization_nonce === auth.nonce &&
|
|
1025
|
+
detail.authorization_statement_hash ===
|
|
1026
|
+
createHash("sha256").update(admission.statement).digest("hex") &&
|
|
1027
|
+
detail.network === auth.network &&
|
|
1028
|
+
detail.network_mode === auth.network_mode &&
|
|
1029
|
+
detail.payer === deps.payerAddress)
|
|
1030
|
+
await deps.closeQuoteAdmission(admission.id, admissionBinding, admissionContext, "not-admitted");
|
|
1031
|
+
}
|
|
1032
|
+
: undefined);
|
|
1033
|
+
if (admission && !admission.quoteJson) {
|
|
1034
|
+
const quoteJson = JSON.stringify(quote);
|
|
1035
|
+
await deps.adoptQuoteAdmission(admission.id, admissionBinding, admissionContext, quoteJson);
|
|
1036
|
+
admission.quoteJson = quoteJson;
|
|
1037
|
+
}
|
|
1038
|
+
if (quote.payer &&
|
|
1039
|
+
(sol ? quote.payer !== deps.payerAddress : quote.payer.toLowerCase() !== deps.payerAddress.toLowerCase())) {
|
|
661
1040
|
throw new Aifp1QuoteError("quote names a different paying wallet");
|
|
662
1041
|
}
|
|
663
1042
|
if (quote.payment_authorization && quote.payment_authorization.scheme !== "wallet-signature-v1") {
|
|
664
1043
|
throw new Aifp1QuoteError("unsupported receipt authorization scheme");
|
|
665
1044
|
}
|
|
666
1045
|
trustedPaymentIssuer(quote, opts.paymentIssuer);
|
|
667
|
-
const merchantWallet = quote.pay_to[chain] ?? quote.pay_to.evm;
|
|
668
|
-
if (opts.v14 &&
|
|
1046
|
+
const merchantWallet = sol ? quote.pay_to.solana : (quote.pay_to[chain] ?? quote.pay_to.evm);
|
|
1047
|
+
if ((opts.v14 || sol) &&
|
|
669
1048
|
(quote.accepted_chains.length !== 1 ||
|
|
670
1049
|
quote.accepted_chains[0] !== chain ||
|
|
671
1050
|
quote.settlement_call?.chain !== chain ||
|
|
672
1051
|
!merchantWallet ||
|
|
673
|
-
(
|
|
1052
|
+
(!sol &&
|
|
1053
|
+
quote.pay_to[chain] !== undefined &&
|
|
674
1054
|
quote.pay_to.evm !== undefined &&
|
|
675
1055
|
quote.pay_to[chain].toLowerCase() !== quote.pay_to.evm.toLowerCase()))) {
|
|
676
1056
|
throw new Aifp1QuoteError("quote does not match the independently selected payment chain");
|
|
@@ -680,19 +1060,28 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
680
1060
|
if (quote.merchant_id !== challenge.merchant_id) {
|
|
681
1061
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} is for merchant ${quote.merchant_id} but ${site} refused as ${challenge.merchant_id} — refusing to pay`);
|
|
682
1062
|
}
|
|
683
|
-
if (opts.v14 &&
|
|
1063
|
+
if ((opts.v14 || sol) &&
|
|
684
1064
|
(quote.scope !== scope ||
|
|
685
1065
|
quote.resource !== (resource ?? "*") ||
|
|
686
1066
|
quote.currency !== "USD" ||
|
|
687
|
-
(quote.network_mode ?? "live") !== "live" ||
|
|
1067
|
+
(quote.network_mode ?? "live") !== (sol?.network === "devnet" ? "test" : "live") ||
|
|
688
1068
|
!Number.isSafeInteger(quote.unit_quota) ||
|
|
689
1069
|
quote.unit_quota <= 0)) {
|
|
690
1070
|
throw new Aifp1QuoteError("v1.4 quote does not match the requested scope, resource or live currency");
|
|
691
1071
|
}
|
|
692
1072
|
validateCanonicalQuoteEconomics(quote);
|
|
693
1073
|
const quoteExpiryMs = Date.parse(quote.expires_at);
|
|
694
|
-
if (!Number.isFinite(quoteExpiryMs)
|
|
695
|
-
throw new Aifp1QuoteError(
|
|
1074
|
+
if (!Number.isFinite(quoteExpiryMs))
|
|
1075
|
+
throw new Aifp1QuoteError("Quote has invalid expiry; retain its admission");
|
|
1076
|
+
if (quoteExpiryMs <= Date.now()) {
|
|
1077
|
+
if (sol && admission && !admission.wasReserved) {
|
|
1078
|
+
validateSolanaQuote(quote, sol, deps.payerAddress, quoteExpiryMs, solDeployment, true);
|
|
1079
|
+
if (!deps.verifyHistoricalSolanaQuote)
|
|
1080
|
+
throw new Aifp1QuoteError("Historical quote signer verification is required");
|
|
1081
|
+
await deps.verifyHistoricalSolanaQuote(quote.settlement_call, sol, quote.quote_id);
|
|
1082
|
+
await deps.closeQuoteAdmission(admission.id, admissionBinding, admissionContext, "expired-unbroadcast", admission.quoteJson);
|
|
1083
|
+
}
|
|
1084
|
+
throw new Aifp1QuoteError(`quote ${quote.quote_id} is expired; no transaction or new quote was submitted`);
|
|
696
1085
|
}
|
|
697
1086
|
// 3. Budget. The quote states the batch total in USD, so both caps are
|
|
698
1087
|
// checked against the real figure, before anything is signed.
|
|
@@ -700,8 +1089,43 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
700
1089
|
if (!Number.isFinite(amountUsd) || amountUsd < 0) {
|
|
701
1090
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
|
|
702
1091
|
}
|
|
703
|
-
|
|
704
|
-
|
|
1092
|
+
let solPlan;
|
|
1093
|
+
let solRate;
|
|
1094
|
+
if (sol) {
|
|
1095
|
+
validateSolanaQuote(quote, sol, deps.payerAddress, quoteExpiryMs, solDeployment);
|
|
1096
|
+
const price = typeof opts.nativeUsdPrice === "function" ? await opts.nativeUsdPrice() : opts.nativeUsdPrice;
|
|
1097
|
+
const age = price ? Date.now() - price.observedAtMs : NaN;
|
|
1098
|
+
if (!price ||
|
|
1099
|
+
!Number.isFinite(price.usd) ||
|
|
1100
|
+
price.usd <= 0 ||
|
|
1101
|
+
!Number.isFinite(age) ||
|
|
1102
|
+
age < -5000 ||
|
|
1103
|
+
age > 60000)
|
|
1104
|
+
throw new Aifp1QuoteError("Fresh independent SOL/USD price required for gross and fee/rent budgeting");
|
|
1105
|
+
solRate = String(price.usd);
|
|
1106
|
+
solanaLamportCostUsd(0n, solRate); // Exact finite independently sourced rate.
|
|
1107
|
+
const gross = BigInt(quote.settlement_call.args.quote.grossAmount);
|
|
1108
|
+
if (!stableAsset) {
|
|
1109
|
+
const debitUsd = solanaLamportCostUsd(gross, solRate);
|
|
1110
|
+
const quotedRate = Number(quote.native_settlement?.rate_usd);
|
|
1111
|
+
if (!Number.isFinite(debitUsd) ||
|
|
1112
|
+
debitUsd <= 0 ||
|
|
1113
|
+
!Number.isFinite(quotedRate) ||
|
|
1114
|
+
quotedRate <= 0 ||
|
|
1115
|
+
Math.abs(debitUsd - amountUsd) > Math.max(amountUsd * 0.02, 1e-6) ||
|
|
1116
|
+
Math.abs(solanaLamportCostUsd(gross, String(quotedRate)) - amountUsd) > Math.max(amountUsd * 0.02, 1e-6))
|
|
1117
|
+
throw new Aifp1QuoteError("SOL gross disagrees with independent/quoted USD rate");
|
|
1118
|
+
amountUsd = Math.max(amountUsd, Math.ceil(debitUsd * 1e6) / 1e6);
|
|
1119
|
+
}
|
|
1120
|
+
if (!deps.prepareSolana || !deps.settleSolana)
|
|
1121
|
+
throw new Aifp1SettlementUnsupportedError("Solana executor unavailable");
|
|
1122
|
+
solPlan = await deps.prepareSolana(quote.settlement_call, sol, quote.quote_id);
|
|
1123
|
+
if (solPlan.feeRentLamports < 0n || solPlan.feeRentLamports > sol.maxFeeLamports)
|
|
1124
|
+
throw new Aifp1QuoteError("Solana fee/rent cap exceeded");
|
|
1125
|
+
amountUsd += solanaLamportCostUsd(solPlan.feeRentLamports, solRate);
|
|
1126
|
+
}
|
|
1127
|
+
else if (stableAsset) {
|
|
1128
|
+
validateStableV14Quote(quote, stableAsset, deps.payerAddress, quoteExpiryMs, evmChain);
|
|
705
1129
|
}
|
|
706
1130
|
else if (opts.settlementPin || opts.v14) {
|
|
707
1131
|
const price = typeof opts.nativeUsdPrice === "function" ? await opts.nativeUsdPrice() : opts.nativeUsdPrice;
|
|
@@ -729,7 +1153,8 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
729
1153
|
amountUsd = Math.max(amountUsd, debitUsd);
|
|
730
1154
|
const call = quote.settlement_call;
|
|
731
1155
|
const args = call?.args;
|
|
732
|
-
if (
|
|
1156
|
+
if (quote.token_settlement !== undefined ||
|
|
1157
|
+
native.asset !== nativeAsset ||
|
|
733
1158
|
native.decimals !== 18 ||
|
|
734
1159
|
(opts.v14
|
|
735
1160
|
? quote.accepted_assets.length !== 1 || quote.accepted_assets[0] !== nativeAsset
|
|
@@ -773,11 +1198,45 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
773
1198
|
}
|
|
774
1199
|
if (!deps.checkPerCall(amountUsd))
|
|
775
1200
|
return null;
|
|
776
|
-
const
|
|
1201
|
+
const budgetContext = {
|
|
1202
|
+
apiBaseUrl: apiBase,
|
|
1203
|
+
quote,
|
|
1204
|
+
asset: stableAsset ?? nativeAsset,
|
|
1205
|
+
chain,
|
|
1206
|
+
paymentIssuer: opts.paymentIssuer,
|
|
1207
|
+
...(sol
|
|
1208
|
+
? {
|
|
1209
|
+
admissionSolUsdPrice: solRate,
|
|
1210
|
+
maxFeeLamports: sol.maxFeeLamports.toString(),
|
|
1211
|
+
transactionFeeLamports: solPlan.transactionFeeLamports.toString(),
|
|
1212
|
+
}
|
|
1213
|
+
: {}),
|
|
1214
|
+
};
|
|
1215
|
+
const binding = opts.v14 || sol ? paymentBudgetBinding(budgetContext, deps.payerAddress, deps.walletIdentity) : undefined;
|
|
1216
|
+
const reservation = await deps.reserveDaily(amountUsd, binding, admission?.id);
|
|
777
1217
|
if (reservation === "skip")
|
|
778
1218
|
return null;
|
|
779
1219
|
let settled = false;
|
|
1220
|
+
let preparedTx;
|
|
1221
|
+
const recoveryFor = (txRef) => ({
|
|
1222
|
+
...budgetContext,
|
|
1223
|
+
chain: evmChain,
|
|
1224
|
+
txRef,
|
|
1225
|
+
...(deps.walletIdentity ? { budgetBindingVersion: 2 } : {}),
|
|
1226
|
+
...(binding && typeof reservation === "string" ? { budgetReservationId: reservation } : {}),
|
|
1227
|
+
...(preparedTx?.hash === txRef ? { serializedTransaction: preparedTx.serializedTransaction } : {}),
|
|
1228
|
+
});
|
|
780
1229
|
try {
|
|
1230
|
+
if (sol) {
|
|
1231
|
+
return await settleSolanaBatch(deps, opts, quote, solPlan, amountUsd, binding, reservation, site, send, () => {
|
|
1232
|
+
settled = true;
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
if (binding &&
|
|
1236
|
+
typeof reservation === "string" &&
|
|
1237
|
+
(!deps.prepareReservation || !deps.assertReservation || !deps.completeReservation)) {
|
|
1238
|
+
throw new Aifp1QuoteError("Capped v1.4 payments require durable bound-journal ledger hooks before broadcast");
|
|
1239
|
+
}
|
|
781
1240
|
// 4. Settle on-chain, then exchange the tx for a receipt.
|
|
782
1241
|
let settleParams;
|
|
783
1242
|
let paidAsset;
|
|
@@ -866,41 +1325,31 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
866
1325
|
...(opts.v14
|
|
867
1326
|
? {
|
|
868
1327
|
onPrepared: async (tx) => {
|
|
1328
|
+
if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(tx.serializedTransaction) ||
|
|
1329
|
+
keccak256(tx.serializedTransaction) !== tx.hash) {
|
|
1330
|
+
throw new Aifp1QuoteError("Prepared transaction hash disagrees with its signed bytes");
|
|
1331
|
+
}
|
|
1332
|
+
if (binding && typeof reservation === "string")
|
|
1333
|
+
await deps.prepareReservation(reservation, tx.hash, binding);
|
|
869
1334
|
await opts.v14.onPrepared({
|
|
870
|
-
|
|
871
|
-
quote,
|
|
872
|
-
txRef: tx.hash,
|
|
873
|
-
asset: paidAsset,
|
|
874
|
-
chain,
|
|
875
|
-
paymentIssuer: opts.paymentIssuer,
|
|
1335
|
+
...recoveryFor(tx.hash),
|
|
876
1336
|
serializedTransaction: tx.serializedTransaction,
|
|
877
1337
|
});
|
|
1338
|
+
preparedTx = tx;
|
|
878
1339
|
},
|
|
879
1340
|
}
|
|
880
1341
|
: {}),
|
|
881
1342
|
});
|
|
882
1343
|
}
|
|
883
1344
|
catch (error) {
|
|
884
|
-
|
|
1345
|
+
const pending = error instanceof SettlementConfirmationPendingError && error.stage === "settlement";
|
|
1346
|
+
const provenRevert = error instanceof V14SettlementError && error.code === "V14_TRANSACTION_REVERTED";
|
|
1347
|
+
if (pending || (preparedTx && !provenRevert)) {
|
|
885
1348
|
// Treat unknown confirmation as spent until reconciliation. Releasing
|
|
886
1349
|
// the reservation here would let a retry spend the same budget again.
|
|
887
1350
|
settled = true;
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
quote,
|
|
891
|
-
txRef: error.txHash,
|
|
892
|
-
asset: paidAsset,
|
|
893
|
-
chain,
|
|
894
|
-
paymentIssuer: opts.paymentIssuer,
|
|
895
|
-
};
|
|
896
|
-
try {
|
|
897
|
-
if (typeof reservation === "string")
|
|
898
|
-
await deps.commit(reservation, amountUsd);
|
|
899
|
-
}
|
|
900
|
-
catch {
|
|
901
|
-
throw new Aifp1PayError("payment broadcast; confirmation and budget reconciliation required", error.txHash, quote.quote_id, recovery);
|
|
902
|
-
}
|
|
903
|
-
throw new Aifp1PayError("payment broadcast; recover its confirmation and receipt without paying again", error.txHash, quote.quote_id, recovery);
|
|
1351
|
+
const hash = pending ? error.txHash : preparedTx.hash;
|
|
1352
|
+
throw new Aifp1PayError("payment broadcast; recover its confirmation and receipt without paying again", hash, quote.quote_id, recoveryFor(hash));
|
|
904
1353
|
}
|
|
905
1354
|
throw error;
|
|
906
1355
|
}
|
|
@@ -913,16 +1362,17 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
913
1362
|
await deps.commit(reservation, amountUsd);
|
|
914
1363
|
}
|
|
915
1364
|
catch {
|
|
916
|
-
throw new Aifp1PayError("payment settled; budget reconciliation and receipt recovery required", txRef, quote.quote_id,
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1365
|
+
throw new Aifp1PayError("payment settled; budget reconciliation and receipt recovery required", txRef, quote.quote_id, recoveryFor(txRef));
|
|
1366
|
+
}
|
|
1367
|
+
let paid;
|
|
1368
|
+
try {
|
|
1369
|
+
paid = await submitPayment(deps, apiBase, quote, txRef, paidAsset, chain, opts);
|
|
1370
|
+
if (binding && typeof reservation === "string")
|
|
1371
|
+
await deps.completeReservation(reservation, txRef, binding);
|
|
1372
|
+
}
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
throw new Aifp1PayError(error instanceof Error ? error.message : "Payment receipt/budget recovery required", txRef, quote.quote_id, recoveryFor(txRef));
|
|
924
1375
|
}
|
|
925
|
-
const paid = await submitPayment(deps, apiBase, quote, txRef, paidAsset, chain, opts);
|
|
926
1376
|
// 6. Keep the batch. This is the whole point of the design: the next call
|
|
927
1377
|
// this receipt covers costs a header, not a transaction.
|
|
928
1378
|
const entry = {
|
|
@@ -960,14 +1410,7 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
960
1410
|
receiptResp = await send(paid.receipt);
|
|
961
1411
|
}
|
|
962
1412
|
catch {
|
|
963
|
-
throw new Aifp1PayError("payment settled and receipt cached; content request failed, retry without paying again", txRef, quote.quote_id,
|
|
964
|
-
apiBaseUrl: apiBase,
|
|
965
|
-
quote,
|
|
966
|
-
txRef,
|
|
967
|
-
asset: paidAsset,
|
|
968
|
-
chain,
|
|
969
|
-
paymentIssuer: opts.paymentIssuer,
|
|
970
|
-
});
|
|
1413
|
+
throw new Aifp1PayError("payment settled and receipt cached; content request failed, retry without paying again", txRef, quote.quote_id, recoveryFor(txRef));
|
|
971
1414
|
}
|
|
972
1415
|
if (receiptResp.status === 402) {
|
|
973
1416
|
throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with settled receipt ${paid.receipt_id} (tx ${txRef}); recover the existing payment`);
|
|
@@ -986,8 +1429,7 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
986
1429
|
return receiptResp;
|
|
987
1430
|
}
|
|
988
1431
|
finally {
|
|
989
|
-
//
|
|
990
|
-
// waiting for the reservation to expire — same rule as call().
|
|
1432
|
+
// Only a proven prebroadcast failure/reverted settlement gives the cap back.
|
|
991
1433
|
if (typeof reservation === "string" && !settled)
|
|
992
1434
|
await deps.release(reservation);
|
|
993
1435
|
}
|
|
@@ -1005,26 +1447,25 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
1005
1447
|
function authorizedV14Chain(chain) {
|
|
1006
1448
|
if (chain === undefined)
|
|
1007
1449
|
return "polygon";
|
|
1008
|
-
if (chain
|
|
1450
|
+
if (!paymentChain(chain))
|
|
1009
1451
|
throw new Aifp1QuoteError(`unsupported v1.4 chain "${chain}"`);
|
|
1010
1452
|
return chain;
|
|
1011
1453
|
}
|
|
1012
1454
|
/** Native by default; stablecoin symbols must be pinned for the selected chain. */
|
|
1013
1455
|
function pinnedStableAsset(asset, chain) {
|
|
1014
|
-
if (asset === undefined || asset === (chain
|
|
1456
|
+
if (asset === undefined || asset === paymentChain(chain).native)
|
|
1015
1457
|
return null;
|
|
1016
|
-
const pinned =
|
|
1458
|
+
const pinned = paymentStableAsset(chain, asset);
|
|
1017
1459
|
if (!pinned) {
|
|
1018
|
-
throw new Aifp1QuoteError(`v14.asset "${asset}" is not a stablecoin pinned for ${chain} v1.4
|
|
1019
|
-
`(${(V14_DEPLOYMENTS[chain]?.splitter.assets ?? []).map((a) => a.symbol).join(", ") || "none"})`);
|
|
1460
|
+
throw new Aifp1QuoteError(`v14.asset "${asset}" is not a stablecoin pinned for ${chain} v1.4`);
|
|
1020
1461
|
}
|
|
1021
1462
|
return pinned.symbol;
|
|
1022
1463
|
}
|
|
1023
1464
|
/**
|
|
1024
1465
|
* Check a v1.4 stablecoin quote before any budget is reserved. A token quote
|
|
1025
1466
|
* has no FX to cross-check, so the binding is exact instead: the signed gross
|
|
1026
|
-
* equals the
|
|
1027
|
-
*
|
|
1467
|
+
* equals the pinned-decimal conversion of the stated USD micro-dollars;
|
|
1468
|
+
* optional token metadata is checked in exact token units; the approval is for
|
|
1028
1469
|
* exactly that gross; and the quote accepts nothing else.
|
|
1029
1470
|
*/
|
|
1030
1471
|
function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
|
|
@@ -1034,17 +1475,37 @@ function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
|
|
|
1034
1475
|
}
|
|
1035
1476
|
const signed = call;
|
|
1036
1477
|
validateV14SettlementCall(signed, { orderId: quote.quote_id, payer });
|
|
1037
|
-
const
|
|
1478
|
+
const pin = paymentStableAsset(chain, asset);
|
|
1479
|
+
const token = pin?.address;
|
|
1038
1480
|
const merchant = quote.pay_to[chain] ?? quote.pay_to.evm;
|
|
1039
1481
|
const q = signed.args.quote;
|
|
1040
1482
|
let micro;
|
|
1041
1483
|
try {
|
|
1484
|
+
if (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/.test(String(quote.amount)))
|
|
1485
|
+
throw new Error("non-exact USD amount");
|
|
1042
1486
|
micro = parseUnits(String(quote.amount), 6);
|
|
1043
1487
|
}
|
|
1044
1488
|
catch {
|
|
1045
1489
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
|
|
1046
1490
|
}
|
|
1047
1491
|
const units = quote.settlement?.total_units;
|
|
1492
|
+
const gross = pin ? micro * 10n ** BigInt(pin.decimals - 6) : 0n;
|
|
1493
|
+
const metadata = quote.token_settlement;
|
|
1494
|
+
// Split the actual token gross, not the rounded legacy micro-dollar legs.
|
|
1495
|
+
const treasury = gross / 100n;
|
|
1496
|
+
if ((pin?.decimals === 18 && metadata === undefined) ||
|
|
1497
|
+
(metadata !== undefined &&
|
|
1498
|
+
(!metadata ||
|
|
1499
|
+
typeof metadata !== "object" ||
|
|
1500
|
+
metadata.asset !== asset ||
|
|
1501
|
+
metadata.token?.toLowerCase() !== token?.toLowerCase() ||
|
|
1502
|
+
metadata.decimals !== pin?.decimals ||
|
|
1503
|
+
metadata.settlement_semantics !== "gross-inclusive" ||
|
|
1504
|
+
metadata.total_units !== String(gross) ||
|
|
1505
|
+
metadata.merchant_units !== String(gross - treasury) ||
|
|
1506
|
+
metadata.protocol_fee_units !== String(treasury) ||
|
|
1507
|
+
metadata.creator_units !== "0")))
|
|
1508
|
+
throw new Aifp1QuoteError("token_settlement disagrees with pinned token, decimals or exact gross-inclusive split");
|
|
1048
1509
|
if (signed.chain !== chain ||
|
|
1049
1510
|
signed.asset !== asset ||
|
|
1050
1511
|
signed.route !== "merchant-aifp1" ||
|
|
@@ -1058,8 +1519,9 @@ function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
|
|
|
1058
1519
|
quote.accepted_assets.length !== 1 ||
|
|
1059
1520
|
quote.accepted_assets[0] !== asset ||
|
|
1060
1521
|
units === undefined ||
|
|
1061
|
-
|
|
1062
|
-
|
|
1522
|
+
units !== String(micro) ||
|
|
1523
|
+
q.grossAmount !== String(gross) ||
|
|
1524
|
+
gross >= 2n ** 256n ||
|
|
1063
1525
|
micro <= 0n ||
|
|
1064
1526
|
!signed.approval ||
|
|
1065
1527
|
signed.approval.token.toLowerCase() !== token.toLowerCase() ||
|
|
@@ -1069,20 +1531,28 @@ function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
|
|
|
1069
1531
|
throw new Aifp1QuoteError("signed v1.4 stablecoin call disagrees with the requested asset, merchant, amount, approval or expiry");
|
|
1070
1532
|
}
|
|
1071
1533
|
}
|
|
1072
|
-
async function requestQuote(deps, apiBase, body, timeoutMs) {
|
|
1534
|
+
async function requestQuote(deps, apiBase, body, timeoutMs, onRefusal) {
|
|
1073
1535
|
let r;
|
|
1074
1536
|
let text;
|
|
1075
1537
|
try {
|
|
1076
1538
|
({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/quote`, {
|
|
1077
1539
|
method: "POST",
|
|
1078
1540
|
headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
|
|
1079
|
-
body: JSON.stringify(body),
|
|
1541
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
1080
1542
|
}, timeoutMs));
|
|
1081
1543
|
}
|
|
1082
1544
|
catch (e) {
|
|
1083
1545
|
throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
|
|
1084
1546
|
}
|
|
1085
1547
|
if (!r.ok) {
|
|
1548
|
+
let detail = null;
|
|
1549
|
+
try {
|
|
1550
|
+
detail = JSON.parse(text);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
/* Unknown response keeps the admission. */
|
|
1554
|
+
}
|
|
1555
|
+
await onRefusal?.(r.status, detail);
|
|
1086
1556
|
// The server's own detail is the useful part — a scope refused for a
|
|
1087
1557
|
// non-flat-rated tier, a batch under the minimum, an owner policy — and
|
|
1088
1558
|
// none of that is guessable from the status alone.
|
|
@@ -1100,6 +1570,28 @@ async function requestQuote(deps, apiBase, body, timeoutMs) {
|
|
|
1100
1570
|
}
|
|
1101
1571
|
return quote;
|
|
1102
1572
|
}
|
|
1573
|
+
/** Sign only this locally constructed request; a server cannot choose the
|
|
1574
|
+
* ownership statement. Retry the same body/nonce after an uncertain response. */
|
|
1575
|
+
export function solanaQuoteAuthorizationMessage(body, auth, issuer) {
|
|
1576
|
+
return JSON.stringify([
|
|
1577
|
+
"AiFinPay quote authorization v1",
|
|
1578
|
+
issuer,
|
|
1579
|
+
auth.network,
|
|
1580
|
+
auth.network_mode,
|
|
1581
|
+
body.merchant_id,
|
|
1582
|
+
body.resource ?? null,
|
|
1583
|
+
body.tier ?? "standard",
|
|
1584
|
+
body.requests ?? null,
|
|
1585
|
+
body.units ?? null,
|
|
1586
|
+
body.currency ?? "USD",
|
|
1587
|
+
body.scope ?? "exact",
|
|
1588
|
+
"solana",
|
|
1589
|
+
body.asset ?? "SOL",
|
|
1590
|
+
auth.payer,
|
|
1591
|
+
auth.nonce,
|
|
1592
|
+
auth.expires_at,
|
|
1593
|
+
]);
|
|
1594
|
+
}
|
|
1103
1595
|
// ── /v1/pay ───────────────────────────────────────────────────────────────
|
|
1104
1596
|
/**
|
|
1105
1597
|
* Exchange a settled transaction for a receipt.
|
|
@@ -1128,7 +1620,7 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, chain, opts) {
|
|
|
1128
1620
|
if (attempt > 0 && Date.now() >= deadline)
|
|
1129
1621
|
throw failure("payment confirmation deadline elapsed; recover the existing transaction");
|
|
1130
1622
|
const expiresAt = Math.floor(Date.now() / 1000) + 240;
|
|
1131
|
-
const payer = deps.payerAddress.toLowerCase();
|
|
1623
|
+
const payer = chain === "solana" ? deps.payerAddress : deps.payerAddress.toLowerCase();
|
|
1132
1624
|
// Construct this locally; never sign an arbitrary server-provided message.
|
|
1133
1625
|
const message = paymentAuthorizationMessage({
|
|
1134
1626
|
quote,
|
|
@@ -1244,14 +1736,20 @@ async function verifyPaidReceipt(paid, quote, asset, chain, txRef, payer, issuer
|
|
|
1244
1736
|
if (claims.iss !== issuer ||
|
|
1245
1737
|
claims.aud !== quote.merchant_id ||
|
|
1246
1738
|
typeof claims.sub !== "string" ||
|
|
1247
|
-
claims.sub.toLowerCase() !== payer.toLowerCase() ||
|
|
1739
|
+
(chain === "solana" ? claims.sub !== payer : claims.sub.toLowerCase() !== payer.toLowerCase()) ||
|
|
1248
1740
|
claims.tx_ref !== txRef ||
|
|
1249
1741
|
claims.scope !== quote.scope ||
|
|
1250
1742
|
claims.resource !== quote.resource ||
|
|
1251
1743
|
claims.chain !== chain ||
|
|
1252
|
-
|
|
1744
|
+
(chain === "solana" &&
|
|
1745
|
+
(claims.network !== quote.settlement_call.network ||
|
|
1746
|
+
claims.program !== quote.settlement_call.contract ||
|
|
1747
|
+
paid.network !== claims.network ||
|
|
1748
|
+
paid.program !== claims.program)) ||
|
|
1749
|
+
typeof claims.asset !== "string" ||
|
|
1750
|
+
claims.asset.toUpperCase() !== asset.toUpperCase() ||
|
|
1253
1751
|
claims.currency !== "USD" ||
|
|
1254
|
-
(claims.network_mode ?? "live") !== "live" ||
|
|
1752
|
+
(claims.network_mode ?? "live") !== (quote.network_mode ?? "live") ||
|
|
1255
1753
|
Number(claims.amount) !== Number(quote.amount) ||
|
|
1256
1754
|
claims.unit_quota !== quote.unit_quota ||
|
|
1257
1755
|
!Number.isSafeInteger(claims.exp) ||
|