Compare commits
10 Commits
ef60ec0330
..
0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f0fb3fe05d | |||
| 3f26f52f8b | |||
| cb3ff714d9 | |||
| d0037747b5 | |||
| 34b4313a36 | |||
| 2d017231eb | |||
| e7797017f6 | |||
| 6c24a8bb46 | |||
| 9aecdb1aaa | |||
| 2be43d3177 |
@@ -28,17 +28,6 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: network
|
|
||||||
attributes:
|
|
||||||
label: Network
|
|
||||||
options:
|
|
||||||
- Mainnet
|
|
||||||
- Testnet
|
|
||||||
- Regtest
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/davide3011/PalladiumWallet/security/policy
|
||||||
|
about: Please report security vulnerabilities privately, not as a public issue. See SECURITY.md.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea or improvement for Palladium Wallet
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to suggest an improvement.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: What problem are you trying to solve? Is it related to something you find missing or frustrating?
|
||||||
|
placeholder: "I'm always frustrated when …"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe what you'd like to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Any alternative solutions or workarounds you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Affected platform(s)
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- Linux
|
||||||
|
- Android
|
||||||
|
- CLI
|
||||||
|
- All
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Before submitting
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and this is not a duplicate
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this PR change and why? -->
|
||||||
|
|
||||||
|
## Related issue
|
||||||
|
|
||||||
|
<!-- Closes #... (if applicable) -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactor / cleanup
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Build / CI
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- How was this verified? e.g. `dotnet test`, manual run on Windows/Linux/Android, golden-vector comparison -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `dotnet test` passes
|
||||||
|
- [ ] No seed/private key material logged or persisted in plaintext (if touching Crypto/Wallet/Storage)
|
||||||
|
- [ ] Server responses still validated with Merkle + checkpoints (if touching Spv/Net)
|
||||||
|
- [ ] Code/comments/commit messages are in English
|
||||||
@@ -74,6 +74,8 @@ temp/
|
|||||||
*.key
|
*.key
|
||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
# Wallet data and local runtime state
|
# Wallet data and local runtime state
|
||||||
|
|||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Technical changelog for PalladiumWallet. Format loosely follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); entries are grouped
|
||||||
|
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||||
|
release and covers the full history from the initial commit.
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-07-02
|
||||||
|
|
||||||
|
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
|
||||||
|
Bitcoin-derived UTXO chain — targeting desktop (Windows/Linux) and Android
|
||||||
|
from a single Avalonia UI codebase on .NET 10 + NBitcoin.
|
||||||
|
|
||||||
|
### Core — Chain
|
||||||
|
|
||||||
|
- Network profiles and consensus constants centralized in `Core/Chain`
|
||||||
|
(`ChainProfiles`/`PalladiumNetworks`): address prefixes, BIP32 headers,
|
||||||
|
bech32 HRP, genesis, ports, `coin_type` 746 — selectable per network
|
||||||
|
(mainnet/testnet/regtest), no scattered magic numbers.
|
||||||
|
- LWMA difficulty / 2-minute blocks: SPV cannot recompute LWMA retargeting,
|
||||||
|
so PoW validation is skipped and trust is anchored to hardcoded
|
||||||
|
checkpoints instead.
|
||||||
|
|
||||||
|
### Core — Crypto
|
||||||
|
|
||||||
|
- HD key derivation: BIP32/39/84, HD accounts, SLIP-132 extended key
|
||||||
|
serialization.
|
||||||
|
- Address types: P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86).
|
||||||
|
- `IWalletAccount` abstraction with WIF/xpub/xprv keystore import (including
|
||||||
|
watch-only accounts from public material only).
|
||||||
|
|
||||||
|
### Core — Net
|
||||||
|
|
||||||
|
- Custom `ElectrumClient`: JSON-RPC 2.0 client for the ElectrumX-like
|
||||||
|
indexing server, with TLS support and TOFU certificate pinning.
|
||||||
|
- `ServerRegistry`: bootstrap server list, peer discovery, persisted last-used
|
||||||
|
server, fallback resolution for `--server`.
|
||||||
|
- Batched writes, zero-allocation reads, bounded in-flight requests for
|
||||||
|
network throughput.
|
||||||
|
- Fix: allow changing the indexing server at any time, including during an
|
||||||
|
active sync.
|
||||||
|
|
||||||
|
### Core — Spv
|
||||||
|
|
||||||
|
- Header sync with Merkle proof verification and scripthash subscriptions
|
||||||
|
against hardcoded checkpoints (no full PoW recomputation, per the chain
|
||||||
|
profile above).
|
||||||
|
- Per-address balance and transaction-count aggregation in sync results.
|
||||||
|
- Electrum-style continuous updates: incremental, parallelized sync across
|
||||||
|
address chains, with a persistent header cache.
|
||||||
|
- Fix: resilient sync — history discovery via `GetHistory`, transaction
|
||||||
|
caching, automatic reconnect fallback.
|
||||||
|
|
||||||
|
### Core — Storage
|
||||||
|
|
||||||
|
- Versioned, encrypted JSON wallet file (`WalletDocument`) with dedicated
|
||||||
|
persistence and loader layer.
|
||||||
|
- `WalletLock`: prevents concurrent access to the same wallet file; acquired
|
||||||
|
before load and before close (fixed a race where it wasn't).
|
||||||
|
- XDG-compliant data paths (`AppPaths`) with per-platform override seam used
|
||||||
|
by the Android head; English as default UI language.
|
||||||
|
- Contacts list (name + address) persisted in `WalletDocument`.
|
||||||
|
- Documented caveat: `WalletStore.Save` writes plaintext JSON when the
|
||||||
|
wallet is unencrypted — seed/keys are only ever encrypted-at-rest when the
|
||||||
|
user opts into a password.
|
||||||
|
|
||||||
|
### Core — Wallet
|
||||||
|
|
||||||
|
- Coin selection, PSBT-centric transaction factory, and wallet loader.
|
||||||
|
- Confirmation-threshold enforcement before spending UTXOs; immature
|
||||||
|
(coinbase) balance surfaced separately from confirmed balance; pending
|
||||||
|
mempool balance shown, with unconfirmed UTXOs excluded from spending by
|
||||||
|
default.
|
||||||
|
- Fix: reject amounts with sub-satoshi precision.
|
||||||
|
|
||||||
|
### App — Avalonia UI (shared, `src/App`)
|
||||||
|
|
||||||
|
- Single shared `MainView` (UserControl) hosted by `MainWindow` on desktop
|
||||||
|
and as the single-view root on Android; single `MainWindowViewModel`
|
||||||
|
(CommunityToolkit.Mvvm), later split into partial files by area
|
||||||
|
(Wizard/Settings/Sync/Send/Contacts/Receive/...).
|
||||||
|
- Step-by-step setup wizard (replacing a single-form panel), including a
|
||||||
|
first-run data-location step, multi-wallet chooser, confirm-password and
|
||||||
|
encrypt toggle, and dedicated flows for creating a wallet vs. importing
|
||||||
|
from xpub/xprv/WIF.
|
||||||
|
- In-app overlay pattern for details/settings/help (`IsXxxOpen` flags, no OS
|
||||||
|
windows) replacing earlier nested submenus and a separate
|
||||||
|
`AddressInfoWindow`: server settings, app settings (language/unit),
|
||||||
|
wallet info (xpub, password-gated seed reveal), address detail
|
||||||
|
(password-gated private key reveal), transaction detail with full
|
||||||
|
on-chain data (inputs/outputs, no truncation), and a Help overlay
|
||||||
|
(Info/Donate tabs).
|
||||||
|
- Connection-status indicator in the bottom bar; connect-before-wallet-open
|
||||||
|
flow; persisted last-used server; server discovery split into its own
|
||||||
|
always-usable button; mainnet hardcoded (network selector removed for the
|
||||||
|
first release).
|
||||||
|
- Localization: `Loc` key → 6-language dictionary (it/en/es/fr/pt/de) with
|
||||||
|
live language switching.
|
||||||
|
- Receive: QR code generation and copy-to-clipboard for the receive address.
|
||||||
|
- Android: QR code scanner for the Send address field.
|
||||||
|
- Centralized design system (color tokens, gradient hero, SVG tab icons);
|
||||||
|
responsive layout for portrait mobile, unified tab bar (desktop +
|
||||||
|
mobile), two-column desktop layout for Send/Receive; various mobile-only
|
||||||
|
fixes (tab bar sizing/indicator overlap, text overflow, full-screen server
|
||||||
|
overlay on mobile).
|
||||||
|
- App version shown in window title and Help overlay, read from the single
|
||||||
|
`<Version>` source in the App csproj.
|
||||||
|
- In-app update check: compares the running version against the latest
|
||||||
|
GitHub release tag on startup and shows an overlay with the new tag when
|
||||||
|
one is available (best-effort — silent on network/parse failure).
|
||||||
|
- In-app bug-report button (Help overlay) opening a pre-filled GitHub issue
|
||||||
|
template; issue/PR templates completed.
|
||||||
|
|
||||||
|
### Android head (`src/App.Android`)
|
||||||
|
|
||||||
|
- Architecture split: shared UI library (`src/App`) + `src/App.Desktop` +
|
||||||
|
`src/App.Android` heads from the same source (`refactor(arch)`), each
|
||||||
|
carrying only the per-platform entry point and packages.
|
||||||
|
- App logo as launcher icon.
|
||||||
|
- Persistent release-signing keystore workflow
|
||||||
|
(`docker/keystore/generate-keystore.sh`, git-ignored output): every
|
||||||
|
release APK is signed with the same key so installing a newer build
|
||||||
|
updates a previous install in place instead of requiring an uninstall.
|
||||||
|
`versionCode` derived from `<Version>` instead of a fixed constant.
|
||||||
|
|
||||||
|
### CLI (`src/Cli`)
|
||||||
|
|
||||||
|
- Commands: `create`/`restore`/`restore-xpub`/`info`,
|
||||||
|
`sync`/`send`/`servers`/`reset-certs`, `newseed`/`addresses`.
|
||||||
|
- `servers` command, `info --addresses`, and registry-based fallback
|
||||||
|
resolution for `--server`.
|
||||||
|
|
||||||
|
### Build & Distribution
|
||||||
|
|
||||||
|
- Docker-based reproducible build system (`docker/build.sh` +
|
||||||
|
`docker/Dockerfile.*`): pinned toolchain (.NET 10 SDK, JDK, Android SDK),
|
||||||
|
builds Windows/Linux single-file executables and a signed Android APK
|
||||||
|
without any SDK installed on the host.
|
||||||
|
- Android release signing wired into `build_android` (see Android head
|
||||||
|
above): requires the persistent keystore, prompts for its passwords at
|
||||||
|
build time, mounts it read-only into the build container.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit test coverage across all Core modules, later expanded to 209 tests.
|
||||||
|
- Property-based tests via CsCheck (`PropertyTests.cs`), bringing total
|
||||||
|
coverage to 218 tests; dedicated `WalletLock` concurrency tests.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `README.md` (project overview, quickstart, reproducible builds), `CLAUDE.md`
|
||||||
|
(codebase guidance for AI tooling, kept in sync with the multi-head
|
||||||
|
architecture and .NET 10 migration), `SECURITY.md` (threat model and SPV
|
||||||
|
trust assumptions), coding-agent guide.
|
||||||
|
- Code comments translated to English project-wide, per the language policy
|
||||||
|
(Italian conversation, English code/docs).
|
||||||
@@ -47,7 +47,7 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
|||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`).
|
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||||
|
|
||||||
@@ -84,3 +84,4 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
|
|
||||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
||||||
|
- **Releases:** whenever a new version tag is created (bumping `<Version>` in `src/App/PalladiumWallet.App.csproj`), update `CHANGELOG.md` with an entry for that version before/with the tag — it's the technical record of what shipped, not optional bookkeeping.
|
||||||
|
|||||||
@@ -218,8 +218,11 @@ The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android
|
|||||||
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
||||||
`AndroidSupportedAbis` property is deprecated and ignored.)
|
`AndroidSupportedAbis` property is deprecated and ignored.)
|
||||||
|
|
||||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) Release signing with your own
|
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) This debug apk is fine for personal
|
||||||
keystore is not set up yet; the debug apk is fine for personal sideloading.
|
sideloading, but debug builds are signed with a key regenerated per build machine/container, so a
|
||||||
|
newer debug apk won't install over an older one without an uninstall first. For a stable signature
|
||||||
|
across releases (needed to update an existing install in place), build via
|
||||||
|
`./docker/build.sh android` instead — see [docker/keystore/README.md](docker/keystore/README.md).
|
||||||
|
|
||||||
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
|
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
|
||||||
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
||||||
@@ -332,6 +335,10 @@ The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
Technical, per-version changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
||||||
|
|||||||
+25
-9
@@ -46,6 +46,16 @@ automatically on first use — a few minutes for desktop, 10–20 minutes for
|
|||||||
Android (large downloads). Subsequent builds reuse the cached images and take
|
Android (large downloads). Subsequent builds reuse the cached images and take
|
||||||
well under a minute (desktop) / a few minutes (Android).
|
well under a minute (desktop) / a few minutes (Android).
|
||||||
|
|
||||||
|
**Android release signing:** before the first `android` build, generate the
|
||||||
|
persistent signing keystore once — see [`keystore/README.md`](keystore/README.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker/keystore/generate-keystore.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Without it, `build_android` refuses to run — every APK must be signed with
|
||||||
|
the same key so future releases can update a previous install in place.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -64,7 +74,7 @@ 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)
|
||||||
android Android APK (debug-signed)
|
android Android APK (release-signed, prompts for keystore passwords)
|
||||||
all All three targets
|
all All three targets
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -109,16 +119,16 @@ 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`.
|
||||||
|
|
||||||
**Android** — a debug-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+
|
||||||
(API 23), arm64 phones and x86_64 emulators.
|
(API 23), arm64 phones and x86_64 emulators.
|
||||||
|
|
||||||
> **Signature caveat:** debug-signed APKs built on different machines carry
|
> **Signature:** every APK is signed with the persistent keystore in
|
||||||
> different keys. If a previous build is already installed, Android refuses
|
> `docker/keystore/` (see [Prerequisites](#prerequisites)), so installing a
|
||||||
> the update (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) — uninstall the old app
|
> newer build over an existing one updates it in place — no uninstall, no
|
||||||
> first. **Uninstalling deletes the app's data: back up the wallet seed
|
> data loss. This only holds as long as every release keeps using that same
|
||||||
> before doing this.** Release signing with a stable key is not set up yet.
|
> keystore file; see `docker/keystore/README.md` for the backup story.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,8 +176,14 @@ docker volume rm plm-nuget-cache
|
|||||||
android workload moved to a newer API level. Bump
|
android workload moved to a newer API level. Bump
|
||||||
`ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in
|
`ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in
|
||||||
`Dockerfile.android` to the level the error names, then `--rebuild`.
|
`Dockerfile.android` to the level the error names, then `--rebuild`.
|
||||||
- **APK won't install over an existing app** — signature mismatch between
|
- **APK won't install over an existing app
|
||||||
debug keys; see the signature caveat above.
|
(`INSTALL_FAILED_UPDATE_INCOMPATIBLE`)** — the new build wasn't signed with
|
||||||
|
the same keystore as the installed one. Make sure `docker/keystore/release.keystore`
|
||||||
|
hasn't changed since the installed build; if it's genuinely a different key,
|
||||||
|
the user must uninstall the old app first (this deletes app data — back up
|
||||||
|
the wallet seed before doing this).
|
||||||
|
- **`build_android` refuses to run, asks to generate a keystore** — run
|
||||||
|
`./docker/keystore/generate-keystore.sh` once (see `docker/keystore/README.md`).
|
||||||
- **Everything is broken / start from scratch** —
|
- **Everything is broken / start from scratch** —
|
||||||
`docker system prune -a && docker volume rm plm-nuget-cache`, then rerun
|
`docker system prune -a && docker volume rm plm-nuget-cache`, then rerun
|
||||||
the script (images and packages are re-downloaded).
|
the script (images and packages are re-downloaded).
|
||||||
|
|||||||
+36
-2
@@ -29,7 +29,7 @@ $(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/
|
||||||
android Android APK (debug-signed) → dist/android/
|
android Android APK (release-signed) → dist/android/
|
||||||
all All three targets
|
all All three targets
|
||||||
|
|
||||||
$(bold "Options:")
|
$(bold "Options:")
|
||||||
@@ -112,6 +112,10 @@ ensure_android_image() {
|
|||||||
# $1 = image name, $2 = inline bash commands to execute inside the container.
|
# $1 = image name, $2 = inline bash commands to execute inside the container.
|
||||||
# Source is copied to /tmp/build inside the container so bin/obj never pollute
|
# Source is copied to /tmp/build inside the container so bin/obj never pollute
|
||||||
# the repo. Output is written to /output (→ host DIST_DIR sub-folder).
|
# the repo. Output is written to /output (→ host DIST_DIR sub-folder).
|
||||||
|
# Optional extra `docker run` args (e.g. keystore mount, signing passwords via
|
||||||
|
# -e) can be set by the caller in the EXTRA_DOCKER_ARGS array beforehand.
|
||||||
|
EXTRA_DOCKER_ARGS=()
|
||||||
|
|
||||||
run_build() {
|
run_build() {
|
||||||
local image="$1"
|
local image="$1"
|
||||||
local commands="$2"
|
local commands="$2"
|
||||||
@@ -123,6 +127,7 @@ run_build() {
|
|||||||
--volume "${PROJECT_ROOT}:/src:ro" \
|
--volume "${PROJECT_ROOT}:/src:ro" \
|
||||||
--volume "${out_dir}:/output" \
|
--volume "${out_dir}:/output" \
|
||||||
--volume "${NUGET_VOLUME}:/root/.nuget/packages" \
|
--volume "${NUGET_VOLUME}:/root/.nuget/packages" \
|
||||||
|
"${EXTRA_DOCKER_ARGS[@]}" \
|
||||||
"$image" \
|
"$image" \
|
||||||
bash -euo pipefail -c "
|
bash -euo pipefail -c "
|
||||||
cp -r /src/. /tmp/build
|
cp -r /src/. /tmp/build
|
||||||
@@ -169,16 +174,45 @@ build_linux() {
|
|||||||
|
|
||||||
build_android() {
|
build_android() {
|
||||||
ensure_android_image
|
ensure_android_image
|
||||||
|
|
||||||
|
local keystore="${SCRIPT_DIR}/keystore/release.keystore"
|
||||||
|
[[ -f "$keystore" ]] || die "No release keystore found at docker/keystore/release.keystore. Run ./docker/keystore/generate-keystore.sh once first (see docker/keystore/README.md)."
|
||||||
|
|
||||||
info "Building Android APK …"
|
info "Building Android APK …"
|
||||||
|
read -r -s -p "Keystore password: " ANDROID_KS_PASS
|
||||||
|
echo
|
||||||
|
read -r -s -p "Key password (press Enter to reuse the keystore password): " ANDROID_KEY_PASS
|
||||||
|
echo
|
||||||
|
ANDROID_KEY_PASS="${ANDROID_KEY_PASS:-$ANDROID_KS_PASS}"
|
||||||
|
|
||||||
|
# versionCode derived from <Version> (MAJOR.MINOR.PATCH, pre-release suffix
|
||||||
|
# stripped) so it always increases with releases — required for some
|
||||||
|
# installers to accept an in-place update even when the signature matches.
|
||||||
|
local semver="${VERSION%%-*}"
|
||||||
|
IFS='.' read -r VMAJOR VMINOR VPATCH <<< "$semver"
|
||||||
|
local VERSION_CODE=$(( ${VMAJOR:-0} * 10000 + ${VMINOR:-0} * 100 + ${VPATCH:-0} ))
|
||||||
|
info "versionCode: ${VERSION_CODE}"
|
||||||
|
|
||||||
|
EXTRA_DOCKER_ARGS=(
|
||||||
|
--volume "${keystore}:/keystore/release.keystore:ro"
|
||||||
|
--env "ANDROID_KS_PASS=${ANDROID_KS_PASS}"
|
||||||
|
--env "ANDROID_KEY_PASS=${ANDROID_KEY_PASS}"
|
||||||
|
)
|
||||||
run_build "$IMAGE_ANDROID" \
|
run_build "$IMAGE_ANDROID" \
|
||||||
"JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
"JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||||
dotnet build src/App.Android \
|
dotnet build src/App.Android \
|
||||||
-c Release \
|
-c Release \
|
||||||
-t:SignAndroidPackage \
|
-t:SignAndroidPackage \
|
||||||
-p:AndroidSdkDirectory=\${ANDROID_HOME}
|
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
|
||||||
|
-p:ApplicationVersion=${VERSION_CODE} \
|
||||||
|
-p:AndroidSigningKeyStore=/keystore/release.keystore \
|
||||||
|
-p:AndroidSigningKeyAlias=palladiumwallet \
|
||||||
|
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
|
||||||
|
-p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS}
|
||||||
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
|
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
|
||||||
\"/output/PalladiumWallet-${VERSION}.apk\"" \
|
\"/output/PalladiumWallet-${VERSION}.apk\"" \
|
||||||
"${DIST_DIR}/android"
|
"${DIST_DIR}/android"
|
||||||
|
EXTRA_DOCKER_ARGS=()
|
||||||
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
|
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Android release-signing keystore
|
||||||
|
|
||||||
|
This folder holds the persistent keystore used to sign release APKs built by
|
||||||
|
`docker/build.sh android`. Every release must be signed with the **same**
|
||||||
|
key, or Android refuses to install a new build over a previously installed
|
||||||
|
one (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and forces the user to uninstall
|
||||||
|
first — which deletes the app's data.
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker/keystore/generate-keystore.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this **once**, on whichever machine will keep producing release builds.
|
||||||
|
It creates `release.keystore` in this folder and asks for a keystore
|
||||||
|
password (and, optionally, a separate key password). Nothing is written to
|
||||||
|
git — `release.keystore` is ignored by `.gitignore`, and the script never
|
||||||
|
saves the passwords anywhere.
|
||||||
|
|
||||||
|
`docker/build.sh android` will refuse to run without this file, and will
|
||||||
|
prompt you for the same passwords at every build.
|
||||||
|
|
||||||
|
## Back it up
|
||||||
|
|
||||||
|
The keystore file and its passwords cannot be regenerated: if lost, no
|
||||||
|
future release can ever update existing installs in place again — every
|
||||||
|
user would need to uninstall and reinstall from scratch. Copy
|
||||||
|
`release.keystore` and the passwords to a password manager or encrypted
|
||||||
|
backup outside this repository as soon as you generate them.
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- Do not commit `release.keystore` (or any `*.jks`/`*.keystore` file) to git.
|
||||||
|
- Do not re-run `generate-keystore.sh` once a keystore already exists — it
|
||||||
|
refuses on purpose; delete the file yourself only if you fully accept the
|
||||||
|
consequences above.
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generates the persistent Android release-signing keystore used by
|
||||||
|
# docker/build.sh. Every APK built with this keystore carries the same
|
||||||
|
# signature, which is what lets Android treat a new release as an update to
|
||||||
|
# a previously installed one instead of a conflicting, different app.
|
||||||
|
#
|
||||||
|
# Run this ONCE. Re-running it after the keystore already exists is refused
|
||||||
|
# below on purpose: regenerating it changes the signature, and every device
|
||||||
|
# with a prior release installed would then need a full uninstall (and lose
|
||||||
|
# any app data that isn't in the wallet file itself) to receive the next
|
||||||
|
# update.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
KEYSTORE_PATH="${SCRIPT_DIR}/release.keystore"
|
||||||
|
ALIAS="palladiumwallet"
|
||||||
|
|
||||||
|
if [[ -f "$KEYSTORE_PATH" ]]; then
|
||||||
|
echo "A keystore already exists at $KEYSTORE_PATH — refusing to overwrite it." >&2
|
||||||
|
echo "Delete it manually first only if you understand the consequences above." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v keytool &>/dev/null || {
|
||||||
|
echo "keytool not found on PATH — install a JDK (e.g. openjdk-17-jdk) and retry." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Creating the Palladium Wallet Android release-signing keystore."
|
||||||
|
echo "This file and the passwords you enter now must be kept safe OUTSIDE this"
|
||||||
|
echo "repository (password manager, encrypted backup, ...). They are never"
|
||||||
|
echo "committed to git. Losing them means no future release can ever update"
|
||||||
|
echo "existing installs in place — only a fresh keystore + full user reinstall."
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -r -s -p "Keystore password (min 6 characters): " KS_PASS
|
||||||
|
echo
|
||||||
|
read -r -s -p "Confirm keystore password: " KS_PASS_CONFIRM
|
||||||
|
echo
|
||||||
|
if [[ "$KS_PASS" != "$KS_PASS_CONFIRM" ]]; then
|
||||||
|
echo "Passwords do not match." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${#KS_PASS}" -lt 6 ]]; then
|
||||||
|
echo "Password too short (keytool requires at least 6 characters)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -s -p "Key password (press Enter to reuse the keystore password): " KEY_PASS
|
||||||
|
echo
|
||||||
|
KEY_PASS="${KEY_PASS:-$KS_PASS}"
|
||||||
|
|
||||||
|
export KS_PASS KEY_PASS
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore "$KEYSTORE_PATH" \
|
||||||
|
-alias "$ALIAS" \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-storepass:env KS_PASS -keypass:env KEY_PASS \
|
||||||
|
-dname "CN=PalladiumWallet, OU=PalladiumWallet, O=PalladiumWallet, C=IT"
|
||||||
|
unset KS_PASS KEY_PASS
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Keystore created at $KEYSTORE_PATH (git-ignored, alias: $ALIAS)."
|
||||||
|
echo "Back it up now, together with the passwords — it cannot be regenerated."
|
||||||
|
echo "./docker/build.sh android will prompt for these same passwords at build time."
|
||||||
@@ -63,6 +63,10 @@ public sealed class Loc
|
|||||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
["help.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"],
|
||||||
|
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
||||||
|
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
||||||
|
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen"],
|
||||||
["donate.desc"] = [
|
["donate.desc"] = [
|
||||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
||||||
"If you find this wallet useful, consider a small donation to the developer.",
|
"If you find this wallet useful, consider a small donation to the developer.",
|
||||||
@@ -169,9 +173,9 @@ public sealed class Loc
|
|||||||
// Wallet panel
|
// Wallet panel
|
||||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
||||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden"],
|
||||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
||||||
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
|
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren"],
|
||||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
||||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
||||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
||||||
@@ -389,6 +393,7 @@ public sealed class Loc
|
|||||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
||||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||||
|
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
||||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
["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.",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
_pendingDonate = new TransactionFactory(_account).Build(
|
_pendingDonate = new TransactionFactory(_account).Build(
|
||||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||||
_doc.Cache.NextChangeIndex, sendAll: false);
|
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
|
||||||
|
|
||||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string unconfirmedText = "";
|
private string unconfirmedText = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string immatureText = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string networkInfo = "";
|
private string networkInfo = "";
|
||||||
|
|
||||||
@@ -249,6 +252,7 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||||
UnconfirmedText = "";
|
UnconfirmedText = "";
|
||||||
|
ImmatureText = "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
@@ -261,11 +265,14 @@ public partial class MainWindowViewModel
|
|||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BalanceText = Fmt(cache.ConfirmedSats);
|
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
|
||||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||||
UnconfirmedText = pending != 0
|
UnconfirmedText = pending != 0
|
||||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||||
: "";
|
: "";
|
||||||
|
ImmatureText = cache.ImmatureSats != 0
|
||||||
|
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
||||||
|
: "";
|
||||||
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)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
_pendingSend = new TransactionFactory(_account).Build(
|
_pendingSend = new TransactionFactory(_account).Build(
|
||||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||||
_doc.Cache.NextChangeIndex, SendAll);
|
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||||
|
|
||||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -55,6 +56,8 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isSyncing;
|
private bool isSyncing;
|
||||||
|
|
||||||
|
private CancellationTokenSource _syncCts = new();
|
||||||
|
|
||||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -165,11 +168,23 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
if (IsSyncing)
|
if (IsSyncing)
|
||||||
{
|
{
|
||||||
|
var (newHost, newPort) = ParseServer();
|
||||||
|
bool serverChanged = _client is null
|
||||||
|
|| _client.Host != newHost
|
||||||
|
|| _client.Port != newPort
|
||||||
|
|| _client.UseSsl != UseSsl;
|
||||||
|
if (serverChanged)
|
||||||
|
_syncCts.Cancel();
|
||||||
|
else
|
||||||
_resyncRequested = true;
|
_resyncRequested = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncCts = new CancellationTokenSource();
|
||||||
|
var ct = _syncCts.Token;
|
||||||
IsSyncing = true;
|
IsSyncing = true;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
|
bool cancelled = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (host, port) = ParseServer();
|
var (host, port) = ParseServer();
|
||||||
@@ -194,12 +209,13 @@ public partial class MainWindowViewModel
|
|||||||
Exception? lastError = null;
|
Exception? lastError = null;
|
||||||
foreach (var (h, p) in candidates)
|
foreach (var (h, p) in candidates)
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||||
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||||
_client.NotificationReceived += OnServerNotification;
|
_client.NotificationReceived += OnServerNotification;
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -226,6 +242,10 @@ public partial class MainWindowViewModel
|
|||||||
lastError = null;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
lastError = ex;
|
lastError = ex;
|
||||||
@@ -258,7 +278,7 @@ public partial class MainWindowViewModel
|
|||||||
do
|
do
|
||||||
{
|
{
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
var result = await _synchronizer.SyncOnceAsync();
|
var result = await _synchronizer.SyncOnceAsync(ct);
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||||
@@ -268,6 +288,7 @@ public partial class MainWindowViewModel
|
|||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
|
ImmatureSats = result.ImmatureSats,
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
@@ -282,7 +303,16 @@ public partial class MainWindowViewModel
|
|||||||
_syncFailed = false;
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||||
} while (_resyncRequested);
|
} while (_resyncRequested && !ct.IsCancellationRequested);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Intentional cancellation due to server change request — not an error.
|
||||||
|
cancelled = true;
|
||||||
|
IsConnected = false;
|
||||||
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||||
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
catch (CertificatePinMismatchException ex)
|
catch (CertificatePinMismatchException ex)
|
||||||
{
|
{
|
||||||
@@ -309,19 +339,63 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
IsSyncing = false;
|
IsSyncing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If cancelled due to a server change, restart immediately with the new server.
|
||||||
|
if (cancelled)
|
||||||
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
||||||
|
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||||
|
/// just to query its peer list, without touching the wallet's connection state.
|
||||||
|
/// </summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DiscoverServers()
|
private async Task DiscoverServers()
|
||||||
{
|
{
|
||||||
if (_client is null || !_client.IsConnected)
|
if (_client is { IsConnected: true } connected)
|
||||||
{
|
{
|
||||||
StatusMessage = Loc.Tr("conn.none") + ".";
|
await DiscoverServersUsing(connected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (host, port) = ParseServer();
|
||||||
|
ElectrumClient? temp = null;
|
||||||
|
Exception? lastError = null;
|
||||||
|
foreach (var (h, p) in BuildServerCandidates(host, port))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
|
temp = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastError = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (temp is null)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Errore nella scoperta peer: {lastError?.Message ?? Loc.Tr("conn.none")}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var added = await Registry.DiscoverAsync(_client);
|
await DiscoverServersUsing(temp);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await temp.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscoverServersUsing(ElectrumClient client)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var added = await Registry.DiscoverAsync(client);
|
||||||
var selected = SelectedKnownServer;
|
var selected = SelectedKnownServer;
|
||||||
RefreshServers();
|
RefreshServers();
|
||||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainWindowViewModel
|
||||||
|
{
|
||||||
|
// ---- update-available overlay ----
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isUpdateAvailableOpen;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string updateAvailableTag = "";
|
||||||
|
|
||||||
|
public string UpdateReleaseUrl { get; private set; } = "";
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
||||||
|
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
||||||
|
/// </summary>
|
||||||
|
private async Task CheckForUpdatesAsync()
|
||||||
|
{
|
||||||
|
var latest = await UpdateChecker.CheckAsync(AppVersion);
|
||||||
|
if (latest is null) return;
|
||||||
|
|
||||||
|
UpdateReleaseUrl = latest.HtmlUrl;
|
||||||
|
UpdateAvailableTag = latest.Tag;
|
||||||
|
IsUpdateAvailableOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||||
_keepAliveTimer.Start();
|
_keepAliveTimer.Start();
|
||||||
|
_ = CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||||
|
|||||||
@@ -314,6 +314,9 @@
|
|||||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
|
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
<TextBlock Text="{Binding 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>
|
||||||
@@ -1233,14 +1236,19 @@
|
|||||||
<!-- Actions — Desktop: in a row -->
|
<!-- Actions — Desktop: in a row -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
||||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
Command="{Binding ConnectAndSyncCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wallet.discover]}"
|
||||||
|
Command="{Binding DiscoverServersCommand}"/>
|
||||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||||
Command="{Binding ResetCertificatesCommand}"/>
|
Command="{Binding ResetCertificatesCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!-- Actions — Mobile: stacked -->
|
<!-- Actions — Mobile: stacked -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
||||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"
|
Command="{Binding ConnectAndSyncCommand}"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
|
<Button Content="{Binding Loc[wallet.discover]}"
|
||||||
|
Command="{Binding DiscoverServersCommand}"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||||
Command="{Binding ResetCertificatesCommand}"
|
Command="{Binding ResetCertificatesCommand}"
|
||||||
@@ -1630,5 +1638,34 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ UPDATE AVAILABLE OVERLAY ============ -->
|
||||||
|
<!-- Same pattern as the help overlay: instant open/close. -->
|
||||||
|
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||||
|
Background="{DynamicResource ScrimBrush}"
|
||||||
|
Tapped="OnUpdateAvailableOverlayBackdropTapped"
|
||||||
|
IsVisible="{Binding IsUpdateAvailableOpen}">
|
||||||
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
|
MaxWidth="440" Margin="16"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Margin="24" Spacing="16">
|
||||||
|
<TextBlock Text="{Binding Loc[update.title]}"
|
||||||
|
FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
<Run Text="{Binding Loc[update.message]}"/>
|
||||||
|
<Run Text=" "/>
|
||||||
|
<Run Text="{Binding UpdateAvailableTag}" FontWeight="Bold"/>
|
||||||
|
</TextBlock>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="{Binding Loc[update.download]}"
|
||||||
|
Classes="accent"
|
||||||
|
Click="OnOpenReleasePageClick"/>
|
||||||
|
<Button Content="{Binding Loc[update.dismiss]}"
|
||||||
|
Command="{Binding CloseUpdateAvailableCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -129,16 +129,30 @@ public partial class MainView : UserControl
|
|||||||
vm.IsHelpOpen = false;
|
vm.IsHelpOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnUpdateAvailableOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(e.Source, sender)) return;
|
||||||
|
if (DataContext is MainWindowViewModel vm)
|
||||||
|
vm.IsUpdateAvailableOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
// TODO: replace with the final GitHub URL once the repo is on GitHub
|
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
||||||
// e.g. https://github.com/davide3011/PalladiumWallet/issues/new?assignees=&labels=bug&template=bug_report.yml
|
|
||||||
const string issueUrl = "https://santantonio.sytes.net/davide/PalladiumWallet/issues/new";
|
|
||||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||||
if (launcher is not null)
|
if (launcher is not null)
|
||||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
||||||
|
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||||
|
if (launcher is not null)
|
||||||
|
await launcher.LaunchUriAsync(new Uri(vm.UpdateReleaseUrl));
|
||||||
|
vm.IsUpdateAvailableOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -151,6 +165,7 @@ public partial class MainView : UserControl
|
|||||||
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
||||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
||||||
|
if (vm.IsUpdateAvailableOpen) { vm.IsUpdateAvailableOpen = false; e.Handled = true; return; }
|
||||||
}
|
}
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -104,8 +104,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($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
||||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
|
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} in maturazione (non spendibile)" : "")
|
||||||
|
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa di conferma (non spendibile)." : ""));
|
||||||
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
||||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||||
}
|
}
|
||||||
@@ -147,6 +148,7 @@ static async Task<int> Sync(string[] o)
|
|||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
|
ImmatureSats = result.ImmatureSats,
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
@@ -158,7 +160,8 @@ static async Task<int> Sync(string[] o)
|
|||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
||||||
|
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} in maturazione (non spendibile)" : "")
|
||||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
||||||
Console.WriteLine($"Storico ({result.History.Count}):");
|
Console.WriteLine($"Storico ({result.History.Count}):");
|
||||||
foreach (var tx in result.History)
|
foreach (var tx in result.History)
|
||||||
@@ -193,7 +196,7 @@ static async Task<int> Send(string[] o)
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
||||||
doc.Cache.NextChangeIndex, sendAll);
|
doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll);
|
||||||
|
|
||||||
Console.WriteLine($"txid: {built.Txid}");
|
Console.WriteLine($"txid: {built.Txid}");
|
||||||
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ public sealed record ChainProfile
|
|||||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||||
public required int BlockTimeSeconds { get; init; }
|
public required int BlockTimeSeconds { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Blocks a coinbase output must wait before it can be spent.</summary>
|
||||||
|
public required int CoinbaseMaturity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Minimum confirmations required for a regular UTXO to be spendable.</summary>
|
||||||
|
public required int MinConfirmations { get; init; }
|
||||||
|
|
||||||
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
||||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public static class ChainProfiles
|
|||||||
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
||||||
SkipPowValidation = true,
|
SkipPowValidation = true,
|
||||||
BlockTimeSeconds = 120,
|
BlockTimeSeconds = 120,
|
||||||
|
CoinbaseMaturity = 120,
|
||||||
|
MinConfirmations = 6,
|
||||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||||
{
|
{
|
||||||
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
||||||
@@ -53,6 +55,7 @@ public static class ChainProfiles
|
|||||||
{
|
{
|
||||||
Kind = NetKind.Testnet,
|
Kind = NetKind.Testnet,
|
||||||
NetName = "testnet",
|
NetName = "testnet",
|
||||||
|
MinConfirmations = 1,
|
||||||
WifPrefix = 0xff,
|
WifPrefix = 0xff,
|
||||||
AddrP2pkh = 127,
|
AddrP2pkh = 127,
|
||||||
AddrP2sh = 115,
|
AddrP2sh = 115,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
|
|||||||
MajorityRejectBlockOutdated = 950,
|
MajorityRejectBlockOutdated = 950,
|
||||||
MajorityWindow = 1000,
|
MajorityWindow = 1000,
|
||||||
MinimumChainWork = uint256.Zero,
|
MinimumChainWork = uint256.Zero,
|
||||||
CoinbaseMaturity = 100,
|
CoinbaseMaturity = 120,
|
||||||
SupportSegwit = true,
|
SupportSegwit = true,
|
||||||
SupportTaproot = true,
|
SupportTaproot = true,
|
||||||
ConsensusFactory = new ConsensusFactory(),
|
ConsensusFactory = new ConsensusFactory(),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
/// <summary>A GitHub release newer than the running app.</summary>
|
||||||
|
public sealed record LatestRelease(string Tag, string HtmlUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the latest GitHub release for the project against the running app version.
|
||||||
|
/// Best-effort only: any network/parse failure or an up-to-date app both resolve to null,
|
||||||
|
/// so callers never need to distinguish "no update" from "couldn't check".
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateChecker
|
||||||
|
{
|
||||||
|
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
|
||||||
|
|
||||||
|
private sealed class GitHubReleaseResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag_name")]
|
||||||
|
public string? TagName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("html_url")]
|
||||||
|
public string? HtmlUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
||||||
|
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
|
var release = await response.Content
|
||||||
|
.ReadFromJsonAsync<GitHubReleaseResponse>(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (release?.TagName is not { Length: > 0 } tag) return null;
|
||||||
|
|
||||||
|
if (!TryParse(tag, out var remote) || !TryParse(currentVersion, out var local))
|
||||||
|
return null;
|
||||||
|
if (remote <= local) return null;
|
||||||
|
|
||||||
|
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
||||||
|
private static bool TryParse(string raw, out Version version)
|
||||||
|
{
|
||||||
|
var s = raw.Trim();
|
||||||
|
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
||||||
|
var dash = s.IndexOf('-');
|
||||||
|
if (dash >= 0) s = s[..dash];
|
||||||
|
return Version.TryParse(s, out version!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
|
|||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
@@ -21,6 +22,13 @@ public sealed class SyncResult
|
|||||||
public required int TipHeight { get; init; }
|
public required int TipHeight { get; init; }
|
||||||
public required long ConfirmedSats { get; init; }
|
public required long ConfirmedSats { get; init; }
|
||||||
public required long UnconfirmedSats { get; init; }
|
public required long UnconfirmedSats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirmed but not yet spendable: coinbase outputs below maturity or regular
|
||||||
|
/// outputs below <see cref="Chain.ChainProfile.MinConfirmations"/>. Subset of
|
||||||
|
/// <see cref="ConfirmedSats"/>.
|
||||||
|
/// </summary>
|
||||||
|
public required long ImmatureSats { get; init; }
|
||||||
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; }
|
||||||
@@ -225,6 +233,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
IsChange = addr.IsChange,
|
IsChange = addr.IsChange,
|
||||||
AddressIndex = addr.Index,
|
AddressIndex = addr.Index,
|
||||||
Height = txHeights[txid],
|
Height = txHeights[txid],
|
||||||
|
IsCoinbase = tx.IsCoinBase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +284,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
TipHeight = tip.Height,
|
TipHeight = tip.Height,
|
||||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||||
|
ImmatureSats = utxos.Where(u =>
|
||||||
|
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile))
|
||||||
|
.Sum(u => u.ValueSats),
|
||||||
NextReceiveIndex = nextReceive,
|
NextReceiveIndex = nextReceive,
|
||||||
NextChangeIndex = nextChange,
|
NextChangeIndex = nextChange,
|
||||||
History = history,
|
History = history,
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ public sealed class SyncCache
|
|||||||
public int TipHeight { get; set; }
|
public int TipHeight { get; set; }
|
||||||
public long ConfirmedSats { get; set; }
|
public long ConfirmedSats { get; set; }
|
||||||
public long UnconfirmedSats { get; set; }
|
public long UnconfirmedSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||||
|
public long ImmatureSats { get; set; }
|
||||||
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; } = [];
|
||||||
@@ -150,5 +153,6 @@ public sealed class CachedUtxo
|
|||||||
public bool IsChange { get; set; }
|
public bool IsChange { get; set; }
|
||||||
public int AddressIndex { get; set; }
|
public int AddressIndex { get; set; }
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
|
public bool IsCoinbase { get; set; }
|
||||||
public bool Frozen { get; set; }
|
public bool Frozen { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
||||||
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||||
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
||||||
|
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
|
||||||
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||||
public BuiltTransaction Build(
|
public BuiltTransaction Build(
|
||||||
IReadOnlyList<CachedUtxo> utxos,
|
IReadOnlyList<CachedUtxo> utxos,
|
||||||
@@ -48,16 +49,41 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
long amountSats,
|
long amountSats,
|
||||||
decimal feeRateSatPerVByte,
|
decimal feeRateSatPerVByte,
|
||||||
int changeIndex,
|
int changeIndex,
|
||||||
|
int tipHeight,
|
||||||
bool sendAll = false)
|
bool sendAll = false)
|
||||||
{
|
{
|
||||||
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
var profile = account.Profile;
|
||||||
// balance but are not spendable until confirmed.
|
var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
|
||||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
|
||||||
if (spendable.Count == 0)
|
if (spendable.Count == 0)
|
||||||
{
|
{
|
||||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
var reasons = new System.Text.StringBuilder();
|
||||||
throw new WalletSpendException(pending > 0
|
|
||||||
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList();
|
||||||
|
if (mempool.Count > 0)
|
||||||
|
reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). ");
|
||||||
|
|
||||||
|
var immature = utxos.Where(u =>
|
||||||
|
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
|
||||||
|
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||||
|
if (immature.Count > 0)
|
||||||
|
{
|
||||||
|
var best = immature.Max(u => u.Confirmations(tipHeight));
|
||||||
|
var threshold = profile.CoinbaseMaturity + 1;
|
||||||
|
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
|
||||||
|
}
|
||||||
|
|
||||||
|
var underConf = utxos.Where(u =>
|
||||||
|
!u.Frozen && u.Height > 0 && !u.IsCoinbase &&
|
||||||
|
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||||
|
if (underConf.Count > 0)
|
||||||
|
{
|
||||||
|
var best = underConf.Max(u => u.Confirmations(tipHeight));
|
||||||
|
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WalletSpendException(reasons.Length > 0
|
||||||
|
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||||
: "No spendable UTXOs selected.");
|
: "No spendable UTXOs selected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirmation-threshold rules shared between coin selection (<see cref="TransactionFactory"/>)
|
||||||
|
/// and balance reporting: a coinbase output needs COINBASE_MATURITY + 1 confirmations
|
||||||
|
/// (consensus rule nSpendHeight - nHeight >= 120, plus one block of safety margin, matching
|
||||||
|
/// the Qt wallet); a regular output needs <see cref="ChainProfile.MinConfirmations"/> (wallet
|
||||||
|
/// policy, no consensus rule).
|
||||||
|
/// </summary>
|
||||||
|
public static class UtxoSpendability
|
||||||
|
{
|
||||||
|
public static int RequiredConfirmations(this CachedUtxo utxo, ChainProfile profile) =>
|
||||||
|
utxo.IsCoinbase ? profile.CoinbaseMaturity + 1 : profile.MinConfirmations;
|
||||||
|
|
||||||
|
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||||
|
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
||||||
|
|
||||||
|
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</summary>
|
||||||
|
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||||
|
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
utxos, txs, destination, amountSats: 400_000,
|
utxos, txs, destination, amountSats: 400_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
Assert.True(built.Signed);
|
Assert.True(built.Signed);
|
||||||
// Output: recipient + change.
|
// Output: recipient + change.
|
||||||
@@ -74,7 +74,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
utxos, txs, destination, amountSats: 0,
|
utxos, txs, destination, amountSats: 0,
|
||||||
feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100, sendAll: true);
|
||||||
|
|
||||||
var output = Assert.Single(built.Transaction.Outputs);
|
var output = Assert.Single(built.Transaction.Outputs);
|
||||||
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
||||||
@@ -88,7 +88,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -100,8 +100,8 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
Assert.Contains("pending confirmation", ex.Message);
|
Assert.Contains("unconfirmed", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -113,7 +113,62 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_utxo_coinbase_immaturi_non_sono_spendibili()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
|
utxos[0].IsCoinbase = true;
|
||||||
|
utxos[0].Height = 100;
|
||||||
|
// Threshold = COINBASE_MATURITY + 1 = 121 (mirrors the Qt wallet: consensus rule is
|
||||||
|
// nSpendHeight - nHeight >= 120, plus one block of safety margin).
|
||||||
|
// At height=100, tip=219 → confs = 219-100+1 = 120 < 121 → immature.
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 219));
|
||||||
|
Assert.Contains("coinbase", ex.Message);
|
||||||
|
Assert.Contains("120/121", ex.Message);
|
||||||
|
|
||||||
|
// tip=220 → confs = 121 ≥ 121 → mature and spendable.
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 220);
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_utxo_con_meno_di_minconf_non_sono_spendibili_su_mainnet()
|
||||||
|
{
|
||||||
|
var mainnetAccount = HdAccount.FromMnemonic(
|
||||||
|
Bip39.TryParse("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", out var m) ? m! : throw new Exception(),
|
||||||
|
null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
|
||||||
|
var funding = PalladiumNetworks.Mainnet.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), mainnetAccount.GetReceiveAddress(0));
|
||||||
|
var txid = funding.GetHash().ToString();
|
||||||
|
var utxos = new List<CachedUtxo>
|
||||||
|
{
|
||||||
|
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
||||||
|
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
||||||
|
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
|
||||||
|
};
|
||||||
|
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
||||||
|
|
||||||
|
// 5 confirmations (tipHeight=104): below the mainnet minimum of 6.
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(mainnetAccount).Build(
|
||||||
|
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 104));
|
||||||
|
Assert.Contains("5 so far", ex.Message);
|
||||||
|
|
||||||
|
// 6 confirmations (tipHeight=105): exactly at the threshold → spendable.
|
||||||
|
var built = new TransactionFactory(mainnetAccount).Build(
|
||||||
|
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 105);
|
||||||
|
Assert.True(built.Signed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -127,7 +182,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(watchOnly).Build(
|
var built = new TransactionFactory(watchOnly).Build(
|
||||||
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
Assert.False(built.Signed);
|
Assert.False(built.Signed);
|
||||||
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
||||||
|
|||||||
Reference in New Issue
Block a user