@kirkelabs/walletless-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/audit.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * audit.js — a tamper-evident audit trail.
3
+ *
4
+ * Two layers, both built on @kirkelabs/open-agent-access-core:
5
+ * 1. An append-only HASH-CHAINED event log (each event commits to the previous
6
+ * one's hash) — editing or removing any event breaks the chain. This is
7
+ * core's `appendAccessEvent` / `verifyAccessEventTrail`.
8
+ * 2. A MERKLE ROOT over the events — a single 32-byte commitment you can anchor
9
+ * on-chain cheaply and use for inclusion proofs.
10
+ *
11
+ * The Merkle construction is the only new cryptographic primitive in this package,
12
+ * so it is built defensively (see `merkleRoot`).
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import algosdk from 'algosdk';
17
+ import {
18
+ appendAccessEvent,
19
+ verifyAccessEventTrail,
20
+ hashAccessEvents,
21
+ canonicalizeJson,
22
+ } from '@kirkelabs/open-agent-access-core';
23
+
24
+ const MAX_LEAVES = 1_000_000; // DoS bound
25
+ // RFC 6962 domain-separation tags: leaves and internal nodes are hashed in
26
+ // DIFFERENT domains so a leaf can never be reinterpreted as an internal node
27
+ // (which would otherwise enable second-preimage forgery of the root).
28
+ const LEAF = Buffer.from([0x00]);
29
+ const NODE = Buffer.from([0x01]);
30
+
31
+ function sha256(...bufs) {
32
+ const h = createHash('sha256');
33
+ for (const b of bufs) h.update(b);
34
+ return h.digest();
35
+ }
36
+ /** Injective leaf encoding via canonical JSON, then domain-tagged hash. */
37
+ function leafHash(item) {
38
+ return sha256(LEAF, Buffer.from(canonicalizeJson(item), 'utf8'));
39
+ }
40
+
41
+ /**
42
+ * Deterministic Merkle root (hex) over an ordered list of items.
43
+ *
44
+ * Defensive choices:
45
+ * - Domain separation: leaf = H(0x00‖data), node = H(0x01‖left‖right).
46
+ * - A lone (odd) node is PROMOTED to the next level unchanged — it is NOT
47
+ * duplicated (duplicating the last node is the Bitcoin CVE-2012-2459
48
+ * ambiguity, where two different trees share a root).
49
+ * - Empty list → H("") (RFC 6962 empty-tree root). Single item → its leaf hash.
50
+ */
51
+ export function merkleRoot(items) {
52
+ if (!Array.isArray(items)) throw new Error('merkleRoot: items must be an array');
53
+ if (items.length > MAX_LEAVES) throw new Error('merkleRoot: too many leaves');
54
+ if (items.length === 0) return sha256(Buffer.alloc(0)).toString('hex');
55
+ let level = items.map(leafHash);
56
+ while (level.length > 1) {
57
+ const next = [];
58
+ for (let i = 0; i < level.length; i += 2) {
59
+ next.push(i + 1 < level.length ? sha256(NODE, level[i], level[i + 1]) : level[i]);
60
+ }
61
+ level = next;
62
+ }
63
+ return level[0].toString('hex');
64
+ }
65
+
66
+ /**
67
+ * Inclusion proof for the item at `index`. Returns the sibling hashes (hex) and
68
+ * their side, which `verifyMerkleProof` replays to recompute the root.
69
+ */
70
+ export function merkleProof(items, index) {
71
+ if (!Array.isArray(items) || index < 0 || index >= items.length)
72
+ throw new Error('merkleProof: index out of range');
73
+ let level = items.map(leafHash);
74
+ let idx = index;
75
+ const path = [];
76
+ while (level.length > 1) {
77
+ const next = [];
78
+ for (let i = 0; i < level.length; i += 2) {
79
+ if (i + 1 < level.length) {
80
+ next.push(sha256(NODE, level[i], level[i + 1]));
81
+ if (i === idx) path.push({ side: 'right', hash: level[i + 1].toString('hex') });
82
+ else if (i + 1 === idx) path.push({ side: 'left', hash: level[i].toString('hex') });
83
+ } else {
84
+ next.push(level[i]); // promoted; no sibling recorded for the lone node
85
+ }
86
+ }
87
+ idx = Math.floor(idx / 2);
88
+ level = next;
89
+ }
90
+ return { index, leaf: leafHash(items[index]).toString('hex'), path };
91
+ }
92
+
93
+ /** Verify an inclusion proof against an expected root. */
94
+ export function verifyMerkleProof({ leaf, path, root }) {
95
+ try {
96
+ let acc = Buffer.from(String(leaf), 'hex');
97
+ for (const step of path || []) {
98
+ const sib = Buffer.from(String(step.hash), 'hex');
99
+ acc = step.side === 'left' ? sha256(NODE, sib, acc) : sha256(NODE, acc, sib);
100
+ }
101
+ return { ok: acc.toString('hex') === String(root) };
102
+ } catch (e) {
103
+ return { ok: false, reason: `verify_error:${e.message}` };
104
+ }
105
+ }
106
+
107
+ /** A fresh empty trail. */
108
+ export function createTrail() {
109
+ return { events: [] };
110
+ }
111
+
112
+ /**
113
+ * Append an event to the trail (hash-chained via core). Returns a NEW trail.
114
+ * Pass deterministic `eventId`/`timestamp` in `input` if you need reproducibility.
115
+ */
116
+ export function append(trail, input) {
117
+ const events = appendAccessEvent(trail?.events ?? [], input ?? {});
118
+ return { events };
119
+ }
120
+
121
+ /** sha256 commitment over the event hashes (cheap integrity digest). */
122
+ export function trailHash(trail) {
123
+ return hashAccessEvents(trail?.events ?? []);
124
+ }
125
+
126
+ /** Merkle root over the trail's events. */
127
+ export function trailRoot(trail) {
128
+ return merkleRoot(trail?.events ?? []);
129
+ }
130
+
131
+ /**
132
+ * Verify the trail end-to-end. Robust to malformed input (never throws). Reports
133
+ * whether the hash-chain is intact and returns the current Merkle root.
134
+ * @returns {{ok:boolean, count:number, errors:string[], root:string|null}}
135
+ */
136
+ export function verifyTrail(trail) {
137
+ try {
138
+ const events = Array.isArray(trail) ? trail : trail?.events;
139
+ if (!Array.isArray(events)) return { ok: false, count: 0, errors: ['not_a_trail'], root: null };
140
+ const res = verifyAccessEventTrail(events);
141
+ return {
142
+ ok: res.valid,
143
+ count: res.count,
144
+ errors: res.errors,
145
+ root: res.valid ? merkleRoot(events) : null,
146
+ };
147
+ } catch (e) {
148
+ return { ok: false, count: 0, errors: [`verify_error:${e.message}`], root: null };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Anchor a Merkle root on-chain as a compact, NON-PII checkpoint: a 0-amount
154
+ * self-payment whose note is `walletless-anchor:v1:<root>`. Network-bound (the
155
+ * transaction carries the node's genesis hash) and size-bounded (note ≤ 1 KB).
156
+ *
157
+ * @param {algosdk.Algodv2} algod
158
+ * @param {{address:string, signTxns:Function}} signer e.g. an oaa-agent-kit LocalOwnerSigner
159
+ * @param {string} root hex Merkle root
160
+ * @returns {Promise<{txid:string, confirmedRound:number, root:string}>}
161
+ */
162
+ export async function anchor(algod, signer, root) {
163
+ if (!/^[0-9a-f]{2,128}$/i.test(String(root)))
164
+ throw new Error('anchor: root must be a hex string');
165
+ const note = new TextEncoder().encode(`walletless-anchor:v1:${root}`);
166
+ if (note.length > 1024) throw new Error('anchor: note exceeds 1KB');
167
+ const sp = await algod.getTransactionParams().do();
168
+ const txn = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
169
+ sender: signer.address,
170
+ receiver: signer.address, // self-payment: moves nothing, just records the note
171
+ amount: 0,
172
+ note,
173
+ suggestedParams: sp,
174
+ });
175
+ const [signed] = await signer.signTxns([txn]);
176
+ const { txid } = await algod.sendRawTransaction(signed).do();
177
+ const res = await algosdk.waitForConfirmation(algod, txid, 10);
178
+ return {
179
+ txid,
180
+ confirmedRound: Number(res.confirmedRound ?? res['confirmed-round']),
181
+ root: String(root),
182
+ };
183
+ }
package/src/draw.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * draw.js — a verifiable, recomputable draw.
3
+ *
4
+ * The winner is a DETERMINISTIC function of (public seed, ordered entries), so
5
+ * anyone can recompute it from the published proof. Fairness is therefore exactly
6
+ * as strong as the seed — no more (see LEGAL.md / README):
7
+ * - A blockchain BLOCK HASH is manipulable: a block producer can withhold or
8
+ * grind a block to bias the outcome. Mitigate with `commitSeedSource` (announce
9
+ * the exact future round whose hash will be the seed BEFORE entries close, so
10
+ * the operator cannot pick a favourable one), and prefer a VRF / drand beacon
11
+ * for anything of value.
12
+ * - Randomness comes from a CSPRNG-style expansion of the seed (SHA-256 stream)
13
+ * with REJECTION SAMPLING (no modulo bias). There is no `Math.random` here.
14
+ *
15
+ * `runDraw` and `verifyDraw` are pure and recomputable; ship the frozen test
16
+ * vector in the tests as the canonical reference.
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { merkleRoot } from './audit.js';
21
+
22
+ const MAX_ENTRIES = 5_000_000;
23
+ const ALGORITHM = 'fisher-yates-sha256-v1';
24
+
25
+ function sha256(...bufs) {
26
+ const h = createHash('sha256');
27
+ for (const b of bufs) h.update(b);
28
+ return h.digest();
29
+ }
30
+
31
+ /** Deterministic byte stream from a seed: SHA-256(seed ‖ ':' ‖ counterLE). */
32
+ function makeRng(seed) {
33
+ const seedBuf = Buffer.from(String(seed), 'utf8');
34
+ let counter = 0;
35
+ let pool = Buffer.alloc(0);
36
+ let used = 0;
37
+ const refill = () => {
38
+ const c = Buffer.alloc(8);
39
+ c.writeUInt32LE(counter >>> 0, 0);
40
+ c.writeUInt32LE(Math.floor(counter / 2 ** 32) >>> 0, 4);
41
+ pool = sha256(seedBuf, Buffer.from(':'), c);
42
+ counter += 1;
43
+ used = 0;
44
+ };
45
+ return {
46
+ nextBytes(n) {
47
+ const out = Buffer.alloc(n);
48
+ let o = 0;
49
+ while (o < n) {
50
+ if (used >= pool.length) refill();
51
+ const take = Math.min(n - o, pool.length - used);
52
+ pool.copy(out, o, used, used + take);
53
+ used += take;
54
+ o += take;
55
+ }
56
+ return out;
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Uniform integer in [0, maxExclusive) via 48-bit rejection sampling (unbiased). */
62
+ function uniformInt(rng, maxExclusive) {
63
+ if (maxExclusive <= 1) return 0;
64
+ const span = 0x1000000000000; // 2^48
65
+ const limit = Math.floor(span / maxExclusive) * maxExclusive;
66
+ for (;;) {
67
+ const v = rng.nextBytes(6).readUIntBE(0, 6);
68
+ if (v < limit) return v % maxExclusive;
69
+ }
70
+ }
71
+
72
+ /** Deterministic Fisher–Yates shuffle of a copy of `items`, driven by `seed`. */
73
+ function shuffle(items, seed) {
74
+ const a = items.slice();
75
+ const rng = makeRng(seed);
76
+ for (let i = a.length - 1; i >= 1; i--) {
77
+ const j = uniformInt(rng, i + 1);
78
+ [a[i], a[j]] = [a[j], a[i]];
79
+ }
80
+ return a;
81
+ }
82
+
83
+ /**
84
+ * Run a draw. Deterministic and recomputable.
85
+ * @param {{entries:string[], seed:string, winners?:number, algorithm?:string}} opts
86
+ * @returns {{winners:string[], winnerIndices:number[], algorithm:string, seed:string, entryCount:number}}
87
+ */
88
+ export function runDraw({ entries, seed, winners = 1, algorithm = ALGORITHM }) {
89
+ if (!Array.isArray(entries) || entries.length === 0)
90
+ throw new Error('runDraw: entries must be a non-empty array');
91
+ if (entries.length > MAX_ENTRIES) throw new Error('runDraw: too many entries');
92
+ if (seed == null || String(seed).length === 0) throw new Error('runDraw: seed is required');
93
+ if (algorithm !== ALGORITHM) throw new Error(`runDraw: unsupported algorithm ${algorithm}`);
94
+ const k = Number(winners);
95
+ if (!Number.isInteger(k) || k < 1 || k > entries.length)
96
+ throw new Error('runDraw: winners must be an integer in [1, entries.length]');
97
+
98
+ const order = shuffle(
99
+ entries.map((_, i) => i),
100
+ seed,
101
+ );
102
+ const winnerIndices = order.slice(0, k);
103
+ return {
104
+ winners: winnerIndices.map((i) => entries[i]),
105
+ winnerIndices,
106
+ algorithm,
107
+ seed: String(seed),
108
+ entryCount: entries.length,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * A publishable proof: the seed, algorithm, a commitment to the exact entry set
114
+ * (Merkle root), and the resulting winners. Anyone can recompute with `verifyDraw`.
115
+ */
116
+ export function publishDrawProof({ entries, seed, winners = 1, algorithm = ALGORITHM }) {
117
+ const result = runDraw({ entries, seed, winners, algorithm });
118
+ return {
119
+ algorithm: result.algorithm,
120
+ seed: result.seed,
121
+ entryCount: result.entryCount,
122
+ entriesRoot: merkleRoot(entries), // commits to the exact ordered entry set
123
+ winners: result.winners,
124
+ winnerIndices: result.winnerIndices,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Recompute a draw from its proof + the entry set and confirm the winners match
130
+ * and the entries match the committed root. Robust to malformed input.
131
+ * @returns {{ok:boolean, reason?:string}}
132
+ */
133
+ export function verifyDraw(proof, entries) {
134
+ try {
135
+ if (!proof || !Array.isArray(entries)) return { ok: false, reason: 'bad_input' };
136
+ if (merkleRoot(entries) !== proof.entriesRoot) return { ok: false, reason: 'entries_root_mismatch' };
137
+ const re = runDraw({
138
+ entries,
139
+ seed: proof.seed,
140
+ winners: proof.winnerIndices?.length ?? 1,
141
+ algorithm: proof.algorithm,
142
+ });
143
+ const same =
144
+ re.winnerIndices.length === proof.winnerIndices.length &&
145
+ re.winnerIndices.every((v, i) => v === proof.winnerIndices[i]);
146
+ return same ? { ok: true } : { ok: false, reason: 'winner_mismatch' };
147
+ } catch (e) {
148
+ return { ok: false, reason: `verify_error:${e.message}` };
149
+ }
150
+ }
151
+
152
+ // ─── Seed adapters ──────────────────────────────────────────────────────────
153
+ // Each returns { source, value, ...context }. Document manipulability honestly.
154
+
155
+ /**
156
+ * Publish a SEED COMMITMENT *before* entries close: it names the exact future
157
+ * round whose block hash will be the seed, so the operator cannot later pick a
158
+ * favourable seed. Reveal the hash (via `blockHashSeed`) only after that round.
159
+ */
160
+ export function commitSeedSource({ source = 'algorand-block', round, committedAtRound }) {
161
+ if (!Number.isInteger(Number(round))) throw new Error('commitSeedSource: round is required');
162
+ return {
163
+ source,
164
+ round: Number(round),
165
+ committedAtRound: committedAtRound == null ? null : Number(committedAtRound),
166
+ note: 'Seed = hash of the named round, knowable only after that round is produced.',
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Derive a seed from an Algorand block's sortition SEED (`block.header.seed`, a
172
+ * 32-byte VRF-derived value). ⚠ A block proposer can still influence or withhold a
173
+ * block to bias the outcome — only use with `commitSeedSource` (commit the round
174
+ * in advance, before entries close) and prefer a VRF/beacon for high-value draws.
175
+ */
176
+ export async function blockHashSeed(algod, round) {
177
+ const blk = await algod.block(Number(round)).do();
178
+ const header = blk?.block?.header ?? blk?.block ?? blk;
179
+ const seed = header?.seed;
180
+ let value;
181
+ if (seed instanceof Uint8Array || Buffer.isBuffer(seed)) value = Buffer.from(seed).toString('hex');
182
+ else if (typeof seed === 'string') value = Buffer.from(seed, 'base64').toString('hex');
183
+ else throw new Error(`blockHashSeed: could not read block seed for round ${round}`);
184
+ return { source: 'algorand-block-seed', round: Number(round), value, manipulable: true };
185
+ }
186
+
187
+ /** Wrap a VRF output (you supply the verified value+proof from your VRF). */
188
+ export function vrfSeed({ value, proof, publicKey }) {
189
+ if (!value) throw new Error('vrfSeed: value is required');
190
+ return { source: 'vrf', value: String(value), proof: proof ?? null, publicKey: publicKey ?? null };
191
+ }
192
+
193
+ /** Wrap a public randomness-beacon value (e.g. drand). */
194
+ export function beaconSeed({ value, round, beacon = 'drand' }) {
195
+ if (!value) throw new Error('beaconSeed: value is required');
196
+ return { source: 'beacon', beacon, round: round == null ? null : Number(round), value: String(value) };
197
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * identity.js — identity-lite (email/SMS OTP) without storing raw contact data.
3
+ *
4
+ * Hardening (see SECURITY.md / LEGAL.md):
5
+ * - OTP codes are generated with a CSPRNG (`crypto.randomInt`), never Math.random.
6
+ * - Single-use, time-bound (expiry), rate-limited (core InMemoryRateLimiter),
7
+ * and locked out after N failed attempts.
8
+ * - Codes are compared in constant time and only their KEYED hash is stored —
9
+ * never the raw code.
10
+ * - Contacts are stored only as KEYED (peppered) hashes — pseudonymous, still
11
+ * personal data under GDPR; never the raw email/phone.
12
+ * - Failures are generic (anti-enumeration): a caller cannot tell whether a
13
+ * given contact has an outstanding challenge.
14
+ */
15
+
16
+ import { createHmac, randomInt } from 'node:crypto';
17
+ import { InMemoryRateLimiter } from '@kirkelabs/open-agent-access-core';
18
+ import { hashPii, hashEquals } from './privacy.js';
19
+
20
+ export class OtpIdentity {
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string} opts.pepper secret keyed-hash pepper (>= 16 chars)
24
+ * @param {Function} [opts.send] async (contact, code) => deliver out-of-band
25
+ * @param {number} [opts.codeLength] digits (default 6)
26
+ * @param {number} [opts.ttlMs] code validity (default 5 min)
27
+ * @param {number} [opts.maxAttempts] failures before lockout (default 5)
28
+ * @param {object} [opts.rateLimit] { requests, window } e.g. {requests:5, window:'10m'}
29
+ */
30
+ constructor({
31
+ pepper,
32
+ send,
33
+ codeLength = 6,
34
+ ttlMs = 5 * 60_000,
35
+ maxAttempts = 5,
36
+ rateLimit = { requests: 5, window: '10m' },
37
+ } = {}) {
38
+ if (typeof pepper !== 'string' || pepper.length < 16)
39
+ throw new Error('OtpIdentity: pepper must be a secret string of >= 16 chars');
40
+ this._pepper = pepper;
41
+ this._send = send;
42
+ this._codeLength = codeLength;
43
+ this._ttlMs = ttlMs;
44
+ this._maxAttempts = maxAttempts;
45
+ this._rateLimit = rateLimit;
46
+ this._rl = new InMemoryRateLimiter();
47
+ this._challenges = new Map(); // contactHash -> { codeHash, expiresAt, attempts, used }
48
+ }
49
+
50
+ /** Keyed, pseudonymous reference for a contact (what you store/de-dup on). */
51
+ contactRef(contact) {
52
+ return hashPii(contact, this._pepper);
53
+ }
54
+
55
+ _codeHash(code) {
56
+ return createHmac('sha256', this._pepper).update(`otp:${code}`, 'utf8').digest('hex');
57
+ }
58
+
59
+ /**
60
+ * Issue a fresh OTP for a contact and deliver it via `send`. Does NOT return the
61
+ * code. Rate-limited per contact.
62
+ * @returns {Promise<{sent:boolean, contactRef:string, expiresAt?:number, reason?:string, retryAfter?:number}>}
63
+ */
64
+ async issueChallenge(contact, { now = Date.now() } = {}) {
65
+ const contactRef = this.contactRef(contact);
66
+ const rl = this._rl.check(`otp-issue:${contactRef}`, this._rateLimit, now);
67
+ if (!rl.allowed)
68
+ return { sent: false, contactRef, reason: 'rate_limited', retryAfter: rl.retryAfter };
69
+
70
+ // CSPRNG numeric code, zero-padded to codeLength.
71
+ const max = 10 ** this._codeLength;
72
+ const code = String(randomInt(0, max)).padStart(this._codeLength, '0');
73
+ const expiresAt = now + this._ttlMs;
74
+ this._challenges.set(contactRef, {
75
+ codeHash: this._codeHash(code),
76
+ expiresAt,
77
+ attempts: 0,
78
+ used: false,
79
+ });
80
+ if (typeof this._send === 'function') await this._send(contact, code);
81
+ return { sent: true, contactRef, expiresAt };
82
+ }
83
+
84
+ /**
85
+ * Verify a submitted code. Single-use, expiring, attempt-limited, constant-time.
86
+ * Returns a GENERIC failure (anti-enumeration) on any invalid/expired/missing case.
87
+ * @returns {Promise<{ok:boolean, contactRef:string, reason?:string, retryAfter?:number}>}
88
+ */
89
+ async verifyChallenge(contact, code, { now = Date.now() } = {}) {
90
+ const contactRef = this.contactRef(contact);
91
+ const rl = this._rl.check(`otp-verify:${contactRef}`, this._rateLimit, now);
92
+ if (!rl.allowed)
93
+ return { ok: false, contactRef, reason: 'rate_limited', retryAfter: rl.retryAfter };
94
+
95
+ const ch = this._challenges.get(contactRef);
96
+ if (!ch || ch.used || now > ch.expiresAt)
97
+ return { ok: false, contactRef, reason: 'invalid_or_expired' };
98
+ if (ch.attempts >= this._maxAttempts)
99
+ return { ok: false, contactRef, reason: 'locked_out' };
100
+
101
+ ch.attempts += 1;
102
+ const match = hashEquals(this._codeHash(String(code)), ch.codeHash);
103
+ if (!match) {
104
+ const locked = ch.attempts >= this._maxAttempts;
105
+ return { ok: false, contactRef, reason: locked ? 'locked_out' : 'invalid_or_expired' };
106
+ }
107
+ ch.used = true; // single-use: consume the challenge on success
108
+ return { ok: true, contactRef };
109
+ }
110
+ }
package/src/index.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @kirkelabs/walletless-kit — public API.
3
+ *
4
+ * A walletless web-architecture toolkit: ephemeral custodial onboarding,
5
+ * receipt-only on-chain proofs, a tamper-evident audit trail, segregated money
6
+ * ledgers, a verifiable recomputable draw, and privacy/erasure helpers. Built on
7
+ * @kirkelabs/open-agent-access-core and @kirkelabs/oaa-agent-kit. MIT.
8
+ *
9
+ * TestNet by default. Some modules are EXPERIMENTAL · UNAUDITED — see LEGAL.md.
10
+ */
11
+
12
+ // Convenience: re-export the Algod client + algosdk from oaa-agent-kit so callers
13
+ // need only one import for the chain client.
14
+ export { getAlgod, algosdk } from '@kirkelabs/oaa-agent-kit';
15
+
16
+ export {
17
+ hashPii,
18
+ hashEquals,
19
+ pseudonymRef,
20
+ eraseSubject,
21
+ assertNoPii,
22
+ } from './privacy.js';
23
+
24
+ export {
25
+ createTrail,
26
+ append,
27
+ merkleRoot,
28
+ merkleProof,
29
+ verifyMerkleProof,
30
+ trailHash,
31
+ trailRoot,
32
+ verifyTrail,
33
+ anchor,
34
+ } from './audit.js';
35
+
36
+ export {
37
+ createEphemeralAccount,
38
+ isExpired,
39
+ expireAccount,
40
+ rotateAccount,
41
+ } from './onboarding.js';
42
+
43
+ export { OtpIdentity } from './identity.js';
44
+
45
+ export {
46
+ buildOrderReceipt,
47
+ deterministicOrderId,
48
+ signReceipt,
49
+ verifyReceiptChain,
50
+ attestOnChain,
51
+ chargeForAction,
52
+ } from './receipt.js';
53
+
54
+ export { Ledger, createLedger } from './ledger.js';
55
+
56
+ export {
57
+ runDraw,
58
+ publishDrawProof,
59
+ verifyDraw,
60
+ commitSeedSource,
61
+ blockHashSeed,
62
+ vrfSeed,
63
+ beaconSeed,
64
+ } from './draw.js';
package/src/ledger.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ledger.js — segregated, append-only money books for a walletless flow.
3
+ *
4
+ * Three independent books — `inflow`, `charity`, `escrow` — keep ticket revenue,
5
+ * charity allocations, and held funds clearly separated for reconciliation.
6
+ *
7
+ * Hardening:
8
+ * - Money is ALWAYS integer microALGO (or BigInt-safe integers). Never a JS
9
+ * float — floating-point money silently drifts and breaks reconciliation.
10
+ * - Books are append-only; `snapshot()` returns a deep-FROZEN copy so a caller
11
+ * can't retro-edit history.
12
+ * - `reconciliationSheet` is a pure, deterministic function of the books.
13
+ *
14
+ * This is bookkeeping/transparency tooling, NOT regulated custody or accounting.
15
+ * See LEGAL.md (money handling / AML).
16
+ */
17
+
18
+ const BOOKS = ['inflow', 'charity', 'escrow'];
19
+ const MAX_SAFE = Number.MAX_SAFE_INTEGER;
20
+
21
+ function assertMicro(amount) {
22
+ const n = Number(amount);
23
+ if (!Number.isInteger(n) || n < 0 || n > MAX_SAFE)
24
+ throw new Error('ledger: amountMicro must be a non-negative integer (microALGO), never a float');
25
+ return n;
26
+ }
27
+
28
+ export class Ledger {
29
+ constructor() {
30
+ this._books = { inflow: [], charity: [], escrow: [] };
31
+ this._seq = 0;
32
+ }
33
+
34
+ /**
35
+ * Append an entry to a book. `amountMicro` MUST be an integer (microALGO).
36
+ * @param {{book:string, amountMicro:number, ref?:string, txRef?:string, kind?:string, meta?:object}} e
37
+ */
38
+ post({ book, amountMicro, ref = null, txRef = null, kind = 'generic', meta = null }) {
39
+ if (!BOOKS.includes(book)) throw new Error(`ledger: unknown book "${book}" (use ${BOOKS.join('/')})`);
40
+ const entry = Object.freeze({
41
+ seq: ++this._seq,
42
+ book,
43
+ amountMicro: assertMicro(amountMicro),
44
+ ref: ref == null ? null : String(ref),
45
+ txRef: txRef == null ? null : String(txRef),
46
+ kind: String(kind),
47
+ meta: meta == null ? null : Object.freeze({ ...meta }),
48
+ });
49
+ this._books[book].push(entry);
50
+ return entry;
51
+ }
52
+
53
+ /** Integer balance of a book. */
54
+ balance(book) {
55
+ if (!BOOKS.includes(book)) throw new Error(`ledger: unknown book "${book}"`);
56
+ return this._books[book].reduce((s, e) => s + e.amountMicro, 0);
57
+ }
58
+
59
+ /** All three balances. */
60
+ balances() {
61
+ return { inflow: this.balance('inflow'), charity: this.balance('charity'), escrow: this.balance('escrow') };
62
+ }
63
+
64
+ /** Deep-frozen immutable snapshot of every book + balances. */
65
+ snapshot() {
66
+ const books = {};
67
+ for (const b of BOOKS) books[b] = Object.freeze(this._books[b].slice());
68
+ return Object.freeze({ books: Object.freeze(books), balances: Object.freeze(this.balances()) });
69
+ }
70
+
71
+ /**
72
+ * Human-readable reconciliation sheet for a draw. Pure/deterministic.
73
+ * @param {string} drawId
74
+ * @param {{winnerProofLink?:string}} [opts]
75
+ */
76
+ reconciliationSheet(drawId, { winnerProofLink = null } = {}) {
77
+ const inflow = this._books.inflow;
78
+ const sum = (arr) => arr.reduce((s, e) => s + e.amountMicro, 0);
79
+ const ticketsSold = inflow.filter((e) => e.kind === 'ticket').length;
80
+ const gross = sum(inflow);
81
+ const fees = sum(
82
+ [...inflow, ...this._books.charity, ...this._books.escrow].filter((e) => e.kind === 'fee'),
83
+ );
84
+ const charityTotal = this.balance('charity');
85
+ const escrowTotal = this.balance('escrow');
86
+ return {
87
+ drawId: String(drawId),
88
+ ticketsSold,
89
+ grossMicroAlgos: gross,
90
+ feesMicroAlgos: fees,
91
+ charity: {
92
+ totalMicroAlgos: charityTotal,
93
+ destinations: this._books.charity.map((e) => ({
94
+ address: e.ref,
95
+ amountMicroAlgos: e.amountMicro,
96
+ txRef: e.txRef,
97
+ })),
98
+ },
99
+ escrow: {
100
+ totalMicroAlgos: escrowTotal,
101
+ refs: this._books.escrow.map((e) => ({ ref: e.ref, txRef: e.txRef, amountMicroAlgos: e.amountMicro })),
102
+ },
103
+ netMicroAlgos: gross - fees - charityTotal - escrowTotal,
104
+ winnerProofLink,
105
+ };
106
+ }
107
+ }
108
+
109
+ /** Convenience factory. */
110
+ export function createLedger() {
111
+ return new Ledger();
112
+ }