javascript-solid-server 0.0.100 → 0.0.102

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/src/mrc20.js ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * MRC20 Token Verification
3
+ *
4
+ * Verifies MRC20 state chain integrity and extracts transfer operations.
5
+ * Used by the pay middleware to accept token deposits.
6
+ *
7
+ * MRC20 profile: mono.mrc20.v0.1
8
+ * State chain: each state links to previous via SHA-256 of JCS-encoded state.
9
+ *
10
+ * References:
11
+ * - Blocktrails: https://blocktrails.org/
12
+ * - JCS (RFC 8785): JSON Canonicalization Scheme
13
+ */
14
+
15
+ import crypto from 'crypto';
16
+ import { secp256k1 } from '@noble/curves/secp256k1';
17
+
18
+ const MRC20_PROFILE = 'mono.mrc20.v0.1';
19
+ const TRANSFER_OP = 'urn:mono:op:transfer';
20
+
21
+ // --- BIP-341 key chaining constants ---
22
+ const SECP_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
23
+ const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
24
+ const BECH32M = 0x2bc830a3;
25
+
26
+ /**
27
+ * JSON Canonicalization Scheme (RFC 8785)
28
+ * Produces deterministic JSON — sorted keys, no whitespace.
29
+ * @param {*} obj
30
+ * @returns {string}
31
+ */
32
+ export function jcs(obj) {
33
+ if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
34
+ if (Array.isArray(obj)) return '[' + obj.map(v => jcs(v)).join(',') + ']';
35
+ const keys = Object.keys(obj).sort();
36
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + jcs(obj[k])).join(',') + '}';
37
+ }
38
+
39
+ /**
40
+ * SHA-256 hex digest of a string
41
+ * @param {string} str
42
+ * @returns {string} Hex hash
43
+ */
44
+ export function sha256Hex(str) {
45
+ return crypto.createHash('sha256').update(str).digest('hex');
46
+ }
47
+
48
+ /**
49
+ * Verify state chain link: state.prev must equal SHA-256(JCS(prevState))
50
+ * @param {object} state - Current state
51
+ * @param {object} prevState - Previous state
52
+ * @returns {{valid: boolean, error?: string}}
53
+ */
54
+ export function verifyStateLink(state, prevState) {
55
+ if (!state || !prevState) {
56
+ return { valid: false, error: 'Missing state or prevState' };
57
+ }
58
+
59
+ const expectedPrev = sha256Hex(jcs(prevState));
60
+ if (state.prev !== expectedPrev) {
61
+ return { valid: false, error: `State chain break: expected prev ${expectedPrev}, got ${state.prev}` };
62
+ }
63
+
64
+ // Verify sequence number
65
+ if (typeof state.seq === 'number' && typeof prevState.seq === 'number') {
66
+ if (state.seq !== prevState.seq + 1) {
67
+ return { valid: false, error: `Sequence mismatch: expected ${prevState.seq + 1}, got ${state.seq}` };
68
+ }
69
+ }
70
+
71
+ return { valid: true };
72
+ }
73
+
74
+ /**
75
+ * Validate that a state object is a valid MRC20 state
76
+ * @param {object} state
77
+ * @returns {{valid: boolean, error?: string}}
78
+ */
79
+ export function validateMrc20State(state) {
80
+ if (!state || typeof state !== 'object') {
81
+ return { valid: false, error: 'State must be an object' };
82
+ }
83
+ if (state.profile !== MRC20_PROFILE) {
84
+ return { valid: false, error: `Invalid profile: expected ${MRC20_PROFILE}, got ${state.profile}` };
85
+ }
86
+ if (!Array.isArray(state.ops)) {
87
+ return { valid: false, error: 'State must have ops array' };
88
+ }
89
+ if (typeof state.prev !== 'string') {
90
+ return { valid: false, error: 'State must have prev hash' };
91
+ }
92
+ return { valid: true };
93
+ }
94
+
95
+ /**
96
+ * Extract transfer operations targeting a specific address
97
+ * @param {object} state - MRC20 state
98
+ * @param {string} toAddress - Recipient address to filter by
99
+ * @returns {Array<{from: string, to: string, amt: number}>} Matching transfers
100
+ */
101
+ export function extractTransfersTo(state, toAddress) {
102
+ if (!state.ops || !Array.isArray(state.ops)) return [];
103
+ return state.ops.filter(op =>
104
+ op.op === TRANSFER_OP && op.to === toAddress && typeof op.amt === 'number' && op.amt > 0
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Get total amount transferred to an address in a state
110
+ * @param {object} state - MRC20 state
111
+ * @param {string} toAddress - Recipient address
112
+ * @returns {number} Total amount transferred
113
+ */
114
+ export function totalTransferredTo(state, toAddress) {
115
+ const transfers = extractTransfersTo(state, toAddress);
116
+ return transfers.reduce((sum, op) => sum + op.amt, 0);
117
+ }
118
+
119
+ /**
120
+ * Verify an MRC20 deposit: validate state chain + extract transfer amount
121
+ * @param {object} params
122
+ * @param {object} params.state - New state containing transfer ops
123
+ * @param {object} params.prevState - Previous state (for chain verification)
124
+ * @param {string} params.toAddress - Pod's address to check transfers against
125
+ * @returns {{valid: boolean, amount: number, ticker?: string, error?: string}}
126
+ */
127
+ export function verifyMrc20Deposit(params) {
128
+ const { state, prevState, toAddress } = params;
129
+
130
+ // Validate MRC20 format
131
+ const stateCheck = validateMrc20State(state);
132
+ if (!stateCheck.valid) return { valid: false, amount: 0, error: stateCheck.error };
133
+
134
+ const prevCheck = validateMrc20State(prevState);
135
+ if (!prevCheck.valid) return { valid: false, amount: 0, error: `prevState: ${prevCheck.error}` };
136
+
137
+ // Verify state chain link
138
+ const linkCheck = verifyStateLink(state, prevState);
139
+ if (!linkCheck.valid) return { valid: false, amount: 0, error: linkCheck.error };
140
+
141
+ // Extract transfers to pod
142
+ const amount = totalTransferredTo(state, toAddress);
143
+ if (amount <= 0) {
144
+ return { valid: false, amount: 0, error: `No transfers to ${toAddress} found in state ops` };
145
+ }
146
+
147
+ return {
148
+ valid: true,
149
+ amount,
150
+ ticker: state.ticker || 'UNKNOWN'
151
+ };
152
+ }
153
+
154
+ // --- BIP-341 key chaining (blocktrails anchor verification) ---
155
+
156
+ function sha256Bytes(data) {
157
+ const buf = data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(data, 'utf8');
158
+ return new Uint8Array(crypto.createHash('sha256').update(buf).digest());
159
+ }
160
+
161
+ function concatBytes(...arrays) {
162
+ const total = arrays.reduce((s, a) => s + a.length, 0);
163
+ const result = new Uint8Array(total);
164
+ let off = 0;
165
+ for (const a of arrays) { result.set(a, off); off += a.length; }
166
+ return result;
167
+ }
168
+
169
+ function bytesToBigInt(bytes) {
170
+ let r = 0n;
171
+ for (const b of bytes) r = (r << 8n) | BigInt(b);
172
+ return r;
173
+ }
174
+
175
+ function hexToU8(hex) {
176
+ const bytes = new Uint8Array(hex.length / 2);
177
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
178
+ return bytes;
179
+ }
180
+
181
+ function bytesToHex(bytes) {
182
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
183
+ }
184
+
185
+ function taggedHash(tag, ...msgs) {
186
+ const tagHash = sha256Bytes(Buffer.from(tag, 'utf8'));
187
+ return sha256Bytes(concatBytes(tagHash, tagHash, ...msgs));
188
+ }
189
+
190
+ function btScalar(pubkeyCompressed, state) {
191
+ const xOnly = pubkeyCompressed.slice(1);
192
+ const stateBytes = typeof state === 'string' ? Buffer.from(state, 'utf8') : state;
193
+ const sh = sha256Bytes(stateBytes);
194
+ return bytesToBigInt(taggedHash('TapTweak', xOnly, sh)) % SECP_N;
195
+ }
196
+
197
+ function btDeriveChainedPubkey(pubkeyBase, states) {
198
+ let P = secp256k1.ProjectivePoint.fromHex(bytesToHex(pubkeyBase));
199
+ let cur = pubkeyBase;
200
+ for (const s of states) {
201
+ const t = btScalar(cur, s);
202
+ P = P.add(secp256k1.ProjectivePoint.BASE.multiply(t));
203
+ cur = new Uint8Array(P.toRawBytes(true));
204
+ }
205
+ return cur;
206
+ }
207
+
208
+ // --- Bech32m encoding ---
209
+
210
+ function convertBits(data, from, to, pad) {
211
+ let acc = 0, bits = 0;
212
+ const ret = [], maxv = (1 << to) - 1;
213
+ for (const v of data) {
214
+ acc = (acc << from) | v;
215
+ bits += from;
216
+ while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv); }
217
+ }
218
+ if (pad && bits > 0) ret.push((acc << (to - bits)) & maxv);
219
+ return ret;
220
+ }
221
+
222
+ function hrpExpand(hrp) {
223
+ const r = [];
224
+ for (const c of hrp) r.push(c.charCodeAt(0) >> 5);
225
+ r.push(0);
226
+ for (const c of hrp) r.push(c.charCodeAt(0) & 31);
227
+ return r;
228
+ }
229
+
230
+ function polymod(values) {
231
+ const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
232
+ let chk = 1;
233
+ for (const v of values) {
234
+ const b = chk >> 25;
235
+ chk = ((chk & 0x1ffffff) << 5) ^ v;
236
+ for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
237
+ }
238
+ return chk;
239
+ }
240
+
241
+ function bech32mEncode(hrp, version, program) {
242
+ const conv = convertBits(program, 8, 5, true);
243
+ const values = [version, ...conv];
244
+ const enc = [...hrpExpand(hrp), ...values, 0, 0, 0, 0, 0, 0];
245
+ const mod = polymod(enc) ^ BECH32M;
246
+ const checksum = [0, 1, 2, 3, 4, 5].map(i => (mod >> (5 * (5 - i))) & 31);
247
+ let result = hrp + '1';
248
+ for (const v of [...values, ...checksum]) result += BECH32_CHARSET[v];
249
+ return result;
250
+ }
251
+
252
+ /**
253
+ * Derive the taproot address for a state chain
254
+ * @param {string} pubkeyHex - Issuer's compressed pubkey (66-char hex)
255
+ * @param {string[]} states - JCS-encoded state strings
256
+ * @param {string} [network='testnet4'] - 'testnet4' or 'mainnet'
257
+ * @returns {string} Bech32m taproot address
258
+ */
259
+ export function btAddress(pubkeyHex, states, network = 'testnet4') {
260
+ const pubkeyBase = hexToU8(pubkeyHex);
261
+ const P = btDeriveChainedPubkey(pubkeyBase, states);
262
+ const xOnly = P.slice(1);
263
+ const hrp = network === 'mainnet' ? 'bc' : 'tb';
264
+ return bech32mEncode(hrp, 1, xOnly);
265
+ }
266
+
267
+ /**
268
+ * Verify that an MRC20 state is anchored to a Bitcoin UTXO
269
+ * @param {object} params
270
+ * @param {object} params.state - Current state with transfer ops
271
+ * @param {object} params.prevState - Previous state (chain verification)
272
+ * @param {string} params.toAddress - Pod's address for transfer verification
273
+ * @param {string} params.pubkey - Issuer's compressed pubkey (66-char hex)
274
+ * @param {string[]} params.stateStrings - All JCS state strings (genesis to current)
275
+ * @param {string} [params.mempoolUrl] - Mempool API base URL
276
+ * @param {string} [params.network] - 'testnet4' or 'mainnet'
277
+ * @returns {Promise<{valid: boolean, amount?: number, ticker?: string, address?: string, error?: string}>}
278
+ */
279
+ export async function verifyMrc20Anchor(params) {
280
+ const {
281
+ state, prevState, toAddress, pubkey, stateStrings,
282
+ mempoolUrl = 'https://mempool.space/testnet4',
283
+ network = 'testnet4'
284
+ } = params;
285
+
286
+ // 1. Standard deposit verification (chain integrity + transfers)
287
+ const depositCheck = verifyMrc20Deposit({ state, prevState, toAddress });
288
+ if (!depositCheck.valid) return depositCheck;
289
+
290
+ // 2. Verify stateStrings is provided and non-empty
291
+ if (!Array.isArray(stateStrings) || stateStrings.length === 0) {
292
+ return { valid: false, amount: 0, error: 'stateStrings required for anchor verification' };
293
+ }
294
+
295
+ // 3. Verify pubkey is provided
296
+ if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 66) {
297
+ return { valid: false, amount: 0, error: 'pubkey must be a 66-char compressed pubkey hex' };
298
+ }
299
+
300
+ // 4. Verify the last stateString matches the current state
301
+ const currentJcs = jcs(state);
302
+ const lastStateString = stateStrings[stateStrings.length - 1];
303
+ if (currentJcs !== lastStateString) {
304
+ return { valid: false, amount: 0, error: 'Last stateString does not match JCS(state)' };
305
+ }
306
+
307
+ // 5. Derive expected taproot address
308
+ let address;
309
+ try {
310
+ address = btAddress(pubkey, stateStrings, network);
311
+ } catch (err) {
312
+ return { valid: false, amount: 0, error: `Key derivation failed: ${err.message}` };
313
+ }
314
+
315
+ // 6. Query mempool for UTXO at derived address
316
+ try {
317
+ const resp = await fetch(`${mempoolUrl}/api/address/${address}/utxo`);
318
+ if (!resp.ok) {
319
+ return { valid: false, amount: 0, error: `Mempool API error: ${resp.status}` };
320
+ }
321
+ const utxos = await resp.json();
322
+ if (!Array.isArray(utxos) || utxos.length === 0) {
323
+ return { valid: false, amount: 0, error: `No UTXO at derived address ${address}` };
324
+ }
325
+ } catch (err) {
326
+ return { valid: false, amount: 0, error: `Mempool lookup failed: ${err.message}` };
327
+ }
328
+
329
+ return {
330
+ valid: true,
331
+ amount: depositCheck.amount,
332
+ ticker: depositCheck.ticker,
333
+ address
334
+ };
335
+ }
package/src/server.js CHANGED
@@ -14,6 +14,7 @@ import { idpPlugin } from './idp/index.js';
14
14
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
15
15
  import { AccessMode } from './wac/parser.js';
16
16
  import { registerNostrRelay } from './nostr/relay.js';
17
+ import { createPayHandler, isPayRequest } from './handlers/pay.js';
17
18
  import { activityPubPlugin, getActorHandler } from './ap/index.js';
18
19
  import { remoteStoragePlugin } from './remotestorage.js';
19
20
  import { dbPlugin } from './db/index.js';
@@ -42,6 +43,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
42
43
  * @param {string} options.apSummary - ActivityPub bio/summary
43
44
  * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
44
45
  * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
46
+ * @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false)
47
+ * @param {number} options.payCost - Cost per request in satoshis (default 1)
48
+ * @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4)
49
+ * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers
45
50
  */
46
51
  export function createServer(options = {}) {
47
52
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -90,6 +95,11 @@ export function createServer(options = {}) {
90
95
  const mongoEnabled = options.mongo ?? false;
91
96
  const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
92
97
  const mongoDatabase = options.mongoDatabase ?? 'solid';
98
+ // HTTP 402 paid /pay/ routes are OFF by default
99
+ const payEnabled = options.pay ?? false;
100
+ const payCost = options.payCost ?? 1;
101
+ const payMempoolUrl = options.payMempoolUrl ?? 'https://mempool.space/testnet4';
102
+ const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
93
103
 
94
104
  // Set data root via environment variable if provided
95
105
  if (options.root) {
@@ -102,6 +112,8 @@ export function createServer(options = {}) {
102
112
  logger: loggerEnabled ? { level: options.logLevel || 'info' } : false,
103
113
  disableRequestLogging: true,
104
114
  trustProxy: true,
115
+ // Force close connections on server.close() (useful for tests with WebSockets)
116
+ forceCloseConnections: options.forceCloseConnections ?? false,
105
117
  // Handle raw body for non-JSON content
106
118
  bodyLimit: 10 * 1024 * 1024, // 10MB
107
119
  // Gracefully handle client TCP errors (ECONNRESET, EPIPE, etc.)
@@ -311,6 +323,11 @@ export function createServer(options = {}) {
311
323
  return;
312
324
  }
313
325
 
326
+ // Allow pay routes through when pay is enabled (.balance, .deposit)
327
+ if (payEnabled && isPayRequest(request.url)) {
328
+ return;
329
+ }
330
+
314
331
  const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
315
332
  const hasForbiddenDotfile = segments.some(seg =>
316
333
  seg.startsWith('.') &&
@@ -355,6 +372,11 @@ export function createServer(options = {}) {
355
372
  });
356
373
  }
357
374
 
375
+ // HTTP 402 Payment Required handler for /pay/* routes
376
+ if (payEnabled) {
377
+ fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress }));
378
+ }
379
+
358
380
  // Authorization hook - check WAC permissions
359
381
  // Skip for pod creation endpoint (needs special handling)
360
382
  fastify.addHook('preHandler', async (request, reply) => {
@@ -378,6 +400,7 @@ export function createServer(options = {}) {
378
400
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
379
401
  isProfileAP ||
380
402
  request.url.startsWith('/storage/') ||
403
+ (payEnabled && isPayRequest(request.url)) ||
381
404
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
382
405
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
383
406
  return;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Web Ledger — spec-compliant balance tracking
3
+ *
4
+ * Implements the Web Ledgers specification (https://webledgers.org/)
5
+ * for mapping URIs to numerical balances. Default unit: satoshi.
6
+ *
7
+ * JSON-LD context: https://w3id.org/webledgers
8
+ *
9
+ * @module webledger
10
+ */
11
+
12
+ import * as storage from './storage/filesystem.js';
13
+
14
+ const DEFAULT_PATH = '/.well-known/webledgers/webledgers.json';
15
+ const CONTEXT = 'https://w3id.org/webledgers';
16
+
17
+ /**
18
+ * Create an empty spec-compliant ledger
19
+ * @param {object} options
20
+ * @param {string} options.name - Ledger name
21
+ * @param {string} options.description - Ledger description
22
+ * @param {string} options.id - Ledger URI identifier
23
+ * @param {string} options.defaultCurrency - Default currency (default 'satoshi')
24
+ * @returns {object} Empty WebLedger object
25
+ */
26
+ export function createLedger(options = {}) {
27
+ const now = Math.floor(Date.now() / 1000);
28
+ return {
29
+ '@context': CONTEXT,
30
+ type: 'WebLedger',
31
+ ...(options.id && { id: options.id }),
32
+ name: options.name ?? 'Pod Credits',
33
+ description: options.description ?? 'Paid API balance ledger',
34
+ defaultCurrency: options.defaultCurrency ?? 'satoshi',
35
+ created: now,
36
+ updated: now,
37
+ entries: []
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Read a webledger from storage
43
+ * @param {string} ledgerPath - URL path to ledger file
44
+ * @returns {Promise<object>} WebLedger object
45
+ */
46
+ export async function readLedger(ledgerPath = DEFAULT_PATH) {
47
+ const buf = await storage.read(ledgerPath);
48
+ if (!buf) {
49
+ return createLedger();
50
+ }
51
+ try {
52
+ const ledger = JSON.parse(buf.toString('utf8'));
53
+ // Migrate legacy format: add missing spec fields
54
+ if (!ledger['@context']) ledger['@context'] = CONTEXT;
55
+ if (!ledger.type) ledger.type = 'WebLedger';
56
+ if (!ledger.defaultCurrency) ledger.defaultCurrency = 'satoshi';
57
+ if (!ledger.created) ledger.created = Math.floor(Date.now() / 1000);
58
+ if (!ledger.entries) ledger.entries = [];
59
+ return ledger;
60
+ } catch {
61
+ return createLedger();
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Write a webledger to storage (updates the `updated` timestamp)
67
+ * @param {object} ledger - WebLedger object
68
+ * @param {string} ledgerPath - URL path to ledger file
69
+ * @returns {Promise<boolean>}
70
+ */
71
+ export async function writeLedger(ledger, ledgerPath = DEFAULT_PATH) {
72
+ ledger.updated = Math.floor(Date.now() / 1000);
73
+ return storage.write(ledgerPath, JSON.stringify(ledger, null, 2));
74
+ }
75
+
76
+ /**
77
+ * Get balance for a URI
78
+ * @param {object} ledger - WebLedger object
79
+ * @param {string} uri - Agent URI (e.g. did:nostr:...)
80
+ * @returns {number} Balance as integer
81
+ */
82
+ export function getBalance(ledger, uri) {
83
+ const entry = ledger.entries.find(e => e.url === uri);
84
+ if (!entry) return 0;
85
+ // Handle both simple string and array amount formats
86
+ if (Array.isArray(entry.amount)) {
87
+ const sat = entry.amount.find(a => a.currency === 'satoshi' || a.currency === 'sat');
88
+ return sat ? parseInt(sat.value, 10) || 0 : 0;
89
+ }
90
+ return parseInt(entry.amount, 10) || 0;
91
+ }
92
+
93
+ /**
94
+ * Set balance for a URI
95
+ * @param {object} ledger - WebLedger object
96
+ * @param {string} uri - Agent URI
97
+ * @param {number} amount - New balance
98
+ */
99
+ export function setBalance(ledger, uri, amount) {
100
+ const entry = ledger.entries.find(e => e.url === uri);
101
+ if (entry) {
102
+ entry.amount = String(amount);
103
+ } else {
104
+ ledger.entries.push({ type: 'Entry', url: uri, amount: String(amount) });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Credit (add to) a balance
110
+ * @param {object} ledger - WebLedger object
111
+ * @param {string} uri - Agent URI
112
+ * @param {number} amount - Amount to add
113
+ * @returns {number} New balance
114
+ */
115
+ export function credit(ledger, uri, amount) {
116
+ const current = getBalance(ledger, uri);
117
+ const newBalance = current + amount;
118
+ setBalance(ledger, uri, newBalance);
119
+ return newBalance;
120
+ }
121
+
122
+ /**
123
+ * Debit (subtract from) a balance
124
+ * @param {object} ledger - WebLedger object
125
+ * @param {string} uri - Agent URI
126
+ * @param {number} amount - Amount to subtract
127
+ * @returns {{success: boolean, balance: number}} Result
128
+ */
129
+ export function debit(ledger, uri, amount) {
130
+ const current = getBalance(ledger, uri);
131
+ if (current < amount) {
132
+ return { success: false, balance: current };
133
+ }
134
+ const newBalance = current - amount;
135
+ setBalance(ledger, uri, newBalance);
136
+ return { success: true, balance: newBalance };
137
+ }
138
+
139
+ /**
140
+ * List all entries with non-zero balances
141
+ * @param {object} ledger - WebLedger object
142
+ * @returns {Array<{url: string, amount: number}>}
143
+ */
144
+ export function listBalances(ledger) {
145
+ return ledger.entries
146
+ .map(e => ({ url: e.url, amount: getBalance(ledger, e.url) }))
147
+ .filter(e => e.amount > 0);
148
+ }
149
+
150
+ /**
151
+ * Remove entries with zero balance
152
+ * @param {object} ledger - WebLedger object
153
+ */
154
+ export function compact(ledger) {
155
+ ledger.entries = ledger.entries.filter(e => {
156
+ const bal = getBalance(ledger, e.url);
157
+ return bal > 0;
158
+ });
159
+ }
160
+
161
+ // Re-export the default path for consumers
162
+ export { DEFAULT_PATH as LEDGER_PATH };
package/test/helpers.js CHANGED
@@ -24,7 +24,7 @@ export async function startTestServer(options = {}) {
24
24
  // Clean up any existing test data
25
25
  await fs.emptyDir(TEST_DATA_DIR);
26
26
 
27
- server = createServer({ logger: false, ...options });
27
+ server = createServer({ logger: false, forceCloseConnections: true, ...options });
28
28
  // Use port 0 to let OS assign available port
29
29
  await server.listen({ port: 0, host: '127.0.0.1' });
30
30
 
@@ -39,7 +39,6 @@ export async function startTestServer(options = {}) {
39
39
  */
40
40
  export async function stopTestServer() {
41
41
  if (server) {
42
- // Force close all connections to avoid hanging
43
42
  await server.close();
44
43
  server = null;
45
44
  }