/** * EPB3 immutable Library contract (all page indices zero based). * * A page address is exactly 3,200 base-29 symbols, including leading zeroes. * Encoding maps each digit to the same-index symbol; decoding is its inverse. * Consequently the canonical concatenation of 410 pages is a bijection onto * all 29^(410*3200) digit volumes. There is no no-adjacent-space restriction. * * A recipe is an immutable alternative spelling of one such canonical volume: * each page is either its complete anchored page or a domain-separated seeded * filler page. Reading never patches in a separately stored submitted passage. * Distinct recipes may expand to the same volume: exact canonicalId equality, * not recipe equality or digest uniqueness, defines canonical identity. * * Frozen framing: candidate seed = BLAKE3(UTF8("EPB/v3/candidate-v1") || NUL * || u64LE(length) || digits || u64LE(instance)); page filler XOF hashes * UTF8("EPB/v3/page-fill-v1") || NUL || seed32 || u16BE(page), rejects bytes * >=232 and maps accepted bytes modulo29. Placement XOF hashes * UTF8("EPB/v3/placement-v1") || NUL || seed32 || UTF8("page"|"offset"); * rejection-sampled u32BE values are modulo their positive range. Digest * hashes UTF8("EPB/v3/canonical-identity-v1") || NUL || raw canonical digits. * Recipe JSON keys and ordering are v,kind,search,seed,anchors, wrapped in * canonical unpadded ASCII base64url. Golden vectors freeze all these choices. */ import { blake3 } from "@noble/hashes/blake3.js"; import { concat, hex, le64, utf8 } from "./bytes.js"; import { PAGE_LEN, PAGES_PER_VOLUME, RADIX, VOLUME_LEN } from "./constants.js"; export const EPB3_VERSION = "epb3"; export const EPB3_SEARCH_VERSION = "epb3-ranked1000-v1"; export const EPB3_PAGE_ALPHABET = "0123456789abcdefghijklmnopqrs"; const RECIPE_PREFIX = "epb3r/"; const CANONICAL_PREFIX = "epb3c/"; const MAX_RECIPE_LENGTH = 2_000_000; function assertDigits(digits, minimum, maximum) { if (!(digits instanceof Uint8Array) || digits.length < minimum || digits.length > maximum) throw new RangeError(`expected ${minimum}..${maximum} base-29 digits`); for (const digit of digits) if (digit >= RADIX) throw new RangeError("non-base-29 digit"); } function encodeDigits(digits) { const ascii = new Uint8Array(digits.length); for (let index = 0; index < digits.length; index++) ascii[index] = EPB3_PAGE_ALPHABET.charCodeAt(digits[index]); return new TextDecoder().decode(ascii); } function validateEncoded(value, length) { if (typeof value !== "string" || value.length !== length) throw new RangeError("invalid fixed-width base-29 address"); for (let index = 0; index < value.length; index++) if (EPB3_PAGE_ALPHABET.indexOf(value[index]) < 0) throw new RangeError("invalid base-29 character"); } function decodeDigits(value) { const digits = new Uint8Array(value.length); for (let index = 0; index < value.length; index++) digits[index] = EPB3_PAGE_ALPHABET.indexOf(value[index]); return digits; } export function encodeEpb3Page(digits) { assertDigits(digits, PAGE_LEN, PAGE_LEN); return encodeDigits(digits); } export function decodeEpb3Page(address) { validateEncoded(address, PAGE_LEN); return decodeDigits(address); } function base64url(value) { // Recipe JSON is ASCII: its frozen keys, seed and encoded pages are ASCII. return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); } function serializeRecipe(recipe) { return RECIPE_PREFIX + base64url(JSON.stringify(recipe)); } function parse(address) { if (typeof address !== "string") throw new RangeError("EPB3 address required"); if (address.startsWith(CANONICAL_PREFIX)) { const digits = address.slice(CANONICAL_PREFIX.length); validateEncoded(digits, VOLUME_LEN); return { kind: "canonical", digits }; } if (!address.startsWith(RECIPE_PREFIX) || address.length > MAX_RECIPE_LENGTH) throw new RangeError("invalid EPB3 recipe"); const encoded = address.slice(RECIPE_PREFIX.length); if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) throw new RangeError("invalid recipe base64url"); let object; try { object = JSON.parse(atob(encoded.replaceAll("-", "+").replaceAll("_", "/"))); } catch { throw new RangeError("invalid recipe JSON"); } if (object === null || typeof object !== "object" || Array.isArray(object)) throw new RangeError("invalid recipe object"); const r = object; if (Object.keys(r).join(",") !== "v,kind,search,seed,anchors" || r.v !== EPB3_VERSION || r.kind !== "anchor-pages-v1" || r.search !== EPB3_SEARCH_VERSION || typeof r.seed !== "string" || !/^[a-f0-9]{64}$/.test(r.seed) || !Array.isArray(r.anchors) || r.anchors.length < 1 || r.anchors.length > PAGES_PER_VOLUME) throw new RangeError("invalid recipe fields"); let last = -1; for (const anchor of r.anchors) { if (!Array.isArray(anchor) || anchor.length !== 2 || !Number.isInteger(anchor[0]) || anchor[0] <= last || anchor[0] >= PAGES_PER_VOLUME || typeof anchor[1] !== "string") throw new RangeError("invalid recipe anchor"); validateEncoded(anchor[1], PAGE_LEN); last = anchor[0]; } const recipe = object; if (serializeRecipe(recipe) !== address) throw new RangeError("noncanonical recipe serialization"); return { kind: "recipe", recipe }; } function seedBytes(seed) { return Uint8Array.from({ length: 32 }, (_, index) => Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16)); } function assertPage(page) { if (!Number.isInteger(page) || page < 0 || page >= PAGES_PER_VOLUME) throw new RangeError("page must be 0..409"); } /** Uniform rejection-sampled page, independent of every other page. */ function fillPage(seed, page) { const hash = blake3.create().update(concat(utf8("EPB/v3/page-fill-v1"), Uint8Array.of(0), seed, Uint8Array.of(page >>> 8, page & 255))); const digits = new Uint8Array(PAGE_LEN); let index = 0; while (index < digits.length) { const bytes = hash.xof(4096); for (const byte of bytes) if (byte < 232) { digits[index++] = byte % RADIX; if (index === digits.length) break; } } hash.destroy(); return digits; } function recipePage(recipe, page, seed) { const anchor = recipe.anchors.find(([anchorPage]) => anchorPage === page); return anchor ? decodeDigits(anchor[1]) : fillPage(seed, page); } function uniform(seed, label, range) { const hash = blake3.create().update(concat(utf8("EPB/v3/placement-v1"), Uint8Array.of(0), seed, utf8(label))); const limit = Math.floor(0x1_0000_0000 / range) * range; for (;;) { const b = hash.xof(4); const value = ((b[0] * 0x1000000) + (b[1] << 16) + (b[2] << 8) + b[3]) >>> 0; if (value < limit) { hash.destroy(); return value % range; } } } export function createEpb3Candidate(digits, instance) { assertDigits(digits, 1, VOLUME_LEN); if (!Number.isSafeInteger(instance) || instance < 0 || instance >= 1000) throw new RangeError("instance must be 0..999"); if (digits.length === VOLUME_LEN) return { address: CANONICAL_PREFIX + encodeDigits(digits), page: 0, pageEnd: PAGES_PER_VOLUME - 1, pageOffset: 0, instance: 0 }; const seed = blake3(concat(utf8("EPB/v3/candidate-v1"), Uint8Array.of(0), le64(digits.length), digits, le64(instance))); const pageCount = Math.ceil(digits.length / PAGE_LEN); const page = uniform(seed, "page", PAGES_PER_VOLUME - pageCount + 1); const pageOffset = digits.length <= PAGE_LEN ? uniform(seed, "offset", PAGE_LEN - digits.length + 1) : 0; const anchors = []; for (let index = 0; index < pageCount; index++) { const content = fillPage(seed, page + index); const sourceOffset = index * PAGE_LEN; content.set(digits.subarray(sourceOffset, Math.min(sourceOffset + PAGE_LEN, digits.length)), index === 0 ? pageOffset : 0); anchors.push([page + index, encodeEpb3Page(content)]); } const recipe = { v: EPB3_VERSION, kind: "anchor-pages-v1", search: EPB3_SEARCH_VERSION, seed: hex(seed), anchors }; return { address: serializeRecipe(recipe), page, pageEnd: page + pageCount - 1, pageOffset, instance }; } export function generateEpb3Page(address, page) { assertPage(page); const parsed = parse(address); return parsed.kind === "canonical" ? decodeDigits(parsed.digits.slice(page * PAGE_LEN, (page + 1) * PAGE_LEN)) : recipePage(parsed.recipe, page, seedBytes(parsed.recipe.seed)); } export function epb3Volume(address) { const parsed = parse(address); if (parsed.kind === "canonical") return decodeDigits(parsed.digits); const digits = new Uint8Array(VOLUME_LEN), seed = seedBytes(parsed.recipe.seed); for (let page = 0; page < PAGES_PER_VOLUME; page++) digits.set(recipePage(parsed.recipe, page, seed), page * PAGE_LEN); return digits; } export function encodeEpb3Canonical(digits) { assertDigits(digits, VOLUME_LEN, VOLUME_LEN); return CANONICAL_PREFIX + encodeDigits(digits); } export function expandEpb3Canonical(address) { return encodeEpb3Canonical(epb3Volume(address)); } export function epb3Identity(address) { const digits = epb3Volume(address); let remainder = 0; for (const digit of digits) remainder = (remainder * RADIX + digit) % 640; const canonicalId = CANONICAL_PREFIX + encodeDigits(digits); const digest = hex(blake3(concat(utf8("EPB/v3/canonical-identity-v1"), Uint8Array.of(0), digits))); return { canonicalId, digest, coordinates: { wall: Math.floor(remainder / 160), shelf: Math.floor((remainder % 160) / 32), volume: remainder % 32 } }; } export function verifyEpb3Hit(address, page, offset, digits) { try { assertPage(page); assertDigits(digits, 1, VOLUME_LEN); if (!Number.isInteger(offset) || offset < 0 || offset >= PAGE_LEN || page * PAGE_LEN + offset + digits.length > VOLUME_LEN) return false; const parsed = parse(address), seed = parsed.kind === "recipe" ? seedBytes(parsed.recipe.seed) : undefined; let cursor = 0; while (cursor < digits.length) { const currentPage = page + Math.floor((offset + cursor) / PAGE_LEN), within = (offset + cursor) % PAGE_LEN; const content = parsed.kind === "canonical" ? decodeDigits(parsed.digits.slice(currentPage * PAGE_LEN, (currentPage + 1) * PAGE_LEN)) : recipePage(parsed.recipe, currentPage, seed); const count = Math.min(PAGE_LEN - within, digits.length - cursor); for (let i = 0; i < count; i++) if (content[within + i] !== digits[cursor + i]) return false; cursor += count; } return true; } catch { return false; } }