diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock
index cdf626f..44da47b 100644
--- a/sdk/Cargo.lock
+++ b/sdk/Cargo.lock
@@ -25,7 +25,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aleo"
-version = "0.3.1"
+version = "0.4.0"
dependencies = [
"anyhow",
"hex",
@@ -1289,9 +1289,9 @@ dependencies = [
[[package]]
name = "k256"
-version = "0.14.0-rc.15"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78ce7f1aa9a24c53c6572d8017c8c1ceb5d44c6071ff68c9912860fa4d262101"
+checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8"
dependencies = [
"cpubits",
"ecdsa",
@@ -2310,8 +2310,8 @@ dependencies = [
[[package]]
name = "snarkvm"
-version = "4.8.1"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"rand",
@@ -2327,8 +2327,8 @@ dependencies = [
[[package]]
name = "snarkvm-algorithms"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -2354,8 +2354,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-account",
"snarkvm-circuit-algorithms",
@@ -2368,8 +2368,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-account"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-network",
"snarkvm-circuit-types",
@@ -2378,8 +2378,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-algorithms"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-types",
"snarkvm-console-algorithms",
@@ -2388,8 +2388,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-collections"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-algorithms",
"snarkvm-circuit-types",
@@ -2398,8 +2398,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-environment"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"indexmap",
@@ -2418,13 +2418,13 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-environment-witness"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
[[package]]
name = "snarkvm-circuit-network"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-algorithms",
"snarkvm-circuit-collections",
@@ -2434,8 +2434,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-program"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-account",
"snarkvm-circuit-algorithms",
@@ -2448,8 +2448,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-address",
@@ -2463,8 +2463,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-address"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-boolean",
@@ -2476,8 +2476,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-boolean"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-console-types-boolean",
@@ -2485,8 +2485,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-field"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-boolean",
@@ -2495,8 +2495,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-group"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-boolean",
@@ -2507,8 +2507,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-integers"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-boolean",
@@ -2519,8 +2519,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-scalar"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-boolean",
@@ -2530,8 +2530,8 @@ dependencies = [
[[package]]
name = "snarkvm-circuit-types-string"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-circuit-environment",
"snarkvm-circuit-types-boolean",
@@ -2542,8 +2542,8 @@ dependencies = [
[[package]]
name = "snarkvm-console"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-account",
"snarkvm-console-algorithms",
@@ -2555,8 +2555,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-account"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"bs58",
"snarkvm-console-network",
@@ -2566,12 +2566,13 @@ dependencies = [
[[package]]
name = "snarkvm-console-algorithms"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"blake2s_simd",
"hex",
"k256",
+ "rayon",
"serde",
"smallvec",
"snarkvm-console-types",
@@ -2582,8 +2583,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-collections"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"parking_lot",
@@ -2595,8 +2596,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-network"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"enum-iterator",
@@ -2615,8 +2616,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-network-environment"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"bech32",
@@ -2633,8 +2634,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-program"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"enum-iterator",
"enum_index",
@@ -2654,8 +2655,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-types"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-address",
@@ -2669,8 +2670,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-types-address"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-boolean",
@@ -2680,16 +2681,16 @@ dependencies = [
[[package]]
name = "snarkvm-console-types-boolean"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
]
[[package]]
name = "snarkvm-console-types-field"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-boolean",
@@ -2698,8 +2699,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-types-group"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-boolean",
@@ -2709,8 +2710,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-types-integers"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-boolean",
@@ -2720,8 +2721,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-types-scalar"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-boolean",
@@ -2731,8 +2732,8 @@ dependencies = [
[[package]]
name = "snarkvm-console-types-string"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console-network-environment",
"snarkvm-console-types-boolean",
@@ -2742,8 +2743,8 @@ dependencies = [
[[package]]
name = "snarkvm-curves"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"rand",
"rustc_version",
@@ -2755,8 +2756,8 @@ dependencies = [
[[package]]
name = "snarkvm-fields"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -2772,8 +2773,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -2802,8 +2803,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-authority"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"rand",
@@ -2814,8 +2815,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-block"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"indexmap",
@@ -2838,8 +2839,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-committee"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"indexmap",
"rayon",
@@ -2850,8 +2851,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-ledger-narwhal-batch-certificate",
"snarkvm-ledger-narwhal-batch-header",
@@ -2863,8 +2864,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal-batch-certificate"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"indexmap",
"rayon",
@@ -2876,8 +2877,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal-batch-header"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"indexmap",
"rayon",
@@ -2888,8 +2889,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal-data"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"bytes",
"serde_json",
@@ -2899,8 +2900,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal-subdag"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"indexmap",
"rayon",
@@ -2914,8 +2915,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal-transmission"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"bytes",
"serde_json",
@@ -2927,8 +2928,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-narwhal-transmission-id"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"snarkvm-console",
"snarkvm-ledger-puzzle",
@@ -2936,8 +2937,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-puzzle"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -2956,8 +2957,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-puzzle-epoch"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -2978,8 +2979,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-query"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"async-trait",
@@ -2995,8 +2996,8 @@ dependencies = [
[[package]]
name = "snarkvm-ledger-store"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std-storage",
"anyhow",
@@ -3020,8 +3021,8 @@ dependencies = [
[[package]]
name = "snarkvm-parameters"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -3042,8 +3043,8 @@ dependencies = [
[[package]]
name = "snarkvm-synthesizer"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -3077,8 +3078,8 @@ dependencies = [
[[package]]
name = "snarkvm-synthesizer-error"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"anyhow",
"snarkvm-circuit-environment",
@@ -3089,8 +3090,8 @@ dependencies = [
[[package]]
name = "snarkvm-synthesizer-process"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"colored",
@@ -3115,8 +3116,8 @@ dependencies = [
[[package]]
name = "snarkvm-synthesizer-program"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"enum-iterator",
"indexmap",
@@ -3136,8 +3137,8 @@ dependencies = [
[[package]]
name = "snarkvm-synthesizer-snark"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"bincode",
"serde_json",
@@ -3149,8 +3150,8 @@ dependencies = [
[[package]]
name = "snarkvm-utilities"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"aleo-std",
"anyhow",
@@ -3172,8 +3173,8 @@ dependencies = [
[[package]]
name = "snarkvm-utilities-derives"
-version = "4.7.3"
-source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899"
+version = "4.9.0"
+source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a"
dependencies = [
"proc-macro2",
"quote 1.0.46",
diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml
index 7444d41..948895c 100644
--- a/sdk/Cargo.toml
+++ b/sdk/Cargo.toml
@@ -29,7 +29,7 @@ serde = "1"
serde_json = "1"
sha2 = "0.10"
-snarkvm = { git = "https://github.com/ProvableHQ/snarkVM.git", tag = "v4.8.1", default-features = false, features = [
+snarkvm = { git = "https://github.com/ProvableHQ/snarkVM.git", tag = "v4.9.0", default-features = false, features = [
"console", "circuit", "synthesizer", "ledger", "utilities", "algorithms", "parameters",
] }
diff --git a/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py
index df109e5..45082d1 100644
--- a/sdk/python/aleo/__init__.py
+++ b/sdk/python/aleo/__init__.py
@@ -12,6 +12,7 @@
pass
from .encryptor import *
+from .merkle import MerkleExclusionProof as MerkleExclusionProof
from .network_client import AleoNetworkClient as AleoNetworkClient
from .async_network_client import AsyncAleoNetworkClient as AsyncAleoNetworkClient
from ._client_common import AleoNetworkError as AleoNetworkError
diff --git a/sdk/python/aleo/async_network_client.py b/sdk/python/aleo/async_network_client.py
index 2ba8d2d..66f6608 100644
--- a/sdk/python/aleo/async_network_client.py
+++ b/sdk/python/aleo/async_network_client.py
@@ -739,6 +739,45 @@ async def get_program_mapping_value(
"getProgramMappingValue",
)
+ async def get_freeze_list(self, program_id: str) -> list[int]:
+ """Read a compliance program's freeze-list Merkle tree.
+
+ Programs following the Sealance architecture publish their freeze list
+ as a sorted Merkle tree and require callers to prove non-inclusion in
+ it. Pair this with :class:`~aleo.MerkleExclusionProof` to turn the tree
+ into the proof a transition expects.
+
+ Args:
+ program_id: The freeze-list program, e.g.
+ ``"shield_swap_freezelist.aleo"``. Each compliance program
+ keeps its own list, so this is a parameter rather than a
+ constant.
+
+ Returns:
+ Every node of the tree in tree order — leaves first, then each layer
+ above, with the Merkle root last. An empty list reads back as the
+ two-leaf zero tree.
+
+ Raises:
+ AleoNetworkError: If the program does not exist or serves no list.
+ ValueError: If the response is not an array of field values.
+ """
+ payload = await self._get(
+ f"/programs/{program_id}/compliance/freeze-list",
+ "getFreezeList",
+ )
+ if not isinstance(payload, list):
+ raise ValueError(
+ f"{program_id} returned a {type(payload).__name__} rather than "
+ f"a freeze list array"
+ )
+ try:
+ return [int(str(node).strip().removesuffix("field")) for node in payload]
+ except ValueError:
+ raise ValueError(
+ f"{program_id} freeze list holds a non-numeric node"
+ ) from None
+
async def get_public_balance(self, address: str) -> int:
"""Read an address's public ``credits.aleo`` balance.
diff --git a/sdk/python/aleo/merkle.py b/sdk/python/aleo/merkle.py
new file mode 100644
index 0000000..189e839
--- /dev/null
+++ b/sdk/python/aleo/merkle.py
@@ -0,0 +1,344 @@
+# Copyright (C) 2019-2026 Provable Inc.
+# This file is part of the Aleo SDK library.
+
+# The Aleo SDK library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# The Aleo SDK library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with the Aleo SDK library. If not, see .
+
+"""Merkle exclusion (non-inclusion) proofs over sorted address trees.
+
+Programs following the Sealance compliance architecture keep a freeze list as a
+sorted Merkle tree of addresses and require callers to prove their address is
+*absent* from it. This module builds those proofs client-side.
+
+It is program-agnostic: it knows about sorted address trees and nothing about
+any particular freeze list, so it serves ``shield_swap_freezelist.aleo``, the
+compliance stablecoins, and anything else built the same way.
+
+Ported from the wasm SDK's ``SealanceMerkleTree``
+(``ProvableHQ/sdk@mainnet:sdk/src/integrations/sealance/merkle-tree.ts``).
+"""
+from __future__ import annotations
+
+from typing import Any, NamedTuple, Sequence
+
+from ._client_common import DEFAULT_NETWORK
+
+__all__ = ["MerkleExclusionProof", "SiblingPath"]
+
+
+class SiblingPath(NamedTuple):
+ """One authentication path, as the verifier's ``MerkleProof`` struct.
+
+ ``siblings[0]`` is the leaf itself and ``siblings[1]`` its leaf-layer
+ sibling; the rest are the sibling at each level above, zero-padded to fill
+ the struct's fixed-size array.
+ """
+
+ siblings: list[int]
+ leaf_index: int
+
+#: The all-zero address, which the tree carries as padding rather than as a
+#: member. Verifiers compare against it, so it is never a real leaf.
+ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc"
+
+#: Domain separators. Leaf-layer pairs hash under ``1field``, every layer above
+#: under ``0field``, which is what keeps a leaf from being replayed as a node.
+_LEAF_PREFIX = "1field"
+_NODE_PREFIX = "0field"
+
+
+class MerkleExclusionProof:
+ """Builds Merkle non-inclusion proofs against a sorted address tree.
+
+ Args:
+ max_depth: Maximum tree depth, matching the verifying program's
+ ``MAX_MERKLE_TREE_DEPTH``. This is the single knob the rest of the
+ shape derives from: the tree holds up to ``2 ** max_depth`` leaves
+ and a proof carries ``max_depth + 1`` siblings (one leaf slot plus
+ one per level). Defaults to 15, the shield_swap value.
+ network: Which extension module supplies ``Poseidon4`` and ``Address``.
+ """
+
+ def __init__(self, *, max_depth: int = 15,
+ network: str = DEFAULT_NETWORK) -> None:
+ if max_depth < 1:
+ raise ValueError(f"max_depth must be at least 1, got {max_depth}")
+ self.max_depth = max_depth
+ self._network = network
+ # Built on first use and reused: a full-depth tree is 65_535 hashes,
+ # and Poseidon setup is not free.
+ self._hasher: Any = None
+
+ def __repr__(self) -> str:
+ return (f"MerkleExclusionProof(max_depth={self.max_depth}, "
+ f"network={self._network!r})")
+
+ def _net(self) -> Any:
+ """The network extension module supplying the hash and address types."""
+ try:
+ if self._network == "testnet":
+ from . import testnet as _mod # type: ignore[attr-defined]
+ else:
+ from . import mainnet as _mod # type: ignore[attr-defined]
+ except ImportError:
+ raise ImportError(
+ f"aleo {self._network} module not available"
+ ) from None
+ return _mod
+
+ # ── Tree construction ────────────────────────────────────────────────
+
+ def leaves_from_addresses(self, addresses: Sequence[str], *,
+ max_depth: int | None = None) -> list[str]:
+ """Addresses as sorted, zero-padded leaf literals.
+
+ Zero addresses are dropped, the rest are converted to fields and sorted
+ ascending, then ``0field`` padding is *prepended* to reach a power of
+ two. Prepending is what keeps the greatest real address at the right
+ boundary, which is the invariant the above-last case relies on.
+
+ Args:
+ addresses: The member addresses, in any order.
+ max_depth: Overrides the instance's depth for this call.
+
+ Returns:
+ ``2 ** k`` field literals, ordered padding-first then ascending.
+
+ Raises:
+ ValueError: If there are more addresses than ``2 ** max_depth``.
+ """
+ depth = self.max_depth if max_depth is None else max_depth
+ capacity = 2 ** depth
+
+ members = [a for a in addresses if a != ZERO_ADDRESS]
+ if len(members) > capacity:
+ raise ValueError(
+ f"freeze list holds {len(members)} addresses, but a depth-"
+ f"{depth} tree caps at {capacity}"
+ )
+
+ num_leaves = 2 if len(members) <= 1 else 1 << (len(members) - 1).bit_length()
+
+ fields = sorted(self.address_to_field(a) for a in members)
+ padding = ["0field"] * (num_leaves - len(fields))
+ return padding + [f"{f}field" for f in fields]
+
+ def build_tree(self, leaves: Sequence[str]) -> list[int]:
+ """Hash *leaves* bottom-up into a flat tree array.
+
+ Args:
+ leaves: An even number of field literals, as
+ :meth:`leaves_from_addresses` returns.
+
+ Returns:
+ Every node as an int, level by level: leaves first, then each layer
+ above, ending with the root.
+
+ Raises:
+ ValueError: If *leaves* is empty or has an odd length.
+ """
+ if not leaves:
+ raise ValueError("leaves cannot be empty")
+ if len(leaves) % 2 != 0:
+ raise ValueError(
+ f"leaves must have an even length, got {len(leaves)}")
+
+ current = list(leaves)
+ tree = list(current)
+ while len(current) > 1:
+ # The leaf layer is the only one whose width equals the input's.
+ prefix = _LEAF_PREFIX if len(current) == len(leaves) else _NODE_PREFIX
+ current = [
+ self._hash_pair(prefix, current[i], current[i + 1])
+ for i in range(0, len(current), 2)
+ ]
+ tree.extend(current)
+ return [int(node.removesuffix("field")) for node in tree]
+
+ def tree_from_nodes(self, nodes: Sequence[str]) -> list[int]:
+ """A pre-built tree from its serialized nodes.
+
+ Freeze-list services publish the whole tree — leaves, then each layer
+ above, root last — as decimal strings, so the common path needs no
+ hashing at all.
+
+ Args:
+ nodes: Every node as a decimal string, in tree order. A ``field``
+ suffix is tolerated.
+
+ Returns:
+ The same nodes as ints, ready for :meth:`exclusion_proof`.
+
+ Raises:
+ ValueError: If *nodes* is empty, is not a whole tree
+ (``2 * leaves - 1`` nodes), or holds a non-numeric entry.
+ """
+ if not nodes:
+ raise ValueError("a tree needs at least one node")
+ try:
+ tree = [int(str(node).strip().removesuffix("field")) for node in nodes]
+ except ValueError as exc:
+ raise ValueError(f"tree holds a non-numeric node: {exc}") from None
+
+ # A complete binary tree over 2**k leaves has 2**(k+1) - 1 nodes.
+ if (len(tree) + 1) & len(tree):
+ raise ValueError(
+ f"{len(tree)} nodes is not a complete tree — expected "
+ f"2 * leaves - 1"
+ )
+ return tree
+
+ def root(self, tree: Sequence[int]) -> str:
+ """The tree's root as a field literal."""
+ if not tree:
+ raise ValueError("tree cannot be empty")
+ return f"{tree[-1]}field"
+
+ # ── Proofs ───────────────────────────────────────────────────────────
+
+ def leaf_indices(self, tree: Sequence[int], address: str) -> tuple[int, int]:
+ """The two leaves whose paths together prove *address* is absent.
+
+ Args:
+ tree: A flat tree as :meth:`build_tree` returns.
+ address: The address to exclude.
+
+ Returns:
+ ``(left, right)``. Normally these bracket the address as adjacent
+ leaves. They collapse to a single index at the boundaries: both
+ ``0`` when the address sorts below every leaf, and both the last
+ index when it sorts above every leaf — the two special cases the
+ verifier checks separately.
+
+ Raises:
+ ValueError: If *address* is a member. A member cannot be proven
+ absent, and returning indices anyway would produce a proof that
+ fails only after the caller has paid to prove and broadcast it.
+ """
+ num_leaves = (len(tree) + 1) // 2
+ leaves = list(tree[:num_leaves])
+ value = self.address_to_field(address)
+
+ if value in leaves:
+ raise ValueError(
+ f"{address} is on the list (leaf {leaves.index(value)}), so it "
+ f"cannot be proven excluded"
+ )
+
+ right = next((i for i, leaf in enumerate(leaves) if value <= leaf), -1)
+ if right == -1:
+ return num_leaves - 1, num_leaves - 1
+ if right == 0:
+ return 0, 0
+ return right - 1, right
+
+ def sibling_path(self, tree: Sequence[int], leaf_index: int, *,
+ max_depth: int | None = None) -> SiblingPath:
+ """The authentication path for *leaf_index*.
+
+ Args:
+ tree: A flat tree as :meth:`build_tree` returns.
+ leaf_index: Which leaf to authenticate.
+ max_depth: Overrides the instance's depth for this call.
+
+ Returns:
+ A :class:`SiblingPath` holding exactly ``max_depth + 1`` siblings.
+ The array is padded to its full width rather than to the tree's own
+ depth: the verifier reads a fixed-size array and treats the trailing
+ zeros as "no more levels", so a short path fails to typecheck and a
+ long one cannot be represented.
+
+ Raises:
+ IndexError: If *leaf_index* is outside the tree's leaf layer.
+ ValueError: If the tree is deeper than ``max_depth`` allows.
+ """
+ depth = self.max_depth if max_depth is None else max_depth
+ width = depth + 1
+
+ num_leaves = (len(tree) + 1) // 2
+ if not 0 <= leaf_index < num_leaves:
+ raise IndexError(
+ f"leaf_index {leaf_index} is outside a tree of {num_leaves} "
+ f"leaves"
+ )
+
+ siblings = [tree[leaf_index]]
+ index = leaf_index
+ parent_index = num_leaves
+ level = 1
+ while parent_index < len(tree):
+ sibling = index + 1 if index % 2 == 0 else index - 1
+ siblings.append(tree[sibling])
+ index = parent_index + leaf_index // (2 ** level)
+ parent_index += num_leaves // (2 ** level)
+ level += 1
+
+ if len(siblings) > width:
+ raise ValueError(
+ f"tree of {num_leaves} leaves needs {len(siblings)} proof "
+ f"slots, but max_depth={depth} allows {width}"
+ )
+ siblings.extend([0] * (width - len(siblings)))
+ return SiblingPath(siblings, leaf_index)
+
+ def format_proof(self, paths: Sequence[SiblingPath]) -> str:
+ """Authentication paths as the Aleo array-of-struct literal."""
+ structs = ", ".join(
+ "{siblings: [" + ", ".join(f"{s}field" for s in path.siblings)
+ + f"], leaf_index: {path.leaf_index}u32}}"
+ for path in paths
+ )
+ return f"[{structs}]"
+
+ def exclusion_proof(self, tree: Sequence[int], address: str, *,
+ max_depth: int | None = None) -> str:
+ """A ready-to-pass ``[MerkleProof; 2]`` literal proving *address* is absent.
+
+ Args:
+ tree: A flat tree as :meth:`build_tree` or :meth:`tree_from_nodes`
+ returns.
+ address: The address to prove absent.
+ max_depth: Overrides the instance's depth for this call.
+
+ Returns:
+ The literal to pass wherever the program takes ``[MerkleProof; 2]``.
+
+ Raises:
+ ValueError: If *address* is on the list.
+ """
+ left, right = self.leaf_indices(tree, address)
+ return self.format_proof([
+ self.sibling_path(tree, left, max_depth=max_depth),
+ self.sibling_path(tree, right, max_depth=max_depth),
+ ])
+
+ # ── Primitives ───────────────────────────────────────────────────────
+
+ def address_to_field(self, address: str) -> int:
+ """An Aleo address as its field element, matching Leo's ``as field``."""
+ net = self._net()
+ return int(str(net.Address.from_string(address).to_field())
+ .removesuffix("field"))
+
+ def _hash_pair(self, prefix: str, left: str, right: str) -> str:
+ """``Poseidon4`` over ``[prefix, left, right]`` as a field literal.
+
+ The operands go through ``Plaintext`` so they hash as a 3-element array,
+ which is what the Leo side does. Hashing a bare list of fields, or
+ going through ``to_fields_raw``, gives a different and wrong answer.
+ """
+ net = self._net()
+ if self._hasher is None:
+ self._hasher = net.Poseidon4()
+ plaintext = net.Plaintext.from_string(f"[{prefix},{left},{right}]")
+ return str(self._hasher.hash(plaintext.to_fields()))
diff --git a/sdk/python/aleo/network_client.py b/sdk/python/aleo/network_client.py
index ed8d388..4aabfe3 100644
--- a/sdk/python/aleo/network_client.py
+++ b/sdk/python/aleo/network_client.py
@@ -740,6 +740,45 @@ def get_program_mapping_plaintext(
)
return Plaintext.from_string(json.loads(raw))
+ def get_freeze_list(self, program_id: str) -> list[int]:
+ """Read a compliance program's freeze-list Merkle tree.
+
+ Programs following the Sealance architecture publish their freeze list
+ as a sorted Merkle tree and require callers to prove non-inclusion in
+ it. Pair this with :class:`~aleo.MerkleExclusionProof` to turn the tree
+ into the proof a transition expects.
+
+ Args:
+ program_id: The freeze-list program, e.g.
+ ``"shield_swap_freezelist.aleo"``. Each compliance program
+ keeps its own list, so this is a parameter rather than a
+ constant.
+
+ Returns:
+ Every node of the tree in tree order — leaves first, then each layer
+ above, with the Merkle root last. An empty list reads back as the
+ two-leaf zero tree.
+
+ Raises:
+ AleoNetworkError: If the program does not exist or serves no list.
+ ValueError: If the response is not an array of field values.
+ """
+ payload = self._get(
+ f"/programs/{program_id}/compliance/freeze-list",
+ "getFreezeList",
+ )
+ if not isinstance(payload, list):
+ raise ValueError(
+ f"{program_id} returned a {type(payload).__name__} rather than "
+ f"a freeze list array"
+ )
+ try:
+ return [int(str(node).strip().removesuffix("field")) for node in payload]
+ except ValueError:
+ raise ValueError(
+ f"{program_id} freeze list holds a non-numeric node"
+ ) from None
+
def get_public_balance(self, address: str) -> int:
"""Read an address's public ``credits.aleo`` balance.
diff --git a/sdk/python/tests/test_merkle.py b/sdk/python/tests/test_merkle.py
new file mode 100644
index 0000000..19c278b
--- /dev/null
+++ b/sdk/python/tests/test_merkle.py
@@ -0,0 +1,318 @@
+# Copyright (C) 2019-2026 Provable Inc.
+# This file is part of the Aleo SDK library.
+
+# The Aleo SDK library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# The Aleo SDK library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with the Aleo SDK library. If not, see .
+
+"""Tests for :class:`aleo.MerkleExclusionProof`.
+
+The known answers come from deployed state rather than from the TypeScript
+reference, so the port is pinned to what the chain actually verifies:
+
+- ``EMPTY_TREE_ROOT`` is ``shield_swap_freezelist.aleo``'s initial
+ ``freeze_list_root[1u8]``, read from testnet 2026-08-06.
+- The address/field pairs are ``SealanceMerkleTree``'s own docstring examples
+ (``ProvableHQ/sdk@mainnet:sdk/src/integrations/sealance/merkle-tree.ts``).
+"""
+
+import re
+
+import pytest
+
+from aleo import MerkleExclusionProof
+from aleo.mainnet import Address, Plaintext, Poseidon4
+
+
+#: ``Poseidon4([1field, 0field, 0field])`` — the root of a two-leaf empty tree.
+EMPTY_TREE_ROOT = (
+ "3642222252059314292809609689035560016959342421640560347114299934615987159853field"
+)
+
+ADDR_A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px"
+ADDR_B = "aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t"
+ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc"
+
+#: Nine freeze-list members in ascending field order. Taking the first ``n``
+#: gives member counts either side of a power of two, which is what exercises
+#: the zero padding.
+MEMBERS = [
+ "aleo10p6fdm4054p50e7ykx5f4ec5hy0gt5pwnm0xyj8rr5vx6pva5cqsyx5k5y",
+ "aleo13smp4u3ctasl5xnsrg9slcagsk384kvsk3enu9z0n3qsjcwjgcps4623cu",
+ "aleo1z0c4vn2erd0908020slz5muqm459c5kkyle2xy4jc3n5xh2w6uzq508wr7",
+ "aleo1zh8kzhr0eydshme7gjdj9ynf47pv6xg3al8mylx32yl4770t3vrqld2y2a",
+ "aleo15ls2v3ga6cxx34ddeqsm5f0ksrvwjaj60e8pztdyxu2fl6lpfyyqu93feh",
+ "aleo1wg0jwg4x9a2fmwhu2dkwdv2jd9j4vzmzp5gldjeyyzkmhj5lyy9q95m824",
+ "aleo1l6qyu9jl34ged8he4sfglcfuxjpupyyulvkjt2qa2qwj6s93ag9snnc929",
+ "aleo1fvx6rtmd6swdgwllgua6ygjjvej73nqj5uakzj9fe2sal8xe4cxsgr7lyq",
+ "aleo1fr5nxtkyflk83zq3jxr3w6tsa7akaj0eslvy83suu6pwut03958sjwvkdx",
+]
+#: Falls between ``MEMBERS[1]`` and ``MEMBERS[2]`` — the bracketed case.
+TARGET_INSIDE = "aleo178tq4f3qpcggwgt4l0xdashky3k2tun2lfm9lu6dt0y7h309fsps4hszf8"
+#: Below every member — the below-first case.
+TARGET_BELOW = "aleo1x9m7n0vx0rd8am8qdr2f5m8u2e2zwzmj53yeh5jszl57yp5lqqqqjm8wh3"
+#: Above every member — the above-last case.
+TARGET_ABOVE = "aleo1c9pnkwzja5m5dj93dg80gjyale8ep0n8zgrwl8cpt0r0cu874gfqyzrcgf"
+
+
+@pytest.fixture
+def merkle() -> MerkleExclusionProof:
+ return MerkleExclusionProof()
+
+
+# ── A independent reimplementation of the on-chain verifier ──────────────────
+#
+# Transcribed from ``verify_merkle_non_inclusion`` and
+# ``calculate_merkle_root_and_depth`` in
+# ``ProvableHQ/amm-v3@development:src/main.leo`` (b66d4f2), which matches the
+# bytecode deployed as ``shield_swap.aleo``. It hashes via its own Poseidon4
+# calls rather than through the class under test, so it can disagree with the
+# implementation — which is the point.
+
+
+def _hash3(prefix: str, left: int, right: int) -> int:
+ plaintext = Plaintext.from_string(f"[{prefix},{left}field,{right}field]")
+ return int(str(Poseidon4().hash(plaintext.to_fields())).removesuffix("field"))
+
+
+def parse_proof_literal(literal: str) -> list[tuple[list[int], int]]:
+ """``[{siblings: [...], leaf_index: Nu32}, ...]`` back into Python."""
+ parsed = []
+ for block in re.findall(r"\{[^{}]*\}", literal):
+ siblings = [int(s) for s in re.findall(r"(\d+)field", block)]
+ leaf_index = int(re.search(r"leaf_index:\s*(\d+)u32", block).group(1))
+ parsed.append((siblings, leaf_index))
+ return parsed
+
+
+def leo_root_and_depth(siblings: list[int], leaf_index: int,
+ max_depth: int = 15) -> tuple[int, int]:
+ root = _hash3("1field", *(
+ (siblings[0], siblings[1]) if leaf_index % 2 == 0
+ else (siblings[1], siblings[0])))
+ for i in range(2, max_depth + 1):
+ if siblings[i] == 0:
+ return root, i - 1
+ pair = ((root, siblings[i]) if (leaf_index // 2 ** (i - 1)) % 2 == 0
+ else (siblings[i], root))
+ root = _hash3("0field", *pair)
+ return root, max_depth
+
+
+def leo_verify_non_inclusion(literal: str, address: str,
+ max_depth: int = 15) -> int:
+ """Return the proven root, or raise ``AssertionError`` as the contract aborts."""
+ proofs = parse_proof_literal(literal)
+ assert len(proofs) == 2, "the verifier takes exactly two paths"
+ (sib0, idx0), (sib1, idx1) = proofs
+
+ root0, depth0 = leo_root_and_depth(sib0, idx0, max_depth)
+ root1, depth1 = leo_root_and_depth(sib1, idx1, max_depth)
+ assert root0 == root1, "the two paths must share a root"
+ assert depth0 == depth1, "the two paths must share a depth"
+
+ value = int(str(Address.from_string(address).to_field()).removesuffix("field"))
+ last_leaf_index = 2 ** depth0 - 1
+ if idx0 == idx1:
+ if idx0 == 0:
+ assert value < sib0[0], "below-first: value must precede leaf 0"
+ else:
+ assert idx0 == last_leaf_index, "above-last: must be the final leaf"
+ assert value > sib0[0], "above-last: value must follow the final leaf"
+ else:
+ assert value > sib0[0], "bracketed: value must follow the left leaf"
+ assert value < sib1[0], "bracketed: value must precede the right leaf"
+ assert idx1 <= last_leaf_index, "bracketed: right leaf out of range"
+ assert idx0 + 1 == idx1, "bracketed: leaves must be adjacent"
+ return root0
+
+
+def synthetic_tree(merkle: MerkleExclusionProof, num_leaves: int) -> list[int]:
+ """A tree of *num_leaves* whose leaves are the fields ``10, 20, 30, ...``.
+
+ Built directly from field literals rather than addresses so a test can
+ place a target value precisely between two members.
+ """
+ leaves = [f"{(i + 1) * 10}field" for i in range(num_leaves)]
+ return merkle.build_tree(leaves)
+
+
+def test_empty_freezelist_root_matches_the_deployed_root(merkle):
+ """An empty address list must reproduce the freezelist's on-chain root."""
+ tree = merkle.build_tree(merkle.leaves_from_addresses([]))
+
+ assert merkle.root(tree) == EMPTY_TREE_ROOT
+
+
+@pytest.mark.parametrize("depth", range(1, 16))
+def test_sibling_path_always_fills_the_proof_array(merkle, depth):
+ """Every proof carries ``max_depth + 1`` siblings, whatever the tree depth.
+
+ The reference implementation pads to ``depth`` instead, yielding 15 slots
+ for every tree below the maximum and 16 only at depth 15. The verifying
+ struct is ``[field; 16]``, so a short path is rejected outright.
+
+ The path's width depends only on the tree's size, so the sweep uses stand-in
+ node values; :func:`test_sibling_path_of_a_hashed_tree_fills_the_array`
+ covers a genuinely hashed tree.
+ """
+ num_leaves = 2 ** depth
+ tree = list(range(1, 2 * num_leaves))
+
+ path = merkle.sibling_path(tree, 0)
+
+ assert len(path.siblings) == merkle.max_depth + 1
+
+
+def test_sibling_path_of_a_hashed_tree_fills_the_array(merkle):
+ """The width also holds for a real Poseidon-hashed tree."""
+ tree = synthetic_tree(merkle, 8)
+
+ path = merkle.sibling_path(tree, 3)
+
+ assert len(path.siblings) == 16
+
+
+#: Eight members fill a tree exactly, so there is no padding and leaf 0 is a
+#: real address. That is the only shape in which the verifier's below-first
+#: case can arise.
+UNPADDED = MEMBERS[:8]
+
+
+def test_leaf_indices_bracket_an_absent_address(merkle):
+ """An address inside the range brackets the two leaves either side of it."""
+ tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED))
+
+ assert merkle.leaf_indices(tree, TARGET_INSIDE) == (1, 2)
+
+
+def test_leaf_indices_below_every_member_collapse_to_the_first_leaf(merkle):
+ """Below an unpadded list, both paths point at leaf 0."""
+ tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED))
+
+ assert merkle.leaf_indices(tree, TARGET_BELOW) == (0, 0)
+
+
+def test_leaf_indices_above_every_member_collapse_to_the_last_leaf(merkle):
+ """Above the whole list, both paths point at the final leaf."""
+ tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED))
+
+ assert merkle.leaf_indices(tree, TARGET_ABOVE) == (7, 7)
+
+
+def test_leaf_indices_bracket_against_padding_below_the_first_member(merkle):
+ """With padding, a below-everything address brackets the last zero leaf.
+
+ Nine members pad to sixteen leaves, so leaf 6 is ``0field`` and leaf 7 is
+ the smallest real address. Every address sorts above zero, so this is the
+ ordinary bracketed case rather than the below-first one.
+ """
+ tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS))
+
+ assert merkle.leaf_indices(tree, TARGET_BELOW) == (6, 7)
+
+
+def test_leaf_indices_reject_an_address_that_is_on_the_list(merkle):
+ """A member cannot be proven absent, so ask for it and get an error.
+
+ The reference brackets with ``<=``, which hands back indices whose proof
+ fails the verifier's strict inequality — but only after the caller has paid
+ to prove and broadcast it.
+ """
+ tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS))
+
+ with pytest.raises(ValueError, match="on the list"):
+ merkle.leaf_indices(tree, MEMBERS[2])
+
+
+@pytest.mark.parametrize("target,case", [
+ (TARGET_INSIDE, "bracketed"),
+ (TARGET_BELOW, "below-first"),
+ (TARGET_ABOVE, "above-last"),
+])
+def test_exclusion_proof_satisfies_the_contract_verifier(merkle, target, case):
+ """Every generated proof is accepted by the transcribed on-chain verifier.
+
+ This is what pins the port to the contract rather than to the TypeScript:
+ the oracle hashes independently, so a wrong ordering, index bit, or domain
+ separator shows up here.
+ """
+ tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED))
+
+ literal = merkle.exclusion_proof(tree, target)
+
+ assert leo_verify_non_inclusion(literal, target) == tree[-1], case
+
+
+@pytest.mark.parametrize("count", [0, 1, 2, 3, 5, 7, 9])
+@pytest.mark.parametrize("target", [TARGET_INSIDE, TARGET_BELOW, TARGET_ABOVE])
+def test_exclusion_proof_survives_zero_padded_trees(merkle, count, target):
+ """Member counts that are not powers of two pad with leading ``0field``."""
+ tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS[:count]))
+
+ literal = merkle.exclusion_proof(tree, target)
+
+ assert leo_verify_non_inclusion(literal, target) == tree[-1]
+
+
+def test_exclusion_proof_literal_matches_the_struct(merkle):
+ """The literal is two ``MerkleProof`` structs with full 16-slot arrays."""
+ tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS))
+
+ literal = merkle.exclusion_proof(tree, TARGET_INSIDE)
+
+ proofs = parse_proof_literal(literal)
+ assert len(proofs) == 2
+ assert all(len(siblings) == 16 for siblings, _ in proofs)
+ assert literal.startswith("[{") and literal.endswith("}]")
+
+
+def test_leaves_capacity_matches_the_depth(merkle):
+ """A depth-``d`` tree holds ``2 ** d`` addresses, not ``2 ** (d - 1)``."""
+ assert len(MerkleExclusionProof(max_depth=2).leaves_from_addresses(MEMBERS[:4])) == 4
+ with pytest.raises(ValueError, match="caps at 2"):
+ MerkleExclusionProof(max_depth=1).leaves_from_addresses(MEMBERS[:4])
+
+
+def test_tree_from_nodes_reads_the_freeze_list_endpoint_shape(merkle):
+ """The endpoint serves the whole tree as decimal strings, root last."""
+ tree = merkle.tree_from_nodes(
+ ["0", "0", EMPTY_TREE_ROOT.removesuffix("field")])
+
+ assert merkle.root(tree) == EMPTY_TREE_ROOT
+
+
+def test_tree_from_nodes_round_trips_a_built_tree(merkle):
+ """A served tree proves exactly like one built locally from addresses."""
+ built = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS))
+
+ served = merkle.tree_from_nodes([str(node) for node in built])
+
+ assert served == built
+ assert leo_verify_non_inclusion(
+ merkle.exclusion_proof(served, TARGET_INSIDE), TARGET_INSIDE) == built[-1]
+
+
+def test_zero_addresses_are_not_members(merkle):
+ """The zero address is padding, so it never becomes a leaf of its own."""
+ with_zero = merkle.leaves_from_addresses([ZERO_ADDRESS, *MEMBERS[:3]])
+
+ assert with_zero == merkle.leaves_from_addresses(MEMBERS[:3])
+
+
+def test_sibling_path_width_follows_a_custom_max_depth(merkle):
+ """``max_depth`` is the single knob: the array is ``max_depth + 1`` wide."""
+ shallow = MerkleExclusionProof(max_depth=10)
+ tree = synthetic_tree(shallow, 8)
+
+ assert len(shallow.sibling_path(tree, 0).siblings) == 11
+ assert len(merkle.sibling_path(tree, 0, max_depth=4).siblings) == 5
diff --git a/sdk/python/tests/test_network_client.py b/sdk/python/tests/test_network_client.py
index 5c25cb8..8123f16 100644
--- a/sdk/python/tests/test_network_client.py
+++ b/sdk/python/tests/test_network_client.py
@@ -875,3 +875,52 @@ def test_set_prover_uri() -> None:
c = make_client()
c.set_prover_uri("https://prover.example.com")
assert c._prover_uri == f"https://prover.example.com/{NET}"
+
+
+# ---------------------------------------------------------------------------
+# Freeze list (compliance)
+# ---------------------------------------------------------------------------
+
+@resp_lib.activate
+def test_get_freeze_list_url_takes_the_program() -> None:
+ """The program is a parameter — one endpoint serves every freeze list."""
+ resp_lib.add(
+ resp_lib.GET,
+ f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list",
+ json=["0", "0", "3642222252059314292809609689035560016959342421640560347114299934615987159853"],
+ )
+ c = make_client()
+
+ tree = c.get_freeze_list("shield_swap_freezelist.aleo")
+
+ assert tree == [
+ 0, 0,
+ 3642222252059314292809609689035560016959342421640560347114299934615987159853,
+ ]
+ assert resp_lib.calls[0].request.url == (
+ f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list")
+
+
+@resp_lib.activate
+def test_get_freeze_list_serves_a_different_program() -> None:
+ resp_lib.add(
+ resp_lib.GET,
+ f"{HOST}/programs/test_usad_freezelist.aleo/compliance/freeze-list",
+ json=["0", "0", "17"],
+ )
+ c = make_client()
+
+ assert c.get_freeze_list("test_usad_freezelist.aleo") == [0, 0, 17]
+
+
+@resp_lib.activate
+def test_get_freeze_list_rejects_a_non_list_payload() -> None:
+ resp_lib.add(
+ resp_lib.GET,
+ f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list",
+ json={"unexpected": True},
+ )
+ c = make_client()
+
+ with pytest.raises(ValueError, match="freeze list"):
+ c.get_freeze_list("shield_swap_freezelist.aleo")
diff --git a/sdk/python/tests/test_network_client_async.py b/sdk/python/tests/test_network_client_async.py
index 6d4f817..6e3fb82 100644
--- a/sdk/python/tests/test_network_client_async.py
+++ b/sdk/python/tests/test_network_client_async.py
@@ -596,3 +596,40 @@ def handler(req: httpx.Request) -> httpx.Response:
assert result["ok"] is False
assert result["status"] == 400
assert post_count == 1
+
+
+# ---------------------------------------------------------------------------
+# Freeze list (compliance)
+# ---------------------------------------------------------------------------
+
+async def test_get_freeze_list_url_takes_the_program() -> None:
+ """The program is a parameter — one endpoint serves every freeze list."""
+ root = "3642222252059314292809609689035560016959342421640560347114299934615987159853"
+ seen: dict[str, str] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return jr(["0", "0", root])
+
+ c = make_client()
+ c._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+
+ tree = await c.get_freeze_list("shield_swap_freezelist.aleo")
+
+ assert tree == [0, 0, int(root)]
+ assert seen["url"] == (
+ f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list")
+
+
+async def test_get_freeze_list_serves_a_different_program() -> None:
+ c = make_client({"test_usad_freezelist.aleo/compliance/freeze-list":
+ jr(["0", "0", "17"])})
+
+ assert await c.get_freeze_list("test_usad_freezelist.aleo") == [0, 0, 17]
+
+
+async def test_get_freeze_list_rejects_a_non_list_payload() -> None:
+ c = make_client({"compliance/freeze-list": jr({"unexpected": True})})
+
+ with pytest.raises(ValueError, match="freeze list"):
+ await c.get_freeze_list("shield_swap_freezelist.aleo")
diff --git a/shield-swap-sdk/tests/integration/devnode_amm.py b/shield-swap-sdk/tests/integration/devnode_amm.py
index e2171fb..111454d 100644
--- a/shield-swap-sdk/tests/integration/devnode_amm.py
+++ b/shield-swap-sdk/tests/integration/devnode_amm.py
@@ -134,18 +134,34 @@ def wait_queryable(self, program_id: str) -> None:
def deploy_program(self, source: str, label: str) -> str:
"""Deploy *source* proofless: dummy verifying keys (no synthesis) and
- an unproven public fee paid by the admin — devnode-only."""
+ an unproven public fee paid by the admin — devnode-only.
+
+ The devnode bundles its own snarkVM, which need not price deployments
+ identically to the bindings computing the fee here; the two diverged at
+ devnode 0.2.3 / snarkVM 4.9.0. A rejected base fee names the amount the
+ node wants, so a short fee is retried at that figure rather than
+ carrying a guessed margin.
+ """
net = self._net()
process = self.aleo.process
program = net.Program.from_source(source)
-
deployment = net.Deployment.from_program_unproven(program, self.admin.address)
+
cost = process.deployment_cost(deployment)
- fee_auth = process.authorize_fee_public(
- self.admin.private_key, cost, 0, deployment.deployment_id())
- fee = net.Fee.from_authorization_unproven(fee_auth, self.state_root())
- tx = net.Transaction.from_deployment(self.admin.private_key, deployment, fee)
- tx_id = self.submit_and_confirm(tx, f"deploy {label}")
+ for attempt in range(2):
+ fee_auth = process.authorize_fee_public(
+ self.admin.private_key, cost, 0, deployment.deployment_id())
+ fee = net.Fee.from_authorization_unproven(fee_auth, self.state_root())
+ tx = net.Transaction.from_deployment(self.admin.private_key, deployment, fee)
+ try:
+ tx_id = self.submit_and_confirm(tx, f"deploy {label}")
+ break
+ except Exception as exc:
+ required = re.search(r"requires (\d+) microcredits", str(exc))
+ if attempt or not required:
+ raise
+ cost = int(required.group(1))
+
self.wait_queryable(label)
# Later deployments/executions resolve this program from the process.
process.add_program(program)
diff --git a/shield-swap-sdk/tests/integration/devnode_merkle_stack.py b/shield-swap-sdk/tests/integration/devnode_merkle_stack.py
new file mode 100644
index 0000000..9fe1046
--- /dev/null
+++ b/shield-swap-sdk/tests/integration/devnode_merkle_stack.py
@@ -0,0 +1,382 @@
+"""Devnode fixture for freeze-list Merkle exclusion proofs.
+
+Boots ``aleo-devnode``, deploys the shield_swap stack **fetched live from the
+network** rather than from repo fixtures — the deployed programs are the
+authoritative statement of what the verifier accepts, and a vendored copy can
+drift from them silently. Then it populates the freeze list with real
+addresses and leaves the stack ready for a transition that must prove
+non-inclusion.
+
+This is what makes the proofs meaningful: against an *empty* freeze list the
+all-zero placeholder literal verifies, so only a populated list exercises
+:class:`aleo.MerkleExclusionProof` for real.
+
+Deployment is proofless (dummy verifying keys, unproven fee), as in
+``devnode_amm``: synthesizing real keys for the AMM takes many minutes and buys
+nothing on a node that skips certificate verification.
+
+Execution ladders:
+
+* **unproven** (default here): the authorization still *evaluates* the
+ transition, so ``verify_merkle_non_inclusion`` and its asserts run in full —
+ only the SNARK is skipped. The finalize, including
+ ``assert_valid_freeze_list_root``, runs on-chain either way. Both halves of
+ the proof are therefore checked.
+* **proven** (``ALEO_DEVNODE_MERKLE_PROVEN=1``): full local proving, much
+ slower.
+"""
+from __future__ import annotations
+
+import os
+import re
+import time
+from dataclasses import dataclass, field
+from typing import Any, Optional
+
+import requests
+
+from aleo import MerkleExclusionProof
+
+#: The node API is the source of truth for deployed program source.
+NETWORK_API = os.environ.get(
+ "ALEO_DEVNODE_SOURCE_API", "https://api.provable.com/v2/testnet")
+
+AMM_PROGRAM = "shield_swap.aleo"
+FREEZELIST_PROGRAM = "shield_swap_freezelist.aleo"
+AMM_MULTISIG = "shield_swap_multisig_core.aleo"
+TOKEN_MULTISIG = "test_arc20_multisig_core.aleo"
+TOKEN0_PROGRAM = "test_arc20_usdc.aleo"
+TOKEN1_PROGRAM = "test_arc20_usdt.aleo"
+
+#: Deployment order — imports before importers.
+DEPLOY_ORDER = [
+ AMM_MULTISIG,
+ FREEZELIST_PROGRAM,
+ AMM_PROGRAM,
+ TOKEN_MULTISIG,
+ TOKEN0_PROGRAM,
+ TOKEN1_PROGRAM,
+]
+
+#: Administrator literals baked into the deployed programs. On a devnode these
+#: keys do not exist, so every occurrence is rewritten to a genesis account.
+#: The AMM and its freeze list share one; the test ARC-20s share another.
+BAKED_ADMINS = [
+ "aleo1z3zwzgpgakk89xpknync5rtklkjkyv33g7cvaqe0gku64zs3lv9qyux0qc",
+ "aleo1axurgcdhztu8m23ttzju38qzchtzs8kyk7nga9n58zyrmnxzmuqqf6wqdc",
+]
+
+#: Freeze-list role bits: 8 grants roles, 16 edits the list.
+FREEZELIST_MANAGER_ROLE = 24
+#: ARC-20 role bits: 8 grants roles, 1 mints.
+TOKEN_MINTER_ROLE = 9
+
+#: Blocks the previous freeze-list root stays valid after a rotation.
+FREEZELIST_WINDOW = 100
+#: The devnode's snarkVM uses TEST consensus heights; the last activates at 20.
+LAST_TEST_CONSENSUS_HEIGHT = 20
+
+TOKEN_SUPPLY = 1_000_000_000_000
+
+PROVEN = os.environ.get("ALEO_DEVNODE_MERKLE_PROVEN") == "1"
+
+
+def identifier_to_field(identifier: str) -> str:
+ """A program identifier (no ``.aleo``) as the field the AMM keys tokens by.
+
+ Little-endian bytes, verified against every entry of the live token
+ registry.
+ """
+ value = 0
+ for byte in reversed(identifier.encode()):
+ value = (value << 8) | byte
+ return f"{value}field"
+
+
+def fetch_program(program_id: str) -> str:
+ """Deployed source for *program_id*, straight from the node API."""
+ response = requests.get(f"{NETWORK_API}/program/{program_id}", timeout=60)
+ response.raise_for_status()
+ source = response.json()
+ if not isinstance(source, str) or f"program {program_id}" not in source:
+ raise RuntimeError(f"{program_id} did not come back as program source")
+ return source
+
+
+def repoint_admins(source: str, admin_address: str) -> str:
+ """Rewrite every baked administrator literal to *admin_address*.
+
+ Both the constructor gate and the finalize gates (``initialize``,
+ ``initialize_token``) compare against the same literal, so all occurrences
+ have to move together or the program deploys but cannot be configured.
+ """
+ for baked in BAKED_ADMINS:
+ source = source.replace(baked, admin_address)
+ return source
+
+
+@dataclass
+class MerkleDevnode:
+ """A devnode running the network's shield_swap stack with a live freeze list."""
+
+ devnode: Any
+ aleo: Any
+ admin: Any
+ user: Any
+ sources: dict[str, str]
+ token0_id: str
+ token1_id: str
+ token0_program: str
+ token1_program: str
+ #: Addresses currently frozen, in insertion order.
+ frozen: list[str] = field(default_factory=list)
+ merkle: MerkleExclusionProof = field(default_factory=MerkleExclusionProof)
+
+ # ── Chain plumbing ──────────────────────────────────────────────────────
+
+ def _net(self) -> Any:
+ from aleo import testnet
+ return testnet
+
+ def state_root(self) -> str:
+ return str(self.aleo.network.get_state_root())
+
+ def submit_and_confirm(self, tx: Any, label: str) -> str:
+ tx_id = self.aleo.network.submit_transaction(tx)
+ self.devnode.advance(1)
+ confirmed = str(self.aleo.network.get_confirmed_transaction(str(tx_id)))
+ if '"accepted"' not in confirmed and "'accepted'" not in confirmed:
+ raise RuntimeError(
+ f"{label}: transaction {tx_id} was not accepted: "
+ f"{confirmed[:400]}\ndevnode logs:\n"
+ + "\n".join(self.devnode.logs()[-15:])
+ )
+ return str(tx_id)
+
+ def wait_queryable(self, program_id: str) -> None:
+ for _ in range(20):
+ try:
+ if f"program {program_id}" in str(
+ self.aleo.network.get_program(program_id)):
+ return
+ except Exception:
+ pass
+ self.devnode.advance(1)
+ time.sleep(0.3)
+ raise RuntimeError(f"{program_id} never became queryable")
+
+ def deploy_program(self, program_id: str) -> str:
+ """Deploy proofless: dummy verifying keys and an unproven public fee.
+
+ The devnode ships its own snarkVM, which need not price deployments
+ identically to the bindings computing the fee here. When the node
+ rejects the base fee it names the amount it wants, so a short fee is
+ retried at that figure rather than guessing a margin.
+ """
+ net = self._net()
+ process = self.aleo.process
+ program = net.Program.from_source(self.sources[program_id])
+ deployment = net.Deployment.from_program_unproven(program, self.admin.address)
+
+ cost = process.deployment_cost(deployment)
+ for attempt in range(2):
+ fee_auth = process.authorize_fee_public(
+ self.admin.private_key, cost, 0, deployment.deployment_id())
+ fee = net.Fee.from_authorization_unproven(fee_auth, self.state_root())
+ tx = net.Transaction.from_deployment(
+ self.admin.private_key, deployment, fee)
+ try:
+ tx_id = self.submit_and_confirm(tx, f"deploy {program_id}")
+ break
+ except Exception as exc:
+ required = re.search(r"requires (\d+) microcredits", str(exc))
+ if attempt or not required:
+ raise
+ cost = int(required.group(1))
+
+ self.wait_queryable(program_id)
+ # Registers the program for later executions and dynamic dispatch.
+ process.add_program(program)
+ return tx_id
+
+ def execute(self, account: Any, program_id: str, function: str,
+ inputs: list[Any], label: str) -> str:
+ """Run one transition as *account* and require on-chain acceptance."""
+ bound = self.aleo.programs.get(program_id).functions[function](*inputs)
+
+ if PROVEN:
+ return self.submit_and_confirm(
+ bound.build_transaction(account).raw, label)
+ # authorize() evaluates the transition, so in-circuit asserts — the
+ # Merkle verification included — run here even though no SNARK follows.
+ return self.submit_and_confirm(self._unproven_tx(bound, account), label)
+
+ def submit_call(self, call: Any, account: Any, label: str) -> str:
+ """Drive a ``DexCall`` through the same ladder as :meth:`execute`.
+
+ Used for the transitions that take no Merkle proof (pool creation), so
+ those keep going through the shipped client rather than hand-built
+ inputs.
+ """
+ if PROVEN:
+ result = call.transact(account)
+ self.devnode.advance(1)
+ return str(result.transaction_id)
+ return self.submit_and_confirm(
+ self._unproven_tx(call._bound, account), label)
+
+ def _unproven_tx(self, bound: Any, account: Any) -> Any:
+ """A proofless execution transaction for an already-bound call."""
+ net = self._net()
+ process = self.aleo.process
+ auth = bound.authorize(account).raw
+ root = self.state_root()
+ execution = net.Execution.from_authorization_unproven(auth, root)
+ cost, _ = process.execution_cost(execution)
+ fee_auth = process.authorize_fee_public(
+ account.private_key, cost, 0, execution.execution_id)
+ fee = net.Fee.from_authorization_unproven(fee_auth, root)
+ return net.Transaction.from_execution(execution, fee)
+
+ def records_of(self, account: Any, tx_id: str) -> list[str]:
+ tx = self.aleo.network.get_transaction_object(tx_id)
+ return [str(r) for r in tx.owned_records(account.view_key)]
+
+ def privatize_token(self, account: Any, token_program: str,
+ amount: int) -> str:
+ tx_id = self.execute(
+ account, token_program, "transfer_public_to_private",
+ [str(account.address), f"{amount}u128"],
+ f"privatize {amount} {token_program}")
+ records = [r for r in self.records_of(account, tx_id) if "amount" in r]
+ if not records:
+ raise RuntimeError(f"no Token record from {token_program} (tx {tx_id})")
+ return records[0]
+
+ def read_mapping(self, program_id: str, mapping: str, key: str) -> Optional[str]:
+ from aleo_shield_swap._core import normalize_mapping_value
+ return normalize_mapping_value(
+ self.aleo.programs.get(program_id).mapping(mapping).get(key))
+
+ # ── Freeze list ─────────────────────────────────────────────────────────
+
+ def on_chain_root(self) -> str:
+ """The freeze list's current root, as the AMM's finalize reads it."""
+ root = self.read_mapping(FREEZELIST_PROGRAM, "freeze_list_root", "1u8")
+ if root is None:
+ raise RuntimeError("freeze list has no root — initialize first")
+ return root
+
+ def local_tree(self) -> list[int]:
+ """The tree over the currently frozen addresses, built locally."""
+ return self.merkle.build_tree(
+ self.merkle.leaves_from_addresses(self.frozen))
+
+ def freeze(self, address: str) -> str:
+ """Add *address* to the freeze list, rotating the root to match.
+
+ The contract stores whatever root the manager hands it, so this is
+ where the locally computed root becomes the one every later proof must
+ reproduce. Entries occupy indices from 1 upward; index 0 is the zero
+ address written at initialization.
+ """
+ previous_root = self.on_chain_root()
+ self.frozen.append(address)
+ new_root = self.merkle.root(self.local_tree())
+ tx_id = self.execute(
+ self.admin, FREEZELIST_PROGRAM, "update_freeze_list",
+ [address, "true", f"{len(self.frozen)}u32", previous_root, new_root],
+ f"freeze {address[:12]}…")
+ return tx_id
+
+ def exclusion_proof(self, address: str) -> str:
+ """A ``[MerkleProof; 2]`` literal proving *address* is not frozen."""
+ return self.merkle.exclusion_proof(self.local_tree(), address)
+
+ def stop(self) -> None:
+ self.devnode.stop()
+
+
+def setup_merkle_devnode(freeze_count: int = 5) -> MerkleDevnode:
+ """Boot a devnode with the network's stack deployed and a populated list.
+
+ Args:
+ freeze_count: How many generated addresses to put on the freeze list.
+ Anything above zero makes the placeholder proof invalid, which is
+ the point.
+ """
+ from aleo.testing import Devnode
+
+ devnode = Devnode().start()
+ aleo = devnode.aleo
+ admin = devnode.accounts[0]
+ aleo.default_account = admin
+ aleo.record_provider = None # no scanner on a devnode
+ devnode.advance(LAST_TEST_CONSENSUS_HEIGHT + 2)
+
+ sources = {
+ pid: repoint_admins(fetch_program(pid), str(admin.address))
+ for pid in DEPLOY_ORDER
+ }
+
+ usdc_id = identifier_to_field(TOKEN0_PROGRAM.removesuffix(".aleo"))
+ usdt_id = identifier_to_field(TOKEN1_PROGRAM.removesuffix(".aleo"))
+ # Pools order their sides by token id, not by name.
+ usdc_first = int(usdc_id.removesuffix("field")) < int(usdt_id.removesuffix("field"))
+
+ ctx = MerkleDevnode(
+ devnode=devnode, aleo=aleo, admin=admin, user=None, sources=sources,
+ token0_id=usdc_id if usdc_first else usdt_id,
+ token1_id=usdt_id if usdc_first else usdc_id,
+ token0_program=TOKEN0_PROGRAM if usdc_first else TOKEN1_PROGRAM,
+ token1_program=TOKEN1_PROGRAM if usdc_first else TOKEN0_PROGRAM,
+ )
+
+ for program_id in DEPLOY_ORDER:
+ ctx.deploy_program(program_id)
+
+ # Freeze list: initialize grants the admin role 8 (grant roles only), so it
+ # has to promote itself to 24 before it can edit the list.
+ ctx.execute(admin, FREEZELIST_PROGRAM, "initialize",
+ [str(admin.address), f"{FREEZELIST_WINDOW}u32"],
+ "freezelist initialize")
+ ctx.execute(admin, FREEZELIST_PROGRAM, "update_role",
+ [str(admin.address), f"{FREEZELIST_MANAGER_ROLE}u16"],
+ "freezelist grant manager role")
+
+ # Tokens: same shape — initialize grants role 8, minting needs bit 1.
+ for token_program in (TOKEN0_PROGRAM, TOKEN1_PROGRAM):
+ ctx.execute(admin, token_program, "initialize_token",
+ [str(admin.address)], f"initialize {token_program}")
+ ctx.execute(admin, token_program, "update_role",
+ [str(admin.address), f"{TOKEN_MINTER_ROLE}u16"],
+ f"grant minter on {token_program}")
+
+ # AMM admin configuration, mirroring the deployed testnet parameters.
+ for label, function, inputs in [
+ ("fee tier 3000", "add_fee_tier", ["3000u16"]),
+ ("tick spacing 60", "add_tick_spacing", ["60u32"]),
+ ("bind 3000->60", "bind_fee_to_tick_spacing", ["3000u16", "60u32"]),
+ ("allow token0", "allow_token", [ctx.token0_id, ctx.token0_id]),
+ ("allow token1", "allow_token", [ctx.token1_id, ctx.token1_id]),
+ ("open pool creation", "set_pool_creation_is_open", ["true"]),
+ ]:
+ ctx.execute(admin, AMM_PROGRAM, function, inputs, f"admin {label}")
+
+ # A funded non-admin does the proving work, so the signer under test is not
+ # the same account that administers the list.
+ user = aleo.account.create()
+ ctx.user = user
+ ctx.execute(admin, "credits.aleo", "transfer_public",
+ [str(user.address), "100000000u64"], "fund user")
+ for token_program in (TOKEN0_PROGRAM, TOKEN1_PROGRAM):
+ ctx.execute(admin, token_program, "mint_public",
+ [str(user.address), f"{TOKEN_SUPPLY}u128"],
+ f"mint {token_program} to user")
+
+ # Populate the list with addresses that are NOT the user, so the user can
+ # still prove non-inclusion against a genuinely non-empty tree.
+ for _ in range(freeze_count):
+ ctx.freeze(str(aleo.account.create().address))
+
+ return ctx
diff --git a/shield-swap-sdk/tests/integration/test_devnode_merkle.py b/shield-swap-sdk/tests/integration/test_devnode_merkle.py
new file mode 100644
index 0000000..5194557
--- /dev/null
+++ b/shield-swap-sdk/tests/integration/test_devnode_merkle.py
@@ -0,0 +1,218 @@
+"""Freeze-list Merkle exclusion proofs against the real deployed AMM.
+
+Deploys the network's ``shield_swap.aleo``, ``shield_swap_freezelist.aleo``,
+and two plain ARC-20s onto a devnode, populates the freeze list, and mints a
+position — the transition that requires three separate non-inclusion proofs
+(signer, recipient, withdrawal).
+
+The unit tests in ``sdk/python/tests/test_merkle.py`` check the port against a
+Python transcription of the verifier. This checks it against the verifier
+itself, compiled into the deployed bytecode, which is the only thing that
+settles whether the proofs are actually right.
+
+``test_placeholder_proof_is_rejected`` is the control: it shows the all-zero
+literal the SDK ships today stops working the moment the list is non-empty, so
+the passing mint is evidence about the proofs rather than about a list that
+happens to accept anything.
+
+Run with::
+
+ pytest tests/integration/test_devnode_merkle.py -m devnode
+"""
+from __future__ import annotations
+
+import pytest
+
+from aleo import MerkleExclusionProof
+from aleo_shield_swap import ShieldSwap
+from aleo_shield_swap import _generated as g
+from aleo_shield_swap._core import default_merkle_proofs, generate_field_nonce
+
+from .devnode_merkle_stack import (
+ AMM_PROGRAM,
+ FREEZELIST_PROGRAM,
+ MerkleDevnode,
+ setup_merkle_devnode,
+)
+
+pytestmark = pytest.mark.devnode
+
+FEE = 3000
+INITIAL_TICK = 0
+MINT_AMOUNT = 100_000_000
+FUND_AMOUNT = 200_000_000
+
+#: ``Poseidon4([1field, 0field, 0field])`` — the root while the list is empty.
+EMPTY_TREE_ROOT = (
+ "3642222252059314292809609689035560016959342421640560347114299934615987159853field"
+)
+
+
+@pytest.fixture(scope="module")
+def ctx() -> MerkleDevnode:
+ try:
+ context = setup_merkle_devnode(freeze_count=5)
+ except Exception as exc:
+ if "aleo-devnode not found" in str(exc):
+ pytest.skip(f"aleo-devnode binary not available: {exc}")
+ raise
+ yield context
+ context.stop()
+
+
+@pytest.fixture(scope="module")
+def dex(ctx: MerkleDevnode) -> ShieldSwap:
+ client = ShieldSwap(ctx.aleo, program=AMM_PROGRAM)
+ ctx.aleo.default_account = ctx.user
+ return client
+
+
+@pytest.fixture(scope="module")
+def pool_key(ctx: MerkleDevnode, dex: ShieldSwap) -> str:
+ """A live pool over the two deployed ARC-20s."""
+ call = dex.create_pool(
+ token0_id=ctx.token0_id, token1_id=ctx.token1_id,
+ fee=FEE, initial_tick=INITIAL_TICK, account=ctx.user)
+ ctx.submit_call(call, ctx.user, "create_pool")
+ key = dex.derive_pool_key(ctx.token0_id, ctx.token1_id, FEE)
+ assert ctx.read_mapping(AMM_PROGRAM, "pools", key), "pool was not created"
+ return key
+
+
+def build_mint_inputs(ctx: MerkleDevnode, dex: ShieldSwap, pool_key: str, *,
+ signer_proofs: str, recipient_proofs: str,
+ withdrawal_proofs: str) -> list[str]:
+ """``mint`` inputs in deployed order, with the proofs left to the caller.
+
+ Mirrors ``ShieldSwap.mint``'s assembly. It is rebuilt here rather than
+ reused because the shipped method hardcodes the placeholder proof and takes
+ no parameter to override it.
+ """
+ slot = dex.get_slot(pool_key)
+ spacing = slot.tick_spacing
+ lower, upper = -10 * spacing, 10 * spacing
+
+ lower_hint = dex.find_tick_predecessor(pool_key, lower)
+ upper_pred = dex.find_tick_predecessor(pool_key, upper)
+ # The finalize inserts the lower tick before validating the upper hint.
+ upper_hint = lower if lower > upper_pred else upper_pred
+
+ request = g.MintPositionRequest(
+ pool=pool_key, tick_lower=lower, tick_upper=upper,
+ amount0_desired=MINT_AMOUNT, amount1_desired=MINT_AMOUNT,
+ amount0_min=0, amount1_min=0,
+ tick_lower_hint=lower_hint, tick_upper_hint=upper_hint,
+ ).to_plaintext()
+
+ record0 = ctx.privatize_token(ctx.user, ctx.token0_program, FUND_AMOUNT)
+ record1 = ctx.privatize_token(ctx.user, ctx.token1_program, FUND_AMOUNT)
+
+ return [
+ generate_field_nonce(), record0, record1,
+ str(ctx.user.address), str(ctx.user.address), request,
+ ctx.token0_id, ctx.token1_id,
+ signer_proofs, recipient_proofs, withdrawal_proofs,
+ ]
+
+
+def test_freeze_list_root_matches_the_locally_built_tree(ctx: MerkleDevnode):
+ """The root the contract stores is the one the port computes.
+
+ Nothing on chain recomputes the root — the manager supplies it — so this is
+ the step that ties the local tree to the value every proof must reproduce.
+ """
+ assert len(ctx.frozen) == 5
+ assert ctx.on_chain_root() == ctx.merkle.root(ctx.local_tree())
+
+
+def test_freeze_list_is_no_longer_empty(ctx: MerkleDevnode):
+ """Without this, a passing mint would prove nothing about the proofs."""
+ assert ctx.on_chain_root() != EMPTY_TREE_ROOT
+
+
+def test_frozen_addresses_cannot_be_proven_absent(ctx: MerkleDevnode):
+ """A member is rejected client-side rather than on-chain."""
+ with pytest.raises(ValueError, match="on the list"):
+ ctx.exclusion_proof(ctx.frozen[0])
+
+
+def test_placeholder_proof_is_rejected(ctx: MerkleDevnode, dex: ShieldSwap,
+ pool_key: str):
+ """The all-zero literal fails against a populated list.
+
+ This is the control for :func:`test_mint_with_exclusion_proofs_succeeds`:
+ without it, a mint could pass because the list accepts anything.
+
+ The placeholder clears the *circuit* — two copies of a depth-1 all-zero
+ path reconstruct the empty-tree root, and every real address sorts above
+ ``0field``, so it is a genuine non-inclusion proof for an empty tree. What
+ stops it is the finalize's ``assert_valid_freeze_list_root``, which no
+ longer matches once the list has entries. The transaction is therefore
+ *rejected* on chain rather than failing to authorize.
+ """
+ placeholder = default_merkle_proofs()
+ inputs = build_mint_inputs(
+ ctx, dex, pool_key, signer_proofs=placeholder,
+ recipient_proofs=placeholder, withdrawal_proofs=placeholder)
+
+ with pytest.raises(RuntimeError, match="was not accepted") as excinfo:
+ ctx.execute(ctx.user, AMM_PROGRAM, "mint", inputs,
+ "mint with placeholder proofs")
+
+ assert "'status': 'rejected'" in str(excinfo.value), (
+ "expected the finalize's root check to reject the placeholder")
+
+
+def test_mint_with_exclusion_proofs_succeeds(ctx: MerkleDevnode, dex: ShieldSwap,
+ pool_key: str):
+ """A real proof carries a mint through the deployed verifier.
+
+ The signer, recipient, and withdrawal address are all the user here, so the
+ same proof satisfies all three checks — the contract verifies each
+ independently and requires all three roots to agree.
+ """
+ proof = ctx.exclusion_proof(str(ctx.user.address))
+ inputs = build_mint_inputs(
+ ctx, dex, pool_key, signer_proofs=proof,
+ recipient_proofs=proof, withdrawal_proofs=proof)
+
+ tx_id = ctx.execute(ctx.user, AMM_PROGRAM, "mint", inputs,
+ "mint with exclusion proofs")
+
+ assert tx_id
+ nfts = [r for r in ctx.records_of(ctx.user, tx_id) if "token_id" in r]
+ assert nfts, "mint produced no PositionNFT"
+
+
+def test_proof_still_verifies_after_the_list_grows(ctx: MerkleDevnode,
+ dex: ShieldSwap,
+ pool_key: str):
+ """Freezing another address rotates the root; a fresh proof tracks it."""
+ before = ctx.on_chain_root()
+ ctx.freeze(str(ctx.aleo.account.create().address))
+ after = ctx.on_chain_root()
+ assert after != before, "freezing did not rotate the root"
+
+ proof = ctx.exclusion_proof(str(ctx.user.address))
+ inputs = build_mint_inputs(
+ ctx, dex, pool_key, signer_proofs=proof,
+ recipient_proofs=proof, withdrawal_proofs=proof)
+
+ assert ctx.execute(ctx.user, AMM_PROGRAM, "mint", inputs,
+ "mint after root rotation")
+
+
+def test_exclusion_proof_from_the_served_tree(ctx: MerkleDevnode):
+ """A tree read back as serialized nodes proves the same as a built one.
+
+ This is the shape ``AleoNetworkClient.get_freeze_list`` returns, so it
+ covers the path a caller takes when the list comes from a service rather
+ than from a local rebuild.
+ """
+ built = ctx.local_tree()
+
+ served = MerkleExclusionProof().tree_from_nodes([str(n) for n in built])
+
+ assert served == built
+ assert (MerkleExclusionProof().exclusion_proof(served, str(ctx.user.address))
+ == ctx.exclusion_proof(str(ctx.user.address)))