javascript-solid-server 0.0.100 → 0.0.101
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/LICENSE +1 -0
- package/README.md +54 -2
- package/bin/jss.js +10 -0
- package/package.json +2 -1
- package/src/config.js +12 -1
- package/src/handlers/pay.js +248 -0
- package/src/mrc20.js +146 -0
- package/src/server.js +23 -0
- package/src/webledger.js +162 -0
- package/test/helpers.js +1 -2
- package/test/idp.test.js +57 -41
- package/test/{live-reload.test.js → live-reload.standalone.js} +1 -0
- package/test/mrc20.test.js +237 -0
- package/test/pay.test.js +231 -0
- package/test/webledger.test.js +174 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MRC20 Token Verification tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
jcs,
|
|
9
|
+
sha256Hex,
|
|
10
|
+
verifyStateLink,
|
|
11
|
+
validateMrc20State,
|
|
12
|
+
extractTransfersTo,
|
|
13
|
+
totalTransferredTo,
|
|
14
|
+
verifyMrc20Deposit
|
|
15
|
+
} from '../src/mrc20.js';
|
|
16
|
+
|
|
17
|
+
const PROFILE = 'mono.mrc20.v0.1';
|
|
18
|
+
|
|
19
|
+
// Helper: create a valid state chain pair
|
|
20
|
+
function createStatePair(ops, toAddress) {
|
|
21
|
+
const prevState = {
|
|
22
|
+
profile: PROFILE,
|
|
23
|
+
prev: '0'.repeat(64),
|
|
24
|
+
seq: 0,
|
|
25
|
+
ticker: 'TEST',
|
|
26
|
+
name: 'Test Token',
|
|
27
|
+
decimals: 0,
|
|
28
|
+
supply: 1000,
|
|
29
|
+
balances: { creator: 1000 },
|
|
30
|
+
ops: []
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
profile: PROFILE,
|
|
35
|
+
prev: sha256Hex(jcs(prevState)),
|
|
36
|
+
seq: 1,
|
|
37
|
+
ticker: 'TEST',
|
|
38
|
+
name: 'Test Token',
|
|
39
|
+
decimals: 0,
|
|
40
|
+
supply: 1000,
|
|
41
|
+
balances: { creator: 900, [toAddress]: 100 },
|
|
42
|
+
ops: ops || [{ op: 'urn:mono:op:transfer', from: 'creator', to: toAddress, amt: 100 }]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return { prevState, state };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('MRC20 Verification', () => {
|
|
49
|
+
describe('jcs', () => {
|
|
50
|
+
it('should sort keys alphabetically', () => {
|
|
51
|
+
assert.strictEqual(jcs({ b: 1, a: 2 }), '{"a":2,"b":1}');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should handle nested objects', () => {
|
|
55
|
+
assert.strictEqual(jcs({ z: { b: 1, a: 2 }, a: 3 }), '{"a":3,"z":{"a":2,"b":1}}');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle arrays', () => {
|
|
59
|
+
assert.strictEqual(jcs([3, 1, 2]), '[3,1,2]');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should handle null and primitives', () => {
|
|
63
|
+
assert.strictEqual(jcs(null), 'null');
|
|
64
|
+
assert.strictEqual(jcs(42), '42');
|
|
65
|
+
assert.strictEqual(jcs('hello'), '"hello"');
|
|
66
|
+
assert.strictEqual(jcs(true), 'true');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should be deterministic', () => {
|
|
70
|
+
const obj = { name: 'TEST', profile: PROFILE, seq: 0, prev: '0'.repeat(64) };
|
|
71
|
+
assert.strictEqual(jcs(obj), jcs(obj));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('sha256Hex', () => {
|
|
76
|
+
it('should return 64-char hex string', () => {
|
|
77
|
+
const hash = sha256Hex('hello');
|
|
78
|
+
assert.strictEqual(hash.length, 64);
|
|
79
|
+
assert.ok(/^[0-9a-f]{64}$/.test(hash));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should be deterministic', () => {
|
|
83
|
+
assert.strictEqual(sha256Hex('test'), sha256Hex('test'));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('verifyStateLink', () => {
|
|
88
|
+
it('should verify valid state chain link', () => {
|
|
89
|
+
const { prevState, state } = createStatePair();
|
|
90
|
+
const result = verifyStateLink(state, prevState);
|
|
91
|
+
assert.strictEqual(result.valid, true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should reject invalid prev hash', () => {
|
|
95
|
+
const { prevState, state } = createStatePair();
|
|
96
|
+
state.prev = 'bad' + state.prev.slice(3);
|
|
97
|
+
const result = verifyStateLink(state, prevState);
|
|
98
|
+
assert.strictEqual(result.valid, false);
|
|
99
|
+
assert.ok(result.error.includes('State chain break'));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should reject wrong sequence number', () => {
|
|
103
|
+
const { prevState, state } = createStatePair();
|
|
104
|
+
state.seq = 5; // should be 1
|
|
105
|
+
const result = verifyStateLink(state, prevState);
|
|
106
|
+
assert.strictEqual(result.valid, false);
|
|
107
|
+
assert.ok(result.error.includes('Sequence mismatch'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should reject missing states', () => {
|
|
111
|
+
assert.strictEqual(verifyStateLink(null, {}).valid, false);
|
|
112
|
+
assert.strictEqual(verifyStateLink({}, null).valid, false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('validateMrc20State', () => {
|
|
117
|
+
it('should accept valid MRC20 state', () => {
|
|
118
|
+
const { state } = createStatePair();
|
|
119
|
+
assert.strictEqual(validateMrc20State(state).valid, true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should reject wrong profile', () => {
|
|
123
|
+
const { state } = createStatePair();
|
|
124
|
+
state.profile = 'wrong.profile';
|
|
125
|
+
assert.strictEqual(validateMrc20State(state).valid, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should reject missing ops', () => {
|
|
129
|
+
const { state } = createStatePair();
|
|
130
|
+
delete state.ops;
|
|
131
|
+
assert.strictEqual(validateMrc20State(state).valid, false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should reject non-object', () => {
|
|
135
|
+
assert.strictEqual(validateMrc20State(null).valid, false);
|
|
136
|
+
assert.strictEqual(validateMrc20State('string').valid, false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('extractTransfersTo', () => {
|
|
141
|
+
it('should extract transfers to specific address', () => {
|
|
142
|
+
const { state } = createStatePair(
|
|
143
|
+
[
|
|
144
|
+
{ op: 'urn:mono:op:transfer', from: 'alice', to: 'pod', amt: 50 },
|
|
145
|
+
{ op: 'urn:mono:op:transfer', from: 'bob', to: 'other', amt: 30 },
|
|
146
|
+
{ op: 'urn:mono:op:transfer', from: 'carol', to: 'pod', amt: 25 }
|
|
147
|
+
],
|
|
148
|
+
'pod'
|
|
149
|
+
);
|
|
150
|
+
const transfers = extractTransfersTo(state, 'pod');
|
|
151
|
+
assert.strictEqual(transfers.length, 2);
|
|
152
|
+
assert.strictEqual(transfers[0].amt, 50);
|
|
153
|
+
assert.strictEqual(transfers[1].amt, 25);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should return empty for no matching transfers', () => {
|
|
157
|
+
const { state } = createStatePair();
|
|
158
|
+
const transfers = extractTransfersTo(state, 'nobody');
|
|
159
|
+
assert.strictEqual(transfers.length, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should ignore non-transfer ops', () => {
|
|
163
|
+
const { state } = createStatePair(
|
|
164
|
+
[{ op: 'urn:mono:op:mint', to: 'pod', amt: 100 }],
|
|
165
|
+
'pod'
|
|
166
|
+
);
|
|
167
|
+
const transfers = extractTransfersTo(state, 'pod');
|
|
168
|
+
assert.strictEqual(transfers.length, 0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('totalTransferredTo', () => {
|
|
173
|
+
it('should sum all transfers to address', () => {
|
|
174
|
+
const { state } = createStatePair(
|
|
175
|
+
[
|
|
176
|
+
{ op: 'urn:mono:op:transfer', from: 'a', to: 'pod', amt: 50 },
|
|
177
|
+
{ op: 'urn:mono:op:transfer', from: 'b', to: 'pod', amt: 25 }
|
|
178
|
+
],
|
|
179
|
+
'pod'
|
|
180
|
+
);
|
|
181
|
+
assert.strictEqual(totalTransferredTo(state, 'pod'), 75);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('verifyMrc20Deposit', () => {
|
|
186
|
+
it('should verify valid deposit', () => {
|
|
187
|
+
const { prevState, state } = createStatePair(
|
|
188
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'mypod', amt: 200 }],
|
|
189
|
+
'mypod'
|
|
190
|
+
);
|
|
191
|
+
const result = verifyMrc20Deposit({ state, prevState, toAddress: 'mypod' });
|
|
192
|
+
assert.strictEqual(result.valid, true);
|
|
193
|
+
assert.strictEqual(result.amount, 200);
|
|
194
|
+
assert.strictEqual(result.ticker, 'TEST');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should reject broken state chain', () => {
|
|
198
|
+
const { prevState, state } = createStatePair(
|
|
199
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'pod', amt: 100 }],
|
|
200
|
+
'pod'
|
|
201
|
+
);
|
|
202
|
+
state.prev = 'tampered';
|
|
203
|
+
const result = verifyMrc20Deposit({ state, prevState, toAddress: 'pod' });
|
|
204
|
+
assert.strictEqual(result.valid, false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should reject deposit to wrong address', () => {
|
|
208
|
+
const { prevState, state } = createStatePair(
|
|
209
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'other-pod', amt: 100 }],
|
|
210
|
+
'other-pod'
|
|
211
|
+
);
|
|
212
|
+
const result = verifyMrc20Deposit({ state, prevState, toAddress: 'my-pod' });
|
|
213
|
+
assert.strictEqual(result.valid, false);
|
|
214
|
+
assert.ok(result.error.includes('No transfers'));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should reject invalid MRC20 profile', () => {
|
|
218
|
+
const { prevState, state } = createStatePair();
|
|
219
|
+
state.profile = 'wrong';
|
|
220
|
+
const result = verifyMrc20Deposit({ state, prevState, toAddress: 'pod' });
|
|
221
|
+
assert.strictEqual(result.valid, false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should sum multiple transfers in same state', () => {
|
|
225
|
+
const { prevState, state } = createStatePair(
|
|
226
|
+
[
|
|
227
|
+
{ op: 'urn:mono:op:transfer', from: 'a', to: 'pod', amt: 100 },
|
|
228
|
+
{ op: 'urn:mono:op:transfer', from: 'b', to: 'pod', amt: 50 }
|
|
229
|
+
],
|
|
230
|
+
'pod'
|
|
231
|
+
);
|
|
232
|
+
const result = verifyMrc20Deposit({ state, prevState, toAddress: 'pod' });
|
|
233
|
+
assert.strictEqual(result.valid, true);
|
|
234
|
+
assert.strictEqual(result.amount, 150);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
package/test/pay.test.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 402 Payment Required tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { schnorr } from '@noble/curves/secp256k1';
|
|
9
|
+
import {
|
|
10
|
+
startTestServer,
|
|
11
|
+
stopTestServer,
|
|
12
|
+
getBaseUrl,
|
|
13
|
+
assertStatus
|
|
14
|
+
} from './helpers.js';
|
|
15
|
+
import { jcs, sha256Hex } from '../src/mrc20.js';
|
|
16
|
+
|
|
17
|
+
// Generate a test keypair for NIP-98 auth
|
|
18
|
+
const privkey = crypto.randomBytes(32);
|
|
19
|
+
const pubkey = Buffer.from(schnorr.getPublicKey(privkey)).toString('hex');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a NIP-98 auth header for a request
|
|
23
|
+
*/
|
|
24
|
+
function createNip98Header(url, method = 'GET') {
|
|
25
|
+
const event = {
|
|
26
|
+
pubkey,
|
|
27
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
28
|
+
kind: 27235,
|
|
29
|
+
tags: [
|
|
30
|
+
['u', url],
|
|
31
|
+
['method', method]
|
|
32
|
+
],
|
|
33
|
+
content: ''
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Compute event id
|
|
37
|
+
const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
|
|
38
|
+
event.id = crypto.createHash('sha256').update(serialized).digest('hex');
|
|
39
|
+
|
|
40
|
+
// Sign with schnorr
|
|
41
|
+
const sig = schnorr.sign(event.id, privkey);
|
|
42
|
+
event.sig = Buffer.from(sig).toString('hex');
|
|
43
|
+
|
|
44
|
+
const token = Buffer.from(JSON.stringify(event)).toString('base64');
|
|
45
|
+
return `Nostr ${token}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('HTTP 402 Pay Middleware', () => {
|
|
49
|
+
const POD_ADDRESS = 'test-pod-address';
|
|
50
|
+
|
|
51
|
+
before(async () => {
|
|
52
|
+
await startTestServer({ pay: true, payCost: 10, payAddress: POD_ADDRESS });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
after(async () => {
|
|
56
|
+
await stopTestServer();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('GET /pay/.balance', () => {
|
|
60
|
+
it('should return 401 without auth', async () => {
|
|
61
|
+
const res = await fetch(`${getBaseUrl()}/pay/.balance`);
|
|
62
|
+
assertStatus(res, 401);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return zero balance for new user', async () => {
|
|
66
|
+
const url = `${getBaseUrl()}/pay/.balance`;
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
headers: { 'Authorization': createNip98Header(url) }
|
|
69
|
+
});
|
|
70
|
+
assertStatus(res, 200);
|
|
71
|
+
const body = await res.json();
|
|
72
|
+
assert.strictEqual(body.balance, 0);
|
|
73
|
+
assert.strictEqual(body.cost, 10);
|
|
74
|
+
assert.strictEqual(body.unit, 'sat');
|
|
75
|
+
assert.ok(body.did.startsWith('did:nostr:'));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('GET /pay/* (paid access)', () => {
|
|
80
|
+
it('should return 401 without auth', async () => {
|
|
81
|
+
const res = await fetch(`${getBaseUrl()}/pay/test-resource`);
|
|
82
|
+
assertStatus(res, 401);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return 402 with zero balance', async () => {
|
|
86
|
+
const url = `${getBaseUrl()}/pay/test-resource`;
|
|
87
|
+
const res = await fetch(url, {
|
|
88
|
+
headers: { 'Authorization': createNip98Header(url) }
|
|
89
|
+
});
|
|
90
|
+
assertStatus(res, 402);
|
|
91
|
+
const body = await res.json();
|
|
92
|
+
assert.strictEqual(body.error, 'Payment Required');
|
|
93
|
+
assert.strictEqual(body.balance, 0);
|
|
94
|
+
assert.strictEqual(body.cost, 10);
|
|
95
|
+
assert.strictEqual(body.deposit, '/pay/.deposit');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('POST /pay/.deposit', () => {
|
|
100
|
+
it('should return 401 without auth', async () => {
|
|
101
|
+
const url = `${getBaseUrl()}/pay/.deposit`;
|
|
102
|
+
const res = await fetch(url, { method: 'POST', body: 'test' });
|
|
103
|
+
assertStatus(res, 401);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should return 400 without TXO URI', async () => {
|
|
107
|
+
const url = `${getBaseUrl()}/pay/.deposit`;
|
|
108
|
+
const res = await fetch(url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Authorization': createNip98Header(url, 'POST') }
|
|
111
|
+
});
|
|
112
|
+
assertStatus(res, 400);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should return 400 for invalid TXO URI', async () => {
|
|
116
|
+
const url = `${getBaseUrl()}/pay/.deposit`;
|
|
117
|
+
const res = await fetch(url, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Authorization': createNip98Header(url, 'POST') },
|
|
120
|
+
body: 'not-a-valid-txo'
|
|
121
|
+
});
|
|
122
|
+
assertStatus(res, 400);
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
assert.ok(body.error.includes('Invalid TXO URI'));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('POST /pay/.deposit (MRC20)', () => {
|
|
129
|
+
function makeStatePair(toAddress, amt = 100) {
|
|
130
|
+
const prevState = {
|
|
131
|
+
profile: 'mono.mrc20.v0.1',
|
|
132
|
+
prev: '0'.repeat(64),
|
|
133
|
+
seq: 0,
|
|
134
|
+
ticker: 'TEST',
|
|
135
|
+
name: 'Test Token',
|
|
136
|
+
decimals: 0,
|
|
137
|
+
supply: 1000,
|
|
138
|
+
balances: { creator: 1000 },
|
|
139
|
+
ops: []
|
|
140
|
+
};
|
|
141
|
+
const state = {
|
|
142
|
+
profile: 'mono.mrc20.v0.1',
|
|
143
|
+
prev: sha256Hex(jcs(prevState)),
|
|
144
|
+
seq: 1,
|
|
145
|
+
ticker: 'TEST',
|
|
146
|
+
name: 'Test Token',
|
|
147
|
+
decimals: 0,
|
|
148
|
+
supply: 1000,
|
|
149
|
+
balances: { creator: 1000 - amt, [toAddress]: amt },
|
|
150
|
+
ops: [{ op: 'urn:mono:op:transfer', from: 'creator', to: toAddress, amt }]
|
|
151
|
+
};
|
|
152
|
+
return { prevState, state };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('should accept valid MRC20 deposit', async () => {
|
|
156
|
+
const { prevState, state } = makeStatePair(POD_ADDRESS, 500);
|
|
157
|
+
const url = `${getBaseUrl()}/pay/.deposit`;
|
|
158
|
+
const res = await fetch(url, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Authorization': createNip98Header(url, 'POST'),
|
|
162
|
+
'Content-Type': 'application/json'
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({ type: 'mrc20', state, prevState })
|
|
165
|
+
});
|
|
166
|
+
assertStatus(res, 200);
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
assert.strictEqual(body.deposited, 500);
|
|
169
|
+
assert.strictEqual(body.ticker, 'TEST');
|
|
170
|
+
assert.strictEqual(body.unit, 'token');
|
|
171
|
+
assert.ok(body.balance >= 500);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should reject MRC20 deposit with broken chain', async () => {
|
|
175
|
+
const { prevState, state } = makeStatePair(POD_ADDRESS);
|
|
176
|
+
state.prev = 'tampered';
|
|
177
|
+
const url = `${getBaseUrl()}/pay/.deposit`;
|
|
178
|
+
const res = await fetch(url, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: {
|
|
181
|
+
'Authorization': createNip98Header(url, 'POST'),
|
|
182
|
+
'Content-Type': 'application/json'
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({ type: 'mrc20', state, prevState })
|
|
185
|
+
});
|
|
186
|
+
assertStatus(res, 400);
|
|
187
|
+
const body = await res.json();
|
|
188
|
+
assert.ok(body.error.includes('State chain break'));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should reject MRC20 deposit to wrong address', async () => {
|
|
192
|
+
const { prevState, state } = makeStatePair('wrong-address');
|
|
193
|
+
const url = `${getBaseUrl()}/pay/.deposit`;
|
|
194
|
+
const res = await fetch(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'Authorization': createNip98Header(url, 'POST'),
|
|
198
|
+
'Content-Type': 'application/json'
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({ type: 'mrc20', state, prevState })
|
|
201
|
+
});
|
|
202
|
+
assertStatus(res, 400);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
assert.ok(body.error.includes('No transfers'));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('Pay disabled', () => {
|
|
209
|
+
let noPayServer;
|
|
210
|
+
let noPayUrl;
|
|
211
|
+
|
|
212
|
+
before(async () => {
|
|
213
|
+
// Start a separate server without pay enabled
|
|
214
|
+
const { createServer } = await import('../src/server.js');
|
|
215
|
+
noPayServer = createServer({ logger: false, forceCloseConnections: true, pay: false });
|
|
216
|
+
await noPayServer.listen({ port: 0, host: '127.0.0.1' });
|
|
217
|
+
const addr = noPayServer.server.address();
|
|
218
|
+
noPayUrl = `http://127.0.0.1:${addr.port}`;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
after(async () => {
|
|
222
|
+
if (noPayServer) await noPayServer.close();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should not intercept /pay/ when disabled', async () => {
|
|
226
|
+
const res = await fetch(`${noPayUrl}/pay/.balance`);
|
|
227
|
+
// Without pay enabled, dotfile security blocks .balance with 403
|
|
228
|
+
assert.ok(res.status === 401 || res.status === 403 || res.status === 404);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Ledger module tests
|
|
3
|
+
* Verifies spec compliance with https://webledgers.org/
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import {
|
|
9
|
+
createLedger,
|
|
10
|
+
getBalance,
|
|
11
|
+
setBalance,
|
|
12
|
+
credit,
|
|
13
|
+
debit,
|
|
14
|
+
listBalances,
|
|
15
|
+
compact
|
|
16
|
+
} from '../src/webledger.js';
|
|
17
|
+
|
|
18
|
+
const CONTEXT = 'https://w3id.org/webledgers';
|
|
19
|
+
|
|
20
|
+
describe('Web Ledger', () => {
|
|
21
|
+
describe('createLedger', () => {
|
|
22
|
+
it('should create spec-compliant ledger with required fields', () => {
|
|
23
|
+
const ledger = createLedger();
|
|
24
|
+
assert.strictEqual(ledger['@context'], CONTEXT);
|
|
25
|
+
assert.strictEqual(ledger.type, 'WebLedger');
|
|
26
|
+
assert.strictEqual(ledger.defaultCurrency, 'satoshi');
|
|
27
|
+
assert.ok(Array.isArray(ledger.entries));
|
|
28
|
+
assert.strictEqual(ledger.entries.length, 0);
|
|
29
|
+
assert.ok(typeof ledger.created === 'number');
|
|
30
|
+
assert.ok(typeof ledger.updated === 'number');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should accept custom options', () => {
|
|
34
|
+
const ledger = createLedger({
|
|
35
|
+
name: 'Alice Credits',
|
|
36
|
+
description: 'Pod credits for alice.pod',
|
|
37
|
+
id: 'https://alice.example/.well-known/webledgers/webledgers.json',
|
|
38
|
+
defaultCurrency: 'USD'
|
|
39
|
+
});
|
|
40
|
+
assert.strictEqual(ledger.name, 'Alice Credits');
|
|
41
|
+
assert.strictEqual(ledger.description, 'Pod credits for alice.pod');
|
|
42
|
+
assert.strictEqual(ledger.id, 'https://alice.example/.well-known/webledgers/webledgers.json');
|
|
43
|
+
assert.strictEqual(ledger.defaultCurrency, 'USD');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('getBalance / setBalance', () => {
|
|
48
|
+
it('should return 0 for unknown URI', () => {
|
|
49
|
+
const ledger = createLedger();
|
|
50
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:abc123'), 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should set and get balance', () => {
|
|
54
|
+
const ledger = createLedger();
|
|
55
|
+
setBalance(ledger, 'did:nostr:abc123', 5000);
|
|
56
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:abc123'), 5000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should create spec-compliant Entry', () => {
|
|
60
|
+
const ledger = createLedger();
|
|
61
|
+
setBalance(ledger, 'did:nostr:abc123', 100);
|
|
62
|
+
const entry = ledger.entries[0];
|
|
63
|
+
assert.strictEqual(entry.type, 'Entry');
|
|
64
|
+
assert.strictEqual(entry.url, 'did:nostr:abc123');
|
|
65
|
+
assert.strictEqual(entry.amount, '100');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should update existing entry', () => {
|
|
69
|
+
const ledger = createLedger();
|
|
70
|
+
setBalance(ledger, 'did:nostr:abc123', 100);
|
|
71
|
+
setBalance(ledger, 'did:nostr:abc123', 200);
|
|
72
|
+
assert.strictEqual(ledger.entries.length, 1);
|
|
73
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:abc123'), 200);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle array amount format', () => {
|
|
77
|
+
const ledger = createLedger();
|
|
78
|
+
ledger.entries.push({
|
|
79
|
+
type: 'Entry',
|
|
80
|
+
url: 'did:nostr:multi',
|
|
81
|
+
amount: [
|
|
82
|
+
{ currency: 'satoshi', value: '50000' },
|
|
83
|
+
{ currency: 'USD', value: '25.00' }
|
|
84
|
+
]
|
|
85
|
+
});
|
|
86
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:multi'), 50000);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('credit / debit', () => {
|
|
91
|
+
it('should credit a new URI', () => {
|
|
92
|
+
const ledger = createLedger();
|
|
93
|
+
const bal = credit(ledger, 'did:nostr:user1', 1000);
|
|
94
|
+
assert.strictEqual(bal, 1000);
|
|
95
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1'), 1000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should accumulate credits', () => {
|
|
99
|
+
const ledger = createLedger();
|
|
100
|
+
credit(ledger, 'did:nostr:user1', 1000);
|
|
101
|
+
const bal = credit(ledger, 'did:nostr:user1', 500);
|
|
102
|
+
assert.strictEqual(bal, 1500);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should debit when sufficient balance', () => {
|
|
106
|
+
const ledger = createLedger();
|
|
107
|
+
credit(ledger, 'did:nostr:user1', 1000);
|
|
108
|
+
const result = debit(ledger, 'did:nostr:user1', 300);
|
|
109
|
+
assert.strictEqual(result.success, true);
|
|
110
|
+
assert.strictEqual(result.balance, 700);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should fail debit when insufficient balance', () => {
|
|
114
|
+
const ledger = createLedger();
|
|
115
|
+
credit(ledger, 'did:nostr:user1', 100);
|
|
116
|
+
const result = debit(ledger, 'did:nostr:user1', 200);
|
|
117
|
+
assert.strictEqual(result.success, false);
|
|
118
|
+
assert.strictEqual(result.balance, 100);
|
|
119
|
+
// Balance unchanged
|
|
120
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1'), 100);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should fail debit on zero balance', () => {
|
|
124
|
+
const ledger = createLedger();
|
|
125
|
+
const result = debit(ledger, 'did:nostr:nobody', 1);
|
|
126
|
+
assert.strictEqual(result.success, false);
|
|
127
|
+
assert.strictEqual(result.balance, 0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('listBalances / compact', () => {
|
|
132
|
+
it('should list non-zero balances', () => {
|
|
133
|
+
const ledger = createLedger();
|
|
134
|
+
credit(ledger, 'did:nostr:a', 100);
|
|
135
|
+
credit(ledger, 'did:nostr:b', 0);
|
|
136
|
+
credit(ledger, 'did:nostr:c', 50);
|
|
137
|
+
const list = listBalances(ledger);
|
|
138
|
+
assert.strictEqual(list.length, 2);
|
|
139
|
+
assert.ok(list.find(e => e.url === 'did:nostr:a' && e.amount === 100));
|
|
140
|
+
assert.ok(list.find(e => e.url === 'did:nostr:c' && e.amount === 50));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should compact zero-balance entries', () => {
|
|
144
|
+
const ledger = createLedger();
|
|
145
|
+
credit(ledger, 'did:nostr:a', 100);
|
|
146
|
+
credit(ledger, 'did:nostr:b', 50);
|
|
147
|
+
debit(ledger, 'did:nostr:b', 50);
|
|
148
|
+
assert.strictEqual(ledger.entries.length, 2);
|
|
149
|
+
compact(ledger);
|
|
150
|
+
assert.strictEqual(ledger.entries.length, 1);
|
|
151
|
+
assert.strictEqual(ledger.entries[0].url, 'did:nostr:a');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('URI format support', () => {
|
|
156
|
+
it('should work with did:nostr URIs', () => {
|
|
157
|
+
const ledger = createLedger();
|
|
158
|
+
credit(ledger, 'did:nostr:de7ecd1e2976a6adb2ffa5f4db81a7d812c8bb6698aa00dcf1e76adb55efd645', 100);
|
|
159
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:de7ecd1e2976a6adb2ffa5f4db81a7d812c8bb6698aa00dcf1e76adb55efd645'), 100);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should work with WebID URIs', () => {
|
|
163
|
+
const ledger = createLedger();
|
|
164
|
+
credit(ledger, 'https://alice.example/profile/card#me', 500);
|
|
165
|
+
assert.strictEqual(getBalance(ledger, 'https://alice.example/profile/card#me'), 500);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should work with mailto URIs', () => {
|
|
169
|
+
const ledger = createLedger();
|
|
170
|
+
credit(ledger, 'mailto:alice@example.com', 200);
|
|
171
|
+
assert.strictEqual(getBalance(ledger, 'mailto:alice@example.com'), 200);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|