swarmlite 0.2.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.
@@ -0,0 +1,240 @@
1
+ // The pure-JS publisher: stamps, upload, and signed feed updates over
2
+ // a Bee node's HTTP API — so a JS developer never needs the Python
3
+ // package. Mirrors swarmlite/publish.py + swarmfs (stamps.py, feeds.py)
4
+ // behavior for behavior; the signing math is checked byte-for-byte
5
+ // against swarmfs-generated fixtures (RFC 6979 is deterministic).
6
+ //
7
+ // Money rules match the Python side exactly: `autoStamp` selects, and
8
+ // `planBatch`/`buyBatch` exist as capabilities — nothing here spends
9
+ // xBZZ unless the caller explicitly asks (the CLI asks the human).
10
+
11
+ // signing uses built-in WebCrypto (noble's signAsync); Node 18 has it
12
+ // under node:crypto rather than on globalThis — browsers skip this
13
+ if (!globalThis.crypto?.subtle) {
14
+ const { webcrypto } = await import('node:crypto');
15
+ globalThis.crypto = webcrypto;
16
+ }
17
+
18
+ import { getPublicKey, signAsync } from '../vendor/noble-secp256k1/index.js';
19
+ import { keccak256Bytes } from '../vendor/js-sha3/index.js';
20
+ import { chunkAddress, feedIdentifier, fromHex, socAddress, toHex } from './verify.js';
21
+ import { topicHex } from './feeds.js';
22
+
23
+ const BLOCK_SECS = 5; // Gnosis block time
24
+ const PLUR_PER_BZZ = 10 ** 16;
25
+ const MIN_TTL_SECS = 60;
26
+
27
+ // upload size -> batch depth; see swarmfs stamps.py — an immutable
28
+ // batch dies when any single bucket fills (measured live: one 42 MB
29
+ // upload filled a depth-18 batch), so keep overflow risk under ~5%
30
+ const DEPTH_TIERS = [[15 * 2 ** 20, 18], [150 * 2 ** 20, 19], [2 ** 30, 20]];
31
+
32
+ export class PublishError extends Error {
33
+ name = 'PublishError';
34
+ }
35
+
36
+ const doFetch = (fetchFn) => fetchFn ?? ((...args) => globalThis.fetch(...args));
37
+
38
+ async function checkOk(resp, what) {
39
+ if (resp.ok) return resp;
40
+ const detail = (await resp.text().catch(() => '')).slice(0, 200);
41
+ throw new PublishError(`Bee API ${resp.status} for ${what}: ${detail}`);
42
+ }
43
+
44
+ // ---- stamps ----------------------------------------------------------------
45
+
46
+ /** Pick the usable batch with the longest TTL (swarmfs auto-selection). */
47
+ export async function autoStamp(apiUrl, { fetchFn } = {}) {
48
+ const resp = await checkOk(await doFetch(fetchFn)(`${apiUrl}/stamps`), '/stamps');
49
+ const stamps = (await resp.json()).stamps ?? [];
50
+ const usable = stamps.filter((s) =>
51
+ s.usable && !(s.utilizationRatio >= 1.0) &&
52
+ !(s.batchTTL >= 0 && s.batchTTL <= MIN_TTL_SECS));
53
+ if (!usable.length) {
54
+ throw new PublishError(
55
+ stamps.length
56
+ ? `no usable postage stamp on ${apiUrl}: ` + stamps.map((s) =>
57
+ `${s.batchID.slice(0, 8)}…(${s.usable ? 'full or expiring' : 'not usable'})`).join('; ')
58
+ : `no postage stamps on ${apiUrl} — buy one first (planBatch/buyBatch, ` +
59
+ 'or `swarmlite publish --buy` from the Python CLI)',
60
+ );
61
+ }
62
+ return usable.reduce((a, b) =>
63
+ (b.batchTTL ?? -1) > (a.batchTTL ?? -1) ? b : a).batchID;
64
+ }
65
+
66
+ export function suggestDepth(sizeBytes) {
67
+ for (const [limit, depth] of DEPTH_TIERS) {
68
+ if (sizeBytes <= limit) return depth;
69
+ }
70
+ return 20 + Math.ceil(Math.log2(sizeBytes / 2 ** 30));
71
+ }
72
+
73
+ /** Price a batch for sizeBytes lasting ttlSecs (floor padded an hour
74
+ * above the chain's strict 24h minimum — mirrors swarmfs). */
75
+ export async function planBatch(apiUrl, sizeBytes, ttlSecs, { fetchFn } = {}) {
76
+ const resp = await checkOk(await doFetch(fetchFn)(`${apiUrl}/chainstate`), '/chainstate');
77
+ const chain = await resp.json();
78
+ const price = Number(chain.currentPrice);
79
+ const floor = Number(chain.minimumValidityBlocks ?? 0) + 3600 / BLOCK_SECS;
80
+ const blocks = Math.max(Math.ceil(ttlSecs / BLOCK_SECS), floor);
81
+ const depth = suggestDepth(sizeBytes);
82
+ const amount = blocks * price;
83
+ return { depth, amount, ttlSecs: blocks * BLOCK_SECS,
84
+ costBzz: amount * 2 ** depth / PLUR_PER_BZZ };
85
+ }
86
+
87
+ /** Buy a batch and poll until usable. Spends the node wallet's xBZZ —
88
+ * callers decide, this only executes. Every failure path after the
89
+ * purchase carries the batch id. */
90
+ export async function buyBatch(apiUrl, amount, depth, { fetchFn, waitSecs = 300 } = {}) {
91
+ const f = doFetch(fetchFn);
92
+ const resp = await checkOk(
93
+ await f(`${apiUrl}/stamps/${amount}/${depth}`, { method: 'POST' }),
94
+ 'POST /stamps');
95
+ const batchId = (await resp.json()).batchID;
96
+ const deadline = Date.now() + waitSecs * 1000;
97
+ while (Date.now() < deadline) {
98
+ const poll = await f(`${apiUrl}/stamps/${batchId}`);
99
+ if (poll.ok && (await poll.json()).usable) return batchId;
100
+ if (!poll.ok && poll.status !== 400 && poll.status !== 404) {
101
+ throw new PublishError(
102
+ `batch ${batchId} was bought (tx submitted) but polling failed ` +
103
+ `(HTTP ${poll.status}) — check ${apiUrl}/stamps/${batchId} and ` +
104
+ 'use it once usable');
105
+ }
106
+ await new Promise((r) => setTimeout(r, 3000));
107
+ }
108
+ throw new PublishError(
109
+ `batch ${batchId} was bought but is still not usable after ${waitSecs}s ` +
110
+ `— check ${apiUrl}/stamps/${batchId} and use it once usable`);
111
+ }
112
+
113
+ // ---- upload ----------------------------------------------------------------
114
+
115
+ /** Upload one file via POST /bzz (Bee builds the single-file manifest).
116
+ * Returns the manifest root reference. */
117
+ export async function uploadFile(apiUrl, name, bytes, stamp, { fetchFn } = {}) {
118
+ const resp = await checkOk(await doFetch(fetchFn)(
119
+ `${apiUrl}/bzz?name=${encodeURIComponent(name)}`,
120
+ {
121
+ method: 'POST',
122
+ body: bytes,
123
+ headers: {
124
+ 'Content-Type': 'application/octet-stream',
125
+ 'Swarm-Postage-Batch-Id': stamp,
126
+ },
127
+ },
128
+ ), 'POST /bzz');
129
+ return (await resp.json()).reference;
130
+ }
131
+
132
+ // ---- signed feed updates ---------------------------------------------------
133
+
134
+ /** The feed owner's 20-byte address for a private key, as hex. */
135
+ export function ownerAddress(privateKeyHex) {
136
+ const pub = getPublicKey(privateKeyHex.replace(/^0x/, ''), false); // uncompressed
137
+ return toHex(keccak256Bytes(pub.subarray(1)).subarray(12));
138
+ }
139
+
140
+ async function personalSign(digest32, privateKeyHex) {
141
+ const prefixed = keccak256Bytes(concat(
142
+ new TextEncoder().encode('\x19Ethereum Signed Message:\n32'), digest32));
143
+ const sig = await signAsync(prefixed, privateKeyHex.replace(/^0x/, ''));
144
+ const out = new Uint8Array(65);
145
+ out.set(sig.toBytes(), 0); // r ‖ s (compact)
146
+ out[64] = sig.recovery + 27;
147
+ return out;
148
+ }
149
+
150
+ function concat(...parts) {
151
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
152
+ let off = 0;
153
+ for (const p of parts) { out.set(p, off); off += p.length; }
154
+ return out;
155
+ }
156
+
157
+ const cacData = (payload) => {
158
+ const span = new Uint8Array(8);
159
+ new DataView(span.buffer).setBigUint64(0, BigInt(payload.length), true);
160
+ return concat(span, payload);
161
+ };
162
+
163
+ /** Build one signed feed-update chunk (bee-js format: timestamp + ref).
164
+ * Pure function of its inputs — testable against swarmfs fixtures. */
165
+ export async function buildFeedUpdate(privateKeyHex, topic, index, referenceHex, timestamp) {
166
+ const identifier = feedIdentifier(fromHex(topicHex(topic)), index);
167
+ const ts = new Uint8Array(8);
168
+ new DataView(ts.buffer).setBigUint64(0, BigInt(timestamp), false);
169
+ const data = cacData(concat(ts, fromHex(referenceHex)));
170
+ const digest = keccak256Bytes(concat(identifier, chunkAddress(data)));
171
+ const signature = await personalSign(digest, privateKeyHex);
172
+ return { identifier, signature, data,
173
+ soc: concat(identifier, signature, data) };
174
+ }
175
+
176
+ /** Publish `referenceHex` as the feed's next update. */
177
+ export async function updateFeed(apiUrl, privateKeyHex, topic, referenceHex, stamp,
178
+ { fetchFn, timestamp } = {}) {
179
+ const f = doFetch(fetchFn);
180
+ const owner = ownerAddress(privateKeyHex);
181
+ // next index: current head + 1, or 0 for a fresh feed
182
+ const head = await f(
183
+ `${apiUrl}/feeds/${owner}/${topicHex(topic)}?type=sequence`,
184
+ { headers: { 'Swarm-Only-Root-Chunk': 'true' } });
185
+ await head.body?.cancel?.();
186
+ const headIndex = head.ok ? head.headers.get('swarm-feed-index') : null;
187
+ const index = headIndex ? parseInt(headIndex, 16) + 1 : 0;
188
+
189
+ const { identifier, signature, data } = await buildFeedUpdate(
190
+ privateKeyHex, topic, index, referenceHex,
191
+ timestamp ?? Math.floor(Date.now() / 1000));
192
+ await checkOk(await f(
193
+ `${apiUrl}/soc/${owner}/${toHex(identifier)}?sig=${toHex(signature)}`,
194
+ {
195
+ method: 'POST',
196
+ body: data,
197
+ headers: { 'Swarm-Postage-Batch-Id': stamp },
198
+ },
199
+ ), 'POST /soc');
200
+ return { owner, index };
201
+ }
202
+
203
+ // ---- orchestration ---------------------------------------------------------
204
+
205
+ /** The whole publish flow: prepare -> upload -> optionally advance a
206
+ * feed. Returns { root, warnings, feed? }. `log` receives the human
207
+ * lines (pin/feed/warnings); pass a no-op for quiet. */
208
+ export async function publish(bytes, {
209
+ apiUrl = 'http://localhost:1633',
210
+ name = 'site.db',
211
+ stamp = 'auto',
212
+ feed = null,
213
+ signer = null,
214
+ fetchFn,
215
+ moduleOptions,
216
+ log = () => {},
217
+ } = {}) {
218
+ const { prepare } = await import('./prepare.js');
219
+ const { bytes: prepared, warnings } = await prepare(bytes, { moduleOptions });
220
+ for (const w of warnings) log(`warning: ${w}`);
221
+
222
+ if (!stamp || stamp === 'auto') stamp = await autoStamp(apiUrl, { fetchFn });
223
+ const root = await uploadFile(apiUrl, name, prepared, stamp, { fetchFn });
224
+ log(`pin: bzz://${root}/${name}`);
225
+
226
+ let feedInfo = null;
227
+ if (feed) {
228
+ if (!signer) {
229
+ throw new PublishError(
230
+ 'feed publishing needs a signer key: pass signer (the feed ' +
231
+ "owner's private key hex)");
232
+ }
233
+ feedInfo = await updateFeed(apiUrl, signer, feed, root, stamp, { fetchFn });
234
+ log(`feed: bzzf://${feedInfo.owner}/${feed}/${name}`);
235
+ } else {
236
+ log('tip: this URL names exactly this version. If the data will ' +
237
+ 'change, republish with a feed so readers get one stable URL');
238
+ }
239
+ return { root, warnings, feed: feedInfo };
240
+ }
package/src/verify.js ADDED
@@ -0,0 +1,267 @@
1
+ // Client-side verification: BMT chunk addressing, a verifying chunk
2
+ // store over GET /chunks, a verified tree reader, and single-owner
3
+ // chunk (feed update) verification. Line-for-line ports of swarmfs's
4
+ // bmt.py / join.py / feeds.py — the two implementations are held
5
+ // together by shared fixtures (js/test/fixtures/, generated BY swarmfs).
6
+ //
7
+ // This is what makes reads through an UNTRUSTED endpoint (a public
8
+ // gateway) actually trustless: a Swarm reference is the hash of the
9
+ // content, so a tampering gateway is caught on the first bad chunk.
10
+ // Against a local light node the node verifies natively and the plain
11
+ // Range-read path is faster — see open()'s `verify` option.
12
+
13
+ import { keccak256Bytes } from '../vendor/js-sha3/index.js';
14
+ import { Signature } from '../vendor/noble-secp256k1/index.js';
15
+
16
+ export const CHUNK_SIZE = 4096;
17
+ const SEGMENT = 32;
18
+ const REF_SIZE = 32;
19
+ const SOC_IDENTIFIER_SIZE = 32;
20
+ const SOC_SIGNATURE_SIZE = 65;
21
+ const SOC_SPAN_OFFSET = SOC_IDENTIFIER_SIZE + SOC_SIGNATURE_SIZE; // 97
22
+ const FETCH_TIMEOUT_MS = 30_000;
23
+
24
+ export class VerificationError extends Error {
25
+ name = 'VerificationError';
26
+ }
27
+
28
+ export const toHex = (bytes) =>
29
+ Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
30
+
31
+ export function fromHex(hex) {
32
+ const out = new Uint8Array(hex.length / 2);
33
+ for (let i = 0; i < out.length; i++) {
34
+ out[i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function concat(...parts) {
40
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
41
+ let off = 0;
42
+ for (const p of parts) { out.set(p, off); off += p.length; }
43
+ return out;
44
+ }
45
+
46
+ // ---- BMT addressing -------------------------------------------------------
47
+
48
+ /** Root of the binary Merkle tree over 32-byte segments of the payload,
49
+ * zero-padded to 4096 bytes. */
50
+ export function bmtRoot(payload) {
51
+ if (payload.length > CHUNK_SIZE) {
52
+ throw new VerificationError(`payload size ${payload.length} exceeds ${CHUNK_SIZE}`);
53
+ }
54
+ const padded = new Uint8Array(CHUNK_SIZE);
55
+ padded.set(payload);
56
+ let level = [];
57
+ for (let i = 0; i < CHUNK_SIZE; i += SEGMENT) {
58
+ level.push(padded.subarray(i, i + SEGMENT));
59
+ }
60
+ while (level.length > 1) {
61
+ const next = [];
62
+ for (let i = 0; i < level.length; i += 2) {
63
+ next.push(keccak256Bytes(concat(level[i], level[i + 1])));
64
+ }
65
+ level = next;
66
+ }
67
+ return level[0];
68
+ }
69
+
70
+ /** BMT address of a content-addressed chunk (data = span + payload). */
71
+ export function chunkAddress(data) {
72
+ return keccak256Bytes(concat(data.subarray(0, 8), bmtRoot(data.subarray(8))));
73
+ }
74
+
75
+ /** Little-endian span -> content length. Bee stores the erasure-coding
76
+ * redundancy level in the top byte (span[7] > 128). */
77
+ export function decodeSpan(span) {
78
+ let size = 0;
79
+ for (let i = 6; i >= 0; i--) size = size * 256 + span[i];
80
+ if (span[7] <= 128) size += span[7] * 256 ** 7;
81
+ return size;
82
+ }
83
+
84
+ // ---- verifying chunk store ------------------------------------------------
85
+
86
+ /** Fetches raw chunks via GET /chunks/{ref} and verifies each against
87
+ * the reference it was requested by. Content-addressed, so the cache
88
+ * never goes stale. */
89
+ export class VerifiedChunkStore {
90
+ #cache = new Map(); // ref hex -> Uint8Array (Map keeps LRU order)
91
+ #cacheChunks;
92
+ #fetch;
93
+ apiUrl;
94
+ stats = { chunksFetched: 0, bytesFetched: 0 };
95
+
96
+ constructor(apiUrl, { cacheChunks = 4096, fetchFn } = {}) {
97
+ this.apiUrl = apiUrl.replace(/\/+$/, '');
98
+ this.#cacheChunks = Math.max(1, cacheChunks);
99
+ this.#fetch = fetchFn ?? ((...args) => globalThis.fetch(...args));
100
+ }
101
+
102
+ /** One verified chunk (span + payload) by 64-hex reference. */
103
+ async chunk(refHex) {
104
+ const hit = this.#cache.get(refHex);
105
+ if (hit !== undefined) {
106
+ this.#cache.delete(refHex); // refresh LRU position
107
+ this.#cache.set(refHex, hit);
108
+ return hit;
109
+ }
110
+ const resp = await this.#fetch(`${this.apiUrl}/chunks/${refHex}`, {
111
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
112
+ });
113
+ if (!resp.ok) {
114
+ throw new Error(`HTTP ${resp.status} fetching chunk ${refHex.slice(0, 16)}…`);
115
+ }
116
+ const data = new Uint8Array(await resp.arrayBuffer());
117
+ if (toHex(chunkAddress(data)) !== refHex) {
118
+ throw new VerificationError(
119
+ `chunk ${refHex.slice(0, 16)}… failed verification: content does ` +
120
+ 'not hash to its address (tampering, corruption, or a bad endpoint)',
121
+ );
122
+ }
123
+ this.stats.chunksFetched += 1;
124
+ this.stats.bytesFetched += data.length;
125
+ this.#cache.set(refHex, data);
126
+ while (this.#cache.size > this.#cacheChunks) {
127
+ this.#cache.delete(this.#cache.keys().next().value);
128
+ }
129
+ return data;
130
+ }
131
+ }
132
+
133
+ // ---- verified tree reader -------------------------------------------------
134
+
135
+ function rootRef(refHex) {
136
+ if (refHex.length !== 64) {
137
+ throw new VerificationError(
138
+ `cannot verify reference ${refHex.slice(0, 16)}…: encrypted ` +
139
+ '(128-hex) references are not supported by the verifying reader yet',
140
+ );
141
+ }
142
+ return refHex;
143
+ }
144
+
145
+ /** Verified content length of a reference. */
146
+ export async function verifiedSize(store, refHex) {
147
+ return decodeSpan((await store.chunk(rootRef(refHex))).subarray(0, 8));
148
+ }
149
+
150
+ /** Verified read of [start, end) of a reference's content, descending
151
+ * only the subtrees the range needs. */
152
+ export async function verifiedRead(store, refHex, start, end) {
153
+ const chunk = await store.chunk(rootRef(refHex));
154
+ const size = decodeSpan(chunk.subarray(0, 8));
155
+ const s = Math.min(start ?? 0, size);
156
+ const e = end === undefined ? size : Math.min(end, size);
157
+ if (e <= s) return new Uint8Array(0);
158
+ const out = new Uint8Array(e - s);
159
+ await readTree(store, chunk, s, e, out, 0);
160
+ return out;
161
+ }
162
+
163
+ /** Append [start, end) of this subtree (both subtree-relative) into
164
+ * out at outPos; returns bytes written. An intermediate chunk's payload
165
+ * holds the data child references first (exactly ceil(span/unit)), then
166
+ * parity references when erasure coding is on — those are ignored.
167
+ *
168
+ * The per-child span `unit` is read from the FIRST child's own span
169
+ * rather than computed as a power of 128: with erasure coding the
170
+ * effective fanout is smaller (seen live: 107 data + 21 parity refs per
171
+ * intermediate on a redundancy-uploaded file), and every child but the
172
+ * last spans the same unit — a Bee tree invariant this walk verifies. */
173
+ async function readTree(store, chunk, start, end, out, outPos) {
174
+ const size = decodeSpan(chunk.subarray(0, 8));
175
+ const payload = chunk.subarray(8);
176
+ if (size <= CHUNK_SIZE) {
177
+ const piece = payload.subarray(start, end);
178
+ out.set(piece, outPos);
179
+ return piece.length;
180
+ }
181
+ if (payload.length < REF_SIZE) {
182
+ throw new VerificationError('intermediate chunk has no child references');
183
+ }
184
+ const first = await store.chunk(toHex(payload.subarray(0, REF_SIZE)));
185
+ const unit = decodeSpan(first.subarray(0, 8));
186
+ if (unit <= 0 || unit >= size) {
187
+ throw new VerificationError(
188
+ `first child spans ${unit} of a ${size}-byte subtree — not a valid tree`,
189
+ );
190
+ }
191
+ const nData = Math.ceil(size / unit);
192
+ if (payload.length < nData * REF_SIZE) {
193
+ throw new VerificationError(
194
+ `intermediate chunk carries ${Math.floor(payload.length / REF_SIZE)} ` +
195
+ `references but its span requires ${nData}`,
196
+ );
197
+ }
198
+ let written = 0;
199
+ for (let i = Math.floor(start / unit); i < nData; i++) {
200
+ const lo = i * unit;
201
+ if (lo >= end) break;
202
+ const hi = Math.min(lo + unit, size);
203
+ const child = i === 0
204
+ ? first
205
+ : await store.chunk(toHex(payload.subarray(i * REF_SIZE, (i + 1) * REF_SIZE)));
206
+ const childSpan = decodeSpan(child.subarray(0, 8));
207
+ if (childSpan !== hi - lo) {
208
+ throw new VerificationError(
209
+ `child chunk span ${childSpan} does not match its position in ` +
210
+ `the tree (expected ${hi - lo})`,
211
+ );
212
+ }
213
+ written += await readTree(
214
+ store, child, Math.max(start, lo) - lo, Math.min(end, hi) - lo,
215
+ out, outPos + written,
216
+ );
217
+ }
218
+ return written;
219
+ }
220
+
221
+ // ---- single-owner chunks (feed updates) -----------------------------------
222
+
223
+ /** 32-byte feed identifier: keccak256(topic ‖ index as 8-byte BE). */
224
+ export function feedIdentifier(topicBytes, index) {
225
+ const idx = new Uint8Array(8);
226
+ new DataView(idx.buffer).setBigUint64(0, BigInt(index), false);
227
+ return keccak256Bytes(concat(topicBytes, idx));
228
+ }
229
+
230
+ /** Address a single-owner chunk lives at: keccak256(identifier ‖ owner). */
231
+ export function socAddress(identifier, ownerBytes) {
232
+ return keccak256Bytes(concat(identifier, ownerBytes));
233
+ }
234
+
235
+ /** Full client-side check of a single-owner chunk: address derivation,
236
+ * and recovery of the owner from the signature over the wrapped chunk's
237
+ * BMT address — exactly what a Bee node checks on upload. Returns the
238
+ * wrapped chunk's payload (the feed update). */
239
+ export function verifySoc(data, ownerBytes, addressBytes) {
240
+ if (data.length < SOC_SPAN_OFFSET + 8) {
241
+ throw new VerificationError(`single-owner chunk too short: ${data.length} bytes`);
242
+ }
243
+ const identifier = data.subarray(0, SOC_IDENTIFIER_SIZE);
244
+ const sig = data.subarray(SOC_IDENTIFIER_SIZE, SOC_SPAN_OFFSET);
245
+ const cac = data.subarray(SOC_SPAN_OFFSET);
246
+ if (toHex(socAddress(identifier, ownerBytes)) !== toHex(addressBytes)) {
247
+ throw new VerificationError('single-owner chunk does not match its address');
248
+ }
249
+ const digest = keccak256Bytes(concat(identifier, chunkAddress(cac)));
250
+ const prefixed = keccak256Bytes(concat(
251
+ new TextEncoder().encode('\x19Ethereum Signed Message:\n32'), digest,
252
+ ));
253
+ let recovered;
254
+ try {
255
+ recovered = Signature.fromCompact(sig.subarray(0, 64))
256
+ .addRecoveryBit(sig[64] - 27)
257
+ .recoverPublicKey(prefixed)
258
+ .toBytes(false); // uncompressed: 0x04 ‖ x ‖ y
259
+ } catch (e) {
260
+ throw new VerificationError(`feed update signature is malformed: ${e}`);
261
+ }
262
+ const recoveredOwner = keccak256Bytes(recovered.subarray(1)).subarray(12);
263
+ if (toHex(recoveredOwner) !== toHex(ownerBytes)) {
264
+ throw new VerificationError('feed update signature was not made by the feed owner');
265
+ }
266
+ return cac.subarray(8); // the update payload (timestamp + reference)
267
+ }
@@ -0,0 +1,20 @@
1
+ Copyright 2015-2023 Chen, Yi-Cyuan
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,19 @@
1
+ # Vendored: js-sha3 0.9.3
2
+
3
+ Source: the `js-sha3` npm package, version 0.9.3
4
+ (https://github.com/emn178/js-sha3), MIT license (see LICENSE).
5
+
6
+ Vendored subset: `src/sha3.js` only (unmodified), plus our `index.js`
7
+ ESM facade. Used for keccak256: hashing human-readable feed topics
8
+ (`src/feeds.js`), and later chunk/BMT verification.
9
+
10
+ Vendored (rather than npm-installed) for the same reason as wa-sqlite:
11
+ demo pages must be fully self-contained to be published on Swarm —
12
+ no CDN, no registry, only relative URLs under one immutable root.
13
+
14
+ Sanity check: `keccak256('hello')` =
15
+ `1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8`
16
+ (the well-known Ethereum keccak vector; asserted in `test/feed.mjs`).
17
+
18
+ To upgrade: `npm pack js-sha3`, extract, copy `src/sha3.js` and
19
+ LICENSE, update the version here, re-run `node js/test/feed.mjs`.
@@ -0,0 +1,12 @@
1
+ // ESM facade over the unmodified UMD build: in ES-module scope neither
2
+ // `module` nor `define` exists, so sha3.js falls through to attaching
3
+ // its methods to the global object (window / self / global); re-export
4
+ // the one method swarmlite uses.
5
+ import './sha3.js';
6
+
7
+ /** keccak256 over a string or byte array -> lowercase hex (no 0x). */
8
+ export const keccak256 = globalThis.keccak_256;
9
+
10
+ /** keccak256 over a byte array -> Uint8Array (for BMT/SOC hashing). */
11
+ export const keccak256Bytes = (data) =>
12
+ Uint8Array.from(globalThis.keccak_256.array(data));