@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.
Files changed (72) hide show
  1. package/README.md +57 -145
  2. package/dist/commerce/alerts.d.ts +37 -0
  3. package/dist/commerce/alerts.js +127 -0
  4. package/dist/commerce/buyer-store.d.ts +101 -0
  5. package/dist/commerce/buyer-store.js +168 -0
  6. package/dist/commerce/buyer.d.ts +138 -0
  7. package/dist/commerce/buyer.js +775 -0
  8. package/dist/commerce/card.d.ts +5 -0
  9. package/dist/commerce/card.js +29 -0
  10. package/dist/commerce/chain.d.ts +24 -0
  11. package/dist/commerce/chain.js +146 -0
  12. package/dist/commerce/cli.d.ts +2 -0
  13. package/dist/commerce/cli.js +318 -0
  14. package/dist/commerce/client-runtime.d.ts +8 -0
  15. package/dist/commerce/client-runtime.js +7 -0
  16. package/dist/commerce/config.d.ts +11 -0
  17. package/dist/commerce/config.js +200 -0
  18. package/dist/commerce/envar-runtime.d.ts +47 -0
  19. package/dist/commerce/envar-runtime.js +276 -0
  20. package/dist/commerce/envar.d.ts +126 -0
  21. package/dist/commerce/envar.js +647 -0
  22. package/dist/commerce/http.d.ts +2 -0
  23. package/dist/commerce/http.js +34 -0
  24. package/dist/commerce/index.d.ts +5 -0
  25. package/dist/commerce/index.js +5 -0
  26. package/dist/commerce/management.d.ts +14 -0
  27. package/dist/commerce/management.js +140 -0
  28. package/dist/commerce/mpp-client.d.ts +70 -0
  29. package/dist/commerce/mpp-client.js +29 -0
  30. package/dist/commerce/mpp-runtime.d.ts +4 -0
  31. package/dist/commerce/mpp-runtime.js +4 -0
  32. package/dist/commerce/mpp.d.ts +53 -0
  33. package/dist/commerce/mpp.js +163 -0
  34. package/dist/commerce/native-skills.d.ts +27 -0
  35. package/dist/commerce/native-skills.js +176 -0
  36. package/dist/commerce/operations.d.ts +22 -0
  37. package/dist/commerce/operations.js +39 -0
  38. package/dist/commerce/peer-proxy.d.ts +44 -0
  39. package/dist/commerce/peer-proxy.js +673 -0
  40. package/dist/commerce/pricing.d.ts +12 -0
  41. package/dist/commerce/pricing.js +60 -0
  42. package/dist/commerce/runtime.d.ts +10 -0
  43. package/dist/commerce/runtime.js +10 -0
  44. package/dist/commerce/server.d.ts +61 -0
  45. package/dist/commerce/server.js +516 -0
  46. package/dist/commerce/setup.d.ts +18 -0
  47. package/dist/commerce/setup.js +344 -0
  48. package/dist/commerce/skill-gate.d.ts +54 -0
  49. package/dist/commerce/skill-gate.js +153 -0
  50. package/dist/commerce/store.d.ts +88 -0
  51. package/dist/commerce/store.js +473 -0
  52. package/dist/commerce/stripe-issuer.d.ts +41 -0
  53. package/dist/commerce/stripe-issuer.js +351 -0
  54. package/dist/commerce/stripe-provider.d.ts +11 -0
  55. package/dist/commerce/stripe-provider.js +132 -0
  56. package/dist/commerce/types.d.ts +138 -0
  57. package/dist/commerce/types.js +8 -0
  58. package/dist/commerce/upstream.d.ts +6 -0
  59. package/dist/commerce/upstream.js +103 -0
  60. package/dist/commerce/vault.d.ts +8 -0
  61. package/dist/commerce/vault.js +55 -0
  62. package/dist/commerce/x402.d.ts +38 -0
  63. package/dist/commerce/x402.js +181 -0
  64. package/examples/buyer-policy.json +18 -0
  65. package/examples/envar-integration-policy.json +18 -0
  66. package/examples/hermes-setup-probe.py +105 -0
  67. package/examples/native-skill-runner.py +197 -0
  68. package/examples/seller.json +52 -0
  69. package/package.json +75 -10
  70. package/schemas/buyer-policy.schema.json +253 -0
  71. package/schemas/envarpay.config.schema.json +383 -0
  72. package/schemas/quote.schema.json +91 -0
package/README.md CHANGED
@@ -1,159 +1,71 @@
1
- # @envarai/envarpay
1
+ # EnvarPay TypeScript
2
2
 
3
- A TypeScript client for an existing **authenticated EnvarPay wallet MCP service**.
4
- The client does not install Python, run a signer, hold wallet keys, or change wallet
5
- policy. Use the Python CLI or a separately operated service for those responsibilities.
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
- This client uses the alpha release channel. See
8
- [installation channels](https://github.com/EnvarAI/EnvarPay/blob/main/docs/packages.md)
9
- for the current registry status and available distributions.
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
- ## Install and start a wallet
12
+ ## Runtime and entry points
12
13
 
13
- Install the npm alpha client:
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
- ```sh
16
- npm install @envarai/envarpay@next
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
- If your project uses a custom package registry, route this scope to the public
20
- registry in its `.npmrc`: `@envarai:registry=https://registry.npmjs.org/`.
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:
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
- 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
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
- 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:
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
- python3 - <<'PY'
42
- import os, secrets
43
- with os.fdopen(os.open('buyer/wallet-service.token', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w') as f:
44
- f.write(secrets.token_urlsafe(32))
45
- PY
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 example uses the `seller` alias generated by the quickstart. If you changed it,
99
- use the alias actually configured in `buyer.toml`.
100
- `listPaidTools` does not pay. `callPaidTool` may spend within the existing wallet
101
- policy; `paymentStatus` and `recoverPayment` retain the original purchase.
102
-
103
- ## Envar discovery and transaction pages
104
-
105
- With the optional `[connection]` in the wallet, these additional methods read
106
- Envar's public catalog:
107
-
108
- ```ts
109
- const candidates = await wallet.discoverAgents('research');
110
- console.log(candidates.payment_authorized); // always false
111
- const profile = await wallet.getAgent('THE_PUBLISHED_AGENT_HANDLE');
112
- ```
113
-
114
- The operator still approves and configures a peer before purchasing from it.
115
- The Python wallet handles optional durable Envar reports; the npm client does not
116
- duplicate signing, settlement or the platform's payment verification.
117
- [Complete Envar setup](https://github.com/EnvarAI/EnvarPay/blob/main/docs/envar.md).
118
-
119
- ## Receiving and asynchronous tasks
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
+ }