Zeus proof and analysis toolkit
Zeus moves real bitcoin. CONTRIBUTING.md requires tests that would have caught the bug ("Test Coverage") and proof-of-work evidence for first-time contributors; this skill generalizes that into an evidence discipline: correctness claims come with an enumeration, a trace, a test, or a byte-for-byte comparison. This skill is the catalog of the analysis methods that produce that evidence, each written as a recipe with a worked example from this repo's actual history (all commit hashes verified against master).
"Prove it, don't just install it" here means: before you claim a fix works, you can name the full space of states/interleavings/backends/inputs it must handle, show which cell of that space the bug lived in, and show a check (test, table, diff) that would have caught it and now guards it.
When to use / When NOT to use
Use this skill when you must establish that something is correct: a race is closed, a migration is safe, a dispatch is complete, an amount path is exact, a decoder matches the spec, a fix cannot regress, a build is reproducible.
Do NOT use this skill for:
| Need | Use instead |
|---|---|
| How changes are classified/gated, PR and review rules, storage-change sign-off | zeus-change-control |
| Writing/locating migrations, keychain contract details, iCloud rules | zeus-storage-and-migrations |
| The 7-backend capability matrix and per-backend quirks | zeus-backends-and-capabilities |
| What happened historically (full incident chronicle) | zeus-failure-archaeology |
yarn verify anatomy, jest configuration traps, adding tests mechanically |
zeus-validation-and-qa |
| Measuring at runtime: logs, dev tools, inspection scripts | zeus-diagnostics-and-tooling |
| The live node-lifecycle-races campaign (decision-gated work plan) | zeus-node-lifecycle-campaign |
| Lightning/Bitcoin domain theory (BOLT11 fields, LSPS, Cashu semantics) | zeus-lightning-reference |
| Evidence bar / idea lifecycle / adversarial refutation discipline | zeus-research-methodology |
This skill owns the methods; siblings own the facts those methods operate on.
Terms used below (defined once)
- sat / msat — satoshi (1e-8 BTC) / millisatoshi (1/1000 sat). Lightning protocols speak msat; most Zeus UI speaks sats. Every amount variable has exactly one of these units.
- BOLT11 — the Lightning invoice encoding spec (bech32 string starting
lnbc…). TLV — type-length-value binary encoding used in Lightning custom records. - LSP — Lightning Service Provider; sells inbound channels. Zero-conf fee — the fee an LSP deducts for a just-in-time channel, subtracted from the invoice amount.
- Backend — one of Zeus's 7 node implementations dispatched through
utils/BackendUtils.ts(lnd,embedded-lnd,ldk-node,lightning-node-connect,cln-rest,lndhub,nostr-wallet-connect). - Keychain — the OS secure credential store (iOS Keychain / Android Keystore), Zeus's canonical persistence via
storage/index.ts. Settings blob — the single JSON document under keyzeus-settings-v2holding all wallets and settings. - MobX observable — reactive state object; Zeus stores are MobX classes, views re-render when observables change.
- Tokio — the Rust async runtime inside the LDK Node native library. Dropping its last reference on one of its own worker threads panics Rust ("runtime dropped from within a runtime") and kills the app.
- FFI / UniFFI — foreign-function interface; the generated Kotlin/Swift bindings that bridge React Native to Rust (LDK Node, Cashu).
- One-shot migration flag (MOD_KEY) — a key in legacy EncryptedStorage marking that a settings migration already ran, so it never runs twice.
Recipe 1 — Race / interleaving analysis
When to use: any crash or corruption that only reproduces "sometimes"; any change to node start/stop/delete, wallet create/switch, or views/Wallet/Wallet.tsx connection logic; any native module holding a long-lived resource (node handle, file, socket).
Steps
- Enumerate the trigger events. In Zeus the recurring set is:
- Wallet screen focus —
handleFocusinviews/Wallet/Wallet.tsxrunsgetSettingsAndNavigate()→fetchData()on Wallet-screen focus wheninitialLoad,posWasEnabled, ortriggerSettingsRefreshis set (guarded by an in-flight_navigatingflag). - App foreground —
handleAppStateChange('active')in the same file, again callinggetSettingsAndNavigate(). - Wallet creation —
createOnboardingWalletinutils/WalletCreationUtils.ts(may build the node itself, then setssettingsStore.walletJustCreated = trueso Wallet.tsx skips re-init). - Wallet deletion / switch —
views/Settings/WalletConfiguration.tsx(stop node, delete on-disk dirs, updatesettings.nodes). - Reconnect —
SettingsStoreclearsfetchLockwhen connection settings change (seefetchLock = falsenear the bottom ofstores/SettingsStore.ts).
- Wallet screen focus —
- Enumerate the shared mutable state each event touches: the native node reference (Kotlin/Swift module field),
SettingsStore.connecting(initializedtrue),SettingsStore.fetchLock,SettingsStore.walletJustCreated, on-disk node directories, and the ~12 storesfetchDataresets. Grep the guards:grep -n "fetchLock\|walletJustCreated\|connecting" views/Wallet/Wallet.tsx | head -30 - Build the interleaving table. Rows = ordered steps of operation A (e.g. delete:
stop node → release ref → delete dirs → clear settings); columns = every other event that can fire between two rows (focus, foreground, create, second delete). For each cell ask: what state does the interloper observe, and is that state valid? - Classify each unsafe cell: needs serialization (lock/flag/blocking await), or can be tolerated (retry on a known transient error), or is unreachable (prove why — e.g.
fetchLockearly-return at the top offetchData). - Verify with a discriminating experiment — add temporary interleaving logs (the repo's own pattern: the
[Wallet] handleFocus: triggering getSettingsAndNavigate (…connecting=…)lines in Wallet.tsx were added exactly for this) and force the ordering by hand (delete-then-immediately-recreate; background/foreground during startup). See zeus-diagnostics-and-tooling for log capture.
Worked example — the LDK Node delete crash (d416052cd + 0e2b89164, both 2026-03-10) and buildNode race (b54e8f138 + 8fc9698a0, 2026-05-17).
Symptom: deleting an LDK Node wallet crashed the app in Rust. Root cause: the native stop handler released the last reference to the Rust Node object on a Tokio worker thread, so the Tokio runtime destructor ran on its own thread → panic. The interleaving table that would have caught it has one row that native code hid from JS: "who drops the final Arc reference, and on which thread?" — a critical section invisible at the TypeScript layer.
The fix history is itself a lesson in incomplete interleaving analysis:
d416052cdreleased the ref off-thread but resolved the JS promise immediately, stopping the node in the background. That opened a new unsafe cell: delete-then-create — JS proceeded to delete wallet files and build a new node while the old one was still stopping.0e2b89164, same day, closed that cell: block onstop()(on a dedicated non-Tokio thread) before resolving, hold the reference ~2s afterward so internal Tokio tasks drop their references first, and add anNSLockaround the node field on iOS. Read both diffs:git show d416052cd/git show 0e2b89164.
The buildNode race (fixed 2026-05-17 in two commits three minutes apart): createOnboardingWallet builds the node, then Wallet.tsx fetchData re-runs on focus and calls into the module while native buildNode has cleared the node reference up-front and is rebuilding on a background queue — every call in that window rejects with "Node not initialized". Fix = tolerate rather than serialize: b54e8f138 added waitForLdkNodeReady in utils/LdkNodeUtils.ts, which retries status() every 500 ms treating "Node not initialized" and "not running yet" as retryable; 8fc9698a0 added the walletJustCreated flag to skip redundant re-init. The docstring on waitForLdkNodeReady documents the race explicitly — that is the standard: a tolerated race must be written down at the tolerance point.
What counts as done: the table exists in your PR description or analysis notes; every event×step cell is labeled serialized / tolerated (with the retryable error named) / unreachable (with the guard named); thread-affinity of native resource release is stated explicitly; you reproduced the bad interleaving at least once before the fix and not after.
Recipe 2 — Migration correctness proof
When to use: any change to utils/MigrationUtils.ts, stores/SettingsStore.ts load/save paths, keychain keys, or default values for existing users. Note: these changes are gated — maintainer sign-off plus a migration plan is mandatory (see zeus-change-control); this recipe produces the proof that goes in that plan.
Steps
- Enumerate the persisted-state space (rows). Minimum rows for Zeus:
# State on disk before app launch 1 Fresh install — nothing stored 2 Legacy blob only — zeus-settingsin EncryptedStorage, no v23 v2 blob — zeus-settings-v2in keychain (the common case)4 iCloud-restored device — old un-prefixed cloud-synced keychain entries exist, local zeus:entries do not5 Multi-wallet — settings.nodes[]has several entries, mixed implementations6 Post-duress — settings.nodesnulled but on-disk node dirs / Cashu DB / orphaned keychain copies surviveAdd rows for any state your change introduces. Columns = each migration function, in actual execution order. - Pin the execution order from the code, not from memory. Verified order in
SettingsStore.getSettings:keychainCloudSyncMigration()runs first, before any settings read → then tryStorage.getItem('zeus-settings-v2')→ if present:migrateRgsDefaultToZeus+migrateInvoiceExpiryDisplayrun on the parsed object → else fall back to legacyzeus-settings→legacySettingsMigrations()(the MOD_KEY chain) →storageMigrationV2(). Consequence: a repair placed only inlegacySettingsMigrationsnever runs for any user already on v2.grep -n "keychainCloudSyncMigration\|legacySettingsMigrations\|storageMigrationV2\|migrateInvoiceExpiryDisplay" stores/SettingsStore.ts - Prove each cell terminates in a valid state. For every row × column: does the migration fire? If yes, is the result valid AND is the source state gone or harmless? If no, is skipping correct for that row?
- Prove one-shot flag idempotency. Each MOD_KEY block must be a no-op on second run (flag read → skip) and the flag must be set only after the mutation persisted (
await settingsStore.setSettings(...)— un-awaited setSettings was a real bug, fixed ind8e648b58). Run the mental test "app killed between mutation and flag write": re-running the mutation must be safe. - Check the two poison patterns: (a) passing a
JSON.stringify'd payload tosetSettings— corrupts the MobX observable (issue #4150, fixed7ed901a10, guarded by a test inutils/MigrationUtils.test.tsasserting the persisted argument is not a string); (b) shallowupdateSettingsmerges destroying sibling keys in nested groups (see zeus-storage-and-migrations).
Worked example A — the #4149 double-repair (missing column cell). 26d4215ea (2026-05-30) added the "3600 hours" invoice-expiry display repair as MOD_KEY9 = 'invoices-expiry-display-fix' inside legacySettingsMigrations — which only executes when the v2 blob is absent (row 2). Every already-upgraded user (row 3, the majority) never ran it. The repair had to be shipped a second time: e8e5b8811 (2026-06-01) moved it onto the v2 load path as the shared migrateInvoiceExpiryDisplay (removing the v1 MOD_KEY9 block) — under the ORIGINAL flag key. A third repair for pre-Feb-2024 installs that never stored expirySeconds then bumped the flag to invoices-expiry-display-fix-v2 (a7860aa96, merged f7b2c30a8, PR #4149, 2026-06-08). The 6×N table makes the first miss a mechanical catch: cell (row 3 × repair) was empty. Bonus: the first attempt also passed JSON.stringify(newSettings) to setSettings — poison pattern (a) — later fixed by 7ed901a10.
Worked example B — the iCloud keychain saga (missing row). The dated timeline with hashes is owned by zeus-failure-archaeology FA-1 — cite that, don't retell it. The proof-relevant lesson: the Dec 2025 migration rewrote keychain entries as local-only (cloudSync: false) but never deleted the old kSecAttrSynchronizable cloud copies, and row 4 of the table ("iCloud-restored new device") was never analyzed — on a new device, iCloud repopulated the old entries and the migration faithfully resurrected stale wallet data. Deletion attempts followed and were ultimately disabled. Current master policy: deleteFromOldKeychain in utils/MigrationUtils.ts is a deliberate no-op ("safety measure"); orphaned pre-migration copies persist by design and utils/KeychainRecoveryUtils.ts depends on them. Do not "fix" this — it is a policy decision, and reversing it goes through change control.
The lesson for your table: a migration's source state is a live replica if the OS can regenerate it (iCloud sync). "Copied to new location" does not empty the source cell; either delete it (with its own proof) or document it as a permanent row.
What counts as done: the full table with every cell justified; idempotency argument for each new flag (including kill-between-steps); explicit statement that both the legacy and v2 load paths are covered (or why one is N/A); jest cases in utils/MigrationUtils.test.ts for the new blocks; the plan reviewed under the storage-change gate.
Recipe 3 — Dispatch-completeness audit
When to use: adding a method to utils/BackendUtils.ts; adding a supports* capability; investigating a crash like false.then is not a function or a feature silently doing nothing on one backend.
Background (verified): BackendUtils.call(funcName) returns false synchronously when the active backend class lacks the method — no throw, no rejected promise (utils/BackendUtils.ts, the if (!cls[funcName]) return false; line). EmbeddedLND and LndHub extend LND, so a method or flag absent from those two files is inherited from LND, not absent. The other four backends (CLNRest, LdkNode, LightningNodeConnect, NostrWalletConnect) are standalone.
Steps — the exact greps
# 1. Does the dispatcher wrapper exist? (a typo'd name silently no-ops)
grep -n "myMethod" utils/BackendUtils.ts
# 2. Who implements or overrides it? Absence in EmbeddedLND.ts/LndHub.ts = inherited from LND.ts
grep -n "myMethod" backends/*.ts
# 3. Confirm the inheritance edges (do not assume)
grep -n "extends" backends/*.ts # EmbeddedLND extends LND; LndHub extends LND
# 4. Every call site, and what gates it
grep -rn "BackendUtils.myMethod(" views/ components/ stores/ utils/
# 5. For each call site: is it reachable only behind a supports* gate?
grep -rn "supportsMyFeature" views/ components/ stores/
Build the 7-row table for the method: for each of lnd, embedded-lnd, ldk-node, lightning-node-connect, cln-rest, lndhub, nostr-wallet-connect, label the method implemented (own definition), deliberately absent (explicit => false override or no definition on a standalone class, with a gate protecting every call site), or inherited-leak (subclass of LND with no override where LND's behavior is wrong for it). Then prove every view call site is unreachable when the capability is false.
Worked example — the LndHub supportsChannelFundMax inherited-leak (present on master today). Grep output: explicit => true in LND.ts, EmbeddedLND.ts, CLNRest.ts, LightningNodeConnect.ts, LdkNode.ts; explicit => false in NostrWalletConnect.ts; no override in LndHub.ts → LndHub inherits true from LND even though a custodial LNDHub account cannot fund channels at all. The only view call site is views/OpenChannel.tsx, and the leak is currently latent because LndHub sets supportsChannelManagement = () => false, which gates every route into that screen — but the flag itself is wrong, and any new call site trusting supportsChannelFundMax() alone would misbehave on LndHub. Classify, then either fix the override or record the masking gate. (Cross-backend crash history and the full capability matrix: zeus-backends-and-capabilities.)
Second pattern worth grepping for — dead dispatch: payLightningInvoiceStreaming is declared in utils/BackendUtils.ts but implemented by zero backends (grep -rn "payLightningInvoiceStreaming" backends/ returns nothing), so it always returns false. A completeness audit of a new method should confirm you did not just add another one of these.
What counts as done: the 7-row table with a file:line evidence pointer per row; every call site listed with its gate; each inherited-leak either fixed (override added) or documented with the exact masking condition; for LND-family version-gated flags, a note on behavior before getNodeInfo resolves (version is unknown then).
Recipe 4 — Amount / fee arithmetic verification
When to use: anything that computes, converts, or displays an amount or fee — invoice creation, LSP wrapping, routing-fee limits, swaps, Cashu melts.
Steps
- Unit-trace the path. Write every variable in the path with its unit annotated (
value: sats (string),amount_msat: msat (number), …). Each×1000/÷1000must sit at a named boundary (usually the wire). A path with an unannotatable variable is unproven. - BigNumber everywhere. Amount arithmetic uses
bignumber.js(imported in 16+ utils/stores incl.AmountUtils,Bolt11Utils,FeeStore,InvoicesStore). Never use float math on msat values; msat quantities exceed float-safe display assumptions and decimal sats (e.g.123.456sats) are legal inputs. - Test the boundary values:
0,1,999,1000,1001sats (the fee-rule boundary), amounts with msat remainders (x.5sats), and fee == amount. - Know the fee rule (verified in
utils/FeeUtils.ts):calculateDefaultRoutingFee(amount)returnsamount * 0.05rounded (DEFAULT_ROUTING_FEE_PERCENT = 0.05) whenamount > 1000sats, but returns the full amount (i.e. a 100% fee limit) whenamount <= 1000— tiny payments may cost up to themselves in routing fees.views/PaymentRequest.tsxmirrors the same boundary:requestAmount > 1000selects the percent fee option, otherwise fixed. Tests pinning this:utils/FeeUtils.test.ts(1000 → '1000',10000 → '500'). - Check error-shape traps on the same path — e.g. LND REST payment timeouts resolve success-shaped with
{payment_error: …}(see zeus-backends-and-capabilities); an amount proof that ends at "the call resolved" has not proven payment.
Worked example — the Flow LSP wrapped-invoice amount mutation (stores/InvoicesStore.ts + stores/LSPStore.ts, verified on master). Unit trace for "user requests an invoice for value sats with LSP enabled":
| Step | Code | Unit |
|---|---|---|
| 1 | user input value |
sats (string) |
| 2 | lspStore.getZeroConfFee(Number(new BigNumber(value).times(1000))) |
msat over the wire (amount_msat in the POST body) |
| 3 | LSPStore stores zeroConfFee = parseInt(fee_amount_msat / 1000) |
back to sats |
| 4 | if value > zeroConfFee: req.value = new BigNumber(value).minus(zeroConfFee) |
sats — the created invoice is for less than the user asked |
| 5 | getZeroConfInvoice(...) wraps it; jit_bolt11 replaces the payment request |
— |
| 6 | on wrap failure: silently fall back to the unwrapped invoice (error state cleared) | — |
| 7 | lnurl-withdraw callback uses `qs.pr = jit_bolt11 |
Three provable claims fall out: (a) the msat↔sat conversions occur exactly at steps 2–3; (b) the amount the payer pays ≠ the amount the node receives, by design — any display or bookkeeping code comparing them must add zeroConfFee back; (c) step 6 means "invoice created" does not imply "channel will be opened" — degradation is silent. If your change touches this path, your proof must state which of the three it preserves.
What counts as done: a unit-annotated trace table like the above in the PR; BigNumber (or integer msat) for every operation; a colocated jest test exercising the boundary set from step 3; no float appears between user input and wire encoding.
Recipe 5 — Crypto / protocol conformance
When to use: writing or modifying any encoder/decoder or key-derivation function — invoice parsing, TLV records, bech32 variants, signature schemes, seed→key paths.
Steps
- Prefer the spec's own vectors. BOLT11 ships worked examples (
11-payment-encoding.mdin the lightning/bolts repo). Pin them as constants with a comment citing the source. - When the spec has no vectors, capture them from a reference implementation (lnd/CLN decode output, or the library version currently in production) and pin the exact hex. The vector then defines the contract across future library swaps.
- Test failure modes too: truncated input, wrong prefix, bad checksum — assert the exact error.
- If the decoder caches or lazies, test that behavior explicitly — caching bugs are conformance bugs (a mutated cached object corrupts every later decode).
Where the known-good vectors live in this repo (all verified):
| File | What it pins |
|---|---|
utils/Bolt11Utils.test.ts |
BOLT11 spec reference vectors (the lnbc2500u hashed-description example and the no-amount example, with source URL in comments), including the expected payee pubkey BOLT11_SPEC_PAYEE recovered via secp256k1 signature recovery; regtest/signet fixtures; amount-prefix parsing (1230n = 123 sats = 123000 msat); lazy-getter semantics — a jest.spyOn(secp, 'recoverPublicKey') asserts recovery runs zero times unless destination is read, and exactly once when read repeatedly |
utils/SigningUtils.test.ts |
secp256k1 DER-signature vectors, including the canonical privKey = 1 → pubkey = G, hash = SHA256("") case, plus invalid-key rejections |
utils/VssAuthUtils.test.ts |
mnemonic → BIP-32 m/130'/0' VSS auth-key vectors, commented "captured against bip39@3.1.0 … MUST remain stable across any BIP-39 library swap" — the textbook example of step 2 |
utils/ClinkUtils.test.ts |
noffer bech32-TLV round-trip tests with a local encodeTLV helper (round-trip style — weaker than pinned vectors; if you touch the wire format, add pinned hex) |
utils/AddressUtils.test.ts, utils/CashuUtils.test.ts |
address-format and cashu-token fixtures for the input-router paths |
Worked example — verifying an invoice decode against BOLT11. utils/Bolt11Utils.ts is a hand-rolled decoder (LRU-cached, with lazy self-replacing getters deferring signature recovery). Its conformance proof is exactly the pattern to copy: (1) spec vector in, assert every parsed field including the recovered destination equals the spec's stated payee; (2) realistic network fixtures (regtest bcrt, signet tbs) for prefixes the spec examples don't cover; (3) invalid string throws the exact message 'Not a proper lightning payment request'; (4) the caching/laziness contract is itself under test — which is why "don't naively serialize/spread decoded invoices" (see zeus-lightning-reference) is an enforced property, not folklore.
What counts as done: at least one spec-sourced vector (or reference-captured, provenance-commented), one repo-realistic fixture, and one invalid-input case; any caching/lazy behavior asserted; vectors are constants with a source citation, never generated inside the test.
Recipe 6 — Regression proof (failing-test-first)
When to use: every bug fix. CONTRIBUTING.md, Test Coverage section: "Bug fixes should include a test that would have caught the bug."
Steps
- Write the test before (or at worst, alongside) the fix, at the layer that owns the logic. Reality check (verified): tests exist only for
utils/(45 files),models/(2), andlndmobile/channel.test.ts— plus the repo-rootcheck-styles.test.tslint helper, 49 files total.stores/,views/,components/,backends/have zero coverage. - If the bug lives in an untested layer, extract the pure logic into
utils/first, then test it there. This is established repo practice, not a workaround — see the worked examples. - Demonstrate the test fails without the fix. Temporarily undo the fix hunk in your working tree, run the single test, watch it fail, restore. State this in the PR ("test fails on pre-fix code").
- Run the full gate:
yarn verify(jest + prettier + tsc + eslint, matching the 4 PR checks — anatomy and jest traps in zeus-validation-and-qa). - The test must encode the bug's mechanism, not its symptom — assert the property whose violation caused the bug.
Worked examples (verified in git):
- Extract-then-test: invoice-expiry logic was extracted to
utils/ExpiryUtils.ts(#3795, merged3b4cdd0b5, 2026-03-08); when the "3600 hours" bug was fixed (26d4215ea), the fix shipped with new cases in bothExpiryUtils.test.tsandMigrationUtils.test.ts. Similarlyd6af12ae4("extract signing utils, add test") pulled DER signing out of backend code intoutils/SigningUtils.ts+ vectors. - Mechanism-encoding test: the #4150 fix (
7ed901a10) added aMigrationUtils.test.tscase assertingsettingsStore.setSettingsreceives a non-string object (expect(typeof persistedSettings).not.toBe('string')) — guarding the entire "stringified payload corrupts the MobX observable" class, not just the one call site. - Race fixed in an untestable layer:
b54e8f138placed the tolerance (waitForLdkNodeReady) inutils/LdkNodeUtils.ts— where a jest test can reach it — with the race documented in its docstring; companion commit8fc9698a0added thewalletJustCreatedskip flag.
What counts as done: a colocated test that fails on pre-fix code and passes after; if the fix is in stores/views/backends, either extracted-and-tested pure logic or an explicit PR note that the layer is untestable and what manual test covered it (manual iOS+Android testing is mandatory regardless — zeus-change-control).
Recipe 7 — Reproducible-build equivalence
When to use: verifying a released APK matches the source; changing anything under android/ that could affect determinism (Gradle config, packaging, dependencies); auditing the release pipeline.
How determinism is achieved (all verified by source read; the build itself was not executed for this document):
| Mechanism | Where |
|---|---|
Builder image pinned by sha256 digest (reactnativecommunity/react-native-android@sha256:c390bfb3…) |
build.sh |
SOURCE_DATE_EPOCH fixed (defaults to 0) and exported into the container |
build.sh |
yarn install --frozen-lockfile inside the container |
build.sh |
| Parallel Gradle disabled ("parallel execution causes non-deterministic file ordering") | android/gradle.properties (org.gradle.parallel=false) |
tasks.withType(Zip) { reproducibleFileOrder = true; preserveFileTimestamps = false } |
android/app/build.gradle |
Release APKs are unsigned; per-ABI versionCodeOverride = versionCode*1000 + {armeabi-v7a:1, x86:2, arm64-v8a:3, x86_64:4} (base versionCode 131 at this writing) |
android/app/build.gradle |
Output: 5 renamed APKs (app- → zeus-, -release-unsigned stripped) + sha256sum per file |
build.sh |
The only build.sh flag is --no-tty. CI never runs this on PRs — .github/workflows/build-android.yml is workflow_dispatch-only. Android only; there is no iOS reproducible build.
Steps to verify equivalence (per docs/ReproducibleBuilds.md — read it first):
- Clone the exact tag:
git clone --depth 1 --branch <tag> https://github.com/ZeusLN/zeus.git && cd zeus ./build.sh(requires Docker; add--no-ttyin scripts/CI).- Compare your
sha256sumoutput against the GitHub release page hashes. The web-distributed APK is the-universalone. - Hashes will not match the official signed APK byte-for-byte — the official one carries a signature. Unpack both and diff:
diffoscope,apksigcopier, ordiff --brief --recursive ./unpacked_official ./unpacked_built. - Equivalence claim = the only differences are the signing certificates (META-INF signature files). Any other diff is a finding: trace it to a non-determinism source and check the table above for which mechanism failed.
Worked example: the mechanisms table is the archaeology — each row exists because its absence produced non-identical builds (the gradle.properties comment records the parallel-ordering failure explicitly). When auditing a Gradle change, re-run the mental checklist: does it introduce a timestamp, an absolute path, a parallel task, or an unpinned input? Releases and commits are PGP-signed with key AAC48DE8AB8DEE84 (PGP.txt) — hash comparison plus signature check is the full trust chain.
What counts as done: two independent builds of the same tag produce identical sha256 sums; official-vs-built unpacked diff shows only signing material; any new build input is digest- or lockfile-pinned.
Provenance and maintenance
All facts verified 2026-07-06 against master @ c5fd094fb (v13.1.3-alpha). Commit hashes cited were confirmed with read-only git show/git log on first-parent master. Re-verify volatile facts before relying on them:
| Fact | Re-verification command |
|---|---|
| Cited commits still exist / messages match | git show -s --oneline d416052cd 0e2b89164 b54e8f138 8fc9698a0 c6323fff4 26d4215ea e8e5b8811 a7860aa96 7ed901a10 d8e648b58 3b4cdd0b5 d6af12ae4 f7b2c30a8 |
call() returns false for missing methods |
grep -n "return false" utils/BackendUtils.ts |
| Backend inheritance edges | grep -n "extends" backends/*.ts |
| LndHub fund-max leak still present | grep -n "supportsChannelFundMax" backends/*.ts (fixed when LndHub.ts gains an override) |
payLightningInvoiceStreaming still dead |
grep -rn "payLightningInvoiceStreaming" backends/ (empty = still dead) |
| Fee rule boundary (5% / ≤1000 sats) | grep -n "DEFAULT_ROUTING_FEE_PERCENT|amount > 1000" utils/FeeUtils.ts |
| Settings blob keys | grep -n "STORAGE_KEY\b|LEGACY_STORAGE_KEY" stores/SettingsStore.ts |
| Migration execution order | grep -n "keychainCloudSyncMigration|legacySettingsMigrations|storageMigrationV2|migrateInvoiceExpiryDisplay" stores/SettingsStore.ts |
| Keychain deletion still disabled (policy) | grep -n "Skipping delete" utils/MigrationUtils.ts |
Expiry-repair flag (v2 only; the v1 MOD_KEY9 block was removed by e8e5b8811) |
grep -n "invoices-expiry-display-fix-v2" utils/MigrationUtils.ts |
| Race-tolerance retry still in place | grep -n "LDK_NODE_NOT_INITIALIZED|LDK_NODE_NOT_RUNNING_YET" utils/LdkNodeUtils.ts |
fetchData re-entrancy guard |
grep -n "fetchLock" views/Wallet/Wallet.tsx stores/SettingsStore.ts |
| Builder image digest / SOURCE_DATE_EPOCH | grep -n "BUILDER_IMAGE|SOURCE_DATE_EPOCH" build.sh |
| Gradle determinism settings | grep -n "org.gradle.parallel" android/gradle.properties && grep -n "reproducibleFileOrder|versionCodeOverride|versionCode " android/app/build.gradle |
| build workflow still dispatch-only | grep -n "workflow_dispatch" .github/workflows/build-android.yml |
| Test-coverage reality (count) | find . -name "*.test.ts*" -not -path "./node_modules/*" -not -path "./zeus_modules/*" | wc -l (49 at this writing) |
| BOLT11 spec vectors present | grep -n "BOLT11_SPEC" utils/Bolt11Utils.test.ts |
| Regression-test rule in docs | grep -n "would have caught the bug" CONTRIBUTING.md |
Labeled uncertainties: the LndHub supportsChannelFundMax leak is latent (masked by supportsChannelManagement = false) — reconfirm both flags before citing it; versionCode 131 and the Docker digest change every release; execution of build.sh itself is verified by source read only.