@envarai/envarpay 0.1.0-alpha.3 → 0.2.0-alpha.10
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 +57 -145
- package/dist/commerce/alerts.d.ts +37 -0
- package/dist/commerce/alerts.js +127 -0
- package/dist/commerce/buyer-store.d.ts +101 -0
- package/dist/commerce/buyer-store.js +168 -0
- package/dist/commerce/buyer.d.ts +138 -0
- package/dist/commerce/buyer.js +775 -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 +318 -0
- package/dist/commerce/client-runtime.d.ts +8 -0
- package/dist/commerce/client-runtime.js +7 -0
- package/dist/commerce/config.d.ts +11 -0
- package/dist/commerce/config.js +200 -0
- package/dist/commerce/envar-runtime.d.ts +47 -0
- package/dist/commerce/envar-runtime.js +276 -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 +5 -0
- package/dist/commerce/index.js +5 -0
- package/dist/commerce/management.d.ts +14 -0
- package/dist/commerce/management.js +140 -0
- package/dist/commerce/mpp-client.d.ts +70 -0
- package/dist/commerce/mpp-client.js +29 -0
- package/dist/commerce/mpp-runtime.d.ts +4 -0
- package/dist/commerce/mpp-runtime.js +4 -0
- package/dist/commerce/mpp.d.ts +53 -0
- package/dist/commerce/mpp.js +163 -0
- package/dist/commerce/native-skills.d.ts +27 -0
- package/dist/commerce/native-skills.js +176 -0
- package/dist/commerce/operations.d.ts +22 -0
- package/dist/commerce/operations.js +39 -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 +10 -0
- package/dist/commerce/runtime.js +10 -0
- package/dist/commerce/server.d.ts +61 -0
- package/dist/commerce/server.js +516 -0
- package/dist/commerce/setup.d.ts +18 -0
- package/dist/commerce/setup.js +344 -0
- package/dist/commerce/skill-gate.d.ts +54 -0
- package/dist/commerce/skill-gate.js +153 -0
- package/dist/commerce/store.d.ts +88 -0
- package/dist/commerce/store.js +473 -0
- package/dist/commerce/stripe-issuer.d.ts +41 -0
- package/dist/commerce/stripe-issuer.js +351 -0
- package/dist/commerce/stripe-provider.d.ts +11 -0
- package/dist/commerce/stripe-provider.js +132 -0
- package/dist/commerce/types.d.ts +138 -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 +38 -0
- package/dist/commerce/x402.js +181 -0
- package/examples/buyer-policy.json +18 -0
- package/examples/envar-integration-policy.json +18 -0
- package/examples/hermes-setup-probe.py +105 -0
- package/examples/native-skill-runner.py +197 -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 +383 -0
- package/schemas/quote.schema.json +91 -0
package/README.md
CHANGED
|
@@ -1,159 +1,71 @@
|
|
|
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
|
+
For Hermes onboarding, see [guided Skill setup](../../docs/hermes-skill-setup.md). The `setup --file ...` command reads the owner's local model configuration and creates a private seller with an empty catalog, ready for the first service to be configured in Envar.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
Node.js 22.14+ (Node 24 recommended). SQLite is single-instance. Contracts can be
|
|
17
|
+
read without loading the seller or buyer runtime; Bun validation covers the
|
|
18
|
+
contracts entry, not the SQLite server.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
| Entry | Purpose |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `@envarai/envarpay` | Config types/validation, integer pricing, quotes and A2A Cards |
|
|
23
|
+
| `/server` | Durable seller A2A handler, exact x402 gate and native upstream executor |
|
|
24
|
+
| `/client` | Buyer policy, atomic budgets, original-operation recovery and private management |
|
|
25
|
+
| `/mpp` | Opt-in native MPP seller and read-only Stripe reconciliation |
|
|
26
|
+
| `/envar` | Optional bounded config pull/ack and durable observation reports |
|
|
24
27
|
|
|
25
28
|
```sh
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
envarpay keygen --output ./buyer/buyer.key
|
|
29
|
+
npm ci --ignore-scripts
|
|
30
|
+
npm run build
|
|
31
|
+
node dist/commerce/cli.js init --directory ./private
|
|
32
|
+
node dist/commerce/cli.js validate --config ./private/seller.json
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
35
|
+
Initialization writes examples and a vault key; it never creates a purchase or
|
|
36
|
+
funds a wallet. Replace example addresses/hosts and review service limits before
|
|
37
|
+
starting. Keep Agent model credentials in the Agent, and signer keys in a separate
|
|
38
|
+
private wallet boundary.
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Add this table to the existing `buyer.toml`:
|
|
49
|
-
|
|
50
|
-
```toml
|
|
51
|
-
[wallet_server]
|
|
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
|
-
```
|
|
57
|
-
|
|
58
|
-
```sh
|
|
59
|
-
envarpay doctor --config ./buyer/buyer.toml
|
|
60
|
-
envarpay wallet-serve --config ./buyer/buyer.toml
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
For local development, connect to `http://127.0.0.1:4021/mcp`. For a separate wallet
|
|
64
|
-
permission boundary, run it under another OS identity/container or on a private
|
|
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
|
-
}
|
|
41
|
+
node dist/commerce/cli.js serve --config seller.json \
|
|
42
|
+
--credentials private/seller-auth.json --state private/seller.sqlite3 \
|
|
43
|
+
--origin https://seller.example --port 4020
|
|
44
|
+
node dist/commerce/cli.js buyer-serve --config buyer-policy.json \
|
|
45
|
+
--credentials private/buyer-auth.json --state private/buyer.sqlite3 \
|
|
46
|
+
--origin https://buyer-management.example --port 4021
|
|
96
47
|
```
|
|
97
48
|
|
|
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.
|
|
49
|
+
The management service is private and owner-authenticated. A website may request a
|
|
50
|
+
quote and confirm an already-reviewed purchase; it cannot change peers, recipient
|
|
51
|
+
allowlists, budgets or signing keys. Unknown outcomes retain the original request,
|
|
52
|
+
signature/SPT and reserved budget. Never create a replacement purchase after a
|
|
53
|
+
timeout.
|
|
54
|
+
|
|
55
|
+
[Seller and wire behavior](../../docs/a2a-commerce.md) ·
|
|
56
|
+
[Buyer management](../../docs/a2a-buyer.md) ·
|
|
57
|
+
[Continuation and recovery](../../docs/a2a-runtime-recovery.md) ·
|
|
58
|
+
[Envar configuration integration](../../docs/a2a-envar.md)
|
|
59
|
+
|
|
60
|
+
## Payment guarantees
|
|
61
|
+
|
|
62
|
+
Payment confirmation, task completion and acceptance are separate facts. The MVP
|
|
63
|
+
charges upfront for a new task; reads and permitted clarification use that same
|
|
64
|
+
purchase. It does not provide escrow, automatic refunds, subscriptions or billing
|
|
65
|
+
for arbitrary internal tools. A2A compatibility alone does not give an Agent a
|
|
66
|
+
wallet or authorize payment.
|
|
67
|
+
|
|
68
|
+
MPP requires an eligible Stripe merchant, a permitted buyer token-creation flow,
|
|
69
|
+
explicit test/live mode and any required customer authentication. Simulator tests
|
|
70
|
+
are not card charges. Provider and chain acceptance must be recorded separately
|
|
71
|
+
from SDK tests and build results.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type SignalName = 'ledger_read' | 'payment_unknown' | 'execution_unknown' | 'recorded_provider_failure' | 'budget_near_limit' | 'budget_exhausted' | 'report_backlog' | 'report_failure';
|
|
2
|
+
type Signal = {
|
|
3
|
+
name: SignalName;
|
|
4
|
+
status: 'clear' | 'alert' | 'unavailable' | 'not_applicable';
|
|
5
|
+
severity: 'info' | 'warning' | 'critical';
|
|
6
|
+
count: number | null;
|
|
7
|
+
};
|
|
8
|
+
type Budget = {
|
|
9
|
+
unit: string;
|
|
10
|
+
maxTotal: string;
|
|
11
|
+
reserved: string;
|
|
12
|
+
spent: string;
|
|
13
|
+
remaining: string;
|
|
14
|
+
state: 'available' | 'near_limit' | 'exhausted';
|
|
15
|
+
};
|
|
16
|
+
export interface LocalAlertOptions {
|
|
17
|
+
policyPath?: string;
|
|
18
|
+
integrationPath?: string;
|
|
19
|
+
integrationDisabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Owner-local aggregate evidence only. Never opens a runtime store, signer, vault or network. */
|
|
22
|
+
export declare function inspectLocalAlerts(path: string, options?: LocalAlertOptions): {
|
|
23
|
+
role: "unavailable" | "buyer" | "seller";
|
|
24
|
+
view: "alerts";
|
|
25
|
+
observation: "local_persisted_state_only";
|
|
26
|
+
status: string;
|
|
27
|
+
signals: Signal[];
|
|
28
|
+
budgets: Budget[];
|
|
29
|
+
alertCount: number;
|
|
30
|
+
unavailableCount: number;
|
|
31
|
+
providerHealth: "unavailable_not_probed";
|
|
32
|
+
recommendedExitCode: number;
|
|
33
|
+
paymentPerformed: boolean;
|
|
34
|
+
refundPerformed: boolean;
|
|
35
|
+
networkRequests: number;
|
|
36
|
+
};
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { loadBuyerPolicy } from './config.js';
|
|
4
|
+
const CURRENCIES = {
|
|
5
|
+
usd: 'usd_cents',
|
|
6
|
+
'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 'base_usdc_atomic',
|
|
7
|
+
'eip155:84532/erc20:0x036cbd53842c5426634e7929541ec2318f3dcf7e': 'base_sepolia_usdc_atomic',
|
|
8
|
+
};
|
|
9
|
+
function privateFile(path, limit) {
|
|
10
|
+
const stat = lstatSync(path);
|
|
11
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()) || (limit !== undefined && stat.size > limit))
|
|
12
|
+
throw new Error('private_source_unavailable');
|
|
13
|
+
}
|
|
14
|
+
function database(path) {
|
|
15
|
+
privateFile(path);
|
|
16
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
17
|
+
try {
|
|
18
|
+
db.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=1000; BEGIN');
|
|
19
|
+
return db;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
db.close();
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const count = (db, sql) => Number(db.prepare(sql).get().n);
|
|
27
|
+
const signal = (name, value, severity = 'warning') => ({ name, status: value ? 'alert' : 'clear', severity: value ? severity : 'info', count: value });
|
|
28
|
+
const missing = (name) => ({ name, status: 'unavailable', severity: 'warning', count: null });
|
|
29
|
+
const irrelevant = (name) => ({ name, status: 'not_applicable', severity: 'info', count: null });
|
|
30
|
+
/** Owner-local aggregate evidence only. Never opens a runtime store, signer, vault or network. */
|
|
31
|
+
export function inspectLocalAlerts(path, options = {}) {
|
|
32
|
+
const signals = [], budgets = [];
|
|
33
|
+
let role = 'unavailable', db;
|
|
34
|
+
try {
|
|
35
|
+
db = database(path);
|
|
36
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name));
|
|
37
|
+
if (tables.has('buyer_purchases') === tables.has('commerce_orders'))
|
|
38
|
+
throw new Error('unknown_ledger');
|
|
39
|
+
role = tables.has('buyer_purchases') ? 'buyer' : 'seller';
|
|
40
|
+
if (db.prepare(`SELECT version FROM ${role === 'buyer' ? 'buyer_meta' : 'commerce_meta'}`).get()?.version !== (role === 'buyer' ? 1 : 2))
|
|
41
|
+
throw new Error('unknown_version');
|
|
42
|
+
const unknownPayments = count(db, role === 'buyer' ? "SELECT COUNT(*) n FROM buyer_purchases WHERE json_extract(record_json,'$.paymentState')='unknown'" : "SELECT COUNT(*) n FROM commerce_orders WHERE payment_state='unknown'");
|
|
43
|
+
const unknownExecution = count(db, role === 'buyer' ? "SELECT COUNT(*) n FROM buyer_purchases WHERE json_extract(record_json,'$.executionState')='unknown'" : "SELECT COUNT(*) n FROM commerce_orders WHERE execution_state='unknown'");
|
|
44
|
+
// Only concrete persisted provider errors count. Generic unknown is not proof of provider failure.
|
|
45
|
+
const providerFailures = count(db, role === 'buyer'
|
|
46
|
+
? "SELECT COUNT(*) n FROM buyer_purchases WHERE json_extract(record_json,'$.paymentState')!='confirmed' AND json_extract(record_json,'$.errorCode') IN ('stripe_transport_unknown','stripe_request_failed','stripe_rate_limit','stripe_response','stripe_response_size')"
|
|
47
|
+
: "SELECT COUNT(*) n FROM commerce_payment_attempts WHERE state!='confirmed' AND json_extract(receipt_json,'$.success')=0 AND length(json_extract(receipt_json,'$.errorReason'))>0");
|
|
48
|
+
signals.push(signal('ledger_read', 0), signal('payment_unknown', unknownPayments), signal('execution_unknown', unknownExecution), signal('recorded_provider_failure', providerFailures));
|
|
49
|
+
if (role === 'seller')
|
|
50
|
+
signals.push(irrelevant('budget_near_limit'), irrelevant('budget_exhausted'));
|
|
51
|
+
else {
|
|
52
|
+
try {
|
|
53
|
+
if (!options.policyPath)
|
|
54
|
+
throw new Error('policy_required');
|
|
55
|
+
privateFile(options.policyPath, 1024 * 1024);
|
|
56
|
+
const policy = loadBuyerPolicy(JSON.parse(readFileSync(options.policyPath, 'utf8')));
|
|
57
|
+
const usage = new Map();
|
|
58
|
+
for (const b of policy.budgets)
|
|
59
|
+
usage.set(b.currency, { reserved: 0n, spent: 0n });
|
|
60
|
+
for (const row of db.prepare("SELECT currency,amount,budget_state FROM buyer_purchases WHERE budget_state!='none'").iterate()) {
|
|
61
|
+
const currency = String(row.currency), amount = String(row.amount), state = row.budget_state;
|
|
62
|
+
const target = usage.get(currency);
|
|
63
|
+
if (!target || !/^[1-9][0-9]{0,77}$/.test(amount) || !['spent', 'reserved'].includes(String(state)))
|
|
64
|
+
throw new Error('budget_binding');
|
|
65
|
+
target[state === 'spent' ? 'spent' : 'reserved'] += BigInt(amount);
|
|
66
|
+
}
|
|
67
|
+
const checked = policy.budgets.map(b => {
|
|
68
|
+
const u = usage.get(b.currency), cap = BigInt(b.maxTotal), used = u.reserved + u.spent;
|
|
69
|
+
const state = used >= cap ? 'exhausted' : used * 10n >= cap * 9n ? 'near_limit' : 'available';
|
|
70
|
+
return { unit: CURRENCIES[b.currency], maxTotal: cap.toString(), reserved: u.reserved.toString(), spent: u.spent.toString(), remaining: (used < cap ? cap - used : 0n).toString(), state };
|
|
71
|
+
});
|
|
72
|
+
budgets.push(...checked);
|
|
73
|
+
signals.push(signal('budget_near_limit', checked.filter(b => b.state === 'near_limit').length), signal('budget_exhausted', checked.filter(b => b.state === 'exhausted').length, 'critical'));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
signals.push(missing('budget_near_limit'), missing('budget_exhausted'));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
role = 'unavailable';
|
|
82
|
+
signals.length = 0;
|
|
83
|
+
budgets.length = 0;
|
|
84
|
+
signals.push(...['ledger_read', 'payment_unknown', 'execution_unknown', 'recorded_provider_failure', 'budget_near_limit', 'budget_exhausted'].map(missing));
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
db?.close();
|
|
88
|
+
}
|
|
89
|
+
let integration;
|
|
90
|
+
try {
|
|
91
|
+
if (options.integrationDisabled && !options.integrationPath) {
|
|
92
|
+
signals.push(irrelevant('report_backlog'), irrelevant('report_failure'));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
if (options.integrationDisabled)
|
|
96
|
+
throw new Error('ambiguous_integration');
|
|
97
|
+
if (!options.integrationPath)
|
|
98
|
+
throw new Error('integration_not_supplied');
|
|
99
|
+
integration = database(options.integrationPath);
|
|
100
|
+
const binding = integration.prepare("SELECT value FROM meta WHERE key='binding'").get();
|
|
101
|
+
const source = integration.prepare("SELECT value FROM meta WHERE key='source_instance'").get();
|
|
102
|
+
if (!binding || !source || typeof binding.value !== 'string' || typeof source.value !== 'string')
|
|
103
|
+
throw new Error('integration_schema');
|
|
104
|
+
const parsed = JSON.parse(binding.value);
|
|
105
|
+
if (!parsed || typeof parsed.platformOrigin !== 'string' || typeof parsed.agentId !== 'string' || typeof parsed.runtimeAgentId !== 'string')
|
|
106
|
+
throw new Error('integration_schema');
|
|
107
|
+
const pending = count(integration, "SELECT COUNT(*) n FROM outbox WHERE state='pending'");
|
|
108
|
+
const failed = count(integration, "SELECT COUNT(*) n FROM outbox WHERE state='pending' AND attempts>0 AND last_error<>''");
|
|
109
|
+
signals.push(signal('report_backlog', pending), signal('report_failure', failed));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
signals.push(missing('report_backlog'), missing('report_failure'));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
integration?.close();
|
|
117
|
+
}
|
|
118
|
+
const alertCount = signals.filter(s => s.status === 'alert').length, unavailableCount = signals.filter(s => s.status === 'unavailable').length;
|
|
119
|
+
return {
|
|
120
|
+
role, view: 'alerts', observation: 'local_persisted_state_only',
|
|
121
|
+
status: role === 'unavailable' ? 'unavailable' : unavailableCount ? 'partial' : 'available',
|
|
122
|
+
signals, budgets, alertCount, unavailableCount,
|
|
123
|
+
providerHealth: 'unavailable_not_probed',
|
|
124
|
+
recommendedExitCode: unavailableCount ? 3 : alertCount ? 2 : 0,
|
|
125
|
+
paymentPerformed: false, refundPerformed: false, networkRequests: 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -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
|
+
}
|