@stellar-agent-services/payment-client 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/browser-storage.d.ts +4 -0
- package/dist/browser-storage.d.ts.map +1 -0
- package/dist/browser-storage.js +77 -0
- package/dist/browser-storage.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +336 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +138 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +522 -0
- package/dist/client.js.map +1 -0
- package/dist/conformance.d.ts +21 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +106 -0
- package/dist/conformance.js.map +1 -0
- package/dist/file-storage.d.ts +4 -0
- package/dist/file-storage.d.ts.map +1 -0
- package/dist/file-storage.js +54 -0
- package/dist/file-storage.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +49 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +100 -0
- package/dist/journal.js.map +1 -0
- package/dist/local-key-signer.d.ts +4 -0
- package/dist/local-key-signer.d.ts.map +1 -0
- package/dist/local-key-signer.js +8 -0
- package/dist/local-key-signer.js.map +1 -0
- package/dist/manifest.d.ts +11 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +50 -0
- package/dist/manifest.js.map +1 -0
- package/dist/onchain.d.ts +17 -0
- package/dist/onchain.d.ts.map +1 -0
- package/dist/onchain.js +75 -0
- package/dist/onchain.js.map +1 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +78 -0
- package/dist/policy.js.map +1 -0
- package/dist/strategies.d.ts +40 -0
- package/dist/strategies.d.ts.map +1 -0
- package/dist/strategies.js +12 -0
- package/dist/strategies.js.map +1 -0
- package/dist/verification.d.ts +29 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +26 -0
- package/dist/verification.js.map +1 -0
- package/dist/x402-exact-stellar.d.ts +11 -0
- package/dist/x402-exact-stellar.d.ts.map +1 -0
- package/dist/x402-exact-stellar.js +17 -0
- package/dist/x402-exact-stellar.js.map +1 -0
- package/package.json +52 -4
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type JobView, type JsonObject, type Problem, type ProviderManifest, type ServiceDescriptor } from "@stellar-agent-services/protocol";
|
|
2
|
+
import type { PaymentRequired, PaymentRequirements } from "@x402/core/types";
|
|
3
|
+
import type { Journal, SecretStore } from "./journal.js";
|
|
4
|
+
import { type PolicyViolation, type SpendingPolicy } from "./policy.js";
|
|
5
|
+
import type { PaymentMethod, Signer } from "./strategies.js";
|
|
6
|
+
import { type VerificationSource } from "./verification.js";
|
|
7
|
+
export interface PaymentClientOptions {
|
|
8
|
+
/** Who pays: localKeySigner(), or a wallet adapter. */
|
|
9
|
+
signer: Signer;
|
|
10
|
+
/** How to pay. The first method that matches the challenge is used. Default: x402ExactStellar(). */
|
|
11
|
+
methods?: PaymentMethod[];
|
|
12
|
+
policy: SpendingPolicy;
|
|
13
|
+
/** Trusted by the application. Required only when the policy sets verification minimums [V1]. */
|
|
14
|
+
verificationSource?: VerificationSource;
|
|
15
|
+
journal: Journal;
|
|
16
|
+
secrets: SecretStore;
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
/** Soroban RPC for the default payment method. Defaults to the public testnet RPC. */
|
|
19
|
+
rpcUrl?: string;
|
|
20
|
+
/** Accept http://localhost providers, for local development. */
|
|
21
|
+
allowLocalhost?: boolean;
|
|
22
|
+
/** How long pay() and recover() follow a running job before returning it. Default 120 s. */
|
|
23
|
+
followSeconds?: number;
|
|
24
|
+
/** Also follow payment_uncertain within followSeconds. Default false: return uncertainty to the caller [J13]. */
|
|
25
|
+
followPaymentUncertain?: boolean;
|
|
26
|
+
/** Time between polls while following a job. Default: the provider's Retry-After, or 2000 ms. */
|
|
27
|
+
pollMs?: number;
|
|
28
|
+
/** How long quote() and pay() wait out a busy provider (429 or 503) before giving up. Default 30 s. */
|
|
29
|
+
busyWaitSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface Quote {
|
|
32
|
+
manifestUrl: string;
|
|
33
|
+
manifest: ProviderManifest;
|
|
34
|
+
service: ServiceDescriptor;
|
|
35
|
+
input: JsonObject;
|
|
36
|
+
jobId: string;
|
|
37
|
+
bindingHash: string;
|
|
38
|
+
challenge: PaymentRequired;
|
|
39
|
+
requirements: PaymentRequirements;
|
|
40
|
+
/** The 402 does not match the manifest [P7], or no installed method can pay it. Any entry blocks the payment. */
|
|
41
|
+
challengeViolations: string[];
|
|
42
|
+
/** The installed payment method that pays this challenge. */
|
|
43
|
+
methodId: string | undefined;
|
|
44
|
+
/** What a policy wallet can still pay on chain, when the signer reports a budget. */
|
|
45
|
+
walletBudget: string | undefined;
|
|
46
|
+
/** The spending policy's violations, and the wallet budget's (`wallet.budget`). */
|
|
47
|
+
policyViolations: PolicyViolation[];
|
|
48
|
+
/** No violations, but above the auto-approve amount: a human must approve. */
|
|
49
|
+
needsApproval: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface PaymentResult {
|
|
52
|
+
job: JobView;
|
|
53
|
+
/** The approved transfer was confirmed on chain. Only then may a UI show "paid" [J16]. */
|
|
54
|
+
confirmed: boolean;
|
|
55
|
+
/** Why a charged job is not confirmed. */
|
|
56
|
+
unconfirmedReason?: string;
|
|
57
|
+
}
|
|
58
|
+
/** The payment may or may not have gone through. Run recover(jobId); never pay again. */
|
|
59
|
+
export declare class PaymentUncertainError extends Error {
|
|
60
|
+
readonly jobId: string;
|
|
61
|
+
/** What happened, without the advice the message adds. */
|
|
62
|
+
readonly reason: string;
|
|
63
|
+
name: string;
|
|
64
|
+
constructor(jobId: string,
|
|
65
|
+
/** What happened, without the advice the message adds. */
|
|
66
|
+
reason: string);
|
|
67
|
+
}
|
|
68
|
+
export declare class PaymentRefusedError extends Error {
|
|
69
|
+
name: string;
|
|
70
|
+
}
|
|
71
|
+
export declare class ProviderError extends Error {
|
|
72
|
+
readonly status: number;
|
|
73
|
+
readonly problem: Partial<Problem<string>> | undefined;
|
|
74
|
+
name: string;
|
|
75
|
+
constructor(status: number, problem: Partial<Problem<string>> | undefined);
|
|
76
|
+
}
|
|
77
|
+
/** The provider is busy (429 or 503) and answered before taking the payment: nothing was charged. */
|
|
78
|
+
export declare class ProviderBusyError extends ProviderError {
|
|
79
|
+
/** When the provider suggested trying again. */
|
|
80
|
+
readonly retryAfterSeconds: number | undefined;
|
|
81
|
+
name: string;
|
|
82
|
+
constructor(status: number, problem: Partial<Problem<string>> | undefined,
|
|
83
|
+
/** When the provider suggested trying again. */
|
|
84
|
+
retryAfterSeconds: number | undefined);
|
|
85
|
+
}
|
|
86
|
+
export declare class PaymentClient {
|
|
87
|
+
private readonly options;
|
|
88
|
+
private readonly fetch;
|
|
89
|
+
private readonly methods;
|
|
90
|
+
/** Jobs inside pay() right now. A slow signer, such as a wallet popup, must not let a job be signed twice. */
|
|
91
|
+
private readonly paying;
|
|
92
|
+
constructor(options: PaymentClientOptions);
|
|
93
|
+
get address(): string;
|
|
94
|
+
/** Everything needed to show the approval screen. Nothing is signed or paid. */
|
|
95
|
+
quote(manifestUrl: string, serviceName: string, input: JsonObject): Promise<Quote>;
|
|
96
|
+
/** Whether quote() found nothing that blocks the payment (approval aside). */
|
|
97
|
+
payable(quote: Quote): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Signs once and pays. `approved` is the human's answer to the approval screen; it is ignored for
|
|
100
|
+
* payments the policy auto-approves. A refusal is journaled and thrown.
|
|
101
|
+
*/
|
|
102
|
+
pay(quote: Quote, { approved }: {
|
|
103
|
+
approved: boolean;
|
|
104
|
+
}): Promise<PaymentResult>;
|
|
105
|
+
/**
|
|
106
|
+
* The human said no on the approval screen. Journaled, and nothing is signed. Too late once the job is
|
|
107
|
+
* being paid or was sent; recover() it instead.
|
|
108
|
+
*/
|
|
109
|
+
decline(quote: Quote): Promise<void>;
|
|
110
|
+
private payOnce;
|
|
111
|
+
/** Resumes a job from the journal with check-status and get-result. Never pays. */
|
|
112
|
+
recover(jobId: string): Promise<PaymentResult>;
|
|
113
|
+
/**
|
|
114
|
+
* Polls get-result while the provider answers 409 request_in_progress, at its Retry-After pace, for at
|
|
115
|
+
* most followSeconds. Resolves with the first job view, or undefined if settlement outlasted the wait.
|
|
116
|
+
*/
|
|
117
|
+
private whileSettling;
|
|
118
|
+
private finish;
|
|
119
|
+
/** Checks the transaction against what this job journaled in `sending`, never against the receipt. */
|
|
120
|
+
private confirm;
|
|
121
|
+
/** The first installed method that pays the entry. A method that throws does not match (fail closed). */
|
|
122
|
+
private methodFor;
|
|
123
|
+
/** Spending policy, trusted verification [V1], and the wallet's on-chain budget must all pass. */
|
|
124
|
+
private checkLimits;
|
|
125
|
+
/**
|
|
126
|
+
* Polls get-result until the job finishes or followSeconds pass, at the provider's Retry-After pace.
|
|
127
|
+
* A busy or in-progress answer while polling only means "ask again later" [Q9], [J7].
|
|
128
|
+
*/
|
|
129
|
+
private follow;
|
|
130
|
+
/** get-result. Answers other than a job view, a busy provider or a job still settling throw. */
|
|
131
|
+
private getResult;
|
|
132
|
+
/** Sends create-job, and sends it again while the provider is busy and `until` allows [Q8]. */
|
|
133
|
+
private createJob;
|
|
134
|
+
private get busyWaitMs();
|
|
135
|
+
private jobHeaders;
|
|
136
|
+
private reasons;
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// The payment flow (docs/payment-client.md, "One payment, step by step"):
|
|
2
|
+
//
|
|
3
|
+
// quote(): manifest -> job id, token, binding hash -> unpaid create-job -> 402 -> checkChallenge
|
|
4
|
+
// -> pick a payment method -> policy and wallet budget. Nothing is signed.
|
|
5
|
+
// pay(): approval -> sign once -> journal `sending` -> paid create-job -> follow the job ->
|
|
6
|
+
// confirm on chain.
|
|
7
|
+
// recover(): check-status / get-result from the journal. Never signs or pays again.
|
|
8
|
+
//
|
|
9
|
+
// A busy provider answers create-job with 429 or 503 before it claims or settles anything. The client
|
|
10
|
+
// waits out Retry-After and sends the same request again, the paid one with the same signed payment,
|
|
11
|
+
// which still buys at most one job (docs/provider-standard.md §5.8) [Q8]. It gives up after
|
|
12
|
+
// busyWaitSeconds, or once the signed payment is about to expire.
|
|
13
|
+
//
|
|
14
|
+
// 409 request_in_progress means the opposite: the provider has claimed the payment and is still
|
|
15
|
+
// settling it [J7]. The client never pays again; it follows the job with get-result until it has a job
|
|
16
|
+
// view, for followSeconds, and otherwise hands it back for recover().
|
|
17
|
+
//
|
|
18
|
+
// The signer and the payment methods are strategies (strategies.ts). Every safety step stays here, so
|
|
19
|
+
// it holds whichever ones are plugged in.
|
|
20
|
+
import { bindingHash, canonicalSha256, checkChallenge, createJobUrl, IDEMPOTENCY_KEY, JOB_TOKEN, jobResultUrl, parseRetryAfter, } from "@stellar-agent-services/protocol";
|
|
21
|
+
import { decodePaymentRequiredHeader, encodePaymentSignatureHeader } from "@x402/core/http";
|
|
22
|
+
import { loadManifest } from "./manifest.js";
|
|
23
|
+
import { checkPolicy, PAY_ACTION, policyHash } from "./policy.js";
|
|
24
|
+
import { X402_EXACT_STELLAR, x402ExactStellar } from "./x402-exact-stellar.js";
|
|
25
|
+
import { matchesVerification } from "./verification.js";
|
|
26
|
+
/** The payment may or may not have gone through. Run recover(jobId); never pay again. */
|
|
27
|
+
export class PaymentUncertainError extends Error {
|
|
28
|
+
jobId;
|
|
29
|
+
reason;
|
|
30
|
+
name = "PaymentUncertainError";
|
|
31
|
+
constructor(jobId,
|
|
32
|
+
/** What happened, without the advice the message adds. */
|
|
33
|
+
reason) {
|
|
34
|
+
super(`${reason}. The payment may have gone through: recover job ${jobId}, do not pay again.`);
|
|
35
|
+
this.jobId = jobId;
|
|
36
|
+
this.reason = reason;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class PaymentRefusedError extends Error {
|
|
40
|
+
name = "PaymentRefusedError";
|
|
41
|
+
}
|
|
42
|
+
export class ProviderError extends Error {
|
|
43
|
+
status;
|
|
44
|
+
problem;
|
|
45
|
+
name = "ProviderError";
|
|
46
|
+
constructor(status, problem) {
|
|
47
|
+
super(`the provider answered ${status}${problem?.code ? ` ${problem.code}` : ""}${problem?.detail ? `: ${problem.detail}` : ""}`);
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.problem = problem;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The provider is busy (429 or 503) and answered before taking the payment: nothing was charged. */
|
|
53
|
+
export class ProviderBusyError extends ProviderError {
|
|
54
|
+
retryAfterSeconds;
|
|
55
|
+
name = "ProviderBusyError";
|
|
56
|
+
constructor(status, problem,
|
|
57
|
+
/** When the provider suggested trying again. */
|
|
58
|
+
retryAfterSeconds) {
|
|
59
|
+
super(status, problem);
|
|
60
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
61
|
+
const again = retryAfterSeconds === undefined ? "later" : `in ${retryAfterSeconds} s`;
|
|
62
|
+
this.message = `the provider is busy${problem?.code ? ` (${problem.code})` : ""}; try again ${again}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Seconds to wait when a busy answer has no usable Retry-After. */
|
|
66
|
+
const BUSY_RETRY_SECONDS = 2;
|
|
67
|
+
/** Room left, before the signed payment expires, for the provider to settle it. */
|
|
68
|
+
const PAYMENT_EXPIRY_MARGIN_SECONDS = 10;
|
|
69
|
+
export class PaymentClient {
|
|
70
|
+
options;
|
|
71
|
+
fetch;
|
|
72
|
+
methods;
|
|
73
|
+
/** Jobs inside pay() right now. A slow signer, such as a wallet popup, must not let a job be signed twice. */
|
|
74
|
+
paying = new Set();
|
|
75
|
+
constructor(options) {
|
|
76
|
+
this.options = options;
|
|
77
|
+
this.fetch = options.fetch ?? fetch;
|
|
78
|
+
this.methods = options.methods ?? [x402ExactStellar(options.rpcUrl === undefined ? {} : { rpcUrl: options.rpcUrl })];
|
|
79
|
+
if (this.methods.length === 0)
|
|
80
|
+
throw new TypeError("PaymentClient needs at least one payment method");
|
|
81
|
+
const ids = this.methods.map((method) => method.id);
|
|
82
|
+
// Recovery finds the method that paid by its id.
|
|
83
|
+
if (new Set(ids).size !== ids.length)
|
|
84
|
+
throw new TypeError(`payment method ids must be unique: ${ids.join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
get address() {
|
|
87
|
+
return this.options.signer.address;
|
|
88
|
+
}
|
|
89
|
+
/** Everything needed to show the approval screen. Nothing is signed or paid. */
|
|
90
|
+
async quote(manifestUrl, serviceName, input) {
|
|
91
|
+
const { journal, secrets, policy } = this.options;
|
|
92
|
+
const loadOptions = { fetch: this.fetch };
|
|
93
|
+
if (this.options.allowLocalhost !== undefined)
|
|
94
|
+
loadOptions.allowLocalhost = this.options.allowLocalhost;
|
|
95
|
+
const manifest = await loadManifest(manifestUrl, loadOptions);
|
|
96
|
+
const service = manifest.services.find((candidate) => candidate.name === serviceName);
|
|
97
|
+
if (service === undefined)
|
|
98
|
+
throw new PaymentRefusedError(`the provider has no service "${serviceName}"`);
|
|
99
|
+
// The job identity is fixed, and saved, before the first request [J1].
|
|
100
|
+
const jobId = crypto.randomUUID();
|
|
101
|
+
const token = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
102
|
+
const binding = await bindingHash(service.name, input);
|
|
103
|
+
await secrets.set(jobId, token);
|
|
104
|
+
const hash = await policyHash(policy);
|
|
105
|
+
await journal.append("intent", jobId, hash, {
|
|
106
|
+
manifestUrl,
|
|
107
|
+
baseUrl: manifest.baseUrl,
|
|
108
|
+
service: service.name,
|
|
109
|
+
bindingHash: binding,
|
|
110
|
+
input,
|
|
111
|
+
});
|
|
112
|
+
const response = await this.createJob(createJobUrl(manifest.baseUrl, service.name), { method: "POST", headers: this.jobHeaders(jobId, token), body: JSON.stringify(input), redirect: "manual" }, Date.now() + this.busyWaitMs);
|
|
113
|
+
if (isBusy(response))
|
|
114
|
+
throw await busyError(response);
|
|
115
|
+
if (response.status !== 402)
|
|
116
|
+
throw new ProviderError(response.status, await readProblem(response));
|
|
117
|
+
const header = response.headers.get("PAYMENT-REQUIRED");
|
|
118
|
+
const challenge = header !== null ? decodePaymentRequiredHeader(header) : (await response.json());
|
|
119
|
+
const challengeViolations = checkChallenge(manifest, service.name, challenge).map((v) => `[${v.rule}] ${v.pointer}: ${v.message}`);
|
|
120
|
+
const requirements = challenge.accepts?.[0] ?? {};
|
|
121
|
+
const method = this.methodFor(requirements);
|
|
122
|
+
if (method === undefined)
|
|
123
|
+
challengeViolations.push(noMethod(requirements));
|
|
124
|
+
const limits = await this.checkLimits(requirements, manifest, manifestUrl);
|
|
125
|
+
const quote = {
|
|
126
|
+
manifestUrl,
|
|
127
|
+
manifest,
|
|
128
|
+
service,
|
|
129
|
+
input,
|
|
130
|
+
jobId,
|
|
131
|
+
bindingHash: binding,
|
|
132
|
+
challenge,
|
|
133
|
+
requirements,
|
|
134
|
+
challengeViolations,
|
|
135
|
+
methodId: method?.id,
|
|
136
|
+
walletBudget: limits.walletBudget,
|
|
137
|
+
policyViolations: limits.violations,
|
|
138
|
+
needsApproval: limits.needsApproval,
|
|
139
|
+
};
|
|
140
|
+
if (!this.payable(quote))
|
|
141
|
+
await journal.append("refused", jobId, hash, { reasons: this.reasons(quote) });
|
|
142
|
+
return quote;
|
|
143
|
+
}
|
|
144
|
+
/** Whether quote() found nothing that blocks the payment (approval aside). */
|
|
145
|
+
payable(quote) {
|
|
146
|
+
return quote.challengeViolations.length === 0 && quote.policyViolations.length === 0;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Signs once and pays. `approved` is the human's answer to the approval screen; it is ignored for
|
|
150
|
+
* payments the policy auto-approves. A refusal is journaled and thrown.
|
|
151
|
+
*/
|
|
152
|
+
async pay(quote, { approved }) {
|
|
153
|
+
if (!this.payable(quote))
|
|
154
|
+
throw new PaymentRefusedError(this.reasons(quote).join("; "));
|
|
155
|
+
if (this.options.journal.forJob(quote.jobId).some((entry) => entry.event === "sending")) {
|
|
156
|
+
throw new PaymentRefusedError(`job ${quote.jobId} was already paid for; use recover()`);
|
|
157
|
+
}
|
|
158
|
+
// Checked and marked before the first await, so a second pay() for this job can't sign it again.
|
|
159
|
+
if (this.paying.has(quote.jobId))
|
|
160
|
+
throw new PaymentRefusedError(`job ${quote.jobId} is already being paid`);
|
|
161
|
+
this.paying.add(quote.jobId);
|
|
162
|
+
try {
|
|
163
|
+
return await this.payOnce(quote, approved);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
this.paying.delete(quote.jobId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The human said no on the approval screen. Journaled, and nothing is signed. Too late once the job is
|
|
171
|
+
* being paid or was sent; recover() it instead.
|
|
172
|
+
*/
|
|
173
|
+
async decline(quote) {
|
|
174
|
+
const { journal, policy } = this.options;
|
|
175
|
+
if (this.paying.has(quote.jobId))
|
|
176
|
+
return;
|
|
177
|
+
if (journal.forJob(quote.jobId).some((entry) => entry.event === "refused" || entry.event === "sending"))
|
|
178
|
+
return;
|
|
179
|
+
await journal.append("refused", quote.jobId, await policyHash(policy), { reasons: ["the user declined"] });
|
|
180
|
+
}
|
|
181
|
+
async payOnce(quote, approved) {
|
|
182
|
+
const { journal, policy, signer } = this.options;
|
|
183
|
+
const hash = await policyHash(policy);
|
|
184
|
+
const method = this.methodFor(quote.requirements);
|
|
185
|
+
if (method === undefined)
|
|
186
|
+
throw new PaymentRefusedError(noMethod(quote.requirements));
|
|
187
|
+
// Limits are checked again: another payment may have run since the quote.
|
|
188
|
+
const decision = await this.checkLimits(quote.requirements, quote.manifest, quote.manifestUrl);
|
|
189
|
+
if (!decision.allowed) {
|
|
190
|
+
await journal.append("refused", quote.jobId, hash, { reasons: decision.violations.map((v) => `${v.field}: ${v.message}`) });
|
|
191
|
+
throw new PaymentRefusedError(decision.violations.map((v) => v.message).join("; "));
|
|
192
|
+
}
|
|
193
|
+
if (decision.needsApproval && !approved) {
|
|
194
|
+
await journal.append("refused", quote.jobId, hash, { reasons: ["the user declined"] });
|
|
195
|
+
throw new PaymentRefusedError("the user declined the payment");
|
|
196
|
+
}
|
|
197
|
+
await journal.append("approved", quote.jobId, hash, { by: decision.needsApproval ? "user" : "policy" });
|
|
198
|
+
let signed;
|
|
199
|
+
// Taken before signing, which may wait on a wallet popup: the payment's validity starts when it is built.
|
|
200
|
+
const signedAt = Date.now();
|
|
201
|
+
try {
|
|
202
|
+
signed = await method.createPayload(quote.requirements, signer);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// Nothing was sent, so nothing was charged, and the user may try again.
|
|
206
|
+
const reason = `the payment was not signed: ${error.message}`;
|
|
207
|
+
await journal.append("refused", quote.jobId, hash, { reasons: [reason] });
|
|
208
|
+
throw new PaymentRefusedError(reason);
|
|
209
|
+
}
|
|
210
|
+
const payload = { ...signed, x402Version: 2, resource: quote.challenge.resource, accepted: quote.requirements };
|
|
211
|
+
// Written and flushed before the request goes out: from here on, this job is never signed again.
|
|
212
|
+
await journal.append("sending", quote.jobId, hash, { ...pick(quote.requirements), payer: signer.address, method: method.id });
|
|
213
|
+
const token = (await this.options.secrets.get(quote.jobId));
|
|
214
|
+
// A busy provider is waited out with the same signed payment [Q8], but only while it can still settle it.
|
|
215
|
+
const expiresAt = signedAt + (quote.requirements.maxTimeoutSeconds - PAYMENT_EXPIRY_MARGIN_SECONDS) * 1000;
|
|
216
|
+
let response;
|
|
217
|
+
try {
|
|
218
|
+
response = await this.createJob(createJobUrl(quote.manifest.baseUrl, quote.service.name), {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { ...this.jobHeaders(quote.jobId, token), "PAYMENT-SIGNATURE": encodePaymentSignatureHeader(payload) },
|
|
221
|
+
body: JSON.stringify(quote.input),
|
|
222
|
+
redirect: "manual",
|
|
223
|
+
}, Math.min(Date.now() + this.busyWaitMs, expiresAt));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
await journal.append("uncertain", quote.jobId, hash, { reason: "the paid request failed in transit" });
|
|
227
|
+
throw new PaymentUncertainError(quote.jobId, `the paid request failed (${error.message})`);
|
|
228
|
+
}
|
|
229
|
+
if (response.status === 402) {
|
|
230
|
+
const reason = (await readChallengeError(response)) ?? "payment_rejected";
|
|
231
|
+
// The provider refused before settling: nothing was charged.
|
|
232
|
+
await journal.append("failed", quote.jobId, hash, { charged: false, reason });
|
|
233
|
+
throw new PaymentRefusedError(`the provider rejected the payment: ${reason}`);
|
|
234
|
+
}
|
|
235
|
+
if (isBusy(response)) {
|
|
236
|
+
// Answered at step 5 of §5.4, before the payment was verified or claimed: nothing was charged.
|
|
237
|
+
const error = await busyError(response);
|
|
238
|
+
await journal.append("failed", quote.jobId, hash, { charged: false, reason: error.problem?.code ?? `provider_busy_${response.status}` });
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
if (isInProgress(response)) {
|
|
242
|
+
// The provider holds the payment and is still settling it: follow the job, never pay again.
|
|
243
|
+
const settled = await this.whileSettling(quote.manifest.baseUrl, quote.jobId, token, response);
|
|
244
|
+
if (settled === undefined) {
|
|
245
|
+
await journal.append("uncertain", quote.jobId, hash, { reason: "the provider was still settling the payment" });
|
|
246
|
+
throw new PaymentUncertainError(quote.jobId, "the provider is still settling the payment");
|
|
247
|
+
}
|
|
248
|
+
return this.finish(quote.manifest.baseUrl, quote.jobId, token, settled.job, hash, settled.retryAfter);
|
|
249
|
+
}
|
|
250
|
+
if (response.status !== 200 && response.status !== 202) {
|
|
251
|
+
await journal.append("uncertain", quote.jobId, hash, { reason: `the provider answered ${response.status}` });
|
|
252
|
+
throw new PaymentUncertainError(quote.jobId, `the provider answered ${response.status}`);
|
|
253
|
+
}
|
|
254
|
+
const job = (await response.json());
|
|
255
|
+
return this.finish(quote.manifest.baseUrl, quote.jobId, token, job, hash, retryAfterMs(response));
|
|
256
|
+
}
|
|
257
|
+
/** Resumes a job from the journal with check-status and get-result. Never pays. */
|
|
258
|
+
async recover(jobId) {
|
|
259
|
+
const { journal, secrets, policy } = this.options;
|
|
260
|
+
const entries = journal.forJob(jobId);
|
|
261
|
+
const intent = entries.find((entry) => entry.event === "intent");
|
|
262
|
+
const token = await secrets.get(jobId);
|
|
263
|
+
if (intent === undefined || token === undefined)
|
|
264
|
+
throw new Error(`job ${jobId} is not in this journal`);
|
|
265
|
+
if (!entries.some((entry) => entry.event === "sending")) {
|
|
266
|
+
throw new Error(`job ${jobId} was never paid for, so there is nothing to recover`);
|
|
267
|
+
}
|
|
268
|
+
const hash = await policyHash(policy);
|
|
269
|
+
const baseUrl = String(intent.details["baseUrl"]);
|
|
270
|
+
await journal.append("recovered", jobId, hash, {});
|
|
271
|
+
const response = await this.getResult(baseUrl, jobId, token);
|
|
272
|
+
if (isBusy(response))
|
|
273
|
+
throw await busyError(response);
|
|
274
|
+
if (isInProgress(response)) {
|
|
275
|
+
const settled = await this.whileSettling(baseUrl, jobId, token, response);
|
|
276
|
+
if (settled === undefined)
|
|
277
|
+
throw new PaymentUncertainError(jobId, "the provider is still settling the payment");
|
|
278
|
+
return this.finish(baseUrl, jobId, token, settled.job, hash, settled.retryAfter);
|
|
279
|
+
}
|
|
280
|
+
const job = await readJob(response);
|
|
281
|
+
return this.finish(baseUrl, jobId, token, job, hash, retryAfterMs(response));
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Polls get-result while the provider answers 409 request_in_progress, at its Retry-After pace, for at
|
|
285
|
+
* most followSeconds. Resolves with the first job view, or undefined if settlement outlasted the wait.
|
|
286
|
+
*/
|
|
287
|
+
async whileSettling(baseUrl, jobId, token, response) {
|
|
288
|
+
const deadline = Date.now() + (this.options.followSeconds ?? 120) * 1000;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const wait = this.options.pollMs ?? retryAfterMs(response) ?? 2000;
|
|
291
|
+
await response.body?.cancel();
|
|
292
|
+
if (Date.now() + wait > deadline)
|
|
293
|
+
return undefined;
|
|
294
|
+
await sleep(wait);
|
|
295
|
+
response = await this.getResult(baseUrl, jobId, token);
|
|
296
|
+
// Busy is "ask again later" too [Q9].
|
|
297
|
+
if (!isInProgress(response) && !isBusy(response))
|
|
298
|
+
return { job: await readJob(response), retryAfter: retryAfterMs(response) };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async finish(baseUrl, jobId, token, first, hash, retryAfter) {
|
|
302
|
+
const { journal } = this.options;
|
|
303
|
+
const job = await this.follow(baseUrl, jobId, token, first, retryAfter);
|
|
304
|
+
let confirmation = { confirmed: false };
|
|
305
|
+
if (job.charged === true) {
|
|
306
|
+
confirmation = job.payment === undefined ? { confirmed: false, reason: "the provider sent no payment receipt" } : await this.confirm(jobId, job.payment.transaction);
|
|
307
|
+
const settled = journal.forJob(jobId).filter((entry) => entry.event === "settled");
|
|
308
|
+
// Journaled once, and again only when a later check confirms what an earlier one could not.
|
|
309
|
+
if (settled.length === 0 || (confirmation.confirmed && !settled.some((entry) => entry.details["confirmed"] === true))) {
|
|
310
|
+
await journal.append("settled", jobId, hash, {
|
|
311
|
+
transaction: job.payment?.transaction ?? "",
|
|
312
|
+
network: job.payment?.network ?? "",
|
|
313
|
+
amount: job.payment?.amount ?? "",
|
|
314
|
+
confirmed: confirmation.confirmed,
|
|
315
|
+
...(confirmation.reason === undefined ? {} : { reason: confirmation.reason }),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (job.status === "failed")
|
|
320
|
+
await journal.append("failed", jobId, hash, { charged: job.charged, code: job.error?.code ?? "" });
|
|
321
|
+
else if (job.status === "succeeded")
|
|
322
|
+
await journal.append("result", jobId, hash, { status: job.status });
|
|
323
|
+
const result = { job, confirmed: confirmation.confirmed };
|
|
324
|
+
if (job.charged === true && confirmation.reason !== undefined)
|
|
325
|
+
result.unconfirmedReason = confirmation.reason;
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
/** Checks the transaction against what this job journaled in `sending`, never against the receipt. */
|
|
329
|
+
async confirm(jobId, transaction) {
|
|
330
|
+
const { journal } = this.options;
|
|
331
|
+
const sending = journal.forJob(jobId).find((entry) => entry.event === "sending").details;
|
|
332
|
+
// One transaction proves one job: a provider must not reuse an earlier payment as a receipt.
|
|
333
|
+
const other = journal
|
|
334
|
+
.entries()
|
|
335
|
+
.find((entry) => entry.event === "settled" && entry.jobId !== jobId && entry.details["confirmed"] === true && entry.details["transaction"] === transaction);
|
|
336
|
+
if (other !== undefined)
|
|
337
|
+
return { confirmed: false, reason: `transaction ${transaction} already confirmed job ${other.jobId}` };
|
|
338
|
+
// Journals from before `method` was recorded were paid with the only method there was.
|
|
339
|
+
const methodId = typeof sending["method"] === "string" ? sending["method"] : X402_EXACT_STELLAR;
|
|
340
|
+
const method = this.methods.find((candidate) => candidate.id === methodId);
|
|
341
|
+
if (method === undefined)
|
|
342
|
+
return { confirmed: false, reason: `payment method "${methodId}" is not installed, so the payment can't be checked` };
|
|
343
|
+
try {
|
|
344
|
+
return await method.confirm(transaction, {
|
|
345
|
+
network: String(sending["network"]),
|
|
346
|
+
asset: String(sending["asset"]),
|
|
347
|
+
amount: String(sending["amount"]),
|
|
348
|
+
payTo: String(sending["payTo"]),
|
|
349
|
+
// Journals from before `payer` was recorded were signed by this client's key.
|
|
350
|
+
payer: typeof sending["payer"] === "string" ? sending["payer"] : this.address,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
return { confirmed: false, reason: `the payment could not be checked: ${error.message}` };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** The first installed method that pays the entry. A method that throws does not match (fail closed). */
|
|
358
|
+
methodFor(requirements) {
|
|
359
|
+
return this.methods.find((method) => {
|
|
360
|
+
try {
|
|
361
|
+
return method.matches(requirements);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** Spending policy, trusted verification [V1], and the wallet's on-chain budget must all pass. */
|
|
369
|
+
async checkLimits(requirements, manifest, manifestUrl) {
|
|
370
|
+
const { policy, journal, signer } = this.options;
|
|
371
|
+
const terms = pick(requirements);
|
|
372
|
+
let verification;
|
|
373
|
+
if (policy.minVerificationScore !== undefined || policy.stellar.minProviderLevel !== undefined) {
|
|
374
|
+
const subject = {
|
|
375
|
+
network: terms.network,
|
|
376
|
+
payTo: terms.payTo,
|
|
377
|
+
manifestUrl,
|
|
378
|
+
manifestSha256: await canonicalSha256(manifest),
|
|
379
|
+
...(manifest.provider.agentId === undefined ? {} : { agentId: manifest.provider.agentId }),
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
const evidence = await this.options.verificationSource?.read({ ...subject });
|
|
383
|
+
if (matchesVerification(evidence, subject))
|
|
384
|
+
verification = evidence;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// An unavailable source is missing evidence, never permission to sign [V1].
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const decision = checkPolicy(policy, {
|
|
391
|
+
action: PAY_ACTION,
|
|
392
|
+
...terms,
|
|
393
|
+
amount: terms.amount ?? "0",
|
|
394
|
+
spentLast24h: journal.spentSince(new Date(Date.now() - 86400_000)),
|
|
395
|
+
...(verification === undefined ? {} : { verification }),
|
|
396
|
+
});
|
|
397
|
+
if (signer.budget === undefined || terms.amount === undefined)
|
|
398
|
+
return { ...decision, walletBudget: undefined };
|
|
399
|
+
let walletBudget;
|
|
400
|
+
let problem;
|
|
401
|
+
try {
|
|
402
|
+
const reported = await signer.budget(terms.network, terms.asset);
|
|
403
|
+
if (reported !== undefined && !/^\d+$/.test(reported))
|
|
404
|
+
problem = `the wallet reported an invalid budget "${reported}"`;
|
|
405
|
+
else
|
|
406
|
+
walletBudget = reported;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
problem = `the wallet's budget could not be read: ${error.message}`;
|
|
410
|
+
}
|
|
411
|
+
if (walletBudget !== undefined && BigInt(terms.amount) > BigInt(walletBudget))
|
|
412
|
+
problem = "the amount is above what the wallet's on-chain budget has left";
|
|
413
|
+
if (problem === undefined)
|
|
414
|
+
return { ...decision, walletBudget };
|
|
415
|
+
return { allowed: false, violations: [...decision.violations, { field: "wallet.budget", message: problem }], needsApproval: false, walletBudget };
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Polls get-result until the job finishes or followSeconds pass, at the provider's Retry-After pace.
|
|
419
|
+
* A busy or in-progress answer while polling only means "ask again later" [Q9], [J7].
|
|
420
|
+
*/
|
|
421
|
+
async follow(baseUrl, jobId, token, job, retryAfter) {
|
|
422
|
+
const deadline = Date.now() + (this.options.followSeconds ?? 120) * 1000;
|
|
423
|
+
while ((job.status !== "succeeded" && job.status !== "failed") || (job.status === "succeeded" && job.output === undefined)) {
|
|
424
|
+
// Only provider reconciliation resolves uncertainty [J13]. Opt-in polling reads that same job [J11].
|
|
425
|
+
if ((job.status === "payment_uncertain" && this.options.followPaymentUncertain !== true) || Date.now() >= deadline)
|
|
426
|
+
return job;
|
|
427
|
+
const wait = this.options.pollMs ?? retryAfter ?? 2000;
|
|
428
|
+
await sleep(Math.min(wait, deadline - Date.now()));
|
|
429
|
+
const response = await this.getResult(baseUrl, jobId, token);
|
|
430
|
+
retryAfter = retryAfterMs(response);
|
|
431
|
+
if (isBusy(response) || isInProgress(response))
|
|
432
|
+
await response.body?.cancel();
|
|
433
|
+
else
|
|
434
|
+
job = await readJob(response);
|
|
435
|
+
}
|
|
436
|
+
return job;
|
|
437
|
+
}
|
|
438
|
+
/** get-result. Answers other than a job view, a busy provider or a job still settling throw. */
|
|
439
|
+
async getResult(baseUrl, jobId, token) {
|
|
440
|
+
const response = await this.fetch(jobResultUrl(baseUrl, jobId), { headers: { [JOB_TOKEN]: token }, redirect: "manual" });
|
|
441
|
+
if (response.status !== 200 && response.status !== 202 && !isBusy(response) && !isInProgress(response)) {
|
|
442
|
+
throw new ProviderError(response.status, await readProblem(response));
|
|
443
|
+
}
|
|
444
|
+
return response;
|
|
445
|
+
}
|
|
446
|
+
/** Sends create-job, and sends it again while the provider is busy and `until` allows [Q8]. */
|
|
447
|
+
async createJob(url, init, until) {
|
|
448
|
+
for (;;) {
|
|
449
|
+
const response = await this.fetch(url, init);
|
|
450
|
+
if (!isBusy(response))
|
|
451
|
+
return response;
|
|
452
|
+
// A little jitter, so clients turned away together don't all come back together.
|
|
453
|
+
const wait = (retryAfterMs(response) ?? BUSY_RETRY_SECONDS * 1000) * (1 + Math.random() * 0.2);
|
|
454
|
+
if (Date.now() + wait > until)
|
|
455
|
+
return response;
|
|
456
|
+
await response.body?.cancel();
|
|
457
|
+
await sleep(wait);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
get busyWaitMs() {
|
|
461
|
+
return (this.options.busyWaitSeconds ?? 30) * 1000;
|
|
462
|
+
}
|
|
463
|
+
jobHeaders(jobId, token) {
|
|
464
|
+
return { "Content-Type": "application/json", [IDEMPOTENCY_KEY]: jobId, [JOB_TOKEN]: token };
|
|
465
|
+
}
|
|
466
|
+
reasons(quote) {
|
|
467
|
+
return [...quote.challengeViolations, ...quote.policyViolations.map((v) => `${v.field}: ${v.message}`)];
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function noMethod(requirements) {
|
|
471
|
+
return `no installed payment method pays scheme "${String(requirements.scheme)}" on ${String(requirements.network)}`;
|
|
472
|
+
}
|
|
473
|
+
/** Without Node's Buffer, so the core runs in the browser too. */
|
|
474
|
+
function base64url(bytes) {
|
|
475
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
476
|
+
}
|
|
477
|
+
function pick(requirements) {
|
|
478
|
+
return {
|
|
479
|
+
network: requirements.network,
|
|
480
|
+
asset: requirements.asset,
|
|
481
|
+
amount: requirements.amount,
|
|
482
|
+
payTo: requirements.payTo,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function isBusy(response) {
|
|
486
|
+
return response.status === 429 || response.status === 503;
|
|
487
|
+
}
|
|
488
|
+
/** 409 request_in_progress: the job exists and its payment is still being claimed or settled [J7]. */
|
|
489
|
+
function isInProgress(response) {
|
|
490
|
+
return response.status === 409;
|
|
491
|
+
}
|
|
492
|
+
function retryAfterMs(response) {
|
|
493
|
+
const seconds = parseRetryAfter(response.headers.get("Retry-After"));
|
|
494
|
+
return seconds === undefined ? undefined : seconds * 1000;
|
|
495
|
+
}
|
|
496
|
+
async function busyError(response) {
|
|
497
|
+
return new ProviderBusyError(response.status, await readProblem(response), parseRetryAfter(response.headers.get("Retry-After")));
|
|
498
|
+
}
|
|
499
|
+
async function readJob(response) {
|
|
500
|
+
return (await response.json());
|
|
501
|
+
}
|
|
502
|
+
function sleep(ms) {
|
|
503
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
504
|
+
}
|
|
505
|
+
async function readProblem(response) {
|
|
506
|
+
try {
|
|
507
|
+
return (await response.json());
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function readChallengeError(response) {
|
|
514
|
+
const header = response.headers.get("PAYMENT-REQUIRED");
|
|
515
|
+
try {
|
|
516
|
+
return header !== null ? decodePaymentRequiredHeader(header).error : (await response.json()).error;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
//# sourceMappingURL=client.js.map
|