19 Commits
Author SHA1 Message Date
davide 8ac4a05c44 feat(docker): add linux-arm64 reproducible build target
Cross-published via the existing x64 desktop image (self-contained
publish doesn't need ARM hardware), for Raspberry Pi and other
64-bit ARM boards.
2026-07-26 11:05:16 +02:00
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
36 changed files with 1790 additions and 409 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
- **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.
- **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.
- **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.
+116
View File
@@ -5,6 +5,122 @@ Technical changelog for PalladiumWallet. Format loosely follows
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
+1 -1
View File
@@ -92,7 +92,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
- **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.
- **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.
- **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.
+14 -6
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.
- **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.
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
- **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); 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.
- **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
@@ -331,6 +331,9 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
## 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
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.
@@ -360,15 +363,20 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
### CLI in brief
```bash
# 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 -- restore "<mnemonic>" [...]
dotnet run --project src/Cli -- info [--net ...] [--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-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
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 -- servers [--discover]
```
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.
---
+19 -2
View File
@@ -41,6 +41,20 @@ It cannot (given correct Merkle verification):
- Fabricate a confirmed transaction with a valid Merkle proof
- 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
@@ -48,7 +62,7 @@ It cannot (given correct Merkle verification):
- 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
- 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
---
@@ -70,7 +84,7 @@ Connections to the indexing server use TOFU (Trust On First Use): the server's T
## 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.
---
@@ -94,3 +108,6 @@ This is a complement to, not a substitute for, independent human or third-party
- 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 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
+24 -1
View File
@@ -578,6 +578,29 @@ If the server is overloaded (busy responses), the wallet retries automatically u
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
@@ -818,7 +841,7 @@ Reset SSL certificates* — see
| 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; the wallet retries automatically (up to 8 attempts, growing back-off). | Wait; progress is cached, so restarting resumes rather than repeats. |
| 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). |
---
+22 -10
View File
@@ -72,10 +72,11 @@ Running without arguments shows an interactive menu — pick a single target or
./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
windows Win x64 single-file executable (native libs embedded)
linux Linux x64 single-file binary (runs as-is, nothing to install)
linux-arm64 Linux ARM64 single-file binary (runs as-is, nothing to install)
android Android APK (release-signed, prompts for keystore passwords)
all All targets above
Options:
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
@@ -86,6 +87,7 @@ Examples:
```bash
./docker/build.sh all # build everything
./docker/build.sh windows # Windows only
./docker/build.sh linux-arm64 # Linux ARM64 only
./docker/build.sh android --rebuild # Android, rebuilding the image first
```
@@ -96,11 +98,12 @@ Examples:
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` |
| Target | Path |
|-------------|-----------------------------------------------------------|
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
| Linux ARM64 | `dist/linux-arm64/PalladiumWallet-{ver}-linux-arm64` |
| 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.
@@ -119,6 +122,15 @@ 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`.
**Linux ARM64** — same as above, cross-published for `aarch64` (e.g.
Raspberry Pi 4/5, ARM-based SBCs/laptops running a 64-bit distro). The build
runs on an x64 Docker host — .NET's self-contained publish cross-targets
`linux-arm64` without needing ARM hardware. Run it the same way:
```bash
./PalladiumWallet-{ver}-linux-arm64
```
**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+
@@ -138,7 +150,7 @@ via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
| Image | Dockerfile | Used for | Size |
|---------------------|----------------------|-----------------|---------|
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux | ~1.5 GB |
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux + linux-arm64 | ~1.5 GB |
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
Images are built automatically the first time a target needs them and reused
+32 -12
View File
@@ -27,10 +27,11 @@ usage() {
$(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
windows Win x64 single-file executable → dist/windows/
linux Linux x64 single-file binary → dist/linux/
linux-arm64 Linux ARM64 single-file binary → dist/linux-arm64/
android Android APK (release-signed) → dist/android/
all All targets above
$(bold "Options:")
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
@@ -50,9 +51,9 @@ TARGET=""
for arg in "$@"; do
case "$arg" in
windows|linux|android|all) TARGET="$arg" ;;
--rebuild) REBUILD=true ;;
-h|--help) usage; exit 0 ;;
windows|linux|linux-arm64|android|all) TARGET="$arg" ;;
--rebuild) REBUILD=true ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown argument: $arg"; usage; exit 1 ;;
esac
done
@@ -62,10 +63,10 @@ if [[ -z "$TARGET" ]]; then
bold "PalladiumWallet — reproducible build"
echo ""
PS3="Select target: "
options=("windows" "linux" "android" "all" "quit")
options=("windows" "linux" "linux-arm64" "android" "all" "quit")
select opt in "${options[@]}"; do
case "$opt" in
windows|linux|android|all) TARGET="$opt"; break ;;
windows|linux|linux-arm64|android|all) TARGET="$opt"; break ;;
quit) echo "Aborted."; exit 0 ;;
*) echo "Invalid choice, try again." ;;
esac
@@ -172,6 +173,23 @@ build_linux() {
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
}
build_linux_arm64() {
ensure_desktop_image
info "Building Linux ARM64 …"
run_build "$IMAGE_DESKTOP" \
"dotnet publish src/App.Desktop \
-r linux-arm64 \
-c Release \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
--self-contained \
-o /tmp/linux-arm64-out
install -m 755 /tmp/linux-arm64-out/PalladiumWallet \
\"/output/PalladiumWallet-${VERSION}-linux-arm64\"" \
"${DIST_DIR}/linux-arm64"
ok "Linux ARM64 → dist/linux-arm64/PalladiumWallet-${VERSION}-linux-arm64"
}
build_android() {
ensure_android_image
@@ -221,12 +239,14 @@ build_android() {
START=$(date +%s)
case "$TARGET" in
windows) build_windows ;;
linux) build_linux ;;
android) build_android ;;
windows) build_windows ;;
linux) build_linux ;;
linux-arm64) build_linux_arm64 ;;
android) build_android ;;
all)
build_windows
build_linux
build_linux_arm64
build_android
;;
esac
+22
View File
@@ -4,6 +4,7 @@ using Android.Content;
using Android.Content.PM;
using Android.OS;
using Avalonia.Android;
using AvaloniaApp = PalladiumWallet.App.App;
namespace PalladiumWallet.Mobile;
@@ -17,6 +18,7 @@ public class MainActivity : AvaloniaMainActivity
internal const int ScanRequestCode = 9001;
internal static TaskCompletionSource<string?>? ScanTcs;
internal static MainActivity? Current;
private bool _wasPaused;
protected override void OnCreate(Bundle? savedInstanceState)
{
@@ -24,6 +26,26 @@ public class MainActivity : AvaloniaMainActivity
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)
{
base.OnActivityResult(requestCode, resultCode, data);
@@ -8,8 +8,8 @@
<Nullable>enable</Nullable>
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
<ApplicationVersion>3</ApplicationVersion>
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<ApplicationVersion>4</ApplicationVersion>
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<!-- 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
+5
View File
@@ -8,6 +8,10 @@ namespace PalladiumWallet.App;
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()
{
AvaloniaXamlLoader.Load(this);
@@ -16,6 +20,7 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
var vm = new MainWindowViewModel();
MainViewModel = vm;
// Desktop (Windows/Linux): classic window. Mobile (Android): single
// view. Same shared UI (MainView) and same ViewModel.
+280 -236
View File
@@ -11,8 +11,8 @@ public sealed class Loc
{
public static Loc Instance { get; private set; } = new();
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
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 string Language { get; private set; } = "en";
@@ -42,256 +42,283 @@ public sealed class Loc
private static readonly Dictionary<string, string[]> Strings = new()
{
// Menu it en es fr pt de
["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.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.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.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
// Menu it en es fr pt de zh
["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.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.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.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.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe", "_帮助"],
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über", "关于"],
["help.info"] = [
"Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
"Lightweight, non-custodial SPV wallet for the Palladium (PLM) network. Local security: seed and keys always encrypted, never exposed on the wire.",
"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 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 leve e não custodial para a rede Palladium (PLM). Segurança local: semente e chaves sempre cifradas, nunca expostas na rede.",
"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"],
["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"],
"Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert.",
"轻量级、非托管的 Palladium(PLM)网络 SPV 钱包。本地安全:种子和密钥始终加密,绝不在网络上明文传输。"],
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info", "信息"],
["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"] = [
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
"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 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.",
"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.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten"],
["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"],
"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.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten", "准备捐赠"],
["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
["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"] = [
"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.",
"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.",
"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."],
["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.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
["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.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
["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.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren"],
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"],
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
["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)"],
"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.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…", "选择文件夹…"],
["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.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen", "打开现有钱包"],
["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.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"],
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"],
["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.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"],
["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"] = [
"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.",
"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.",
"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."],
["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.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.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
["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.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
["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.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
["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)"],
["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.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.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.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
["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"],
"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.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.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
["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.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase", "可选密码短语"],
["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.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)", "钱包名称(可选)"],
["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)", "例如:储蓄、交易…(留空则自动命名)"],
["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.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.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.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen", "创建钱包"],
["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"] = [
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
"Warning: without encryption the seed stays in plaintext on disk.",
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
"Atenção: sem criptografia a semente fica em texto simples no disco.",
"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.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"],
"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.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"] = [
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
"Determines the address format. If unsure, use 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.",
"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."],
["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"],
["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.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.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.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"],
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit.",
"决定地址格式。如果不确定,请使用原生隔离见证(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", "BIP44 · m/44'/… · P 地址"],
["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.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.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"] = [
"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.",
"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.",
"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."],
["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…"],
["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"],
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt.",
"粘贴 xpub/zpub/ypub(仅观察)或 xprv/zprv/yprv(可花费)。脚本类型将自动识别。"],
["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"] = [
"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.",
"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.",
"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."],
["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)"],
"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.",
"粘贴一个或多个 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
["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.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.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.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.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.", "该 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.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
["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.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren"],
["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.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
["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.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"],
["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.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", "或手动输入 host:port"],
["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", "重置证书"],
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen", "接收"],
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf", "历史"],
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen", "地址"],
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden", "发送"],
["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.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren", "复制"],
["receive.hint"] = [
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
"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.",
"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.",
"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.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
["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.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen"],
["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.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
["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"],
"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.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo", "余额"],
["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.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:", "派生路径:"],
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:", "公钥:"],
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):", "私钥(WIF):"],
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen", "显示"],
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen", "确认身份"],
["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.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang", "接收"],
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld", "找零"],
["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.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.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.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.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
["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.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
["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.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.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
["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.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
["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.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.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.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.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.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
["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.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.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.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"],
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
["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.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.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", "0 次确认 · 在内存池中"],
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen", "次确认"],
["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.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum", "日期"],
["tx.to"] = ["A", "To", "Para", "À", "Para", "An", "至"],
["tx.from"] = ["Da", "From", "De", "De", "De", "Von", "来自"],
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll", "支出"],
["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.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.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.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)", "可替换(RBF"],
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge", "输入"],
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge", "输出"],
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja", "是"],
["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.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.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.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.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.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag", "金额"],
["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:", "手续费 sat/vB"],
["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.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.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.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
["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.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei"],
["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.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)"],
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter 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.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)"],
["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.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.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.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.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen"],
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
["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.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei", "文件"],
["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.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)", "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", "已导入 WIF 密钥"],
["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.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.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)", "种子(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.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.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
["walletinfo.seed.warning"] = [
"Non condividere mai queste parole. Chi le possiede controlla i fondi.",
"Never share these words. Whoever holds them controls the funds.",
"Nunca compartas estas palabras. Quien las tenga controla los fondos.",
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
"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)"],
"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", "BIP39 密码短语"],
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)", "(已设置)"],
// Connection status
["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.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.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
["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.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.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert", "证书已更改"],
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden", "已连接"],
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu", "正在连接"],
// Main status messages
["msg.welcome.existing"] = [
@@ -300,151 +327,168 @@ public sealed class Loc
"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.",
"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"] = [
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
"Welcome: create a new wallet or restore from seed.",
"Bienvenido: crea un nuevo wallet o restaura desde semilla.",
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
"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"] = [
"Inserisci la password del file (lascia vuoto se non impostata).",
"Enter the file password (leave empty if not set).",
"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).",
"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"] = [
"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.",
"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.",
"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"] = [
"Reinserisci le 12 parole per confermare di averle scritte.",
"Re-enter the 12 words to confirm you wrote them down.",
"Reingresa las 12 palabras para confirmar que las has anotado.",
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
"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"] = [
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
"The words do not match: check what you wrote on paper.",
"Las palabras no coinciden: revisa lo que escribiste en papel.",
"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.",
"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"] = [
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
"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).",
"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"] = [
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
"Invalid mnemonic (wrong words or checksum): check again.",
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
"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"] = [
"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.",
"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.",
"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"] = [
"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.",
"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.",
"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."],
["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."],
"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.password.required"] = [
"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”).",
"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 »).",
"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"] = [
"Le due password non coincidono.",
"The two passwords do not match.",
"Las dos contraseñas no coinciden.",
"Les deux mots de passe ne correspondent pas.",
"As duas senhas não coincidem.",
"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.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.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"],
"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.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.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"] = [
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
"SPV-verified transactions. Real-time updates active.",
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
"transactions vérifiées SPV. Mises à jour en temps réel actives.",
"transações verificadas SPV. Atualizações em tempo real ativas.",
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
["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.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv.",
"已通过 SPV 验证的交易。实时更新已启用。"],
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe", "高度"],
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung", "等待确认"],
["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"] = [
"Certificati SSL azzerati: riprova la connessione.",
"SSL certificates cleared: retry the connection.",
"Certificados SSL restablecidos: reintenta la conexión.",
"Certificats SSL réinitialisés : réessayez la connexion.",
"Certificados SSL redefinidos: tente novamente a conexão.",
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
["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})."],
["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})."],
["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"],
["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)"],
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen.",
"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."],
"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.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
["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.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.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.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
["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.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.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.", "没有已保存的联系人。"],
// Settings window
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
["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.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
["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.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen", "设置"],
["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.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern", "保存"],
["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…", "索引服务器…"],
// Server window
["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.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.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.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.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.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>
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
titolo della finestra ed è incisa nei binari pubblicati. -->
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
@@ -28,6 +28,9 @@ public partial class MainWindowViewModel
[ObservableProperty]
private string immatureText = "";
[ObservableProperty]
private string verifyingText = "";
[ObservableProperty]
private string networkInfo = "";
@@ -253,6 +256,7 @@ public partial class MainWindowViewModel
BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = "";
ImmatureText = "";
VerifyingText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear();
Addresses.Clear();
@@ -265,7 +269,11 @@ public partial class MainWindowViewModel
$"m/{_doc!.AccountPath}/0/{i}"));
return;
}
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
// 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);
UnconfirmedText = pending != 0
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
@@ -273,13 +281,20 @@ public partial class MainWindowViewModel
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();
History.Clear();
foreach (var tx in cache.History)
History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid));
tx.Txid,
tx.Verified));
Addresses.Clear();
foreach (var a in cache.Addresses)
@@ -31,6 +31,14 @@ public partial class MainWindowViewModel
[ObservableProperty]
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]
private async Task ScanQr()
{
@@ -81,11 +89,13 @@ public partial class MainWindowViewModel
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
HasPendingSend = _pendingSend.Signed;
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
}
catch (Exception ex)
{
_pendingSend = null;
HasPendingSend = false;
PendingPsbtBase64 = "";
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
}
await Task.CompletedTask;
@@ -103,6 +113,7 @@ public partial class MainWindowViewModel
SendTo = SendAmount = "";
_pendingSend = null;
HasPendingSend = false;
PendingPsbtBase64 = "";
await ConnectAndSync();
}
catch (Exception ex)
@@ -12,6 +12,7 @@ public partial class MainWindowViewModel
public bool IsLangFr => _config.Language == "fr";
public bool IsLangPt => _config.Language == "pt";
public bool IsLangDe => _config.Language == "de";
public bool IsLangZh => _config.Language == "zh";
public bool IsUnitPlm => _config.Unit == "PLM";
public bool IsUnitMilli => _config.Unit == "mPLM";
public bool IsUnitMicro => _config.Unit == "µPLM";
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
OnPropertyChanged(nameof(IsLangFr));
OnPropertyChanged(nameof(IsLangPt));
OnPropertyChanged(nameof(IsLangDe));
OnPropertyChanged(nameof(IsLangZh));
OnPropertyChanged(nameof(IsUnitPlm));
OnPropertyChanged(nameof(IsUnitMilli));
OnPropertyChanged(nameof(IsUnitMicro));
+87 -25
View File
@@ -271,34 +271,41 @@ public partial class MainWindowViewModel
_doc.Cache?.BlockHeaders,
_doc.Cache?.NextReceiveIndex ?? 0,
_doc.Cache?.NextChangeIndex ?? 0,
net);
net,
_doc.Cache?.AnchoredUpTo);
_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
{
_resyncRequested = false;
var result = await _synchronizer.SyncOnceAsync(ct);
// 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;
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
PalladiumNetworks.For(_account.Profile.Kind));
_doc.Cache = new SyncCache
var cache = new SyncCache
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
};
WalletStore.Save(_doc, _walletPath!, _password);
FillDisplayFields(cache, result);
_doc.Cache = cache;
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache);
_syncFailed = false;
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
@@ -307,11 +314,13 @@ public partial class MainWindowViewModel
}
catch (OperationCanceledException)
{
// Intentional cancellation due to server change request — not an error.
// 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 = Loc.Tr("conn.none");
ConnectionStatusShort = Loc.Tr("conn.none");
ConnectionStatus = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
ConnectionStatusShort = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
StatusMessage = "";
}
catch (CertificatePinMismatchException ex)
@@ -324,9 +333,22 @@ public partial class MainWindowViewModel
catch (Exception ex)
{
IsConnected = _client?.IsConnected == true;
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 (_resumeRecovering && !IsConnected)
{
// 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)
{
_syncFailed = true;
@@ -338,6 +360,7 @@ public partial class MainWindowViewModel
finally
{
IsSyncing = false;
_resumeRecovering = false;
}
// If cancelled due to a server change, restart immediately with the new server.
@@ -345,6 +368,44 @@ public partial class MainWindowViewModel
_ = 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
@@ -453,12 +514,13 @@ public partial class MainWindowViewModel
try
{
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)
return;
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
_doc.Cache.VerifiedAt = verifiedAt;
_doc.Cache.BlockHeaders = blockHeaders;
_doc.Cache.AnchoredUpTo = anchoredUpTo;
WalletStore.Save(_doc, _walletPath, _password);
}
catch { /* non-fatal: the next full save will recover */ }
@@ -27,9 +27,10 @@ public partial class MainWindowViewModel
public const string StepScriptType = "script-type";
public const string StepImportXkey = "import-xkey";
public const string StepImportWif = "import-wif";
public const string StepImportAddress = "import-address";
public const string StepPassword = "password";
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress }
private WizardFlowKind _wizardFlow;
[ObservableProperty]
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
[NotifyPropertyChangedFor(nameof(IsStepImportAddress))]
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart;
@@ -56,9 +58,10 @@ public partial class MainWindowViewModel
public bool IsStepWords => SetupStep == StepWords;
public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepScriptType => SetupStep == StepScriptType;
public bool IsStepImportXkey => SetupStep == StepImportXkey;
public bool IsStepImportWif => SetupStep == StepImportWif;
public bool IsStepPassword => SetupStep == StepPassword;
public bool IsStepImportXkey => SetupStep == StepImportXkey;
public bool IsStepImportWif => SetupStep == StepImportWif;
public bool IsStepImportAddress => SetupStep == StepImportAddress;
public bool IsStepPassword => SetupStep == StepPassword;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLegacySelected))]
@@ -85,6 +88,9 @@ public partial class MainWindowViewModel
[ObservableProperty]
private string importWifInput = "";
[ObservableProperty]
private string importAddressInput = "";
// Script type detected during xkey decoding (to display to the user)
[ObservableProperty]
private string importXkeyDetectedKind = "";
@@ -214,6 +220,15 @@ public partial class MainWindowViewModel
StatusMessage = "";
}
[RelayCommand]
private void WizardStartImportAddress()
{
_wizardFlow = WizardFlowKind.ImportAddress;
ImportAddressInput = "";
SetupStep = StepImportAddress;
StatusMessage = "";
}
[RelayCommand]
private void WizardNextFromShowSeed()
{
@@ -312,6 +327,35 @@ public partial class MainWindowViewModel
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]
private void WizardNextFromScriptType()
{
@@ -327,11 +371,16 @@ public partial class MainWindowViewModel
SetupStep = SetupStep switch
{
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,
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
StepPassword => _wizardFlow switch
{
WizardFlowKind.ImportXkey => StepImportXkey,
WizardFlowKind.ImportAddress => StepImportAddress,
_ => StepScriptType,
},
_ => StepStart,
};
if (SetupStep == StepStart)
@@ -391,6 +440,14 @@ public partial class MainWindowViewModel
(doc, account) = (d, a);
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:
{
var (d, a) = WalletLoader.NewFromMnemonic(
@@ -497,13 +554,14 @@ public partial class MainWindowViewModel
_walletPath = path;
_password = password;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = "";
SetupStep = StepStart;
OnPropertyChanged(nameof(IsWatchOnlyAccount));
var walletKindTag = account switch
{
ImportedKeyAccount => " · imported",
{ IsWatchOnly: true } => " · watch-only",
ImportedKeyAccount => " · imported",
_ => ""
};
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
+88 -11
View File
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <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>
public sealed record AddressRow(
@@ -77,6 +77,7 @@ public partial class MainWindowViewModel : ViewModelBase
// ---- keep-alive ----
private bool _autoReconnect;
private bool _syncFailed;
private bool _resumeRecovering;
private readonly DispatcherTimer _keepAliveTimer;
// ---- server UI sync ----
@@ -161,28 +162,102 @@ public partial class MainWindowViewModel : ViewModelBase
_ = CheckForUpdatesAsync();
}
private bool _keepAliveRunning;
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;
if (_client is { IsConnected: true })
_keepAliveRunning = true;
try
{
// If the wallet is open and the last sync failed, retry automatically.
if (_syncFailed && _account is not null)
if (_client is { IsConnected: true } client)
{
// If the wallet is open and the last sync failed, retry automatically.
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();
return;
}
try { await _client.PingAsync(); }
catch { }
}
else if (_autoReconnect)
finally
{
ConnectionStatus = Loc.Tr("conn.reconnecting");
await ConnectAndSync();
_keepAliveRunning = false;
}
}
/// <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 ----
[RelayCommand]
@@ -201,6 +276,8 @@ public partial class MainWindowViewModel : ViewModelBase
_lastTransactions = null;
_pendingSend = null;
HasPendingSend = false;
PendingPsbtBase64 = "";
OnPropertyChanged(nameof(IsWatchOnlyAccount));
History.Clear();
Contacts.Clear();
SelectedContactInList = null;
@@ -102,7 +102,8 @@ public sealed class TransactionDetailsViewModel
{
if (d.Confirmations <= 0)
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)
+72 -8
View File
@@ -60,7 +60,7 @@
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
HorizontalAlignment="Center">
HorizontalAlignment="Stretch">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/>
@@ -99,6 +99,9 @@
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartImportWifCommand}"/>
<Button Content="{Binding Loc[wiz.importaddress.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartImportAddressCommand}"/>
</StackPanel>
<!-- Step: choose wallet (multiple files present) -->
@@ -212,6 +215,19 @@
</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 -->
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>
@@ -317,6 +333,9 @@
<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"
Margin="0,2,0,0"/>
</StackPanel>
@@ -373,13 +392,16 @@
<DataTemplate x:DataType="vm:HistoryRow">
<Panel Cursor="Hand">
<!-- Desktop: 3 fixed columns -->
<Grid ColumnDefinitions="90,160,*"
<Grid ColumnDefinitions="90,160,*,Auto"
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<TextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"
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>
<!-- Mobile: vertical card -->
<StackPanel Spacing="2"
@@ -389,6 +411,9 @@
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11"
IsVisible="{Binding !Verified}"/>
</StackPanel>
</Panel>
</DataTemplate>
@@ -420,7 +445,7 @@
<!-- ── DESKTOP: Recipient | Amount side-by-side ── -->
<Grid IsVisible="{Binding IsDesktop}"
RowDefinitions="Auto,Auto,Auto" RowSpacing="14">
RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="14">
<Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14">
<!-- Recipient card -->
@@ -491,11 +516,29 @@
<SelectableTextBlock Text="{Binding SendPreview}"
TextWrapping="Wrap" FontSize="13" Classes="mono"
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>
</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 -->
<Grid Grid.Row="2" ColumnDefinitions="*,*" ColumnSpacing="14">
<Grid Grid.Row="3" ColumnDefinitions="*,*" ColumnSpacing="14">
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
Command="{Binding PrepareSendCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
@@ -595,9 +638,26 @@
<SelectableTextBlock Text="{Binding SendPreview}"
TextWrapping="Wrap" FontSize="13" Classes="mono"
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>
</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 -->
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
Command="{Binding ConfirmSendCommand}"
@@ -984,7 +1044,7 @@
<Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="360" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center">
HorizontalAlignment="Stretch" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
FontSize="16" FontWeight="Bold"/>
@@ -1325,7 +1385,7 @@
<Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="500" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center">
HorizontalAlignment="Stretch" VerticalAlignment="Center">
<ScrollViewer MaxHeight="620">
<StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[walletinfo.title]}"
@@ -1502,6 +1562,9 @@
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
IsChecked="{Binding IsLangDe, Mode=OneWay}"
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>
</StackPanel>
@@ -1585,8 +1648,9 @@
</ScrollViewer>
</TabItem>
<!-- Tab: Donate -->
<TabItem Header="{Binding Loc[help.tab.donate]}">
<!-- Tab: Donate (needs an open wallet to send from) -->
<TabItem Header="{Binding Loc[help.tab.donate]}"
IsVisible="{Binding IsWalletOpen}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="12" Margin="0,12,0,0">
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
+11
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)
{
if (DataContext is MainWindowViewModel vm)
+37 -4
View File
@@ -18,6 +18,7 @@ try
["create", .. var rest] => Create(rest),
["restore", var words, .. var rest] => Restore(words, rest),
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
["restore-address", var addrs, .. var rest] => RestoreAddress(addrs, rest),
["info", .. var rest] => Info(rest),
["sync", .. var rest] => await Sync(rest),
["send", .. var rest] => await Send(rest),
@@ -95,6 +96,31 @@ static int RestoreXpub(string xpubText, string[] o)
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;
}
static int Info(string[] o)
{
var (doc, account, path) = OpenWallet(o);
@@ -104,8 +130,9 @@ static int Info(string[] o)
Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache)
{
Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendable"
Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
+ (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
+ (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)}");
@@ -139,16 +166,19 @@ static async Task<int> Sync(string[] o)
doc.Cache?.BlockHeaders,
doc.Cache?.NextReceiveIndex ?? 0,
doc.Cache?.NextChangeIndex ?? 0,
net);
net,
doc.Cache?.AnchoredUpTo);
var result = await sync.SyncOnceAsync();
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
PendingVerificationSats = result.PendingVerificationSats,
SpendableSats = result.SpendableSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
@@ -157,11 +187,13 @@ static async Task<int> Sync(string[] o)
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
};
WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendable"
Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
+ (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)
@@ -348,6 +380,7 @@ static int Usage()
[--passphrase W] [--password P] [--file PATH]
restore "<mnemonic>" [same options as create] [--path m/...]
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]
Network (indexing server; without --server the first known server is used):
+13
View File
@@ -52,6 +52,19 @@ public static class DerivationPaths
_ => 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>
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
+20
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>
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>
public readonly record struct ChainTip(int Height, string HeaderHex);
@@ -82,6 +85,23 @@ public static class ElectrumApi
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,
CancellationToken ct = default)
{
+12 -5
View File
@@ -36,8 +36,12 @@ public sealed class ElectrumClient : IAsyncDisposable
// single segment; this gate avoids flooding the server with thousands of
// simultaneous requests on large wallets → no bursts of -101/-102 nor
// connection drops. Writes still stay pipelined up to this degree.
private const int MaxInFlight = 32;
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight);
// Configurable per connection (see ConnectAsync): the right value trades off
// 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;
@@ -49,19 +53,22 @@ public sealed class ElectrumClient : IAsyncDisposable
public event Action<string, JsonElement>? NotificationReceived;
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;
_stream = stream;
Host = host;
Port = port;
UseSsl = useSsl;
_inFlight = new SemaphoreSlim(maxInFlight, maxInFlight);
_readLoop = Task.Run(ReadLoopAsync);
_writeLoop = Task.Run(WriteLoopAsync);
}
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 };
try
@@ -90,7 +97,7 @@ public sealed class ElectrumClient : IAsyncDisposable
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);
return client;
}
+232 -52
View File
@@ -29,6 +29,23 @@ public sealed class SyncResult
/// <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 NextChangeIndex { get; init; }
public required IReadOnlyList<CachedTx> History { get; init; }
@@ -46,14 +63,47 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
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 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();
// checkpoint height -> highest height already proven to hash-chain back to it
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
// 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
// discovery — already-used addresses are fetched in a single burst instead of
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
@@ -70,16 +120,31 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
Dictionary<int, string>? blockHeaders,
int knownReceiveIndex,
int knownChangeIndex,
Network network)
Network network,
Dictionary<int, int>? anchoredUpTo = null)
{
foreach (var (txid, hex) in rawTxHex)
{
_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)
if (!_verifiedAtHeight.ContainsKey(txid))
_verifiedAtHeight[txid] = height;
if (blockHeaders is not null)
foreach (var (height, hex) in blockHeaders)
_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;
_knownChangeIndex = knownChangeIndex;
}
@@ -91,10 +156,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// </summary>
public (Dictionary<string, string> RawTxHex,
Dictionary<string, int> VerifiedAt,
Dictionary<int, string> BlockHeaders)
Dictionary<int, string> BlockHeaders,
Dictionary<int, int> AnchoredUpTo)
ExportCaches(Network network)
{
var rawHex = _verifiedAtHeight.Keys
var rawHex = _confirmedTxids.Keys
.Where(_txCache.ContainsKey)
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
@@ -105,7 +171,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (task.IsCompletedSuccessfully)
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)
@@ -162,7 +229,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
foreach (var item in historyByAddress.Values.SelectMany(h => h))
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 missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
var toVerify = txHeights
@@ -170,47 +245,95 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
.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)
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 dlDone = 0;
var merkDone = 0;
var raw = await client.GetTransactionAsync(txid, ct);
_txCache[txid] = Transaction.Parse(raw, network);
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 () =>
{
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));
SyncResult BuildSnapshot() =>
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
{
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);
await AnchorToCheckpointAsync(height, ct);
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));
PartialResult?.Invoke(BuildSnapshot());
await Task.WhenAll(dlTasks.Concat(merkTasks));
foreach (var (txid, height) in toVerify)
_verifiedAtHeight[txid] = height;
}
var merkDone = 0;
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
{
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 verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
// 6. Local UTXO reconstruction.
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
@@ -222,6 +345,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var utxos = new List<CachedUtxo>();
foreach (var (txid, tx) in transactions)
{
var height = txHeights[txid];
var verifiedTx = IsTxVerified(txid, height);
for (var vout = 0; vout < tx.Outputs.Count; vout++)
{
var output = tx.Outputs[vout];
@@ -237,8 +362,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
Address = addr.Address.ToString(),
IsChange = addr.IsChange,
AddressIndex = addr.Index,
Height = txHeights[txid],
Height = height,
IsCoinbase = tx.IsCoinBase,
Verified = verifiedTx,
});
}
}
@@ -247,6 +373,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var history = new List<CachedTx>();
foreach (var (txid, tx) in transactions)
{
var height = txHeights[txid];
var received = tx.Outputs
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
.Sum(o => o.Value.Satoshi);
@@ -257,9 +384,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
history.Add(new CachedTx
{
Txid = txid,
Height = txHeights[txid],
Height = height,
DeltaSats = received - sentSats,
Verified = verified[txid],
Verified = IsTxVerified(txid, height),
});
}
history.Sort((a, b) =>
@@ -286,12 +413,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
return new SyncResult
{
TipHeight = tip.Height,
TipHeight = tipHeight,
ConfirmedSats = 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(tip.Height) < u.RequiredConfirmations(account.Profile))
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,
NextChangeIndex = nextChange,
History = history,
@@ -325,9 +454,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
return;
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1)
.Select(async h => BlockHeaderInfo.Parse(
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct)))));
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(
@@ -341,6 +472,41 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
_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>
/// Scans one chain (receiving or change).
///
@@ -414,7 +580,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
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;
for (var attempt = 0; ; attempt++)
@@ -423,13 +594,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
catch (ElectrumServerException ex)
when (IsBusy(ex) && attempt < 7)
{
ReportBusyRetry(delay);
await Task.Delay(delay, ct);
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;
for (var attempt = 0; ; attempt++)
@@ -438,12 +610,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
catch (ElectrumServerException ex)
when (IsBusy(ex) && attempt < 7)
{
ReportBusyRetry(delay);
await Task.Delay(delay, ct);
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) =>
ex.Message.Contains("-102") ||
ex.Message.Contains("-101") ||
+35
View File
@@ -40,6 +40,9 @@ public sealed class WalletDocument
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
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>
public int GapLimit { get; set; } = 20;
@@ -98,6 +101,16 @@ public sealed class SyncCache
/// <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 NextChangeIndex { get; set; }
public List<CachedTx> History { get; set; } = [];
@@ -124,6 +137,14 @@ public sealed class SyncCache
/// subsequent syncs.
/// </summary>
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>
@@ -141,6 +162,13 @@ public sealed class CachedTx
public required string Txid { get; set; }
public int Height { 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; }
}
@@ -155,4 +183,11 @@ public sealed class CachedUtxo
public int Height { get; set; }
public bool IsCoinbase { 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.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));
}
+118 -7
View File
@@ -29,6 +29,8 @@ public sealed class BuiltTransaction
/// </summary>
public sealed class TransactionFactory(IWalletAccount account)
{
private const int MaxStandardTransactionVirtualSize = 100_000;
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary>
@@ -68,9 +70,9 @@ public sealed class TransactionFactory(IWalletAccount account)
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (immature.Count > 0)
{
var best = immature.Max(u => u.Confirmations(tipHeight));
var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
var threshold = profile.CoinbaseMaturity + 1;
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). ");
}
var underConf = utxos.Where(u =>
@@ -78,20 +80,96 @@ public sealed class TransactionFactory(IWalletAccount account)
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (underConf.Count > 0)
{
var best = underConf.Max(u => u.Confirmations(tipHeight));
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
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.");
}
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),
transactions[u.Txid].Outputs[u.Vout])).ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
var builder = Network.CreateTransactionBuilder();
builder.SetVersion(2);
// RBF sequence to allow fee bumping (§6.6).
@@ -107,7 +185,7 @@ public sealed class TransactionFactory(IWalletAccount account)
if (!account.IsWatchOnly)
{
builder.AddKeys(spendable
builder.AddKeys(selectedUtxos
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
.OfType<Key>()
.ToArray());
@@ -118,11 +196,27 @@ public sealed class TransactionFactory(IWalletAccount account)
{
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)
{
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 (!builder.Verify(tx, out TransactionPolicyError[] errors))
@@ -140,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)
{
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
@@ -147,6 +256,8 @@ public sealed class TransactionFactory(IWalletAccount account)
.Sum(c => (Money)c.Amount);
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>
+8 -2
View File
@@ -18,7 +18,13 @@ public static class UtxoSpendability
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 and is not frozen.</summary>
/// <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.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
!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);
}
// 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)
throw new InvalidDataException("Wallet file has no xpub and no seed.");
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
@@ -170,4 +178,47 @@ public static class WalletLoader
};
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);
}
}
@@ -101,6 +101,15 @@ public class WalletSynchronizerTests
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) };
});
}
}
@@ -232,7 +241,10 @@ public class WalletSynchronizerTests
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(250_000, result.UnconfirmedSats);
var entry = Assert.Single(result.History);
Assert.False(entry.Verified);
// 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"));
}
@@ -318,8 +330,11 @@ public class WalletSynchronizerTests
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint.
Assert.Equal(6, server.CallCount("blockchain.block.header"));
// 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]
@@ -352,12 +367,13 @@ public class WalletSynchronizerTests
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 call count stays at the 6 of the first walk (103 is cached).
// 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(6, server.CallCount("blockchain.block.header"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
}
[Fact]
@@ -426,6 +442,89 @@ public class WalletSynchronizerTests
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]
@@ -496,7 +595,7 @@ public class WalletSynchronizerTests
var first = new WalletSynchronizer(account, client);
var result1 = await first.SyncOnceAsync();
var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
Assert.Single(rawTx); // the confirmed tx is exported
Assert.Single(verifiedAt); // with its verified height
@@ -506,7 +605,7 @@ public class WalletSynchronizerTests
server.ResetCallCounts();
var second = new WalletSynchronizer(account, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
var result2 = await second.SyncOnceAsync();
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
@@ -515,6 +614,55 @@ public class WalletSynchronizerTests
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()
{
@@ -526,7 +674,7 @@ public class WalletSynchronizerTests
var sync = new WalletSynchronizer(account, client);
await sync.SyncOnceAsync();
var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
var (rawTx, verifiedAt, _, _) = sync.ExportCaches(Net);
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
Assert.Empty(rawTx);
@@ -148,6 +148,21 @@ public class StorageTests
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
}
[Fact]
public void WatchAddresses_fa_roundtrip_json_ed_e_watch_only()
{
var doc = new WalletDocument
{
Network = "mainnet",
ScriptKind = "NativeSegwit",
WatchAddresses = ["bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"],
};
var restored = WalletDocument.FromJson(doc.ToJson());
Assert.Equal(doc.WatchAddresses, restored.WatchAddresses);
Assert.True(restored.IsWatchOnly);
}
[Fact]
public void Json_corrotto_lancia_eccezione()
{
@@ -33,12 +33,53 @@ public class TransactionFactoryTests
{
Txid = txid, Vout = 0, ValueSats = sats,
Address = account.GetReceiveAddress(0).ToString(),
IsChange = false, AddressIndex = 0, Height = 100,
IsChange = false, AddressIndex = 0, Height = 100, Verified = true,
},
};
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
}
private static void AddFund(
HdAccount account,
List<CachedUtxo> utxos,
Dictionary<string, Transaction> transactions,
int index,
long sats)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)index)));
funding.Outputs.Add(Money.Satoshis(sats), account.GetReceiveAddress(index));
var txid = funding.GetHash().ToString();
transactions[txid] = funding;
utxos.Add(new CachedUtxo
{
Txid = txid,
Vout = 0,
ValueSats = sats,
Address = account.GetReceiveAddress(index).ToString(),
IsChange = false,
AddressIndex = index,
Height = 100,
Verified = true,
});
}
[Fact]
public void Un_utxo_confermato_ma_non_ancora_verificato_non_e_spendibile()
{
// Confirmed by the server, but its Merkle proof hasn't been checked yet (progressive
// background verification, §7.4): must never be treated as spendable — otherwise a
// malicious server could get a fabricated balance spent before the forgery is caught.
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Verified = false;
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(5), amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
Assert.Contains("awaiting Merkle-proof verification", ex.Message);
}
[Fact]
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
{
@@ -169,7 +210,7 @@ public class TransactionFactoryTests
{
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false, Verified = true },
};
var txs = new Dictionary<string, Transaction> { [txid] = funding };
@@ -209,6 +250,33 @@ public class TransactionFactoryTests
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
}
[Fact]
public void Un_account_di_soli_indirizzi_watch_only_produce_una_psbt_non_firmata()
{
var full = Account();
var (utxos, txs) = Fund(full, 1_000_000);
// Pure address import: no private key at all (unlike xpub watch-only, which
// can still derive public keys/scriptPubKeys — this account can't upgrade
// to spendable without the user separately importing the matching key).
var watchOnly = new ImportedKeyAccount(
[(full.GetReceiveAddress(0), (Key?)null)], ScriptKind.NativeSegwit, Profile);
var built = new TransactionFactory(watchOnly).Build(
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
Assert.False(built.Signed);
Assert.True(watchOnly.IsWatchOnly);
Assert.Null(watchOnly.GetPrivateKey(false, 0));
var psbt = built.Psbt;
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
psbt.Finalize();
var tx = psbt.ExtractTransaction();
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
}
[Theory]
[InlineData("0.00000001", 1L)]
[InlineData("1", 100_000_000L)]
@@ -295,7 +363,7 @@ public class TransactionFactoryTests
{
Txid = txid, Vout = 0, ValueSats = 300_000,
Address = account.GetReceiveAddress(i).ToString(),
IsChange = false, AddressIndex = i, Height = 100,
IsChange = false, AddressIndex = i, Height = 100, Verified = true,
});
}
@@ -309,6 +377,42 @@ public class TransactionFactoryTests
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
}
[Fact]
public void Automatic_coin_selection_uses_large_utxos_before_dust()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
AddFund(account, allUtxos, allTxs, index: 0, sats: 2_000_000);
for (var i = 1; i <= 1_200; i++)
AddFund(account, allUtxos, allTxs, i, sats: 10_000);
var built = new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(1_250), amountSats: 500_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
Assert.Single(built.Transaction.Inputs);
Assert.True(built.Transaction.GetVirtualSize() < 100_000);
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 500_000);
}
[Fact]
public void Spending_more_than_the_standard_input_limit_reports_a_clear_error()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
for (var i = 0; i < 1_600; i++)
AddFund(account, allUtxos, allTxs, i, sats: 10_000);
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(1_650), amountSats: 15_300_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100));
Assert.Contains("standard relay limit", ex.Message);
Assert.Contains("multiple smaller transactions", ex.Message);
}
[Fact]
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
{
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
@@ -249,4 +250,85 @@ public class WalletLoaderTests
Assert.Throws<InvalidDataException>(
() => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
}
// ---- NewFromAddresses (pure watch-only, no key at all) ----
private static string SampleAddress(ScriptKind kind = ScriptKind.NativeSegwit) =>
WalletLoader.NewFromMnemonic(ValidMnemonic, null, kind, ChainProfiles.Mainnet)
.Account.GetReceiveAddress(0).ToString();
[Fact]
public void NewFromAddresses_crea_documento_watch_only_senza_chiavi()
{
var address = SampleAddress();
var (doc, account) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet);
Assert.Equal("mainnet", doc.Network);
Assert.Null(doc.Mnemonic);
Assert.Null(doc.AccountXprv);
Assert.Null(doc.AccountXpub);
Assert.Null(doc.WifKeys);
Assert.Equal([address], doc.WatchAddresses);
Assert.True(doc.IsWatchOnly);
Assert.True(account.IsWatchOnly);
Assert.Null(account.GetPrivateKey(false, 0));
Assert.Equal(address, account.GetReceiveAddress(0).ToString());
}
[Fact]
public void NewFromAddresses_rileva_lo_scriptkind_dallindirizzo()
{
var legacyAddr = SampleAddress(ScriptKind.Legacy);
var (doc, _) = WalletLoader.NewFromAddresses([legacyAddr], ChainProfiles.Mainnet);
Assert.Equal("Legacy", doc.ScriptKind);
}
[Fact]
public void NewFromAddresses_piu_indirizzi_sono_tutti_scansionabili()
{
var addr1 = SampleAddress();
var addr2 = WalletLoader.NewFromMnemonic(ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
.Account.GetReceiveAddress(0).ToString();
var (_, account) = WalletLoader.NewFromAddresses([addr1, addr2], ChainProfiles.Mainnet);
Assert.NotNull(account.FixedAddresses);
Assert.Equal(2, account.FixedAddresses!.Count);
Assert.True(account.FixedAddresses!.All(e => account.GetPrivateKey(e.IsChange, e.Index) is null));
}
[Fact]
public void NewFromAddresses_lista_vuota_lancia_eccezione()
{
Assert.Throws<InvalidDataException>(
() => WalletLoader.NewFromAddresses([], ChainProfiles.Mainnet));
}
[Fact]
public void NewFromAddresses_indirizzo_invalido_lancia_eccezione()
{
Assert.Throws<InvalidDataException>(
() => WalletLoader.NewFromAddresses(["not-an-address"], ChainProfiles.Mainnet));
}
[Fact]
public void NewFromAddresses_indirizzo_di_rete_sbagliata_lancia_eccezione()
{
var testnetAddr = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Testnet)
.Account.GetReceiveAddress(0).ToString();
Assert.Throws<InvalidDataException>(
() => WalletLoader.NewFromAddresses([testnetAddr], ChainProfiles.Mainnet));
}
[Fact]
public void ToAccount_da_watch_addresses_ricostruisce_lo_stesso_account()
{
var address = SampleAddress();
var (doc, _) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet);
var account = WalletLoader.ToAccount(doc);
Assert.True(account.IsWatchOnly);
Assert.Equal(address, account.GetReceiveAddress(0).ToString());
Assert.Null(account.GetPrivateKey(false, 0));
}
}