aeron-wallet 0.2.0 → 0.3.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
@@ -37,7 +37,32 @@ need ETH: the facilitator relays the transaction and pays gas.
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
- ## MCP
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
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](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,31 @@ 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
+ ## What a result means
146
+
147
+ A request that comes back 4xx is not one situation, it is three, and they
148
+ differ in the only way that matters: whether the money left the wallet. The
149
+ signal is the settlement receipt — a service that settled returns
150
+ `X-PAYMENT-RESPONSE` with a transaction hash, and one that did not, does not.
151
+
152
+ | `status` | Charged | What happened |
153
+ |---|---|---|
154
+ | `settled` | yes | The service answered. `reason` is set only in the bad case below. |
155
+ | `rejected` | no | HTTP 402. The service refused the payment; the authorization is unspent. |
156
+ | `failed` | no | The service returned an error *and declined to charge* — usually its own upstream failed. |
157
+
158
+ The case worth naming: a `settled` row **with** a `reason` means the money
159
+ moved and nothing came back. That is the only outcome where the wallet is out
160
+ of pocket for nothing, so it is reported as itself rather than folded in with
161
+ refusals that cost nothing.
162
+
163
+ Only `settled` counts against `DAILY_CAP_USD`. A refusal and an upstream
164
+ failure leave the balance untouched, so neither eats into the cap.
165
+
166
+ `reason` quotes the service's own message when it gave one, instead of a
167
+ generic phrase — an agent operator reading a log needs to know whether to
168
+ retry, top up, or fix the seller.
169
+
120
170
  ## Configuration
121
171
 
122
172
  | Variable | Default |
@@ -128,6 +178,13 @@ The wallet refuses to sign above either cap, so a loop cannot drain it.
128
178
  | `AERON_WALLET_KEY` | unset. Overrides the stored key. |
129
179
  | `AERON_WALLET_SESSION` | unset. Binds the whole process to one session. |
130
180
 
181
+ ## Releases
182
+
183
+ Published from a tag by GitHub Actions using npm trusted publishing, so no
184
+ long-lived npm token exists to leak and every tarball carries a provenance
185
+ attestation: proof of the commit and workflow it was built from. Verify with
186
+ `npm audit signatures` after installing.
187
+
131
188
  ## Where payments go
132
189
 
133
190
  Payments settle on Robinhood Chain mainnet in USDG through the Aeron
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
  },
@@ -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
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { hashDomain } from 'viem';
4
4
  import { checkSession } from './sessions.js';
5
+ import { describeOutcome } from './outcome.js';
5
6
  /** EIP-3009 typed data, mirrored from the facilitator side. */
6
7
  const TRANSFER_WITH_AUTHORIZATION_TYPES = {
7
8
  TransferWithAuthorization: [
@@ -169,8 +170,10 @@ export async function payX402(url, init, deps) {
169
170
  /* receipt header is informational */
170
171
  }
171
172
  }
172
- const settled = second.status < 400;
173
- if (settled && binding)
173
+ // Whether money moved is the settlement receipt's business, not the status
174
+ // code's: a service can refuse to charge and still answer 4xx.
175
+ const outcome = describeOutcome(second.status, transaction, secondBody);
176
+ if (outcome.charged && binding)
174
177
  binding.recordSpend(amountUsd);
175
178
  history.append({
176
179
  ts: new Date().toISOString(),
@@ -178,11 +181,11 @@ export async function payX402(url, init, deps) {
178
181
  amountUsd,
179
182
  payer: account.address,
180
183
  transaction,
181
- status: settled ? 'settled' : 'rejected',
182
- reason: settled ? undefined : secondBody.slice(0, 200),
184
+ status: outcome.status,
185
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
183
186
  });
184
187
  return {
185
- paid: settled, status: second.status, amountUsd, transaction, body: secondBody,
186
- reason: settled ? undefined : 'payment rejected by the service',
188
+ paid: outcome.ok, status: second.status, amountUsd, transaction, body: secondBody,
189
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
187
190
  };
188
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aeron-wallet",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",