aeron-wallet 0.2.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 +94 -2
- package/dist/cli-args.js +32 -0
- package/dist/cli.js +12 -8
- package/dist/history.js +2 -0
- package/dist/offers.js +149 -0
- package/dist/outcome.js +71 -0
- package/dist/payer.js +46 -50
- package/dist/payment-header.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,14 +30,39 @@ 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. |
|
|
37
37
|
| `session revoke <id>` | Kill a session. It stops paying on its next call. |
|
|
38
38
|
| `mcp` | Run as an MCP server over stdio. The default with no arguments. |
|
|
39
39
|
|
|
40
|
-
##
|
|
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
|
+
[](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**
|
|
41
66
|
|
|
42
67
|
```json
|
|
43
68
|
{
|
|
@@ -117,6 +142,66 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
|
|
|
117
142
|
| `MAX_PER_CALL_USD` | `0.05` | Largest single payment. |
|
|
118
143
|
| `DAILY_CAP_USD` | `1` | Total for the current UTC day. |
|
|
119
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
|
+
|
|
180
|
+
## What a result means
|
|
181
|
+
|
|
182
|
+
A request that comes back 4xx is not one situation, it is three, and they
|
|
183
|
+
differ in the only way that matters: whether the money left the wallet. The
|
|
184
|
+
signal is the settlement receipt — a service that settled returns
|
|
185
|
+
`X-PAYMENT-RESPONSE` with a transaction hash, and one that did not, does not.
|
|
186
|
+
|
|
187
|
+
| `status` | Charged | What happened |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `settled` | yes | The service answered. `reason` is set only in the bad case below. |
|
|
190
|
+
| `rejected` | no | HTTP 402. The service refused the payment; the authorization is unspent. |
|
|
191
|
+
| `failed` | no | The service returned an error *and declined to charge* — usually its own upstream failed. |
|
|
192
|
+
|
|
193
|
+
The case worth naming: a `settled` row **with** a `reason` means the money
|
|
194
|
+
moved and nothing came back. That is the only outcome where the wallet is out
|
|
195
|
+
of pocket for nothing, so it is reported as itself rather than folded in with
|
|
196
|
+
refusals that cost nothing.
|
|
197
|
+
|
|
198
|
+
Only `settled` counts against `DAILY_CAP_USD`. A refusal and an upstream
|
|
199
|
+
failure leave the balance untouched, so neither eats into the cap.
|
|
200
|
+
|
|
201
|
+
`reason` quotes the service's own message when it gave one, instead of a
|
|
202
|
+
generic phrase — an agent operator reading a log needs to know whether to
|
|
203
|
+
retry, top up, or fix the seller.
|
|
204
|
+
|
|
120
205
|
## Configuration
|
|
121
206
|
|
|
122
207
|
| Variable | Default |
|
|
@@ -128,6 +213,13 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
|
|
|
128
213
|
| `AERON_WALLET_KEY` | unset. Overrides the stored key. |
|
|
129
214
|
| `AERON_WALLET_SESSION` | unset. Binds the whole process to one session. |
|
|
130
215
|
|
|
216
|
+
## Releases
|
|
217
|
+
|
|
218
|
+
Published from a tag by GitHub Actions using npm trusted publishing, so no
|
|
219
|
+
long-lived npm token exists to leak and every tarball carries a provenance
|
|
220
|
+
attestation: proof of the commit and workflow it was built from. Verify with
|
|
221
|
+
`npm audit signatures` after installing.
|
|
222
|
+
|
|
131
223
|
## Where payments go
|
|
132
224
|
|
|
133
225
|
Payments settle on Robinhood Chain mainnet in USDG through the Aeron
|
package/dist/cli-args.js
ADDED
|
@@ -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
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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/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/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/outcome.js
ADDED
|
@@ -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,7 +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';
|
|
4
|
+
import { describeOutcome } from './outcome.js';
|
|
5
|
+
import { readOffers, selectOffer } from './offers.js';
|
|
6
|
+
import { paymentAttempts } from './payment-header.js';
|
|
5
7
|
/** EIP-3009 typed data, mirrored from the facilitator side. */
|
|
6
8
|
const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
7
9
|
TransferWithAuthorization: [
|
|
@@ -43,18 +45,6 @@ export async function resolveDomain(publicClient, cfg) {
|
|
|
43
45
|
}
|
|
44
46
|
throw new Error('could not match the token EIP-712 domain version');
|
|
45
47
|
}
|
|
46
|
-
const requirementsSchema = z.looseObject({
|
|
47
|
-
scheme: z.literal('exact'),
|
|
48
|
-
network: z.string(),
|
|
49
|
-
maxAmountRequired: z.string().regex(/^\d+$/),
|
|
50
|
-
payTo: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
51
|
-
asset: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
52
|
-
maxTimeoutSeconds: z.number().optional(),
|
|
53
|
-
});
|
|
54
|
-
const body402Schema = z.looseObject({
|
|
55
|
-
x402Version: z.number(),
|
|
56
|
-
accepts: z.array(requirementsSchema).min(1),
|
|
57
|
-
});
|
|
58
48
|
/** Refused by the wallet before any request went out. */
|
|
59
49
|
const refused = (reason, amountUsd = 0) => ({
|
|
60
50
|
paid: false,
|
|
@@ -87,18 +77,18 @@ export async function payX402(url, init, deps) {
|
|
|
87
77
|
if (first.status !== 402) {
|
|
88
78
|
return { paid: false, status: first.status, amountUsd: 0, transaction: null, body: firstBody };
|
|
89
79
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
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 };
|
|
100
89
|
}
|
|
101
|
-
const
|
|
90
|
+
const offer = choice.offer;
|
|
91
|
+
const amountUsd = Number(offer.atomicAmount) / 1e6;
|
|
102
92
|
if (amountUsd > cfg.MAX_PER_CALL_USD) {
|
|
103
93
|
return {
|
|
104
94
|
paid: false, status: 402, amountUsd, transaction: null, body: firstBody,
|
|
@@ -125,7 +115,7 @@ export async function payX402(url, init, deps) {
|
|
|
125
115
|
const authorization = {
|
|
126
116
|
from: account.address,
|
|
127
117
|
to: offer.payTo,
|
|
128
|
-
value: BigInt(offer.
|
|
118
|
+
value: BigInt(offer.atomicAmount),
|
|
129
119
|
validAfter: BigInt(t - 60),
|
|
130
120
|
validBefore: BigInt(t + (offer.maxTimeoutSeconds ?? 60) + 540),
|
|
131
121
|
nonce: `0x${randomBytes(32).toString('hex')}`,
|
|
@@ -136,27 +126,31 @@ export async function payX402(url, init, deps) {
|
|
|
136
126
|
primaryType: 'TransferWithAuthorization',
|
|
137
127
|
message: authorization,
|
|
138
128
|
});
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
value: String(authorization.value),
|
|
149
|
-
validAfter: String(authorization.validAfter),
|
|
150
|
-
validBefore: String(authorization.validBefore),
|
|
151
|
-
nonce: authorization.nonce,
|
|
152
|
-
},
|
|
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,
|
|
153
138
|
},
|
|
154
|
-
})).toString('base64');
|
|
155
|
-
const second = await fetchImpl(url, {
|
|
156
|
-
...init,
|
|
157
|
-
headers: { ...init.headers, 'x-payment': paymentHeader },
|
|
158
139
|
});
|
|
159
|
-
|
|
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
|
+
}
|
|
160
154
|
let transaction = null;
|
|
161
155
|
const receiptHeader = second.headers.get('x-payment-response');
|
|
162
156
|
if (receiptHeader) {
|
|
@@ -169,8 +163,10 @@ export async function payX402(url, init, deps) {
|
|
|
169
163
|
/* receipt header is informational */
|
|
170
164
|
}
|
|
171
165
|
}
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
// Whether money moved is the settlement receipt's business, not the status
|
|
167
|
+
// code's: a service can refuse to charge and still answer 4xx.
|
|
168
|
+
const outcome = describeOutcome(second.status, transaction, secondBody);
|
|
169
|
+
if (outcome.charged && binding)
|
|
174
170
|
binding.recordSpend(amountUsd);
|
|
175
171
|
history.append({
|
|
176
172
|
ts: new Date().toISOString(),
|
|
@@ -178,11 +174,11 @@ export async function payX402(url, init, deps) {
|
|
|
178
174
|
amountUsd,
|
|
179
175
|
payer: account.address,
|
|
180
176
|
transaction,
|
|
181
|
-
status:
|
|
182
|
-
reason
|
|
177
|
+
status: outcome.status,
|
|
178
|
+
...(outcome.reason ? { reason: outcome.reason } : {}),
|
|
183
179
|
});
|
|
184
180
|
return {
|
|
185
|
-
paid:
|
|
186
|
-
reason
|
|
181
|
+
paid: outcome.ok, status: second.status, amountUsd, transaction, body: secondBody,
|
|
182
|
+
...(outcome.reason ? { reason: outcome.reason } : {}),
|
|
187
183
|
};
|
|
188
184
|
}
|
|
@@ -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
|
+
"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",
|