javascript-solid-server 0.0.104 → 0.0.106

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/PAY.md ADDED
@@ -0,0 +1,338 @@
1
+ # PAY.md — HTTP 402 Payment System
2
+
3
+ ## What This Is
4
+
5
+ JSS has a built-in payment system. Resources under `/pay/*` cost satoshis to access. Users authenticate with a Nostr key, deposit sats, and spend them on API requests. Optionally, the pod mints its own token (MRC20 on Bitcoin) that users can buy, sell, and trade.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ User (Nostr keypair)
11
+
12
+ ├── POST /pay/.deposit → credit sat balance
13
+ ├── GET /pay/.balance → check balance
14
+ ├── GET /pay/* → spend 1 sat, get resource
15
+ ├── POST /pay/.buy → spend sats, get tokens (Bitcoin TX)
16
+ ├── POST /pay/.withdraw → spend balance, get tokens back
17
+ ├── POST /pay/.sell → list tokens for sale
18
+ └── POST /pay/.swap → buy someone's sell order
19
+ ```
20
+
21
+ All state lives in two places:
22
+ - **Webledger** — `/.well-known/webledgers/webledgers.json` — sat balances per `did:nostr:<pubkey>`
23
+ - **Token trail** — `/.well-known/token/<ticker>.json` — MRC20 state chain anchored to Bitcoin
24
+
25
+ ## Authentication
26
+
27
+ Every request to `/pay/*` (except `.info` and `.offers`) requires a NIP-98 auth header:
28
+
29
+ ```
30
+ Authorization: Nostr <base64-encoded-signed-event>
31
+ ```
32
+
33
+ The event is kind 27235 with tags `["u", "<url>"]` and `["method", "<METHOD>"]`, signed with the user's Nostr private key. The server extracts the pubkey and maps it to `did:nostr:<pubkey>` for balance lookup.
34
+
35
+ ## Endpoints
36
+
37
+ ### GET /pay/.info
38
+ **Public. No auth required.**
39
+
40
+ Returns pod payment configuration.
41
+
42
+ Response:
43
+ ```json
44
+ {
45
+ "cost": 1,
46
+ "unit": "sat",
47
+ "deposit": "/pay/.deposit",
48
+ "balance": "/pay/.balance",
49
+ "token": {
50
+ "ticker": "PODS",
51
+ "rate": 10,
52
+ "buy": "/pay/.buy",
53
+ "withdraw": "/pay/.withdraw",
54
+ "supply": 10000,
55
+ "issuer": "025e60b6..."
56
+ }
57
+ }
58
+ ```
59
+
60
+ The `token` field is only present when `--pay-token` is configured. `rate` is sats per token.
61
+
62
+ ### GET /pay/.balance
63
+ **Requires NIP-98 auth.**
64
+
65
+ Returns the caller's sat balance.
66
+
67
+ Response:
68
+ ```json
69
+ {
70
+ "did": "did:nostr:4fa459ad...",
71
+ "balance": 1041588,
72
+ "cost": 1,
73
+ "unit": "sat"
74
+ }
75
+ ```
76
+
77
+ ### POST /pay/.deposit
78
+ **Requires NIP-98 auth.**
79
+
80
+ Credits the caller's balance. Two deposit types:
81
+
82
+ **Sats (TXO URI):**
83
+ ```
84
+ POST /pay/.deposit
85
+ Content-Type: text/plain
86
+ Body: <txid>:<vout>
87
+ ```
88
+
89
+ The server calls the mempool API to verify the UTXO exists and reads its value. The sat amount is credited to the caller's webledger balance.
90
+
91
+ **MRC20 tokens (state proof):**
92
+ ```json
93
+ POST /pay/.deposit
94
+ Content-Type: application/json
95
+ {
96
+ "type": "mrc20",
97
+ "state": { ... },
98
+ "prevState": { ... },
99
+ "anchor": {
100
+ "pubkey": "<issuer-compressed-pubkey>",
101
+ "stateStrings": ["<jcs-of-each-state>"],
102
+ "network": "testnet4"
103
+ }
104
+ }
105
+ ```
106
+
107
+ The server verifies: state chain integrity (`state.prev == SHA256(JCS(prevState))`), transfer to the pod's `payAddress`, and optionally anchor verification (derives expected taproot address from pubkey + state chain, checks mempool for UTXO). Replay protection rejects duplicate state hashes.
108
+
109
+ ### POST /pay/.buy
110
+ **Requires NIP-98 auth. Requires `--pay-token` configured.**
111
+
112
+ Buy tokens from the pod at the configured `payRate` (sats per token).
113
+
114
+ Request (pick one):
115
+ ```json
116
+ { "amount": 100 } // buy 100 tokens
117
+ { "sats": 1000 } // spend 1000 sats worth
118
+ ```
119
+
120
+ The server:
121
+ 1. Checks caller's sat balance >= cost
122
+ 2. Loads the pod's MRC20 token trail
123
+ 3. Calls `transferToken()` — creates a new MRC20 state transferring tokens to the buyer's pubkey, derives a new taproot address via BIP-341 key chaining, builds and broadcasts a Bitcoin transaction
124
+ 4. Debits sats from caller's webledger balance
125
+
126
+ Response includes a portable proof:
127
+ ```json
128
+ {
129
+ "bought": 100,
130
+ "ticker": "PODS",
131
+ "cost": 1000,
132
+ "rate": 10,
133
+ "balance": 1040588,
134
+ "txid": "c3183f41...",
135
+ "proof": {
136
+ "state": { ... },
137
+ "prevState": { ... },
138
+ "anchor": {
139
+ "pubkey": "025e60b6...",
140
+ "stateStrings": ["<jcs>", "<jcs>"],
141
+ "network": "testnet4"
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ The proof is independently verifiable: anyone can derive the expected taproot address from the pubkey + stateStrings and check the Bitcoin UTXO.
148
+
149
+ ### POST /pay/.withdraw
150
+ **Requires NIP-98 auth. Requires `--pay-token` configured.**
151
+
152
+ Convert sat balance back to portable tokens. Same mechanism as buy.
153
+
154
+ Request (pick one):
155
+ ```json
156
+ { "tokens": 50 } // withdraw 50 tokens
157
+ { "sats": 500 } // withdraw 500 sats worth
158
+ { "all": true } // drain entire balance
159
+ ```
160
+
161
+ Response is identical to buy, with `"withdrawn"` instead of `"bought"`.
162
+
163
+ ### GET /pay/.offers
164
+ **Public. No auth required.**
165
+
166
+ Returns open sell orders from the secondary market.
167
+
168
+ Response:
169
+ ```json
170
+ [
171
+ {
172
+ "id": "uuid",
173
+ "seller": "<pubkey>",
174
+ "ticker": "PODS",
175
+ "amount": 100,
176
+ "price": 1500,
177
+ "rate": 15,
178
+ "status": "pending",
179
+ "created": 1773259044534
180
+ }
181
+ ]
182
+ ```
183
+
184
+ ### POST /pay/.sell
185
+ **Requires NIP-98 auth. Requires `--pay-token` configured.**
186
+
187
+ Create a sell order. The seller must have tokens on the pod's MRC20 trail.
188
+
189
+ Request:
190
+ ```json
191
+ {
192
+ "amount": 100,
193
+ "price": 1500
194
+ }
195
+ ```
196
+
197
+ `amount` = tokens to sell, `price` = total sats asked. The server verifies the seller's token balance on the trail before creating the order.
198
+
199
+ ### POST /pay/.swap
200
+ **Requires NIP-98 auth. Requires `--pay-token` configured.**
201
+
202
+ Execute a swap against an open sell order.
203
+
204
+ Request:
205
+ ```json
206
+ { "id": "<offer-uuid>" }
207
+ ```
208
+
209
+ The server:
210
+ 1. Finds the pending offer
211
+ 2. Checks buyer's sat balance >= offer price
212
+ 3. Transfers tokens from seller to buyer on the MRC20 trail (Bitcoin TX)
213
+ 4. Debits buyer's sats, credits seller's sats on the webledger
214
+ 5. Marks offer as filled
215
+
216
+ Response includes the portable proof, same as buy.
217
+
218
+ ### GET /pay/*
219
+ **Requires NIP-98 auth.**
220
+
221
+ Access a paid resource. The server deducts `cost` sats from the caller's balance. If balance < cost, returns 402:
222
+
223
+ ```json
224
+ {
225
+ "error": "Payment Required",
226
+ "balance": 0,
227
+ "cost": 1,
228
+ "unit": "sat",
229
+ "deposit": "/pay/.deposit"
230
+ }
231
+ ```
232
+
233
+ On success, the resource is served normally with headers:
234
+ ```
235
+ X-Balance: 1040587
236
+ X-Cost: 1
237
+ ```
238
+
239
+ ### PUT /pay/*
240
+
241
+ Standard upload — goes through normal WAC auth, not the pay middleware. Only the pod owner can write to `/pay/`.
242
+
243
+ ## Configuration
244
+
245
+ ### CLI flags
246
+ ```
247
+ --pay Enable HTTP 402 for /pay/* routes
248
+ --pay-cost <n> Cost per request in satoshis (default: 1)
249
+ --pay-mempool-url <url> Mempool API URL (default: testnet4)
250
+ --pay-address <addr> Address for receiving MRC20 deposits
251
+ --pay-token <ticker> Token ticker (enables buy/withdraw/sell/swap)
252
+ --pay-rate <n> Sats per token for buy/withdraw (default: 1)
253
+ ```
254
+
255
+ ### Environment variables
256
+ ```
257
+ JSS_PAY=true
258
+ JSS_PAY_COST=1
259
+ JSS_PAY_MEMPOOL_URL=https://mempool.space/testnet4
260
+ JSS_PAY_ADDRESS=tb1q...
261
+ JSS_PAY_TOKEN=PODS
262
+ JSS_PAY_RATE=10
263
+ ```
264
+
265
+ ## Token Management (CLI)
266
+
267
+ ```bash
268
+ # Mint a new token (requires funded Bitcoin UTXO)
269
+ jss token mint --ticker PODS --supply 10000 \
270
+ --voucher "txo:btc:<txid>:<vout>?amount=<sats>&key=<privkey-hex>"
271
+
272
+ # Transfer tokens
273
+ jss token transfer --ticker PODS --to <pubkey> --amount 100
274
+
275
+ # Show token info
276
+ jss token info PODS
277
+ ```
278
+
279
+ ## Data Files
280
+
281
+ | File | Contents |
282
+ |------|----------|
283
+ | `/.well-known/webledgers/webledgers.json` | Sat balances per DID (webledgers.org spec) |
284
+ | `/.well-known/webledgers/replay.json` | Seen MRC20 state hashes (replay protection) |
285
+ | `/.well-known/webledgers/offers.json` | Open sell orders (secondary market) |
286
+ | `/.well-known/token/<ticker>.json` | MRC20 token trail (state chain, keys, UTXO) |
287
+
288
+ ## Source Files
289
+
290
+ | File | Purpose |
291
+ |------|---------|
292
+ | `src/handlers/pay.js` | All `/pay/*` route handling |
293
+ | `src/webledger.js` | Balance read/write/credit/debit |
294
+ | `src/mrc20.js` | MRC20 verification, JCS, BIP-341 key chaining, bech32m |
295
+ | `src/token.js` | Token mint/transfer, Bitcoin TX building, trail persistence |
296
+ | `src/auth/nostr.js` | NIP-98 auth extraction |
297
+
298
+ ## Key Concepts
299
+
300
+ **Webledger**: A JSON file mapping URIs to numerical balances, following the [webledgers.org](https://webledgers.org/) spec. The URI format is `did:nostr:<pubkey>`.
301
+
302
+ **MRC20**: A token profile on blocktrails. Each state is a JSON object with `profile`, `prev` (hash link to previous state), `seq`, `ticker`, `balances`, and `ops`. States form a hash chain.
303
+
304
+ **BIP-341 Key Chaining**: Each MRC20 state is hashed and used as a taproot tweak scalar. The scalar is added to the issuer's public key via elliptic curve addition to derive a unique P2TR address per state. This anchors the state chain to Bitcoin — anyone can verify by re-deriving the address and checking the UTXO.
305
+
306
+ **JCS (RFC 8785)**: JSON Canonicalization Scheme — sorted keys, no whitespace, deterministic serialization. Used for hashing states.
307
+
308
+ **NIP-98**: Nostr HTTP Authentication. A signed event (kind 27235) with the request URL and method in tags, base64-encoded in the Authorization header.
309
+
310
+ **NIP-69 (kind 38383)**: P2P order events for trading. Used as the convention for sell orders in the secondary market.
311
+
312
+ ## Flow Examples
313
+
314
+ ### Agent buys API access
315
+ ```
316
+ 1. Agent has a funded TXO (Bitcoin UTXO with known private key)
317
+ 2. GET /pay/.info → learns cost=1, deposit endpoint
318
+ 3. POST /pay/.deposit → posts TXO URI, gets 1M sats credited
319
+ 4. GET /pay/data/feed.json → costs 1 sat, returns data
320
+ 5. (repeat step 4 up to 1M times)
321
+ ```
322
+
323
+ ### User buys and trades tokens
324
+ ```
325
+ 1. POST /pay/.deposit → deposit sats
326
+ 2. POST /pay/.buy → buy 100 PODS for 1000 sats, get Bitcoin proof
327
+ 3. POST /pay/.sell → list 50 PODS for sale at 750 sats
328
+ 4. (another user)
329
+ 5. GET /pay/.offers → sees the sell order
330
+ 6. POST /pay/.swap → buys the 50 PODS, seller gets 750 sats credited
331
+ ```
332
+
333
+ ### Full exit
334
+ ```
335
+ 1. POST /pay/.withdraw { "all": true } → converts entire balance to portable tokens
336
+ 2. User now holds MRC20 proof, independently verifiable on Bitcoin
337
+ 3. Can deposit on another pod, or trade peer-to-peer
338
+ ```
package/README.md CHANGED
@@ -156,6 +156,8 @@ jss --help # Show help
156
156
  | `--pay-cost <n>` | Cost per request in satoshis | 1 |
157
157
  | `--pay-mempool-url <url>` | Mempool API URL for deposit verification | (testnet4) |
158
158
  | `--pay-address <addr>` | Address for receiving deposits | - |
159
+ | `--pay-token <ticker>` | Token to sell (enables primary market + withdrawal) | - |
160
+ | `--pay-rate <n>` | Sats per token for buy/withdraw | 1 |
159
161
  | `--mongo` | Enable MongoDB-backed /db/ route | false |
160
162
  | `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
161
163
  | `--mongo-database <name>` | MongoDB database name | solid |
@@ -187,6 +189,8 @@ export JSS_SOLIDOS_UI=true
187
189
  export JSS_PAY=true
188
190
  export JSS_PAY_COST=10
189
191
  export JSS_PAY_ADDRESS=your-address
192
+ export JSS_PAY_TOKEN=PODS
193
+ export JSS_PAY_RATE=10
190
194
  export JSS_MONGO=true
191
195
  export JSS_MONGO_URL=mongodb://localhost:27017
192
196
  export JSS_MONGO_DATABASE=solid
@@ -823,15 +827,18 @@ Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
823
827
  Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance.
824
828
 
825
829
  ```bash
826
- jss start --pay --pay-cost 10 --pay-address your-address
830
+ jss start --pay --pay-cost 10 --pay-address your-address --pay-token PODS --pay-rate 10
827
831
  ```
828
832
 
829
833
  ### Routes
830
834
 
831
835
  | Method | Path | Description |
832
836
  |--------|------|-------------|
837
+ | GET | `/pay/.info` | Public: cost, token info, available routes |
833
838
  | GET | `/pay/.balance` | Check your balance (NIP-98 auth) |
834
- | POST | `/pay/.deposit` | Deposit sats via TXO URI (`txid:vout`) |
839
+ | POST | `/pay/.deposit` | Deposit sats via TXO URI or MRC20 state proof |
840
+ | POST | `/pay/.buy` | Buy tokens with sat balance (requires `--pay-token`) |
841
+ | POST | `/pay/.withdraw` | Withdraw balance as portable tokens (requires `--pay-token`) |
835
842
  | GET | `/pay/*` | Paid resource access (deducts balance) |
836
843
 
837
844
  ### How It Works
@@ -840,7 +847,8 @@ jss start --pay --pay-cost 10 --pay-address your-address
840
847
  2. Check balance at `/pay/.balance`
841
848
  3. Deposit sats by POSTing a TXO URI to `/pay/.deposit`
842
849
  4. Access paid resources — each request deducts the configured cost
843
- 5. Balance tracked in a [Web Ledger](https://webledgers.org/) at `/.well-known/webledgers/webledgers.json`
850
+ 5. Optionally buy tokens (`/pay/.buy`) or withdraw as portable tokens (`/pay/.withdraw`)
851
+ 6. Balance tracked in a [Web Ledger](https://webledgers.org/) at `/.well-known/webledgers/webledgers.json`
844
852
 
845
853
  ### Example
846
854
 
@@ -855,9 +863,21 @@ curl -X POST -H "Authorization: Nostr <base64-event>" \
855
863
 
856
864
  # Access paid resource
857
865
  curl -H "Authorization: Nostr <base64-event>" http://localhost:3000/pay/my-resource
866
+
867
+ # Buy tokens with sat balance
868
+ curl -X POST -H "Authorization: Nostr <base64-event>" \
869
+ -H "Content-Type: application/json" \
870
+ http://localhost:3000/pay/.buy \
871
+ -d '{"amount": 100}'
872
+
873
+ # Withdraw entire balance as portable tokens
874
+ curl -X POST -H "Authorization: Nostr <base64-event>" \
875
+ -H "Content-Type: application/json" \
876
+ http://localhost:3000/pay/.withdraw \
877
+ -d '{"all": true}'
858
878
  ```
859
879
 
860
- Deposit verification uses the mempool API (default: testnet4). The `X-Balance` and `X-Cost` headers are returned on successful paid requests.
880
+ Deposit verification uses the mempool API (default: testnet4). The `X-Balance` and `X-Cost` headers are returned on successful paid requests. Buy and withdraw return portable MRC20 proofs with Bitcoin anchor data for independent verification.
861
881
 
862
882
  ## Authentication
863
883
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "benchmark": "node benchmark.js"
25
25
  },
26
26
  "dependencies": {
27
+ "@noble/curves": "^1.2.0",
27
28
  "@fastify/middie": "^8.3.3",
28
29
  "@fastify/rate-limit": "^9.1.0",
29
30
  "@fastify/websocket": "^8.3.1",
@@ -5,10 +5,14 @@
5
5
  * Authentication via NIP-98. Balance tracking via Web Ledgers spec.
6
6
  *
7
7
  * Routes:
8
+ * GET /pay/.info — public endpoint: cost, token info, available routes
8
9
  * GET /pay/.balance — check your balance
9
10
  * POST /pay/.deposit — deposit sats (TXO URI) or tokens (MRC20 state proof)
10
11
  * POST /pay/.buy — buy tokens with sat balance (primary market)
11
12
  * POST /pay/.withdraw — withdraw balance as tokens (portable MRC20 proof)
13
+ * GET /pay/.offers — list open sell orders (secondary market)
14
+ * POST /pay/.sell — create a sell order (NIP-69 kind 38383)
15
+ * POST /pay/.swap — execute a swap against a sell order
12
16
  * GET /pay/* — paid resource access (requires balance >= cost)
13
17
  * PUT /pay/* — upload resources (standard auth)
14
18
  *
@@ -21,6 +25,7 @@
21
25
  * - MRC20 profile: https://blocktrails.org/
22
26
  */
23
27
 
28
+ import crypto from 'crypto';
24
29
  import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
25
30
  import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
26
31
  import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, sha256Hex } from '../mrc20.js';
@@ -53,6 +58,21 @@ async function checkAndRecordState(stateHash) {
53
58
  return true;
54
59
  }
55
60
 
61
+ // --- Offers storage (secondary market) ---
62
+ const offersFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/offers.json');
63
+
64
+ async function loadOffers() {
65
+ try {
66
+ const data = await fs.readFile(offersFile(), 'utf8');
67
+ return JSON.parse(data);
68
+ } catch { return []; }
69
+ }
70
+
71
+ async function saveOffers(offers) {
72
+ await fs.ensureDir(path.dirname(offersFile()));
73
+ await fs.writeFile(offersFile(), JSON.stringify(offers, null, 2));
74
+ }
75
+
56
76
  // --- Deposit verification via mempool API ---
57
77
 
58
78
  async function verifySatsDeposit(txoUri, mempoolUrl) {
@@ -157,6 +177,30 @@ export function createPayHandler(options = {}) {
157
177
  const url = request.url.split('?')[0];
158
178
  if (!isPayRequest(request.url)) return;
159
179
 
180
+ // --- GET /pay/.info — public, no auth ---
181
+ if (url === '/pay/.info' && request.method === 'GET') {
182
+ const info = {
183
+ cost,
184
+ unit: 'sat',
185
+ deposit: '/pay/.deposit',
186
+ balance: '/pay/.balance'
187
+ };
188
+ if (payToken) {
189
+ const trail = await loadTrail(payToken);
190
+ info.token = {
191
+ ticker: payToken,
192
+ rate: payRate,
193
+ buy: '/pay/.buy',
194
+ withdraw: '/pay/.withdraw'
195
+ };
196
+ if (trail) {
197
+ info.token.supply = trail.latestState?.supply ?? null;
198
+ info.token.issuer = trail.pubkeyBase ?? null;
199
+ }
200
+ }
201
+ return reply.send(info);
202
+ }
203
+
160
204
  // --- GET /pay/.balance ---
161
205
  if (url === '/pay/.balance' && request.method === 'GET') {
162
206
  const pubkey = await getNostrPubkey(request);
@@ -280,8 +324,12 @@ export function createPayHandler(options = {}) {
280
324
 
281
325
  // Parse buy request
282
326
  let body = request.body;
283
- if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
284
- if (typeof body === 'string') body = JSON.parse(body);
327
+ try {
328
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
329
+ if (typeof body === 'string') body = JSON.parse(body);
330
+ } catch {
331
+ return reply.code(400).send({ error: 'Invalid JSON body' });
332
+ }
285
333
 
286
334
  const ticker = body?.ticker || payToken;
287
335
  if (ticker !== payToken) {
@@ -378,8 +426,12 @@ export function createPayHandler(options = {}) {
378
426
 
379
427
  // Parse withdraw request
380
428
  let body = request.body;
381
- if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
382
- if (typeof body === 'string') body = JSON.parse(body);
429
+ try {
430
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
431
+ if (typeof body === 'string') body = JSON.parse(body);
432
+ } catch {
433
+ return reply.code(400).send({ error: 'Invalid JSON body' });
434
+ }
383
435
 
384
436
  const didUri = pubkeyToDidNostr(pubkey);
385
437
  const ledger = await readLedger();
@@ -461,6 +513,166 @@ export function createPayHandler(options = {}) {
461
513
  });
462
514
  }
463
515
 
516
+ // --- GET /pay/.offers — list open sell orders ---
517
+ if (url === '/pay/.offers' && request.method === 'GET') {
518
+ const offers = await loadOffers();
519
+ return reply.send(offers.filter(o => o.status === 'pending'));
520
+ }
521
+
522
+ // --- POST /pay/.sell — create a sell order ---
523
+ if (url === '/pay/.sell' && request.method === 'POST') {
524
+ const pubkey = await getNostrPubkey(request);
525
+ if (!pubkey) {
526
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
527
+ }
528
+
529
+ if (!payToken) {
530
+ return reply.code(400).send({ error: 'Secondary market not configured (no --pay-token set)' });
531
+ }
532
+
533
+ let body = request.body;
534
+ try {
535
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
536
+ if (typeof body === 'string') body = JSON.parse(body);
537
+ } catch {
538
+ return reply.code(400).send({ error: 'Invalid JSON body' });
539
+ }
540
+
541
+ const amount = Math.floor(body?.amount || 0);
542
+ const price = Math.floor(body?.price || 0); // total sats for the lot
543
+ if (amount <= 0 || price <= 0) {
544
+ return reply.code(400).send({ error: 'Specify amount (tokens) and price (total sats)' });
545
+ }
546
+
547
+ // Verify seller has tokens on the trail
548
+ const trail = await loadTrail(payToken);
549
+ if (!trail) {
550
+ return reply.code(500).send({ error: `Token ${payToken} not minted on this pod` });
551
+ }
552
+ const currentState = trail.states[trail.states.length - 1];
553
+ const sellerBalance = currentState.balances[pubkey] || 0;
554
+ if (sellerBalance < amount) {
555
+ return reply.code(400).send({
556
+ error: 'Insufficient token balance on trail',
557
+ balance: sellerBalance,
558
+ amount
559
+ });
560
+ }
561
+
562
+ const offer = {
563
+ id: crypto.randomUUID(),
564
+ seller: pubkey,
565
+ ticker: payToken,
566
+ amount,
567
+ price,
568
+ rate: Math.round(price / amount * 100) / 100,
569
+ status: 'pending',
570
+ created: Date.now()
571
+ };
572
+
573
+ const offers = await loadOffers();
574
+ offers.push(offer);
575
+ await saveOffers(offers);
576
+
577
+ return reply.send(offer);
578
+ }
579
+
580
+ // --- POST /pay/.swap — execute a swap against a sell order ---
581
+ if (url === '/pay/.swap' && request.method === 'POST') {
582
+ const pubkey = await getNostrPubkey(request);
583
+ if (!pubkey) {
584
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
585
+ }
586
+
587
+ if (!payToken) {
588
+ return reply.code(400).send({ error: 'Secondary market not configured (no --pay-token set)' });
589
+ }
590
+
591
+ let body = request.body;
592
+ try {
593
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
594
+ if (typeof body === 'string') body = JSON.parse(body);
595
+ } catch {
596
+ return reply.code(400).send({ error: 'Invalid JSON body' });
597
+ }
598
+
599
+ const offerId = body?.id;
600
+ if (!offerId) {
601
+ return reply.code(400).send({ error: 'Specify offer id' });
602
+ }
603
+
604
+ // Find the offer
605
+ const offers = await loadOffers();
606
+ const offer = offers.find(o => o.id === offerId && o.status === 'pending');
607
+ if (!offer) {
608
+ return reply.code(404).send({ error: 'Offer not found or already filled' });
609
+ }
610
+
611
+ // Can't buy your own offer
612
+ if (offer.seller === pubkey) {
613
+ return reply.code(400).send({ error: 'Cannot swap with your own offer' });
614
+ }
615
+
616
+ // Check buyer's sat balance
617
+ const didUri = pubkeyToDidNostr(pubkey);
618
+ const sellerDid = pubkeyToDidNostr(offer.seller);
619
+ const ledger = await readLedger();
620
+ const balance = getBalance(ledger, didUri);
621
+ if (balance < offer.price) {
622
+ return reply.code(402).send({
623
+ error: 'Insufficient sat balance',
624
+ balance,
625
+ cost: offer.price,
626
+ deposit: '/pay/.deposit'
627
+ });
628
+ }
629
+
630
+ // Transfer tokens from seller to buyer on the trail
631
+ let result;
632
+ try {
633
+ result = await transferToken({
634
+ ticker: payToken,
635
+ from: offer.seller,
636
+ to: pubkey,
637
+ amount: offer.amount,
638
+ mempoolUrl
639
+ });
640
+ } catch (err) {
641
+ return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
642
+ }
643
+
644
+ // Debit buyer, credit seller
645
+ debit(ledger, didUri, offer.price);
646
+ credit(ledger, sellerDid, offer.price);
647
+ await writeLedger(ledger);
648
+
649
+ // Mark offer as filled
650
+ offer.status = 'filled';
651
+ offer.buyer = pubkey;
652
+ offer.filledAt = Date.now();
653
+ offer.txid = result.txid;
654
+ await saveOffers(offers);
655
+
656
+ return reply.send({
657
+ swapped: offer.amount,
658
+ ticker: payToken,
659
+ cost: offer.price,
660
+ rate: offer.rate,
661
+ balance: getBalance(ledger, didUri),
662
+ sellerCredited: offer.price,
663
+ txid: result.txid,
664
+ proof: {
665
+ state: result.state,
666
+ prevState: result.prevState,
667
+ anchor: {
668
+ pubkey: result.trail.pubkeyBase,
669
+ stateStrings: result.trail.stateStrings,
670
+ network: result.trail.network
671
+ }
672
+ }
673
+ });
674
+ }
675
+
464
676
  // --- GET/HEAD /pay/* — paid resource access ---
465
677
  if (request.method === 'GET' || request.method === 'HEAD') {
466
678
  const pubkey = await getNostrPubkey(request);
package/src/token.js CHANGED
@@ -306,7 +306,7 @@ export async function mintToken({ ticker, name, supply, voucher, mempoolUrl = 'h
306
306
  }
307
307
 
308
308
  // --- Transfer: send tokens to an address ---
309
- export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://mempool.space/testnet4' }) {
309
+ export async function transferToken({ ticker, from, to, amount, mempoolUrl = 'https://mempool.space/testnet4' }) {
310
310
  const trail = await loadTrail(ticker);
311
311
  if (!trail) throw new Error(`Token ${ticker} not found`);
312
312
 
@@ -317,15 +317,15 @@ export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://
317
317
  const currentState = trail.states[trail.states.length - 1];
318
318
  const currentBalances = { ...currentState.balances };
319
319
 
320
- // Check issuer balance
321
- const issuerAddr = trail.pubkeyBase;
322
- const issuerBalance = currentBalances[issuerAddr] || 0;
323
- if (issuerBalance < amount) {
324
- throw new Error(`Insufficient balance: ${issuerBalance} < ${amount}`);
320
+ // Check sender balance (default: issuer)
321
+ const senderAddr = from || trail.pubkeyBase;
322
+ const senderBalance = currentBalances[senderAddr] || 0;
323
+ if (senderBalance < amount) {
324
+ throw new Error(`Insufficient balance: ${senderBalance} < ${amount}`);
325
325
  }
326
326
 
327
327
  // Create transfer state
328
- currentBalances[issuerAddr] = issuerBalance - amount;
328
+ currentBalances[senderAddr] = senderBalance - amount;
329
329
  currentBalances[to] = (currentBalances[to] || 0) + amount;
330
330
  // Remove zero balances
331
331
  for (const [k, v] of Object.entries(currentBalances)) {
@@ -342,7 +342,7 @@ export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://
342
342
  decimals: 0,
343
343
  supply: trail.supply,
344
344
  balances: currentBalances,
345
- ops: [{ op: 'urn:mono:op:transfer', from: issuerAddr, to, amt: amount }]
345
+ ops: [{ op: 'urn:mono:op:transfer', from: senderAddr, to, amt: amount }]
346
346
  };
347
347
  const newJcs = jcs(newState);
348
348
 
package/test/pay.test.js CHANGED
@@ -205,6 +205,347 @@ describe('HTTP 402 Pay Middleware', () => {
205
205
  });
206
206
  });
207
207
 
208
+ describe('GET /pay/.info', () => {
209
+ it('should return info without auth', async () => {
210
+ const res = await fetch(`${getBaseUrl()}/pay/.info`);
211
+ assertStatus(res, 200);
212
+ const body = await res.json();
213
+ assert.strictEqual(body.cost, 10);
214
+ assert.strictEqual(body.unit, 'sat');
215
+ assert.strictEqual(body.deposit, '/pay/.deposit');
216
+ assert.strictEqual(body.balance, '/pay/.balance');
217
+ });
218
+
219
+ it('should not include token info when payToken not configured', async () => {
220
+ const res = await fetch(`${getBaseUrl()}/pay/.info`);
221
+ const body = await res.json();
222
+ assert.strictEqual(body.token, undefined);
223
+ });
224
+ });
225
+
226
+ describe('POST /pay/.buy', () => {
227
+ it('should return 401 without auth', async () => {
228
+ const url = `${getBaseUrl()}/pay/.buy`;
229
+ const res = await fetch(url, { method: 'POST', body: '{"amount":10}' });
230
+ assertStatus(res, 401);
231
+ });
232
+
233
+ it('should return 400 when payToken not configured', async () => {
234
+ const url = `${getBaseUrl()}/pay/.buy`;
235
+ const res = await fetch(url, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'Authorization': createNip98Header(url, 'POST'),
239
+ 'Content-Type': 'application/json'
240
+ },
241
+ body: JSON.stringify({ amount: 10 })
242
+ });
243
+ assertStatus(res, 400);
244
+ const body = await res.json();
245
+ assert.ok(body.error.includes('not configured'));
246
+ });
247
+ });
248
+
249
+ describe('POST /pay/.withdraw', () => {
250
+ it('should return 401 without auth', async () => {
251
+ const url = `${getBaseUrl()}/pay/.withdraw`;
252
+ const res = await fetch(url, { method: 'POST', body: '{"all":true}' });
253
+ assertStatus(res, 401);
254
+ });
255
+
256
+ it('should return 400 when payToken not configured', async () => {
257
+ const url = `${getBaseUrl()}/pay/.withdraw`;
258
+ const res = await fetch(url, {
259
+ method: 'POST',
260
+ headers: {
261
+ 'Authorization': createNip98Header(url, 'POST'),
262
+ 'Content-Type': 'application/json'
263
+ },
264
+ body: JSON.stringify({ all: true })
265
+ });
266
+ assertStatus(res, 400);
267
+ const body = await res.json();
268
+ assert.ok(body.error.includes('not configured'));
269
+ });
270
+ });
271
+
272
+ describe('Pay with token configured', () => {
273
+ let tokenServer;
274
+ let tokenUrl;
275
+ const tokenPrivkey = crypto.randomBytes(32);
276
+ const tokenPubkey = Buffer.from(schnorr.getPublicKey(tokenPrivkey)).toString('hex');
277
+
278
+ function tokenNip98(url, method = 'GET') {
279
+ const event = {
280
+ pubkey: tokenPubkey,
281
+ created_at: Math.floor(Date.now() / 1000),
282
+ kind: 27235,
283
+ tags: [['u', url], ['method', method]],
284
+ content: ''
285
+ };
286
+ const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
287
+ event.id = crypto.createHash('sha256').update(serialized).digest('hex');
288
+ event.sig = Buffer.from(schnorr.sign(event.id, tokenPrivkey)).toString('hex');
289
+ return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
290
+ }
291
+
292
+ before(async () => {
293
+ const { createServer } = await import('../src/server.js');
294
+ tokenServer = createServer({
295
+ logger: false,
296
+ forceCloseConnections: true,
297
+ pay: true,
298
+ payCost: 5,
299
+ payAddress: 'test-addr',
300
+ payToken: 'TEST',
301
+ payRate: 10
302
+ });
303
+ await tokenServer.listen({ port: 0, host: '127.0.0.1' });
304
+ const addr = tokenServer.server.address();
305
+ tokenUrl = `http://127.0.0.1:${addr.port}`;
306
+ });
307
+
308
+ after(async () => {
309
+ if (tokenServer) await tokenServer.close();
310
+ });
311
+
312
+ it('GET /pay/.info should include token info', async () => {
313
+ const res = await fetch(`${tokenUrl}/pay/.info`);
314
+ assertStatus(res, 200);
315
+ const body = await res.json();
316
+ assert.strictEqual(body.cost, 5);
317
+ assert.strictEqual(body.token.ticker, 'TEST');
318
+ assert.strictEqual(body.token.rate, 10);
319
+ assert.strictEqual(body.token.buy, '/pay/.buy');
320
+ assert.strictEqual(body.token.withdraw, '/pay/.withdraw');
321
+ });
322
+
323
+ it('POST /pay/.buy should return 402 with zero balance', async () => {
324
+ const url = `${tokenUrl}/pay/.buy`;
325
+ const res = await fetch(url, {
326
+ method: 'POST',
327
+ headers: {
328
+ 'Authorization': tokenNip98(url, 'POST'),
329
+ 'Content-Type': 'application/json'
330
+ },
331
+ body: JSON.stringify({ amount: 10 })
332
+ });
333
+ assertStatus(res, 402);
334
+ const body = await res.json();
335
+ assert.strictEqual(body.error, 'Insufficient sat balance');
336
+ assert.strictEqual(body.balance, 0);
337
+ assert.strictEqual(body.cost, 100); // 10 tokens * rate 10
338
+ assert.strictEqual(body.rate, 10);
339
+ });
340
+
341
+ it('POST /pay/.buy should reject wrong ticker', async () => {
342
+ const url = `${tokenUrl}/pay/.buy`;
343
+ const res = await fetch(url, {
344
+ method: 'POST',
345
+ headers: {
346
+ 'Authorization': tokenNip98(url, 'POST'),
347
+ 'Content-Type': 'application/json'
348
+ },
349
+ body: JSON.stringify({ ticker: 'WRONG', amount: 10 })
350
+ });
351
+ assertStatus(res, 400);
352
+ const body = await res.json();
353
+ assert.ok(body.error.includes('only sells TEST'));
354
+ });
355
+
356
+ it('POST /pay/.buy should reject malformed JSON', async () => {
357
+ const url = `${tokenUrl}/pay/.buy`;
358
+ const res = await fetch(url, {
359
+ method: 'POST',
360
+ headers: {
361
+ 'Authorization': tokenNip98(url, 'POST'),
362
+ 'Content-Type': 'application/json'
363
+ },
364
+ body: '{not valid json'
365
+ });
366
+ assertStatus(res, 400);
367
+ });
368
+
369
+ it('POST /pay/.buy should reject missing amount', async () => {
370
+ const url = `${tokenUrl}/pay/.buy`;
371
+ const res = await fetch(url, {
372
+ method: 'POST',
373
+ headers: {
374
+ 'Authorization': tokenNip98(url, 'POST'),
375
+ 'Content-Type': 'application/json'
376
+ },
377
+ body: JSON.stringify({})
378
+ });
379
+ assertStatus(res, 400);
380
+ const body = await res.json();
381
+ assert.ok(body.error.includes('Specify'));
382
+ });
383
+
384
+ it('POST /pay/.withdraw should return 400 with zero balance and all:true', async () => {
385
+ const url = `${tokenUrl}/pay/.withdraw`;
386
+ const res = await fetch(url, {
387
+ method: 'POST',
388
+ headers: {
389
+ 'Authorization': tokenNip98(url, 'POST'),
390
+ 'Content-Type': 'application/json'
391
+ },
392
+ body: JSON.stringify({ all: true })
393
+ });
394
+ assertStatus(res, 400);
395
+ const body = await res.json();
396
+ assert.ok(body.error.includes('Nothing to withdraw'));
397
+ });
398
+
399
+ it('POST /pay/.withdraw should return 402 when balance insufficient', async () => {
400
+ const url = `${tokenUrl}/pay/.withdraw`;
401
+ const res = await fetch(url, {
402
+ method: 'POST',
403
+ headers: {
404
+ 'Authorization': tokenNip98(url, 'POST'),
405
+ 'Content-Type': 'application/json'
406
+ },
407
+ body: JSON.stringify({ tokens: 1000 })
408
+ });
409
+ assertStatus(res, 402);
410
+ const body = await res.json();
411
+ assert.strictEqual(body.error, 'Insufficient balance');
412
+ });
413
+
414
+ it('POST /pay/.withdraw should reject malformed JSON', async () => {
415
+ const url = `${tokenUrl}/pay/.withdraw`;
416
+ const res = await fetch(url, {
417
+ method: 'POST',
418
+ headers: {
419
+ 'Authorization': tokenNip98(url, 'POST'),
420
+ 'Content-Type': 'application/json'
421
+ },
422
+ body: '{bad json'
423
+ });
424
+ assertStatus(res, 400);
425
+ });
426
+
427
+ it('POST /pay/.withdraw should reject missing params', async () => {
428
+ const url = `${tokenUrl}/pay/.withdraw`;
429
+ const res = await fetch(url, {
430
+ method: 'POST',
431
+ headers: {
432
+ 'Authorization': tokenNip98(url, 'POST'),
433
+ 'Content-Type': 'application/json'
434
+ },
435
+ body: JSON.stringify({})
436
+ });
437
+ assertStatus(res, 400);
438
+ const body = await res.json();
439
+ assert.ok(body.error.includes('Specify'));
440
+ });
441
+
442
+ it('POST /pay/.sell should reject missing amount/price', async () => {
443
+ const url = `${tokenUrl}/pay/.sell`;
444
+ const res = await fetch(url, {
445
+ method: 'POST',
446
+ headers: {
447
+ 'Authorization': tokenNip98(url, 'POST'),
448
+ 'Content-Type': 'application/json'
449
+ },
450
+ body: JSON.stringify({})
451
+ });
452
+ assertStatus(res, 400);
453
+ const body = await res.json();
454
+ assert.ok(body.error.includes('Specify'));
455
+ });
456
+
457
+ it('POST /pay/.swap should reject missing offer id', async () => {
458
+ const url = `${tokenUrl}/pay/.swap`;
459
+ const res = await fetch(url, {
460
+ method: 'POST',
461
+ headers: {
462
+ 'Authorization': tokenNip98(url, 'POST'),
463
+ 'Content-Type': 'application/json'
464
+ },
465
+ body: JSON.stringify({})
466
+ });
467
+ assertStatus(res, 400);
468
+ const body = await res.json();
469
+ assert.ok(body.error.includes('Specify offer id'));
470
+ });
471
+
472
+ it('POST /pay/.swap should return 404 for unknown offer', async () => {
473
+ const url = `${tokenUrl}/pay/.swap`;
474
+ const res = await fetch(url, {
475
+ method: 'POST',
476
+ headers: {
477
+ 'Authorization': tokenNip98(url, 'POST'),
478
+ 'Content-Type': 'application/json'
479
+ },
480
+ body: JSON.stringify({ id: 'nonexistent' })
481
+ });
482
+ assertStatus(res, 404);
483
+ });
484
+
485
+ it('GET /pay/.offers should return empty list', async () => {
486
+ const res = await fetch(`${tokenUrl}/pay/.offers`);
487
+ assertStatus(res, 200);
488
+ const body = await res.json();
489
+ assert.ok(Array.isArray(body));
490
+ });
491
+ });
492
+
493
+ describe('GET /pay/.offers', () => {
494
+ it('should return empty list without auth', async () => {
495
+ const res = await fetch(`${getBaseUrl()}/pay/.offers`);
496
+ assertStatus(res, 200);
497
+ const body = await res.json();
498
+ assert.ok(Array.isArray(body));
499
+ assert.strictEqual(body.length, 0);
500
+ });
501
+ });
502
+
503
+ describe('POST /pay/.sell', () => {
504
+ it('should return 401 without auth', async () => {
505
+ const url = `${getBaseUrl()}/pay/.sell`;
506
+ const res = await fetch(url, { method: 'POST', body: '{}' });
507
+ assertStatus(res, 401);
508
+ });
509
+
510
+ it('should return 400 when payToken not configured', async () => {
511
+ const url = `${getBaseUrl()}/pay/.sell`;
512
+ const res = await fetch(url, {
513
+ method: 'POST',
514
+ headers: {
515
+ 'Authorization': createNip98Header(url, 'POST'),
516
+ 'Content-Type': 'application/json'
517
+ },
518
+ body: JSON.stringify({ amount: 10, price: 100 })
519
+ });
520
+ assertStatus(res, 400);
521
+ const body = await res.json();
522
+ assert.ok(body.error.includes('not configured'));
523
+ });
524
+ });
525
+
526
+ describe('POST /pay/.swap', () => {
527
+ it('should return 401 without auth', async () => {
528
+ const url = `${getBaseUrl()}/pay/.swap`;
529
+ const res = await fetch(url, { method: 'POST', body: '{}' });
530
+ assertStatus(res, 401);
531
+ });
532
+
533
+ it('should return 400 when payToken not configured', async () => {
534
+ const url = `${getBaseUrl()}/pay/.swap`;
535
+ const res = await fetch(url, {
536
+ method: 'POST',
537
+ headers: {
538
+ 'Authorization': createNip98Header(url, 'POST'),
539
+ 'Content-Type': 'application/json'
540
+ },
541
+ body: JSON.stringify({ id: 'test-id' })
542
+ });
543
+ assertStatus(res, 400);
544
+ const body = await res.json();
545
+ assert.ok(body.error.includes('not configured'));
546
+ });
547
+ });
548
+
208
549
  describe('Pay disabled', () => {
209
550
  let noPayServer;
210
551
  let noPayUrl;