aeron-wallet 0.1.0 → 0.2.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 +45 -1
- package/dist/cli-sessions.js +91 -0
- package/dist/cli.js +23 -4
- package/dist/config.js +6 -0
- package/dist/mcp.js +50 -3
- package/dist/payer.js +31 -0
- package/dist/sessions.js +131 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -32,6 +32,9 @@ 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
40
|
## MCP
|
|
@@ -47,7 +50,47 @@ need ETH: the facilitator relays the transaction and pays gas.
|
|
|
47
50
|
}
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
Four tools: `get_address`, `get_balance`, `pay`, `history`.
|
|
53
|
+
Four tools: `get_address`, `get_balance`, `pay`, `history`. An unbound server
|
|
54
|
+
also gets `create_session`, `list_sessions`, and `revoke_session`.
|
|
55
|
+
|
|
56
|
+
## Sessions
|
|
57
|
+
|
|
58
|
+
A session is a scope you can hand to an agent without handing over the wallet.
|
|
59
|
+
It names the hosts that may be paid, a total budget, a per-call cap, and an
|
|
60
|
+
expiry:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
aeron-wallet session create --host inference.aeron.sh --budget 0.25 --ttl 2h
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That prints a token, once. Bind a server to it and every call through that
|
|
67
|
+
server inherits the scope:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"aeron-wallet": {
|
|
73
|
+
"command": "npx",
|
|
74
|
+
"args": ["-y", "aeron-wallet", "mcp"],
|
|
75
|
+
"env": { "AERON_WALLET_SESSION": "<token>" }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A bound server deliberately has no session tools. An agent that could mint
|
|
82
|
+
itself a wider session would not be contained by one. It also cannot reach a
|
|
83
|
+
host outside the scope: the wallet refuses before the request goes out, so an
|
|
84
|
+
agent talked into paying an attacker's endpoint never contacts it.
|
|
85
|
+
|
|
86
|
+
Revoking takes effect on the next call, including for a server already
|
|
87
|
+
running, because the scope is re-read every time rather than captured at
|
|
88
|
+
startup.
|
|
89
|
+
|
|
90
|
+
`aeron-wallet pay --session <token> <url>` applies a scope to a single call.
|
|
91
|
+
|
|
92
|
+
Sessions narrow the wallet; they never widen it. The caps below still apply
|
|
93
|
+
underneath, so a $5 session on a $1/day wallet spends $1 a day.
|
|
51
94
|
|
|
52
95
|
## Your key
|
|
53
96
|
|
|
@@ -83,6 +126,7 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
|
|
|
83
126
|
| `USDG_ADDRESS` | `0x5fc5360d0400a0fd4f2af552add042d716f1d168` |
|
|
84
127
|
| `AERON_WALLET_DIR` | `~/.aeron/wallet` |
|
|
85
128
|
| `AERON_WALLET_KEY` | unset. Overrides the stored key. |
|
|
129
|
+
| `AERON_WALLET_SESSION` | unset. Binds the whole process to one session. |
|
|
86
130
|
|
|
87
131
|
## Where payments go
|
|
88
132
|
|
|
@@ -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', ...
|
|
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/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',
|
|
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
|
+
}
|
package/dist/payer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
4
5
|
/** EIP-3009 typed data, mirrored from the facilitator side. */
|
|
5
6
|
const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
6
7
|
TransferWithAuthorization: [
|
|
@@ -54,6 +55,15 @@ const body402Schema = z.looseObject({
|
|
|
54
55
|
x402Version: z.number(),
|
|
55
56
|
accepts: z.array(requirementsSchema).min(1),
|
|
56
57
|
});
|
|
58
|
+
/** Refused by the wallet before any request went out. */
|
|
59
|
+
const refused = (reason, amountUsd = 0) => ({
|
|
60
|
+
paid: false,
|
|
61
|
+
status: 0,
|
|
62
|
+
amountUsd,
|
|
63
|
+
transaction: null,
|
|
64
|
+
body: '',
|
|
65
|
+
reason,
|
|
66
|
+
});
|
|
57
67
|
/**
|
|
58
68
|
* The x402 client flow: call, read the 402 offer, enforce budget caps, sign
|
|
59
69
|
* an exact-amount EIP-3009 authorization, retry with X-PAYMENT.
|
|
@@ -62,6 +72,16 @@ export async function payX402(url, init, deps) {
|
|
|
62
72
|
const { cfg, account, history, domain } = deps;
|
|
63
73
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
64
74
|
const now = deps.now ?? (() => Math.floor(Date.now() / 1000));
|
|
75
|
+
// Scope first: an out-of-scope host should never even be contacted.
|
|
76
|
+
const binding = deps.binding ?? null;
|
|
77
|
+
if (binding) {
|
|
78
|
+
const session = binding.current();
|
|
79
|
+
if (!session)
|
|
80
|
+
return refused('this session is no longer active');
|
|
81
|
+
const preflight = checkSession(session, { url, amountUsd: 0, now: Date.now() });
|
|
82
|
+
if (!preflight.ok)
|
|
83
|
+
return refused(preflight.reason);
|
|
84
|
+
}
|
|
65
85
|
const first = await fetchImpl(url, init);
|
|
66
86
|
const firstBody = await first.text();
|
|
67
87
|
if (first.status !== 402) {
|
|
@@ -85,6 +105,15 @@ export async function payX402(url, init, deps) {
|
|
|
85
105
|
reason: `amount $${amountUsd} exceeds per-call cap $${cfg.MAX_PER_CALL_USD}`,
|
|
86
106
|
};
|
|
87
107
|
}
|
|
108
|
+
if (binding) {
|
|
109
|
+
const session = binding.current();
|
|
110
|
+
if (!session)
|
|
111
|
+
return refused('this session is no longer active', amountUsd);
|
|
112
|
+
const outcome = checkSession(session, { url, amountUsd, now: Date.now() });
|
|
113
|
+
if (!outcome.ok) {
|
|
114
|
+
return { paid: false, status: 402, amountUsd, transaction: null, body: firstBody, reason: outcome.reason };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
88
117
|
const spent = history.spentTodayUsd();
|
|
89
118
|
if (spent + amountUsd > cfg.DAILY_CAP_USD) {
|
|
90
119
|
return {
|
|
@@ -141,6 +170,8 @@ export async function payX402(url, init, deps) {
|
|
|
141
170
|
}
|
|
142
171
|
}
|
|
143
172
|
const settled = second.status < 400;
|
|
173
|
+
if (settled && binding)
|
|
174
|
+
binding.recordSpend(amountUsd);
|
|
144
175
|
history.append({
|
|
145
176
|
ts: new Date().toISOString(),
|
|
146
177
|
url,
|
package/dist/sessions.js
ADDED
|
@@ -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,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aeron-wallet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"mcpName": "io.github.aeronlabs/aeron-wallet",
|
|
4
5
|
"description": "Non-custodial agent wallet for Robinhood Chain. Pays x402 requests in USDG. Ships as a CLI and an MCP server.",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"homepage": "https://aeron.sh/wallet/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/aeronlabs/aeron-wallet.git"
|
|
11
|
+
},
|
|
7
12
|
"type": "module",
|
|
8
13
|
"bin": {
|
|
9
14
|
"aeron-wallet": "dist/cli.js"
|