feat(fuzz): add SharpFuzz-based fuzzing for untrusted-input parsers

New tests/PalladiumWallet.Fuzz project: one target per parser that
consumes untrusted input (block headers, Merkle proofs, peer lists,
wallet files, user-pasted mnemonics/keys/addresses/amounts), each
encoding the parser's documented error contract - any exception beyond
that contract is a finding, reported as a crash.

Three ways to run: the seed corpus (with regression inputs for every
crash found so far, including the two fixed in prior commits) replays
automatically inside dotnet test via FuzzCorpusTests, so a fixed
contract violation can't silently return; a built-in random-mutation
mode needs no external tooling (dotnet run -- <target> --random N);
and fuzz.sh drives real coverage-guided campaigns via afl++ +
SharpFuzz instrumentation for pre-release or post-parser-change runs.
This commit is contained in:
2026-07-07 22:05:08 +02:00
parent a264669151
commit cdede17683
36 changed files with 683 additions and 66 deletions
@@ -0,0 +1 @@
plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
@@ -0,0 +1 @@
hello world
@@ -0,0 +1 @@
PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
@@ -0,0 +1 @@
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
@@ -0,0 +1 @@
ábÊco ábaco0ábacoábÊ
@@ -0,0 +1 @@
ábaco ábaco ábaco
@@ -0,0 +1 @@
0,001
@@ -0,0 +1 @@
79228162514264337593543950335
@@ -0,0 +1 @@
1.50000000
@@ -0,0 +1 @@
{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}
@@ -0,0 +1 @@
hello
@@ -0,0 +1 @@
{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}
@@ -0,0 +1 @@
0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]
@@ -0,0 +1 @@
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","‡50002"]]]
@@ -0,0 +1 @@
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]
@@ -0,0 +1 @@
not-a-key
@@ -0,0 +1 @@
xpub661MyMwAqRbcF
@@ -0,0 +1 @@
zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs
@@ -0,0 +1 @@
{"Version":99}
@@ -0,0 +1 @@
{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}
@@ -0,0 +1 @@
hello
+141
View File
@@ -0,0 +1,141 @@
using System.Text;
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Fuzz;
/// <summary>
/// Fuzz targets over the parsers that consume untrusted input (server responses,
/// wallet files, user-pasted keys/addresses/amounts). Each target enforces the
/// parser's error contract: the exception types documented as its failure mode
/// are swallowed, anything else escapes and is reported as a crash by the fuzzer.
/// </summary>
public static class FuzzTargets
{
public static readonly IReadOnlyDictionary<string, Action<byte[]>> All =
new Dictionary<string, Action<byte[]>>
{
["header"] = Header,
["merkle"] = Merkle,
["slip132"] = Slip132Keys,
["bip39"] = Bip39Mnemonic,
["address"] = Address,
["coinamount"] = CoinAmountParse,
["walletdoc"] = WalletDoc,
["encfile"] = EncFile,
["peers"] = Peers,
};
/// <summary>Runs one input through a named target (used by replay and the regression test).</summary>
public static void Run(string target, byte[] data) => All[target](data);
private static string Utf8(byte[] data) => Encoding.UTF8.GetString(data);
// 80-byte block headers arrive from the server both as raw bytes and as hex.
// Contract: exactly 80 bytes never throws; any other length is ArgumentException;
// the hex overload may also reject non-hex text with FormatException.
private static void Header(byte[] data)
{
try { BlockHeaderInfo.Parse(data); }
catch (ArgumentException) when (data.Length != BlockHeaderInfo.Size) { }
try { BlockHeaderInfo.Parse(Utf8(data)); }
catch (FormatException) { }
catch (ArgumentException) { }
}
// Merkle proof verification runs on server-supplied txid/pos/branch.
// Contract: never throws, for any position (including negative) and branch.
private static void Merkle(byte[] data)
{
if (data.Length < 68)
return;
var txid = new uint256(data.AsSpan(0, 32));
var position = BitConverter.ToInt32(data, 32);
var root = new uint256(data.AsSpan(36, 32));
var branch = new List<uint256>();
for (var offset = 68; offset + 32 <= data.Length; offset += 32)
branch.Add(new uint256(data.AsSpan(offset, 32)));
MerkleProof.Verify(txid, position, branch, root);
}
// SLIP-132 extended keys are pasted by the user on import.
// Contract: the Try* pair never throws, on any profile.
private static void Slip132Keys(byte[] data)
{
var text = Utf8(data);
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
{
Slip132.TryDecodePublic(text, profile, out _, out _);
Slip132.TryDecodePrivate(text, profile, out _, out _);
}
}
// Mnemonics are pasted by the user on restore. Contract: TryParse never throws,
// with auto-detected and explicit wordlist language alike.
private static void Bip39Mnemonic(byte[] data)
{
var text = Utf8(data);
Bip39.TryParse(text, out _);
if (data.Length > 0)
Bip39.TryParse(text, out _, (MnemonicLanguage)(data[0] % 8));
}
// Recipient addresses are pasted by the user (or scanned from a QR).
// Contract: NBitcoin rejects anything invalid with FormatException.
private static void Address(byte[] data)
{
try { BitcoinAddress.Create(Utf8(data), PalladiumNetworks.Mainnet); }
catch (FormatException) { }
}
// Amounts are typed by the user in the currently selected unit.
// Contract: TryParse* never throws for any of the official units.
private static void CoinAmountParse(byte[] data)
{
var text = Utf8(data);
foreach (var unit in CoinAmount.Units)
CoinAmount.TryParseIn(text, unit, out _);
CoinAmount.TryParseCoins(text, out _);
}
// The plaintext wallet document is read from disk at every open.
// Contract: malformed JSON or schema is JsonException/InvalidDataException.
private static void WalletDoc(byte[] data)
{
try { WalletDocument.FromJson(Utf8(data)); }
catch (JsonException) { }
catch (InvalidDataException) { }
}
// The encrypted container wraps the document on disk; a tampered file must
// fail with the two typed exceptions, never a raw parser error, and
// IsEncrypted (the format sniffer) must never throw at all.
private static void EncFile(byte[] data)
{
var text = Utf8(data);
EncryptedFile.IsEncrypted(text);
try { EncryptedFile.Decrypt(text, "fuzz-password"); }
catch (WrongPasswordException) { }
catch (InvalidDataException) { }
}
// server.peers.subscribe responses are attacker-controlled JSON.
// Contract: any well-formed JSON of any shape parses to a (possibly empty)
// peer list without throwing.
private static void Peers(byte[] data)
{
JsonDocument doc;
try { doc = JsonDocument.Parse(data); }
catch (JsonException) { return; }
using (doc)
ElectrumApi.ParsePeers(doc.RootElement);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Fuzzing wants stable, non-inlined instrumentation points. -->
<TieredCompilation>false</TieredCompilation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpFuzz" Version="2.3.0" />
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Seed corpus travels next to the binary so replay and the regression
test in PalladiumWallet.Tests can find it via AppContext.BaseDirectory. -->
<None Include="Corpus\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+215
View File
@@ -0,0 +1,215 @@
using System.Text;
using PalladiumWallet.Fuzz;
// Fuzzing entry point. One binary, one target per process (fuzzers want a
// single deterministic execution path). Modes:
//
// <target> --afl run under afl-fuzz (instrumented Core, see fuzz.sh)
// <target> --libfuzzer run under libfuzzer-dotnet
// <target> --random N [seed] built-in dumb fuzzer: N corpus mutations (no tooling needed)
// <target> <file|dir> ... replay saved inputs (used by the regression test / triage)
// --make-seeds <dir> regenerate the seed corpus
//
// A "crash" is any exception escaping the target: each target already swallows
// the exception types that are part of the parser's documented contract.
if (args.Length >= 2 && args[0] == "--make-seeds")
{
SeedCorpus.WriteAll(args[1]);
Console.WriteLine($"Seed corpus written to {args[1]}");
return 0;
}
if (args.Length < 2 || !FuzzTargets.All.TryGetValue(args[0], out var target))
{
Console.Error.WriteLine("Usage: PalladiumWallet.Fuzz <target> --afl | --libfuzzer | --random N [seed] | <file|dir>...");
Console.Error.WriteLine(" PalladiumWallet.Fuzz --make-seeds <dir>");
Console.Error.WriteLine($"Targets: {string.Join(' ', FuzzTargets.All.Keys)}");
return 2;
}
switch (args[1])
{
case "--afl":
SharpFuzz.Fuzzer.Run(stream =>
{
using var ms = new MemoryStream();
stream.CopyTo(ms);
target(ms.ToArray());
});
return 0;
case "--libfuzzer":
SharpFuzz.Fuzzer.LibFuzzer.Run(span => target(span.ToArray()));
return 0;
case "--random":
{
var iterations = args.Length > 2 ? int.Parse(args[2]) : 100_000;
var seed = args.Length > 3 ? ulong.Parse(args[3]) : 0xF022_5EEDUL;
return RandomFuzz.Run(args[0], target, iterations, seed);
}
default:
{
var failures = 0;
foreach (var path in args[1..])
foreach (var file in Directory.Exists(path)
? Directory.EnumerateFiles(path).OrderBy(f => f, StringComparer.Ordinal)
: (IEnumerable<string>)[path])
{
var data = File.ReadAllBytes(file);
try
{
target(data);
}
catch (Exception ex)
{
failures++;
Console.Error.WriteLine($"CRASH {args[0]} on {file}:");
Console.Error.WriteLine(ex);
}
}
return failures == 0 ? 0 : 1;
}
}
/// <summary>
/// Minimal corpus-mutation fuzzer for smoke runs and CI: not coverage-guided,
/// but catches shallow contract violations without afl++/libFuzzer installed.
/// Deterministic for a given seed; on crash it saves the input for replay.
/// </summary>
internal static class RandomFuzz
{
public static int Run(string name, Action<byte[]> target, int iterations, ulong seed)
{
var corpus = SeedCorpus.For(name);
var rng = seed;
ulong Next() { rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17; return rng; }
for (var i = 0; i < iterations; i++)
{
var data = (byte[])corpus[(int)(Next() % (ulong)corpus.Count)].Clone();
var mutations = 1 + (int)(Next() % 8);
for (var m = 0; m < mutations && data.Length > 0; m++)
switch (Next() % 4)
{
case 0: data[Next() % (ulong)data.Length] = (byte)Next(); break;
case 1: data[Next() % (ulong)data.Length] ^= (byte)(1 << (int)(Next() % 8)); break;
case 2: Array.Resize(ref data, (int)(Next() % 512) + 1); break;
case 3: data = [.. data, .. data[..(int)(Next() % (ulong)data.Length)]]; break;
}
try
{
target(data);
}
catch (Exception ex)
{
var crashFile = $"crash-{name}-{i}.bin";
File.WriteAllBytes(crashFile, data);
Console.Error.WriteLine(
$"CRASH {name} at iteration {i}: {ex.GetType().Name}: {ex.Message} (input saved to {crashFile})");
return 1;
}
}
Console.WriteLine($"{name}: {iterations} random mutations, no contract violations");
return 0;
}
}
/// <summary>
/// Seed inputs per target: small valid (or near-valid) examples that give the
/// mutation engines a meaningful starting shape. Regenerate with --make-seeds.
/// </summary>
internal static class SeedCorpus
{
private static readonly Dictionary<string, (string Name, byte[] Data)[]> Seeds = new()
{
["header"] =
[
("genesis", Convert.FromHexString(
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
("genesis-hex", Encoding.UTF8.GetBytes(
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
("short", [0x01, 0x00, 0x00, 0x00]),
],
["merkle"] =
[
("one-level", [.. new byte[32], .. BitConverter.GetBytes(1), .. new byte[32], .. Enumerable.Repeat((byte)0xAA, 32)]),
("no-branch", [.. Enumerable.Repeat((byte)0x11, 32), .. BitConverter.GetBytes(0), .. Enumerable.Repeat((byte)0x11, 32)]),
],
["slip132"] =
[
("zpub", Encoding.UTF8.GetBytes(
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs")),
("xpub-prefix", Encoding.UTF8.GetBytes("xpub661MyMwAqRbcF")),
("garbage", Encoding.UTF8.GetBytes("not-a-key")),
],
["bip39"] =
[
("english-12", Encoding.UTF8.GetBytes(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")),
("spanish-word", Encoding.UTF8.GetBytes("ábaco ábaco ábaco")),
("empty", []),
// Regression: NBitcoin AutoDetect → "Unknown" language → NotSupportedException
// escaped TryParse (fixed by catching it).
("regression-notsupported", Convert.FromHexString(
"c3a162ca636f20c3a16261636f30c3a16261636fc3a162ca")),
],
["address"] =
[
("bech32", Encoding.UTF8.GetBytes("plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay")),
("legacy-ish", Encoding.UTF8.GetBytes("PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q")),
("garbage", Encoding.UTF8.GetBytes("hello world")),
],
["coinamount"] =
[
("plain", Encoding.UTF8.GetBytes("1.50000000")),
("comma", Encoding.UTF8.GetBytes("0,001")),
("huge", Encoding.UTF8.GetBytes("79228162514264337593543950335")),
],
["walletdoc"] =
[
("minimal", Encoding.UTF8.GetBytes(
"""{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}""")),
("bad-version", Encoding.UTF8.GetBytes("""{"Version":99}""")),
("not-json", Encoding.UTF8.GetBytes("hello")),
],
["encfile"] =
[
// Well-formed container with a tiny iteration count so every replay and
// mutated descendant skips the full PBKDF2 cost (decryption then fails
// with the typed WrongPasswordException — exactly the contract under test).
("container", Encoding.UTF8.GetBytes(
"""{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}""")),
("wrong-format", Encoding.UTF8.GetBytes(
"""{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}""")),
("not-json", Encoding.UTF8.GetBytes("hello")),
],
["peers"] =
[
("typical", Encoding.UTF8.GetBytes(
"""[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]""")),
("odd-shapes", Encoding.UTF8.GetBytes("""[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]""")),
// Regression: raw invalid-UTF-8 byte inside a JSON string parses but cannot
// be transcoded by GetString() → InvalidOperationException (fixed in AsString).
("regression-bad-utf8", Convert.FromHexString(
"5b5b22312e322e332e34222c22706565722e6578616d706c65222c5b2276312e342e32222c22703130222c227435303030" +
"31222c22873530303032225d5d5d")),
],
};
public static IReadOnlyList<byte[]> For(string target) =>
Seeds[target].Select(s => s.Data).ToList();
public static void WriteAll(string root)
{
foreach (var (target, seeds) in Seeds)
{
var dir = Path.Combine(root, target);
Directory.CreateDirectory(dir);
foreach (var (name, data) in seeds)
File.WriteAllBytes(Path.Combine(dir, name + ".bin"), data);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
# Fuzzing
Fuzz targets over every parser that consumes **untrusted input**: server
responses (block headers, Merkle proofs, peer lists), wallet files (plaintext
document and encrypted container), and user-pasted text (mnemonics, SLIP-132
keys, addresses, amounts).
Each target encodes the parser's **error contract**: the exception types
documented as its failure mode are swallowed, anything else escaping is a
finding. Targets: `header` `merkle` `slip132` `bip39` `address` `coinamount`
`walletdoc` `encfile` `peers` (see `FuzzTargets.cs` for each contract).
## Three ways to run
**1. Corpus replay — automatic.** The seed corpus (including regression inputs
for every crash found so far) replays through all targets on every
`dotnet test` run via `FuzzCorpusTests`, so fixed findings cannot come back.
**2. Built-in random smoke — no tooling.** Not coverage-guided, but catches
shallow contract violations in seconds:
```bash
dotnet run -- bip39 --random 50000 # target, iterations [, seed]
```
**3. Coverage-guided campaign — afl++.** The real thing; run it before a
release or after touching any parser:
```bash
apt install afl++
dotnet tool install --global SharpFuzz.CommandLine
./fuzz.sh header # one target per campaign
./fuzz.sh peers -V 3600 # extra args go to afl-fuzz
```
`fuzz.sh` builds Release, instruments `PalladiumWallet.Core.dll` (and NBitcoin)
with SharpFuzz, and launches afl-fuzz with the target's seed corpus. Findings
land in `findings/<target>/crashes/`.
## Triage workflow
```bash
dotnet run -- <target> findings/<target>/crashes/<file> # replay: full stack trace
```
Fix the parser (typed exception or graceful rejection — see the hardening
commits for `Bip39.TryParse`, `ElectrumApi.ParsePeers`, `EncryptedFile.Decrypt`
as examples), then add the input to `SeedCorpus` in `Program.cs` as a
`regression-*` seed and regenerate with `dotnet run -- --make-seeds Corpus`.
## Findings so far
The first smoke run found two real bugs, both reachable from untrusted input:
- `Bip39.TryParse` crashed with `NotSupportedException` on restore text
resembling no wordlist (NBitcoin's `Wordlist.AutoDetect` throws for its
internal "Unknown" language).
- `ElectrumApi.ParsePeers` crashed with `InvalidOperationException` on a peer
list containing a JSON string with invalid UTF-8 bytes (parses fine,
fails at `GetString()` transcoding) — attacker-controlled server data.
Plus two hardening changes made so the `encfile`/`peers` contracts could be
strict at all: `EncryptedFile.Decrypt` maps every malformed-container failure
to `InvalidDataException` and bounds the PBKDF2 iteration count (a tampered
file could previously demand 2^31 iterations, hanging the wallet at open), and
`ParsePeers` tolerates any JSON shape.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Coverage-guided fuzzing with afl++ + SharpFuzz.
#
# One-time setup:
# apt install afl++ (or build from source)
# dotnet tool install --global SharpFuzz.CommandLine
#
# Usage:
# ./fuzz.sh <target> [afl-fuzz args...]
# ./fuzz.sh header
# ./fuzz.sh peers -V 3600 # time-limited campaign (seconds)
#
# Targets: header merkle slip132 bip39 address coinamount walletdoc encfile peers
#
# Findings land in findings/<target>/crashes/: replay one with
# dotnet run -- <target> findings/<target>/crashes/<file>
# then add it to the seed corpus in Program.cs as a regression input.
set -euo pipefail
cd "$(dirname "$0")"
TARGET="${1:?usage: ./fuzz.sh <target> [afl-fuzz args...]}"
shift || true
command -v afl-fuzz >/dev/null || { echo "afl-fuzz not found: apt install afl++"; exit 1; }
command -v sharpfuzz >/dev/null || { echo "sharpfuzz not found: dotnet tool install --global SharpFuzz.CommandLine"; exit 1; }
dotnet build -c Release
BIN="bin/Release/net10.0"
# Instrument the assemblies under test (idempotent: SharpFuzz skips already-
# instrumented DLLs). Core is the real target; NBitcoin gives the fuzzer
# visibility into the library our parsers delegate to.
sharpfuzz "$BIN/PalladiumWallet.Core.dll"
sharpfuzz "$BIN/NBitcoin.dll" || true
mkdir -p "findings/$TARGET"
afl-fuzz -i "Corpus/$TARGET" -o "findings/$TARGET" -t 5000 "$@" \
-- dotnet "$BIN/PalladiumWallet.Fuzz.dll" "$TARGET" --afl
@@ -0,0 +1,36 @@
using PalladiumWallet.Fuzz;
namespace PalladiumWallet.Tests;
/// <summary>
/// Replays the fuzzing seed corpus (tests/PalladiumWallet.Fuzz/Corpus, copied
/// next to the test binary) through every fuzz target on each test run: the
/// corpus includes the regression inputs for crashes found by past campaigns,
/// so a fixed contract violation can never silently come back. The real
/// coverage-guided campaigns run separately via tests/PalladiumWallet.Fuzz/fuzz.sh.
/// </summary>
public class FuzzCorpusTests
{
public static TheoryData<string> Targets()
{
var data = new TheoryData<string>();
foreach (var name in FuzzTargets.All.Keys)
data.Add(name);
return data;
}
[Theory]
[MemberData(nameof(Targets))]
public void Il_corpus_del_target_rispetta_il_contratto_del_parser(string target)
{
var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", target);
Assert.True(Directory.Exists(dir), $"seed corpus missing for '{target}' at {dir}");
foreach (var file in Directory.EnumerateFiles(dir))
{
// Any escaping exception is a contract violation: the target itself
// swallows the exception types documented as the parser's failure mode.
FuzzTargets.Run(target, File.ReadAllBytes(file));
}
}
}
@@ -21,8 +21,9 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
<ItemGroup>
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
</ItemGroup>
</Project>