Compare commits
20
Commits
322ce8f305
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac4a05c44 | ||
|
|
0d541d0fe3 | ||
|
|
7057905d94 | ||
|
|
51f1af8786 | ||
|
|
4cd5fab736 | ||
|
|
94a474fe41 | ||
|
|
bbed21e820 | ||
|
|
06f512e2f7 | ||
|
|
5bb94c071f | ||
|
|
11b6a9a9ab | ||
|
|
9b00002e39 | ||
|
|
f0fb5bfcc6 | ||
|
|
9fa5440ae5 | ||
|
|
214abd2892 | ||
|
|
6d05a88073 | ||
|
|
d9dd05aa52 | ||
|
|
b6440484c1 | ||
|
|
1a4fefadc3 | ||
|
|
3460e53b4f | ||
|
|
feb765663c |
@@ -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.
|
- **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).
|
- 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.
|
- 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.
|
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||||
|
|
||||||
|
|||||||
+116
@@ -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
|
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||||
release and covers the full history from the initial commit.
|
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
|
## [1.0.0] — 2026-07-09
|
||||||
|
|
||||||
First stable release. Closes the last open security gap from 0.9.x (header
|
First stable release. Closes the last open security gap from 0.9.x (header
|
||||||
|
|||||||
@@ -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.
|
- **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).
|
- 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.
|
- 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.
|
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ Unlike generic wallets adapted to many coins, Palladium Wallet is designed aroun
|
|||||||
|
|
||||||
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
||||||
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
||||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub.
|
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub or from plain addresses (no key material at all).
|
||||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
|
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped); watch-only wallets export an unsigned PSBT for offline signing. Multisig script kinds are defined in the network profile but not yet implemented (planned, see `Core/Crypto/DerivationPaths.cs`).
|
||||||
- **Multi-network**: mainnet, testnet, regtest.
|
- **Multi-network**: mainnet, testnet, regtest.
|
||||||
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
||||||
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German.
|
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German, Chinese (Simplified).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -331,6 +331,9 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
|||||||
|
|
||||||
## User guide (quick)
|
## User guide (quick)
|
||||||
|
|
||||||
|
A condensed overview follows; for the complete, exhaustive walkthrough (every screen, every
|
||||||
|
validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md).
|
||||||
|
|
||||||
### First launch
|
### First launch
|
||||||
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
||||||
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
||||||
@@ -362,13 +365,18 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
|||||||
# Wallet
|
# Wallet
|
||||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
||||||
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
||||||
|
dotnet run --project src/Cli -- 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]
|
dotnet run --project src/Cli -- info [--net ...] [--password P]
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
||||||
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
||||||
|
dotnet run --project src/Cli -- servers [--discover]
|
||||||
```
|
```
|
||||||
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
||||||
|
Run without arguments for the full command list (also covers `newseed`, `addresses`, `reset-certs`);
|
||||||
|
see [USERGUIDE.md §17](USERGUIDE.md#17-command-line-interface-cli) for complete flag reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -41,6 +41,20 @@ It cannot (given correct Merkle verification):
|
|||||||
- Fabricate a confirmed transaction with a valid Merkle proof
|
- Fabricate a confirmed transaction with a valid Merkle proof
|
||||||
- Forge a payment to a wrong address
|
- Forge a payment to a wrong address
|
||||||
|
|
||||||
|
**Progressive verification (mobile-friendly sync).** On a wallet with many historical
|
||||||
|
transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof:
|
||||||
|
balance and history are shown as soon as transaction downloads finish (`PartialResult`,
|
||||||
|
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
|
||||||
|
each transaction's `Verified` flag catches up progressively. This means the UI can display a
|
||||||
|
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
|
||||||
|
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
|
||||||
|
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
|
||||||
|
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
|
||||||
|
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
|
||||||
|
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
|
||||||
|
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
|
||||||
|
a sync, never a partial one, so no unverified data survives a restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key and seed management
|
## Key and seed management
|
||||||
@@ -48,7 +62,7 @@ It cannot (given correct Merkle verification):
|
|||||||
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock
|
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock
|
||||||
- Private keys are never written to disk, logged, or sent over the network
|
- Private keys are never written to disk, logged, or sent over the network
|
||||||
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
|
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
|
||||||
- Watch-only wallets (`restore-xpub`) hold no private keys and cannot sign transactions
|
- Watch-only wallets (`restore-xpub`, or `restore-address` for pure address imports) hold no private keys and cannot sign transactions; `TransactionFactory` only calls `AddKeys`/signs when the account reports a private key, so an address-only import can never produce a signed transaction, only an exportable unsigned PSBT
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,7 +84,7 @@ Connections to the indexing server use TOFU (Trust On First Use): the server's T
|
|||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device.
|
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets (restored from xpub or from a plain address), the private keys must be kept in a separate cold storage device.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 coin control (automatic UTXO selection only)
|
||||||
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
||||||
- No Lightning Network support
|
- No Lightning Network support
|
||||||
|
- No multisig (M-of-N) wallets: the network profile defines multisig SLIP-132 header
|
||||||
|
variants, but derivation for them is not implemented — attempting to use one throws
|
||||||
|
rather than silently producing an insecure/incorrect wallet
|
||||||
|
|||||||
+24
-1
@@ -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
|
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.
|
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
|
## 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. |
|
| 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. |
|
| 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). |
|
| 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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -74,8 +74,9 @@ Running without arguments shows an interactive menu — pick a single target or
|
|||||||
Targets:
|
Targets:
|
||||||
windows Win x64 single-file executable (native libs embedded)
|
windows Win x64 single-file executable (native libs embedded)
|
||||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
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)
|
android Android APK (release-signed, prompts for keystore passwords)
|
||||||
all All three targets
|
all All targets above
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
||||||
@@ -86,6 +87,7 @@ Examples:
|
|||||||
```bash
|
```bash
|
||||||
./docker/build.sh all # build everything
|
./docker/build.sh all # build everything
|
||||||
./docker/build.sh windows # Windows only
|
./docker/build.sh windows # Windows only
|
||||||
|
./docker/build.sh linux-arm64 # Linux ARM64 only
|
||||||
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,9 +99,10 @@ All artifacts land in `dist/` at the repository root. The version number is
|
|||||||
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
||||||
|
|
||||||
| Target | Path |
|
| Target | Path |
|
||||||
|---------|--------------------------------------------------|
|
|-------------|-----------------------------------------------------------|
|
||||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||||
|
| Linux ARM64 | `dist/linux-arm64/PalladiumWallet-{ver}-linux-arm64` |
|
||||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||||
|
|
||||||
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
||||||
@@ -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),
|
transfer it through a channel that strips permissions (e.g. a web download),
|
||||||
restore the execute bit with `chmod +x`.
|
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
|
**Android** — a release-signed APK for sideloading: transfer it to the phone
|
||||||
and open it (enable "install from unknown sources" if prompted), or install
|
and open it (enable "install from unknown sources" if prompted), or install
|
||||||
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
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 |
|
| 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 |
|
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
|
||||||
|
|
||||||
Images are built automatically the first time a target needs them and reused
|
Images are built automatically the first time a target needs them and reused
|
||||||
|
|||||||
+24
-4
@@ -29,8 +29,9 @@ $(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
|||||||
$(bold "Targets:")
|
$(bold "Targets:")
|
||||||
windows Win x64 single-file executable → dist/windows/
|
windows Win x64 single-file executable → dist/windows/
|
||||||
linux Linux x64 single-file binary → dist/linux/
|
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/
|
android Android APK (release-signed) → dist/android/
|
||||||
all All three targets
|
all All targets above
|
||||||
|
|
||||||
$(bold "Options:")
|
$(bold "Options:")
|
||||||
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
||||||
@@ -50,7 +51,7 @@ TARGET=""
|
|||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
windows|linux|android|all) TARGET="$arg" ;;
|
windows|linux|linux-arm64|android|all) TARGET="$arg" ;;
|
||||||
--rebuild) REBUILD=true ;;
|
--rebuild) REBUILD=true ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
||||||
@@ -62,10 +63,10 @@ if [[ -z "$TARGET" ]]; then
|
|||||||
bold "PalladiumWallet — reproducible build"
|
bold "PalladiumWallet — reproducible build"
|
||||||
echo ""
|
echo ""
|
||||||
PS3="Select target: "
|
PS3="Select target: "
|
||||||
options=("windows" "linux" "android" "all" "quit")
|
options=("windows" "linux" "linux-arm64" "android" "all" "quit")
|
||||||
select opt in "${options[@]}"; do
|
select opt in "${options[@]}"; do
|
||||||
case "$opt" in
|
case "$opt" in
|
||||||
windows|linux|android|all) TARGET="$opt"; break ;;
|
windows|linux|linux-arm64|android|all) TARGET="$opt"; break ;;
|
||||||
quit) echo "Aborted."; exit 0 ;;
|
quit) echo "Aborted."; exit 0 ;;
|
||||||
*) echo "Invalid choice, try again." ;;
|
*) echo "Invalid choice, try again." ;;
|
||||||
esac
|
esac
|
||||||
@@ -172,6 +173,23 @@ build_linux() {
|
|||||||
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
|
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() {
|
build_android() {
|
||||||
ensure_android_image
|
ensure_android_image
|
||||||
|
|
||||||
@@ -223,10 +241,12 @@ START=$(date +%s)
|
|||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
windows) build_windows ;;
|
windows) build_windows ;;
|
||||||
linux) build_linux ;;
|
linux) build_linux ;;
|
||||||
|
linux-arm64) build_linux_arm64 ;;
|
||||||
android) build_android ;;
|
android) build_android ;;
|
||||||
all)
|
all)
|
||||||
build_windows
|
build_windows
|
||||||
build_linux
|
build_linux
|
||||||
|
build_linux_arm64
|
||||||
build_android
|
build_android
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Android.Content;
|
|||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
using Android.OS;
|
using Android.OS;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using AvaloniaApp = PalladiumWallet.App.App;
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public class MainActivity : AvaloniaMainActivity
|
|||||||
internal const int ScanRequestCode = 9001;
|
internal const int ScanRequestCode = 9001;
|
||||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||||
internal static MainActivity? Current;
|
internal static MainActivity? Current;
|
||||||
|
private bool _wasPaused;
|
||||||
|
|
||||||
protected override void OnCreate(Bundle? savedInstanceState)
|
protected override void OnCreate(Bundle? savedInstanceState)
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,26 @@ public class MainActivity : AvaloniaMainActivity
|
|||||||
Current = this;
|
Current = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnPause()
|
||||||
|
{
|
||||||
|
base.OnPause();
|
||||||
|
_wasPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnResume()
|
||||||
|
{
|
||||||
|
base.OnResume();
|
||||||
|
if (!_wasPaused) return;
|
||||||
|
_wasPaused = false;
|
||||||
|
// The TCP socket can die silently while the screen was off/locked (Doze,
|
||||||
|
// mobile radio suspend, NAT timeout) without the app ever observing the
|
||||||
|
// failure. Force an immediate health check instead of waiting for the next
|
||||||
|
// 20s keep-alive tick, which may itself have been suspended for longer than
|
||||||
|
// the lock.
|
||||||
|
if (AvaloniaApp.MainViewModel is { } vm)
|
||||||
|
_ = vm.CheckConnectionOnResumeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||||
{
|
{
|
||||||
base.OnActivityResult(requestCode, resultCode, data);
|
base.OnActivityResult(requestCode, resultCode, data);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||||
<ApplicationVersion>3</ApplicationVersion>
|
<ApplicationVersion>4</ApplicationVersion>
|
||||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ namespace PalladiumWallet.App;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
/// <summary>Set once the single ViewModel is created; lets platform heads (e.g. the
|
||||||
|
/// Android activity) reach it for lifecycle events without a second instance.</summary>
|
||||||
|
public static MainWindowViewModel? MainViewModel { get; private set; }
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -16,6 +20,7 @@ public partial class App : Application
|
|||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
var vm = new MainWindowViewModel();
|
var vm = new MainWindowViewModel();
|
||||||
|
MainViewModel = vm;
|
||||||
|
|
||||||
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
||||||
// view. Same shared UI (MainView) and same ViewModel.
|
// view. Same shared UI (MainView) and same ViewModel.
|
||||||
|
|||||||
+280
-236
@@ -11,8 +11,8 @@ public sealed class Loc
|
|||||||
{
|
{
|
||||||
public static Loc Instance { get; private set; } = new();
|
public static Loc Instance { get; private set; } = new();
|
||||||
|
|
||||||
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
|
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de", "zh"];
|
||||||
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
|
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch", "中文"];
|
||||||
|
|
||||||
public string Language { get; private set; } = "en";
|
public string Language { get; private set; } = "en";
|
||||||
|
|
||||||
@@ -42,256 +42,283 @@ public sealed class Loc
|
|||||||
|
|
||||||
private static readonly Dictionary<string, string[]> Strings = new()
|
private static readonly Dictionary<string, string[]> Strings = new()
|
||||||
{
|
{
|
||||||
// Menu it en es fr pt de
|
// Menu it en es fr pt de zh
|
||||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
|
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei", "_文件"],
|
||||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
|
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…", "新建/恢复钱包…"],
|
||||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
|
||||||
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden"],
|
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden", "退出"],
|
||||||
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
|
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk", "_网络"],
|
||||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
|
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)", "发现服务器(节点)"],
|
||||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
|
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen", "重置 SSL 证书"],
|
||||||
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
|
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen", "_设置"],
|
||||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe", "_帮助"],
|
||||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über", "关于"],
|
||||||
["help.info"] = [
|
["help.info"] = [
|
||||||
"Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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."],
|
"Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert.",
|
||||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
"轻量级、非托管的 Palladium(PLM)网络 SPV 钱包。本地安全:种子和密钥始终加密,绝不在网络上明文传输。"],
|
||||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info", "信息"],
|
||||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden", "捐赠"],
|
||||||
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch"],
|
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden", "报告错误"],
|
||||||
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch", "用户指南"],
|
||||||
["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.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar", "有可用更新"],
|
||||||
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
["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.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen"],
|
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen", "下载"],
|
||||||
|
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen", "忽略"],
|
||||||
["donate.desc"] = [
|
["donate.desc"] = [
|
||||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
||||||
"If you find this wallet useful, consider a small donation to the developer.",
|
"If you find this wallet useful, consider a small donation to the developer.",
|
||||||
"Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.",
|
"Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.",
|
||||||
"Si ce portefeuille vous est utile, envisagez un petit don au développeur.",
|
"Si ce portefeuille vous est utile, envisagez un petit don au développeur.",
|
||||||
"Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.",
|
"Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.",
|
||||||
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler."],
|
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler.",
|
||||||
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse"],
|
"如果您觉得这个钱包有用,请考虑向开发者捐赠一点。"],
|
||||||
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag"],
|
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse", "开发者地址"],
|
||||||
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten"],
|
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag", "捐赠金额"],
|
||||||
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden"],
|
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten", "准备捐赠"],
|
||||||
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
|
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden", "确认并发送"],
|
||||||
|
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit", "单位"],
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
|
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden", "数据存储位置"],
|
||||||
["wiz.data.info"] = [
|
["wiz.data.info"] = [
|
||||||
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
|
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
|
||||||
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
|
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
|
||||||
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
|
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
|
||||||
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
|
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
|
||||||
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
|
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
|
||||||
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
|
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen.",
|
||||||
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
|
"选择用于存储钱包、配置和证书的文件夹。可以使用默认路径,也可以自行选择。"],
|
||||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:", "默认路径:"],
|
||||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden", "使用默认路径"],
|
||||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…", "选择文件夹…"],
|
||||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen", "选择要打开的钱包"],
|
||||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
|
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen", "打开现有钱包"],
|
||||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen", "创建新钱包"],
|
||||||
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren"],
|
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
|
||||||
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"],
|
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"],
|
||||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
|
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"],
|
||||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"],
|
["wiz.importaddress.btn"] = ["Importa indirizzo (sola lettura)", "Import address (watch-only)", "Importar dirección (solo lectura)", "Importer une adresse (lecture seule)", "Importar endereço (somente leitura)", "Adresse importieren (nur lesend)", "导入地址(仅观察)"],
|
||||||
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
|
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"],
|
||||||
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)"],
|
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)", "文件密码(未设置则留空)"],
|
||||||
|
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen", "打开"],
|
||||||
|
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)", "您的种子(12 个单词)"],
|
||||||
["wiz.seed.warning"] = [
|
["wiz.seed.warning"] = [
|
||||||
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
|
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
|
||||||
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable.",
|
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable.",
|
||||||
"Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
|
"Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
|
||||||
"Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
|
"Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
|
||||||
"Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
|
"Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
|
||||||
"Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren."],
|
"Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren.",
|
||||||
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter"],
|
"请按顺序将这些单词写在纸上。持有这些单词的人即可控制资金;一旦丢失,资金将无法找回。"],
|
||||||
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"],
|
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter", "已记录 — 下一步"],
|
||||||
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben"],
|
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen", "确认种子"],
|
||||||
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben", "重新输入以空格分隔的 12 个单词"],
|
||||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
|
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
|
||||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)", "BIP39 助记词(12 或 24 个单词,以空格分隔)"],
|
||||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
|
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase", "可选密码短语"],
|
||||||
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
|
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen", "留空以跳过"],
|
||||||
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)"],
|
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)", "钱包名称(可选)"],
|
||||||
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen."],
|
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)", "例如:储蓄、交易…(留空则自动命名)"],
|
||||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
|
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen.", "已存在同名钱包,请选择其他名称。"],
|
||||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
|
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort", "钱包文件密码"],
|
||||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
|
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)", "建议设置(留空 = 文件在磁盘上以明文保存)"],
|
||||||
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"],
|
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen", "创建钱包"],
|
||||||
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"],
|
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen", "重复密码"],
|
||||||
|
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln", "使用密码加密钱包文件"],
|
||||||
["wiz.password.encrypt.hint"] = [
|
["wiz.password.encrypt.hint"] = [
|
||||||
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
|
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
|
||||||
"Warning: without encryption the seed stays in plaintext on disk.",
|
"Warning: without encryption the seed stays in plaintext on disk.",
|
||||||
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
|
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
|
||||||
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
|
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
|
||||||
"Atenção: sem criptografia a semente fica em texto simples no disco.",
|
"Atenção: sem criptografia a semente fica em texto simples no disco.",
|
||||||
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
|
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte.",
|
||||||
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
|
"警告:不加密的话,种子将以明文形式保存在磁盘上。"],
|
||||||
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
|
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück", "返回"],
|
||||||
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen"],
|
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter", "下一步"],
|
||||||
|
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen", "脚本类型和地址"],
|
||||||
["wiz.scripttype.hint"] = [
|
["wiz.scripttype.hint"] = [
|
||||||
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
|
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
|
||||||
"Determines the address format. If unsure, use Native SegWit.",
|
"Determines the address format. If unsure, use Native SegWit.",
|
||||||
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
|
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
|
||||||
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
|
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
|
||||||
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
|
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
|
||||||
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit."],
|
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit.",
|
||||||
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen"],
|
"决定地址格式。如果不确定,请使用原生隔离见证(Native SegWit)。"],
|
||||||
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen"],
|
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen", "BIP44 · m/44'/… · P 地址"],
|
||||||
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen"],
|
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen", "BIP49 · m/49'/… · 3 地址"],
|
||||||
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen"],
|
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen", "BIP84 · m/84'/… · plm1q 地址 — 推荐"],
|
||||||
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"],
|
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen", "BIP86 · m/86'/… · plm1p 地址"],
|
||||||
|
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren", "导入扩展密钥"],
|
||||||
["wiz.importxkey.hint"] = [
|
["wiz.importxkey.hint"] = [
|
||||||
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
|
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
|
||||||
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
|
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
|
||||||
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
|
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
|
||||||
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
|
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
|
||||||
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
|
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
|
||||||
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt."],
|
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt.",
|
||||||
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
"粘贴 xpub/zpub/ypub(仅观察)或 xprv/zprv/yprv(可花费)。脚本类型将自动识别。"],
|
||||||
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren"],
|
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
||||||
|
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren", "导入 WIF 私钥"],
|
||||||
["wiz.importwif.hint"] = [
|
["wiz.importwif.hint"] = [
|
||||||
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
|
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
|
||||||
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
|
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
|
||||||
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
|
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
|
||||||
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
|
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
|
||||||
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
|
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
|
||||||
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."],
|
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten.",
|
||||||
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"],
|
"粘贴一个或多个 WIF 密钥(每行一个)。您可以导入多个密钥,用同一个钱包控制多个地址。"],
|
||||||
|
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)", "K… / L… / 5…(每行一个密钥)"],
|
||||||
|
["wiz.importaddress.title"] = ["Importa indirizzi in sola lettura", "Import watch-only addresses", "Importar direcciones de solo lectura", "Importer des adresses en lecture seule", "Importar endereços somente leitura", "Nur-Lese-Adressen importieren", "导入仅观察地址"],
|
||||||
|
["wiz.importaddress.hint"] = [
|
||||||
|
"Incolla uno o più indirizzi (uno per riga). Nessuna chiave privata è coinvolta: potrai vedere saldo e cronologia ma non potrai mai firmare o inviare transazioni da questo wallet.",
|
||||||
|
"Paste one or more addresses (one per line). No private key is involved: you'll be able to see the balance and history but you can never sign or send transactions from this wallet.",
|
||||||
|
"Pega una o más direcciones (una por línea). No hay ninguna clave privada involucrada: podrás ver el saldo y el historial, pero nunca podrás firmar ni enviar transacciones desde este wallet.",
|
||||||
|
"Collez une ou plusieurs adresses (une par ligne). Aucune clé privée n'est impliquée : vous pourrez voir le solde et l'historique, mais vous ne pourrez jamais signer ni envoyer de transactions depuis ce wallet.",
|
||||||
|
"Cole um ou mais endereços (um por linha). Nenhuma chave privada está envolvida: você poderá ver o saldo e o histórico, mas nunca poderá assinar ou enviar transações a partir desta carteira.",
|
||||||
|
"Fügen Sie eine oder mehrere Adressen ein (eine pro Zeile). Es ist kein privater Schlüssel beteiligt: Sie können den Kontostand und den Verlauf sehen, aber nie Transaktionen aus diesem Wallet signieren oder senden.",
|
||||||
|
"粘贴一个或多个地址(每行一个)。不涉及任何私钥:您可以查看余额和历史记录,但永远无法从此钱包签名或发送交易。"],
|
||||||
|
["wiz.importaddress.placeholder"] = ["Indirizzo… (uno per riga)", "Address… (one per line)", "Dirección… (una por línea)", "Adresse… (une par ligne)", "Endereço… (um por linha)", "Adresse… (eine pro Zeile)", "地址…(每行一个)"],
|
||||||
|
|
||||||
// Import error messages
|
// Import error messages
|
||||||
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."],
|
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante).", "请粘贴扩展密钥(xpub/xprv 或其变体)。"],
|
||||||
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."],
|
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt.", "此网络无法识别该扩展密钥。"],
|
||||||
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."],
|
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein.", "请至少粘贴一个 WIF 密钥。"],
|
||||||
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."],
|
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig.", "该 WIF 密钥对此网络无效。"],
|
||||||
|
["msg.address.required"] = ["Incolla almeno un indirizzo.", "Paste at least one address.", "Pega al menos una dirección.", "Collez au moins une adresse.", "Cole pelo menos um endereço.", "Fügen Sie mindestens eine Adresse ein.", "请至少粘贴一个地址。"],
|
||||||
|
["msg.address.invalid"] = ["Indirizzo non valido per questa rete.", "Address not valid for this network.", "Dirección no válida para esta red.", "Adresse invalide pour ce réseau.", "Endereço inválido para esta rede.", "Adresse für dieses Netzwerk ungültig.", "该地址对此网络无效。"],
|
||||||
|
|
||||||
// Wallet panel
|
// Wallet panel
|
||||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
|
||||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:", "服务器:"],
|
||||||
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden"],
|
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden", "连接"],
|
||||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port", "或手动输入 host:port"],
|
||||||
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren"],
|
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren", "同步服务器"],
|
||||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen", "重置证书"],
|
||||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen", "接收"],
|
||||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf", "历史"],
|
||||||
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
|
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen", "地址"],
|
||||||
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
|
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden", "发送"],
|
||||||
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"],
|
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte", "联系人"],
|
||||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"],
|
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:", "下一个未使用地址:"],
|
||||||
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"],
|
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren", "复制"],
|
||||||
["receive.hint"] = [
|
["receive.hint"] = [
|
||||||
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
||||||
"Payments received here will appear in the history at the next synchronization.",
|
"Payments received here will appear in the history at the next synchronization.",
|
||||||
"Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
|
"Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
|
||||||
"Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
|
"Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
|
||||||
"Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
|
"Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
|
||||||
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."],
|
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf.",
|
||||||
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"],
|
"在此收到的付款将在下次同步时出现在历史记录中。"],
|
||||||
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
|
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ", "类型"],
|
||||||
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index", "索引"],
|
||||||
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
|
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
|
||||||
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"],
|
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo", "余额"],
|
||||||
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
|
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert", "地址已复制到剪贴板"],
|
||||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
|
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:", "派生路径:"],
|
||||||
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
|
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:", "公钥:"],
|
||||||
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
|
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):", "私钥(WIF):"],
|
||||||
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen"],
|
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen", "显示"],
|
||||||
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen."],
|
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen", "确认身份"],
|
||||||
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen.", "输入钱包密码以显示私钥。"],
|
||||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
|
||||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang", "接收"],
|
||||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld", "找零"],
|
||||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen", "地址信息"],
|
||||||
|
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen", "关闭"],
|
||||||
|
|
||||||
// History → transaction detail
|
// History → transaction detail
|
||||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details.", "双击某笔交易可查看详情。"],
|
||||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails", "交易详情"],
|
||||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen", "关闭"],
|
||||||
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"],
|
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…", "正在从服务器加载交易数据…"],
|
||||||
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"],
|
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status", "状态"],
|
||||||
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"],
|
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool", "0 次确认 · 在内存池中"],
|
||||||
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
|
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen", "次确认"],
|
||||||
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"],
|
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block", "区块"],
|
||||||
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"],
|
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool", "在内存池中"],
|
||||||
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
|
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum", "日期"],
|
||||||
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
|
["tx.to"] = ["A", "To", "Para", "À", "Para", "An", "至"],
|
||||||
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
|
["tx.from"] = ["Da", "From", "De", "De", "De", "Von", "来自"],
|
||||||
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
|
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll", "支出"],
|
||||||
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"],
|
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben", "收入"],
|
||||||
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"],
|
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr", "交易手续费"],
|
||||||
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"],
|
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte", "每虚拟字节手续费"],
|
||||||
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"],
|
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag", "净额"],
|
||||||
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
|
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID", "交易 ID"],
|
||||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
|
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße", "总大小"],
|
||||||
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
|
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe", "虚拟大小"],
|
||||||
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
|
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)", "可替换(RBF)"],
|
||||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge", "输入"],
|
||||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge", "输出"],
|
||||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja", "是"],
|
||||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein", "否"],
|
||||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen.", "连接服务器以查看交易详情。"],
|
||||||
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht"],
|
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht", "概览"],
|
||||||
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren"],
|
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren", "金额与手续费"],
|
||||||
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details"],
|
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details", "技术详情"],
|
||||||
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase"],
|
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "创币交易"],
|
||||||
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)"],
|
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)", "新生成(挖矿)"],
|
||||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:", "来自联系人:"],
|
||||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen", "选择以填充地址"],
|
||||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse", "收款地址"],
|
||||||
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
|
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag", "金额"],
|
||||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden", "全部发送"],
|
||||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:", "手续费 sat/vB:"],
|
||||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten", "准备交易"],
|
||||||
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen", "扫描二维码"],
|
||||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN", "确认并广播"],
|
||||||
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger"],
|
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger", "收款人"],
|
||||||
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr"],
|
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr", "金额与手续费"],
|
||||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
|
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung", "摘要"],
|
||||||
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
|
["send.psbt.label"] = ["PSBT non firmata (base64) — da firmare altrove", "Unsigned PSBT (base64) — sign it elsewhere", "PSBT sin firmar (base64) — fírmala en otro lugar", "PSBT non signée (base64) — à signer ailleurs", "PSBT não assinada (base64) — assine em outro lugar", "Unsignierte PSBT (base64) — anderswo signieren", "未签名 PSBT(base64)— 请在别处签名"],
|
||||||
|
["send.psbt.copy"] = ["Copia PSBT", "Copy PSBT", "Copiar PSBT", "Copier la PSBT", "Copiar PSBT", "PSBT kopieren", "复制 PSBT"],
|
||||||
|
["send.watchonly.hint"] = ["Wallet in sola lettura: non può firmare. Esporta la PSBT e firmala con un wallet che possiede le chiavi.", "Watch-only wallet: it cannot sign. Export the PSBT and sign it with a wallet that holds the keys.", "Wallet de solo lectura: no puede firmar. Exporta la PSBT y fírmala con un wallet que tenga las claves.", "Wallet en lecture seule : il ne peut pas signer. Exportez la PSBT et signez-la avec un wallet possédant les clés.", "Carteira somente leitura: não pode assinar. Exporte a PSBT e assine-a com uma carteira que tenha as chaves.", "Nur-Lese-Wallet: kann nicht signieren. Exportieren Sie die PSBT und signieren Sie sie mit einem Wallet, das die Schlüssel besitzt.", "仅观察钱包:无法签名。请导出 PSBT 并用持有密钥的钱包签名。"],
|
||||||
|
["psbt.copied"] = ["PSBT copiata negli appunti", "PSBT copied to clipboard", "PSBT copiada al portapapeles", "PSBT copiée dans le presse-papiers", "PSBT copiada para a área de transferência", "PSBT in die Zwischenablage kopiert", "PSBT 已复制到剪贴板"],
|
||||||
|
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse", "您的地址"],
|
||||||
|
|
||||||
// Wallet info overlay
|
// Wallet info overlay
|
||||||
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet"],
|
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_钱包"],
|
||||||
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen"],
|
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen", "钱包信息"],
|
||||||
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei"],
|
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei", "文件"],
|
||||||
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk"],
|
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk", "网络"],
|
||||||
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ"],
|
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ", "钱包类型"],
|
||||||
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)"],
|
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)", "HD(BIP39 种子)"],
|
||||||
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)"],
|
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)", "HD(已导入 xprv)"],
|
||||||
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel"],
|
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel", "已导入 WIF 密钥"],
|
||||||
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)"],
|
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "仅观察(xpub)"],
|
||||||
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script"],
|
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script", "脚本"],
|
||||||
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad"],
|
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad", "派生路径"],
|
||||||
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel"],
|
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel", "扩展公钥"],
|
||||||
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint"],
|
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint", "主指纹"],
|
||||||
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)"],
|
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)", "种子(BIP39 助记词)"],
|
||||||
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert)."],
|
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert).", "此钱包没有种子(仅观察或已导入)。"],
|
||||||
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:"],
|
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:", "输入钱包文件密码以解锁种子:"],
|
||||||
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen"],
|
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen", "显示种子"],
|
||||||
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
|
||||||
["walletinfo.seed.warning"] = [
|
["walletinfo.seed.warning"] = [
|
||||||
"Non condividere mai queste parole. Chi le possiede controlla i fondi.",
|
"Non condividere mai queste parole. Chi le possiede controlla i fondi.",
|
||||||
"Never share these words. Whoever holds them controls the funds.",
|
"Never share these words. Whoever holds them controls the funds.",
|
||||||
"Nunca compartas estas palabras. Quien las tenga controla los fondos.",
|
"Nunca compartas estas palabras. Quien las tenga controla los fondos.",
|
||||||
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
|
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
|
||||||
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
|
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
|
||||||
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder."],
|
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder.",
|
||||||
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase"],
|
"切勿分享这些单词。持有者即可控制资金。"],
|
||||||
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)"],
|
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase", "BIP39 密码短语"],
|
||||||
|
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)", "(已设置)"],
|
||||||
|
|
||||||
// Connection status
|
// Connection status
|
||||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden", "未连接"],
|
||||||
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt", "已断开"],
|
||||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
|
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…", "正在重新连接…"],
|
||||||
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"],
|
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler", "连接错误"],
|
||||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
|
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert", "证书已更改"],
|
||||||
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
|
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden", "已连接"],
|
||||||
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
|
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu", "正在连接"],
|
||||||
|
|
||||||
// Main status messages
|
// Main status messages
|
||||||
["msg.welcome.existing"] = [
|
["msg.welcome.existing"] = [
|
||||||
@@ -300,151 +327,168 @@ public sealed class Loc
|
|||||||
"Se encontró un wallet existente en esta red: ábrelo o crea otro.",
|
"Se encontró un wallet existente en esta red: ábrelo o crea otro.",
|
||||||
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
|
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
|
||||||
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
|
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
|
||||||
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues."],
|
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues.",
|
||||||
|
"在此网络上发现现有钱包:打开它,或创建新钱包。"],
|
||||||
["msg.welcome.new"] = [
|
["msg.welcome.new"] = [
|
||||||
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
||||||
"Welcome: create a new wallet or restore from seed.",
|
"Welcome: create a new wallet or restore from seed.",
|
||||||
"Bienvenido: crea un nuevo wallet o restaura desde semilla.",
|
"Bienvenido: crea un nuevo wallet o restaura desde semilla.",
|
||||||
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
|
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
|
||||||
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
|
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
|
||||||
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."],
|
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her.",
|
||||||
|
"欢迎:创建新钱包或从种子恢复。"],
|
||||||
["msg.open.password"] = [
|
["msg.open.password"] = [
|
||||||
"Inserisci la password del file (lascia vuoto se non impostata).",
|
"Inserisci la password del file (lascia vuoto se non impostata).",
|
||||||
"Enter the file password (leave empty if not set).",
|
"Enter the file password (leave empty if not set).",
|
||||||
"Ingresa la contraseña del archivo (deja vacío si no establecida).",
|
"Ingresa la contraseña del archivo (deja vacío si no establecida).",
|
||||||
"Entrez le mot de passe du fichier (laisser vide si non défini).",
|
"Entrez le mot de passe du fichier (laisser vide si non défini).",
|
||||||
"Digite a senha do arquivo (deixe vazio se não definida).",
|
"Digite a senha do arquivo (deixe vazio se não definida).",
|
||||||
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."],
|
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt).",
|
||||||
|
"输入文件密码(未设置则留空)。"],
|
||||||
["msg.seed.write"] = [
|
["msg.seed.write"] = [
|
||||||
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
||||||
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
|
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
|
||||||
"Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
|
"Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
|
||||||
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
|
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
|
||||||
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
|
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
|
||||||
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets."],
|
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets.",
|
||||||
|
"请将这 12 个单词按顺序写在纸上。它们是钱包唯一的备份。"],
|
||||||
["msg.seed.retype"] = [
|
["msg.seed.retype"] = [
|
||||||
"Reinserisci le 12 parole per confermare di averle scritte.",
|
"Reinserisci le 12 parole per confermare di averle scritte.",
|
||||||
"Re-enter the 12 words to confirm you wrote them down.",
|
"Re-enter the 12 words to confirm you wrote them down.",
|
||||||
"Reingresa las 12 palabras para confirmar que las has anotado.",
|
"Reingresa las 12 palabras para confirmar que las has anotado.",
|
||||||
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
|
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
|
||||||
"Reinsira as 12 palavras para confirmar que as anotou.",
|
"Reinsira as 12 palavras para confirmar que as anotou.",
|
||||||
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."],
|
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben.",
|
||||||
|
"重新输入这 12 个单词,以确认您已记录。"],
|
||||||
["msg.seed.mismatch"] = [
|
["msg.seed.mismatch"] = [
|
||||||
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
||||||
"The words do not match: check what you wrote on paper.",
|
"The words do not match: check what you wrote on paper.",
|
||||||
"Las palabras no coinciden: revisa lo que escribiste en papel.",
|
"Las palabras no coinciden: revisa lo que escribiste en papel.",
|
||||||
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
|
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
|
||||||
"As palavras não correspondem: verifique o que escreveu no papel.",
|
"As palavras não correspondem: verifique o que escreveu no papel.",
|
||||||
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."],
|
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben.",
|
||||||
|
"单词不匹配:请检查您写在纸上的内容。"],
|
||||||
["msg.words.enter"] = [
|
["msg.words.enter"] = [
|
||||||
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
||||||
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
|
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
|
||||||
"Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
|
"Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
|
||||||
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
|
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
|
||||||
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
|
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
|
||||||
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."],
|
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter).",
|
||||||
|
"输入 BIP39 助记词(12 或 24 个单词,以空格分隔)。"],
|
||||||
["msg.words.invalid"] = [
|
["msg.words.invalid"] = [
|
||||||
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
||||||
"Invalid mnemonic (wrong words or checksum): check again.",
|
"Invalid mnemonic (wrong words or checksum): check again.",
|
||||||
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
|
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
|
||||||
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
|
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
|
||||||
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
|
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
|
||||||
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."],
|
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen.",
|
||||||
|
"助记词无效(单词错误或校验和不正确):请重新检查。"],
|
||||||
["msg.passphrase.info"] = [
|
["msg.passphrase.info"] = [
|
||||||
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
|
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
|
||||||
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip.",
|
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip.",
|
||||||
"Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
|
"Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
|
||||||
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
|
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
|
||||||
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
|
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
|
||||||
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen."],
|
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen.",
|
||||||
|
"可选的 BIP39 密码短语:它会派生出一个完全不同的钱包。如果使用,请与种子分开记录;一旦丢失,资金将无法找回。留空以跳过。"],
|
||||||
["msg.password.info"] = [
|
["msg.password.info"] = [
|
||||||
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
|
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
|
||||||
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file.",
|
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file.",
|
||||||
"Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
|
"Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
|
||||||
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
|
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
|
||||||
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
|
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
|
||||||
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
|
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei.",
|
||||||
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
|
"用于加密磁盘上钱包文件的密码(建议设置)。它不能替代种子,仅用于保护文件。"],
|
||||||
|
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines.", "有多个可用钱包:请选择一个。"],
|
||||||
["msg.password.required"] = [
|
["msg.password.required"] = [
|
||||||
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
|
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
|
||||||
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
|
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
|
||||||
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
|
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
|
||||||
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
|
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
|
||||||
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
|
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
|
||||||
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
|
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“).",
|
||||||
|
"请输入密码以加密钱包(或取消勾选“加密钱包文件”)。"],
|
||||||
["msg.password.mismatch"] = [
|
["msg.password.mismatch"] = [
|
||||||
"Le due password non coincidono.",
|
"Le due password non coincidono.",
|
||||||
"The two passwords do not match.",
|
"The two passwords do not match.",
|
||||||
"Las dos contraseñas no coinciden.",
|
"Las dos contraseñas no coinciden.",
|
||||||
"Les deux mots de passe ne correspondent pas.",
|
"Les deux mots de passe ne correspondent pas.",
|
||||||
"As duas senhas não coincidem.",
|
"As duas senhas não coincidem.",
|
||||||
"Die beiden Passwörter stimmen nicht überein."],
|
"Die beiden Passwörter stimmen nicht überein.",
|
||||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
|
"两次输入的密码不一致。"],
|
||||||
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."],
|
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort.", "密码错误。"],
|
||||||
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."],
|
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet.", "钱包已在应用程序的另一个实例中打开。"],
|
||||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
|
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen.", "无法访问钱包文件:请检查文件权限。"],
|
||||||
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
|
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…", "钱包已打开:正在连接服务器…"],
|
||||||
|
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert", "已同步"],
|
||||||
["msg.synced.detail"] = [
|
["msg.synced.detail"] = [
|
||||||
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
||||||
"SPV-verified transactions. Real-time updates active.",
|
"SPV-verified transactions. Real-time updates active.",
|
||||||
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
|
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
|
||||||
"transactions vérifiées SPV. Mises à jour en temps réel actives.",
|
"transactions vérifiées SPV. Mises à jour en temps réel actives.",
|
||||||
"transações verificadas SPV. Atualizações em tempo real ativas.",
|
"transações verificadas SPV. Atualizações em tempo real ativas.",
|
||||||
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
|
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv.",
|
||||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
"已通过 SPV 验证的交易。实时更新已启用。"],
|
||||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe", "高度"],
|
||||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung", "等待确认"],
|
||||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
["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.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung", "成熟中"],
|
||||||
|
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft", "正在进行 SPV 验证"],
|
||||||
|
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…", "验证中…"],
|
||||||
|
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert.", "设置已保存。"],
|
||||||
["msg.certreset"] = [
|
["msg.certreset"] = [
|
||||||
"Certificati SSL azzerati: riprova la connessione.",
|
"Certificati SSL azzerati: riprova la connessione.",
|
||||||
"SSL certificates cleared: retry the connection.",
|
"SSL certificates cleared: retry the connection.",
|
||||||
"Certificados SSL restablecidos: reintenta la conexión.",
|
"Certificados SSL restablecidos: reintenta la conexión.",
|
||||||
"Certificats SSL réinitialisés : réessayez la connexion.",
|
"Certificats SSL réinitialisés : réessayez la connexion.",
|
||||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
"Certificados SSL redefinidos: tente novamente a conexão.",
|
||||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen.",
|
||||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
"SSL 证书已清除:请重试连接。"],
|
||||||
["msg.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler"],
|
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler", "错误"],
|
||||||
["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.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler", "广播错误"],
|
||||||
["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.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.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.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.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.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.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.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.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag."],
|
["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.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.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag.", "金额无效。"],
|
||||||
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen"],
|
["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.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid"],
|
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen", "已广播"],
|
||||||
["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.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"] = [
|
["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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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
|
||||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
|
||||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
|
||||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
|
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname", "联系人姓名"],
|
||||||
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
|
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse", "区块链地址"],
|
||||||
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
|
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen", "添加"],
|
||||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen", "删除所选"],
|
||||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte.", "没有已保存的联系人。"],
|
||||||
|
|
||||||
// Settings window
|
// Settings window
|
||||||
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
|
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen", "设置"],
|
||||||
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
|
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache", "语言"],
|
||||||
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
|
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit", "金额单位"],
|
||||||
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
|
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern", "保存"],
|
||||||
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
|
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen", "取消"],
|
||||||
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
|
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…", "索引服务器…"],
|
||||||
|
|
||||||
// Server window
|
// Server window
|
||||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver", "索引服务器"],
|
||||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host", "主机"],
|
||||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port", "端口"],
|
||||||
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
|
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):", "已知服务器(点击使用):"],
|
||||||
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
|
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden.", "没有已知服务器。连接后请使用“发现服务器”。"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||||
<Version>1.0.0</Version>
|
<Version>1.1.0</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string immatureText = "";
|
private string immatureText = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string verifyingText = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string networkInfo = "";
|
private string networkInfo = "";
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ public partial class MainWindowViewModel
|
|||||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||||
UnconfirmedText = "";
|
UnconfirmedText = "";
|
||||||
ImmatureText = "";
|
ImmatureText = "";
|
||||||
|
VerifyingText = "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
@@ -265,7 +269,11 @@ public partial class MainWindowViewModel
|
|||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||||
return;
|
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);
|
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||||
UnconfirmedText = pending != 0
|
UnconfirmedText = pending != 0
|
||||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||||
@@ -273,13 +281,20 @@ public partial class MainWindowViewModel
|
|||||||
ImmatureText = cache.ImmatureSats != 0
|
ImmatureText = cache.ImmatureSats != 0
|
||||||
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
||||||
: "";
|
: "";
|
||||||
|
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
|
||||||
|
// proof background verification hasn't reached yet — same "not spendable" treatment
|
||||||
|
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
|
||||||
|
VerifyingText = cache.PendingVerificationSats != 0
|
||||||
|
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
|
||||||
|
: "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
foreach (var tx in cache.History)
|
foreach (var tx in cache.History)
|
||||||
History.Add(new HistoryRow(
|
History.Add(new HistoryRow(
|
||||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||||
tx.Txid));
|
tx.Txid,
|
||||||
|
tx.Verified));
|
||||||
|
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
foreach (var a in cache.Addresses)
|
foreach (var a in cache.Addresses)
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool hasPendingSend;
|
private bool hasPendingSend;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string pendingPsbtBase64 = "";
|
||||||
|
|
||||||
|
/// <summary>True when the open account cannot sign — Send only prepares an unsigned PSBT to export.</summary>
|
||||||
|
public bool IsWatchOnlyAccount => _account?.IsWatchOnly ?? false;
|
||||||
|
|
||||||
|
public void NotifyPsbtCopied() => StatusMessage = Loc.Tr("psbt.copied");
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ScanQr()
|
private async Task ScanQr()
|
||||||
{
|
{
|
||||||
@@ -81,11 +89,13 @@ public partial class MainWindowViewModel
|
|||||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||||
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
||||||
HasPendingSend = _pendingSend.Signed;
|
HasPendingSend = _pendingSend.Signed;
|
||||||
|
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_pendingSend = null;
|
_pendingSend = null;
|
||||||
HasPendingSend = false;
|
HasPendingSend = false;
|
||||||
|
PendingPsbtBase64 = "";
|
||||||
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||||
}
|
}
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
@@ -103,6 +113,7 @@ public partial class MainWindowViewModel
|
|||||||
SendTo = SendAmount = "";
|
SendTo = SendAmount = "";
|
||||||
_pendingSend = null;
|
_pendingSend = null;
|
||||||
HasPendingSend = false;
|
HasPendingSend = false;
|
||||||
|
PendingPsbtBase64 = "";
|
||||||
await ConnectAndSync();
|
await ConnectAndSync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public partial class MainWindowViewModel
|
|||||||
public bool IsLangFr => _config.Language == "fr";
|
public bool IsLangFr => _config.Language == "fr";
|
||||||
public bool IsLangPt => _config.Language == "pt";
|
public bool IsLangPt => _config.Language == "pt";
|
||||||
public bool IsLangDe => _config.Language == "de";
|
public bool IsLangDe => _config.Language == "de";
|
||||||
|
public bool IsLangZh => _config.Language == "zh";
|
||||||
public bool IsUnitPlm => _config.Unit == "PLM";
|
public bool IsUnitPlm => _config.Unit == "PLM";
|
||||||
public bool IsUnitMilli => _config.Unit == "mPLM";
|
public bool IsUnitMilli => _config.Unit == "mPLM";
|
||||||
public bool IsUnitMicro => _config.Unit == "µPLM";
|
public bool IsUnitMicro => _config.Unit == "µPLM";
|
||||||
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
|
|||||||
OnPropertyChanged(nameof(IsLangFr));
|
OnPropertyChanged(nameof(IsLangFr));
|
||||||
OnPropertyChanged(nameof(IsLangPt));
|
OnPropertyChanged(nameof(IsLangPt));
|
||||||
OnPropertyChanged(nameof(IsLangDe));
|
OnPropertyChanged(nameof(IsLangDe));
|
||||||
|
OnPropertyChanged(nameof(IsLangZh));
|
||||||
OnPropertyChanged(nameof(IsUnitPlm));
|
OnPropertyChanged(nameof(IsUnitPlm));
|
||||||
OnPropertyChanged(nameof(IsUnitMilli));
|
OnPropertyChanged(nameof(IsUnitMilli));
|
||||||
OnPropertyChanged(nameof(IsUnitMicro));
|
OnPropertyChanged(nameof(IsUnitMicro));
|
||||||
|
|||||||
@@ -271,34 +271,41 @@ public partial class MainWindowViewModel
|
|||||||
_doc.Cache?.BlockHeaders,
|
_doc.Cache?.BlockHeaders,
|
||||||
_doc.Cache?.NextReceiveIndex ?? 0,
|
_doc.Cache?.NextReceiveIndex ?? 0,
|
||||||
_doc.Cache?.NextChangeIndex ?? 0,
|
_doc.Cache?.NextChangeIndex ?? 0,
|
||||||
net);
|
net,
|
||||||
|
_doc.Cache?.AnchoredUpTo);
|
||||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||||
|
// Progressive verification (§7.4): the wallet becomes usable as soon as
|
||||||
|
// transaction downloads finish, without waiting for every historical Merkle
|
||||||
|
// proof — critical on mobile, where verifying thousands of proofs can take far
|
||||||
|
// longer than the download itself. Never persisted to disk on its own (only the
|
||||||
|
// final, fully-verified snapshot below is saved); coin selection still refuses
|
||||||
|
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
|
||||||
|
// early can't be exploited to spend a server-fabricated balance.
|
||||||
|
_synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
var result = await _synchronizer.SyncOnceAsync(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;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
var cache = new SyncCache
|
||||||
PalladiumNetworks.For(_account.Profile.Kind));
|
|
||||||
_doc.Cache = new SyncCache
|
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
AnchoredUpTo = anchoredUpTo,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
|
||||||
ImmatureSats = result.ImmatureSats,
|
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
|
||||||
History = [.. result.History],
|
|
||||||
Utxos = [.. result.Utxos],
|
|
||||||
Addresses = [.. result.AddressRows],
|
|
||||||
RawTxHex = rawHex,
|
|
||||||
VerifiedAt = verifiedAt,
|
|
||||||
BlockHeaders = blockHeaders,
|
|
||||||
};
|
};
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
FillDisplayFields(cache, result);
|
||||||
|
_doc.Cache = cache;
|
||||||
|
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
||||||
ApplyCache(_doc.Cache);
|
ApplyCache(_doc.Cache);
|
||||||
_syncFailed = false;
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
@@ -307,11 +314,13 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
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;
|
cancelled = true;
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
ConnectionStatus = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
ConnectionStatusShort = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
catch (CertificatePinMismatchException ex)
|
catch (CertificatePinMismatchException ex)
|
||||||
@@ -324,9 +333,22 @@ public partial class MainWindowViewModel
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
|
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");
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||||
|
}
|
||||||
if (_account is not null)
|
if (_account is not null)
|
||||||
{
|
{
|
||||||
_syncFailed = true;
|
_syncFailed = true;
|
||||||
@@ -338,6 +360,7 @@ public partial class MainWindowViewModel
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsSyncing = false;
|
IsSyncing = false;
|
||||||
|
_resumeRecovering = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cancelled due to a server change, restart immediately with the new server.
|
// If cancelled due to a server change, restart immediately with the new server.
|
||||||
@@ -345,6 +368,44 @@ public partial class MainWindowViewModel
|
|||||||
_ = ConnectAndSync();
|
_ = 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>
|
/// <summary>
|
||||||
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
/// 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
|
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||||
@@ -453,12 +514,13 @@ public partial class MainWindowViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(net);
|
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = _synchronizer.ExportCaches(net);
|
||||||
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||||
return;
|
return;
|
||||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||||
_doc.Cache.VerifiedAt = verifiedAt;
|
_doc.Cache.VerifiedAt = verifiedAt;
|
||||||
_doc.Cache.BlockHeaders = blockHeaders;
|
_doc.Cache.BlockHeaders = blockHeaders;
|
||||||
|
_doc.Cache.AnchoredUpTo = anchoredUpTo;
|
||||||
WalletStore.Save(_doc, _walletPath, _password);
|
WalletStore.Save(_doc, _walletPath, _password);
|
||||||
}
|
}
|
||||||
catch { /* non-fatal: the next full save will recover */ }
|
catch { /* non-fatal: the next full save will recover */ }
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ public partial class MainWindowViewModel
|
|||||||
public const string StepScriptType = "script-type";
|
public const string StepScriptType = "script-type";
|
||||||
public const string StepImportXkey = "import-xkey";
|
public const string StepImportXkey = "import-xkey";
|
||||||
public const string StepImportWif = "import-wif";
|
public const string StepImportWif = "import-wif";
|
||||||
|
public const string StepImportAddress = "import-address";
|
||||||
public const string StepPassword = "password";
|
public const string StepPassword = "password";
|
||||||
|
|
||||||
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
|
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress }
|
||||||
private WizardFlowKind _wizardFlow;
|
private WizardFlowKind _wizardFlow;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
|
|||||||
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
|
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
|
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
|
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsStepImportAddress))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
||||||
private string setupStep = StepStart;
|
private string setupStep = StepStart;
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ public partial class MainWindowViewModel
|
|||||||
public bool IsStepScriptType => SetupStep == StepScriptType;
|
public bool IsStepScriptType => SetupStep == StepScriptType;
|
||||||
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
||||||
public bool IsStepImportWif => SetupStep == StepImportWif;
|
public bool IsStepImportWif => SetupStep == StepImportWif;
|
||||||
|
public bool IsStepImportAddress => SetupStep == StepImportAddress;
|
||||||
public bool IsStepPassword => SetupStep == StepPassword;
|
public bool IsStepPassword => SetupStep == StepPassword;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -85,6 +88,9 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string importWifInput = "";
|
private string importWifInput = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string importAddressInput = "";
|
||||||
|
|
||||||
// Script type detected during xkey decoding (to display to the user)
|
// Script type detected during xkey decoding (to display to the user)
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string importXkeyDetectedKind = "";
|
private string importXkeyDetectedKind = "";
|
||||||
@@ -214,6 +220,15 @@ public partial class MainWindowViewModel
|
|||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardStartImportAddress()
|
||||||
|
{
|
||||||
|
_wizardFlow = WizardFlowKind.ImportAddress;
|
||||||
|
ImportAddressInput = "";
|
||||||
|
SetupStep = StepImportAddress;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WizardNextFromShowSeed()
|
private void WizardNextFromShowSeed()
|
||||||
{
|
{
|
||||||
@@ -312,6 +327,35 @@ public partial class MainWindowViewModel
|
|||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardNextFromImportAddress()
|
||||||
|
{
|
||||||
|
var addresses = ImportAddressInput.Split('\n',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (addresses.Length == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.address.required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var network = PalladiumNetworks.For(Net);
|
||||||
|
foreach (var a in addresses)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = NBitcoin.BitcoinAddress.Create(a, network);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.address.invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PasswordInput = ConfirmPasswordInput = "";
|
||||||
|
EncryptWallet = true;
|
||||||
|
SetupStep = StepPassword;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WizardNextFromScriptType()
|
private void WizardNextFromScriptType()
|
||||||
{
|
{
|
||||||
@@ -327,11 +371,16 @@ public partial class MainWindowViewModel
|
|||||||
SetupStep = SetupStep switch
|
SetupStep = SetupStep switch
|
||||||
{
|
{
|
||||||
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
||||||
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart,
|
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif or StepImportAddress => StepStart,
|
||||||
StepConfirmSeed => StepShowSeed,
|
StepConfirmSeed => StepShowSeed,
|
||||||
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
|
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
|
||||||
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
|
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
|
||||||
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
|
StepPassword => _wizardFlow switch
|
||||||
|
{
|
||||||
|
WizardFlowKind.ImportXkey => StepImportXkey,
|
||||||
|
WizardFlowKind.ImportAddress => StepImportAddress,
|
||||||
|
_ => StepScriptType,
|
||||||
|
},
|
||||||
_ => StepStart,
|
_ => StepStart,
|
||||||
};
|
};
|
||||||
if (SetupStep == StepStart)
|
if (SetupStep == StepStart)
|
||||||
@@ -391,6 +440,14 @@ public partial class MainWindowViewModel
|
|||||||
(doc, account) = (d, a);
|
(doc, account) = (d, a);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case WizardFlowKind.ImportAddress:
|
||||||
|
{
|
||||||
|
var addressLines = ImportAddressInput.Split('\n',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var (d, a) = WalletLoader.NewFromAddresses(addressLines, Profile);
|
||||||
|
(doc, account) = (d, a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
var (d, a) = WalletLoader.NewFromMnemonic(
|
var (d, a) = WalletLoader.NewFromMnemonic(
|
||||||
@@ -497,13 +554,14 @@ public partial class MainWindowViewModel
|
|||||||
_walletPath = path;
|
_walletPath = path;
|
||||||
_password = password;
|
_password = password;
|
||||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
|
||||||
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
|
ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = "";
|
||||||
SetupStep = StepStart;
|
SetupStep = StepStart;
|
||||||
|
OnPropertyChanged(nameof(IsWatchOnlyAccount));
|
||||||
|
|
||||||
var walletKindTag = account switch
|
var walletKindTag = account switch
|
||||||
{
|
{
|
||||||
ImportedKeyAccount => " · imported",
|
|
||||||
{ IsWatchOnly: true } => " · watch-only",
|
{ IsWatchOnly: true } => " · watch-only",
|
||||||
|
ImportedKeyAccount => " · imported",
|
||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
|
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>Transaction history row for the view.</summary>
|
/// <summary>Transaction history row for the view.</summary>
|
||||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
|
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
|
||||||
|
|
||||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||||
public sealed record AddressRow(
|
public sealed record AddressRow(
|
||||||
@@ -77,6 +77,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
// ---- keep-alive ----
|
// ---- keep-alive ----
|
||||||
private bool _autoReconnect;
|
private bool _autoReconnect;
|
||||||
private bool _syncFailed;
|
private bool _syncFailed;
|
||||||
|
private bool _resumeRecovering;
|
||||||
private readonly DispatcherTimer _keepAliveTimer;
|
private readonly DispatcherTimer _keepAliveTimer;
|
||||||
|
|
||||||
// ---- server UI sync ----
|
// ---- server UI sync ----
|
||||||
@@ -161,11 +162,19 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_ = CheckForUpdatesAsync();
|
_ = CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _keepAliveRunning;
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||||
{
|
{
|
||||||
if (IsSyncing)
|
// Re-entrancy guard: without it, a ping stuck on a half-open socket (see
|
||||||
|
// below) would let every subsequent 20s tick pile up another concurrent
|
||||||
|
// ping/reconnect attempt on top of it.
|
||||||
|
if (IsSyncing || _keepAliveRunning)
|
||||||
return;
|
return;
|
||||||
if (_client is { IsConnected: true })
|
_keepAliveRunning = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_client is { IsConnected: true } client)
|
||||||
{
|
{
|
||||||
// If the wallet is open and the last sync failed, retry automatically.
|
// If the wallet is open and the last sync failed, retry automatically.
|
||||||
if (_syncFailed && _account is not null)
|
if (_syncFailed && _account is not null)
|
||||||
@@ -173,8 +182,30 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
await ConnectAndSync();
|
await ConnectAndSync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try { await _client.PingAsync(); }
|
try
|
||||||
catch { }
|
{
|
||||||
|
// 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)
|
else if (_autoReconnect)
|
||||||
{
|
{
|
||||||
@@ -182,6 +213,50 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
await ConnectAndSync();
|
await ConnectAndSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_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 ----
|
// ---- wallet lifecycle ----
|
||||||
|
|
||||||
@@ -201,6 +276,8 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_lastTransactions = null;
|
_lastTransactions = null;
|
||||||
_pendingSend = null;
|
_pendingSend = null;
|
||||||
HasPendingSend = false;
|
HasPendingSend = false;
|
||||||
|
PendingPsbtBase64 = "";
|
||||||
|
OnPropertyChanged(nameof(IsWatchOnlyAccount));
|
||||||
History.Clear();
|
History.Clear();
|
||||||
Contacts.Clear();
|
Contacts.Clear();
|
||||||
SelectedContactInList = null;
|
SelectedContactInList = null;
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ public sealed class TransactionDetailsViewModel
|
|||||||
{
|
{
|
||||||
if (d.Confirmations <= 0)
|
if (d.Confirmations <= 0)
|
||||||
return loc["tx.status.mempool"];
|
return loc["tx.status.mempool"];
|
||||||
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||||
|
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string Signed(long sats)
|
private string Signed(long sats)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
|
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
|
||||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||||
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Stretch">
|
||||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
@@ -99,6 +99,9 @@
|
|||||||
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
|
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
Command="{Binding WizardStartImportWifCommand}"/>
|
Command="{Binding WizardStartImportWifCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.importaddress.btn]}" FontSize="16"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding WizardStartImportAddressCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Step: choose wallet (multiple files present) -->
|
<!-- Step: choose wallet (multiple files present) -->
|
||||||
@@ -212,6 +215,19 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Step: import watch-only addresses (no key at all) -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepImportAddress}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.importaddress.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.importaddress.hint]}" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.importaddress.placeholder]}"
|
||||||
|
Text="{Binding ImportAddressInput}" AcceptsReturn="True" Height="80"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||||
|
Command="{Binding WizardNextFromImportAddressCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Step: choose script/address type -->
|
<!-- Step: choose script/address type -->
|
||||||
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
|
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
|
||||||
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>
|
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
@@ -317,6 +333,9 @@
|
|||||||
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding VerifyingText}" Foreground="#FCD34D"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding VerifyingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
||||||
Margin="0,2,0,0"/>
|
Margin="0,2,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -373,13 +392,16 @@
|
|||||||
<DataTemplate x:DataType="vm:HistoryRow">
|
<DataTemplate x:DataType="vm:HistoryRow">
|
||||||
<Panel Cursor="Hand">
|
<Panel Cursor="Hand">
|
||||||
<!-- Desktop: 3 fixed columns -->
|
<!-- Desktop: 3 fixed columns -->
|
||||||
<Grid ColumnDefinitions="90,160,*"
|
<Grid ColumnDefinitions="90,160,*,Auto"
|
||||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||||
FontFamily="monospace" FontSize="12"
|
FontFamily="monospace" FontSize="12"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||||
|
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
|
||||||
|
IsVisible="{Binding !Verified}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Mobile: vertical card -->
|
<!-- Mobile: vertical card -->
|
||||||
<StackPanel Spacing="2"
|
<StackPanel Spacing="2"
|
||||||
@@ -389,6 +411,9 @@
|
|||||||
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
||||||
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||||
|
Foreground="#FCD34D" FontSize="11"
|
||||||
|
IsVisible="{Binding !Verified}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -420,7 +445,7 @@
|
|||||||
|
|
||||||
<!-- ── DESKTOP: Recipient | Amount side-by-side ── -->
|
<!-- ── DESKTOP: Recipient | Amount side-by-side ── -->
|
||||||
<Grid IsVisible="{Binding IsDesktop}"
|
<Grid IsVisible="{Binding IsDesktop}"
|
||||||
RowDefinitions="Auto,Auto,Auto" RowSpacing="14">
|
RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="14">
|
||||||
|
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14">
|
<Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||||
<!-- Recipient card -->
|
<!-- Recipient card -->
|
||||||
@@ -491,11 +516,29 @@
|
|||||||
<SelectableTextBlock Text="{Binding SendPreview}"
|
<SelectableTextBlock Text="{Binding SendPreview}"
|
||||||
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<StackPanel Spacing="6"
|
||||||
|
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
|
||||||
|
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
|
||||||
|
AcceptsReturn="True" Height="90"/>
|
||||||
|
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||||
|
Content="{Binding Loc[send.psbt.copy]}"
|
||||||
|
Click="OnCopyPsbtClick"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Watch-only notice -->
|
||||||
|
<TextBlock Grid.Row="2" Text="{Binding Loc[send.watchonly.hint]}"
|
||||||
|
IsVisible="{Binding IsWatchOnlyAccount}"
|
||||||
|
TextWrapping="Wrap" FontSize="12"
|
||||||
|
Foreground="{DynamicResource WarningBrush}"/>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="*,*" ColumnSpacing="14">
|
<Grid Grid.Row="3" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||||
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
|
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
|
||||||
Command="{Binding PrepareSendCommand}"
|
Command="{Binding PrepareSendCommand}"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
@@ -595,9 +638,26 @@
|
|||||||
<SelectableTextBlock Text="{Binding SendPreview}"
|
<SelectableTextBlock Text="{Binding SendPreview}"
|
||||||
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<StackPanel Spacing="6"
|
||||||
|
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
|
||||||
|
<TextBox Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
|
||||||
|
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
|
||||||
|
AcceptsReturn="True" Height="90"/>
|
||||||
|
<Button Content="{Binding Loc[send.psbt.copy]}"
|
||||||
|
Click="OnCopyPsbtClick"
|
||||||
|
MinHeight="44"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Watch-only notice -->
|
||||||
|
<TextBlock Text="{Binding Loc[send.watchonly.hint]}"
|
||||||
|
IsVisible="{Binding IsWatchOnlyAccount}"
|
||||||
|
TextWrapping="Wrap" FontSize="12"
|
||||||
|
Foreground="{DynamicResource WarningBrush}"/>
|
||||||
|
|
||||||
<!-- Action buttons: primary (Confirm) prominent, secondary below -->
|
<!-- Action buttons: primary (Confirm) prominent, secondary below -->
|
||||||
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||||
Command="{Binding ConfirmSendCommand}"
|
Command="{Binding ConfirmSendCommand}"
|
||||||
@@ -984,7 +1044,7 @@
|
|||||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
MaxWidth="360" Margin="16"
|
MaxWidth="360" Margin="16"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||||
<StackPanel Margin="24" Spacing="14">
|
<StackPanel Margin="24" Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
||||||
FontSize="16" FontWeight="Bold"/>
|
FontSize="16" FontWeight="Bold"/>
|
||||||
@@ -1325,7 +1385,7 @@
|
|||||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
MaxWidth="500" Margin="16"
|
MaxWidth="500" Margin="16"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||||
<ScrollViewer MaxHeight="620">
|
<ScrollViewer MaxHeight="620">
|
||||||
<StackPanel Margin="24" Spacing="14">
|
<StackPanel Margin="24" Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
||||||
@@ -1502,6 +1562,9 @@
|
|||||||
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
|
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
|
||||||
IsChecked="{Binding IsLangDe, Mode=OneWay}"
|
IsChecked="{Binding IsLangDe, Mode=OneWay}"
|
||||||
Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
|
Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
|
||||||
|
<RadioButton GroupName="lang" Content="中文" Margin="0,0,14,4"
|
||||||
|
IsChecked="{Binding IsLangZh, Mode=OneWay}"
|
||||||
|
Command="{Binding SetLanguageCommand}" CommandParameter="zh"/>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -1585,8 +1648,9 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- Tab: Donate -->
|
<!-- Tab: Donate (needs an open wallet to send from) -->
|
||||||
<TabItem Header="{Binding Loc[help.tab.donate]}">
|
<TabItem Header="{Binding Loc[help.tab.donate]}"
|
||||||
|
IsVisible="{Binding IsWalletOpen}">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="12" Margin="0,12,0,0">
|
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||||
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ public partial class MainView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnCopyPsbtClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.PendingPsbtBase64))
|
||||||
|
return;
|
||||||
|
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||||
|
{
|
||||||
|
await clipboard.SetTextAsync(vm.PendingPsbtBase64);
|
||||||
|
vm.NotifyPsbtCopied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is MainWindowViewModel vm)
|
if (DataContext is MainWindowViewModel vm)
|
||||||
|
|||||||
+37
-4
@@ -18,6 +18,7 @@ try
|
|||||||
["create", .. var rest] => Create(rest),
|
["create", .. var rest] => Create(rest),
|
||||||
["restore", var words, .. var rest] => Restore(words, rest),
|
["restore", var words, .. var rest] => Restore(words, rest),
|
||||||
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
|
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
|
||||||
|
["restore-address", var addrs, .. var rest] => RestoreAddress(addrs, rest),
|
||||||
["info", .. var rest] => Info(rest),
|
["info", .. var rest] => Info(rest),
|
||||||
["sync", .. var rest] => await Sync(rest),
|
["sync", .. var rest] => await Sync(rest),
|
||||||
["send", .. var rest] => await Send(rest),
|
["send", .. var rest] => await Send(rest),
|
||||||
@@ -95,6 +96,31 @@ static int RestoreXpub(string xpubText, string[] o)
|
|||||||
return 0;
|
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)
|
static int Info(string[] o)
|
||||||
{
|
{
|
||||||
var (doc, account, path) = OpenWallet(o);
|
var (doc, account, path) = OpenWallet(o);
|
||||||
@@ -104,8 +130,9 @@ static int Info(string[] o)
|
|||||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||||
if (doc.Cache is { } cache)
|
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.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)." : ""));
|
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
||||||
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
||||||
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||||
@@ -139,16 +166,19 @@ static async Task<int> Sync(string[] o)
|
|||||||
doc.Cache?.BlockHeaders,
|
doc.Cache?.BlockHeaders,
|
||||||
doc.Cache?.NextReceiveIndex ?? 0,
|
doc.Cache?.NextReceiveIndex ?? 0,
|
||||||
doc.Cache?.NextChangeIndex ?? 0,
|
doc.Cache?.NextChangeIndex ?? 0,
|
||||||
net);
|
net,
|
||||||
|
doc.Cache?.AnchoredUpTo);
|
||||||
var result = await sync.SyncOnceAsync();
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
|
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
|
||||||
doc.Cache = new SyncCache
|
doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
ImmatureSats = result.ImmatureSats,
|
ImmatureSats = result.ImmatureSats,
|
||||||
|
PendingVerificationSats = result.PendingVerificationSats,
|
||||||
|
SpendableSats = result.SpendableSats,
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
@@ -157,11 +187,13 @@ static async Task<int> Sync(string[] o)
|
|||||||
RawTxHex = rawHex,
|
RawTxHex = rawHex,
|
||||||
VerifiedAt = verifiedAt,
|
VerifiedAt = verifiedAt,
|
||||||
BlockHeaders = blockHeaders,
|
BlockHeaders = blockHeaders,
|
||||||
|
AnchoredUpTo = anchoredUpTo,
|
||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
Console.WriteLine($"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.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)" : ""));
|
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
||||||
Console.WriteLine($"History ({result.History.Count}):");
|
Console.WriteLine($"History ({result.History.Count}):");
|
||||||
foreach (var tx in result.History)
|
foreach (var tx in result.History)
|
||||||
@@ -348,6 +380,7 @@ static int Usage()
|
|||||||
[--passphrase W] [--password P] [--file PATH]
|
[--passphrase W] [--password P] [--file PATH]
|
||||||
restore "<mnemonic>" [same options as create] [--path m/...]
|
restore "<mnemonic>" [same options as create] [--path m/...]
|
||||||
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||||
|
restore-address <addr1,addr2,...> [--net ...] [--password P] [--file PATH] (watch-only, no keys)
|
||||||
info [--net ...] [--password P] [--file PATH]
|
info [--net ...] [--password P] [--file PATH]
|
||||||
|
|
||||||
Network (indexing server; without --server the first known server is used):
|
Network (indexing server; without --server the first known server is used):
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ public static class ChainProfiles
|
|||||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||||
],
|
],
|
||||||
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||||
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one
|
// RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
|
||||||
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
|
||||||
|
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||||
// bounds how far back a forged header chain must be walked to be caught, since
|
// bounds how far back a forged header chain must be walked to be caught, since
|
||||||
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
||||||
Checkpoints =
|
Checkpoints =
|
||||||
@@ -78,6 +79,7 @@ public static class ChainProfiles
|
|||||||
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
||||||
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
||||||
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
||||||
|
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ public static class DerivationPaths
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort reverse mapping from an already-known address to a ScriptKind, used to
|
||||||
|
/// label pure address imports (no derivation involved, so this is informational only).
|
||||||
|
/// </summary>
|
||||||
|
public static ScriptKind KindFor(BitcoinAddress address) => address switch
|
||||||
|
{
|
||||||
|
BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit,
|
||||||
|
TaprootAddress => ScriptKind.Taproot,
|
||||||
|
BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig,
|
||||||
|
BitcoinScriptAddress => ScriptKind.WrappedSegwit,
|
||||||
|
_ => ScriptKind.Legacy,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
||||||
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
|
|||||||
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
||||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||||
|
|
||||||
|
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
|
||||||
|
public readonly record struct HeaderRangeResponse(int Count, string Hex);
|
||||||
|
|
||||||
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
||||||
public readonly record struct ChainTip(int Height, string HeaderHex);
|
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||||
|
|
||||||
@@ -82,6 +85,23 @@ public static class ElectrumApi
|
|||||||
return r.GetString()!;
|
return r.GetString()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
|
||||||
|
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
|
||||||
|
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
|
||||||
|
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
|
||||||
|
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
|
||||||
|
/// callers must loop until the full range is covered.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
|
||||||
|
int startHeight, int count, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
|
||||||
|
return new HeaderRangeResponse(
|
||||||
|
r.GetProperty("count").GetInt32(),
|
||||||
|
r.GetProperty("hex").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
// single segment; this gate avoids flooding the server with thousands of
|
// single segment; this gate avoids flooding the server with thousands of
|
||||||
// simultaneous requests on large wallets → no bursts of -101/-102 nor
|
// simultaneous requests on large wallets → no bursts of -101/-102 nor
|
||||||
// connection drops. Writes still stay pipelined up to this degree.
|
// connection drops. Writes still stay pipelined up to this degree.
|
||||||
private const int MaxInFlight = 32;
|
// Configurable per connection (see ConnectAsync): the right value trades off
|
||||||
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight);
|
// initial-sync throughput against how aggressively a given server tolerates
|
||||||
|
// concurrent requests before throttling — no single constant is right for
|
||||||
|
// every server, so callers may raise/lower it instead of recompiling.
|
||||||
|
public const int DefaultMaxInFlight = 32;
|
||||||
|
private readonly SemaphoreSlim _inFlight;
|
||||||
|
|
||||||
private long _nextId;
|
private long _nextId;
|
||||||
|
|
||||||
@@ -49,19 +53,22 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
public event Action<string, JsonElement>? NotificationReceived;
|
public event Action<string, JsonElement>? NotificationReceived;
|
||||||
public event Action<Exception?>? Disconnected;
|
public event Action<Exception?>? Disconnected;
|
||||||
|
|
||||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl,
|
||||||
|
int maxInFlight)
|
||||||
{
|
{
|
||||||
_tcp = tcp;
|
_tcp = tcp;
|
||||||
_stream = stream;
|
_stream = stream;
|
||||||
Host = host;
|
Host = host;
|
||||||
Port = port;
|
Port = port;
|
||||||
UseSsl = useSsl;
|
UseSsl = useSsl;
|
||||||
|
_inFlight = new SemaphoreSlim(maxInFlight, maxInFlight);
|
||||||
_readLoop = Task.Run(ReadLoopAsync);
|
_readLoop = Task.Run(ReadLoopAsync);
|
||||||
_writeLoop = Task.Run(WriteLoopAsync);
|
_writeLoop = Task.Run(WriteLoopAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
||||||
CertificatePinStore? pins = null, CancellationToken ct = default)
|
CertificatePinStore? pins = null, CancellationToken ct = default,
|
||||||
|
int maxInFlight = DefaultMaxInFlight)
|
||||||
{
|
{
|
||||||
var tcp = new TcpClient { NoDelay = true };
|
var tcp = new TcpClient { NoDelay = true };
|
||||||
try
|
try
|
||||||
@@ -90,7 +97,7 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
stream = ssl;
|
stream = ssl;
|
||||||
}
|
}
|
||||||
|
|
||||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
var client = new ElectrumClient(tcp, stream, host, port, useSsl, maxInFlight);
|
||||||
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ public sealed class SyncResult
|
|||||||
/// <see cref="ConfirmedSats"/>.
|
/// <see cref="ConfirmedSats"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required long ImmatureSats { get; init; }
|
public required long ImmatureSats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
|
||||||
|
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
|
||||||
|
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
|
||||||
|
/// (an immature coinbase can also be unverified), so never subtract both from
|
||||||
|
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
|
||||||
|
/// </summary>
|
||||||
|
public required long PendingVerificationSats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
|
||||||
|
/// now — the true spendable balance. Computed directly from the same gate coin selection
|
||||||
|
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
|
||||||
|
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
|
||||||
|
/// </summary>
|
||||||
|
public required long SpendableSats { get; init; }
|
||||||
public required int NextReceiveIndex { get; init; }
|
public required int NextReceiveIndex { get; init; }
|
||||||
public required int NextChangeIndex { get; init; }
|
public required int NextChangeIndex { get; init; }
|
||||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||||
@@ -46,14 +63,47 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
||||||
public event Action<string>? Progress;
|
public event Action<string>? Progress;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
|
||||||
|
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
|
||||||
|
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
|
||||||
|
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
|
||||||
|
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
|
||||||
|
/// only completes once verification is fully done, for callers that need the final state.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<SyncResult>? PartialResult;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
|
||||||
|
// txids known confirmed (height > 0) as of download time, independent of whether their
|
||||||
|
// Merkle proof has been verified yet — lets ExportCaches persist raw tx bytes for a
|
||||||
|
// confirmed transaction interrupted before verification, instead of forcing a
|
||||||
|
// re-download on the next sync just because _verifiedAtHeight hasn't caught up. Every
|
||||||
|
// entry here has txHeights[txid] > 0 at the time it was recorded, i.e. server-confirmed;
|
||||||
|
// unconfirmed (mempool/RBF-able) transactions are deliberately never added.
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _confirmedTxids = new();
|
||||||
|
|
||||||
|
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
||||||
|
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||||
|
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
// checkpoint height -> highest height already proven to hash-chain back to it
|
// checkpoint height -> highest height already proven to hash-chain back to it.
|
||||||
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
// 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();
|
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||||
|
|
||||||
|
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
|
||||||
|
// being verified) would otherwise race on overlapping ranges and issue duplicate range
|
||||||
|
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
|
||||||
|
// serializing them costs nothing that matters.
|
||||||
|
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
|
||||||
|
|
||||||
|
// Max headers requested per blockchain.block.headers call. The server may return fewer
|
||||||
|
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
|
||||||
|
private const int HeaderBatchSize = 2016;
|
||||||
|
|
||||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||||
// discovery — already-used addresses are fetched in a single burst instead of
|
// discovery — already-used addresses are fetched in a single burst instead of
|
||||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
||||||
@@ -70,16 +120,31 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
Dictionary<int, string>? blockHeaders,
|
Dictionary<int, string>? blockHeaders,
|
||||||
int knownReceiveIndex,
|
int knownReceiveIndex,
|
||||||
int knownChangeIndex,
|
int knownChangeIndex,
|
||||||
Network network)
|
Network network,
|
||||||
|
Dictionary<int, int>? anchoredUpTo = null)
|
||||||
{
|
{
|
||||||
foreach (var (txid, hex) in rawTxHex)
|
foreach (var (txid, hex) in rawTxHex)
|
||||||
|
{
|
||||||
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
||||||
|
// ExportCaches only ever wrote confirmed transactions here (see its own
|
||||||
|
// filter), so every preloaded entry is safe to mark confirmed too.
|
||||||
|
_confirmedTxids.TryAdd(txid, 0);
|
||||||
|
}
|
||||||
foreach (var (txid, height) in verifiedAt)
|
foreach (var (txid, height) in verifiedAt)
|
||||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
if (blockHeaders is not null)
|
if (blockHeaders is not null)
|
||||||
foreach (var (height, hex) in blockHeaders)
|
foreach (var (height, hex) in blockHeaders)
|
||||||
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
||||||
|
// Only trust a preloaded anchor up to a height whose header is also cached: if the
|
||||||
|
// header cache was cleared/corrupted independently, re-deriving the chain-of-hashes
|
||||||
|
// check on next use (AnchorToCheckpointAsync re-fetches what's missing) is safer than
|
||||||
|
// trusting a stale "already validated" claim against headers that may no longer match.
|
||||||
|
if (anchoredUpTo is not null)
|
||||||
|
foreach (var (checkpointHeight, upToHeight) in anchoredUpTo)
|
||||||
|
if (_headerFetches.ContainsKey(upToHeight))
|
||||||
|
_anchoredUpTo.AddOrUpdate(checkpointHeight, upToHeight,
|
||||||
|
(_, existing) => Math.Max(existing, upToHeight));
|
||||||
_knownReceiveIndex = knownReceiveIndex;
|
_knownReceiveIndex = knownReceiveIndex;
|
||||||
_knownChangeIndex = knownChangeIndex;
|
_knownChangeIndex = knownChangeIndex;
|
||||||
}
|
}
|
||||||
@@ -91,10 +156,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public (Dictionary<string, string> RawTxHex,
|
public (Dictionary<string, string> RawTxHex,
|
||||||
Dictionary<string, int> VerifiedAt,
|
Dictionary<string, int> VerifiedAt,
|
||||||
Dictionary<int, string> BlockHeaders)
|
Dictionary<int, string> BlockHeaders,
|
||||||
|
Dictionary<int, int> AnchoredUpTo)
|
||||||
ExportCaches(Network network)
|
ExportCaches(Network network)
|
||||||
{
|
{
|
||||||
var rawHex = _verifiedAtHeight.Keys
|
var rawHex = _confirmedTxids.Keys
|
||||||
.Where(_txCache.ContainsKey)
|
.Where(_txCache.ContainsKey)
|
||||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||||
|
|
||||||
@@ -105,7 +171,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
if (task.IsCompletedSuccessfully)
|
if (task.IsCompletedSuccessfully)
|
||||||
headers[height] = task.Result;
|
headers[height] = task.Result;
|
||||||
|
|
||||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers);
|
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers,
|
||||||
|
new Dictionary<int, int>(_anchoredUpTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||||
@@ -162,7 +229,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||||
txHeights[item.TxHash] = item.Height;
|
txHeights[item.TxHash] = item.Height;
|
||||||
|
|
||||||
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
// 4. Download missing transactions — needed to compute amounts/UTXOs locally.
|
||||||
|
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
|
||||||
|
// wallet with thousands of transactions, downloads finish in seconds while proofs can
|
||||||
|
// take much longer over a high-latency link, and the wallet has everything it needs to
|
||||||
|
// show balance/history the moment downloads are done. Verification then continues in
|
||||||
|
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
|
||||||
|
// while coin selection stays locked out of any UTXO until its own proof is checked
|
||||||
|
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
|
||||||
|
// cannot get a fabricated balance spent just because it was shown early.
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
var toVerify = txHeights
|
var toVerify = txHeights
|
||||||
@@ -170,47 +245,95 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (missing.Count > 0 || toVerify.Count > 0)
|
// 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
|
||||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
// fetched last time (see _confirmedTxids/PreloadCaches) — reporting against the total
|
||||||
var dlDone = 0;
|
// shows "n/n transactions" immediately instead of a misleading "0/0" before jumping
|
||||||
var merkDone = 0;
|
// straight to proof verification.
|
||||||
|
var totalTx = txHeights.Count;
|
||||||
|
var alreadyCached = totalTx - missing.Count;
|
||||||
|
|
||||||
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
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 () =>
|
||||||
{
|
{
|
||||||
var raw = await client.GetTransactionAsync(txid, ct);
|
var raw = await client.GetTransactionAsync(txid, ct);
|
||||||
_txCache[txid] = Transaction.Parse(raw, network);
|
_txCache[txid] = Transaction.Parse(raw, network);
|
||||||
|
if (txHeights[txid] > 0)
|
||||||
|
_confirmedTxids.TryAdd(txid, 0);
|
||||||
var n = Interlocked.Increment(ref dlDone);
|
var n = Interlocked.Increment(ref dlDone);
|
||||||
if (n % 50 == 0 || n == missing.Count)
|
if (n % 50 == 0 || n == missing.Count)
|
||||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
|
Progress?.Invoke(DownloadVerifyStatus(alreadyCached + n, 0));
|
||||||
}, ct));
|
}, ct)));
|
||||||
|
|
||||||
|
SyncResult BuildSnapshot() =>
|
||||||
|
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
|
||||||
|
|
||||||
|
PartialResult?.Invoke(BuildSnapshot());
|
||||||
|
|
||||||
|
var merkDone = 0;
|
||||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
var (txid, height) = kv;
|
var (txid, height) = kv;
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
var headerTask = _headerFetches.GetOrAdd(height,
|
// Anchor first: on a checkpointed height this fills _headerFetches[height]
|
||||||
h => client.GetBlockHeaderAsync(h, ct));
|
// via the batched range fetch (§7.3), so the header lookup below is a cache
|
||||||
var proof = await proofTask;
|
// hit instead of a second individual blockchain.block.header RPC per tx —
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
// 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);
|
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(
|
if (!MerkleProof.Verify(
|
||||||
uint256.Parse(txid), proof.Pos,
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||||
|
_verifiedAtHeight[txid] = height;
|
||||||
var n = Interlocked.Increment(ref merkDone);
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
if (n % 50 == 0 || n == toVerify.Count)
|
if (n % 50 == 0 || n == toVerify.Count)
|
||||||
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
Progress?.Invoke(DownloadVerifyStatus(totalTx, n));
|
||||||
}, ct));
|
if (n % PartialResultBatchSize == 0)
|
||||||
|
PartialResult?.Invoke(BuildSnapshot());
|
||||||
|
}, ct)).ToList();
|
||||||
|
|
||||||
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
if (merkTasks.Count > 0)
|
||||||
foreach (var (txid, height) in toVerify)
|
await Task.WhenAll(merkTasks);
|
||||||
_verifiedAtHeight[txid] = height;
|
|
||||||
|
return BuildSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
|
||||||
|
// every single verified proof would make the background verification phase itself O(n²) for
|
||||||
|
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
|
||||||
|
// visibly without that cost.
|
||||||
|
private const int PartialResultBatchSize = 200;
|
||||||
|
|
||||||
|
private bool IsTxVerified(string txid, int height) =>
|
||||||
|
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
|
||||||
|
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
|
||||||
|
/// transaction downloads finish (proofs still pending), and again as verification progresses,
|
||||||
|
/// each time reflecting whichever transactions have been proof-checked so far.
|
||||||
|
/// </summary>
|
||||||
|
private SyncResult BuildResult(
|
||||||
|
int tipHeight,
|
||||||
|
List<TrackedAddress> tracked,
|
||||||
|
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
|
||||||
|
Dictionary<string, int> txHeights,
|
||||||
|
int nextReceive,
|
||||||
|
int nextChange)
|
||||||
|
{
|
||||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
|
||||||
|
|
||||||
// 6. Local UTXO reconstruction.
|
// 6. Local UTXO reconstruction.
|
||||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||||
@@ -222,6 +345,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
var utxos = new List<CachedUtxo>();
|
var utxos = new List<CachedUtxo>();
|
||||||
foreach (var (txid, tx) in transactions)
|
foreach (var (txid, tx) in transactions)
|
||||||
{
|
{
|
||||||
|
var height = txHeights[txid];
|
||||||
|
var verifiedTx = IsTxVerified(txid, height);
|
||||||
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
||||||
{
|
{
|
||||||
var output = tx.Outputs[vout];
|
var output = tx.Outputs[vout];
|
||||||
@@ -237,8 +362,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
Address = addr.Address.ToString(),
|
Address = addr.Address.ToString(),
|
||||||
IsChange = addr.IsChange,
|
IsChange = addr.IsChange,
|
||||||
AddressIndex = addr.Index,
|
AddressIndex = addr.Index,
|
||||||
Height = txHeights[txid],
|
Height = height,
|
||||||
IsCoinbase = tx.IsCoinBase,
|
IsCoinbase = tx.IsCoinBase,
|
||||||
|
Verified = verifiedTx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +373,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
var history = new List<CachedTx>();
|
var history = new List<CachedTx>();
|
||||||
foreach (var (txid, tx) in transactions)
|
foreach (var (txid, tx) in transactions)
|
||||||
{
|
{
|
||||||
|
var height = txHeights[txid];
|
||||||
var received = tx.Outputs
|
var received = tx.Outputs
|
||||||
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
||||||
.Sum(o => o.Value.Satoshi);
|
.Sum(o => o.Value.Satoshi);
|
||||||
@@ -257,9 +384,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
history.Add(new CachedTx
|
history.Add(new CachedTx
|
||||||
{
|
{
|
||||||
Txid = txid,
|
Txid = txid,
|
||||||
Height = txHeights[txid],
|
Height = height,
|
||||||
DeltaSats = received - sentSats,
|
DeltaSats = received - sentSats,
|
||||||
Verified = verified[txid],
|
Verified = IsTxVerified(txid, height),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
history.Sort((a, b) =>
|
history.Sort((a, b) =>
|
||||||
@@ -286,12 +413,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
|
|
||||||
return new SyncResult
|
return new SyncResult
|
||||||
{
|
{
|
||||||
TipHeight = tip.Height,
|
TipHeight = tipHeight,
|
||||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||||
ImmatureSats = utxos.Where(u =>
|
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),
|
.Sum(u => u.ValueSats),
|
||||||
|
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
|
||||||
|
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
|
||||||
NextReceiveIndex = nextReceive,
|
NextReceiveIndex = nextReceive,
|
||||||
NextChangeIndex = nextChange,
|
NextChangeIndex = nextChange,
|
||||||
History = history,
|
History = history,
|
||||||
@@ -325,9 +454,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1)
|
await FetchHeaderRangeAsync(cp.Height, height, ct);
|
||||||
.Select(async h => BlockHeaderInfo.Parse(
|
|
||||||
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct)))));
|
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||||
|
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (!headers[0].MatchesCheckpoint(cp))
|
if (!headers[0].MatchesCheckpoint(cp))
|
||||||
throw new SpvVerificationException(
|
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));
|
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
|
||||||
|
/// is present in <see cref="_headerFetches"/>, downloading gaps with
|
||||||
|
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
|
||||||
|
/// </summary>
|
||||||
|
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _headerRangeLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var h = fromHeight;
|
||||||
|
while (h <= toHeightInclusive)
|
||||||
|
{
|
||||||
|
if (_headerFetches.ContainsKey(h)) { h++; continue; }
|
||||||
|
|
||||||
|
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
|
||||||
|
var range = await client.GetBlockHeadersAsync(h, requested, ct);
|
||||||
|
if (range.Count == 0)
|
||||||
|
throw new SpvVerificationException(
|
||||||
|
$"Server returned no headers starting at height {h}: server is not trustworthy.");
|
||||||
|
|
||||||
|
for (var i = 0; i < range.Count; i++)
|
||||||
|
{
|
||||||
|
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
|
||||||
|
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
|
||||||
|
}
|
||||||
|
h += range.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_headerRangeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans one chain (receiving or change).
|
/// Scans one chain (receiving or change).
|
||||||
///
|
///
|
||||||
@@ -414,7 +580,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
return (firstUnused, tracked, history);
|
return (firstUnused, tracked, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
// Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
|
||||||
|
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
|
||||||
|
// rather than guessed at from wall-clock numbers alone.
|
||||||
|
private int _busyRetries;
|
||||||
|
|
||||||
|
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var delay = 200;
|
var delay = 200;
|
||||||
for (var attempt = 0; ; attempt++)
|
for (var attempt = 0; ; attempt++)
|
||||||
@@ -423,13 +594,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
catch (ElectrumServerException ex)
|
catch (ElectrumServerException ex)
|
||||||
when (IsBusy(ex) && attempt < 7)
|
when (IsBusy(ex) && attempt < 7)
|
||||||
{
|
{
|
||||||
|
ReportBusyRetry(delay);
|
||||||
await Task.Delay(delay, ct);
|
await Task.Delay(delay, ct);
|
||||||
delay = Math.Min(delay * 2, 5_000);
|
delay = Math.Min(delay * 2, 5_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var delay = 200;
|
var delay = 200;
|
||||||
for (var attempt = 0; ; attempt++)
|
for (var attempt = 0; ; attempt++)
|
||||||
@@ -438,12 +610,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
catch (ElectrumServerException ex)
|
catch (ElectrumServerException ex)
|
||||||
when (IsBusy(ex) && attempt < 7)
|
when (IsBusy(ex) && attempt < 7)
|
||||||
{
|
{
|
||||||
|
ReportBusyRetry(delay);
|
||||||
await Task.Delay(delay, ct);
|
await Task.Delay(delay, ct);
|
||||||
delay = Math.Min(delay * 2, 5_000);
|
delay = Math.Min(delay * 2, 5_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReportBusyRetry(int delayMs)
|
||||||
|
{
|
||||||
|
var n = Interlocked.Increment(ref _busyRetries);
|
||||||
|
if (n == 1 || n % 20 == 0)
|
||||||
|
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsBusy(ElectrumServerException ex) =>
|
private static bool IsBusy(ElectrumServerException ex) =>
|
||||||
ex.Message.Contains("-102") ||
|
ex.Message.Contains("-102") ||
|
||||||
ex.Message.Contains("-101") ||
|
ex.Message.Contains("-101") ||
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public sealed class WalletDocument
|
|||||||
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
||||||
public List<string>? WifKeys { get; set; }
|
public List<string>? WifKeys { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Watch-only addresses with no associated private key (pure address import).</summary>
|
||||||
|
public List<string>? WatchAddresses { get; set; }
|
||||||
|
|
||||||
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
||||||
public int GapLimit { get; set; } = 20;
|
public int GapLimit { get; set; } = 20;
|
||||||
|
|
||||||
@@ -98,6 +101,16 @@ public sealed class SyncCache
|
|||||||
|
|
||||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||||
public long ImmatureSats { get; set; }
|
public long ImmatureSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
|
||||||
|
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
|
||||||
|
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
|
||||||
|
/// </summary>
|
||||||
|
public long PendingVerificationSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
|
||||||
|
public long SpendableSats { get; set; }
|
||||||
public int NextReceiveIndex { get; set; }
|
public int NextReceiveIndex { get; set; }
|
||||||
public int NextChangeIndex { get; set; }
|
public int NextChangeIndex { get; set; }
|
||||||
public List<CachedTx> History { get; set; } = [];
|
public List<CachedTx> History { get; set; } = [];
|
||||||
@@ -124,6 +137,14 @@ public sealed class SyncCache
|
|||||||
/// subsequent syncs.
|
/// subsequent syncs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<int, string>? BlockHeaders { get; set; }
|
public Dictionary<int, string>? BlockHeaders { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checkpoint height → highest height already proven to hash-chain back to it (§7.3).
|
||||||
|
/// Avoids re-walking and re-verifying the whole header chain from the checkpoint on
|
||||||
|
/// every launch: the chain-of-hashes check already done for a height doesn't need
|
||||||
|
/// redoing once headers themselves are cached.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<int, int>? AnchoredUpTo { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
||||||
@@ -141,6 +162,13 @@ public sealed class CachedTx
|
|||||||
public required string Txid { get; set; }
|
public required string Txid { get; set; }
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public long DeltaSats { get; set; }
|
public long DeltaSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once this tx's Merkle proof has actually been checked against a
|
||||||
|
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
|
||||||
|
/// be pending its own proof while background verification catches up). Always
|
||||||
|
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
|
||||||
|
/// </summary>
|
||||||
public bool Verified { get; set; }
|
public bool Verified { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,4 +183,11 @@ public sealed class CachedUtxo
|
|||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public bool IsCoinbase { get; set; }
|
public bool IsCoinbase { get; set; }
|
||||||
public bool Frozen { get; set; }
|
public bool Frozen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
|
||||||
|
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
|
||||||
|
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
|
||||||
|
/// </summary>
|
||||||
|
public bool Verified { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,12 @@ public static class WalletStore
|
|||||||
File.WriteAllText(tmp, content);
|
File.WriteAllText(tmp, content);
|
||||||
File.Move(tmp, path, overwrite: true);
|
File.Move(tmp, path, overwrite: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
|
||||||
|
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
|
||||||
|
/// transactions/headers), where the synchronous version would block the UI.
|
||||||
|
/// </summary>
|
||||||
|
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
|
||||||
|
Task.Run(() => Save(doc, path, password));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public sealed class BuiltTransaction
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransactionFactory(IWalletAccount account)
|
public sealed class TransactionFactory(IWalletAccount account)
|
||||||
{
|
{
|
||||||
|
private const int MaxStandardTransactionVirtualSize = 100_000;
|
||||||
|
|
||||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -68,9 +70,9 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||||
if (immature.Count > 0)
|
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;
|
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 =>
|
var underConf = utxos.Where(u =>
|
||||||
@@ -78,20 +80,96 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||||
if (underConf.Count > 0)
|
if (underConf.Count > 0)
|
||||||
{
|
{
|
||||||
var best = underConf.Max(u => u.Confirmations(tipHeight));
|
var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
|
||||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
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
|
throw new WalletSpendException(reasons.Length > 0
|
||||||
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||||
: "No spendable UTXOs selected.");
|
: "No spendable UTXOs selected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var coins = spendable.Select(u => new Coin(
|
var ordered = spendable
|
||||||
|
.OrderByDescending(u => u.ValueSats)
|
||||||
|
.ThenBy(u => u.Height)
|
||||||
|
.ThenBy(u => u.Txid, StringComparer.Ordinal)
|
||||||
|
.ThenBy(u => u.Vout)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||||
|
|
||||||
|
if (sendAll)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return BuildWithSelectedUtxos(
|
||||||
|
ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count);
|
||||||
|
}
|
||||||
|
catch (TransactionTooLargeException ex)
|
||||||
|
{
|
||||||
|
throw new WalletSpendException(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotEnoughFundsException? lastInsufficientFunds = null;
|
||||||
|
TransactionTooLargeException? tooLarge = null;
|
||||||
|
BuiltTransaction? best = null;
|
||||||
|
var low = 1;
|
||||||
|
var high = ordered.Count;
|
||||||
|
while (low <= high)
|
||||||
|
{
|
||||||
|
var count = low + ((high - low) / 2);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
best = BuildWithSelectedUtxos(
|
||||||
|
ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex,
|
||||||
|
sendAll: false, totalSpendableCount: ordered.Count);
|
||||||
|
high = count - 1;
|
||||||
|
}
|
||||||
|
catch (NotEnoughFundsException ex)
|
||||||
|
{
|
||||||
|
lastInsufficientFunds = ex;
|
||||||
|
low = count + 1;
|
||||||
|
}
|
||||||
|
catch (TransactionTooLargeException ex)
|
||||||
|
{
|
||||||
|
tooLarge = ex;
|
||||||
|
high = count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best is not null)
|
||||||
|
return best;
|
||||||
|
|
||||||
|
if (tooLarge is not null)
|
||||||
|
throw new WalletSpendException(tooLarge.Message);
|
||||||
|
|
||||||
|
throw new WalletSpendException(lastInsufficientFunds is null
|
||||||
|
? "Insufficient funds."
|
||||||
|
: $"Insufficient funds: {lastInsufficientFunds.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuiltTransaction BuildWithSelectedUtxos(
|
||||||
|
IReadOnlyList<CachedUtxo> selectedUtxos,
|
||||||
|
IReadOnlyDictionary<string, Transaction> transactions,
|
||||||
|
BitcoinAddress destination,
|
||||||
|
long amountSats,
|
||||||
|
FeeRate feeRate,
|
||||||
|
int changeIndex,
|
||||||
|
bool sendAll,
|
||||||
|
int totalSpendableCount)
|
||||||
|
{
|
||||||
|
var coins = selectedUtxos.Select(u => new Coin(
|
||||||
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
|
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
|
||||||
transactions[u.Txid].Outputs[u.Vout])).ToList();
|
transactions[u.Txid].Outputs[u.Vout])).ToList();
|
||||||
|
|
||||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
|
||||||
var builder = Network.CreateTransactionBuilder();
|
var builder = Network.CreateTransactionBuilder();
|
||||||
builder.SetVersion(2);
|
builder.SetVersion(2);
|
||||||
// RBF sequence to allow fee bumping (§6.6).
|
// RBF sequence to allow fee bumping (§6.6).
|
||||||
@@ -107,7 +185,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
|
|
||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
builder.AddKeys(spendable
|
builder.AddKeys(selectedUtxos
|
||||||
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
||||||
.OfType<Key>()
|
.OfType<Key>()
|
||||||
.ToArray());
|
.ToArray());
|
||||||
@@ -118,11 +196,27 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
{
|
{
|
||||||
tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
|
tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
|
||||||
}
|
}
|
||||||
|
catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// NBitcoin's coin selector refuses to assemble a combination over the standard size
|
||||||
|
// cap itself and reports it through NotEnoughFundsException rather than ever handing
|
||||||
|
// back an oversized transaction — the GetVirtualSize() check below is unreachable for
|
||||||
|
// this case and exists only as a defense-in-depth net for other NBitcoin versions.
|
||||||
|
throw new TransactionTooLargeException(
|
||||||
|
BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll));
|
||||||
|
}
|
||||||
|
catch (NotEnoughFundsException) when (!sendAll)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (NotEnoughFundsException ex)
|
catch (NotEnoughFundsException ex)
|
||||||
{
|
{
|
||||||
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
|
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize)
|
||||||
|
throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize()));
|
||||||
|
|
||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
||||||
@@ -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)
|
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
|
||||||
{
|
{
|
||||||
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
|
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
|
||||||
@@ -147,6 +256,8 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
.Sum(c => (Money)c.Amount);
|
.Sum(c => (Money)c.Amount);
|
||||||
return inputSum - tx.Outputs.Sum(o => o.Value);
|
return inputSum - tx.Outputs.Sum(o => o.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class TransactionTooLargeException(string message) : Exception(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
|
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ public static class UtxoSpendability
|
|||||||
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||||
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
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) =>
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,15 @@ public static class WalletLoader
|
|||||||
return new ImportedKeyAccount(entries, kind, profile);
|
return new ImportedKeyAccount(entries, kind, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Watch-only from xpub
|
// 4. Watch-only imported addresses (no keys, no HD derivation)
|
||||||
|
if (doc.WatchAddresses is { Count: > 0 } watchAddresses)
|
||||||
|
{
|
||||||
|
var entries = watchAddresses.Select(a =>
|
||||||
|
(BitcoinAddress.Create(a.Trim(), network), (Key?)null)).ToList();
|
||||||
|
return new ImportedKeyAccount(entries, kind, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Watch-only from xpub
|
||||||
if (doc.AccountXpub is null)
|
if (doc.AccountXpub is null)
|
||||||
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
||||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||||
@@ -170,4 +178,47 @@ public static class WalletLoader
|
|||||||
};
|
};
|
||||||
return (doc, account);
|
return (doc, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the document from one or more plain addresses, with no private key at all
|
||||||
|
/// (pure watch-only import — the account can never sign, unlike xpub- or WIF-based accounts
|
||||||
|
/// which can be upgraded later by supplying the matching key). ScriptKind is informational
|
||||||
|
/// only here (no derivation happens from a fixed address list) and is auto-detected from the
|
||||||
|
/// first address unless <paramref name="kindOverride"/> is given.
|
||||||
|
/// </summary>
|
||||||
|
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses(
|
||||||
|
IReadOnlyList<string> addresses, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||||
|
{
|
||||||
|
if (addresses.Count == 0)
|
||||||
|
throw new InvalidDataException("At least one address is required.");
|
||||||
|
|
||||||
|
var network = PalladiumNetworks.For(profile.Kind);
|
||||||
|
var entries = new List<(BitcoinAddress, Key?)>();
|
||||||
|
var addressStrings = new List<string>();
|
||||||
|
|
||||||
|
foreach (var raw in addresses)
|
||||||
|
{
|
||||||
|
BitcoinAddress addr;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
addr = BitcoinAddress.Create(raw.Trim(), network);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Invalid address: {ex.Message}");
|
||||||
|
}
|
||||||
|
entries.Add((addr, null));
|
||||||
|
addressStrings.Add(raw.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind = kindOverride ?? DerivationPaths.KindFor(entries[0].Item1);
|
||||||
|
var account = new ImportedKeyAccount(entries, kind, profile);
|
||||||
|
var doc = new WalletDocument
|
||||||
|
{
|
||||||
|
Network = profile.NetName,
|
||||||
|
ScriptKind = kind.ToString(),
|
||||||
|
WatchAddresses = addressStrings,
|
||||||
|
};
|
||||||
|
return (doc, account);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ public class WalletSynchronizerTests
|
|||||||
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
||||||
? hex
|
? hex
|
||||||
: throw new FakeElectrumError(-32600, "no such block"));
|
: 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(0, result.ConfirmedSats);
|
||||||
Assert.Equal(250_000, result.UnconfirmedSats);
|
Assert.Equal(250_000, result.UnconfirmedSats);
|
||||||
var entry = Assert.Single(result.History);
|
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.transaction.get_merkle"));
|
||||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
}
|
}
|
||||||
@@ -318,8 +330,11 @@ public class WalletSynchronizerTests
|
|||||||
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
||||||
|
|
||||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint.
|
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
|
||||||
Assert.Equal(6, server.CallCount("blockchain.block.header"));
|
// 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]
|
[Fact]
|
||||||
@@ -352,12 +367,13 @@ public class WalletSynchronizerTests
|
|||||||
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
||||||
|
|
||||||
// 103 <= the memoized 105: anchoring must early-return without re-walking,
|
// 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));
|
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
||||||
var result = await sync.SyncOnceAsync();
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
Assert.Equal(1_500_000, result.ConfirmedSats);
|
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]
|
[Fact]
|
||||||
@@ -426,6 +442,89 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Equal(1, server.CallCount("blockchain.block.header"));
|
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 ----
|
// ---- resilienza ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -496,7 +595,7 @@ public class WalletSynchronizerTests
|
|||||||
|
|
||||||
var first = new WalletSynchronizer(account, client);
|
var first = new WalletSynchronizer(account, client);
|
||||||
var result1 = await first.SyncOnceAsync();
|
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(rawTx); // the confirmed tx is exported
|
||||||
Assert.Single(verifiedAt); // with its verified height
|
Assert.Single(verifiedAt); // with its verified height
|
||||||
@@ -506,7 +605,7 @@ public class WalletSynchronizerTests
|
|||||||
server.ResetCallCounts();
|
server.ResetCallCounts();
|
||||||
var second = new WalletSynchronizer(account, client);
|
var second = new WalletSynchronizer(account, client);
|
||||||
second.PreloadCaches(rawTx, verifiedAt, headers,
|
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
|
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
|
||||||
var result2 = await second.SyncOnceAsync();
|
var result2 = await second.SyncOnceAsync();
|
||||||
|
|
||||||
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||||
@@ -515,6 +614,55 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
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]
|
[Fact]
|
||||||
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
|
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);
|
var sync = new WalletSynchronizer(account, client);
|
||||||
await sync.SyncOnceAsync();
|
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.
|
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
|
||||||
Assert.Empty(rawTx);
|
Assert.Empty(rawTx);
|
||||||
|
|||||||
@@ -148,6 +148,21 @@ public class StorageTests
|
|||||||
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
|
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]
|
[Fact]
|
||||||
public void Json_corrotto_lancia_eccezione()
|
public void Json_corrotto_lancia_eccezione()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,12 +33,53 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
Txid = txid, Vout = 0, ValueSats = sats,
|
Txid = txid, Vout = 0, ValueSats = sats,
|
||||||
Address = account.GetReceiveAddress(0).ToString(),
|
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 });
|
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]
|
[Fact]
|
||||||
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
|
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,
|
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
||||||
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
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 };
|
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);
|
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]
|
[Theory]
|
||||||
[InlineData("0.00000001", 1L)]
|
[InlineData("0.00000001", 1L)]
|
||||||
[InlineData("1", 100_000_000L)]
|
[InlineData("1", 100_000_000L)]
|
||||||
@@ -295,7 +363,7 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
Txid = txid, Vout = 0, ValueSats = 300_000,
|
Txid = txid, Vout = 0, ValueSats = 300_000,
|
||||||
Address = account.GetReceiveAddress(i).ToString(),
|
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);
|
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]
|
[Fact]
|
||||||
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
|
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
@@ -249,4 +250,85 @@ public class WalletLoaderTests
|
|||||||
Assert.Throws<InvalidDataException>(
|
Assert.Throws<InvalidDataException>(
|
||||||
() => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
|
() => 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user