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 +36 -1
- package/dist/cli-args.js +32 -0
- package/dist/cli.js +12 -8
- package/dist/offers.js +149 -0
- package/dist/payer.js +37 -44
- package/dist/payment-header.js +50 -0
- package/package.json +1 -1
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
|
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/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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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.
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|