@fangorn-network/westmarch 0.0.1-dev

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.
@@ -0,0 +1,200 @@
1
+ // Who gets paid, and how much, when something sells.
2
+ //
3
+ // An app owner registers terms; publishers join under them; a buyer pays once.
4
+ // This file is the arithmetic in the middle, and it is the only revenue
5
+ // mechanism in the whole architecture — discovery cannot be charged for (the
6
+ // query never leaves the client and the index holds 4 KB per corpus), so a take
7
+ // at settlement is the business model or there isn't one.
8
+ //
9
+ // Two layers, because they are set by different people at different times:
10
+ //
11
+ // app terms the owner's cut, fixed when the app is registered and hashed so
12
+ // a publisher can prove what they agreed to
13
+ // lineage what a publisher owes UPSTREAM, declared per resource when they
14
+ // publish. Paid out of the publisher's share, never the app's:
15
+ // a derivative's author pays their sources, and the app's cut does
16
+ // not shrink because someone built on someone else.
17
+ //
18
+ // That second layer is the point. An agent buys three corpora, synthesises a
19
+ // fourth, publishes it with lineage naming the three, and every later sale of
20
+ // the derivative pays back up the chain. Knowledge in, concept out, concept
21
+ // becomes knowledge someone else builds on — with the money following the
22
+ // citations instead of stopping at the last hop.
23
+ //
24
+ // ponytail: BigInt base units, one level of lineage, no recursion. A derivative
25
+ // OF a derivative declares its own immediate sources and each hop settles on its
26
+ // own sale — walking the tree at settlement time would mean fetching a stranger's
27
+ // terms mid-payment, and a payment that depends on someone else's server being
28
+ // up is a payment that fails.
29
+
30
+ const BPS = 10000n;
31
+ const ADDR = /^0x[0-9a-fA-F]{40}$/;
32
+
33
+ const addr = (a) => {
34
+ const s = String(a ?? "").toLowerCase();
35
+ if (!ADDR.test(s)) throw new Error(`not an address: ${a}`);
36
+ return s;
37
+ };
38
+ const bps = (n, what) => {
39
+ const v = BigInt(n ?? 0);
40
+ if (v < 0n || v > BPS) throw new Error(`${what} must be 0..10000 bps, got ${n}`);
41
+ return v;
42
+ };
43
+
44
+ /**
45
+ * An app's terms, canonical.
46
+ *
47
+ * Canonical because the hash is the thing a publisher agrees to and a buyer can
48
+ * check: same terms must produce the same bytes on any machine, in any key
49
+ * order, forever. So: fixed field order, lowercase addresses, decimal strings
50
+ * for numbers (JSON has no integers), and nothing else in the object.
51
+ */
52
+ export function appTerms({ appId, owner, appBps = 0, currency = "USDC", chainId = 421614 }) {
53
+ if (!appId) throw new Error("terms need an appId");
54
+ return {
55
+ v: 1,
56
+ appId: String(appId),
57
+ owner: addr(owner),
58
+ appBps: String(bps(appBps, "appBps")),
59
+ currency: String(currency),
60
+ chainId: Number(chainId),
61
+ };
62
+ }
63
+
64
+ /** Canonical bytes → sha-256. Async because that is the only hash both node and
65
+ * a browser have without a dependency. */
66
+ export async function termsHash(t) {
67
+ const canon = JSON.stringify(appTerms(t));
68
+ const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canon));
69
+ return `0x${[...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
70
+ }
71
+
72
+ /** What a publisher owes upstream for one resource. Deduped by payee — the same
73
+ * source cited twice is one debt, not two, and merging is the only reading that
74
+ * cannot be gamed by repeating an entry. */
75
+ export function lineage(entries = []) {
76
+ const by = new Map();
77
+ for (const e of entries) {
78
+ const to = addr(e.to);
79
+ by.set(to, { to, bps: (by.get(to)?.bps ?? 0n) + bps(e.bps, "lineage bps"), note: e.note ?? by.get(to)?.note ?? "" });
80
+ }
81
+ const out = [...by.values()];
82
+ const total = out.reduce((s, e) => s + e.bps, 0n);
83
+ if (total > BPS) throw new Error(`lineage owes ${total} bps of the publisher's share — more than all of it`);
84
+ return out.map((e) => ({ to: e.to, bps: String(e.bps), ...(e.note ? { note: e.note } : {}) }));
85
+ }
86
+
87
+ /**
88
+ * One payment → the payouts it becomes.
89
+ *
90
+ * Integer base units end to end, and the dust is given to the publisher rather
91
+ * than rounded away: the payouts MUST sum to exactly what the buyer paid, or
92
+ * settlement is inventing or destroying money. Every share rounds down; the
93
+ * publisher absorbs the difference, which is at most one base unit per payee.
94
+ */
95
+ export function splitPayment(amount, { terms, publisher, lineage: up = [] } = {}) {
96
+ const total = BigInt(amount);
97
+ if (total < 0n) throw new Error("amount must not be negative");
98
+ const t = appTerms(terms);
99
+ const pub = addr(publisher);
100
+
101
+ const payouts = [];
102
+ const appCut = (total * BigInt(t.appBps)) / BPS;
103
+ if (appCut > 0n) payouts.push({ to: t.owner, amount: appCut, why: `app ${t.appId} (${t.appBps} bps)` });
104
+
105
+ // Everything the app did not take is the publisher's, and upstream is paid
106
+ // out of that — so a publisher who cites nobody keeps it all, and one who
107
+ // builds on three sources pays them from their own half.
108
+ const publisherPool = total - appCut;
109
+ let owed = 0n;
110
+ for (const e of lineage(up)) {
111
+ const cut = (publisherPool * BigInt(e.bps)) / BPS;
112
+ if (cut === 0n) continue;
113
+ owed += cut;
114
+ payouts.push({ to: e.to, amount: cut, why: `upstream ${e.note || e.to.slice(0, 10)} (${e.bps} bps of the publisher's share)` });
115
+ }
116
+
117
+ const keeps = publisherPool - owed;
118
+ payouts.push({ to: pub, amount: keeps, why: "publisher" });
119
+
120
+ // Merge payees — an app owner who is also the publisher gets one payout, not
121
+ // two, and a downstream reconciliation that sees the same address twice has
122
+ // no way to tell a split from a double-pay.
123
+ const by = new Map();
124
+ for (const p of payouts) {
125
+ const at = by.get(p.to);
126
+ by.set(p.to, at ? { ...at, amount: at.amount + p.amount, why: `${at.why} + ${p.why}` } : p);
127
+ }
128
+ const merged = [...by.values()];
129
+ const sum = merged.reduce((s, p) => s + p.amount, 0n);
130
+ if (sum !== total) throw new Error(`payouts sum to ${sum}, buyer paid ${total}`);
131
+ return merged;
132
+ }
133
+
134
+ /** Human-readable, for a receipt or a quote. 6-decimal base units — every asset
135
+ * this settles on is 6-decimal, and a bare "50000" on a buy button is not a
136
+ * price anyone reads. */
137
+ export const label = (base, currency = "USDC") => `${(Number(base) / 1e6).toFixed(2)} ${currency}`;
138
+
139
+ // ── self-check: `node publish/terms.js` ────────────────────────────────────
140
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
141
+ const A = "0x" + "a".repeat(40); // app owner
142
+ const P = "0x" + "b".repeat(40); // publisher
143
+ const S1 = "0x" + "c".repeat(40); // a source
144
+ const S2 = "0x" + "d".repeat(40); // another
145
+ const T = { appId: "fangorn.tv", owner: A, appBps: 1000 };
146
+ const at = (ps, to) => ps.find((p) => p.to === to)?.amount ?? 0n;
147
+
148
+ // The plain case: 10% to the app, the rest to whoever published it.
149
+ let ps = splitPayment(1_000_000n, { terms: T, publisher: P });
150
+ if (at(ps, A) !== 100_000n || at(ps, P) !== 900_000n) throw new Error(`plain split wrong: ${JSON.stringify(ps, (k, v) => typeof v === "bigint" ? String(v) : v)}`);
151
+
152
+ // A derivative: two sources, 20% and 5% OF THE PUBLISHER'S SHARE. The app's
153
+ // cut must not move — that is the whole reason lineage comes out of the
154
+ // publisher's half.
155
+ ps = splitPayment(1_000_000n, { terms: T, publisher: P, lineage: [{ to: S1, bps: 2000 }, { to: S2, bps: 500 }] });
156
+ if (at(ps, A) !== 100_000n) throw new Error("the app's cut must not shrink because the publisher cited someone");
157
+ if (at(ps, S1) !== 180_000n || at(ps, S2) !== 45_000n) throw new Error(`upstream is a share of 900000: ${at(ps, S1)}/${at(ps, S2)}`);
158
+ if (at(ps, P) !== 675_000n) throw new Error(`publisher keeps the rest: ${at(ps, P)}`);
159
+
160
+ // Money is neither created nor destroyed, at any amount.
161
+ for (const amt of [0n, 1n, 3n, 7n, 999n, 1_000_001n, 123_456_789n]) {
162
+ const out = splitPayment(amt, { terms: T, publisher: P, lineage: [{ to: S1, bps: 3333 }, { to: S2, bps: 3333 }] });
163
+ const sum = out.reduce((s, p) => s + p.amount, 0n);
164
+ if (sum !== amt) throw new Error(`${amt} split into ${sum}`);
165
+ if (out.some((p) => p.amount < 0n)) throw new Error(`negative payout at ${amt}`);
166
+ }
167
+
168
+ // Dust goes to the publisher, never to rounding.
169
+ ps = splitPayment(7n, { terms: { ...T, appBps: 3333 }, publisher: P });
170
+ if (at(ps, A) !== 2n || at(ps, P) !== 5n) throw new Error(`dust must land on the publisher: ${JSON.stringify(ps.map((p) => [p.to.slice(0, 4), String(p.amount)]))}`);
171
+
172
+ // One payee, one payout: an app owner who publishes under their own app.
173
+ ps = splitPayment(1_000_000n, { terms: T, publisher: A });
174
+ if (ps.length !== 1 || at(ps, A) !== 1_000_000n) throw new Error("the same address must be paid once, not twice");
175
+
176
+ // Citing the same source twice is one debt.
177
+ ps = splitPayment(1_000_000n, { terms: T, publisher: P, lineage: [{ to: S1, bps: 1000 }, { to: S1, bps: 1000 }] });
178
+ if (at(ps, S1) !== 180_000n) throw new Error(`a repeated citation must merge, not double-pay: ${at(ps, S1)}`);
179
+
180
+ // Terms are refused, not silently clamped.
181
+ for (const bad of [{ ...T, appBps: 10001 }, { ...T, appBps: -1 }, { ...T, owner: "0xnope" }, { appId: "", owner: A }]) {
182
+ let threw = false;
183
+ try { splitPayment(1n, { terms: bad, publisher: P }); } catch { threw = true; }
184
+ if (!threw) throw new Error(`bad terms must throw: ${JSON.stringify(bad)}`);
185
+ }
186
+ let threw = false;
187
+ try { lineage([{ to: S1, bps: 6000 }, { to: S2, bps: 6000 }]); } catch { threw = true; }
188
+ if (!threw) throw new Error("a publisher cannot owe more than they have");
189
+
190
+ // The hash is what a publisher agreed to: stable across key order, and it
191
+ // moves the moment a single term does.
192
+ const h1 = await termsHash(T);
193
+ const h2 = await termsHash({ owner: A, appBps: 1000, appId: "fangorn.tv" });
194
+ if (h1 !== h2) throw new Error("key order must not change the hash");
195
+ if (h1 === await termsHash({ ...T, appBps: 1001 })) throw new Error("a changed cut must change the hash");
196
+ if (!/^0x[0-9a-f]{64}$/.test(h1)) throw new Error(`hash shape: ${h1}`);
197
+
198
+ console.log("terms.js self-check ok — app cut fixed, lineage paid from the publisher's share, payouts always sum to what was paid, "
199
+ + "dust to the publisher, repeated payees merged, bad terms refused, terms hash canonical");
200
+ }
@@ -0,0 +1,162 @@
1
+ // Your taste, sealed to your wallet, kept in Fangorn.
2
+ //
3
+ // WHY NOT localStorage, AND WHY NOT A URL
4
+ // ---------------------------------------
5
+ // `localStorage` is origin-scoped, so a taste learned in one app is invisible to
6
+ // the next, and gone when the browser is. Passing it in a `?taste=` query fixes
7
+ // the first problem and none of the others: it cannot survive a new machine, it
8
+ // leaks the object into every referrer header and server log along the way, and
9
+ // it makes the person responsible for carrying their own state around by hand.
10
+ //
11
+ // So the taste goes where every other durable thing in this system goes — the
12
+ // graph — and the wallet is what opens it. Same wallet on a new laptop, same
13
+ // taste, no export, no backup file, nothing to lose.
14
+ //
15
+ // WHY IT IS ENCRYPTED, WHICH IS NOT OPTIONAL
16
+ // ------------------------------------------
17
+ // A taste vector is not metadata about behaviour, it IS the behaviour: μ is where
18
+ // you are, `no` is what you rejected, and both are directly comparable to every
19
+ // corpus on the network. Publishing that in the clear, on a public chain, bound
20
+ // permanently to an address, would build exactly the profile this project exists
21
+ // to refuse — and worse than the ad networks do, because a chain does not forget
22
+ // and cannot be asked to delete.
23
+ //
24
+ // The key is derived from ONE deterministic signature over a fixed string, which
25
+ // is the same trick `sond3r/src/pay/buy.js:deriveBuyer` already uses to re-derive
26
+ // a Semaphore identity on a machine that has never seen it. ECDSA signing is
27
+ // deterministic (RFC 6979), so the wallet is the only input.
28
+ //
29
+ // The string is DIFFERENT from the identity one on purpose. Two secrets derived
30
+ // from one signature are one secret, and the buyer identity is used to prove
31
+ // payment — a vault that shared it would let anyone who could open your taste
32
+ // also spend your entitlements.
33
+ //
34
+ // ponytail: a single key for the whole vault, rotated by nobody. Rotation needs
35
+ // somewhere to put the version, and there is no second reader to coordinate with
36
+ // yet. Add a `keyEpoch` to the vertex when one exists.
37
+
38
+ const ENC = new TextEncoder();
39
+ const DEC = new TextDecoder();
40
+
41
+ /** The string the wallet signs. Domain-separated from `fangorn:identity:v1`. */
42
+ export const VAULT_MESSAGE = "fangorn:taste-vault:v1";
43
+ /** What a sealed blob announces itself as, so a reader can refuse politely. */
44
+ export const VAULT_VERSION = 1;
45
+
46
+ const b64 = (u8) => btoa(String.fromCharCode(...u8));
47
+ const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
48
+
49
+ /**
50
+ * Wallet → the AES key that opens this person's vault.
51
+ *
52
+ * `walletClient` is anything with `signMessage({ account, message })` — viem's
53
+ * local account, a browser wallet, or a Privy signer. The signature never leaves
54
+ * this function; what comes back is a non-extractable CryptoKey.
55
+ */
56
+ export async function vaultKey(walletClient) {
57
+ const account = walletClient.account;
58
+ const signature = await walletClient.signMessage({ account, message: VAULT_MESSAGE });
59
+ // HKDF, not the raw signature. A signature is 65 bytes of structured data
60
+ // with a recoverable public key in it — fine as key MATERIAL, wrong as a key.
61
+ const material = await crypto.subtle.importKey("raw", ENC.encode(signature), "HKDF", false, ["deriveKey"]);
62
+ return crypto.subtle.deriveKey(
63
+ { name: "HKDF", hash: "SHA-256", salt: ENC.encode("fangorn/taste-vault"), info: ENC.encode(VAULT_VERSION.toString()) },
64
+ material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
65
+ );
66
+ }
67
+
68
+ /** Seal any JSON-able object. A fresh IV every time, so two seals of the same
69
+ * taste are not recognisably the same taste to anyone watching the chain. */
70
+ export async function seal(key, obj) {
71
+ const iv = crypto.getRandomValues(new Uint8Array(12));
72
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, ENC.encode(JSON.stringify(obj)));
73
+ return { v: VAULT_VERSION, iv: b64(iv), ct: b64(new Uint8Array(ct)) };
74
+ }
75
+
76
+ /** Open a sealed blob, or null. Null rather than throw: the common failure is
77
+ * the wrong wallet, which is a question to ask the person, not a crash. */
78
+ export async function open(key, blob) {
79
+ if (!blob || blob.v !== VAULT_VERSION || !blob.iv || !blob.ct) return null;
80
+ try {
81
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(blob.iv) }, key, unb64(blob.ct));
82
+ return JSON.parse(DEC.decode(pt));
83
+ } catch { return null; }
84
+ }
85
+
86
+ /**
87
+ * The vertex this becomes in the reader's own namespace.
88
+ *
89
+ * One id, overwritten on every sync, because a taste has no history worth keeping
90
+ * on-chain — and a chain of past selves is precisely the profile the encryption
91
+ * exists to prevent. The payload carries no `path`, so it never appears in an
92
+ * app's file tree; it is state, not content.
93
+ */
94
+ export const TASTE_ID = "self/taste";
95
+ export const tasteVertex = (blob) => ({
96
+ id: TASTE_ID, tag: "taste",
97
+ payload: { kind: "taste", model: "nomic-256", enc: `vault-v${VAULT_VERSION}`, blob: JSON.stringify(blob) },
98
+ });
99
+
100
+ /** Pull the sealed blob back out of whatever `fangorn read` handed over. */
101
+ export function tasteFrom(vertices = []) {
102
+ const v = vertices.find((x) => (x.payload ?? x)?.kind === "taste");
103
+ if (!v) return null;
104
+ try { return JSON.parse((v.payload ?? v).blob); } catch { return null; }
105
+ }
106
+
107
+ // ── self-check: `node publish/vault.js` ───────────────────────────────────
108
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
109
+ const { privateKeyToAccount } = await import("viem/accounts");
110
+ const KEY_A = `0x${"11".repeat(32)}`, KEY_B = `0x${"22".repeat(32)}`;
111
+ // A "machine": a fresh client over the same key, holding no state at all.
112
+ const machine = (pk) => { const account = privateKeyToAccount(pk); return { account, signMessage: (a) => account.signMessage(a) }; };
113
+
114
+ const TASTE = { v: 1, model: "nomic-256", q: "AQIDBA==", mu: "BQYHCA==", from: ["Nosferatu (1922)"], rejected: ["a sitcom"], n: 6 };
115
+
116
+ const k1 = await vaultKey(machine(KEY_A));
117
+ const blob = await seal(k1, TASTE);
118
+ if (JSON.stringify(await open(k1, blob)) !== JSON.stringify(TASTE)) throw new Error("round trip must be exact");
119
+
120
+ // THE POINT: a different machine, no shared state, same wallet — same taste.
121
+ const k2 = await vaultKey(machine(KEY_A));
122
+ if (JSON.stringify(await open(k2, blob)) !== JSON.stringify(TASTE)) throw new Error("the same wallet on a new machine must open the vault");
123
+
124
+ // …and nobody else's wallet does.
125
+ if (await open(await vaultKey(machine(KEY_B)), blob) !== null) throw new Error("another wallet must not open it");
126
+
127
+ // The chain sees ciphertext and nothing else. The titles are the part that
128
+ // would identify a person, so they are the part that must not survive.
129
+ const wire = JSON.stringify(tasteVertex(blob));
130
+ for (const secret of ["Nosferatu", "sitcom", "AQIDBA==", "nomic-256\",\"q\""]) {
131
+ if (wire.includes(secret)) throw new Error(`the published vertex leaks ${JSON.stringify(secret)}`);
132
+ }
133
+ if (!wire.includes("vault-v1")) throw new Error("a reader must be able to tell what sealed this");
134
+ if (wire.includes("\"path\"")) throw new Error("a taste is state, not a file — it must not enter an app's tree");
135
+
136
+ // Two seals of one taste must not be recognisably equal to an observer.
137
+ const again = await seal(k1, TASTE);
138
+ if (again.ct === blob.ct || again.iv === blob.iv) throw new Error("a fresh IV every seal, or syncing twice announces 'unchanged'");
139
+ if (JSON.stringify(await open(k1, again)) !== JSON.stringify(TASTE)) throw new Error("…and both must still open");
140
+
141
+ // Tampering is caught by GCM rather than producing plausible garbage.
142
+ const bad = { ...blob, ct: blob.ct.slice(0, -4) + (blob.ct.endsWith("A") ? "BBBB" : "AAAA") };
143
+ if (await open(k1, bad) !== null) throw new Error("a modified blob must not open");
144
+ if (await open(k1, { ...blob, v: 99 }) !== null) throw new Error("an unknown version must be refused, not guessed at");
145
+ if (await open(k1, null) !== null || await open(k1, {}) !== null) throw new Error("absent input reads as absent, not as a crash");
146
+
147
+ // Domain separation: the vault key must not be the buyer identity's secret.
148
+ const acct = privateKeyToAccount(KEY_A);
149
+ const idSig = await acct.signMessage({ message: "fangorn:identity:v1" });
150
+ const vaultSig = await acct.signMessage({ message: VAULT_MESSAGE });
151
+ if (idSig === vaultSig) throw new Error("one signature for two secrets is one secret — opening a vault would grant spending");
152
+
153
+ // Round-trips out of the shape `fangorn read` returns.
154
+ if (JSON.stringify(tasteFrom([{ payload: { kind: "other" } }, tasteVertex(blob).payload ? tasteVertex(blob) : null].filter(Boolean))) !== JSON.stringify(blob)) {
155
+ throw new Error("the blob must survive a trip through the graph");
156
+ }
157
+ if (tasteFrom([]) !== null) throw new Error("an empty namespace is no taste, not a crash");
158
+
159
+ console.log("vault.js self-check ok — one deterministic signature opens it on any machine, no other wallet opens it, "
160
+ + "the published vertex carries ciphertext and no title, a fresh IV per sync, tampering and unknown versions refused, "
161
+ + "and the vault key is domain-separated from the buyer identity");
162
+ }