Commit Graph

36 Commits

Author SHA1 Message Date
davide 3460e53b4f feat(spv): verify Merkle proofs progressively, gate spendability on it
Balance/history now render as soon as tx downloads finish instead of
blocking on every historical Merkle proof, critical for mobile where
proof-checking can take much longer than the download itself. Proofs
continue to be checked in the background and each tx's Verified flag
catches up progressively; header ranges are now fetched in batches
(blockchain.block.headers) instead of one call per header to keep this
fast over high-latency links.

Coin selection (UtxoSpendability.IsSpendable) refuses to spend a UTXO
until its Merkle proof is actually checked, regardless of confirmation
count, so a server that fabricates a confirmed balance can get it
displayed early but never spent before the forgery is caught. The disk
cache only ever persists the fully-verified end state of a sync.

UI surfaces the new PendingVerificationSats/SpendableSats split with a
"verifying..." badge, and the sync save now runs off the UI thread to
avoid freezing on slower hardware.
2026-07-17 19:43:18 +02:00
davide 077835a30b feat(wallet): decode OP_RETURN payloads and coinbase pool tags in tx details
Transaction detail view silently dropped OP_RETURN messages and coinbase
scriptSig text (e.g. pool tags), since TransactionInspector discarded
scriptPubKey/scriptSig bytes once no destination address could be derived.
Each output/input is decoded independently, so multiple OP_RETURN outputs
in one tx are all shown.
2026-07-09 08:35:39 +02:00
davide cdede17683 feat(fuzz): add SharpFuzz-based fuzzing for untrusted-input parsers
New tests/PalladiumWallet.Fuzz project: one target per parser that
consumes untrusted input (block headers, Merkle proofs, peer lists,
wallet files, user-pasted mnemonics/keys/addresses/amounts), each
encoding the parser's documented error contract - any exception beyond
that contract is a finding, reported as a crash.

Three ways to run: the seed corpus (with regression inputs for every
crash found so far, including the two fixed in prior commits) replays
automatically inside dotnet test via FuzzCorpusTests, so a fixed
contract violation can't silently return; a built-in random-mutation
mode needs no external tooling (dotnet run -- <target> --random N);
and fuzz.sh drives real coverage-guided campaigns via afl++ +
SharpFuzz instrumentation for pre-release or post-parser-change runs.
2026-07-07 22:05:38 +02:00
davide a264669151 fix(storage): EncryptedFile.Decrypt must reject malformed containers cleanly
A tampered or corrupted wallet file could reach Decrypt with broken
JSON, missing fields, invalid base64, or a wrong nonce/tag size, all of
which previously escaped as raw JsonException/FormatException/
ArgumentNullException instead of the documented WrongPasswordException/
InvalidDataException contract. Worse, the iteration count is read
straight from the (attacker-controlled) container with no upper bound:
a tampered file demanding e.g. 2^31 PBKDF2 iterations would hang the
wallet at open - a DoS via a file the user merely tries to open. Now
every malformed shape maps to InvalidDataException and the iteration
count is clamped to a sane maximum (10,000,000, well above the current
600,000 default).
2026-07-07 22:05:08 +02:00
davide d9c75eaec2 fix(net): ParsePeers must tolerate any JSON shape from the server
server.peers.subscribe is attacker-controlled data: ParsePeers assumed
the standard [ip, hostname, [features...]] shape and threw on anything
else (non-array response, wrong element types), and GetString() on a
JSON string containing invalid UTF-8 bytes threw InvalidOperationException
instead of failing to parse — both found by fuzzing. Any unexpected
shape or unparseable string now degrades to "no usable data" instead of
propagating an exception into the sync/discovery path.
2026-07-07 21:52:00 +02:00
davide c868c17505 fix(crypto): Bip39.TryParse must not throw on unrecognisable text
Wordlist.AutoDetect lands on NBitcoin's internal "Unknown" language for
text resembling no wordlist and throws NotSupportedException instead
of failing gracefully — found by fuzzing the restore-mnemonic input.
TryParse now treats that (and the same failure mode from the Mnemonic
constructor) as "not a valid mnemonic", consistent with every other
rejection path.
2026-07-07 21:51:31 +02:00
davide 413392f608 test(storage): exercise AppPaths' full data-root precedence chain
DefaultDataRoot, the pointer-file location and the portable-mode root
all read machine-global locations (APPDATA, AppContext.BaseDirectory),
so the override→portable→pointer→default precedence in DataRoot/
IsDataLocationConfigured was previously untestable beyond the override
case. Added three internal overrides (BootstrapDirOverride,
PortableBaseOverride, DefaultRootOverride) as sandboxing seams, and a
new AppPathsResolutionTests class covering every precedence branch:
default-only, portable-beats-pointer, pointer-beats-default, a blank
pointer falling back to default, "already has data" counting as
configured, and override beating everything. Both AppPaths test
classes share the "AppPaths" xUnit collection since this state is
static.
2026-07-07 16:26:20 +02:00
davide 970253cbbe test(net): exercise UpdateChecker.CheckAsync end-to-end via a stub transport
CheckAsync built its own HttpClient inline, so nothing beyond tag
parsing was testable without hitting the real GitHub API. Added an
internal CheckAsync(currentVersion, HttpMessageHandler, ct) overload
(the public method now delegates to it) as a seam for a stub handler,
and tests for the full best-effort matrix: newer/equal/older/unparseable
tag, missing html_url, HTTP error status, malformed JSON, a thrown
HttpRequestException, and an unparseable local version — all of which
must resolve to null or the correct LatestRelease, never throw.
2026-07-07 16:26:06 +02:00
davide 31012ce825 test(net): cover ElectrumClient's multi-segment response dispatch
DispatchLine's ArrayPool-copy path (for a JSON-RPC line spanning
multiple PipeReader segments) only ran for small responses in existing
tests, which stay single-segment. A 256 KB fake response forces the
multi-segment branch and verifies the payload still arrives intact.
2026-07-07 16:25:50 +02:00
davide a46f5e6638 test(wallet): cover WalletLoader's defensive branches for corrupted files
ToAccount's InvalidDataException paths (corrupted mnemonic/xprv/xpub,
a document with no key material at all) and the equivalent guards in
NewFromXpub/NewFromXprv/NewFromWif had no coverage — these are the
checks that turn a damaged wallet file into a clear error instead of
an unhandled exception or, worse, a silently wrong account.
2026-07-07 16:25:37 +02:00
davide 61ed0c0ed0 test(chain): cover PalladiumNetworks.For and the INetworkSet plumbing
The out-of-range throw in For(NetKind) and the entire
PalladiumNetworkSet class (CryptoCode, the three network getters,
GetNetwork including its throw for an unrecognised ChainName) had no
coverage.
2026-07-07 16:25:23 +02:00
davide 9097ca4abc test(crypto): cover Bip39 empty input and all 8 wordlist languages
TryParse's empty/whitespace/null guard had no test, and ToWordlist's
language map was only exercised for English and Spanish even though
USERGUIDE §2.1 documents all 8 languages as supported on restore.
Also covers the ArgumentOutOfRangeException for a language outside
the enum.
2026-07-07 16:25:10 +02:00
davide 2ed8c04064 test(crypto): cover Slip132 rejection of malformed and corrupted keys
TryDecodePublic/TryDecodePrivate return false on several inputs that
weren't exercised: well-formed Base58Check with a payload that isn't
78 bytes, and a correct SLIP-132 header with a corrupted key body
(bad pubkey point prefix / bad private-key padding byte) — both must
fail cleanly inside the try/catch, not throw.
2026-07-07 16:24:56 +02:00
davide 56135c29f8 test(wallet): cover TransactionFactory's standardness-policy rejection
builder.Verify's failure branch (the safety net right before a signed
tx would be returned to the caller) had no test: an absurd fee rate
now proves the "Invalid transaction: ..." path fires instead of
silently returning a policy-violating transaction.
2026-07-07 16:24:42 +02:00
davide 5d74038aad test(wallet): cover ImportedKeyAccount edge cases and fund-safety fallbacks
Empty entry list rejection, GetPublicKey boundaries (mirroring the
existing GetPrivateKey tests), and explicit assertions that change and
out-of-range indices always fall back to the first imported address —
never to an address the wallet doesn't hold keys for.
2026-07-07 16:24:28 +02:00
davide db5b65ca0d test(spv): cover checkpoint-anchoring memoization and the non-generic retry path
Two branches of WalletSynchronizer had no coverage: the early-return in
AnchorToCheckpointAsync when a range is already memoized (a second sync
with a tx below the previously-anchored height must not re-walk the
header chain), and the void RetryOnBusyAsync overload used by the
transaction-download tasks (only the generic <T> overload, exercised by
get_history, had a test).
2026-07-07 16:24:11 +02:00
davide 45f7b1401e test(spv): cover the PoW-checked branch of BlockHeaderInfo.IsValidChild
Every configured ChainProfile sets SkipPowValidation=true, so the
hash<=target branch (taken only when a future profile disables the
skip) had never run in the suite. Added a genesis-hash-satisfies-target
case and a tampered-bits case that pushes the target below the hash.
2026-07-07 16:23:58 +02:00
davide e349a80ddc feat(spv): anchor header trust to hardcoded mainnet checkpoints
ChainProfiles.Mainnet.Checkpoints was an empty array with a "populate
before release" TODO, and even BlockHeaderInfo.MatchesCheckpoint()/
IsValidChild() — the methods meant to enforce it — were never called
anywhere in WalletSynchronizer. So on this LWMA chain, where PoW can't
be recomputed client-side (SkipPowValidation), a malicious or
eclipsing server could hand back any internally-consistent 80-byte
header for a Merkle proof: nothing tied it to the real Palladium
chain.

Populated Mainnet.Checkpoints with 24 real [height, hash, bits]
checkpoints (every 20,000 blocks + one near tip), pulled via RPC from
a fully-synced palladiumd. Testnet/Regtest are left empty (no node
available to verify against, and WalletSynchronizer already treats a
missing checkpoint as a no-op, so this is safe, just unanchored).

Added WalletSynchronizer.AnchorToCheckpointAsync: for every header
used in a Merkle proof, downloads the intervening headers back to the
nearest checkpoint at or below that height and verifies an unbroken
prev-hash chain terminating in the checkpoint's exact hash, finally
wiring up the previously-dead MatchesCheckpoint/IsValidChild. Verified
ranges are memoized in-memory per sync session to avoid re-walking.

Updated SECURITY.md and USERGUIDE.md to describe what is actually
enforced now (previously the guide deliberately avoided promising
checkpoint anchoring, since the array was empty).
2026-07-07 15:12:17 +02:00
davide 56ce1d4259 test: extend wallet/storage/property coverage and fix nullable warnings
- AppPaths: data-root resolution, per-network paths, wallet file listing.
- TransactionFactory: legacy/P2SH/segwit destinations, multi-UTXO selection,
  dust change absorbed into fee, and a golden txid for the PSBT signing path
  (RFC 6979 makes it deterministic; the builder's own output shuffling for
  privacy makes anchoring the vector at the top-level Build() unreliable).
- PropertyTests: Slip132 roundtrip for every script kind and network,
  WalletDocument JSON roundtrip with arbitrary labels/contacts, Scripthash
  cross-checked against an independent SHA-256 computation.
- StorageTests: regression cases for the IsEncrypted fix.
- Removed CS8602/CS8604 nullable warnings from existing address/loader tests.
2026-07-02 23:21:25 +02:00
davide 448f7dc65d test: cover network/SPV layer end to end (was previously untested)
ElectrumClient, WalletSynchronizer, TransactionInspector, CertificatePinStore
and peer discovery went from 0% coverage to close to full coverage, all
against the fake ElectrumX server:

- ElectrumClient: request/response correlation under load, server errors,
  notifications, disconnection, cancellation, SSL handshake with TOFU pinning.
- WalletSynchronizer: gap-limit scanning, UTXO/history reconstruction,
  unconfirmed/immature balances, busy-retry, disk-cache reuse, and the
  security-critical path where a lying server fails Merkle verification and
  the sync must abort.
- TransactionInspector: fee calculation from resolved inputs, mine/theirs
  attribution, RBF flag, coinbase handling, degraded paths when a previous
  transaction can't be resolved.
- CertificatePinStore: TOFU pin/match/mismatch/reset, corrupted-file fallback.
- ServerRegistry: peer discovery and persistence via DiscoverAsync.
- UpdateChecker: release-tag parsing and semver comparison.
2026-07-02 23:21:01 +02:00
davide e8ff99a768 test: add in-process fake ElectrumX server for network-layer testing
Real loopback TCP socket speaking newline-delimited JSON-RPC 2.0, optionally
TLS with a self-signed certificate, with per-method handlers and call
counters. Lets ElectrumClient, WalletSynchronizer, TransactionInspector, and
CertificatePinStore be exercised against the same code paths used in
production instead of being mocked.

Also exposes UpdateChecker's tag-parsing logic internally so its version
comparison can be tested without a real GitHub API call.
2026-07-02 23:20:32 +02:00
davide 9aecdb1aaa feat(wallet): enforce confirmation thresholds before spending UTXOs
Coinbase outputs require COINBASE_MATURITY + 1 = 121 confirmations before
they can be spent. The consensus rule (palladiumcore tx_verify.cpp) is
nSpendHeight - nHeight >= 120; the +1 adds one block of safety margin,
matching the Qt wallet (wallet.cpp: COINBASE_MATURITY+1). Regular UTXOs
require MinConfirmations (6 on mainnet, 1 on testnet/regtest — wallet
policy, no consensus rule).

Changes:
- ChainProfile: add CoinbaseMaturity and MinConfirmations fields
- ChainProfiles: mainnet CoinbaseMaturity=120 / MinConfirmations=6;
  testnet/regtest inherit 120, override MinConfirmations=1
- PalladiumNetworks: align NBitcoin Consensus.CoinbaseMaturity to 120
- CachedUtxo: add IsCoinbase flag (ElectrumX listunspent does not expose
  this; derived from Transaction.IsCoinBase on the raw tx)
- WalletSynchronizer: set IsCoinbase during local UTXO reconstruction
- TransactionFactory.Build: add tipHeight param; filter by threshold;
  error reports reason (immature coinbase with X/121 counter, under-
  confirmed regulars with best-seen count, mempool amounts)
- All Build() call sites updated (App, Donate, CLI)
- Two new tests: coinbase maturity boundary (119→220 tip) and mainnet
  min-conf boundary (5→6 confirmations)
2026-07-02 16:25:42 +02:00
davide 3d5a226a5a docs: translate all code comments to English (language policy)
Translate every Italian /// XML doc comment, <!-- --> XAML comment, and
// inline comment to English across all source files (Core, App, tests).
Add the language policy to CLAUDE.md (conversation Italian; all code
and docs English). Update one test assertion that checked an Italian
exception message that was also translated.
2026-06-16 14:40:11 +02:00
davide 47b6064964 feat(wallet): IWalletAccount abstraction and WIF/xpub/xprv keystore import types
Introduce IWalletAccount interface to abstract HD, imported-xprv, WIF,
and watch-only account types. HdAccount implements the interface; the
new ImportedKeyAccount handles lists of WIF keys with fixed addresses
(no gap scanning, change returns to first address).

WalletLoader gains three factory methods:
- NewFromXpub: watch-only from SLIP-132 xpub/ypub/zpub
- NewFromXprv: spendable from SLIP-132 xprv/yprv/zprv
- NewFromWif: spendable from one or more WIF private keys

WalletDocument gains optional AccountXprv and WifKeys fields; AccountPath
and AccountXpub become nullable (absent for WIF wallets). IsWatchOnly
updated to cover all non-signing wallet kinds.

TransactionFactory and CLI OpenWallet() updated to IWalletAccount.
12 new unit tests in ImportedKeyAccountTests cover factory round-trips,
address derivation, and watch-only detection.
2026-06-15 14:20:13 +02:00
davide 002c854497 feat(wallet): add P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86) address types
- ScriptKind.Taproot (BIP86, purpose 86', P2WPKH → witness v1 bech32m plm1p)
  derivation wired in DerivationPaths and ChainProfiles (xprv/xpub headers,
  no SLIP-132 standard for P2TR); NBitcoin ScriptPubKeyType.TaprootBIP86
- Wizard step StepScriptType inserted between passphrase and password: 4 radio
  buttons (Legacy / Wrapped SegWit / Native SegWit★ / Taproot), localized in
  6 languages; CreateOrRestore now uses SelectedScriptKind instead of hardcoded
  NativeSegwit; back navigation updated accordingly
- BIP86 golden-vector tests (Bip86TaprootTests): output key verified against
  official Bitcoin BIP86 addresses, plm1p prefix asserted
2026-06-15 12:03:52 +02:00
davide 8cdbd70966 test: add property-based tests with CsCheck (218 total)
9 property tests covering:
- CoinAmount: TryParseIn/TryParseCoins never throw on arbitrary strings;
  FormatIn→TryParseIn roundtrip holds for any sats in [0, MaxSupply];
  parsed results always ≥ 0
- EncryptedFile: Encrypt→Decrypt roundtrip for any plaintext/password;
  wrong password always raises WrongPasswordException (never other exceptions);
  IsEncrypted never throws
- MerkleProof: every leaf in a randomly generated tree verifies against its root
  (1–16 leaves, covers odd/even/single at every position); foreign txid never
  verifies and never crashes
2026-06-13 22:09:02 +02:00
davide ee0b73dd52 test: expand coverage to 209 tests across all core modules
Added tests for CoinAmount (all units, comma decimal, sub-satoshi rejection,
overflow, unknown unit ArgumentException, roundtrip), Storage (nonce/salt
uniqueness per encrypt, atomic write, WalletLock acquire/release/stale-file),
WalletLoader (NewFromMnemonic, passphrase isolation, watch-only, invalid
inputs, ProfileOf), SPV/Merkle (single tx, odd/even counts, altered proof,
empty list), and Chain (unknown NetKind, distinct profiles, port constants).
2026-06-13 21:59:51 +02:00
davide 008e9c395a fix(wallet): reject amounts with sub-satoshi precision
Replace the silent (long) cast with an explicit fractional check:
if value * factor has a non-zero remainder, TryParseIn/TryParseCoins
return false. The caller already shows "Importo non valido." to the
user, so no UI change is needed.

Covers TryParseIn (all units) and TryParseCoins. Adds CoinAmountTests
with valid and invalid cases including the triggering example (1.9 sat).
2026-06-13 20:39:08 +02:00
davide 70cce640aa test(storage): add WalletLock unit tests
Covers acquire/release, double-acquire returning null, and
re-acquire after dispose.
2026-06-13 20:34:21 +02:00
davide e94eaf7700 refactor(arch): split App into shared library + Desktop + Android heads
The Avalonia UI code (App, Views, ViewModels, Localization, Assets) now
lives in src/App as a plain library (no OutputType). Two thin heads
reference it:

- src/App.Desktop/ — WinExe, Avalonia.Desktop, hosts MainView in a
  MainWindow; carries Program.cs and app.manifest (moved from src/App)
- src/App.Android/ — net10.0-android, Avalonia.Android, MainActivity/
  MainApplication; targets API 23+, EmbedAssembliesIntoApk=true so the
  apk is self-contained for sideloading

All event handlers and TopLevel-dependent calls (file picker, clipboard,
folder picker) moved from MainWindow.axaml.cs into the new shared
MainView.axaml.cs (UserControl), using TopLevel.GetTopLevel(this) so
they work on both platforms. Esc/Back key handling is also in MainView.
MainWindow becomes a thin shell that hosts MainView.

Framework bump: all projects move to net10.0; Cli and Tests follow.
2026-06-12 16:06:46 +02:00
davide f3bf4cf94a feat(app): first-run data location wizard step
On first launch (no data yet, no portable dir, no pointer file),
the wizard now shows a new step-0 screen asking where to store
wallets, config and certificates.

AppPaths changes:
- DefaultDataRoot() → ~/.PalladiumWallet (home, always writable)
- IsDataLocationConfigured() → true when portable / override / pointer
  already written / legacy or default already has data
- ConfigureDataLocation(root) writes a bootstrap pointer file and
  creates the directory
- DataRoot() resolution order: override → portable → pointer → legacy
  (has data) → default

ViewModel: new StepDataLocation step, UseDefaultDataLocationCommand,
ApplyDataLocation(root) (also called from View's folder picker).

View: new wizard panel with description, default-path display, two
buttons (use default / choose folder); folder picker via
StorageProvider.OpenFolderPickerAsync.

Loc: add wiz.data.* keys (6 languages); fix fallback language "it"→"en";
update test assertion accordingly.
2026-06-12 09:25:04 +02:00
davide 75583f7a69 feat(app): settings menu with language and display unit
Add global AppConfig (config.json, blueprint §8) persisting language
and amount unit. CoinAmount gains unit-aware formatting and parsing
(PLM, mPLM, µPLM, sat), applied everywhere amounts are shown or
entered, including the send form. New Loc i18n layer (it/en) with
live-updating XAML bindings covering menu, wizard, wallet panel and
status messages.

The Impostazioni menu opens a dropdown with separate Lingua and Unità
entries (radio-checked submenus, applied and saved on click), ready to
host future settings.
2026-06-11 16:23:06 +02:00
davide ece634f42e feat(wallet): show pending mempool balance and exclude unconfirmed UTXOs from spending
TransactionFactory now selects only confirmed UTXOs (height > 0), so
mempool funds are visible as pending balance but never spendable, with
a clear error when only unconfirmed funds are available. GUI and CLI
labels updated to "in attesa di conferma (non spendibile)".
2026-06-11 11:34:21 +02:00
davide 1c2dacc27c feat(net): server registry, bootstrap servers, peer discovery 2026-06-11 11:27:16 +02:00
davide 34c588c6f4 test: unit tests for all core modules 2026-06-11 10:48:27 +02:00
davide ca70570242 chore: solution and project files 2026-06-11 10:46:10 +02:00