67 Commits

Author SHA1 Message Date
davide 0d541d0fe3 chore(release): bump version to 1.1.0
Fills in the CHANGELOG.md entry for the watch-only address-only import
mode, progressive Merkle verification, transaction size-limit fix, and
Android sync-reconnect fixes since 1.0.0, and bumps <Version>/versionCode
across the App and Android head csproj files ahead of the tag.
2026-07-19 17:59:56 +02:00
davide 7057905d94 fix(ui): hide Donate tab in Help until a wallet is open
The tab requires an open wallet to send from, so showing it before
one is loaded led to a dead end. Gate it behind IsWalletOpen, same as
the other wallet-only UI.
2026-07-19 17:46:34 +02:00
davide 51f1af8786 fix(ui): stop wizard/overlay TextBoxes from resizing on focus
Width-capped containers (wizard steps, private-key prompt, wallet info
overlay) used HorizontalAlignment="Center" with MaxWidth, which sizes
the panel from its content's DesiredSize. Since PlaceholderText only
contributes to that measurement while a TextBox is empty and
unfocused, clicking into an empty field shrank the whole panel.

Switch to HorizontalAlignment="Stretch": Avalonia's ArrangeCore sizes
Stretch from the available arrange rect (clamped by MaxWidth) instead
of DesiredSize, and centers the result exactly like Center alignment
does when MaxWidth caps it — so the box stays a fixed width regardless
of focus/placeholder state, on both desktop and Android.
2026-07-19 17:44:01 +02:00
davide 4cd5fab736 fix(sync): bound keep-alive ping and resume a sync stuck on lock/unlock
Three issues surfaced by testing the previous lock/unlock reconnect fix
on Android with a large wallet:

- The keep-alive ping had no timeout, so a "half-open" TCP connection
  (remote end gone with no FIN/RST ever delivered, the common outcome
  of Doze/mobile-radio suspend after a longer lock) left it awaiting a
  response that never arrives — the failure path that tears the dead
  client down was never reached. Bound the ping to 8s and added a
  re-entrancy guard so overlapping 20s ticks can't pile up concurrent
  reconnect attempts while one is stuck.

- CheckConnectionOnResumeAsync bailed out whenever a sync was already
  in progress, so a lock/unlock during an active sync (which has no
  per-request timeout of its own) left it hung indefinitely instead of
  recovering. It now cancels the stuck sync and tears down the dead
  client, marking the interruption as self-inflicted (_resumeRecovering)
  so the UI shows "reconnecting" and restarts immediately instead of a
  transient error message.

- WalletSynchronizer.ExportCaches persisted raw transaction bytes only
  for already-verified transactions, discarding anything downloaded but
  not yet through Merkle-proof verification when a sync was interrupted
  mid-way — forcing a full re-download of a large wallet's transactions
  on every resume instead of resuming straight into proof verification.
  Track confirmed txids at download time (_confirmedTxids), independent
  of verification status, and export/preload against that instead.

Also unified the three sync progress messages onto one template
reporting against the sync's total transaction/proof counts rather than
this session's download count, so a resumed sync immediately shows
"transactions n/n" instead of a misleading "0/0" before jumping into
proof verification.
2026-07-19 17:43:31 +02:00
davide 94a474fe41 fix(sync): recover connection after Android screen lock/unlock
TcpClient.Connected only reflects the last known socket state, so a
connection killed silently while the phone was locked (Doze, mobile
radio suspend, NAT timeout) still reported IsConnected == true. The
keep-alive ping's failure was swallowed by an empty catch, so the
stale "connected" state never cleared and sync kept retrying on a
dead socket instead of reconnecting.

Treat a failed keep-alive ping as a disconnect (tear down the client
and reconnect), and add OnPause/OnResume to the Android activity to
force an immediate health check on resume instead of waiting for the
20s timer, which may itself be suspended during Doze.
2026-07-19 16:01:18 +02:00
davide bbed21e820 docs: reconcile README/SECURITY with current repo state
README.md was out of date after several recent features: it listed 6
UI languages (missing Chinese Simplified), described watch-only as
xpub-only (address-only import also exists), overclaimed multisig as a
working PSBT flow (derivation for it actually throws — not
implemented), and its CLI quick-reference omitted restore-address and
servers. Also links the new USERGUIDE.md, previously unreferenced from
README.

SECURITY.md's "known limitations" list didn't mention multisig is
unsupported, worth stating explicitly since it's fund-safety adjacent.
2026-07-19 12:59:15 +02:00
davide 06f512e2f7 docs(userguide): document first-sync timing and warn against mining payouts
Explains why initial sync scales with transaction count rather than
wallet age (one Merkle-proof round trip per confirmed transaction, no
batching in the Electrum-style protocol) and why it's slower on mobile,
plus why later syncs are fast (cache persists proofs/headers/anchoring
state). Advises against using this wallet as a mining payout address:
the many small transactions typical of payouts make sync noticeably
slower (observed ~2 minutes past 5,000 transactions).
2026-07-19 12:59:02 +02:00
davide 5bb94c071f Merge branch 'watch-only'
Adds a pure address-only watch-only wallet mode (Core + CLI + App wizard
+ Send UI), on top of the existing xpub/xprv/WIF import flows which are
unchanged. TransactionFactory already refuses to sign for any IsWatchOnly
account, so the new address-only accounts inherit that guarantee for free.
2026-07-19 12:44:51 +02:00
davide 11b6a9a9ab docs(security): document address-only watch-only wallets
Keeps the key-management section of the threat model accurate for the
new restore-address / pure address import path (SECURITY.md must stay
in sync with any change to key/seed handling per project convention).
2026-07-19 12:39:52 +02:00
davide 9b00002e39 feat(app): label watch-only send flow and export unsigned PSBT
Send already refused to enable Confirm for watch-only accounts (Signed
false), but the App had no way to get the resulting PSBT out to sign
elsewhere — only the CLI printed it. Adds a base64 PSBT box + copy button
in the Send summary card, and a visible warning banner explaining the
wallet cannot sign, on both desktop and mobile layouts.
2026-07-19 12:39:28 +02:00
davide f0fb5bfcc6 feat(app): add watch-address import step to setup wizard
New wizard flow mirroring the existing xpub/WIF import steps: paste one
or more plain addresses (no key material at all) and go straight to the
password step, since there is no derivation and no script-type ambiguity
to resolve for a fixed address list.
2026-07-19 12:38:58 +02:00
davide 9fa5440ae5 feat(cli): add restore-address command for pure address watch-only
Parallels restore-xpub but for one or more plain addresses with no
extended key and no private key — output is explicitly labelled
"cannot sign" since the resulting wallet can never produce a signed tx.
2026-07-19 12:38:11 +02:00
davide 214abd2892 feat(core): support pure watch-only address import
Adds a WalletDocument.WatchAddresses field and WalletLoader.NewFromAddresses
to build an ImportedKeyAccount with no private key at all (unlike xpub/WIF
imports, which can still derive/hold key material). ScriptKind is inferred
from the address itself via a new DerivationPaths.KindFor helper. Signing
already refuses to run for any IsWatchOnly account (TransactionFactory), so
this only had to wire up construction/reload of the new account shape.
2026-07-19 12:37:31 +02:00
davide 6d05a88073 perf(net): make ElectrumClient's in-flight request cap configurable
MaxInFlight was a hardcoded constant (32); different indexing servers
tolerate different levels of request concurrency before throttling, so
expose it as an optional ConnectAsync parameter instead of requiring a
recompile to tune initial-sync throughput against a given server.
2026-07-19 12:16:18 +02:00
davide d9dd05aa52 perf(spv): persist checkpoint-anchoring state across sync sessions
_anchoredUpTo tracked which heights were already proven to hash-chain
back to a checkpoint, but only in memory: every app restart re-walked
and re-verified the whole header chain from the checkpoint even when
the header bytes themselves were already cached on disk, dominating
reconnect time on large wallets. Round-trip it through SyncCache like
the other sync caches (RawTxHex/VerifiedAt/BlockHeaders).
2026-07-19 12:16:00 +02:00
davide b6440484c1 feat(app): add Chinese (Simplified) as a 7th UI language
Translate every key in Loc.Strings and register "zh" in Loc.Languages
/LanguageNames. Wire it into the Settings language picker, which is a
hand-written RadioButton list (not generated from Loc.Languages), so
add IsLangZh to MainWindowViewModel and the matching button in
MainView.axaml. Update CLAUDE.md/AGENTS.md language count and note the
non-dynamic picker to avoid the same gap next time.
2026-07-18 12:06:31 +02:00
davide 1a4fefadc3 fix(wallet): keep transactions under the standard 100 KvB relay limit
Sending a large amount from a wallet with many small UTXOs could
produce a transaction over the standard relay size limit, surfaced
only as a cryptic "Invalid transaction: Transaction's size is too
high" from builder.Verify() after everything else had already
succeeded — with no way for the user to recover other than manually
picking fewer coins.

Build() now orders spendable UTXOs largest-first and binary-searches
the smallest prefix of them that produces a valid transaction, so it
naturally prefers big coins over dust and avoids the limit whenever a
smaller input set can cover the amount. NBitcoin's own coin selector
already refuses to assemble a combination over the size cap and
reports it via NotEnoughFundsException with a distinctive message
rather than ever handing back an oversized transaction, so that case
is now recognized and translated into a clear, actionable error
instead of being read as "insufficient funds".
2026-07-17 19:44:41 +02:00
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 feb765663c chore(chain): add mainnet checkpoint at height 475124 2026-07-17 15:14:24 +02:00
davide 322ce8f305 chore(release): bump version to 1.0.0
Fills in the CHANGELOG.md entry for the checkpoint-anchoring security fix,
fuzzing infrastructure and its findings, OP_RETURN/coinbase-tag decoding,
and localization fixes since 0.9.1 (see previous commits), and bumps
<Version>/versionCode across the App and Android head csproj files ahead
of the tag.
2026-07-17 14:02:52 +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 663460d62e feat(ui): add user guide button to help overlay
Adds a "User guide" button next to "Report a bug" in the Help > Info
tab, linking to USERGUIDE.md on GitHub via the same Launcher pattern
used for bug reports and release pages.
2026-07-07 22:35:19 +02:00
davide be818a50a8 docs: tighten CLAUDE.md/AGENTS.md formatting for scannability
Convert dense prose paragraphs to bullet lists (Commands, Architecture,
GUI conventions, Working rules) and trim redundant wording; no content
removed. Both files kept in sync per the header sync rule.
2026-07-07 22:29:46 +02:00
davide 6f4ae679e5 docs: document fuzzing infrastructure and its security guarantees
Sync AGENTS.md/CLAUDE.md with the fuzzing workflow, add a README section
pointing to tests/PalladiumWallet.Fuzz, and record in SECURITY.md the two
guarantees the fuzz suite enforces: a bounded PBKDF2 iteration count on read
and a typed InvalidDataException for any malformed encrypted container.
2026-07-07 22:06:45 +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 d8917fbd9a docs: sync test-coverage description with the expanded suite
README's "what the suite covers" table and the CLAUDE.md/AGENTS.md test
seam notes were written before this round of coverage work (checkpoint
anchoring, PoW branch, wordlists, corrupted-key rejection, the
UpdateChecker/AppPaths seams, ...). Updated both to describe what is
actually tested now, including the two new internal seams so future
contributors reach for them instead of re-inventing or skipping
coverage of UpdateChecker/AppPaths.
2026-07-07 16:26:35 +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 69be42aba0 docs(cli): translate console output and comments to English
The CLI printed all wallet/sync/send output, error messages and usage
text in Italian by design; per the project's language policy (code,
comments and docs are English-only, Italian is reserved for user
conversation) this was a design mismatch, not a documented feature.
Translated every user-facing string and the file's Italian comments,
and updated USERGUIDE.md §17 to match.
2026-07-07 14:46:08 +02:00
davide af2fdcc894 fix(gui): route runtime status messages through Loc instead of hardcoded Italian
Send/Donate/Sync/Wizard view models wrote status/error strings
directly in Italian ("Importo non valido.", "Errore: …", the TLS
pin-mismatch message, …), so a user running the app in English/German/
etc. still saw them in Italian despite the 6-language Loc system
covering the rest of the UI.

Added the missing Loc keys and routed every occurrence through
Loc.Tr(...). CertificatePinMismatchException (Core) no longer bakes an
Italian message into .Message; it now exposes Host/Port so the UI can
translate it, with an English fallback for any exception type the UI
doesn't specifically handle. Also drops the now-stale USERGUIDE.md
caveat about a few messages staying in Italian regardless of language.
2026-07-07 14:45:43 +02:00
davide 1f4421ae16 docs: add end-user guide covering GUI and CLI
Full reference for every user-facing behavior (wizard flows, script
types, fees, gap limit, TOFU cert pinning, CLI commands) with the
exact numbers the software enforces, so users don't have to guess or
read the source to understand a rule.
2026-07-07 14:44:48 +02:00
davide b304996ff5 docs(security): correct PBKDF2 iteration count and salt size
SECURITY.md still documented the pre-hardening parameters (100,000
iterations, 32-byte salt); the code has used 600,000 iterations and a
16-byte salt (EncryptedFile.DefaultIterations) since the encryption
upgrade, with the count itself stored in the file container for
forward compatibility.
2026-07-07 14:24:43 +02:00
davide c75e3921aa docs: sync AGENTS.md with CLAUDE.md and fix stale references
AGENTS.md had drifted from CLAUDE.md (ASCII dashes instead of em-dashes,
missing recent updates); both now carry identical content except for the
header, with an explicit rule to keep them in sync on future edits. Also
documents update-version.sh as the release workflow, the per-feature
partial-class split of MainWindowViewModel, UpdateChecker, and fixes a
reference to a nonexistent Core/Spv/MerkleVerifier.cs (the real file is
MerkleProof.cs) in SECURITY.md.
2026-07-02 23:45:53 +02:00
davide 50d8e7f21a docs(security): disclose AI-assisted testing methodology
Documents the concrete techniques used (adversarial fake-server simulation
of a lying indexing server, property-based fuzzing, targeted security code
review) rather than a generic "AI-reviewed" claim, and states explicitly
that this complements rather than replaces independent security review.
2026-07-02 23:38:25 +02:00
davide 15978bf564 chore(release): bump version to 0.9.1
Fills in the CHANGELOG.md entry for the test-suite expansion and bug fixes
since 0.9.0 (see previous commits), and bumps <Version>/versionCode across
the App and Android head csproj files ahead of the tag.
2026-07-02 23:25:48 +02:00
davide 31aabd0856 docs: document expanded test coverage and the fake ElectrumX server
README's "Running tests" section now lists what each test area covers and
how to measure coverage; CLAUDE.md/AGENTS.md point future work at extending
the fake ElectrumX server instead of mocking ElectrumClient (it isn't an
interface, by design).
2026-07-02 23:21:54 +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 27231c8eec fix(core): stop corrupted state from permanently breaking SSL and wallet loading
CertificatePinStore.Load threw on a corrupted pin file, blocking every SSL
connection until the user manually deleted it; EncryptedFile.IsEncrypted
threw on valid JSON with a non-object root or invalid UTF-16 instead of
returning false. Both now degrade gracefully. Found by property-based tests
while extending the test suite.
2026-07-02 23:19:46 +02:00
davide eb7ec9e835 chore: add update-version.sh to bump version across the project
Prompts for the new version and updates the App csproj Version (single
source of truth), mirrors it into the Android head's
ApplicationDisplayVersion, bumps the Android versionCode, and stubs a
new CHANGELOG.md entry.
2026-07-02 22:21:19 +02:00
davide f0fb3fe05d docs: link CHANGELOG.md and require updating it on new version tags
CLAUDE.md: add a working rule to update CHANGELOG.md whenever <Version> is
bumped for a new release tag. README.md: add a Changelog section pointing
to it, and fix a stale claim that release signing "is not set up yet" for
Android — it now is, via docker/build.sh android and the persistent
keystore (docker/keystore/).
2026-07-02 22:01:35 +02:00
davide 3f26f52f8b docs: add CHANGELOG.md for 0.9.0
Technical changelog covering the full history from the initial commit,
grouped by subsystem (Core/Chain, Crypto, Net, Spv, Storage, Wallet, App UI,
Android, CLI, Build, Testing, Docs) rather than raw commit order, since this
is the first release.
2026-07-02 22:01:17 +02:00
davide cb3ff714d9 feat(android): sign release builds with the persistent keystore
build_android now requires the keystore generated by the previous commit,
prompts for its passwords at build time, and signs the APK with it instead
of an ephemeral debug key auto-generated per container run — that was the
actual cause of every release needing a manual uninstall to update. Also
derives versionCode from <Version> instead of leaving it fixed at 1, so
version ordering stays monotonic across releases.

Docs (docker/README.md, CLAUDE.md) updated to match the new signing flow.
2026-07-02 21:54:23 +02:00
davide d0037747b5 chore(android): add persistent release-signing keystore generator
An Android APK's signature identifies the app to the OS; a new build must
carry the same one as the last install or Android refuses it as an update.
generate-keystore.sh creates the keystore once, interactively, and refuses
to run again once one exists to avoid ever changing it by accident. The
resulting file is git-ignored — it's a secret that must live outside the
repo, backed up separately.
2026-07-02 21:53:56 +02:00
davide 34b4313a36 feat(app): check GitHub releases on startup and prompt for updates
Compares the running assembly version against the latest GitHub release
tag on every app launch (desktop and Android) and shows an in-app
overlay with the new version tag when one is available. Best-effort:
network/parse failures are silent, matching the app's existing
non-blocking startup checks.
2026-07-02 21:33:18 +02:00
davide 2d017231eb chore(github): complete issue/PR templates and wire in-app bug report to them
Add a feature request template and a config.yml (blank issues disabled,
security reports routed to SECURITY.md) alongside the existing bug report
template, plus a PR template with a checklist matching the project's
security rules. Point the app's "Report a bug" button at the new GitHub
template now that the repo is hosted there instead of the old self-hosted
tracker.
2026-07-02 21:22:42 +02:00
davide e7797017f6 feat(gui): split server discovery into its own always-usable button
Previously the server settings overlay had only "Connect and sync" and
"Reset certs" — peer discovery had no button and only ran implicitly
when reopening the dialog while connected. Add a dedicated "Sincronizza"
button wired to DiscoverServersCommand, and make it work even without
an active connection by opening a short-lived connection to a candidate
server just to query its peer list, then closing it without touching
the wallet's connection state.
2026-07-02 19:24:16 +02:00
davide 6c24a8bb46 feat(wallet): surface immature balance separately from confirmed
Extract confirmation-threshold logic into UtxoSpendability (shared by
TransactionFactory and WalletSynchronizer) and use it to compute
ImmatureSats, so coinbase/under-confirmed amounts are shown separately
from spendable balance in the GUI and CLI instead of being lumped into
"confirmed".
2026-07-02 18:24:33 +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 2be43d3177 fix(sync): allow server change at any time, even during an active sync
When IsSyncing and the user requests a different server, cancel the
running CancellationTokenSource instead of silently ignoring the click.
OperationCanceledException is caught separately (not an error), and
ConnectAndSync restarts automatically with the new server after the
cancelled task unwinds.

The "Connect" button no longer has IsEnabled="{Binding !IsSyncing}":
it is always clickable, and clicking it with the same server sets
_resyncRequested as before. CancellationToken is now passed to both
ElectrumClient.ConnectAsync and WalletSynchronizer.SyncOnceAsync.
2026-07-02 14:56:58 +02:00
davide ef60ec0330 loc: expand About description to include non-custodial and local security details
All six languages updated: the one-liner was replaced with a two-sentence
description that names the network (PLM), the lightweight SPV design,
the non-custodial model, and the local encryption guarantee.
2026-07-02 12:29:38 +02:00
davide 567c0de501 docs: document reproducible builds and fix Windows publish command
README.md: add "Reproducible builds (Docker)" section at the top of
Building, explaining why it matters for a wallet (auditable toolchain,
no drift between releases, anyone can verify binaries from source).
Fix the manual Windows publish command to include
IncludeNativeLibrariesForSelfExtract=true — the previous command produced
an exe that silently failed to start because Avalonia's native DLLs were
left outside it.

CLAUDE.md: document docker/ in the project tree, promote ./docker/build.sh
as the primary release path, record the two non-obvious gotchas (native
libs flag for single-file desktop; XA5207 / API level coupling for Android).
2026-07-02 10:45:57 +02:00
davide 6a5daa0c18 feat(build): add Docker-based reproducible build system
Adds docker/ with two Dockerfiles and a build.sh interactive script that
builds all three release targets (Windows exe, Linux binary, Android APK)
inside pinned Docker containers — no SDK required on the host.

Key design decisions:
- Source mounted read-only; build runs on an in-container copy so bin/obj
  never pollute the working tree.
- NuGet packages cached in a named Docker volume (plm-nuget-cache) across
  runs to avoid re-downloading on each build.
- Single-file desktop publishes use IncludeNativeLibrariesForSelfExtract so
  Avalonia's native libs (Skia, HarfBuzz, ANGLE) are embedded — without this
  flag the exe/binary silently fails to start.
- Android Dockerfile pins platform API 36 (dictated by the .NET 10 android
  workload, error XA5207 if mismatched) and bakes the full Android SDK +
  workload into the image layer.
- Artifacts are chown'd back to the host user so dist/ files are never
  owned by root.

All three targets verified end-to-end from a clean docker system prune -a:
Windows PE32+ exe (103 MB), Linux single-file binary launches on this host,
Android APK installs and renders UI on API-34 x86_64 emulator.
2026-07-02 10:45:43 +02:00
107 changed files with 7656 additions and 644 deletions
-11
View File
@@ -28,17 +28,6 @@ body:
validations: validations:
required: true required: true
- type: dropdown
id: network
attributes:
label: Network
options:
- Mainnet
- Testnet
- Regtest
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/davide3011/PalladiumWallet/security/policy
about: Please report security vulnerabilities privately, not as a public issue. See SECURITY.md.
@@ -0,0 +1,55 @@
name: Feature Request
description: Suggest an idea or improvement for Palladium Wallet
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest an improvement.
- type: textarea
id: problem
attributes:
label: Problem
description: What problem are you trying to solve? Is it related to something you find missing or frustrating?
placeholder: "I'm always frustrated when …"
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: Describe what you'd like to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any alternative solutions or workarounds you've considered.
validations:
required: false
- type: dropdown
id: platform
attributes:
label: Affected platform(s)
multiple: true
options:
- Windows
- Linux
- Android
- CLI
- All
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and this is not a duplicate
required: true
+26
View File
@@ -0,0 +1,26 @@
## Summary
<!-- What does this PR change and why? -->
## Related issue
<!-- Closes #... (if applicable) -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / cleanup
- [ ] Documentation
- [ ] Build / CI
## Testing
<!-- How was this verified? e.g. `dotnet test`, manual run on Windows/Linux/Android, golden-vector comparison -->
## Checklist
- [ ] `dotnet test` passes
- [ ] No seed/private key material logged or persisted in plaintext (if touching Crypto/Wallet/Storage)
- [ ] Server responses still validated with Merkle + checkpoints (if touching Spv/Net)
- [ ] Code/comments/commit messages are in English
+6
View File
@@ -74,6 +74,8 @@ temp/
*.key *.key
*.pfx *.pfx
*.p12 *.p12
*.keystore
*.jks
secrets/ secrets/
# Wallet data and local runtime state # Wallet data and local runtime state
@@ -91,3 +93,7 @@ settings.local.json
# Local agent/tooling metadata # Local agent/tooling metadata
.agents/ .agents/
.codex/ .codex/
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
findings/
crash-*.bin
+56 -36
View File
@@ -1,23 +1,27 @@
# AGENTS.md # AGENTS.md
This file provides guidance to coding agents when working with code in this repository. This file provides guidance to coding agents (OpenAI Codex and others following the AGENTS.md convention) when working with code in this repository.
> **Sync rule:** `CLAUDE.md` (read by Claude Code) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
## Role ## Role
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified. Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
## Language policy ## Language policy
- **Conversation with the user**: Italian. - **Conversation with the user**: Italian.
- **All code, comments, commit messages, and documentation files**: English only. - **Code, comments, commit messages, documentation files**: English only.
## How to assist ## How to assist
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement -- an honest opinion is worth more than blind execution. Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project proceed. Useless, redundant, already covered elsewhere, or likely to degrade the codesay so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement an honest opinion is worth more than blind execution.
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
## What it is ## What it is
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release. SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
## Stack and structure ## Stack and structure
@@ -26,59 +30,75 @@ SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin
``` ```
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency) src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets) src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico -> runnable src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico runnable
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity -> apk src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity apk
src/Cli/ CLI on the same Core tests/ xUnit src/Cli/ CLI on the same Core tests/ xUnit
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
``` ```
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the - The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted - `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
by `MainWindow` on desktop and as the single-view root on Android. - **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
## Commands ## Commands
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run .NET 10 SDK lives in `~/.dotnet10` in non-interactive shells, before any `dotnet` command run
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`. `export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
- Build: `dotnet build` - **Build:** `dotnet build`
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s - **Test** (headless, primary verification layer): `dotnet test`
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install) - Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
- CLI: `dotnet run --project src/Cli -- <command>` (no args -> usage) - Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained` - Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy) - Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK **Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
(`JAVA_HOME`), and the Android SDK. To provision the SDK once: - Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`. - Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk``src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`): - Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it). **CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
## Architecture (points that require reading multiple files) ## Architecture (points that require reading multiple files)
- **Layers:** GUI -> wallet domain -> SPV/Sync -> Network -> Cryptography -> Persistence; each layer depends only downward. - **Layers:** GUI wallet domain SPV/Sync Network Cryptography Persistence; each layer depends only downward.
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers. - **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it -> `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. Custom layer: NBitcoin assumes Bitcoin's retargeting. - **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing -- **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file. - **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware). - **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333). - **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
## GUI conventions (`src/App`) ## GUI conventions (`src/App`)
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`). - **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here. - **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s -- instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes. - Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
- **Localization:** `Localization/Loc.cs`, key->6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced. - **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. - Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam -- the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null. - Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
- **Localization:** `Localization/Loc.cs`, key→7 languages dictionary (it/en/es/fr/pt/de/zh); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced. The language picker in the Settings overlay (`MainView.axaml`) is a hand-written list of `RadioButton`s bound to `IsLangXx` properties in `MainWindowViewModel.Settings.cs` — it does **not** read `Loc.Languages` dynamically, so a new language needs a button + property added there too, not just a dictionary column.
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
## Working rules ## Working rules
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug. - **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. - **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
+422
View File
@@ -0,0 +1,422 @@
# Changelog
Technical changelog for PalladiumWallet. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); entries are grouped
by subsystem rather than strictly by date, since `0.9.0` is the first
release and covers the full history from the initial commit.
## [1.1.0] — 2026-07-19
Adds a pure address-only watch-only mode (Core + CLI + App wizard + Send
PSBT export), makes SPV sync render balance/history progressively instead
of blocking on full Merkle verification, and fixes several Android
sync-reconnect bugs found by testing the previous fix on a large wallet.
### Added
- Pure watch-only wallets from one or more plain addresses, no extended
key or private key material at all (unlike existing xpub/WIF imports,
which can still derive/hold key material): `WalletDocument.WatchAddresses`
+ `WalletLoader.NewFromAddresses`, `ScriptKind` inferred from the address
via `DerivationPaths.KindFor`, CLI `restore-address` command, and a
matching setup-wizard step. `TransactionFactory` already refused to sign
for any `IsWatchOnly` account, so the new address-only accounts inherit
that guarantee for free.
- Send flow: base64 PSBT export box + copy button and a visible warning
banner for watch-only accounts, so the unsigned PSBT built from a
watch-only wallet can actually be taken elsewhere to sign (previously
only the CLI printed it).
- Chinese (Simplified) as a 7th UI language (`Loc.Strings`/`Languages`);
the Settings language picker is a hand-written `RadioButton` list, not
generated from `Loc.Languages`, so `IsLangZh` was added to
`MainWindowViewModel.Settings.cs` and `MainView.axaml` too.
- New mainnet checkpoint at height 475124 (`ChainProfiles`).
### Changed
- SPV sync now renders balance/history as soon as transaction downloads
finish instead of blocking on every historical Merkle proof — critical
on mobile, where proof-checking can take much longer than the download.
Proofs keep verifying in the background and each transaction's
`Verified` flag catches up progressively; header ranges are fetched in
batches (`blockchain.block.headers`) instead of one call per header.
Coin selection (`UtxoSpendability.IsSpendable`) still refuses to spend a
UTXO until its Merkle proof is actually checked, regardless of
confirmation count — a server fabricating 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. UI surfaces the
new `PendingVerificationSats`/`SpendableSats` split with a
"verifying..." badge.
- `ElectrumClient`'s in-flight request cap (`MaxInFlight`, previously a
hardcoded 32) is now an optional `ConnectAsync` parameter, since
different indexing servers tolerate different concurrency before
throttling.
### Performance
- Checkpoint-anchoring state (`_anchoredUpTo`) is now persisted across
sync sessions via `SyncCache` instead of being re-walked and
re-verified from scratch on every app restart, even when the header
bytes were already cached on disk — this dominated reconnect time on
large wallets.
### Fixed
- `TransactionFactory.Build`: sending a large amount from a wallet with
many small UTXOs could produce a transaction over the standard 100 KvB
relay limit, previously surfaced only as a cryptic
`Transaction's size is too high` error after everything else had
already succeeded. UTXOs are now ordered largest-first with a binary
search for the smallest spendable prefix, naturally preferring big
coins over dust; NBitcoin's own oversized-selection case is now
recognized and translated into a clear, actionable error instead of
being read as "insufficient funds".
- Android: a connection killed silently while the phone was locked (Doze,
radio suspend, NAT timeout) still reported `IsConnected == true`
because `TcpClient.Connected` only reflects the last known socket
state, and the keep-alive ping's failure was swallowed by an empty
catch — sync kept retrying on a dead socket instead of reconnecting.
Fixed across two passes:
- A failed keep-alive ping now tears down the client and reconnects;
`OnPause`/`OnResume` on the Android activity force an immediate
health check on resume instead of waiting for the 20s timer (itself
liable to be suspended during Doze).
- The keep-alive ping had no timeout, so a half-open TCP connection
(the common outcome of a longer Doze suspend) left it awaiting a
response that never arrives, so the teardown path was never reached
— bounded to 8s, plus a re-entrancy guard against overlapping ticks.
- Resume checking bailed out whenever a sync was already in progress,
leaving a lock/unlock during an active sync hung indefinitely; it now
cancels the stuck sync and tears down the dead client instead.
- `WalletSynchronizer.ExportCaches` persisted raw transaction bytes only
for already-verified transactions, discarding anything downloaded but
not yet proof-verified when a sync was interrupted — forcing a full
re-download on every resume of a large wallet. Confirmed txids are
now tracked at download time, independent of verification status.
- Wizard/overlay `TextBox`es (wizard steps, private-key prompt, wallet
info overlay) shrank the whole panel when clicked into empty: their
`HorizontalAlignment="Center"` + `MaxWidth` containers sized from
content `DesiredSize`, and `PlaceholderText` only contributes to that
measurement while empty and unfocused. Switched to
`HorizontalAlignment="Stretch"`, which sizes from the available arrange
rect (clamped by `MaxWidth`) regardless of focus/placeholder state.
- Help overlay's Donate tab is now gated behind `IsWalletOpen`, like the
rest of the wallet-only UI — it requires an open wallet to send from,
so showing it earlier was a dead end.
### Documentation
- `USERGUIDE.md`: documents why initial sync scales with transaction
count rather than wallet age (one Merkle-proof round trip per confirmed
transaction, no batching in the Electrum-style protocol), why later
syncs are fast (cache persists proofs/headers/anchoring state), and
warns against using this wallet as a mining payout address (many small
transactions measurably slow sync).
- `SECURITY.md`: documents address-only watch-only wallets, and adds
multisig to the explicit "known limitations" list (unsupported —
derivation for it throws, not just unimplemented UI).
- `README.md` reconciled with current repo state: 7 UI languages (was
6, missing Chinese Simplified), watch-only described as xpub *and*
address-only (was xpub-only), multisig no longer overclaimed as a
working PSBT flow, CLI quick-reference includes `restore-address` and
`servers`, links the new `USERGUIDE.md`.
## [1.0.0] — 2026-07-09
First stable release. Closes the last open security gap from 0.9.x (header
trust was not actually anchored to any checkpoint), fixes several crash
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
decoding to the transaction detail view.
### Security
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
plus one near tip). Previously the checkpoint array was empty and the
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
never called — on this LWMA chain, where PoW can't be recomputed
client-side, a malicious or eclipsing server could hand back any
internally-consistent header for a Merkle proof with nothing tying it to
the real chain. For every header used in a Merkle proof, the intervening
headers are now downloaded back to the nearest checkpoint and verified as
an unbroken prev-hash chain terminating in that checkpoint's exact hash
(memoized per sync session). Testnet/Regtest remain unanchored (no node
available to source checkpoints from); a missing checkpoint is a no-op,
not a failure.
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
target per untrusted-input parser (header, Merkle proof, peer list,
wallet file, mnemonic/key/address/amount), each encoding that parser's
documented error contract. Found and fixed:
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
wordlist instead of failing gracefully.
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
shape other than the expected `[ip, hostname, [features...]]`, and on a
JSON string containing invalid UTF-8.
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
the documented `WrongPasswordException`/`InvalidDataException`
contract; worse, the PBKDF2 iteration count was read from the
(attacker-controlled) container with no upper bound — a tampered file
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
count is now clamped to 10,000,000.
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
connection until manually deleted; now falls back to first-contact
TOFU like `ServerRegistry` already did.
- The seed corpus (incl. a regression input per fixed finding) replays
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
regress.
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
and disclosure of the AI-assisted testing methodology used (adversarial
fake-server simulation, property-based fuzzing, targeted security
review) as a complement to, not a replacement for, independent review.
### Added
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
are each decoded independently) and coinbase scriptSig pool tags (e.g.
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
height/extranonce). Both were previously dropped entirely — discarded
once no destination address could be derived from the script.
- Help overlay: "User guide" button next to "Report a bug", linking to
`USERGUIDE.md` on GitHub.
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
exact numbers the software enforces.
### Fixed
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
in Italian regardless of the active language; routed through `Loc` with
the missing keys added. `CertificatePinMismatchException` no longer
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
the UI to translate.
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
the code/docs-are-English-only policy; translated every user-facing
string and comment.
### Testing
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
checkpoint-anchoring (including the memoization and non-generic-retry
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
wordlist languages plus the empty-input guard, SLIP-132 rejection of
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
`WalletLoader`'s defensive branches for corrupted files,
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
precedence chain (via new internal override seams), `UpdateChecker`
end-to-end via a stub-transport seam, and `ElectrumClient`'s
multi-segment response dispatch.
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
absence of false positives on standard outputs/inputs.
### Documentation
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
dense prose to scannable bullet lists; a stale `SECURITY.md` file
reference fixed.
- `README.md` test-coverage section and `SECURITY.md` updated to describe
the checkpoint anchoring and fuzzing guarantees actually enforced now.
## [0.9.1] — 2026-07-02
### Testing
- Test suite expanded from 239 to 307 tests; `Core` line coverage raised
from ~50% to ~92% (branch coverage from ~41% to ~79%).
- In-process fake ElectrumX server (`tests/.../Net/FakeElectrumServer.cs`):
a 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 — exercises the network stack against real socket code
instead of mocks.
- New end-to-end coverage for previously untested network/SPV code:
`ElectrumClient` (request pipelining, error mapping, notifications,
disconnection, cancellation, TLS/TOFU handshake), `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 aborts),
`TransactionInspector` (fee calculation, mine/theirs attribution, RBF,
coinbase handling), `CertificatePinStore` (TOFU pin/match/mismatch/reset),
`ServerRegistry` peer discovery, and `UpdateChecker` tag parsing.
- `TransactionFactory`: added coverage for legacy/P2SH/segwit destinations,
multi-UTXO selection, dust change absorbed into the fee, and a golden
txid anchoring the PSBT signing path (deterministic via RFC 6979).
- Property-based tests extended: SLIP-132 roundtrip for every script kind
and network, `WalletDocument` JSON roundtrip with arbitrary
labels/contacts, `Scripthash` cross-checked against an independent
SHA-256 computation.
- `update-version.sh`: single script to bump the version across the project
ahead of a release tag.
### Fixed
- `CertificatePinStore.Load`: a corrupted pin file threw and blocked every
SSL connection until the user manually deleted it; now falls back to
first-contact TOFU, same as `ServerRegistry` already did for its own file.
- `EncryptedFile.IsEncrypted`: threw on valid JSON with a non-object root
(e.g. a bare number or array) or on invalid UTF-16 input, instead of
returning `false`. Both bugs were found by the expanded property tests.
### Documentation
- `README.md`: "Running tests" section rewritten with a per-area coverage
table, the coverage-measurement command, and a description of the fake
ElectrumX server.
- `CLAUDE.md`/`AGENTS.md` kept in sync, pointing future work at extending
the fake ElectrumX server instead of mocking `ElectrumClient` (not an
interface, by design).
## [0.9.0] — 2026-07-02
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
Bitcoin-derived UTXO chain — targeting desktop (Windows/Linux) and Android
from a single Avalonia UI codebase on .NET 10 + NBitcoin.
### Core — Chain
- Network profiles and consensus constants centralized in `Core/Chain`
(`ChainProfiles`/`PalladiumNetworks`): address prefixes, BIP32 headers,
bech32 HRP, genesis, ports, `coin_type` 746 — selectable per network
(mainnet/testnet/regtest), no scattered magic numbers.
- LWMA difficulty / 2-minute blocks: SPV cannot recompute LWMA retargeting,
so PoW validation is skipped and trust is anchored to hardcoded
checkpoints instead.
### Core — Crypto
- HD key derivation: BIP32/39/84, HD accounts, SLIP-132 extended key
serialization.
- Address types: P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86).
- `IWalletAccount` abstraction with WIF/xpub/xprv keystore import (including
watch-only accounts from public material only).
### Core — Net
- Custom `ElectrumClient`: JSON-RPC 2.0 client for the ElectrumX-like
indexing server, with TLS support and TOFU certificate pinning.
- `ServerRegistry`: bootstrap server list, peer discovery, persisted last-used
server, fallback resolution for `--server`.
- Batched writes, zero-allocation reads, bounded in-flight requests for
network throughput.
- Fix: allow changing the indexing server at any time, including during an
active sync.
### Core — Spv
- Header sync with Merkle proof verification and scripthash subscriptions
against hardcoded checkpoints (no full PoW recomputation, per the chain
profile above).
- Per-address balance and transaction-count aggregation in sync results.
- Electrum-style continuous updates: incremental, parallelized sync across
address chains, with a persistent header cache.
- Fix: resilient sync — history discovery via `GetHistory`, transaction
caching, automatic reconnect fallback.
### Core — Storage
- Versioned, encrypted JSON wallet file (`WalletDocument`) with dedicated
persistence and loader layer.
- `WalletLock`: prevents concurrent access to the same wallet file; acquired
before load and before close (fixed a race where it wasn't).
- XDG-compliant data paths (`AppPaths`) with per-platform override seam used
by the Android head; English as default UI language.
- Contacts list (name + address) persisted in `WalletDocument`.
- Documented caveat: `WalletStore.Save` writes plaintext JSON when the
wallet is unencrypted — seed/keys are only ever encrypted-at-rest when the
user opts into a password.
### Core — Wallet
- Coin selection, PSBT-centric transaction factory, and wallet loader.
- Confirmation-threshold enforcement before spending UTXOs; immature
(coinbase) balance surfaced separately from confirmed balance; pending
mempool balance shown, with unconfirmed UTXOs excluded from spending by
default.
- Fix: reject amounts with sub-satoshi precision.
### App — Avalonia UI (shared, `src/App`)
- Single shared `MainView` (UserControl) hosted by `MainWindow` on desktop
and as the single-view root on Android; single `MainWindowViewModel`
(CommunityToolkit.Mvvm), later split into partial files by area
(Wizard/Settings/Sync/Send/Contacts/Receive/...).
- Step-by-step setup wizard (replacing a single-form panel), including a
first-run data-location step, multi-wallet chooser, confirm-password and
encrypt toggle, and dedicated flows for creating a wallet vs. importing
from xpub/xprv/WIF.
- In-app overlay pattern for details/settings/help (`IsXxxOpen` flags, no OS
windows) replacing earlier nested submenus and a separate
`AddressInfoWindow`: server settings, app settings (language/unit),
wallet info (xpub, password-gated seed reveal), address detail
(password-gated private key reveal), transaction detail with full
on-chain data (inputs/outputs, no truncation), and a Help overlay
(Info/Donate tabs).
- Connection-status indicator in the bottom bar; connect-before-wallet-open
flow; persisted last-used server; server discovery split into its own
always-usable button; mainnet hardcoded (network selector removed for the
first release).
- Localization: `Loc` key → 6-language dictionary (it/en/es/fr/pt/de) with
live language switching.
- Receive: QR code generation and copy-to-clipboard for the receive address.
- Android: QR code scanner for the Send address field.
- Centralized design system (color tokens, gradient hero, SVG tab icons);
responsive layout for portrait mobile, unified tab bar (desktop +
mobile), two-column desktop layout for Send/Receive; various mobile-only
fixes (tab bar sizing/indicator overlap, text overflow, full-screen server
overlay on mobile).
- App version shown in window title and Help overlay, read from the single
`<Version>` source in the App csproj.
- In-app update check: compares the running version against the latest
GitHub release tag on startup and shows an overlay with the new tag when
one is available (best-effort — silent on network/parse failure).
- In-app bug-report button (Help overlay) opening a pre-filled GitHub issue
template; issue/PR templates completed.
### Android head (`src/App.Android`)
- Architecture split: shared UI library (`src/App`) + `src/App.Desktop` +
`src/App.Android` heads from the same source (`refactor(arch)`), each
carrying only the per-platform entry point and packages.
- App logo as launcher icon.
- Persistent release-signing keystore workflow
(`docker/keystore/generate-keystore.sh`, git-ignored output): every
release APK is signed with the same key so installing a newer build
updates a previous install in place instead of requiring an uninstall.
`versionCode` derived from `<Version>` instead of a fixed constant.
### CLI (`src/Cli`)
- Commands: `create`/`restore`/`restore-xpub`/`info`,
`sync`/`send`/`servers`/`reset-certs`, `newseed`/`addresses`.
- `servers` command, `info --addresses`, and registry-based fallback
resolution for `--server`.
### Build & Distribution
- Docker-based reproducible build system (`docker/build.sh` +
`docker/Dockerfile.*`): pinned toolchain (.NET 10 SDK, JDK, Android SDK),
builds Windows/Linux single-file executables and a signed Android APK
without any SDK installed on the host.
- Android release signing wired into `build_android` (see Android head
above): requires the persistent keystore, prompts for its passwords at
build time, mounts it read-only into the build container.
### Testing
- Unit test coverage across all Core modules, later expanded to 209 tests.
- Property-based tests via CsCheck (`PropertyTests.cs`), bringing total
coverage to 218 tests; dedicated `WalletLock` concurrency tests.
### Documentation
- `README.md` (project overview, quickstart, reproducible builds), `CLAUDE.md`
(codebase guidance for AI tooling, kept in sync with the multi-head
architecture and .NET 10 migration), `SECURITY.md` (threat model and SPV
trust assumptions), coding-agent guide.
- Code comments translated to English project-wide, per the language policy
(Italian conversation, English code/docs).
+50 -30
View File
@@ -2,22 +2,26 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **Sync rule:** `AGENTS.md` (read by other coding agents) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
## Role ## Role
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified. Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
## Language policy ## Language policy
- **Conversation with the user**: Italian. - **Conversation with the user**: Italian.
- **All code, comments, commit messages, and documentation files**: English only. - **Code, comments, commit messages, documentation files**: English only.
## How to assist ## How to assist
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution. Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project proceed. Useless, redundant, already covered elsewhere, or likely to degrade the codesay so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
## What it is ## What it is
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release. SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
## Stack and structure ## Stack and structure
@@ -29,34 +33,43 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
src/Cli/ CLI on the same Core tests/ xUnit src/Cli/ CLI on the same Core tests/ xUnit
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
``` ```
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the - The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted - `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
by `MainWindow` on desktop and as the single-view root on Android. - **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
## Commands ## Commands
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run .NET 10 SDK lives in `~/.dotnet10` in non-interactive shells, before any `dotnet` command run
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`. `export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
- Build: `dotnet build` - **Build:** `dotnet build`
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s - **Test** (headless, primary verification layer): `dotnet test`
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install) - Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage) - Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained` - Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy) - Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK **Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
(`JAVA_HOME`), and the Android SDK. To provision the SDK once: - Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`. - Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk``src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`): - Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it). **CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
@@ -64,21 +77,28 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward. - **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers. - **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. Custom layer: NBitcoin assumes Bitcoin's retargeting. - **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file. - **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware). - **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333). - **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
## GUI conventions (`src/App`) ## GUI conventions (`src/App`)
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`). - **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here. - **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes. - Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced. - **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. - Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
- **Localization:** `Localization/Loc.cs`, key→7 languages dictionary (it/en/es/fr/pt/de/zh); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced. The language picker in the Settings overlay (`MainView.axaml`) is a hand-written list of `RadioButton`s bound to `IsLangXx` properties in `MainWindowViewModel.Settings.cs` — it does **not** read `Loc.Languages` dynamically, so a new language needs a button + property added there too, not just a dictionary column.
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null. - **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
## Working rules ## Working rules
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug. - **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. - **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
+134 -64
View File
@@ -1,64 +1,134 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
EndProject EndProject
Global Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
GlobalSection(SolutionConfigurationPlatforms) = preSolution EndProject
Debug|Any CPU = Debug|Any CPU Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
Release|Any CPU = Release|Any CPU EndProject
EndGlobalSection Global
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
HideSolutionNode = FALSE Debug|Any CPU = Debug|Any CPU
EndGlobalSection Debug|x64 = Debug|x64
GlobalSection(ProjectConfigurationPlatforms) = postSolution Debug|x86 = Debug|x86
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU Release|Any CPU = Release|Any CPU
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU Release|x64 = Release|x64
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU Release|x86 = Release|x86
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU GlobalSection(ProjectConfigurationPlatforms) = postSolution
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU {13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
EndGlobalSection {13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
GlobalSection(NestedProjects) = preSolution {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933} {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
EndGlobalSection {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
EndGlobal {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
EndGlobalSection
EndGlobal
+85 -10
View File
@@ -8,11 +8,11 @@ Unlike generic wallets adapted to many coins, Palladium Wallet is designed aroun
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain. - **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints. - **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub. - **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub or from plain addresses (no key material at all).
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig). - **PSBT-centric**: signing flows go through PSBT (offline / air-gapped); watch-only wallets export an unsigned PSBT for offline signing. Multisig script kinds are defined in the network profile but not yet implemented (planned, see `Core/Crypto/DerivationPaths.cs`).
- **Multi-network**: mainnet, testnet, regtest. - **Multi-network**: mainnet, testnet, regtest.
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core. - **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German. - **Multilingual**: Italian, English, Spanish, French, Portuguese, German, Chinese (Simplified).
## Architecture ## Architecture
@@ -139,14 +139,71 @@ Run only the tests in one project:
dotnet test tests/PalladiumWallet.Tests dotnet test tests/PalladiumWallet.Tests
``` ```
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`):
```bash
dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"
```
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug. > Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
The suite includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s. ### What the suite covers
The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
| Area | What is verified |
|---|---|
| `Chain/` | Network profiles (prefixes, ports, coin type 746, SLIP-132 headers), NBitcoin network registration and the `INetworkSet` plumbing |
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation, all 8 supported wordlist languages), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode including corrupted-payload rejection, HD and imported-key accounts (fund-safety fallbacks for out-of-range indices), watch-only isolation |
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing and PoW target validation, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the paths where a lying server fails Merkle verification or serves a header chain that does not anchor to a hardcoded checkpoint, and the sync must abort |
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation, oversized responses), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), the update check end-to-end via a stubbed HTTP transport (newer/equal/older tags, HTTP errors, malformed JSON, offline — all best-effort to null) |
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data-path resolution with its full precedence chain (override → portable → pointer file → default) |
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path, standardness-policy rejection of absurd fees), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting, corrupted-wallet-file rejection |
Network-facing code is tested against an **in-process fake ElectrumX server**
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
socket speaking newline-delimited JSON-RPC (optionally TLS with a self-signed
certificate), with per-method handlers and call counters. Client, synchroniser
and inspector therefore exercise the same code paths used in production —
framing, retries, TLS pinning included — without any external dependency.
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
### Fuzzing
`tests/PalladiumWallet.Fuzz` fuzzes every parser that consumes untrusted input
(server-supplied headers/proofs/peer lists, wallet files, user-pasted
keys/mnemonics/addresses/amounts) via [SharpFuzz](https://github.com/Metalnem/sharpfuzz):
each target enforces the parser's documented error contract, so any other
exception escaping is a finding. The seed corpus — including a regression input
for every crash found so far — replays automatically inside `dotnet test`;
coverage-guided campaigns run separately with afl++ (`tests/PalladiumWallet.Fuzz/fuzz.sh`),
and a built-in random-mutation mode (`dotnet run -- <target> --random N`) needs
no external tooling. See `tests/PalladiumWallet.Fuzz/README.md`.
--- ---
## Building ## Building
### Reproducible builds (Docker) — recommended for release binaries
All three distribution targets (Windows exe, Linux binary, Android apk) can be
built with one command inside Docker, with **no SDK installed on the host**:
```bash
./docker/build.sh # interactive menu, or: ./docker/build.sh all
```
Why build this way: the whole toolchain is pinned in the Dockerfiles, so every
build uses exactly the same SDK versions regardless of the host machine — no
toolchain drift between releases — and the build environment itself is
reviewable in the repo. For a wallet this is a trust property, not a
convenience: anyone can rebuild the published binaries from source and check
they were produced by the process the repository declares.
See [docker/README.md](docker/README.md) for prerequisites, usage, how to run
each produced artifact, and troubleshooting. The sections below cover manual
builds with a locally installed SDK (the normal path during development).
### Development build ### Development build
```bash ```bash
@@ -162,7 +219,10 @@ dotnet build src/App.Desktop # desktop head only
```bash ```bash
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe) # Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true --self-contained # IncludeNativeLibrariesForSelfExtract embeds Avalonia's native libs (Skia, HarfBuzz, ANGLE):
# without it they stay as separate DLLs and the .exe alone silently fails to start.
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true --self-contained
# Linux — self-contained; AppImage then produced with PupNet Deploy # Linux — self-contained; AppImage then produced with PupNet Deploy
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet) # (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
@@ -195,8 +255,11 @@ The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
`AndroidSupportedAbis` property is deprecated and ignored.) `AndroidSupportedAbis` property is deprecated and ignored.)
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) Release signing with your own (Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) This debug apk is fine for personal
keystore is not set up yet; the debug apk is fine for personal sideloading. sideloading, but debug builds are signed with a key regenerated per build machine/container, so a
newer debug apk won't install over an older one without an uninstall first. For a stable signature
across releases (needed to update an existing install in place), build via
`./docker/build.sh android` instead — see [docker/keystore/README.md](docker/keystore/README.md).
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator > **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB, > (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
@@ -268,6 +331,9 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
## User guide (quick) ## User guide (quick)
A condensed overview follows; for the complete, exhaustive walkthrough (every screen, every
validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md).
### First launch ### First launch
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox. 1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder. 2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
@@ -297,18 +363,27 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
### CLI in brief ### CLI in brief
```bash ```bash
# Wallet # Wallet
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P] dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
dotnet run --project src/Cli -- restore "<mnemonic>" [...] dotnet run --project src/Cli -- restore "<mnemonic>" [...]
dotnet run --project src/Cli -- info [--net ...] [--password P] dotnet run --project src/Cli -- restore-xpub <slip132-key> [--net ...] [--password P]
dotnet run --project src/Cli -- restore-address <addr1,addr2,...> [--net ...] [--password P]
dotnet run --project src/Cli -- info [--net ...] [--password P]
# Network # Network
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl] dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast] dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
dotnet run --project src/Cli -- servers [--discover]
``` ```
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`). The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
Run without arguments for the full command list (also covers `newseed`, `addresses`, `reset-certs`);
see [USERGUIDE.md §17](USERGUIDE.md#17-command-line-interface-cli) for complete flag reference.
--- ---
## Changelog
Technical, per-version changes are tracked in [CHANGELOG.md](CHANGELOG.md).
## License ## License
Released under the MIT License. See the [LICENSE](LICENSE) file. Released under the MIT License. See the [LICENSE](LICENSE) file.
+35 -5
View File
@@ -21,8 +21,8 @@ It does **not** protect against:
This wallet is an SPV client, not a full node. It validates: This wallet is an SPV client, not a full node. It validates:
- Block headers (proof of work checked up to the last checkpoint; `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side — trust is anchored to hardcoded checkpoints in `Core/Chain/ChainProfiles.cs`) - Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) back to the nearest hardcoded checkpoint at or below its height, and that checkpoint's hash must match exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`). Currently populated for mainnet only (every 20,000 blocks); testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleVerifier.cs`) - Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
It does **not** validate: It does **not** validate:
@@ -41,6 +41,20 @@ It cannot (given correct Merkle verification):
- Fabricate a confirmed transaction with a valid Merkle proof - Fabricate a confirmed transaction with a valid Merkle proof
- Forge a payment to a wrong address - Forge a payment to a wrong address
**Progressive verification (mobile-friendly sync).** On a wallet with many historical
transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof:
balance and history are shown as soon as transaction downloads finish (`PartialResult`,
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
each transaction's `Verified` flag catches up progressively. This means the UI can display a
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
a sync, never a partial one, so no unverified data survives a restart.
--- ---
## Key and seed management ## Key and seed management
@@ -48,15 +62,16 @@ It cannot (given correct Merkle verification):
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock - The BIP39 mnemonic and derived private keys exist only in process memory after unlock
- Private keys are never written to disk, logged, or sent over the network - Private keys are never written to disk, logged, or sent over the network
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only - The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
- Watch-only wallets (`restore-xpub`) hold no private keys and cannot sign transactions - Watch-only wallets (`restore-xpub`, or `restore-address` for pure address imports) hold no private keys and cannot sign transactions; `TransactionFactory` only calls `AddKeys`/signs when the account reports a private key, so an address-only import can never produce a signed transaction, only an exportable unsigned PSBT
--- ---
## Encryption at rest ## Encryption at rest
- Algorithm: AES-256-GCM - Algorithm: AES-256-GCM
- Key derivation: PBKDF2-HMAC-SHA512, 100 000 iterations, 32-byte random salt - Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible — bounded at 10 000 000 on read, since the count is attacker-controlled in a tampered file and an absurd value would hang the wallet at open)
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption - Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
- A malformed container (broken JSON, missing fields, bad base64, wrong nonce/tag size) always fails with a typed `InvalidDataException`, never a raw parser exception — the decrypt path is fuzz-tested (`tests/PalladiumWallet.Fuzz`)
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent - The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
--- ---
@@ -69,7 +84,19 @@ Connections to the indexing server use TOFU (Trust On First Use): the server's T
## Backup ## Backup
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device. The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets (restored from xpub or from a plain address), the private keys must be kept in a separate cold storage device.
---
## AI-assisted testing and vulnerability discovery
Part of the test suite and security review for this project is produced with **Claude Fable 5** (Anthropic), used as a targeted tool rather than a blanket "AI-reviewed" stamp. Concretely:
- **Adversarial network simulation**: the SPV/network layer (`ElectrumClient`, `WalletSynchronizer`, `CertificatePinStore`) is tested against an in-process fake ElectrumX server that can be programmed to lie — serve a transaction with a Merkle proof that doesn't match its claimed block header, drop connections mid-request, return malformed or throttling responses. These are exactly the behaviors a malicious or compromised indexing server would exhibit; the suite asserts the wallet detects and rejects them (see `SpvVerificationException`) instead of trusting unverified server data.
- **Property-based fuzzing** (CsCheck): parsers and cryptographic roundtrips (amount parsing, SLIP-132 key encoding, Merkle proof verification, AES-GCM encrypt/decrypt) are exercised against hundreds of generated inputs per run, checking invariants — no crash on arbitrary input, correct rejection of a wrong password, no false-negative Merkle verification — rather than a handful of hand-picked cases. This methodology has already found and fixed two real defects: a corrupted TLS pin file that permanently blocked reconnection, and a wallet-detection routine that threw on unexpected but valid JSON instead of failing safe.
- **Targeted security-focused code review** over the areas where a bug has direct financial impact: key derivation, transaction signing, coin selection/spendability rules, and encryption at rest — cross-checked against the invariants stated in this document (e.g. "the server cannot fabricate a confirmed transaction with a valid Merkle proof").
This is a complement to, not a substitute for, independent human or third-party security review — which is still recommended before relying on this wallet for significant mainnet funds, particularly ahead of a 1.0 release.
--- ---
@@ -81,3 +108,6 @@ The wallet file is the only thing that needs to be backed up. For encrypted wall
- No coin control (automatic UTXO selection only) - No coin control (automatic UTXO selection only)
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed) - No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
- No Lightning Network support - No Lightning Network support
- No multisig (M-of-N) wallets: the network profile defines multisig SLIP-132 header
variants, but derivation for them is not implemented — attempting to use one throws
rather than silently producing an insecure/incorrect wallet
+881
View File
@@ -0,0 +1,881 @@
# Palladium Wallet — User Guide
This guide covers **every user-facing feature** of Palladium Wallet (GUI and CLI), including
default values, validation rules, limits, and edge cases. It is written so that no behavior
has to be guessed: when the wallet enforces a rule, the rule is stated here with its exact
numbers.
Applies to version **0.9.x**. Where the GUI and the CLI differ, both are described.
---
## Table of contents
1. [What Palladium Wallet is (and is not)](#1-what-palladium-wallet-is-and-is-not)
2. [Key concepts you must understand before holding funds](#2-key-concepts)
3. [Installation and platform differences](#3-installation-and-platform-differences)
4. [First launch: the setup wizard](#4-first-launch-the-setup-wizard)
5. [Opening, closing and managing multiple wallets](#5-opening-closing-and-managing-multiple-wallets)
6. [The main screen](#6-the-main-screen)
7. [Receiving funds](#7-receiving-funds)
8. [Sending funds](#8-sending-funds)
9. [Transaction history and details](#9-transaction-history-and-details)
10. [The Addresses tab](#10-the-addresses-tab)
11. [Contacts](#11-contacts)
12. [Connection, servers and certificate pinning](#12-connection-servers-and-certificate-pinning)
13. [Settings](#13-settings)
14. [Wallet Information (xpub, seed, fingerprint)](#14-wallet-information)
15. [Backup and recovery](#15-backup-and-recovery)
16. [Security model and known limitations](#16-security-model-and-known-limitations)
17. [Command-line interface (CLI)](#17-command-line-interface-cli)
18. [Troubleshooting and error messages](#18-troubleshooting-and-error-messages)
19. [Glossary](#19-glossary)
---
## 1. What Palladium Wallet is (and is not)
Palladium Wallet is a **self-custody SPV (Simplified Payment Verification) wallet** for the
Palladium (PLM) cryptocurrency, a Bitcoin-derived UTXO chain with 2-minute blocks. It runs on
Windows, Linux and Android from the same codebase, plus a separate command-line interface.
**Self-custody** means: *you* hold the keys. There is no account, no server-side backup, no
password recovery service. If you lose your seed phrase (and your wallet file plus its
password), your funds are permanently unrecoverable. Nobody — including the developer — can
restore them.
**SPV** means the wallet does **not** download or validate the full blockchain. It connects to
an **indexing server** (ElectrumX-compatible, ports 50001 TCP / 50002 SSL) and downloads only
the data relevant to your addresses. Every *confirmed* transaction the server reports is
independently verified with a **Merkle proof** against the block header; a server that lies
about confirmed transactions is detected and the synchronization aborts. See
[section 16](#16-security-model-and-known-limitations) for exactly what the server can and
cannot do to you.
What this wallet intentionally does **not** include (as of this version):
- Lightning Network
- Hardware-wallet integration
- Coin control (manual UTXO selection) — coin selection is always automatic
- RBF fee bumping (transactions *signal* RBF, but there is no "bump fee" button)
- Tor/proxy support — the indexing server can observe which addresses you query
- Fiat currency conversion
- Network selection in the GUI — the **GUI operates on mainnet only**; testnet and regtest
are available through the CLI (`--net`)
---
## 2. Key concepts
Read this section once, carefully. Every rule below is enforced by the software exactly as
written.
### 2.1 The seed phrase (BIP39 mnemonic)
- When you create a new wallet in the GUI, it generates a **12-word English** BIP39 mnemonic.
It is shown **once**, during the wizard, and you must write it down **on paper, in order**.
- Whoever knows these words controls the funds — from any device, forever, with no further
information needed (unless you also set a passphrase, see below).
- When *restoring*, the wallet accepts any valid BIP39 mnemonic (12 or 24 words in the GUI
prompt; the parser also accepts 15/18/21-word mnemonics and auto-detects the wordlist
language). The checksum is validated: a mistyped word is rejected, not silently accepted.
### 2.2 The optional passphrase ("25th word")
During creation or restore you may set a **BIP39 passphrase**. Understand precisely what it
does:
- The passphrase is combined with the seed words to derive the keys. Seed + passphrase
produce a **completely different wallet** than the same seed without a passphrase (or with
any other passphrase, including one differing by a single character or by case).
- There is **no validity check possible**: entering the "wrong" passphrase during a restore
does not produce an error — it silently opens a different, empty wallet. If you restore and
see a zero balance where you expected funds, the first thing to check is the passphrase
(then the script type, see 2.3).
- Store the passphrase **separately** from the seed words. Losing the passphrase makes the
funds unrecoverable even if you still have the 12 words.
### 2.3 Script type (address format)
Each wallet is created with exactly one **script type**, which determines the address format
and the derivation path. It cannot be changed later; to switch, create a new wallet and move
the funds.
| Script type | Standard | Derivation | Mainnet addresses look like |
|---|---|---|---|
| Legacy | BIP44 | `m/44'/746'/0'` | start with `P` |
| Wrapped SegWit | BIP49 | `m/49'/746'/0'` | start with `3` |
| **Native SegWit** (default, recommended) | BIP84 | `m/84'/746'/0'` | start with `plm1q` |
| Taproot | BIP86 | `m/86'/746'/0'` | start with `plm1p` |
**Restoring with the wrong script type is the same trap as the wrong passphrase**: the seed
is accepted, but different addresses are derived and the balance shows zero. When restoring,
select the same script type the wallet was created with. If unsure, try Native SegWit first
(the default), then the others.
The coin type in the derivation path is **746** on mainnet (1 on testnet/regtest).
### 2.4 Wallet types
| Type | Created via | Can sign/spend | Notes |
|---|---|---|---|
| HD (BIP39 seed) | Create new / Restore from seed | Yes | The normal case |
| HD (imported xprv) | Import extended key (xprv/yprv/zprv) | Yes | Full account key, no seed words |
| Watch-only (xpub) | Import extended key (xpub/ypub/zpub) | **No** | Sees balances/history; cannot sign. In the GUI it can *prepare* a transaction preview but not sign or broadcast it. The CLI produces an unsigned PSBT for offline signing |
| Imported WIF keys | Import WIF key(s) | Yes | Fixed address list, **no HD derivation and no change chain — change always returns to the first imported address** (this links your coins together; prefer an HD wallet for privacy) |
The extended-key format is **SLIP-132**: `xpub`/`xprv` (Legacy and Taproot), `ypub`/`yprv`
(Wrapped SegWit), `zpub`/`zprv` (Native SegWit). The script type is auto-detected from the
prefix when importing.
### 2.5 The wallet file and its encryption
Everything the wallet knows is stored in **one file per wallet**:
`<data folder>/mainnet/wallets/<name>.wallet.json` (default name `default.wallet.json`).
It contains the seed (or xpub/xprv/WIF keys), the derivation settings, your **contacts**, and
a sync cache (balances, history, verified proofs).
- If you set a password, the file is encrypted with **AES-256-GCM**; the key is derived with
**PBKDF2-HMAC-SHA512, 600,000 iterations, random 16-byte salt** (fresh salt and nonce on
every save). Tampering with the file is detected before decryption.
- If you **decline encryption** (the wizard checkbox, or omitting `--password` in the CLI),
**the seed is stored in plaintext on disk**. The wizard warns you explicitly. Only do this
on a disk you fully trust (e.g. an encrypted volume).
- There are **no password strength requirements and no attempt limits**. An empty password is
not allowed when encryption is enabled. Choose a strong password yourself: an attacker who
copies the file can try passwords offline at their leisure.
- There is currently **no "change password" function** in the GUI. To change the password,
restore the wallet from its seed into a new wallet file with the new password, verify the
new file opens and syncs, then delete the old file. (Contacts must be re-entered — see
[section 15](#15-backup-and-recovery).)
### 2.6 Confirmations and spendability
Incoming funds are not immediately spendable. The exact rules on mainnet:
- **Unconfirmed (mempool)** transactions: shown as *"pending confirmation … not yet
spendable"*. Never spendable, and — important — unconfirmed amounts are reported by the
server **without cryptographic proof**, so treat them as provisional until confirmed.
- **Regular outputs**: spendable after **6 confirmations** (~12 minutes at 2-minute blocks).
- **Mining (coinbase) outputs**: spendable after **121 confirmations** (~4 hours). Until
then they appear as *"maturing … not yet spendable"*.
The main balance shown is *confirmed minus immature*; pending and maturing amounts are shown
as separate amber lines under the balance when present.
---
## 3. Installation and platform differences
### 3.1 Desktop (Windows / Linux)
Release binaries are self-contained — no runtime to install. Run the single executable
(`PalladiumWallet.exe` on Windows, `PalladiumWallet` on Linux).
### 3.2 Android
Install the `.apk` (sideloading must be allowed). Minimum Android version: 6.0 (API 23).
The app requests the **camera** permission only if you use the QR scanner; **internet** is
required for synchronization.
> **Updating in place**: releases built with the project's official signing key update over
> the installed app. If Android refuses to install an update ("app not installed" /
> signature mismatch), the new apk was signed with a different key — you would have to
> uninstall first, **which deletes the wallet file**. Back up your seed before uninstalling,
> always.
### 3.3 What differs between desktop and Android
| Aspect | Desktop | Android |
|---|---|---|
| Data location | Chosen at first run (default or custom folder) | Fixed: the app's private sandbox (step skipped) |
| Menu bar | File / Network / Settings / Wallet / Help | No menu bar; same functions reachable from the UI |
| Close overlays | `Esc` key or click the dark backdrop | Hardware/gesture **Back** button or tap the backdrop |
| QR code **scanning** (Send) | Not available | Camera scan button in the Send form |
| CLI | Available | Not available |
Everything else — wallet formats, security, sync, features — is identical.
### 3.4 Where your data lives (desktop)
Resolution order:
1. A `palladium-data/` folder next to the executable, if present (**portable mode** — create
the folder manually before first launch to get a fully portable install).
2. The custom folder you picked in the first-run wizard (remembered via a small pointer file
in the default location).
3. The default: `%APPDATA%\PalladiumWallet` on Windows, `~/.palladium-wallet` on Linux.
Inside the data root: `mainnet/wallets/*.wallet.json` (your wallets),
`mainnet/server-certs.json` (pinned TLS certificates), `mainnet/servers.json` (discovered
servers), `config.json` (language, unit, last server — shared across wallets).
---
## 4. First launch: the setup wizard
### 4.1 Step: data location (desktop only, very first run)
Choose where the wallet stores its data: **"Use the default path"** (shown on screen) or
**"Choose a folder…"** (native folder picker). This step never reappears once configured.
On Android it is skipped entirely.
### 4.2 Step: start
Five options:
1. **Open existing wallet** — see [section 5](#5-opening-closing-and-managing-multiple-wallets).
2. **Create a new wallet**
3. **Restore from seed**
4. **Import xpub / xprv**
5. **Import WIF key**
### 4.3 Creating a new wallet
1. **Your seed (12 words)** — the freshly generated mnemonic is displayed. Write the words
on paper, in order. *They are never shown in full again except through the password-gated
"Show seed" function ([section 14](#14-wallet-information)).* Do not photograph them, do
not store them in a cloud note.
2. **Confirm seed** — retype the 12 words, space-separated. Comparison is case-insensitive
and tolerant of extra spaces. A mismatch shows *"The words do not match: check what you
wrote on paper."* and lets you retry.
3. **Optional passphrase** — leave empty to skip. Re-read [section 2.2](#22-the-optional-passphrase-25th-word)
before setting one.
4. **Script type** — default Native SegWit. If unsure, keep the default.
5. **Password step** — see 4.7.
### 4.4 Restoring from seed
1. Enter the mnemonic, words separated by spaces. Invalid words or a bad checksum produce
*"Invalid mnemonic (wrong words or checksum): check again."*
2. Enter the **same passphrase** used originally (empty if none was used).
3. Select the **same script type** used originally.
4. Password step (4.7).
After the first synchronization the balance and full history reappear. If the balance is
zero when it shouldn't be: wrong passphrase or wrong script type
([sections 2.22.3](#22-the-optional-passphrase-25th-word)).
### 4.5 Importing an extended key (xpub / xprv)
Paste a SLIP-132 extended key:
- **Public key** (`xpub`/`ypub`/`zpub`) → **watch-only** wallet: monitors balances and
history, cannot spend.
- **Private key** (`xprv`/`yprv`/`zprv`) → fully spendable HD wallet (account level, no seed
words).
The script type is detected automatically from the key prefix and shown for confirmation
(e.g. *"NativeSegwit (watch-only)"*). An unrecognized key shows *"Extended key not
recognised for this network."* — check that it is a Palladium key of a supported format, not
a key from another coin.
### 4.6 Importing WIF private keys
Paste one or more WIF keys, **one per line**. Each key controls exactly one address; there
is no HD derivation. Select the script type (which address form the keys map to), then the
password step. Remember the change-address caveat from
[section 2.4](#24-wallet-types).
### 4.7 The password step (all flows)
- **Wallet name** (optional): letters/numbers recommended; invalid filesystem characters are
replaced with `_`. Left blank → automatic name (`default`, then `wallet-2`, `wallet-3`, …).
A name that already exists is rejected: *"A wallet with this name already exists."*
- **"Encrypt the wallet file with the password"** — checked by default. If you uncheck it,
the file (including the seed) is written **in plaintext** and the UI warns you.
- **Password + confirmation** — must match and be non-empty when encryption is on. No other
rules are imposed; strength is your responsibility.
- **Create wallet** finalizes: the file is written, locked against concurrent access, opened,
and the first connection/sync starts automatically.
---
## 5. Opening, closing and managing multiple wallets
- You can keep **any number of wallets**: each is a separate `*.wallet.json` file in the
wallets folder. With more than one file, "Open existing wallet" shows a chooser list; with
exactly one, it goes straight to the password prompt.
- **Password prompt**: leave empty for an unencrypted wallet. Wrong password → *"Wrong
password."* — retries are unlimited.
- The GUI **only lists wallets inside its own wallets folder**. To use a wallet file from
elsewhere, copy it into `<data folder>/mainnet/wallets/` first (while the app is closed,
or before pressing Open).
- **Single-instance lock**: a wallet file can be open in only one running instance at a
time. A second attempt fails with *"Wallet already open in another instance of the
application."* A stale `.lock` file left by a crash is released automatically when the
owning process is gone.
- **Switching wallets**: menu *File → Close wallet* (returns to the wizard), then *Open
existing wallet*. *File → New / restore wallet…* also closes the current wallet first.
- **Deleting a wallet**: there is no in-app delete. Close the app and delete the
`.wallet.json` file manually — **only after verifying you have the seed on paper** (or,
for imported wallets, the original keys). Deleting the file of an unbacked-up wallet
destroys the funds' keys.
After opening, the header shows the wallet's identity, e.g.
`Mainnet · NativeSegwit · m/84'/746'/0'`, with a ` · watch-only` or ` · imported` tag when
applicable.
---
## 6. The main screen
Five tabs: **History**, **Send**, **Receive**, **Addresses**, **Contacts**.
- **Balance** (top): confirmed, spendable balance in your chosen unit. Below it, when
applicable, two amber advisory lines: *pending confirmation* (mempool, unproven, not
spendable) and *maturing* (young coinbase, not spendable — see
[section 2.6](#26-confirmations-and-spendability)).
- **Status bar** (bottom): left, the sync status (e.g. *"Synchronized: height 123456, 42
SPV-verified transactions. Real-time updates active."*); right, the **connection
indicator** (*connected host:port* / *connecting…* / *not connected* / *reconnecting…*).
**Click/tap the connection indicator to open the server settings.**
- All secondary screens (settings, details, wallet info, help) are **in-app overlays**, not
separate windows. Close them with the ✕ button, by clicking the dark backdrop, with `Esc`
(desktop) or Back (Android).
---
## 7. Receiving funds
The **Receive** tab always shows the **next unused receive address**, as text and as a QR
code (the QR encodes the bare address).
- **Copy** puts the address on the clipboard (*"Address copied to clipboard"*).
- Give a **fresh address to each payer**. After an address receives a payment and a sync
runs, the tab automatically advances to the next unused address. Reusing addresses is not
blocked, but it degrades your privacy by linking payments together.
- Incoming payments appear in History **at the next synchronization** — normally within
seconds, because the wallet subscribes to server push notifications. A payment shows as
*mempool* first, then with its block height once mined.
- The address format is fixed by the wallet's script type
([section 2.3](#23-script-type-address-format)); senders must support that format (in
particular, very old software may not send to `plm1…` bech32 addresses).
**Gap limit — the one receiving rule you must know.** The wallet derives addresses on
demand and, when restoring from seed, scans forward until it finds **20 consecutive unused
addresses** and then stops. Consequence: if you hand out many addresses that never receive
funds, do not skip ahead by more than 20 — a payment to address #30, with #10#29 unused,
would **not be found after a restore from seed**. Under normal use (addresses handed out
sequentially, most getting used) this never triggers. The limit (20) is stored per wallet
and is not editable in the GUI.
---
## 8. Sending funds
Prerequisites: the wallet must be **connected and synchronized** (otherwise: *"Connect to
the server and synchronize before sending"* / *"Synchronize before sending"*), and the
wallet must be spendable (not watch-only) to actually broadcast.
### 8.1 The form
- **From contacts** — optional dropdown that fills the recipient address from your address
book.
- **Recipient address** — validated when you prepare: an address of the wrong network or
with a typo is rejected (checksums make silent typos virtually impossible).
- **Amount** — interpreted in the **currently selected display unit** (PLM by default — check
the unit in Settings before typing!). Rejected if negative, malformed, or more precise
than 1 satoshi (0.00000001 PLM).
- **Send all** — checkbox; disables the amount field and sends the entire spendable balance,
**with the fee deducted from the amount** (the recipient receives balance fee).
- **Fee (sat/vB)** — a single manually-entered fee rate. **Default: 1 sat/vB.** There are no
presets and no automatic fee estimation. On the Palladium chain, blocks are rarely full,
so 1 sat/vB normally confirms in the next few blocks; raise it if a transaction lingers in
the mempool. Must be a number greater than zero.
- **QR scan** (Android only) — camera button; reads a plain address or a
`palladium:` payment URI (the address part is used).
### 8.2 Prepare, then confirm
Sending is a **two-step** operation:
1. **Prepare transaction** — builds and signs the transaction locally, without sending
anything. The summary card shows the txid (truncated), the **exact fee**, and the virtual
size, e.g. `txid 4f3a…| fee 0.00000141 PLM (141 vB)`. Nothing has left your wallet yet;
you can change the fields and prepare again, or simply navigate away to abandon it.
2. **CONFIRM AND BROADCAST** — transmits the prepared transaction to the server. Success
shows the full txid; the form clears and a re-sync starts. After broadcasting, **a
transaction cannot be cancelled** — it is in the network's mempool and will confirm.
### 8.3 How the transaction is built (facts you may need)
- **Coin selection is automatic** (there is no coin control). The wallet spends from its
spendable UTXOs and sends any **change back to its own internal change chain** — this is
why, in transaction details, one output to an address you don't recognize as "yours" may
still be marked as yours: it is your change.
- All transactions **signal RBF** (BIP125) and use transaction version 2. The wallet does
not offer fee bumping; if you underpay the fee, wait — with RBF signaled, other wallets
could in principle replace it, but this wallet has no UI for that.
- **Dust change is absorbed into the fee**: if the change output would be uneconomically
small, it is added to the fee instead of creating a dust output. The fee shown in the
summary already accounts for this — always check the summary fee before confirming.
- A prepared transaction is fully verified against standardness policy before it can be
confirmed; a policy failure surfaces as *"Invalid transaction: …"* instead of a broadcast.
### 8.4 Why "insufficient funds" can appear despite a visible balance
The error message itemizes the reasons. Funds may be temporarily unspendable because they
are:
- **in the mempool** (unconfirmed — never spendable),
- **recently confirmed** (fewer than 6 confirmations),
- **immature coinbase** (mining rewards younger than 121 blocks).
Wait for confirmations and retry. Also remember that the fee itself must fit: sending your
exact full balance fails unless you use **Send all**.
### 8.5 Watch-only wallets and offline signing
A watch-only (xpub) wallet in the **GUI** can prepare a transaction to preview the fee and
size — the summary is tagged *"unsigned (watch-only)"* — but the confirm button stays
disabled: **the GUI cannot export the unsigned transaction**. To actually use the
watch-only + offline-signing workflow, use the **CLI**, whose `send` command prints an
**unsigned PSBT (base64)** for a watch-only wallet
([section 17](#17-command-line-interface-cli)). Sign that PSBT on the offline machine with
software of your choice, then broadcast the finalized transaction.
### 8.6 The Donate tab (Help → Donate)
The Help overlay includes a donation form (developer address
`plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay`) using the same prepare/confirm flow and the
fee rate from the Send tab (falling back to 1 sat/vB if invalid). Entirely optional.
---
## 9. Transaction history and details
The **History** tab lists all wallet transactions, newest first; unconfirmed ones are
labeled **mempool** and sorted on top, confirmed ones show their **block height**. The
amount is the transaction's net effect on your wallet (`+` incoming).
**Double-click** (desktop) or **tap** (Android) a row to open the full details. This
requires a live server connection (details are fetched on demand): otherwise *"Connect to
the server to view transaction details."*
The details overlay shows:
- **Overview** — status (*"N confirmations (block H)"* or *"0 confirmations · in
mempool"*), local date/time, counterparty addresses, and the signed net amount. Mining
rewards show *"Newly generated (mining)"* as the sender.
- **Amounts & fees** — the absolute fee, your net amount, and the fee rate in sat/vB.
- **Technical details** — full transaction ID, total size, virtual size, **Replaceable
(RBF)** yes/no, version, locktime.
- **Inputs** — each spent outpoint with its address and amount; your own inputs are
highlighted. Coinbase inputs display the miner's **scriptSig message** when it is readable
text.
- **Outputs** — each output with index, address and amount; your own outputs (including
change) are highlighted. **OP_RETURN** outputs display their decoded text message when the
payload is valid readable text; non-standard outputs show their script type.
Notes and limits:
- Confirmation counts advance automatically as blocks arrive (real-time header
subscription).
- Every **confirmed** transaction in your history has been verified with a Merkle proof
during sync. **Unconfirmed** entries are the server's word only — do not treat a mempool
payment as received until it confirms.
- There are no per-transaction labels and no block-explorer links in this version. The
public explorer is at `https://explorer.palladium-coin.com/` — paste the txid there
manually if needed.
---
## 10. The Addresses tab
A complete table of every derived address: **type** (receive/change), **index**,
**address**, **balance**, and **transaction count**. Before the first sync it shows the
first 10 receive addresses with no data.
Tap (or right-click) a row to open the **address details** overlay:
- the full address and its **derivation path** (e.g. `m/84'/746'/0'/0/3`),
- the **public key** (hex),
- the **private key (WIF)**, hidden behind a **"Show"** button. For an encrypted wallet,
revealing it requires re-entering the wallet password (*"Enter the wallet password to
reveal the private key."*); for an unencrypted wallet it is shown directly. **Never
reveal a private key with anyone watching your screen** — one key controls that one
address's funds. Watch-only wallets have no private keys to show.
`change` addresses are where your own transactions send their change
([section 8.3](#83-how-the-transaction-is-built-facts-you-may-need)) — seeing balance on
them is normal.
---
## 11. Contacts
A simple per-wallet address book, on its own tab:
- **Add**: name + address, both required (whitespace is trimmed). Note: the address format
is **not validated when saving** — it is only validated when you actually send to it. Copy
addresses carefully.
- **Remove selected**: deletes the highlighted contact. There is no edit function — remove
and re-add.
- Contacts are used by the **From contacts** dropdown on the Send tab.
- Contacts are stored **inside the wallet file**. They travel with a file backup, but are
**not** recoverable from the seed phrase ([section 15](#15-backup-and-recovery)).
---
## 12. Connection, servers and certificate pinning
### 12.1 Server settings
Open by clicking the **connection indicator** in the status bar (or Settings → *Indexing
server…*, or menu *Network* on desktop). Fields:
- **Host** and **Port** — defaults: port **50001** (plain TCP) or **50002** (SSL). Editing
one of the known ports auto-toggles the SSL switch to match, and toggling SSL swaps the
port. Note these are the *indexing server* ports — not the Palladium node's P2P port
(2333), which this wallet never uses.
- **Use SSL** — default **on**. Strongly recommended: without SSL, traffic (including which
addresses you query) is readable on the wire.
- **Known servers** list — click an entry to select it. The wallet ships with four bootstrap
mainnet servers and remembers any servers learned via discovery.
- **Discover servers (peers)** — asks the connected server for its known peers and adds new
ones to the list.
Connection behavior: the wallet tries your selected server first, then walks the rest of
the known list until one answers. The last working server is remembered and reused at next
launch. A 20-second keep-alive ping detects drops and reconnects automatically;
server push notifications trigger instant re-syncs when a new block or a relevant
transaction appears (*"Real-time updates active"*).
### 12.2 TLS certificate pinning (TOFU) — read before "fixing" a certificate error
SSL connections use **Trust On First Use**: the first time you connect to a server, its
certificate's fingerprint is saved (in `server-certs.json`). Every later connection must
present the **same** certificate. If it doesn't, the connection is refused with a message
stating that the TLS certificate of that server **has changed**.
This is a security feature, not a bug. A changed certificate means one of two things:
1. **Benign**: the server operator renewed/rotated the certificate (common, e.g. Let's
Encrypt renewals).
2. **Hostile**: someone is intercepting your connection (man-in-the-middle).
You cannot distinguish the two from the wallet alone. If the change is expected or you can
verify it out-of-band, clear the pins: menu **Network → Reset SSL certificates** (desktop),
the **Reset certs** button in server settings, or `reset-certs` in the CLI. This deletes
**all** pinned certificates for the network; the next connection re-pins whatever it sees.
If you have any reason to suspect interception, switch networks (e.g. off a public Wi-Fi)
or servers instead of resetting.
### 12.3 What synchronization actually does
Each sync: gets the chain tip → scans your receive and change address chains (gap limit
20) → downloads your transactions → **verifies a Merkle proof for every confirmed
transaction** against its block header → rebuilds your UTXO set and balances locally.
On mainnet, each block header used for a proof is also chained back (via its previous-block
hash) to the nearest built-in checkpoint, so a server cannot substitute a fabricated header
for a real one. Verified data is cached in the wallet file, so later syncs only fetch what
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
unverified data; switch servers.
If the server is overloaded (busy responses), the wallet retries automatically up to 8
times with increasing back-off — a large wallet's first sync may take a little while, but it
resumes from the cache instead of restarting.
**First sync can take noticeably longer than later ones — this is expected.** Each confirmed
transaction requires its own Merkle-proof round trip to the indexing server
(`blockchain.transaction.get_merkle`, one request per transaction — the Electrum-style
protocol has no batched form of this call), plus, on mainnet, chaining the covering block
header back to the nearest hardcoded checkpoint. Sync time therefore scales with the number
of confirmed transactions in the wallet's history, not with wall-clock time since creation.
On **Android**, the first sync is typically slower still than on desktop for the same
wallet: mobile networks add higher round-trip latency and lower sustained throughput than a
desktop's wired/Wi-Fi connection, and every proof round trip pays that latency individually.
Every subsequent sync is fast: verified proofs, raw transaction bytes, downloaded block
headers, and the checkpoint hash-chain anchoring state are all persisted into the wallet
file's cache, so a resumed or later sync only fetches and verifies what changed since the
last one — even across an app restart.
**Do not use this wallet as a mining payout address.** Pool or solo mining payouts typically
arrive as many small, frequent transactions, and — because of the per-transaction Merkle
proof cost described above — sync time grows with transaction count, not balance. A wallet
whose history has accumulated **over 5,000 transactions** has been observed taking on the
order of a couple of minutes to fully synchronize even on a stable connection, with slower
networks (see the Android note above) pushing that further. If you mine, pay out to a wallet
purpose-built for high transaction volume (or one that lets you consolidate UTXOs
aggressively), and only move funds into Palladium Wallet in batches.
---
## 13. Settings
The Settings overlay (gear icon / menu *Settings*) contains exactly two preferences, plus
the door to the server settings:
- **Language** — Italiano, English, Español, Français, Português, Deutsch. Applies
immediately. Default: English.
- **Amount unit** — how amounts are displayed *and interpreted when you type them*:
| Unit | In satoshi | Decimals shown |
|---|---|---|
| PLM (default) | 100,000,000 | 8 |
| mPLM | 100,000 | 5 |
| µPLM | 100 | 2 |
| sat | 1 | 0 |
⚠ The Send form's amount field uses this unit. If you switch to `sat` and forget, typing
`100` means 100 satoshi, not 100 PLM. Double-check the unit before sending.
Settings are saved globally (per data folder, shared by all wallets), not per wallet.
There is no theme selector (the app uses its built-in dark theme) and no fiat display.
---
## 14. Wallet Information
Menu **Wallet → Wallet Information** (desktop) or the equivalent button. Shows:
- **File name**, **network**, **wallet type** (*HD (BIP39 seed)* / *HD (imported xprv)* /
*Imported WIF key* / *Watch-only (xpub)*), **script type**, **derivation path**.
- **Extended public key (xpub)** — displayed as selectable text. This is how you *export*
the xpub: select and copy it. Give the xpub to another device/app to create a
**watch-only** copy of this wallet — it reveals your entire address history and balance to
whoever holds it (privacy risk), but can never spend.
- **Master fingerprint** — 4-byte identifier of the master key, used in PSBT workflows.
- **BIP39 passphrase** — shows only whether one is *set*; the passphrase itself is never
displayed anywhere.
- **Show seed** — reveals the mnemonic, after re-entering the wallet password (for
encrypted wallets). Use it to re-verify your paper backup. The on-screen warning is
literal: *never share these words; whoever holds them controls the funds.* Watch-only and
imported wallets have no seed to show.
---
## 15. Backup and recovery
### 15.1 What to back up
There are two complementary backups; know exactly what each one restores:
| Backup | Restores | Does NOT restore |
|---|---|---|
| **Seed words on paper** (+ passphrase if set, + script type) | All keys, addresses, balance and on-chain history, on any device, forever | Contacts; wallet name; settings |
| **The wallet file** (+ its password) | Everything, including contacts | — |
Recommended: **always** have the paper seed (it survives disk failure, device loss, and
software obsolescence); *additionally* copy the `.wallet.json` file if you care about your
contacts list. An encrypted wallet file is safe to store on ordinary media — but it is only
as strong as its password, and it is useless without it.
For a **watch-only** wallet, the file only restores the watching capability; the actual
keys live wherever you keep the corresponding seed/xprv — back *that* up.
For an **imported WIF** wallet, back up the WIF keys themselves; they are not derivable from
anything else.
### 15.2 Restoring, step by step
1. Install the wallet on the new device.
2. Wizard → **Restore from seed** → enter the words → enter the **same passphrase** (or
leave empty if none) → choose the **same script type** → set a (new) password.
3. Let it synchronize. Balance and history are rebuilt from the chain.
If the balance is zero: wrong passphrase, or wrong script type, or (rare) funds beyond the
gap limit ([section 7](#7-receiving-funds)). Nothing is lost — the coins are still on the
chain; you are simply looking at the wrong derived wallet. Retry with the right parameters.
Restoring from a **file copy** instead: place the `.wallet.json` into
`<data folder>/mainnet/wallets/` and open it with its password.
### 15.3 Unrecoverable situations — be aware
- Seed lost **and** wallet file (or its password) lost → funds gone. No exceptions.
- Passphrase forgotten → funds gone, even with the 12 words in hand.
- Encrypted file's password forgotten, no seed backup → funds gone (the encryption has no
backdoor).
---
## 16. Security model and known limitations
A condensed version of the project's published threat model (`SECURITY.md`).
**The wallet protects you against:**
- Theft of the wallet file at rest (AES-256-GCM + PBKDF2, [section 2.5](#25-the-wallet-file-and-its-encryption)) —
provided you set a good password.
- A **lying indexing server**: it cannot fabricate a confirmed transaction or forge a
payment to a wrong address, because every confirmed transaction must carry a valid Merkle
proof, which the wallet checks itself.
- **Silent TLS interception after first contact** (certificate pinning,
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
**The server can still (semi-trusted component):**
- lie about **unconfirmed** transactions (which is why they are marked not-spendable and
should not be trusted until confirmed);
- **withhold** information — hide transactions, delay new blocks, refuse to broadcast;
- **observe** which addresses belong to you (no Tor/proxy support in this version; using
your own indexing server is the strongest mitigation).
**The wallet cannot protect you against:**
- malware on your device (anything that can read process memory can take the keys once the
wallet is unlocked);
- an attacker holding both your wallet file **and** its password (or your seed);
- yourself: there are no confirmation "cool-downs", no address whitelists, and broadcast is
irreversible.
**Honest limitations of this version** (also listed in [section 1](#1-what-palladium-wallet-is-and-is-not)):
single server connection (no pooling), no coin control, no fee estimation, no RBF bumping,
no hardware wallets, no Tor, GUI mainnet-only, and PSBT export only via the CLI.
---
## 17. Command-line interface (CLI)
The CLI (`src/Cli`) runs on the same core as the GUI: same wallet files, same validation,
same security. It is desktop-only and its console output is in English. Run it
as `dotnet run --project src/Cli -- <command>` from the repository (or the published `Cli`
binary); with no arguments it prints usage.
**Shared conventions.** Default wallet file:
`~/.palladium-wallet/<network>/wallets/default.wallet.json` — override with `--file PATH`.
Default network mainnet — override with `--net testnet|regtest` (the CLI is the only way to
use test networks). `--password P` both decrypts an existing file and encrypts on save;
**omitting it on `create`/`restore` writes the seed in plaintext** (a warning is printed).
`--kind legacy|wrapped|segwit` selects the script type (default Native SegWit — note that
only the literal values `legacy` and `wrapped` switch away from the default). Errors print
to stderr and set exit code 1.
### Wallet commands
```
newseed [--words 24]
```
Prints a fresh mnemonic (12 words unless `--words 24`) and writes nothing to disk.
```
create [--words 24] [--kind K] [--net N] [--passphrase W] [--password P] [--file PATH] [--path m/...]
```
Generates a mnemonic, prints it once, saves the wallet, prints the file path and the first
receive address. `--path` overrides the derivation path (advanced; you must remember it to
restore).
```
restore "<mnemonic>" [same options as create]
```
Restores from an existing mnemonic (quoted, words space-separated).
```
restore-xpub <slip132-key> [--net N] [--password P] [--file PATH]
```
Creates a **watch-only** wallet from an xpub/ypub/zpub; the script type is inferred from
the key prefix.
```
info [--net N] [--password P] [--file PATH] [--addresses]
```
Prints the wallet's identity and, if it has ever synced, the balance breakdown (spendable /
maturing / pending), tip height, transaction count and next receive address.
`--addresses` adds the per-address list with balances.
```
addresses "<mnemonic>" [--kind K] [--net N] [--count N] [--passphrase W] [--path m/...]
```
Stateless tool: derives and prints receive (default 5) and change addresses from a mnemonic
without creating any file. Useful to check "which script type was this seed used with"
before restoring.
### Network commands
```
sync [--server host[:port]] [--ssl] [--net N] [--password P] [--file PATH]
```
Connects (default: the first known server; port defaults to 50001, or 50002 with `--ssl`),
synchronizes with full Merkle verification, persists the cache into the wallet file, and
prints balance and history. Transactions the server reported but that are unconfirmed are
marked as unverified.
```
send --to ADDRESS (--amount X | --all) [--feerate R] [--broadcast]
[--server ...] [--ssl] [--net N] [--password P] [--file PATH]
```
Builds a transaction from the synced cache (`sync` must have been run first). `--amount` is
in **PLM**; `--all` sends everything minus the fee. `--feerate` is in sat/vB, **default 1**.
Behavior by wallet type and flags:
- **Watch-only** wallet → prints the **unsigned PSBT in base64** and stops. This is the
air-gapped workflow: carry the PSBT to the offline signer, sign it there, broadcast the
final transaction by any means.
- Spendable wallet, **without `--broadcast`** → signs and prints the raw transaction hex,
explicitly marked as *not transmitted*. Nothing is sent — a dry run you can inspect or
broadcast elsewhere.
- Spendable wallet, **with `--broadcast`** → signs, transmits, prints the txid. Irreversible
from this point.
```
servers [--discover] [--server ...] [--ssl] [--net N]
```
Lists known servers; with `--discover`, connects and asks the server for its peers, adding
new ones.
```
reset-certs [--net N]
```
Deletes all pinned TLS certificates for the network (the CLI counterpart of *Network →
Reset SSL certificates* — see
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
---
## 18. Troubleshooting and error messages
| Symptom / message | Meaning | What to do |
|---|---|---|
| *Wrong password.* | The password does not decrypt the file (or the file was tampered with — the two are indistinguishable by design). | Retry; check keyboard layout/caps lock. If truly lost, restore from seed ([15.2](#152-restoring-step-by-step)). |
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
| Restored wallet shows zero balance | Wrong passphrase, wrong script type, or funds beyond the gap limit. | See [15.2](#152-restoring-step-by-step) — the coins are not lost. |
| *Invalid mnemonic (wrong words or checksum)* | A word is misspelled, not on the BIP39 list, or the checksum fails. | Compare against your paper backup, word by word. |
| *Extended key not recognised for this network.* | The pasted xpub/xprv is not a SLIP-132 key of this network. | Check the key's prefix and the source wallet's export format. |
| *A wallet with this name already exists.* | Filename collision in the wallets folder. | Pick another name, or leave blank for an automatic one. |
| Payment sent to me doesn't appear | Not yet synced/connected, or sender hasn't broadcast. | Check the connection indicator; mempool entries appear within seconds of broadcast when connected. |
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
| First sync is slow / server busy errors | Server throttling (automatic retry, up to 8 attempts) and/or a large transaction history — sync time scales with transaction count, not balance, worse on mobile. | Wait; progress is cached, so restarting resumes rather than repeats. See [12.3](#123-what-synchronization-actually-does). Do not use this wallet for mining payouts (many small transactions). |
---
## 19. Glossary
- **SPV** — Simplified Payment Verification: validating that transactions are included in
blocks via Merkle proofs, without running a full node.
- **Seed / mnemonic** — the 12 (or 24) BIP39 words that deterministically generate every key
in the wallet.
- **Passphrase (BIP39)** — optional extra secret combined with the seed; a different
passphrase yields a different wallet.
- **Script type** — the address format standard (Legacy/BIP44, Wrapped SegWit/BIP49, Native
SegWit/BIP84, Taproot/BIP86).
- **xpub / xprv** — extended public/private key of the wallet's account; the xpub watches,
the xprv spends. SLIP-132 variants: ypub/zpub etc.
- **WIF** — Wallet Import Format: a single address's private key as text.
- **UTXO** — Unspent Transaction Output: a discrete "coin" the wallet can spend.
- **Change** — the portion of spent UTXOs returned to your own (internal) addresses.
- **Coinbase** — the transaction paying the miner of a block; spendable after 121
confirmations.
- **Mempool** — the set of broadcast-but-unconfirmed transactions.
- **Gap limit** — how many consecutive unused addresses the wallet scans past before
stopping (20).
- **PSBT** — Partially Signed Bitcoin Transaction: the standard interchange format for
signing a transaction on a device other than the one that built it.
- **RBF** — Replace-By-Fee: a signal that a transaction may be replaced by a higher-fee
version while unconfirmed.
- **TOFU** — Trust On First Use: pinning a server's TLS certificate at first contact and
refusing silent changes afterwards.
- **Watch-only** — a wallet holding only public keys: full visibility, zero spending
ability.
---
*This guide documents observed behavior of the software and is kept in sync with the code.
For the formal threat model see [SECURITY.md](SECURITY.md); for per-release changes see
[CHANGELOG.md](CHANGELOG.md).*
+39
View File
@@ -0,0 +1,39 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0
# ── Java (required by the Android SDK tools) ─────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless \
wget \
unzip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV ANDROID_HOME=/opt/android-sdk
# .NET 10 android workload compiles against API level 36 (see error XA5207
# if this drifts: the workload's Xamarin.Android.Tooling.targets dictates it).
ENV ANDROID_SDK_PLATFORM=36
ENV ANDROID_SDK_BUILD_TOOLS=36.0.0
# ── Android command-line tools ────────────────────────────────────────────────
# Pin the build number to a known-good release; update when Google breaks the URL.
# Latest as of 2025: commandlinetools-linux-11076708_latest.zip (cmdline-tools 12.0)
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" && \
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-O /tmp/cmdtools.zip && \
unzip -q /tmp/cmdtools.zip -d "${ANDROID_HOME}/cmdline-tools" && \
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" && \
rm /tmp/cmdtools.zip
ENV PATH="${JAVA_HOME}/bin:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
# ── Android SDK packages ──────────────────────────────────────────────────────
RUN yes | sdkmanager --licenses > /dev/null && \
sdkmanager \
"platform-tools" \
"platforms;android-${ANDROID_SDK_PLATFORM}" \
"build-tools;${ANDROID_SDK_BUILD_TOOLS}"
# ── dotnet android workload (~1 GB, baked into this layer) ───────────────────
RUN dotnet workload install android
WORKDIR /tmp/build
+11
View File
@@ -0,0 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0
# clang and zlib are required by the .NET SDK for native compilation steps
# that occur even during managed cross-publish (win-x64 / linux-x64).
RUN apt-get update && apt-get install -y --no-install-recommends \
clang \
zlib1g-dev \
libkrb5-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/build
+199
View File
@@ -0,0 +1,199 @@
# PalladiumWallet — Reproducible Builds via Docker
This folder builds all three distribution targets — **Windows**, **Linux**,
**Android** — inside Docker containers. The entire toolchain (.NET 10 SDK,
JDK, Android SDK, android workload) lives in the container images, pinned in
the Dockerfiles, so:
- **anyone can produce the official binaries** with a single command, on any
Linux machine, without installing any SDK on the host;
- **the toolchain never drifts**: every build uses exactly the same SDK
versions, regardless of what is installed (or updated) on the host;
- **the build environment is auditable**: the Dockerfiles in this folder are
the complete, reviewable definition of how release binaries are made — which
matters for a wallet, where users must be able to trust that the shipped
binary comes from the published source.
> **Scope note:** this pins the *build environment*. Bit-for-bit identical
> output across machines is a stronger property that .NET does not guarantee
> by default (embedded timestamps, signing); if two builds of the same commit
> differ, they differ only in those metadata, not in code.
---
## Prerequisites
A Linux host (native, WSL2, or a VM) with **Docker Engine** installed and
running. Nothing else — no .NET SDK, no JDK, no Android SDK.
```bash
# Check that Docker works:
docker info
```
If Docker is missing, install it from <https://docs.docker.com/engine/install/>
(or `sudo apt install docker.io` on Debian/Ubuntu). If `docker info` fails
with a permission error, add yourself to the `docker` group and start a new
shell:
```bash
sudo usermod -aG docker $USER
```
**Disk space:** ~2 GB for the desktop image, ~7 GB for the Android image
(SDK + emulator-less toolchain). **First-run time:** the images are built
automatically on first use — a few minutes for desktop, 1020 minutes for
Android (large downloads). Subsequent builds reuse the cached images and take
well under a minute (desktop) / a few minutes (Android).
**Android release signing:** before the first `android` build, generate the
persistent signing keystore once — see [`keystore/README.md`](keystore/README.md):
```bash
./docker/keystore/generate-keystore.sh
```
Without it, `build_android` refuses to run — every APK must be signed with
the same key so future releases can update a previous install in place.
---
## Quick start
```bash
# From the repository root (or from the docker/ folder — both work):
./docker/build.sh
```
Running without arguments shows an interactive menu — pick a single target or
`all`. Non-interactive usage:
```
./docker/build.sh [TARGET] [--rebuild]
Targets:
windows Win x64 single-file executable (native libs embedded)
linux Linux x64 single-file binary (runs as-is, nothing to install)
android Android APK (release-signed, prompts for keystore passwords)
all All three targets
Options:
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
```
Examples:
```bash
./docker/build.sh all # build everything
./docker/build.sh windows # Windows only
./docker/build.sh android --rebuild # Android, rebuilding the image first
```
---
## Output — and how to use each artifact
All artifacts land in `dist/` at the repository root. The version number is
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
| Target | Path |
|---------|--------------------------------------------------|
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
**Windows** — a single self-contained `.exe` (runtime and native libraries
embedded). Copy it to any 64-bit Windows 10/11 machine and double-click.
The first launch takes a few extra seconds (it unpacks native libraries to a
per-user cache); later launches are normal. SmartScreen may warn because the
binary is not code-signed — choose "Run anyway".
**Linux** — a single self-contained binary, already executable. Copy and run:
```bash
./PalladiumWallet-{ver}-linux-x64
```
Works on any desktop distro with glibc, X11/Wayland and fontconfig (i.e.
effectively all of them); no .NET or other packages to install. If you
transfer it through a channel that strips permissions (e.g. a web download),
restore the execute bit with `chmod +x`.
**Android** — a release-signed APK for sideloading: transfer it to the phone
and open it (enable "install from unknown sources" if prompted), or install
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
(API 23), arm64 phones and x86_64 emulators.
> **Signature:** every APK is signed with the persistent keystore in
> `docker/keystore/` (see [Prerequisites](#prerequisites)), so installing a
> newer build over an existing one updates it in place — no uninstall, no
> data loss. This only holds as long as every release keeps using that same
> keystore file; see `docker/keystore/README.md` for the backup story.
---
## How it works
### Docker images
| Image | Dockerfile | Used for | Size |
|---------------------|----------------------|-----------------|---------|
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux | ~1.5 GB |
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
Images are built automatically the first time a target needs them and reused
afterwards. Use `--rebuild` only after modifying a Dockerfile.
### Source isolation
The repository is mounted **read-only** inside the container; the build works
on a copy at `/tmp/build`. Your working tree is never touched — no stray
`bin/`/`obj/` directories, and a dirty working tree doesn't leak into the
build beyond the files it contains. Artifacts are written back through a
bind mount to `dist/` and chown'd to your user.
### NuGet cache
A Docker named volume `plm-nuget-cache` holds downloaded NuGet packages
across builds. To reclaim the space or force a clean re-download:
```bash
docker volume rm plm-nuget-cache
```
---
## Troubleshooting
- **`Docker daemon is not running`** — start it (`sudo systemctl start
docker`; on WSL2, start Docker Desktop or the docker service).
- **Android image build fails downloading `commandlinetools`** — Google
rotates the build number in the URL. Update the URL in
`Dockerfile.android` to the current one from
<https://developer.android.com/studio#command-tools> and rerun with
`--rebuild`.
- **`error XA5207: Could not find android.jar for API level N`** — the .NET
android workload moved to a newer API level. Bump
`ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in
`Dockerfile.android` to the level the error names, then `--rebuild`.
- **APK won't install over an existing app
(`INSTALL_FAILED_UPDATE_INCOMPATIBLE`)** — the new build wasn't signed with
the same keystore as the installed one. Make sure `docker/keystore/release.keystore`
hasn't changed since the installed build; if it's genuinely a different key,
the user must uninstall the old app first (this deletes app data — back up
the wallet seed before doing this).
- **`build_android` refuses to run, asks to generate a keystore** — run
`./docker/keystore/generate-keystore.sh` once (see `docker/keystore/README.md`).
- **Everything is broken / start from scratch** —
`docker system prune -a && docker volume rm plm-nuget-cache`, then rerun
the script (images and packages are re-downloaded).
---
## Linux AppImage (future)
The Linux target currently produces a single-file self-contained binary.
Once a `pupnet.conf` is added to the repository, the `build_linux` function
in `build.sh` can be extended to call PupNet Deploy inside the same
`plm-build-desktop` image to also produce an AppImage with desktop
integration (icon, menu entry).
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env bash
# Reproducible build script for PalladiumWallet using Docker.
# Run from anywhere; paths are resolved relative to this script.
set -euo pipefail
# ── Paths ─────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST_DIR="${PROJECT_ROOT}/dist"
# ── Docker image names ────────────────────────────────────────────────────────
IMAGE_DESKTOP="plm-build-desktop"
IMAGE_ANDROID="plm-build-android"
# Named volume for NuGet package cache (shared across all builds, survives reboots).
NUGET_VOLUME="plm-nuget-cache"
# ── Helpers ───────────────────────────────────────────────────────────────────
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf '\033[1;34m>>>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
die() { err "$*"; exit 1; }
usage() {
cat <<EOF
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
$(bold "Targets:")
windows Win x64 single-file executable → dist/windows/
linux Linux x64 single-file binary → dist/linux/
android Android APK (release-signed) → dist/android/
all All three targets
$(bold "Options:")
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
-h, --help Show this help
$(bold "Examples:")
$(basename "$0") # interactive menu
$(basename "$0") all # build everything
$(basename "$0") windows # Windows only
$(basename "$0") android --rebuild
EOF
}
# ── Argument parsing ──────────────────────────────────────────────────────────
REBUILD=false
TARGET=""
for arg in "$@"; do
case "$arg" in
windows|linux|android|all) TARGET="$arg" ;;
--rebuild) REBUILD=true ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown argument: $arg"; usage; exit 1 ;;
esac
done
# ── Interactive menu if no target given ───────────────────────────────────────
if [[ -z "$TARGET" ]]; then
bold "PalladiumWallet — reproducible build"
echo ""
PS3="Select target: "
options=("windows" "linux" "android" "all" "quit")
select opt in "${options[@]}"; do
case "$opt" in
windows|linux|android|all) TARGET="$opt"; break ;;
quit) echo "Aborted."; exit 0 ;;
*) echo "Invalid choice, try again." ;;
esac
done
echo ""
fi
# ── Preflight checks ──────────────────────────────────────────────────────────
command -v docker &>/dev/null || die "Docker is not installed or not in PATH."
docker info &>/dev/null || die "Docker daemon is not running."
# ── Read project version from csproj ─────────────────────────────────────────
CSPROJ="${PROJECT_ROOT}/src/App/PalladiumWallet.App.csproj"
[[ -f "$CSPROJ" ]] || die "Cannot find $CSPROJ — run this script from the repo."
VERSION=$(grep -oP '(?<=<Version>)[^<]+' "$CSPROJ") || die "Cannot extract <Version> from $CSPROJ."
info "Version: ${VERSION}"
# ── Ensure NuGet volume exists ────────────────────────────────────────────────
docker volume inspect "$NUGET_VOLUME" &>/dev/null || \
docker volume create "$NUGET_VOLUME" > /dev/null
# ── Image builders ────────────────────────────────────────────────────────────
ensure_desktop_image() {
if [[ "$REBUILD" == true ]] || ! docker image inspect "$IMAGE_DESKTOP" &>/dev/null; then
info "Building Docker image: ${IMAGE_DESKTOP}"
docker build \
--tag "$IMAGE_DESKTOP" \
--file "${SCRIPT_DIR}/Dockerfile.desktop" \
"${SCRIPT_DIR}"
fi
}
ensure_android_image() {
if [[ "$REBUILD" == true ]] || ! docker image inspect "$IMAGE_ANDROID" &>/dev/null; then
info "Building Docker image: ${IMAGE_ANDROID}"
docker build \
--tag "$IMAGE_ANDROID" \
--file "${SCRIPT_DIR}/Dockerfile.android" \
"${SCRIPT_DIR}"
fi
}
# ── Common docker run wrapper ─────────────────────────────────────────────────
# $1 = image name, $2 = inline bash commands to execute inside the container.
# Source is copied to /tmp/build inside the container so bin/obj never pollute
# the repo. Output is written to /output (→ host DIST_DIR sub-folder).
# Optional extra `docker run` args (e.g. keystore mount, signing passwords via
# -e) can be set by the caller in the EXTRA_DOCKER_ARGS array beforehand.
EXTRA_DOCKER_ARGS=()
run_build() {
local image="$1"
local commands="$2"
local out_dir="$3"
mkdir -p "$out_dir"
docker run --rm \
--volume "${PROJECT_ROOT}:/src:ro" \
--volume "${out_dir}:/output" \
--volume "${NUGET_VOLUME}:/root/.nuget/packages" \
"${EXTRA_DOCKER_ARGS[@]}" \
"$image" \
bash -euo pipefail -c "
cp -r /src/. /tmp/build
cd /tmp/build
${commands}
chown -R $(id -u):$(id -g) /output
"
}
# ── Per-target build functions ────────────────────────────────────────────────
build_windows() {
ensure_desktop_image
info "Building Windows x64 …"
run_build "$IMAGE_DESKTOP" \
"dotnet publish src/App.Desktop \
-r win-x64 \
-c Release \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
--self-contained \
-o /tmp/win-out
cp /tmp/win-out/PalladiumWallet.exe \
\"/output/PalladiumWallet-${VERSION}-win-x64.exe\"" \
"${DIST_DIR}/windows"
ok "Windows → dist/windows/PalladiumWallet-${VERSION}-win-x64.exe"
}
build_linux() {
ensure_desktop_image
info "Building Linux x64 …"
run_build "$IMAGE_DESKTOP" \
"dotnet publish src/App.Desktop \
-r linux-x64 \
-c Release \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
--self-contained \
-o /tmp/linux-out
install -m 755 /tmp/linux-out/PalladiumWallet \
\"/output/PalladiumWallet-${VERSION}-linux-x64\"" \
"${DIST_DIR}/linux"
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
}
build_android() {
ensure_android_image
local keystore="${SCRIPT_DIR}/keystore/release.keystore"
[[ -f "$keystore" ]] || die "No release keystore found at docker/keystore/release.keystore. Run ./docker/keystore/generate-keystore.sh once first (see docker/keystore/README.md)."
info "Building Android APK …"
read -r -s -p "Keystore password: " ANDROID_KS_PASS
echo
read -r -s -p "Key password (press Enter to reuse the keystore password): " ANDROID_KEY_PASS
echo
ANDROID_KEY_PASS="${ANDROID_KEY_PASS:-$ANDROID_KS_PASS}"
# versionCode derived from <Version> (MAJOR.MINOR.PATCH, pre-release suffix
# stripped) so it always increases with releases — required for some
# installers to accept an in-place update even when the signature matches.
local semver="${VERSION%%-*}"
IFS='.' read -r VMAJOR VMINOR VPATCH <<< "$semver"
local VERSION_CODE=$(( ${VMAJOR:-0} * 10000 + ${VMINOR:-0} * 100 + ${VPATCH:-0} ))
info "versionCode: ${VERSION_CODE}"
EXTRA_DOCKER_ARGS=(
--volume "${keystore}:/keystore/release.keystore:ro"
--env "ANDROID_KS_PASS=${ANDROID_KS_PASS}"
--env "ANDROID_KEY_PASS=${ANDROID_KEY_PASS}"
)
run_build "$IMAGE_ANDROID" \
"JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
dotnet build src/App.Android \
-c Release \
-t:SignAndroidPackage \
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
-p:ApplicationVersion=${VERSION_CODE} \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=/keystore/release.keystore \
-p:AndroidSigningKeyAlias=palladiumwallet \
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
-p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS}
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
\"/output/PalladiumWallet-${VERSION}.apk\"" \
"${DIST_DIR}/android"
EXTRA_DOCKER_ARGS=()
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
}
# ── Dispatch ──────────────────────────────────────────────────────────────────
START=$(date +%s)
case "$TARGET" in
windows) build_windows ;;
linux) build_linux ;;
android) build_android ;;
all)
build_windows
build_linux
build_android
;;
esac
ELAPSED=$(( $(date +%s) - START ))
bold ""
bold "Done in ${ELAPSED}s. Artifacts in: ${DIST_DIR}/"
+37
View File
@@ -0,0 +1,37 @@
# Android release-signing keystore
This folder holds the persistent keystore used to sign release APKs built by
`docker/build.sh android`. Every release must be signed with the **same**
key, or Android refuses to install a new build over a previously installed
one (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and forces the user to uninstall
first — which deletes the app's data.
## First-time setup
```bash
./docker/keystore/generate-keystore.sh
```
Run this **once**, on whichever machine will keep producing release builds.
It creates `release.keystore` in this folder and asks for a keystore
password (and, optionally, a separate key password). Nothing is written to
git — `release.keystore` is ignored by `.gitignore`, and the script never
saves the passwords anywhere.
`docker/build.sh android` will refuse to run without this file, and will
prompt you for the same passwords at every build.
## Back it up
The keystore file and its passwords cannot be regenerated: if lost, no
future release can ever update existing installs in place again — every
user would need to uninstall and reinstall from scratch. Copy
`release.keystore` and the passwords to a password manager or encrypted
backup outside this repository as soon as you generate them.
## What NOT to do
- Do not commit `release.keystore` (or any `*.jks`/`*.keystore` file) to git.
- Do not re-run `generate-keystore.sh` once a keystore already exists — it
refuses on purpose; delete the file yourself only if you fully accept the
consequences above.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Generates the persistent Android release-signing keystore used by
# docker/build.sh. Every APK built with this keystore carries the same
# signature, which is what lets Android treat a new release as an update to
# a previously installed one instead of a conflicting, different app.
#
# Run this ONCE. Re-running it after the keystore already exists is refused
# below on purpose: regenerating it changes the signature, and every device
# with a prior release installed would then need a full uninstall (and lose
# any app data that isn't in the wallet file itself) to receive the next
# update.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KEYSTORE_PATH="${SCRIPT_DIR}/release.keystore"
ALIAS="palladiumwallet"
if [[ -f "$KEYSTORE_PATH" ]]; then
echo "A keystore already exists at $KEYSTORE_PATH — refusing to overwrite it." >&2
echo "Delete it manually first only if you understand the consequences above." >&2
exit 1
fi
command -v keytool &>/dev/null || {
echo "keytool not found on PATH — install a JDK (e.g. openjdk-17-jdk) and retry." >&2
exit 1
}
echo "Creating the Palladium Wallet Android release-signing keystore."
echo "This file and the passwords you enter now must be kept safe OUTSIDE this"
echo "repository (password manager, encrypted backup, ...). They are never"
echo "committed to git. Losing them means no future release can ever update"
echo "existing installs in place — only a fresh keystore + full user reinstall."
echo
read -r -s -p "Keystore password (min 6 characters): " KS_PASS
echo
read -r -s -p "Confirm keystore password: " KS_PASS_CONFIRM
echo
if [[ "$KS_PASS" != "$KS_PASS_CONFIRM" ]]; then
echo "Passwords do not match." >&2
exit 1
fi
if [[ "${#KS_PASS}" -lt 6 ]]; then
echo "Password too short (keytool requires at least 6 characters)." >&2
exit 1
fi
read -r -s -p "Key password (press Enter to reuse the keystore password): " KEY_PASS
echo
KEY_PASS="${KEY_PASS:-$KS_PASS}"
export KS_PASS KEY_PASS
keytool -genkeypair -v \
-keystore "$KEYSTORE_PATH" \
-alias "$ALIAS" \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass:env KS_PASS -keypass:env KEY_PASS \
-dname "CN=PalladiumWallet, OU=PalladiumWallet, O=PalladiumWallet, C=IT"
unset KS_PASS KEY_PASS
echo
echo "Keystore created at $KEYSTORE_PATH (git-ignored, alias: $ALIAS)."
echo "Back it up now, together with the passwords — it cannot be regenerated."
echo "./docker/build.sh android will prompt for these same passwords at build time."
+22
View File
@@ -4,6 +4,7 @@ using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using Android.OS; using Android.OS;
using Avalonia.Android; using Avalonia.Android;
using AvaloniaApp = PalladiumWallet.App.App;
namespace PalladiumWallet.Mobile; namespace PalladiumWallet.Mobile;
@@ -17,6 +18,7 @@ public class MainActivity : AvaloniaMainActivity
internal const int ScanRequestCode = 9001; internal const int ScanRequestCode = 9001;
internal static TaskCompletionSource<string?>? ScanTcs; internal static TaskCompletionSource<string?>? ScanTcs;
internal static MainActivity? Current; internal static MainActivity? Current;
private bool _wasPaused;
protected override void OnCreate(Bundle? savedInstanceState) protected override void OnCreate(Bundle? savedInstanceState)
{ {
@@ -24,6 +26,26 @@ public class MainActivity : AvaloniaMainActivity
Current = this; Current = this;
} }
protected override void OnPause()
{
base.OnPause();
_wasPaused = true;
}
protected override void OnResume()
{
base.OnResume();
if (!_wasPaused) return;
_wasPaused = false;
// The TCP socket can die silently while the screen was off/locked (Doze,
// mobile radio suspend, NAT timeout) without the app ever observing the
// failure. Force an immediate health check instead of waiting for the next
// 20s keep-alive tick, which may itself have been suspended for longer than
// the lock.
if (AvaloniaApp.MainViewModel is { } vm)
_ = vm.CheckConnectionOnResumeAsync();
}
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
{ {
base.OnActivityResult(requestCode, resultCode, data); base.OnActivityResult(requestCode, resultCode, data);
@@ -8,8 +8,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId> <ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName --> <!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>4</ApplicationVersion>
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast <!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
+5
View File
@@ -8,6 +8,10 @@ namespace PalladiumWallet.App;
public partial class App : Application public partial class App : Application
{ {
/// <summary>Set once the single ViewModel is created; lets platform heads (e.g. the
/// Android activity) reach it for lifecycle events without a second instance.</summary>
public static MainWindowViewModel? MainViewModel { get; private set; }
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
@@ -16,6 +20,7 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
var vm = new MainWindowViewModel(); var vm = new MainWindowViewModel();
MainViewModel = vm;
// Desktop (Windows/Linux): classic window. Mobile (Android): single // Desktop (Windows/Linux): classic window. Mobile (Android): single
// view. Same shared UI (MainView) and same ViewModel. // view. Same shared UI (MainView) and same ViewModel.
+291 -223
View File
@@ -11,8 +11,8 @@ public sealed class Loc
{ {
public static Loc Instance { get; private set; } = new(); public static Loc Instance { get; private set; } = new();
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"]; public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de", "zh"];
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"]; public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch", "中文"];
public string Language { get; private set; } = "en"; public string Language { get; private set; } = "en";
@@ -42,251 +42,283 @@ public sealed class Loc
private static readonly Dictionary<string, string[]> Strings = new() private static readonly Dictionary<string, string[]> Strings = new()
{ {
// Menu it en es fr pt de // Menu it en es fr pt de zh
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"], ["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei", "_文件"],
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"], ["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…", "新建/恢复钱包…"],
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"], ["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden"], ["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden", "退出"],
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"], ["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk", "_网络"],
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"], ["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)", "发现服务器(节点)"],
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"], ["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen", "重置 SSL 证书"],
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"], ["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen", "_设置"],
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"], ["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe", "_帮助"],
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"], ["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über", "关于"],
["help.info"] = [ ["help.info"] = [
"Wallet SPV per la criptovaluta Palladium (PLM).", "Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
"SPV wallet for the Palladium (PLM) cryptocurrency.", "Lightweight, non-custodial SPV wallet for the Palladium (PLM) network. Local security: seed and keys always encrypted, never exposed on the wire.",
"Monedero SPV para la criptomoneda Palladium (PLM).", "Monedero SPV ligero y sin custodia para la red Palladium (PLM). Seguridad local: semilla y claves siempre cifradas, nunca expuestas en la red.",
"Portefeuille SPV pour la cryptomonnaie Palladium (PLM).", "Portefeuille SPV léger et non-custodial pour le réseau Palladium (PLM). Sécurité locale : graine et clés toujours chiffrées, jamais exposées sur le réseau.",
"Carteira SPV para a criptomoeda Palladium (PLM).", "Carteira SPV leve e não custodial para a rede Palladium (PLM). Segurança local: semente e chaves sempre cifradas, nunca expostas na rede.",
"SPV-Wallet für die Kryptowährung Palladium (PLM)."], "Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert.",
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"], "轻量级、非托管的 Palladium(PLM)网络 SPV 钱包。本地安全:种子和密钥始终加密,绝不在网络上明文传输。"],
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"], ["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info", "信息"],
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"], ["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden", "捐赠"],
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden", "报告错误"],
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch", "用户指南"],
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar", "有可用更新"],
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:", "有新版本可用:"],
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen", "下载"],
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen", "忽略"],
["donate.desc"] = [ ["donate.desc"] = [
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.", "Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
"If you find this wallet useful, consider a small donation to the developer.", "If you find this wallet useful, consider a small donation to the developer.",
"Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.", "Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.",
"Si ce portefeuille vous est utile, envisagez un petit don au développeur.", "Si ce portefeuille vous est utile, envisagez un petit don au développeur.",
"Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.", "Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.",
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler."], "Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler.",
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse"], "如果您觉得这个钱包有用,请考虑向开发者捐赠一点。"],
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag"], ["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse", "开发者地址"],
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten"], ["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag", "捐赠金额"],
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden"], ["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten", "准备捐赠"],
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"], ["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden", "确认并发送"],
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit", "单位"],
// Wizard // Wizard
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"], ["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden", "数据存储位置"],
["wiz.data.info"] = [ ["wiz.data.info"] = [
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.", "Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.", "Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.", "Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.", "Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.", "Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."], "Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen.",
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"], "选择用于存储钱包、配置和证书的文件夹。可以使用默认路径,也可以自行选择。"],
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"], ["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:", "默认路径:"],
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"], ["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden", "使用默认路径"],
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"], ["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…", "选择文件夹…"],
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"], ["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen", "选择要打开的钱包"],
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"], ["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen", "打开现有钱包"],
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"], ["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen", "创建新钱包"],
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren"], ["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"], ["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"],
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"], ["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"],
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"], ["wiz.importaddress.btn"] = ["Importa indirizzo (sola lettura)", "Import address (watch-only)", "Importar dirección (solo lectura)", "Importer une adresse (lecture seule)", "Importar endereço (somente leitura)", "Adresse importieren (nur lesend)", "导入地址(仅观察)"],
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"], ["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"],
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)"], ["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)", "文件密码(未设置则留空)"],
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen", "打开"],
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)", "您的种子(12 个单词)"],
["wiz.seed.warning"] = [ ["wiz.seed.warning"] = [
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.", "Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable.", "Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable.",
"Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.", "Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
"Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.", "Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
"Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.", "Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
"Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren."], "Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren.",
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter"], "请按顺序将这些单词写在纸上。持有这些单词的人即可控制资金;一旦丢失,资金将无法找回。"],
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"], ["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter", "已记录 — 下一步"],
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben"], ["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen", "确认种子"],
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"], ["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben", "重新输入以空格分隔的 12 个单词"],
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"], ["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"], ["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)", "BIP39 助记词(12 或 24 个单词,以空格分隔)"],
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"], ["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase", "可选密码短语"],
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"], ["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen", "留空以跳过"],
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)"], ["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)", "钱包名称(可选)"],
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen."], ["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)", "例如:储蓄、交易…(留空则自动命名)"],
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"], ["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen.", "已存在同名钱包,请选择其他名称。"],
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"], ["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort", "钱包文件密码"],
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"], ["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)", "建议设置(留空 = 文件在磁盘上以明文保存)"],
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"], ["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen", "创建钱包"],
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"], ["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen", "重复密码"],
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln", "使用密码加密钱包文件"],
["wiz.password.encrypt.hint"] = [ ["wiz.password.encrypt.hint"] = [
"Attenzione: senza cifratura il seed resta in chiaro sul disco.", "Attenzione: senza cifratura il seed resta in chiaro sul disco.",
"Warning: without encryption the seed stays in plaintext on disk.", "Warning: without encryption the seed stays in plaintext on disk.",
"Atención: sin cifrado la semilla queda en texto claro en el disco.", "Atención: sin cifrado la semilla queda en texto claro en el disco.",
"Attention : sans chiffrement, la graine reste en clair sur le disque.", "Attention : sans chiffrement, la graine reste en clair sur le disque.",
"Atenção: sem criptografia a semente fica em texto simples no disco.", "Atenção: sem criptografia a semente fica em texto simples no disco.",
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."], "Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte.",
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"], "警告:不加密的话,种子将以明文形式保存在磁盘上。"],
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"], ["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück", "返回"],
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen"], ["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter", "下一步"],
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen", "脚本类型和地址"],
["wiz.scripttype.hint"] = [ ["wiz.scripttype.hint"] = [
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.", "Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
"Determines the address format. If unsure, use Native SegWit.", "Determines the address format. If unsure, use Native SegWit.",
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.", "Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.", "Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.", "Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit."], "Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit.",
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen"], "决定地址格式。如果不确定,请使用原生隔离见证(Native SegWit)。"],
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen"], ["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen", "BIP44 · m/44'/… · P 地址"],
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen"], ["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen", "BIP49 · m/49'/… · 3 地址"],
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen"], ["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen", "BIP84 · m/84'/… · plm1q 地址 — 推荐"],
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"], ["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen", "BIP86 · m/86'/… · plm1p 地址"],
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren", "导入扩展密钥"],
["wiz.importxkey.hint"] = [ ["wiz.importxkey.hint"] = [
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.", "Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.", "Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.", "Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.", "Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.", "Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt."], "Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt.",
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"], "粘贴 xpub/zpub/ypub(仅观察)或 xprv/zprv/yprv(可花费)。脚本类型将自动识别。"],
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren"], ["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren", "导入 WIF 私钥"],
["wiz.importwif.hint"] = [ ["wiz.importwif.hint"] = [
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.", "Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.", "Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.", "Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.", "Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.", "Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."], "Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten.",
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"], "粘贴一个或多个 WIF 密钥(每行一个)。您可以导入多个密钥,用同一个钱包控制多个地址。"],
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)", "K… / L… / 5…(每行一个密钥)"],
["wiz.importaddress.title"] = ["Importa indirizzi in sola lettura", "Import watch-only addresses", "Importar direcciones de solo lectura", "Importer des adresses en lecture seule", "Importar endereços somente leitura", "Nur-Lese-Adressen importieren", "导入仅观察地址"],
["wiz.importaddress.hint"] = [
"Incolla uno o più indirizzi (uno per riga). Nessuna chiave privata è coinvolta: potrai vedere saldo e cronologia ma non potrai mai firmare o inviare transazioni da questo wallet.",
"Paste one or more addresses (one per line). No private key is involved: you'll be able to see the balance and history but you can never sign or send transactions from this wallet.",
"Pega una o más direcciones (una por línea). No hay ninguna clave privada involucrada: podrás ver el saldo y el historial, pero nunca podrás firmar ni enviar transacciones desde este wallet.",
"Collez une ou plusieurs adresses (une par ligne). Aucune clé privée n'est impliquée : vous pourrez voir le solde et l'historique, mais vous ne pourrez jamais signer ni envoyer de transactions depuis ce wallet.",
"Cole um ou mais endereços (um por linha). Nenhuma chave privada está envolvida: você poderá ver o saldo e o histórico, mas nunca poderá assinar ou enviar transações a partir desta carteira.",
"Fügen Sie eine oder mehrere Adressen ein (eine pro Zeile). Es ist kein privater Schlüssel beteiligt: Sie können den Kontostand und den Verlauf sehen, aber nie Transaktionen aus diesem Wallet signieren oder senden.",
"粘贴一个或多个地址(每行一个)。不涉及任何私钥:您可以查看余额和历史记录,但永远无法从此钱包签名或发送交易。"],
["wiz.importaddress.placeholder"] = ["Indirizzo… (uno per riga)", "Address… (one per line)", "Dirección… (una por línea)", "Adresse… (une par ligne)", "Endereço… (um por linha)", "Adresse… (eine pro Zeile)", "地址…(每行一个)"],
// Import error messages // Import error messages
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."], ["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante).", "请粘贴扩展密钥(xpub/xprv 或其变体)。"],
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."], ["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt.", "此网络无法识别该扩展密钥。"],
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."], ["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein.", "请至少粘贴一个 WIF 密钥。"],
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."], ["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig.", "该 WIF 密钥对此网络无效。"],
["msg.address.required"] = ["Incolla almeno un indirizzo.", "Paste at least one address.", "Pega al menos una dirección.", "Collez au moins une adresse.", "Cole pelo menos um endereço.", "Fügen Sie mindestens eine Adresse ein.", "请至少粘贴一个地址。"],
["msg.address.invalid"] = ["Indirizzo non valido per questa rete.", "Address not valid for this network.", "Dirección no válida para esta red.", "Adresse invalide pour ce réseau.", "Endereço inválido para esta rede.", "Adresse für dieses Netzwerk ungültig.", "该地址对此网络无效。"],
// Wallet panel // Wallet panel
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"], ["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"], ["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:", "服务器:"],
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"], ["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden", "连接"],
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"], ["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port", "或手动输入 host:port"],
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"], ["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren", "同步服务器"],
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"], ["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen", "重置证书"],
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"], ["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen", "接收"],
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"], ["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf", "历史"],
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"], ["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen", "地址"],
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"], ["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden", "发送"],
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"], ["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte", "联系人"],
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"], ["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:", "下一个未使用地址:"],
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"], ["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren", "复制"],
["receive.hint"] = [ ["receive.hint"] = [
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.", "Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
"Payments received here will appear in the history at the next synchronization.", "Payments received here will appear in the history at the next synchronization.",
"Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.", "Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
"Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.", "Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
"Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.", "Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."], "Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf.",
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"], "在此收到的付款将在下次同步时出现在历史记录中。"],
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"], ["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ", "类型"],
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"], ["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index", "索引"],
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"], ["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"], ["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo", "余额"],
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"], ["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert", "地址已复制到剪贴板"],
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"], ["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:", "派生路径:"],
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"], ["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:", "公钥:"],
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"], ["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):", "私钥(WIF):"],
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen"], ["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen", "显示"],
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen."], ["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen", "确认身份"],
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"], ["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen.", "输入钱包密码以显示私钥。"],
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"], ["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"], ["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang", "接收"],
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"], ["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld", "找零"],
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"], ["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen", "地址信息"],
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen", "关闭"],
// History → transaction detail // History → transaction detail
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."], ["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details.", "双击某笔交易可查看详情。"],
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"], ["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails", "交易详情"],
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"], ["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen", "关闭"],
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"], ["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…", "正在从服务器加载交易数据…"],
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"], ["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status", "状态"],
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"], ["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool", "0 次确认 · 在内存池中"],
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"], ["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen", "次确认"],
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"], ["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block", "区块"],
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"], ["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool", "在内存池中"],
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"], ["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum", "日期"],
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"], ["tx.to"] = ["A", "To", "Para", "À", "Para", "An", "至"],
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"], ["tx.from"] = ["Da", "From", "De", "De", "De", "Von", "来自"],
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"], ["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll", "支出"],
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"], ["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben", "收入"],
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"], ["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr", "交易手续费"],
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"], ["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte", "每虚拟字节手续费"],
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"], ["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag", "净额"],
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"], ["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID", "交易 ID"],
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"], ["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße", "总大小"],
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"], ["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe", "虚拟大小"],
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"], ["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)", "可替换(RBF"],
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"], ["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge", "输入"],
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"], ["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge", "输出"],
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"], ["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja", "是"],
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"], ["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein", "否"],
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."], ["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen.", "连接服务器以查看交易详情。"],
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht"], ["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht", "概览"],
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren"], ["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren", "金额与手续费"],
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details"], ["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details", "技术详情"],
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase"], ["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "创币交易"],
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)"], ["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)", "新生成(挖矿)"],
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"], ["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:", "来自联系人:"],
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"], ["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen", "选择以填充地址"],
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"], ["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse", "收款地址"],
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"], ["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag", "金额"],
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"], ["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden", "全部发送"],
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"], ["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:", "手续费 sat/vB"],
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"], ["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten", "准备交易"],
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"], ["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen", "扫描二维码"],
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"], ["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN", "确认并广播"],
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger"], ["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger", "收款人"],
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr"], ["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr", "金额与手续费"],
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"], ["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung", "摘要"],
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"], ["send.psbt.label"] = ["PSBT non firmata (base64) — da firmare altrove", "Unsigned PSBT (base64) — sign it elsewhere", "PSBT sin firmar (base64) — fírmala en otro lugar", "PSBT non signée (base64) — à signer ailleurs", "PSBT não assinada (base64) — assine em outro lugar", "Unsignierte PSBT (base64) — anderswo signieren", "未签名 PSBTbase64)— 请在别处签名"],
["send.psbt.copy"] = ["Copia PSBT", "Copy PSBT", "Copiar PSBT", "Copier la PSBT", "Copiar PSBT", "PSBT kopieren", "复制 PSBT"],
["send.watchonly.hint"] = ["Wallet in sola lettura: non può firmare. Esporta la PSBT e firmala con un wallet che possiede le chiavi.", "Watch-only wallet: it cannot sign. Export the PSBT and sign it with a wallet that holds the keys.", "Wallet de solo lectura: no puede firmar. Exporta la PSBT y fírmala con un wallet que tenga las claves.", "Wallet en lecture seule : il ne peut pas signer. Exportez la PSBT et signez-la avec un wallet possédant les clés.", "Carteira somente leitura: não pode assinar. Exporte a PSBT e assine-a com uma carteira que tenha as chaves.", "Nur-Lese-Wallet: kann nicht signieren. Exportieren Sie die PSBT und signieren Sie sie mit einem Wallet, das die Schlüssel besitzt.", "仅观察钱包:无法签名。请导出 PSBT 并用持有密钥的钱包签名。"],
["psbt.copied"] = ["PSBT copiata negli appunti", "PSBT copied to clipboard", "PSBT copiada al portapapeles", "PSBT copiée dans le presse-papiers", "PSBT copiada para a área de transferência", "PSBT in die Zwischenablage kopiert", "PSBT 已复制到剪贴板"],
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse", "您的地址"],
// Wallet info overlay // Wallet info overlay
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet"], ["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_钱包"],
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen"], ["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen", "钱包信息"],
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei"], ["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei", "文件"],
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk"], ["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk", "网络"],
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ"], ["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ", "钱包类型"],
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)"], ["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)", "HDBIP39 种子)"],
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)"], ["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)", "HD(已导入 xprv"],
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel"], ["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel", "已导入 WIF 密钥"],
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)"], ["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "仅观察(xpub"],
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script"], ["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script", "脚本"],
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad"], ["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad", "派生路径"],
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel"], ["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel", "扩展公钥"],
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint"], ["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint", "主指纹"],
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)"], ["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)", "种子(BIP39 助记词)"],
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert)."], ["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert).", "此钱包没有种子(仅观察或已导入)。"],
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:"], ["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:", "输入钱包文件密码以解锁种子:"],
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen"], ["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen", "显示种子"],
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"], ["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
["walletinfo.seed.warning"] = [ ["walletinfo.seed.warning"] = [
"Non condividere mai queste parole. Chi le possiede controlla i fondi.", "Non condividere mai queste parole. Chi le possiede controlla i fondi.",
"Never share these words. Whoever holds them controls the funds.", "Never share these words. Whoever holds them controls the funds.",
"Nunca compartas estas palabras. Quien las tenga controla los fondos.", "Nunca compartas estas palabras. Quien las tenga controla los fondos.",
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.", "Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.", "Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder."], "Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder.",
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase"], "切勿分享这些单词。持有者即可控制资金。"],
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)"], ["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase", "BIP39 密码短语"],
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)", "(已设置)"],
// Connection status // Connection status
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"], ["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden", "未连接"],
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"], ["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt", "已断开"],
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"], ["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…", "正在重新连接…"],
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"], ["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler", "连接错误"],
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"], ["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert", "证书已更改"],
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"], ["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden", "已连接"],
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"], ["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu", "正在连接"],
// Main status messages // Main status messages
["msg.welcome.existing"] = [ ["msg.welcome.existing"] = [
@@ -295,132 +327,168 @@ public sealed class Loc
"Se encontró un wallet existente en esta red: ábrelo o crea otro.", "Se encontró un wallet existente en esta red: ábrelo o crea otro.",
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.", "Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.", "Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues."], "Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues.",
"在此网络上发现现有钱包:打开它,或创建新钱包。"],
["msg.welcome.new"] = [ ["msg.welcome.new"] = [
"Benvenuto: crea un nuovo wallet o ripristina da seed.", "Benvenuto: crea un nuovo wallet o ripristina da seed.",
"Welcome: create a new wallet or restore from seed.", "Welcome: create a new wallet or restore from seed.",
"Bienvenido: crea un nuevo wallet o restaura desde semilla.", "Bienvenido: crea un nuevo wallet o restaura desde semilla.",
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.", "Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
"Bem-vindo: crie uma nova carteira ou restaure da semente.", "Bem-vindo: crie uma nova carteira ou restaure da semente.",
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."], "Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her.",
"欢迎:创建新钱包或从种子恢复。"],
["msg.open.password"] = [ ["msg.open.password"] = [
"Inserisci la password del file (lascia vuoto se non impostata).", "Inserisci la password del file (lascia vuoto se non impostata).",
"Enter the file password (leave empty if not set).", "Enter the file password (leave empty if not set).",
"Ingresa la contraseña del archivo (deja vacío si no establecida).", "Ingresa la contraseña del archivo (deja vacío si no establecida).",
"Entrez le mot de passe du fichier (laisser vide si non défini).", "Entrez le mot de passe du fichier (laisser vide si non défini).",
"Digite a senha do arquivo (deixe vazio se não definida).", "Digite a senha do arquivo (deixe vazio se não definida).",
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."], "Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt).",
"输入文件密码(未设置则留空)。"],
["msg.seed.write"] = [ ["msg.seed.write"] = [
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.", "Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet.", "Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
"Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.", "Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.", "Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.", "Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets."], "Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets.",
"请将这 12 个单词按顺序写在纸上。它们是钱包唯一的备份。"],
["msg.seed.retype"] = [ ["msg.seed.retype"] = [
"Reinserisci le 12 parole per confermare di averle scritte.", "Reinserisci le 12 parole per confermare di averle scritte.",
"Re-enter the 12 words to confirm you wrote them down.", "Re-enter the 12 words to confirm you wrote them down.",
"Reingresa las 12 palabras para confirmar que las has anotado.", "Reingresa las 12 palabras para confirmar que las has anotado.",
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.", "Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
"Reinsira as 12 palavras para confirmar que as anotou.", "Reinsira as 12 palavras para confirmar que as anotou.",
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."], "Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben.",
"重新输入这 12 个单词,以确认您已记录。"],
["msg.seed.mismatch"] = [ ["msg.seed.mismatch"] = [
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.", "Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
"The words do not match: check what you wrote on paper.", "The words do not match: check what you wrote on paper.",
"Las palabras no coinciden: revisa lo que escribiste en papel.", "Las palabras no coinciden: revisa lo que escribiste en papel.",
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.", "Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
"As palavras não correspondem: verifique o que escreveu no papel.", "As palavras não correspondem: verifique o que escreveu no papel.",
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."], "Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben.",
"单词不匹配:请检查您写在纸上的内容。"],
["msg.words.enter"] = [ ["msg.words.enter"] = [
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).", "Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).", "Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
"Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).", "Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).", "Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).", "Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."], "Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter).",
"输入 BIP39 助记词(12 或 24 个单词,以空格分隔)。"],
["msg.words.invalid"] = [ ["msg.words.invalid"] = [
"Mnemonica non valida (parole o checksum errati): ricontrolla.", "Mnemonica non valida (parole o checksum errati): ricontrolla.",
"Invalid mnemonic (wrong words or checksum): check again.", "Invalid mnemonic (wrong words or checksum): check again.",
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.", "Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.", "Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.", "Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."], "Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen.",
"助记词无效(单词错误或校验和不正确):请重新检查。"],
["msg.passphrase.info"] = [ ["msg.passphrase.info"] = [
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.", "Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip.", "Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip.",
"Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.", "Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.", "Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.", "Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen."], "Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen.",
"可选的 BIP39 密码短语:它会派生出一个完全不同的钱包。如果使用,请与种子分开记录;一旦丢失,资金将无法找回。留空以跳过。"],
["msg.password.info"] = [ ["msg.password.info"] = [
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.", "Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file.", "Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file.",
"Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.", "Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.", "Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.", "Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."], "Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei.",
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."], "用于加密磁盘上钱包文件的密码(建议设置)。它不能替代种子,仅用于保护文件。"],
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines.", "有多个可用钱包:请选择一个。"],
["msg.password.required"] = [ ["msg.password.required"] = [
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).", "Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).", "Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).", "Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).", "Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).", "Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."], "Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“).",
"请输入密码以加密钱包(或取消勾选“加密钱包文件”)。"],
["msg.password.mismatch"] = [ ["msg.password.mismatch"] = [
"Le due password non coincidono.", "Le due password non coincidono.",
"The two passwords do not match.", "The two passwords do not match.",
"Las dos contraseñas no coinciden.", "Las dos contraseñas no coinciden.",
"Les deux mots de passe ne correspondent pas.", "Les deux mots de passe ne correspondent pas.",
"As duas senhas não coincidem.", "As duas senhas não coincidem.",
"Die beiden Passwörter stimmen nicht überein."], "Die beiden Passwörter stimmen nicht überein.",
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."], "两次输入的密码不一致。"],
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."], ["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort.", "密码错误。"],
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."], ["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet.", "钱包已在应用程序的另一个实例中打开。"],
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"], ["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen.", "无法访问钱包文件:请检查文件权限。"],
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"], ["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…", "钱包已打开:正在连接服务器…"],
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert", "已同步"],
["msg.synced.detail"] = [ ["msg.synced.detail"] = [
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.", "transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
"SPV-verified transactions. Real-time updates active.", "SPV-verified transactions. Real-time updates active.",
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.", "transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
"transactions vérifiées SPV. Mises à jour en temps réel actives.", "transactions vérifiées SPV. Mises à jour en temps réel actives.",
"transações verificadas SPV. Atualizações em tempo real ativas.", "transações verificadas SPV. Atualizações em tempo real ativas.",
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."], "SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv.",
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"], "已通过 SPV 验证的交易。实时更新已启用。"],
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"], ["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe", "高度"],
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"], ["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung", "等待确认"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."], ["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar", "尚不可花费"],
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung", "成熟中"],
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft", "正在进行 SPV 验证"],
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…", "验证中…"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert.", "设置已保存。"],
["msg.certreset"] = [ ["msg.certreset"] = [
"Certificati SSL azzerati: riprova la connessione.", "Certificati SSL azzerati: riprova la connessione.",
"SSL certificates cleared: retry the connection.", "SSL certificates cleared: retry the connection.",
"Certificados SSL restablecidos: reintenta la conexión.", "Certificados SSL restablecidos: reintenta la conexión.",
"Certificats SSL réinitialisés : réessayez la connexion.", "Certificats SSL réinitialisés : réessayez la connexion.",
"Certificados SSL redefinidos: tente novamente a conexão.", "Certificados SSL redefinidos: tente novamente a conexão.",
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."], "SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen.",
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"], "SSL 证书已清除:请重试连接。"],
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler", "错误"],
["msg.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler", "广播错误"],
["msg.peer.discovery.error"] = ["Errore nella scoperta peer", "Peer discovery error", "Error al descubrir peers", "Erreur de découverte des pairs", "Erro na descoberta de peers", "Fehler bei der Peer-Suche", "节点发现错误"],
["msg.peer.discovery.found"] = ["Trovati {0} nuovi server dai peer (totale {1}).", "Found {0} new servers from peers (total {1}).", "Se encontraron {0} nuevos servidores de peers (total {1}).", "{0} nouveaux serveurs trouvés via les pairs (total {1}).", "Encontrados {0} novos servidores dos peers (total {1}).", "{0} neue Server von Peers gefunden (insgesamt {1}).", "从节点发现了 {0} 个新服务器(共 {1} 个)。"],
["msg.peer.discovery.none"] = ["Nessun nuovo server annunciato (totale {0}).", "No new servers announced (total {0}).", "No se anunciaron nuevos servidores (total {0}).", "Aucun nouveau serveur annoncé (total {0}).", "Nenhum novo servidor anunciado (total {0}).", "Keine neuen Server angekündigt (insgesamt {0}).", "没有新的服务器公告(共 {0} 个)。"],
["msg.send.sync.first"] = ["Sincronizza prima di inviare.", "Synchronize before sending.", "Sincroniza antes de enviar.", "Synchronisez avant d'envoyer.", "Sincronize antes de enviar.", "Vor dem Senden synchronisieren.", "发送前请先同步。"],
["msg.send.connect.first"] = ["Connettiti al server e sincronizza prima di inviare.", "Connect to the server and synchronize before sending.", "Conéctate al servidor y sincroniza antes de enviar.", "Connectez-vous au serveur et synchronisez avant d'envoyer.", "Conecte-se ao servidor e sincronize antes de enviar.", "Mit dem Server verbinden und vor dem Senden synchronisieren.", "发送前请先连接服务器并同步。"],
["msg.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag.", "金额无效。"],
["msg.feerate.invalid"] = ["Fee rate non valido.", "Invalid fee rate.", "Tarifa no válida.", "Taux de frais invalide.", "Taxa inválida.", "Ungültige Gebührenrate.", "手续费率无效。"],
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen", "已广播"],
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid", "谢谢!交易 ID"],
["msg.unsigned.watchonly"] = [" · NON firmata (watch-only)", " · NOT signed (watch-only)", " · NO firmada (watch-only)", " · NON signée (watch-only)", " · NÃO assinada (watch-only)", " · NICHT signiert (watch-only)", " · 未签名(仅观察)"],
["msg.cert.mismatch"] = [
"Il certificato TLS di {0} è cambiato rispetto a quello salvato. Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.",
"The TLS certificate of {0} has changed from the one saved. If the server renewed its certificate, reset the SSL certificates.",
"El certificado TLS de {0} ha cambiado respecto al guardado. Si el servidor renovó su certificado, restablece los certificados SSL.",
"Le certificat TLS de {0} a changé par rapport à celui enregistré. Si le serveur a renouvelé son certificat, réinitialisez les certificats SSL.",
"O certificado TLS de {0} mudou em relação ao salvo. Se o servidor renovou o certificado, redefina os certificados SSL.",
"Das TLS-Zertifikat von {0} hat sich gegenüber dem gespeicherten geändert. Wenn der Server das Zertifikat erneuert hat, setzen Sie die SSL-Zertifikate zurück.",
"{0} 的 TLS 证书与已保存的证书不同。如果服务器更新了证书,请重置 SSL 证书。"],
// Contacts // Contacts
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"], ["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"], ["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"], ["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname", "联系人姓名"],
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"], ["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse", "区块链地址"],
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"], ["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen", "添加"],
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"], ["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen", "删除所选"],
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."], ["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte.", "没有已保存的联系人。"],
// Settings window // Settings window
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"], ["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen", "设置"],
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"], ["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache", "语言"],
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"], ["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit", "金额单位"],
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"], ["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern", "保存"],
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"], ["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen", "取消"],
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"], ["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…", "索引服务器…"],
// Server window // Server window
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"], ["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver", "索引服务器"],
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"], ["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host", "主机"],
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"], ["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port", "端口"],
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"], ["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):", "已知服务器(点击使用):"],
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."], ["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden.", "没有已知服务器。连接后请使用“发现服务器”。"],
}; };
} }
+1 -1
View File
@@ -6,7 +6,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel <!-- Versione dell'applicazione: unico punto da modificare. Compare nel
titolo della finestra ed è incisa nei binari pubblicati. --> titolo della finestra ed è incisa nei binari pubblicati. -->
<Version>0.9.0</Version> <Version>1.1.0</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using NBitcoin; using NBitcoin;
using PalladiumWallet.App.Localization;
using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Wallet; using PalladiumWallet.Core.Wallet;
@@ -32,7 +33,7 @@ public partial class MainWindowViewModel
if (_account is null || _doc?.Cache is null || _lastTransactions is null) if (_account is null || _doc?.Cache is null || _lastTransactions is null)
{ {
DonatePreview = "Sincronizza prima di inviare."; DonatePreview = Loc.Tr("msg.send.sync.first");
return; return;
} }
try try
@@ -40,7 +41,7 @@ public partial class MainWindowViewModel
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net)); var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0) if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
{ {
DonatePreview = "Importo non valido."; DonatePreview = Loc.Tr("msg.amount.invalid");
return; return;
} }
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any, if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
@@ -49,7 +50,7 @@ public partial class MainWindowViewModel
_pendingDonate = new TransactionFactory(_account).Build( _pendingDonate = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
_doc.Cache.NextChangeIndex, sendAll: false); _doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " + DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " + $"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
@@ -61,7 +62,7 @@ public partial class MainWindowViewModel
{ {
_pendingDonate = null; _pendingDonate = null;
HasPendingDonate = false; HasPendingDonate = false;
DonatePreview = $"Errore: {ex.Message}"; DonatePreview = $"{Loc.Tr("msg.error")}: {ex.Message}";
} }
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -74,7 +75,7 @@ public partial class MainWindowViewModel
try try
{ {
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex()); var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
DonatePreview = $"Grazie! txid: {txid}"; DonatePreview = $"{Loc.Tr("msg.donate.thanks")}: {txid}";
DonateAmount = ""; DonateAmount = "";
_pendingDonate = null; _pendingDonate = null;
HasPendingDonate = false; HasPendingDonate = false;
@@ -82,7 +83,7 @@ public partial class MainWindowViewModel
} }
catch (Exception ex) catch (Exception ex)
{ {
DonatePreview = $"Errore broadcast: {ex.Message}"; DonatePreview = $"{Loc.Tr("msg.broadcast.error")}: {ex.Message}";
} }
} }
@@ -25,6 +25,12 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private string unconfirmedText = ""; private string unconfirmedText = "";
[ObservableProperty]
private string immatureText = "";
[ObservableProperty]
private string verifyingText = "";
[ObservableProperty] [ObservableProperty]
private string networkInfo = ""; private string networkInfo = "";
@@ -249,6 +255,8 @@ public partial class MainWindowViewModel
{ {
BalanceText = $"0.00000000 {Profile.CoinUnit}"; BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = ""; UnconfirmedText = "";
ImmatureText = "";
VerifyingText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString(); ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear(); History.Clear();
Addresses.Clear(); Addresses.Clear();
@@ -261,18 +269,32 @@ public partial class MainWindowViewModel
$"m/{_doc!.AccountPath}/0/{i}")); $"m/{_doc!.AccountPath}/0/{i}"));
return; return;
} }
BalanceText = Fmt(cache.ConfirmedSats); // Not "Confirmed - Immature - PendingVerification": those two can overlap (an
// immature coinbase can also be unverified), which double-subtracts and can go
// negative. SpendableSats is computed directly from the same IsSpendable gate
// coin selection uses, so it's always correct.
BalanceText = Fmt(cache.SpendableSats);
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0 UnconfirmedText = pending != 0
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
: ""; : "";
ImmatureText = cache.ImmatureSats != 0
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
: "";
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
// proof background verification hasn't reached yet — same "not spendable" treatment
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
VerifyingText = cache.PendingVerificationSats != 0
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
: "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear(); History.Clear();
foreach (var tx in cache.History) foreach (var tx in cache.History)
History.Add(new HistoryRow( History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool", tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false), (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid)); tx.Txid,
tx.Verified));
Addresses.Clear(); Addresses.Clear();
foreach (var a in cache.Addresses) foreach (var a in cache.Addresses)
+21 -9
View File
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using NBitcoin; using NBitcoin;
using PalladiumWallet.App.Localization;
using PalladiumWallet.App.Services; using PalladiumWallet.App.Services;
using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Net;
@@ -30,6 +31,14 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private bool hasPendingSend; private bool hasPendingSend;
[ObservableProperty]
private string pendingPsbtBase64 = "";
/// <summary>True when the open account cannot sign — Send only prepares an unsigned PSBT to export.</summary>
public bool IsWatchOnlyAccount => _account?.IsWatchOnly ?? false;
public void NotifyPsbtCopied() => StatusMessage = Loc.Tr("psbt.copied");
[RelayCommand] [RelayCommand]
private async Task ScanQr() private async Task ScanQr()
{ {
@@ -47,14 +56,14 @@ public partial class MainWindowViewModel
{ {
if (_account is null || _doc?.Cache is null) if (_account is null || _doc?.Cache is null)
{ {
SendPreview = "Sincronizza prima di inviare."; SendPreview = Loc.Tr("msg.send.sync.first");
return; return;
} }
try try
{ {
if (_lastTransactions is null) if (_lastTransactions is null)
{ {
SendPreview = "Connettiti al server e sincronizza prima di inviare."; SendPreview = Loc.Tr("msg.send.connect.first");
return; return;
} }
@@ -62,30 +71,32 @@ public partial class MainWindowViewModel
long amount = 0; long amount = 0;
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount)) if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
{ {
SendPreview = "Importo non valido."; SendPreview = Loc.Tr("msg.amount.invalid");
return; return;
} }
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0) if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
{ {
SendPreview = "Fee rate non valido."; SendPreview = Loc.Tr("msg.feerate.invalid");
return; return;
} }
_pendingSend = new TransactionFactory(_account).Build( _pendingSend = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
_doc.Cache.NextChangeIndex, SendAll); _doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " + $"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" + $"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)"); (_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
HasPendingSend = _pendingSend.Signed; HasPendingSend = _pendingSend.Signed;
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
} }
catch (Exception ex) catch (Exception ex)
{ {
_pendingSend = null; _pendingSend = null;
HasPendingSend = false; HasPendingSend = false;
SendPreview = $"Errore: {ex.Message}"; PendingPsbtBase64 = "";
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
} }
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -98,15 +109,16 @@ public partial class MainWindowViewModel
try try
{ {
var txid = await _client.BroadcastAsync(_pendingSend.ToHex()); var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
SendPreview = $"Trasmessa: {txid}"; SendPreview = $"{Loc.Tr("msg.broadcasted")}: {txid}";
SendTo = SendAmount = ""; SendTo = SendAmount = "";
_pendingSend = null; _pendingSend = null;
HasPendingSend = false; HasPendingSend = false;
PendingPsbtBase64 = "";
await ConnectAndSync(); await ConnectAndSync();
} }
catch (Exception ex) catch (Exception ex)
{ {
SendPreview = $"Errore broadcast: {ex.Message}"; SendPreview = $"{Loc.Tr("msg.broadcast.error")}: {DescribeError(ex)}";
} }
} }
} }
@@ -12,6 +12,7 @@ public partial class MainWindowViewModel
public bool IsLangFr => _config.Language == "fr"; public bool IsLangFr => _config.Language == "fr";
public bool IsLangPt => _config.Language == "pt"; public bool IsLangPt => _config.Language == "pt";
public bool IsLangDe => _config.Language == "de"; public bool IsLangDe => _config.Language == "de";
public bool IsLangZh => _config.Language == "zh";
public bool IsUnitPlm => _config.Unit == "PLM"; public bool IsUnitPlm => _config.Unit == "PLM";
public bool IsUnitMilli => _config.Unit == "mPLM"; public bool IsUnitMilli => _config.Unit == "mPLM";
public bool IsUnitMicro => _config.Unit == "µPLM"; public bool IsUnitMicro => _config.Unit == "µPLM";
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
OnPropertyChanged(nameof(IsLangFr)); OnPropertyChanged(nameof(IsLangFr));
OnPropertyChanged(nameof(IsLangPt)); OnPropertyChanged(nameof(IsLangPt));
OnPropertyChanged(nameof(IsLangDe)); OnPropertyChanged(nameof(IsLangDe));
OnPropertyChanged(nameof(IsLangZh));
OnPropertyChanged(nameof(IsUnitPlm)); OnPropertyChanged(nameof(IsUnitPlm));
OnPropertyChanged(nameof(IsUnitMilli)); OnPropertyChanged(nameof(IsUnitMilli));
OnPropertyChanged(nameof(IsUnitMicro)); OnPropertyChanged(nameof(IsUnitMicro));
+179 -31
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -55,6 +56,8 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private bool isSyncing; private bool isSyncing;
private CancellationTokenSource _syncCts = new();
public ObservableCollection<KnownServer> KnownServers { get; } = []; public ObservableCollection<KnownServer> KnownServers { get; } = [];
[ObservableProperty] [ObservableProperty]
@@ -165,11 +168,23 @@ public partial class MainWindowViewModel
{ {
if (IsSyncing) if (IsSyncing)
{ {
_resyncRequested = true; var (newHost, newPort) = ParseServer();
bool serverChanged = _client is null
|| _client.Host != newHost
|| _client.Port != newPort
|| _client.UseSsl != UseSsl;
if (serverChanged)
_syncCts.Cancel();
else
_resyncRequested = true;
return; return;
} }
_syncCts = new CancellationTokenSource();
var ct = _syncCts.Token;
IsSyncing = true; IsSyncing = true;
StatusMessage = ""; StatusMessage = "";
bool cancelled = false;
try try
{ {
var (host, port) = ParseServer(); var (host, port) = ParseServer();
@@ -194,12 +209,13 @@ public partial class MainWindowViewModel
Exception? lastError = null; Exception? lastError = null;
foreach (var (h, p) in candidates) foreach (var (h, p) in candidates)
{ {
ct.ThrowIfCancellationRequested();
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…"; ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…"; ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
try try
{ {
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins); _client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
_client.NotificationReceived += OnServerNotification; _client.NotificationReceived += OnServerNotification;
_client.Disconnected += _ => Dispatcher.UIThread.Post(() => _client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
{ {
@@ -226,6 +242,10 @@ public partial class MainWindowViewModel
lastError = null; lastError = null;
break; break;
} }
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
lastError = ex; lastError = ex;
@@ -251,52 +271,84 @@ public partial class MainWindowViewModel
_doc.Cache?.BlockHeaders, _doc.Cache?.BlockHeaders,
_doc.Cache?.NextReceiveIndex ?? 0, _doc.Cache?.NextReceiveIndex ?? 0,
_doc.Cache?.NextChangeIndex ?? 0, _doc.Cache?.NextChangeIndex ?? 0,
net); net,
_doc.Cache?.AnchoredUpTo);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
// Progressive verification (§7.4): the wallet becomes usable as soon as
// transaction downloads finish, without waiting for every historical Merkle
// proof — critical on mobile, where verifying thousands of proofs can take far
// longer than the download itself. Never persisted to disk on its own (only the
// final, fully-verified snapshot below is saved); coin selection still refuses
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
// early can't be exploited to spend a server-fabricated balance.
_synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r));
} }
do do
{ {
_resyncRequested = false; _resyncRequested = false;
var result = await _synchronizer.SyncOnceAsync(); // Off the UI thread: sync does heavy CPU work (LINQ over thousands of
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
var (result, rawHex, verifiedAt, blockHeaders, anchoredUpTo) = await Task.Run(async () =>
{
var r = await _synchronizer.SyncOnceAsync(ct);
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders, caches.AnchoredUpTo);
}, ct);
_lastTransactions = result.Transactions; _lastTransactions = result.Transactions;
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches( var cache = new SyncCache
PalladiumNetworks.For(_account.Profile.Kind));
_doc.Cache = new SyncCache
{ {
TipHeight = result.TipHeight, RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
ConfirmedSats = result.ConfirmedSats, AnchoredUpTo = anchoredUpTo,
UnconfirmedSats = result.UnconfirmedSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
}; };
WalletStore.Save(_doc, _walletPath!, _password); FillDisplayFields(cache, result);
_doc.Cache = cache;
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache); ApplyCache(_doc.Cache);
_syncFailed = false; _syncFailed = false;
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; $"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
} while (_resyncRequested); } while (_resyncRequested && !ct.IsCancellationRequested);
}
catch (OperationCanceledException)
{
// Intentional cancellation: a server change request, or CheckConnectionOnResumeAsync
// recovering a sync stuck on a socket that died while the app was suspended
// (_resumeRecovering) — neither is an error the user needs to see as one.
cancelled = true;
IsConnected = false;
ConnectionStatus = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
ConnectionStatusShort = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
StatusMessage = "";
} }
catch (CertificatePinMismatchException ex) catch (CertificatePinMismatchException ex)
{ {
IsConnected = false; IsConnected = false;
ConnectionStatus = Loc.Tr("conn.none"); ConnectionStatus = Loc.Tr("conn.none");
ConnectionStatusShort = Loc.Tr("conn.none"); ConnectionStatusShort = Loc.Tr("conn.none");
StatusMessage = ex.Message; StatusMessage = DescribeError(ex);
} }
catch (Exception ex) catch (Exception ex)
{ {
IsConnected = _client?.IsConnected == true; IsConnected = _client?.IsConnected == true;
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none"); if (_resumeRecovering && !IsConnected)
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none"); {
StatusMessage = $"Errore: {ex.Message}"; // We tore the connection down ourselves (see CheckConnectionOnResumeAsync);
// whether that surfaces here as a cancellation or as a transport exception
// is a race, not a real failure — show "reconnecting" and restart right away
// instead of a scary error message and a wait for the next keep-alive tick.
cancelled = true;
ConnectionStatus = Loc.Tr("conn.reconnecting");
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
}
else
{
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
}
if (_account is not null) if (_account is not null)
{ {
_syncFailed = true; _syncFailed = true;
@@ -308,34 +360,129 @@ public partial class MainWindowViewModel
finally finally
{ {
IsSyncing = false; IsSyncing = false;
_resumeRecovering = false;
} }
// If cancelled due to a server change, restart immediately with the new server.
if (cancelled)
_ = ConnectAndSync();
} }
/// <summary>
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
/// resume without re-downloading) were never written.
/// </summary>
private void ApplyPartialResult(SyncResult result)
{
if (_doc is null)
return;
var previous = _doc.Cache;
var cache = new SyncCache
{
RawTxHex = previous?.RawTxHex,
VerifiedAt = previous?.VerifiedAt,
BlockHeaders = previous?.BlockHeaders,
};
FillDisplayFields(cache, result);
_doc.Cache = cache;
_lastTransactions = result.Transactions;
ApplyCache(cache);
}
private static void FillDisplayFields(SyncCache cache, SyncResult result)
{
cache.TipHeight = result.TipHeight;
cache.ConfirmedSats = result.ConfirmedSats;
cache.UnconfirmedSats = result.UnconfirmedSats;
cache.ImmatureSats = result.ImmatureSats;
cache.PendingVerificationSats = result.PendingVerificationSats;
cache.SpendableSats = result.SpendableSats;
cache.NextReceiveIndex = result.NextReceiveIndex;
cache.NextChangeIndex = result.NextChangeIndex;
cache.History = [.. result.History];
cache.Utxos = [.. result.Utxos];
cache.Addresses = [.. result.AddressRows];
}
/// <summary>
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
/// that connection; otherwise it opens a short-lived connection to a candidate server
/// just to query its peer list, without touching the wallet's connection state.
/// </summary>
[RelayCommand] [RelayCommand]
private async Task DiscoverServers() private async Task DiscoverServers()
{ {
if (_client is null || !_client.IsConnected) if (_client is { IsConnected: true } connected)
{ {
StatusMessage = Loc.Tr("conn.none") + "."; await DiscoverServersUsing(connected);
return;
}
var (host, port) = ParseServer();
ElectrumClient? temp = null;
Exception? lastError = null;
foreach (var (h, p) in BuildServerCandidates(host, port))
{
try
{
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
temp = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
lastError = null;
break;
}
catch (Exception ex)
{
lastError = ex;
}
}
if (temp is null)
{
StatusMessage = $"{Loc.Tr("msg.peer.discovery.error")}: " +
(lastError is null ? Loc.Tr("conn.none") : DescribeError(lastError));
return; return;
} }
try try
{ {
var added = await Registry.DiscoverAsync(_client); await DiscoverServersUsing(temp);
}
finally
{
await temp.DisposeAsync();
}
}
private async Task DiscoverServersUsing(ElectrumClient client)
{
try
{
var added = await Registry.DiscoverAsync(client);
var selected = SelectedKnownServer; var selected = SelectedKnownServer;
RefreshServers(); RefreshServers();
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host) SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
?? KnownServers.FirstOrDefault(); ?? KnownServers.FirstOrDefault();
StatusMessage = added > 0 StatusMessage = added > 0
? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})." ? string.Format(Loc.Tr("msg.peer.discovery.found"), added, KnownServers.Count)
: $"Nessun nuovo server annunciato (totale {KnownServers.Count})."; : string.Format(Loc.Tr("msg.peer.discovery.none"), KnownServers.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Errore nella scoperta peer: {ex.Message}"; StatusMessage = $"{Loc.Tr("msg.peer.discovery.error")}: {DescribeError(ex)}";
} }
} }
/// <summary>
/// Translates known Core exception types to the current UI language; falls back to the
/// exception's own message (Core has no localization, so it is English by default).
/// </summary>
private static string DescribeError(Exception ex) => ex switch
{
CertificatePinMismatchException cert =>
string.Format(Loc.Tr("msg.cert.mismatch"), $"{cert.Host}:{cert.Port}"),
_ => ex.Message,
};
private void OnServerNotification(string method, System.Text.Json.JsonElement payload) private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
{ {
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe") if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
@@ -367,12 +514,13 @@ public partial class MainWindowViewModel
try try
{ {
var net = PalladiumNetworks.For(_account.Profile.Kind); var net = PalladiumNetworks.For(_account.Profile.Kind);
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(net); var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = _synchronizer.ExportCaches(net);
if (rawHex.Count == 0 && verifiedAt.Count == 0) if (rawHex.Count == 0 && verifiedAt.Count == 0)
return; return;
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex; (_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
_doc.Cache.VerifiedAt = verifiedAt; _doc.Cache.VerifiedAt = verifiedAt;
_doc.Cache.BlockHeaders = blockHeaders; _doc.Cache.BlockHeaders = blockHeaders;
_doc.Cache.AnchoredUpTo = anchoredUpTo;
WalletStore.Save(_doc, _walletPath, _password); WalletStore.Save(_doc, _walletPath, _password);
} }
catch { /* non-fatal: the next full save will recover */ } catch { /* non-fatal: the next full save will recover */ }
@@ -0,0 +1,36 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.App.ViewModels;
public partial class MainWindowViewModel
{
// ---- update-available overlay ----
[ObservableProperty]
private bool isUpdateAvailableOpen;
[ObservableProperty]
private string updateAvailableTag = "";
public string UpdateReleaseUrl { get; private set; } = "";
[RelayCommand]
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
/// <summary>
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
/// </summary>
private async Task CheckForUpdatesAsync()
{
var latest = await UpdateChecker.CheckAsync(AppVersion);
if (latest is null) return;
UpdateReleaseUrl = latest.HtmlUrl;
UpdateAvailableTag = latest.Tag;
IsUpdateAvailableOpen = true;
}
}
@@ -27,9 +27,10 @@ public partial class MainWindowViewModel
public const string StepScriptType = "script-type"; public const string StepScriptType = "script-type";
public const string StepImportXkey = "import-xkey"; public const string StepImportXkey = "import-xkey";
public const string StepImportWif = "import-wif"; public const string StepImportWif = "import-wif";
public const string StepImportAddress = "import-address";
public const string StepPassword = "password"; public const string StepPassword = "password";
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif } private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress }
private WizardFlowKind _wizardFlow; private WizardFlowKind _wizardFlow;
[ObservableProperty] [ObservableProperty]
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
[NotifyPropertyChangedFor(nameof(IsStepScriptType))] [NotifyPropertyChangedFor(nameof(IsStepScriptType))]
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))] [NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
[NotifyPropertyChangedFor(nameof(IsStepImportWif))] [NotifyPropertyChangedFor(nameof(IsStepImportWif))]
[NotifyPropertyChangedFor(nameof(IsStepImportAddress))]
[NotifyPropertyChangedFor(nameof(IsStepPassword))] [NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart; private string setupStep = StepStart;
@@ -56,9 +58,10 @@ public partial class MainWindowViewModel
public bool IsStepWords => SetupStep == StepWords; public bool IsStepWords => SetupStep == StepWords;
public bool IsStepPassphrase => SetupStep == StepPassphrase; public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepScriptType => SetupStep == StepScriptType; public bool IsStepScriptType => SetupStep == StepScriptType;
public bool IsStepImportXkey => SetupStep == StepImportXkey; public bool IsStepImportXkey => SetupStep == StepImportXkey;
public bool IsStepImportWif => SetupStep == StepImportWif; public bool IsStepImportWif => SetupStep == StepImportWif;
public bool IsStepPassword => SetupStep == StepPassword; public bool IsStepImportAddress => SetupStep == StepImportAddress;
public bool IsStepPassword => SetupStep == StepPassword;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLegacySelected))] [NotifyPropertyChangedFor(nameof(IsLegacySelected))]
@@ -85,6 +88,9 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private string importWifInput = ""; private string importWifInput = "";
[ObservableProperty]
private string importAddressInput = "";
// Script type detected during xkey decoding (to display to the user) // Script type detected during xkey decoding (to display to the user)
[ObservableProperty] [ObservableProperty]
private string importXkeyDetectedKind = ""; private string importXkeyDetectedKind = "";
@@ -214,6 +220,15 @@ public partial class MainWindowViewModel
StatusMessage = ""; StatusMessage = "";
} }
[RelayCommand]
private void WizardStartImportAddress()
{
_wizardFlow = WizardFlowKind.ImportAddress;
ImportAddressInput = "";
SetupStep = StepImportAddress;
StatusMessage = "";
}
[RelayCommand] [RelayCommand]
private void WizardNextFromShowSeed() private void WizardNextFromShowSeed()
{ {
@@ -312,6 +327,35 @@ public partial class MainWindowViewModel
StatusMessage = ""; StatusMessage = "";
} }
[RelayCommand]
private void WizardNextFromImportAddress()
{
var addresses = ImportAddressInput.Split('\n',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (addresses.Length == 0)
{
StatusMessage = Loc.Tr("msg.address.required");
return;
}
var network = PalladiumNetworks.For(Net);
foreach (var a in addresses)
{
try
{
_ = NBitcoin.BitcoinAddress.Create(a, network);
}
catch
{
StatusMessage = Loc.Tr("msg.address.invalid");
return;
}
}
PasswordInput = ConfirmPasswordInput = "";
EncryptWallet = true;
SetupStep = StepPassword;
StatusMessage = "";
}
[RelayCommand] [RelayCommand]
private void WizardNextFromScriptType() private void WizardNextFromScriptType()
{ {
@@ -327,11 +371,16 @@ public partial class MainWindowViewModel
SetupStep = SetupStep switch SetupStep = SetupStep switch
{ {
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart, StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif or StepImportAddress => StepStart,
StepConfirmSeed => StepShowSeed, StepConfirmSeed => StepShowSeed,
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed, StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase, StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType, StepPassword => _wizardFlow switch
{
WizardFlowKind.ImportXkey => StepImportXkey,
WizardFlowKind.ImportAddress => StepImportAddress,
_ => StepScriptType,
},
_ => StepStart, _ => StepStart,
}; };
if (SetupStep == StepStart) if (SetupStep == StepStart)
@@ -391,6 +440,14 @@ public partial class MainWindowViewModel
(doc, account) = (d, a); (doc, account) = (d, a);
break; break;
} }
case WizardFlowKind.ImportAddress:
{
var addressLines = ImportAddressInput.Split('\n',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var (d, a) = WalletLoader.NewFromAddresses(addressLines, Profile);
(doc, account) = (d, a);
break;
}
default: default:
{ {
var (d, a) = WalletLoader.NewFromMnemonic( var (d, a) = WalletLoader.NewFromMnemonic(
@@ -416,7 +473,7 @@ public partial class MainWindowViewModel
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Errore: {ex.Message}"; StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
} }
} }
@@ -473,7 +530,7 @@ public partial class MainWindowViewModel
catch (Exception ex) catch (Exception ex)
{ {
newLock.Dispose(); newLock.Dispose();
StatusMessage = $"Errore: {ex.Message}"; StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
} }
} }
@@ -497,13 +554,14 @@ public partial class MainWindowViewModel
_walletPath = path; _walletPath = path;
_password = password; _password = password;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = ""; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = ""; ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = "";
SetupStep = StepStart; SetupStep = StepStart;
OnPropertyChanged(nameof(IsWatchOnlyAccount));
var walletKindTag = account switch var walletKindTag = account switch
{ {
ImportedKeyAccount => " · imported",
{ IsWatchOnly: true } => " · watch-only", { IsWatchOnly: true } => " · watch-only",
ImportedKeyAccount => " · imported",
_ => "" _ => ""
}; };
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : ""; var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
+90 -12
View File
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels; namespace PalladiumWallet.App.ViewModels;
/// <summary>Transaction history row for the view.</summary> /// <summary>Transaction history row for the view.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid); public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
/// <summary>Address view row with pre-computed keys and derivation path.</summary> /// <summary>Address view row with pre-computed keys and derivation path.</summary>
public sealed record AddressRow( public sealed record AddressRow(
@@ -77,6 +77,7 @@ public partial class MainWindowViewModel : ViewModelBase
// ---- keep-alive ---- // ---- keep-alive ----
private bool _autoReconnect; private bool _autoReconnect;
private bool _syncFailed; private bool _syncFailed;
private bool _resumeRecovering;
private readonly DispatcherTimer _keepAliveTimer; private readonly DispatcherTimer _keepAliveTimer;
// ---- server UI sync ---- // ---- server UI sync ----
@@ -158,30 +159,105 @@ public partial class MainWindowViewModel : ViewModelBase
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) }; _keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync(); _keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
_keepAliveTimer.Start(); _keepAliveTimer.Start();
_ = CheckForUpdatesAsync();
} }
private bool _keepAliveRunning;
private async System.Threading.Tasks.Task KeepAliveTickAsync() private async System.Threading.Tasks.Task KeepAliveTickAsync()
{ {
if (IsSyncing) // Re-entrancy guard: without it, a ping stuck on a half-open socket (see
// below) would let every subsequent 20s tick pile up another concurrent
// ping/reconnect attempt on top of it.
if (IsSyncing || _keepAliveRunning)
return; return;
if (_client is { IsConnected: true }) _keepAliveRunning = true;
try
{ {
// If the wallet is open and the last sync failed, retry automatically. if (_client is { IsConnected: true } client)
if (_syncFailed && _account is not null)
{ {
await ConnectAndSync(); // If the wallet is open and the last sync failed, retry automatically.
return; if (_syncFailed && _account is not null)
{
await ConnectAndSync();
return;
}
try
{
// A "half-open" TCP connection (remote end gone with no FIN/RST
// ever delivered — the common outcome of Android Doze/mobile-radio
// suspend killing the route silently) never fails the write, so
// PingAsync would otherwise await a response that never arrives.
// Bound it explicitly instead of relying on it to throw.
using var timeoutCts = new System.Threading.CancellationTokenSource(System.TimeSpan.FromSeconds(8));
await client.PingAsync(timeoutCts.Token);
}
catch
{
// TcpClient.Connected only reflects the last known socket state, so a
// connection killed silently while the app was suspended still reports
// IsConnected == true. A failed/timed-out ping is the only reliable
// signal here: tear the dead client down so the next tick reconnects
// instead of retrying forever on a dead socket.
await DisconnectAsync();
if (_autoReconnect)
{
ConnectionStatus = Loc.Tr("conn.reconnecting");
await ConnectAndSync();
}
}
}
else if (_autoReconnect)
{
ConnectionStatus = Loc.Tr("conn.reconnecting");
await ConnectAndSync();
} }
try { await _client.PingAsync(); }
catch { }
} }
else if (_autoReconnect) finally
{ {
ConnectionStatus = Loc.Tr("conn.reconnecting"); _keepAliveRunning = false;
await ConnectAndSync();
} }
} }
/// <summary>Forces an immediate connection health check, bypassing the 20s timer.
/// Called when the app resumes from background/lock screen, since the socket may
/// have died silently while suspended and the UI would otherwise show a stale
/// "connected" state until the next scheduled tick (or forever, if it never fires
/// during Doze).</summary>
public async System.Threading.Tasks.Task CheckConnectionOnResumeAsync()
{
if (IsSyncing)
{
// A sync in progress when the phone locked may be stuck awaiting a
// response on a socket that died silently while suspended (same
// half-open-TCP scenario as the keep-alive ping, but sync requests have
// no timeout of their own). Cancel it and tear the client down instead
// of leaving it hung forever. _resumeRecovering tells ConnectAndSync's
// error handling that this interruption is self-inflicted and expected,
// so it shows "reconnecting" and restarts immediately instead of
// flashing a scary error message and waiting for the next keep-alive tick.
_resumeRecovering = true;
ConnectionStatus = Loc.Tr("conn.reconnecting");
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
_syncCts.Cancel();
// Deliberately not DisconnectAsync() here: it also nulls _synchronizer,
// which still holds whatever this sync already downloaded/verified (e.g.
// thousands of Merkle proofs on a large wallet). Nulling it before the
// cancelled ConnectAndSync unwinds would discard that progress, forcing a
// full restart instead of a resume. Only tear down the dead socket here;
// ConnectAndSync's own cancellation handling persists the partial cache
// (PersistPartialTxCache) before it recreates the synchronizer — same
// sequencing already used for the "server changed mid-sync" case.
if (_client is { } deadClient)
{
_client = null;
try { await deadClient.DisposeAsync(); } catch { }
}
return;
}
await KeepAliveTickAsync();
}
// ---- wallet lifecycle ---- // ---- wallet lifecycle ----
[RelayCommand] [RelayCommand]
@@ -200,6 +276,8 @@ public partial class MainWindowViewModel : ViewModelBase
_lastTransactions = null; _lastTransactions = null;
_pendingSend = null; _pendingSend = null;
HasPendingSend = false; HasPendingSend = false;
PendingPsbtBase64 = "";
OnPropertyChanged(nameof(IsWatchOnlyAccount));
History.Clear(); History.Clear();
Contacts.Clear(); Contacts.Clear();
SelectedContactInList = null; SelectedContactInList = null;
@@ -64,13 +64,17 @@ public sealed class TransactionDetailsViewModel
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"]; RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow( Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}", i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—", i.IsCoinbase
? i.CoinbaseTag is { Length: > 0 } tag
? $"{loc["tx.coinbase.newcoins"]} — {tag}"
: loc["tx.coinbase.newcoins"]
: i.Address ?? "—",
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—", i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
i.IsMine))); i.IsMine)));
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow( Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
$"#{o.Index}", $"#{o.Index}",
o.Address ?? $"({o.ScriptType})", o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"),
CoinAmount.FormatIn(o.AmountSats, unit), CoinAmount.FormatIn(o.AmountSats, unit),
o.IsMine))); o.IsMine)));
} }
@@ -98,7 +102,8 @@ public sealed class TransactionDetailsViewModel
{ {
if (d.Confirmations <= 0) if (d.Confirmations <= 0)
return loc["tx.status.mempool"]; return loc["tx.status.mempool"];
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})"; var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
} }
private string Signed(long sats) private string Signed(long sats)
+119 -13
View File
@@ -60,7 +60,7 @@
<!-- ============ SETUP WIZARD (§15): one step at a time ============ --> <!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}"> <ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18" <StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
HorizontalAlignment="Center"> HorizontalAlignment="Stretch">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold" <TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
@@ -99,6 +99,9 @@
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16" <Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartImportWifCommand}"/> Command="{Binding WizardStartImportWifCommand}"/>
<Button Content="{Binding Loc[wiz.importaddress.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartImportAddressCommand}"/>
</StackPanel> </StackPanel>
<!-- Step: choose wallet (multiple files present) --> <!-- Step: choose wallet (multiple files present) -->
@@ -212,6 +215,19 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<!-- Step: import watch-only addresses (no key at all) -->
<StackPanel IsVisible="{Binding IsStepImportAddress}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.importaddress.title]}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding Loc[wiz.importaddress.hint]}" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBox PlaceholderText="{Binding Loc[wiz.importaddress.placeholder]}"
Text="{Binding ImportAddressInput}" AcceptsReturn="True" Height="80"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromImportAddressCommand}"/>
</StackPanel>
</StackPanel>
<!-- Step: choose script/address type --> <!-- Step: choose script/address type -->
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12"> <StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/> <TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>
@@ -314,6 +330,12 @@
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D" <TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
TextWrapping="Wrap" TextWrapping="Wrap"
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
TextWrapping="Wrap"
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding VerifyingText}" Foreground="#FCD34D"
TextWrapping="Wrap"
IsVisible="{Binding VerifyingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12" <TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
Margin="0,2,0,0"/> Margin="0,2,0,0"/>
</StackPanel> </StackPanel>
@@ -370,13 +392,16 @@
<DataTemplate x:DataType="vm:HistoryRow"> <DataTemplate x:DataType="vm:HistoryRow">
<Panel Cursor="Hand"> <Panel Cursor="Hand">
<!-- Desktop: 3 fixed columns --> <!-- Desktop: 3 fixed columns -->
<Grid ColumnDefinitions="90,160,*" <Grid ColumnDefinitions="90,160,*,Auto"
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}"> IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/> <TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/> <TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<TextBlock Grid.Column="2" Text="{Binding Txid}" <TextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12" FontFamily="monospace" FontSize="12"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
IsVisible="{Binding !Verified}"/>
</Grid> </Grid>
<!-- Mobile: vertical card --> <!-- Mobile: vertical card -->
<StackPanel Spacing="2" <StackPanel Spacing="2"
@@ -386,6 +411,9 @@
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/> <TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11" <TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11"
IsVisible="{Binding !Verified}"/>
</StackPanel> </StackPanel>
</Panel> </Panel>
</DataTemplate> </DataTemplate>
@@ -417,7 +445,7 @@
<!-- ── DESKTOP: Recipient | Amount side-by-side ── --> <!-- ── DESKTOP: Recipient | Amount side-by-side ── -->
<Grid IsVisible="{Binding IsDesktop}" <Grid IsVisible="{Binding IsDesktop}"
RowDefinitions="Auto,Auto,Auto" RowSpacing="14"> RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="14">
<Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14"> <Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14">
<!-- Recipient card --> <!-- Recipient card -->
@@ -488,11 +516,29 @@
<SelectableTextBlock Text="{Binding SendPreview}" <SelectableTextBlock Text="{Binding SendPreview}"
TextWrapping="Wrap" FontSize="13" Classes="mono" TextWrapping="Wrap" FontSize="13" Classes="mono"
Foreground="{DynamicResource TextSecondaryBrush}"/> Foreground="{DynamicResource TextSecondaryBrush}"/>
<StackPanel Spacing="6"
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
AcceptsReturn="True" Height="90"/>
<Button Grid.Column="1" Margin="8,0,0,0"
Content="{Binding Loc[send.psbt.copy]}"
Click="OnCopyPsbtClick"/>
</Grid>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Watch-only notice -->
<TextBlock Grid.Row="2" Text="{Binding Loc[send.watchonly.hint]}"
IsVisible="{Binding IsWatchOnlyAccount}"
TextWrapping="Wrap" FontSize="12"
Foreground="{DynamicResource WarningBrush}"/>
<!-- Action buttons --> <!-- Action buttons -->
<Grid Grid.Row="2" ColumnDefinitions="*,*" ColumnSpacing="14"> <Grid Grid.Row="3" ColumnDefinitions="*,*" ColumnSpacing="14">
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}" <Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
Command="{Binding PrepareSendCommand}" Command="{Binding PrepareSendCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/> HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
@@ -592,9 +638,26 @@
<SelectableTextBlock Text="{Binding SendPreview}" <SelectableTextBlock Text="{Binding SendPreview}"
TextWrapping="Wrap" FontSize="13" Classes="mono" TextWrapping="Wrap" FontSize="13" Classes="mono"
Foreground="{DynamicResource TextSecondaryBrush}"/> Foreground="{DynamicResource TextSecondaryBrush}"/>
<StackPanel Spacing="6"
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
<TextBox Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
AcceptsReturn="True" Height="90"/>
<Button Content="{Binding Loc[send.psbt.copy]}"
Click="OnCopyPsbtClick"
MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Watch-only notice -->
<TextBlock Text="{Binding Loc[send.watchonly.hint]}"
IsVisible="{Binding IsWatchOnlyAccount}"
TextWrapping="Wrap" FontSize="12"
Foreground="{DynamicResource WarningBrush}"/>
<!-- Action buttons: primary (Confirm) prominent, secondary below --> <!-- Action buttons: primary (Confirm) prominent, secondary below -->
<Button Content="{Binding Loc[send.confirm]}" Classes="accent" <Button Content="{Binding Loc[send.confirm]}" Classes="accent"
Command="{Binding ConfirmSendCommand}" Command="{Binding ConfirmSendCommand}"
@@ -981,7 +1044,7 @@
<Border Background="{DynamicResource OverlayCardBrush}" <Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8" BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="360" Margin="16" MaxWidth="360" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Stretch" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="14"> <StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}" <TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
FontSize="16" FontWeight="Bold"/> FontSize="16" FontWeight="Bold"/>
@@ -1233,14 +1296,19 @@
<!-- Actions — Desktop: in a row --> <!-- Actions — Desktop: in a row -->
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}"> <StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent" <Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/> Command="{Binding ConnectAndSyncCommand}"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="{Binding Loc[wallet.resetcert]}" <Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}"/> Command="{Binding ResetCertificatesCommand}"/>
</StackPanel> </StackPanel>
<!-- Actions — Mobile: stacked --> <!-- Actions — Mobile: stacked -->
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}"> <StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent" <Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}" Command="{Binding ConnectAndSyncCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/> HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
<Button Content="{Binding Loc[wallet.resetcert]}" <Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}" Command="{Binding ResetCertificatesCommand}"
@@ -1317,7 +1385,7 @@
<Border Background="{DynamicResource OverlayCardBrush}" <Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8" BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="500" Margin="16" MaxWidth="500" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Stretch" VerticalAlignment="Center">
<ScrollViewer MaxHeight="620"> <ScrollViewer MaxHeight="620">
<StackPanel Margin="24" Spacing="14"> <StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[walletinfo.title]}" <TextBlock Text="{Binding Loc[walletinfo.title]}"
@@ -1494,6 +1562,9 @@
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4" <RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
IsChecked="{Binding IsLangDe, Mode=OneWay}" IsChecked="{Binding IsLangDe, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="de"/> Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
<RadioButton GroupName="lang" Content="中文" Margin="0,0,14,4"
IsChecked="{Binding IsLangZh, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="zh"/>
</WrapPanel> </WrapPanel>
</StackPanel> </StackPanel>
@@ -1555,9 +1626,14 @@
Foreground="{DynamicResource TextSecondaryBrush}"/> Foreground="{DynamicResource TextSecondaryBrush}"/>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/> <TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
<Button Content="{Binding Loc[help.bug.report]}" <StackPanel Orientation="Horizontal" Spacing="8">
Click="OnOpenBugReportClick" <Button Content="{Binding Loc[help.bug.report]}"
HorizontalAlignment="Left"/> Click="OnOpenBugReportClick"
HorizontalAlignment="Left"/>
<Button Content="{Binding Loc[help.user.guide]}"
Click="OnOpenUserGuideClick"
HorizontalAlignment="Left"/>
</StackPanel>
<Border BorderBrush="{DynamicResource BorderSubtleBrush}" <Border BorderBrush="{DynamicResource BorderSubtleBrush}"
BorderThickness="0,1,0,0" Margin="0,4,0,0"/> BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@@ -1572,8 +1648,9 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<!-- Tab: Donate --> <!-- Tab: Donate (needs an open wallet to send from) -->
<TabItem Header="{Binding Loc[help.tab.donate]}"> <TabItem Header="{Binding Loc[help.tab.donate]}"
IsVisible="{Binding IsWalletOpen}">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="12" Margin="0,12,0,0"> <StackPanel Spacing="12" Margin="0,12,0,0">
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap" <TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
@@ -1630,5 +1707,34 @@
</StackPanel> </StackPanel>
</Border> </Border>
</Border> </Border>
<!-- ============ UPDATE AVAILABLE OVERLAY ============ -->
<!-- Same pattern as the help overlay: instant open/close. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="{DynamicResource ScrimBrush}"
Tapped="OnUpdateAvailableOverlayBackdropTapped"
IsVisible="{Binding IsUpdateAvailableOpen}">
<Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="440" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="{Binding Loc[update.title]}"
FontSize="18" FontWeight="Bold"/>
<TextBlock TextWrapping="Wrap">
<Run Text="{Binding Loc[update.message]}"/>
<Run Text=" "/>
<Run Text="{Binding UpdateAvailableTag}" FontWeight="Bold"/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="{Binding Loc[update.download]}"
Classes="accent"
Click="OnOpenReleasePageClick"/>
<Button Content="{Binding Loc[update.dismiss]}"
Command="{Binding CloseUpdateAvailableCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Border>
</Grid> </Grid>
</UserControl> </UserControl>
+37 -3
View File
@@ -102,6 +102,17 @@ public partial class MainView : UserControl
} }
} }
private async void OnCopyPsbtClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.PendingPsbtBase64))
return;
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
{
await clipboard.SetTextAsync(vm.PendingPsbtBase64);
vm.NotifyPsbtCopied();
}
}
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e) private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
{ {
if (DataContext is MainWindowViewModel vm) if (DataContext is MainWindowViewModel vm)
@@ -129,16 +140,38 @@ public partial class MainView : UserControl
vm.IsHelpOpen = false; vm.IsHelpOpen = false;
} }
private void OnUpdateAvailableOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.IsUpdateAvailableOpen = false;
}
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e) private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
{ {
// TODO: replace with the final GitHub URL once the repo is on GitHub const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
// e.g. https://github.com/davide3011/PalladiumWallet/issues/new?assignees=&labels=bug&template=bug_report.yml
const string issueUrl = "https://santantonio.sytes.net/davide/PalladiumWallet/issues/new";
var launcher = TopLevel.GetTopLevel(this)?.Launcher; var launcher = TopLevel.GetTopLevel(this)?.Launcher;
if (launcher is not null) if (launcher is not null)
await launcher.LaunchUriAsync(new Uri(issueUrl)); await launcher.LaunchUriAsync(new Uri(issueUrl));
} }
private async void OnOpenUserGuideClick(object? sender, RoutedEventArgs e)
{
const string userGuideUrl = "https://github.com/davide3011/PalladiumWallet/blob/main/USERGUIDE.md";
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
if (launcher is not null)
await launcher.LaunchUriAsync(new Uri(userGuideUrl));
}
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
if (launcher is not null)
await launcher.LaunchUriAsync(new Uri(vm.UpdateReleaseUrl));
vm.IsUpdateAvailableOpen = false;
}
// Esc (desktop) or Back (Android) closes the topmost open overlay. // Esc (desktop) or Back (Android) closes the topmost open overlay.
protected override void OnKeyDown(KeyEventArgs e) protected override void OnKeyDown(KeyEventArgs e)
{ {
@@ -151,6 +184,7 @@ public partial class MainView : UserControl
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; } if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; } if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; } if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
if (vm.IsUpdateAvailableOpen) { vm.IsUpdateAvailableOpen = false; e.Handled = true; return; }
} }
base.OnKeyDown(e); base.OnKeyDown(e);
} }
+87 -51
View File
@@ -6,8 +6,8 @@ using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet; using PalladiumWallet.Core.Wallet;
// CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di // Wallet CLI (blueprint §13): Core use cases exposed via command line,
// comando, per scripting, test e confronto col wallet di riferimento. // for scripting, testing and comparison against the reference wallet.
try try
{ {
@@ -18,6 +18,7 @@ try
["create", .. var rest] => Create(rest), ["create", .. var rest] => Create(rest),
["restore", var words, .. var rest] => Restore(words, rest), ["restore", var words, .. var rest] => Restore(words, rest),
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest), ["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
["restore-address", var addrs, .. var rest] => RestoreAddress(addrs, rest),
["info", .. var rest] => Info(rest), ["info", .. var rest] => Info(rest),
["sync", .. var rest] => await Sync(rest), ["sync", .. var rest] => await Sync(rest),
["send", .. var rest] => await Send(rest), ["send", .. var rest] => await Send(rest),
@@ -29,7 +30,7 @@ try
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException) or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
{ {
Console.Error.WriteLine($"Errore: {ex.Message}"); Console.Error.WriteLine($"Error: {ex.Message}");
return 1; return 1;
} }
@@ -46,7 +47,7 @@ static int Addresses(string words, string[] o)
if (account is null) if (account is null)
return 1; return 1;
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5; var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}"); Console.WriteLine($"network: {account.Profile.NetName} kind: {account.Kind} path: m/{account.AccountPath}");
Console.WriteLine($"account: {account.ToSlip132()}"); Console.WriteLine($"account: {account.ToSlip132()}");
for (var i = 0; i < count; i++) for (var i = 0; i < count; i++)
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}"); Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
@@ -58,7 +59,7 @@ static int Addresses(string words, string[] o)
static int Create(string[] o) static int Create(string[] o)
{ {
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve); var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):"); Console.WriteLine("New mnemonic (write it on paper, it is NOT shown again):");
Console.WriteLine($" {mnemonic}"); Console.WriteLine($" {mnemonic}");
return SaveWallet(mnemonic.ToString(), o); return SaveWallet(mnemonic.ToString(), o);
} }
@@ -67,7 +68,7 @@ static int Restore(string words, string[] o)
{ {
if (!Bip39.TryParse(words, out _)) if (!Bip39.TryParse(words, out _))
{ {
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati)."); Console.Error.WriteLine("Invalid mnemonic (wrong words or checksum).");
return 1; return 1;
} }
return SaveWallet(words.Trim(), o); return SaveWallet(words.Trim(), o);
@@ -78,7 +79,7 @@ static int RestoreXpub(string xpubText, string[] o)
var profile = Profile(o); var profile = Profile(o);
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind)) if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
{ {
Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete."); Console.Error.WriteLine("Extended key not recognised for this network.");
return 1; return 1;
} }
var account = HdAccount.FromAccountXpub(xpub!, kind, profile); var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
@@ -91,7 +92,32 @@ static int RestoreXpub(string xpubText, string[] o)
}; };
var path = WalletPath(o, profile); var path = WalletPath(o, profile);
WalletStore.Save(doc, path, Opt(o, "--password")); WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Wallet watch-only salvato in {path}"); Console.WriteLine($"Watch-only wallet saved to {path}");
return 0;
}
static int RestoreAddress(string addrsText, string[] o)
{
var profile = Profile(o);
var addresses = addrsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (addresses.Length == 0)
{
Console.Error.WriteLine("At least one address is required.");
return 1;
}
WalletDocument doc;
try
{
(doc, _) = WalletLoader.NewFromAddresses(addresses, profile);
}
catch (InvalidDataException ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
var path = WalletPath(o, profile);
WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Watch-only wallet saved to {path} ({addresses.Length} address(es), cannot sign)");
return 0; return 0;
} }
@@ -99,26 +125,28 @@ static int Info(string[] o)
{ {
var (doc, account, path) = OpenWallet(o); var (doc, account, path) = OpenWallet(o);
Console.WriteLine($"file: {path}"); Console.WriteLine($"file: {path}");
Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}"); Console.WriteLine($"network: {doc.Network} kind: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
Console.WriteLine($"path: m/{doc.AccountPath}"); Console.WriteLine($"path: m/{doc.AccountPath}");
Console.WriteLine($"xpub: {doc.AccountXpub}"); Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache) if (doc.Cache is { } cache)
{ {
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato" Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : "")); + (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni"); + (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}"); + (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
} }
else else
{ {
Console.WriteLine($"ricezione: {account.GetReceiveAddress(0)} (mai sincronizzato)"); Console.WriteLine($"receive: {account.GetReceiveAddress(0)} (never synchronized)");
} }
if (o.Contains("--addresses") && doc.Cache is { } c) if (o.Contains("--addresses") && doc.Cache is { } c)
{ {
Console.WriteLine("indirizzi:"); Console.WriteLine("addresses:");
foreach (var a in c.Addresses) foreach (var a in c.Addresses)
Console.WriteLine($" {(a.IsChange ? "change " : "ricev. ")}{a.Index,3} {a.Address} " + Console.WriteLine($" {(a.IsChange ? "change " : "receive")}{a.Index,3} {a.Address} " +
$"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)"); $"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)");
} }
return 0; return 0;
@@ -138,15 +166,19 @@ static async Task<int> Sync(string[] o)
doc.Cache?.BlockHeaders, doc.Cache?.BlockHeaders,
doc.Cache?.NextReceiveIndex ?? 0, doc.Cache?.NextReceiveIndex ?? 0,
doc.Cache?.NextChangeIndex ?? 0, doc.Cache?.NextChangeIndex ?? 0,
net); net,
doc.Cache?.AnchoredUpTo);
var result = await sync.SyncOnceAsync(); var result = await sync.SyncOnceAsync();
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net); var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
doc.Cache = new SyncCache doc.Cache = new SyncCache
{ {
TipHeight = result.TipHeight, TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats, ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats, UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
PendingVerificationSats = result.PendingVerificationSats,
SpendableSats = result.SpendableSats,
NextReceiveIndex = result.NextReceiveIndex, NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex, NextChangeIndex = result.NextChangeIndex,
History = [.. result.History], History = [.. result.History],
@@ -155,16 +187,19 @@ static async Task<int> Sync(string[] o)
RawTxHex = rawHex, RawTxHex = rawHex,
VerifiedAt = verifiedAt, VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders, BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
}; };
WalletStore.Save(doc, path, Opt(o, "--password")); WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato" Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : "")); + (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
Console.WriteLine($"Storico ({result.History.Count}):"); + (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
Console.WriteLine($"History ({result.History.Count}):");
foreach (var tx in result.History) foreach (var tx in result.History)
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " + Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" + $"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
(tx.Verified ? "" : " (non verificata)")); (tx.Verified ? "" : " (unverified)"));
return 0; return 0;
} }
@@ -173,18 +208,18 @@ static async Task<int> Send(string[] o)
var (doc, account, path) = OpenWallet(o); var (doc, account, path) = OpenWallet(o);
if (doc.Cache is null) if (doc.Cache is null)
{ {
Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'."); Console.Error.WriteLine("Wallet never synchronized: run 'sync' first.");
return 1; return 1;
} }
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to mancante."); var to = Opt(o, "--to") ?? throw new WalletSpendException("--to missing.");
var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind)); var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind));
var sendAll = o.Contains("--all"); var sendAll = o.Contains("--all");
long amount = 0; long amount = 0;
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount)) if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
throw new WalletSpendException("--amount mancante o non valido (oppure usa --all)."); throw new WalletSpendException("--amount missing or invalid (or use --all).");
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m; var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
// Tx di provenienza degli UTXO: servono per importi e firma. // Source transactions of the UTXOs: needed for amounts and signing.
await using var client = await Connect(o, account.Profile); await using var client = await Connect(o, account.Profile);
var network = PalladiumNetworks.For(account.Profile.Kind); var network = PalladiumNetworks.For(account.Profile.Kind);
var transactions = new Dictionary<string, Transaction>(); var transactions = new Dictionary<string, Transaction>();
@@ -193,7 +228,7 @@ static async Task<int> Send(string[] o)
var built = new TransactionFactory(account).Build( var built = new TransactionFactory(account).Build(
doc.Cache.Utxos, transactions, destination, amount, feeRate, doc.Cache.Utxos, transactions, destination, amount, feeRate,
doc.Cache.NextChangeIndex, sendAll); doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll);
Console.WriteLine($"txid: {built.Txid}"); Console.WriteLine($"txid: {built.Txid}");
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " + Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
@@ -201,20 +236,20 @@ static async Task<int> Send(string[] o)
if (!built.Signed) if (!built.Signed)
{ {
Console.WriteLine("Wallet watch-only: PSBT da firmare offline (§6.5):"); Console.WriteLine("Watch-only wallet: PSBT to sign offline (§6.5):");
Console.WriteLine(built.Psbt.ToBase64()); Console.WriteLine(built.Psbt.ToBase64());
return 0; return 0;
} }
if (!o.Contains("--broadcast")) if (!o.Contains("--broadcast"))
{ {
Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):"); Console.WriteLine("Signed transaction (NOT broadcast, add --broadcast):");
Console.WriteLine(built.ToHex()); Console.WriteLine(built.ToHex());
return 0; return 0;
} }
var txid2 = await client.BroadcastAsync(built.ToHex()); var txid2 = await client.BroadcastAsync(built.ToHex());
Console.WriteLine($"Trasmessa: {txid2}"); Console.WriteLine($"Broadcast: {txid2}");
return 0; return 0;
} }
@@ -222,7 +257,7 @@ static int ResetCerts(string[] o)
{ {
var profile = Profile(o); var profile = Profile(o);
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll(); new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati."); Console.WriteLine($"Saved SSL certificates for {profile.NetName} cleared.");
return 0; return 0;
} }
@@ -235,16 +270,16 @@ static async Task<int> Servers(string[] o)
{ {
await using var client = await Connect(o, profile); await using var client = await Connect(o, profile);
var added = await registry.DiscoverAsync(client); var added = await registry.DiscoverAsync(client);
Console.WriteLine($"Scoperti {added} nuovi server dai peer."); Console.WriteLine($"Discovered {added} new servers from peers.");
} }
Console.WriteLine($"Server noti ({profile.NetName}):"); Console.WriteLine($"Known servers ({profile.NetName}):");
foreach (var server in registry.All) foreach (var server in registry.All)
Console.WriteLine($" {server}"); Console.WriteLine($" {server}");
return 0; return 0;
} }
// ----- helper comuni ----- // ----- shared helpers -----
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
{ {
@@ -267,7 +302,7 @@ static HdAccount? AccountFromWords(string words, string[] o)
{ {
if (!Bip39.TryParse(words, out var mnemonic)) if (!Bip39.TryParse(words, out var mnemonic))
{ {
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati)."); Console.Error.WriteLine("Invalid mnemonic (wrong words or checksum).");
return null; return null;
} }
var profile = Profile(o); var profile = Profile(o);
@@ -286,11 +321,11 @@ static int SaveWallet(string words, string[] o)
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null); Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
var password = Opt(o, "--password"); var password = Opt(o, "--password");
if (string.IsNullOrEmpty(password)) if (string.IsNullOrEmpty(password))
Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17)."); Console.WriteLine("WARNING: no --password, the seed will be stored in plaintext on disk (§17).");
var path = WalletPath(o, profile); var path = WalletPath(o, profile);
WalletStore.Save(doc, path, password); WalletStore.Save(doc, path, password);
Console.WriteLine($"Wallet salvato in {path}"); Console.WriteLine($"Wallet saved to {path}");
Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}"); Console.WriteLine($"First address: {account.GetReceiveAddress(0)}");
return 0; return 0;
} }
@@ -298,7 +333,7 @@ static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
{ {
var path = WalletPath(o, Profile(o)); var path = WalletPath(o, Profile(o));
if (!WalletStore.Exists(path)) if (!WalletStore.Exists(path))
throw new WalletSpendException($"Nessun wallet in {path}: usa 'create' o 'restore'."); throw new WalletSpendException($"No wallet at {path}: use 'create' or 'restore'.");
var doc = WalletStore.Load(path, Opt(o, "--password")); var doc = WalletStore.Load(path, Opt(o, "--password"));
return (doc, WalletLoader.ToAccount(doc), path); return (doc, WalletLoader.ToAccount(doc), path);
} }
@@ -317,15 +352,15 @@ static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
} }
else else
{ {
// Senza --server si usa il primo server noto (bootstrap §3 o scoperto §9). // Without --server, use the first known server (bootstrap §3 or discovered §9).
var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default
?? throw new WalletSpendException("Nessun server noto per questa rete: usa --server host:porta."); ?? throw new WalletSpendException("No known server for this network: use --server host:port.");
host = known.Host; host = known.Host;
port = known.PortFor(useSsl); port = known.PortFor(useSsl);
} }
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)); var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
Console.WriteLine($"Connessione a {host}:{port}{(useSsl ? " (TLS)" : "")}…"); Console.WriteLine($"Connecting to {host}:{port}{(useSsl ? " (TLS)" : "")}…");
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins); return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
} }
@@ -343,20 +378,21 @@ static int Usage()
Wallet: Wallet:
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
[--passphrase W] [--password P] [--file PATH] [--passphrase W] [--password P] [--file PATH]
restore "<mnemonica>" [stesse opzioni di create] [--path m/...] restore "<mnemonic>" [same options as create] [--path m/...]
restore-xpub <xpub slip132> [--net ...] [--password P] [--file PATH] (watch-only) restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
restore-address <addr1,addr2,...> [--net ...] [--password P] [--file PATH] (watch-only, no keys)
info [--net ...] [--password P] [--file PATH] info [--net ...] [--password P] [--file PATH]
Rete (server di indicizzazione; senza --server usa il primo server noto): Network (indexing server; without --server the first known server is used):
sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH] sync [--server host[:port]] [--ssl] [--net ...] [--password P] [--file PATH]
send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB] send --to ADDRESS (--amount X | --all) [--feerate sat/vB]
[--server host[:porta]] [--ssl] [--broadcast] [...] [--server host[:port]] [--ssl] [--broadcast] [...]
servers [--discover] [--server host[:porta]] [--ssl] [--net ...] servers [--discover] [--server host[:port]] [--ssl] [--net ...]
reset-certs [--net ...] reset-certs [--net ...]
Strumenti: Tools:
newseed [--words 12|24] newseed [--words 12|24]
addresses "<mnemonica>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...] addresses "<mnemonic>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
"""); """);
return 1; return 1;
} }
+6
View File
@@ -126,6 +126,12 @@ public sealed record ChainProfile
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary> /// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
public required int BlockTimeSeconds { get; init; } public required int BlockTimeSeconds { get; init; }
/// <summary>Blocks a coinbase output must wait before it can be spent.</summary>
public required int CoinbaseMaturity { get; init; }
/// <summary>Minimum confirmations required for a regular UTXO to be spendable.</summary>
public required int MinConfirmations { get; init; }
/// <summary>BIP32/SLIP-132 headers for each script type.</summary> /// <summary>BIP32/SLIP-132 headers for each script type.</summary>
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; } public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
+41 -2
View File
@@ -26,6 +26,8 @@ public static class ChainProfiles
ExplorerUrl = "https://explorer.palladium-coin.com/", ExplorerUrl = "https://explorer.palladium-coin.com/",
SkipPowValidation = true, SkipPowValidation = true,
BlockTimeSeconds = 120, BlockTimeSeconds = 120,
CoinbaseMaturity = 120,
MinConfirmations = 6,
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
{ {
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub [ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
@@ -45,14 +47,47 @@ public static class ChainProfiles
new ServerEndpoint("66.94.115.80", 50001, 50002), new ServerEndpoint("66.94.115.80", 50001, 50002),
new ServerEndpoint("89.117.149.130", 50001, 50002), new ServerEndpoint("89.117.149.130", 50001, 50002),
], ],
// TODO: populate with the chain's real [hash, bits] (§7.3) before release. // Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
Checkpoints = [], // RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
// bounds how far back a forged header chain must be walked to be caught, since
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
Checkpoints =
[
new Checkpoint(20000, "00000000000018ffaa9a332cfb418b5c8c3f988cf26598e378bbea9e93d26f74", 0x1b00ffff),
new Checkpoint(40000, "000000000000011ad2ae53d9647a2130d2b1c41b18d200455211f1fef2a4ffb7", 0x1a013d28),
new Checkpoint(60000, "000000000000034288c737d62011855fb598cd5c6ebb5c46c08acdec17620c8b", 0x1a0390b5),
new Checkpoint(80000, "00000000000003662534639c7eb1dd7166efd85c308be19b367467c015279361", 0x1a0577b1),
new Checkpoint(100000, "0000000000000850eba93bbc491f085e2c79c0c30c497292858c72e90cae69a5", 0x1a2c39e4),
new Checkpoint(120000, "0000000000004c421e06c84f08a947d994cb801b8ac7cade12d616209d851d43", 0x1a510836),
new Checkpoint(140000, "00000000000075b1a095a5969a2ca729b646ab0e2b9a9bd72aa603b3b889c398", 0x1b00a257),
new Checkpoint(160000, "000000000000028c8ba89e695f80fc78491bcf7e583fc7cd868e0a9c2973dfbe", 0x1a0ed8bb),
new Checkpoint(180000, "00000000000003632ffdcf60ce3892f44613dbbfe761b14522e91a1e650c092f", 0x1a064544),
new Checkpoint(200000, "000000000000221a9e16556453fc86308b260d95d80c14bafaf053a09374e7eb", 0x1a22c142),
new Checkpoint(220000, "0000000000001f63d259df5b9b20182dbaea92f2858fe836b895b2a33430c6dd", 0x1a3311af),
new Checkpoint(240000, "0000000000004c8a80484a1d6ab8a08460ac688445ccafe5cbac8b11bc471f11", 0x1a764de8),
new Checkpoint(260000, "000000000000ab1b71140485359633ef991588613f520052ce87acb70a36b4de", 0x1b022691),
new Checkpoint(280000, "00000000000678b07eda63cf01b099737ce832470d0e71769d157190f4d9ac9b", 0x1b118047),
new Checkpoint(300000, "0000000000013acdf07a4fb988bbe9824c36eb421478a71c8196cf524dcba143", 0x1b01ddc1),
new Checkpoint(320000, "000000000000079f2fb9866f1bb452ee5f47a35e0d494c6bc90331f582b07991", 0x1a0e8592),
new Checkpoint(340000, "000000000000000e45f7fbcff239da7965e1bd58aea3a10aef2bc8afbca822be", 0x1a0328e2),
new Checkpoint(360000, "000000000000016d60397423447eb42f8b4ba693fe16f1e64e9fdf58c94ca2a6", 0x1a020861),
new Checkpoint(380000, "000000000000004ba0d45a0462501a12251947d27e52ec810edd69007b7acf90", 0x1a01568d),
new Checkpoint(400000, "0000000000000010ac708514e8b837703233161099bf55400433cef32311f495", 0x1a01ce70),
new Checkpoint(420000, "00000000000000351392487709e637bc7a9b0b2296a0e443f54e2af5b3f00e16", 0x1a01197e),
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
],
}; };
public static ChainProfile Testnet { get; } = Mainnet with public static ChainProfile Testnet { get; } = Mainnet with
{ {
Kind = NetKind.Testnet, Kind = NetKind.Testnet,
NetName = "testnet", NetName = "testnet",
MinConfirmations = 1,
WifPrefix = 0xff, WifPrefix = 0xff,
AddrP2pkh = 127, AddrP2pkh = 127,
AddrP2sh = 115, AddrP2sh = 115,
@@ -75,6 +110,9 @@ public static class ChainProfiles
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard) [ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
}, },
BootstrapServers = [], BootstrapServers = [],
// TODO: populate from a synced testnet palladiumd (§7.3) — left empty because
// WalletSynchronizer already treats "no checkpoint at or below this height" as
// a no-op, so an empty array is safe, just unanchored.
Checkpoints = [], Checkpoints = [],
}; };
@@ -87,6 +125,7 @@ public static class ChainProfiles
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed). // TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
NodeP2pPort = 28444, NodeP2pPort = 28444,
// Regtest is regenerated locally on demand: hardcoded checkpoints make no sense here.
Checkpoints = [], Checkpoints = [],
}; };
+1 -1
View File
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
MajorityRejectBlockOutdated = 950, MajorityRejectBlockOutdated = 950,
MajorityWindow = 1000, MajorityWindow = 1000,
MinimumChainWork = uint256.Zero, MinimumChainWork = uint256.Zero,
CoinbaseMaturity = 100, CoinbaseMaturity = 120,
SupportSegwit = true, SupportSegwit = true,
SupportTaproot = true, SupportTaproot = true,
ConsensusFactory = new ConsensusFactory(), ConsensusFactory = new ConsensusFactory(),
+31 -14
View File
@@ -58,28 +58,45 @@ public static class Bip39
// ideographic spaces — the text must not be altered further (§4.1). // ideographic spaces — the text must not be altered further (§4.1).
text = text.Trim(); text = text.Trim();
IEnumerable<Wordlist> candidates = language is not null Wordlist wordlist;
? [ToWordlist(language.Value)] if (language is not null)
: [Wordlist.AutoDetect(text)]; {
wordlist = ToWordlist(language.Value);
foreach (var wordlist in candidates) }
else
{ {
try try
{ {
var parsed = new Mnemonic(text, wordlist); wordlist = Wordlist.AutoDetect(text);
// The constructor does NOT verify the checksum — explicit check is mandatory.
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
{
mnemonic = parsed;
return true;
}
} }
catch (FormatException) catch (NotSupportedException)
{ {
// Words outside the wordlist or invalid count — try the next candidate. // AutoDetect lands on NBitcoin's internal "Unknown" language for
// text resembling no wordlist and throws instead of returning a
// default (found by fuzzing) — not a valid mnemonic, plain and simple.
return false;
} }
} }
try
{
var parsed = new Mnemonic(text, wordlist);
// The constructor does NOT verify the checksum — explicit check is mandatory.
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
{
mnemonic = parsed;
return true;
}
}
catch (FormatException)
{
// Words outside the wordlist or invalid count.
}
catch (NotSupportedException)
{
// Defensive: same NBitcoin failure mode surfacing from the constructor.
}
return false; return false;
} }
+13
View File
@@ -52,6 +52,19 @@ public static class DerivationPaths
_ => throw new ArgumentOutOfRangeException(nameof(kind)), _ => throw new ArgumentOutOfRangeException(nameof(kind)),
}; };
/// <summary>
/// Best-effort reverse mapping from an already-known address to a ScriptKind, used to
/// label pure address imports (no derivation involved, so this is informational only).
/// </summary>
public static ScriptKind KindFor(BitcoinAddress address) => address switch
{
BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit,
TaprootAddress => ScriptKind.Taproot,
BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig,
BitcoinScriptAddress => ScriptKind.WrappedSegwit,
_ => ScriptKind.Legacy,
};
/// <summary> /// <summary>
/// Account path relative to the root: purpose'/coin'/account' (§4.2). /// Account path relative to the root: purpose'/coin'/account' (§4.2).
/// coin_type is taken from the profile (746 mainnet, 1 testnet). /// coin_type is taken from the profile (746 mainnet, 1 testnet).
+15 -4
View File
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
} }
} }
private Dictionary<string, string> Load() => private Dictionary<string, string> Load()
File.Exists(filePath) {
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [] if (!File.Exists(filePath))
: []; return [];
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
}
catch (JsonException)
{
// A corrupt pin file must not make every SSL connection fail forever:
// fall back to first-contact TOFU (same trust level as the bootstrap).
return [];
}
}
private void Save(Dictionary<string, string> pins) private void Save(Dictionary<string, string> pins)
{ {
+46 -4
View File
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary> /// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle); public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
public readonly record struct HeaderRangeResponse(int Count, string Hex);
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary> /// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
public readonly record struct ChainTip(int Height, string HeaderHex); public readonly record struct ChainTip(int Height, string HeaderHex);
@@ -82,6 +85,23 @@ public static class ElectrumApi
return r.GetString()!; return r.GetString()!;
} }
/// <summary>
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
/// callers must loop until the full range is covered.
/// </summary>
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
int startHeight, int count, CancellationToken ct = default)
{
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
return new HeaderRangeResponse(
r.GetProperty("count").GetInt32(),
r.GetProperty("hex").GetString()!);
}
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex, public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@@ -124,17 +144,22 @@ public static class ElectrumApi
/// Parses the server.peers.subscribe response: a list of /// Parses the server.peers.subscribe response: a list of
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]]; /// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
/// "t"/"s" without a number = network default port (resolved by the caller). /// "t"/"s" without a number = network default port (resolved by the caller).
/// The response is untrusted server data: any unexpected shape (non-array,
/// wrong element types) is skipped, never thrown on.
/// </summary> /// </summary>
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response) public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
{ {
var peers = new List<PeerInfo>(); var peers = new List<PeerInfo>();
if (response.ValueKind != JsonValueKind.Array)
return peers;
foreach (var entry in response.EnumerateArray()) foreach (var entry in response.EnumerateArray())
{ {
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3) if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3
|| entry[2].ValueKind != JsonValueKind.Array)
continue; continue;
var host = entry[1].GetString(); var host = AsString(entry[1]);
if (string.IsNullOrWhiteSpace(host)) if (string.IsNullOrWhiteSpace(host))
host = entry[0].GetString(); host = AsString(entry[0]);
if (string.IsNullOrWhiteSpace(host)) if (string.IsNullOrWhiteSpace(host))
continue; continue;
@@ -142,7 +167,7 @@ public static class ElectrumApi
string? version = null; string? version = null;
foreach (var feature in entry[2].EnumerateArray()) foreach (var feature in entry[2].EnumerateArray())
{ {
var f = feature.GetString(); var f = AsString(feature);
if (string.IsNullOrEmpty(f)) if (string.IsNullOrEmpty(f))
continue; continue;
switch (f[0]) switch (f[0])
@@ -157,4 +182,21 @@ public static class ElectrumApi
} }
return peers; return peers;
} }
private static string? AsString(JsonElement element)
{
if (element.ValueKind != JsonValueKind.String)
return null;
try
{
return element.GetString();
}
catch (InvalidOperationException)
{
// Syntactically valid JSON string that cannot be transcoded to UTF-16
// (invalid UTF-8 bytes / lone surrogates) — untrusted server data,
// treat as absent (found by fuzzing).
return null;
}
}
} }
+18 -7
View File
@@ -36,8 +36,12 @@ public sealed class ElectrumClient : IAsyncDisposable
// single segment; this gate avoids flooding the server with thousands of // single segment; this gate avoids flooding the server with thousands of
// simultaneous requests on large wallets → no bursts of -101/-102 nor // simultaneous requests on large wallets → no bursts of -101/-102 nor
// connection drops. Writes still stay pipelined up to this degree. // connection drops. Writes still stay pipelined up to this degree.
private const int MaxInFlight = 32; // Configurable per connection (see ConnectAsync): the right value trades off
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight); // initial-sync throughput against how aggressively a given server tolerates
// concurrent requests before throttling — no single constant is right for
// every server, so callers may raise/lower it instead of recompiling.
public const int DefaultMaxInFlight = 32;
private readonly SemaphoreSlim _inFlight;
private long _nextId; private long _nextId;
@@ -49,19 +53,22 @@ public sealed class ElectrumClient : IAsyncDisposable
public event Action<string, JsonElement>? NotificationReceived; public event Action<string, JsonElement>? NotificationReceived;
public event Action<Exception?>? Disconnected; public event Action<Exception?>? Disconnected;
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl) private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl,
int maxInFlight)
{ {
_tcp = tcp; _tcp = tcp;
_stream = stream; _stream = stream;
Host = host; Host = host;
Port = port; Port = port;
UseSsl = useSsl; UseSsl = useSsl;
_inFlight = new SemaphoreSlim(maxInFlight, maxInFlight);
_readLoop = Task.Run(ReadLoopAsync); _readLoop = Task.Run(ReadLoopAsync);
_writeLoop = Task.Run(WriteLoopAsync); _writeLoop = Task.Run(WriteLoopAsync);
} }
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl, public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
CertificatePinStore? pins = null, CancellationToken ct = default) CertificatePinStore? pins = null, CancellationToken ct = default,
int maxInFlight = DefaultMaxInFlight)
{ {
var tcp = new TcpClient { NoDelay = true }; var tcp = new TcpClient { NoDelay = true };
try try
@@ -90,7 +97,7 @@ public sealed class ElectrumClient : IAsyncDisposable
stream = ssl; stream = ssl;
} }
var client = new ElectrumClient(tcp, stream, host, port, useSsl); var client = new ElectrumClient(tcp, stream, host, port, useSsl, maxInFlight);
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion); await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
return client; return client;
} }
@@ -284,5 +291,9 @@ public sealed class ElectrumServerException(string error) : Exception(error);
/// It is unlocked with an explicit reset of the certificates. /// It is unlocked with an explicit reset of the certificates.
/// </summary> /// </summary>
public sealed class CertificatePinMismatchException(string host, int port) : Exception( public sealed class CertificatePinMismatchException(string host, int port) : Exception(
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " + $"The TLS certificate of {host}:{port} has changed from the one saved. " +
"Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL."); "If the server renewed its certificate, reset the SSL certificates.")
{
public string Host { get; } = host;
public int Port { get; } = port;
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace PalladiumWallet.Core.Net;
/// <summary>A GitHub release newer than the running app.</summary>
public sealed record LatestRelease(string Tag, string HtmlUrl);
/// <summary>
/// Checks the latest GitHub release for the project against the running app version.
/// Best-effort only: any network/parse failure or an up-to-date app both resolve to null,
/// so callers never need to distinguish "no update" from "couldn't check".
/// </summary>
public static class UpdateChecker
{
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
private sealed class GitHubReleaseResponse
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
}
public static Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default) =>
CheckAsync(currentVersion, handler: null, ct);
/// <summary>Test seam: <paramref name="handler"/> replaces the real HTTP transport.</summary>
internal static async Task<LatestRelease?> CheckAsync(string currentVersion,
HttpMessageHandler? handler, CancellationToken ct = default)
{
try
{
using var http = handler is null
? new HttpClient()
: new HttpClient(handler, disposeHandler: false);
http.Timeout = TimeSpan.FromSeconds(10);
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return null;
var release = await response.Content
.ReadFromJsonAsync<GitHubReleaseResponse>(ct)
.ConfigureAwait(false);
if (release?.TagName is not { Length: > 0 } tag) return null;
if (!TryParse(tag, out var remote) || !TryParse(currentVersion, out var local))
return null;
if (remote <= local) return null;
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
}
catch
{
return null;
}
}
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
internal static bool TryParse(string raw, out Version version)
{
var s = raw.Trim();
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
var dash = s.IndexOf('-');
if (dash >= 0) s = s[..dash];
return Version.TryParse(s, out version!);
}
}
+6 -2
View File
@@ -6,8 +6,12 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NBitcoin" Version="10.0.6" /> <PackageReference Include="NBitcoin" Version="10.0.6" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+281 -45
View File
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Core.Spv; namespace PalladiumWallet.Core.Spv;
@@ -21,6 +22,30 @@ public sealed class SyncResult
public required int TipHeight { get; init; } public required int TipHeight { get; init; }
public required long ConfirmedSats { get; init; } public required long ConfirmedSats { get; init; }
public required long UnconfirmedSats { get; init; } public required long UnconfirmedSats { get; init; }
/// <summary>
/// Confirmed but not yet spendable: coinbase outputs below maturity or regular
/// outputs below <see cref="Chain.ChainProfile.MinConfirmations"/>. Subset of
/// <see cref="ConfirmedSats"/>.
/// </summary>
public required long ImmatureSats { get; init; }
/// <summary>
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
/// (an immature coinbase can also be unverified), so never subtract both from
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
/// </summary>
public required long PendingVerificationSats { get; init; }
/// <summary>
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
/// now — the true spendable balance. Computed directly from the same gate coin selection
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
/// </summary>
public required long SpendableSats { get; init; }
public required int NextReceiveIndex { get; init; } public required int NextReceiveIndex { get; init; }
public required int NextChangeIndex { get; init; } public required int NextChangeIndex { get; init; }
public required IReadOnlyList<CachedTx> History { get; init; } public required IReadOnlyList<CachedTx> History { get; init; }
@@ -38,10 +63,47 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary> /// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
public event Action<string>? Progress; public event Action<string>? Progress;
/// <summary>
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
/// only completes once verification is fully done, for callers that need the final state.
/// </summary>
public event Action<SyncResult>? PartialResult;
private readonly ConcurrentDictionary<string, Transaction> _txCache = new(); private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
private readonly Dictionary<string, int> _verifiedAtHeight = [];
// txids known confirmed (height > 0) as of download time, independent of whether their
// Merkle proof has been verified yet — lets ExportCaches persist raw tx bytes for a
// confirmed transaction interrupted before verification, instead of forcing a
// re-download on the next sync just because _verifiedAtHeight hasn't caught up. Every
// entry here has txHeights[txid] > 0 at the time it was recorded, i.e. server-confirmed;
// unconfirmed (mempool/RBF-able) transactions are deliberately never added.
private readonly ConcurrentDictionary<string, byte> _confirmedTxids = new();
// Concurrent: written incrementally by individual merkle-verification tasks as they
// complete (§7.4 progressive verification), not just once after they all finish.
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new(); private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
// checkpoint height -> highest height already proven to hash-chain back to it.
// Persisted across sessions (see ExportCaches/PreloadCaches): without it, every restart
// re-walks and re-verifies the whole header chain from the checkpoint even though the
// header bytes themselves are cached, which dominates reconnect time on large wallets.
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
// being verified) would otherwise race on overlapping ranges and issue duplicate range
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
// serializing them costs nothing that matters.
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
// Max headers requested per blockchain.block.headers call. The server may return fewer
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
private const int HeaderBatchSize = 2016;
// Indices known from the previous sync: used by ScanChainAsync for incremental // Indices known from the previous sync: used by ScanChainAsync for incremental
// discovery — already-used addresses are fetched in a single burst instead of // discovery — already-used addresses are fetched in a single burst instead of
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1). // sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
@@ -58,16 +120,31 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
Dictionary<int, string>? blockHeaders, Dictionary<int, string>? blockHeaders,
int knownReceiveIndex, int knownReceiveIndex,
int knownChangeIndex, int knownChangeIndex,
Network network) Network network,
Dictionary<int, int>? anchoredUpTo = null)
{ {
foreach (var (txid, hex) in rawTxHex) foreach (var (txid, hex) in rawTxHex)
{
_txCache.TryAdd(txid, Transaction.Parse(hex, network)); _txCache.TryAdd(txid, Transaction.Parse(hex, network));
// ExportCaches only ever wrote confirmed transactions here (see its own
// filter), so every preloaded entry is safe to mark confirmed too.
_confirmedTxids.TryAdd(txid, 0);
}
foreach (var (txid, height) in verifiedAt) foreach (var (txid, height) in verifiedAt)
if (!_verifiedAtHeight.ContainsKey(txid)) if (!_verifiedAtHeight.ContainsKey(txid))
_verifiedAtHeight[txid] = height; _verifiedAtHeight[txid] = height;
if (blockHeaders is not null) if (blockHeaders is not null)
foreach (var (height, hex) in blockHeaders) foreach (var (height, hex) in blockHeaders)
_headerFetches.TryAdd(height, Task.FromResult(hex)); _headerFetches.TryAdd(height, Task.FromResult(hex));
// Only trust a preloaded anchor up to a height whose header is also cached: if the
// header cache was cleared/corrupted independently, re-deriving the chain-of-hashes
// check on next use (AnchorToCheckpointAsync re-fetches what's missing) is safer than
// trusting a stale "already validated" claim against headers that may no longer match.
if (anchoredUpTo is not null)
foreach (var (checkpointHeight, upToHeight) in anchoredUpTo)
if (_headerFetches.ContainsKey(upToHeight))
_anchoredUpTo.AddOrUpdate(checkpointHeight, upToHeight,
(_, existing) => Math.Max(existing, upToHeight));
_knownReceiveIndex = knownReceiveIndex; _knownReceiveIndex = knownReceiveIndex;
_knownChangeIndex = knownChangeIndex; _knownChangeIndex = knownChangeIndex;
} }
@@ -79,10 +156,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// </summary> /// </summary>
public (Dictionary<string, string> RawTxHex, public (Dictionary<string, string> RawTxHex,
Dictionary<string, int> VerifiedAt, Dictionary<string, int> VerifiedAt,
Dictionary<int, string> BlockHeaders) Dictionary<int, string> BlockHeaders,
Dictionary<int, int> AnchoredUpTo)
ExportCaches(Network network) ExportCaches(Network network)
{ {
var rawHex = _verifiedAtHeight.Keys var rawHex = _confirmedTxids.Keys
.Where(_txCache.ContainsKey) .Where(_txCache.ContainsKey)
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex()); .ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
@@ -93,7 +171,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (task.IsCompletedSuccessfully) if (task.IsCompletedSuccessfully)
headers[height] = task.Result; headers[height] = task.Result;
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers); return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers,
new Dictionary<int, int>(_anchoredUpTo));
} }
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default) public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
@@ -150,7 +229,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
foreach (var item in historyByAddress.Values.SelectMany(h => h)) foreach (var item in historyByAddress.Values.SelectMany(h => h))
txHeights[item.TxHash] = item.Height; txHeights[item.TxHash] = item.Height;
// 4+5. Download missing transactions and verify Merkle proofs in parallel. // 4. Download missing transactions — needed to compute amounts/UTXOs locally.
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
// wallet with thousands of transactions, downloads finish in seconds while proofs can
// take much longer over a high-latency link, and the wallet has everything it needs to
// show balance/history the moment downloads are done. Verification then continues in
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
// while coin selection stays locked out of any UTXO until its own proof is checked
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
// cannot get a fabricated balance spent just because it was shown early.
var network = PalladiumNetworks.For(account.Profile.Kind); var network = PalladiumNetworks.For(account.Profile.Kind);
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList(); var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
var toVerify = txHeights var toVerify = txHeights
@@ -158,46 +245,95 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value)) && (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
.ToList(); .ToList();
// Total/already-cached counts (not just this session's downloads): on a sync resumed
// after an interruption, `missing` is often empty because everything was already
// fetched last time (see _confirmedTxids/PreloadCaches) — reporting against the total
// shows "n/n transactions" immediately instead of a misleading "0/0" before jumping
// straight to proof verification.
var totalTx = txHeights.Count;
var alreadyCached = totalTx - missing.Count;
string DownloadVerifyStatus(int downloaded, int verified) =>
$"transactions {downloaded}/{totalTx}, proofs {verified}/{toVerify.Count}…";
if (missing.Count > 0 || toVerify.Count > 0) if (missing.Count > 0 || toVerify.Count > 0)
Progress?.Invoke(DownloadVerifyStatus(alreadyCached, 0));
var dlDone = 0;
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
{ {
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…"); var raw = await client.GetTransactionAsync(txid, ct);
var dlDone = 0; _txCache[txid] = Transaction.Parse(raw, network);
var merkDone = 0; if (txHeights[txid] > 0)
_confirmedTxids.TryAdd(txid, 0);
var n = Interlocked.Increment(ref dlDone);
if (n % 50 == 0 || n == missing.Count)
Progress?.Invoke(DownloadVerifyStatus(alreadyCached + n, 0));
}, ct)));
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () => SyncResult BuildSnapshot() =>
{ BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
var raw = await client.GetTransactionAsync(txid, ct);
_txCache[txid] = Transaction.Parse(raw, network);
var n = Interlocked.Increment(ref dlDone);
if (n % 50 == 0 || n == missing.Count)
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
}, ct));
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () => PartialResult?.Invoke(BuildSnapshot());
{
var (txid, height) = kv;
var proofTask = client.GetMerkleAsync(txid, height, ct);
var headerTask = _headerFetches.GetOrAdd(height,
h => client.GetBlockHeaderAsync(h, ct));
var proof = await proofTask;
var header = BlockHeaderInfo.Parse(await headerTask);
if (!MerkleProof.Verify(
uint256.Parse(txid), proof.Pos,
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
throw new SpvVerificationException(
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
var n = Interlocked.Increment(ref merkDone);
if (n % 50 == 0 || n == toVerify.Count)
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
}, ct));
await Task.WhenAll(dlTasks.Concat(merkTasks)); var merkDone = 0;
foreach (var (txid, height) in toVerify) var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
_verifiedAtHeight[txid] = height; {
} var (txid, height) = kv;
var proofTask = client.GetMerkleAsync(txid, height, ct);
// Anchor first: on a checkpointed height this fills _headerFetches[height]
// via the batched range fetch (§7.3), so the header lookup below is a cache
// hit instead of a second individual blockchain.block.header RPC per tx —
// halves round-trips for this stage on mainnet, where it matters most on
// high-latency mobile links. Falls back to an individual fetch when no
// checkpoint covers this height (testnet/regtest today).
await AnchorToCheckpointAsync(height, ct);
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
var header = BlockHeaderInfo.Parse(headerHex);
var proof = await proofTask;
if (!MerkleProof.Verify(
uint256.Parse(txid), proof.Pos,
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
throw new SpvVerificationException(
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
_verifiedAtHeight[txid] = height;
var n = Interlocked.Increment(ref merkDone);
if (n % 50 == 0 || n == toVerify.Count)
Progress?.Invoke(DownloadVerifyStatus(totalTx, n));
if (n % PartialResultBatchSize == 0)
PartialResult?.Invoke(BuildSnapshot());
}, ct)).ToList();
if (merkTasks.Count > 0)
await Task.WhenAll(merkTasks);
return BuildSnapshot();
}
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
// every single verified proof would make the background verification phase itself O(n²) for
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
// visibly without that cost.
private const int PartialResultBatchSize = 200;
private bool IsTxVerified(string txid, int height) =>
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
/// <summary>
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
/// transaction downloads finish (proofs still pending), and again as verification progresses,
/// each time reflecting whichever transactions have been proof-checked so far.
/// </summary>
private SyncResult BuildResult(
int tipHeight,
List<TrackedAddress> tracked,
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
Dictionary<string, int> txHeights,
int nextReceive,
int nextChange)
{
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]); var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
// 6. Local UTXO reconstruction. // 6. Local UTXO reconstruction.
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t); var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
@@ -209,6 +345,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var utxos = new List<CachedUtxo>(); var utxos = new List<CachedUtxo>();
foreach (var (txid, tx) in transactions) foreach (var (txid, tx) in transactions)
{ {
var height = txHeights[txid];
var verifiedTx = IsTxVerified(txid, height);
for (var vout = 0; vout < tx.Outputs.Count; vout++) for (var vout = 0; vout < tx.Outputs.Count; vout++)
{ {
var output = tx.Outputs[vout]; var output = tx.Outputs[vout];
@@ -224,7 +362,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
Address = addr.Address.ToString(), Address = addr.Address.ToString(),
IsChange = addr.IsChange, IsChange = addr.IsChange,
AddressIndex = addr.Index, AddressIndex = addr.Index,
Height = txHeights[txid], Height = height,
IsCoinbase = tx.IsCoinBase,
Verified = verifiedTx,
}); });
} }
} }
@@ -233,6 +373,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var history = new List<CachedTx>(); var history = new List<CachedTx>();
foreach (var (txid, tx) in transactions) foreach (var (txid, tx) in transactions)
{ {
var height = txHeights[txid];
var received = tx.Outputs var received = tx.Outputs
.Where(o => byScript.ContainsKey(o.ScriptPubKey)) .Where(o => byScript.ContainsKey(o.ScriptPubKey))
.Sum(o => o.Value.Satoshi); .Sum(o => o.Value.Satoshi);
@@ -243,9 +384,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
history.Add(new CachedTx history.Add(new CachedTx
{ {
Txid = txid, Txid = txid,
Height = txHeights[txid], Height = height,
DeltaSats = received - sentSats, DeltaSats = received - sentSats,
Verified = verified[txid], Verified = IsTxVerified(txid, height),
}); });
} }
history.Sort((a, b) => history.Sort((a, b) =>
@@ -272,9 +413,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
return new SyncResult return new SyncResult
{ {
TipHeight = tip.Height, TipHeight = tipHeight,
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats), ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats), UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
ImmatureSats = utxos.Where(u =>
u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile))
.Sum(u => u.ValueSats),
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
NextReceiveIndex = nextReceive, NextReceiveIndex = nextReceive,
NextChangeIndex = nextChange, NextChangeIndex = nextChange,
History = history, History = history,
@@ -285,6 +431,82 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
}; };
} }
/// <summary>
/// Anchors a header at <paramref name="height"/> to the nearest hardcoded checkpoint at
/// or below it (§7.3): downloads every intervening header and verifies an unbroken
/// prev-hash chain from the checkpoint's known-good hash up to this height. Without this,
/// the Merkle proof above only proves a transaction belongs to *some* header the server
/// handed over — on this LWMA chain the wallet cannot recompute PoW to catch a forged one,
/// so the checkpoint is the only fixed point of truth. A no-op when the network profile has
/// no checkpoint at or below <paramref name="height"/> (e.g. testnet/regtest today, or
/// mainnet heights below the first checkpoint).
/// </summary>
private async Task AnchorToCheckpointAsync(int height, CancellationToken ct)
{
var profile = account.Profile;
Checkpoint? checkpoint = null;
foreach (var c in profile.Checkpoints)
if (c.Height <= height && (checkpoint is not { } best || c.Height > best.Height))
checkpoint = c;
if (checkpoint is not { } cp)
return;
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
return;
await FetchHeaderRangeAsync(cp.Height, height, ct);
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
.ToArray();
if (!headers[0].MatchesCheckpoint(cp))
throw new SpvVerificationException(
$"Header at checkpoint height {cp.Height} does not match the hardcoded hash: server is not trustworthy.");
for (var i = 1; i < headers.Length; i++)
if (!headers[i].IsValidChild(headers[i - 1].Hash, profile))
throw new SpvVerificationException(
$"Broken header chain at height {cp.Height + i}: server is not trustworthy.");
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
}
/// <summary>
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
/// is present in <see cref="_headerFetches"/>, downloading gaps with
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
/// </summary>
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
{
await _headerRangeLock.WaitAsync(ct);
try
{
var h = fromHeight;
while (h <= toHeightInclusive)
{
if (_headerFetches.ContainsKey(h)) { h++; continue; }
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
var range = await client.GetBlockHeadersAsync(h, requested, ct);
if (range.Count == 0)
throw new SpvVerificationException(
$"Server returned no headers starting at height {h}: server is not trustworthy.");
for (var i = 0; i < range.Count; i++)
{
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
}
h += range.Count;
}
}
finally
{
_headerRangeLock.Release();
}
}
/// <summary> /// <summary>
/// Scans one chain (receiving or change). /// Scans one chain (receiving or change).
/// ///
@@ -358,7 +580,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
return (firstUnused, tracked, history); return (firstUnused, tracked, history);
} }
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct) // Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
// rather than guessed at from wall-clock numbers alone.
private int _busyRetries;
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
{ {
var delay = 200; var delay = 200;
for (var attempt = 0; ; attempt++) for (var attempt = 0; ; attempt++)
@@ -367,13 +594,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
catch (ElectrumServerException ex) catch (ElectrumServerException ex)
when (IsBusy(ex) && attempt < 7) when (IsBusy(ex) && attempt < 7)
{ {
ReportBusyRetry(delay);
await Task.Delay(delay, ct); await Task.Delay(delay, ct);
delay = Math.Min(delay * 2, 5_000); delay = Math.Min(delay * 2, 5_000);
} }
} }
} }
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct) private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
{ {
var delay = 200; var delay = 200;
for (var attempt = 0; ; attempt++) for (var attempt = 0; ; attempt++)
@@ -382,12 +610,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
catch (ElectrumServerException ex) catch (ElectrumServerException ex)
when (IsBusy(ex) && attempt < 7) when (IsBusy(ex) && attempt < 7)
{ {
ReportBusyRetry(delay);
await Task.Delay(delay, ct); await Task.Delay(delay, ct);
delay = Math.Min(delay * 2, 5_000); delay = Math.Min(delay * 2, 5_000);
} }
} }
} }
private void ReportBusyRetry(int delayMs)
{
var n = Interlocked.Increment(ref _busyRetries);
if (n == 1 || n % 20 == 0)
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
}
private static bool IsBusy(ElectrumServerException ex) => private static bool IsBusy(ElectrumServerException ex) =>
ex.Message.Contains("-102") || ex.Message.Contains("-102") ||
ex.Message.Contains("-101") || ex.Message.Contains("-101") ||
+13 -3
View File
@@ -21,6 +21,13 @@ public static class AppPaths
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary> /// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
public static string? OverrideDataRoot { get; set; } public static string? OverrideDataRoot { get; set; }
// Test seams: redirect the machine-global bootstrap locations (APPDATA pointer
// dir, executable dir, per-user default root) to a sandbox so the resolution
// precedence can be exercised without touching real user data.
internal static string? BootstrapDirOverride { get; set; }
internal static string? PortableBaseOverride { get; set; }
internal static string? DefaultRootOverride { get; set; }
/// <summary> /// <summary>
/// Default data root, following each platform's convention: /// Default data root, following each platform's convention:
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin); /// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
@@ -29,6 +36,8 @@ public static class AppPaths
/// </summary> /// </summary>
public static string DefaultDataRoot() public static string DefaultDataRoot()
{ {
if (DefaultRootOverride is { } o)
return o;
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
return Path.Combine( return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -41,11 +50,12 @@ public static class AppPaths
/// bootstrap location that is always writable and independent of the data root.</summary> /// bootstrap location that is always writable and independent of the data root.</summary>
private static string LocationPointerPath() => private static string LocationPointerPath() =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), BootstrapDirOverride ?? Path.Combine(
AppDirName, "data-location"); Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDirName),
"data-location");
private static string PortableRoot() => private static string PortableRoot() =>
Path.Combine(AppContext.BaseDirectory, PortableDirName); Path.Combine(PortableBaseOverride ?? AppContext.BaseDirectory, PortableDirName);
private static bool HasData(string root) => private static bool HasData(string root) =>
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any(); Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
+50 -10
View File
@@ -13,6 +13,10 @@ public static class EncryptedFile
{ {
private const string Format = "plm-wallet-aesgcm-v1"; private const string Format = "plm-wallet-aesgcm-v1";
private const int DefaultIterations = 600_000; private const int DefaultIterations = 600_000;
// Upper bound on the iteration count read from the container: the value is
// attacker-controlled in a tampered file, and an absurd count would hang the
// wallet at open (PBKDF2 DoS). Leaves ample room for future increases.
private const int MaxIterations = 10_000_000;
private const int SaltSize = 16; private const int SaltSize = 16;
private const int NonceSize = 12; private const int NonceSize = 12;
private const int TagSize = 16; private const int TagSize = 16;
@@ -26,13 +30,21 @@ public static class EncryptedFile
try try
{ {
using var doc = JsonDocument.Parse(fileContent); using var doc = JsonDocument.Parse(fileContent);
return doc.RootElement.TryGetProperty("Format", out var f) return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("Format", out var f)
&& f.ValueKind == JsonValueKind.String
&& f.GetString() == Format; && f.GetString() == Format;
} }
catch (JsonException) catch (JsonException)
{ {
return false; return false;
} }
catch (ArgumentException)
{
// Invalid UTF-16 (lone surrogates) cannot be transcoded for parsing:
// certainly not an encrypted container.
return false;
}
} }
public static string Encrypt(string plaintext, string password) public static string Encrypt(string plaintext, string password)
@@ -54,23 +66,51 @@ public static class EncryptedFile
Convert.ToBase64String(tag), Convert.ToBase64String(cipher))); Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
} }
/// <summary>Throws <see cref="WrongPasswordException"/> if the password is wrong or the file is tampered with.</summary> /// <summary>
/// Throws <see cref="WrongPasswordException"/> if the password is wrong or the
/// ciphertext is tampered with, <see cref="InvalidDataException"/> for any
/// malformed container (broken JSON, missing fields, bad base64, wrong
/// nonce/tag size, out-of-range iteration count) — never a raw parsing exception.
/// </summary>
public static string Decrypt(string fileContent, string password) public static string Decrypt(string fileContent, string password)
{ {
var container = JsonSerializer.Deserialize<Container>(fileContent) Container? container;
?? throw new InvalidDataException("Contenitore cifrato non valido."); try
{
container = JsonSerializer.Deserialize<Container>(fileContent);
}
catch (JsonException)
{
throw new InvalidDataException("Invalid encrypted container.");
}
if (container is null)
throw new InvalidDataException("Invalid encrypted container.");
if (container.Format != Format) if (container.Format != Format)
throw new InvalidDataException($"Formato sconosciuto: {container.Format}"); throw new InvalidDataException($"Unknown container format: {container.Format}");
if (container.Iterations is <= 0 or > MaxIterations)
throw new InvalidDataException($"Iteration count out of range: {container.Iterations}");
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations); byte[] salt, nonce, tag, cipher;
var cipher = Convert.FromBase64String(container.Data); try
{
salt = Convert.FromBase64String(container.Salt);
nonce = Convert.FromBase64String(container.Nonce);
tag = Convert.FromBase64String(container.Tag);
cipher = Convert.FromBase64String(container.Data);
}
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
{
throw new InvalidDataException("Invalid encrypted container.");
}
if (nonce.Length != NonceSize || tag.Length != TagSize)
throw new InvalidDataException("Invalid encrypted container.");
var key = DeriveKey(password, salt, container.Iterations);
var plain = new byte[cipher.Length]; var plain = new byte[cipher.Length];
try try
{ {
using var aes = new AesGcm(key, TagSize); using var aes = new AesGcm(key, TagSize);
aes.Decrypt( aes.Decrypt(nonce, cipher, tag, plain);
Convert.FromBase64String(container.Nonce), cipher,
Convert.FromBase64String(container.Tag), plain);
} }
catch (AuthenticationTagMismatchException) catch (AuthenticationTagMismatchException)
{ {
+39
View File
@@ -40,6 +40,9 @@ public sealed class WalletDocument
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary> /// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
public List<string>? WifKeys { get; set; } public List<string>? WifKeys { get; set; }
/// <summary>Watch-only addresses with no associated private key (pure address import).</summary>
public List<string>? WatchAddresses { get; set; }
/// <summary>Gap limit for address scanning (§5), configurable.</summary> /// <summary>Gap limit for address scanning (§5), configurable.</summary>
public int GapLimit { get; set; } = 20; public int GapLimit { get; set; } = 20;
@@ -95,6 +98,19 @@ public sealed class SyncCache
public int TipHeight { get; set; } public int TipHeight { get; set; }
public long ConfirmedSats { get; set; } public long ConfirmedSats { get; set; }
public long UnconfirmedSats { get; set; } public long UnconfirmedSats { get; set; }
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
public long ImmatureSats { get; set; }
/// <summary>
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
/// </summary>
public long PendingVerificationSats { get; set; }
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
public long SpendableSats { get; set; }
public int NextReceiveIndex { get; set; } public int NextReceiveIndex { get; set; }
public int NextChangeIndex { get; set; } public int NextChangeIndex { get; set; }
public List<CachedTx> History { get; set; } = []; public List<CachedTx> History { get; set; } = [];
@@ -121,6 +137,14 @@ public sealed class SyncCache
/// subsequent syncs. /// subsequent syncs.
/// </summary> /// </summary>
public Dictionary<int, string>? BlockHeaders { get; set; } public Dictionary<int, string>? BlockHeaders { get; set; }
/// <summary>
/// Checkpoint height → highest height already proven to hash-chain back to it (§7.3).
/// Avoids re-walking and re-verifying the whole header chain from the checkpoint on
/// every launch: the chain-of-hashes check already done for a height doesn't need
/// redoing once headers themselves are cached.
/// </summary>
public Dictionary<int, int>? AnchoredUpTo { get; set; }
} }
/// <summary>Scanned address with its own balance and transaction count (address view).</summary> /// <summary>Scanned address with its own balance and transaction count (address view).</summary>
@@ -138,6 +162,13 @@ public sealed class CachedTx
public required string Txid { get; set; } public required string Txid { get; set; }
public int Height { get; set; } public int Height { get; set; }
public long DeltaSats { get; set; } public long DeltaSats { get; set; }
/// <summary>
/// True once this tx's Merkle proof has actually been checked against a
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
/// be pending its own proof while background verification catches up). Always
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
/// </summary>
public bool Verified { get; set; } public bool Verified { get; set; }
} }
@@ -150,5 +181,13 @@ public sealed class CachedUtxo
public bool IsChange { get; set; } public bool IsChange { get; set; }
public int AddressIndex { get; set; } public int AddressIndex { get; set; }
public int Height { get; set; } public int Height { get; set; }
public bool IsCoinbase { get; set; }
public bool Frozen { get; set; } public bool Frozen { get; set; }
/// <summary>
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
/// </summary>
public bool Verified { get; set; }
} }
+8
View File
@@ -37,4 +37,12 @@ public static class WalletStore
File.WriteAllText(tmp, content); File.WriteAllText(tmp, content);
File.Move(tmp, path, overwrite: true); File.Move(tmp, path, overwrite: true);
} }
/// <summary>
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
/// transactions/headers), where the synchronous version would block the UI.
/// </summary>
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
Task.Run(() => Save(doc, path, password));
} }
+146 -9
View File
@@ -29,6 +29,8 @@ public sealed class BuiltTransaction
/// </summary> /// </summary>
public sealed class TransactionFactory(IWalletAccount account) public sealed class TransactionFactory(IWalletAccount account)
{ {
private const int MaxStandardTransactionVirtualSize = 100_000;
private Network Network => PalladiumNetworks.For(account.Profile.Kind); private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary> /// <summary>
@@ -40,6 +42,7 @@ public sealed class TransactionFactory(IWalletAccount account)
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param> /// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param> /// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
/// <param name="changeIndex">Index of the next change address (internal chain).</param> /// <param name="changeIndex">Index of the next change address (internal chain).</param>
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param> /// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
public BuiltTransaction Build( public BuiltTransaction Build(
IReadOnlyList<CachedUtxo> utxos, IReadOnlyList<CachedUtxo> utxos,
@@ -48,24 +51,125 @@ public sealed class TransactionFactory(IWalletAccount account)
long amountSats, long amountSats,
decimal feeRateSatPerVByte, decimal feeRateSatPerVByte,
int changeIndex, int changeIndex,
int tipHeight,
bool sendAll = false) bool sendAll = false)
{ {
// Only confirmed UTXOs are spent: mempool funds appear in the "pending" var profile = account.Profile;
// balance but are not spendable until confirmed. var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
if (spendable.Count == 0) if (spendable.Count == 0)
{ {
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats); var reasons = new System.Text.StringBuilder();
throw new WalletSpendException(pending > 0
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)." var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList();
if (mempool.Count > 0)
reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). ");
var immature = utxos.Where(u =>
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (immature.Count > 0)
{
var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
var threshold = profile.CoinbaseMaturity + 1;
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). ");
}
var underConf = utxos.Where(u =>
!u.Frozen && u.Height > 0 && !u.IsCoinbase &&
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (underConf.Count > 0)
{
var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). ");
}
var unverified = utxos.Where(u =>
!u.Frozen && u.Height > 0 && !u.Verified &&
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
if (unverified.Count > 0)
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
throw new WalletSpendException(reasons.Length > 0
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
: "No spendable UTXOs selected."); : "No spendable UTXOs selected.");
} }
var coins = spendable.Select(u => new Coin( var ordered = spendable
.OrderByDescending(u => u.ValueSats)
.ThenBy(u => u.Height)
.ThenBy(u => u.Txid, StringComparer.Ordinal)
.ThenBy(u => u.Vout)
.ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
if (sendAll)
{
try
{
return BuildWithSelectedUtxos(
ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count);
}
catch (TransactionTooLargeException ex)
{
throw new WalletSpendException(ex.Message);
}
}
NotEnoughFundsException? lastInsufficientFunds = null;
TransactionTooLargeException? tooLarge = null;
BuiltTransaction? best = null;
var low = 1;
var high = ordered.Count;
while (low <= high)
{
var count = low + ((high - low) / 2);
try
{
best = BuildWithSelectedUtxos(
ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex,
sendAll: false, totalSpendableCount: ordered.Count);
high = count - 1;
}
catch (NotEnoughFundsException ex)
{
lastInsufficientFunds = ex;
low = count + 1;
}
catch (TransactionTooLargeException ex)
{
tooLarge = ex;
high = count - 1;
}
}
if (best is not null)
return best;
if (tooLarge is not null)
throw new WalletSpendException(tooLarge.Message);
throw new WalletSpendException(lastInsufficientFunds is null
? "Insufficient funds."
: $"Insufficient funds: {lastInsufficientFunds.Message}");
}
private BuiltTransaction BuildWithSelectedUtxos(
IReadOnlyList<CachedUtxo> selectedUtxos,
IReadOnlyDictionary<string, Transaction> transactions,
BitcoinAddress destination,
long amountSats,
FeeRate feeRate,
int changeIndex,
bool sendAll,
int totalSpendableCount)
{
var coins = selectedUtxos.Select(u => new Coin(
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout), new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
transactions[u.Txid].Outputs[u.Vout])).ToList(); transactions[u.Txid].Outputs[u.Vout])).ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
var builder = Network.CreateTransactionBuilder(); var builder = Network.CreateTransactionBuilder();
builder.SetVersion(2); builder.SetVersion(2);
// RBF sequence to allow fee bumping (§6.6). // RBF sequence to allow fee bumping (§6.6).
@@ -81,7 +185,7 @@ public sealed class TransactionFactory(IWalletAccount account)
if (!account.IsWatchOnly) if (!account.IsWatchOnly)
{ {
builder.AddKeys(spendable builder.AddKeys(selectedUtxos
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex)) .Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
.OfType<Key>() .OfType<Key>()
.ToArray()); .ToArray());
@@ -92,11 +196,27 @@ public sealed class TransactionFactory(IWalletAccount account)
{ {
tx = builder.BuildTransaction(sign: !account.IsWatchOnly); tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
} }
catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase))
{
// NBitcoin's coin selector refuses to assemble a combination over the standard size
// cap itself and reports it through NotEnoughFundsException rather than ever handing
// back an oversized transaction — the GetVirtualSize() check below is unreachable for
// this case and exists only as a defense-in-depth net for other NBitcoin versions.
throw new TransactionTooLargeException(
BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll));
}
catch (NotEnoughFundsException) when (!sendAll)
{
throw;
}
catch (NotEnoughFundsException ex) catch (NotEnoughFundsException ex)
{ {
throw new WalletSpendException($"Insufficient funds: {ex.Message}"); throw new WalletSpendException($"Insufficient funds: {ex.Message}");
} }
if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize)
throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize()));
if (!account.IsWatchOnly) if (!account.IsWatchOnly)
{ {
if (!builder.Verify(tx, out TransactionPolicyError[] errors)) if (!builder.Verify(tx, out TransactionPolicyError[] errors))
@@ -114,6 +234,21 @@ public sealed class TransactionFactory(IWalletAccount account)
}; };
} }
private static string BuildTooLargeMessage(
int selectedInputCount,
int totalSpendableCount,
bool sendAll,
int? actualVirtualSize = null)
{
var prefix = sendAll
? "Send-all cannot fit in one standard transaction"
: "Transaction cannot fit in one standard transaction";
var size = actualVirtualSize is { } vsize ? $"{vsize} vB exceeds" : "Estimated size exceeds";
return $"{prefix}: {size} the {MaxStandardTransactionVirtualSize} vB standard relay limit " +
$"with {selectedInputCount}/{totalSpendableCount} spendable input(s). Send a smaller amount or consolidate in multiple smaller transactions.";
}
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins) private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
{ {
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet(); var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
@@ -121,6 +256,8 @@ public sealed class TransactionFactory(IWalletAccount account)
.Sum(c => (Money)c.Amount); .Sum(c => (Money)c.Amount);
return inputSum - tx.Outputs.Sum(o => o.Value); return inputSum - tx.Outputs.Sum(o => o.Value);
} }
private sealed class TransactionTooLargeException(string message) : Exception(message);
} }
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary> /// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
+55 -4
View File
@@ -5,12 +5,18 @@ using PalladiumWallet.Core.Spv;
namespace PalladiumWallet.Core.Wallet; namespace PalladiumWallet.Core.Wallet;
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary> /// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
/// <param name="CoinbaseTag">
/// Printable ASCII runs (e.g. pool tags like "/slush/") extracted from the coinbase
/// scriptSig; null for non-coinbase inputs or if no printable text was found.
/// </param>
public sealed record TxInputInfo( public sealed record TxInputInfo(
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase); string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase,
string? CoinbaseTag = null);
/// <summary>An output of a transaction.</summary> /// <summary>An output of a transaction.</summary>
/// <param name="OpReturnText">Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise.</param>
public sealed record TxOutputInfo( public sealed record TxOutputInfo(
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine); uint Index, long AmountSats, string? Address, string ScriptType, string? OpReturnText, bool IsMine);
/// <summary> /// <summary>
/// Complete transaction data assembled by querying the server: the raw transaction /// Complete transaction data assembled by querying the server: the raw transaction
@@ -105,8 +111,9 @@ public static class TransactionInspector
{ {
var o = tx.Outputs[i]; var o = tx.Outputs[i];
var addr = AddrOf(o.ScriptPubKey); var addr = AddrOf(o.ScriptPubKey);
var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null;
outputs.Add(new TxOutputInfo( outputs.Add(new TxOutputInfo(
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), (uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), opReturn,
addr is not null && ownedAddresses.Contains(addr))); addr is not null && ownedAddresses.Contains(addr)));
} }
@@ -134,7 +141,7 @@ public static class TransactionInspector
{ {
if (tx.IsCoinBase) if (tx.IsCoinBase)
{ {
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true)); inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true, CoinbaseTagOf(inp.ScriptSig.ToBytes())));
continue; continue;
} }
@@ -189,4 +196,48 @@ public static class TransactionInspector
} }
catch { return "—"; } catch { return "—"; }
} }
/// <summary>Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text.</summary>
private static string? OpReturnTextOf(Script script)
{
if (!script.IsUnspendable) return null;
try
{
var data = script.ToOps().Skip(1)
.Where(op => op.PushData is { Length: > 0 })
.SelectMany(op => op.PushData)
.ToArray();
return data.Length == 0 ? null : DecodeUtf8OrHex(data);
}
catch { return null; }
}
private static string DecodeUtf8OrHex(byte[] data)
{
var text = System.Text.Encoding.UTF8.GetString(data);
var looksLikeText = !text.Contains('') && text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t');
return looksLikeText ? text : "0x" + Convert.ToHexString(data);
}
/// <summary>
/// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool
/// tags like "/slush/" embedded among the binary BIP34 height and extranonce.
/// </summary>
private static string? CoinbaseTagOf(byte[] scriptSig)
{
var runs = new List<string>();
var run = new System.Text.StringBuilder();
void Flush()
{
if (run.Length >= 4) runs.Add(run.ToString());
run.Clear();
}
foreach (var b in scriptSig)
{
if (b is >= 0x20 and <= 0x7E) run.Append((char)b);
else Flush();
}
Flush();
return runs.Count == 0 ? null : string.Join(" ", runs);
}
} }
+30
View File
@@ -0,0 +1,30 @@
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Confirmation-threshold rules shared between coin selection (<see cref="TransactionFactory"/>)
/// and balance reporting: a coinbase output needs COINBASE_MATURITY + 1 confirmations
/// (consensus rule nSpendHeight - nHeight >= 120, plus one block of safety margin, matching
/// the Qt wallet); a regular output needs <see cref="ChainProfile.MinConfirmations"/> (wallet
/// policy, no consensus rule).
/// </summary>
public static class UtxoSpendability
{
public static int RequiredConfirmations(this CachedUtxo utxo, ChainProfile profile) =>
utxo.IsCoinbase ? profile.CoinbaseMaturity + 1 : profile.MinConfirmations;
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
/// <summary>
/// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle
/// proof has actually been checked. Without the <see cref="CachedUtxo.Verified"/> gate a
/// malicious server could report a fake confirmed UTXO and have it spent before background
/// verification ever caught the forgery.
/// </summary>
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
!utxo.Frozen && utxo.Height > 0 && utxo.Verified
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
}
+52 -1
View File
@@ -51,7 +51,15 @@ public static class WalletLoader
return new ImportedKeyAccount(entries, kind, profile); return new ImportedKeyAccount(entries, kind, profile);
} }
// 4. Watch-only from xpub // 4. Watch-only imported addresses (no keys, no HD derivation)
if (doc.WatchAddresses is { Count: > 0 } watchAddresses)
{
var entries = watchAddresses.Select(a =>
(BitcoinAddress.Create(a.Trim(), network), (Key?)null)).ToList();
return new ImportedKeyAccount(entries, kind, profile);
}
// 5. Watch-only from xpub
if (doc.AccountXpub is null) if (doc.AccountXpub is null)
throw new InvalidDataException("Wallet file has no xpub and no seed."); throw new InvalidDataException("Wallet file has no xpub and no seed.");
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _)) if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
@@ -170,4 +178,47 @@ public static class WalletLoader
}; };
return (doc, account); return (doc, account);
} }
/// <summary>
/// Creates the document from one or more plain addresses, with no private key at all
/// (pure watch-only import — the account can never sign, unlike xpub- or WIF-based accounts
/// which can be upgraded later by supplying the matching key). ScriptKind is informational
/// only here (no derivation happens from a fixed address list) and is auto-detected from the
/// first address unless <paramref name="kindOverride"/> is given.
/// </summary>
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses(
IReadOnlyList<string> addresses, ChainProfile profile, ScriptKind? kindOverride = null)
{
if (addresses.Count == 0)
throw new InvalidDataException("At least one address is required.");
var network = PalladiumNetworks.For(profile.Kind);
var entries = new List<(BitcoinAddress, Key?)>();
var addressStrings = new List<string>();
foreach (var raw in addresses)
{
BitcoinAddress addr;
try
{
addr = BitcoinAddress.Create(raw.Trim(), network);
}
catch (Exception ex)
{
throw new InvalidDataException($"Invalid address: {ex.Message}");
}
entries.Add((addr, null));
addressStrings.Add(raw.Trim());
}
var kind = kindOverride ?? DerivationPaths.KindFor(entries[0].Item1);
var account = new ImportedKeyAccount(entries, kind, profile);
var doc = new WalletDocument
{
Network = profile.NetName,
ScriptKind = kind.ToString(),
WatchAddresses = addressStrings,
};
return (doc, account);
}
} }
@@ -0,0 +1 @@
plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
@@ -0,0 +1 @@
hello world
@@ -0,0 +1 @@
PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
@@ -0,0 +1 @@
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
@@ -0,0 +1 @@
ábÊco ábaco0ábacoábÊ
@@ -0,0 +1 @@
ábaco ábaco ábaco
@@ -0,0 +1 @@
0,001
@@ -0,0 +1 @@
79228162514264337593543950335
@@ -0,0 +1 @@
1.50000000
@@ -0,0 +1 @@
{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}
@@ -0,0 +1 @@
hello
@@ -0,0 +1 @@
{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}
@@ -0,0 +1 @@
0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]
@@ -0,0 +1 @@
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","‡50002"]]]
@@ -0,0 +1 @@
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]
@@ -0,0 +1 @@
not-a-key
@@ -0,0 +1 @@
xpub661MyMwAqRbcF
@@ -0,0 +1 @@
zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs
@@ -0,0 +1 @@
{"Version":99}
@@ -0,0 +1 @@
{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}
@@ -0,0 +1 @@
hello
+141
View File
@@ -0,0 +1,141 @@
using System.Text;
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Fuzz;
/// <summary>
/// Fuzz targets over the parsers that consume untrusted input (server responses,
/// wallet files, user-pasted keys/addresses/amounts). Each target enforces the
/// parser's error contract: the exception types documented as its failure mode
/// are swallowed, anything else escapes and is reported as a crash by the fuzzer.
/// </summary>
public static class FuzzTargets
{
public static readonly IReadOnlyDictionary<string, Action<byte[]>> All =
new Dictionary<string, Action<byte[]>>
{
["header"] = Header,
["merkle"] = Merkle,
["slip132"] = Slip132Keys,
["bip39"] = Bip39Mnemonic,
["address"] = Address,
["coinamount"] = CoinAmountParse,
["walletdoc"] = WalletDoc,
["encfile"] = EncFile,
["peers"] = Peers,
};
/// <summary>Runs one input through a named target (used by replay and the regression test).</summary>
public static void Run(string target, byte[] data) => All[target](data);
private static string Utf8(byte[] data) => Encoding.UTF8.GetString(data);
// 80-byte block headers arrive from the server both as raw bytes and as hex.
// Contract: exactly 80 bytes never throws; any other length is ArgumentException;
// the hex overload may also reject non-hex text with FormatException.
private static void Header(byte[] data)
{
try { BlockHeaderInfo.Parse(data); }
catch (ArgumentException) when (data.Length != BlockHeaderInfo.Size) { }
try { BlockHeaderInfo.Parse(Utf8(data)); }
catch (FormatException) { }
catch (ArgumentException) { }
}
// Merkle proof verification runs on server-supplied txid/pos/branch.
// Contract: never throws, for any position (including negative) and branch.
private static void Merkle(byte[] data)
{
if (data.Length < 68)
return;
var txid = new uint256(data.AsSpan(0, 32));
var position = BitConverter.ToInt32(data, 32);
var root = new uint256(data.AsSpan(36, 32));
var branch = new List<uint256>();
for (var offset = 68; offset + 32 <= data.Length; offset += 32)
branch.Add(new uint256(data.AsSpan(offset, 32)));
MerkleProof.Verify(txid, position, branch, root);
}
// SLIP-132 extended keys are pasted by the user on import.
// Contract: the Try* pair never throws, on any profile.
private static void Slip132Keys(byte[] data)
{
var text = Utf8(data);
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
{
Slip132.TryDecodePublic(text, profile, out _, out _);
Slip132.TryDecodePrivate(text, profile, out _, out _);
}
}
// Mnemonics are pasted by the user on restore. Contract: TryParse never throws,
// with auto-detected and explicit wordlist language alike.
private static void Bip39Mnemonic(byte[] data)
{
var text = Utf8(data);
Bip39.TryParse(text, out _);
if (data.Length > 0)
Bip39.TryParse(text, out _, (MnemonicLanguage)(data[0] % 8));
}
// Recipient addresses are pasted by the user (or scanned from a QR).
// Contract: NBitcoin rejects anything invalid with FormatException.
private static void Address(byte[] data)
{
try { BitcoinAddress.Create(Utf8(data), PalladiumNetworks.Mainnet); }
catch (FormatException) { }
}
// Amounts are typed by the user in the currently selected unit.
// Contract: TryParse* never throws for any of the official units.
private static void CoinAmountParse(byte[] data)
{
var text = Utf8(data);
foreach (var unit in CoinAmount.Units)
CoinAmount.TryParseIn(text, unit, out _);
CoinAmount.TryParseCoins(text, out _);
}
// The plaintext wallet document is read from disk at every open.
// Contract: malformed JSON or schema is JsonException/InvalidDataException.
private static void WalletDoc(byte[] data)
{
try { WalletDocument.FromJson(Utf8(data)); }
catch (JsonException) { }
catch (InvalidDataException) { }
}
// The encrypted container wraps the document on disk; a tampered file must
// fail with the two typed exceptions, never a raw parser error, and
// IsEncrypted (the format sniffer) must never throw at all.
private static void EncFile(byte[] data)
{
var text = Utf8(data);
EncryptedFile.IsEncrypted(text);
try { EncryptedFile.Decrypt(text, "fuzz-password"); }
catch (WrongPasswordException) { }
catch (InvalidDataException) { }
}
// server.peers.subscribe responses are attacker-controlled JSON.
// Contract: any well-formed JSON of any shape parses to a (possibly empty)
// peer list without throwing.
private static void Peers(byte[] data)
{
JsonDocument doc;
try { doc = JsonDocument.Parse(data); }
catch (JsonException) { return; }
using (doc)
ElectrumApi.ParsePeers(doc.RootElement);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Fuzzing wants stable, non-inlined instrumentation points. -->
<TieredCompilation>false</TieredCompilation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpFuzz" Version="2.3.0" />
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Seed corpus travels next to the binary so replay and the regression
test in PalladiumWallet.Tests can find it via AppContext.BaseDirectory. -->
<None Include="Corpus\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+215
View File
@@ -0,0 +1,215 @@
using System.Text;
using PalladiumWallet.Fuzz;
// Fuzzing entry point. One binary, one target per process (fuzzers want a
// single deterministic execution path). Modes:
//
// <target> --afl run under afl-fuzz (instrumented Core, see fuzz.sh)
// <target> --libfuzzer run under libfuzzer-dotnet
// <target> --random N [seed] built-in dumb fuzzer: N corpus mutations (no tooling needed)
// <target> <file|dir> ... replay saved inputs (used by the regression test / triage)
// --make-seeds <dir> regenerate the seed corpus
//
// A "crash" is any exception escaping the target: each target already swallows
// the exception types that are part of the parser's documented contract.
if (args.Length >= 2 && args[0] == "--make-seeds")
{
SeedCorpus.WriteAll(args[1]);
Console.WriteLine($"Seed corpus written to {args[1]}");
return 0;
}
if (args.Length < 2 || !FuzzTargets.All.TryGetValue(args[0], out var target))
{
Console.Error.WriteLine("Usage: PalladiumWallet.Fuzz <target> --afl | --libfuzzer | --random N [seed] | <file|dir>...");
Console.Error.WriteLine(" PalladiumWallet.Fuzz --make-seeds <dir>");
Console.Error.WriteLine($"Targets: {string.Join(' ', FuzzTargets.All.Keys)}");
return 2;
}
switch (args[1])
{
case "--afl":
SharpFuzz.Fuzzer.Run(stream =>
{
using var ms = new MemoryStream();
stream.CopyTo(ms);
target(ms.ToArray());
});
return 0;
case "--libfuzzer":
SharpFuzz.Fuzzer.LibFuzzer.Run(span => target(span.ToArray()));
return 0;
case "--random":
{
var iterations = args.Length > 2 ? int.Parse(args[2]) : 100_000;
var seed = args.Length > 3 ? ulong.Parse(args[3]) : 0xF022_5EEDUL;
return RandomFuzz.Run(args[0], target, iterations, seed);
}
default:
{
var failures = 0;
foreach (var path in args[1..])
foreach (var file in Directory.Exists(path)
? Directory.EnumerateFiles(path).OrderBy(f => f, StringComparer.Ordinal)
: (IEnumerable<string>)[path])
{
var data = File.ReadAllBytes(file);
try
{
target(data);
}
catch (Exception ex)
{
failures++;
Console.Error.WriteLine($"CRASH {args[0]} on {file}:");
Console.Error.WriteLine(ex);
}
}
return failures == 0 ? 0 : 1;
}
}
/// <summary>
/// Minimal corpus-mutation fuzzer for smoke runs and CI: not coverage-guided,
/// but catches shallow contract violations without afl++/libFuzzer installed.
/// Deterministic for a given seed; on crash it saves the input for replay.
/// </summary>
internal static class RandomFuzz
{
public static int Run(string name, Action<byte[]> target, int iterations, ulong seed)
{
var corpus = SeedCorpus.For(name);
var rng = seed;
ulong Next() { rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17; return rng; }
for (var i = 0; i < iterations; i++)
{
var data = (byte[])corpus[(int)(Next() % (ulong)corpus.Count)].Clone();
var mutations = 1 + (int)(Next() % 8);
for (var m = 0; m < mutations && data.Length > 0; m++)
switch (Next() % 4)
{
case 0: data[Next() % (ulong)data.Length] = (byte)Next(); break;
case 1: data[Next() % (ulong)data.Length] ^= (byte)(1 << (int)(Next() % 8)); break;
case 2: Array.Resize(ref data, (int)(Next() % 512) + 1); break;
case 3: data = [.. data, .. data[..(int)(Next() % (ulong)data.Length)]]; break;
}
try
{
target(data);
}
catch (Exception ex)
{
var crashFile = $"crash-{name}-{i}.bin";
File.WriteAllBytes(crashFile, data);
Console.Error.WriteLine(
$"CRASH {name} at iteration {i}: {ex.GetType().Name}: {ex.Message} (input saved to {crashFile})");
return 1;
}
}
Console.WriteLine($"{name}: {iterations} random mutations, no contract violations");
return 0;
}
}
/// <summary>
/// Seed inputs per target: small valid (or near-valid) examples that give the
/// mutation engines a meaningful starting shape. Regenerate with --make-seeds.
/// </summary>
internal static class SeedCorpus
{
private static readonly Dictionary<string, (string Name, byte[] Data)[]> Seeds = new()
{
["header"] =
[
("genesis", Convert.FromHexString(
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
("genesis-hex", Encoding.UTF8.GetBytes(
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
("short", [0x01, 0x00, 0x00, 0x00]),
],
["merkle"] =
[
("one-level", [.. new byte[32], .. BitConverter.GetBytes(1), .. new byte[32], .. Enumerable.Repeat((byte)0xAA, 32)]),
("no-branch", [.. Enumerable.Repeat((byte)0x11, 32), .. BitConverter.GetBytes(0), .. Enumerable.Repeat((byte)0x11, 32)]),
],
["slip132"] =
[
("zpub", Encoding.UTF8.GetBytes(
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs")),
("xpub-prefix", Encoding.UTF8.GetBytes("xpub661MyMwAqRbcF")),
("garbage", Encoding.UTF8.GetBytes("not-a-key")),
],
["bip39"] =
[
("english-12", Encoding.UTF8.GetBytes(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")),
("spanish-word", Encoding.UTF8.GetBytes("ábaco ábaco ábaco")),
("empty", []),
// Regression: NBitcoin AutoDetect → "Unknown" language → NotSupportedException
// escaped TryParse (fixed by catching it).
("regression-notsupported", Convert.FromHexString(
"c3a162ca636f20c3a16261636f30c3a16261636fc3a162ca")),
],
["address"] =
[
("bech32", Encoding.UTF8.GetBytes("plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay")),
("legacy-ish", Encoding.UTF8.GetBytes("PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q")),
("garbage", Encoding.UTF8.GetBytes("hello world")),
],
["coinamount"] =
[
("plain", Encoding.UTF8.GetBytes("1.50000000")),
("comma", Encoding.UTF8.GetBytes("0,001")),
("huge", Encoding.UTF8.GetBytes("79228162514264337593543950335")),
],
["walletdoc"] =
[
("minimal", Encoding.UTF8.GetBytes(
"""{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}""")),
("bad-version", Encoding.UTF8.GetBytes("""{"Version":99}""")),
("not-json", Encoding.UTF8.GetBytes("hello")),
],
["encfile"] =
[
// Well-formed container with a tiny iteration count so every replay and
// mutated descendant skips the full PBKDF2 cost (decryption then fails
// with the typed WrongPasswordException — exactly the contract under test).
("container", Encoding.UTF8.GetBytes(
"""{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}""")),
("wrong-format", Encoding.UTF8.GetBytes(
"""{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}""")),
("not-json", Encoding.UTF8.GetBytes("hello")),
],
["peers"] =
[
("typical", Encoding.UTF8.GetBytes(
"""[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]""")),
("odd-shapes", Encoding.UTF8.GetBytes("""[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]""")),
// Regression: raw invalid-UTF-8 byte inside a JSON string parses but cannot
// be transcoded by GetString() → InvalidOperationException (fixed in AsString).
("regression-bad-utf8", Convert.FromHexString(
"5b5b22312e322e332e34222c22706565722e6578616d706c65222c5b2276312e342e32222c22703130222c227435303030" +
"31222c22873530303032225d5d5d")),
],
};
public static IReadOnlyList<byte[]> For(string target) =>
Seeds[target].Select(s => s.Data).ToList();
public static void WriteAll(string root)
{
foreach (var (target, seeds) in Seeds)
{
var dir = Path.Combine(root, target);
Directory.CreateDirectory(dir);
foreach (var (name, data) in seeds)
File.WriteAllBytes(Path.Combine(dir, name + ".bin"), data);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
# Fuzzing
Fuzz targets over every parser that consumes **untrusted input**: server
responses (block headers, Merkle proofs, peer lists), wallet files (plaintext
document and encrypted container), and user-pasted text (mnemonics, SLIP-132
keys, addresses, amounts).
Each target encodes the parser's **error contract**: the exception types
documented as its failure mode are swallowed, anything else escaping is a
finding. Targets: `header` `merkle` `slip132` `bip39` `address` `coinamount`
`walletdoc` `encfile` `peers` (see `FuzzTargets.cs` for each contract).
## Three ways to run
**1. Corpus replay — automatic.** The seed corpus (including regression inputs
for every crash found so far) replays through all targets on every
`dotnet test` run via `FuzzCorpusTests`, so fixed findings cannot come back.
**2. Built-in random smoke — no tooling.** Not coverage-guided, but catches
shallow contract violations in seconds:
```bash
dotnet run -- bip39 --random 50000 # target, iterations [, seed]
```
**3. Coverage-guided campaign — afl++.** The real thing; run it before a
release or after touching any parser:
```bash
apt install afl++
dotnet tool install --global SharpFuzz.CommandLine
./fuzz.sh header # one target per campaign
./fuzz.sh peers -V 3600 # extra args go to afl-fuzz
```
`fuzz.sh` builds Release, instruments `PalladiumWallet.Core.dll` (and NBitcoin)
with SharpFuzz, and launches afl-fuzz with the target's seed corpus. Findings
land in `findings/<target>/crashes/`.
## Triage workflow
```bash
dotnet run -- <target> findings/<target>/crashes/<file> # replay: full stack trace
```
Fix the parser (typed exception or graceful rejection — see the hardening
commits for `Bip39.TryParse`, `ElectrumApi.ParsePeers`, `EncryptedFile.Decrypt`
as examples), then add the input to `SeedCorpus` in `Program.cs` as a
`regression-*` seed and regenerate with `dotnet run -- --make-seeds Corpus`.
## Findings so far
The first smoke run found two real bugs, both reachable from untrusted input:
- `Bip39.TryParse` crashed with `NotSupportedException` on restore text
resembling no wordlist (NBitcoin's `Wordlist.AutoDetect` throws for its
internal "Unknown" language).
- `ElectrumApi.ParsePeers` crashed with `InvalidOperationException` on a peer
list containing a JSON string with invalid UTF-8 bytes (parses fine,
fails at `GetString()` transcoding) — attacker-controlled server data.
Plus two hardening changes made so the `encfile`/`peers` contracts could be
strict at all: `EncryptedFile.Decrypt` maps every malformed-container failure
to `InvalidDataException` and bounds the PBKDF2 iteration count (a tampered
file could previously demand 2^31 iterations, hanging the wallet at open), and
`ParsePeers` tolerates any JSON shape.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Coverage-guided fuzzing with afl++ + SharpFuzz.
#
# One-time setup:
# apt install afl++ (or build from source)
# dotnet tool install --global SharpFuzz.CommandLine
#
# Usage:
# ./fuzz.sh <target> [afl-fuzz args...]
# ./fuzz.sh header
# ./fuzz.sh peers -V 3600 # time-limited campaign (seconds)
#
# Targets: header merkle slip132 bip39 address coinamount walletdoc encfile peers
#
# Findings land in findings/<target>/crashes/: replay one with
# dotnet run -- <target> findings/<target>/crashes/<file>
# then add it to the seed corpus in Program.cs as a regression input.
set -euo pipefail
cd "$(dirname "$0")"
TARGET="${1:?usage: ./fuzz.sh <target> [afl-fuzz args...]}"
shift || true
command -v afl-fuzz >/dev/null || { echo "afl-fuzz not found: apt install afl++"; exit 1; }
command -v sharpfuzz >/dev/null || { echo "sharpfuzz not found: dotnet tool install --global SharpFuzz.CommandLine"; exit 1; }
dotnet build -c Release
BIN="bin/Release/net10.0"
# Instrument the assemblies under test (idempotent: SharpFuzz skips already-
# instrumented DLLs). Core is the real target; NBitcoin gives the fuzzer
# visibility into the library our parsers delegate to.
sharpfuzz "$BIN/PalladiumWallet.Core.dll"
sharpfuzz "$BIN/NBitcoin.dll" || true
mkdir -p "findings/$TARGET"
afl-fuzz -i "Corpus/$TARGET" -o "findings/$TARGET" -t 5000 "$@" \
-- dotnet "$BIN/PalladiumWallet.Fuzz.dll" "$TARGET" --afl
@@ -75,4 +75,25 @@ public class PalladiumNetworksTests
Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest); Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet)); Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
} }
[Fact]
public void For_con_un_kind_fuori_enum_lancia_eccezione()
{
Assert.Throws<ArgumentOutOfRangeException>(() => PalladiumNetworks.For((NetKind)99));
}
[Fact]
public void Il_network_set_espone_le_tre_reti_sotto_il_codice_plm()
{
var set = PalladiumNetworkSet.Instance;
Assert.Equal("PLM", set.CryptoCode);
Assert.Same(PalladiumNetworks.Mainnet, set.Mainnet);
Assert.Same(PalladiumNetworks.Testnet, set.Testnet);
Assert.Same(PalladiumNetworks.Regtest, set.Regtest);
Assert.Same(PalladiumNetworks.Mainnet, set.GetNetwork(ChainName.Mainnet));
Assert.Same(PalladiumNetworks.Testnet, set.GetNetwork(ChainName.Testnet));
Assert.Same(PalladiumNetworks.Regtest, set.GetNetwork(ChainName.Regtest));
Assert.Throws<ArgumentOutOfRangeException>(() => set.GetNetwork(new ChainName("signet")));
}
} }
@@ -20,7 +20,7 @@ public class AddressDerivationTests
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference). // Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy, var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'")); ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0); var pubKey = account.GetPublicKey(isChange: false, 0)!;
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main); var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString()); Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
@@ -37,7 +37,7 @@ public class AddressDerivationTests
{ {
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit, var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'")); ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0); var pubKey = account.GetPublicKey(isChange: false, 0)!;
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main); var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString()); Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
@@ -90,4 +90,51 @@ public class Bip39Tests
Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass)); Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass)); Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
} }
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Testo_vuoto_o_solo_spazi_viene_rifiutato(string? text)
{
Assert.False(Bip39.TryParse(text!, out var mnemonic));
Assert.Null(mnemonic);
}
[Theory]
[InlineData(MnemonicLanguage.English)]
[InlineData(MnemonicLanguage.Spanish)]
[InlineData(MnemonicLanguage.French)]
[InlineData(MnemonicLanguage.Japanese)]
[InlineData(MnemonicLanguage.PortugueseBrazil)]
[InlineData(MnemonicLanguage.ChineseSimplified)]
[InlineData(MnemonicLanguage.ChineseTraditional)]
[InlineData(MnemonicLanguage.Czech)]
public void Ogni_lingua_supportata_genera_e_riparsa_la_propria_mnemonica(MnemonicLanguage language)
{
var mnemonic = Bip39.Generate(MnemonicLength.Twelve, language);
// Explicit-language parse must always succeed; it also covers the full
// language→wordlist map (USERGUIDE §2.1 promises these languages on restore).
Assert.True(Bip39.TryParse(mnemonic.ToString(), out var parsed, language));
Assert.Equal(mnemonic.ToString(), parsed!.ToString());
}
[Fact]
public void Una_lingua_fuori_enum_viene_rifiutata()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => Bip39.Generate(MnemonicLength.Twelve, (MnemonicLanguage)99));
}
[Fact]
public void Testo_che_non_somiglia_a_nessuna_wordlist_viene_rifiutato_senza_eccezioni()
{
// NBitcoin's Wordlist.AutoDetect throws NotSupportedException("Unknown")
// for text resembling no wordlist instead of failing gracefully (found by
// fuzzing): TryParse must swallow it and return false — this string reaches
// TryParse straight from the user's restore input.
Assert.False(Bip39.TryParse("ábÊco ábaco0ábaco ábÊ", out var mnemonic));
Assert.Null(mnemonic);
}
} }
@@ -1,4 +1,5 @@
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders;
using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Crypto;
@@ -74,6 +75,39 @@ public class Bip84Slip132Tests
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
} }
[Fact]
public void Una_xkey_base58_valida_ma_di_lunghezza_sbagliata_viene_rifiutata()
{
// Well-formed Base58Check, but the decoded payload is not 78 bytes.
var tooShort = Encoders.Base58Check.EncodeData([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
Assert.False(Slip132.TryDecodePublic(tooShort, ChainProfiles.Mainnet, out _, out _));
Assert.False(Slip132.TryDecodePrivate(tooShort, ChainProfiles.Mainnet, out _, out _));
}
[Fact]
public void Una_zpub_con_payload_corrotto_viene_rifiutata_senza_eccezioni()
{
// Correct zpub header, but the 33-byte pubkey has an invalid point prefix:
// the ExtPubKey constructor must fail and TryDecodePublic must return false.
var data = Encoders.Base58Check.DecodeData(Account().ToSlip132());
data[45] = 0xFF; // pubkey prefix (offset 4 header + 41) — not 0x02/0x03
var corrupted = Encoders.Base58Check.EncodeData(data);
Assert.False(Slip132.TryDecodePublic(corrupted, ChainProfiles.Mainnet, out _, out _));
}
[Fact]
public void Una_zprv_con_payload_corrotto_viene_rifiutata_senza_eccezioni()
{
// Correct zprv header, but the byte before the 32-byte key must be 0x00:
// ExtKey.CreateFromBytes must fail and TryDecodePrivate must return false.
var data = Encoders.Base58Check.DecodeData(Account().ToSlip132Private());
data[45] = 0xFF;
var corrupted = Encoders.Base58Check.EncodeData(data);
Assert.False(Slip132.TryDecodePrivate(corrupted, ChainProfiles.Mainnet, out _, out _));
}
[Theory] [Theory]
[InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c", [InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")] "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
@@ -83,7 +117,7 @@ public class Bip84Slip132Tests
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress) bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
{ {
var account = Account(); var account = Account();
var pubKey = account.GetPublicKey(isChange, index); var pubKey = account.GetPublicKey(isChange, index)!;
if (expectedPubKeyHex is not null) if (expectedPubKeyHex is not null)
Assert.Equal(expectedPubKeyHex, pubKey.ToHex()); Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
@@ -0,0 +1,36 @@
using PalladiumWallet.Fuzz;
namespace PalladiumWallet.Tests;
/// <summary>
/// Replays the fuzzing seed corpus (tests/PalladiumWallet.Fuzz/Corpus, copied
/// next to the test binary) through every fuzz target on each test run: the
/// corpus includes the regression inputs for crashes found by past campaigns,
/// so a fixed contract violation can never silently come back. The real
/// coverage-guided campaigns run separately via tests/PalladiumWallet.Fuzz/fuzz.sh.
/// </summary>
public class FuzzCorpusTests
{
public static TheoryData<string> Targets()
{
var data = new TheoryData<string>();
foreach (var name in FuzzTargets.All.Keys)
data.Add(name);
return data;
}
[Theory]
[MemberData(nameof(Targets))]
public void Il_corpus_del_target_rispetta_il_contratto_del_parser(string target)
{
var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", target);
Assert.True(Directory.Exists(dir), $"seed corpus missing for '{target}' at {dir}");
foreach (var file in Directory.EnumerateFiles(dir))
{
// Any escaping exception is a contract violation: the target itself
// swallows the exception types documented as the parser's failure mode.
FuzzTargets.Run(target, File.ReadAllBytes(file));
}
}
}
@@ -0,0 +1,160 @@
using System.Security.Cryptography.X509Certificates;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
/// certificate passes, changed certificate is rejected until an explicit reset.
/// </summary>
public class CertificatePinStoreTests
{
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
private static X509Certificate2 Cert(string cn) =>
FakeElectrumServer.CreateSelfSignedCertificate(cn);
private static void Cleanup(string path)
{
if (Directory.Exists(Path.GetDirectoryName(path)))
Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
}
[Fact]
public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
{
var path = TempPath();
try
{
using var cert = Cert("server");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
Assert.True(File.Exists(path));
Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
// A fresh instance reads the same file (persistence).
Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
}
finally { Cleanup(path); }
}
[Fact]
public void Un_certificato_cambiato_viene_rifiutato()
{
var path = TempPath();
try
{
using var original = Cert("originale");
using var changed = Cert("cambiato");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, original));
Assert.False(store.VerifyOrPin("host", 50002, changed));
// The rejection must not overwrite the pin.
Assert.True(store.VerifyOrPin("host", 50002, original));
}
finally { Cleanup(path); }
}
[Fact]
public void Host_e_porte_diverse_hanno_pin_indipendenti()
{
var path = TempPath();
try
{
using var cert1 = Cert("uno");
using var cert2 = Cert("due");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
}
finally { Cleanup(path); }
}
[Fact]
public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
{
var path = TempPath();
try
{
using var original = Cert("originale");
using var renewed = Cert("rinnovato");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, original));
Assert.False(store.VerifyOrPin("host", 50002, renewed));
Assert.True(store.Reset("host", 50002));
Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
}
finally { Cleanup(path); }
}
[Fact]
public void Il_reset_di_un_server_mai_visto_restituisce_false()
{
var path = TempPath();
try
{
Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
}
finally { Cleanup(path); }
}
[Fact]
public void ResetAll_cancella_il_file_e_tutti_i_pin()
{
var path = TempPath();
try
{
using var cert1 = Cert("uno");
using var cert2 = Cert("due");
var store = new CertificatePinStore(path);
store.VerifyOrPin("host-a", 50002, cert1);
store.VerifyOrPin("host-b", 50002, cert1);
store.ResetAll();
Assert.False(File.Exists(path));
Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
}
finally { Cleanup(path); }
}
[Fact]
public void Un_file_pin_corrotto_non_blocca_le_connessioni()
{
var path = TempPath();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "{ non-json ");
using var cert = Cert("server");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
}
finally { Cleanup(path); }
}
[Fact]
public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
{
using var cert = Cert("server");
var fingerprint = CertificatePinStore.Fingerprint(cert);
Assert.Equal(64, fingerprint.Length);
Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
Assert.Equal(
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
.ToLowerInvariant(),
fingerprint);
}
}
@@ -0,0 +1,204 @@
using System.Text.Json;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
/// (real loopback TCP socket: framing, pipelining, and failure paths are the
/// same code paths used in production).
/// </summary>
public class ElectrumClientTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task Connessione_e_richiesta_fanno_roundtrip()
{
await using var server = new FakeElectrumServer();
server.Handle("server.banner", _ => "benvenuto");
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
Assert.True(client.IsConnected);
Assert.Equal("benvenuto", await client.BannerAsync());
// ConnectAsync performs the protocol handshake up front.
Assert.Equal(1, server.CallCount("server.version"));
}
[Fact]
public async Task Un_errore_del_server_diventa_ElectrumServerException()
{
await using var server = new FakeElectrumServer();
server.Handle("blockchain.transaction.get",
_ => throw new FakeElectrumError(-32600, "tx non trovata"));
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var ex = await Assert.ThrowsAsync<ElectrumServerException>(
() => client.GetTransactionAsync("00"));
Assert.Contains("tx non trovata", ex.Message);
Assert.Contains("-32600", ex.Message);
}
[Fact]
public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
{
await using var server = new FakeElectrumServer();
server.Handle("echo", p => p[0].GetInt32());
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
// More requests than the in-flight cap (32): exercises both the write
// batching and the id → response correlation.
var tasks = Enumerable.Range(0, 200)
.Select(i => client.RequestAsync("echo", default, i))
.ToList();
var results = await Task.WhenAll(tasks);
for (var i = 0; i < results.Length; i++)
Assert.Equal(i, results[i].GetInt32());
}
[Fact]
public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
{
await using var server = new FakeElectrumServer();
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
await server.NotifyAsync("blockchain.headers.subscribe", new object[]
{
new { height = 4242, hex = "00" },
});
var (method, parameters) = await received.Task.WaitAsync(Timeout);
Assert.Equal("blockchain.headers.subscribe", method);
Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
}
[Fact]
public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
{
await using var server = new FakeElectrumServer();
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.Disconnected += _ => disconnected.TrySetResult();
var pending = client.BannerAsync();
server.DropClients();
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
await disconnected.Task.WaitAsync(Timeout);
}
[Fact]
public async Task Una_risposta_molto_grande_arriva_integra()
{
// A payload far beyond the PipeReader's segment size forces the
// multi-segment dispatch path (ArrayPool copy) instead of the fast span.
var big = new string('x', 256 * 1024);
await using var server = new FakeElectrumServer();
server.Handle("blockchain.transaction.get", _ => big);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var result = await client.GetTransactionAsync("00").WaitAsync(Timeout);
Assert.Equal(big, result);
}
[Fact]
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
{
await using var server = new FakeElectrumServer();
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
using var cts = new CancellationTokenSource();
var pending = client.BannerAsync(cts.Token);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending.WaitAsync(Timeout));
}
[Fact]
public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
{
await using var server = new FakeElectrumServer();
server.Handle("blockchain.scripthash.listunspent", _ => new object[]
{
new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
});
server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
server.Handle("blockchain.estimatefee", _ => 0.00012m);
server.Handle("blockchain.relayfee", _ => 0.00001m);
server.Handle("server.ping", _ => null);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
Assert.Equal(12_345L, unspent.ValueSats);
Assert.Equal(1, unspent.TxPos);
Assert.Equal(99, unspent.Height);
Assert.Equal("4", await client.BroadcastAsync("beef"));
Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
Assert.Equal(0.00001m, await client.RelayFeeAsync());
await client.PingAsync(); // must not throw
}
[Fact]
public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
{
var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
try
{
var pins = new CertificatePinStore(pinsPath);
int port;
// First contact: any certificate is pinned (trust on first use).
using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
{
await using var server = new FakeElectrumServer(cert1);
port = server.Port;
await using var client = await ElectrumClient.ConnectAsync(
server.Host, port, useSsl: true, pins);
Assert.True(client.IsConnected);
Assert.True(client.UseSsl);
}
// Same host:port, different certificate: the pin must block the connection.
using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
{
await using var server = await RebindAsync(cert2, port);
await Assert.ThrowsAsync<CertificatePinMismatchException>(() =>
ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
}
}
finally
{
File.Delete(pinsPath);
}
}
/// <summary>Rebinds a fresh server to the port just released by the previous one.</summary>
private static async Task<FakeElectrumServer> RebindAsync(
System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
{
for (var attempt = 0; ; attempt++)
{
try { return new FakeElectrumServer(cert, port); }
catch (System.Net.Sockets.SocketException) when (attempt < 20)
{
await Task.Delay(50);
}
}
}
}
@@ -0,0 +1,212 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Error a handler can throw to make the fake server reply with a JSON-RPC
/// error object (e.g. code -102 "server busy" to exercise the retry path).
/// </summary>
public sealed class FakeElectrumError(int code, string message) : Exception(message)
{
public int Code { get; } = code;
}
/// <summary>
/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
/// Handlers are registered per method and receive the request "params" array;
/// whatever they return is serialised as the "result". "server.version" is
/// pre-registered. Calls are counted per method (cache/retry assertions).
/// </summary>
public sealed class FakeElectrumServer : IAsyncDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly Task _acceptLoop;
private readonly X509Certificate2? _certificate;
private readonly ConcurrentDictionary<string, Func<JsonElement, object?>> _handlers = new();
private readonly ConcurrentDictionary<string, int> _callCounts = new();
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
public static readonly object NoResponse = new();
public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
{
_certificate = certificate;
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
_acceptLoop = Task.Run(AcceptLoopAsync);
}
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public string Host => "127.0.0.1";
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
public void Handle(string method, Func<JsonElement, object?> handler) =>
_handlers[method] = handler;
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
public void ResetCallCounts() => _callCounts.Clear();
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
public async Task NotifyAsync(string method, object parameters)
{
var line = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
method,
@params = parameters,
});
foreach (var (stream, writeLock, _) in _clients)
await WriteLineAsync(stream, writeLock, line);
}
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
public void DropClients()
{
foreach (var (_, _, tcp) in _clients)
tcp.Close();
}
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
// PFX roundtrip so the private key is usable by SslStream on every platform.
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
}
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
_ = Task.Run(() => ServeClientAsync(tcp));
}
}
catch (OperationCanceledException) { }
catch (SocketException) { }
}
private async Task ServeClientAsync(TcpClient tcp)
{
try
{
Stream stream = tcp.GetStream();
if (_certificate is not null)
{
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(_certificate);
stream = ssl;
}
var writeLock = new SemaphoreSlim(1, 1);
_clients.Add((stream, writeLock, tcp));
using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
while (!_cts.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(_cts.Token);
if (line is null) break;
if (line.Length == 0) continue;
await HandleRequestAsync(stream, writeLock, line);
}
}
catch (Exception)
{
// Client went away or TLS failed (e.g. rejected pin): normal in tests.
}
}
private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
var id = root.GetProperty("id").GetInt64();
var method = root.GetProperty("method").GetString()!;
var parameters = root.TryGetProperty("params", out var p) ? p : default;
_callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
byte[] payload;
if (!_handlers.TryGetValue(method, out var handler))
{
payload = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
id,
error = new { code = -32601, message = $"unknown method '{method}'" },
});
}
else
{
try
{
var result = handler(parameters);
if (ReferenceEquals(result, NoResponse))
return;
payload = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
id,
result,
});
}
catch (FakeElectrumError err)
{
payload = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
id,
error = new { code = err.Code, message = err.Message },
});
}
}
await WriteLineAsync(stream, writeLock, payload);
}
private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
{
await writeLock.WaitAsync();
try
{
await stream.WriteAsync(payload);
stream.WriteByte((byte)'\n');
await stream.FlushAsync();
}
catch (Exception)
{
// Connection already gone: irrelevant for the test in progress.
}
finally
{
writeLock.Release();
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_listener.Stop();
DropClients();
try { await _acceptLoop; } catch { }
_cts.Dispose();
}
}
@@ -27,6 +27,35 @@ public class PeerParsingTests
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]); Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]); Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
} }
[Theory]
[InlineData("""{"not":"an array"}""")]
[InlineData(""" "just a string" """)]
[InlineData("42")]
[InlineData("""[[1,2,3]]""")] // numeric ip/hostname
[InlineData("""[["h","n",42]]""")] // features not an array
[InlineData("""[["h","n",[42,{"x":1},null]]]""")] // non-string features
public void Una_risposta_peers_di_forma_inattesa_produce_una_lista_vuota(string json)
{
// The response is untrusted server data: any shape must degrade to an
// empty list, never throw (found by fuzzing).
Assert.Empty(ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement));
}
[Fact]
public void Una_stringa_json_con_utf8_invalido_viene_ignorata_senza_eccezioni()
{
// Raw 0x87 inside a JSON string: JsonDocument.Parse accepts the bytes but
// GetString() cannot transcode them to UTF-16 (found by fuzzing). The
// whole entry degrades to "no usable host/feature", not an exception.
var bytes = "[[\"1.2.3.4\",\"#\",[\"v1\",\"t#\"]]]"u8.ToArray();
var hostIdx = Array.IndexOf(bytes, (byte)'#');
bytes[hostIdx] = 0x87;
bytes[Array.LastIndexOf(bytes, (byte)'#')] = 0x87;
using var doc = JsonDocument.Parse(bytes);
Assert.Empty(ElectrumApi.ParsePeers(doc.RootElement));
}
} }
public class ServerRegistryTests public class ServerRegistryTests
@@ -79,6 +108,49 @@ public class ServerRegistryTests
} }
} }
[Fact]
public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
{
var path = TempPath();
try
{
await using var server = new FakeElectrumServer();
server.Handle("server.peers.subscribe", _ => new object[]
{
// Full announcement, port-less announcement (→ profile defaults),
// and a bootstrap duplicate that must not be re-added.
new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
});
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
var bootstrapCount = registry.All.Count;
var added = await registry.DiscoverAsync(client);
Assert.Equal(2, added);
Assert.Equal(bootstrapCount + 2, registry.All.Count);
var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
// Persisted for the next session (bootstrap servers excluded from the file).
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
var savedJson = File.ReadAllText(path);
Assert.DoesNotContain("173.212.224.67", savedJson);
// A second discovery of the same peers adds nothing.
Assert.Equal(0, await registry.DiscoverAsync(client));
}
finally
{
File.Delete(path);
}
}
[Fact] [Fact]
public void Un_file_server_corrotto_non_blocca_l_avvio() public void Un_file_server_corrotto_non_blocca_l_avvio()
{ {
@@ -0,0 +1,116 @@
using System.Net;
using System.Text;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Tests for the update check: tag parsing plus the full CheckAsync flow via
/// a stub HttpMessageHandler (the same seam the production overload wraps),
/// so no real GitHub call is ever made.
/// </summary>
public class UpdateCheckerTests
{
/// <summary>Handler stub: replies with a fixed status/body, or throws.</summary>
private sealed class StubHandler(HttpStatusCode status, string? body = null,
Exception? throws = null) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct) =>
throws is not null
? Task.FromException<HttpResponseMessage>(throws)
: Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(body ?? "", Encoding.UTF8, "application/json"),
});
}
private static Task<LatestRelease?> Check(string current, HttpStatusCode status,
string? body = null, Exception? throws = null) =>
UpdateChecker.CheckAsync(current, new StubHandler(status, body, throws));
[Fact]
public async Task Una_release_piu_nuova_viene_segnalata_con_tag_e_url()
{
var release = await Check("0.9.1", HttpStatusCode.OK,
"""{"tag_name":"v1.0.0","html_url":"https://example.test/rel/v1.0.0"}""");
Assert.NotNull(release);
Assert.Equal("v1.0.0", release!.Tag);
Assert.Equal("https://example.test/rel/v1.0.0", release.HtmlUrl);
}
[Fact]
public async Task Senza_html_url_viene_costruito_il_link_alla_pagina_della_release()
{
var release = await Check("0.9.1", HttpStatusCode.OK, """{"tag_name":"v1.0.0"}""");
Assert.NotNull(release);
Assert.Contains("/releases/tag/v1.0.0", release!.HtmlUrl);
}
[Theory]
[InlineData("""{"tag_name":"v0.9.1"}""")] // same version
[InlineData("""{"tag_name":"v0.9.0"}""")] // older
[InlineData("""{"tag_name":"main"}""")] // unparseable tag
[InlineData("""{"tag_name":""}""")] // empty tag
[InlineData("""{}""")] // missing tag
public async Task Nessun_aggiornamento_quando_il_tag_non_e_piu_nuovo(string body)
{
Assert.Null(await Check("0.9.1", HttpStatusCode.OK, body));
}
[Fact]
public async Task Una_risposta_http_di_errore_risolve_a_null()
{
Assert.Null(await Check("0.9.1", HttpStatusCode.NotFound));
Assert.Null(await Check("0.9.1", HttpStatusCode.InternalServerError));
}
[Fact]
public async Task Json_malformato_o_errore_di_rete_risolvono_a_null()
{
Assert.Null(await Check("0.9.1", HttpStatusCode.OK, "not json at all"));
Assert.Null(await Check("0.9.1", HttpStatusCode.OK,
"""{"tag_name":"v9.9.9"}""", throws: new HttpRequestException("offline")));
}
[Fact]
public async Task Una_versione_locale_non_parsabile_risolve_a_null()
{
Assert.Null(await Check("dev-build", HttpStatusCode.OK, """{"tag_name":"v9.9.9"}"""));
}
[Theory]
[InlineData("v1.2.3", "1.2.3")]
[InlineData("V0.9.1", "0.9.1")]
[InlineData("1.2.3", "1.2.3")]
[InlineData("0.9.0-beta", "0.9.0")]
[InlineData("v2.0.0-rc.1", "2.0.0")]
[InlineData(" 1.4 ", "1.4")]
public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
{
Assert.True(UpdateChecker.TryParse(tag, out var version));
Assert.Equal(Version.Parse(expected), version);
}
[Theory]
[InlineData("")]
[InlineData("main")]
[InlineData("v")]
[InlineData("1")]
[InlineData("non-una-versione")]
public void I_tag_non_versionati_vengono_rifiutati(string tag)
{
Assert.False(UpdateChecker.TryParse(tag, out _));
}
[Fact]
public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
{
// The same comparison CheckAsync applies between remote tag and running app.
Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
}
}
@@ -21,8 +21,9 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" /> <ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -182,6 +182,121 @@ public class PropertyTests
}); });
} }
// ── Scripthash ────────────────────────────────────────────────────────────
/// The scripthash must equal an independent SHA-256-and-reverse computation
/// for any script bytes (cross-check, not a re-run of the same code).
[Fact]
public void Scripthash_coincide_con_il_calcolo_indipendente_per_ogni_script()
{
Gen.Byte.Array[0, 64].Sample(bytes =>
{
var expected = System.Security.Cryptography.SHA256.HashData(bytes);
Array.Reverse(expected);
Assert.Equal(
Convert.ToHexString(expected).ToLowerInvariant(),
Core.Spv.Scripthash.FromScript(Script.FromBytesUnsafe(bytes)));
});
}
// ── Slip132 ───────────────────────────────────────────────────────────────
private static readonly Gen<Core.Chain.ScriptKind> GenScriptKind = Gen.OneOfConst(
Core.Chain.ScriptKind.Legacy,
Core.Chain.ScriptKind.WrappedSegwit,
Core.Chain.ScriptKind.NativeSegwit,
Core.Chain.ScriptKind.WrappedSegwitMultisig,
Core.Chain.ScriptKind.NativeSegwitMultisig);
private static readonly Gen<Core.Chain.ChainProfile> GenProfile = Gen.OneOfConst(
Core.Chain.ChainProfiles.Mainnet, Core.Chain.ChainProfiles.Testnet);
/// Encode → TryDecodePrivate must roundtrip key bytes and header family
/// for any key, script kind, and network.
[Fact]
public void Slip132_roundtrip_chiave_privata_per_ogni_kind_e_rete()
{
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
{
var key = ExtKey.CreateFromSeed(seed);
var encoded = Core.Crypto.Slip132.Encode(key, kind, profile);
Assert.True(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out var decoded, out var decodedKind));
Assert.Equal(key.ToBytes(), decoded!.ToBytes());
// Legacy and Taproot share the BIP32 header: compare header families, not enum values.
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
// A private key must never decode as public.
Assert.False(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out _, out _));
});
}
/// Same roundtrip for the public (watch-only import) side.
[Fact]
public void Slip132_roundtrip_chiave_pubblica_per_ogni_kind_e_rete()
{
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
{
var xpub = ExtKey.CreateFromSeed(seed).Neuter();
var encoded = Core.Crypto.Slip132.Encode(xpub, kind, profile);
Assert.True(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out var decoded, out var decodedKind));
Assert.Equal(xpub.ToBytes(), decoded!.ToBytes());
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
Assert.False(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out _, out _));
});
}
/// TryDecode must never throw on arbitrary input strings.
[Fact]
public void Slip132_TryDecode_non_lancia_mai_su_input_arbitrario()
{
Gen.String.Sample(text =>
{
try
{
Core.Crypto.Slip132.TryDecodePublic(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
Core.Crypto.Slip132.TryDecodePrivate(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
}
catch (Exception ex)
{
Assert.Fail($"TryDecode threw {ex.GetType().Name} for '{text}'");
}
});
}
// ── WalletDocument ────────────────────────────────────────────────────────
/// ToJson → FromJson must preserve labels and contacts for arbitrary strings
/// (unicode, quotes, control characters…).
[Fact]
public void WalletDocument_roundtrip_json_con_etichette_e_contatti_arbitrari()
{
Gen.Select(Gen.Select(Gen.String, Gen.String).Array[0, 8], Gen.String).Sample((pairs, contactName) =>
{
// Lone UTF-16 surrogates are not representable in JSON (the writer
// replaces them): an exact roundtrip is impossible by design, skip.
if (pairs.Any(p => p.Item1.Any(char.IsSurrogate) || p.Item2.Any(char.IsSurrogate))
|| contactName.Any(char.IsSurrogate))
return;
var doc = new WalletDocument
{
Network = "regtest",
ScriptKind = "NativeSegwit",
AccountPath = "84'/1'/0'",
AccountXpub = "vpub-test",
Contacts = { new StoredContact { Name = contactName, Address = "rplm1qtest" } },
};
foreach (var (k, v) in pairs)
doc.Labels[k] = v;
var restored = WalletDocument.FromJson(doc.ToJson());
Assert.Equal(doc.Labels, restored.Labels);
Assert.Equal(contactName, Assert.Single(restored.Contacts).Name);
});
}
// helper: builds the branch for the given position // helper: builds the branch for the given position
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position) private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
{ {
@@ -239,4 +239,29 @@ public class BlockHeaderInfoTests
{ {
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ")); Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ"));
} }
[Fact]
public void Senza_skip_pow_un_hash_sotto_il_target_passa()
{
// Bitcoin's genesis satisfies its own 0x1d00ffff target by construction.
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
var powProfile = ChainProfiles.Mainnet with { SkipPowValidation = false };
Assert.True(header.IsValidChild(uint256.Zero, powProfile));
}
[Fact]
public void Senza_skip_pow_un_hash_sopra_il_target_viene_rifiutato()
{
// Rewrite the genesis bits to an almost-impossible target (0x03000001 →
// tiny) without re-mining: the untouched hash can no longer satisfy it.
var raw = Convert.FromHexString(GenesisHeaderHex);
raw[72] = 0x01; raw[73] = 0x00; raw[74] = 0x00; raw[75] = 0x03;
var header = BlockHeaderInfo.Parse(raw);
var powProfile = ChainProfiles.Mainnet with { SkipPowValidation = false };
Assert.False(header.IsValidChild(uint256.Zero, powProfile));
// Same header with skip enabled: the target is ignored.
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
}
} }
@@ -0,0 +1,705 @@
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.Spv;
/// <summary>
/// End-to-end tests of the SPV synchroniser against the in-process fake server:
/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
/// path that must reject a lying server), busy retry, and the disk cache.
/// Every block in these scenarios contains a single transaction, so the Merkle
/// root is the txid itself and the branch is empty (the tree math has its own
/// dedicated tests in <see cref="MerkleProofTests"/>).
/// </summary>
public class WalletSynchronizerTests
{
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
private static readonly Network Net = PalladiumNetworks.Regtest;
private static HdAccount Account(ChainProfile? profile = null)
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, profile ?? Profile);
}
/// <summary>
/// Server-side chain state: transactions, per-scripthash histories and
/// single-transaction block headers, wired onto a FakeElectrumServer.
/// </summary>
private sealed class Scenario
{
public int TipHeight = 200;
public readonly Dictionary<string, Transaction> Txs = [];
public readonly Dictionary<string, List<(string Txid, int Height)>> History = [];
public readonly Dictionary<int, string> Headers = [];
/// <summary>Creates a tx paying <paramref name="sats"/> to <paramref name="to"/> and registers it.</summary>
public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
{
var tx = Net.CreateTransaction();
tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
tx.Outputs.Add(Money.Satoshis(sats), to);
if (changeTo is not null)
tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
Register(tx, height, to, changeTo);
return tx;
}
public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
{
var txid = tx.GetHash().ToString();
Txs[txid] = tx;
foreach (var addr in touchedAddresses)
{
if (addr is null) continue;
var sh = Scripthash.FromAddress(addr);
if (!History.TryGetValue(sh, out var list))
History[sh] = list = [];
if (!list.Contains((txid, height)))
list.Add((txid, height));
}
if (height > 0 && !Headers.ContainsKey(height))
Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
}
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null,
uint256? prevHash = null)
{
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
header.HashPrevBlock = prevHash ?? uint256.Zero;
header.HashMerkleRoot = merkleRoot ?? txid;
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
header.Bits = new Target(0x1d00ffffu);
header.Nonce = (uint)height;
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
}
public void WireTo(FakeElectrumServer server)
{
server.Handle("blockchain.headers.subscribe",
_ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
server.Handle("blockchain.scripthash.subscribe", _ => null);
server.Handle("blockchain.scripthash.get_history", p =>
(History.GetValueOrDefault(p[0].GetString()!) ?? [])
.Select(h => new { tx_hash = h.Txid, height = h.Height }));
server.Handle("blockchain.transaction.get", p =>
Txs.TryGetValue(p[0].GetString()!, out var tx)
? tx.ToHex()
: throw new FakeElectrumError(-32600, "no such transaction"));
server.Handle("blockchain.transaction.get_merkle", p =>
new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() });
server.Handle("blockchain.block.header", p =>
Headers.TryGetValue(p[0].GetInt32(), out var hex)
? hex
: throw new FakeElectrumError(-32600, "no such block"));
server.Handle("blockchain.block.headers", p =>
{
var start = p[0].GetInt32();
var count = p[1].GetInt32();
var hexes = new List<string>();
for (var h = start; hexes.Count < count && Headers.TryGetValue(h, out var hex); h++)
hexes.Add(hex);
return new { count = hexes.Count, hex = string.Concat(hexes) };
});
}
}
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
{
var server = new FakeElectrumServer();
scenario.WireTo(server);
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
return (server, client);
}
// ---- scansione ----
[Fact]
public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
{
var scenario = new Scenario();
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
Assert.Equal(200, result.TipHeight);
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(0, result.UnconfirmedSats);
Assert.Equal(0, result.NextReceiveIndex);
Assert.Equal(0, result.NextChangeIndex);
// Default gap limit 20 on both chains.
Assert.Equal(40, result.Addresses.Count);
Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
Assert.Empty(result.Utxos);
Assert.Empty(result.History);
}
[Fact]
public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
// Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
Assert.Equal(4, result.NextReceiveIndex);
Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
Assert.Equal(50_000, result.ConfirmedSats);
}
// ---- ricostruzione UTXO e storico ----
[Fact]
public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
Assert.Equal(1, result.NextReceiveIndex);
var utxo = Assert.Single(result.Utxos);
Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
Assert.Equal(100, utxo.Height);
Assert.False(utxo.IsChange);
var entry = Assert.Single(result.History);
Assert.Equal(1_000_000, entry.DeltaSats);
Assert.True(entry.Verified); // Merkle proof checked against the header
var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
Assert.Equal(1_000_000, row.BalanceSats);
Assert.Equal(1, row.TxCount);
}
[Fact]
public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
// Spend: 600k to an external address, 390k change to change/0, 10k fee.
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var spend = Net.CreateTransaction();
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
spend.Outputs.Add(Money.Satoshis(600_000), external);
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
// The funding UTXO is spent: only the change remains.
var utxo = Assert.Single(result.Utxos);
Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
Assert.True(utxo.IsChange);
Assert.Equal(390_000, result.ConfirmedSats);
Assert.Equal(1, result.NextChangeIndex);
// History newest-first with the net deltas.
Assert.Equal(2, result.History.Count);
Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change 1M spent
Assert.Equal(1_000_000, result.History[1].DeltaSats);
}
[Fact]
public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(250_000, result.UnconfirmedSats);
var entry = Assert.Single(result.History);
// Verified means "its Merkle proof was checked" — a mempool tx has no proof to
// check yet, so it's vacuously true; "not confirmed" is signalled by Height <= 0,
// not by Verified (no merkle/header RPCs happen, asserted below).
Assert.True(entry.Verified);
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
{
var account = Account();
var scenario = new Scenario { TipHeight = 200 };
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(5_000_000_000, result.ConfirmedSats);
Assert.Equal(5_000_000_000, result.ImmatureSats);
Assert.True(Assert.Single(result.Utxos).IsCoinbase);
}
// ---- sicurezza SPV ----
[Fact]
public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
// The server lies: the block 100 header commits to a different Merkle root.
scenario.Headers[100] = Scenario.SingleTxHeaderHex(
funding.GetHash(), 100, merkleRoot: uint256.One);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(account, client).SyncOnceAsync());
Assert.Contains(funding.GetHash().ToString(), ex.Message);
}
// ---- checkpoint anchoring ----
/// <summary>
/// Builds a chain of single-tx headers from <paramref name="fromHeight"/> to
/// <paramref name="toHeight"/> (inclusive), each linked to the previous via
/// HashPrevBlock. The last height carries <paramref name="txid"/> as its Merkle
/// root; <paramref name="roots"/> overrides the root at specific heights.
/// </summary>
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid,
Dictionary<int, uint256>? roots = null)
{
var headers = new Dictionary<int, string>();
uint256? prevHash = null;
for (var h = fromHeight; h <= toHeight; h++)
{
var root = roots?.GetValueOrDefault(h) ?? (h == toHeight ? txid : uint256.One);
var hex = Scenario.SingleTxHeaderHex(root, h, merkleRoot: root, prevHash: prevHash);
headers[h] = hex;
prevHash = BlockHeaderInfo.Parse(hex).Hash;
}
return headers;
}
[Fact]
public async Task Una_tx_anchorata_a_un_checkpoint_valido_verifica_la_catena_di_header()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
// covering 100..105 back to the checkpoint already includes the tx's own header —
// no separate single-header call needed.
Assert.Equal(0, server.CallCount("blockchain.block.header"));
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
}
[Fact]
public async Task Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
// Second tx (at height 103) known upfront so the chain can commit to it,
// but announced by the server only after the first sync.
var tx2 = Net.CreateTransaction();
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
var chain = ChainedHeaders(100, 105, funding.GetHash(),
roots: new() { [103] = tx2.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var sync = new WalletSynchronizer(checkpointAccount, client);
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
// 103 <= the memoized 105: anchoring must early-return without re-walking,
// so the header/range call counts stay at those of the first walk (103 is cached).
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
var result = await sync.SyncOnceAsync();
Assert.Equal(1_500_000, result.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.block.header"));
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
}
[Fact]
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
// Wrong hash at the checkpoint height: the real chain does not match it.
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
Assert.Contains("checkpoint height 100", ex.Message);
}
[Fact]
public async Task Una_catena_di_header_spezzata_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
// Tamper with block 103: it no longer points at block 102's real hash.
chain[103] = Scenario.SingleTxHeaderHex(uint256.One, 103, prevHash: uint256.One);
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
Assert.Contains("Broken header chain at height 103", ex.Message);
}
[Fact]
public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op()
{
// Regtest profile (Profile) has no checkpoints at all: same behaviour as before
// this feature existed — only the Merkle proof is checked.
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(1, server.CallCount("blockchain.block.header"));
}
// ---- verifica progressiva (§7.4) ----
[Fact]
public async Task PartialResult_arriva_prima_della_proof_poi_il_risultato_finale_e_completamente_verificato()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
// Gate the Merkle-proof response so the download phase can complete (and fire
// PartialResult) well before verification does.
using var gate = new ManualResetEventSlim(false);
server.Handle("blockchain.transaction.get_merkle", p =>
{
gate.Wait();
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
});
var sync = new WalletSynchronizer(account, client);
SyncResult? partial = null;
var partialReceived = new TaskCompletionSource();
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
var syncTask = sync.SyncOnceAsync();
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(partial);
Assert.False(Assert.Single(partial!.History).Verified);
Assert.False(Assert.Single(partial.Utxos).Verified);
Assert.Equal(1_000_000, partial.PendingVerificationSats);
Assert.Equal(1_000_000, partial.ConfirmedSats); // reported confirmed, just not yet proof-checked
gate.Set();
var final = await syncTask;
Assert.True(Assert.Single(final.History).Verified);
Assert.True(Assert.Single(final.Utxos).Verified);
Assert.Equal(0, final.PendingVerificationSats);
}
[Fact]
public async Task Un_coinbase_immaturo_e_non_ancora_verificato_non_produce_un_saldo_spendibile_negativo()
{
// Regression: ImmatureSats and PendingVerificationSats can overlap (an immature
// coinbase can also be unverified) — "ConfirmedSats - ImmatureSats - PendingVerificationSats"
// double-subtracts that overlap and can go negative. SpendableSats must be computed
// directly from IsSpendable instead.
var account = Account();
var scenario = new Scenario { TipHeight = 200 };
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121 — immature.
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
using var gate = new ManualResetEventSlim(false);
server.Handle("blockchain.transaction.get_merkle", p =>
{
gate.Wait();
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
});
var sync = new WalletSynchronizer(account, client);
var partialReceived = new TaskCompletionSource();
SyncResult? partial = null;
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
var syncTask = sync.SyncOnceAsync();
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Immature AND unverified at the same time: both non-spendable subsets are the
// full 5 PLM, but the actual spendable balance must be exactly zero, not negative.
Assert.Equal(5_000_000_000, partial!.ImmatureSats);
Assert.Equal(5_000_000_000, partial.PendingVerificationSats);
Assert.Equal(0, partial.SpendableSats);
gate.Set();
await syncTask;
}
// ---- resilienza ----
[Fact]
public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var server = new FakeElectrumServer();
scenario.WireTo(server);
// The first 3 history calls fail with the ElectrumX throttling error.
var failures = 3;
var histories = scenario.History;
server.Handle("blockchain.scripthash.get_history", p =>
{
if (Interlocked.Decrement(ref failures) >= 0)
throw new FakeElectrumError(-102, "excessive resource usage");
return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
.Select(h => new { tx_hash = h.Txid, height = h.Height });
});
await using var _ = server;
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
}
[Fact]
public async Task I_download_di_transazioni_su_server_busy_vengono_ritentati()
{
// Same throttling scenario but on transaction.get, which runs inside the
// download tasks (the non-generic retry overload, unlike get_history).
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var server = new FakeElectrumServer();
scenario.WireTo(server);
var failures = 2;
var txs = scenario.Txs;
server.Handle("blockchain.transaction.get", p =>
{
if (Interlocked.Decrement(ref failures) >= 0)
throw new FakeElectrumError(-101, "server busy");
return txs[p[0].GetString()!].ToHex();
});
await using var _ = server;
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(funding.GetHash().ToString(), Assert.Single(result.History).Txid);
}
// ---- cache su disco ----
[Fact]
public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var first = new WalletSynchronizer(account, client);
var result1 = await first.SyncOnceAsync();
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
Assert.Single(rawTx); // the confirmed tx is exported
Assert.Single(verifiedAt); // with its verified height
Assert.NotEmpty(headers);
// Fresh synchroniser (new launch), warm cache: no re-download.
server.ResetCallCounts();
var second = new WalletSynchronizer(account, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
var result2 = await second.SyncOnceAsync();
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Lo_stato_di_anchoring_precaricato_da_disco_evita_di_ricamminare_la_catena_al_riavvio()
{
// Same scenario as "Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo", but
// across two separate WalletSynchronizer instances (simulating an app restart) with
// AnchoredUpTo round-tripped through ExportCaches/PreloadCaches like a real save/reload —
// the in-memory-only memoization that test covers wouldn't survive that on its own.
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var tx2 = Net.CreateTransaction();
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
var chain = ChainedHeaders(100, 105, funding.GetHash(),
roots: new() { [103] = tx2.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var first = new WalletSynchronizer(checkpointAccount, client);
var result1 = await first.SyncOnceAsync(); // walks and memoizes the anchor up to 105
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
Assert.NotEmpty(anchoredUpTo);
// Fresh synchroniser (new launch) preloaded from the exported caches, then a new tx at
// height 103 — within the already-anchored range — is announced.
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
server.ResetCallCounts();
var second = new WalletSynchronizer(checkpointAccount, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
var result2 = await second.SyncOnceAsync();
// 103 <= the persisted anchor of 105: no header-range re-walk despite this being a
// brand-new synchroniser instance that never anchored anything itself.
Assert.Equal(1_500_000, result2.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.block.header"));
Assert.Equal(0, server.CallCount("blockchain.block.headers"));
}
[Fact]
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var sync = new WalletSynchronizer(account, client);
await sync.SyncOnceAsync();
var (rawTx, verifiedAt, _, _) = sync.ExportCaches(Net);
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
Assert.Empty(rawTx);
Assert.Empty(verifiedAt);
}
// ---- account a indirizzi fissi (WIF importati) ----
[Fact]
public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
{
var key = new Key();
var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
var scenario = new Scenario();
scenario.Pay(address, 750_000, height: 150);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(750_000, result.ConfirmedSats);
Assert.Single(result.Addresses);
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
Assert.Equal(1, result.NextReceiveIndex);
}
}

Some files were not shown because too many files have changed in this diff Show More