@envarai/envarpay 0.1.0-alpha.3 → 0.2.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -146
- package/dist/commerce/buyer-store.d.ts +101 -0
- package/dist/commerce/buyer-store.js +168 -0
- package/dist/commerce/buyer.d.ts +132 -0
- package/dist/commerce/buyer.js +755 -0
- package/dist/commerce/card.d.ts +5 -0
- package/dist/commerce/card.js +29 -0
- package/dist/commerce/chain.d.ts +24 -0
- package/dist/commerce/chain.js +146 -0
- package/dist/commerce/cli.d.ts +2 -0
- package/dist/commerce/cli.js +274 -0
- package/dist/commerce/client-runtime.d.ts +8 -0
- package/dist/commerce/client-runtime.js +7 -0
- package/dist/commerce/config.d.ts +7 -0
- package/dist/commerce/config.js +182 -0
- package/dist/commerce/envar-runtime.d.ts +46 -0
- package/dist/commerce/envar-runtime.js +256 -0
- package/dist/commerce/envar.d.ts +126 -0
- package/dist/commerce/envar.js +647 -0
- package/dist/commerce/http.d.ts +2 -0
- package/dist/commerce/http.js +34 -0
- package/dist/commerce/index.d.ts +4 -0
- package/dist/commerce/index.js +4 -0
- package/dist/commerce/management.d.ts +14 -0
- package/dist/commerce/management.js +138 -0
- package/dist/commerce/mpp-client.d.ts +59 -0
- package/dist/commerce/mpp-client.js +24 -0
- package/dist/commerce/mpp-runtime.d.ts +3 -0
- package/dist/commerce/mpp-runtime.js +3 -0
- package/dist/commerce/mpp.d.ts +53 -0
- package/dist/commerce/mpp.js +163 -0
- package/dist/commerce/operations.d.ts +16 -0
- package/dist/commerce/operations.js +33 -0
- package/dist/commerce/peer-proxy.d.ts +44 -0
- package/dist/commerce/peer-proxy.js +673 -0
- package/dist/commerce/pricing.d.ts +12 -0
- package/dist/commerce/pricing.js +60 -0
- package/dist/commerce/runtime.d.ts +8 -0
- package/dist/commerce/runtime.js +8 -0
- package/dist/commerce/server.d.ts +59 -0
- package/dist/commerce/server.js +499 -0
- package/dist/commerce/store.d.ts +88 -0
- package/dist/commerce/store.js +473 -0
- package/dist/commerce/stripe-provider.d.ts +9 -0
- package/dist/commerce/stripe-provider.js +97 -0
- package/dist/commerce/types.d.ts +134 -0
- package/dist/commerce/types.js +8 -0
- package/dist/commerce/upstream.d.ts +6 -0
- package/dist/commerce/upstream.js +103 -0
- package/dist/commerce/vault.d.ts +8 -0
- package/dist/commerce/vault.js +55 -0
- package/dist/commerce/x402.d.ts +37 -0
- package/dist/commerce/x402.js +174 -0
- package/examples/buyer-policy.json +18 -0
- package/examples/envar-integration-policy.json +18 -0
- package/examples/seller.json +52 -0
- package/package.json +75 -10
- package/schemas/buyer-policy.schema.json +253 -0
- package/schemas/envarpay.config.schema.json +376 -0
- package/schemas/quote.schema.json +91 -0
package/README.md
CHANGED
|
@@ -1,159 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# EnvarPay TypeScript
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Independent service commerce for user-operated Agents. Public communication uses
|
|
4
|
+
A2A 1.0; payments use native x402 v2 exact USDC or MPP Stripe charge. Prices and
|
|
5
|
+
scope are versioned configuration, enforced before dispatch, not instructions in
|
|
6
|
+
a prompt. Envar is an optional directory and management UI.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
The 0.2 source line replaces the previous npm wallet-only MCP client. Build from
|
|
9
|
+
this checkout while the release is undergoing acceptance; a source version is
|
|
10
|
+
not evidence that its npm/OCI artifact is published.
|
|
10
11
|
|
|
11
|
-
##
|
|
12
|
+
## Runtime and entry points
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
Node.js 22.14+ (Node 24 recommended). SQLite is single-instance. Contracts can be
|
|
15
|
+
read without loading the seller or buyer runtime; Bun validation covers the
|
|
16
|
+
contracts entry, not the SQLite server.
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Node.js >=22.14 and Bun are supported. Import this package from an ESM application.
|
|
23
|
-
The npm library is a client; the wallet runs as a separate service:
|
|
24
|
-
|
|
25
|
-
```sh
|
|
26
|
-
uv tool install --python 3.13 envarpay==0.1.0a10
|
|
27
|
-
envarpay init --agent mcp --role buyer --directory ./buyer \
|
|
28
|
-
--peer-url https://seller.example/mcp --pay-to SELLER_FULL_RECEIVING_ADDRESS \
|
|
29
|
-
--tool ask_agent --max-per-call 0.01 --budget 0.05
|
|
30
|
-
envarpay keygen --output ./buyer/buyer.key
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
The generated policy uses Base Sepolia test USDC and payments off. Fund the
|
|
34
|
-
dedicated test wallet, review the recipient/tool/limits and enable payments only
|
|
35
|
-
for your intended purchases. The agent never receives the wallet key.
|
|
36
|
-
|
|
37
|
-
Create the wallet-access credential in your private operator environment. This
|
|
38
|
-
command never prints it and refuses to overwrite an existing file:
|
|
18
|
+
| Entry | Purpose |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `@envarai/envarpay` | Config types/validation, integer pricing, quotes and A2A Cards |
|
|
21
|
+
| `/server` | Durable seller A2A handler, exact x402 gate and native upstream executor |
|
|
22
|
+
| `/client` | Buyer policy, atomic budgets, original-operation recovery and private management |
|
|
23
|
+
| `/mpp` | Opt-in native MPP seller and read-only Stripe reconciliation |
|
|
24
|
+
| `/envar` | Optional bounded config pull/ack and durable observation reports |
|
|
39
25
|
|
|
40
26
|
```sh
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
PY
|
|
27
|
+
npm ci --ignore-scripts
|
|
28
|
+
npm run build
|
|
29
|
+
node dist/commerce/cli.js init --directory ./private
|
|
30
|
+
node dist/commerce/cli.js validate --config ./private/seller.json
|
|
46
31
|
```
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
host = "127.0.0.1"
|
|
53
|
-
port = 4021
|
|
54
|
-
allowed_hosts = ["127.0.0.1:*", "localhost:*"]
|
|
55
|
-
bearer_token_file = "./wallet-service.token"
|
|
56
|
-
```
|
|
33
|
+
Initialization writes examples and a vault key; it never creates a purchase or
|
|
34
|
+
funds a wallet. Replace example addresses/hosts and review service limits before
|
|
35
|
+
starting. Keep Agent model credentials in the Agent, and signer keys in a separate
|
|
36
|
+
private wallet boundary.
|
|
57
37
|
|
|
58
38
|
```sh
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
service with HTTPS; only the wallet receives its key, policy and ledger. Keep the
|
|
66
|
-
wallet service private. Give the client its wallet-access token, not a seller's
|
|
67
|
-
token or an Envar machine credential.
|
|
68
|
-
|
|
69
|
-
## Discover tools and make a purchase
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
import { WalletClient, OperationUnknownError } from '@envarai/envarpay';
|
|
73
|
-
|
|
74
|
-
const wallet = await WalletClient.connect({
|
|
75
|
-
url: 'https://wallet.example/mcp',
|
|
76
|
-
token: process.env.ENVARPAY_WALLET_TOKEN!,
|
|
77
|
-
});
|
|
78
|
-
try {
|
|
79
|
-
const tools = await wallet.listPaidTools('seller');
|
|
80
|
-
console.log(tools.tools.map(tool => tool.name));
|
|
81
|
-
// This may pay within the wallet operator's existing allowlist and budget.
|
|
82
|
-
const result = await wallet.callPaidTool({
|
|
83
|
-
peer: 'seller', tool: 'ask_agent',
|
|
84
|
-
arguments: { question: 'Review my API design' }, requestId: 'review-001',
|
|
85
|
-
});
|
|
86
|
-
console.log(result.result.content);
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if (error instanceof OperationUnknownError) {
|
|
89
|
-
console.log(await wallet.paymentStatus(error.requestId));
|
|
90
|
-
// When the operator-approved seller supports recovery:
|
|
91
|
-
// await wallet.recoverPayment(error.requestId);
|
|
92
|
-
} else throw error;
|
|
93
|
-
} finally {
|
|
94
|
-
await wallet.close();
|
|
95
|
-
}
|
|
39
|
+
node dist/commerce/cli.js serve --config seller.json \
|
|
40
|
+
--credentials private/seller-auth.json --state private/seller.sqlite3 \
|
|
41
|
+
--origin https://seller.example --port 4020
|
|
42
|
+
node dist/commerce/cli.js buyer-serve --config buyer-policy.json \
|
|
43
|
+
--credentials private/buyer-auth.json --state private/buyer.sqlite3 \
|
|
44
|
+
--origin https://buyer-management.example --port 4021
|
|
96
45
|
```
|
|
97
46
|
|
|
98
|
-
The
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
For **receiving**, put the Python `envarpay` payment gate in front of your existing
|
|
122
|
-
private JS/TS MCP tool and expose only the gate through HTTPS. No buyer-wallet
|
|
123
|
-
client is needed to receive payments; the seller does not need a signing key.
|
|
124
|
-
[Seller quickstart](https://github.com/EnvarAI/EnvarPay/blob/main/docs/selling.md).
|
|
125
|
-
|
|
126
|
-
For **asynchronous escrow/acceptance/refunds**, use Python `envarpay[task]` and
|
|
127
|
-
`envarpay task wallet` with your host's standard MCP client. The task mode is an
|
|
128
|
-
experimental Base Sepolia-only flow, separate from this upfront wallet API.
|
|
129
|
-
[Task guide](https://github.com/EnvarAI/EnvarPay/blob/main/docs/tasks/README.md).
|
|
130
|
-
|
|
131
|
-
## Errors and recovery
|
|
132
|
-
|
|
133
|
-
Persist the request ID in your application before calling. Never replace it after
|
|
134
|
-
an uncertain result. The client does not retry paid calls; the wallet retains the
|
|
135
|
-
original authorization and ledger. A `WalletToolError` preserves an explicit
|
|
136
|
-
wallet refusal/execution error. An `OperationUnknownError` means the result is
|
|
137
|
-
uncertain, including transport failures and timeouts; it does not mean no payment occurred.
|
|
138
|
-
|
|
139
|
-
HTTPS is required except for loopback HTTP. Redirects are rejected, including
|
|
140
|
-
redirects to another wallet URL. The bearer token belongs to the wallet service,
|
|
141
|
-
not to a seller or public directory; keep it in the application's private config.
|
|
142
|
-
|
|
143
|
-
Targets Node.js >=22.14 and Bun. Local tests use the actual Python wallet MCP
|
|
144
|
-
adapter with a fake service and transfer no funds; they are not real payment evidence.
|
|
145
|
-
This client has no task-escrow API or framework-native plugin manifest.
|
|
146
|
-
|
|
147
|
-
## Reviewed purchases from Envar
|
|
148
|
-
|
|
149
|
-
Use the published Python EnvarPay 0.1.0a10 service with this client. `walletPolicy()` reads limits
|
|
150
|
-
and already-approved peers. `callAgent()` selects only a peer bound to the reviewed
|
|
151
|
-
Agent and endpoint IDs; it never adds a directory candidate to wallet policy. Supply
|
|
152
|
-
`expectedNetwork`, `expectedPayTo` and `expectedAmountAtomic` from the terms you
|
|
153
|
-
reviewed. A different live price is refused before signing. Keep `requestId` across
|
|
154
|
-
uncertain outcomes and use the original status/recovery methods.
|
|
155
|
-
|
|
156
|
-
The website can use the same authenticated wallet through its owner-only entry.
|
|
157
|
-
For task creation, result verification and acceptance/refunds, connect the private
|
|
158
|
-
`envarpay task wallet-serve` using a standard MCP client. The task signer and
|
|
159
|
-
contract verification stay in Python; the task mode remains experimental/testnet.
|
|
47
|
+
The management service is private and owner-authenticated. A website may request a
|
|
48
|
+
quote and confirm an already-reviewed purchase; it cannot change peers, recipient
|
|
49
|
+
allowlists, budgets or signing keys. Unknown outcomes retain the original request,
|
|
50
|
+
signature/SPT and reserved budget. Never create a replacement purchase after a
|
|
51
|
+
timeout.
|
|
52
|
+
|
|
53
|
+
[Seller and wire behavior](../../docs/a2a-commerce.md) ·
|
|
54
|
+
[Buyer management](../../docs/a2a-buyer.md) ·
|
|
55
|
+
[Continuation and recovery](../../docs/a2a-runtime-recovery.md) ·
|
|
56
|
+
[Envar configuration integration](../../docs/a2a-envar.md)
|
|
57
|
+
|
|
58
|
+
## Payment guarantees
|
|
59
|
+
|
|
60
|
+
Payment confirmation, task completion and acceptance are separate facts. The MVP
|
|
61
|
+
charges upfront for a new task; reads and permitted clarification use that same
|
|
62
|
+
purchase. It does not provide escrow, automatic refunds, subscriptions or billing
|
|
63
|
+
for arbitrary internal tools. A2A compatibility alone does not give an Agent a
|
|
64
|
+
wallet or authorize payment.
|
|
65
|
+
|
|
66
|
+
MPP requires an eligible Stripe merchant, a permitted buyer token-creation flow,
|
|
67
|
+
explicit test/live mode and any required customer authentication. Simulator tests
|
|
68
|
+
are not card charges. Provider and chain acceptance must be recorded separately
|
|
69
|
+
from SDK tests and build results.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PaymentRequired, PaymentRequirements, SettleResponse } from '@x402/core/types';
|
|
2
|
+
import type { Challenge } from 'mppx';
|
|
3
|
+
import type { MppReceipt } from './mpp-client.js';
|
|
4
|
+
import { type BuyerPolicy, type Input } from './types.js';
|
|
5
|
+
export interface BuyerQuote {
|
|
6
|
+
messageId: string;
|
|
7
|
+
payer: string;
|
|
8
|
+
amount: string;
|
|
9
|
+
currency: string | null;
|
|
10
|
+
recipient: string | null;
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
serviceId: string;
|
|
13
|
+
serviceRevision: number;
|
|
14
|
+
offerId: string;
|
|
15
|
+
inputDigest: string;
|
|
16
|
+
termsDigest: string;
|
|
17
|
+
quoteId: string;
|
|
18
|
+
/** Absent for the original seller-advertised EnvarPay contract. */
|
|
19
|
+
termsSource?: 'local-policy';
|
|
20
|
+
}
|
|
21
|
+
export interface BuyerRecord {
|
|
22
|
+
id: string;
|
|
23
|
+
caller: string;
|
|
24
|
+
peerId: string;
|
|
25
|
+
cardUrl: string;
|
|
26
|
+
endpoint: string;
|
|
27
|
+
messageId: string;
|
|
28
|
+
fingerprint: string;
|
|
29
|
+
body: string;
|
|
30
|
+
quoteToken: string;
|
|
31
|
+
quote: BuyerQuote;
|
|
32
|
+
input: Input;
|
|
33
|
+
inputSchema: Record<string, unknown>;
|
|
34
|
+
peerMode?: 'standard-a2a';
|
|
35
|
+
standardBinding?: {
|
|
36
|
+
policyDigest: string;
|
|
37
|
+
bodyDigest: string;
|
|
38
|
+
challengeDigest: string;
|
|
39
|
+
};
|
|
40
|
+
continuations?: {
|
|
41
|
+
messageId: string;
|
|
42
|
+
input: Input;
|
|
43
|
+
body?: string;
|
|
44
|
+
state: 'pending' | 'unknown' | 'resolved';
|
|
45
|
+
beforeDigest: string;
|
|
46
|
+
}[];
|
|
47
|
+
protocol?: 'x402' | 'mpp' | 'free';
|
|
48
|
+
mppChallenge?: Challenge.Challenge;
|
|
49
|
+
tokenOperationId?: string;
|
|
50
|
+
tokenRef?: string;
|
|
51
|
+
mppMode?: 'test' | 'live';
|
|
52
|
+
mppPaymentMethod?: string;
|
|
53
|
+
required?: PaymentRequired;
|
|
54
|
+
requirements?: PaymentRequirements;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
state: 'previewed' | 'signing' | 'submitted' | 'unknown' | 'completed' | 'failed';
|
|
58
|
+
paymentState: 'quoted' | 'reserved' | 'unknown' | 'confirmed' | 'rejected' | 'not_required';
|
|
59
|
+
executionState: string;
|
|
60
|
+
credentialRef?: string;
|
|
61
|
+
nonce?: string;
|
|
62
|
+
fromBlock?: string;
|
|
63
|
+
receipt?: SettleResponse | MppReceipt;
|
|
64
|
+
task?: Record<string, unknown>;
|
|
65
|
+
result?: unknown;
|
|
66
|
+
errorCode?: string;
|
|
67
|
+
rejectionEvidence?: {
|
|
68
|
+
kind: 'finalized_expired_unused';
|
|
69
|
+
checkedAt: string;
|
|
70
|
+
nonce: string;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** One wallet ledger, one process. Unknown authorizations retain cumulative budget. */
|
|
74
|
+
export declare class BuyerStore {
|
|
75
|
+
private readonly db;
|
|
76
|
+
private readonly owner?;
|
|
77
|
+
constructor(path: string);
|
|
78
|
+
close(): void;
|
|
79
|
+
private transaction;
|
|
80
|
+
get(id: string, caller: string): BuyerRecord | undefined;
|
|
81
|
+
assertMessageOwner(caller: string, messageId: string): void;
|
|
82
|
+
find(caller: string, messageId: string): BuyerRecord | undefined;
|
|
83
|
+
insert(value: Omit<BuyerRecord, 'id' | 'createdAt' | 'updatedAt' | 'state' | 'paymentState' | 'executionState'>): BuyerRecord;
|
|
84
|
+
update(id: string, patch: Partial<Pick<BuyerRecord, 'state' | 'paymentState' | 'executionState' | 'credentialRef' | 'nonce' | 'fromBlock' | 'receipt' | 'task' | 'result' | 'errorCode' | 'continuations' | 'tokenOperationId' | 'tokenRef' | 'rejectionEvidence'>>): BuyerRecord;
|
|
85
|
+
pendingTasks(): BuyerRecord[];
|
|
86
|
+
usage(currency: string): {
|
|
87
|
+
reserved: string;
|
|
88
|
+
spent: string;
|
|
89
|
+
};
|
|
90
|
+
reserve(id: string, caller: string, policy: BuyerPolicy): {
|
|
91
|
+
record: BuyerRecord;
|
|
92
|
+
fresh: boolean;
|
|
93
|
+
};
|
|
94
|
+
beginFree(id: string, caller: string): {
|
|
95
|
+
record: BuyerRecord;
|
|
96
|
+
fresh: boolean;
|
|
97
|
+
};
|
|
98
|
+
rejectBeforeSigning(id: string, caller: string, errorCode: string): BuyerRecord;
|
|
99
|
+
rejectExpiredAuthorization(id: string, caller: string): BuyerRecord;
|
|
100
|
+
confirmed(id: string, receipt: SettleResponse | MppReceipt): BuyerRecord;
|
|
101
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { chmodSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { atomic } from './config.js';
|
|
6
|
+
import { CommerceError } from './types.js';
|
|
7
|
+
/** One wallet ledger, one process. Unknown authorizations retain cumulative budget. */
|
|
8
|
+
export class BuyerStore {
|
|
9
|
+
db;
|
|
10
|
+
owner;
|
|
11
|
+
constructor(path) {
|
|
12
|
+
if (path !== ':memory:') {
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
14
|
+
this.owner = new DatabaseSync(path + '.owner.sqlite3');
|
|
15
|
+
chmodSync(path + '.owner.sqlite3', 0o600);
|
|
16
|
+
try {
|
|
17
|
+
this.owner.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
this.owner.close();
|
|
21
|
+
throw new CommerceError('store_locked', 'Buyer ledger already has an active process');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
this.db = new DatabaseSync(path);
|
|
26
|
+
if (path !== ':memory:')
|
|
27
|
+
chmodSync(path, 0o600);
|
|
28
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
|
|
29
|
+
CREATE TABLE IF NOT EXISTS buyer_meta(version INTEGER NOT NULL);
|
|
30
|
+
INSERT INTO buyer_meta SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM buyer_meta);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS buyer_purchases(
|
|
32
|
+
id TEXT PRIMARY KEY, caller TEXT NOT NULL, message_id TEXT NOT NULL,
|
|
33
|
+
currency TEXT NOT NULL, amount TEXT NOT NULL, budget_state TEXT NOT NULL,
|
|
34
|
+
record_json TEXT NOT NULL, UNIQUE(caller,message_id), UNIQUE(message_id)
|
|
35
|
+
);`);
|
|
36
|
+
if (this.db.prepare('SELECT version FROM buyer_meta').get()?.version !== 1)
|
|
37
|
+
throw new CommerceError('store_version', 'Unsupported buyer ledger version');
|
|
38
|
+
// Never resume signing after a crash: no second nonce may be created.
|
|
39
|
+
for (const row of this.db.prepare('SELECT record_json FROM buyer_purchases').all()) {
|
|
40
|
+
const record = JSON.parse(String(row.record_json));
|
|
41
|
+
if (record.state === 'signing' || record.state === 'submitted')
|
|
42
|
+
this.update(record.id, { state: 'unknown', paymentState: ['confirmed', 'not_required'].includes(record.paymentState) ? record.paymentState : 'unknown', errorCode: 'interrupted_purchase' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
this.owner?.close();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
close() { this.db.close(); if (this.owner) {
|
|
51
|
+
this.owner.exec('ROLLBACK');
|
|
52
|
+
this.owner.close();
|
|
53
|
+
} }
|
|
54
|
+
transaction(fn) {
|
|
55
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
56
|
+
try {
|
|
57
|
+
const result = fn();
|
|
58
|
+
this.db.exec('COMMIT');
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.db.exec('ROLLBACK');
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
get(id, caller) {
|
|
67
|
+
const row = this.db.prepare('SELECT record_json FROM buyer_purchases WHERE id=? AND caller=?').get(id, caller);
|
|
68
|
+
return row ? JSON.parse(String(row.record_json)) : undefined;
|
|
69
|
+
}
|
|
70
|
+
assertMessageOwner(caller, messageId) {
|
|
71
|
+
const row = this.db.prepare('SELECT caller FROM buyer_purchases WHERE message_id=?').get(messageId);
|
|
72
|
+
if (row && row.caller !== caller)
|
|
73
|
+
throw new CommerceError('purchase_conflict', 'Message ID is already reserved to another caller');
|
|
74
|
+
}
|
|
75
|
+
find(caller, messageId) {
|
|
76
|
+
const row = this.db.prepare('SELECT record_json FROM buyer_purchases WHERE caller=? AND message_id=?').get(caller, messageId);
|
|
77
|
+
return row ? JSON.parse(String(row.record_json)) : undefined;
|
|
78
|
+
}
|
|
79
|
+
insert(value) {
|
|
80
|
+
return this.transaction(() => {
|
|
81
|
+
this.assertMessageOwner(value.caller, value.messageId);
|
|
82
|
+
const prior = this.find(value.caller, value.messageId);
|
|
83
|
+
if (prior) {
|
|
84
|
+
if (prior.fingerprint !== value.fingerprint)
|
|
85
|
+
throw new CommerceError('purchase_conflict', 'Message ID already identifies another purchase');
|
|
86
|
+
return prior;
|
|
87
|
+
}
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
const record = { ...value, id: randomUUID(), createdAt: now, updatedAt: now, state: 'previewed', paymentState: 'quoted', executionState: 'not_started' };
|
|
90
|
+
this.db.prepare('INSERT INTO buyer_purchases VALUES(?,?,?,?,?,?,?)').run(record.id, record.caller, record.messageId, record.quote.currency ?? '', record.quote.amount, 'none', JSON.stringify(record));
|
|
91
|
+
return record;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
update(id, patch) {
|
|
95
|
+
const row = this.db.prepare('SELECT record_json FROM buyer_purchases WHERE id=?').get(id);
|
|
96
|
+
if (!row)
|
|
97
|
+
throw new CommerceError('purchase_not_found', 'Purchase not found');
|
|
98
|
+
const record = { ...JSON.parse(String(row.record_json)), ...patch, updatedAt: new Date().toISOString() };
|
|
99
|
+
this.db.prepare('UPDATE buyer_purchases SET record_json=? WHERE id=?').run(JSON.stringify(record), id);
|
|
100
|
+
return record;
|
|
101
|
+
}
|
|
102
|
+
pendingTasks() {
|
|
103
|
+
return this.db.prepare('SELECT record_json FROM buyer_purchases').all()
|
|
104
|
+
.map(row => JSON.parse(String(row.record_json)))
|
|
105
|
+
.filter(record => record.task?.id && ['working', 'unknown'].includes(record.executionState));
|
|
106
|
+
}
|
|
107
|
+
usage(currency) {
|
|
108
|
+
let reserved = 0n, spent = 0n;
|
|
109
|
+
for (const row of this.db.prepare("SELECT amount,budget_state FROM buyer_purchases WHERE currency=? AND budget_state!='none'").all(currency)) {
|
|
110
|
+
if (row.budget_state === 'spent')
|
|
111
|
+
spent += BigInt(String(row.amount));
|
|
112
|
+
else
|
|
113
|
+
reserved += BigInt(String(row.amount));
|
|
114
|
+
}
|
|
115
|
+
return { reserved: reserved.toString(), spent: spent.toString() };
|
|
116
|
+
}
|
|
117
|
+
reserve(id, caller, policy) {
|
|
118
|
+
return this.transaction(() => {
|
|
119
|
+
const record = this.get(id, caller);
|
|
120
|
+
if (!record)
|
|
121
|
+
throw new CommerceError('purchase_not_found', 'Purchase not found');
|
|
122
|
+
if (record.state !== 'previewed')
|
|
123
|
+
return { record, fresh: false };
|
|
124
|
+
const limit = policy.budgets.find(b => b.currency === record.quote.currency);
|
|
125
|
+
const usage = this.usage(record.quote.currency ?? '');
|
|
126
|
+
if (!limit || BigInt(usage.reserved) + BigInt(usage.spent) + atomic(record.quote.amount) > atomic(limit.maxTotal))
|
|
127
|
+
throw new CommerceError('budget_exceeded', 'Cumulative wallet budget is exhausted or reserved');
|
|
128
|
+
this.db.prepare("UPDATE buyer_purchases SET budget_state='reserved' WHERE id=?").run(id);
|
|
129
|
+
return { record: this.update(id, { state: 'signing', paymentState: 'reserved' }), fresh: true };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
beginFree(id, caller) {
|
|
133
|
+
return this.transaction(() => {
|
|
134
|
+
const record = this.get(id, caller);
|
|
135
|
+
if (!record || record.protocol !== 'free' || record.quote.amount !== '0' || record.quote.currency !== null || record.quote.recipient !== null)
|
|
136
|
+
throw new CommerceError('free_purchase_binding', 'Invalid original free purchase');
|
|
137
|
+
if (record.state !== 'previewed')
|
|
138
|
+
return { record, fresh: false };
|
|
139
|
+
return { record: this.update(id, { state: 'submitted', paymentState: 'not_required' }), fresh: true };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
rejectBeforeSigning(id, caller, errorCode) {
|
|
143
|
+
return this.transaction(() => {
|
|
144
|
+
const record = this.get(id, caller);
|
|
145
|
+
if (!record || record.state !== 'signing' || record.credentialRef || record.nonce)
|
|
146
|
+
throw new CommerceError('authorization_may_exist', 'Cannot release an existing or ambiguous authorization');
|
|
147
|
+
this.db.prepare("UPDATE buyer_purchases SET budget_state='none' WHERE id=? AND budget_state='reserved'").run(id);
|
|
148
|
+
return this.update(id, { state: 'failed', paymentState: 'rejected', executionState: 'not_started', errorCode });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
rejectExpiredAuthorization(id, caller) {
|
|
152
|
+
return this.transaction(() => {
|
|
153
|
+
const record = this.get(id, caller);
|
|
154
|
+
if (!record || record.protocol === 'mpp' || record.protocol === 'free' || !record.credentialRef || !record.nonce)
|
|
155
|
+
throw new CommerceError('authorization_recovery_required', 'Original x402 authorization is required');
|
|
156
|
+
if (record.paymentState === 'confirmed')
|
|
157
|
+
return record;
|
|
158
|
+
this.db.prepare("UPDATE buyer_purchases SET budget_state='none' WHERE id=? AND budget_state='reserved'").run(id);
|
|
159
|
+
return this.update(id, { state: 'failed', paymentState: 'rejected', errorCode: 'authorization_expired_unused', rejectionEvidence: { kind: 'finalized_expired_unused', checkedAt: new Date().toISOString(), nonce: record.nonce } });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
confirmed(id, receipt) {
|
|
163
|
+
return this.transaction(() => {
|
|
164
|
+
this.db.prepare("UPDATE buyer_purchases SET budget_state='spent' WHERE id=? AND budget_state IN ('reserved','spent')").run(id);
|
|
165
|
+
return this.update(id, { paymentState: 'confirmed', receipt });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ClientEvmSigner } from '@x402/evm';
|
|
2
|
+
import type { PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types';
|
|
3
|
+
import { type BuyerPolicy, type Input, type StandardA2APeer } from './types.js';
|
|
4
|
+
import { CredentialVault } from './vault.js';
|
|
5
|
+
import { BuyerStore, type BuyerQuote } from './buyer-store.js';
|
|
6
|
+
import { type MppBuyerOptions, type MppReceipt } from './mpp-client.js';
|
|
7
|
+
export interface BuyerPreviewInput {
|
|
8
|
+
cardUrl: string;
|
|
9
|
+
messageId: string;
|
|
10
|
+
input: Input;
|
|
11
|
+
offerId: string;
|
|
12
|
+
}
|
|
13
|
+
export interface BuyerConfirmInput {
|
|
14
|
+
previewId: string;
|
|
15
|
+
quoteToken: string;
|
|
16
|
+
messageId: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CommerceBuyerOptions {
|
|
19
|
+
policy: BuyerPolicy;
|
|
20
|
+
store: BuyerStore;
|
|
21
|
+
vault?: CredentialVault;
|
|
22
|
+
signer?: ClientEvmSigner;
|
|
23
|
+
mpp?: MppBuyerOptions;
|
|
24
|
+
peerTokens: Readonly<Record<string, string>>;
|
|
25
|
+
/** Override sibling /a2a only when the local wallet owner explicitly allows it. */
|
|
26
|
+
peerEndpoints?: Readonly<Record<string, string>>;
|
|
27
|
+
verifyReceipt?: (receipt: SettleResponse, payload: PaymentPayload, requirements: PaymentRequirements) => Promise<boolean>;
|
|
28
|
+
checkpoint?: (requirements: PaymentRequirements) => Promise<string>;
|
|
29
|
+
proveExpiredUnused?: (payload: PaymentPayload, requirements: PaymentRequirements) => Promise<boolean>;
|
|
30
|
+
findOriginalReceipt?: (payload: PaymentPayload, requirements: PaymentRequirements, fromBlock: string) => Promise<SettleResponse | undefined>;
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
export interface BuyerSnapshot {
|
|
34
|
+
id: string;
|
|
35
|
+
quoteToken: string;
|
|
36
|
+
messageId: string;
|
|
37
|
+
payer: string;
|
|
38
|
+
state: string;
|
|
39
|
+
quote: BuyerQuote;
|
|
40
|
+
paymentState: string;
|
|
41
|
+
executionState: string;
|
|
42
|
+
task?: Record<string, unknown>;
|
|
43
|
+
result?: unknown;
|
|
44
|
+
errorCode?: string;
|
|
45
|
+
continuation?: {
|
|
46
|
+
messageId: string;
|
|
47
|
+
state: 'pending' | 'unknown' | 'resolved';
|
|
48
|
+
input: Input;
|
|
49
|
+
};
|
|
50
|
+
receipt?: SettleResponse | MppReceipt;
|
|
51
|
+
nonce?: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
updatedAt: string;
|
|
54
|
+
}
|
|
55
|
+
/** Independent x402 wallet; payment and execution state are deliberately separate. */
|
|
56
|
+
export declare class CommerceBuyer {
|
|
57
|
+
private readonly options;
|
|
58
|
+
readonly policy: BuyerPolicy;
|
|
59
|
+
private readonly fetchImpl;
|
|
60
|
+
private readonly active;
|
|
61
|
+
private timer?;
|
|
62
|
+
private readonly previews;
|
|
63
|
+
constructor(options: CommerceBuyerOptions);
|
|
64
|
+
/** Background observation only; never signs, replays credentials or changes reservations. */
|
|
65
|
+
start(intervalMs?: number): void;
|
|
66
|
+
stop(): Promise<void>;
|
|
67
|
+
policySnapshot(): {
|
|
68
|
+
capabilities: ("free" | "x402" | "mpp")[];
|
|
69
|
+
state: string;
|
|
70
|
+
payer: string | undefined;
|
|
71
|
+
budgets: {
|
|
72
|
+
reserved: string;
|
|
73
|
+
spent: string;
|
|
74
|
+
currency: string;
|
|
75
|
+
maxTotal: string;
|
|
76
|
+
period: "cumulative";
|
|
77
|
+
}[];
|
|
78
|
+
policyVersion: 1;
|
|
79
|
+
paymentsEnabled: boolean;
|
|
80
|
+
approval: "per_purchase" | "within_preapproved_limits";
|
|
81
|
+
peers: (StandardA2APeer | {
|
|
82
|
+
id: string;
|
|
83
|
+
cardUrl: string;
|
|
84
|
+
mode?: "envarpay";
|
|
85
|
+
protocol: "x402" | "mpp";
|
|
86
|
+
currency: string;
|
|
87
|
+
recipient: string;
|
|
88
|
+
maxPerPurchase: string;
|
|
89
|
+
} | {
|
|
90
|
+
id: string;
|
|
91
|
+
cardUrl: string;
|
|
92
|
+
mode?: "envarpay";
|
|
93
|
+
protocol: "free";
|
|
94
|
+
currency: null;
|
|
95
|
+
recipient: null;
|
|
96
|
+
maxPerPurchase: "0";
|
|
97
|
+
})[];
|
|
98
|
+
};
|
|
99
|
+
private peer;
|
|
100
|
+
private endpoint;
|
|
101
|
+
private scopedFetch;
|
|
102
|
+
private record;
|
|
103
|
+
private snapshot;
|
|
104
|
+
get(caller: string, id: string): BuyerSnapshot;
|
|
105
|
+
preview(caller: string, input: BuyerPreviewInput): Promise<BuyerSnapshot>;
|
|
106
|
+
private prepare;
|
|
107
|
+
private prepareStandard;
|
|
108
|
+
private prepareFree;
|
|
109
|
+
private replayFree;
|
|
110
|
+
private prepareMpp;
|
|
111
|
+
private mppCredential;
|
|
112
|
+
private purchaseMpp;
|
|
113
|
+
private savedMpp;
|
|
114
|
+
private verifyMpp;
|
|
115
|
+
private replayMpp;
|
|
116
|
+
private recoverMpp;
|
|
117
|
+
private assertCurrentPolicy;
|
|
118
|
+
confirm(caller: string, input: BuyerConfirmInput): Promise<BuyerSnapshot>;
|
|
119
|
+
private exclusive;
|
|
120
|
+
private purchase;
|
|
121
|
+
private saved;
|
|
122
|
+
private verify;
|
|
123
|
+
private task;
|
|
124
|
+
private replay;
|
|
125
|
+
private client;
|
|
126
|
+
private observe;
|
|
127
|
+
continue(caller: string, id: string, input: {
|
|
128
|
+
messageId: string;
|
|
129
|
+
input: Input;
|
|
130
|
+
}): Promise<BuyerSnapshot>;
|
|
131
|
+
recover(caller: string, id: string): Promise<BuyerSnapshot>;
|
|
132
|
+
}
|