banca / client / test / receipts.test.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
// The double-signed portable receipt (`wrc`).
//
// banca does not invent a receipt: both halves already exist in the log — the
// payer's order inside a `pay` entry, and the banker's signature inside the
// `ack` that names it — so a receipt is a PROJECTION every member can compute
// for themselves. `wrc.seq` is the bank's own log reference and banca uses THE
// PAY ENTRY'S HASH, which is exactly what `ack.h` names.
//
// The honest asymmetry this file pins: a banker CAN append an ack whose `bsig`
// is well-shaped and simply wrong. The fold still treats that payment as final
// — finality is authorship, and a banker's act is attributable, not preventable
// — but the exported receipt does not verify for a third party. Both halves are
// asserted below, because a reader deserves to know which one they are relying
// on. See docs/PROTOCOL.md §8.

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import * as L from "../test-dist/testlib.js";
import {
  receiptPreimageByHand, issuancePreimageByHand, unsignedIssuanceByHand,
  canonIndependent, verifyOver,
} from "./vectors/independent.mjs";

const V = JSON.parse(readFileSync(new URL("./vectors/receipts.json", import.meta.url), "utf8"));
const F = JSON.parse(readFileSync(new URL("./vectors/fold.json", import.meta.url), "utf8"));
const A = F.actors;
const P = V.parts;
const W = V.wri;

const built = () => L.receiptOf(P.po, P.logRef, P.ts, P.bank, P.bsig);

test("the settlement preimage is byte-identical to the by-hand template", () => {
  assert.equal(L.settlementPreimage(V.wire), V.preimage);
  assert.equal(receiptPreimageByHand({ po: P.po, seq: P.logRef, ts: P.ts, bank: P.bank }), V.preimage);
  // …and to the domain prefix plus an independently re-implemented canon()
  assert.equal(`wpay-rcp|v1|${V.unsigned_canon}`, V.preimage);
  assert.equal(
    L.canon(L.unsignedSettlement(V.wire)),
    canonIndependent({ v: 1, t: "wrc", po: P.po, seq: P.logRef, ts: P.ts, bank: P.bank }),
  );
});

test("the preimage matches wallet-kit's OWN published wrc string", () => {
  // The cross-repo pin, and the one that earns this file. banca now CALLS
  // wallet-kit's settlement preimage rather than transcribing it, and this
  // asserts the result against a string that repo published — not against
  // anything banca or the by-hand template computed. It caught a wrong
  // transcription once (the first draft of the template dropped `po.sig` from
  // the preimage); what it catches now is a kit bump that moves the wire.
  const K = V.wallet_kit_wrc;
  assert.equal(L.settlementPreimage(K.wire), K.preimage);
  assert.ok(K.preimage.includes('"sig":"4xufyzCb'), "the SIGNED order is inside the preimage");
  // …and the unsigned order is not what goes in: dropping `sig` must change it
  const noSig = { ...K.wire.po };
  delete noSig.sig;
  assert.notEqual(L.settlementPreimage({ ...K.wire, po: noSig }), K.preimage);
});

test("`t` is INSIDE the preimage, so a decline can never be re-read as a receipt", () => {
  // wrc and wrj deliberately share the "wpay-rcp" domain: they are the same act
  // by the same signer, and a bank needs one signing path rather than two. What
  // keeps them apart is `t` sitting inside canon(), so the two preimages differ.
  const asDecline = { v: 1, t: "wrj", po: P.po, why: "insufficient", ts: P.ts, bank: P.bank, bsig: P.bsig };
  assert.ok(L.settlementPreimage(V.wire).startsWith("wpay-rcp|v1|"));
  assert.ok(L.settlementPreimage(asDecline).startsWith("wpay-rcp|v1|"));
  assert.notEqual(L.settlementPreimage(V.wire), L.settlementPreimage(asDecline));
  assert.ok(L.settlementPreimage(asDecline).includes('"t":"wrj"'));
});

test("receiptOf emits wrc's exact seven-key wire order", () => {
  const r = built();
  assert.deepEqual(Object.keys(r), V.key_order);
  assert.equal(JSON.stringify(r), V.wire_json);
});

test("the fixture receipt verifies — both signatures, independently", async () => {
  assert.ok(await L.verifySettlement(V.wire, A.bank));
  // and with @noble/curves rather than id-kit, so the two agree about Ed25519
  assert.equal(verifyOver(P.bank, P.bsig, V.preimage), true);
  assert.equal(
    verifyOver(P.po.from, P.po.sig, L.orderPreimage(P.po)),
    true,
    "the EMBEDDED order's own signature must hold too — otherwise a bank could launder a forgery",
  );
});

test("every reject row verifies to null", async () => {
  for (const r of V.rejects) {
    assert.equal(await L.verifySettlement(r.wire, A.bank), null, r.why);
  }
  assert.equal(V.rejects.length, 6);
});

test("the receipt gate is not vacuous — the unmutated fixture verifies", async () => {
  // Each reject row is the fixture plus one change; if verifySettlement rejected
  // everything, all six would pass and prove nothing.
  assert.ok(await L.verifySettlement(V.wire, A.bank));
});

test("verifySettlement REQUIRES the bank you were waiting for", async () => {
  // Without `expectedBankPub` a well-formed settlement by some OTHER bank —
  // over that bank's own paper, with that bank's own valid signature, every
  // internal check passing — is indistinguishable from the one you asked for.
  // That is the replay a wallet must not fold, and banca's arity used to be 1.
  const B2 = V.other_bank;
  assert.ok(await L.verifySettlement(B2.wire, A.bank2), "bank2's receipt is genuinely valid — for bank2");
  assert.equal(await L.verifySettlement(B2.wire, A.bank), null, "…and must not pass as bank1's");
  // …and a caller who names no bank gets nil rather than the permissive check.
  // wallet-kit treats an omitted expectedBankPub as "any bank"; banca refuses
  // to have that mode at all, so a forgotten argument fails CLOSED.
  for (const nobody of [undefined, null, "", "nope", A.bank.slice(0, 42), 7, {}]) {
    assert.equal(await L.verifySettlement(B2.wire, nobody), null, String(nobody));
    assert.equal(await L.verifySettlement(V.wire, nobody), null, `${String(nobody)} on the fixture too`);
  }
  assert.equal(L.verifySettlement.length, 2, "the parameter is required, not optional");
});

test("`wrc.seq` is a STRING and `wpo.seq` is a NUMBER, and they mean different things", () => {
  // The name collision is on the wire in both artifacts. `wpo.seq` is the
  // payer's slot in the bank's fold — the double-spend defense. `wrc.seq` is the
  // bank's opaque log reference and is excluded from dedup keys entirely. A
  // second implementation that conflates them will double-spend or double-fold.
  assert.equal(typeof V.wire.seq, "string");
  assert.equal(typeof V.wire.po.seq, "number");
  assert.equal(V.wire.seq, "h09");
  assert.equal(V.wire.po.seq, 1);
});

// ---- the fold's own projection ----------------------------------------------

const scenario = (name) => F.scenarios.find((s) => s.name === name);

async function foldOf(entries) {
  const f = new L.BankFold(A.bank, null);
  for (const e of structuredClone(entries)) f.ingest(e);
  await f.awaitVerified();
  return f;
}

test("receiptFor rebuilds a verifiable receipt for a FINAL payment", async () => {
  const f = await foldOf(scenario("acked-is-final").entries);
  const r = f.receiptFor("h09");
  assert.ok(r, "an acked payment must have a receipt");
  assert.deepEqual(Object.keys(r), V.key_order);
  assert.equal(r.seq, "h09", "the log ref is the PAY ENTRY's hash — what the ack names in `h`");
  assert.equal(r.bank, A.bank);
  assert.ok(await L.verifySettlement(r, A.bank), "and it must verify for a third party");
});

test("receiptFor returns null for anything that is not a settled, acked payment", async () => {
  const provisional = await foldOf(scenario("settled-provisionally").entries);
  assert.equal(provisional.receiptFor("h09"), null, "a provisional settlement has no receipt to show");

  const overdraft = await foldOf(scenario("overdraft-consumes-the-slot").entries);
  assert.equal(overdraft.receiptFor("h09"), null, "a failed payment has none either");

  // THE CASE THE `final` GUARD IS ACTUALLY FOR, and the only one: a payment that
  // is FAILED *and* ACKED. The banker acked both contenders for one slot, so an
  // ack naming the loser exists and every other ingredient of a receipt is
  // present — the signed order, the banker's `bsig`, the log ref. Only the
  // status says the money never moved. Without that guard banca would hand a
  // member an exportable "receipt" for a payment that failed.
  const both = await foldOf(scenario("an-ack-on-both-contenders").entries);
  const loser = both.snapshot().payments.find((p) => p.h === "h10");
  assert.equal(loser.status, "failed");
  assert.equal(loser.acked, true, "the ingredients really are all there");
  assert.equal(both.receiptFor("h10"), null, "a failed payment has no receipt, acked or not");
  assert.ok(both.receiptFor("h09"), "…while the winner of the same slot does");

  const acked = await foldOf(scenario("acked-is-final").entries);
  assert.equal(acked.receiptFor("nope"), null);
  assert.equal(acked.receiptFor(null), null);
});

// ---- the issuance receipt (`wri`) -------------------------------------------

test("the issuance preimage is byte-identical to the by-hand template", () => {
  assert.equal(L.issuancePreimage(W.wire), W.preimage);
  assert.equal(issuancePreimageByHand(W.parts), W.preimage);
  // …and to the domain prefix plus an independently re-implemented canon()
  assert.equal(`wpay-iss|v1|${W.unsigned_canon}`, W.preimage);
  assert.equal(L.canon(L.unsignedIssuance(W.wire)), canonIndependent(unsignedIssuanceByHand(W.parts)));
  assert.equal(L.DOM_PAY_ISS, "wpay-iss");
});

test("the issuance preimage matches wallet-kit's OWN published wri string", () => {
  // The cross-repo pin: banca CALLS the kit's issuance preimage, and this
  // asserts the result against a string that repo published — not against
  // anything banca or the by-hand template computed. A kit bump that moved the
  // wire goes red here rather than silently in a bank.
  const K = W.wallet_kit_wri;
  assert.equal(L.issuancePreimage(K.wire), K.preimage);
  assert.ok(K.preimage.startsWith("wpay-iss|v1|"), "a different domain than the settlement's");
  assert.notEqual(L.DOM_PAY_ISS, L.DOM_PAY_RCP, "…so a settlement signature can never dress up as a wri");
});

test("issuanceOf emits wri's exact ten-key wire order", () => {
  const r = L.issuanceOf(W.parts.cur, W.parts.seq, W.parts.to, W.parts.amt,
                         W.parts.h, W.parts.ts, W.parts.bank, W.bsig);
  assert.deepEqual(Object.keys(r), W.key_order);
  assert.equal(JSON.stringify(r), W.wire_json);
});

test("the fixture wri verifies — against the kit and against @noble/curves", async () => {
  assert.ok(await L.verifyIssuance(W.wire, A.bank));
  assert.equal(verifyOver(W.parts.bank, W.wire.bsig, W.preimage), true);
});

test("every wri reject row verifies to null — and the gate is not vacuous", async () => {
  for (const r of W.rejects) {
    assert.equal(await L.verifyIssuance(r.wire, A.bank), null, r.why);
  }
  assert.equal(W.rejects.length, 7);
  // each row is the fixture plus one change; if verifyIssuance rejected
  // everything, all seven would pass and prove nothing
  assert.ok(await L.verifyIssuance(W.wire, A.bank));
});

test("verifyIssuance REQUIRES the bank you were waiting for", async () => {
  // Same replay, issuance flavour: bank2's receipt for bank2's own mint is
  // genuinely valid — for bank2 — and must not pass as bank1's. And a caller
  // who names no bank gets null rather than the kit's permissive "any bank".
  const B2 = W.other_bank;
  assert.ok(await L.verifyIssuance(B2.wire, A.bank2), "bank2's wri is genuinely valid — for bank2");
  assert.equal(await L.verifyIssuance(B2.wire, A.bank), null, "…and must not pass as bank1's");
  for (const nobody of [undefined, null, "", "nope", A.bank.slice(0, 42), 7, {}]) {
    assert.equal(await L.verifyIssuance(B2.wire, nobody), null, String(nobody));
    assert.equal(await L.verifyIssuance(W.wire, nobody), null, `${String(nobody)} on the fixture too`);
  }
  assert.equal(L.verifyIssuance.length, 2, "the parameter is required, not optional");
});

test("issuanceKey is blind to everything but (bank, h) — the fact being made final", async () => {
  // A receipt re-issued with a fresh ts is the same fact said twice; a receipt
  // with a different AMOUNT under the same hash is a signed contradiction, and
  // the key colliding is what makes it one a fold can catch rather than a
  // second credit. The flip side is banca's to keep: never emit two wri with
  // different content for one entry hash — `view/ackable` excludes acked
  // entries, so the banker's screen never builds a second one.
  const k = await L.issuanceKey(W.wire);
  assert.equal(await L.issuanceKey({ ...W.wire, ts: W.wire.ts + 5000 }), k);
  assert.equal(await L.issuanceKey({ ...W.wire, amt: 999 }), k);
  assert.equal(await L.issuanceKey({ ...W.wire, seq: 9 }), k);
  assert.notEqual(await L.issuanceKey({ ...W.wire, h: "h09" }), k);
  assert.notEqual(await L.issuanceKey({ ...W.wire, bank: A.bank2 }), k);
});

test("receiptFor rebuilds a verifiable wri for a FINAL mint", async () => {
  const f = await foldOf(scenario("an-acked-mint-is-final-with-a-wri").entries);
  const mint = f.snapshot().payments.find((p) => p.h === "h08");
  assert.equal(mint.kind, "mint");
  assert.equal(mint.status, "final");
  const r = f.receiptFor("h08");
  assert.ok(r, "a pinned mint must have a receipt");
  assert.equal(r.t, "wri");
  assert.deepEqual(Object.keys(r), W.key_order);
  assert.equal(r.h, "h08", "the entry hash — what the ack names in `h`");
  assert.equal(r.cur, `${A.bank}.LEI`, "the charter's currency");
  assert.equal(r.bank, A.bank);
  assert.ok(await L.verifyIssuance(r, A.bank), "and it must verify for a third party");
  // …and the ack moved no money, exactly as on a payment
  const plain = await foldOf(scenario("prelude").entries);
  assert.deepEqual(
    f.snapshot().accounts.map((a) => a.balExact),
    plain.snapshot().accounts.map((a) => a.balExact),
  );
});

test("receiptFor answers null for a settled or failed mint — and for a BURN, always", async () => {
  const settled = await foldOf(scenario("prelude").entries);
  assert.equal(settled.snapshot().payments.find((p) => p.h === "h08").status, "settled");
  assert.equal(settled.receiptFor("h08"), null, "an unpinned mint has no receipt to show");

  const displaced = await foldOf(scenario("withheld-backdated-mint-displaces-an-issuance").entries);
  assert.equal(displaced.snapshot().payments.find((p) => p.h === "h08").status, "failed");
  assert.equal(displaced.receiptFor("h08"), null, "a displaced mint has none either");

  // THE MINT-ONLY RULE, exercised where it could actually fail: a burn forced
  // to `final` by an ack (the fold's pre-pass is blind to kind — a log some
  // other implementation wrote can contain this) still gets no receipt. Not
  // because anything is missing — seq, amt and the ack are all there — but
  // because a burn names no recipient, so a burn-wri would be signed content
  // with no natural meaning. wallet-kit's buildIssuanceReceipt would sign one
  // without complaint; this null is where banca keeps the rule.
  const sc = scenario("burn-debits-the-banker-only");
  const acked = [...sc.entries,
    { hash: "h20", from: A.bank, clock: 20, op: { t: "ack", ts: 1735689660000, h: "h10", bsig: W.bsig } }];
  const f = await foldOf(acked);
  const burn = f.snapshot().payments.find((p) => p.h === "h10");
  assert.equal(burn.kind, "burn");
  assert.equal(burn.status, "final", "the fold DID pin it — finality is authorship");
  assert.equal(f.receiptFor("h10"), null, "…and there is still no receipt, by design");
});

test("HONEST LIMIT, issuance flavour: a wrong-content wri bsig still finalises, and the receipt fails", async () => {
  // The banker acks the mint with a bsig over a DIFFERENT amount — well-formed,
  // wrong, and exactly the signed contradiction issuanceKey's blindness exists
  // to expose. The fold cannot tell and should not pretend to; what a third
  // party gets is a rebuilt receipt that does not verify, and the export path
  // says so instead of handing it over.
  const base = scenario("an-acked-mint-is-final-with-a-wri").entries;
  const wrongBsig = W.rejects[0].wire.bsig;
  const wrong = base.map((e) =>
    e.op.t === "ack" ? { ...e, op: { ...e.op, bsig: wrongBsig } } : e);
  const f = await foldOf(wrong);
  const mint = f.snapshot().payments.find((p) => p.h === "h08");
  assert.equal(mint.status, "final", "the mint IS final — the banker said so, attributably");
  const r = f.receiptFor("h08");
  assert.ok(r, "and a receipt is still rebuilt…");
  assert.equal(await L.verifyIssuance(r, A.bank), null, "…but it does not verify, which is the truth");
});

test("HONEST LIMIT: a garbage ack still finalises, and the receipt still fails", async () => {
  // The banker signs the ack for a DIFFERENT log ref — well-formed, wrong. The
  // fold cannot tell, and should not pretend to: `ack` is banker-only by
  // authorship and finality is the banker's word. What a third party gets is a
  // receipt that does not verify, and that is the truthful outcome.
  const base = scenario("acked-is-final").entries;
  const wrong = base.map((e) =>
    e.op.t === "ack" ? { ...e, op: { ...e.op, bsig: V.rejects[0].wire.bsig } } : e);
  const f = await foldOf(wrong);
  const p = f.snapshot().payments.find((x) => x.h === "h09");
  assert.equal(p.status, "final", "the payment IS final — the banker said so, attributably");
  assert.equal(p.acked, true);
  const r = f.receiptFor("h09");
  assert.ok(r, "and a receipt is still rebuilt…");
  assert.equal(await L.verifySettlement(r, A.bank), null, "…but it does not verify, which is the truth");
  // the money is identical either way: an ack never moves anything
  assert.deepEqual(
    f.snapshot().accounts.map((a) => a.balExact),
    (await foldOf(base)).snapshot().accounts.map((a) => a.balExact),
  );
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/banca.git