javascript-solid-server 0.0.105 → 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 +338 -0
- package/package.json +2 -1
- package/src/handlers/pay.js +191 -4
- package/src/token.js +8 -8
- package/test/pay.test.js +341 -0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
package/src/handlers/pay.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* POST /pay/.deposit — deposit sats (TXO URI) or tokens (MRC20 state proof)
|
|
11
11
|
* POST /pay/.buy — buy tokens with sat balance (primary market)
|
|
12
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
|
|
13
16
|
* GET /pay/* — paid resource access (requires balance >= cost)
|
|
14
17
|
* PUT /pay/* — upload resources (standard auth)
|
|
15
18
|
*
|
|
@@ -22,6 +25,7 @@
|
|
|
22
25
|
* - MRC20 profile: https://blocktrails.org/
|
|
23
26
|
*/
|
|
24
27
|
|
|
28
|
+
import crypto from 'crypto';
|
|
25
29
|
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
|
|
26
30
|
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
|
|
27
31
|
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, sha256Hex } from '../mrc20.js';
|
|
@@ -54,6 +58,21 @@ async function checkAndRecordState(stateHash) {
|
|
|
54
58
|
return true;
|
|
55
59
|
}
|
|
56
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
|
+
|
|
57
76
|
// --- Deposit verification via mempool API ---
|
|
58
77
|
|
|
59
78
|
async function verifySatsDeposit(txoUri, mempoolUrl) {
|
|
@@ -305,8 +324,12 @@ export function createPayHandler(options = {}) {
|
|
|
305
324
|
|
|
306
325
|
// Parse buy request
|
|
307
326
|
let body = request.body;
|
|
308
|
-
|
|
309
|
-
|
|
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
|
+
}
|
|
310
333
|
|
|
311
334
|
const ticker = body?.ticker || payToken;
|
|
312
335
|
if (ticker !== payToken) {
|
|
@@ -403,8 +426,12 @@ export function createPayHandler(options = {}) {
|
|
|
403
426
|
|
|
404
427
|
// Parse withdraw request
|
|
405
428
|
let body = request.body;
|
|
406
|
-
|
|
407
|
-
|
|
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
|
+
}
|
|
408
435
|
|
|
409
436
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
410
437
|
const ledger = await readLedger();
|
|
@@ -486,6 +513,166 @@ export function createPayHandler(options = {}) {
|
|
|
486
513
|
});
|
|
487
514
|
}
|
|
488
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
|
+
|
|
489
676
|
// --- GET/HEAD /pay/* — paid resource access ---
|
|
490
677
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
491
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
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
324
|
-
throw new Error(`Insufficient balance: ${
|
|
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[
|
|
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:
|
|
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;
|