diff --git a/.gitignore b/.gitignore
index 6f9b6a5..ef9101a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -93,3 +93,7 @@ settings.local.json
# Local agent/tooling metadata
.agents/
.codex/
+
+# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
+findings/
+crash-*.bin
diff --git a/PalladiumWallet.sln b/PalladiumWallet.sln
index 700f567..542ccaa 100644
--- a/PalladiumWallet.sln
+++ b/PalladiumWallet.sln
@@ -1,64 +1,134 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
- {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
- {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
- {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
- {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
- {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
- {13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
- {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
- {C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
- {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
- {BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
- EndGlobalSection
-EndGlobal
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
+ {13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
+ {13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
+ {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
+ {C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
+ {A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
+ {BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
+ {BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
+ {BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
+ EndGlobalSection
+EndGlobal
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/address/bech32.bin b/tests/PalladiumWallet.Fuzz/Corpus/address/bech32.bin
new file mode 100644
index 0000000..fa4de45
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/address/bech32.bin
@@ -0,0 +1 @@
+plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/address/garbage.bin b/tests/PalladiumWallet.Fuzz/Corpus/address/garbage.bin
new file mode 100644
index 0000000..95d09f2
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/address/garbage.bin
@@ -0,0 +1 @@
+hello world
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/address/legacy-ish.bin b/tests/PalladiumWallet.Fuzz/Corpus/address/legacy-ish.bin
new file mode 100644
index 0000000..efaad60
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/address/legacy-ish.bin
@@ -0,0 +1 @@
+PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/bip39/empty.bin b/tests/PalladiumWallet.Fuzz/Corpus/bip39/empty.bin
new file mode 100644
index 0000000..e69de29
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/bip39/english-12.bin b/tests/PalladiumWallet.Fuzz/Corpus/bip39/english-12.bin
new file mode 100644
index 0000000..1cba8e0
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/bip39/english-12.bin
@@ -0,0 +1 @@
+abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/bip39/regression-notsupported.bin b/tests/PalladiumWallet.Fuzz/Corpus/bip39/regression-notsupported.bin
new file mode 100644
index 0000000..5b36e35
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/bip39/regression-notsupported.bin
@@ -0,0 +1 @@
+ábÊco ábaco0ábacoábÊ
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/bip39/spanish-word.bin b/tests/PalladiumWallet.Fuzz/Corpus/bip39/spanish-word.bin
new file mode 100644
index 0000000..4f18b88
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/bip39/spanish-word.bin
@@ -0,0 +1 @@
+ábaco ábaco ábaco
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/coinamount/comma.bin b/tests/PalladiumWallet.Fuzz/Corpus/coinamount/comma.bin
new file mode 100644
index 0000000..273bcd8
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/coinamount/comma.bin
@@ -0,0 +1 @@
+0,001
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/coinamount/huge.bin b/tests/PalladiumWallet.Fuzz/Corpus/coinamount/huge.bin
new file mode 100644
index 0000000..4a013cd
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/coinamount/huge.bin
@@ -0,0 +1 @@
+79228162514264337593543950335
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/coinamount/plain.bin b/tests/PalladiumWallet.Fuzz/Corpus/coinamount/plain.bin
new file mode 100644
index 0000000..e26cdce
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/coinamount/plain.bin
@@ -0,0 +1 @@
+1.50000000
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/encfile/container.bin b/tests/PalladiumWallet.Fuzz/Corpus/encfile/container.bin
new file mode 100644
index 0000000..767fcd0
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/encfile/container.bin
@@ -0,0 +1 @@
+{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/encfile/not-json.bin b/tests/PalladiumWallet.Fuzz/Corpus/encfile/not-json.bin
new file mode 100644
index 0000000..b6fc4c6
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/encfile/not-json.bin
@@ -0,0 +1 @@
+hello
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/encfile/wrong-format.bin b/tests/PalladiumWallet.Fuzz/Corpus/encfile/wrong-format.bin
new file mode 100644
index 0000000..3b5f8f2
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/encfile/wrong-format.bin
@@ -0,0 +1 @@
+{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/header/genesis-hex.bin b/tests/PalladiumWallet.Fuzz/Corpus/header/genesis-hex.bin
new file mode 100644
index 0000000..15816b9
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/header/genesis-hex.bin
@@ -0,0 +1 @@
+0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/header/genesis.bin b/tests/PalladiumWallet.Fuzz/Corpus/header/genesis.bin
new file mode 100644
index 0000000..df52c61
Binary files /dev/null and b/tests/PalladiumWallet.Fuzz/Corpus/header/genesis.bin differ
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/header/short.bin b/tests/PalladiumWallet.Fuzz/Corpus/header/short.bin
new file mode 100644
index 0000000..f66c9cf
Binary files /dev/null and b/tests/PalladiumWallet.Fuzz/Corpus/header/short.bin differ
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/merkle/no-branch.bin b/tests/PalladiumWallet.Fuzz/Corpus/merkle/no-branch.bin
new file mode 100644
index 0000000..b2506c7
Binary files /dev/null and b/tests/PalladiumWallet.Fuzz/Corpus/merkle/no-branch.bin differ
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/merkle/one-level.bin b/tests/PalladiumWallet.Fuzz/Corpus/merkle/one-level.bin
new file mode 100644
index 0000000..d18d8a9
Binary files /dev/null and b/tests/PalladiumWallet.Fuzz/Corpus/merkle/one-level.bin differ
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/peers/odd-shapes.bin b/tests/PalladiumWallet.Fuzz/Corpus/peers/odd-shapes.bin
new file mode 100644
index 0000000..13b52d1
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/peers/odd-shapes.bin
@@ -0,0 +1 @@
+[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/peers/regression-bad-utf8.bin b/tests/PalladiumWallet.Fuzz/Corpus/peers/regression-bad-utf8.bin
new file mode 100644
index 0000000..4bd5bd9
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/peers/regression-bad-utf8.bin
@@ -0,0 +1 @@
+[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","‡50002"]]]
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/peers/typical.bin b/tests/PalladiumWallet.Fuzz/Corpus/peers/typical.bin
new file mode 100644
index 0000000..8851650
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/peers/typical.bin
@@ -0,0 +1 @@
+[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/slip132/garbage.bin b/tests/PalladiumWallet.Fuzz/Corpus/slip132/garbage.bin
new file mode 100644
index 0000000..119b20b
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/slip132/garbage.bin
@@ -0,0 +1 @@
+not-a-key
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/slip132/xpub-prefix.bin b/tests/PalladiumWallet.Fuzz/Corpus/slip132/xpub-prefix.bin
new file mode 100644
index 0000000..52fe509
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/slip132/xpub-prefix.bin
@@ -0,0 +1 @@
+xpub661MyMwAqRbcF
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/slip132/zpub.bin b/tests/PalladiumWallet.Fuzz/Corpus/slip132/zpub.bin
new file mode 100644
index 0000000..801c2dc
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/slip132/zpub.bin
@@ -0,0 +1 @@
+zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/bad-version.bin b/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/bad-version.bin
new file mode 100644
index 0000000..1d79824
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/bad-version.bin
@@ -0,0 +1 @@
+{"Version":99}
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/minimal.bin b/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/minimal.bin
new file mode 100644
index 0000000..98eb66f
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/minimal.bin
@@ -0,0 +1 @@
+{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/not-json.bin b/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/not-json.bin
new file mode 100644
index 0000000..b6fc4c6
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Corpus/walletdoc/not-json.bin
@@ -0,0 +1 @@
+hello
\ No newline at end of file
diff --git a/tests/PalladiumWallet.Fuzz/FuzzTargets.cs b/tests/PalladiumWallet.Fuzz/FuzzTargets.cs
new file mode 100644
index 0000000..2aad4c6
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/FuzzTargets.cs
@@ -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;
+
+///
+/// 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.
+///
+public static class FuzzTargets
+{
+ public static readonly IReadOnlyDictionary> All =
+ new Dictionary>
+ {
+ ["header"] = Header,
+ ["merkle"] = Merkle,
+ ["slip132"] = Slip132Keys,
+ ["bip39"] = Bip39Mnemonic,
+ ["address"] = Address,
+ ["coinamount"] = CoinAmountParse,
+ ["walletdoc"] = WalletDoc,
+ ["encfile"] = EncFile,
+ ["peers"] = Peers,
+ };
+
+ /// Runs one input through a named target (used by replay and the regression test).
+ 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();
+ 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);
+ }
+}
diff --git a/tests/PalladiumWallet.Fuzz/PalladiumWallet.Fuzz.csproj b/tests/PalladiumWallet.Fuzz/PalladiumWallet.Fuzz.csproj
new file mode 100644
index 0000000..e169bbb
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/PalladiumWallet.Fuzz.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/PalladiumWallet.Fuzz/Program.cs b/tests/PalladiumWallet.Fuzz/Program.cs
new file mode 100644
index 0000000..6d0bbb2
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/Program.cs
@@ -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:
+//
+// --afl run under afl-fuzz (instrumented Core, see fuzz.sh)
+// --libfuzzer run under libfuzzer-dotnet
+// --random N [seed] built-in dumb fuzzer: N corpus mutations (no tooling needed)
+// ... replay saved inputs (used by the regression test / triage)
+// --make-seeds 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 --afl | --libfuzzer | --random N [seed] | ...");
+ Console.Error.WriteLine(" PalladiumWallet.Fuzz --make-seeds ");
+ 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)[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;
+ }
+}
+
+///
+/// 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.
+///
+internal static class RandomFuzz
+{
+ public static int Run(string name, Action 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;
+ }
+}
+
+///
+/// Seed inputs per target: small valid (or near-valid) examples that give the
+/// mutation engines a meaningful starting shape. Regenerate with --make-seeds.
+///
+internal static class SeedCorpus
+{
+ private static readonly Dictionary 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 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);
+ }
+ }
+}
diff --git a/tests/PalladiumWallet.Fuzz/README.md b/tests/PalladiumWallet.Fuzz/README.md
new file mode 100644
index 0000000..7fc2c17
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/README.md
@@ -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//crashes/`.
+
+## Triage workflow
+
+```bash
+dotnet run -- findings//crashes/ # 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.
diff --git a/tests/PalladiumWallet.Fuzz/fuzz.sh b/tests/PalladiumWallet.Fuzz/fuzz.sh
new file mode 100755
index 0000000..ee4ff85
--- /dev/null
+++ b/tests/PalladiumWallet.Fuzz/fuzz.sh
@@ -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 [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//crashes/: replay one with
+# dotnet run -- findings//crashes/
+# 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 [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
diff --git a/tests/PalladiumWallet.Tests/FuzzCorpusTests.cs b/tests/PalladiumWallet.Tests/FuzzCorpusTests.cs
new file mode 100644
index 0000000..8ce3cbb
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/FuzzCorpusTests.cs
@@ -0,0 +1,36 @@
+using PalladiumWallet.Fuzz;
+
+namespace PalladiumWallet.Tests;
+
+///
+/// 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.
+///
+public class FuzzCorpusTests
+{
+ public static TheoryData Targets()
+ {
+ var data = new TheoryData();
+ 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));
+ }
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj b/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj
index d2a304c..a58e472 100644
--- a/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj
+++ b/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj
@@ -21,8 +21,9 @@
-
-
+
+
+