7 Commits

Author SHA1 Message Date
davide 15978bf564 chore(release): bump version to 0.9.1
Fills in the CHANGELOG.md entry for the test-suite expansion and bug fixes
since 0.9.0 (see previous commits), and bumps <Version>/versionCode across
the App and Android head csproj files ahead of the tag.
2026-07-02 23:25:48 +02:00
davide 31aabd0856 docs: document expanded test coverage and the fake ElectrumX server
README's "Running tests" section now lists what each test area covers and
how to measure coverage; CLAUDE.md/AGENTS.md point future work at extending
the fake ElectrumX server instead of mocking ElectrumClient (it isn't an
interface, by design).
2026-07-02 23:21:54 +02:00
davide 56ce1d4259 test: extend wallet/storage/property coverage and fix nullable warnings
- AppPaths: data-root resolution, per-network paths, wallet file listing.
- TransactionFactory: legacy/P2SH/segwit destinations, multi-UTXO selection,
  dust change absorbed into fee, and a golden txid for the PSBT signing path
  (RFC 6979 makes it deterministic; the builder's own output shuffling for
  privacy makes anchoring the vector at the top-level Build() unreliable).
- PropertyTests: Slip132 roundtrip for every script kind and network,
  WalletDocument JSON roundtrip with arbitrary labels/contacts, Scripthash
  cross-checked against an independent SHA-256 computation.
- StorageTests: regression cases for the IsEncrypted fix.
- Removed CS8602/CS8604 nullable warnings from existing address/loader tests.
2026-07-02 23:21:25 +02:00
davide 448f7dc65d test: cover network/SPV layer end to end (was previously untested)
ElectrumClient, WalletSynchronizer, TransactionInspector, CertificatePinStore
and peer discovery went from 0% coverage to close to full coverage, all
against the fake ElectrumX server:

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

Also exposes UpdateChecker's tag-parsing logic internally so its version
comparison can be tested without a real GitHub API call.
2026-07-02 23:20:32 +02:00
davide 27231c8eec fix(core): stop corrupted state from permanently breaking SSL and wallet loading
CertificatePinStore.Load threw on a corrupted pin file, blocking every SSL
connection until the user manually deleted it; EncryptedFile.IsEncrypted
threw on valid JSON with a non-object root or invalid UTF-16 instead of
returning false. Both now degrade gracefully. Found by property-based tests
while extending the test suite.
2026-07-02 23:19:46 +02:00
davide eb7ec9e835 chore: add update-version.sh to bump version across the project
Prompts for the new version and updates the App csproj Version (single
source of truth), mirrors it into the Android head's
ApplicationDisplayVersion, bumps the Android versionCode, and stubs a
new CHANGELOG.md entry.
2026-07-02 22:21:19 +02:00
25 changed files with 1751 additions and 22 deletions
+6 -3
View File
@@ -29,6 +29,7 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico -> runnable
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity -> apk
src/Cli/ CLI on the same Core tests/ xUnit
docker/ reproducible release builds (build.sh + pinned Dockerfiles) -> dist/
```
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
@@ -43,11 +44,12 @@ by `MainWindow` on desktop and as the single-view root on Android.
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
- Build: `dotnet build`
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
- 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; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client -- it isn't an interface, by design.
- 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)
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
- **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 Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
@@ -82,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.
- **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.
+48
View File
@@ -5,6 +5,54 @@ Technical changelog for PalladiumWallet. Format loosely follows
by subsystem rather than strictly by date, since `0.9.0` is the first
release and covers the full history from the initial commit.
## [0.9.1] — 2026-07-02
### Testing
- Test suite expanded from 239 to 307 tests; `Core` line coverage raised
from ~50% to ~92% (branch coverage from ~41% to ~79%).
- In-process fake ElectrumX server (`tests/.../Net/FakeElectrumServer.cs`):
a real loopback TCP socket speaking newline-delimited JSON-RPC 2.0,
optionally TLS with a self-signed certificate, with per-method handlers
and call counters — exercises the network stack against real socket code
instead of mocks.
- New end-to-end coverage for previously untested network/SPV code:
`ElectrumClient` (request pipelining, error mapping, notifications,
disconnection, cancellation, TLS/TOFU handshake), `WalletSynchronizer`
(gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature
balances, busy-retry, disk-cache reuse, and the security-critical path
where a lying server fails Merkle verification and the sync aborts),
`TransactionInspector` (fee calculation, mine/theirs attribution, RBF,
coinbase handling), `CertificatePinStore` (TOFU pin/match/mismatch/reset),
`ServerRegistry` peer discovery, and `UpdateChecker` tag parsing.
- `TransactionFactory`: added coverage for legacy/P2SH/segwit destinations,
multi-UTXO selection, dust change absorbed into the fee, and a golden
txid anchoring the PSBT signing path (deterministic via RFC 6979).
- Property-based tests extended: SLIP-132 roundtrip for every script kind
and network, `WalletDocument` JSON roundtrip with arbitrary
labels/contacts, `Scripthash` cross-checked against an independent
SHA-256 computation.
- `update-version.sh`: single script to bump the version across the project
ahead of a release tag.
### Fixed
- `CertificatePinStore.Load`: a corrupted pin file threw and blocked every
SSL connection until the user manually deleted it; now falls back to
first-contact TOFU, same as `ServerRegistry` already did for its own file.
- `EncryptedFile.IsEncrypted`: threw on valid JSON with a non-object root
(e.g. a bare number or array) or on invalid UTF-16 input, instead of
returning `false`. Both bugs were found by the expanded property tests.
### Documentation
- `README.md`: "Running tests" section rewritten with a per-area coverage
table, the coverage-measurement command, and a description of the fake
ElectrumX server.
- `CLAUDE.md`/`AGENTS.md` kept in sync, pointing future work at extending
the fake ElectrumX server instead of mocking `ElectrumClient` (not an
interface, by design).
## [0.9.0] — 2026-07-02
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
+1 -1
View File
@@ -44,7 +44,7 @@ by `MainWindow` on desktop and as the single-view root on Android.
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
- Build: `dotnet build`
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
- 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; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible 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.
+26 -1
View File
@@ -139,9 +139,34 @@ Run only the tests in one project:
dotnet test tests/PalladiumWallet.Tests
```
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`):
```bash
dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"
```
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
The suite includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
### What the suite covers
The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
| Area | What is verified |
|---|---|
| `Chain/` | Network profiles (prefixes, ports, coin type 746, SLIP-132 headers), NBitcoin network registration |
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode, HD and imported-key accounts, watch-only isolation |
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the path where a lying server fails Merkle verification and the sync must abort |
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), release-tag parsing |
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data paths |
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting |
Network-facing code is tested against an **in-process fake ElectrumX server**
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
socket speaking newline-delimited JSON-RPC (optionally TLS with a self-signed
certificate), with per-method handlers and call counters. Client, synchroniser
and inspector therefore exercise the same code paths used in production —
framing, retries, TLS pinning included — without any external dependency.
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
---
@@ -8,8 +8,8 @@
<Nullable>enable</Nullable>
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion>
<ApplicationDisplayVersion>0.9.1</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
+1 -1
View File
@@ -6,7 +6,7 @@
<TargetFramework>net10.0</TargetFramework>
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
titolo della finestra ed è incisa nei binari pubblicati. -->
<Version>0.9.0</Version>
<Version>0.9.1</Version>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
+15 -4
View File
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
}
}
private Dictionary<string, string> Load() =>
File.Exists(filePath)
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
: [];
private Dictionary<string, string> Load()
{
if (!File.Exists(filePath))
return [];
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
}
catch (JsonException)
{
// A corrupt pin file must not make every SSL connection fail forever:
// fall back to first-contact TOFU (same trust level as the bootstrap).
return [];
}
}
private void Save(Dictionary<string, string> pins)
{
+1 -1
View File
@@ -55,7 +55,7 @@ public static class UpdateChecker
}
/// <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)
internal static bool TryParse(string raw, out Version version)
{
var s = raw.Trim();
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
+4
View File
@@ -10,4 +10,8 @@
<PackageReference Include="NBitcoin" Version="10.0.6" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
</ItemGroup>
</Project>
+9 -1
View File
@@ -26,13 +26,21 @@ public static class EncryptedFile
try
{
using var doc = JsonDocument.Parse(fileContent);
return doc.RootElement.TryGetProperty("Format", out var f)
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("Format", out var f)
&& f.ValueKind == JsonValueKind.String
&& f.GetString() == Format;
}
catch (JsonException)
{
return false;
}
catch (ArgumentException)
{
// Invalid UTF-16 (lone surrogates) cannot be transcoded for parsing:
// certainly not an encrypted container.
return false;
}
}
public static string Encrypt(string plaintext, string password)
@@ -20,7 +20,7 @@ public class AddressDerivationTests
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0);
var pubKey = account.GetPublicKey(isChange: false, 0)!;
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
@@ -37,7 +37,7 @@ public class AddressDerivationTests
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0);
var pubKey = account.GetPublicKey(isChange: false, 0)!;
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
@@ -83,7 +83,7 @@ public class Bip84Slip132Tests
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
{
var account = Account();
var pubKey = account.GetPublicKey(isChange, index);
var pubKey = account.GetPublicKey(isChange, index)!;
if (expectedPubKeyHex is not null)
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
@@ -0,0 +1,160 @@
using System.Security.Cryptography.X509Certificates;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
/// certificate passes, changed certificate is rejected until an explicit reset.
/// </summary>
public class CertificatePinStoreTests
{
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
private static X509Certificate2 Cert(string cn) =>
FakeElectrumServer.CreateSelfSignedCertificate(cn);
private static void Cleanup(string path)
{
if (Directory.Exists(Path.GetDirectoryName(path)))
Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
}
[Fact]
public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
{
var path = TempPath();
try
{
using var cert = Cert("server");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
Assert.True(File.Exists(path));
Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
// A fresh instance reads the same file (persistence).
Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
}
finally { Cleanup(path); }
}
[Fact]
public void Un_certificato_cambiato_viene_rifiutato()
{
var path = TempPath();
try
{
using var original = Cert("originale");
using var changed = Cert("cambiato");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, original));
Assert.False(store.VerifyOrPin("host", 50002, changed));
// The rejection must not overwrite the pin.
Assert.True(store.VerifyOrPin("host", 50002, original));
}
finally { Cleanup(path); }
}
[Fact]
public void Host_e_porte_diverse_hanno_pin_indipendenti()
{
var path = TempPath();
try
{
using var cert1 = Cert("uno");
using var cert2 = Cert("due");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
}
finally { Cleanup(path); }
}
[Fact]
public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
{
var path = TempPath();
try
{
using var original = Cert("originale");
using var renewed = Cert("rinnovato");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, original));
Assert.False(store.VerifyOrPin("host", 50002, renewed));
Assert.True(store.Reset("host", 50002));
Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
}
finally { Cleanup(path); }
}
[Fact]
public void Il_reset_di_un_server_mai_visto_restituisce_false()
{
var path = TempPath();
try
{
Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
}
finally { Cleanup(path); }
}
[Fact]
public void ResetAll_cancella_il_file_e_tutti_i_pin()
{
var path = TempPath();
try
{
using var cert1 = Cert("uno");
using var cert2 = Cert("due");
var store = new CertificatePinStore(path);
store.VerifyOrPin("host-a", 50002, cert1);
store.VerifyOrPin("host-b", 50002, cert1);
store.ResetAll();
Assert.False(File.Exists(path));
Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
}
finally { Cleanup(path); }
}
[Fact]
public void Un_file_pin_corrotto_non_blocca_le_connessioni()
{
var path = TempPath();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "{ non-json ");
using var cert = Cert("server");
var store = new CertificatePinStore(path);
Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
}
finally { Cleanup(path); }
}
[Fact]
public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
{
using var cert = Cert("server");
var fingerprint = CertificatePinStore.Fingerprint(cert);
Assert.Equal(64, fingerprint.Length);
Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
Assert.Equal(
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
.ToLowerInvariant(),
fingerprint);
}
}
@@ -0,0 +1,189 @@
using System.Text.Json;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
/// (real loopback TCP socket: framing, pipelining, and failure paths are the
/// same code paths used in production).
/// </summary>
public class ElectrumClientTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task Connessione_e_richiesta_fanno_roundtrip()
{
await using var server = new FakeElectrumServer();
server.Handle("server.banner", _ => "benvenuto");
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
Assert.True(client.IsConnected);
Assert.Equal("benvenuto", await client.BannerAsync());
// ConnectAsync performs the protocol handshake up front.
Assert.Equal(1, server.CallCount("server.version"));
}
[Fact]
public async Task Un_errore_del_server_diventa_ElectrumServerException()
{
await using var server = new FakeElectrumServer();
server.Handle("blockchain.transaction.get",
_ => throw new FakeElectrumError(-32600, "tx non trovata"));
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var ex = await Assert.ThrowsAsync<ElectrumServerException>(
() => client.GetTransactionAsync("00"));
Assert.Contains("tx non trovata", ex.Message);
Assert.Contains("-32600", ex.Message);
}
[Fact]
public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
{
await using var server = new FakeElectrumServer();
server.Handle("echo", p => p[0].GetInt32());
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
// More requests than the in-flight cap (32): exercises both the write
// batching and the id → response correlation.
var tasks = Enumerable.Range(0, 200)
.Select(i => client.RequestAsync("echo", default, i))
.ToList();
var results = await Task.WhenAll(tasks);
for (var i = 0; i < results.Length; i++)
Assert.Equal(i, results[i].GetInt32());
}
[Fact]
public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
{
await using var server = new FakeElectrumServer();
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
await server.NotifyAsync("blockchain.headers.subscribe", new object[]
{
new { height = 4242, hex = "00" },
});
var (method, parameters) = await received.Task.WaitAsync(Timeout);
Assert.Equal("blockchain.headers.subscribe", method);
Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
}
[Fact]
public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
{
await using var server = new FakeElectrumServer();
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.Disconnected += _ => disconnected.TrySetResult();
var pending = client.BannerAsync();
server.DropClients();
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
await disconnected.Task.WaitAsync(Timeout);
}
[Fact]
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
{
await using var server = new FakeElectrumServer();
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
using var cts = new CancellationTokenSource();
var pending = client.BannerAsync(cts.Token);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending.WaitAsync(Timeout));
}
[Fact]
public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
{
await using var server = new FakeElectrumServer();
server.Handle("blockchain.scripthash.listunspent", _ => new object[]
{
new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
});
server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
server.Handle("blockchain.estimatefee", _ => 0.00012m);
server.Handle("blockchain.relayfee", _ => 0.00001m);
server.Handle("server.ping", _ => null);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
Assert.Equal(12_345L, unspent.ValueSats);
Assert.Equal(1, unspent.TxPos);
Assert.Equal(99, unspent.Height);
Assert.Equal("4", await client.BroadcastAsync("beef"));
Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
Assert.Equal(0.00001m, await client.RelayFeeAsync());
await client.PingAsync(); // must not throw
}
[Fact]
public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
{
var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
try
{
var pins = new CertificatePinStore(pinsPath);
int port;
// First contact: any certificate is pinned (trust on first use).
using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
{
await using var server = new FakeElectrumServer(cert1);
port = server.Port;
await using var client = await ElectrumClient.ConnectAsync(
server.Host, port, useSsl: true, pins);
Assert.True(client.IsConnected);
Assert.True(client.UseSsl);
}
// Same host:port, different certificate: the pin must block the connection.
using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
{
await using var server = await RebindAsync(cert2, port);
await Assert.ThrowsAsync<CertificatePinMismatchException>(() =>
ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
}
}
finally
{
File.Delete(pinsPath);
}
}
/// <summary>Rebinds a fresh server to the port just released by the previous one.</summary>
private static async Task<FakeElectrumServer> RebindAsync(
System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
{
for (var attempt = 0; ; attempt++)
{
try { return new FakeElectrumServer(cert, port); }
catch (System.Net.Sockets.SocketException) when (attempt < 20)
{
await Task.Delay(50);
}
}
}
}
@@ -0,0 +1,212 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Error a handler can throw to make the fake server reply with a JSON-RPC
/// error object (e.g. code -102 "server busy" to exercise the retry path).
/// </summary>
public sealed class FakeElectrumError(int code, string message) : Exception(message)
{
public int Code { get; } = code;
}
/// <summary>
/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
/// Handlers are registered per method and receive the request "params" array;
/// whatever they return is serialised as the "result". "server.version" is
/// pre-registered. Calls are counted per method (cache/retry assertions).
/// </summary>
public sealed class FakeElectrumServer : IAsyncDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly Task _acceptLoop;
private readonly X509Certificate2? _certificate;
private readonly ConcurrentDictionary<string, Func<JsonElement, object?>> _handlers = new();
private readonly ConcurrentDictionary<string, int> _callCounts = new();
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
public static readonly object NoResponse = new();
public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
{
_certificate = certificate;
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
_acceptLoop = Task.Run(AcceptLoopAsync);
}
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public string Host => "127.0.0.1";
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
public void Handle(string method, Func<JsonElement, object?> handler) =>
_handlers[method] = handler;
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
public void ResetCallCounts() => _callCounts.Clear();
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
public async Task NotifyAsync(string method, object parameters)
{
var line = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
method,
@params = parameters,
});
foreach (var (stream, writeLock, _) in _clients)
await WriteLineAsync(stream, writeLock, line);
}
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
public void DropClients()
{
foreach (var (_, _, tcp) in _clients)
tcp.Close();
}
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
// PFX roundtrip so the private key is usable by SslStream on every platform.
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
}
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
_ = Task.Run(() => ServeClientAsync(tcp));
}
}
catch (OperationCanceledException) { }
catch (SocketException) { }
}
private async Task ServeClientAsync(TcpClient tcp)
{
try
{
Stream stream = tcp.GetStream();
if (_certificate is not null)
{
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(_certificate);
stream = ssl;
}
var writeLock = new SemaphoreSlim(1, 1);
_clients.Add((stream, writeLock, tcp));
using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
while (!_cts.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(_cts.Token);
if (line is null) break;
if (line.Length == 0) continue;
await HandleRequestAsync(stream, writeLock, line);
}
}
catch (Exception)
{
// Client went away or TLS failed (e.g. rejected pin): normal in tests.
}
}
private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
var id = root.GetProperty("id").GetInt64();
var method = root.GetProperty("method").GetString()!;
var parameters = root.TryGetProperty("params", out var p) ? p : default;
_callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
byte[] payload;
if (!_handlers.TryGetValue(method, out var handler))
{
payload = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
id,
error = new { code = -32601, message = $"unknown method '{method}'" },
});
}
else
{
try
{
var result = handler(parameters);
if (ReferenceEquals(result, NoResponse))
return;
payload = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
id,
result,
});
}
catch (FakeElectrumError err)
{
payload = JsonSerializer.SerializeToUtf8Bytes(new
{
jsonrpc = "2.0",
id,
error = new { code = err.Code, message = err.Message },
});
}
}
await WriteLineAsync(stream, writeLock, payload);
}
private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
{
await writeLock.WaitAsync();
try
{
await stream.WriteAsync(payload);
stream.WriteByte((byte)'\n');
await stream.FlushAsync();
}
catch (Exception)
{
// Connection already gone: irrelevant for the test in progress.
}
finally
{
writeLock.Release();
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_listener.Stop();
DropClients();
try { await _acceptLoop; } catch { }
_cts.Dispose();
}
}
@@ -79,6 +79,49 @@ public class ServerRegistryTests
}
}
[Fact]
public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
{
var path = TempPath();
try
{
await using var server = new FakeElectrumServer();
server.Handle("server.peers.subscribe", _ => new object[]
{
// Full announcement, port-less announcement (→ profile defaults),
// and a bootstrap duplicate that must not be re-added.
new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
});
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
var bootstrapCount = registry.All.Count;
var added = await registry.DiscoverAsync(client);
Assert.Equal(2, added);
Assert.Equal(bootstrapCount + 2, registry.All.Count);
var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
// Persisted for the next session (bootstrap servers excluded from the file).
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
var savedJson = File.ReadAllText(path);
Assert.DoesNotContain("173.212.224.67", savedJson);
// A second discovery of the same peers adds nothing.
Assert.Equal(0, await registry.DiscoverAsync(client));
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Un_file_server_corrotto_non_blocca_l_avvio()
{
@@ -0,0 +1,44 @@
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Tests for the release-tag parsing used by the update check. The network
/// call itself is best-effort by design (any failure → null) and is not
/// exercised here: only the version-comparison logic is deterministic.
/// </summary>
public class UpdateCheckerTests
{
[Theory]
[InlineData("v1.2.3", "1.2.3")]
[InlineData("V0.9.1", "0.9.1")]
[InlineData("1.2.3", "1.2.3")]
[InlineData("0.9.0-beta", "0.9.0")]
[InlineData("v2.0.0-rc.1", "2.0.0")]
[InlineData(" 1.4 ", "1.4")]
public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
{
Assert.True(UpdateChecker.TryParse(tag, out var version));
Assert.Equal(Version.Parse(expected), version);
}
[Theory]
[InlineData("")]
[InlineData("main")]
[InlineData("v")]
[InlineData("1")]
[InlineData("non-una-versione")]
public void I_tag_non_versionati_vengono_rifiutati(string tag)
{
Assert.False(UpdateChecker.TryParse(tag, out _));
}
[Fact]
public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
{
// The same comparison CheckAsync applies between remote tag and running app.
Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
}
}
@@ -182,6 +182,121 @@ public class PropertyTests
});
}
// ── Scripthash ────────────────────────────────────────────────────────────
/// The scripthash must equal an independent SHA-256-and-reverse computation
/// for any script bytes (cross-check, not a re-run of the same code).
[Fact]
public void Scripthash_coincide_con_il_calcolo_indipendente_per_ogni_script()
{
Gen.Byte.Array[0, 64].Sample(bytes =>
{
var expected = System.Security.Cryptography.SHA256.HashData(bytes);
Array.Reverse(expected);
Assert.Equal(
Convert.ToHexString(expected).ToLowerInvariant(),
Core.Spv.Scripthash.FromScript(Script.FromBytesUnsafe(bytes)));
});
}
// ── Slip132 ───────────────────────────────────────────────────────────────
private static readonly Gen<Core.Chain.ScriptKind> GenScriptKind = Gen.OneOfConst(
Core.Chain.ScriptKind.Legacy,
Core.Chain.ScriptKind.WrappedSegwit,
Core.Chain.ScriptKind.NativeSegwit,
Core.Chain.ScriptKind.WrappedSegwitMultisig,
Core.Chain.ScriptKind.NativeSegwitMultisig);
private static readonly Gen<Core.Chain.ChainProfile> GenProfile = Gen.OneOfConst(
Core.Chain.ChainProfiles.Mainnet, Core.Chain.ChainProfiles.Testnet);
/// Encode → TryDecodePrivate must roundtrip key bytes and header family
/// for any key, script kind, and network.
[Fact]
public void Slip132_roundtrip_chiave_privata_per_ogni_kind_e_rete()
{
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
{
var key = ExtKey.CreateFromSeed(seed);
var encoded = Core.Crypto.Slip132.Encode(key, kind, profile);
Assert.True(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out var decoded, out var decodedKind));
Assert.Equal(key.ToBytes(), decoded!.ToBytes());
// Legacy and Taproot share the BIP32 header: compare header families, not enum values.
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
// A private key must never decode as public.
Assert.False(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out _, out _));
});
}
/// Same roundtrip for the public (watch-only import) side.
[Fact]
public void Slip132_roundtrip_chiave_pubblica_per_ogni_kind_e_rete()
{
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
{
var xpub = ExtKey.CreateFromSeed(seed).Neuter();
var encoded = Core.Crypto.Slip132.Encode(xpub, kind, profile);
Assert.True(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out var decoded, out var decodedKind));
Assert.Equal(xpub.ToBytes(), decoded!.ToBytes());
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
Assert.False(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out _, out _));
});
}
/// TryDecode must never throw on arbitrary input strings.
[Fact]
public void Slip132_TryDecode_non_lancia_mai_su_input_arbitrario()
{
Gen.String.Sample(text =>
{
try
{
Core.Crypto.Slip132.TryDecodePublic(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
Core.Crypto.Slip132.TryDecodePrivate(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
}
catch (Exception ex)
{
Assert.Fail($"TryDecode threw {ex.GetType().Name} for '{text}'");
}
});
}
// ── WalletDocument ────────────────────────────────────────────────────────
/// ToJson → FromJson must preserve labels and contacts for arbitrary strings
/// (unicode, quotes, control characters…).
[Fact]
public void WalletDocument_roundtrip_json_con_etichette_e_contatti_arbitrari()
{
Gen.Select(Gen.Select(Gen.String, Gen.String).Array[0, 8], Gen.String).Sample((pairs, contactName) =>
{
// Lone UTF-16 surrogates are not representable in JSON (the writer
// replaces them): an exact roundtrip is impossible by design, skip.
if (pairs.Any(p => p.Item1.Any(char.IsSurrogate) || p.Item2.Any(char.IsSurrogate))
|| contactName.Any(char.IsSurrogate))
return;
var doc = new WalletDocument
{
Network = "regtest",
ScriptKind = "NativeSegwit",
AccountPath = "84'/1'/0'",
AccountXpub = "vpub-test",
Contacts = { new StoredContact { Name = contactName, Address = "rplm1qtest" } },
};
foreach (var (k, v) in pairs)
doc.Labels[k] = v;
var restored = WalletDocument.FromJson(doc.ToJson());
Assert.Equal(doc.Labels, restored.Labels);
Assert.Equal(contactName, Assert.Single(restored.Contacts).Name);
});
}
// helper: builds the branch for the given position
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
{
@@ -0,0 +1,376 @@
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.Spv;
/// <summary>
/// End-to-end tests of the SPV synchroniser against the in-process fake server:
/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
/// path that must reject a lying server), busy retry, and the disk cache.
/// Every block in these scenarios contains a single transaction, so the Merkle
/// root is the txid itself and the branch is empty (the tree math has its own
/// dedicated tests in <see cref="MerkleProofTests"/>).
/// </summary>
public class WalletSynchronizerTests
{
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
private static readonly Network Net = PalladiumNetworks.Regtest;
private static HdAccount Account()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
}
/// <summary>
/// Server-side chain state: transactions, per-scripthash histories and
/// single-transaction block headers, wired onto a FakeElectrumServer.
/// </summary>
private sealed class Scenario
{
public int TipHeight = 200;
public readonly Dictionary<string, Transaction> Txs = [];
public readonly Dictionary<string, List<(string Txid, int Height)>> History = [];
public readonly Dictionary<int, string> Headers = [];
/// <summary>Creates a tx paying <paramref name="sats"/> to <paramref name="to"/> and registers it.</summary>
public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
{
var tx = Net.CreateTransaction();
tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
tx.Outputs.Add(Money.Satoshis(sats), to);
if (changeTo is not null)
tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
Register(tx, height, to, changeTo);
return tx;
}
public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
{
var txid = tx.GetHash().ToString();
Txs[txid] = tx;
foreach (var addr in touchedAddresses)
{
if (addr is null) continue;
var sh = Scripthash.FromAddress(addr);
if (!History.TryGetValue(sh, out var list))
History[sh] = list = [];
if (!list.Contains((txid, height)))
list.Add((txid, height));
}
if (height > 0 && !Headers.ContainsKey(height))
Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
}
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null)
{
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
header.HashPrevBlock = uint256.Zero;
header.HashMerkleRoot = merkleRoot ?? txid;
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
header.Bits = new Target(0x1d00ffffu);
header.Nonce = (uint)height;
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
}
public void WireTo(FakeElectrumServer server)
{
server.Handle("blockchain.headers.subscribe",
_ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
server.Handle("blockchain.scripthash.subscribe", _ => null);
server.Handle("blockchain.scripthash.get_history", p =>
(History.GetValueOrDefault(p[0].GetString()!) ?? [])
.Select(h => new { tx_hash = h.Txid, height = h.Height }));
server.Handle("blockchain.transaction.get", p =>
Txs.TryGetValue(p[0].GetString()!, out var tx)
? tx.ToHex()
: throw new FakeElectrumError(-32600, "no such transaction"));
server.Handle("blockchain.transaction.get_merkle", p =>
new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() });
server.Handle("blockchain.block.header", p =>
Headers.TryGetValue(p[0].GetInt32(), out var hex)
? hex
: throw new FakeElectrumError(-32600, "no such block"));
}
}
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
{
var server = new FakeElectrumServer();
scenario.WireTo(server);
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
return (server, client);
}
// ---- scansione ----
[Fact]
public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
{
var scenario = new Scenario();
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
Assert.Equal(200, result.TipHeight);
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(0, result.UnconfirmedSats);
Assert.Equal(0, result.NextReceiveIndex);
Assert.Equal(0, result.NextChangeIndex);
// Default gap limit 20 on both chains.
Assert.Equal(40, result.Addresses.Count);
Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
Assert.Empty(result.Utxos);
Assert.Empty(result.History);
}
[Fact]
public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
// Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
Assert.Equal(4, result.NextReceiveIndex);
Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
Assert.Equal(50_000, result.ConfirmedSats);
}
// ---- ricostruzione UTXO e storico ----
[Fact]
public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
Assert.Equal(1, result.NextReceiveIndex);
var utxo = Assert.Single(result.Utxos);
Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
Assert.Equal(100, utxo.Height);
Assert.False(utxo.IsChange);
var entry = Assert.Single(result.History);
Assert.Equal(1_000_000, entry.DeltaSats);
Assert.True(entry.Verified); // Merkle proof checked against the header
var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
Assert.Equal(1_000_000, row.BalanceSats);
Assert.Equal(1, row.TxCount);
}
[Fact]
public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
// Spend: 600k to an external address, 390k change to change/0, 10k fee.
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var spend = Net.CreateTransaction();
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
spend.Outputs.Add(Money.Satoshis(600_000), external);
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
// The funding UTXO is spent: only the change remains.
var utxo = Assert.Single(result.Utxos);
Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
Assert.True(utxo.IsChange);
Assert.Equal(390_000, result.ConfirmedSats);
Assert.Equal(1, result.NextChangeIndex);
// History newest-first with the net deltas.
Assert.Equal(2, result.History.Count);
Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change 1M spent
Assert.Equal(1_000_000, result.History[1].DeltaSats);
}
[Fact]
public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(250_000, result.UnconfirmedSats);
var entry = Assert.Single(result.History);
Assert.False(entry.Verified);
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
{
var account = Account();
var scenario = new Scenario { TipHeight = 200 };
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(5_000_000_000, result.ConfirmedSats);
Assert.Equal(5_000_000_000, result.ImmatureSats);
Assert.True(Assert.Single(result.Utxos).IsCoinbase);
}
// ---- sicurezza SPV ----
[Fact]
public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
// The server lies: the block 100 header commits to a different Merkle root.
scenario.Headers[100] = Scenario.SingleTxHeaderHex(
funding.GetHash(), 100, merkleRoot: uint256.One);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(account, client).SyncOnceAsync());
Assert.Contains(funding.GetHash().ToString(), ex.Message);
}
// ---- resilienza ----
[Fact]
public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var server = new FakeElectrumServer();
scenario.WireTo(server);
// The first 3 history calls fail with the ElectrumX throttling error.
var failures = 3;
var histories = scenario.History;
server.Handle("blockchain.scripthash.get_history", p =>
{
if (Interlocked.Decrement(ref failures) >= 0)
throw new FakeElectrumError(-102, "excessive resource usage");
return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
.Select(h => new { tx_hash = h.Txid, height = h.Height });
});
await using var _ = server;
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
}
// ---- cache su disco ----
[Fact]
public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var first = new WalletSynchronizer(account, client);
var result1 = await first.SyncOnceAsync();
var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
Assert.Single(rawTx); // the confirmed tx is exported
Assert.Single(verifiedAt); // with its verified height
Assert.NotEmpty(headers);
// Fresh synchroniser (new launch), warm cache: no re-download.
server.ResetCallCounts();
var second = new WalletSynchronizer(account, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
var result2 = await second.SyncOnceAsync();
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var sync = new WalletSynchronizer(account, client);
await sync.SyncOnceAsync();
var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
Assert.Empty(rawTx);
Assert.Empty(verifiedAt);
}
// ---- account a indirizzi fissi (WIF importati) ----
[Fact]
public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
{
var key = new Key();
var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
var scenario = new Scenario();
scenario.Pay(address, 750_000, height: 150);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(750_000, result.ConfirmedSats);
Assert.Single(result.Addresses);
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
Assert.Equal(1, result.NextReceiveIndex);
}
}
@@ -0,0 +1,84 @@
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Tests.Storage;
/// <summary>
/// Tests for the data-path resolution (§8), pinned to a temporary root via
/// <see cref="AppPaths.OverrideDataRoot"/> — the same seam the Android head and
/// the CLI --data-dir use — so nothing outside the temp folder is touched.
/// The pointer-file and portable-mode branches read machine-global locations
/// and are deliberately not exercised here.
/// </summary>
public class AppPathsTests : IDisposable
{
private readonly string _root;
private readonly string? _savedOverride;
public AppPathsTests()
{
_root = Path.Combine(Path.GetTempPath(), $"plm-data-{Guid.NewGuid()}");
_savedOverride = AppPaths.OverrideDataRoot;
AppPaths.OverrideDataRoot = _root;
}
public void Dispose()
{
AppPaths.OverrideDataRoot = _savedOverride;
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
[Fact]
public void L_override_ha_priorita_su_tutto()
{
Assert.Equal(_root, AppPaths.DataRoot());
Assert.True(AppPaths.IsDataLocationConfigured());
}
[Fact]
public void Ogni_rete_ha_la_propria_sottocartella_con_il_nome_del_profilo()
{
foreach (var net in new[] { NetKind.Mainnet, NetKind.Testnet, NetKind.Regtest })
{
var dir = AppPaths.ForNetwork(net);
Assert.Equal(Path.Combine(_root, ChainProfiles.For(net).NetName), dir);
Assert.True(Directory.Exists(dir));
}
}
[Fact]
public void I_percorsi_dei_file_di_rete_stanno_sotto_la_cartella_della_rete()
{
var netDir = AppPaths.ForNetwork(NetKind.Mainnet);
Assert.Equal(Path.Combine(netDir, "server-certs.json"), AppPaths.CertificatePinsPath(NetKind.Mainnet));
Assert.Equal(Path.Combine(netDir, "servers.json"), AppPaths.ServersPath(NetKind.Mainnet));
Assert.Equal(Path.Combine(netDir, "wallets", "default.wallet.json"),
AppPaths.DefaultWalletPath(NetKind.Mainnet));
Assert.Equal(Path.Combine(_root, "config.json"), AppPaths.ConfigPath());
}
[Fact]
public void WalletFiles_elenca_solo_i_wallet_in_ordine_alfabetico()
{
var dir = AppPaths.WalletsDir(NetKind.Regtest);
File.WriteAllText(Path.Combine(dir, "zeta.wallet.json"), "{}");
File.WriteAllText(Path.Combine(dir, "alfa.wallet.json"), "{}");
File.WriteAllText(Path.Combine(dir, "non-un-wallet.txt"), "x");
File.WriteAllText(Path.Combine(dir, "default.wallet.json.lock"), "x");
var files = AppPaths.WalletFiles(NetKind.Regtest);
Assert.Equal(2, files.Count);
Assert.EndsWith("alfa.wallet.json", files[0]);
Assert.EndsWith("zeta.wallet.json", files[1]);
}
[Fact]
public void Le_reti_non_condividono_le_cartelle_wallet()
{
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Mainnet), AppPaths.WalletsDir(NetKind.Testnet));
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest));
}
}
@@ -77,6 +77,31 @@ public class StorageTests
Assert.False(EncryptedFile.IsEncrypted("non è json"));
}
// Regression: valid JSON whose root is not an object (found by the
// property test) must not throw from TryGetProperty.
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("\"stringa\"")]
[InlineData("[1, 2]")]
public void IsEncrypted_restituisce_false_per_json_con_radice_non_oggetto(string content)
{
Assert.False(EncryptedFile.IsEncrypted(content));
}
[Fact]
public void IsEncrypted_restituisce_false_per_utf16_invalido()
{
// Lone surrogate: cannot be transcoded to UTF-8 for JSON parsing.
Assert.False(EncryptedFile.IsEncrypted("\ud800"));
}
[Fact]
public void IsEncrypted_restituisce_false_se_Format_non_e_una_stringa()
{
Assert.False(EncryptedFile.IsEncrypted("{\"Format\": 42}"));
}
// ---- WalletDocument JSON ----
[Fact]
@@ -244,6 +244,104 @@ public class TransactionFactoryTests
}
}
[Theory]
[InlineData(ScriptPubKeyType.Legacy)]
[InlineData(ScriptPubKeyType.SegwitP2SH)]
[InlineData(ScriptPubKeyType.Segwit)]
public void Si_puo_pagare_ogni_tipo_di_indirizzo_standard(ScriptPubKeyType kind)
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
var destination = new Key().PubKey.GetAddress(kind, Net);
var built = new TransactionFactory(account).Build(
utxos, txs, destination, amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
Assert.True(built.Signed);
Assert.Contains(built.Transaction.Outputs,
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
}
[Fact]
public void Piu_utxo_vengono_combinati_quando_uno_solo_non_basta()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
for (var i = 0; i < 3; i++)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)i)));
funding.Outputs.Add(Money.Satoshis(300_000), account.GetReceiveAddress(i));
var txid = funding.GetHash().ToString();
allTxs[txid] = funding;
allUtxos.Add(new CachedUtxo
{
Txid = txid, Vout = 0, ValueSats = 300_000,
Address = account.GetReceiveAddress(i).ToString(),
IsChange = false, AddressIndex = i, Height = 100,
});
}
var built = new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(5), amountSats: 700_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
// 700k > any pair? No: it needs all three 300k coins (600k < 700k + fee).
Assert.Equal(3, built.Transaction.Inputs.Count);
Assert.True(built.Signed);
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
}
[Fact]
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
{
var account = Account();
var (utxos, txs) = Fund(account, 100_000);
// Leaves ~200 sats after the fee: below the P2WPKH dust threshold,
// so no change output must be created and the remainder goes to the fee.
var built = new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(5), amountSats: 99_650,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
var output = Assert.Single(built.Transaction.Outputs);
Assert.Equal(99_650, output.Value.Satoshi);
Assert.Equal(100_000 - 99_650, built.Fee.Satoshi);
}
[Fact]
public void La_firma_di_una_tx_fissa_produce_il_txid_golden()
{
// Golden vector for the signing path (derivation → sighash → witness):
// the factory shuffles outputs for privacy, so the vector is anchored one
// level below, on a transaction with fixed structure signed through PSBT
// (the same flow used air-gapped, §6.5). RFC 6979 makes it deterministic:
// any change in this txid is a blocking regression.
var account = Account();
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
var spend = Net.CreateTransaction();
spend.Version = 2;
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
spend.Outputs.Add(Money.Satoshis(600_000), account.GetReceiveAddress(5));
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
var psbt = PSBT.FromTransaction(spend, Net);
psbt.AddCoins(new Coin(funding, 0));
psbt.SignWithKeys(account.GetExtPrivateKey(isChange: false, 0));
psbt.Finalize();
var signed = psbt.ExtractTransaction();
Assert.Equal(
"a943cf6bf606fa0050e490cb76ed9313959d228fb0ffa235b7e8b7f6834610b6",
signed.GetHash().ToString());
}
[Fact]
public void Una_config_corrotta_torna_ai_default()
{
@@ -0,0 +1,217 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Wallet;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.Wallet;
/// <summary>
/// Tests for the transaction detail assembly (§10): fee from resolved inputs,
/// mine/theirs attribution, RBF flag, coinbase, and the degraded paths when the
/// server cannot provide a previous transaction.
/// </summary>
public class TransactionInspectorTests
{
private static readonly Network Net = PalladiumNetworks.Regtest;
private static HdAccount Account()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
}
/// <summary>Funding (1M sats to receive/0) + spend (600k external, 390k change/0, 10k fee, RBF).</summary>
private static (Transaction Funding, Transaction Spend, BitcoinAddress External, HdAccount Account) SpendPair()
{
var account = Account();
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var spend = Net.CreateTransaction();
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
spend.Outputs.Add(Money.Satoshis(600_000), external);
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
return (funding, spend, external, account);
}
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(
params Transaction[] served)
{
var byId = served.ToDictionary(t => t.GetHash().ToString());
var server = new FakeElectrumServer();
server.Handle("blockchain.transaction.get", p =>
byId.TryGetValue(p[0].GetString()!, out var tx)
? tx.ToHex()
: throw new FakeElectrumError(-32600, "no such transaction"));
server.Handle("blockchain.block.header", p =>
{
var height = p[0].GetInt32();
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
header.Bits = new Target(0x1d00ffffu);
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
});
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
return (server, client);
}
private static HashSet<string> Owned(HdAccount account) =>
[
account.GetReceiveAddress(0).ToString(),
account.GetChangeAddress(0).ToString(),
];
[Fact]
public async Task Una_spesa_confermata_riporta_fee_conferme_e_attribuzione_mine_theirs()
{
var (funding, spend, external, account) = SpendPair();
var (server, client) = await StartAsync(funding, spend);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: -610_000, verified: true);
Assert.Equal(10_000, details.FeeSats);
Assert.Equal(1_000_000, details.TotalInSats);
Assert.Equal(990_000, details.TotalOutSats);
Assert.Equal(100, details.Confirmations); // 200 101 + 1
Assert.True(details.RbfSignaled);
Assert.True(details.Verified);
Assert.False(details.IsCoinbase);
Assert.False(details.IsIncoming);
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_101), details.BlockTime);
Assert.Equal((double)10_000 / details.VirtualSize, details.FeeRateSatPerVb);
var input = Assert.Single(details.Inputs);
Assert.True(input.IsMine);
Assert.Equal(1_000_000, input.AmountSats);
Assert.Equal(account.GetReceiveAddress(0).ToString(), input.Address);
Assert.Equal(2, details.Outputs.Count);
Assert.Equal(600_000, details.SentToOthersSats);
Assert.Equal(390_000, details.ReceivedSats);
Assert.Contains(details.Outputs, o => o.IsMine && o.AmountSats == 390_000);
Assert.Contains(details.Outputs, o => !o.IsMine && o.AmountSats == 600_000);
// Outgoing tx: the counterparty is the external recipient.
Assert.Equal([external.ToString()], details.CounterpartyAddresses);
}
[Fact]
public async Task Una_ricezione_indica_il_mittente_come_controparte()
{
var account = Account();
var senderKey = new Key();
var senderAddr = senderKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
// The sender's coin, then the payment to us spending it.
var senderFunding = Net.CreateTransaction();
senderFunding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
senderFunding.Outputs.Add(Money.Satoshis(2_000_000), senderAddr);
var payment = Net.CreateTransaction();
payment.Inputs.Add(new TxIn(new OutPoint(senderFunding, 0)));
payment.Outputs.Add(Money.Satoshis(1_500_000), account.GetReceiveAddress(0));
var (server, client) = await StartAsync(senderFunding, payment);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, payment.GetHash().ToString(), tipHeight: 200, height: 150,
Owned(account), netSats: 1_500_000, verified: true);
Assert.True(details.IsIncoming);
Assert.Equal([senderAddr.ToString()], details.CounterpartyAddresses);
Assert.Equal(500_000, details.FeeSats); // 2M in 1.5M out
}
[Fact]
public async Task Una_coinbase_non_ha_fee_ne_importi_in_ingresso()
{
var account = Account();
var coinbase = Net.CreateTransaction();
coinbase.Inputs.Add(new TxIn());
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
var (server, client) = await StartAsync(coinbase);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
Owned(account), netSats: 5_000_000_000, verified: true);
Assert.True(details.IsCoinbase);
Assert.Null(details.FeeSats);
Assert.Null(details.TotalInSats);
Assert.Null(details.FeeRateSatPerVb);
Assert.Equal(11, details.Confirmations);
var input = Assert.Single(details.Inputs);
Assert.True(input.IsCoinbase);
Assert.Null(input.AmountSats);
// No previous transactions to resolve.
Assert.Equal(0, server.CallCount("blockchain.transaction.get") - 1); // only the coinbase itself
}
[Fact]
public async Task Se_la_tx_precedente_non_e_recuperabile_la_fee_resta_ignota()
{
var (funding, spend, _, account) = SpendPair();
// The server only knows the spend: the funding lookup fails.
var (server, client) = await StartAsync(spend);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: -610_000, verified: false);
Assert.Null(details.FeeSats);
Assert.Null(details.TotalInSats);
var input = Assert.Single(details.Inputs);
Assert.Null(input.AmountSats);
Assert.False(input.IsMine); // unresolvable → not attributable
Assert.Equal(funding.GetHash().ToString(), input.PrevTxid);
}
[Fact]
public async Task Una_tx_in_mempool_non_ha_conferme_ne_timestamp()
{
var (funding, spend, _, account) = SpendPair();
var (server, client) = await StartAsync(funding, spend);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
Owned(account), netSats: -610_000, verified: false);
Assert.Equal(0, details.Confirmations);
Assert.Null(details.BlockTime);
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
{
var (funding, spend, _, account) = SpendPair();
var cache = new Dictionary<string, Transaction>
{
[funding.GetHash().ToString()] = funding,
[spend.GetHash().ToString()] = spend,
};
var (server, client) = await StartAsync();
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
Owned(account), netSats: -610_000, verified: false, cache);
Assert.Equal(10_000, details.FeeSats);
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
}
}
@@ -28,8 +28,8 @@ public class WalletLoaderTests
Assert.Equal("NativeSegwit", doc.ScriptKind);
Assert.Equal(ValidMnemonic, doc.Mnemonic);
Assert.Null(doc.Passphrase);
Assert.NotEmpty(doc.AccountXpub);
Assert.NotEmpty(doc.MasterFingerprint);
Assert.NotEmpty(doc.AccountXpub!);
Assert.NotEmpty(doc.MasterFingerprint!);
Assert.False(doc.IsWatchOnly);
}
@@ -66,7 +66,7 @@ public class WalletLoaderTests
var (doc, _) = WalletLoader.NewFromMnemonic(
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Equal("mainnet", doc.Network);
Assert.NotEmpty(doc.AccountXpub);
Assert.NotEmpty(doc.AccountXpub!);
}
[Fact]
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Bumps the app version everywhere it's tracked and stubs a CHANGELOG.md entry.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
APP_CSPROJ="src/App/PalladiumWallet.App.csproj"
ANDROID_CSPROJ="src/App.Android/PalladiumWallet.App.Android.csproj"
CHANGELOG="CHANGELOG.md"
current_version=$(grep -oP '(?<=<Version>)[^<]+(?=</Version>)' "$APP_CSPROJ")
echo "Current version: $current_version"
read -rp "New version (e.g. 1.0.0): " new_version
if [[ -z "$new_version" ]]; then
echo "No version entered, aborting." >&2
exit 1
fi
if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version format: '$new_version' (expected X.Y.Z)." >&2
exit 1
fi
if [[ "$new_version" == "$current_version" ]]; then
echo "New version is the same as the current one ($current_version), aborting." >&2
exit 1
fi
# 1. Single source of truth: App csproj <Version>
sed -i "s|<Version>$current_version</Version>|<Version>$new_version</Version>|" "$APP_CSPROJ"
# 2. Android head: ApplicationDisplayVersion (versionName) must mirror it,
# and ApplicationVersion (versionCode) must strictly increase on every
# release or users can't update in place.
current_code=$(grep -oP '(?<=<ApplicationVersion>)[^<]+(?=</ApplicationVersion>)' "$ANDROID_CSPROJ")
new_code=$((current_code + 1))
sed -i "s|<ApplicationDisplayVersion>$current_version</ApplicationDisplayVersion>|<ApplicationDisplayVersion>$new_version</ApplicationDisplayVersion>|" "$ANDROID_CSPROJ"
sed -i "s|<ApplicationVersion>$current_code</ApplicationVersion>|<ApplicationVersion>$new_code</ApplicationVersion>|" "$ANDROID_CSPROJ"
# 3. CHANGELOG.md: stub a new entry above the previous top-most one.
today=$(date +%F)
if grep -q "^## \[$new_version\]" "$CHANGELOG"; then
echo "CHANGELOG.md already has an entry for $new_version, leaving it untouched."
else
awk -v ver="$new_version" -v date="$today" '
!done && /^## \[/ {
print "## [" ver "] — " date
print ""
print "TODO: describe the changes in this release."
print ""
done = 1
}
{ print }
' "$CHANGELOG" > "$CHANGELOG.tmp"
mv "$CHANGELOG.tmp" "$CHANGELOG"
fi
echo
echo "Updated:"
echo " - $APP_CSPROJ: Version $current_version -> $new_version"
echo " - $ANDROID_CSPROJ: ApplicationDisplayVersion $current_version -> $new_version, ApplicationVersion (versionCode) $current_code -> $new_code"
echo " - $CHANGELOG: stubbed entry for $new_version (fill in the details manually)"
echo
echo "Review the diff, fill in the CHANGELOG entry, then commit and tag manually."