diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0771929 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# Documentation + +| Document | Purpose | +|----------|---------| +| [docs-as-code.md](docs-as-code.md) | How documentation is written, reviewed and checked | +| [adr/](adr/README.md) | Architecture Decision Records, one per feature | + +Related, at the repository root: [README](../README.md), [USERGUIDE](../USERGUIDE.md), +[SECURITY](../SECURITY.md), [CHANGELOG](../CHANGELOG.md). + +Check the docs before committing: `./docs/check-docs.sh` diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md new file mode 100644 index 0000000..dbf9670 --- /dev/null +++ b/docs/adr/0001-record-architecture-decisions.md @@ -0,0 +1,25 @@ +# 0001. Record architecture decisions as ADRs + +- **Status:** Accepted +- **Area:** Process +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Design choices in this wallet (SPV trust model, key handling, storage format) have direct financial impact and are easy to forget or to undo by accident. The reasoning currently lives in code comments, `SECURITY.md` and `CHANGELOG.md`, which record *what* but rarely *why one option was chosen over another*. + +## Decision + +Keep one Architecture Decision Record per feature in `docs/adr/`, in Markdown, versioned with the code and reviewed in the same pull request as the change they describe. Format and workflow are defined in `docs/docs-as-code.md` and `docs/adr/template.md`. An accepted ADR is never rewritten: a changed decision gets a new ADR that supersedes the old one. + +## Consequences + +- The rationale survives contributor turnover and is reviewable like code. +- Decisions recorded retroactively (0002 onwards) reconstruct intent from code, comments and the changelog; they are marked as such and should be corrected if the original author remembers differently. +- Small overhead per feature: an ADR is expected when a decision is hard to reverse or touches cryptography, SPV validation, key handling or the file format. + +## References + +- `docs/docs-as-code.md` +- `docs/adr/template.md` +- `docs/check-docs.sh` diff --git a/docs/adr/0002-shared-avalonia-ui-with-platform-heads.md b/docs/adr/0002-shared-avalonia-ui-with-platform-heads.md new file mode 100644 index 0000000..f2e6ad9 --- /dev/null +++ b/docs/adr/0002-shared-avalonia-ui-with-platform-heads.md @@ -0,0 +1,25 @@ +# 0002. One shared Avalonia UI with thin platform heads + +- **Status:** Accepted +- **Area:** Architecture +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The wallet targets Windows, Linux and Android. Maintaining separate UIs would triple the surface to test and make behaviour drift between platforms. + +## Decision + +Write the UI once in the library `src/App` (Avalonia UI 12, .NET 10). `MainView` (a `UserControl`) is the shared root: hosted by `MainWindow` on desktop and used as the single-view root on Android. The heads `src/App.Desktop` (WinExe) and `src/App.Android` carry only the entry point and platform packages. Top-level services (file picker, clipboard) are reached through `TopLevel.GetTopLevel(this)`; platform differences are gated by `MainWindowViewModel.IsDesktop`. + +## Consequences + +- One code path for UI logic and one localization table. +- UI must stay mobile-friendly (no OS windows, see 0021) and avoid desktop-only APIs outside `IsDesktop` guards. +- Android needs its own workload, SDK and signing flow (0028, 0029). + +## References + +- `src/App/Views/MainView.axaml` +- `src/App.Desktop` +- `src/App.Android` diff --git a/docs/adr/0003-core-without-ui-dependency.md b/docs/adr/0003-core-without-ui-dependency.md new file mode 100644 index 0000000..068fb91 --- /dev/null +++ b/docs/adr/0003-core-without-ui-dependency.md @@ -0,0 +1,24 @@ +# 0003. Layered architecture: Core knows nothing about the UI + +- **Status:** Accepted +- **Area:** Architecture +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The same wallet logic must be driven by a GUI, a CLI and headless tests. If cryptography or network code leaked into view code it could not be tested without a display and could not be audited in isolation. + +## Decision + +Layers depend only downward: GUI → wallet domain → SPV/sync → network → cryptography → persistence. `src/Core` (`Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/`) has no UI dependency. `src/App` and `src/Cli` depend only on `Core`; the UI reaches the network and cryptography exclusively through the wallet domain. + +## Consequences + +- `dotnet test` covers almost all behaviour headlessly. +- The CLI is a second front end for free (0024). +- Violations (e.g. a ViewModel opening a socket) are review blockers, not style issues. + +## References + +- `src/Core` +- `src/Cli/Program.cs` diff --git a/docs/adr/0004-centralized-network-profile.md b/docs/adr/0004-centralized-network-profile.md new file mode 100644 index 0000000..f815535 --- /dev/null +++ b/docs/adr/0004-centralized-network-profile.md @@ -0,0 +1,25 @@ +# 0004. Centralize all chain constants in a network profile + +- **Status:** Accepted +- **Area:** Chain +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Palladium is a Bitcoin-derived chain with its own address prefixes, BIP32 headers, bech32 HRP, genesis, ports and coin type, and it runs on mainnet, testnet and regtest. Scattered magic numbers make a wrong-network address or key a silent fund-safety bug. + +## Decision + +Define every chain constant once in `Core/Chain` (`ChainProfile`, `ChainProfiles`, `PalladiumNetworks`): address prefixes (P2PKH 55, P2SH 5), bech32 HRP `plm`, SLIP-132 headers, genesis hash, default ports (50001/50002), BIP44 coin type 746, maturity and confirmation policy, bootstrap servers and checkpoints. The profile is selected per network and passed into the code that needs it. + +## Consequences + +- A new network or parameter change is a one-file edit. +- Testnet and regtest ship with empty bootstrap-server and checkpoint lists (TODO in code), so they rely on a user-supplied server and have no checkpoint anchoring (see 0006). +- Mainnet currently reuses Bitcoin's genesis hash, as noted in the profile. + +## References + +- `src/Core/Chain/ChainProfile.cs` +- `src/Core/Chain/ChainProfiles.cs` +- `src/Core/Chain/PalladiumNetworks.cs` diff --git a/docs/adr/0005-nbitcoin-for-protocol-primitives.md b/docs/adr/0005-nbitcoin-for-protocol-primitives.md new file mode 100644 index 0000000..45613f6 --- /dev/null +++ b/docs/adr/0005-nbitcoin-for-protocol-primitives.md @@ -0,0 +1,24 @@ +# 0005. Use NBitcoin for protocol primitives, hand-write only the rest + +- **Status:** Accepted +- **Area:** Crypto +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Reimplementing BIP32/39, address encoding, transaction serialization, signing or PSBT is a classic source of subtle, fund-losing bugs. + +## Decision + +Build on NBitcoin for the custom network definition, BIP32/39, addresses, transactions, PSBT, signing, encoding and hashing. Hand-written code is limited to what NBitcoin does not provide for this chain: the Electrum JSON-RPC client (0009), SPV sync with Merkle verification and checkpoint anchoring (0006, 0007), coin selection and fee policy (0017), SLIP-132 variants beyond xpub/xprv (0014) and the encrypted wallet container (0012). + +## Consequences + +- Smaller audited surface; cross-implementation golden vectors catch divergence. +- NBitcoin assumes Bitcoin retargeting, which is why PoW validation is skipped (0006). +- A dependency upgrade can change behaviour; golden-vector tests must pass before bumping. + +## References + +- `src/Core/Chain/PalladiumNetworks.cs` +- `src/Core/Crypto/Slip132.cs` diff --git a/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md b/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md new file mode 100644 index 0000000..1fd41c5 --- /dev/null +++ b/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md @@ -0,0 +1,27 @@ +# 0006. Anchor headers to hardcoded checkpoints and skip PoW validation + +- **Status:** Accepted +- **Area:** SPV +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Palladium uses LWMA difficulty with 2-minute blocks. An SPV client cannot recompute LWMA without the full header history, and NBitcoin assumes Bitcoin's retargeting rules, so checking proof of work client-side would either be wrong or require downloading every header. + +## Decision + +Set `SkipPowValidation = true` in the profile and anchor trust to hardcoded checkpoints instead. For each header used in a Merkle proof, `WalletSynchronizer.AnchorToCheckpointAsync` downloads the intervening headers, verifies the prev-hash chain back to the nearest checkpoint at or below that height, and requires that checkpoint's hash to match exactly. Mainnet carries a checkpoint every 20,000 blocks. + +## Consequences + +- A server cannot feed an alternative history below the newest checkpoint. +- Headers above the newest checkpoint are linked but their work is not verified; this is an accepted limitation documented in `SECURITY.md`. +- Testnet/regtest have no checkpoints, so headers there are trusted at face value. +- Checkpoints must be extended with releases; a wrong checkpoint would reject every honest server. + +## References + +- `src/Core/Spv/WalletSynchronizer.cs` +- `src/Core/Spv/BlockHeaderInfo.cs` +- `src/Core/Chain/ChainProfiles.cs` +- `SECURITY.md` diff --git a/docs/adr/0007-mandatory-merkle-verification.md b/docs/adr/0007-mandatory-merkle-verification.md new file mode 100644 index 0000000..12fb656 --- /dev/null +++ b/docs/adr/0007-mandatory-merkle-verification.md @@ -0,0 +1,25 @@ +# 0007. Verify a Merkle proof for every confirmed transaction + +- **Status:** Accepted +- **Area:** SPV +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The indexing server is semi-trusted: it can lie about history. Without proof of inclusion it could fabricate a confirmed payment. + +## Decision + +Every confirmed transaction must come with a Merkle branch that `MerkleProof.Verify` checks against the merkle root of an anchored header (0006). The txid, position and branch are taken from the server, the root only from a verified header. A mismatch fails the sync with `SpvVerificationException`; unconfirmed transactions are shown as such and are never spendable. + +## Consequences + +- A malicious server cannot fabricate a confirmed transaction with a valid proof. +- Cost: one proof request per confirmed transaction, mitigated by caching verified state (0013) and progressive verification (0008). +- Script execution and double-spend detection are not covered; see `SECURITY.md`. + +## References + +- `src/Core/Spv/MerkleProof.cs` +- `src/Core/Spv/WalletSynchronizer.cs` +- `SECURITY.md` diff --git a/docs/adr/0008-progressive-verification-sync.md b/docs/adr/0008-progressive-verification-sync.md new file mode 100644 index 0000000..e1e1168 --- /dev/null +++ b/docs/adr/0008-progressive-verification-sync.md @@ -0,0 +1,25 @@ +# 0008. Progressive verification: show early, spend only after proof + +- **Status:** Accepted +- **Area:** SPV +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +On wallets with thousands of historical transactions, blocking the whole sync on every Merkle proof made the app unusable, especially on mobile hardware. + +## Decision + +`WalletSynchronizer` raises `PartialResult` as soon as transaction downloads finish, then keeps verifying proofs in the background and refreshes the result periodically. Each cached transaction and UTXO carries a `Verified` flag. The UI marks unverified entries ("verifying…") and shows `PendingVerificationSats` separately. The invariant: `UtxoSpendability.IsSpendable` requires `Verified`, so unverified coins can be displayed but never spent. `SyncOnceAsync` still completes only when verification is finished, and the disk cache stores only the fully verified end state. + +## Consequences + +- Fast time-to-usable-balance. +- A forged balance can be displayed briefly but never spent before the proof failure aborts the sync. +- Balances have overlapping subsets (immature vs. pending verification); consumers must use `SpendableSats`, never subtract the subsets. + +## References + +- `src/Core/Spv/WalletSynchronizer.cs` +- `src/Core/Wallet/UtxoSpendability.cs` +- `SECURITY.md` diff --git a/docs/adr/0009-custom-electrum-client.md b/docs/adr/0009-custom-electrum-client.md new file mode 100644 index 0000000..9826d6d --- /dev/null +++ b/docs/adr/0009-custom-electrum-client.md @@ -0,0 +1,24 @@ +# 0009. Hand-written Electrum JSON-RPC client with pipelining and an in-flight cap + +- **Status:** Accepted +- **Area:** Network +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The wallet talks to an ElectrumX/Fulcrum-style indexing server (ports 50001/50002, not the node's P2P port 2333). No library matched the needs: protocol 1.4, a testable in-process transport and control over throughput on large wallets. + +## Decision + +Implement `ElectrumClient` directly over `TcpClient`/`SslStream`, newline-delimited JSON-RPC 2.0. A single-reader channel batches writes (like Electrum's buffered writer); a read loop completes pending requests by id; a semaphore caps in-flight requests (`DefaultMaxInFlight = 32`, configurable per connection) to avoid server throttling and disconnects. Typed helpers live in `ElectrumApi`. The class is deliberately not an interface: tests run against a real in-process server instead (0025). + +## Consequences + +- Full control over request pipelining and error mapping (`ElectrumServerException`). +- No single in-flight value suits every server, hence the per-connection knob. +- Protocol quirks (e.g. `server.version` allowed once per connection) are our responsibility; the server probe (0027) exists to catch server differences. + +## References + +- `src/Core/Net/ElectrumClient.cs` +- `src/Core/Net/ElectrumApi.cs` diff --git a/docs/adr/0010-tls-tofu-certificate-pinning.md b/docs/adr/0010-tls-tofu-certificate-pinning.md new file mode 100644 index 0000000..1dd6630 --- /dev/null +++ b/docs/adr/0010-tls-tofu-certificate-pinning.md @@ -0,0 +1,24 @@ +# 0010. TOFU certificate pinning for indexing servers + +- **Status:** Accepted +- **Area:** Network +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Indexing servers commonly use self-signed certificates, so CA validation is not available, yet silent MITM substitution must still be detectable. + +## Decision + +Trust On First Use: the certificate fingerprint is pinned on first successful TLS connection (`CertificatePinStore`, persisted per network in `server-certs.json`). A later mismatch raises `CertificatePinMismatchException` and blocks the connection until the user explicitly resets the pin (GUI, or CLI `reset-certs`). A corrupted pin file must not permanently block reconnection. + +## Consequences + +- Detects certificate substitution after first contact. +- Does not protect the first connection; users cannot distinguish a legitimate rotation from an attack, so the reset is deliberately manual. +- Reflects `SECURITY.md`; any change to the pinning policy must update it in the same commit. + +## References + +- `src/Core/Net/CertificatePinStore.cs` +- `SECURITY.md` diff --git a/docs/adr/0011-server-registry-and-discovery.md b/docs/adr/0011-server-registry-and-discovery.md new file mode 100644 index 0000000..b901775 --- /dev/null +++ b/docs/adr/0011-server-registry-and-discovery.md @@ -0,0 +1,24 @@ +# 0011. Server registry seeded by bootstrap list and peer discovery + +- **Status:** Accepted +- **Area:** Network +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The wallet needs a server without hardcoding a single point of failure, and users should be able to pick their own. + +## Decision + +`ServerRegistry` starts from the profile's bootstrap servers, adds peers announced via `server.peers.subscribe` (missing ports fall back to profile defaults) and persists the list per network. The first entry is the default when the user specifies none. The last successfully connected server is stored in `AppConfig`. + +## Consequences + +- Works out of the box and survives bootstrap servers disappearing. +- There is no multi-server pooling or cross-checking yet (listed as a limitation in `SECURITY.md`). +- Discovered peers are unauthenticated hints; every response is still verified (0007). + +## References + +- `src/Core/Net/ServerRegistry.cs` +- `src/Core/Storage/AppConfig.cs` diff --git a/docs/adr/0012-encrypted-wallet-file.md b/docs/adr/0012-encrypted-wallet-file.md new file mode 100644 index 0000000..d75f06d --- /dev/null +++ b/docs/adr/0012-encrypted-wallet-file.md @@ -0,0 +1,26 @@ +# 0012. Wallet file encrypted with AES-256-GCM and PBKDF2-HMAC-SHA512 + +- **Status:** Accepted +- **Area:** Storage +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The wallet file contains the seed and keys; theft of the file must not mean theft of funds. + +## Decision + +`EncryptedFile` wraps the whole wallet document in a self-describing JSON container (`plm-wallet-aesgcm-v1`: kdf, iterations, salt, nonce, ciphertext, tag). AES-256-GCM, key from PBKDF2-HMAC-SHA512 with 600,000 iterations, a fresh 16-byte salt and 12-byte nonce on every save. The iteration count is stored in the file, so it can be raised later, and is capped at 10,000,000 on read because it is attacker-controlled. A wrong password or tampering raises `WrongPasswordException`; any malformed container raises `InvalidDataException`, never a raw parser error. Writes are atomic (temp file + rename). + +## Consequences + +- Tampering is detected before decryption; parameter upgrades stay backward compatible. +- The GCM tag cannot tell a wrong password from a corrupted file. +- Users may opt out of encryption (`Save` with a null password), only with an explicit UI warning. +- Changes to parameters or format must update `SECURITY.md` in the same commit. + +## References + +- `src/Core/Storage/EncryptedFile.cs` +- `src/Core/Storage/WalletStore.cs` +- `SECURITY.md` diff --git a/docs/adr/0013-versioned-wallet-document-and-sync-cache.md b/docs/adr/0013-versioned-wallet-document-and-sync-cache.md new file mode 100644 index 0000000..a21c063 --- /dev/null +++ b/docs/adr/0013-versioned-wallet-document-and-sync-cache.md @@ -0,0 +1,24 @@ +# 0013. Versioned JSON wallet document with an offline sync cache + +- **Status:** Accepted +- **Area:** Storage +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Wallet metadata (labels, contacts, gap limit) and the last synced state must persist across sessions and across app upgrades; balances should be viewable offline. + +## Decision + +`WalletDocument` is a versioned JSON schema (`CurrentVersion = 1`) with a migration hook on open. It holds the seed or keys (so it must be encrypted, 0012), account xpub and path, labels, contacts, gap limit (default 20) and a `SyncCache` with balance/history. Only confirmed transactions are cached because unconfirmed ones can change (RBF); only the fully verified end state of a sync is persisted (0008). + +## Consequences + +- Reopening a wallet is fast and works offline. +- Schema changes require a version bump and a migration, tested before release. +- A large cache makes saves heavy, so `WalletStore.SaveAsync` moves serialization and I/O off the UI thread. + +## References + +- `src/Core/Storage/WalletDocument.cs` +- `src/Core/Storage/WalletStore.cs` diff --git a/docs/adr/0014-hd-derivation-and-slip132.md b/docs/adr/0014-hd-derivation-and-slip132.md new file mode 100644 index 0000000..6ea7b2f --- /dev/null +++ b/docs/adr/0014-hd-derivation-and-slip132.md @@ -0,0 +1,25 @@ +# 0014. BIP32/39 HD wallets with BIP44/49/84 paths and SLIP-132 keys + +- **Status:** Accepted +- **Area:** Crypto +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Users expect seed compatibility with other wallets and a choice of address format. + +## Decision + +Derive accounts with BIP39 + BIP32 using purposes 44 (legacy P2PKH), 49 (wrapped P2SH-P2WPKH) and 84 (native P2WPKH), coin type 746 from the profile (`DerivationPaths`). Extended keys are encoded and parsed as SLIP-132 (xpub/ypub/zpub families) with headers from the profile (`Slip132`), since NBitcoin only registers xprv/xpub. An optional BIP39 passphrase is supported. Native SegWit is the default kind. + +## Consequences + +- Restore from seed or xpub/xprv reproduces the same addresses; golden vectors compare against a reference wallet. +- Taproot (BIP86) and multisig (BIP48) constants exist, but derivation for them is not implemented (see 0031). + +## References + +- `src/Core/Crypto/DerivationPaths.cs` +- `src/Core/Crypto/Slip132.cs` +- `src/Core/Crypto/HdAccount.cs` +- `src/Core/Crypto/Bip39.cs` diff --git a/docs/adr/0015-imported-keys-and-watch-only-accounts.md b/docs/adr/0015-imported-keys-and-watch-only-accounts.md new file mode 100644 index 0000000..60d4b5b --- /dev/null +++ b/docs/adr/0015-imported-keys-and-watch-only-accounts.md @@ -0,0 +1,25 @@ +# 0015. Imported-key and watch-only accounts + +- **Status:** Accepted +- **Area:** Wallet +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Users need to import WIF keys, track an xpub, or watch plain addresses without exposing key material. + +## Decision + +`IWalletAccount` abstracts HD and imported accounts. `ImportedKeyAccount` holds a fixed address list (no HD derivation; change returns to the first imported address). Watch-only accounts (from xpub, or from addresses with no keys) report no private key, and `TransactionFactory` only signs when the account has one, so they can only ever produce an unsigned PSBT. + +## Consequences + +- Watch-only is structurally read-only, not just disabled in the UI. +- Imported-key wallets reuse addresses for change, a privacy trade-off. +- Pure address imports derive the script kind only informationally. + +## References + +- `src/Core/Crypto/IWalletAccount.cs` +- `src/Core/Crypto/ImportedKeyAccount.cs` +- `src/Core/Wallet/WalletLoader.cs` diff --git a/docs/adr/0016-psbt-centric-signing.md b/docs/adr/0016-psbt-centric-signing.md new file mode 100644 index 0000000..25c434a --- /dev/null +++ b/docs/adr/0016-psbt-centric-signing.md @@ -0,0 +1,23 @@ +# 0016. Route every signing flow through PSBT + +- **Status:** Accepted +- **Area:** Wallet +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Offline, air-gapped and hardware-assisted signing all need a transport format, and a wallet that signs through a different path for each case multiplies the code that handles keys. + +## Decision + +`TransactionFactory.Build` produces a `BuiltTransaction` that always carries a PSBT. Accounts that hold keys also return the signed transaction; watch-only accounts return the unsigned PSBT for export and offline signing. + +## Consequences + +- One build path, interoperable with other PSBT tools. +- Hardware-wallet and multisig integration (not in v1) can reuse the same format. +- Cross-implementation tests compare PSBTs to a reference wallet; a differing PSBT or txid is a blocking bug. + +## References + +- `src/Core/Wallet/TransactionFactory.cs` diff --git a/docs/adr/0017-coin-selection-fee-and-spendability-policy.md b/docs/adr/0017-coin-selection-fee-and-spendability-policy.md new file mode 100644 index 0000000..19d284b --- /dev/null +++ b/docs/adr/0017-coin-selection-fee-and-spendability-policy.md @@ -0,0 +1,24 @@ +# 0017. Coin selection, fee and confirmation policy + +- **Status:** Accepted +- **Area:** Wallet +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Fund safety depends on never spending immature or unverified coins and on producing standard, relayable transactions. + +## Decision + +`TransactionFactory` supports automatic or manual UTXO selection, a fixed fee rate in sat/vByte, send-all with the fee subtracted, change on the internal chain, and RBF enabled on every transaction. It rejects transactions above 100,000 vbytes. `UtxoSpendability.IsSpendable` is the single gate shared by coin selection and balance reporting: a coinbase output needs maturity + 1 confirmations (121 on mainnet), a regular output needs `MinConfirmations` (policy, not consensus), frozen UTXOs are excluded, and the Merkle proof must be verified (0008). + +## Consequences + +- Balances and coin selection cannot disagree. +- Users may see "insufficient funds" despite a larger visible balance when part of it is immature or unverified. +- RBF is set but fee bumping is not exposed in the UI (see `SECURITY.md`). + +## References + +- `src/Core/Wallet/TransactionFactory.cs` +- `src/Core/Wallet/UtxoSpendability.cs` diff --git a/docs/adr/0018-exclusive-wallet-file-lock.md b/docs/adr/0018-exclusive-wallet-file-lock.md new file mode 100644 index 0000000..abbb9e6 --- /dev/null +++ b/docs/adr/0018-exclusive-wallet-file-lock.md @@ -0,0 +1,22 @@ +# 0018. Exclusive OS-level lock on the open wallet file + +- **Status:** Accepted +- **Area:** Storage +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Two processes opening the same wallet could overwrite each other's saves and corrupt state. + +## Decision + +`WalletLock.TryAcquire` holds a `FileStream` opened with `FileShare.None` on a companion `.lock` file for the session. The OS releases it on exit or crash, so there are no stale locks to clean up. A second opener gets `null`; permission errors propagate so the UI can show a distinct message. + +## Consequences + +- Safe multi-instance behaviour without lock-file housekeeping. +- Protects against concurrent app instances only, not against external tools editing the file. + +## References + +- `src/Core/Storage/WalletLock.cs` diff --git a/docs/adr/0019-data-location-resolution.md b/docs/adr/0019-data-location-resolution.md new file mode 100644 index 0000000..f49c873 --- /dev/null +++ b/docs/adr/0019-data-location-resolution.md @@ -0,0 +1,23 @@ +# 0019. Resolve the data root by explicit precedence + +- **Status:** Accepted +- **Area:** Storage +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Desktop users want portable installs or a custom folder; Android must use its sandbox; existing installs must keep their data. + +## Decision + +`AppPaths.DataRoot()` resolves in order: `OverrideDataRoot` (set by the Android head to the app sandbox, or by tests), portable `palladium-data` folder next to the executable, user-chosen folder stored in a pointer file at a fixed bootstrap location, legacy location if it already holds data, then the platform default (`%APPDATA%\PalladiumWallet` on Windows, `~/.palladium-wallet` on Linux). Each network gets its own subfolder (config, wallets, certificates, servers). + +## Consequences + +- First-run wizard asks for a location only when none is determined; Android skips that step. +- Static override seams complicate tests, which therefore share the xUnit collection `AppPaths`. + +## References + +- `src/Core/Storage/AppPaths.cs` +- `src/App.Android` diff --git a/docs/adr/0020-single-viewmodel-with-feature-partials.md b/docs/adr/0020-single-viewmodel-with-feature-partials.md new file mode 100644 index 0000000..fe22a78 --- /dev/null +++ b/docs/adr/0020-single-viewmodel-with-feature-partials.md @@ -0,0 +1,25 @@ +# 0020. A single ViewModel split into partial classes by feature + +- **Status:** Accepted +- **Area:** UI +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The UI is one `MainView` with many overlays sharing wallet, sync and connection state; many small ViewModels would need heavy cross-wiring. + +## Decision + +Use one `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`) that drives `Core` directly, split into partial classes by feature (`.Send`, `.Receive`, `.Sync`, `.Settings`, `.Wizard`, `.Contacts`, `.Update`, `.WalletInfo`, `.Donate`). New feature logic goes into the matching partial or a new one. + +## Consequences + +- Shared state needs no messaging layer. +- The class is large; discipline on partial boundaries is what keeps it navigable. +- Heavy network work must run off the UI thread (`Task.Run`). + +## References + +- `src/App/ViewModels/MainWindowViewModel.cs` +- `src/App/ViewModels/MainWindowViewModel.Send.cs` +- `src/App/ViewModels/MainWindowViewModel.Sync.cs` diff --git a/docs/adr/0021-in-app-overlays-instead-of-windows.md b/docs/adr/0021-in-app-overlays-instead-of-windows.md new file mode 100644 index 0000000..bd22f2b --- /dev/null +++ b/docs/adr/0021-in-app-overlays-instead-of-windows.md @@ -0,0 +1,23 @@ +# 0021. In-app overlays instead of OS windows + +- **Status:** Accepted +- **Area:** UI +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Secondary windows and popups are slow on WSLg and do not exist on Android's single-view root. + +## Decision + +Details (address, transaction), settings and help are full-screen `Border`s gated by an `IsXxxOpen` flag, with Open/Close commands, a backdrop handler and an Esc handler in `MainView`'s code-behind. Close buttons bind via `$parent[UserControl]`, not `$parent[Window]`. + +## Consequences + +- Instant open/close and identical behaviour on desktop and Android. +- Overlay state lives in the ViewModel and must be closed on navigation. + +## References + +- `src/App/Views/MainView.axaml` +- `src/App/Views/MainView.axaml.cs` diff --git a/docs/adr/0022-localization-seven-languages.md b/docs/adr/0022-localization-seven-languages.md new file mode 100644 index 0000000..0869fe2 --- /dev/null +++ b/docs/adr/0022-localization-seven-languages.md @@ -0,0 +1,23 @@ +# 0022. In-code localization table for seven languages + +- **Status:** Accepted +- **Area:** UI +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The wallet targets users across several markets and must work offline, with the strings available identically on every platform. + +## Decision + +`Loc` holds a key → 7-language table (it, en, es, fr, pt, de, zh). XAML uses `{Binding Loc[key]}`, C# uses `Loc.Tr("key")`. On language change the `Loc` instance is replaced so bindings refresh. An unknown language falls back to English. The picker in the Settings overlay is a hand-written list of `RadioButton`s bound to `IsLangXx` properties, so a new language needs a column, a button and a property. + +## Consequences + +- No resource-file tooling and no runtime lookup failures. +- Adding a language touches three places; forgetting the picker leaves it unreachable. + +## References + +- `src/App/Localization/Loc.cs` +- `src/App/ViewModels/MainWindowViewModel.Settings.cs` diff --git a/docs/adr/0023-github-release-update-check.md b/docs/adr/0023-github-release-update-check.md new file mode 100644 index 0000000..def140e --- /dev/null +++ b/docs/adr/0023-github-release-update-check.md @@ -0,0 +1,25 @@ +# 0023. Best-effort update check against GitHub releases + +- **Status:** Accepted +- **Area:** Distribution +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Android builds are signed sideloads and desktop builds are single-file executables, so there is no store to notify users of fixes. + +## Decision + +`UpdateChecker.CheckAsync` compares the running version (single source: `` in the App csproj) with the latest GitHub release tag at startup. Any network or parse failure, or an up-to-date app, resolves to null; the UI prompts only when a newer release exists. The HTTP handler is injectable so tests never reach GitHub. + +## Consequences + +- Users learn about security fixes promptly; no telemetry is sent beyond the release API request. +- The check leaks to GitHub that a wallet started; this is not covered by a Tor/proxy option. +- The repository URL is hardcoded in the source and must be kept in sync if the project moves. + +## References + +- `src/Core/Net/UpdateChecker.cs` +- `src/App/ViewModels/MainWindowViewModel.Update.cs` +- `tests/PalladiumWallet.Tests/Net/UpdateCheckerTests.cs` diff --git a/docs/adr/0024-cli-on-shared-core.md b/docs/adr/0024-cli-on-shared-core.md new file mode 100644 index 0000000..66f6c36 --- /dev/null +++ b/docs/adr/0024-cli-on-shared-core.md @@ -0,0 +1,22 @@ +# 0024. A CLI front end on the same Core + +- **Status:** Accepted +- **Area:** Distribution +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Scripting, headless servers and quick manual verification need the wallet without a GUI. + +## Decision + +`src/Cli` exposes `create`, `restore`, `restore-xpub`, `restore-address`, `info`, `sync`, `send`, `servers`, `reset-certs`, `newseed` and `addresses` on the same `Core` as the GUI, with `--net`, `--file`, `--server host:port` and `--ssl` options. The default wallet file is `~/.palladium-wallet//wallets/default.wallet.json`. + +## Consequences + +- Core behaviour is exercised outside the GUI. +- Passwords on the command line are visible to other local users and shell history; the CLI is a developer tool, not a hardened interface. + +## References + +- `src/Cli/Program.cs` diff --git a/docs/adr/0025-in-process-fake-electrum-server-for-tests.md b/docs/adr/0025-in-process-fake-electrum-server-for-tests.md new file mode 100644 index 0000000..c259421 --- /dev/null +++ b/docs/adr/0025-in-process-fake-electrum-server-for-tests.md @@ -0,0 +1,23 @@ +# 0025. Test the network layer against an in-process fake server + +- **Status:** Accepted +- **Area:** Testing +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +SPV code must be proven to reject lying servers, which cannot be done reliably against real servers, and mocks of `ElectrumClient` would hide transport bugs. + +## Decision + +`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` is a loopback TCP server with optional TLS, per-method handlers and call counters. Tests program it to return wrong Merkle proofs, drop connections or throttle, and assert that `ElectrumClient`, `WalletSynchronizer`, `TransactionInspector` and certificate pinning react correctly. The client is not made an interface on purpose. + +## Consequences + +- Real sockets, real serialization, deterministic adversarial scenarios. +- New protocol behaviour means extending the fake, not mocking. + +## References + +- `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` +- `tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs` diff --git a/docs/adr/0026-fuzzing-untrusted-input-parsers.md b/docs/adr/0026-fuzzing-untrusted-input-parsers.md new file mode 100644 index 0000000..ac975f4 --- /dev/null +++ b/docs/adr/0026-fuzzing-untrusted-input-parsers.md @@ -0,0 +1,24 @@ +# 0026. Fuzz every untrusted-input parser + +- **Status:** Accepted +- **Area:** Testing +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Headers, Merkle branches, extended keys, mnemonics, addresses, amounts, wallet documents, encrypted files and peer lists all arrive from outside the trust boundary. A crash or unexpected exception there is a bug or a denial of service. + +## Decision + +`tests/PalladiumWallet.Fuzz` (SharpFuzz) has one target per parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract: any other escaping exception is a finding. The seed corpus, including regression inputs for past findings, replays inside `dotnet test` through `FuzzCorpusTests`. Property-based tests (CsCheck) complement it for roundtrip invariants. + +## Consequences + +- Past findings (corrupted pin file, JSON wallet detection) became permanent regression inputs. +- A new parser requires a new fuzz target and seed entries. +- Coverage-guided campaigns need afl++, so they are manual rather than part of the default test run. + +## References + +- `tests/PalladiumWallet.Fuzz/Program.cs` +- `tests/PalladiumWallet.Fuzz/fuzz.sh` diff --git a/docs/adr/0027-server-probe-and-live-server-test.md b/docs/adr/0027-server-probe-and-live-server-test.md new file mode 100644 index 0000000..41c2fad --- /dev/null +++ b/docs/adr/0027-server-probe-and-live-server-test.md @@ -0,0 +1,24 @@ +# 0027. Server probe tool and opt-in live-server test + +- **Status:** Accepted +- **Area:** Testing +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Servers differ (Fulcrum, ElectrumX, electrs), can be on the wrong chain, lag behind or serve invalid proofs. The fake server cannot reveal how a real deployment behaves. + +## Decision + +`tests/PalladiumWallet.ServerProbe` is a read-only CLI that runs black-box checks against real servers through the wallet's own `ElectrumClient`: handshake and TLS fingerprint, `server.features` (genesis and `sha256`), tip freshness, genesis header, header linkage, a Merkle proof of a recent coinbase against the header root, fees, peers, error handling, latency and a pipelined burst, plus a cross-server tip comparison. `LiveServerTests` runs it from `dotnet test` only when `PALLADIUM_TEST_SERVERS` is set, so the default run stays offline. + +## Consequences + +- Operators and developers can qualify a server before using it. +- The live test depends on third-party availability and is therefore opt-in. +- The probe reuses production client code, so a pass means the wallet can use the server. + +## References + +- `tests/PalladiumWallet.ServerProbe/Program.cs` +- `tests/PalladiumWallet.Tests/Net/LiveServerTests.cs` diff --git a/docs/adr/0028-reproducible-docker-release-builds.md b/docs/adr/0028-reproducible-docker-release-builds.md new file mode 100644 index 0000000..32e4de0 --- /dev/null +++ b/docs/adr/0028-reproducible-docker-release-builds.md @@ -0,0 +1,26 @@ +# 0028. Reproducible release builds inside pinned Docker images + +- **Status:** Accepted +- **Area:** Distribution +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Users of a wallet must be able to trust that a shipped binary comes from the published source, and builds must not drift with whatever SDK a developer has installed. + +## Decision + +`docker/build.sh [windows|linux|android|all]` builds each target in a container whose toolchain (.NET SDK, JDK, Android SDK and workload) is pinned in `docker/Dockerfile.*`; artifacts land in `dist/`. The desktop single-file publish needs `IncludeNativeLibrariesForSelfExtract=true`, otherwise Avalonia's native libraries stay outside the executable and it silently fails to start. + +## Consequences + +- The Dockerfiles are the auditable definition of how releases are made. +- This pins the environment; bit-for-bit identical output is not guaranteed by .NET (timestamps, signing). +- The Android workload dictates the SDK API level (error XA5207 means bump `ANDROID_SDK_PLATFORM`). + +## References + +- `docker/build.sh` +- `docker/Dockerfile.desktop` +- `docker/Dockerfile.android` +- `docker/README.md` diff --git a/docs/adr/0029-persistent-android-signing-keystore.md b/docs/adr/0029-persistent-android-signing-keystore.md new file mode 100644 index 0000000..5166361 --- /dev/null +++ b/docs/adr/0029-persistent-android-signing-keystore.md @@ -0,0 +1,24 @@ +# 0029. Persistent Android release keystore kept out of git + +- **Status:** Accepted +- **Area:** Distribution +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Android refuses to update an installed app signed with a different key, and the user would have to uninstall, which deletes the wallet data. + +## Decision + +Release APKs are signed with one persistent keystore generated once by `docker/keystore/generate-keystore.sh`. `release.keystore` is ignored by git; passwords are supplied through environment variables or prompts at each build and are never stored. `docker/build.sh android` refuses to run without the keystore. + +## Consequences + +- In-place updates keep working across releases. +- Losing the keystore or its passwords permanently breaks in-place updates; it must be backed up outside the repository. + +## References + +- `docker/keystore/generate-keystore.sh` +- `docker/keystore/README.md` +- `.gitignore` diff --git a/docs/adr/0030-single-source-versioning-and-changelog.md b/docs/adr/0030-single-source-versioning-and-changelog.md new file mode 100644 index 0000000..ee4db93 --- /dev/null +++ b/docs/adr/0030-single-source-versioning-and-changelog.md @@ -0,0 +1,24 @@ +# 0030. Single-source versioning with a mandatory changelog entry + +- **Status:** Accepted +- **Area:** Distribution +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +The App version, Android's display version and Android's `versionCode` must stay consistent, and `versionCode` must strictly increase or users cannot update in place. + +## Decision + +`` in `src/App/PalladiumWallet.App.csproj` is the only source. `update-version.sh` updates it, mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` and stubs a `CHANGELOG.md` entry. The changelog entry is filled in before or with the tag; commit and tag are manual. + +## Consequences + +- The title bar, update checker (0023) and release builds read the same value. +- The changelog is the technical record of what shipped, not optional bookkeeping. + +## References + +- `update-version.sh` +- `CHANGELOG.md` +- `src/App/PalladiumWallet.App.csproj` diff --git a/docs/adr/0031-defer-multisig-lightning-and-hardware-wallets.md b/docs/adr/0031-defer-multisig-lightning-and-hardware-wallets.md new file mode 100644 index 0000000..a25d9d5 --- /dev/null +++ b/docs/adr/0031-defer-multisig-lightning-and-hardware-wallets.md @@ -0,0 +1,24 @@ +# 0031. Explicitly defer multisig, Lightning, Tor and hardware wallets + +- **Status:** Accepted +- **Area:** Scope +- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog) + +## Context + +Each of these adds large attack surface or UX complexity. Shipping a half-working version in a wallet is worse than not shipping it. + +## Decision + +The first release excludes Lightning, M-of-N multisig, hardware-wallet integration, Tor/proxy support, multi-server pooling and RBF/CPFP fee-bumping UI. Where the profile already defines the constants (multisig SLIP-132 headers, BIP48/BIP86 purposes), the unimplemented paths throw rather than silently producing an incorrect wallet. Coin control exists in `TransactionFactory` (manual UTXO selection) and freezing in the cache; `SECURITY.md` lists the limitations users must know. + +## Consequences + +- A smaller, auditable v1. +- Network traffic reveals which addresses are queried (no Tor); this is stated in the threat model. +- Each deferred feature needs its own ADR when taken up. + +## References + +- `SECURITY.md` +- `src/Core/Crypto/DerivationPaths.cs` diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..a4c8e4d --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,40 @@ +# Architecture Decision Records + +One ADR per feature or significant design decision. Format: `template.md`. Workflow: `../docs-as-code.md`. + +| # | Decision | Area | Status | +|---|----------|------|--------| +| 0001 | [Record architecture decisions as ADRs](0001-record-architecture-decisions.md) | Process | Accepted | +| 0002 | [One shared Avalonia UI with thin platform heads](0002-shared-avalonia-ui-with-platform-heads.md) | Architecture | Accepted | +| 0003 | [Layered architecture: Core knows nothing about the UI](0003-core-without-ui-dependency.md) | Architecture | Accepted | +| 0004 | [Centralize all chain constants in a network profile](0004-centralized-network-profile.md) | Chain | Accepted | +| 0005 | [Use NBitcoin for protocol primitives, hand-write only the rest](0005-nbitcoin-for-protocol-primitives.md) | Crypto | Accepted | +| 0006 | [Anchor headers to hardcoded checkpoints and skip PoW validation](0006-spv-checkpoint-anchoring-without-pow.md) | SPV | Accepted | +| 0007 | [Verify a Merkle proof for every confirmed transaction](0007-mandatory-merkle-verification.md) | SPV | Accepted | +| 0008 | [Progressive verification: show early, spend only after proof](0008-progressive-verification-sync.md) | SPV | Accepted | +| 0009 | [Hand-written Electrum JSON-RPC client with pipelining and an in-flight cap](0009-custom-electrum-client.md) | Network | Accepted | +| 0010 | [TOFU certificate pinning for indexing servers](0010-tls-tofu-certificate-pinning.md) | Network | Accepted | +| 0011 | [Server registry seeded by bootstrap list and peer discovery](0011-server-registry-and-discovery.md) | Network | Accepted | +| 0012 | [Wallet file encrypted with AES-256-GCM and PBKDF2-HMAC-SHA512](0012-encrypted-wallet-file.md) | Storage | Accepted | +| 0013 | [Versioned JSON wallet document with an offline sync cache](0013-versioned-wallet-document-and-sync-cache.md) | Storage | Accepted | +| 0014 | [BIP32/39 HD wallets with BIP44/49/84 paths and SLIP-132 keys](0014-hd-derivation-and-slip132.md) | Crypto | Accepted | +| 0015 | [Imported-key and watch-only accounts](0015-imported-keys-and-watch-only-accounts.md) | Wallet | Accepted | +| 0016 | [Route every signing flow through PSBT](0016-psbt-centric-signing.md) | Wallet | Accepted | +| 0017 | [Coin selection, fee and confirmation policy](0017-coin-selection-fee-and-spendability-policy.md) | Wallet | Accepted | +| 0018 | [Exclusive OS-level lock on the open wallet file](0018-exclusive-wallet-file-lock.md) | Storage | Accepted | +| 0019 | [Resolve the data root by explicit precedence](0019-data-location-resolution.md) | Storage | Accepted | +| 0020 | [A single ViewModel split into partial classes by feature](0020-single-viewmodel-with-feature-partials.md) | UI | Accepted | +| 0021 | [In-app overlays instead of OS windows](0021-in-app-overlays-instead-of-windows.md) | UI | Accepted | +| 0022 | [In-code localization table for seven languages](0022-localization-seven-languages.md) | UI | Accepted | +| 0023 | [Best-effort update check against GitHub releases](0023-github-release-update-check.md) | Distribution | Accepted | +| 0024 | [A CLI front end on the same Core](0024-cli-on-shared-core.md) | Distribution | Accepted | +| 0025 | [Test the network layer against an in-process fake server](0025-in-process-fake-electrum-server-for-tests.md) | Testing | Accepted | +| 0026 | [Fuzz every untrusted-input parser](0026-fuzzing-untrusted-input-parsers.md) | Testing | Accepted | +| 0027 | [Server probe tool and opt-in live-server test](0027-server-probe-and-live-server-test.md) | Testing | Accepted | +| 0028 | [Reproducible release builds inside pinned Docker images](0028-reproducible-docker-release-builds.md) | Distribution | Accepted | +| 0029 | [Persistent Android release keystore kept out of git](0029-persistent-android-signing-keystore.md) | Distribution | Accepted | +| 0030 | [Single-source versioning with a mandatory changelog entry](0030-single-source-versioning-and-changelog.md) | Distribution | Accepted | +| 0031 | [Explicitly defer multisig, Lightning, Tor and hardware wallets](0031-defer-multisig-lightning-and-hardware-wallets.md) | Scope | Accepted | + +ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog. +If one misstates the original reasoning, fix it in a pull request. diff --git a/docs/adr/template.md b/docs/adr/template.md new file mode 100644 index 0000000..a673e80 --- /dev/null +++ b/docs/adr/template.md @@ -0,0 +1,25 @@ +# NNNN. Title in the imperative, naming the decision + +- **Status:** Proposed | Accepted | Deprecated | Superseded by [NNNN](NNNN-slug.md) +- **Area:** Architecture | Chain | Crypto | Net | SPV | Storage | Wallet | UI | Testing | Distribution | Scope | Process +- **Recorded:** YYYY-MM-DD + +## Context + +The forces at play: the problem, the constraints, what was tried or rejected. +Facts only; link to code, issues or specs instead of restating them. + +## Decision + +What we do, in the active voice ("We use…"). Specific enough that a reviewer +can check the code against it. + +## Consequences + +What becomes easier, what becomes harder, new risks, follow-up work. +Be honest about the downsides. + +## References + +- `path/to/code.cs` +- Related ADRs, specs (BIPs), issues diff --git a/docs/check-docs.sh b/docs/check-docs.sh new file mode 100755 index 0000000..01e7254 --- /dev/null +++ b/docs/check-docs.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Mechanical checks for docs/ (see docs/docs-as-code.md). Run from anywhere. +set -u +cd "$(dirname "$0")/.." || exit 2 +fail=0 +err() { echo "FAIL: $*"; fail=1; } + +adr_dir=docs/adr +prev=0 +for f in $(ls $adr_dir/[0-9][0-9][0-9][0-9]-*.md 2>/dev/null | sort); do + base=$(basename "$f"); num=${base:0:4} + n=$((10#$num)) + [ "$n" -eq $((prev + 1)) ] || err "$base: numbering gap or duplicate (previous $prev)" + prev=$n + head -1 "$f" | grep -q "^# $num\. " || err "$base: first line must be '# $num. '" + grep -qE '^- \*\*Status:\*\* ' "$f" || err "$base: missing Status line" + for s in Context Decision Consequences; do + grep -q "^## $s\$" "$f" || err "$base: missing '## $s' section" + done + grep -q "($base)" $adr_dir/README.md || err "$base: not listed in $adr_dir/README.md" +done +[ "$prev" -gt 0 ] || err "no ADRs found" + +# Index rows must point to existing files. +grep -oE '\]\([0-9]{4}-[^)]+\.md\)' $adr_dir/README.md | tr -d '])(' | while read -r l; do + [ -f "$adr_dir/$l" ] || { echo "FAIL: index links to missing $l"; exit 1; } +done || fail=1 + +# Repo paths cited in backticks must exist. +while IFS= read -r line; do + file=${line%%:*}; p=${line#*:} + [ -e "$p" ] || err "$file: cited path does not exist: $p" +done < <(grep -oE '`(src|tests|docker|docs)/[A-Za-z0-9_./-]+`|`(README|USERGUIDE|SECURITY|CHANGELOG|CLAUDE|AGENTS)\.md`|`update-version\.sh`|`\.gitignore`' -r docs --include='*.md' \ + | grep -v 'template.md' | sed -E 's/`//g' | grep -v 'docs/adr/NNNN' | grep -v 'path/to') + +# Relative markdown links in docs/ must resolve. +for f in $(find docs -name '*.md'); do + d=$(dirname "$f") + for l in $(grep -oE '\]\([^)#:]+\.md\)' "$f" | sed -E "s/^\]\(|\)\$//g" | grep -v NNNN); do + [ -e "$d/$l" ] || err "$f: broken link $l" + done +done + +# CLAUDE.md and AGENTS.md must match apart from the header block (before '## Role'). +if ! diff <(sed -n '/^## Role/,$p' CLAUDE.md) <(sed -n '/^## Role/,$p' AGENTS.md) >/dev/null; then + err "CLAUDE.md and AGENTS.md differ after the header block" +fi + +[ "$fail" -eq 0 ] && echo "docs OK ($prev ADRs)" +exit "$fail" diff --git a/docs/docs-as-code.md b/docs/docs-as-code.md new file mode 100644 index 0000000..5b083cb --- /dev/null +++ b/docs/docs-as-code.md @@ -0,0 +1,110 @@ +# Docs as code + +Documentation in this repository is treated like source code: plain text in git, +changed through pull requests, checked by a script, and kept next to the code it +describes. This page defines how. + +## Principles + +1. **Docs live in the repo.** Markdown files, versioned with the code. No wiki, + no separate document store that can drift from the release it describes. +2. **One change, one pull request.** A change to behaviour, a file format, + cryptography, SPV validation or key handling updates the affected docs in the + same commit. `SECURITY.md` is the published threat model and must stay accurate + (see `CLAUDE.md`, *Working rules*). +3. **Review docs like code.** Reviewers check that the text matches the diff. +4. **English only** for documentation, comments and commit messages. +5. **Diagrams are text.** Use Mermaid code blocks, which render on GitHub and diff + cleanly. No binary diagram files unless a tool cannot export text. +6. **Prefer links to copies.** Point to the code path or the ADR instead of + restating it, so there is one place to update. +7. **Check mechanically.** `docs/check-docs.sh` runs the checks below; run it + before committing documentation changes. + +## Where things go + +| What | Where | Audience | +|------|-------|----------| +| Project overview, build and run, test | `README.md` | Developers | +| End-user manual | `USERGUIDE.md` | Users | +| Threat model, crypto parameters, SPV trust | `SECURITY.md` | Users, reviewers | +| What shipped in each release | `CHANGELOG.md` | Everyone | +| Why the design is the way it is | `docs/adr/` | Maintainers, reviewers | +| This process | `docs/docs-as-code.md` | Contributors | +| Build, signing and release procedure | `docker/README.md`, `docker/keystore/README.md` | Release managers | +| Rules for coding agents | `CLAUDE.md` and `AGENTS.md` (kept identical apart from their header) | Agents | + +Rule of thumb: *what* and *how* belong to the README, user guide and code comments; +*why this and not the alternative* belongs to an ADR. + +## Architecture Decision Records + +One ADR per feature or significant decision, in `docs/adr/NNNN-slug.md`, written +from `docs/adr/template.md`. The index is `docs/adr/README.md`. + +**When to write one.** Add or change an ADR when a change: + +- introduces a feature or subsystem; +- touches cryptography, SPV validation, key or seed handling, or the file format; +- is hard to reverse, or someone is likely to ask "why not X?" later. + +Bug fixes and refactors that keep the decision intact do not need an ADR. + +**Lifecycle.** + +```mermaid +stateDiagram-v2 + [*] --> Proposed: opened in a pull request + Proposed --> Accepted: merged + Proposed --> [*]: rejected, PR closed + Accepted --> Superseded: a newer ADR replaces it + Accepted --> Deprecated: feature removed +``` + +- Number ADRs sequentially; never reuse or renumber. +- Do not rewrite the substance of an Accepted ADR. To change a decision, write a + new ADR, set the old one's status to `Superseded by [NNNN](NNNN-slug.md)` and + link back from the new one. Typos and broken links can be fixed in place. +- Keep each ADR short: context, decision, consequences, references. +- Reference code by repository-relative path in backticks. The check script + verifies these paths still exist, so a rename shows up as a docs failure. + +## Workflow + +```mermaid +flowchart LR + A[Change code] --> B{Decision hard to<br/>reverse or security-relevant?} + B -- yes --> C[Add or supersede ADR] + B -- no --> D[Update README / USERGUIDE / SECURITY if affected] + C --> D + D --> E[Run docs/check-docs.sh] + E --> F[Pull request: code + docs together] + F --> G[Review checks text against diff] +``` + +1. Make the code change. +2. Decide whether an ADR is needed (see above) and write or supersede it. +3. Update `README.md`, `USERGUIDE.md`, `SECURITY.md` and `CHANGELOG.md` where the + change affects them. If you edit `CLAUDE.md`, mirror the edit into `AGENTS.md`. +4. Run `docs/check-docs.sh`. +5. Commit code and docs together and open the pull request. + +## What `docs/check-docs.sh` verifies + +- Every ADR file is named `NNNN-slug.md`, numbers are unique and contiguous. +- Every ADR has a title line matching its number, a `Status`, and the sections + *Context*, *Decision*, *Consequences*. +- Every ADR is listed in `docs/adr/README.md`, and every index row points to a file. +- Repository paths cited in backticks (`src/…`, `tests/…`, `docker/…`, root files) + exist. +- Relative Markdown links in `docs/` resolve. +- `CLAUDE.md` and `AGENTS.md` match apart from their header block. + +It needs only `bash` and standard tools, so it runs on a developer machine and can +be wired into CI later (the repository has no CI workflow today). + +## Possible next steps (not in place yet) + +- Run `docs/check-docs.sh` and a Markdown linter in a CI workflow on pull requests. +- Add a link checker for external URLs. +- Publish `docs/` as a static site if the audience outgrows GitHub's renderer.