wallet-kit / test / vectors / build-vectors.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
/**
 * Golden-vector construction — DELIBERATELY INDEPENDENT OF THE KIT.
 *
 * A vector generated by calling the implementation it is supposed to pin proves
 * nothing: it records what the code did, and it will keep agreeing with the
 * code through any change they make together. So nothing in this file imports
 * dist/. Everything here is derived a second way:
 *
 *   - canonical JSON is RE-IMPLEMENTED below (`canonIndependent`) from the
 *     spec sentence "sorted keys, no whitespace, JSON values only", not
 *     imported from social-kit;
 *   - every signature preimage is additionally spelled out as a LITERAL
 *     TEMPLATE (`preimageByHand`), so the committed `preimage` field is a
 *     string a reader can check against the artifact with their eyes;
 *   - every signature is produced by @noble/curves' Ed25519 over that
 *     preimage — a different implementation from the WebCrypto path id-kit
 *     uses in this environment;
 *   - every dedup key is SHA-256 from node:crypto, base64url'd here.
 *
 * The kit is then required to reproduce all of it (test/vectors.test.mjs), and
 * to return null for every tamper row. Rows the fixture cannot carry faithfully
 * as JSON carry an `input_desc` instead, describing how to rebuild them.
 *
 * `npm test` never runs this file — it is not a *.test.mjs and it lives one
 * directory down, so `node --test test/*.test.mjs` cannot reach it. Run it by
 * hand only when adding vectors:  node test/vectors/build-vectors.mjs
 *
 * The committed fixtures are the CONTRACT. Add to them; do not regenerate them
 * to make a failing test pass.
 */

import { writeFile } from "node:fs/promises";
import { deriveSuiteXKeyPair } from "ardegazu-id-kit/xkey";
import {
  SEED_PAYER,
  SEED_PAYEE,
  SEED_BANK,
  SEED_BANK2,
  TS_FIXED,
  HASHLOCK_PRE_BYTES,
  ORDER_ID_BYTES,
  b64url,
  sha256b64url,
  pubOf,
  signOver,
  canonIndependent,
  preimage,
} from "./independent.mjs";

// ---- the actors -------------------------------------------------------------

const payerPub = pubOf(SEED_PAYER);
const payeePub = pubOf(SEED_PAYEE);
const bankPub = pubOf(SEED_BANK);
const bank2Pub = pubOf(SEED_BANK2);
const CUR = `${bankPub}.LEI`;
const CUR2 = `${bank2Pub}.LEI`;
const orderId = b64url(ORDER_ID_BYTES);

// ---- the four artifacts, hand-composed in wire key order --------------------

const payeeX = await deriveSuiteXKeyPair(b64url(SEED_PAYEE));
const TOX = payeeX.pubB64;

const wprUnsigned = {
  v: 1,
  t: "wpr",
  cur: CUR,
  amt: 100,
  to: payeePub,
  tox: TOX,
  ctx: "",
  memo: "cafea",
  exp: TS_FIXED + 3600000,
};

const wpoUnsigned = {
  v: 1,
  t: "wpo",
  id: orderId,
  cur: CUR,
  amt: 100,
  seq: 7,
  from: payerPub,
  to: payeePub,
  ctx: "stake:demo",
  memo: "cafea ☕",
  ts: TS_FIXED,
  exp: TS_FIXED + 600000,
};

const wprPre = preimage("wpr", wprUnsigned);
const wpr = { ...wprUnsigned, sig: signOver(SEED_PAYEE, wprPre) };

const wpoPre = preimage("wpo", wpoUnsigned);
const wpo = { ...wpoUnsigned, sig: signOver(SEED_PAYER, wpoPre) };

const wrcUnsigned = { v: 1, t: "wrc", po: wpo, seq: "L-42", ts: TS_FIXED + 1000, bank: bankPub };
const wrcPre = preimage("wrc", wrcUnsigned);
const wrc = { ...wrcUnsigned, bsig: signOver(SEED_BANK, wrcPre) };

const wrjUnsigned = { v: 1, t: "wrj", po: wpo, why: "insufficient", ts: TS_FIXED + 2000, bank: bankPub };
const wrjPre = preimage("wrj", wrjUnsigned);
const wrj = { ...wrjUnsigned, bsig: signOver(SEED_BANK, wrjPre) };

// The issuance receipt: the bank co-signing one MINT of its own currency, so
// the ack that pins its issuance slot has a preimage to cover. `h` is the mint
// entry's log hash and is OPAQUE to wallet-kit — a non-empty control-free
// string, compared only for equality — so the fixture uses a multibase-looking
// literal without claiming the grammar.
const MINT_H = "zdpuAtq8kqPXzS3rVGkD1nDNJCoCUqfEC97WQMg4vDeqQm3wg";

const wriUnsigned = {
  v: 1,
  t: "wri",
  cur: CUR,
  seq: 7, // the BANK's issuance slot number, not any payer's
  to: payeePub,
  amt: 100,
  h: MINT_H,
  ts: TS_FIXED + 3000,
  bank: bankPub,
};
const wriPre = preimage("wri", wriUnsigned);
const wri = { ...wriUnsigned, bsig: signOver(SEED_BANK, wriPre) };

// ---- escrow (BANK/2): the lock, its receipt, and a release -------------------
//
// A lock is a payment order with a condition: the money is HELD, and whoever
// produces a preimage of `hash` is paid. Two of these under ONE hashlock, in
// two different banks, is an atomic trade — the secret-holder claims first and
// thereby publishes the secret, and the counterparty claims with it.
//
// The hashlock here is DERIVED, never random: 32 fixed bytes, and the digest is
// taken over their base64url TEXT (see the HASHLOCK note in consts.cljs — the
// text, not the bytes, so there is one rule instead of two and no place for a
// second implementation to disagree).

const HL_PRE = b64url(HASHLOCK_PRE_BYTES);
const HL_HASH = sha256b64url(HL_PRE);

/** The lock entry's hash in the bank's log — opaque here, as `h` is for a wri. */
const LOCK_H = "zdpuAoLKcHt4kEQvKQEsy7BFHRvVfKY3JnfXqpFPoLxvnkxbF";

const wlkUnsigned = {
  v: 1,
  t: "wlk",
  id: orderId,
  cur: CUR,
  amt: 100,
  seq: 7, // the PAYER's slot — the same space a wpo draws from, not a second one
  from: payerPub,
  to: payeePub,
  hash: HL_HASH,
  ctx: "match:demo",
  memo: "cafea ☕",
  ts: TS_FIXED,
  exp: TS_FIXED + 600000,
};
const wlkPre = preimage("wlk", wlkUnsigned);
const wlk = { ...wlkUnsigned, sig: signOver(SEED_PAYER, wlkPre) };

// `seq` is the bank's log ref for THE CLAIM, not for the lock: the lock is the
// promise and the claim is the money moving, and a receipt is for the latter.
const wlrUnsigned = { v: 1, t: "wlr", lk: wlk, seq: "L-77", ts: TS_FIXED + 3000, bank: bankPub };
const wlrPre = preimage("wlr", wlrUnsigned);
const wlr = { ...wlrUnsigned, bsig: signOver(SEED_BANK, wlrPre) };

// signed by the PAYEE — the beneficiary of the lock, giving the money back.
// This is the only artifact in the kit whose signer is the party paid TO.
const wrlUnsigned = { v: 1, t: "wrl", cur: CUR, lh: LOCK_H, to: payeePub, ts: TS_FIXED + 4000 };
const wrlPre = preimage("wrl", wrlUnsigned);
const wrl = { ...wrlUnsigned, sig: signOver(SEED_PAYEE, wrlPre) };

// escrow tamper material
const reSignedLock = (patch) => {
  const unsigned = { ...wlkUnsigned, ...patch };
  return { ...unsigned, sig: signOver(SEED_PAYER, preimage("wlk", unsigned)) };
};
const neutralLockUnsigned = { ...wlkUnsigned, cur: "~.GAZ" };
const neutralLock = {
  ...neutralLockUnsigned,
  sig: signOver(SEED_PAYER, preimage("wlk", neutralLockUnsigned)),
};
// a receipt over BANK's paper, signed by BANK2 naming itself: internally
// consistent, and refused because banker(lk.cur) is not the signer
const crossBankLrUnsigned = { ...wlrUnsigned, bank: bank2Pub };
const crossBankLr = {
  ...crossBankLrUnsigned,
  bsig: signOver(SEED_BANK2, preimage("wlr", crossBankLrUnsigned)),
};
// a release signed by the PAYER — who is not the beneficiary. It verifies
// against nothing, and it is the case a bank must catch by comparing to lk.to.
const wrongSignerRel = { ...wrlUnsigned, sig: signOver(SEED_PAYER, wrlPre) };

// ---- dedup keys, hashed here -------------------------------------------------

const oidInput = canonIndependent(wpoUnsigned);
const oid = sha256b64url(oidInput);
const rcKeyInput = canonIndependent({ v: 1, t: "wrc", bank: bankPub, oid });
const rjKeyInput = canonIndependent({ v: 1, t: "wrj", bank: bankPub, oid });
const ikeyInput = canonIndependent({ v: 1, t: "wri", bank: bankPub, h: MINT_H });
const lockIdInput = canonIndependent(wlkUnsigned);
const lockIdV = sha256b64url(lockIdInput);
const lrKeyInput = canonIndependent({ v: 1, t: "wlr", bank: bankPub, lkid: lockIdV });

// ---- tamper rows -------------------------------------------------------------
//
// Every row here must verify to null. `mutate` names the single change, so the
// fixture reads as a list of claims rather than a pile of blobs.

const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

/** Change a real byte of a base64url blob: the FIRST character carries six. */
const flipFirstChar = (s) => (s[0] === "A" ? "B" : "A") + s.slice(1);

/**
 * Change ONLY the padding bits of a base64url blob.
 *
 * An 86-character encoding of 64 bytes spends its last character on 4 real bits
 * and 4 bits of nothing, so the next character in the alphabet decodes to the
 * SAME 64 bytes. The signature is therefore still valid and the string is not —
 * sixteen spellings of one signature, which is a dedup and cache hazard and a
 * gift to anyone who wants "the same artifact" to have two answers. The kit
 * pins the padding bits to zero (consts.cljs) so these rows are refused; a
 * second implementation must refuse them too.
 */
const nonCanonicalTail = (s) => s.slice(0, -1) + B64[B64.indexOf(s.slice(-1)) + 1];

const tampered = (base, fn) => {
  const copy = JSON.parse(JSON.stringify(base));
  fn(copy);
  return copy;
};

// a settlement signed by BANK2 over an order drawn on BANK's currency: both
// signatures are real, and it must still be rejected
const crossBankUnsigned = { v: 1, t: "wrc", po: wpo, seq: "L-1", ts: TS_FIXED + 1000, bank: bank2Pub };
const crossBank = {
  ...crossBankUnsigned,
  bsig: signOver(SEED_BANK2, preimage("wrc", crossBankUnsigned)),
};

// a neutral-unit order, correctly signed: the signature is valid and the
// artifact must still be refused, because ~.GAZ is quote-only
const neutralUnsigned = { ...wpoUnsigned, cur: "~.GAZ" };
const neutralOrder = {
  ...neutralUnsigned,
  sig: signOver(SEED_PAYER, `wpay-ord|v1|${canonIndependent(neutralUnsigned)}`),
};

// an issuance of BANK's currency signed by BANK2, naming itself as the bank:
// the signature is real and it must still be rejected — the signing bank must
// BE the banker of `cur`, or any bank could "finalize" another bank's mints
const crossBankIssUnsigned = { ...wriUnsigned, bank: bank2Pub };
const crossBankIss = {
  ...crossBankIssUnsigned,
  bsig: signOver(SEED_BANK2, preimage("wri", crossBankIssUnsigned)),
};

// the SAME wri content, bank field left as the true banker, but the signature
// produced by a key that is NOT the bank's: the binding holds, the shape is
// perfect, and only the signature check can refuse it
const forgedIss = { ...wriUnsigned, bsig: signOver(SEED_PAYER, wriPre) };

// a neutral-unit issuance, correctly signed: ~.GAZ is never issuable, and a
// receipt claiming otherwise is refused even though its banker literally
// cannot exist to have signed it — the payable check runs before the binding
const neutralIssUnsigned = { ...wriUnsigned, cur: "~.GAZ" };
const neutralIss = {
  ...neutralIssUnsigned,
  bsig: signOver(SEED_BANK, preimage("wri", neutralIssUnsigned)),
};

/** The fixture wri with one field replaced and RE-SIGNED by the bank, so the
 *  rejection is about the rule and not about a signature broken on the way. */
const reSignedIss = (patch) => {
  const unsigned = { ...wriUnsigned, ...patch };
  return { ...unsigned, bsig: signOver(SEED_BANK, preimage("wri", unsigned)) };
};

// ---- files -------------------------------------------------------------------

const files = {
  "escrow.json": {
    _doc:
      "The three BANK/2 escrow artifacts at the same fixed seeds and timestamp: exact wire JSON (key order included), the exact signature preimage, and byte-exact Ed25519 signatures produced by @noble/curves — not by the kit. Kept in its own file so artifacts.json stays byte-identical to what it was before escrow existed.",
    input_desc: {
      hashlock:
        "preimage = 32 bytes 0xc0..0xdf, base64url; hash = sha256 of THAT TEXT, base64url. The digest is over the 43-character text, not the 32 bytes — one hashing rule for the whole kit, and no place for a second implementation to silently pick the other reading.",
      lock_h:
        "the wrl fixture's `lh` is an arbitrary multibase-looking literal. Like a wri's `h`, a lock entry's log hash is OPAQUE to this kit — non-empty, control-free, at most CTX_MAX code points, compared only for equality.",
      seq_space:
        "wlk.seq is 7, the PAYER's slot, drawn from the SAME per-payer-per-bank space a wpo draws from. A lock and a payment are both this account moving its own money and a bank consumes one slot per act; a wallet that handed slot 7 to both would have one of them fail permanently.",
      wlr_seq:
        "wlr.seq is the bank's log ref for THE CLAIM, not for the lock. The lock is the promise; the claim is the money moving.",
    },
    actors: { payerPub, payeePub, bankPub, bank2Pub, cur: CUR, cur2: CUR2 },
    hashlock: {
      pre: HL_PRE,
      hash: HL_HASH,
      _doc: "sha256B64url(pre) === hash. One preimage per lock, ever: the first claim publishes it, and every other lock sharing the digest becomes claimable by anyone watching.",
    },
    artifacts: {
      wlk: { wire: wlk, wire_json: JSON.stringify(wlk), preimage: wlkPre, signer: "payer" },
      wlr: { wire: wlr, wire_json: JSON.stringify(wlr), preimage: wlrPre, signer: "bank" },
      wrl: { wire: wrl, wire_json: JSON.stringify(wrl), preimage: wrlPre, signer: "payee (the beneficiary)" },
    },
    key_order: {
      _doc:
        "The transmitted key order. wlk is FOURTEEN keys — wpo's thirteen with `hash` between `to` and `ctx`. Note that ASCII sorting puts `hash` somewhere else entirely (after `from`, before `id`): the wire order and the signed order are different, deliberately, and both are pinned.",
      wlk: Object.keys(wlk),
      wlr: Object.keys(wlr),
      wrl: Object.keys(wrl),
    },
    domains: {
      _doc:
        "wlk and wrl get their own domains; wlr SHARES wrc's. The rule: artifacts share a domain when they are the same act by the same signer with the instruction embedded and `t` inside canon() — a bank answering one instruction. wri needed its own because it embeds NO artifact, so nothing inside canon() pins it against a future field-name collision; a wlr embeds a whole signed lk, exactly as a wrc embeds a whole signed po.",
      wlk: "wpay-lock",
      wlr: "wpay-rcp",
      wrl: "wpay-rel",
    },
    dedup_keys: {
      lock_id: { input_desc: "canon(the fixture lock minus its sig) — thirteen keys, ASCII-sorted", input: lockIdInput, sha256_b64url: lockIdV },
      lock_receipt_key: { input_desc: 'canon({v:1, t:"wlr", bank:<bankPub>, lkid:<the lock id above>})', input: lrKeyInput, sha256_b64url: sha256b64url(lrKeyInput) },
    },
    order_independence: {
      _doc:
        "Key order is the WIRE but not the SIGNATURE: canon() sorts, so the same artifact assembled backwards must canon to the same bytes and verify against the same signature.",
      wlk_reversed_json: JSON.stringify(Object.fromEntries(Object.entries(wlk).reverse())),
      wlr_reversed_json: JSON.stringify(Object.fromEntries(Object.entries(wlr).reverse())),
      expect_same_preimage: { wlk: wlkPre, wlr: wlrPre },
    },
    tamper: [
      { name: "wlk: one flipped byte in the signature", kind: "wlk", artifact: tampered(wlk, (a) => (a.sig = flipFirstChar(a.sig))) },
      { name: "wlk: the hashlock swapped after signing", kind: "wlk", artifact: tampered(wlk, (a) => (a.hash = flipFirstChar(a.hash))) },
      { name: "wlk: amt raised after signing", kind: "wlk", artifact: tampered(wlk, (a) => (a.amt = 100000)) },
      { name: "wlk: beneficiary swapped after signing", kind: "wlk", artifact: tampered(wlk, (a) => (a.to = bankPub)) },
      { name: "wlk: seq bumped after signing", kind: "wlk", artifact: tampered(wlk, (a) => (a.seq = 8)) },
      { name: "wlk: ctx rewritten after signing — the trade it settles", kind: "wlk", artifact: tampered(wlk, (a) => (a.ctx = "match:other")) },
      { name: "wlk: an extra key stapled on", kind: "wlk", artifact: tampered(wlk, (a) => (a.evil = 1)) },
      { name: "wlk: sig field removed", kind: "wlk", artifact: tampered(wlk, (a) => delete a.sig) },
      { name: "wlk: relabelled as a payment order", kind: "wlk", artifact: tampered(wlk, (a) => (a.t = "wpo")) },
      { name: "wlk: the never-lockable neutral unit, correctly signed", kind: "wlk", artifact: neutralLock },
      { name: "wlk: a hashlock with non-canonical base64url, re-signed", kind: "wlk", artifact: reSignedLock({ hash: nonCanonicalTail(HL_HASH) }) },
      { name: "wlk: a truncated hashlock, re-signed", kind: "wlk", artifact: reSignedLock({ hash: HL_HASH.slice(0, -1) }) },
      { name: "wlk: an empty hashlock, re-signed", kind: "wlk", artifact: reSignedLock({ hash: "" }) },
      { name: "wlk: a zero amount, re-signed — zero is not a lock", kind: "wlk", artifact: reSignedLock({ amt: 0 }) },
      { name: "wlk: exp before ts, re-signed", kind: "wlk", artifact: reSignedLock({ exp: TS_FIXED - 1 }) },
      { name: "wlk: non-canonical base64url padding on a still-valid signature", kind: "wlk", artifact: tampered(wlk, (a) => (a.sig = nonCanonicalTail(a.sig))) },
      { name: "wlr: one flipped byte in the bank signature", kind: "wlr", artifact: tampered(wlr, (a) => (a.bsig = flipFirstChar(a.bsig))) },
      { name: "wlr: the embedded lock's signature flipped", kind: "wlr", artifact: tampered(wlr, (a) => (a.lk.sig = flipFirstChar(a.lk.sig))) },
      { name: "wlr: the embedded lock's amount raised", kind: "wlr", artifact: tampered(wlr, (a) => (a.lk.amt = 100000)) },
      { name: "wlr: the embedded lock's hashlock swapped", kind: "wlr", artifact: tampered(wlr, (a) => (a.lk.hash = flipFirstChar(a.lk.hash))) },
      { name: "wlr: claim log ref rewritten after signing", kind: "wlr", artifact: tampered(wlr, (a) => (a.seq = "L-78")) },
      { name: "wlr: relabelled as a settlement", kind: "wlr", artifact: tampered(wlr, (a) => (a.t = "wrc")) },
      { name: "wlr: an extra key stapled on", kind: "wlr", artifact: tampered(wlr, (a) => (a.evil = 1)) },
      { name: "wlr: signed by a bank that does not issue lk.cur", kind: "wlr", artifact: crossBankLr },
      { name: "wrl: one flipped byte in the signature", kind: "wrl", artifact: tampered(wrl, (a) => (a.sig = flipFirstChar(a.sig))) },
      { name: "wrl: the lock entry hash rewritten after signing", kind: "wrl", artifact: tampered(wrl, (a) => (a.lh = a.lh.slice(0, -1) + "x")) },
      { name: "wrl: beneficiary swapped after signing", kind: "wrl", artifact: tampered(wrl, (a) => (a.to = payerPub)) },
      { name: "wrl: signed by the payer, who is not the beneficiary", kind: "wrl", artifact: wrongSignerRel },
      { name: "wrl: an extra key stapled on", kind: "wrl", artifact: tampered(wrl, (a) => (a.evil = 1)) },
      { name: "wrl: relabelled as an issuance receipt", kind: "wrl", artifact: tampered(wrl, (a) => (a.t = "wri")) },
    ],
  },
  "artifacts.json": {
    _doc:
      "The five wallet artifacts at fixed seeds and a fixed 2025-01-01 timestamp: exact wire JSON (key order included), the exact signature preimage, and byte-exact Ed25519 signatures produced by @noble/curves — not by the kit. Also the order-independence pairs (the same artifact assembled in a different key order signs identically) and the tamper table (every row must verify to null).",
    input_desc: {
      seeds:
        "payer = 32 bytes 0x00..0x1f; payee = 32 bytes 0x20..0x3f; bank = 32 bytes of 0x11; bank2 = 32 bytes of 0x33. The seed IS the raw Ed25519 private key (id-kit and @noble/curves agree on this).",
      order_id: "16 bytes 0xa0..0xaf, base64url — 22 characters, as ORDER_ID_RE requires.",
      tox: "the payee seed's suite X25519 public key (id-kit/xkey deriveSuiteXKeyPair). wallet-kit only shape-checks it.",
      mint_h:
        "the wri fixture's `h` is an arbitrary multibase-looking literal. wallet-kit treats a mint entry's log hash as OPAQUE — a non-empty, control-free string of at most CTX_MAX code points, compared only for equality — so no base is pinned and none must be.",
      ts: `TS_FIXED = ${TS_FIXED} (2025-01-01T00:00:00Z). In the past, so no verdict here can change with the clock — expiry is not a verification failure in this kit.`,
    },
    lengths_by_hand: {
      _doc: "Sizes a reader can check without running anything.",
      identity_pub_b64url: 43,
      ed25519_sig_b64url: 86,
      order_id_b64url: 22,
      sha256_b64url: 43,
    },
    actors: { payerPub, payeePub, bankPub, bank2Pub, cur: CUR, cur2: CUR2, tox: TOX },
    artifacts: {
      wpr: { wire: wpr, wire_json: JSON.stringify(wpr), preimage: wprPre, signer: "payee" },
      wpo: { wire: wpo, wire_json: JSON.stringify(wpo), preimage: wpoPre, signer: "payer" },
      wrc: { wire: wrc, wire_json: JSON.stringify(wrc), preimage: wrcPre, signer: "bank" },
      wrj: { wire: wrj, wire_json: JSON.stringify(wrj), preimage: wrjPre, signer: "bank" },
      wri: { wire: wri, wire_json: JSON.stringify(wri), preimage: wriPre, signer: "bank" },
    },
    key_order: {
      _doc:
        "The transmitted key order. wpo is thirteen keys — past the nine-pair cliff where a #js{} / js-obj literal silently switches to hash order.",
      wpr: Object.keys(wpr),
      wpo: Object.keys(wpo),
      wrc: Object.keys(wrc),
      wrj: Object.keys(wrj),
      wri: Object.keys(wri),
    },
    order_independence: {
      _doc:
        "Key order is the WIRE but not the SIGNATURE: canon() sorts, so the same artifact assembled backwards must canon to the same bytes and verify against the same signature. The reversed forms below are byte-different JSON with an identical preimage.",
      wpo_reversed_json: JSON.stringify(
        Object.fromEntries(Object.entries(wpo).reverse()),
      ),
      wrc_reversed_json: JSON.stringify(
        Object.fromEntries(Object.entries(wrc).reverse()),
      ),
      wri_reversed_json: JSON.stringify(
        Object.fromEntries(Object.entries(wri).reverse()),
      ),
      expect_same_preimage: { wpo: wpoPre, wrc: wrcPre, wri: wriPre },
    },
    tamper: [
      { name: "wpr: one flipped byte in the signature", kind: "wpr", artifact: tampered(wpr, (a) => (a.sig = flipFirstChar(a.sig))) },
      { name: "wpr: amt mutated after signing", kind: "wpr", artifact: tampered(wpr, (a) => (a.amt = 101)) },
      { name: "wpr: payee swapped for the payer", kind: "wpr", artifact: tampered(wpr, (a) => (a.to = payerPub)) },
      { name: "wpr: an extra key stapled on", kind: "wpr", artifact: tampered(wpr, (a) => (a.evil = 1)) },
      { name: "wpo: one flipped byte in the signature", kind: "wpo", artifact: tampered(wpo, (a) => (a.sig = flipFirstChar(a.sig))) },
      { name: "wpo: from and to swapped", kind: "wpo", artifact: tampered(wpo, (a) => { const f = a.from; a.from = a.to; a.to = f; }) },
      { name: "wpo: seq bumped after signing", kind: "wpo", artifact: tampered(wpo, (a) => (a.seq = 8)) },
      { name: "wpo: ctx rewritten after signing", kind: "wpo", artifact: tampered(wpo, (a) => (a.ctx = "stake:other")) },
      { name: "wpo: an extra key stapled on", kind: "wpo", artifact: tampered(wpo, (a) => (a.evil = 1)) },
      { name: "wpo: sig field removed", kind: "wpo", artifact: tampered(wpo, (a) => delete a.sig) },
      { name: "wpo: denominated in the neutral unit, correctly signed", kind: "wpo", artifact: neutralOrder },
      { name: "wrc: one flipped byte in the bank signature", kind: "wrc", artifact: tampered(wrc, (a) => (a.bsig = flipFirstChar(a.bsig))) },
      { name: "wrc: the embedded order's signature flipped", kind: "wrc", artifact: tampered(wrc, (a) => (a.po.sig = flipFirstChar(a.po.sig))) },
      { name: "wrc: the embedded order's amount raised", kind: "wrc", artifact: tampered(wrc, (a) => (a.po.amt = 100000)) },
      { name: "wrc: log ref rewritten after signing", kind: "wrc", artifact: tampered(wrc, (a) => (a.seq = "L-43")) },
      { name: "wrc: relabelled as a decline", kind: "wrc", artifact: tampered(wrc, (a) => (a.t = "wrj")) },
      { name: "wrc: signed by a bank that does not issue po.cur", kind: "wrc", artifact: crossBank },
      { name: "wrj: an unknown decline reason", kind: "wrj", artifact: tampered(wrj, (a) => (a.why = "because")) },
      { name: "wrj: relabelled as a receipt", kind: "wrj", artifact: tampered(wrj, (a) => (a.t = "wrc")) },
      { name: "wpo: non-canonical base64url padding on a still-valid signature", kind: "wpo", artifact: tampered(wpo, (a) => (a.sig = nonCanonicalTail(a.sig))) },
      { name: "wrc: non-canonical base64url padding on the bank signature", kind: "wrc", artifact: tampered(wrc, (a) => (a.bsig = nonCanonicalTail(a.bsig))) },
      { name: "wpo: non-canonical base64url padding on the order id", kind: "wpo", artifact: tampered(wpo, (a) => (a.id = nonCanonicalTail(a.id))) },
      { name: "wri: one flipped byte in the bank signature", kind: "wri", artifact: tampered(wri, (a) => (a.bsig = flipFirstChar(a.bsig))) },
      { name: "wri: amt raised after signing", kind: "wri", artifact: tampered(wri, (a) => (a.amt = 100000)) },
      { name: "wri: recipient swapped after signing", kind: "wri", artifact: tampered(wri, (a) => (a.to = payerPub)) },
      { name: "wri: issuance slot bumped after signing", kind: "wri", artifact: tampered(wri, (a) => (a.seq = 8)) },
      { name: "wri: the mint entry hash rewritten after signing", kind: "wri", artifact: tampered(wri, (a) => (a.h = a.h.slice(0, -1) + "x")) },
      { name: "wri: an extra key stapled on", kind: "wri", artifact: tampered(wri, (a) => (a.evil = 1)) },
      { name: "wri: bsig field removed", kind: "wri", artifact: tampered(wri, (a) => delete a.bsig) },
      { name: "wri: relabelled as a settlement", kind: "wri", artifact: tampered(wri, (a) => (a.t = "wrc")) },
      { name: "wri: signed by a bank that does not issue cur", kind: "wri", artifact: crossBankIss },
      { name: "wri: bsig re-signed by a key that is not the bank's", kind: "wri", artifact: forgedIss },
      { name: "wri: the never-issuable neutral unit, correctly signed", kind: "wri", artifact: neutralIss },
      { name: "wri: non-canonical base64url padding on a still-valid signature", kind: "wri", artifact: tampered(wri, (a) => (a.bsig = nonCanonicalTail(a.bsig))) },
      { name: "wri: an empty mint entry hash, re-signed", kind: "wri", artifact: reSignedIss({ h: "" }) },
      { name: "wri: a control character in the mint entry hash, re-signed", kind: "wri", artifact: reSignedIss({ h: "a\nb" }) },
      { name: "wri: a mint entry hash past CTX_MAX code points, re-signed", kind: "wri", artifact: reSignedIss({ h: "x".repeat(129) }) },
      { name: "wri: a zero amount, re-signed — zero is not a mint", kind: "wri", artifact: reSignedIss({ amt: 0 }) },
      { name: "wri: an amount past 2^50, re-signed", kind: "wri", artifact: reSignedIss({ amt: 1125899906842625 }) },
      { name: "wri: issuance slot zero, re-signed — slots start at 1", kind: "wri", artifact: reSignedIss({ seq: 0 }) },
      { name: "wri: a fractional issuance slot, re-signed", kind: "wri", artifact: reSignedIss({ seq: 7.5 }) },
      { name: "wri: a negative ts, re-signed", kind: "wri", artifact: reSignedIss({ ts: -1 }) },
    ],
    replay: {
      _doc:
        "verifySettlement's expectedBankPub argument. The receipt is genuine; presented as the answer of a DIFFERENT bank it must be refused, which is the whole reason the argument exists.",
      artifact: wrc,
      expect_accept_for: bankPub,
      expect_reject_for: bank2Pub,
    },
    replay_issuance: {
      _doc:
        "verifyIssuanceReceipt's expectedBankPub argument, with verifySettlement's exact semantics: a genuine mint receipt presented as a DIFFERENT bank's must be refused.",
      artifact: wri,
      expect_accept_for: bankPub,
      expect_reject_for: bank2Pub,
    },
  },

  "keys.json": {
    _doc:
      "Dedup key derivations. Each row carries the exact string that was hashed, so a reader can reproduce it with `printf %s '<input>' | openssl dgst -sha256 -binary | basenc --base64url` and never has to trust this file.",
    order_id: {
      input_desc: "canon(the fixture order minus its sig) — twelve keys, ASCII-sorted",
      input: oidInput,
      sha256_b64url: oid,
    },
    receipt_key_wrc: {
      input_desc: 'canon({v:1, t:"wrc", bank:<bankPub>, oid:<the order id above>})',
      input: rcKeyInput,
      sha256_b64url: sha256b64url(rcKeyInput),
    },
    receipt_key_wrj: {
      input_desc: 'canon({v:1, t:"wrj", bank:<bankPub>, oid:<the order id above>}) — the SAME order, so the two keys must differ only because `t` does',
      input: rjKeyInput,
      sha256_b64url: sha256b64url(rjKeyInput),
    },
    issuance_key: {
      input_desc: 'canon({v:1, t:"wri", bank:<bankPub>, h:<the fixture mint entry hash>}) — receiptKey\'s shape with the log hash where the order id sits: (bank, h) IS the fact being made final',
      input: ikeyInput,
      sha256_b64url: sha256b64url(ikeyInput),
    },
    properties: {
      _doc: "Claims about the derivation that the test checks directly against dist/.",
      order_id_ignores_sig: true,
      receipt_key_ignores_bank_ts_and_log_ref: true,
      receipt_and_decline_keys_differ: true,
      issuance_key_ignores_ts_seq_to_amt_and_cur: true,
    },
  },

  "currency.json": {
    _doc:
      'The currency-id grammar and the reserved neutral unit. Verdicts only — every row is a claim about the RULE ("<bankerIdPub>.<CODE>", CODE ^[A-Z]{3,8}$, ~.GAZ parses but is never payable), independent of how it is implemented.',
    rows: [
      { cur: CUR, parse: { banker: bankPub, code: "LEI" }, payable: true, why: "the ordinary case" },
      { cur: `${bankPub}.RONALDO`, parse: { banker: bankPub, code: "RONALDO" }, payable: true, why: "seven letters, inside 3..8" },
      { cur: `${bankPub}.RONALDOS`, parse: { banker: bankPub, code: "RONALDOS" }, payable: true, why: "eight letters, the ceiling" },
      { cur: "~.GAZ", parse: { banker: "~", code: "GAZ" }, payable: false, why: "reserved: a denomination for pricing, and `~` is not base64url so no key can spell it" },
      { cur: `${bankPub}.RONALDOSS`, parse: null, payable: false, why: "nine letters" },
      { cur: `${bankPub}.LE`, parse: null, payable: false, why: "two letters" },
      { cur: `${bankPub}.lei`, parse: null, payable: false, why: "lowercase" },
      { cur: `${bankPub}.LE1`, parse: null, payable: false, why: "a digit in the code" },
      { cur: `${bankPub}.LEI.RON`, parse: null, payable: false, why: "two dots" },
      { cur: "LEI", parse: null, payable: false, why: "no banker" },
      { cur: `${bankPub.slice(0, 42)}.LEI`, parse: null, payable: false, why: "42-character banker" },
      { cur: `~.LEI`, parse: null, payable: false, why: "the `~` banker exists only for GAZ" },
      { cur: "~.GAZZ", parse: null, payable: false, why: "not the reserved literal" },
      { cur: "", parse: null, payable: false, why: "empty" },
    ],
  },

  "limits.json": {
    _doc:
      "Boundary verdicts for the validated fields. Each row is the fixture order with ONE field replaced and re-signed, so an acceptance is a real acceptance and a rejection is about the rule and not about a broken signature. `resign: true` means the row is rebuilt and signed at test time from `field`/`value`.",
    amt: [
      { value: 1, accept: true, why: "AMT_MIN" },
      { value: 0, accept: false, why: "below AMT_MIN — zero is not a payment" },
      { value: -1, accept: false, why: "negative" },
      { value: 1125899906842624, accept: true, why: "2^50, the exact-double ceiling" },
      { value: 1125899906842625, accept: false, why: "2^50 + 1" },
      { value: 1.5, accept: false, why: "not an integer — amounts are minor units" },
      { value: "100", accept: false, why: "a string" },
    ],
    seq: [
      { value: 1, accept: true, why: "the first slot" },
      { value: 0, accept: false, why: "sequences start at 1" },
      { value: -1, accept: false, why: "negative" },
      { value: 2.5, accept: false, why: "not an integer" },
      { value: 1125899906842625, accept: false, why: "past the exact-double ceiling" },
    ],
    memo: [
      { value: "", accept: true, why: "empty is the default" },
      { value: "x".repeat(140), accept: true, why: "MEMO_MAX code points" },
      { value: "x".repeat(141), accept: false, why: "one over" },
      { value: "☕".repeat(140), accept: true, why: "140 CODE POINTS, not UTF-16 units" },
      { value: "a\nb", accept: false, why: "a control character" },
      { value: "a\u0000b", accept: false, why: "NUL" },
      { value: "a\u007Fb", accept: false, why: "DEL" },
    ],
    ctx: [
      { value: "stake:demo", accept: true, why: "the ordinary case" },
      { value: "x".repeat(128), accept: true, why: "CTX_MAX code points" },
      { value: "x".repeat(129), accept: false, why: "one over" },
      { value: "a\tb", accept: false, why: "a control character" },
    ],
  },

  "paylink.json": {
    _doc:
      "The `#pay=` codec. The URL is base64url of the request's wire JSON under a fixed base, so `expect_url` is checkable by hand; the tolerance rows are what a real address bar hands you.",
    base: "https://banca.ardegazu.ro/",
    request: wpr,
    expect_url: `https://banca.ardegazu.ro/#pay=${Buffer.from(JSON.stringify(wpr), "utf8").toString("base64url")}`,
    accept_forms: [
      { name: "the whole URL", form: "url" },
      { name: "the fragment with its #", form: "hash" },
      { name: "the bare fragment", form: "bare" },
      { name: "other parameters before it", form: "prefixed" },
      { name: "other parameters after it", form: "suffixed" },
    ],
    reject_forms: [
      { name: "no pay parameter", input: "#other=1" },
      { name: "empty fragment", input: "#" },
      { name: "not base64url", input: "#pay=!!!!" },
      { name: "base64url of something that is not JSON", input: `#pay=${Buffer.from("not json", "utf8").toString("base64url")}` },
      { name: "base64url of an unsigned request", input: `#pay=${Buffer.from(JSON.stringify(wprUnsigned), "utf8").toString("base64url")}` },
      { name: "base64url of a request with a flipped signature", input: `#pay=${Buffer.from(JSON.stringify({ ...wpr, sig: flipFirstChar(wpr.sig) }), "utf8").toString("base64url")}` },
      {
        name: "standard base64 with padding, not base64url",
        why: "decodes to the same request under a lenient decoder, which would give one request three link spellings; the kit is strict about encoding malleability everywhere else",
        input: `#pay=${Buffer.from(JSON.stringify(wpr), "utf8").toString("base64")}`,
      },
      {
        name: "canonical base64url with a stray = pad",
        why: "unpadded is the only form; a padded one is a second spelling of one link",
        input: `#pay=${Buffer.from(JSON.stringify(wpr), "utf8").toString("base64url")}=`,
      },
      {
        name: "percent-encoded payload",
        why: "base64url has no character a fragment escapes, so a percent sequence is a re-encoder fingerprint, never a user link",
        input: `#pay=${encodeURIComponent(Buffer.from(JSON.stringify(wpr), "utf8").toString("base64"))}`,
      },
      {
        name: "a length no byte string can encode to",
        why: "86 % 4 == 2 is fine; a length of 1 mod 4 is not the encoding of anything",
        input: `#pay=${Buffer.from(JSON.stringify(wpr), "utf8").toString("base64url").slice(0, -1)}`,
      },
    ],
  },

  "ledger.json": {
    _doc:
      "The balance fold, as arithmetic anyone can check. `me` is the payer; every amount below is in the fixture currency. settled folds receipts only (credit when po.to === me, debit when po.from === me), held sums OUTGOING pending orders, available = settled - held. The expected numbers are written out by hand, not read back from a run.",
    me: payerPub,
    cur: CUR,
    steps: [
      { op: "start", expect: null, nextSeq: 1, why: "a fresh ledger has no currencies and no history" },
      { op: "addOrder", order: "out_a", amt: 100, seq: 1, expect: { settled: 0, held: 100, available: -100 }, nextSeq: 2, why: "signing an order commits nothing and settles nothing" },
      { op: "addOrder", order: "out_b", amt: 250, seq: 2, expect: { settled: 0, held: 350, available: -350 }, nextSeq: 3, why: "100 + 250 held" },
      { op: "addOrder", order: "out_a", amt: 100, seq: 1, expect: { settled: 0, held: 350, available: -350 }, nextSeq: 3, expectResult: false, why: "the same order twice is one order" },
      { op: "applyReceipt", order: "out_a", expect: { settled: -100, held: 250, available: -350 }, nextSeq: 3, why: "the debit lands and the hold is released: 0-100 settled, 350-100 held" },
      { op: "applyReceipt", order: "out_a", expect: { settled: -100, held: 250, available: -350 }, nextSeq: 3, expectResult: false, why: "idempotent — folding a receipt twice would double a debit" },
      { op: "applyDecline", order: "out_b", expect: { settled: -100, held: 0, available: -100 }, nextSeq: 3, why: "a decline releases the hold and moves nothing" },
      { op: "applyReceipt", order: "in_c", amt: 500, seq: 1, expect: { settled: 400, held: 0, available: 400 }, nextSeq: 3, why: "an incoming receipt credits: -100 + 500 = 400. Its seq is the PAYEE's, so nextSeq is untouched" },
      { op: "reload", expect: { settled: 400, held: 0, available: 400 }, nextSeq: 3, why: "a fresh LedgerStore on the same key folds to the same numbers" },
    ],
    issuance_steps: {
      _doc:
        "The issuance fold, as arithmetic anyone can check. `me` is the payer again; a wri credits `to` and NEVER debits anyone in this ledger — a burn has no wri at all. The mints are named rows the test signs with the fixed bank seed; each carries its own `h`, and the re-issue row reuses mint_a's `h` with a fresh ts, which is the same fact said twice.",
      mints: {
        mint_a: { seq: 1, amt: 500, h: "mint-entry-a", to: "me" },
        mint_b: { seq: 2, amt: 250, h: "mint-entry-b", to: "me" },
        mint_other: { seq: 3, amt: 999, h: "mint-entry-c", to: "payee" },
      },
      steps: [
        { op: "applyIssuance", mint: "mint_a", expect: { settled: 500, held: 0, available: 500 }, nextSeq: 1, expectResult: true, why: "a mint credits with no order and no debit anywhere" },
        { op: "applyIssuance", mint: "mint_a", reissueTsDelta: 5000, expect: { settled: 500, held: 0, available: 500 }, nextSeq: 1, expectResult: false, why: "a re-issued receipt with a fresh ts is the same mint — issuanceKey is blind to ts" },
        { op: "applyIssuance", mint: "mint_other", expect: { settled: 500, held: 0, available: 500 }, nextSeq: 1, expectResult: false, why: "a mint to someone else is rejected outright: a wri has exactly one beneficiary" },
        { op: "addOrder", order: "out_a", amt: 100, seq: 1, expect: { settled: 500, held: 100, available: 400 }, nextSeq: 2, expectResult: true, why: "minted money can then be committed by an ordinary order" },
        { op: "applyReceipt", order: "out_a", expect: { settled: 400, held: 0, available: 400 }, nextSeq: 2, expectResult: true, why: "and settled: 500 - 100" },
        { op: "applyIssuance", mint: "mint_b", expect: { settled: 650, held: 0, available: 650 }, nextSeq: 2, expectResult: true, why: "a second mint on a different entry hash folds: 400 + 250. The bank's issuance seq never touches the payer's order seq" },
        { op: "reload", expect: { settled: 650, held: 0, available: 650 }, nextSeq: 2, why: "a fresh LedgerStore on the same key folds to the same numbers" },
      ],
    },
  },
};

/**
 * JSON.stringify escapes U+0000..U+001F and stops there, so a DEL or a C1
 * character in a fixture row lands in the file as a raw byte — and these
 * fixtures exist precisely to say "this control character must be rejected".
 * Escaping the rest keeps every committed file printable (the source-hygiene
 * lint enforces it) while parsing back to exactly the same string.
 */
const printableJson = (v) =>
  JSON.stringify(v, null, 2).replace(/[\u007F-\u009F]/g, (c) =>
    "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));

for (const [name, data] of Object.entries(files)) {
  await writeFile(new URL(`./${name}`, import.meta.url), printableJson(data) + "\n");
  console.log(`wrote ${name}`);
}
console.log("done — fixtures derived independently of dist/");

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/wallet-kit.git