aeron-wallet 0.3.0 → 0.4.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
@@ -30,7 +30,7 @@ need ETH: the facilitator relays the transaction and pays gas.
30
30
  |---|---|
31
31
  | `address` | Print the wallet address. Creates the key if none exists. |
32
32
  | `balance` | ETH and USDG balances, read from chain. |
33
- | `pay <url> [json]` | Call an x402 endpoint, paying if it answers 402. |
33
+ | `pay [--method GET] <url> [json]` | Call an x402 endpoint, paying if it answers 402. POST unless told otherwise. |
34
34
  | `history` | The last 10 payments, from the local log. |
35
35
  | `session create` | Mint a scoped session: hosts, budget, per-call cap, expiry. |
36
36
  | `session list` | Every session, what it spent, and whether it is still live. |
@@ -142,6 +142,41 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
142
142
  | `MAX_PER_CALL_USD` | `0.05` | Largest single payment. |
143
143
  | `DAILY_CAP_USD` | `1` | Total for the current UTC day. |
144
144
 
145
+ ## Paying merchants you did not write
146
+
147
+ Reading a 402 sounds like one line — take `accepts[0]` from the body — and that
148
+ line works against servers written the same way this wallet was. It works
149
+ against almost nothing else. On a survey of the machine-payable endpoints
150
+ listed on Robinhood Chain, **not one merchant put this rail's offer first**:
151
+ every one of them leads with Base, and the payable entry sits somewhere down a
152
+ list of a dozen.
153
+
154
+ So the offer is searched for, not assumed, across every shape merchants
155
+ actually use:
156
+
157
+ | What differs | What is done |
158
+ |---|---|
159
+ | Offers in the JSON body, or in a base64 `payment-required` header, or both | Both are read, and the same offer stated twice is one offer |
160
+ | The amount is `maxAmountRequired` (v1) or `amount` (v2) | Either is accepted |
161
+ | The list mixes chains and address formats this wallet has no key for | Non-EVM entries are skipped rather than treated as errors |
162
+ | Several offers are payable | The cheapest one wins |
163
+ | Nothing is payable | The refusal names what *was* offered, so the reason is actionable |
164
+
165
+ ### The two protocol versions are not a version number
166
+
167
+ v1 carries the payment in `X-PAYMENT` and names the scheme and network at the
168
+ top level. v2 carries it in `payment-signature`, names neither, and states the
169
+ chosen offer verbatim in `accepted` — a rebuilt copy does not match, because
170
+ the server compares it against what it advertised. Answering a v2 merchant in
171
+ v1's form does not degrade; it is refused.
172
+
173
+ Worse, the split is not clean in the wild: merchants advertise a v2 header
174
+ beside a v1 body, and one host in a family of five wants `X-PAYMENT` while its
175
+ siblings want `payment-signature`. So the payment is offered in the form the
176
+ version asks for and, if that is refused outright, in the other one. Both
177
+ carry the **same** signed authorization, whose EIP-3009 nonce can be spent
178
+ exactly once — so the fallback cannot pay twice, however the server answers.
179
+
145
180
  ## What a result means
146
181
 
147
182
  A request that comes back 4xx is not one situation, it is three, and they
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pulling `--name value` out of an argument list.
3
+ *
4
+ * Small on purpose: the CLI takes positional arguments that mean different
5
+ * things per command, so a flag has to be lifted out before the positions are
6
+ * read, or `pay <url> <body>` starts seeing `--method` as its body.
7
+ */
8
+ export function takeFlag(argv, name) {
9
+ const at = argv.indexOf(`--${name}`);
10
+ if (at === -1)
11
+ return { value: undefined, rest: [...argv] };
12
+ const value = argv[at + 1];
13
+ if (value === undefined || value.startsWith('--'))
14
+ throw new Error(`--${name} needs a value`);
15
+ return { value, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };
16
+ }
17
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
18
+ /**
19
+ * The method to call a resource with.
20
+ *
21
+ * Defaults to POST, which is what most paid endpoints take — but plenty are
22
+ * plain GETs, and a wallet that can only POST cannot pay them at all.
23
+ */
24
+ export function parseMethod(raw) {
25
+ if (raw === undefined)
26
+ return 'POST';
27
+ const method = raw.toUpperCase();
28
+ if (!METHODS.includes(method)) {
29
+ throw new Error(`--method must be one of ${METHODS.join(', ')}`);
30
+ }
31
+ return method;
32
+ }
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { payX402, resolveDomain } from './payer.js';
7
7
  import { startMcpServer } from './mcp.js';
8
8
  import { bindSession, createSessions } from './sessions.js';
9
9
  import { runSessionCommand } from './cli-sessions.js';
10
+ import { parseMethod, takeFlag } from './cli-args.js';
10
11
  const out = (line) => process.stdout.write(`${line}\n`);
11
12
  async function main() {
12
13
  const cfg = loadConfig();
@@ -23,12 +24,10 @@ async function main() {
23
24
  const [command = 'mcp', ...argv] = process.argv.slice(2);
24
25
  // A `--session <token>` flag scopes one call; AERON_WALLET_SESSION scopes
25
26
  // 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;
27
+ const session = takeFlag(argv, 'session');
28
+ const methodFlag = takeFlag(session.rest, 'method');
29
+ const rest = methodFlag.rest;
30
+ const token = session.value ?? cfg.AERON_WALLET_SESSION;
32
31
  const binding = token ? bindSession(sessions, token) : null;
33
32
  if (binding && !binding.current()) {
34
33
  throw new Error('that session token is unknown, revoked, or already gone');
@@ -51,9 +50,14 @@ async function main() {
51
50
  case 'pay': {
52
51
  const url = rest[0];
53
52
  if (!url)
54
- throw new Error('usage: pay <url> [json-body]');
53
+ throw new Error('usage: pay [--method GET] <url> [json-body]');
55
54
  const body = rest[1];
56
- const result = await payX402(url, { method: 'POST', headers: { 'content-type': 'application/json' }, ...(body ? { body } : {}) }, { cfg, account, history, domain: await domain(), binding });
55
+ const method = parseMethod(methodFlag.value);
56
+ const result = await payX402(url, {
57
+ method,
58
+ // A GET carries no body, so it should not announce one either.
59
+ ...(body ? { headers: { 'content-type': 'application/json' }, body } : {}),
60
+ }, { cfg, account, history, domain: await domain(), binding });
57
61
  out(JSON.stringify(result, null, 2));
58
62
  if (!result.paid && result.reason)
59
63
  process.exitCode = 1;
package/dist/offers.js ADDED
@@ -0,0 +1,149 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Reading what a 402 is actually offering.
4
+ *
5
+ * This was one line for a long time — `accepts[0]` from the response body —
6
+ * and that line only ever worked against servers written the same way this
7
+ * wallet was. Out in the open, a merchant lists a dozen offers across a dozen
8
+ * chains, in whichever order suits it, and the one this wallet can pay is
9
+ * rarely the first. Taking the first offer is not a simplification of the
10
+ * protocol; it is a different protocol.
11
+ *
12
+ * Three shapes have to be read as one:
13
+ *
14
+ * - the offers live in the JSON body, or in a base64 `payment-required`
15
+ * header, or both;
16
+ * - the amount is called `maxAmountRequired` by some servers and `amount` by
17
+ * others;
18
+ * - the list mixes chains and address formats this wallet has no key for.
19
+ *
20
+ * So: gather every offer from wherever it is stated, keep the ones this rail
21
+ * can actually settle, and take the cheapest of those.
22
+ */
23
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
24
+ const ATOMIC = /^\d+$/;
25
+ const offerSchema = z.looseObject({
26
+ scheme: z.string(),
27
+ network: z.string(),
28
+ asset: z.string(),
29
+ payTo: z.string(),
30
+ maxAmountRequired: z.string().optional(),
31
+ amount: z.string().optional(),
32
+ maxTimeoutSeconds: z.number().optional(),
33
+ });
34
+ const envelopeSchema = z.looseObject({
35
+ x402Version: z.number().optional(),
36
+ accepts: z.array(z.unknown()).min(1),
37
+ extensions: z.unknown().optional(),
38
+ });
39
+ /**
40
+ * An offer this wallet could sign for, or null.
41
+ *
42
+ * Non-EVM offers are dropped here rather than treated as errors: a Solana or
43
+ * Stellar payee in the list is a normal thing for a merchant to publish, and
44
+ * nothing for an EVM wallet to complain about.
45
+ */
46
+ function normalize(raw, version, extensions) {
47
+ const parsed = offerSchema.safeParse(raw);
48
+ if (!parsed.success)
49
+ return null;
50
+ const offer = parsed.data;
51
+ if (offer.scheme !== 'exact')
52
+ return null;
53
+ if (!ADDRESS.test(offer.payTo) || !ADDRESS.test(offer.asset))
54
+ return null;
55
+ const atomicAmount = offer.maxAmountRequired ?? offer.amount;
56
+ if (atomicAmount === undefined || !ATOMIC.test(atomicAmount) || atomicAmount === '0')
57
+ return null;
58
+ return {
59
+ network: offer.network,
60
+ asset: offer.asset,
61
+ payTo: offer.payTo,
62
+ atomicAmount,
63
+ x402Version: version,
64
+ raw,
65
+ ...(extensions !== undefined ? { extensions } : {}),
66
+ ...(offer.maxTimeoutSeconds !== undefined ? { maxTimeoutSeconds: offer.maxTimeoutSeconds } : {}),
67
+ };
68
+ }
69
+ const acceptsIn = (value) => {
70
+ const parsed = envelopeSchema.safeParse(value);
71
+ if (!parsed.success)
72
+ return { accepts: [], version: 1 };
73
+ // An envelope that does not say is v1: that is the version that predates
74
+ // anyone having to say.
75
+ return {
76
+ accepts: parsed.data.accepts,
77
+ version: parsed.data.x402Version ?? 1,
78
+ ...(parsed.data.extensions !== undefined ? { extensions: parsed.data.extensions } : {}),
79
+ };
80
+ };
81
+ const EMPTY = { accepts: [], version: 1 };
82
+ function decodeHeader(header) {
83
+ if (!header)
84
+ return EMPTY;
85
+ try {
86
+ return acceptsIn(JSON.parse(Buffer.from(header, 'base64').toString('utf8')));
87
+ }
88
+ catch {
89
+ return EMPTY;
90
+ }
91
+ }
92
+ function decodeBody(body) {
93
+ try {
94
+ return acceptsIn(JSON.parse(body));
95
+ }
96
+ catch {
97
+ return EMPTY;
98
+ }
99
+ }
100
+ /**
101
+ * Every offer a 402 states, from the body and the `payment-required` header
102
+ * alike. Neither is authoritative over the other: servers use one, the other,
103
+ * or both, and an offer is an offer wherever it was written down.
104
+ */
105
+ export function readOffers(body, paymentRequiredHeader) {
106
+ const fromBody = decodeBody(body);
107
+ const fromHeader = decodeHeader(paymentRequiredHeader ?? null);
108
+ const offers = [
109
+ ...fromBody.accepts.map((raw) => normalize(raw, fromBody.version, fromBody.extensions)),
110
+ ...fromHeader.accepts.map((raw) => normalize(raw, fromHeader.version, fromHeader.extensions)),
111
+ ].filter((offer) => offer !== null);
112
+ // The same offer stated in both places is one offer, not two — and when a
113
+ // server states it twice it is usually a v2 server keeping a v1 body around
114
+ // for old clients. Answering the older statement makes a v2 server refuse a
115
+ // payment it advertised itself, so the newer one wins.
116
+ const best = new Map();
117
+ for (const offer of offers) {
118
+ const key = `${offer.network}|${offer.asset.toLowerCase()}|${offer.payTo.toLowerCase()}|${offer.atomicAmount}`;
119
+ const held = best.get(key);
120
+ if (!held || offer.x402Version > held.x402Version)
121
+ best.set(key, offer);
122
+ }
123
+ return [...best.values()];
124
+ }
125
+ /**
126
+ * The cheapest offer payable on this rail.
127
+ *
128
+ * When nothing matches, the reason names what was on the table instead — a
129
+ * buyer debugging a refusal needs to know whether the merchant wants another
130
+ * chain or another token, not merely that it said no.
131
+ */
132
+ export function selectOffer(offers, want) {
133
+ if (offers.length === 0)
134
+ return { ok: false, reason: 'unrecognized 402 offer' };
135
+ const asset = want.asset.toLowerCase();
136
+ const payable = offers
137
+ .filter((offer) => offer.network === want.network && offer.asset.toLowerCase() === asset)
138
+ .sort((a, b) => (BigInt(a.atomicAmount) < BigInt(b.atomicAmount) ? -1 : 1));
139
+ const offer = payable[0];
140
+ if (offer)
141
+ return { ok: true, offer };
142
+ const onNetwork = offers.filter((o) => o.network === want.network);
143
+ if (onNetwork.length > 0) {
144
+ const tokens = [...new Set(onNetwork.map((o) => o.asset))].join(', ');
145
+ return { ok: false, reason: `no offer in USDG on ${want.network} (offered: ${tokens})` };
146
+ }
147
+ const networks = [...new Set(offers.map((o) => o.network))].join(', ');
148
+ return { ok: false, reason: `no offer on ${want.network} (offered: ${networks})` };
149
+ }
package/dist/payer.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { z } from 'zod';
3
2
  import { hashDomain } from 'viem';
4
3
  import { checkSession } from './sessions.js';
5
4
  import { describeOutcome } from './outcome.js';
5
+ import { readOffers, selectOffer } from './offers.js';
6
+ import { paymentAttempts } from './payment-header.js';
6
7
  /** EIP-3009 typed data, mirrored from the facilitator side. */
7
8
  const TRANSFER_WITH_AUTHORIZATION_TYPES = {
8
9
  TransferWithAuthorization: [
@@ -44,18 +45,6 @@ export async function resolveDomain(publicClient, cfg) {
44
45
  }
45
46
  throw new Error('could not match the token EIP-712 domain version');
46
47
  }
47
- const requirementsSchema = z.looseObject({
48
- scheme: z.literal('exact'),
49
- network: z.string(),
50
- maxAmountRequired: z.string().regex(/^\d+$/),
51
- payTo: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
52
- asset: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
53
- maxTimeoutSeconds: z.number().optional(),
54
- });
55
- const body402Schema = z.looseObject({
56
- x402Version: z.number(),
57
- accepts: z.array(requirementsSchema).min(1),
58
- });
59
48
  /** Refused by the wallet before any request went out. */
60
49
  const refused = (reason, amountUsd = 0) => ({
61
50
  paid: false,
@@ -88,18 +77,18 @@ export async function payX402(url, init, deps) {
88
77
  if (first.status !== 402) {
89
78
  return { paid: false, status: first.status, amountUsd: 0, transaction: null, body: firstBody };
90
79
  }
91
- const parsed = body402Schema.safeParse(JSON.parse(firstBody));
92
- if (!parsed.success) {
93
- return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: 'unrecognized 402 offer' };
94
- }
95
- const offer = parsed.data.accepts[0];
96
- if (offer.network !== cfg.network) {
97
- return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: `network mismatch: ${offer.network}` };
98
- }
99
- if (offer.asset.toLowerCase() !== cfg.USDG_ADDRESS.toLowerCase()) {
100
- return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: 'offer asset is not USDG' };
80
+ // A merchant states its offers in the body, in the payment-required header,
81
+ // or both, and lists every chain it takes. The one this wallet can settle is
82
+ // rarely the first, so it is searched for rather than assumed.
83
+ const choice = selectOffer(readOffers(firstBody, first.headers.get('payment-required')), {
84
+ network: cfg.network,
85
+ asset: cfg.USDG_ADDRESS,
86
+ });
87
+ if (!choice.ok) {
88
+ return { paid: false, status: 402, amountUsd: 0, transaction: null, body: firstBody, reason: choice.reason };
101
89
  }
102
- const amountUsd = Number(offer.maxAmountRequired) / 1e6;
90
+ const offer = choice.offer;
91
+ const amountUsd = Number(offer.atomicAmount) / 1e6;
103
92
  if (amountUsd > cfg.MAX_PER_CALL_USD) {
104
93
  return {
105
94
  paid: false, status: 402, amountUsd, transaction: null, body: firstBody,
@@ -126,7 +115,7 @@ export async function payX402(url, init, deps) {
126
115
  const authorization = {
127
116
  from: account.address,
128
117
  to: offer.payTo,
129
- value: BigInt(offer.maxAmountRequired),
118
+ value: BigInt(offer.atomicAmount),
130
119
  validAfter: BigInt(t - 60),
131
120
  validBefore: BigInt(t + (offer.maxTimeoutSeconds ?? 60) + 540),
132
121
  nonce: `0x${randomBytes(32).toString('hex')}`,
@@ -137,27 +126,31 @@ export async function payX402(url, init, deps) {
137
126
  primaryType: 'TransferWithAuthorization',
138
127
  message: authorization,
139
128
  });
140
- const paymentHeader = Buffer.from(JSON.stringify({
141
- x402Version: 1,
142
- scheme: 'exact',
143
- network: cfg.network,
144
- payload: {
145
- signature,
146
- authorization: {
147
- from: authorization.from,
148
- to: authorization.to,
149
- value: String(authorization.value),
150
- validAfter: String(authorization.validAfter),
151
- validBefore: String(authorization.validBefore),
152
- nonce: authorization.nonce,
153
- },
129
+ const attempts = paymentAttempts(offer, {
130
+ signature,
131
+ authorization: {
132
+ from: authorization.from,
133
+ to: authorization.to,
134
+ value: String(authorization.value),
135
+ validAfter: String(authorization.validAfter),
136
+ validBefore: String(authorization.validBefore),
137
+ nonce: authorization.nonce,
154
138
  },
155
- })).toString('base64');
156
- const second = await fetchImpl(url, {
157
- ...init,
158
- headers: { ...init.headers, 'x-payment': paymentHeader },
159
139
  });
160
- const secondBody = await second.text();
140
+ // Offer the payment in the form the version asks for, then in the other one
141
+ // if it is refused outright. Both carry the same authorization, so at most
142
+ // one of them can ever settle.
143
+ let second;
144
+ let secondBody = '';
145
+ for (const attempt of attempts) {
146
+ second = await fetchImpl(url, {
147
+ ...init,
148
+ headers: { ...init.headers, [attempt.name]: attempt.value },
149
+ });
150
+ secondBody = await second.text();
151
+ if (second.status !== 402)
152
+ break;
153
+ }
161
154
  let transaction = null;
162
155
  const receiptHeader = second.headers.get('x-payment-response');
163
156
  if (receiptHeader) {
@@ -0,0 +1,50 @@
1
+ const encode = (payload) => Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
2
+ function v2Header(offer, signed) {
3
+ return {
4
+ name: 'payment-signature',
5
+ value: encode({
6
+ x402Version: Math.max(offer.x402Version, 2),
7
+ // The chosen offer, exactly as the server wrote it. v2 has no top-level
8
+ // scheme or network — they live in here, and a server that matches the
9
+ // payment against what it advertised needs the entry back byte for byte
10
+ // rather than rebuilt.
11
+ accepted: offer.raw,
12
+ payload: signed,
13
+ // Echoed the way the reference client echoes it: the server sent these
14
+ // with the offer, and some read them again on the way in.
15
+ ...(offer.extensions !== undefined ? { extensions: offer.extensions } : {}),
16
+ }),
17
+ };
18
+ }
19
+ function v1Header(offer, signed) {
20
+ return {
21
+ name: 'x-payment',
22
+ value: encode({
23
+ x402Version: 1,
24
+ scheme: 'exact',
25
+ network: offer.network,
26
+ payload: signed,
27
+ }),
28
+ };
29
+ }
30
+ /** The form this offer's own version asks for. */
31
+ export function buildPaymentHeader(offer, signed) {
32
+ return offer.x402Version >= 2 ? v2Header(offer, signed) : v1Header(offer, signed);
33
+ }
34
+ /**
35
+ * The forms to offer a payment in, best guess first.
36
+ *
37
+ * Out in the open the two versions are not cleanly separated: merchants
38
+ * advertise a v2 header beside a v1 body, and which one they will actually
39
+ * accept is not reliably stated anywhere — one host in a family of five wants
40
+ * `X-PAYMENT` while its siblings want `payment-signature`.
41
+ *
42
+ * So present the other form too. A refusal costs nothing, and both forms carry
43
+ * the *same* signed authorization, whose EIP-3009 nonce can be spent exactly
44
+ * once. The fallback therefore cannot pay twice, however the server answers.
45
+ */
46
+ export function paymentAttempts(offer, signed) {
47
+ return offer.x402Version >= 2
48
+ ? [v2Header(offer, signed), v1Header(offer, signed)]
49
+ : [v1Header(offer, signed), v2Header(offer, signed)];
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aeron-wallet",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",