banca / client / test / protocol.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
// The op union — BANK/1's and BANK/2's: key order, and the validators.
//
// Two claims are under test and they are independent of each other.
//
//   KEY ORDER IS THE WIRE. Every op is `JSON.stringify`'d before the rooms core
//   seals it, so its key order is part of the bytes a peer verifies. The
//   expected orders come from test/vectors/fold.json, written by
//   independent.mjs's own hand-written builders — so lib/protocol.cljs and a
//   second implementation have to agree with each other rather than with
//   themselves.
//
//   A VALIDATOR IS THE CONTRACT. Each reject row below is a valid op plus ONE
//   change, and the accompanying probe asserts the unmutated op validates — a
//   gate that rejected everything would pass every reject row and prove nothing.

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 { CUR, HASHLOCK_PRE, SEED_PAYEE, SEED_PAYER, makeLock, makeRelease } from "./vectors/independent.mjs";

const V = JSON.parse(readFileSync(new URL("./vectors/fold.json", import.meta.url), "utf8"));
const KO = V.op_key_order;
const S = V.op_samples;
const A = V.actors;

const long = (n) => "x".repeat(n);
const ctl = (cp) => "a" + String.fromCodePoint(cp) + "b";

test("APP-SALT is banca's, and it is the fork", () => {
  // The salt is what makes this app's rooms unreachable from chat's, whatever
  // the op shapes are.
  assert.equal(L.APP_SALT, "banca.ardegazu.ro/v1");
  assert.equal(L.NS, "banca-ardegazu-ro-v1");
});

/** BANK/2 material: a signed lock, a release, and the fixture preimage. */
const ESCROW = {
  lk: makeLock({ seed: SEED_PAYER, idN: 1, cur: CUR, amt: 100, seq: 1, to: A.payee }),
  rel: makeRelease({ seed: SEED_PAYEE, cur: CUR, lh: "h09" }),
  pre: HASHLOCK_PRE,
};

test("every builder emits its documented key order", () => {
  assert.deepEqual(Object.keys(L.mkCharter("n", "LEI", "L", 2, "open")), KO.charter);
  assert.deepEqual(Object.keys(L.mkOpen("n")), KO.open);
  assert.deepEqual(Object.keys(L.mkAcct(A.payer, "approve")), KO.acct);
  assert.deepEqual(Object.keys(L.mkMint(1, A.payer, 1)), KO.mint);
  assert.deepEqual(Object.keys(L.mkMint(1, A.payer, 1, "m")), KO.mint_with_memo);
  assert.deepEqual(Object.keys(L.mkBurn(1, 1)), KO.burn);
  assert.deepEqual(Object.keys(L.mkPay(S.pay.po)), KO.pay);
  assert.deepEqual(Object.keys(L.mkAck("h09", S.ack.bsig)), KO.ack);
  assert.deepEqual(Object.keys(L.mkRate(1, 2)), KO.rate);
  assert.deepEqual(Object.keys(L.mkReq(A.payer, 1)), KO.req);
  assert.deepEqual(Object.keys(L.mkReq(A.payer, 1, "m")), KO.req_with_memo);
});

test("`t` is always first and `ts` always second", () => {
  // Not decoration: a projector reads `t` to dispatch and every op in this suite
  // leads with it, so a human reading a hex dump of a sealed entry can tell what
  // it is from the first twenty bytes.
  for (const [name, order] of Object.entries(KO)) {
    if (name === "_doc") continue;
    assert.equal(order[0], "t", name);
    assert.equal(order[1], "ts", name);
  }
});

test("an absent memo is an ABSENT KEY, never an empty string or a null", () => {
  // Exact key sets mean the two are different ops. A builder that wrote
  // `memo: undefined` would produce a five-key object that JSON.stringify turns
  // into a four-key one — the classic way a validator and a wire disagree.
  assert.equal("memo" in L.mkMint(1, A.payer, 1), false);
  assert.equal("memo" in L.mkMint(1, A.payer, 1, ""), false, "an empty memo is no memo");
  assert.equal("memo" in L.mkReq(A.payer, 1), false);
  assert.ok("memo" in L.mkMint(1, A.payer, 1, "m"));
});

test("every sample op validates, through validOp and through its own validator", () => {
  // The probe the reject tables owe. Run FIRST in spirit: if this fails, every
  // rejection below is meaningless.
  const pairs = [
    ["charter", L.validCharter], ["open", L.validOpen], ["acct", L.validAcct],
    ["mint", L.validMint], ["mint_with_memo", L.validMint], ["burn", L.validBurn],
    ["pay", L.validPay], ["ack", L.validAck], ["rate", L.validRate],
    ["req", L.validReq], ["req_with_memo", L.validReq],
  ];
  for (const [name, fn] of pairs) {
    assert.equal(fn(S[name]), true, `${name} must validate through its own validator`);
    assert.equal(L.validOp(S[name]), true, `${name} must validate through validOp`);
  }
});

/** Each row: a sample op name, a mutation, and why it must not validate. */
const REJECTS = [
  // -- shared shape --------------------------------------------------------
  ["charter", (o) => ({ ...o, extra: 1 }), "an eighth key — v1 has NO extension slot"],
  ["charter", (o) => { const c = { ...o }; delete c.sym; return c; }, "a missing key"],
  ["charter", (o) => ({ ...o, ts: -1 }), "a negative ts"],
  ["charter", (o) => ({ ...o, ts: 1.5 }), "a non-integer ts"],
  ["charter", (o) => ({ ...o, t: "chart" }), "the wrong `t`"],
  // -- charter -------------------------------------------------------------
  ["charter", (o) => ({ ...o, code: "GAZ" }), "the reserved neutral code, refused for EVERY banker"],
  ["charter", (o) => ({ ...o, code: "lei" }), "a lowercase code"],
  ["charter", (o) => ({ ...o, code: "AB" }), "a two-letter code"],
  ["charter", (o) => ({ ...o, code: "ABCDEFGHI" }), "a nine-letter code"],
  ["charter", (o) => ({ ...o, join: "maybe" }), "a join policy that is neither open nor approve"],
  ["charter", (o) => ({ ...o, dec: 9 }), "a minor-unit exponent above 8"],
  ["charter", (o) => ({ ...o, dec: -1 }), "a negative exponent"],
  ["charter", (o) => ({ ...o, dec: 2.5 }), "a fractional exponent"],
  ["charter", (o) => ({ ...o, name: long(41) }), "a name past MAX_NAME"],
  ["charter", (o) => ({ ...o, sym: long(9) }), "a symbol past MAX_SYM"],
  ["charter", (o) => ({ ...o, sym: ctl(8232) }), "a U+2028 line separator in the symbol"],
  // -- open ----------------------------------------------------------------
  ["open", (o) => ({ ...o, name: long(41) }), "a name past MAX_NAME"],
  ["open", (o) => ({ ...o, name: 7 }), "a name that is not a string"],
  ["open", (o) => ({ ...o, to: A.payer }), "a fourth key"],
  // -- acct ----------------------------------------------------------------
  ["acct", (o) => ({ ...o, op: "deny" }), "an action outside {approve, close}"],
  ["acct", (o) => ({ ...o, to: A.payer.slice(0, 42) }), "a `to` that is not a canonical 43-character key"],
  ["acct", (o) => ({ ...o, to: "b".repeat(43) }), "a `to` whose last character sets the spare padding bits"],
  // -- mint ----------------------------------------------------------------
  ["mint", (o) => ({ ...o, amt: 0 }), "amt below AMT_MIN"],
  ["mint", (o) => ({ ...o, amt: 2 ** 50 + 1 }), "amt above AMT_MAX"],
  ["mint", (o) => ({ ...o, seq: 0 }), "a zero slot — slots are 1-based"],
  ["mint", (o) => ({ ...o, seq: 1.5 }), "a fractional slot"],
  ["mint", (o) => ({ ...o, memo: long(141) }), "a memo past MEMO_MAX"],
  ["mint", (o) => ({ ...o, other: 1 }), "a sixth key that is NOT memo — the count alone must not buy entry"],
  ["mint_with_memo", (o) => ({ ...o, memo: ctl(0) }), "a NUL in the memo"],
  // -- burn ----------------------------------------------------------------
  ["burn", (o) => ({ ...o, to: A.payer }), "a `to` — burn debits the AUTHOR and names nobody"],
  ["burn", (o) => ({ ...o, amt: -1 }), "a negative amount"],
  // -- pay -----------------------------------------------------------------
  ["pay", (o) => ({ ...o, po: { ...o.po, amt: 999 } }), "an order whose amount no longer matches its shape... (still shaped, caught at verify)"],
  ["pay", (o) => ({ ...o, po: { ...o.po, extra: 1 } }), "a fourteenth key inside the order"],
  ["pay", (o) => ({ ...o, po: null }), "no order at all"],
  ["pay", (o) => ({ ...o, po: { ...o.po, cur: "~.GAZ" } }), "an order denominated in the neutral unit"],
  // -- ack -----------------------------------------------------------------
  ["ack", (o) => ({ ...o, h: "" }), "an empty entry reference"],
  ["ack", (o) => ({ ...o, h: long(129) }), "an entry reference past MAX_REF"],
  ["ack", (o) => ({ ...o, bsig: S.ack.bsig.slice(0, 85) + "R" }), "a non-canonical base64url signature"],
  ["ack", (o) => ({ ...o, bsig: S.ack.bsig.slice(0, 85) }), "a truncated signature"],
  // -- rate ----------------------------------------------------------------
  ["rate", (o) => ({ ...o, den: 0 }), "a zero denominator"],
  ["rate", (o) => ({ ...o, num: -3 }), "a negative numerator"],
  ["rate", (o) => ({ ...o, num: 1.5 }), "a fractional numerator"],
  // -- req -----------------------------------------------------------------
  ["req", (o) => ({ ...o, amt: 0 }), "a zero request"],
  ["req", (o) => ({ ...o, to: 7 }), "a `to` that is not a key"],
];

test("every reject row fails validOp", () => {
  const survivors = [];
  for (const [name, mutate, why] of REJECTS) {
    const bad = mutate(S[name]);
    // `pay`'s amount row is structural-only by design: a tampered amount still
    // SHAPES, and dies at Ed25519 in the fold. It is listed to make that
    // boundary explicit and is checked in fold.test.mjs instead.
    if (name === "pay" && why.startsWith("an order whose amount")) continue;
    if (L.validOp(bad) !== false) survivors.push(`${name}: ${why}`);
  }
  assert.deepEqual(survivors, []);
  assert.ok(REJECTS.length >= 40, "the reject table must not shrink silently");
});

test("a tampered order still SHAPES — validators are structure, signatures are the fold's job", () => {
  // This is the boundary between the two authorization mechanisms and it must
  // be visible: `validPay` answers "is there an order-shaped thing here", and
  // nothing more. Whether the payer actually signed it is Ed25519, therefore
  // async, therefore the fold's.
  const tampered = { ...S.pay, po: { ...S.pay.po, amt: 999 } };
  assert.equal(L.validPay(tampered), true);
  assert.equal(L.validOp(tampered), true);
});

test("an op from a future BANK/1 is dropped, not guessed at", () => {
  assert.equal(L.validOp({ t: "freeze", ts: 1, to: A.payer }), false);
  assert.equal(L.opType("freeze"), false);
  assert.equal(L.validOp(null), false);
  assert.equal(L.validOp([]), false);
  assert.equal(L.validOp("charter"), false);
  assert.equal(L.validOp(undefined), false);
});

test("the banker-only set is exactly the six issuance/authority ops", () => {
  for (const t of ["charter", "acct", "mint", "burn", "ack", "rate"]) {
    assert.equal(L.bankerOnly(t), true, t);
  }
  // The load-bearing exception: `pay` is authorized by the payer's signature
  // inside the order, so ANY member may relay one. That is what makes an
  // offline payment ride the mailbox and settle wherever it lands.
  for (const t of ["pay", "open", "req"]) assert.equal(L.bankerOnly(t), false, t);
});

test("the slot-claiming set is exactly the three money-moving ops", () => {
  for (const t of ["pay", "mint", "burn"]) assert.equal(L.slotType(t), true, t);
  for (const t of ["charter", "open", "acct", "ack", "rate", "req"]) {
    assert.equal(L.slotType(t), false, t);
  }
});

// ---- BANK/2: the escrow ops -------------------------------------------------

test("the escrow builders emit their documented key order", () => {
  const lk = { v: 1, t: "wlk" }; // shape is the validator's business, not the builder's
  assert.deepEqual(Object.keys(L.mkLock(lk)), ["t", "ts", "lk"]);
  assert.deepEqual(Object.keys(L.mkClaim("h09", "p")), ["t", "ts", "h", "pre"]);
  assert.deepEqual(Object.keys(L.mkUnlock("h09")), ["t", "ts", "h"]);
  assert.deepEqual(Object.keys(L.mkUnlock("h09", { v: 1 })), ["t", "ts", "h", "rel"]);
});

test("an escrow op is refused under BANK/1 and accepted under BANK/2", () => {
  // THE COMPATIBILITY GATE. A v1 client drops an unknown `t` silently, so a v2
  // fold that admitted an escrow op into a v1 bank would partition the two —
  // and the partition would arrive from the side that knew better.
  const lock = L.mkLock(ESCROW.lk);
  assert.equal(L.validLock(lock), true, "the op itself is well formed");
  assert.equal(L.validOp(lock, 2), true, "…and BANK/2 folds it");
  assert.equal(L.validOp(lock, 1), false, "…and BANK/1 must not");
  assert.equal(L.validOp(lock), false, "an absent version fails CLOSED, at v1");
  for (const v of [null, undefined, "2", NaN, {}]) {
    assert.equal(L.validOp(lock, v), false, `a garbled version reads as v1: ${String(v)}`);
  }
});

test("every BANK/1 op stays valid under BANK/2", () => {
  // The gate must be one-directional. A version that refused an older op would
  // strand every bank it was meant to protect.
  const ops = [
    L.mkCharter("n", "LEI", "L", 2, "open"),
    L.mkOpen("n"),
    L.mkAcct(A.payer, "approve"),
    L.mkMint(1, A.payer, 1),
    L.mkBurn(1, 1),
    L.mkPay(S.pay.po),
    L.mkAck("h09", S.ack.bsig),
    L.mkRate(1, 2),
  ];
  for (const op of ops) {
    assert.equal(L.validOp(op, 1), true, `${op.t} under v1`);
    assert.equal(L.validOp(op, 2), true, `${op.t} under v2`);
    assert.equal(L.opVersion(op.t), 1, `${op.t} is a BANK/1 op`);
  }
  for (const t of ["lock", "claim", "unlock"]) assert.equal(L.opVersion(t), 2);
});

test("a four-key unlock must be the `rel` one, not a stray field wearing its count", () => {
  // The rule mkMint's memo already follows. An op union with an exact key COUNT
  // and no key CHECK would let a peer staple anything on.
  const rel = ESCROW.rel;
  assert.equal(L.validUnlock({ t: "unlock", ts: 0, h: "h09", rel }), true);
  assert.equal(L.validUnlock({ t: "unlock", ts: 0, h: "h09", evil: 1 }), false);
  assert.equal(L.validUnlock({ t: "unlock", ts: 0, h: "h09", rel: { v: 1 } }), false);
  assert.equal(L.validUnlock({ t: "unlock", ts: 0, h: "h09" }), true);
  assert.equal(L.validUnlock({ t: "unlock", ts: 0 }), false, "h is required");
});

test("a claim's preimage must be a canonical digest, and its target a real ref", () => {
  const pre = ESCROW.pre;
  assert.equal(L.validClaim({ t: "claim", ts: 0, h: "h09", pre }), true);
  assert.equal(L.validClaim({ t: "claim", ts: 0, h: "h09", pre: pre.slice(0, -1) }), false);
  assert.equal(L.validClaim({ t: "claim", ts: 0, h: "h09", pre: "" }), false);
  assert.equal(L.validClaim({ t: "claim", ts: 0, h: "", pre }), false);
  assert.equal(L.validClaim({ t: "claim", ts: 0, h: "a\nb", pre }), false);
});

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