aeron-wallet 0.1.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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/balances.js +29 -0
- package/dist/cli.js +64 -0
- package/dist/config.js +29 -0
- package/dist/history.js +37 -0
- package/dist/keystore.js +24 -0
- package/dist/mcp.js +33 -0
- package/dist/payer.js +157 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aeron
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# aeron-wallet
|
|
2
|
+
|
|
3
|
+
A non-custodial wallet for agents. It holds USDG on Robinhood Chain and pays
|
|
4
|
+
`402 Payment Required` responses on its own, so an agent can call a metered API
|
|
5
|
+
without a card, an account, or a human in the loop.
|
|
6
|
+
|
|
7
|
+
Ships two ways: a CLI, and an MCP server for Claude and other MCP clients.
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y aeron-wallet address
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That prints your wallet address and creates a key on first run. Send USDG to
|
|
16
|
+
that address, then pay for a call:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx -y aeron-wallet pay https://inference.aeron.sh/v1/chat/completions \
|
|
20
|
+
'{"model":"deepseek/deepseek-chat","messages":[{"role":"user","content":"hi"}]}'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The wallet reads the 402 challenge, checks it against your budget caps, signs an
|
|
24
|
+
EIP-3009 transfer, and retries the request with the payment attached. You do not
|
|
25
|
+
need ETH: the facilitator relays the transaction and pays gas.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
| Command | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `address` | Print the wallet address. Creates the key if none exists. |
|
|
32
|
+
| `balance` | ETH and USDG balances, read from chain. |
|
|
33
|
+
| `pay <url> [json]` | Call an x402 endpoint, paying if it answers 402. |
|
|
34
|
+
| `history` | The last 10 payments, from the local log. |
|
|
35
|
+
| `mcp` | Run as an MCP server over stdio. The default with no arguments. |
|
|
36
|
+
|
|
37
|
+
## MCP
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"aeron-wallet": {
|
|
43
|
+
"command": "npx",
|
|
44
|
+
"args": ["-y", "aeron-wallet", "mcp"]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Four tools: `get_address`, `get_balance`, `pay`, `history`.
|
|
51
|
+
|
|
52
|
+
## Your key
|
|
53
|
+
|
|
54
|
+
The key is generated on your machine on first run and written to
|
|
55
|
+
`~/.aeron/wallet/key` with `0600` permissions. It never leaves the machine and
|
|
56
|
+
nobody else can derive your address. Every install creates a different wallet.
|
|
57
|
+
|
|
58
|
+
Two consequences worth planning for:
|
|
59
|
+
|
|
60
|
+
- **Ephemeral containers.** If `$HOME` is wiped between runs, the wallet
|
|
61
|
+
regenerates and any USDG left on the old address is stranded. Mount a volume
|
|
62
|
+
for `~/.aeron`, set `AERON_WALLET_DIR` to a path that persists, or supply the
|
|
63
|
+
key yourself with `AERON_WALLET_KEY`.
|
|
64
|
+
- **Hot wallet.** The key sits unencrypted on disk so an agent can sign without
|
|
65
|
+
a prompt. Keep the balance small. Fund it the way you would top up a prepaid
|
|
66
|
+
card, not the way you would fund savings.
|
|
67
|
+
|
|
68
|
+
## Budget caps
|
|
69
|
+
|
|
70
|
+
The wallet refuses to sign above either cap, so a loop cannot drain it.
|
|
71
|
+
|
|
72
|
+
| Variable | Default | Meaning |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `MAX_PER_CALL_USD` | `0.05` | Largest single payment. |
|
|
75
|
+
| `DAILY_CAP_USD` | `1` | Total for the current UTC day. |
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
| Variable | Default |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `RPC_URL` | `https://rpc.mainnet.chain.robinhood.com` |
|
|
82
|
+
| `CHAIN_ID` | `4663` |
|
|
83
|
+
| `USDG_ADDRESS` | `0x5fc5360d0400a0fd4f2af552add042d716f1d168` |
|
|
84
|
+
| `AERON_WALLET_DIR` | `~/.aeron/wallet` |
|
|
85
|
+
| `AERON_WALLET_KEY` | unset. Overrides the stored key. |
|
|
86
|
+
|
|
87
|
+
## Where payments go
|
|
88
|
+
|
|
89
|
+
Payments settle on Robinhood Chain mainnet in USDG through the Aeron
|
|
90
|
+
facilitator at `x402.aeron.sh`. The wallet works with any x402 endpoint on the
|
|
91
|
+
same network, not only Aeron's.
|
|
92
|
+
|
|
93
|
+
More at [aeron.sh/wallet](https://aeron.sh/wallet/).
|
package/dist/balances.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createPublicClient, defineChain, http, formatEther } from 'viem';
|
|
2
|
+
const ERC20_ABI = [
|
|
3
|
+
{
|
|
4
|
+
type: 'function', name: 'balanceOf', stateMutability: 'view',
|
|
5
|
+
inputs: [{ name: 'account', type: 'address' }],
|
|
6
|
+
outputs: [{ type: 'uint256' }],
|
|
7
|
+
},
|
|
8
|
+
];
|
|
9
|
+
export function createChainClient(cfg) {
|
|
10
|
+
const chain = defineChain({
|
|
11
|
+
id: cfg.CHAIN_ID,
|
|
12
|
+
name: 'Robinhood Chain',
|
|
13
|
+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
|
14
|
+
rpcUrls: { default: { http: [cfg.RPC_URL] } },
|
|
15
|
+
});
|
|
16
|
+
return createPublicClient({ chain, transport: http(cfg.RPC_URL) });
|
|
17
|
+
}
|
|
18
|
+
export async function readBalances(publicClient, cfg, address) {
|
|
19
|
+
const [wei, usdgRaw] = await Promise.all([
|
|
20
|
+
publicClient.getBalance({ address }),
|
|
21
|
+
publicClient.readContract({
|
|
22
|
+
address: cfg.USDG_ADDRESS,
|
|
23
|
+
abi: ERC20_ABI,
|
|
24
|
+
functionName: 'balanceOf',
|
|
25
|
+
args: [address],
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
return { eth: formatEther(wei), usdg: (Number(usdgRaw) / 1e6).toFixed(6) };
|
|
29
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { loadOrCreateAccount } from './keystore.js';
|
|
4
|
+
import { createHistory } from './history.js';
|
|
5
|
+
import { createChainClient, readBalances } from './balances.js';
|
|
6
|
+
import { payX402, resolveDomain } from './payer.js';
|
|
7
|
+
import { startMcpServer } from './mcp.js';
|
|
8
|
+
const out = (line) => process.stdout.write(`${line}\n`);
|
|
9
|
+
async function main() {
|
|
10
|
+
const cfg = loadConfig();
|
|
11
|
+
const { account, created } = loadOrCreateAccount(cfg);
|
|
12
|
+
const history = createHistory(cfg);
|
|
13
|
+
const publicClient = createChainClient(cfg);
|
|
14
|
+
let cachedDomain = null;
|
|
15
|
+
const domain = async () => {
|
|
16
|
+
if (!cachedDomain)
|
|
17
|
+
cachedDomain = await resolveDomain(publicClient, cfg);
|
|
18
|
+
return cachedDomain;
|
|
19
|
+
};
|
|
20
|
+
const [command = 'mcp', ...rest] = process.argv.slice(2);
|
|
21
|
+
if (created && command !== 'mcp') {
|
|
22
|
+
out(`new wallet created at ${cfg.AERON_WALLET_DIR} (hot wallet; keep balances small)`);
|
|
23
|
+
}
|
|
24
|
+
switch (command) {
|
|
25
|
+
case 'address': {
|
|
26
|
+
out(account.address);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
case 'balance': {
|
|
30
|
+
const balances = await readBalances(publicClient, cfg, account.address);
|
|
31
|
+
out(`address ${account.address}`);
|
|
32
|
+
out(`eth ${balances.eth}`);
|
|
33
|
+
out(`usdg ${balances.usdg}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
case 'pay': {
|
|
37
|
+
const url = rest[0];
|
|
38
|
+
if (!url)
|
|
39
|
+
throw new Error('usage: pay <url> [json-body]');
|
|
40
|
+
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() });
|
|
42
|
+
out(JSON.stringify(result, null, 2));
|
|
43
|
+
if (!result.paid && result.reason)
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
case 'history': {
|
|
48
|
+
for (const r of history.recent(10)) {
|
|
49
|
+
out(`${r.ts} $${r.amountUsd} ${r.status} ${r.transaction ?? ''} ${r.url}`);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
case 'mcp': {
|
|
54
|
+
await startMcpServer({ cfg, account, history, publicClient, domain });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
default:
|
|
58
|
+
throw new Error(`unknown command: ${command} (use address|balance|pay|history|mcp)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
main().catch((err) => {
|
|
62
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const envSchema = z.object({
|
|
5
|
+
RPC_URL: z.string().url().default('https://rpc.mainnet.chain.robinhood.com'),
|
|
6
|
+
CHAIN_ID: z.coerce.number().int().positive().default(4663),
|
|
7
|
+
USDG_ADDRESS: z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^0x[0-9a-fA-F]{40}$/)
|
|
10
|
+
.default('0x5fc5360d0400a0fd4f2af552add042d716f1d168'),
|
|
11
|
+
/** Override the stored key (hot wallets only; small balances). */
|
|
12
|
+
AERON_WALLET_KEY: z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^0x[0-9a-fA-F]{64}$/)
|
|
15
|
+
.optional()
|
|
16
|
+
.or(z.literal('').transform(() => undefined)),
|
|
17
|
+
AERON_WALLET_DIR: z.string().default(join(homedir(), '.aeron', 'wallet')),
|
|
18
|
+
/** Budget caps, USD. The wallet refuses to sign above these. */
|
|
19
|
+
MAX_PER_CALL_USD: z.coerce.number().positive().default(0.05),
|
|
20
|
+
DAILY_CAP_USD: z.coerce.number().positive().default(1),
|
|
21
|
+
});
|
|
22
|
+
export function loadConfig(env = process.env) {
|
|
23
|
+
const parsed = envSchema.safeParse(env);
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
26
|
+
throw new Error(`Invalid configuration: ${detail}`);
|
|
27
|
+
}
|
|
28
|
+
return { ...parsed.data, network: `eip155:${parsed.data.CHAIN_ID}` };
|
|
29
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function createHistory(cfg) {
|
|
4
|
+
const filePath = join(cfg.AERON_WALLET_DIR, 'history.jsonl');
|
|
5
|
+
function readAll() {
|
|
6
|
+
if (!existsSync(filePath))
|
|
7
|
+
return [];
|
|
8
|
+
return readFileSync(filePath, 'utf8')
|
|
9
|
+
.split('\n')
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.map((line) => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(line);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
.filter((r) => r !== null);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
append(record) {
|
|
23
|
+
mkdirSync(cfg.AERON_WALLET_DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
appendFileSync(filePath, `${JSON.stringify(record)}\n`);
|
|
25
|
+
},
|
|
26
|
+
recent(limit) {
|
|
27
|
+
return readAll().slice(-limit).reverse();
|
|
28
|
+
},
|
|
29
|
+
spentTodayUsd(now = new Date()) {
|
|
30
|
+
const midnight = new Date(now);
|
|
31
|
+
midnight.setHours(0, 0, 0, 0);
|
|
32
|
+
return readAll()
|
|
33
|
+
.filter((r) => r.status === 'settled' && new Date(r.ts) >= midnight)
|
|
34
|
+
.reduce((sum, r) => sum + r.amountUsd, 0);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/keystore.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
|
|
4
|
+
/**
|
|
5
|
+
* Load or create the wallet key. Stored at <dir>/key with 0600 perms.
|
|
6
|
+
* This is a hot wallet for small, metered spending; not for savings.
|
|
7
|
+
*/
|
|
8
|
+
export function loadOrCreateAccount(cfg) {
|
|
9
|
+
if (cfg.AERON_WALLET_KEY) {
|
|
10
|
+
return { account: privateKeyToAccount(cfg.AERON_WALLET_KEY), created: false };
|
|
11
|
+
}
|
|
12
|
+
const keyPath = join(cfg.AERON_WALLET_DIR, 'key');
|
|
13
|
+
if (existsSync(keyPath)) {
|
|
14
|
+
const key = readFileSync(keyPath, 'utf8').trim();
|
|
15
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(key))
|
|
16
|
+
throw new Error(`corrupt key file at ${keyPath}`);
|
|
17
|
+
return { account: privateKeyToAccount(key), created: false };
|
|
18
|
+
}
|
|
19
|
+
mkdirSync(cfg.AERON_WALLET_DIR, { recursive: true, mode: 0o700 });
|
|
20
|
+
const key = generatePrivateKey();
|
|
21
|
+
writeFileSync(keyPath, `${key}\n`, { mode: 0o600 });
|
|
22
|
+
chmodSync(keyPath, 0o600);
|
|
23
|
+
return { account: privateKeyToAccount(key), created: true };
|
|
24
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { readBalances } from './balances.js';
|
|
5
|
+
import { payX402 } from './payer.js';
|
|
6
|
+
export async function startMcpServer(deps) {
|
|
7
|
+
const { cfg, account, history, publicClient } = deps;
|
|
8
|
+
const server = new McpServer({ name: 'aeron-wallet', version: '0.1.0' });
|
|
9
|
+
server.tool('get_address', 'The wallet address on Robinhood Chain. Fund it with USDG to pay for services.', {}, async () => ({
|
|
10
|
+
content: [{ type: 'text', text: JSON.stringify({ address: account.address, network: cfg.network }) }],
|
|
11
|
+
}));
|
|
12
|
+
server.tool('get_balance', 'ETH and USDG balances of this wallet.', {}, async () => {
|
|
13
|
+
const balances = await readBalances(publicClient, cfg, account.address);
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify({ address: account.address, ...balances }) }] };
|
|
15
|
+
});
|
|
16
|
+
server.tool('pay', 'Call a machine-payable (x402) endpoint and pay in USDG if it answers 402. Budget caps apply.', {
|
|
17
|
+
url: z.string().url(),
|
|
18
|
+
method: z.enum(['GET', 'POST']).default('POST'),
|
|
19
|
+
body: z.string().optional().describe('JSON body to send'),
|
|
20
|
+
}, async ({ url, method, body }) => {
|
|
21
|
+
const domain = await deps.domain();
|
|
22
|
+
const result = await payX402(url, {
|
|
23
|
+
method,
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
...(body ? { body } : {}),
|
|
26
|
+
}, { cfg, account, history, domain });
|
|
27
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
28
|
+
});
|
|
29
|
+
server.tool('history', 'Recent payments made by this wallet.', { limit: z.number().int().min(1).max(50).default(10) }, async ({ limit }) => ({
|
|
30
|
+
content: [{ type: 'text', text: JSON.stringify(history.recent(limit)) }],
|
|
31
|
+
}));
|
|
32
|
+
await server.connect(new StdioServerTransport());
|
|
33
|
+
}
|
package/dist/payer.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { hashDomain } from 'viem';
|
|
4
|
+
/** EIP-3009 typed data, mirrored from the facilitator side. */
|
|
5
|
+
const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
6
|
+
TransferWithAuthorization: [
|
|
7
|
+
{ name: 'from', type: 'address' },
|
|
8
|
+
{ name: 'to', type: 'address' },
|
|
9
|
+
{ name: 'value', type: 'uint256' },
|
|
10
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
11
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
12
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
const DOMAIN_ABI = [
|
|
16
|
+
{ type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
|
|
17
|
+
{ type: 'function', name: 'DOMAIN_SEPARATOR', stateMutability: 'view', inputs: [], outputs: [{ type: 'bytes32' }] },
|
|
18
|
+
];
|
|
19
|
+
export function computeDomainSeparator(domain) {
|
|
20
|
+
return hashDomain({
|
|
21
|
+
domain: { ...domain, chainId: BigInt(domain.chainId) },
|
|
22
|
+
types: {
|
|
23
|
+
EIP712Domain: [
|
|
24
|
+
{ name: 'name', type: 'string' },
|
|
25
|
+
{ name: 'version', type: 'string' },
|
|
26
|
+
{ name: 'chainId', type: 'uint256' },
|
|
27
|
+
{ name: 'verifyingContract', type: 'address' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function resolveDomain(publicClient, cfg) {
|
|
33
|
+
const address = cfg.USDG_ADDRESS;
|
|
34
|
+
const [name, onchain] = await Promise.all([
|
|
35
|
+
publicClient.readContract({ address, abi: DOMAIN_ABI, functionName: 'name' }),
|
|
36
|
+
publicClient.readContract({ address, abi: DOMAIN_ABI, functionName: 'DOMAIN_SEPARATOR' }),
|
|
37
|
+
]);
|
|
38
|
+
for (const version of ['1', '2', '3']) {
|
|
39
|
+
const domain = { name, version, chainId: cfg.CHAIN_ID, verifyingContract: address };
|
|
40
|
+
if (computeDomainSeparator(domain).toLowerCase() === onchain.toLowerCase())
|
|
41
|
+
return domain;
|
|
42
|
+
}
|
|
43
|
+
throw new Error('could not match the token EIP-712 domain version');
|
|
44
|
+
}
|
|
45
|
+
const requirementsSchema = z.looseObject({
|
|
46
|
+
scheme: z.literal('exact'),
|
|
47
|
+
network: z.string(),
|
|
48
|
+
maxAmountRequired: z.string().regex(/^\d+$/),
|
|
49
|
+
payTo: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
50
|
+
asset: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
51
|
+
maxTimeoutSeconds: z.number().optional(),
|
|
52
|
+
});
|
|
53
|
+
const body402Schema = z.looseObject({
|
|
54
|
+
x402Version: z.number(),
|
|
55
|
+
accepts: z.array(requirementsSchema).min(1),
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* The x402 client flow: call, read the 402 offer, enforce budget caps, sign
|
|
59
|
+
* an exact-amount EIP-3009 authorization, retry with X-PAYMENT.
|
|
60
|
+
*/
|
|
61
|
+
export async function payX402(url, init, deps) {
|
|
62
|
+
const { cfg, account, history, domain } = deps;
|
|
63
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
64
|
+
const now = deps.now ?? (() => Math.floor(Date.now() / 1000));
|
|
65
|
+
const first = await fetchImpl(url, init);
|
|
66
|
+
const firstBody = await first.text();
|
|
67
|
+
if (first.status !== 402) {
|
|
68
|
+
return { paid: false, status: first.status, amountUsd: 0, transaction: null, body: firstBody };
|
|
69
|
+
}
|
|
70
|
+
const parsed = body402Schema.safeParse(JSON.parse(firstBody));
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: 'unrecognized 402 offer' };
|
|
73
|
+
}
|
|
74
|
+
const offer = parsed.data.accepts[0];
|
|
75
|
+
if (offer.network !== cfg.network) {
|
|
76
|
+
return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: `network mismatch: ${offer.network}` };
|
|
77
|
+
}
|
|
78
|
+
if (offer.asset.toLowerCase() !== cfg.USDG_ADDRESS.toLowerCase()) {
|
|
79
|
+
return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: 'offer asset is not USDG' };
|
|
80
|
+
}
|
|
81
|
+
const amountUsd = Number(offer.maxAmountRequired) / 1e6;
|
|
82
|
+
if (amountUsd > cfg.MAX_PER_CALL_USD) {
|
|
83
|
+
return {
|
|
84
|
+
paid: false, status: 402, amountUsd, transaction: null, body: firstBody,
|
|
85
|
+
reason: `amount $${amountUsd} exceeds per-call cap $${cfg.MAX_PER_CALL_USD}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const spent = history.spentTodayUsd();
|
|
89
|
+
if (spent + amountUsd > cfg.DAILY_CAP_USD) {
|
|
90
|
+
return {
|
|
91
|
+
paid: false, status: 402, amountUsd, transaction: null, body: firstBody,
|
|
92
|
+
reason: `daily cap reached ($${spent.toFixed(4)} of $${cfg.DAILY_CAP_USD})`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const t = now();
|
|
96
|
+
const authorization = {
|
|
97
|
+
from: account.address,
|
|
98
|
+
to: offer.payTo,
|
|
99
|
+
value: BigInt(offer.maxAmountRequired),
|
|
100
|
+
validAfter: BigInt(t - 60),
|
|
101
|
+
validBefore: BigInt(t + (offer.maxTimeoutSeconds ?? 60) + 540),
|
|
102
|
+
nonce: `0x${randomBytes(32).toString('hex')}`,
|
|
103
|
+
};
|
|
104
|
+
const signature = await account.signTypedData({
|
|
105
|
+
domain,
|
|
106
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
107
|
+
primaryType: 'TransferWithAuthorization',
|
|
108
|
+
message: authorization,
|
|
109
|
+
});
|
|
110
|
+
const paymentHeader = Buffer.from(JSON.stringify({
|
|
111
|
+
x402Version: 1,
|
|
112
|
+
scheme: 'exact',
|
|
113
|
+
network: cfg.network,
|
|
114
|
+
payload: {
|
|
115
|
+
signature,
|
|
116
|
+
authorization: {
|
|
117
|
+
from: authorization.from,
|
|
118
|
+
to: authorization.to,
|
|
119
|
+
value: String(authorization.value),
|
|
120
|
+
validAfter: String(authorization.validAfter),
|
|
121
|
+
validBefore: String(authorization.validBefore),
|
|
122
|
+
nonce: authorization.nonce,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
})).toString('base64');
|
|
126
|
+
const second = await fetchImpl(url, {
|
|
127
|
+
...init,
|
|
128
|
+
headers: { ...init.headers, 'x-payment': paymentHeader },
|
|
129
|
+
});
|
|
130
|
+
const secondBody = await second.text();
|
|
131
|
+
let transaction = null;
|
|
132
|
+
const receiptHeader = second.headers.get('x-payment-response');
|
|
133
|
+
if (receiptHeader) {
|
|
134
|
+
try {
|
|
135
|
+
const receipt = JSON.parse(Buffer.from(receiptHeader, 'base64').toString('utf8'));
|
|
136
|
+
if (typeof receipt.transaction === 'string')
|
|
137
|
+
transaction = receipt.transaction;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* receipt header is informational */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const settled = second.status < 400;
|
|
144
|
+
history.append({
|
|
145
|
+
ts: new Date().toISOString(),
|
|
146
|
+
url,
|
|
147
|
+
amountUsd,
|
|
148
|
+
payer: account.address,
|
|
149
|
+
transaction,
|
|
150
|
+
status: settled ? 'settled' : 'rejected',
|
|
151
|
+
reason: settled ? undefined : secondBody.slice(0, 200),
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
paid: settled, status: second.status, amountUsd, transaction, body: secondBody,
|
|
155
|
+
reason: settled ? undefined : 'payment rejected by the service',
|
|
156
|
+
};
|
|
157
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aeron-wallet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Non-custodial agent wallet for Robinhood Chain. Pays x402 requests in USDG. Ships as a CLI and an MCP server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://aeron.sh/wallet/",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"aeron-wallet": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"x402",
|
|
19
|
+
"mcp",
|
|
20
|
+
"agent",
|
|
21
|
+
"wallet",
|
|
22
|
+
"usdg",
|
|
23
|
+
"stablecoin",
|
|
24
|
+
"micropayments",
|
|
25
|
+
"robinhood-chain"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
29
|
+
"mcp": "tsx src/cli.ts mcp",
|
|
30
|
+
"cli": "tsx src/cli.ts",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"coverage": "vitest run --coverage",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.21.1",
|
|
38
|
+
"viem": "^2.44.3",
|
|
39
|
+
"zod": "^4.1.12"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^24.10.1",
|
|
43
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
44
|
+
"fastify": "^5.6.1",
|
|
45
|
+
"tsx": "^4.20.6",
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
48
|
+
}
|
|
49
|
+
}
|