Ledgdex is a Python package (ledgdex, in py/) and a JavaScript core (js/) that make the same bytes, ids, signatures and state. Signatures and descriptions below are generated from the code. Start with Example: Python API, Example: JavaScript and Extending Ledgdex; the format is in Ledger File Format and the specification.
Python: ledgdex.core, ledgdex.canon, ledgdex.state, ledgdex.dex, ledgdex.render, ledgdex.check, ledgdex.publish, ledgdex.cli, ledgdex.sig.
ledgdex.core: Messages, entries and ledgers: signing, verifying, reading a ledger (spec 2, 3).
class ledgdex.core.Ledger(data, cache=True, root=None)A parsed ledger. Verification stops at the first structurally invalid line (spec 3.3).
Verified prefixes are cached (in ~/.cache/ledgdex, by ledger id, length and SHA-256 of the exact bytes), so a ledger that only grew is verified only from where it was verified before. Any changed byte misses the cache and is verified in full. cache=False, or LEDGDEX_NO_CACHE=1, always verifies everything.
ledgdex.core.Ledger.append(self, entry)Verify entry on top of this ledger and add it. Raises Invalid if it is not valid.
ledgdex.core.Ledger.next_entry(self, secret, msg, at=None)A new signed entry recording msg on top of this ledger. Raises Invalid if it would not be valid.
ledgdex.core.Ledger.find(self, id_)Index of the entry with this entry id, or None.
ledgdex.core.Ledger.head(self)ledgdex.core.Ledger.owner (property)The current owner key (the header's, until a rotate or a recovery).
ledgdex.core.Ledger.devices (property)ledgdex.core.Ledger.dex (property)The dex address in the "open" entry ('' if none).
ledgdex.core.Ledger.whole (property)ledgdex.core.new_ledger(secret, name, about, dex_url, at=None)Header plus the "open" entry (spec 3.1, 5.1).
ledgdex.core.message(secret, type_, body, at=None, root=None)ledgdex.core.new_secret()ledgdex.core.public(secret)ledgdex.core.sign(secret, obj)ledgdex.core.verify(key, obj, sig)ledgdex.core.unsigned(obj)ledgdex.core.check_message(m, root=None)Raise Invalid unless m is a valid message (spec 2).
ledgdex.core.check_body(type_, body, root=None)ledgdex.core.check_receipt(body, root=None)A receipt verifies on its own (spec 5.1, invariant 10): the entry is signed by the other ledger's header key, or by a key that the key entries in "keys" (rotate, device, device_revoke, recovered, in order) lead to. A "recovered" key entry is checked against the root, as in the ledger itself: without it anyone could hand the other ledger to a key of their own and sign a false receipt.
ledgdex.core.root_recovers(root, ledger_id, e, prior=())Spec 5.7: a "recovered" entry e of ledger ledger_id is signed by the new key, which a "recover" entry in the root names. A recover entry is spent by its first "recovered" (prior: the ledger's earlier key entries). Nothing the ledger's own keys do can void it: they may be a thief's, and entry times are theirs to choose. Raises Invalid otherwise.
ledgdex.core.is_key(v)ledgdex.core.is_id(v)ledgdex.core.is_time(v)ledgdex.core.now()ledgdex.core.seconds(t)ledgdex.core.utc(s)class ledgdex.core.Keys(owner)A ledger's signing keys as its entries change them (spec 5.1, 5.7, 6.1).
class ledgdex.core.InvalidInappropriate argument value (of correct type).
ledgdex.core.SKEW = 300ledgdex.core.OWNER_ONLY = {'device', 'device_revoke', 'open', 'recover', 'rotate'}ledgdex.core.COUNTERPARTY = {'bid', 'claim', 'confirmed', 'dispute', 'paid', 'reveal', 'ruling'}ledgdex.core.KEY_TYPES = {'device', 'device_revoke', 'recovered', 'rotate'}ledgdex.canon: Canonical JSON: the exact bytes that are hashed and signed (spec 1.2).
ledgdex.canon.canon(v, keys=re.compile('[a-z][a-z0-9_]*\\Z'))canon(x): canonical JSON bytes. keys=ID_KEY only for the state object, whose keys are ids.
ledgdex.canon.parse(data)Parse bytes that MUST already be canonical JSON. Returns the value or raises CanonError.
ledgdex.canon.hash_(v)hash(x) = "sha256:" + hex(SHA-256(canon(x))).
ledgdex.canon.sha256(data)ledgdex.canon.check(v, keys=re.compile('[a-z][a-z0-9_]*\\Z'), depth=1)Raise CanonError unless v follows rules 1-3 and 9.
class ledgdex.canon.CanonErrorInappropriate argument value (of correct type).
ledgdex.canon.MAX_DEPTH = 32ledgdex.canon.MAX_INT = 9007199254740991ledgdex.state: The state function: a ledger replayed into one JSON object (spec 6).
ledgdex.state.state(led, root=None, now=None)Replay a parsed Ledger. root: the root Ledger, for "allow": "admitted" (spec 5.7). now: the time auction statuses are judged at (default: the time of the last entry).
ledgdex.state.judged_at(m, entry_time)When a claim or bid is judged against the root (spec 5.7), in seconds: the later of the time its author signed it (msg.at, which the seller cannot move) and the time the seller recorded it less the allowed skew (which the buyer cannot move back: a revoked key cannot buy by dating its claim before the revoke). Never a later root entry: a past claim stays as it was judged.
ledgdex.state.admissions(root)The root's admit and revoke entries in order, as {key: [(seconds, admitted)]} (a message recorded twice counts once, as in 6.2). A claim or bid is judged by the root's admissions at judged_at() (spec 5.7).
ledgdex.state.admitted_at(admits, key, t)Whether the root had admitted key at time t (seconds).
ledgdex.dex: A ledgdex on disk: keys, this dex's ledger, and reading other ledgers.
ledgdex.dex.load(dex, root=None)ledgdex.dex.record(dex, msgs, at=None, secret=None, root=None, skipped=None)Record messages in this dex's ledger. Only appends. Returns the entry ids. Signed with secret, or with the key signer() picks. skipped: a list to collect (message, reason) for messages that cannot be recorded (one counterparty's message signed in the future must not stop the rest); without it, the first one raises.
ledgdex.dex.fetch(src, cache=True, root=None, remote=False)Read a ledger from a dex folder, a file, or a dex URL. Returns (Ledger, url). remote: src came from a ledger (a receipt, a listing), so besides http(s) only a dex folder or a .jsonl file is read (ledgers kept on one machine), never any other local file.
ledgdex.dex.http_get(url, limit=None)The bytes at an http(s) address, at most limit (default MAX_BYTES) of them.
ledgdex.dex.load_root(dex)The root ledger named in config.json ("ledgdex": {"root": SOURCE, "root_id": ID}), or None.
An address can start serving another ledger (its hosting or DNS changes hands), so the root is pinned by ledger id: the first time it is read, its id is written to "root_id" (trust on first use), and from then on a root of another id, or a broken one, is refused. A root that rotates or recovers its key keeps its id. The last root read is kept in the dex folder: when the address cannot be read, work goes on with it, and an address serving an older copy (which could hide a revoke or a recovery) is ignored for it.
ledgdex.dex.keygen(name)Write a new secret key to ~/.ledgdex/NAME.key (mode 600) and return its public key.
ledgdex.dex.load_key(name)ledgdex.dex.find_key(pub)The secret key in ~/.ledgdex whose public key is pub.
ledgdex.dex.signer(led, owner_only=False)The local secret key to append to led with: an active device key when this machine has one, else the owner key. owner_only: the owner key itself (owner-only types, and messages sent to other ledgers, which carry the self's identity).
ledgdex.dex.key_dir()ledgdex.dex.locked(dex)One writer at a time: two appends at once would both take the next seq, and the second would break the ledger. The lock file stays in the dex folder (dexweb publishes only gen/).
ledgdex.dex.write(dex, data)Replace the ledger file (only for re-sequencing unpublished entries, spec 3.5). The caller holds locked(dex).
ledgdex.dex.inside(folder)Run with folder as the working folder (dexweb works on the current folder).
ledgdex.dex.LEDGER = 'ledgdex.jsonl'ledgdex.dex.MAX_BYTES = 67108864ledgdex.dex.ROOT_COPY = '.ledgdex-root.jsonl'ledgdex.render: Every ledgdex is a dex: the pages ledgdex generates, and the dexweb build (spec 7).
ledgdex.render.render(dex, build=True)Spec 7.3: rewrite the generated pages in data.json, copy the ledger into gen/, build with dexweb.
ledgdex.render.create_dex(dex, data, dexname, publish=None)A complete dex (spec 7.1) around the ledger bytes `data`: config.json with dexweb's own template and "publish" with "append_only", data.json, run.py, the ledgdex styles.css, then render. Keeps each of these files that is already there.
ledgdex.render.pages(led, author_titles)The generated pages (spec 7.2) for a whole ledger, as dex page objects.
ledgdex.render.dex_name(name)A dex name dexweb can write into its pages as it is (it does not escape it, and upper-cases it, which no escaping survives): without the characters HTML gives meaning to, or control characters.
ledgdex.render.safe_url(u)Only http(s) addresses become links: a ledger names them, so a javascript: or data: address must not.
ledgdex.render.esc(s)ledgdex.render.amount(currency, n)INR 20,000.00 (2000000). Currencies without known minor units are written as the integer.
ledgdex.render.MARKER = '<!-- ledgdex -->'ledgdex.check: Tamper checks over time (spec 9.1).
ledgdex.check.check(sources, state_dir='.ledgdex-check', media=False, published=False, log=<built-in function print>, root=None)class ledgdex.check.Checker(state_dir='.ledgdex-check', media=False, log=<built-in function print>, root=None)ledgdex.check.workflow(sources, cron='17 6 * * *', media=False, version=None)The workflow file. Sources are shell-quoted and the cron is checked, so neither can add steps; ledgdex is installed from a release tag, not from a moving branch.
ledgdex.publish: Publishing with dexweb, from one or more devices (spec 7.4).
ledgdex.publish.publish(dex, at=None)Spec 7.4. True when every entry in the ledger is published.
ledgdex.publish.catch_up(dex, old, at=None)Put this dex's unpublished entries on top of the published ledger `old` (bytes). Spec 3.5 rule 3.
ledgdex.cli: The command line, also callable from Python.
ledgdex.cli.main(argv=None)ledgdex.cli.parser()ledgdex.cli.next_at(led, secret)The time to sign a new message with: now, or one second after this key's last message in the ledger (kept or sent) if that is later. A message recorded twice counts once (spec 6.2), so two messages with the same body must differ in "at": two claims for the same offer within one second are two claims, not one.
ledgdex.sig: Ed25519 backends: the cryptography package when present, else the built-in code.
ledgdex.sig.backend (object: .name, .public_key(secret), .sign(secret, msg), .verify(public, msg, signature))The Ed25519 backend in use: the cryptography package (name "cryptography") when it is installed and passes a known-answer test, else the built-in code (name "pure"). LEDGDEX_PURE=1 forces the built-in code. Both verify identically.
ledgdex.sig.pure (object: .name, .public_key(secret), .sign(secret, msg), .verify(public, msg, signature))The built-in Ed25519 (name "pure"), always available. Not constant time when signing.
ledgdex.sig.sign(secret, msg)Sign with the chosen backend. The vendored code is not constant time: say so once, unless asked for.
JavaScript (ES modules, no dependencies; where it runs and what it is for: JavaScript Core; served next to the viewer, e.g. https://ledgdex.github.io/viewer/core.js). Asynchronous twins (messageA, newLedgerA, nextEntryA) take a signer from sig.js (Web Crypto).
core.js: Keys, messages, entries and ledger verification (spec 1-5). Mirrors py/ledgdex/core.py.
SKEW
class Invalid
newSecret()
publicKey(secret)
sign(secret, obj)
verify(key, obj, sig)
isKey(v)
isId(v)
isTime(v)
seconds(t)
utc(s)
now()
has(o, k)
BODIES
COUNTERPARTY
OWNER_ONLY
KEY_TYPES
class Keys
checkBody(type, body, root = null)
checkMessage(m, root = null)
rootRecovers(root, ledgerId, e, prior = [])
checkReceipt(body, root = null)
unsigned(obj)
message(secret, type, body, at, root = null)
signA(signer, obj)
messageA(signer, type, body, at, root = null)
class Ledger
newLedger(secret, name, about, dex, at)
newLedgerA(signer, name, about, dex, at)canon.js: Canonical JSON (spec 1.2): the exact bytes that are hashed and signed.
KEY
ID_KEY
MAX_DEPTH
class CanonError
check(v, keys = KEY, depth = 1)
canonString(v, keys = KEY)
canon(v, keys = KEY)
sha256id(bytes)
hash(v)
parse(bytes)state.js: The state function (spec 6): replay a ledger into one JSON object. Mirrors py/ledgdex/state.py.
admissions(root)
judgedAt(m, entryTime)
admittedAt(admits, key, t)
state(led, root = null, now = null)render.js: Every ledgdex is a dex (spec 7.2): the generated dex pages for a ledger. Mirrors py/ledgdex/render.py pages(), byte for byte (checked against vectors/pages.json).
MARKER
LEDGER
esc(s)
amount(currency, n)
short(s)
code(s)
htmlTitle(t)
dexName(name)
safeUrl(u)
pages(led, authorTitles = [])sig.js: Signers for the browser (spec Part III): Web Crypto's Ed25519 when the browser has it and it passes a known-answer test, else the vendored code (ed25519.js). Web Crypto signs in constant time and derives the public key itself, so the secret never goes through the vendored arithmetic. Verification stays with the vendored code everywhere, so every browser judges a ledger the same way. A signer is {name, public, sign(bytes) -> Promise<Uint8Array>}.
vendoredSigner(secret)
webCryptoSigner(secret)
webCryptoWorks()
signer(secret)keystore.js: Keeping secrets in the browser, encrypted (Web Crypto). A secret key is stored only sealed with a passphrase: PBKDF2-SHA-256 (600,000 rounds, 16-byte salt) makes an AES-256-GCM key that encrypts it. Bid secrets (amount and nonce, until the reveal) are sealed with a key made from the secret key itself, so they need no passphrase and cannot be read without the key that bid. Nothing secret is stored in the clear.
ROUNDS
MIN_PASSPHRASE
sealKey(secret, pass, pub, rounds = ROUNDS)
openKey(box, pass)
sealWith(secret, value)
openWith(secret, box)dexweb.js: A complete dex in the browser (spec 7.1), the same files "ledgdex init" makes with dexweb: py/ledgdex/render.py create_dex() and dexweb's dexgen. Checked file for file against dexs dexweb built (vectors/dex/).
RUN_PY
pyJson(v, indent = null, ascii = true, depth = 0)
buildGen(data, config)
makeDex(data, dexname, template, publish = {})zip.js: A ZIP file (stored, no compression) from {path: Uint8Array} and empty folders, for downloading a whole dex.
zip(files, dirs = [], prefix = '')