aeron-wallet 0.1.1 → 0.3.0

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 CHANGED
@@ -32,9 +32,37 @@ need ETH: the facilitator relays the transaction and pays gas.
32
32
  | `balance` | ETH and USDG balances, read from chain. |
33
33
  | `pay <url> [json]` | Call an x402 endpoint, paying if it answers 402. |
34
34
  | `history` | The last 10 payments, from the local log. |
35
+ | `session create` | Mint a scoped session: hosts, budget, per-call cap, expiry. |
36
+ | `session list` | Every session, what it spent, and whether it is still live. |
37
+ | `session revoke <id>` | Kill a session. It stops paying on its next call. |
35
38
  | `mcp` | Run as an MCP server over stdio. The default with no arguments. |
36
39
 
37
- ## MCP
40
+ ## Install it in an agent
41
+
42
+ **Claude Code**
43
+
44
+ ```
45
+ /plugin marketplace add aeronlabs/aeron-wallet
46
+ /plugin install aeron-wallet@aeronlabs
47
+ ```
48
+
49
+ **Cursor**
50
+
51
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=aeron-wallet&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImFlcm9uLXdhbGxldCIsIm1jcCJdfQ==)
52
+
53
+ **Gemini CLI**
54
+
55
+ ```bash
56
+ gemini extensions install https://github.com/aeronlabs/aeron-wallet
57
+ ```
58
+
59
+ **VS Code**
60
+
61
+ ```bash
62
+ code --add-mcp '{"name":"aeron-wallet","command":"npx","args":["-y","aeron-wallet","mcp"]}'
63
+ ```
64
+
65
+ **Anything else that speaks MCP**
38
66
 
39
67
  ```json
40
68
  {
@@ -47,7 +75,47 @@ need ETH: the facilitator relays the transaction and pays gas.
47
75
  }
48
76
  ```
49
77
 
50
- Four tools: `get_address`, `get_balance`, `pay`, `history`.
78
+ Four tools: `get_address`, `get_balance`, `pay`, `history`. An unbound server
79
+ also gets `create_session`, `list_sessions`, and `revoke_session`.
80
+
81
+ ## Sessions
82
+
83
+ A session is a scope you can hand to an agent without handing over the wallet.
84
+ It names the hosts that may be paid, a total budget, a per-call cap, and an
85
+ expiry:
86
+
87
+ ```bash
88
+ aeron-wallet session create --host inference.aeron.sh --budget 0.25 --ttl 2h
89
+ ```
90
+
91
+ That prints a token, once. Bind a server to it and every call through that
92
+ server inherits the scope:
93
+
94
+ ```json
95
+ {
96
+ "mcpServers": {
97
+ "aeron-wallet": {
98
+ "command": "npx",
99
+ "args": ["-y", "aeron-wallet", "mcp"],
100
+ "env": { "AERON_WALLET_SESSION": "<token>" }
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ A bound server deliberately has no session tools. An agent that could mint
107
+ itself a wider session would not be contained by one. It also cannot reach a
108
+ host outside the scope: the wallet refuses before the request goes out, so an
109
+ agent talked into paying an attacker's endpoint never contacts it.
110
+
111
+ Revoking takes effect on the next call, including for a server already
112
+ running, because the scope is re-read every time rather than captured at
113
+ startup.
114
+
115
+ `aeron-wallet pay --session <token> <url>` applies a scope to a single call.
116
+
117
+ Sessions narrow the wallet; they never widen it. The caps below still apply
118
+ underneath, so a $5 session on a $1/day wallet spends $1 a day.
51
119
 
52
120
  ## Your key
53
121
 
@@ -74,6 +142,31 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
74
142
  | `MAX_PER_CALL_USD` | `0.05` | Largest single payment. |
75
143
  | `DAILY_CAP_USD` | `1` | Total for the current UTC day. |
76
144
 
145
+ ## What a result means
146
+
147
+ A request that comes back 4xx is not one situation, it is three, and they
148
+ differ in the only way that matters: whether the money left the wallet. The
149
+ signal is the settlement receipt — a service that settled returns
150
+ `X-PAYMENT-RESPONSE` with a transaction hash, and one that did not, does not.
151
+
152
+ | `status` | Charged | What happened |
153
+ |---|---|---|
154
+ | `settled` | yes | The service answered. `reason` is set only in the bad case below. |
155
+ | `rejected` | no | HTTP 402. The service refused the payment; the authorization is unspent. |
156
+ | `failed` | no | The service returned an error *and declined to charge* — usually its own upstream failed. |
157
+
158
+ The case worth naming: a `settled` row **with** a `reason` means the money
159
+ moved and nothing came back. That is the only outcome where the wallet is out
160
+ of pocket for nothing, so it is reported as itself rather than folded in with
161
+ refusals that cost nothing.
162
+
163
+ Only `settled` counts against `DAILY_CAP_USD`. A refusal and an upstream
164
+ failure leave the balance untouched, so neither eats into the cap.
165
+
166
+ `reason` quotes the service's own message when it gave one, instead of a
167
+ generic phrase — an agent operator reading a log needs to know whether to
168
+ retry, top up, or fix the seller.
169
+
77
170
  ## Configuration
78
171
 
79
172
  | Variable | Default |
@@ -83,6 +176,14 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
83
176
  | `USDG_ADDRESS` | `0x5fc5360d0400a0fd4f2af552add042d716f1d168` |
84
177
  | `AERON_WALLET_DIR` | `~/.aeron/wallet` |
85
178
  | `AERON_WALLET_KEY` | unset. Overrides the stored key. |
179
+ | `AERON_WALLET_SESSION` | unset. Binds the whole process to one session. |
180
+
181
+ ## Releases
182
+
183
+ Published from a tag by GitHub Actions using npm trusted publishing, so no
184
+ long-lived npm token exists to leak and every tarball carries a provenance
185
+ attestation: proof of the commit and workflow it was built from. Verify with
186
+ `npm audit signatures` after installing.
86
187
 
87
188
  ## Where payments go
88
189
 
@@ -0,0 +1,91 @@
1
+ import { parseTtlSeconds } from './sessions.js';
2
+ /** Collect repeated `--flag value` pairs. Unknown flags are an error, not a shrug. */
3
+ function readFlags(args, known) {
4
+ const flags = new Map();
5
+ for (let i = 0; i < args.length; i += 1) {
6
+ const arg = args[i];
7
+ if (!arg.startsWith('--'))
8
+ throw new Error(`unexpected argument "${arg}"`);
9
+ const name = arg.slice(2);
10
+ if (!known.includes(name))
11
+ throw new Error(`unknown flag --${name}; expected ${known.map((k) => `--${k}`).join(', ')}`);
12
+ const value = args[i + 1];
13
+ if (value === undefined || value.startsWith('--'))
14
+ throw new Error(`--${name} needs a value`);
15
+ flags.set(name, [...(flags.get(name) ?? []), value]);
16
+ i += 1;
17
+ }
18
+ return flags;
19
+ }
20
+ function positiveNumber(raw, label) {
21
+ const value = Number(raw);
22
+ if (!Number.isFinite(value) || value <= 0)
23
+ throw new Error(`${label} must be a positive number, got "${raw}"`);
24
+ return value;
25
+ }
26
+ const CREATE_FLAGS = ['host', 'budget', 'ttl', 'max-per-call'];
27
+ function create(args, sessions, cfg, out) {
28
+ const flags = readFlags(args, CREATE_FLAGS);
29
+ const hosts = (flags.get('host') ?? []).flatMap((h) => h.split(',')).map((h) => h.trim()).filter(Boolean);
30
+ if (hosts.length === 0)
31
+ throw new Error('at least one --host is required; a session with no scope is not a scope');
32
+ const budgetRaw = flags.get('budget')?.[0];
33
+ const ttlRaw = flags.get('ttl')?.[0];
34
+ if (!budgetRaw)
35
+ throw new Error('--budget is required, in USD');
36
+ if (!ttlRaw)
37
+ throw new Error('--ttl is required, for example 2h');
38
+ const budgetUsd = positiveNumber(budgetRaw, '--budget');
39
+ const maxPerCallUsd = flags.has('max-per-call')
40
+ ? positiveNumber(flags.get('max-per-call')[0], '--max-per-call')
41
+ : Math.min(cfg.MAX_PER_CALL_USD, budgetUsd);
42
+ const { session, token } = sessions.create({
43
+ hosts,
44
+ budgetUsd,
45
+ maxPerCallUsd,
46
+ ttlSeconds: parseTtlSeconds(ttlRaw),
47
+ });
48
+ out(`session ${session.id}`);
49
+ out(`hosts ${session.hosts.join(', ')}`);
50
+ out(`budget $${session.budgetUsd} total, $${session.maxPerCallUsd} per call`);
51
+ out(`expires ${session.expiresAt}`);
52
+ out('');
53
+ out('token, shown once:');
54
+ out(token);
55
+ out('');
56
+ out('Give it to an agent by binding a server to it:');
57
+ out(` AERON_WALLET_SESSION=${token} aeron-wallet mcp`);
58
+ if (budgetUsd > cfg.DAILY_CAP_USD) {
59
+ out('');
60
+ out(`note: the wallet's own daily cap of $${cfg.DAILY_CAP_USD} still applies and is lower than this budget.`);
61
+ }
62
+ }
63
+ function list(sessions, out, now = new Date()) {
64
+ const all = sessions.list();
65
+ if (all.length === 0) {
66
+ out('no sessions');
67
+ return;
68
+ }
69
+ for (const s of all) {
70
+ const state = s.revokedAt ? 'revoked' : Date.parse(s.expiresAt) < now.getTime() ? 'expired' : 'active';
71
+ out(`${s.id} ${state.padEnd(7)} $${s.spentUsd.toFixed(4)}/$${s.budgetUsd} ${s.hosts.join(',')} until ${s.expiresAt}`);
72
+ }
73
+ }
74
+ export function runSessionCommand(args, deps) {
75
+ const [sub, ...rest] = args;
76
+ switch (sub) {
77
+ case 'create':
78
+ return create(rest, deps.sessions, deps.cfg, deps.out);
79
+ case 'list':
80
+ return list(deps.sessions, deps.out);
81
+ case 'revoke': {
82
+ const id = rest[0];
83
+ if (!id)
84
+ throw new Error('usage: session revoke <id>');
85
+ deps.out(deps.sessions.revoke(id) ? `revoked ${id}` : `no active session with id ${id}`);
86
+ return;
87
+ }
88
+ default:
89
+ throw new Error('usage: session create|list|revoke');
90
+ }
91
+ }
package/dist/cli.js CHANGED
@@ -5,11 +5,14 @@ import { createHistory } from './history.js';
5
5
  import { createChainClient, readBalances } from './balances.js';
6
6
  import { payX402, resolveDomain } from './payer.js';
7
7
  import { startMcpServer } from './mcp.js';
8
+ import { bindSession, createSessions } from './sessions.js';
9
+ import { runSessionCommand } from './cli-sessions.js';
8
10
  const out = (line) => process.stdout.write(`${line}\n`);
9
11
  async function main() {
10
12
  const cfg = loadConfig();
11
13
  const { account, created } = loadOrCreateAccount(cfg);
12
14
  const history = createHistory(cfg);
15
+ const sessions = createSessions(cfg);
13
16
  const publicClient = createChainClient(cfg);
14
17
  let cachedDomain = null;
15
18
  const domain = async () => {
@@ -17,7 +20,19 @@ async function main() {
17
20
  cachedDomain = await resolveDomain(publicClient, cfg);
18
21
  return cachedDomain;
19
22
  };
20
- const [command = 'mcp', ...rest] = process.argv.slice(2);
23
+ const [command = 'mcp', ...argv] = process.argv.slice(2);
24
+ // A `--session <token>` flag scopes one call; AERON_WALLET_SESSION scopes
25
+ // the whole process, which is how you hand a sub-agent a bounded server.
26
+ const flagAt = argv.indexOf('--session');
27
+ const inlineToken = flagAt === -1 ? undefined : argv[flagAt + 1];
28
+ if (flagAt !== -1 && !inlineToken)
29
+ throw new Error('--session needs a token');
30
+ const rest = flagAt === -1 ? argv : [...argv.slice(0, flagAt), ...argv.slice(flagAt + 2)];
31
+ const token = inlineToken ?? cfg.AERON_WALLET_SESSION;
32
+ const binding = token ? bindSession(sessions, token) : null;
33
+ if (binding && !binding.current()) {
34
+ throw new Error('that session token is unknown, revoked, or already gone');
35
+ }
21
36
  if (created && command !== 'mcp') {
22
37
  out(`new wallet created at ${cfg.AERON_WALLET_DIR} (hot wallet; keep balances small)`);
23
38
  }
@@ -38,7 +53,7 @@ async function main() {
38
53
  if (!url)
39
54
  throw new Error('usage: pay <url> [json-body]');
40
55
  const body = rest[1];
41
- const result = await payX402(url, { method: 'POST', headers: { 'content-type': 'application/json' }, ...(body ? { body } : {}) }, { cfg, account, history, domain: await domain() });
56
+ const result = await payX402(url, { method: 'POST', headers: { 'content-type': 'application/json' }, ...(body ? { body } : {}) }, { cfg, account, history, domain: await domain(), binding });
42
57
  out(JSON.stringify(result, null, 2));
43
58
  if (!result.paid && result.reason)
44
59
  process.exitCode = 1;
@@ -50,12 +65,16 @@ async function main() {
50
65
  }
51
66
  return;
52
67
  }
68
+ case 'session': {
69
+ runSessionCommand(rest, { sessions, cfg, out });
70
+ return;
71
+ }
53
72
  case 'mcp': {
54
- await startMcpServer({ cfg, account, history, publicClient, domain });
73
+ await startMcpServer({ cfg, account, history, publicClient, domain, sessions, binding });
55
74
  return;
56
75
  }
57
76
  default:
58
- throw new Error(`unknown command: ${command} (use address|balance|pay|history|mcp)`);
77
+ throw new Error(`unknown command: ${command} (use address|balance|pay|history|session|mcp)`);
59
78
  }
60
79
  }
61
80
  main().catch((err) => {
package/dist/config.js CHANGED
@@ -15,6 +15,12 @@ const envSchema = z.object({
15
15
  .optional()
16
16
  .or(z.literal('').transform(() => undefined)),
17
17
  AERON_WALLET_DIR: z.string().default(join(homedir(), '.aeron', 'wallet')),
18
+ /** Bind this process to one session. The scope then applies to every call. */
19
+ AERON_WALLET_SESSION: z
20
+ .string()
21
+ .regex(/^[0-9a-f]{64}$/)
22
+ .optional()
23
+ .or(z.literal('').transform(() => undefined)),
18
24
  /** Budget caps, USD. The wallet refuses to sign above these. */
19
25
  MAX_PER_CALL_USD: z.coerce.number().positive().default(0.05),
20
26
  DAILY_CAP_USD: z.coerce.number().positive().default(1),
package/dist/history.js CHANGED
@@ -30,6 +30,8 @@ export function createHistory(cfg) {
30
30
  const midnight = new Date(now);
31
31
  midnight.setHours(0, 0, 0, 0);
32
32
  return readAll()
33
+ // Only 'settled' rows moved money. A refusal and an upstream failure both
34
+ // leave the balance untouched, so neither may eat into the daily cap.
33
35
  .filter((r) => r.status === 'settled' && new Date(r.ts) >= midnight)
34
36
  .reduce((sum, r) => sum + r.amountUsd, 0);
35
37
  },
package/dist/mcp.js CHANGED
@@ -3,8 +3,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod';
4
4
  import { readBalances } from './balances.js';
5
5
  import { payX402 } from './payer.js';
6
+ import { parseTtlSeconds } from './sessions.js';
6
7
  export async function startMcpServer(deps) {
7
- const { cfg, account, history, publicClient } = deps;
8
+ const { cfg, account, history, publicClient, sessions } = deps;
9
+ const binding = deps.binding ?? null;
8
10
  const server = new McpServer({ name: 'aeron-wallet', version: '0.1.0' });
9
11
  server.tool('get_address', 'The wallet address on Robinhood Chain. Fund it with USDG to pay for services.', {}, async () => ({
10
12
  content: [{ type: 'text', text: JSON.stringify({ address: account.address, network: cfg.network }) }],
@@ -13,7 +15,9 @@ export async function startMcpServer(deps) {
13
15
  const balances = await readBalances(publicClient, cfg, account.address);
14
16
  return { content: [{ type: 'text', text: JSON.stringify({ address: account.address, ...balances }) }] };
15
17
  });
16
- server.tool('pay', 'Call a machine-payable (x402) endpoint and pay in USDG if it answers 402. Budget caps apply.', {
18
+ server.tool('pay', binding
19
+ ? 'Call a machine-payable (x402) endpoint and pay in USDG if it answers 402. This server is bound to a session: calls outside its hosts, per-call cap, budget, or expiry are refused.'
20
+ : 'Call a machine-payable (x402) endpoint and pay in USDG if it answers 402. Budget caps apply.', {
17
21
  url: z.string().url(),
18
22
  method: z.enum(['GET', 'POST']).default('POST'),
19
23
  body: z.string().optional().describe('JSON body to send'),
@@ -23,11 +27,54 @@ export async function startMcpServer(deps) {
23
27
  method,
24
28
  headers: { 'content-type': 'application/json' },
25
29
  ...(body ? { body } : {}),
26
- }, { cfg, account, history, domain });
30
+ }, { cfg, account, history, domain, binding });
27
31
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
28
32
  });
29
33
  server.tool('history', 'Recent payments made by this wallet.', { limit: z.number().int().min(1).max(50).default(10) }, async ({ limit }) => ({
30
34
  content: [{ type: 'text', text: JSON.stringify(history.recent(limit)) }],
31
35
  }));
36
+ if (!binding)
37
+ registerSessionTools(server, sessions, cfg);
32
38
  await server.connect(new StdioServerTransport());
33
39
  }
40
+ /**
41
+ * Only an unbound server gets these. Handing them to a bound agent would let
42
+ * it write itself a wider scope, which is the whole thing a session prevents.
43
+ */
44
+ function registerSessionTools(server, sessions, cfg) {
45
+ server.tool('create_session', 'Create a scoped session: allowed hosts, a total budget, a per-call cap, and an expiry. Returns a token that binds an agent to that scope.', {
46
+ hosts: z.array(z.string().min(1)).min(1).describe('hostnames this session may pay, e.g. inference.aeron.sh'),
47
+ budgetUsd: z.number().positive().describe('total USD this session may spend'),
48
+ ttl: z.string().describe('lifetime, e.g. 90, 30m, 2h, 1d'),
49
+ maxPerCallUsd: z.number().positive().optional(),
50
+ }, async ({ hosts, budgetUsd, ttl, maxPerCallUsd }) => {
51
+ const { session, token } = sessions.create({
52
+ hosts,
53
+ budgetUsd,
54
+ maxPerCallUsd: maxPerCallUsd ?? Math.min(cfg.MAX_PER_CALL_USD, budgetUsd),
55
+ ttlSeconds: parseTtlSeconds(ttl),
56
+ });
57
+ return {
58
+ content: [
59
+ {
60
+ type: 'text',
61
+ text: JSON.stringify({
62
+ id: session.id,
63
+ token,
64
+ hosts: session.hosts,
65
+ budgetUsd: session.budgetUsd,
66
+ maxPerCallUsd: session.maxPerCallUsd,
67
+ expiresAt: session.expiresAt,
68
+ bind: `AERON_WALLET_SESSION=${token} aeron-wallet mcp`,
69
+ }),
70
+ },
71
+ ],
72
+ };
73
+ });
74
+ server.tool('list_sessions', 'Every session, with what it has spent and whether it is still live.', {}, async () => ({
75
+ content: [{ type: 'text', text: JSON.stringify(sessions.list()) }],
76
+ }));
77
+ server.tool('revoke_session', 'Revoke a session by id. It stops paying on its next call.', { id: z.string().min(1) }, async ({ id }) => ({
78
+ content: [{ type: 'text', text: JSON.stringify({ id, revoked: sessions.revoke(id) }) }],
79
+ }));
80
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What actually happened after the wallet handed over a payment.
3
+ *
4
+ * "The request came back 4xx" is not one situation, it is three, and they
5
+ * differ in the only way an agent operator cares about: whether the money
6
+ * left the wallet.
7
+ *
8
+ * - the service answered → paid, got the goods
9
+ * - the service refused the payment → not charged, the authorization stands
10
+ * - the service refused to charge → not charged, its upstream failed
11
+ * - the service charged and failed → charged, got nothing ← say this loudly
12
+ *
13
+ * The signal is the settlement receipt. A service that settles returns
14
+ * X-PAYMENT-RESPONSE with a transaction hash; one that did not, does not.
15
+ */
16
+ /** The service's own words, dug out of whatever shape it used to say them. */
17
+ export function serviceMessage(body) {
18
+ if (!body.trim())
19
+ return null;
20
+ try {
21
+ const parsed = JSON.parse(body);
22
+ const error = parsed.error;
23
+ if (typeof error === 'string' && error.trim())
24
+ return error.trim();
25
+ if (error && typeof error === 'object') {
26
+ const message = error.message;
27
+ if (typeof message === 'string' && message.trim())
28
+ return message.trim();
29
+ }
30
+ return null;
31
+ }
32
+ catch {
33
+ // Not JSON. A short plain-text body is still better than a generic phrase.
34
+ const text = body.trim();
35
+ return text.length <= 200 ? text : null;
36
+ }
37
+ }
38
+ const withMessage = (fallback, body) => {
39
+ const message = serviceMessage(body);
40
+ return message ? `${fallback}: ${message}` : fallback;
41
+ };
42
+ export function describeOutcome(httpStatus, transaction, body) {
43
+ const charged = transaction !== null;
44
+ if (httpStatus < 400)
45
+ return { ok: true, charged, status: 'settled' };
46
+ if (charged) {
47
+ // The worst case and the quietest one: the money moved and the caller has
48
+ // nothing to show for it. Named explicitly so it cannot be mistaken for a
49
+ // refusal that cost nothing.
50
+ return {
51
+ ok: false,
52
+ charged: true,
53
+ status: 'settled',
54
+ reason: withMessage(`charged (${transaction}) but the service then returned HTTP ${httpStatus}`, body),
55
+ };
56
+ }
57
+ if (httpStatus === 402) {
58
+ return {
59
+ ok: false,
60
+ charged: false,
61
+ status: 'rejected',
62
+ reason: withMessage('the service refused the payment; you were not charged', body),
63
+ };
64
+ }
65
+ return {
66
+ ok: false,
67
+ charged: false,
68
+ status: 'failed',
69
+ reason: withMessage(`the service returned HTTP ${httpStatus} and did not charge you`, body),
70
+ };
71
+ }
package/dist/payer.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { hashDomain } from 'viem';
4
+ import { checkSession } from './sessions.js';
5
+ import { describeOutcome } from './outcome.js';
4
6
  /** EIP-3009 typed data, mirrored from the facilitator side. */
5
7
  const TRANSFER_WITH_AUTHORIZATION_TYPES = {
6
8
  TransferWithAuthorization: [
@@ -54,6 +56,15 @@ const body402Schema = z.looseObject({
54
56
  x402Version: z.number(),
55
57
  accepts: z.array(requirementsSchema).min(1),
56
58
  });
59
+ /** Refused by the wallet before any request went out. */
60
+ const refused = (reason, amountUsd = 0) => ({
61
+ paid: false,
62
+ status: 0,
63
+ amountUsd,
64
+ transaction: null,
65
+ body: '',
66
+ reason,
67
+ });
57
68
  /**
58
69
  * The x402 client flow: call, read the 402 offer, enforce budget caps, sign
59
70
  * an exact-amount EIP-3009 authorization, retry with X-PAYMENT.
@@ -62,6 +73,16 @@ export async function payX402(url, init, deps) {
62
73
  const { cfg, account, history, domain } = deps;
63
74
  const fetchImpl = deps.fetchImpl ?? fetch;
64
75
  const now = deps.now ?? (() => Math.floor(Date.now() / 1000));
76
+ // Scope first: an out-of-scope host should never even be contacted.
77
+ const binding = deps.binding ?? null;
78
+ if (binding) {
79
+ const session = binding.current();
80
+ if (!session)
81
+ return refused('this session is no longer active');
82
+ const preflight = checkSession(session, { url, amountUsd: 0, now: Date.now() });
83
+ if (!preflight.ok)
84
+ return refused(preflight.reason);
85
+ }
65
86
  const first = await fetchImpl(url, init);
66
87
  const firstBody = await first.text();
67
88
  if (first.status !== 402) {
@@ -85,6 +106,15 @@ export async function payX402(url, init, deps) {
85
106
  reason: `amount $${amountUsd} exceeds per-call cap $${cfg.MAX_PER_CALL_USD}`,
86
107
  };
87
108
  }
109
+ if (binding) {
110
+ const session = binding.current();
111
+ if (!session)
112
+ return refused('this session is no longer active', amountUsd);
113
+ const outcome = checkSession(session, { url, amountUsd, now: Date.now() });
114
+ if (!outcome.ok) {
115
+ return { paid: false, status: 402, amountUsd, transaction: null, body: firstBody, reason: outcome.reason };
116
+ }
117
+ }
88
118
  const spent = history.spentTodayUsd();
89
119
  if (spent + amountUsd > cfg.DAILY_CAP_USD) {
90
120
  return {
@@ -140,18 +170,22 @@ export async function payX402(url, init, deps) {
140
170
  /* receipt header is informational */
141
171
  }
142
172
  }
143
- const settled = second.status < 400;
173
+ // Whether money moved is the settlement receipt's business, not the status
174
+ // code's: a service can refuse to charge and still answer 4xx.
175
+ const outcome = describeOutcome(second.status, transaction, secondBody);
176
+ if (outcome.charged && binding)
177
+ binding.recordSpend(amountUsd);
144
178
  history.append({
145
179
  ts: new Date().toISOString(),
146
180
  url,
147
181
  amountUsd,
148
182
  payer: account.address,
149
183
  transaction,
150
- status: settled ? 'settled' : 'rejected',
151
- reason: settled ? undefined : secondBody.slice(0, 200),
184
+ status: outcome.status,
185
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
152
186
  });
153
187
  return {
154
- paid: settled, status: second.status, amountUsd, transaction, body: secondBody,
155
- reason: settled ? undefined : 'payment rejected by the service',
188
+ paid: outcome.ok, status: second.status, amountUsd, transaction, body: secondBody,
189
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
156
190
  };
157
191
  }
@@ -0,0 +1,131 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ const sessionSchema = z.object({
6
+ id: z.string().min(1),
7
+ tokenHash: z.string().regex(/^[0-9a-f]{64}$/),
8
+ hosts: z.array(z.string().min(1)).min(1),
9
+ budgetUsd: z.number().positive(),
10
+ maxPerCallUsd: z.number().positive(),
11
+ spentUsd: z.number().min(0),
12
+ createdAt: z.string(),
13
+ expiresAt: z.string(),
14
+ revokedAt: z.string().nullable(),
15
+ });
16
+ const TTL_PATTERN = /^(\d+)([mhd])?$/;
17
+ const TTL_UNITS = { m: 60, h: 3600, d: 86400 };
18
+ /** Read a duration written as seconds, or with an m/h/d suffix. */
19
+ export function parseTtlSeconds(input) {
20
+ const match = TTL_PATTERN.exec(input.trim());
21
+ if (!match)
22
+ throw new Error(`invalid duration "${input}"; use 90, 30m, 2h, or 1d`);
23
+ const amount = Number(match[1]);
24
+ if (amount <= 0)
25
+ throw new Error(`duration must be greater than zero, got "${input}"`);
26
+ return amount * (match[2] ? TTL_UNITS[match[2]] : 1);
27
+ }
28
+ const hash = (token) => createHash('sha256').update(token).digest('hex');
29
+ function sameHash(a, b) {
30
+ const left = Buffer.from(a, 'hex');
31
+ const right = Buffer.from(b, 'hex');
32
+ return left.length === right.length && timingSafeEqual(left, right);
33
+ }
34
+ /** Everything a session forbids, decided without touching disk or the chain. */
35
+ export function checkSession(session, input) {
36
+ const deny = (reason) => ({ ok: false, reason });
37
+ if (session.revokedAt)
38
+ return deny(`session ${session.id} was revoked at ${session.revokedAt}`);
39
+ if (input.now > Date.parse(session.expiresAt)) {
40
+ return deny(`session ${session.id} expired at ${session.expiresAt}`);
41
+ }
42
+ let host;
43
+ try {
44
+ host = new URL(input.url).hostname;
45
+ }
46
+ catch {
47
+ return deny(`could not read a host from "${input.url}"`);
48
+ }
49
+ if (!session.hosts.includes(host)) {
50
+ return deny(`host ${host} is outside this session's scope (${session.hosts.join(', ')})`);
51
+ }
52
+ if (input.amountUsd > session.maxPerCallUsd) {
53
+ return deny(`$${input.amountUsd} is over the session per-call cap of $${session.maxPerCallUsd}`);
54
+ }
55
+ const remaining = session.budgetUsd - session.spentUsd;
56
+ if (input.amountUsd > remaining) {
57
+ return deny(`session budget spent: $${remaining.toFixed(6)} left of $${session.budgetUsd}`);
58
+ }
59
+ return { ok: true };
60
+ }
61
+ export function bindSession(store, token) {
62
+ return {
63
+ current: () => store.findByToken(token),
64
+ recordSpend: (amountUsd) => {
65
+ const session = store.findByToken(token);
66
+ if (session)
67
+ store.recordSpend(session.id, amountUsd);
68
+ },
69
+ };
70
+ }
71
+ export function createSessions(cfg) {
72
+ const filePath = join(cfg.AERON_WALLET_DIR, 'sessions.json');
73
+ function readAll() {
74
+ if (!existsSync(filePath))
75
+ return [];
76
+ let raw;
77
+ try {
78
+ raw = JSON.parse(readFileSync(filePath, 'utf8'));
79
+ }
80
+ catch {
81
+ throw new Error(`sessions file is not valid JSON: ${filePath}`);
82
+ }
83
+ const parsed = z.array(sessionSchema).safeParse(raw);
84
+ if (!parsed.success)
85
+ throw new Error(`sessions file is malformed: ${filePath}`);
86
+ return parsed.data;
87
+ }
88
+ function writeAll(sessions) {
89
+ mkdirSync(cfg.AERON_WALLET_DIR, { recursive: true, mode: 0o700 });
90
+ writeFileSync(filePath, `${JSON.stringify(sessions, null, 2)}\n`, { mode: 0o600 });
91
+ chmodSync(filePath, 0o600);
92
+ }
93
+ const strip = ({ tokenHash: _tokenHash, ...rest }) => rest;
94
+ return {
95
+ create(grant, now = new Date()) {
96
+ const token = randomBytes(32).toString('hex');
97
+ const session = {
98
+ id: randomBytes(4).toString('hex'),
99
+ tokenHash: hash(token),
100
+ hosts: [...grant.hosts],
101
+ budgetUsd: grant.budgetUsd,
102
+ maxPerCallUsd: grant.maxPerCallUsd,
103
+ spentUsd: 0,
104
+ createdAt: now.toISOString(),
105
+ expiresAt: new Date(now.getTime() + grant.ttlSeconds * 1000).toISOString(),
106
+ revokedAt: null,
107
+ };
108
+ writeAll([...readAll(), session]);
109
+ return { session, token };
110
+ },
111
+ list() {
112
+ return readAll().map(strip);
113
+ },
114
+ findByToken(token) {
115
+ if (!/^[0-9a-f]{64}$/.test(token))
116
+ return null;
117
+ const wanted = hash(token);
118
+ return readAll().find((s) => !s.revokedAt && sameHash(s.tokenHash, wanted)) ?? null;
119
+ },
120
+ recordSpend(id, amountUsd) {
121
+ writeAll(readAll().map((s) => (s.id === id ? { ...s, spentUsd: s.spentUsd + amountUsd } : s)));
122
+ },
123
+ revoke(id, now = new Date()) {
124
+ const sessions = readAll();
125
+ if (!sessions.some((s) => s.id === id && !s.revokedAt))
126
+ return false;
127
+ writeAll(sessions.map((s) => (s.id === id ? { ...s, revokedAt: now.toISOString() } : s)));
128
+ return true;
129
+ },
130
+ };
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aeron-wallet",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.aeronlabs/aeron-wallet",
5
5
  "description": "Non-custodial agent wallet for Robinhood Chain. Pays x402 requests in USDG. Ships as a CLI and an MCP server.",
6
6
  "license": "MIT",