Repository: zcash/zcash (language: rust)

Issue:
Store Sapling bundles as Rust types inside `CTransaction`
Currently we store Sapling Spend and Output descriptions as C++ types. However, this means we end up parsing the bundles repeatedly when operating on them, as more of the Sapling consensus rules are moved into Rust.

We should switch to storing Sapling bundles in Rust, like we do for Orchard.


Discussion / hints:
This is also necessary for migrating to `zcash_primitives 0.10`, where we improved type safety in a way that prevents us constructing Sapling bundles directly (instead requiring that they be constructed from the parser). Storing Sapling bundles as Rust types will better match the new type-safe API.
We are now unblocking #6398 with https://github.com/zcash/librustzcash/pull/784, so this will now happen afterwards using the newer type-safe Sapling APIs.

Fix patch:
diff --git a/.cargo/config.offline b/.cargo/config.offline
index ca836a44ac9..29ae97d7de4 100644
--- a/.cargo/config.offline
+++ b/.cargo/config.offline
@@ -6,7 +6,7 @@ replace-with = "vendored-sources"
 
 [source."https://github.com/zcash/librustzcash.git"]
 git = "https://github.com/zcash/librustzcash.git"
-rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a"
+rev = "8db7a071a0b79687d79ffc6894fe573944b16008"
 replace-with = "vendored-sources"
 
 [source.vendored-sources]
diff --git a/Cargo.lock b/Cargo.lock
index 0ef1f8225e9..fe8f8071e08 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -230,12 +230,6 @@ version = "3.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535"
 
-[[package]]
-name = "byte-slice-cast"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3ac9f8b63eca6fd385229b3675f6cc0dc5c8a5c8a54a59d4f52ffd670d87b0c"
-
 [[package]]
 name = "byteorder"
 version = "1.4.3"
@@ -517,7 +511,7 @@ checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
 [[package]]
 name = "equihash"
 version = "0.2.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "blake2b_simd",
  "byteorder",
@@ -526,7 +520,7 @@ dependencies = [
 [[package]]
 name = "f4jumble"
 version = "0.1.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "blake2b_simd",
 ]
@@ -544,13 +538,10 @@ dependencies = [
 
 [[package]]
 name = "fixed-hash"
-version = "0.7.0"
+version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c"
+checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534"
 dependencies = [
- "byteorder",
- "rand",
- "rustc-hex",
  "static_assertions",
 ]
 
@@ -815,26 +806,6 @@ dependencies = [
  "want",
 ]
 
-[[package]]
-name = "impl-codec"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f"
-dependencies = [
- "parity-scale-codec",
-]
-
-[[package]]
-name = "impl-trait-for-tuples"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d7a9f6330b71fea57921c9b61c47ee6e84f72d394754eff6163ae67e7395eb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
 [[package]]
 name = "incrementalmerkletree"
 version = "0.3.1"
@@ -1284,32 +1255,6 @@ dependencies = [
  "group",
 ]
 
-[[package]]
-name = "parity-scale-codec"
-version = "3.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "637935964ff85a605d114591d4d2c13c5d1ba2806dae97cea6bf180238a749ac"
-dependencies = [
- "arrayvec",
- "bitvec",
- "byte-slice-cast",
- "impl-trait-for-tuples",
- "parity-scale-codec-derive",
- "serde",
-]
-
-[[package]]
-name = "parity-scale-codec-derive"
-version = "3.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86b26a931f824dd4eca30b3e43bb4f31cd5f0d3a403c5f5ff27106b805bfde7b"
-dependencies = [
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
 [[package]]
 name = "parking_lot"
 version = "0.12.1"
@@ -1444,25 +1389,14 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
 
 [[package]]
 name = "primitive-types"
-version = "0.11.1"
+version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e28720988bff275df1f51b171e1b2a18c30d194c4d2b61defdacecd625a5d94a"
+checksum = "9f3486ccba82358b11a77516035647c34ba167dfa53312630de83b12bd4f3d66"
 dependencies = [
  "fixed-hash",
- "impl-codec",
  "uint",
 ]
 
-[[package]]
-name = "proc-macro-crate"
-version = "1.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
-dependencies = [
- "once_cell",
- "toml_edit",
-]
-
 [[package]]
 name = "proc-macro2"
 version = "1.0.56"
@@ -1675,12 +1609,6 @@ version = "0.1.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
 
-[[package]]
-name = "rustc-hex"
-version = "2.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6"
-
 [[package]]
 name = "ryu"
 version = "1.0.13"
@@ -1957,23 +1885,6 @@ dependencies = [
  "windows-sys",
 ]
 
-[[package]]
-name = "toml_datetime"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622"
-
-[[package]]
-name = "toml_edit"
-version = "0.19.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
-dependencies = [
- "indexmap",
- "toml_datetime",
- "winnow",
-]
-
 [[package]]
 name = "tower-service"
 version = "0.3.2"
@@ -2293,15 +2204,6 @@ version = "0.42.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
 
-[[package]]
-name = "winnow"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28"
-dependencies = [
- "memchr",
-]
-
 [[package]]
 name = "wyz"
 version = "0.5.1"
@@ -2314,7 +2216,7 @@ dependencies = [
 [[package]]
 name = "zcash_address"
 version = "0.2.1"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "bech32",
  "bs58",
@@ -2325,7 +2227,7 @@ dependencies = [
 [[package]]
 name = "zcash_encoding"
 version = "0.2.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "byteorder",
  "nonempty",
@@ -2334,7 +2236,7 @@ dependencies = [
 [[package]]
 name = "zcash_history"
 version = "0.3.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "blake2b_simd",
  "byteorder",
@@ -2344,7 +2246,7 @@ dependencies = [
 [[package]]
 name = "zcash_note_encryption"
 version = "0.3.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "chacha20",
  "chacha20poly1305",
@@ -2356,7 +2258,7 @@ dependencies = [
 [[package]]
 name = "zcash_primitives"
 version = "0.11.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "aes",
  "bip0039",
@@ -2391,7 +2293,7 @@ dependencies = [
 [[package]]
 name = "zcash_proofs"
 version = "0.11.0"
-source = "git+https://github.com/zcash/librustzcash.git?rev=ec1c1bcf21c43b0736d298d3a437162253946a8a#ec1c1bcf21c43b0736d298d3a437162253946a8a"
+source = "git+https://github.com/zcash/librustzcash.git?rev=8db7a071a0b79687d79ffc6894fe573944b16008#8db7a071a0b79687d79ffc6894fe573944b16008"
 dependencies = [
  "bellman",
  "blake2b_simd",
diff --git a/Cargo.toml b/Cargo.toml
index 58e7acfbfa4..5fa08b65c4e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -114,10 +114,10 @@ panic = 'abort'
 codegen-units = 1
 
 [patch.crates-io]
-equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
-zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
-zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
-zcash_history = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
-zcash_note_encryption = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
-zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
-zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "ec1c1bcf21c43b0736d298d3a437162253946a8a" }
+equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
+zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
+zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
+zcash_history = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
+zcash_note_encryption = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
+zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
+zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "8db7a071a0b79687d79ffc6894fe573944b16008" }
diff --git a/qa/supply-chain/config.toml b/qa/supply-chain/config.toml
index 832857961a4..84c52a0cbc3 100644
--- a/qa/supply-chain/config.toml
+++ b/qa/supply-chain/config.toml
@@ -111,10 +111,6 @@ criteria = "safe-to-deploy"
 version = "0.4.0"
 criteria = "safe-to-deploy"
 
-[[exemptions.byte-slice-cast]]
-version = "1.2.1"
-criteria = "safe-to-deploy"
-
 [[exemptions.byteorder]]
 version = "1.4.3"
 criteria = "safe-to-deploy"
@@ -204,7 +200,7 @@ version = "0.12.0"
 criteria = "safe-to-deploy"
 
 [[exemptions.fixed-hash]]
-version = "0.7.0"
+version = "0.8.0"
 criteria = "safe-to-deploy"
 
 [[exemptions.fpe]]
@@ -275,14 +271,6 @@ criteria = "safe-to-deploy"
 version = "0.14.25"
 criteria = "safe-to-deploy"
 
-[[exemptions.impl-codec]]
-version = "0.6.0"
-criteria = "safe-to-deploy"
-
-[[exemptions.impl-trait-for-tuples]]
-version = "0.2.2"
-criteria = "safe-to-deploy"
-
 [[exemptions.incrementalmerkletree]]
 version = "0.3.0"
 criteria = "safe-to-deploy"
@@ -407,14 +395,6 @@ criteria = "safe-to-deploy"
 version = "0.22.0"
 criteria = "safe-to-deploy"
 
-[[exemptions.parity-scale-codec]]
-version = "3.2.1"
-criteria = "safe-to-deploy"
-
-[[exemptions.parity-scale-codec-derive]]
-version = "3.1.3"
-criteria = "safe-to-deploy"
-
 [[exemptions.parking_lot]]
 version = "0.11.2"
 criteria = "safe-to-deploy"
@@ -468,11 +448,7 @@ version = "0.2.16"
 criteria = "safe-to-deploy"
 
 [[exemptions.primitive-types]]
-version = "0.11.1"
-criteria = "safe-to-deploy"
-
-[[exemptions.proc-macro-crate]]
-version = "1.2.1"
+version = "0.12.1"
 criteria = "safe-to-deploy"
 
 [[exemptions.quanta]]
@@ -535,10 +511,6 @@ criteria = "safe-to-deploy"
 version = "0.1.3"
 criteria = "safe-to-deploy"
 
-[[exemptions.rustc-hex]]
-version = "2.1.0"
-criteria = "safe-to-deploy"
-
 [[exemptions.ryu]]
 version = "1.0.11"
 criteria = "safe-to-deploy"
@@ -647,10 +619,6 @@ criteria = "safe-to-deploy"
 version = "1.27.0"
 criteria = "safe-to-deploy"
 
-[[exemptions.toml_edit]]
-version = "0.19.7"
-criteria = "safe-to-deploy"
-
 [[exemptions.tower-service]]
 version = "0.3.2"
 criteria = "safe-to-deploy"
@@ -731,10 +699,6 @@ criteria = "safe-to-deploy"
 version = "0.4.0"
 criteria = "safe-to-deploy"
 
-[[exemptions.winnow]]
-version = "0.4.1"
-criteria = "safe-to-deploy"
-
 [[exemptions.wyz]]
 version = "0.5.0"
 criteria = "safe-to-deploy"
diff --git a/src/bench/verification.cpp b/src/bench/verification.cpp
index 9c3decedbe5..d20e9d76821 100644
--- a/src/bench/verification.cpp
+++ b/src/bench/verification.cpp
@@ -99,24 +99,20 @@ static void JoinSplitSig(benchmark::State& state)
 
 static void SaplingSpend(benchmark::State& state)
 {
-    SpendDescription spend;
-    CDataStream ss(
-        ParseHex("8c6cf86bbb83bf0d075e5bd9bb4b5cd56141577be69f032880b11e26aa32aa5ef09fd00899e4b469fb11f38e9d09dc0379f0b11c23b5fe541765f76695120a03f0261d32af5d2a2b1e5c9a04200cd87d574dc42349de9790012ce560406a8a876a1e54cfcdc0eb74998abec2a9778330eeb2a0ac0e41d0c9ed5824fbd0dbf7da930ab299966ce333fd7bc1321dada0817aac5444e02c754069e218746bf879d5f2a20a8b028324fb2c73171e63336686aa5ec2e6e9a08eb18b87c14758c572f4531ccf6b55d09f44beb8b47563be4eff7a52598d80959dd9c9fee5ac4783d8370cb7d55d460053d3e067b5f9fe75ff2722623fb1825fcba5e9593d4205b38d1f502ff03035463043bd393a5ee039ce75a5d54f21b395255df6627ef96751566326f7d4a77d828aa21b1827282829fcbc42aad59cdb521e1a3aaa08b99ea8fe7fff0a04da31a52260fc6daeccd79bb877bdd8506614282258e15b3fe74bf71a93f4be3b770119edf99a317b205eea7d5ab800362b97384273888106c77d633600"),
-        SER_NETWORK,
-        PROTOCOL_VERSION);
-    ss >> spend;
+    auto spendBytes = ParseHex("8c6cf86bbb83bf0d075e5bd9bb4b5cd56141577be69f032880b11e26aa32aa5ef09fd00899e4b469fb11f38e9d09dc0379f0b11c23b5fe541765f76695120a03f0261d32af5d2a2b1e5c9a04200cd87d574dc42349de9790012ce560406a8a876a1e54cfcdc0eb74998abec2a9778330eeb2a0ac0e41d0c9ed5824fbd0dbf7da930ab299966ce333fd7bc1321dada0817aac5444e02c754069e218746bf879d5f2a20a8b028324fb2c73171e63336686aa5ec2e6e9a08eb18b87c14758c572f4531ccf6b55d09f44beb8b47563be4eff7a52598d80959dd9c9fee5ac4783d8370cb7d55d460053d3e067b5f9fe75ff2722623fb1825fcba5e9593d4205b38d1f502ff03035463043bd393a5ee039ce75a5d54f21b395255df6627ef96751566326f7d4a77d828aa21b1827282829fcbc42aad59cdb521e1a3aaa08b99ea8fe7fff0a04da31a52260fc6daeccd79bb877bdd8506614282258e15b3fe74bf71a93f4be3b770119edf99a317b205eea7d5ab800362b97384273888106c77d633600");
+    auto spend = sapling::parse_v4_spend({spendBytes.data(), spendBytes.size()});
     uint256 dataToBeSigned = uint256S("0x2dbf83fe7b88a7cbd80fac0c719483906bb9a0c4fc69071e4780d5f2c76e592c");
 
     auto ctx = sapling::init_verifier();
 
     while (state.KeepRunning()) {
         ctx->check_spend(
-            spend.cv().GetRawBytes(),
-            spend.anchor().GetRawBytes(),
-            spend.nullifier().GetRawBytes(),
-            spend.rk().GetRawBytes(),
-            spend.zkproof(),
-            spend.spend_auth_sig(),
+            spend->cv(),
+            spend->anchor(),
+            spend->nullifier(),
+            spend->rk(),
+            spend->zkproof(),
+            spend->spend_auth_sig(),
             dataToBeSigned.GetRawBytes()
         );
     }
@@ -124,21 +120,17 @@ static void SaplingSpend(benchmark::State& state)
 
 static void SaplingOutput(benchmark::State& state)
 {
-    OutputDescription output;
-    CDataStream ss(
-        ParseHex("edd742af18857e5ec2d71d346a7fe2ac97c137339bd5268eea86d32e0ff4f38f76213fa8cfed3347ac4e8572dd88aff395c0c10a59f8b3f49d2bc539ed6c726667e29d4763f914ddd0abf1cdfa84e44de87c233434c7e69b8b5b8f4623c8aa444163425bae5cef842972fed66046c1c6ce65c866ad894d02e6e6dcaae7a962d9f2ef95757a09c486928e61f0f7aed90ad0a542b0d3dc5fe140dfa7626b9315c77e03b055f19cbacd21a866e46f06c00e0c7792b2a590a611439b510a9aaffcf1073bad23e712a9268b36888e3727033eee2ab4d869f54a843f93b36ef489fb177bf74b41a9644e5d2a0a417c6ac1c8869bc9b83273d453f878ed6fd96b82a5939903f7b64ecaf68ea16e255a7fb7cc0b6d8b5608a1c6b0ed3024cc62c2f0f9c5cfc7b431ae6e9d40815557aa1d010523f9e1960de77b2274cb6710d229d475c87ae900183206ba90cb5bbc8ec0df98341b82726c705e0308ca5dc08db4db609993a1046dfb43dfd8c760be506c0bed799bb2205fc29dc2e654dce731034a23b0aaf6da0199248702ee0523c159f41f4cbfff6c35ace4dd9ae834e44e09c76a0cbdda1d3f6a2c75ad71212daf9575ab5f09ca148718e667f29ddf18c8a330a86ace18a86e89454653902aa393c84c6b694f27d0d42e24e7ac9fe34733de5ec15f5066081ce912c62c1a804a2bb4dedcef7cc80274f6bb9e89e2fce91dc50d6a73c8aefb9872f1cf3524a92626a0b8f39bbf7bf7d96ca2f770fc04d7f457021c536a506a187a93b2245471ddbfb254a71bc4a0d72c8d639a31c7b1920087ffca05c24214157e2e7b28184e91989ef0b14f9b34c3dc3cc0ac64226b9e337095870cb0885737992e120346e630a416a9b217679ce5a778fb15779c136bcecca5efe79012013d77d90b4e99dd22c8f35bc77121716e160d05bd30d288ee8886390ee436f85bdc9029df888a3a3326d9d4ddba5cb5318b3274928829d662e96fea1d601f7a306251ed8c6cc4e5a3a7a98c35a3650482a0eee08f3b4c2da9b22947c96138f1505c2f081f8972d429f3871f32bef4aaa51aa6945df8e9c9760531ac6f627d17c1518202818a91ca304fb4037875c666060597976144fcbbc48a776a2c61beb9515fa8f3ae6d3a041d320a38a8ac75cb47bb9c866ee497fc3cd13299970c4b369c1c2ceb4220af082fbecdd8114492a8e4d713b5a73396fd224b36c1185bd5e20d683e6c8db35346c47ae7401988255da7cfffdced5801067d4d296688ee8fe424b4a8a69309ce257eefb9345ebfda3f6de46bb11ec94133e1f72cd7ac54934d6cf17b3440800e70b80ebc7c7bfc6fb0fc2c"),
-        SER_NETWORK,
-        PROTOCOL_VERSION);
-    ss >> output;
+    auto outputBytes = ParseHex("edd742af18857e5ec2d71d346a7fe2ac97c137339bd5268eea86d32e0ff4f38f76213fa8cfed3347ac4e8572dd88aff395c0c10a59f8b3f49d2bc539ed6c726667e29d4763f914ddd0abf1cdfa84e44de87c233434c7e69b8b5b8f4623c8aa444163425bae5cef842972fed66046c1c6ce65c866ad894d02e6e6dcaae7a962d9f2ef95757a09c486928e61f0f7aed90ad0a542b0d3dc5fe140dfa7626b9315c77e03b055f19cbacd21a866e46f06c00e0c7792b2a590a611439b510a9aaffcf1073bad23e712a9268b36888e3727033eee2ab4d869f54a843f93b36ef489fb177bf74b41a9644e5d2a0a417c6ac1c8869bc9b83273d453f878ed6fd96b82a5939903f7b64ecaf68ea16e255a7fb7cc0b6d8b5608a1c6b0ed3024cc62c2f0f9c5cfc7b431ae6e9d40815557aa1d010523f9e1960de77b2274cb6710d229d475c87ae900183206ba90cb5bbc8ec0df98341b82726c705e0308ca5dc08db4db609993a1046dfb43dfd8c760be506c0bed799bb2205fc29dc2e654dce731034a23b0aaf6da0199248702ee0523c159f41f4cbfff6c35ace4dd9ae834e44e09c76a0cbdda1d3f6a2c75ad71212daf9575ab5f09ca148718e667f29ddf18c8a330a86ace18a86e89454653902aa393c84c6b694f27d0d42e24e7ac9fe34733de5ec15f5066081ce912c62c1a804a2bb4dedcef7cc80274f6bb9e89e2fce91dc50d6a73c8aefb9872f1cf3524a92626a0b8f39bbf7bf7d96ca2f770fc04d7f457021c536a506a187a93b2245471ddbfb254a71bc4a0d72c8d639a31c7b1920087ffca05c24214157e2e7b28184e91989ef0b14f9b34c3dc3cc0ac64226b9e337095870cb0885737992e120346e630a416a9b217679ce5a778fb15779c136bcecca5efe79012013d77d90b4e99dd22c8f35bc77121716e160d05bd30d288ee8886390ee436f85bdc9029df888a3a3326d9d4ddba5cb5318b3274928829d662e96fea1d601f7a306251ed8c6cc4e5a3a7a98c35a3650482a0eee08f3b4c2da9b22947c96138f1505c2f081f8972d429f3871f32bef4aaa51aa6945df8e9c9760531ac6f627d17c1518202818a91ca304fb4037875c666060597976144fcbbc48a776a2c61beb9515fa8f3ae6d3a041d320a38a8ac75cb47bb9c866ee497fc3cd13299970c4b369c1c2ceb4220af082fbecdd8114492a8e4d713b5a73396fd224b36c1185bd5e20d683e6c8db35346c47ae7401988255da7cfffdced5801067d4d296688ee8fe424b4a8a69309ce257eefb9345ebfda3f6de46bb11ec94133e1f72cd7ac54934d6cf17b3440800e70b80ebc7c7bfc6fb0fc2c");
+    auto output = sapling::parse_v4_output({outputBytes.data(), outputBytes.size()});
 
     auto ctx = sapling::init_verifier();
 
     while (state.KeepRunning()) {
         ctx->check_output(
-            output.cv().GetRawBytes(),
-            output.cmu().GetRawBytes(),
-            output.ephemeral_key().GetRawBytes(),
-            output.zkproof()
+            output->cv(),
+            output->cmu(),
+            output->ephemeral_key(),
+            output->zkproof()
         );
     }
 }
diff --git a/src/coins.cpp b/src/coins.cpp
index 67ed7cbd1b8..9597f44fc11 100644
--- a/src/coins.cpp
+++ b/src/coins.cpp
@@ -679,7 +679,7 @@ void CCoinsViewCache::SetNullifiers(const CTransaction& tx, bool spent) {
         }
     }
     for (const auto& spendDescription : tx.GetSaplingSpends()) {
-        std::pair<CNullifiersMap::iterator, bool> ret = cacheSaplingNullifiers.insert(std::make_pair(spendDescription.nullifier(), CNullifiersCacheEntry()));
+        std::pair<CNullifiersMap::iterator, bool> ret = cacheSaplingNullifiers.insert(std::make_pair(uint256::FromRawBytes(spendDescription.nullifier()), CNullifiersCacheEntry()));
         ret.first->second.entered = spent;
         ret.first->second.flags |= CNullifiersCacheEntry::DIRTY;
     }
@@ -1021,7 +1021,7 @@ tl::expected<void, UnsatisfiedShieldedReq> CCoinsViewCache::CheckShieldedRequire
     }
 
     for (const auto& spendDescription : tx.GetSaplingSpends()) {
-        uint256 nullifier = spendDescription.nullifier();
+        uint256 nullifier = uint256::FromRawBytes(spendDescription.nullifier());
         if (GetNullifier(nullifier, SAPLING)) { // Prevent double spends
             auto txid = tx.GetHash().ToString();
             auto nf = nullifier.ToString();
@@ -1032,7 +1032,7 @@ tl::expected<void, UnsatisfiedShieldedReq> CCoinsViewCache::CheckShieldedRequire
         }
 
         SaplingMerkleTree tree;
-        uint256 rt = spendDescription.anchor();
+        uint256 rt = uint256::FromRawBytes(spendDescription.anchor());
         if (!GetSaplingAnchorAt(rt, tree)) {
             auto txid = tx.GetHash().ToString();
             auto anchor = rt.ToString();
diff --git a/src/core_memusage.h b/src/core_memusage.h
index f4bd1ca3b1f..7379d1ee840 100644
--- a/src/core_memusage.h
+++ b/src/core_memusage.h
@@ -35,8 +35,7 @@ static inline size_t RecursiveDynamicUsage(const CTransaction& tx) {
         mem += RecursiveDynamicUsage(*it);
     }
     mem += memusage::DynamicUsage(tx.vJoinSplit);
-    mem += memusage::DynamicUsage(tx.vShieldedSpend);
-    mem += memusage::DynamicUsage(tx.vShieldedOutput);
+    mem += tx.GetSaplingBundle().RecursiveDynamicUsage();
     mem += tx.GetOrchardBundle().RecursiveDynamicUsage();
     return mem;
 }
@@ -50,8 +49,7 @@ static inline size_t RecursiveDynamicUsage(const CMutableTransaction& tx) {
         mem += RecursiveDynamicUsage(*it);
     }
     mem += memusage::DynamicUsage(tx.vJoinSplit);
-    mem += memusage::DynamicUsage(tx.vShieldedSpend);
-    mem += memusage::DynamicUsage(tx.vShieldedOutput);
+    mem += tx.saplingBundle.RecursiveDynamicUsage();
     mem += tx.orchardBundle.RecursiveDynamicUsage();
     return mem;
 }
diff --git a/src/hash.h b/src/hash.h
index 73865fd72cb..106a97620c1 100644
--- a/src/hash.h
+++ b/src/hash.h
@@ -175,6 +175,11 @@ class CBLAKE2bWriter
     int GetType() const { return nType; }
     int GetVersion() const { return nVersion; }
 
+    void write_u8(const unsigned char* pch, size_t size)
+    {
+        state->update({pch, size});
+    }
+
     CBLAKE2bWriter& write(const char *pch, size_t size) {
         state->update({(const unsigned char*)pch, size});
         return (*this);
diff --git a/src/main.cpp b/src/main.cpp
index bdf2397caee..0e8ae580571 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -996,9 +996,9 @@ bool ContextualCheckTransaction(
                         nHeight,
                         ovk.GetRawBytes(),
                         {
-                            output.cv().GetRawBytes(),
-                            output.cmu().GetRawBytes(),
-                            output.ephemeral_key().GetRawBytes(),
+                            output.cv(),
+                            output.cmu(),
+                            output.ephemeral_key(),
                             output.enc_ciphertext(),
                             output.out_ciphertext(),
                         });
@@ -1015,7 +1015,11 @@ bool ContextualCheckTransaction(
                 }
               } else {
                 auto outPlaintext = SaplingOutgoingPlaintext::decrypt(
-                    output.out_ciphertext(), ovk, output.cv(), output.cmu(), output.ephemeral_key());
+                    output.out_ciphertext(),
+                    ovk,
+                    uint256::FromRawBytes(output.cv()),
+                    uint256::FromRawBytes(output.cmu()),
+                    uint256::FromRawBytes(output.ephemeral_key()));
                 if (!outPlaintext) {
                     return state.DoS(
                         DOS_LEVEL_BLOCK,
@@ -1028,10 +1032,10 @@ bool ContextualCheckTransaction(
                     consensus,
                     nHeight,
                     output.enc_ciphertext(),
-                    output.ephemeral_key(),
+                    uint256::FromRawBytes(output.ephemeral_key()),
                     outPlaintext->esk,
                     outPlaintext->pk_d,
-                    output.cmu());
+                    uint256::FromRawBytes(output.cmu()));
 
                 if (!encPlaintext) {
                     return state.DoS(
@@ -1316,8 +1320,7 @@ bool ContextualCheckShieldedInputs(
 
     // Create signature hashes for shielded components.
     if (!tx.vJoinSplit.empty() ||
-        tx.GetSaplingSpendsCount() > 0 ||
-        tx.GetSaplingOutputsCount() > 0 ||
+        tx.GetSaplingBundle().IsPresent() ||
         tx.GetOrchardBundle().IsPresent())
     {
         // Empty output script.
@@ -1357,57 +1360,13 @@ bool ContextualCheckShieldedInputs(
         }
     }
 
-    if (tx.GetSaplingSpendsCount() > 0 ||
-        tx.GetSaplingOutputsCount() > 0)
-    {
-        auto assembler = sapling::new_bundle_assembler();
-
-        for (const auto& spend : tx.GetSaplingSpends()) {
-            if (!assembler->add_spend(
-                spend.cv().GetRawBytes(),
-                spend.anchor().GetRawBytes(),
-                spend.nullifier().GetRawBytes(),
-                spend.rk().GetRawBytes(),
-                spend.zkproof(),
-                spend.spend_auth_sig()
-            )) {
-                return state.DoS(
-                    dosLevelPotentiallyRelaxing,
-                    error("ContextualCheckShieldedInputs(): Sapling spend description invalid"),
-                    REJECT_INVALID, "bad-txns-sapling-spend-description-invalid");
-            }
-        }
-
-        for (const auto& output : tx.GetSaplingOutputs()) {
-            if (!assembler->add_output(
-                output.cv().GetRawBytes(),
-                output.cmu().GetRawBytes(),
-                output.ephemeral_key().GetRawBytes(),
-                output.enc_ciphertext(),
-                output.out_ciphertext(),
-                output.zkproof()
-            )) {
-                // This should be a non-contextual check, but we check it here
-                // as we need to pass over the outputs anyway in order to then
-                // call ctx->final_check().
-                return state.DoS(100, error("ContextualCheckShieldedInputs(): Sapling output description invalid"),
-                                      REJECT_INVALID, "bad-txns-sapling-output-description-invalid");
-            }
-        }
-
-        auto bundle = sapling::finish_bundle_assembly(
-            std::move(assembler),
-            tx.GetValueBalanceSapling(),
-            tx.bindingSig);
-
-        // Queue Sapling bundle to be batch-validated. This also checks some consensus rules.
-        if (saplingAuth.has_value()) {
-            if (!saplingAuth.value()->check_bundle(std::move(bundle), dataToBeSigned.GetRawBytes())) {
-                return state.DoS(
-                    dosLevelPotentiallyRelaxing,
-                    error("ContextualCheckShieldedInputs(): Sapling bundle invalid"),
-                    REJECT_INVALID, "bad-txns-sapling-bundle-invalid");
-            }
+    // Queue Sapling bundle to be batch-validated. This also checks some consensus rules.
+    if (saplingAuth.has_value()) {
+        if (!tx.GetSaplingBundle().QueueAuthValidation(*saplingAuth.value(), dataToBeSigned)) {
+            return state.DoS(
+                dosLevelPotentiallyRelaxing,
+                error("ContextualCheckShieldedInputs(): Sapling bundle invalid"),
+                REJECT_INVALID, "bad-txns-sapling-bundle-invalid");
         }
     }
 
@@ -1722,7 +1681,7 @@ bool CheckTransactionWithoutProofVerification(const CTransaction& tx, CValidatio
 
     // Check for duplicate sapling nullifiers in this transaction
     {
-        set<uint256> vSaplingNullifiers;
+        std::set<libzcash::nullifier_t> vSaplingNullifiers;
         for (const auto& spend_desc : tx.GetSaplingSpends())
         {
             if (vSaplingNullifiers.count(spend_desc.nullifier()))
@@ -3428,7 +3387,7 @@ bool ConnectBlock(const CBlock& block, CValidationState& state, CBlockIndex* pin
         }
 
         for (const auto &outputDescription : tx.GetSaplingOutputs()) {
-            sapling_tree.append(outputDescription.cmu());
+            sapling_tree.append(uint256::FromRawBytes(outputDescription.cmu()));
         }
 
         if (!orchard_tree.AppendBundle(tx.GetOrchardBundle())) {
@@ -3441,7 +3400,7 @@ bool ConnectBlock(const CBlock& block, CValidationState& state, CBlockIndex* pin
             transparentValueDelta += out.nValue;
         }
 
-        if (!(tx.GetSaplingSpendsCount() == 0 && tx.GetSaplingOutputsCount() == 0)) {
+        if (tx.GetSaplingBundle().IsPresent()) {
             total_sapling_tx += 1;
         }
 
@@ -7194,8 +7153,7 @@ bool static ProcessMessage(const CChainParams& chainparams, CNode* pfrom, string
         // TODO: currently, prohibit joinsplits and shielded spends/outputs/actions from entering mapOrphans
         else if (fMissingInputs &&
                  tx.vJoinSplit.empty() &&
-                 tx.GetSaplingSpendsCount() == 0 &&
-                 tx.GetSaplingOutputsCount() == 0 &&
+                 !tx.GetSaplingBundle().IsPresent() &&
                  !tx.GetOrchardBundle().IsPresent())
         {
             AddOrphanTx(tx, pfrom->GetId());
diff --git a/src/miner.cpp b/src/miner.cpp
index 1090f4de8c8..5c56d5d5240 100644
--- a/src/miner.cpp
+++ b/src/miner.cpp
@@ -93,34 +93,26 @@ class AddFundingStreamValueToTx
 {
 private:
     CMutableTransaction &mtx;
-    rust::Box<sapling::Prover>& ctx;
+    sapling::Builder& saplingBuilder;
     const CAmount fundingStreamValue;
     const libzcash::Zip212Enabled zip212Enabled;
 public:
     AddFundingStreamValueToTx(
             CMutableTransaction &mtx,
-            rust::Box<sapling::Prover>& ctx,
+            sapling::Builder& saplingBuilder,
             const CAmount fundingStreamValue,
-            const libzcash::Zip212Enabled zip212Enabled): mtx(mtx), ctx(ctx), fundingStreamValue(fundingStreamValue), zip212Enabled(zip212Enabled) {}
-
-    bool operator()(const libzcash::SaplingPaymentAddress& pa) const {
-        uint256 ovk;
-        auto note = libzcash::SaplingNote(pa, fundingStreamValue, zip212Enabled);
-        auto output = OutputDescriptionInfo(ovk, note, std::nullopt);
-
-        auto odesc = output.Build(ctx);
-        if (odesc) {
-            mtx.vShieldedOutput.push_back(odesc.value());
-            mtx.valueBalanceSapling -= fundingStreamValue;
-            return true;
-        } else {
-            return false;
-        }
+            const libzcash::Zip212Enabled zip212Enabled): mtx(mtx), saplingBuilder(saplingBuilder), fundingStreamValue(fundingStreamValue), zip212Enabled(zip212Enabled) {}
+
+    void operator()(const libzcash::SaplingPaymentAddress& pa) const {
+        saplingBuilder.add_recipient(
+            {},
+            pa.GetRawBytes(),
+            fundingStreamValue,
+            libzcash::Memo::ToBytes(std::nullopt));
     }
 
-    bool operator()(const CScript& scriptPubKey) const {
+    void operator()(const CScript& scriptPubKey) const {
         mtx.vout.push_back(CTxOut(fundingStreamValue, scriptPubKey));
-        return true;
     }
 };
 
@@ -148,7 +140,7 @@ class AddOutputsToCoinbaseTxAndSign
         }
     }
 
-    CAmount SetFoundersRewardAndGetMinerValue(rust::Box<sapling::Prover>& ctx) const {
+    CAmount SetFoundersRewardAndGetMinerValue(sapling::Builder& saplingBuilder) const {
         auto block_subsidy = GetBlockSubsidy(nHeight, chainparams.GetConsensus());
         auto miner_reward = block_subsidy; // founders' reward or funding stream amounts will be subtracted below
 
@@ -161,10 +153,7 @@ class AddOutputsToCoinbaseTxAndSign
 
                 for (Consensus::FundingStreamElement fselem : fundingStreamElements) {
                     miner_reward -= fselem.second;
-                    bool added = std::visit(AddFundingStreamValueToTx(mtx, ctx, fselem.second, GetZip212Flag()), fselem.first);
-                    if (!added) {
-                        throw new std::runtime_error("Failed to add funding stream output.");
-                    }
+                    std::visit(AddFundingStreamValueToTx(mtx, saplingBuilder, fselem.second, GetZip212Flag()), fselem.first);
                 }
             } else if (nHeight <= chainparams.GetConsensus().GetLastFoundersRewardBlockHeight(nHeight)) {
                 // Founders reward is 20% of the block subsidy
@@ -182,19 +171,27 @@ class AddOutputsToCoinbaseTxAndSign
         return miner_reward + nFees;
     }
 
-    void ComputeBindingSig(rust::Box<sapling::Prover> saplingCtx, std::optional<orchard::UnauthorizedBundle> orchardBundle) const {
+    void ComputeBindingSig(rust::Box<sapling::Builder> saplingBuilder, std::optional<orchard::UnauthorizedBundle> orchardBundle) const {
+        auto consensusBranchId = CurrentEpochBranchId(nHeight, chainparams.GetConsensus());
+        auto saplingBundle = sapling::build_bundle(std::move(saplingBuilder), nHeight);
+
         // Empty output script.
         uint256 dataToBeSigned;
         try {
-            if (orchardBundle.has_value()) {
-                // Orchard is only usable with v5+ transactions.
-                dataToBeSigned = ProduceZip244SignatureHash(mtx, {}, orchardBundle.value());
+            if (mtx.fOverwintered) {
+                // ProduceShieldedSignatureHash is only usable with v3+ transactions.
+                dataToBeSigned = ProduceShieldedSignatureHash(
+                    consensusBranchId,
+                    mtx,
+                    {},
+                    *saplingBundle,
+                    orchardBundle);
             } else {
                 CScript scriptCode;
                 PrecomputedTransactionData txdata(mtx, {});
                 dataToBeSigned = SignatureHash(
                     scriptCode, mtx, NOT_AN_INPUT, SIGHASH_ALL, 0,
-                    CurrentEpochBranchId(nHeight, chainparams.GetConsensus()),
+                    consensusBranchId,
                     txdata);
             }
         } catch (std::logic_error ex) {
@@ -210,19 +207,13 @@ class AddOutputsToCoinbaseTxAndSign
             }
         }
 
-        bool success = saplingCtx->binding_sig(
-            mtx.valueBalanceSapling,
-            dataToBeSigned.GetRawBytes(),
-            mtx.bindingSig);
-
-        if (!success) {
-            throw new std::runtime_error("An error occurred computing the binding signature.");
-        }
+        mtx.saplingBundle = sapling::apply_bundle_signatures(
+            std::move(saplingBundle), dataToBeSigned.GetRawBytes());
     }
 
     // Create Orchard output
     void operator()(const libzcash::OrchardRawAddress &to) const {
-        auto ctx = sapling::init_prover();
+        auto saplingBuilder = sapling::new_builder(*chainparams.RustNetwork(), nHeight);
 
         // `enableSpends` must be set to `false` for coinbase transactions. This
         // means the Orchard anchor is unconstrained, so we set it to the empty
@@ -232,7 +223,7 @@ class AddOutputsToCoinbaseTxAndSign
 
         // Shielded coinbase outputs must be recoverable with an all-zeroes ovk.
         uint256 ovk;
-        auto miner_reward = SetFoundersRewardAndGetMinerValue(ctx);
+        auto miner_reward = SetFoundersRewardAndGetMinerValue(*saplingBuilder);
         builder.AddOutput(ovk, to, miner_reward, std::nullopt);
 
         // orchard::Builder pads to two Actions, but does so using a "no OVK" policy for
@@ -253,46 +244,38 @@ class AddOutputsToCoinbaseTxAndSign
             throw new std::runtime_error("Failed to create shielded output for miner");
         }
 
-        ComputeBindingSig(std::move(ctx), std::move(bundle));
+        ComputeBindingSig(std::move(saplingBuilder), std::move(bundle));
     }
 
-    // Create shielded output
+    // Create Sapling output
     void operator()(const libzcash::SaplingPaymentAddress &pa) const {
-        auto ctx = sapling::init_prover();
-
-        auto miner_reward = SetFoundersRewardAndGetMinerValue(ctx);
-        mtx.valueBalanceSapling -= miner_reward;
-
-        uint256 ovk;
+        auto saplingBuilder = sapling::new_builder(*chainparams.RustNetwork(), nHeight);
 
-        auto note = libzcash::SaplingNote(pa, miner_reward, GetZip212Flag());
-        auto output = OutputDescriptionInfo(ovk, note, std::nullopt);
+        auto miner_reward = SetFoundersRewardAndGetMinerValue(*saplingBuilder);
 
-        auto odesc = output.Build(ctx);
-        if (!odesc) {
-            throw new std::runtime_error("Failed to create shielded output for miner");
-        }
-        mtx.vShieldedOutput.push_back(odesc.value());
+        saplingBuilder->add_recipient(
+            {},
+            pa.GetRawBytes(),
+            miner_reward,
+            libzcash::Memo::ToBytes(std::nullopt));
 
-        ComputeBindingSig(std::move(ctx), std::nullopt);
+        ComputeBindingSig(std::move(saplingBuilder), std::nullopt);
     }
 
     // Create transparent output
     void operator()(const boost::shared_ptr<CReserveScript> &coinbaseScript) const {
         // Add the FR output and fetch the miner's output value.
-        auto ctx = sapling::init_prover();
+        auto saplingBuilder = sapling::new_builder(*chainparams.RustNetwork(), nHeight);
 
         // Miner output will be vout[0]; Founders' Reward & funding stream outputs
         // will follow.
         mtx.vout.resize(1);
-        auto value = SetFoundersRewardAndGetMinerValue(ctx);
+        auto value = SetFoundersRewardAndGetMinerValue(*saplingBuilder);
 
         // Now fill in the miner's output.
         mtx.vout[0] = CTxOut(value, coinbaseScript->reserveScript);
 
-        if (mtx.vShieldedOutput.size() > 0) {
-            ComputeBindingSig(std::move(ctx), std::nullopt);
-        }
+        ComputeBindingSig(std::move(saplingBuilder), std::nullopt);
     }
 };
 
@@ -442,7 +425,7 @@ CBlockTemplate* BlockAssembler::CreateNewBlock(
     // Update the Sapling commitment tree.
     for (const CTransaction& tx : pblock->vtx) {
         for (const auto& odesc : tx.GetSaplingOutputs()) {
-            sapling_tree.append(odesc.cmu());
+            sapling_tree.append(uint256::FromRawBytes(odesc.cmu()));
         }
     }
 
diff --git a/src/primitives/sapling.h b/src/primitives/sapling.h
new file mode 100644
index 00000000000..343c6d3fc7e
--- /dev/null
+++ b/src/primitives/sapling.h
@@ -0,0 +1,185 @@
+// Copyright (c) 2023 The Zcash developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or https://www.opensource.org/licenses/mit-license.php .
+
+#ifndef ZCASH_PRIMITIVES_SAPLING_H
+#define ZCASH_PRIMITIVES_SAPLING_H
+
+#include "amount.h"
+#include "streams.h"
+#include "streams_rust.h"
+
+#include <rust/bridge.h>
+
+/**
+ * The Sapling component of an authorized transaction.
+ */
+class SaplingBundle
+{
+private:
+    /// An optional Sapling bundle.
+    /// Memory is allocated by Rust.
+    rust::Box<sapling::Bundle> inner;
+
+    friend class SaplingV4Reader;
+public:
+    SaplingBundle() : inner(sapling::none_bundle()) {}
+
+    SaplingBundle(rust::Box<sapling::Bundle>&& bundle) : inner(std::move(bundle)) {}
+
+    SaplingBundle(SaplingBundle&& bundle) : inner(std::move(bundle.inner)) {}
+
+    SaplingBundle(const SaplingBundle& bundle) :
+        inner(bundle.inner->box_clone()) {}
+
+    SaplingBundle& operator=(SaplingBundle&& bundle)
+    {
+        if (this != &bundle) {
+            inner = std::move(bundle.inner);
+        }
+        return *this;
+    }
+
+    SaplingBundle& operator=(const SaplingBundle& bundle)
+    {
+        if (this != &bundle) {
+            inner = bundle.inner->box_clone();
+        }
+        return *this;
+    }
+
+    const sapling::Bundle& GetDetails() const {
+        return *inner;
+    }
+
+    /// @brief Only for tests.
+    /// @return A mutable reference to the Sapling bundle.
+    sapling::Bundle& GetDetailsMut() {
+        return *inner;
+    }
+
+    size_t RecursiveDynamicUsage() const {
+        return inner->recursive_dynamic_usage();
+    }
+
+    template<typename Stream>
+    void Serialize(Stream& s) const {
+        try {
+            inner->serialize_v5(*ToRustStream(s));
+        } catch (const std::exception& e) {
+            throw std::ios_base::failure(e.what());
+        }
+    }
+
+    template<typename Stream>
+    void Unserialize(Stream& s) {
+        try {
+            inner = sapling::parse_v5_bundle(*ToRustStream(s));
+        } catch (const std::exception& e) {
+            throw std::ios_base::failure(e.what());
+        }
+    }
+
+    /// Returns true if this contains a Sapling bundle, or false if there is no
+    /// Sapling component.
+    bool IsPresent() const { return inner->is_present(); }
+
+    /// Returns the net value entering or exiting the Sapling pool as a result of this
+    /// bundle.
+    CAmount GetValueBalance() const {
+        return inner->value_balance_zat();
+    }
+
+    /// Checks the bundle against Sapling-specific consensus rules, and queues its
+    /// authorization for validation.
+    ///
+    /// Returns `false` if the bundle doesn't satisfy the checked consensus rules. This
+    /// `BatchValidator` can continue to be used regardless, but some or all of the proofs
+    /// and signatures from this bundle may have already been added to the batch even if
+    /// it fails other consensus rules.
+    ///
+    /// `sighash` must be for the transaction this bundle is within.
+    ///
+    /// If this batch was configured to not cache the results, then if the bundle was in
+    /// the global bundle validity cache, it will have been removed (and this method will
+    /// return `true`).
+    bool QueueAuthValidation(
+        sapling::BatchValidator& batch, const uint256& sighash) const
+    {
+        return batch.check_bundle(inner->box_clone(), sighash.GetRawBytes());
+    }
+
+    const size_t GetSpendsCount() const {
+        return inner->num_spends();
+    }
+
+    const size_t GetOutputsCount() const {
+        return inner->num_outputs();
+    }
+};
+
+/**
+ * Reader for the Sapling components of a v4 transaction, excluding binding signature.
+ */
+class SaplingV4Reader
+{
+private:
+    rust::Box<sapling::BundleAssembler> inner;
+    bool hasSapling;
+public:
+    SaplingV4Reader(bool hasSapling) :
+        inner(sapling::new_bundle_assembler()), hasSapling(hasSapling) {}
+
+    template<typename Stream>
+    void Serialize(Stream& s) const {
+        throw std::ios_base::failure("Can't write Sapling bundles with SaplingV4Reader");
+    }
+
+    template<typename Stream>
+    void Unserialize(Stream& s) {
+        try {
+            inner = sapling::parse_v4_components(*ToRustStream(s), hasSapling);
+        } catch (const std::exception& e) {
+            throw std::ios_base::failure(e.what());
+        }
+    }
+
+    bool HaveActions() const { return inner->have_actions(); }
+
+    SaplingBundle FinishBundleAssembly(
+        std::array<unsigned char, 64> bindingSig)
+    {
+        SaplingBundle bundle;
+        bundle.inner = sapling::finish_bundle_assembly(std::move(inner), bindingSig);
+        return bundle;
+    }
+};
+
+/**
+ * Writer for the Sapling components of a v4 transaction, excluding binding signature.
+ */
+class SaplingV4Writer
+{
+private:
+    const SaplingBundle& bundle;
+    bool hasSapling;
+public:
+    SaplingV4Writer(const SaplingBundle& bundle, bool hasSapling) :
+        bundle(bundle), hasSapling(hasSapling) {}
+
+    template<typename Stream>
+    void Serialize(Stream& s) const {
+        try {
+            bundle.GetDetails().serialize_v4_components(*ToRustStream(s), hasSapling);
+        } catch (const std::exception& e) {
+            throw std::ios_base::failure(e.what());
+        }
+    }
+
+    template<typename Stream>
+    void Unserialize(Stream& s) {
+        throw std::ios_base::failure("Can't read Sapling bundles with SaplingV4Writer");
+    }
+};
+
+#endif // ZCASH_PRIMITIVES_SAPLING_H
diff --git a/src/primitives/transaction.cpp b/src/primitives/transaction.cpp
index b4d8148f0a0..bb2bcaceb69 100644
--- a/src/primitives/transaction.cpp
+++ b/src/primitives/transaction.cpp
@@ -14,66 +14,6 @@
 
 #include <rust/transaction.h>
 
-SaplingBundle::SaplingBundle(
-    const std::vector<SpendDescription>& vShieldedSpend,
-    const std::vector<OutputDescription>& vShieldedOutput,
-    const CAmount& valueBalanceSapling,
-    const binding_sig_t& bindingSig)
-        : valueBalanceSapling(valueBalanceSapling), bindingSigSapling(bindingSig)
-{
-    for (auto &spend : vShieldedSpend) {
-        vSpendsSapling.emplace_back(spend.cv(), spend.nullifier(), spend.rk());
-        if (anchorSapling.IsNull()) {
-            anchorSapling = spend.anchor();
-        } else {
-            assert(anchorSapling == spend.anchor());
-        }
-        vSpendProofsSapling.push_back(spend.zkproof());
-        vSpendAuthSigSapling.push_back(spend.spend_auth_sig());
-    }
-    for (auto &output : vShieldedOutput) {
-        vOutputsSapling.emplace_back(
-            output.cv(),
-            output.cmu(),
-            output.ephemeral_key(),
-            output.enc_ciphertext(),
-            output.out_ciphertext());
-        vOutputProofsSapling.push_back(output.zkproof());
-    }
-}
-
-std::vector<SpendDescription> SaplingBundle::GetV4ShieldedSpend()
-{
-    std::vector<SpendDescription> vShieldedSpend;
-    for (int i = 0; i < vSpendsSapling.size(); i++) {
-        auto spend = vSpendsSapling[i];
-        vShieldedSpend.emplace_back(
-            spend.cv(),
-            anchorSapling,
-            spend.nullifier(),
-            spend.rk(),
-            vSpendProofsSapling[i],
-            vSpendAuthSigSapling[i]);
-    }
-    return vShieldedSpend;
-}
-
-std::vector<OutputDescription> SaplingBundle::GetV4ShieldedOutput()
-{
-    std::vector<OutputDescription> vShieldedOutput;
-    for (int i = 0; i < vOutputsSapling.size(); i++) {
-        auto output = vOutputsSapling[i];
-        vShieldedOutput.emplace_back(
-            output.cv(),
-            output.cmu(),
-            output.ephemeral_key(),
-            output.enc_ciphertext(),
-            output.out_ciphertext(),
-            vOutputProofsSapling[i]);
-    }
-    return vShieldedOutput;
-}
-
 std::string COutPoint::ToString() const
 {
     return strprintf("COutPoint(%s, %u)", hash.ToString().substr(0,10), n);
@@ -151,14 +91,13 @@ std::string CTxOut::ToString() const
 }
 
 
-CMutableTransaction::CMutableTransaction() : nVersion(CTransaction::SPROUT_MIN_CURRENT_VERSION), fOverwintered(false), nVersionGroupId(0), nExpiryHeight(0), nLockTime(0), valueBalanceSapling(0) {}
+CMutableTransaction::CMutableTransaction() : nVersion(CTransaction::SPROUT_MIN_CURRENT_VERSION), fOverwintered(false), nVersionGroupId(0), nExpiryHeight(0), nLockTime(0) {}
 CMutableTransaction::CMutableTransaction(const CTransaction& tx) : nVersion(tx.nVersion), fOverwintered(tx.fOverwintered), nVersionGroupId(tx.nVersionGroupId), nExpiryHeight(tx.nExpiryHeight),
                                                                    nConsensusBranchId(tx.GetConsensusBranchId()),
                                                                    vin(tx.vin), vout(tx.vout), nLockTime(tx.nLockTime),
-                                                                   valueBalanceSapling(tx.GetValueBalanceSapling()), vShieldedSpend(tx.vShieldedSpend), vShieldedOutput(tx.vShieldedOutput),
+                                                                   saplingBundle(tx.GetSaplingBundle()),
                                                                    orchardBundle(tx.GetOrchardBundle()),
-                                                                   vJoinSplit(tx.vJoinSplit), joinSplitPubKey(tx.joinSplitPubKey), joinSplitSig(tx.joinSplitSig),
-                                                                   bindingSig(tx.bindingSig)
+                                                                   vJoinSplit(tx.vJoinSplit), joinSplitPubKey(tx.joinSplitPubKey), joinSplitSig(tx.joinSplitSig)
 {
 }
 
@@ -212,18 +151,16 @@ CTransaction::CTransaction() : nVersion(CTransaction::SPROUT_MIN_CURRENT_VERSION
                                fOverwintered(false), nVersionGroupId(0), nExpiryHeight(0),
                                nConsensusBranchId(std::nullopt),
                                vin(), vout(), nLockTime(0),
-                               valueBalanceSapling(0), vShieldedSpend(), vShieldedOutput(),
+                               saplingBundle(),
                                orchardBundle(),
-                               vJoinSplit(), joinSplitPubKey(), joinSplitSig(),
-                               bindingSig() { }
+                               vJoinSplit(), joinSplitPubKey(), joinSplitSig() { }
 
 CTransaction::CTransaction(const CMutableTransaction &tx) : nVersion(tx.nVersion), fOverwintered(tx.fOverwintered), nVersionGroupId(tx.nVersionGroupId), nExpiryHeight(tx.nExpiryHeight),
                                                             nConsensusBranchId(tx.nConsensusBranchId),
                                                             vin(tx.vin), vout(tx.vout), nLockTime(tx.nLockTime),
-                                                            valueBalanceSapling(tx.valueBalanceSapling), vShieldedSpend(tx.vShieldedSpend), vShieldedOutput(tx.vShieldedOutput),
+                                                            saplingBundle(tx.saplingBundle),
                                                             orchardBundle(tx.orchardBundle),
-                                                            vJoinSplit(tx.vJoinSplit), joinSplitPubKey(tx.joinSplitPubKey), joinSplitSig(tx.joinSplitSig),
-                                                            bindingSig(tx.bindingSig)
+                                                            vJoinSplit(tx.vJoinSplit), joinSplitPubKey(tx.joinSplitPubKey), joinSplitSig(tx.joinSplitSig)
 {
     UpdateHash();
 }
@@ -235,10 +172,9 @@ CTransaction::CTransaction(
     bool evilDeveloperFlag) : nVersion(tx.nVersion), fOverwintered(tx.fOverwintered), nVersionGroupId(tx.nVersionGroupId), nExpiryHeight(tx.nExpiryHeight),
                               nConsensusBranchId(tx.nConsensusBranchId),
                               vin(tx.vin), vout(tx.vout), nLockTime(tx.nLockTime),
-                              valueBalanceSapling(tx.valueBalanceSapling), vShieldedSpend(tx.vShieldedSpend), vShieldedOutput(tx.vShieldedOutput),
+                              saplingBundle(tx.saplingBundle),
                               orchardBundle(tx.orchardBundle),
-                              vJoinSplit(tx.vJoinSplit), joinSplitPubKey(tx.joinSplitPubKey), joinSplitSig(tx.joinSplitSig),
-                              bindingSig(tx.bindingSig)
+                              vJoinSplit(tx.vJoinSplit), joinSplitPubKey(tx.joinSplitPubKey), joinSplitSig(tx.joinSplitSig)
 {
     assert(evilDeveloperFlag);
 }
@@ -248,12 +184,10 @@ CTransaction::CTransaction(CMutableTransaction &&tx) : nVersion(tx.nVersion),
                                                        nConsensusBranchId(tx.nConsensusBranchId),
                                                        vin(std::move(tx.vin)), vout(std::move(tx.vout)),
                                                        nLockTime(tx.nLockTime), nExpiryHeight(tx.nExpiryHeight),
-                                                       valueBalanceSapling(tx.valueBalanceSapling),
-                                                       vShieldedSpend(std::move(tx.vShieldedSpend)), vShieldedOutput(std::move(tx.vShieldedOutput)),
+                                                       saplingBundle(std::move(tx.saplingBundle)),
                                                        orchardBundle(std::move(tx.orchardBundle)),
                                                        vJoinSplit(std::move(tx.vJoinSplit)),
-                                                       joinSplitPubKey(std::move(tx.joinSplitPubKey)), joinSplitSig(std::move(tx.joinSplitSig)),
-                                                       bindingSig(std::move(tx.bindingSig))
+                                                       joinSplitPubKey(std::move(tx.joinSplitPubKey)), joinSplitSig(std::move(tx.joinSplitSig))
 {
     UpdateHash();
 }
@@ -267,14 +201,11 @@ CTransaction& CTransaction::operator=(const CTransaction &tx) {
     *const_cast<std::vector<CTxOut>*>(&vout) = tx.vout;
     *const_cast<unsigned int*>(&nLockTime) = tx.nLockTime;
     *const_cast<uint32_t*>(&nExpiryHeight) = tx.nExpiryHeight;
-    valueBalanceSapling = tx.valueBalanceSapling;
-    *const_cast<std::vector<SpendDescription>*>(&vShieldedSpend) = tx.vShieldedSpend;
-    *const_cast<std::vector<OutputDescription>*>(&vShieldedOutput) = tx.vShieldedOutput;
+    saplingBundle = tx.saplingBundle;
     orchardBundle = tx.orchardBundle;
     *const_cast<std::vector<JSDescription>*>(&vJoinSplit) = tx.vJoinSplit;
     *const_cast<ed25519::VerificationKey*>(&joinSplitPubKey) = tx.joinSplitPubKey;
     *const_cast<ed25519::Signature*>(&joinSplitSig) = tx.joinSplitSig;
-    *const_cast<binding_sig_t*>(&bindingSig) = tx.bindingSig;
     *const_cast<uint256*>(&wtxid.hash) = tx.wtxid.hash;
     *const_cast<uint256*>(&wtxid.authDigest) = tx.wtxid.authDigest;
     return *this;
@@ -293,6 +224,7 @@ CAmount CTransaction::GetValueOut() const
         }
     }
 
+    auto valueBalanceSapling = saplingBundle.GetValueBalance();
     if (valueBalanceSapling <= 0) {
         // NB: negative valueBalanceSapling "takes" money from the transparent value pool just as outputs do
         if (valueBalanceSapling < -MAX_MONEY) {
@@ -335,6 +267,7 @@ CAmount CTransaction::GetShieldedValueIn() const
 {
     CAmount nValue = 0;
 
+    auto valueBalanceSapling = saplingBundle.GetValueBalance();
     if (valueBalanceSapling >= 0) {
         // NB: positive valueBalanceSapling "gives" money to the transparent value pool just as inputs do
         if (valueBalanceSapling > MAX_MONEY) {
@@ -411,7 +344,7 @@ std::string CTransaction::ToString() const
             vout.size(),
             nLockTime,
             nExpiryHeight,
-            valueBalanceSapling,
+            saplingBundle.GetValueBalance(),
             GetSaplingSpendsCount(),
             GetSaplingOutputsCount());
         if (nVersion >= ZIP225_MIN_TX_VERSION) {
diff --git a/src/primitives/transaction.h b/src/primitives/transaction.h
index e9939680958..a91f340f6e3 100644
--- a/src/primitives/transaction.h
+++ b/src/primitives/transaction.h
@@ -24,6 +24,7 @@
 #include "zcash/Proof.hpp"
 
 #include <rust/ed25519.h>
+#include <primitives/sapling.h>
 #include <primitives/orchard.h>
 
 // Overwinter transaction version group id
@@ -93,293 +94,6 @@ static inline size_t JOINSPLIT_SIZE(int transactionVersion) {
     return transactionVersion >= SAPLING_TX_VERSION ? 1698 : 1802;
 }
 
-/**
- * The storage format for Sapling Spend descriptions in v5 transactions.
- */
-class SpendDescriptionV5
-{
-public:
-    uint256 inner_cv;                    //!< A value commitment to the value of the input note.
-    uint256 inner_nullifier;             //!< The nullifier of the input note.
-    uint256 inner_rk;                    //!< The randomized public key for spendAuthSig.
-
-    SpendDescriptionV5() { }
-
-    SpendDescriptionV5(uint256 cv, uint256 nullifier, uint256 rk)
-        : inner_cv(cv), inner_nullifier(nullifier), inner_rk(rk) { }
-
-    ADD_SERIALIZE_METHODS;
-
-    template <typename Stream, typename Operation>
-    inline void SerializationOp(Stream& s, Operation ser_action) {
-        READWRITE(inner_cv);
-        READWRITE(inner_nullifier);
-        READWRITE(inner_rk);
-    }
-
-    const uint256& cv() const {
-        return inner_cv;
-    }
-
-    const uint256& nullifier() const {
-        return inner_nullifier;
-    }
-
-    const uint256& rk() const {
-        return inner_rk;
-    }
-};
-
-/**
- * A shielded input to a transaction. It contains data that describes a Spend transfer.
- */
-class SpendDescription : public SpendDescriptionV5
-{
-public:
-    typedef std::array<unsigned char, 64> spend_auth_sig_t;
-
-    uint256 inner_anchor;                //!< A Merkle root of the Sapling note commitment tree at some block height in the past.
-    libzcash::GrothProof inner_zkproof;  //!< A zero-knowledge proof using the spend circuit.
-    spend_auth_sig_t inner_spendAuthSig; //!< A signature authorizing this spend.
-
-    SpendDescription() { }
-
-    SpendDescription(
-        uint256 cv,
-        uint256 anchor,
-        uint256 nullifier,
-        uint256 rk,
-        libzcash::GrothProof zkproof,
-        spend_auth_sig_t spendAuthSig)
-        : SpendDescriptionV5(cv, nullifier, rk), inner_anchor(anchor), inner_zkproof(zkproof), inner_spendAuthSig(spendAuthSig) { }
-
-    ADD_SERIALIZE_METHODS;
-
-    template <typename Stream, typename Operation>
-    inline void SerializationOp(Stream& s, Operation ser_action) {
-        READWRITE(inner_cv);
-        READWRITE(inner_anchor);
-        READWRITE(inner_nullifier);
-        READWRITE(inner_rk);
-        READWRITE(inner_zkproof);
-        READWRITE(inner_spendAuthSig);
-    }
-
-    friend bool operator==(const SpendDescription& a, const SpendDescription& b)
-    {
-        return (
-            a.inner_cv == b.inner_cv &&
-            a.inner_anchor == b.inner_anchor &&
-            a.inner_nullifier == b.inner_nullifier &&
-            a.inner_rk == b.inner_rk &&
-            a.inner_zkproof == b.inner_zkproof &&
-            a.inner_spendAuthSig == b.inner_spendAuthSig
-            );
-    }
-
-    friend bool operator!=(const SpendDescription& a, const SpendDescription& b)
-    {
-        return !(a == b);
-    }
-
-    const uint256& anchor() const {
-        return inner_anchor;
-    }
-
-    const libzcash::GrothProof& zkproof() const {
-        return inner_zkproof;
-    }
-
-    const spend_auth_sig_t& spend_auth_sig() const {
-        return inner_spendAuthSig;
-    }
-
-    spend_auth_sig_t& spend_auth_sig_mut() {
-        return inner_spendAuthSig;
-    }
-};
-
-/**
- * The storage format for Sapling Output descriptions in v5 transactions.
- */
-class OutputDescriptionV5
-{
-public:
-    uint256 inner_cv;                     //!< A value commitment to the value of the output note.
-    uint256 inner_cmu;                     //!< The u-coordinate of the note commitment for the output note.
-    uint256 inner_ephemeralKey;           //!< A Jubjub public key.
-    libzcash::SaplingEncCiphertext inner_encCiphertext; //!< A ciphertext component for the encrypted output note.
-    libzcash::SaplingOutCiphertext inner_outCiphertext; //!< A ciphertext component for the encrypted output note.
-
-    OutputDescriptionV5() { }
-
-    OutputDescriptionV5(
-        uint256 cv,
-        uint256 cmu,
-        uint256 ephemeralKey,
-        libzcash::SaplingEncCiphertext encCiphertext,
-        libzcash::SaplingOutCiphertext outCiphertext)
-        : inner_cv(cv), inner_cmu(cmu), inner_ephemeralKey(ephemeralKey), inner_encCiphertext(encCiphertext), inner_outCiphertext(outCiphertext) { }
-
-    ADD_SERIALIZE_METHODS;
-
-    template <typename Stream, typename Operation>
-    inline void SerializationOp(Stream& s, Operation ser_action) {
-        READWRITE(inner_cv);
-        READWRITE(inner_cmu);
-        READWRITE(inner_ephemeralKey);
-        READWRITE(inner_encCiphertext);
-        READWRITE(inner_outCiphertext);
-    }
-
-    const uint256& cv() const {
-        return inner_cv;
-    }
-
-    const uint256& cmu() const {
-        return inner_cmu;
-    }
-
-    const uint256& ephemeral_key() const {
-        return inner_ephemeralKey;
-    }
-
-    const libzcash::SaplingEncCiphertext& enc_ciphertext() const {
-        return inner_encCiphertext;
-    }
-
-    const libzcash::SaplingOutCiphertext& out_ciphertext() const {
-        return inner_outCiphertext;
-    }
-};
-
-/**
- * A shielded output to a transaction. It contains data that describes an Output transfer.
- */
-class OutputDescription : public OutputDescriptionV5
-{
-public:
-    libzcash::GrothProof inner_zkproof;   //!< A zero-knowledge proof using the output circuit.
-
-    OutputDescription() { }
-
-    OutputDescription(
-        uint256 cv,
-        uint256 cmu,
-        uint256 ephemeralKey,
-        libzcash::SaplingEncCiphertext encCiphertext,
-        libzcash::SaplingOutCiphertext outCiphertext,
-        libzcash::GrothProof zkproof)
-        : OutputDescriptionV5(cv, cmu, ephemeralKey, encCiphertext, outCiphertext), inner_zkproof(zkproof) { }
-
-    ADD_SERIALIZE_METHODS;
-
-    template <typename Stream, typename Operation>
-    inline void SerializationOp(Stream& s, Operation ser_action) {
-        OutputDescriptionV5::SerializationOp(s, ser_action);
-        READWRITE(inner_zkproof);
-    }
-
-    friend bool operator==(const OutputDescription& a, const OutputDescription& b)
-    {
-        return (
-            a.inner_cv == b.inner_cv &&
-            a.inner_cmu == b.inner_cmu &&
-            a.inner_ephemeralKey == b.inner_ephemeralKey &&
-            a.inner_encCiphertext == b.inner_encCiphertext &&
-            a.inner_outCiphertext == b.inner_outCiphertext &&
-            a.inner_zkproof == b.inner_zkproof
-            );
-    }
-
-    friend bool operator!=(const OutputDescription& a, const OutputDescription& b)
-    {
-        return !(a == b);
-    }
-
-    const libzcash::GrothProof& zkproof() const {
-        return inner_zkproof;
-    }
-};
-
-/**
- * The Sapling component of a v5 transaction.
- */
-class SaplingBundle
-{
-private:
-    typedef std::array<unsigned char, 64> binding_sig_t;
-
-    std::vector<SpendDescriptionV5> vSpendsSapling;
-    std::vector<OutputDescriptionV5> vOutputsSapling;
-    uint256 anchorSapling;
-    std::vector<libzcash::GrothProof> vSpendProofsSapling;
-    std::vector<SpendDescription::spend_auth_sig_t> vSpendAuthSigSapling;
-    std::vector<libzcash::GrothProof> vOutputProofsSapling;
-
-public:
-    CAmount valueBalanceSapling;
-    binding_sig_t bindingSigSapling = {{0}};
-
-    SaplingBundle() : valueBalanceSapling(0) {}
-
-    SaplingBundle(
-        const std::vector<SpendDescription>& vShieldedSpend,
-        const std::vector<OutputDescription>& vShieldedOutput,
-        const CAmount& valueBalance,
-        const binding_sig_t& bindingSig);
-
-    ADD_SERIALIZE_METHODS;
-
-    template <typename Stream, typename Operation>
-    inline void SerializationOp(Stream& s, Operation ser_action) {
-        READWRITE(vSpendsSapling);
-        READWRITE(vOutputsSapling);
-
-        bool hasSapling = !(vSpendsSapling.empty() && vOutputsSapling.empty());
-
-        if (hasSapling) {
-            READWRITE(valueBalanceSapling);
-        }
-        if (!vSpendsSapling.empty()) {
-            READWRITE(anchorSapling);
-        }
-        if (ser_action.ForRead()) {
-            for (auto &spend : vSpendsSapling) {
-                libzcash::GrothProof zkproof;
-                READWRITE(zkproof);
-                vSpendProofsSapling.push_back(zkproof);
-            }
-            for (auto &spend : vSpendsSapling) {
-                SpendDescription::spend_auth_sig_t spendAuthSig;
-                READWRITE(spendAuthSig);
-                vSpendAuthSigSapling.push_back(spendAuthSig);
-            }
-            for (auto &output : vOutputsSapling) {
-                libzcash::GrothProof zkproof;
-                READWRITE(zkproof);
-                vOutputProofsSapling.push_back(zkproof);
-            }
-        } else {
-            for (auto &zkproof : vSpendProofsSapling) {
-                READWRITE(zkproof);
-            }
-            for (auto &spendAuthSig : vSpendAuthSigSapling) {
-                READWRITE(spendAuthSig);
-            }
-            for (auto &zkproof : vOutputProofsSapling) {
-                READWRITE(zkproof);
-            }
-        }
-        if (hasSapling) {
-            READWRITE(bindingSigSapling);
-        }
-    }
-
-    std::vector<SpendDescription> GetV4ShieldedSpend();
-    std::vector<OutputDescription> GetV4ShieldedOutput();
-};
-
 template <typename Stream>
 class SproutProofSerializer
 {
@@ -743,7 +457,7 @@ class CTransaction
     /// The consensus branch ID that this transaction commits to.
     /// Serialized from v5 onwards.
     std::optional<uint32_t> nConsensusBranchId;
-    CAmount valueBalanceSapling;
+    SaplingBundle saplingBundle;
     OrchardBundle orchardBundle;
 
     /** Memory only. */
@@ -758,7 +472,6 @@ class CTransaction
 
 public:
     typedef std::array<unsigned char, 64> joinsplit_sig_t;
-    typedef std::array<unsigned char, 64> binding_sig_t;
 
     // Transactions that include a list of JoinSplits are >= version 2.
     static const int32_t SPROUT_MIN_CURRENT_VERSION = 1;
@@ -806,12 +519,9 @@ class CTransaction
     const std::vector<CTxOut> vout;
     const uint32_t nLockTime{0};
     const uint32_t nExpiryHeight{0};
-    const std::vector<SpendDescription> vShieldedSpend;
-    const std::vector<OutputDescription> vShieldedOutput;
     const std::vector<JSDescription> vJoinSplit;
     const ed25519::VerificationKey joinSplitPubKey;
     const ed25519::Signature joinSplitSig;
-    const binding_sig_t bindingSig = {{0}};
 
     /** Construct a CTransaction that qualifies as IsNull() */
     CTransaction();
@@ -887,23 +597,7 @@ class CTransaction
             READWRITE(*const_cast<std::vector<CTxOut>*>(&vout));
 
             // Sapling Transaction Fields
-            if (ser_action.ForRead()) {
-                SaplingBundle saplingBundle;
-                READWRITE(saplingBundle);
-                *const_cast<std::vector<SpendDescription>*>(&vShieldedSpend) =
-                    saplingBundle.GetV4ShieldedSpend();
-                *const_cast<std::vector<OutputDescription>*>(&vShieldedOutput) =
-                    saplingBundle.GetV4ShieldedOutput();
-                valueBalanceSapling = saplingBundle.valueBalanceSapling;
-                *const_cast<binding_sig_t*>(&bindingSig) = saplingBundle.bindingSigSapling;
-            } else {
-                SaplingBundle saplingBundle(
-                    vShieldedSpend,
-                    vShieldedOutput,
-                    valueBalanceSapling,
-                    bindingSig);
-                READWRITE(saplingBundle);
-            }
+            READWRITE(saplingBundle);
 
             // Orchard Transaction Fields
             READWRITE(orchardBundle);
@@ -915,10 +609,15 @@ class CTransaction
             if (isOverwinterV3 || isSaplingV4 || isFuture) {
                 READWRITE(*const_cast<uint32_t*>(&nExpiryHeight));
             }
-            if (isSaplingV4 || isFuture) {
-                READWRITE(valueBalanceSapling);
-                READWRITE(*const_cast<std::vector<SpendDescription>*>(&vShieldedSpend));
-                READWRITE(*const_cast<std::vector<OutputDescription>*>(&vShieldedOutput));
+            SaplingV4Reader saplingReader(isSaplingV4 || isFuture);
+            bool haveSaplingActions;
+            if (ser_action.ForRead()) {
+                READWRITE(saplingReader);
+                haveSaplingActions = saplingReader.HaveActions();
+            } else {
+                SaplingV4Writer saplingWriter(saplingBundle, isSaplingV4 || isFuture);
+                READWRITE(saplingWriter);
+                haveSaplingActions = saplingBundle.IsPresent();
             }
             if (nVersion >= 2) {
                 // These fields do not depend on fOverwintered
@@ -929,8 +628,15 @@ class CTransaction
                     READWRITE(*const_cast<ed25519::Signature*>(&joinSplitSig));
                 }
             }
-            if ((isSaplingV4 || isFuture) && !(vShieldedSpend.empty() && vShieldedOutput.empty())) {
-                READWRITE(*const_cast<binding_sig_t*>(&bindingSig));
+            if ((isSaplingV4 || isFuture) && haveSaplingActions) {
+                std::array<unsigned char, 64> bindingSig;
+                if (!ser_action.ForRead()) {
+                    bindingSig = saplingBundle.GetDetails().binding_sig();
+                }
+                READWRITE(bindingSig);
+                if (ser_action.ForRead()) {
+                    saplingBundle = saplingReader.FinishBundleAssembly(bindingSig);
+                }
             }
         }
         if (ser_action.ForRead())
@@ -976,26 +682,33 @@ class CTransaction
     }
 
     size_t GetSaplingSpendsCount() const {
-        return vShieldedSpend.size();
+        return saplingBundle.GetSpendsCount();
     }
 
     size_t GetSaplingOutputsCount() const {
-        return vShieldedOutput.size();
+        return saplingBundle.GetOutputsCount();
     }
 
-    const std::vector<SpendDescription>& GetSaplingSpends() const {
-        return vShieldedSpend;
+    const rust::Vec<sapling::Spend> GetSaplingSpends() const {
+        return saplingBundle.GetDetails().spends();
     }
 
-    const std::vector<OutputDescription> GetSaplingOutputs() const {
-        return vShieldedOutput;
+    const rust::Vec<sapling::Output> GetSaplingOutputs() const {
+        return saplingBundle.GetDetails().outputs();
     }
 
     /**
      * Returns the Sapling value balance for the transaction.
      */
-    const CAmount& GetValueBalanceSapling() const {
-        return valueBalanceSapling;
+    CAmount GetValueBalanceSapling() const {
+        return saplingBundle.GetValueBalance();
+    }
+
+    /**
+     * Returns the Sapling bundle for the transaction.
+     */
+    const SaplingBundle& GetSaplingBundle() const {
+        return saplingBundle;
     }
 
     /**
@@ -1063,14 +776,11 @@ struct CMutableTransaction
     std::vector<CTxOut> vout;
     uint32_t nLockTime{0};
     uint32_t nExpiryHeight{0};
-    CAmount valueBalanceSapling{0};
-    std::vector<SpendDescription> vShieldedSpend;
-    std::vector<OutputDescription> vShieldedOutput;
+    SaplingBundle saplingBundle;
     OrchardBundle orchardBundle;
     std::vector<JSDescription> vJoinSplit;
     ed25519::VerificationKey joinSplitPubKey;
     ed25519::Signature joinSplitSig;
-    CTransaction::binding_sig_t bindingSig = {{0}};
 
     CMutableTransaction();
     CMutableTransaction(const CTransaction& tx);
@@ -1136,21 +846,7 @@ struct CMutableTransaction
             READWRITE(vout);
 
             // Sapling Transaction Fields
-            if (ser_action.ForRead()) {
-                SaplingBundle saplingBundle;
-                READWRITE(saplingBundle);
-                vShieldedSpend = saplingBundle.GetV4ShieldedSpend();
-                vShieldedOutput = saplingBundle.GetV4ShieldedOutput();
-                valueBalanceSapling = saplingBundle.valueBalanceSapling;
-                bindingSig = saplingBundle.bindingSigSapling;
-            } else {
-                SaplingBundle saplingBundle(
-                    vShieldedSpend,
-                    vShieldedOutput,
-                    valueBalanceSapling,
-                    bindingSig);
-                READWRITE(saplingBundle);
-            }
+            READWRITE(saplingBundle);
 
             // Orchard Transaction Fields
             READWRITE(orchardBundle);
@@ -1162,10 +858,15 @@ struct CMutableTransaction
             if (isOverwinterV3 || isSaplingV4 || isFuture) {
                 READWRITE(nExpiryHeight);
             }
-            if (isSaplingV4 || isFuture) {
-                READWRITE(valueBalanceSapling);
-                READWRITE(vShieldedSpend);
-                READWRITE(vShieldedOutput);
+            SaplingV4Reader saplingReader(isSaplingV4 || isFuture);
+            bool haveSaplingActions;
+            if (ser_action.ForRead()) {
+                READWRITE(saplingReader);
+                haveSaplingActions = saplingReader.HaveActions();
+            } else {
+                SaplingV4Writer saplingWriter(saplingBundle, isSaplingV4 || isFuture);
+                READWRITE(saplingWriter);
+                haveSaplingActions = saplingBundle.IsPresent();
             }
             if (nVersion >= 2) {
                 auto os = WithVersion(&s, static_cast<int>(header));
@@ -1175,8 +876,15 @@ struct CMutableTransaction
                     READWRITE(joinSplitSig);
                 }
             }
-            if ((isSaplingV4 || isFuture) && !(vShieldedSpend.empty() && vShieldedOutput.empty())) {
+            if ((isSaplingV4 || isFuture) && haveSaplingActions) {
+                std::array<unsigned char, 64> bindingSig;
+                if (!ser_action.ForRead()) {
+                    bindingSig = saplingBundle.GetDetails().binding_sig();
+                }
                 READWRITE(bindingSig);
+                if (ser_action.ForRead()) {
+                    saplingBundle = saplingReader.FinishBundleAssembly(bindingSig);
+                }
             }
         }
     }
diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
index 69c9ca0cf86..1303f61687b 100644
--- a/src/rpc/rawtransaction.cpp
+++ b/src/rpc/rawtransaction.cpp
@@ -122,31 +122,31 @@ UniValue TxJoinSplitToJSON(const CTransaction& tx) {
     return vJoinSplit;
 }
 
-UniValue TxShieldedSpendsToJSON(const CTransaction& tx) {
+UniValue TxShieldedSpendsToJSON(const rust::Vec<sapling::Spend>& saplingSpends) {
     UniValue vdesc(UniValue::VARR);
-    for (const auto& spendDesc : tx.GetSaplingSpends()) {
+    for (const auto& spendDesc : saplingSpends) {
         UniValue obj(UniValue::VOBJ);
-        obj.pushKV("cv", spendDesc.cv().GetHex());
-        obj.pushKV("anchor", spendDesc.anchor().GetHex());
-        obj.pushKV("nullifier", spendDesc.nullifier().GetHex());
-        obj.pushKV("rk", spendDesc.rk().GetHex());
-        obj.pushKV("proof", HexStr(spendDesc.zkproof().begin(), spendDesc.zkproof().end()));
-        obj.pushKV("spendAuthSig", HexStr(spendDesc.spend_auth_sig().begin(), spendDesc.spend_auth_sig().end()));
+        obj.pushKV("cv", uint256::FromRawBytes(spendDesc.cv()).GetHex());
+        obj.pushKV("anchor", uint256::FromRawBytes(spendDesc.anchor()).GetHex());
+        obj.pushKV("nullifier", uint256::FromRawBytes(spendDesc.nullifier()).GetHex());
+        obj.pushKV("rk", uint256::FromRawBytes(spendDesc.rk()).GetHex());
+        obj.pushKV("proof", HexStr(spendDesc.zkproof()));
+        obj.pushKV("spendAuthSig", HexStr(spendDesc.spend_auth_sig()));
         vdesc.push_back(obj);
     }
     return vdesc;
 }
 
-UniValue TxShieldedOutputsToJSON(const CTransaction& tx) {
+UniValue TxShieldedOutputsToJSON(const rust::Vec<sapling::Output>& saplingOutputs) {
     UniValue vdesc(UniValue::VARR);
-    for (const auto& outputDesc : tx.GetSaplingOutputs()) {
+    for (const auto& outputDesc : saplingOutputs) {
         UniValue obj(UniValue::VOBJ);
-        obj.pushKV("cv", outputDesc.cv().GetHex());
-        obj.pushKV("cmu", outputDesc.cmu().GetHex());
-        obj.pushKV("ephemeralKey", outputDesc.ephemeral_key().GetHex());
-        obj.pushKV("encCiphertext", HexStr(outputDesc.enc_ciphertext().begin(), outputDesc.enc_ciphertext().end()));
-        obj.pushKV("outCiphertext", HexStr(outputDesc.out_ciphertext().begin(), outputDesc.out_ciphertext().end()));
-        obj.pushKV("proof", HexStr(outputDesc.zkproof().begin(), outputDesc.zkproof().end()));
+        obj.pushKV("cv", uint256::FromRawBytes(outputDesc.cv()).GetHex());
+        obj.pushKV("cmu", uint256::FromRawBytes(outputDesc.cmu()).GetHex());
+        obj.pushKV("ephemeralKey", uint256::FromRawBytes(outputDesc.ephemeral_key()).GetHex());
+        obj.pushKV("encCiphertext", HexStr(outputDesc.enc_ciphertext()));
+        obj.pushKV("outCiphertext", HexStr(outputDesc.out_ciphertext()));
+        obj.pushKV("proof", HexStr(outputDesc.zkproof()));
         vdesc.push_back(obj);
     }
     return vdesc;
@@ -288,14 +288,16 @@ void TxToJSON(const CTransaction& tx, const uint256 hashBlock, UniValue& entry)
 
     if (tx.fOverwintered) {
         if (tx.nVersion >= SAPLING_TX_VERSION) {
+            const auto& bundle = tx.GetSaplingBundle().GetDetails();
             entry.pushKV("valueBalance", ValueFromAmount(tx.GetValueBalanceSapling()));
             entry.pushKV("valueBalanceZat", tx.GetValueBalanceSapling());
-            UniValue vspenddesc = TxShieldedSpendsToJSON(tx);
+            UniValue vspenddesc = TxShieldedSpendsToJSON(bundle.spends());
             entry.pushKV("vShieldedSpend", vspenddesc);
-            UniValue voutputdesc = TxShieldedOutputsToJSON(tx);
+            UniValue voutputdesc = TxShieldedOutputsToJSON(bundle.outputs());
             entry.pushKV("vShieldedOutput", voutputdesc);
             if (!(vspenddesc.empty() && voutputdesc.empty())) {
-                entry.pushKV("bindingSig", HexStr(tx.bindingSig.begin(), tx.bindingSig.end()));
+                auto bindingSig = bundle.binding_sig();
+                entry.pushKV("bindingSig", HexStr(bindingSig.begin(), bindingSig.end()));
             }
         }
         if (tx.nVersion >= ZIP225_TX_VERSION) {
diff --git a/src/rust/include/rust/builder.h b/src/rust/include/rust/builder.h
index 4ec3608c755..8e16782151f 100644
--- a/src/rust/include/rust/builder.h
+++ b/src/rust/include/rust/builder.h
@@ -86,18 +86,6 @@ OrchardBundlePtr* orchard_unauthorized_bundle_prove_and_sign(
     size_t keys_len,
     const unsigned char* sighash);
 
-/// Calculates a ZIP 244 shielded signature digest for the given under-construction
-/// transaction.
-///
-/// Returns `false` if any of the parameters are invalid; in this case, `sighash_ret`
-/// will be unaltered.
-///
-/// `preTx` is always freed by this method.
-bool zcash_builder_zip244_shielded_signature_digest(
-    PrecomputedTxParts* preTx,
-    const OrchardUnauthorizedBundlePtr* bundle,
-    unsigned char* sighash_ret);
-
 #ifdef __cplusplus
 }
 #endif
diff --git a/src/rust/src/bridge.rs b/src/rust/src/bridge.rs
index 403465988de..d1303752fd5 100644
--- a/src/rust/src/bridge.rs
+++ b/src/rust/src/bridge.rs
@@ -8,6 +8,7 @@
 //
 // This file can't use a module comment (`//! comment`) because it causes compilation issues in zcash_script.
 use crate::{
+    builder_ffi::shielded_signature_digest,
     bundlecache::init as bundlecache_init,
     merkle_frontier::{new_orchard, orchard_empty_root, parse_orchard, Orchard, OrchardWallet},
     note_encryption::{
@@ -19,13 +20,21 @@ use crate::{
     orchard_ffi::{orchard_batch_validation_init, BatchValidator as OrchardBatchValidator},
     params::{network, Network},
     sapling::{
-        finish_bundle_assembly, init_batch_validator as init_sapling_batch_validator, init_prover,
-        init_verifier, new_bundle_assembler, BatchValidator as SaplingBatchValidator,
-        Bundle as SaplingBundle, BundleAssembler as SaplingBundleAssembler, Prover, Verifier,
+        apply_sapling_bundle_signatures, build_sapling_bundle, finish_bundle_assembly,
+        init_batch_validator as init_sapling_batch_validator, init_verifier, new_bundle_assembler,
+        new_sapling_builder, none_sapling_bundle, parse_v4_sapling_components,
+        parse_v4_sapling_output, parse_v4_sapling_spend, parse_v5_sapling_bundle,
+        BatchValidator as SaplingBatchValidator, Bundle as SaplingBundle,
+        BundleAssembler as SaplingBundleAssembler, Output, SaplingBuilder,
+        SaplingUnauthorizedBundle, Spend, Verifier,
     },
     streams::{
-        from_auto_file, from_buffered_file, from_data, from_hash_writer, from_size_computer,
-        CppStream,
+        from_auto_file, from_blake2b_writer, from_buffered_file, from_data, from_hash_writer,
+        from_size_computer, CppStream,
+    },
+    test_harness_ffi::{
+        test_only_invalid_sapling_bundle, test_only_replace_sapling_nullifier,
+        test_only_replace_sapling_output_parts,
     },
     wallet_scanner::{init_batch_scanner, BatchResult, BatchScanner},
 };
@@ -42,6 +51,7 @@ pub(crate) mod ffi {
         type CAutoFile = crate::streams::ffi::CAutoFile;
         type CBufferedFile = crate::streams::ffi::CBufferedFile;
         type CHashWriter = crate::streams::ffi::CHashWriter;
+        type CBLAKE2bWriter = crate::streams::ffi::CBLAKE2bWriter;
         type CSizeComputer = crate::streams::ffi::CSizeComputer;
     }
     #[namespace = "stream"]
@@ -52,6 +62,7 @@ pub(crate) mod ffi {
         fn from_auto_file(file: Pin<&mut CAutoFile>) -> Box<CppStream<'_>>;
         fn from_buffered_file(file: Pin<&mut CBufferedFile>) -> Box<CppStream<'_>>;
         fn from_hash_writer(writer: Pin<&mut CHashWriter>) -> Box<CppStream<'_>>;
+        fn from_blake2b_writer(writer: Pin<&mut CBLAKE2bWriter>) -> Box<CppStream<'_>>;
         fn from_size_computer(sc: Pin<&mut CSizeComputer>) -> Box<CppStream<'_>>;
     }
 
@@ -88,70 +99,123 @@ pub(crate) mod ffi {
 
     #[namespace = "sapling"]
     extern "Rust" {
-        #[rust_name = "SaplingBundle"]
-        type Bundle;
+        type Spend;
 
-        #[rust_name = "SaplingBundleAssembler"]
-        type BundleAssembler;
+        #[cxx_name = "parse_v4_spend"]
+        fn parse_v4_sapling_spend(bytes: &[u8]) -> Result<Box<Spend>>;
+        fn cv(self: &Spend) -> [u8; 32];
+        fn anchor(self: &Spend) -> [u8; 32];
+        fn nullifier(self: &Spend) -> [u8; 32];
+        fn rk(self: &Spend) -> [u8; 32];
+        fn zkproof(self: &Spend) -> [u8; 192];
+        fn spend_auth_sig(self: &Spend) -> [u8; 64];
 
-        fn new_bundle_assembler() -> Box<SaplingBundleAssembler>;
-        fn add_spend(
-            self: &mut SaplingBundleAssembler,
-            cv: &[u8; 32],
-            anchor: &[u8; 32],
+        type Output;
+
+        #[cxx_name = "parse_v4_output"]
+        fn parse_v4_sapling_output(bytes: &[u8]) -> Result<Box<Output>>;
+        fn cv(self: &Output) -> [u8; 32];
+        fn cmu(self: &Output) -> [u8; 32];
+        fn ephemeral_key(self: &Output) -> [u8; 32];
+        fn enc_ciphertext(self: &Output) -> [u8; 580];
+        fn out_ciphertext(self: &Output) -> [u8; 80];
+        fn zkproof(self: &Output) -> [u8; 192];
+        fn serialize_v4(self: &Output, stream: &mut CppStream<'_>) -> Result<()>;
+
+        #[cxx_name = "Bundle"]
+        type SaplingBundle;
+
+        #[cxx_name = "none_bundle"]
+        fn none_sapling_bundle() -> Box<SaplingBundle>;
+        fn box_clone(self: &SaplingBundle) -> Box<SaplingBundle>;
+        #[cxx_name = "parse_v5_bundle"]
+        fn parse_v5_sapling_bundle(stream: &mut CppStream<'_>) -> Result<Box<SaplingBundle>>;
+        fn serialize_v4_components(
+            self: &SaplingBundle,
+            stream: &mut CppStream<'_>,
+            has_sapling: bool,
+        ) -> Result<()>;
+        fn serialize_v5(self: &SaplingBundle, stream: &mut CppStream<'_>) -> Result<()>;
+        fn recursive_dynamic_usage(self: &SaplingBundle) -> usize;
+        fn is_present(self: &SaplingBundle) -> bool;
+        fn spends(self: &SaplingBundle) -> Vec<Spend>;
+        fn outputs(self: &SaplingBundle) -> Vec<Output>;
+        fn num_spends(self: &SaplingBundle) -> usize;
+        fn num_outputs(self: &SaplingBundle) -> usize;
+        fn value_balance_zat(self: &SaplingBundle) -> i64;
+        fn binding_sig(self: &SaplingBundle) -> [u8; 64];
+
+        #[cxx_name = "test_only_invalid_bundle"]
+        fn test_only_invalid_sapling_bundle(
+            spends: usize,
+            outputs: usize,
+            value_balance: i64,
+        ) -> Box<SaplingBundle>;
+        #[cxx_name = "test_only_replace_nullifier"]
+        fn test_only_replace_sapling_nullifier(
+            bundle: &mut SaplingBundle,
+            spend_index: usize,
             nullifier: [u8; 32],
-            rk: &[u8; 32],
-            zkproof: [u8; 192], // GROTH_PROOF_SIZE
-            spend_auth_sig: &[u8; 64],
-        ) -> bool;
-        fn add_output(
-            self: &mut SaplingBundleAssembler,
-            cv: &[u8; 32],
-            cmu: &[u8; 32],
-            ephemeral_key: [u8; 32],
+        );
+        #[cxx_name = "test_only_replace_output_parts"]
+        fn test_only_replace_sapling_output_parts(
+            bundle: &mut SaplingBundle,
+            output_index: usize,
+            cmu: [u8; 32],
             enc_ciphertext: [u8; 580],
             out_ciphertext: [u8; 80],
-            zkproof: [u8; 192], // GROTH_PROOF_SIZE
-        ) -> bool;
+        );
+
+        #[rust_name = "SaplingBundleAssembler"]
+        type BundleAssembler;
+
+        fn new_bundle_assembler() -> Box<SaplingBundleAssembler>;
+        #[cxx_name = "parse_v4_components"]
+        fn parse_v4_sapling_components(
+            stream: &mut CppStream<'_>,
+            has_sapling: bool,
+        ) -> Result<Box<SaplingBundleAssembler>>;
+        fn have_actions(self: &SaplingBundleAssembler) -> bool;
         fn finish_bundle_assembly(
             assembler: Box<SaplingBundleAssembler>,
-            value_balance: i64,
             binding_sig: [u8; 64],
         ) -> Box<SaplingBundle>;
 
-        type Prover;
+        #[cxx_name = "Builder"]
+        type SaplingBuilder;
 
-        fn init_prover() -> Box<Prover>;
-        #[allow(clippy::too_many_arguments)]
-        fn create_spend_proof(
-            self: &mut Prover,
-            ak: &[u8; 32],
-            nsk: &[u8; 32],
-            diversifier: &[u8; 11],
-            rcm: &[u8; 32],
-            ar: &[u8; 32],
+        #[cxx_name = "new_builder"]
+        fn new_sapling_builder(network: &Network, height: u32) -> Box<SaplingBuilder>;
+        fn add_spend(
+            self: &mut SaplingBuilder,
+            extsk: &[u8],
+            diversifier: [u8; 11],
+            recipient: [u8; 43],
             value: u64,
-            anchor: &[u8; 32],
-            merkle_path: &[u8; 1065], // 1 + 33 * SAPLING_TREE_DEPTH + 8
-            cv: &mut [u8; 32],
-            rk_out: &mut [u8; 32],
-            zkproof: &mut [u8; 192], // GROTH_PROOF_SIZE
-        ) -> bool;
-        fn create_output_proof(
-            self: &mut Prover,
-            esk: &[u8; 32],
-            payment_address: &[u8; 43],
-            rcm: &[u8; 32],
+            rcm: [u8; 32],
+            merkle_path: [u8; 1065],
+        ) -> Result<()>;
+        fn add_recipient(
+            self: &mut SaplingBuilder,
+            ovk: [u8; 32],
+            to: [u8; 43],
             value: u64,
-            cv: &mut [u8; 32],
-            zkproof: &mut [u8; 192], // GROTH_PROOF_SIZE
-        ) -> bool;
-        fn binding_sig(
-            self: &mut Prover,
-            value_balance: i64,
-            sighash: &[u8; 32],
-            result: &mut [u8; 64],
-        ) -> bool;
+            memo: [u8; 512],
+        ) -> Result<()>;
+        #[cxx_name = "build_bundle"]
+        fn build_sapling_bundle(
+            builder: Box<SaplingBuilder>,
+            target_height: u32,
+        ) -> Result<Box<SaplingUnauthorizedBundle>>;
+
+        #[cxx_name = "UnauthorizedBundle"]
+        type SaplingUnauthorizedBundle;
+
+        #[cxx_name = "apply_bundle_signatures"]
+        fn apply_sapling_bundle_signatures(
+            bundle: Box<SaplingUnauthorizedBundle>,
+            sighash_bytes: [u8; 32],
+        ) -> Result<Box<SaplingBundle>>;
 
         type Verifier;
 
@@ -261,6 +325,21 @@ pub(crate) mod ffi {
         unsafe fn init_wallet(self: &Orchard, wallet: *mut OrchardWallet) -> bool;
     }
 
+    unsafe extern "C++" {
+        include!("rust/builder.h");
+        type OrchardUnauthorizedBundlePtr;
+    }
+    #[namespace = "builder"]
+    extern "Rust" {
+        unsafe fn shielded_signature_digest(
+            consensus_branch_id: u32,
+            tx_bytes: &[u8],
+            all_prev_outputs: &[u8],
+            sapling_bundle: &SaplingUnauthorizedBundle,
+            orchard_bundle: *const OrchardUnauthorizedBundlePtr,
+        ) -> Result<[u8; 32]>;
+    }
+
     #[namespace = "wallet"]
     struct SaplingDecryptionResult {
         txid: [u8; 32],
diff --git a/src/rust/src/builder_ffi.rs b/src/rust/src/builder_ffi.rs
index c24743d01fa..cd9b72c7df9 100644
--- a/src/rust/src/builder_ffi.rs
+++ b/src/rust/src/builder_ffi.rs
@@ -1,4 +1,4 @@
-use std::convert::TryInto;
+use std::convert::TryFrom;
 use std::ptr;
 use std::slice;
 
@@ -15,16 +15,19 @@ use orchard::{
 };
 use rand_core::OsRng;
 use tracing::error;
-use zcash_primitives::transaction::{
-    components::{sapling, Amount},
-    sighash::SignableInput,
-    sighash_v5::v5_signature_hash,
-    txid::TxIdDigester,
-    Authorization, TransactionData, TxVersion,
+use zcash_primitives::{
+    consensus::BranchId,
+    transaction::{
+        components::{sapling, Amount},
+        sighash::{signature_hash, SignableInput},
+        txid::TxIdDigester,
+        Authorization, Transaction, TransactionData,
+    },
 };
 
 use crate::{
-    transaction_ffi::{PrecomputedTxParts, TransparentAuth},
+    bridge::ffi::OrchardUnauthorizedBundlePtr,
+    transaction_ffi::{MapTransparent, TransparentAuth},
     ORCHARD_PK,
 };
 
@@ -186,48 +189,61 @@ pub extern "C" fn orchard_unauthorized_bundle_prove_and_sign(
     }
 }
 
-/// Calculates a ZIP 244 shielded signature digest for the given under-construction
-/// transaction.
+/// Calculates a shielded signature digest for the given under-construction transaction.
 ///
 /// Returns `false` if any of the parameters are invalid; in this case, `sighash_ret`
 /// will be unaltered.
-#[no_mangle]
-pub extern "C" fn zcash_builder_zip244_shielded_signature_digest(
-    precomputed_tx: *mut PrecomputedTxParts,
-    bundle: *const Bundle<InProgress<Unproven, Unauthorized>, Amount>,
-    sighash_ret: *mut [u8; 32],
-) -> bool {
-    let precomputed_tx = if !precomputed_tx.is_null() {
-        unsafe { Box::from_raw(precomputed_tx) }
-    } else {
-        error!("Invalid precomputed transaction");
-        return false;
+pub(crate) fn shielded_signature_digest(
+    consensus_branch_id: u32,
+    tx_bytes: &[u8],
+    all_prev_outputs: &[u8],
+    sapling_bundle: &crate::sapling::SaplingUnauthorizedBundle,
+    orchard_bundle: *const OrchardUnauthorizedBundlePtr,
+) -> Result<[u8; 32], String> {
+    // TODO: This is also parsing a transaction that may have partially-filled fields.
+    // This doesn't matter for transparent components (the only such field would be the
+    // scriptSig fields of transparent inputs, which would serialize as empty Scripts),
+    // but is ill-defined for shielded components (we'll be serializing 64 bytes of zeroes
+    // for each signature). This is an internal FFI so it's fine for now, but we should
+    // refactor the transaction builder (which is the only source of partially-created
+    // shielded components) to use a different FFI for obtaining the sighash, that passes
+    // across the transaction components and then constructs the TransactionData. This is
+    // already being done as part of the Orchard changes to the transaction builder, since
+    // the Orchard bundle will already be built on the Rust side, and we can avoid passing
+    // it back and forward across the FFI with this change. We should similarly refactor
+    // the Sapling code to do the same.
+    let consensus_branch_id = BranchId::try_from(consensus_branch_id)
+        .expect("caller should provide a valid consensus branch ID");
+    let tx = Transaction::read(tx_bytes, consensus_branch_id)
+        .map_err(|e| format!("Failed to parse transaction: {}", e))?;
+    // This method should only be called with an in-progress transaction that contains no
+    // Sapling or Orchard component.
+    assert!(tx.sapling_bundle().is_none());
+    assert!(tx.orchard_bundle().is_none());
+
+    let f_transparent = MapTransparent::parse(all_prev_outputs, &tx)?;
+    let orchard_bundle = unsafe {
+        orchard_bundle
+            .cast::<Bundle<InProgress<Unproven, Unauthorized>, Amount>>()
+            .as_ref()
     };
-    if matches!(
-        precomputed_tx.tx.version(),
-        TxVersion::Sprout(_) | TxVersion::Overwinter | TxVersion::Sapling,
-    ) {
-        error!("Cannot calculate ZIP 244 digest for pre-v5 transaction");
-        return false;
-    }
-    let bundle = unsafe { bundle.as_ref().unwrap() };
 
+    #[derive(Debug)]
     struct Signable {}
     impl Authorization for Signable {
         type TransparentAuth = TransparentAuth;
-        type SaplingAuth = sapling::Authorized;
+        type SaplingAuth = sapling::builder::Unauthorized;
         type OrchardAuth = InProgress<Unproven, Unauthorized>;
     }
 
-    let txdata: TransactionData<Signable> =
-        precomputed_tx
-            .tx
-            .map_bundles(|b| b, |b| b, |_| Some(bundle.clone()));
+    let txdata: TransactionData<Signable> = tx.into_data().map_bundles(
+        |b| b.map(|b| b.map_authorization(f_transparent)),
+        |_| sapling_bundle.bundle.clone(),
+        |_| orchard_bundle.cloned(),
+    );
     let txid_parts = txdata.digest(TxIdDigester);
 
-    let sighash = v5_signature_hash(&txdata, &SignableInput::Shielded, &txid_parts);
+    let sighash = signature_hash(&txdata, &SignableInput::Shielded, &txid_parts);
 
-    // `v5_signature_hash` output is always 32 bytes.
-    *unsafe { &mut *sighash_ret } = sighash.as_ref().try_into().unwrap();
-    true
+    Ok(*sighash.as_ref())
 }
diff --git a/src/rust/src/sapling.rs b/src/rust/src/sapling.rs
index 95bc3855254..1d594c9ee4c 100644
--- a/src/rust/src/sapling.rs
+++ b/src/rust/src/sapling.rs
@@ -3,25 +3,31 @@
 // file COPYING or https://www.opensource.org/licenses/mit-license.php .
 
 use std::convert::TryInto;
+use std::io::{self, Read, Write};
+use std::mem;
 
 use bellman::groth16::{prepare_verifying_key, Proof};
-use group::{cofactor::CofactorGroup, GroupEncoding};
-
+use group::GroupEncoding;
+use memuse::DynamicUsage;
 use rand_core::OsRng;
-use zcash_note_encryption::EphemeralKeyBytes;
+use zcash_encoding::Vector;
 use zcash_primitives::{
+    keys::OutgoingViewingKey,
+    memo::MemoBytes,
     merkle_tree::MerklePath,
     sapling::{
         note::ExtractedNoteCommitment,
+        prover::TxProver,
         redjubjub::{self, Signature},
-        value::ValueCommitment,
-        Diversifier, Nullifier, PaymentAddress, ProofGenerationKey, Rseed,
+        value::{NoteValue, ValueCommitment},
+        Diversifier, Node, Note, PaymentAddress, ProofGenerationKey, Rseed,
     },
     transaction::{
         components::{sapling, Amount},
         txid::{BlockTxCommitmentDigester, TxIdDigester},
-        Authorized, TransactionDigest,
+        Authorized, Transaction, TransactionDigest,
     },
+    zip32::ExtendedSpendingKey,
 };
 use zcash_proofs::{
     circuit::sapling::TREE_DEPTH as SAPLING_TREE_DEPTH,
@@ -32,266 +38,374 @@ use super::GROTH_PROOF_SIZE;
 use super::{
     de_ct, SAPLING_OUTPUT_PARAMS, SAPLING_OUTPUT_VK, SAPLING_SPEND_PARAMS, SAPLING_SPEND_VK,
 };
-use crate::bundlecache::{
-    sapling_bundle_validity_cache, sapling_bundle_validity_cache_mut, CacheEntries,
+use crate::params::Network;
+use crate::{
+    bundlecache::{sapling_bundle_validity_cache, sapling_bundle_validity_cache_mut, CacheEntries},
+    streams::CppStream,
 };
 
-pub(crate) struct Bundle(sapling::Bundle<sapling::Authorized>);
+pub(crate) struct Spend(sapling::SpendDescription<sapling::Authorized>);
+
+pub(crate) fn parse_v4_sapling_spend(bytes: &[u8]) -> Result<Box<Spend>, String> {
+    sapling::SpendDescription::read(&mut io::Cursor::new(bytes))
+        .map(Spend)
+        .map(Box::new)
+        .map_err(|e| format!("{}", e))
+}
+
+impl Spend {
+    pub(crate) fn cv(&self) -> [u8; 32] {
+        self.0.cv().to_bytes()
+    }
+
+    pub(crate) fn anchor(&self) -> [u8; 32] {
+        self.0.anchor().to_bytes()
+    }
+
+    pub(crate) fn nullifier(&self) -> [u8; 32] {
+        self.0.nullifier().0
+    }
+
+    pub(crate) fn rk(&self) -> [u8; 32] {
+        self.0.rk().0.to_bytes()
+    }
+
+    pub(crate) fn zkproof(&self) -> [u8; 192] {
+        *self.0.zkproof()
+    }
+
+    pub(crate) fn spend_auth_sig(&self) -> [u8; 64] {
+        let mut ret = [0; 64];
+        self.0
+            .spend_auth_sig()
+            .write(&mut ret[..])
+            .expect("correct length");
+        ret
+    }
+}
+
+pub(crate) struct Output(sapling::OutputDescription<[u8; 192]>);
+
+pub(crate) fn parse_v4_sapling_output(bytes: &[u8]) -> Result<Box<Output>, String> {
+    sapling::OutputDescription::read(&mut io::Cursor::new(bytes))
+        .map(Output)
+        .map(Box::new)
+        .map_err(|e| format!("{}", e))
+}
+
+impl Output {
+    pub(crate) fn cv(&self) -> [u8; 32] {
+        self.0.cv().to_bytes()
+    }
+
+    pub(crate) fn cmu(&self) -> [u8; 32] {
+        self.0.cmu().to_bytes()
+    }
+
+    pub(crate) fn ephemeral_key(&self) -> [u8; 32] {
+        self.0.ephemeral_key().0
+    }
+
+    pub(crate) fn enc_ciphertext(&self) -> [u8; 580] {
+        *self.0.enc_ciphertext()
+    }
+
+    pub(crate) fn out_ciphertext(&self) -> [u8; 80] {
+        *self.0.out_ciphertext()
+    }
+
+    pub(crate) fn zkproof(&self) -> [u8; 192] {
+        *self.0.zkproof()
+    }
+
+    pub(crate) fn serialize_v4(&self, writer: &mut CppStream<'_>) -> Result<(), String> {
+        self.0
+            .write_v4(writer)
+            .map_err(|e| format!("Failed to write v4 Sapling Output Description: {}", e))
+    }
+}
+
+#[derive(Clone)]
+pub(crate) struct Bundle(pub(crate) Option<sapling::Bundle<sapling::Authorized>>);
+
+pub(crate) fn none_sapling_bundle() -> Box<Bundle> {
+    Box::new(Bundle(None))
+}
+
+/// Parses an authorized Sapling bundle from the given stream.
+pub(crate) fn parse_v5_sapling_bundle(reader: &mut CppStream<'_>) -> Result<Box<Bundle>, String> {
+    Bundle::parse_v5(reader)
+}
 
 impl Bundle {
+    /// Returns a copy of the value.
+    pub(crate) fn box_clone(&self) -> Box<Self> {
+        Box::new(self.clone())
+    }
+
+    /// Parses an authorized Sapling bundle from the given stream.
+    fn parse_v5(reader: &mut CppStream<'_>) -> Result<Box<Self>, String> {
+        match Transaction::temporary_zcashd_read_v5_sapling(reader) {
+            Ok(parsed) => Ok(Box::new(Bundle(parsed))),
+            Err(e) => Err(format!("Failed to parse v5 Sapling bundle: {}", e)),
+        }
+    }
+
+    pub(crate) fn serialize_v4_components(
+        &self,
+        mut writer: &mut CppStream<'_>,
+        has_sapling: bool,
+    ) -> Result<(), String> {
+        if has_sapling {
+            let mut write_v4 = || {
+                writer.write_all(
+                    &self
+                        .0
+                        .as_ref()
+                        .map_or(Amount::zero(), |b| *b.value_balance())
+                        .to_i64_le_bytes(),
+                )?;
+                Vector::write(
+                    &mut writer,
+                    self.0.as_ref().map_or(&[], |b| b.shielded_spends()),
+                    |w, e| e.write_v4(w),
+                )?;
+                Vector::write(
+                    &mut writer,
+                    self.0.as_ref().map_or(&[], |b| b.shielded_outputs()),
+                    |w, e| e.write_v4(w),
+                )
+            };
+            write_v4().map_err(|e| format!("{}", e))?;
+        } else if self.0.is_some() {
+            return Err(
+                "Sapling components may not be present if Sapling is not active.".to_string(),
+            );
+        }
+
+        Ok(())
+    }
+
+    /// Serializes an authorized Sapling bundle to the given stream.
+    ///
+    /// If `bundle == None`, this serializes `nSpendsSapling = nOutputsSapling = 0`.
+    pub(crate) fn serialize_v5(&self, writer: &mut CppStream<'_>) -> Result<(), String> {
+        Transaction::temporary_zcashd_write_v5_sapling(self.inner(), writer)
+            .map_err(|e| format!("Failed to serialize Sapling bundle: {}", e))
+    }
+
+    pub(crate) fn inner(&self) -> Option<&sapling::Bundle<sapling::Authorized>> {
+        self.0.as_ref()
+    }
+
+    /// Returns the amount of dynamically-allocated memory used by this bundle.
+    pub(crate) fn recursive_dynamic_usage(&self) -> usize {
+        self.inner()
+            // Bundles are boxed on the heap, so we count their own size as well as the size
+            // of `Vec`s they allocate.
+            .map(|bundle| mem::size_of_val(bundle) + bundle.dynamic_usage())
+            // If the transaction has no Sapling component, nothing is allocated for it.
+            .unwrap_or(0)
+    }
+
+    /// Returns whether the Sapling bundle is present.
+    pub(crate) fn is_present(&self) -> bool {
+        self.0.is_some()
+    }
+
+    pub(crate) fn spends(&self) -> Vec<Spend> {
+        self.0
+            .iter()
+            .flat_map(|b| b.shielded_spends().iter())
+            .cloned()
+            .map(Spend)
+            .collect()
+    }
+
+    pub(crate) fn outputs(&self) -> Vec<Output> {
+        self.0
+            .iter()
+            .flat_map(|b| b.shielded_outputs().iter())
+            .cloned()
+            .map(Output)
+            .collect()
+    }
+
+    pub(crate) fn num_spends(&self) -> usize {
+        self.inner().map(|b| b.shielded_spends().len()).unwrap_or(0)
+    }
+
+    pub(crate) fn num_outputs(&self) -> usize {
+        self.inner()
+            .map(|b| b.shielded_outputs().len())
+            .unwrap_or(0)
+    }
+
+    /// Returns the value balance for this Sapling bundle.
+    ///
+    /// A transaction with no Sapling component has a value balance of zero.
+    pub(crate) fn value_balance_zat(&self) -> i64 {
+        self.inner()
+            .map(|b| b.value_balance().into())
+            // From section 7.1 of the Zcash prototol spec:
+            // If valueBalanceSapling is not present, then v^balanceSapling is defined to be 0.
+            .unwrap_or(0)
+    }
+
+    /// Returns the binding signature for the bundle.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the bundle is not present.
+    pub(crate) fn binding_sig(&self) -> [u8; 64] {
+        let mut ret = [0; 64];
+        self.inner()
+            .expect("Bundle actions should have been checked to be non-empty")
+            .authorization()
+            .binding_sig
+            .write(&mut ret[..])
+            .expect("correct length");
+        ret
+    }
+
     fn commitment<D: TransactionDigest<Authorized>>(&self, digester: D) -> D::SaplingDigest {
-        digester.digest_sapling(Some(&self.0))
+        digester.digest_sapling(self.inner())
     }
 }
 
 pub(crate) struct BundleAssembler {
+    value_balance: Amount,
     shielded_spends: Vec<sapling::SpendDescription<sapling::Authorized>>,
     shielded_outputs: Vec<sapling::OutputDescription<[u8; 192]>>, // GROTH_PROOF_SIZE
 }
 
 pub(crate) fn new_bundle_assembler() -> Box<BundleAssembler> {
     Box::new(BundleAssembler {
+        value_balance: Amount::zero(),
         shielded_spends: vec![],
         shielded_outputs: vec![],
     })
 }
 
-impl BundleAssembler {
-    pub(crate) fn add_spend(
-        self: &mut BundleAssembler,
-        cv: &[u8; 32],
-        anchor: &[u8; 32],
-        nullifier: [u8; 32],
-        rk: &[u8; 32],
-        zkproof: [u8; 192], // GROTH_PROOF_SIZE
-        spend_auth_sig: &[u8; 64],
-    ) -> bool {
-        // Deserialize the value commitment
-        let cv = match Option::from(ValueCommitment::from_bytes_not_small_order(cv)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // Deserialize the anchor, which should be an element
-        // of Fr.
-        let anchor = match de_ct(bls12_381::Scalar::from_bytes(anchor)) {
-            Some(a) => a,
-            None => return false,
-        };
-
-        // Deserialize rk
-        let rk = match redjubjub::PublicKey::read(&rk[..]) {
-            Ok(p) => p,
-            Err(_) => return false,
-        };
+pub(crate) fn parse_v4_sapling_components(
+    reader: &mut CppStream<'_>,
+    has_sapling: bool,
+) -> Result<Box<BundleAssembler>, String> {
+    BundleAssembler::parse_v4_components(reader, has_sapling)
+        .map_err(|e| format!("Failed to parse v4 Sapling bundle: {}", e))
+}
 
-        // Deserialize the signature
-        let spend_auth_sig = match Signature::read(&spend_auth_sig[..]) {
-            Ok(sig) => sig,
-            Err(_) => return false,
+impl BundleAssembler {
+    pub(crate) fn parse_v4_components(
+        mut reader: &mut CppStream<'_>,
+        has_sapling: bool,
+    ) -> io::Result<Box<Self>> {
+        let (value_balance, shielded_spends, shielded_outputs) = if has_sapling {
+            let vb = {
+                let mut tmp = [0; 8];
+                reader.read_exact(&mut tmp)?;
+                Amount::from_i64_le_bytes(tmp).map_err(|_| {
+                    io::Error::new(io::ErrorKind::InvalidData, "valueBalance out of range")
+                })?
+            };
+            #[allow(clippy::redundant_closure)]
+            let ss: Vec<sapling::SpendDescription<sapling::Authorized>> =
+                Vector::read(&mut reader, |r| sapling::SpendDescription::read(r))?;
+            #[allow(clippy::redundant_closure)]
+            let so: Vec<sapling::OutputDescription<sapling::GrothProofBytes>> =
+                Vector::read(&mut reader, |r| sapling::OutputDescription::read(r))?;
+            (vb, ss, so)
+        } else {
+            (Amount::zero(), vec![], vec![])
         };
 
-        self.shielded_spends
-            .push(sapling::SpendDescription::temporary_zcashd_from_parts(
-                cv,
-                anchor,
-                Nullifier(nullifier),
-                rk,
-                zkproof,
-                spend_auth_sig,
-            ));
-
-        true
+        Ok(Box::new(Self {
+            value_balance,
+            shielded_spends,
+            shielded_outputs,
+        }))
     }
 
-    pub(crate) fn add_output(
-        self: &mut BundleAssembler,
-        cv: &[u8; 32],
-        cm: &[u8; 32],
-        ephemeral_key: [u8; 32],
-        enc_ciphertext: [u8; 580],
-        out_ciphertext: [u8; 80],
-        zkproof: [u8; 192], // GROTH_PROOF_SIZE
-    ) -> bool {
-        // Deserialize the value commitment
-        let cv = match Option::from(ValueCommitment::from_bytes_not_small_order(cv)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // Deserialize the extracted note commitment.
-        let cmu = match Option::from(ExtractedNoteCommitment::from_bytes(cm)) {
-            Some(a) => a,
-            None => return false,
-        };
-
-        self.shielded_outputs
-            .push(sapling::OutputDescription::temporary_zcashd_from_parts(
-                cv,
-                cmu,
-                EphemeralKeyBytes(ephemeral_key),
-                enc_ciphertext,
-                out_ciphertext,
-                zkproof,
-            ));
-
-        true
+    pub(crate) fn have_actions(&self) -> bool {
+        !(self.shielded_spends.is_empty() && self.shielded_outputs.is_empty())
     }
 }
 
 #[allow(clippy::boxed_local)]
 pub(crate) fn finish_bundle_assembly(
     assembler: Box<BundleAssembler>,
-    value_balance: i64,
     binding_sig: [u8; 64],
 ) -> Box<Bundle> {
-    let value_balance = Amount::from_i64(value_balance).expect("parsed elsewhere");
     let binding_sig = redjubjub::Signature::read(&binding_sig[..]).expect("parsed elsewhere");
 
-    Box::new(Bundle(sapling::Bundle::temporary_zcashd_from_parts(
+    Box::new(Bundle(Some(sapling::Bundle::temporary_zcashd_from_parts(
         assembler.shielded_spends,
         assembler.shielded_outputs,
-        value_balance,
+        assembler.value_balance,
         sapling::Authorized { binding_sig },
-    )))
-}
-
-pub(crate) struct Prover(SaplingProvingContext);
-
-pub(crate) fn init_prover() -> Box<Prover> {
-    Box::new(Prover(SaplingProvingContext::new()))
+    ))))
 }
 
-impl Prover {
-    #[allow(clippy::too_many_arguments)]
-    pub(crate) fn create_spend_proof(
-        &mut self,
-        ak: &[u8; 32],
-        nsk: &[u8; 32],
-        diversifier: &[u8; 11],
-        rcm: &[u8; 32],
-        ar: &[u8; 32],
-        value: u64,
-        anchor: &[u8; 32],
-        merkle_path: &[u8; 1 + 33 * SAPLING_TREE_DEPTH + 8],
-        cv: &mut [u8; 32],
-        rk_out: &mut [u8; 32],
-        zkproof: &mut [u8; GROTH_PROOF_SIZE],
-    ) -> bool {
-        // Grab `ak` from the caller, which should be a point.
-        let ak = match de_ct(jubjub::ExtendedPoint::from_bytes(ak)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // `ak` should be prime order.
-        let ak = match de_ct(ak.into_subgroup()) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // Grab `nsk` from the caller
-        let nsk = match de_ct(jubjub::Scalar::from_bytes(nsk)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // Construct the proof generation key
-        let proof_generation_key = ProofGenerationKey { ak, nsk };
-
-        // Grab the diversifier from the caller
-        let diversifier = Diversifier(*diversifier);
-
-        // The caller chooses the note randomness
-        // If this is after ZIP 212, the caller has calculated rcm, and we don't need to call
-        // Note::derive_esk, so we just pretend the note was using this rcm all along.
-        let rseed = match de_ct(jubjub::Scalar::from_bytes(rcm)) {
-            Some(p) => Rseed::BeforeZip212(p),
-            None => return false,
-        };
-
-        // The caller also chooses the re-randomization of ak
-        let ar = match de_ct(jubjub::Scalar::from_bytes(ar)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // We need to compute the anchor of the Spend.
-        let anchor = match de_ct(bls12_381::Scalar::from_bytes(anchor)) {
-            Some(p) => p,
-            None => return false,
-        };
+pub(crate) struct StaticTxProver;
 
-        // Parse the Merkle path from the caller
-        let merkle_path = match MerklePath::from_slice(merkle_path) {
-            Ok(w) => w,
-            Err(_) => return false,
-        };
+impl TxProver for StaticTxProver {
+    type SaplingProvingContext = SaplingProvingContext;
 
-        // Create proof
-        let (proof, value_commitment, rk) = self
-            .0
-            .spend_proof(
-                proof_generation_key,
-                diversifier,
-                rseed,
-                ar,
-                value,
-                anchor,
-                merkle_path,
-                unsafe { SAPLING_SPEND_PARAMS.as_ref() }.expect(
-                    "Parameters not loaded: SAPLING_SPEND_PARAMS should have been initialized",
-                ),
-                &prepare_verifying_key(unsafe { SAPLING_SPEND_VK.as_ref() }.expect(
-                    "Parameters not loaded: SAPLING_SPEND_VK should have been initialized",
-                )),
-            )
-            .expect("proving should not fail");
+    fn new_sapling_proving_context(&self) -> Self::SaplingProvingContext {
+        SaplingProvingContext::new()
+    }
 
-        // Write value commitment to caller
-        *cv = value_commitment.to_bytes();
+    fn spend_proof(
+        &self,
+        ctx: &mut Self::SaplingProvingContext,
+        proof_generation_key: ProofGenerationKey,
+        diversifier: Diversifier,
+        rseed: Rseed,
+        ar: jubjub::Fr,
+        value: u64,
+        anchor: bls12_381::Scalar,
+        merkle_path: MerklePath<Node>,
+    ) -> Result<
+        (
+            [u8; GROTH_PROOF_SIZE],
+            ValueCommitment,
+            redjubjub::PublicKey,
+        ),
+        (),
+    > {
+        let (proof, cv, rk) = ctx.spend_proof(
+            proof_generation_key,
+            diversifier,
+            rseed,
+            ar,
+            value,
+            anchor,
+            merkle_path,
+            unsafe { SAPLING_SPEND_PARAMS.as_ref() }
+                .expect("Parameters not loaded: SAPLING_SPEND_PARAMS should have been initialized"),
+            &prepare_verifying_key(
+                unsafe { SAPLING_SPEND_VK.as_ref() }
+                    .expect("Parameters not loaded: SAPLING_SPEND_VK should have been initialized"),
+            ),
+        )?;
 
-        // Write proof out to caller
+        let mut zkproof = [0u8; GROTH_PROOF_SIZE];
         proof
             .write(&mut zkproof[..])
             .expect("should be able to serialize a proof");
 
-        // Write out `rk` to the caller
-        rk.write(&mut rk_out[..])
-            .expect("should be able to write to rk_out");
-
-        true
+        Ok((zkproof, cv, rk))
     }
 
-    pub(crate) fn create_output_proof(
-        &mut self,
-        esk: &[u8; 32],
-        payment_address: &[u8; 43],
-        rcm: &[u8; 32],
+    fn output_proof(
+        &self,
+        ctx: &mut Self::SaplingProvingContext,
+        esk: jubjub::Fr,
+        payment_address: PaymentAddress,
+        rcm: jubjub::Fr,
         value: u64,
-        cv: &mut [u8; 32],
-        zkproof: &mut [u8; GROTH_PROOF_SIZE],
-    ) -> bool {
-        // Grab `esk`, which the caller should have constructed for the DH key exchange.
-        let esk = match de_ct(jubjub::Scalar::from_bytes(esk)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // Grab the payment address from the caller
-        let payment_address = match PaymentAddress::from_bytes(payment_address) {
-            Some(pa) => pa,
-            None => return false,
-        };
-
-        // The caller provides the commitment randomness for the output note
-        let rcm = match de_ct(jubjub::Scalar::from_bytes(rcm)) {
-            Some(p) => p,
-            None => return false,
-        };
-
-        // Create proof
-        let (proof, value_commitment) = self.0.output_proof(
+    ) -> ([u8; GROTH_PROOF_SIZE], ValueCommitment) {
+        let (proof, cv) = ctx.output_proof(
             esk,
             payment_address,
             rcm,
@@ -301,39 +415,127 @@ impl Prover {
             ),
         );
 
-        // Write the proof out to the caller
+        let mut zkproof = [0u8; GROTH_PROOF_SIZE];
         proof
             .write(&mut zkproof[..])
             .expect("should be able to serialize a proof");
 
-        // Write the value commitment to the caller
-        *cv = value_commitment.to_bytes();
+        (zkproof, cv)
+    }
 
-        true
+    fn binding_sig(
+        &self,
+        ctx: &mut Self::SaplingProvingContext,
+        value_balance: zcash_primitives::transaction::components::Amount,
+        sighash: &[u8; 32],
+    ) -> Result<redjubjub::Signature, ()> {
+        ctx.binding_sig(value_balance, sighash)
     }
+}
+
+pub(crate) struct SaplingBuilder(sapling::builder::SaplingBuilder<Network>);
+
+pub(crate) fn new_sapling_builder(network: &Network, target_height: u32) -> Box<SaplingBuilder> {
+    Box::new(SaplingBuilder(sapling::builder::SaplingBuilder::new(
+        *network,
+        target_height.into(),
+    )))
+}
 
-    pub(crate) fn binding_sig(
+#[allow(clippy::boxed_local)]
+pub(crate) fn build_sapling_bundle(
+    builder: Box<SaplingBuilder>,
+    target_height: u32,
+) -> Result<Box<SaplingUnauthorizedBundle>, String> {
+    builder.build(target_height).map(Box::new)
+}
+
+impl SaplingBuilder {
+    pub(crate) fn add_spend(
         &mut self,
-        value_balance: i64,
-        sighash: &[u8; 32],
-        result: &mut [u8; 64],
-    ) -> bool {
-        let value_balance = match Amount::from_i64(value_balance) {
-            Ok(vb) => vb,
-            Err(()) => return false,
-        };
+        extsk: &[u8],
+        diversifier: [u8; 11],
+        recipient: [u8; 43],
+        value: u64,
+        rcm: [u8; 32],
+        merkle_path: [u8; 1 + 33 * SAPLING_TREE_DEPTH + 8],
+    ) -> Result<(), String> {
+        let extsk =
+            ExtendedSpendingKey::from_bytes(extsk).map_err(|_| "Invalid ExtSK".to_owned())?;
+        let diversifier = Diversifier(diversifier);
+        let recipient =
+            PaymentAddress::from_bytes(&recipient).ok_or("Invalid recipient address")?;
+        let value = NoteValue::from_raw(value);
+        // If this is after ZIP 212, the caller has calculated rcm, and we don't need to call
+        // Note::derive_esk, so we just pretend the note was using this rcm all along.
+        let rseed = de_ct(jubjub::Scalar::from_bytes(&rcm))
+            .map(Rseed::BeforeZip212)
+            .ok_or("Invalid rcm")?;
+        let note = Note::from_parts(recipient, value, rseed);
+        let merkle_path = MerklePath::from_slice(&merkle_path)
+            .map_err(|()| "Invalid Sapling Merkle path".to_owned())?;
 
-        // Sign
-        let sig = match self.0.binding_sig(value_balance, sighash) {
-            Ok(s) => s,
-            Err(_) => return false,
-        };
+        self.0
+            .add_spend(OsRng, extsk, diversifier, note, merkle_path)
+            .map_err(|e| format!("Failed to add Sapling spend: {}", e))
+    }
+
+    pub(crate) fn add_recipient(
+        &mut self,
+        ovk: [u8; 32],
+        to: [u8; 43],
+        value: u64,
+        memo: [u8; 512],
+    ) -> Result<(), String> {
+        let ovk = Some(OutgoingViewingKey(ovk));
+        let to = PaymentAddress::from_bytes(&to).ok_or("Invalid recipient address")?;
+        let value = NoteValue::from_raw(value);
+        let memo = MemoBytes::from_bytes(&memo).map_err(|e| format!("Invalid memo: {}", e))?;
+
+        self.0
+            .add_output(OsRng, ovk, to, value, memo)
+            .map_err(|e| format!("Failed to add Sapling recipient: {}", e))
+    }
+
+    fn build(self, target_height: u32) -> Result<SaplingUnauthorizedBundle, String> {
+        let prover = crate::sapling::StaticTxProver;
+        let mut ctx = prover.new_sapling_proving_context();
+        let rng = OsRng;
+        let bundle = self
+            .0
+            .build(&prover, &mut ctx, rng, target_height.into(), None)
+            .map_err(|e| format!("Failed to build Sapling bundle: {}", e))?;
+        Ok(SaplingUnauthorizedBundle { bundle, ctx })
+    }
+}
+
+pub(crate) struct SaplingUnauthorizedBundle {
+    pub(crate) bundle: Option<sapling::Bundle<sapling::builder::Unauthorized>>,
+    ctx: SaplingProvingContext,
+}
 
-        // Write out signature
-        sig.write(&mut result[..])
-            .expect("result should be 64 bytes");
+pub(crate) fn apply_sapling_bundle_signatures(
+    bundle: Box<SaplingUnauthorizedBundle>,
+    sighash_bytes: [u8; 32],
+) -> Result<Box<crate::sapling::Bundle>, String> {
+    bundle.apply_signatures(sighash_bytes).map(Box::new)
+}
+
+impl SaplingUnauthorizedBundle {
+    fn apply_signatures(self, sighash_bytes: [u8; 32]) -> Result<crate::sapling::Bundle, String> {
+        let SaplingUnauthorizedBundle { bundle, mut ctx } = self;
+
+        let authorized = if let Some(bundle) = bundle {
+            let prover = crate::sapling::StaticTxProver;
+            let (authorized, _) = bundle
+                .apply_signatures(&prover, &mut ctx, &mut OsRng, &sighash_bytes)
+                .map_err(|e| format!("Failed to apply signatures to Sapling bundle: {}", e))?;
+            Some(authorized)
+        } else {
+            None
+        };
 
-        true
+        Ok(crate::sapling::Bundle(authorized))
     }
 }
 
@@ -500,32 +702,40 @@ impl BatchValidator {
     /// return `true`).
     #[allow(clippy::boxed_local)]
     pub(crate) fn check_bundle(&mut self, bundle: Box<Bundle>, sighash: [u8; 32]) -> bool {
-        if let Some(inner) = &mut self.0 {
-            let cache = sapling_bundle_validity_cache();
-
-            // Compute the cache entry for this bundle.
-            let cache_entry = {
-                let bundle_commitment = bundle.commitment(TxIdDigester).unwrap();
-                let bundle_authorizing_commitment = bundle.commitment(BlockTxCommitmentDigester);
-                cache.compute_entry(
-                    bundle_commitment.as_bytes().try_into().unwrap(),
-                    bundle_authorizing_commitment.as_bytes().try_into().unwrap(),
-                    &sighash,
-                )
-            };
-
-            // Check if this bundle's validation result exists in the cache.
-            if cache.contains(cache_entry, &mut inner.queued_entries) {
+        match (&mut self.0, bundle.inner()) {
+            (Some(inner), Some(_)) => {
+                let cache = sapling_bundle_validity_cache();
+
+                // Compute the cache entry for this bundle.
+                let cache_entry = {
+                    let bundle_commitment = bundle.commitment(TxIdDigester).unwrap();
+                    let bundle_authorizing_commitment =
+                        bundle.commitment(BlockTxCommitmentDigester);
+                    cache.compute_entry(
+                        bundle_commitment.as_bytes().try_into().unwrap(),
+                        bundle_authorizing_commitment.as_bytes().try_into().unwrap(),
+                        &sighash,
+                    )
+                };
+
+                // Check if this bundle's validation result exists in the cache.
+                if cache.contains(cache_entry, &mut inner.queued_entries) {
+                    true
+                } else {
+                    // The bundle has been added to `inner.queued_entries` because it was not
+                    // in the cache. We now check the bundle against the Sapling-specific
+                    // consensus rules, and add its authorization to the validation batch.
+                    inner.validator.check_bundle(bundle.0.unwrap(), sighash)
+                }
+            }
+            (Some(_), None) => {
+                tracing::debug!("Tx has no Sapling component");
                 true
-            } else {
-                // The bundle has been added to `inner.queued_entries` because it was not
-                // in the cache. We now check the bundle against the Sapling-specific
-                // consensus rules, and add its authorization to the validation batch.
-                inner.validator.check_bundle(bundle.0, sighash)
             }
-        } else {
-            tracing::error!("sapling::BatchValidator has already been used");
-            false
+            (None, _) => {
+                tracing::error!("sapling::BatchValidator has already been used");
+                false
+            }
         }
     }
 
diff --git a/src/rust/src/streams.rs b/src/rust/src/streams.rs
index 98c7d22d3b7..b67c4ac1017 100644
--- a/src/rust/src/streams.rs
+++ b/src/rust/src/streams.rs
@@ -23,6 +23,13 @@ pub(crate) mod ffi {
         unsafe fn write_u8(self: Pin<&mut CHashWriter>, pch: *const u8, nSize: usize)
             -> Result<()>;
 
+        type CBLAKE2bWriter;
+        unsafe fn write_u8(
+            self: Pin<&mut CBLAKE2bWriter>,
+            pch: *const u8,
+            nSize: usize,
+        ) -> Result<()>;
+
         type CSizeComputer;
         unsafe fn write_u8(
             self: Pin<&mut CSizeComputer>,
@@ -35,6 +42,7 @@ pub(crate) mod ffi {
     impl UniquePtr<CAutoFile> {}
     impl UniquePtr<CBufferedFile> {}
     impl UniquePtr<CHashWriter> {}
+    impl UniquePtr<CBLAKE2bWriter> {}
     impl UniquePtr<CSizeComputer> {}
 }
 
@@ -54,6 +62,10 @@ pub(crate) fn from_hash_writer(writer: Pin<&mut ffi::CHashWriter>) -> Box<CppStr
     Box::new(CppStream::Hash(writer))
 }
 
+pub(crate) fn from_blake2b_writer(writer: Pin<&mut ffi::CBLAKE2bWriter>) -> Box<CppStream<'_>> {
+    Box::new(CppStream::Blake2b(writer))
+}
+
 pub(crate) fn from_size_computer(sc: Pin<&mut ffi::CSizeComputer>) -> Box<CppStream<'_>> {
     Box::new(CppStream::Size(sc))
 }
@@ -63,6 +75,7 @@ pub(crate) enum CppStream<'a> {
     AutoFile(Pin<&'a mut ffi::CAutoFile>),
     BufferedFile(Pin<&'a mut ffi::CBufferedFile>),
     Hash(Pin<&'a mut ffi::CHashWriter>),
+    Blake2b(Pin<&'a mut ffi::CBLAKE2bWriter>),
     Size(Pin<&'a mut ffi::CSizeComputer>),
 }
 
@@ -84,6 +97,10 @@ impl<'a> io::Read for CppStream<'a> {
                 io::ErrorKind::Unsupported,
                 "Cannot read from CHashWriter",
             )),
+            CppStream::Blake2b(_) => Err(io::Error::new(
+                io::ErrorKind::Unsupported,
+                "Cannot read from CBLAKE2bWriter",
+            )),
             CppStream::Size(_) => Err(io::Error::new(
                 io::ErrorKind::Unsupported,
                 "Cannot read from CSizeComputer",
@@ -110,6 +127,9 @@ impl<'a> io::Write for CppStream<'a> {
             CppStream::Hash(inner) => unsafe { inner.as_mut().write_u8(pch, len) }
                 .map(|()| buf.len())
                 .map_err(|e| io::Error::new(io::ErrorKind::Other, e)),
+            CppStream::Blake2b(inner) => unsafe { inner.as_mut().write_u8(pch, len) }
+                .map(|()| buf.len())
+                .map_err(|e| io::Error::new(io::ErrorKind::Other, e)),
             CppStream::Size(inner) => unsafe { inner.as_mut().write_u8(pch, len) }
                 .map(|()| buf.len())
                 .map_err(|e| io::Error::new(io::ErrorKind::Other, e)),
diff --git a/src/rust/src/transaction_ffi.rs b/src/rust/src/transaction_ffi.rs
index bd8c92efba9..e10a5e34e8b 100644
--- a/src/rust/src/transaction_ffi.rs
+++ b/src/rust/src/transaction_ffi.rs
@@ -84,10 +84,52 @@ impl TransparentAuthorizingContext for TransparentAuth {
     }
 }
 
-struct MapTransparent {
+pub(crate) struct MapTransparent {
     auth: TransparentAuth,
 }
 
+impl MapTransparent {
+    pub(crate) fn parse(all_prev_outputs: &[u8], tx: &Transaction) -> Result<Self, String> {
+        let mut cursor = Cursor::new(all_prev_outputs);
+        match Vector::read(&mut cursor, transparent::TxOut::read) {
+            Err(e) => Err(format!("Invalid all_prev_outputs field: {}", e)),
+            Ok(_) if (cursor.position() as usize) != all_prev_outputs.len() => {
+                Err("all_prev_outputs had trailing data".into())
+            }
+            Ok(all_prev_outputs)
+                if tx.transparent_bundle().map_or(false, |t| {
+                    // Coinbase txs have one fake input.
+                    t.is_coinbase() && !all_prev_outputs.is_empty()
+                }) =>
+            {
+                Err(format!(
+                    "all_prev_outputs should be empty for a coinbase tx but has length {}",
+                    all_prev_outputs.len()
+                ))
+            }
+            Ok(all_prev_outputs)
+                if tx
+                    .transparent_bundle()
+                    .map(|t| {
+                        // For non-coinbase txs, every input is real.
+                        !t.is_coinbase() && t.vin.len() != all_prev_outputs.len()
+                    })
+                    // If we have no transparent part, we should have no prev outputs.
+                    .unwrap_or_else(|| !all_prev_outputs.is_empty()) =>
+            {
+                Err(format!(
+                    "all_prev_outputs is incorrect length {} (should be {})",
+                    all_prev_outputs.len(),
+                    tx.transparent_bundle().map(|t| t.vin.len()).unwrap_or(0),
+                ))
+            }
+            Ok(all_prev_outputs) => Ok(MapTransparent {
+                auth: TransparentAuth { all_prev_outputs },
+            }),
+        }
+    }
+}
+
 impl transparent::MapAuth<transparent::Authorized, TransparentAuth> for MapTransparent {
     fn map_script_sig(
         &self,
@@ -158,48 +200,12 @@ pub extern "C" fn zcash_transaction_precomputed_init(
             let all_prev_outputs =
                 unsafe { slice::from_raw_parts(all_prev_outputs, all_prev_outputs_len) };
 
-            let mut cursor = Cursor::new(all_prev_outputs);
-            let f_transparent = match Vector::read(&mut cursor, transparent::TxOut::read) {
+            let f_transparent = match MapTransparent::parse(all_prev_outputs, &tx) {
+                Ok(f) => f,
                 Err(e) => {
-                    error!("Invalid all_prev_outputs field: {}", e);
-                    return ptr::null_mut();
-                }
-                Ok(_) if (cursor.position() as usize) != all_prev_outputs.len() => {
-                    error!("all_prev_outputs had trailing data");
-                    return ptr::null_mut();
-                }
-                Ok(all_prev_outputs)
-                    if tx.transparent_bundle().map_or(false, |t| {
-                        // Coinbase txs have one fake input.
-                        t.is_coinbase() && !all_prev_outputs.is_empty()
-                    }) =>
-                {
-                    error!(
-                        "all_prev_outputs should be empty for a coinbase tx but has length {}",
-                        all_prev_outputs.len()
-                    );
-                    return ptr::null_mut();
-                }
-                Ok(all_prev_outputs)
-                    if tx
-                        .transparent_bundle()
-                        .map(|t| {
-                            // For non-coinbase txs, every input is real.
-                            !t.is_coinbase() && t.vin.len() != all_prev_outputs.len()
-                        })
-                        // If we have no transparent part, we should have no prev outputs.
-                        .unwrap_or_else(|| !all_prev_outputs.is_empty()) =>
-                {
-                    error!(
-                        "all_prev_outputs is incorrect length {} (should be {})",
-                        all_prev_outputs.len(),
-                        tx.transparent_bundle().map(|t| t.vin.len()).unwrap_or(0),
-                    );
+                    error!("{}", e);
                     return ptr::null_mut();
                 }
-                Ok(all_prev_outputs) => MapTransparent {
-                    auth: TransparentAuth { all_prev_outputs },
-                },
             };
 
             let tx = tx.into_data().map_authorization(f_transparent, (), ());
diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp
index e0193db33bd..b97249f8b21 100644
--- a/src/script/interpreter.cpp
+++ b/src/script/interpreter.cpp
@@ -1122,8 +1122,9 @@ uint256 GetShieldedSpendsHash(const CTransaction& txTo) {
 
 uint256 GetShieldedOutputsHash(const CTransaction& txTo) {
     CBLAKE2bWriter ss(SER_GETHASH, 0, ZCASH_SHIELDED_OUTPUTS_HASH_PERSONALIZATION);
+    auto ssRs = ToRustStream(ss);
     for (const auto& output : txTo.GetSaplingOutputs()) {
-        ss << output;
+        output.serialize_v4(*ssRs);
     }
     return ss.GetHash();
 }
diff --git a/src/streams_rust.cpp b/src/streams_rust.cpp
index 9b0eef45d01..a056e795489 100644
--- a/src/streams_rust.cpp
+++ b/src/streams_rust.cpp
@@ -20,6 +20,10 @@ rust::Box<stream::CppStream> ToRustStream(CHashWriter& writer) {
     return stream::from_hash_writer(writer);
 }
 
+rust::Box<stream::CppStream> ToRustStream(CBLAKE2bWriter& writer) {
+    return stream::from_blake2b_writer(writer);
+}
+
 rust::Box<stream::CppStream> ToRustStream(CSizeComputer& sc) {
     return stream::from_size_computer(sc);
 }
diff --git a/src/streams_rust.h b/src/streams_rust.h
index a255b976af1..12bf4436c71 100644
--- a/src/streams_rust.h
+++ b/src/streams_rust.h
@@ -16,6 +16,7 @@ rust::Box<stream::CppStream> ToRustStream(RustDataStream& stream);
 rust::Box<stream::CppStream> ToRustStream(CAutoFile& file);
 rust::Box<stream::CppStream> ToRustStream(CBufferedFile& file);
 rust::Box<stream::CppStream> ToRustStream(CHashWriter& writer);
+rust::Box<stream::CppStream> ToRustStream(CBLAKE2bWriter& writer);
 rust::Box<stream::CppStream> ToRustStream(CSizeComputer& sc);
 
 #endif // ZCASH_STREAMS_RUST_H
diff --git a/src/transaction_builder.cpp b/src/transaction_builder.cpp
index 4cff82fe2fd..93bc15764e8 100644
--- a/src/transaction_builder.cpp
+++ b/src/transaction_builder.cpp
@@ -13,23 +13,36 @@
 #include "zcash/Note.hpp"
 
 #include <librustzcash.h>
+#include <rust/builder.h>
 #include <rust/ed25519.h>
 
-uint256 ProduceZip244SignatureHash(
+uint256 ProduceShieldedSignatureHash(
+    uint32_t consensusBranchId,
     const CTransaction& tx,
     const std::vector<CTxOut>& allPrevOutputs,
-    const orchard::UnauthorizedBundle& orchardBundle)
+    const sapling::UnauthorizedBundle& saplingBundle,
+    const std::optional<orchard::UnauthorizedBundle>& orchardBundle)
 {
-    uint256 dataToBeSigned;
-    PrecomputedTransactionData local(tx, allPrevOutputs);
-    if (!zcash_builder_zip244_shielded_signature_digest(
-        local.preTx.release(),
-        orchardBundle.inner.get(),
-        dataToBeSigned.begin()))
-    {
-        throw std::logic_error("ZIP 225 signature hash failed");
+    CDataStream sTx(SER_NETWORK, PROTOCOL_VERSION);
+    sTx << tx;
+
+    CDataStream sAllPrevOutputs(SER_NETWORK, PROTOCOL_VERSION);
+    sAllPrevOutputs << allPrevOutputs;
+
+    const OrchardUnauthorizedBundlePtr* orchardBundlePtr;
+    if (orchardBundle.has_value()) {
+        orchardBundlePtr = orchardBundle->inner.get();
+    } else {
+        orchardBundlePtr = nullptr;
     }
-    return dataToBeSigned;
+
+    auto dataToBeSigned = builder::shielded_signature_digest(
+        consensusBranchId,
+        {reinterpret_cast<const unsigned char*>(sTx.data()), sTx.size()},
+        {reinterpret_cast<const unsigned char*>(sAllPrevOutputs.data()), sAllPrevOutputs.size()},
+        saplingBundle,
+        orchardBundlePtr);
+    return uint256::FromRawBytes(dataToBeSigned);
 }
 
 namespace orchard {
@@ -116,63 +129,6 @@ std::optional<OrchardBundle> UnauthorizedBundle::ProveAndSign(
 
 } // namespace orchard
 
-SpendDescriptionInfo::SpendDescriptionInfo(
-    libzcash::SaplingExpandedSpendingKey expsk,
-    libzcash::SaplingNote note,
-    uint256 anchor,
-    SaplingWitness witness) : expsk(expsk), note(note), anchor(anchor), witness(witness)
-{
-    librustzcash_sapling_generate_r(alpha.begin());
-}
-
-std::optional<OutputDescription> OutputDescriptionInfo::Build(rust::Box<sapling::Prover>& ctx) {
-    auto cmu = this->note.cmu();
-    if (!cmu) {
-        return std::nullopt;
-    }
-
-    libzcash::SaplingNotePlaintext notePlaintext(this->note, this->memo);
-
-    auto res = notePlaintext.encrypt(this->note.pk_d);
-    if (!res) {
-        return std::nullopt;
-    }
-    auto enc = res.value();
-    auto encryptor = enc.second;
-
-    libzcash::SaplingPaymentAddress address(this->note.d, this->note.pk_d);
-    CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
-    ss << address;
-    std::array<unsigned char, 43> addressBytes;
-    std::move(ss.begin(), ss.end(), addressBytes.begin());
-
-    std::array<unsigned char, 32> cvBytes;
-    libzcash::GrothProof zkproof;
-    uint256 rcm = this->note.rcm();
-    if (!ctx->create_output_proof(
-            encryptor.get_esk().GetRawBytes(),
-            addressBytes,
-            rcm.GetRawBytes(),
-            this->note.value(),
-            cvBytes,
-            zkproof)) {
-        return std::nullopt;
-    }
-
-    auto cv = uint256::FromRawBytes(cvBytes);
-    auto ephemeralKey = encryptor.get_epk();
-    auto encCiphertext = enc.first;
-
-    libzcash::SaplingOutgoingPlaintext outPlaintext(this->note.pk_d, encryptor.get_esk());
-    auto outCiphertext = outPlaintext.encrypt(
-        this->ovk,
-        cv,
-        *cmu,
-        encryptor);
-
-    return OutputDescription(cv, *cmu, ephemeralKey, encCiphertext, outCiphertext, zkproof);
-}
-
 JSDescription JSDescriptionInfo::BuildDeterministic(
     bool computeProof,
     uint256 *esk // payment disclosure
@@ -250,18 +206,19 @@ std::string TransactionBuilderResult::GetError() {
 }
 
 TransactionBuilder::TransactionBuilder(
-    const Consensus::Params& consensusParams,
+    const CChainParams& params,
     int nHeight,
     std::optional<uint256> orchardAnchor,
     const CKeyStore* keystore,
     const CCoinsViewCache* coinsView,
     CCriticalSection* cs_coinsView) :
-    consensusParams(consensusParams),
+    consensusParams(params.GetConsensus()),
     nHeight(nHeight),
     keystore(keystore),
     coinsView(coinsView),
     cs_coinsView(cs_coinsView),
-    orchardAnchor(orchardAnchor)
+    orchardAnchor(orchardAnchor),
+    saplingBuilder(sapling::new_builder(*params.RustNetwork(), nHeight))
 {
     mtx = CreateNewContextualCMutableTransaction(
             consensusParams, nHeight,
@@ -351,9 +308,8 @@ void TransactionBuilder::AddOrchardOutput(
 }
 
 void TransactionBuilder::AddSaplingSpend(
-    libzcash::SaplingExpandedSpendingKey expsk,
+    libzcash::SaplingExtendedSpendingKey extsk,
     libzcash::SaplingNote note,
-    uint256 anchor,
     SaplingWitness witness)
 {
     // Sanity check: cannot add Sapling spend to pre-Sapling transaction
@@ -361,13 +317,29 @@ void TransactionBuilder::AddSaplingSpend(
         throw std::runtime_error("TransactionBuilder cannot add Sapling spend to pre-Sapling transaction");
     }
 
-    // Consistency check: all anchors must equal the first one
-    if (spends.size() > 0 && spends[0].anchor != anchor) {
-        throw JSONRPCError(RPC_WALLET_ERROR, "Anchor does not match previously-added Sapling spends.");
-    }
+    CDataStream ssExtSk(SER_NETWORK, PROTOCOL_VERSION);
+    ssExtSk << extsk;
+
+    libzcash::SaplingPaymentAddress recipient(note.d, note.pk_d);
 
-    spends.emplace_back(expsk, note, anchor, witness);
-    mtx.valueBalanceSapling += note.value();
+    CDataStream ssPath(SER_NETWORK, PROTOCOL_VERSION);
+    ssPath << witness.path();
+    std::array<unsigned char, 1065> merkle_path;
+    std::move(ssPath.begin(), ssPath.end(), merkle_path.begin());
+
+    saplingBuilder->add_spend(
+        {reinterpret_cast<uint8_t*>(ssExtSk.data()), ssExtSk.size()},
+        note.d,
+        recipient.GetRawBytes(),
+        note.value(),
+        note.rcm().GetRawBytes(),
+        merkle_path);
+    if (!firstSaplingSpendAddr.has_value()) {
+        firstSaplingSpendAddr = std::make_pair(
+            extsk.ToXFVK().GetOVKs().first,
+            libzcash::SaplingPaymentAddress(note.d, note.pk_d));
+    }
+    valueBalanceSapling += note.value();
 }
 
 void TransactionBuilder::AddSaplingOutput(
@@ -381,15 +353,10 @@ void TransactionBuilder::AddSaplingOutput(
         throw std::runtime_error("TransactionBuilder cannot add Sapling output to pre-Sapling transaction");
     }
 
-    libzcash::Zip212Enabled zip_212_enabled = libzcash::Zip212Enabled::BeforeZip212;
-    // We use nHeight = chainActive.Height() + 1 since the output will be included in the next block
-    if (consensusParams.NetworkUpgradeActive(nHeight, Consensus::UPGRADE_CANOPY)) {
-        zip_212_enabled = libzcash::Zip212Enabled::AfterZip212;
-    }
+    auto memoBytes = libzcash::Memo::ToBytes(memo);
 
-    auto note = libzcash::SaplingNote(to, value, zip_212_enabled);
-    outputs.emplace_back(ovk, note, memo);
-    mtx.valueBalanceSapling -= value;
+    saplingBuilder->add_recipient(ovk.GetRawBytes(), to.GetRawBytes(), value, memoBytes);
+    valueBalanceSapling -= value;
 }
 
 void TransactionBuilder::AddSproutInput(
@@ -486,7 +453,7 @@ TransactionBuilderResult TransactionBuilder::Build()
     //
 
     // Valid change
-    CAmount change = mtx.valueBalanceSapling + valueBalanceOrchard - fee;
+    CAmount change = valueBalanceSapling + valueBalanceOrchard - fee;
     for (auto jsInput : jsInputs) {
         change += jsInput.note.value();
     }
@@ -525,11 +492,11 @@ TransactionBuilderResult TransactionBuilder::Build()
         } else if (firstOrchardSpendAddr.has_value()) {
             auto ovk = orchardSpendingKeys[0].ToFullViewingKey().ToInternalOutgoingViewingKey();
             AddOrchardOutput(ovk, firstOrchardSpendAddr.value(), change, std::nullopt);
-        } else if (!spends.empty()) {
-            auto fvk = spends[0].expsk.full_viewing_key();
-            auto note = spends[0].note;
-            libzcash::SaplingPaymentAddress changeAddr(note.d, note.pk_d);
-            AddSaplingOutput(fvk.ovk, changeAddr, change, std::nullopt);
+        } else if (firstSaplingSpendAddr.has_value()) {
+            uint256 ovk;
+            libzcash::SaplingPaymentAddress changeAddr;
+            std::tie(ovk, changeAddr) = firstSaplingSpendAddr.value();
+            AddSaplingOutput(ovk, changeAddr, change, std::nullopt);
         } else if (!jsInputs.empty()) {
             auto changeAddr = jsInputs[0].key.address();
             AddSproutOutput(changeAddr, change, std::nullopt);
@@ -556,65 +523,13 @@ TransactionBuilderResult TransactionBuilder::Build()
     // Sapling spends and outputs
     //
 
-    auto ctx = sapling::init_prover();
-
-    // Create Sapling SpendDescriptions
-    for (auto spend : spends) {
-        auto cm = spend.note.cmu();
-        auto nf = spend.note.nullifier(
-            spend.expsk.full_viewing_key(), spend.witness.position());
-        if (!cm || !nf) {
-            return TransactionBuilderResult("Spend is invalid");
-        }
-
-        CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
-        ss << spend.witness.path();
-        std::array<unsigned char, 1065> witness;
-        std::move(ss.begin(), ss.end(), witness.begin());
-
-        std::array<unsigned char, 32> cv;
-        std::array<unsigned char, 32> rk;
-        libzcash::GrothProof zkproof;
-        uint256 rcm = spend.note.rcm();
-        if (!ctx->create_spend_proof(
-                spend.expsk.full_viewing_key().ak.GetRawBytes(),
-                spend.expsk.nsk.GetRawBytes(),
-                spend.note.d,
-                rcm.GetRawBytes(),
-                spend.alpha.GetRawBytes(),
-                spend.note.value(),
-                spend.anchor.GetRawBytes(),
-                witness,
-                cv,
-                rk,
-                zkproof)) {
-            return TransactionBuilderResult("Spend proof failed");
-        }
-
-        SpendDescription::spend_auth_sig_t spendAuthSig;
-        mtx.vShieldedSpend.push_back(SpendDescription(
-            uint256::FromRawBytes(cv),
-            spend.anchor,
-            *nf,
-            uint256::FromRawBytes(rk),
-            zkproof,
-            spendAuthSig));
-    }
-
-    // Create Sapling OutputDescriptions
-    for (auto output : outputs) {
-        // Check this out here as well to provide better logging.
-        if (!output.note.cmu()) {
-            return TransactionBuilderResult("Output is invalid");
-        }
-
-        auto odesc = output.Build(ctx);
-        if (!odesc) {
-            return TransactionBuilderResult("Failed to create output description");
-        }
-
-        mtx.vShieldedOutput.push_back(odesc.value());
+    std::optional<rust::Box<sapling::UnauthorizedBundle>> maybeSaplingBundle;
+    try {
+        maybeSaplingBundle = sapling::build_bundle(std::move(saplingBuilder), nHeight);
+    } catch (rust::Error e) {
+        return TransactionBuilderResult("Failed to build Sapling bundle: " + std::string(e.what()));
     }
+    auto saplingBundle = std::move(maybeSaplingBundle.value());
 
     //
     // Sprout JoinSplits
@@ -641,9 +556,14 @@ TransactionBuilderResult TransactionBuilder::Build()
     // Empty output script.
     uint256 dataToBeSigned;
     try {
-        if (orchardBundle.has_value()) {
-            // Orchard is only usable with v5+ transactions.
-            dataToBeSigned = ProduceZip244SignatureHash(mtx, tIns, orchardBundle.value());
+        if (mtx.fOverwintered) {
+            // ProduceShieldedSignatureHash is only usable with v3+ transactions.
+            dataToBeSigned = ProduceShieldedSignatureHash(
+                consensusBranchId,
+                mtx,
+                tIns,
+                *saplingBundle,
+                orchardBundle);
         } else {
             CScript scriptCode;
             const PrecomputedTransactionData txdata(mtx, tIns);
@@ -666,17 +586,12 @@ TransactionBuilderResult TransactionBuilder::Build()
     }
 
     // Create Sapling spendAuth and binding signatures
-    for (size_t i = 0; i < spends.size(); i++) {
-        librustzcash_sapling_spend_sig(
-            spends[i].expsk.ask.begin(),
-            spends[i].alpha.begin(),
-            dataToBeSigned.begin(),
-            mtx.vShieldedSpend[i].spend_auth_sig_mut().data());
-    }
-    ctx->binding_sig(
-        mtx.valueBalanceSapling,
-        dataToBeSigned.GetRawBytes(),
-        mtx.bindingSig);
+    try {
+        mtx.saplingBundle = sapling::apply_bundle_signatures(
+            std::move(saplingBundle), dataToBeSigned.GetRawBytes());
+    } catch (rust::Error e) {
+        return TransactionBuilderResult(e.what());
+    }
 
     // Create Sprout joinSplitSig
     ed25519::sign(
diff --git a/src/transaction_builder.h b/src/transaction_builder.h
index ded62fd9f3e..7645ac87721 100644
--- a/src/transaction_builder.h
+++ b/src/transaction_builder.h
@@ -5,6 +5,7 @@
 #ifndef ZCASH_TRANSACTION_BUILDER_H
 #define ZCASH_TRANSACTION_BUILDER_H
 
+#include "chainparams.h"
 #include "coins.h"
 #include "consensus/params.h"
 #include "keystore.h"
@@ -29,10 +30,12 @@
 class OrchardWallet;
 namespace orchard { class UnauthorizedBundle; }
 
-uint256 ProduceZip244SignatureHash(
+uint256 ProduceShieldedSignatureHash(
+    uint32_t consensusBranchId,
     const CTransaction& tx,
     const std::vector<CTxOut>& allPrevOutputs,
-    const orchard::UnauthorizedBundle& orchardBundle);
+    const sapling::UnauthorizedBundle& saplingBundle,
+    const std::optional<orchard::UnauthorizedBundle>& orchardBundle);
 
 namespace orchard {
 
@@ -145,12 +148,14 @@ class UnauthorizedBundle {
     friend class Builder;
     // The parentheses here are necessary to avoid the following compilation error:
     //     error: C++ requires a type specifier for all declarations
-    //             friend uint256 ::ProduceZip244SignatureHash(
+    //             friend uint256 ::ProduceShieldedSignatureHash(
     //             ~~~~~~           ^
-    friend uint256 (::ProduceZip244SignatureHash(
+    friend uint256 (::ProduceShieldedSignatureHash(
+        uint32_t consensusBranchId,
         const CTransaction& tx,
         const std::vector<CTxOut>& allPrevOutputs,
-        const UnauthorizedBundle& orchardBundle));
+        const sapling::UnauthorizedBundle& saplingBundle,
+        const std::optional<orchard::UnauthorizedBundle>& orchardBundle));
 
 public:
     // UnauthorizedBundle should never be copied
@@ -179,33 +184,6 @@ class UnauthorizedBundle {
 
 } // namespace orchard
 
-struct SpendDescriptionInfo {
-    libzcash::SaplingExpandedSpendingKey expsk;
-    libzcash::SaplingNote note;
-    uint256 alpha;
-    uint256 anchor;
-    SaplingWitness witness;
-
-    SpendDescriptionInfo(
-        libzcash::SaplingExpandedSpendingKey expsk,
-        libzcash::SaplingNote note,
-        uint256 anchor,
-        SaplingWitness witness);
-};
-
-struct OutputDescriptionInfo {
-    uint256 ovk;
-    libzcash::SaplingNote note;
-    std::optional<libzcash::Memo> memo;
-
-    OutputDescriptionInfo(
-        uint256 ovk,
-        libzcash::SaplingNote note,
-        std::optional<libzcash::Memo> memo) : ovk(ovk), note(note), memo(memo) {}
-
-    std::optional<OutputDescription> Build(rust::Box<sapling::Prover>& ctx);
-};
-
 struct JSDescriptionInfo {
     ed25519::VerificationKey joinSplitPubKey;
     uint256 anchor;
@@ -264,11 +242,12 @@ class TransactionBuilder
     std::optional<uint256> orchardAnchor;
     std::optional<orchard::Builder> orchardBuilder;
     CAmount valueBalanceOrchard = 0;
+    rust::Box<sapling::Builder> saplingBuilder;
+    CAmount valueBalanceSapling = 0;
 
     std::vector<libzcash::OrchardSpendingKey> orchardSpendingKeys;
     std::optional<libzcash::OrchardRawAddress> firstOrchardSpendAddr;
-    std::vector<SpendDescriptionInfo> spends;
-    std::vector<OutputDescriptionInfo> outputs;
+    std::optional<std::pair<uint256, libzcash::SaplingPaymentAddress>> firstSaplingSpendAddr;
     std::vector<libzcash::JSInput> jsInputs;
     std::vector<libzcash::JSOutput> jsOutputs;
     std::vector<CTxOut> tIns;
@@ -279,7 +258,6 @@ class TransactionBuilder
     std::optional<CTxDestination> tChangeAddr;
 
 public:
-    TransactionBuilder() {}
     /**
      * If an Orchard anchor is provided, the resulting builder will always attempt
      * to construct a V5 transaction unless NU5 is not yet active. If an Orchard
@@ -287,7 +265,7 @@ class TransactionBuilder
      * value of the -preferredtxversion configuration flag.
      */
     TransactionBuilder(
-        const Consensus::Params& consensusParams,
+        const CChainParams& params,
         int nHeight,
         std::optional<uint256> orchardAnchor,
         const CKeyStore* keyStore = nullptr,
@@ -308,11 +286,15 @@ class TransactionBuilder
         orchardAnchor(std::move(builder.orchardAnchor)),
         orchardBuilder(std::move(builder.orchardBuilder)),
         valueBalanceOrchard(std::move(builder.valueBalanceOrchard)),
-        spends(std::move(builder.spends)),
-        outputs(std::move(builder.outputs)),
+        saplingBuilder(std::move(builder.saplingBuilder)),
+        valueBalanceSapling(std::move(builder.valueBalanceSapling)),
+        orchardSpendingKeys(std::move(orchardSpendingKeys)),
+        firstOrchardSpendAddr(std::move(firstOrchardSpendAddr)),
+        firstSaplingSpendAddr(std::move(firstSaplingSpendAddr)),
         jsInputs(std::move(builder.jsInputs)),
         jsOutputs(std::move(builder.jsOutputs)),
         tIns(std::move(builder.tIns)),
+        orchardChangeAddr(std::move(builder.orchardChangeAddr)),
         saplingChangeAddr(std::move(builder.saplingChangeAddr)),
         sproutChangeAddr(std::move(builder.sproutChangeAddr)),
         tChangeAddr(std::move(builder.tChangeAddr)) {}
@@ -328,11 +310,15 @@ class TransactionBuilder
             fee = std::move(builder.fee);
             orchardBuilder = std::move(builder.orchardBuilder);
             valueBalanceOrchard = std::move(builder.valueBalanceOrchard);
-            spends = std::move(builder.spends);
-            outputs = std::move(builder.outputs);
+            saplingBuilder = std::move(builder.saplingBuilder);
+            valueBalanceSapling = std::move(builder.valueBalanceSapling);
+            orchardSpendingKeys = std::move(builder.orchardSpendingKeys),
+            firstOrchardSpendAddr = std::move(builder.firstOrchardSpendAddr),
+            firstSaplingSpendAddr = std::move(builder.firstSaplingSpendAddr),
             jsInputs = std::move(builder.jsInputs);
             jsOutputs = std::move(builder.jsOutputs);
             tIns = std::move(builder.tIns);
+            orchardChangeAddr = std::move(builder.orchardChangeAddr);
             saplingChangeAddr = std::move(builder.saplingChangeAddr);
             sproutChangeAddr = std::move(builder.sproutChangeAddr);
             tChangeAddr = std::move(builder.tChangeAddr);
@@ -361,9 +347,8 @@ class TransactionBuilder
     // Throws if the anchor does not match the anchor used by
     // previously-added Sapling spends.
     void AddSaplingSpend(
-        libzcash::SaplingExpandedSpendingKey expsk,
+        libzcash::SaplingExtendedSpendingKey extsk,
         libzcash::SaplingNote note,
-        uint256 anchor,
         SaplingWitness witness);
 
     void AddSaplingOutput(
diff --git a/src/txmempool.cpp b/src/txmempool.cpp
index 5241afbd4f0..57798058ce9 100644
--- a/src/txmempool.cpp
+++ b/src/txmempool.cpp
@@ -705,7 +705,7 @@ void CTxMemPool::removeWithAnchor(const uint256 &invalidRoot, ShieldedType type)
             break;
             case SAPLING:
                 for (const auto& spendDescription : tx.GetSaplingSpends()) {
-                    if (spendDescription.anchor() == invalidRoot) {
+                    if (uint256::FromRawBytes(spendDescription.anchor()) == invalidRoot) {
                         transactionsToRemove.push_back(tx);
                         break;
                     }
@@ -759,7 +759,7 @@ void CTxMemPool::removeConflicts(const CTransaction &tx, std::list<CTransaction>
         }
     }
     for (const auto& spendDescription : tx.GetSaplingSpends()) {
-        std::map<uint256, const CTransaction*>::iterator it = mapSaplingNullifiers.find(spendDescription.nullifier());
+        auto it = mapSaplingNullifiers.find(spendDescription.nullifier());
         if (it != mapSaplingNullifiers.end()) {
             const CTransaction &txConflict = *it->second;
             if (txConflict != tx) {
@@ -974,31 +974,18 @@ void CTxMemPool::check(const CCoinsViewCache *pcoins) const
         assert(it->first == it->second.ptx->vin[it->second.n].prevout);
     }
 
-    checkNullifiers(SPROUT);
-    checkNullifiers(SAPLING);
-    checkNullifiers(ORCHARD);
+    checkNullifiers(mapSproutNullifiers);
+    checkNullifiers(mapSaplingNullifiers);
+    checkNullifiers(mapOrchardNullifiers);
 
     assert(totalTxSize == checkTotal);
     assert(innerUsage == cachedInnerUsage);
 }
 
-void CTxMemPool::checkNullifiers(ShieldedType type) const
+template<typename T>
+void CTxMemPool::checkNullifiers(const std::map<T, const CTransaction*>& mapToUse) const
 {
-    const std::map<uint256, const CTransaction*>* mapToUse;
-    switch (type) {
-        case SPROUT:
-            mapToUse = &mapSproutNullifiers;
-            break;
-        case SAPLING:
-            mapToUse = &mapSaplingNullifiers;
-            break;
-        case ORCHARD:
-            mapToUse = &mapOrchardNullifiers;
-            break;
-        default:
-            throw runtime_error("Unknown nullifier type");
-    }
-    for (const auto& entry : *mapToUse) {
+    for (const auto& entry : mapToUse) {
         uint256 hash = entry.second->GetHash();
         CTxMemPool::indexed_transaction_set::const_iterator findTx = mapTx.find(hash);
         const CTransaction& tx = findTx->GetTx();
@@ -1148,7 +1135,7 @@ bool CTxMemPool::nullifierExists(const uint256& nullifier, ShieldedType type) co
         case SPROUT:
             return mapSproutNullifiers.count(nullifier);
         case SAPLING:
-            return mapSaplingNullifiers.count(nullifier);
+            return mapSaplingNullifiers.count(nullifier.GetRawBytes());
         case ORCHARD:
             return mapOrchardNullifiers.count(nullifier);
         default:
diff --git a/src/txmempool.h b/src/txmempool.h
index 8e95dc84134..2cbc73be1f8 100644
--- a/src/txmempool.h
+++ b/src/txmempool.h
@@ -22,6 +22,7 @@
 #include "spentindex.h"
 #include "util/time.h"
 #include "weighted_map.h"
+#include "zcash/Note.hpp"
 
 #undef foreach
 #include "boost/multi_index_container.hpp"
@@ -353,12 +354,13 @@ class CTxMemPool
     uint64_t nNotifiedSequence = 0;
 
     std::map<uint256, const CTransaction*> mapSproutNullifiers;
-    std::map<uint256, const CTransaction*> mapSaplingNullifiers;
+    std::map<libzcash::nullifier_t, const CTransaction*> mapSaplingNullifiers;
     std::map<uint256, const CTransaction*> mapOrchardNullifiers;
     RecentlyEvictedList* recentlyEvicted = new RecentlyEvictedList(GetNodeClock(), DEFAULT_MEMPOOL_EVICTION_MEMORY_MINUTES * 60);
     MempoolLimitTxSet* limitSet = new MempoolLimitTxSet(DEFAULT_MEMPOOL_TOTAL_COST_LIMIT);
 
-    void checkNullifiers(ShieldedType type) const;
+    template<typename T>
+    void checkNullifiers(const std::map<T, const CTransaction*>& mapToUse) const;
 
     CFeeRate minReasonableRelayFee;
 
diff --git a/src/wallet/asyncrpcoperation_mergetoaddress.cpp b/src/wallet/asyncrpcoperation_mergetoaddress.cpp
index d1c64aabb86..22f098931c5 100644
--- a/src/wallet/asyncrpcoperation_mergetoaddress.cpp
+++ b/src/wallet/asyncrpcoperation_mergetoaddress.cpp
@@ -183,7 +183,7 @@ main_impl(
         LogPrint("zrpc", "%s: fee: %s\n", id, FormatMoney(effects.GetFee()));
 
         auto buildResult = effects.ApproveAndBuild(
-                chainparams.GetConsensus(),
+                chainparams,
                 wallet,
                 chainActive,
                 strategy);
diff --git a/src/wallet/asyncrpcoperation_saplingmigration.cpp b/src/wallet/asyncrpcoperation_saplingmigration.cpp
index 6aaf7ffb7b2..a30281db894 100644
--- a/src/wallet/asyncrpcoperation_saplingmigration.cpp
+++ b/src/wallet/asyncrpcoperation_saplingmigration.cpp
@@ -112,7 +112,7 @@ bool AsyncRPCOperation_saplingmigration::main_impl() {
     CCoinsViewCache coinsView(pcoinsTip);
     do {
         CAmount amountToSend = chooseAmount(availableFunds);
-        auto builder = TransactionBuilder(consensusParams, targetHeight_, std::nullopt, pwalletMain, &coinsView, &cs_main);
+        auto builder = TransactionBuilder(Params(), targetHeight_, std::nullopt, pwalletMain, &coinsView, &cs_main);
         builder.SetExpiryHeight(targetHeight_ + MIGRATION_EXPIRY_DELTA);
         LogPrint("zrpcunsafe", "%s: Beginning creating transaction with Sapling output amount=%s\n", getId(), FormatMoney(amountToSend - LEGACY_DEFAULT_FEE));
         std::vector<SproutNoteEntry> fromNotes;
diff --git a/src/wallet/asyncrpcoperation_sendmany.cpp b/src/wallet/asyncrpcoperation_sendmany.cpp
index f565b9dabc4..657e643221a 100644
--- a/src/wallet/asyncrpcoperation_sendmany.cpp
+++ b/src/wallet/asyncrpcoperation_sendmany.cpp
@@ -180,7 +180,7 @@ AsyncRPCOperation_sendmany::main_impl(CWallet& wallet) {
                 LogPrint("zrpc", "%s: fee: %s\n", getId(), FormatMoney(effects.GetFee()));
 
                 auto buildResult = effects.ApproveAndBuild(
-                        Params().GetConsensus(),
+                        Params(),
                         wallet,
                         chainActive,
                         strategy_);
diff --git a/src/wallet/asyncrpcoperation_shieldcoinbase.cpp b/src/wallet/asyncrpcoperation_shieldcoinbase.cpp
index a52aa951ee9..6f85b06ad83 100644
--- a/src/wallet/asyncrpcoperation_shieldcoinbase.cpp
+++ b/src/wallet/asyncrpcoperation_shieldcoinbase.cpp
@@ -233,7 +233,7 @@ uint256 AsyncRPCOperation_shieldcoinbase::main_impl(CWallet& wallet) {
         LogPrint("zrpc", "%s: fee: %s\n", getId(), FormatMoney(effects_->GetFee()));
 
         auto buildResult = effects_->ApproveAndBuild(
-                Params().GetConsensus(),
+                Params(),
                 wallet,
                 chainActive,
                 strategy_);
diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp
index 36ee9212646..d8fb8940992 100644
--- a/src/wallet/rpcwallet.cpp
+++ b/src/wallet/rpcwallet.cpp
@@ -2775,7 +2775,7 @@ UniValue z_listunspent(const UniValue& params, bool fHelp)
 
     std::optional<NoteFilter> noteFilter = std::nullopt;
     std::set<std::pair<libzcash::SproutPaymentAddress, uint256>> sproutNullifiers;
-    std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> saplingNullifiers;
+    std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> saplingNullifiers;
 
     KeyIO keyIO(Params());
     // User has supplied zaddrs to filter on
@@ -3794,7 +3794,7 @@ UniValue z_listreceivedbyaddress(const UniValue& params, bool fHelp)
 
     auto push_sapling_result = [&](const libzcash::SaplingPaymentAddress& addr) -> void {
         bool hasSpendingKey = pwalletMain->HaveSaplingSpendingKeyForAddress(addr);
-        std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> nullifierSet;
+        std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> nullifierSet;
         if (hasSpendingKey) {
             nullifierSet = pwalletMain->GetSaplingNullifiers({addr});
         }
@@ -4732,6 +4732,7 @@ size_t EstimateTxSize(
     // Depending on the input notes, the actual tx size may turn out to be larger and perhaps invalid.
     size_t txsize = 0;
     size_t taddrRecipientCount = 0;
+    size_t saplingRecipientCount = 0;
     size_t orchardRecipientCount = 0;
     for (const Payment& recipient : recipients) {
         examine(recipient.GetAddress(), match {
@@ -4742,7 +4743,7 @@ size_t EstimateTxSize(
                 taddrRecipientCount += 1;
             },
             [&](const libzcash::SaplingPaymentAddress& addr) {
-                mtx.vShieldedOutput.push_back(RandomInvalidOutputDescription());
+                saplingRecipientCount += 1;
             },
             [&](const libzcash::SproutPaymentAddress& addr) {
                 JSDescription jsdesc;
@@ -4753,7 +4754,7 @@ size_t EstimateTxSize(
                 if (addr.GetOrchardReceiver().has_value()) {
                     orchardRecipientCount += 1;
                 } else if (addr.GetSaplingReceiver().has_value()) {
-                    mtx.vShieldedOutput.push_back(RandomInvalidOutputDescription());
+                    saplingRecipientCount += 1;
                 } else if (addr.GetP2PKHReceiver().has_value()
                            || addr.GetP2SHReceiver().has_value()) {
                     taddrRecipientCount += 1;
@@ -4771,6 +4772,8 @@ size_t EstimateTxSize(
         mtx.nVersionGroupId = ZIP225_VERSION_GROUP_ID;
         mtx.nVersion = ZIP225_TX_VERSION;
     }
+    // Fine to call this because we are only testing that `mtx` is a valid size.
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(0, saplingRecipientCount, 0);
 
     CTransaction tx(mtx);
     txsize += GetSerializeSize(tx, SER_NETWORK, tx.nVersion);
diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
index b1d21ffd918..41c6bf136e7 100644
--- a/src/wallet/wallet.cpp
+++ b/src/wallet/wallet.cpp
@@ -1548,9 +1548,9 @@ std::set<std::pair<libzcash::SproutPaymentAddress, uint256>> CWallet::GetSproutN
     return nullifierSet;
 }
 
-std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> CWallet::GetSaplingNullifiers(
+std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> CWallet::GetSaplingNullifiers(
         const std::set<libzcash::SaplingPaymentAddress>& addresses) {
-    std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> nullifierSet;
+    std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> nullifierSet;
     if (!addresses.empty()) {
         // Sapling ivk -> list of addrs map
         // (There may be more than one diversified address for a given ivk.)
@@ -1568,7 +1568,7 @@ std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> CWallet::GetSaplin
                 auto & ivk = noteData.ivk;
                 if (nullifier && ivkMap.count(ivk) > 0) {
                     for (const auto & addr : ivkMap[ivk]) {
-                        nullifierSet.insert(std::make_pair(addr, nullifier.value()));
+                        nullifierSet.insert(std::make_pair(addr, nullifier->GetRawBytes()));
                     }
                 }
             }
@@ -1603,7 +1603,7 @@ bool CWallet::IsNoteSproutChange(
 }
 
 bool CWallet::IsNoteSaplingChange(
-        const std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> & nullifierSet,
+        const std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> & nullifierSet,
         const libzcash::SaplingPaymentAddress& address,
         const SaplingOutPoint & op)
 {
@@ -1706,7 +1706,7 @@ set<uint256> CWallet::GetConflicts(const uint256& txid) const
     std::pair<TxNullifiers::const_iterator, TxNullifiers::const_iterator> range_o;
 
     for (const auto& spend : wtx.GetSaplingSpends()) {
-        const uint256& nullifier = spend.nullifier();
+        const uint256 nullifier = uint256::FromRawBytes(spend.nullifier());
         if (mapTxSaplingNullifiers.count(nullifier) <= 1) {
             continue;  // No conflict if zero or one spends
         }
@@ -2527,7 +2527,7 @@ void CWallet::AddToSpends(const uint256& wtxid)
         }
     }
     for (const auto& spend : thisTx.GetSaplingSpends()) {
-        AddToSaplingSpends(spend.nullifier(), wtxid);
+        AddToSaplingSpends(uint256::FromRawBytes(spend.nullifier()), wtxid);
     }
 
     // for Orchard, the effects of this operation are performed by
@@ -2791,11 +2791,11 @@ void CWallet::IncrementNoteWitnesses(
         }
         // Sapling
         for (const auto& spend : tx.GetSaplingSpends()) {
-            nullifiersSapling.emplace_back(spend.nullifier());
+            nullifiersSapling.emplace_back(uint256::FromRawBytes(spend.nullifier()));
         }
         uint32_t i = 0;
         for (const auto& output : tx.GetSaplingOutputs()) {
-            const uint256& note_commitment = output.cmu();
+            const uint256& note_commitment = uint256::FromRawBytes(output.cmu());
             frontiers.sapling.append(note_commitment);
             noteCommitmentsSapling.emplace_back(note_commitment);
 
@@ -3198,7 +3198,7 @@ void CWallet::UpdateNullifierNoteMapWithTx(const CWalletTx& wtx)
 
         for (const mapSaplingNoteData_t::value_type& item : wtx.mapSaplingNoteData) {
             if (item.second.nullifier) {
-                mapSaplingNullifiersToNotes[*item.second.nullifier] = item.first;
+                mapSaplingNullifiersToNotes[item.second.nullifier->GetRawBytes()] = item.first;
             }
         }
     }
@@ -3220,7 +3220,7 @@ void CWallet::UpdateSaplingNullifierNoteMapWithTx(CWalletTx& wtx) {
             //
             // If there are no witnesses, erase the nullifier and associated mapping.
             if (item.second.nullifier) {
-                mapSaplingNullifiersToNotes.erase(item.second.nullifier.value());
+                mapSaplingNullifiersToNotes.erase(item.second.nullifier->GetRawBytes());
             }
             item.second.nullifier = std::nullopt;
         }
@@ -3243,7 +3243,7 @@ void CWallet::UpdateSaplingNullifierNoteMapWithTx(CWalletTx& wtx) {
             // This should not happen.  If it does, maybe the position has been corrupted or miscalculated?
             assert(optNullifier != std::nullopt);
             uint256 nullifier = optNullifier.value();
-            mapSaplingNullifiersToNotes[nullifier] = op;
+            mapSaplingNullifiersToNotes[nullifier.GetRawBytes()] = op;
             item.second.nullifier = nullifier;
         }
     }
@@ -3660,10 +3660,12 @@ void CWallet::MarkAffectedTransactionsDirty(const CTransaction& tx)
     }
 
     for (const auto& spend : tx.GetSaplingSpends()) {
-        const uint256& nullifier = spend.nullifier();
-        if (mapSaplingNullifiersToNotes.count(nullifier) &&
-            mapWallet.count(mapSaplingNullifiersToNotes[nullifier].hash)) {
-            mapWallet[mapSaplingNullifiersToNotes[nullifier].hash].MarkDirty();
+        auto it = mapSaplingNullifiersToNotes.find(spend.nullifier());
+        if (it != mapSaplingNullifiersToNotes.end()) {
+            auto itTx = mapWallet.find(it->second.hash);
+            if (itTx != mapWallet.end()) {
+                itTx->second.MarkDirty();
+            }
         }
     }
 }
@@ -3797,9 +3799,9 @@ std::pair<mapSaplingNoteData_t, SaplingIncomingViewingKeyMap> CWallet::FindMySap
                     height,
                     ivk.GetRawBytes(),
                     {
-                        output.cv().GetRawBytes(),
-                        output.cmu().GetRawBytes(),
-                        output.ephemeral_key().GetRawBytes(),
+                        output.cv(),
+                        output.cmu(),
+                        output.ephemeral_key(),
                         output.enc_ciphertext(),
                         output.out_ciphertext(),
                     });
@@ -3839,12 +3841,12 @@ bool CWallet::IsSproutNullifierFromMe(const uint256& nullifier) const
     return false;
 }
 
-bool CWallet::IsSaplingNullifierFromMe(const uint256& nullifier) const
+bool CWallet::IsSaplingNullifierFromMe(const libzcash::nullifier_t& nullifier) const
 {
     {
         LOCK(cs_wallet);
-        if (mapSaplingNullifiersToNotes.count(nullifier) &&
-                mapWallet.count(mapSaplingNullifiersToNotes.at(nullifier).hash)) {
+        auto it = mapSaplingNullifiersToNotes.find(nullifier);
+        if (it != mapSaplingNullifiersToNotes.end() && mapWallet.count(it->second.hash)) {
             return true;
         }
     }
@@ -4322,7 +4324,8 @@ std::optional<std::pair<
         return std::nullopt;
     }
 
-    auto output = GetSaplingOutputs()[op.n];
+    auto outputs = GetSaplingOutputs();
+    auto& output = outputs[op.n];
     auto nd = this->mapSaplingNoteData.at(op);
 
     try {
@@ -4333,9 +4336,9 @@ std::optional<std::pair<
             params.GetConsensus().vUpgrades[Consensus::UPGRADE_CANOPY].nActivationHeight,
             nd.ivk.GetRawBytes(),
             {
-                output.cv().GetRawBytes(),
-                output.cmu().GetRawBytes(),
-                output.ephemeral_key().GetRawBytes(),
+                output.cv(),
+                output.cmu(),
+                output.ephemeral_key(),
                 output.enc_ciphertext(),
                 output.out_ciphertext(),
             });
@@ -4350,7 +4353,8 @@ std::optional<std::pair<
     SaplingNotePlaintext,
     SaplingPaymentAddress>> CWalletTx::RecoverSaplingNote(const CChainParams& params, SaplingOutPoint op, std::set<uint256>& ovks) const
 {
-    auto output = GetSaplingOutputs()[op.n];
+    auto outputs = GetSaplingOutputs();
+    auto& output = outputs[op.n];
 
     for (auto ovk : ovks) {
         try {
@@ -4361,9 +4365,9 @@ std::optional<std::pair<
                 params.GetConsensus().vUpgrades[Consensus::UPGRADE_CANOPY].nActivationHeight,
                 ovk.GetRawBytes(),
                 {
-                    output.cv().GetRawBytes(),
-                    output.cmu().GetRawBytes(),
-                    output.ephemeral_key().GetRawBytes(),
+                    output.cv(),
+                    output.cmu(),
+                    output.ephemeral_key(),
                     output.enc_ciphertext(),
                     output.out_ciphertext(),
                 });
diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h
index 90dc0694859..4084da6a6b6 100644
--- a/src/wallet/wallet.h
+++ b/src/wallet/wallet.h
@@ -1498,7 +1498,7 @@ class CWallet : public CCryptoKeyStore, public CValidationInterface
      */
     std::map<uint256, JSOutPoint> mapSproutNullifiersToNotes;
 
-    std::map<uint256, SaplingOutPoint> mapSaplingNullifiersToNotes;
+    std::map<libzcash::nullifier_t, SaplingOutPoint> mapSaplingNullifiersToNotes;
 
     std::map<uint256, CWalletTx> mapWallet;
 
@@ -1994,7 +1994,7 @@ class CWallet : public CCryptoKeyStore, public CValidationInterface
         const CTransaction& tx,
         int height) const;
     bool IsSproutNullifierFromMe(const uint256& nullifier) const;
-    bool IsSaplingNullifierFromMe(const uint256& nullifier) const;
+    bool IsSaplingNullifierFromMe(const libzcash::nullifier_t& nullifier) const;
 
     bool GetSproutNoteWitnesses(
          const std::vector<JSOutPoint>& notes,
@@ -2049,10 +2049,10 @@ class CWallet : public CCryptoKeyStore, public CValidationInterface
             const libzcash::SproutPaymentAddress& address,
             const JSOutPoint & entry);
 
-    std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> GetSaplingNullifiers(
+    std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> GetSaplingNullifiers(
             const std::set<libzcash::SaplingPaymentAddress>& addresses);
     bool IsNoteSaplingChange(
-            const std::set<std::pair<libzcash::SaplingPaymentAddress, uint256>> & nullifierSet,
+            const std::set<std::pair<libzcash::SaplingPaymentAddress, libzcash::nullifier_t>> & nullifierSet,
             const libzcash::SaplingPaymentAddress& address,
             const SaplingOutPoint & entry);
 
diff --git a/src/wallet/wallet_tx_builder.cpp b/src/wallet/wallet_tx_builder.cpp
index f2b31a3bfcf..5ddfdf78033 100644
--- a/src/wallet/wallet_tx_builder.cpp
+++ b/src/wallet/wallet_tx_builder.cpp
@@ -893,7 +893,7 @@ bool TransactionEffects::InvolvesOrchard() const
 }
 
 TransactionBuilderResult TransactionEffects::ApproveAndBuild(
-        const Consensus::Params& consensus,
+        const CChainParams& params,
         const CWallet& wallet,
         const CChain& chain,
         const TransactionStrategy& strategy) const
@@ -923,7 +923,7 @@ TransactionBuilderResult TransactionEffects::ApproveAndBuild(
         orchardAnchor = anchorBlockIndex->hashFinalOrchardRoot;
     }
 
-    auto builder = TransactionBuilder(consensus, nextBlockHeight, orchardAnchor, &wallet);
+    auto builder = TransactionBuilder(params, nextBlockHeight, orchardAnchor, &wallet);
     builder.SetFee(fee);
 
     // Track the total of notes that we've added to the builder. This
@@ -986,7 +986,7 @@ TransactionBuilderResult TransactionEffects::ApproveAndBuild(
             ));
         }
 
-        builder.AddSaplingSpend(saplingKeys[i].expsk, saplingNotes[i], anchor, witnesses[i].value());
+        builder.AddSaplingSpend(saplingKeys[i], saplingNotes[i], witnesses[i].value());
     }
 
     // Add outputs
diff --git a/src/wallet/wallet_tx_builder.h b/src/wallet/wallet_tx_builder.h
index 62b2e61b027..51dd364832f 100644
--- a/src/wallet/wallet_tx_builder.h
+++ b/src/wallet/wallet_tx_builder.h
@@ -5,7 +5,7 @@
 #ifndef ZCASH_WALLET_WALLET_TX_BUILDER_H
 #define ZCASH_WALLET_WALLET_TX_BUILDER_H
 
-#include "consensus/params.h"
+#include "chainparams.h"
 #include "transaction_builder.h"
 #include "wallet/wallet.h"
 #include "zcash/memo.h"
@@ -220,7 +220,7 @@ class TransactionEffects {
     bool InvolvesOrchard() const;
 
     TransactionBuilderResult ApproveAndBuild(
-            const Consensus::Params& consensus,
+            const CChainParams& params,
             const CWallet& wallet,
             const CChain& chain,
             const TransactionStrategy& strategy) const;
diff --git a/src/zcash/Note.cpp b/src/zcash/Note.cpp
index 474115e549c..e0edfc9dbaa 100644
--- a/src/zcash/Note.cpp
+++ b/src/zcash/Note.cpp
@@ -339,48 +339,6 @@ std::optional<SaplingNotePlaintext> SaplingNotePlaintext::plaintext_checks_witho
     return plaintext;
 }
 
-std::optional<SaplingNotePlaintextEncryptionResult> SaplingNotePlaintext::encrypt(const uint256& pk_d) const
-{
-    // Get the encryptor
-    auto sne = SaplingNoteEncryption::FromDiversifier(d, generate_or_derive_esk());
-    if (!sne) {
-        return std::nullopt;
-    }
-    auto enc = sne.value();
-
-    // Create the plaintext
-    CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
-    ss << (*this);
-    SaplingEncPlaintext pt;
-    assert(pt.size() == ss.size());
-    memcpy(&pt[0], &ss[0], pt.size());
-
-    // Encrypt the plaintext
-    auto encciphertext = enc.encrypt_to_recipient(pk_d, pt);
-    if (!encciphertext) {
-        return std::nullopt;
-    }
-    return SaplingNotePlaintextEncryptionResult(encciphertext.value(), enc);
-}
-
-
-SaplingOutCiphertext SaplingOutgoingPlaintext::encrypt(
-        const uint256& ovk,
-        const uint256& cv,
-        const uint256& cm,
-        SaplingNoteEncryption& enc
-    ) const
-{
-    // Create the plaintext
-    CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
-    ss << (*this);
-    SaplingOutPlaintext pt;
-    assert(pt.size() == ss.size());
-    memcpy(&pt[0], &ss[0], pt.size());
-
-    return enc.encrypt_to_ourselves(ovk, cv, cm, pt);
-}
-
 uint256 SaplingNotePlaintext::rcm() const {
     if (leadbyte != 0x01) {
         assert(leadbyte == 0x02);
diff --git a/src/zcash/Note.hpp b/src/zcash/Note.hpp
index 3cd53bb7d3b..53773476f64 100644
--- a/src/zcash/Note.hpp
+++ b/src/zcash/Note.hpp
@@ -16,6 +16,8 @@
 
 namespace libzcash {
 
+typedef std::array<uint8_t, 32> nullifier_t;
+
 class BaseNote {
 protected:
     uint64_t value_ = 0;
@@ -213,8 +215,6 @@ class SaplingNotePlaintext : public BaseNotePlaintext {
         READWRITE(memo_);       // 512 bytes
     }
 
-    std::optional<SaplingNotePlaintextEncryptionResult> encrypt(const uint256& pk_d) const;
-
     uint256 rcm() const;
     uint256 generate_or_derive_esk() const;
     unsigned char get_leadbyte() const {
@@ -247,13 +247,6 @@ class SaplingOutgoingPlaintext
         const uint256& cm,
         const uint256& epk
     );
-
-    SaplingOutCiphertext encrypt(
-        const uint256& ovk,
-        const uint256& cv,
-        const uint256& cm,
-        SaplingNoteEncryption& enc
-    ) const;
 };
 
 
diff --git a/src/zcash/address/sapling.cpp b/src/zcash/address/sapling.cpp
index adf2c891133..5ba4229f7a7 100644
--- a/src/zcash/address/sapling.cpp
+++ b/src/zcash/address/sapling.cpp
@@ -18,6 +18,14 @@ const unsigned char ZCASH_SAPLING_FVFP_PERSONALIZATION[blake2b::PERSONALBYTES] =
     {'Z', 'c', 'a', 's', 'h', 'S', 'a', 'p', 'l', 'i', 'n', 'g', 'F', 'V', 'F', 'P'};
 
 //! Sapling
+std::array<uint8_t, 43> SaplingPaymentAddress::GetRawBytes() const {
+    CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
+    ss << *this;
+    std::array<uint8_t, 43> rawBytes;
+    std::move(ss.begin(), ss.end(), rawBytes.begin());
+    return rawBytes;
+}
+
 uint256 SaplingPaymentAddress::GetHash() const {
     CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
     ss << *this;
diff --git a/src/zcash/address/sapling.hpp b/src/zcash/address/sapling.hpp
index 20697eba970..d4fccdc4c05 100644
--- a/src/zcash/address/sapling.hpp
+++ b/src/zcash/address/sapling.hpp
@@ -38,6 +38,8 @@ class SaplingPaymentAddress {
         READWRITE(pk_d);
     }
 
+    std::array<uint8_t, 43> GetRawBytes() const;
+
     //! Get the 256-bit SHA256d hash of this payment address.
     uint256 GetHash() const;
 
diff --git a/src/zcbenchmarks.cpp b/src/zcbenchmarks.cpp
index a77bebd1e05..973fc053c95 100644
--- a/src/zcbenchmarks.cpp
+++ b/src/zcbenchmarks.cpp
@@ -292,9 +292,6 @@ double benchmark_try_decrypt_sprout_notes(size_t nKeys)
 
 double benchmark_try_decrypt_sapling_notes(size_t nKeys)
 {
-    // Set params
-    const Consensus::Params& consensusParams = Params().GetConsensus();
-
     auto masterKey = GetTestMasterSaplingSpendingKey();
 
     CWallet wallet(Params());
@@ -306,7 +303,7 @@ double benchmark_try_decrypt_sapling_notes(size_t nKeys)
 
     // Generate a key that has not been added to the wallet
     auto sk = masterKey.Derive(nKeys);
-    auto tx = GetValidSaplingReceive(consensusParams, wallet, sk, 10);
+    auto tx = GetValidSaplingReceive(Params(), wallet, sk, 10);
 
     struct timeval tv_start;
     timer_start(tv_start);
@@ -372,10 +369,10 @@ double benchmark_increment_sprout_note_witnesses(size_t nTxs)
     return timer_stop(tv_start);
 }
 
-CWalletTx CreateSaplingTxWithNoteData(const Consensus::Params& consensusParams,
+CWalletTx CreateSaplingTxWithNoteData(const CChainParams& params,
                                       CBasicKeyStore& keyStore,
                                       const libzcash::SaplingExtendedSpendingKey &sk) {
-    auto wtx = GetValidSaplingReceive(consensusParams, keyStore, sk, 10);
+    auto wtx = GetValidSaplingReceive(params, keyStore, sk, 10);
     auto testNote = GetTestSaplingNote(sk.ToXFVK().DefaultAddress(), 10);
     auto fvk = sk.expsk.full_viewing_key();
     auto nullifier = testNote.note.nullifier(fvk, testNote.tree.witness().position()).value();
@@ -393,8 +390,6 @@ CWalletTx CreateSaplingTxWithNoteData(const Consensus::Params& consensusParams,
 
 double benchmark_increment_sapling_note_witnesses(size_t nTxs)
 {
-    const Consensus::Params& consensusParams = Params().GetConsensus();
-
     CWallet wallet(Params());
     MerkleFrontiers frontiers;
 
@@ -404,7 +399,7 @@ double benchmark_increment_sapling_note_witnesses(size_t nTxs)
     // First block
     CBlock block1;
     for (int i = 0; i < nTxs; ++i) {
-        auto wtx = CreateSaplingTxWithNoteData(consensusParams, wallet, saplingSpendingKey);
+        auto wtx = CreateSaplingTxWithNoteData(Params(), wallet, saplingSpendingKey);
         wallet.LoadWalletTx(wtx);
         block1.vtx.push_back(wtx);
     }
@@ -419,7 +414,7 @@ double benchmark_increment_sapling_note_witnesses(size_t nTxs)
     CBlock block2;
     block2.hashPrevBlock = block1.GetHash();
     {
-        auto saplingTx = CreateSaplingTxWithNoteData(consensusParams, wallet, saplingSpendingKey);
+        auto saplingTx = CreateSaplingTxWithNoteData(Params(), wallet, saplingSpendingKey);
         wallet.LoadWalletTx(saplingTx);
         block1.vtx.push_back(saplingTx);
     }
@@ -756,55 +751,32 @@ double benchmark_create_sapling_spend()
     SaplingMerkleTree tree;
     auto maybe_cmu = note.cmu();
     tree.append(maybe_cmu.value());
-    auto anchor = tree.root();
     auto witness = tree.witness();
-    auto maybe_nf = note.nullifier(expsk.full_viewing_key(), witness.position());
-    if (!(maybe_cmu && maybe_nf)) {
-        throw JSONRPCError(RPC_INTERNAL_ERROR, "Could not create note commitment and nullifier");
-    }
+
+    CDataStream ssExtSk(SER_NETWORK, PROTOCOL_VERSION);
+    ssExtSk << sk;
 
     CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
     ss << witness.path();
     std::array<unsigned char, 1065> witnessChars;
     std::move(ss.begin(), ss.end(), witnessChars.begin());
 
-    uint256 alpha;
-    librustzcash_sapling_generate_r(alpha.begin());
-
-    auto ctx = sapling::init_prover();
+    auto nHeight = Params().GetConsensus().vUpgrades[Consensus::UPGRADE_SAPLING].nActivationHeight;
+    auto builder = sapling::new_builder(*Params().RustNetwork(), nHeight);
+    builder->add_spend(
+        {reinterpret_cast<uint8_t*>(ssExtSk.data()), ssExtSk.size()},
+        note.d,
+        address.GetRawBytes(),
+        note.value(),
+        note.rcm().GetRawBytes(),
+        witnessChars);
 
     struct timeval tv_start;
     timer_start(tv_start);
 
-    std::array<unsigned char, 32> cv;
-    std::array<unsigned char, 32> rk;
-    uint256 rcm = note.rcm();
-    libzcash::GrothProof zkproof;
-    SpendDescription::spend_auth_sig_t spendAuthSig;
-    bool result = ctx->create_spend_proof(
-        expsk.full_viewing_key().ak.GetRawBytes(),
-        expsk.nsk.GetRawBytes(),
-        note.d,
-        rcm.GetRawBytes(),
-        alpha.GetRawBytes(),
-        note.value(),
-        anchor.GetRawBytes(),
-        witnessChars,
-        cv,
-        rk,
-        zkproof);
-    SpendDescription sdesc(
-        uint256::FromRawBytes(cv),
-        anchor,
-        maybe_nf.value(),
-        uint256::FromRawBytes(rk),
-        zkproof,
-        spendAuthSig);
+    auto result = sapling::build_bundle(std::move(builder), nHeight);
 
     double t = timer_stop(tv_start);
-    if (!result) {
-        throw JSONRPCError(RPC_INTERNAL_ERROR, "librustzcash_sapling_spend_proof() should return true");
-    }
     return t;
 }
 
@@ -813,49 +785,20 @@ double benchmark_create_sapling_output()
     auto sk = libzcash::SaplingSpendingKey::random();
     auto address = sk.default_address();
 
-    SaplingNote note(address, GetRand(MAX_MONEY),  libzcash::Zip212Enabled::BeforeZip212);
-
-    libzcash::SaplingNotePlaintext notePlaintext(note, std::nullopt);
-    auto res = notePlaintext.encrypt(note.pk_d);
-    if (!res) {
-        throw JSONRPCError(RPC_INTERNAL_ERROR, "SaplingNotePlaintext::encrypt() failed");
-    }
-
-    auto enc = res.value();
-    auto encryptor = enc.second;
-
-    CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
-    ss << address;
-    std::array<unsigned char, 43> addressBytes;
-    std::move(ss.begin(), ss.end(), addressBytes.begin());
-
-    auto ctx = sapling::init_prover();
+    auto nHeight = Params().GetConsensus().vUpgrades[Consensus::UPGRADE_SAPLING].nActivationHeight;
+    auto builder = sapling::new_builder(*Params().RustNetwork(), nHeight);
+    builder->add_recipient(
+        uint256().GetRawBytes(),
+        address.GetRawBytes(),
+        GetRand(MAX_MONEY),
+        libzcash::Memo::ToBytes(std::nullopt));
 
     struct timeval tv_start;
     timer_start(tv_start);
 
-    std::array<unsigned char, 32> cv;
-    libzcash::GrothProof zkproof;
-    uint256 rcm = note.rcm();
-    bool result = ctx->create_output_proof(
-        encryptor.get_esk().GetRawBytes(),
-        addressBytes,
-        rcm.GetRawBytes(),
-        note.value(),
-        cv,
-        zkproof);
-    OutputDescription odesc(
-        uint256::FromRawBytes(cv),
-        uint256(),
-        uint256(),
-        {{}},
-        {{}},
-        zkproof);
+    auto result = sapling::build_bundle(std::move(builder), nHeight);
 
     double t = timer_stop(tv_start);
-    if (!result) {
-        throw JSONRPCError(RPC_INTERNAL_ERROR, "librustzcash_sapling_output_proof() should return true");
-    }
     return t;
 }
 
@@ -864,9 +807,8 @@ double benchmark_create_sapling_output()
 // position: 0
 double benchmark_verify_sapling_spend()
 {
-    SpendDescription spend;
-    CDataStream ss(ParseHex("8c6cf86bbb83bf0d075e5bd9bb4b5cd56141577be69f032880b11e26aa32aa5ef09fd00899e4b469fb11f38e9d09dc0379f0b11c23b5fe541765f76695120a03f0261d32af5d2a2b1e5c9a04200cd87d574dc42349de9790012ce560406a8a876a1e54cfcdc0eb74998abec2a9778330eeb2a0ac0e41d0c9ed5824fbd0dbf7da930ab299966ce333fd7bc1321dada0817aac5444e02c754069e218746bf879d5f2a20a8b028324fb2c73171e63336686aa5ec2e6e9a08eb18b87c14758c572f4531ccf6b55d09f44beb8b47563be4eff7a52598d80959dd9c9fee5ac4783d8370cb7d55d460053d3e067b5f9fe75ff2722623fb1825fcba5e9593d4205b38d1f502ff03035463043bd393a5ee039ce75a5d54f21b395255df6627ef96751566326f7d4a77d828aa21b1827282829fcbc42aad59cdb521e1a3aaa08b99ea8fe7fff0a04da31a52260fc6daeccd79bb877bdd8506614282258e15b3fe74bf71a93f4be3b770119edf99a317b205eea7d5ab800362b97384273888106c77d633600"), SER_NETWORK, PROTOCOL_VERSION);
-    ss >> spend;
+    auto spendBytes = ParseHex("8c6cf86bbb83bf0d075e5bd9bb4b5cd56141577be69f032880b11e26aa32aa5ef09fd00899e4b469fb11f38e9d09dc0379f0b11c23b5fe541765f76695120a03f0261d32af5d2a2b1e5c9a04200cd87d574dc42349de9790012ce560406a8a876a1e54cfcdc0eb74998abec2a9778330eeb2a0ac0e41d0c9ed5824fbd0dbf7da930ab299966ce333fd7bc1321dada0817aac5444e02c754069e218746bf879d5f2a20a8b028324fb2c73171e63336686aa5ec2e6e9a08eb18b87c14758c572f4531ccf6b55d09f44beb8b47563be4eff7a52598d80959dd9c9fee5ac4783d8370cb7d55d460053d3e067b5f9fe75ff2722623fb1825fcba5e9593d4205b38d1f502ff03035463043bd393a5ee039ce75a5d54f21b395255df6627ef96751566326f7d4a77d828aa21b1827282829fcbc42aad59cdb521e1a3aaa08b99ea8fe7fff0a04da31a52260fc6daeccd79bb877bdd8506614282258e15b3fe74bf71a93f4be3b770119edf99a317b205eea7d5ab800362b97384273888106c77d633600");
+    auto spend = sapling::parse_v4_spend({spendBytes.data(), spendBytes.size()});
     uint256 dataToBeSigned = uint256S("0x2dbf83fe7b88a7cbd80fac0c719483906bb9a0c4fc69071e4780d5f2c76e592c");
 
     auto ctx = sapling::init_verifier();
@@ -875,12 +817,12 @@ double benchmark_verify_sapling_spend()
     timer_start(tv_start);
 
     bool result = ctx->check_spend(
-        spend.cv().GetRawBytes(),
-        spend.anchor().GetRawBytes(),
-        spend.nullifier().GetRawBytes(),
-        spend.rk().GetRawBytes(),
-        spend.zkproof(),
-        spend.spend_auth_sig(),
+        spend->cv(),
+        spend->anchor(),
+        spend->nullifier(),
+        spend->rk(),
+        spend->zkproof(),
+        spend->spend_auth_sig(),
         dataToBeSigned.GetRawBytes()
     );
 
@@ -896,9 +838,8 @@ double benchmark_verify_sapling_spend()
 // position: 0
 double benchmark_verify_sapling_output()
 {
-    OutputDescription output;
-    CDataStream ss(ParseHex("edd742af18857e5ec2d71d346a7fe2ac97c137339bd5268eea86d32e0ff4f38f76213fa8cfed3347ac4e8572dd88aff395c0c10a59f8b3f49d2bc539ed6c726667e29d4763f914ddd0abf1cdfa84e44de87c233434c7e69b8b5b8f4623c8aa444163425bae5cef842972fed66046c1c6ce65c866ad894d02e6e6dcaae7a962d9f2ef95757a09c486928e61f0f7aed90ad0a542b0d3dc5fe140dfa7626b9315c77e03b055f19cbacd21a866e46f06c00e0c7792b2a590a611439b510a9aaffcf1073bad23e712a9268b36888e3727033eee2ab4d869f54a843f93b36ef489fb177bf74b41a9644e5d2a0a417c6ac1c8869bc9b83273d453f878ed6fd96b82a5939903f7b64ecaf68ea16e255a7fb7cc0b6d8b5608a1c6b0ed3024cc62c2f0f9c5cfc7b431ae6e9d40815557aa1d010523f9e1960de77b2274cb6710d229d475c87ae900183206ba90cb5bbc8ec0df98341b82726c705e0308ca5dc08db4db609993a1046dfb43dfd8c760be506c0bed799bb2205fc29dc2e654dce731034a23b0aaf6da0199248702ee0523c159f41f4cbfff6c35ace4dd9ae834e44e09c76a0cbdda1d3f6a2c75ad71212daf9575ab5f09ca148718e667f29ddf18c8a330a86ace18a86e89454653902aa393c84c6b694f27d0d42e24e7ac9fe34733de5ec15f5066081ce912c62c1a804a2bb4dedcef7cc80274f6bb9e89e2fce91dc50d6a73c8aefb9872f1cf3524a92626a0b8f39bbf7bf7d96ca2f770fc04d7f457021c536a506a187a93b2245471ddbfb254a71bc4a0d72c8d639a31c7b1920087ffca05c24214157e2e7b28184e91989ef0b14f9b34c3dc3cc0ac64226b9e337095870cb0885737992e120346e630a416a9b217679ce5a778fb15779c136bcecca5efe79012013d77d90b4e99dd22c8f35bc77121716e160d05bd30d288ee8886390ee436f85bdc9029df888a3a3326d9d4ddba5cb5318b3274928829d662e96fea1d601f7a306251ed8c6cc4e5a3a7a98c35a3650482a0eee08f3b4c2da9b22947c96138f1505c2f081f8972d429f3871f32bef4aaa51aa6945df8e9c9760531ac6f627d17c1518202818a91ca304fb4037875c666060597976144fcbbc48a776a2c61beb9515fa8f3ae6d3a041d320a38a8ac75cb47bb9c866ee497fc3cd13299970c4b369c1c2ceb4220af082fbecdd8114492a8e4d713b5a73396fd224b36c1185bd5e20d683e6c8db35346c47ae7401988255da7cfffdced5801067d4d296688ee8fe424b4a8a69309ce257eefb9345ebfda3f6de46bb11ec94133e1f72cd7ac54934d6cf17b3440800e70b80ebc7c7bfc6fb0fc2c"), SER_NETWORK, PROTOCOL_VERSION);
-    ss >> output;
+    auto outputBytes = ParseHex("edd742af18857e5ec2d71d346a7fe2ac97c137339bd5268eea86d32e0ff4f38f76213fa8cfed3347ac4e8572dd88aff395c0c10a59f8b3f49d2bc539ed6c726667e29d4763f914ddd0abf1cdfa84e44de87c233434c7e69b8b5b8f4623c8aa444163425bae5cef842972fed66046c1c6ce65c866ad894d02e6e6dcaae7a962d9f2ef95757a09c486928e61f0f7aed90ad0a542b0d3dc5fe140dfa7626b9315c77e03b055f19cbacd21a866e46f06c00e0c7792b2a590a611439b510a9aaffcf1073bad23e712a9268b36888e3727033eee2ab4d869f54a843f93b36ef489fb177bf74b41a9644e5d2a0a417c6ac1c8869bc9b83273d453f878ed6fd96b82a5939903f7b64ecaf68ea16e255a7fb7cc0b6d8b5608a1c6b0ed3024cc62c2f0f9c5cfc7b431ae6e9d40815557aa1d010523f9e1960de77b2274cb6710d229d475c87ae900183206ba90cb5bbc8ec0df98341b82726c705e0308ca5dc08db4db609993a1046dfb43dfd8c760be506c0bed799bb2205fc29dc2e654dce731034a23b0aaf6da0199248702ee0523c159f41f4cbfff6c35ace4dd9ae834e44e09c76a0cbdda1d3f6a2c75ad71212daf9575ab5f09ca148718e667f29ddf18c8a330a86ace18a86e89454653902aa393c84c6b694f27d0d42e24e7ac9fe34733de5ec15f5066081ce912c62c1a804a2bb4dedcef7cc80274f6bb9e89e2fce91dc50d6a73c8aefb9872f1cf3524a92626a0b8f39bbf7bf7d96ca2f770fc04d7f457021c536a506a187a93b2245471ddbfb254a71bc4a0d72c8d639a31c7b1920087ffca05c24214157e2e7b28184e91989ef0b14f9b34c3dc3cc0ac64226b9e337095870cb0885737992e120346e630a416a9b217679ce5a778fb15779c136bcecca5efe79012013d77d90b4e99dd22c8f35bc77121716e160d05bd30d288ee8886390ee436f85bdc9029df888a3a3326d9d4ddba5cb5318b3274928829d662e96fea1d601f7a306251ed8c6cc4e5a3a7a98c35a3650482a0eee08f3b4c2da9b22947c96138f1505c2f081f8972d429f3871f32bef4aaa51aa6945df8e9c9760531ac6f627d17c1518202818a91ca304fb4037875c666060597976144fcbbc48a776a2c61beb9515fa8f3ae6d3a041d320a38a8ac75cb47bb9c866ee497fc3cd13299970c4b369c1c2ceb4220af082fbecdd8114492a8e4d713b5a73396fd224b36c1185bd5e20d683e6c8db35346c47ae7401988255da7cfffdced5801067d4d296688ee8fe424b4a8a69309ce257eefb9345ebfda3f6de46bb11ec94133e1f72cd7ac54934d6cf17b3440800e70b80ebc7c7bfc6fb0fc2c");
+    auto output = sapling::parse_v4_output({outputBytes.data(), outputBytes.size()});
 
     auto ctx = sapling::init_verifier();
 
@@ -906,10 +847,10 @@ double benchmark_verify_sapling_output()
     timer_start(tv_start);
 
     bool result = ctx->check_output(
-        output.cv().GetRawBytes(),
-        output.cmu().GetRawBytes(),
-        output.ephemeral_key().GetRawBytes(),
-        output.zkproof()
+        output->cv(),
+        output->cmu(),
+        output->ephemeral_key(),
+        output->zkproof()
     );
 
     double t = timer_stop(tv_start);


Test patch:
diff --git a/qa/rpc-tests/finalorchardroot.py b/qa/rpc-tests/finalorchardroot.py
index f40cf3758cf..5aefb1b5815 100755
--- a/qa/rpc-tests/finalorchardroot.py
+++ b/qa/rpc-tests/finalorchardroot.py
@@ -140,8 +140,8 @@ def run_test(self):
         # Verify the final Orchard root has changed
         blk = self.nodes[0].getblock("211")
         root = blk["finalorchardroot"]
-        assert root is not ORCHARD_TREE_EMPTY_ROOT
-        assert root is not NULL_FIELD
+        assert root != ORCHARD_TREE_EMPTY_ROOT
+        assert root != NULL_FIELD
 
         # Verify there is a Orchard output description (its commitment was added to tree)
         result = self.nodes[0].getrawtransaction(mytxid, 1)
@@ -239,7 +239,7 @@ def run_test(self):
 
         assert_equal(len(self.nodes[0].getblock("216")["tx"]), 2)
         assert_equal(self.nodes[1].z_getbalance(orchardAddr1), Decimal("2.34"))
-        assert root is not self.nodes[0].getblock("216")["finalorchardroot"]
+        assert root != self.nodes[0].getblock("216")["finalorchardroot"]
 
         # Verify there is a Orchard output description (its commitment was added to tree)
         result = self.nodes[0].getrawtransaction(mytxid, 1)
@@ -267,7 +267,7 @@ def run_test(self):
 
         assert_equal(len(self.nodes[0].getblock("217")["tx"]), 2)
         assert_equal(self.nodes[0].z_getbalance(taddr2), Decimal("2.34"))
-        assert root is not self.nodes[0].getblock("217")["finalorchardroot"]
+        assert root != self.nodes[0].getblock("217")["finalorchardroot"]
 
         # Verify there is a Orchard output description (its commitment was added to tree)
         result = self.nodes[0].getrawtransaction(mytxid, 1)
diff --git a/qa/rpc-tests/finalsaplingroot.py b/qa/rpc-tests/finalsaplingroot.py
index 59909f3ef99..4261bccdc6a 100755
--- a/qa/rpc-tests/finalsaplingroot.py
+++ b/qa/rpc-tests/finalsaplingroot.py
@@ -107,8 +107,8 @@ def run_test(self):
         # Verify the final Sapling root has changed
         blk = self.nodes[0].getblock("201")
         root = blk["finalsaplingroot"]
-        assert root is not SAPLING_TREE_EMPTY_ROOT
-        assert root is not NULL_FIELD
+        assert root != SAPLING_TREE_EMPTY_ROOT
+        assert root != NULL_FIELD
 
         # Verify there is a Sapling output description (its commitment was added to tree)
         result = self.nodes[0].getrawtransaction(mytxid, 1)
@@ -179,7 +179,7 @@ def run_test(self):
 
         assert_equal(len(self.nodes[0].getblock("205")["tx"]), 2)
         assert_equal(self.nodes[1].z_getbalance(saplingAddr1), Decimal("2.34"))
-        assert root is not self.nodes[0].getblock("205")["finalsaplingroot"]
+        assert root != self.nodes[0].getblock("205")["finalsaplingroot"]
 
         # Verify there is a Sapling output description (its commitment was added to tree)
         result = self.nodes[0].getrawtransaction(mytxid, 1)
@@ -193,7 +193,7 @@ def run_test(self):
         assert_equal(len(new_treestate["sapling"]["commitments"]["finalState"]), 136)
         treestate = new_treestate
 
-        # Mine a block with a Sapling shielded sender and transparent recipient and verify the final Sapling root doesn't change
+        # Mine a block with a Sapling shielded sender and transparent recipient.
         taddr2 = self.nodes[0].getnewaddress()
         recipients = []
         recipients.append({"address": taddr2, "amount": Decimal('2.34')})
@@ -206,14 +206,25 @@ def run_test(self):
 
         assert_equal(len(self.nodes[0].getblock("206")["tx"]), 2)
         assert_equal(self.nodes[0].z_getbalance(taddr2), Decimal("2.34"))
+        assert_equal(self.nodes[1].z_getbalance(saplingAddr1), 0)
 
+        # Verify the final Sapling root changes (because the Sapling bundle was padded
+        # with 2 dummy outputs).
         blk = self.nodes[0].getblock("206")
         root = blk["finalsaplingroot"]
-        assert_equal(root, self.nodes[0].getblock("205")["finalsaplingroot"])
+        assert root != self.nodes[0].getblock("205")["finalsaplingroot"]
+
+        # Verify there are two Sapling output descriptions.
+        result = self.nodes[0].getrawtransaction(mytxid, 1)
+        assert_equal(len(result["vShieldedOutput"]), 2)
 
         new_treestate = self.nodes[0].z_gettreestate(str(-1))
         assert_equal(new_treestate["sprout"], treestate["sprout"])
-        assert_equal(new_treestate["sapling"], treestate["sapling"])
+        assert new_treestate["sapling"]["commitments"]["finalRoot"] != treestate["sapling"]["commitments"]["finalRoot"]
+        assert new_treestate["sapling"]["commitments"]["finalState"] != treestate["sapling"]["commitments"]["finalState"]
+        assert_equal(len(new_treestate["sapling"]["commitments"]["finalRoot"]), 64)
+        assert_equal(len(new_treestate["sapling"]["commitments"]["finalState"]), 138)
+        treestate = new_treestate
 
         # Activate NU5; more testing should be added once we can mine orchard transactions.
         self.sync_all()
diff --git a/qa/rpc-tests/wallet_listreceived.py b/qa/rpc-tests/wallet_listreceived.py
index 2edb77fb475..130daafef3f 100755
--- a/qa/rpc-tests/wallet_listreceived.py
+++ b/qa/rpc-tests/wallet_listreceived.py
@@ -77,13 +77,13 @@ def test_received_sapling(self, height):
         assert_equal(len(pt['spends']), 0)
         assert_equal(len(pt['outputs']), 2)
 
-        # Outputs are not returned in a defined order but the amounts are deterministic
+        # Outputs are shuffled during transaction building, but the amounts are deterministic
         outputs = sorted(pt['outputs'], key=lambda x: x['valueZat'])
         assert_equal(outputs[0]['pool'], 'sapling')
         assert_equal(outputs[0]['address'], zaddr1)
         assert_equal(outputs[0]['value'], Decimal('1'))
         assert_equal(outputs[0]['valueZat'], 100000000)
-        assert_equal(outputs[0]['output'], 0)
+        output_1zec = outputs[0]['output']
         assert_equal(outputs[0]['outgoing'], False)
         assert_equal(outputs[0]['memo'], my_memo)
         assert_equal(outputs[0]['memoStr'], my_memo_str)
@@ -92,7 +92,6 @@ def test_received_sapling(self, height):
         assert_equal(outputs[1]['address'], zaddrExt)
         assert_equal(outputs[1]['value'], Decimal('2'))
         assert_equal(outputs[1]['valueZat'], 200000000)
-        assert_equal(outputs[1]['output'], 1)
         assert_equal(outputs[1]['outgoing'], True)
         assert_equal(outputs[1]['memo'], no_memo)
         assert 'memoStr' not in outputs[1]
@@ -147,18 +146,17 @@ def test_received_sapling(self, height):
         assert_equal(pt['spends'][0]['pool'], 'sapling')
         assert_equal(pt['spends'][0]['txidPrev'], txidPrev)
         assert_equal(pt['spends'][0]['spend'], 0)
-        assert_equal(pt['spends'][0]['outputPrev'], 0)
+        assert_equal(pt['spends'][0]['outputPrev'], output_1zec)
         assert_equal(pt['spends'][0]['address'], zaddr1)
         assert_equal(pt['spends'][0]['value'], Decimal('1.0'))
         assert_equal(pt['spends'][0]['valueZat'], 100000000)
 
-        # Outputs are not returned in a defined order but the amounts are deterministic
+        # Outputs are shuffled during transaction building, but the amounts are deterministic
         outputs = sorted(pt['outputs'], key=lambda x: x['valueZat'])
         assert_equal(outputs[0]['pool'], 'sapling')
         assert_equal(outputs[0]['address'], zaddr1)
         assert_equal(outputs[0]['value'], Decimal('0.4') - conventional_fee(2))
         assert_equal(outputs[0]['valueZat'], 40000000 - conventional_fee_zats(2))
-        assert_equal(outputs[0]['output'], 1)
         assert_equal(outputs[0]['outgoing'], False)
         assert_equal(outputs[0]['memo'], no_memo)
         assert 'memoStr' not in outputs[0]
@@ -167,7 +165,6 @@ def test_received_sapling(self, height):
         assert_equal(outputs[1]['address'], zaddr2)
         assert_equal(outputs[1]['value'], Decimal('0.6'))
         assert_equal(outputs[1]['valueZat'], 60000000)
-        assert_equal(outputs[1]['output'], 0)
         assert_equal(outputs[1]['outgoing'], False)
         assert_equal(outputs[1]['memo'], no_memo)
         assert 'memoStr' not in outputs[1]
diff --git a/src/gtest/test_checktransaction.cpp b/src/gtest/test_checktransaction.cpp
index 8b34593bd12..83e5b5e4f05 100644
--- a/src/gtest/test_checktransaction.cpp
+++ b/src/gtest/test_checktransaction.cpp
@@ -326,8 +326,8 @@ TEST(ChecktransactionTests, BadTxnsTxouttotalToolargeOutputs) {
 }
 
 TEST(ChecktransactionTests, ValueBalanceNonZero) {
-    CMutableTransaction mtx = GetValidTransaction();
-    mtx.valueBalanceSapling = 10;
+    CMutableTransaction mtx = GetValidTransaction(NetworkUpgradeInfo[Consensus::UPGRADE_SAPLING].nBranchId);
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(0, 0, 10);
 
     CTransaction tx(mtx);
 
@@ -336,35 +336,10 @@ TEST(ChecktransactionTests, ValueBalanceNonZero) {
     CheckTransactionWithoutProofVerification(tx, state);
 }
 
-TEST(ChecktransactionTests, PositiveValueBalanceTooLarge) {
-    CMutableTransaction mtx = GetValidTransaction();
-    mtx.vShieldedSpend.resize(1);
-    mtx.valueBalanceSapling = MAX_MONEY + 1;
-
-    CTransaction tx(mtx);
-
-    MockCValidationState state;
-    EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-txns-valuebalance-toolarge", false, "")).Times(1);
-    CheckTransactionWithoutProofVerification(tx, state);
-}
-
-TEST(ChecktransactionTests, NegativeValueBalanceTooLarge) {
-    CMutableTransaction mtx = GetValidTransaction();
-    mtx.vShieldedSpend.resize(1);
-    mtx.valueBalanceSapling = -(MAX_MONEY + 1);
-
-    CTransaction tx(mtx);
-
-    MockCValidationState state;
-    EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-txns-valuebalance-toolarge", false, "")).Times(1);
-    CheckTransactionWithoutProofVerification(tx, state);
-}
-
 TEST(ChecktransactionTests, ValueBalanceOverflowsTotal) {
-    CMutableTransaction mtx = GetValidTransaction();
-    mtx.vShieldedSpend.resize(1);
+    CMutableTransaction mtx = GetValidTransaction(NetworkUpgradeInfo[Consensus::UPGRADE_SAPLING].nBranchId);
     mtx.vout[0].nValue = 1;
-    mtx.valueBalanceSapling = -MAX_MONEY;
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(1, 0, -MAX_MONEY);
 
     CTransaction tx(mtx);
 
@@ -879,7 +854,7 @@ TEST(ChecktransactionTests, SaplingSproutInputSumsTooLarge) {
         mtx.vJoinSplit.push_back(jsdesc);
     }
 
-    mtx.vShieldedSpend.push_back(RandomInvalidSpendDescription());
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(1, 0, 0);
 
     mtx.vJoinSplit[0].vpub_new = (MAX_MONEY / 2) + 10;
 
@@ -889,7 +864,7 @@ TEST(ChecktransactionTests, SaplingSproutInputSumsTooLarge) {
         EXPECT_TRUE(CheckTransactionWithoutProofVerification(tx, state));
     }
 
-    mtx.valueBalanceSapling = (MAX_MONEY / 2) + 10;
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(1, 0, (MAX_MONEY / 2) + 10);
 
     {
         UNSAFE_CTransaction tx(mtx);
@@ -1151,7 +1126,7 @@ TEST(ChecktransactionTests, InvalidSaplingShieldedCoinbase) {
     // Make it an invalid shielded coinbase (no ciphertexts or commitments).
     mtx.vin.resize(1);
     mtx.vin[0].prevout.SetNull();
-    mtx.vShieldedOutput.push_back(RandomInvalidOutputDescription());
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(0, 1, 0);
     mtx.vJoinSplit.resize(0);
 
     CTransaction tx(mtx);
@@ -1177,13 +1152,12 @@ TEST(ChecktransactionTests, HeartwoodAcceptsSaplingShieldedCoinbase) {
     RegtestActivateHeartwood(false, Consensus::NetworkUpgrade::ALWAYS_ACTIVE);
     auto chainparams = Params();
 
-    uint256 ovk;
-    auto note = libzcash::SaplingNote(
-        libzcash::SaplingSpendingKey::random().default_address(), CAmount(123456), libzcash::Zip212Enabled::BeforeZip212);
-    auto output = OutputDescriptionInfo(ovk, note, std::nullopt);
-
-    auto ctx = sapling::init_prover();
-    auto odesc = output.Build(ctx).value();
+    auto builder = sapling::new_builder(*chainparams.RustNetwork(), 10);
+    builder->add_recipient(
+        uint256().GetRawBytes(),
+        libzcash::SaplingSpendingKey::random().default_address().GetRawBytes(),
+        123456,
+        libzcash::Memo::ToBytes(std::nullopt));
 
     CMutableTransaction mtx = GetValidTransaction();
     mtx.fOverwintered = true;
@@ -1193,67 +1167,66 @@ TEST(ChecktransactionTests, HeartwoodAcceptsSaplingShieldedCoinbase) {
     mtx.vin.resize(1);
     mtx.vin[0].prevout.SetNull();
     mtx.vJoinSplit.resize(0);
-    mtx.vShieldedOutput.push_back(odesc);
+    mtx.saplingBundle = sapling::apply_bundle_signatures(sapling::build_bundle(std::move(builder), 10), {});
+    auto outputs = mtx.saplingBundle.GetDetails().outputs();
+    auto& odesc = outputs[0];
 
     // Transaction should fail with a bad public cmu.
     {
-        mtx.vShieldedOutput[0] = OutputDescription(
-            odesc.cv(),
-            uint256S("1234"),
-            odesc.ephemeral_key(),
+        sapling::test_only_replace_output_parts(
+            mtx.saplingBundle.GetDetailsMut(),
+            0,
+            uint256S("1234").GetRawBytes(),
             odesc.enc_ciphertext(),
-            odesc.out_ciphertext(),
-            odesc.zkproof());
+            odesc.out_ciphertext());
         CTransaction tx(mtx);
         EXPECT_TRUE(tx.IsCoinBase());
 
         MockCValidationState state;
         EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-cb-output-desc-invalid-outct", false, "")).Times(1);
         ContextualCheckTransaction(tx, state, chainparams, 10, 57);
-
-        mtx.vShieldedOutput[0] = odesc;
     }
 
     // Transaction should fail with a bad outCiphertext.
     {
-        mtx.vShieldedOutput[0] = OutputDescription(
-            odesc.cv(),
+        sapling::test_only_replace_output_parts(
+            mtx.saplingBundle.GetDetailsMut(),
+            0,
             odesc.cmu(),
-            odesc.ephemeral_key(),
             odesc.enc_ciphertext(),
-            {{}},
-            odesc.zkproof());
+            {{}});
         CTransaction tx(mtx);
         EXPECT_TRUE(tx.IsCoinBase());
 
         MockCValidationState state;
         EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-cb-output-desc-invalid-outct", false, "")).Times(1);
         ContextualCheckTransaction(tx, state, chainparams, 10, 57);
-
-        mtx.vShieldedOutput[0] = odesc;
     }
 
     // Transaction should fail with a bad encCiphertext.
     {
-        mtx.vShieldedOutput[0] = OutputDescription(
-            odesc.cv(),
+        sapling::test_only_replace_output_parts(
+            mtx.saplingBundle.GetDetailsMut(),
+            0,
             odesc.cmu(),
-            odesc.ephemeral_key(),
             {{}},
-            odesc.out_ciphertext(),
-            odesc.zkproof());
+            odesc.out_ciphertext());
         CTransaction tx(mtx);
         EXPECT_TRUE(tx.IsCoinBase());
 
         MockCValidationState state;
         EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-cb-output-desc-invalid-encct", false, "")).Times(1);
         ContextualCheckTransaction(tx, state, chainparams, 10, 57);
-
-        mtx.vShieldedOutput[0] = odesc;
     }
 
     // Test the success case.
     {
+        sapling::test_only_replace_output_parts(
+            mtx.saplingBundle.GetDetailsMut(),
+            0,
+            odesc.cmu(),
+            odesc.enc_ciphertext(),
+            odesc.out_ciphertext());
         CTransaction tx(mtx);
         EXPECT_TRUE(tx.IsCoinBase());
 
@@ -1273,11 +1246,6 @@ TEST(ChecktransactionTests, HeartwoodEnforcesSaplingRulesOnShieldedCoinbase) {
     RegtestActivateHeartwood(false, Consensus::NetworkUpgrade::ALWAYS_ACTIVE);
     auto chainparams = Params();
 
-    uint256 ovk;
-    auto note = libzcash::SaplingNote(
-        libzcash::SaplingSpendingKey::random().default_address(), CAmount(123456), libzcash::Zip212Enabled::BeforeZip212);
-    auto output = OutputDescriptionInfo(ovk, note, std::nullopt);
-
     CMutableTransaction mtx = GetValidTransaction();
     mtx.fOverwintered = true;
     mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
@@ -1287,7 +1255,7 @@ TEST(ChecktransactionTests, HeartwoodEnforcesSaplingRulesOnShieldedCoinbase) {
     mtx.vin[0].prevout.SetNull();
     mtx.vin[0].scriptSig << 123;
     mtx.vJoinSplit.resize(0);
-    mtx.valueBalanceSapling = -1000;
+    mtx.saplingBundle = sapling::test_only_invalid_bundle(0, 0, -1000);
 
     // Coinbase transaction should fail non-contextual checks with no shielded
     // outputs and non-zero valueBalanceSapling.
@@ -1301,34 +1269,14 @@ TEST(ChecktransactionTests, HeartwoodEnforcesSaplingRulesOnShieldedCoinbase) {
     }
 
     // Add a Sapling output.
-    auto ctx = sapling::init_prover();
-    auto odesc = output.Build(ctx).value();
-    mtx.vShieldedOutput.push_back(odesc);
-
-    // Coinbase transaction should fail non-contextual checks with valueBalanceSapling
-    // out of range.
-    {
-        mtx.valueBalanceSapling = MAX_MONEY + 1;
-        EXPECT_THROW((CTransaction(mtx)), std::ios_base::failure);
-        UNSAFE_CTransaction tx(mtx);
-        EXPECT_TRUE(tx.IsCoinBase());
-
-        MockCValidationState state;
-        EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-txns-valuebalance-toolarge", false, "")).Times(1);
-        EXPECT_FALSE(CheckTransactionWithoutProofVerification(tx, state));
-    }
-    {
-        mtx.valueBalanceSapling = -MAX_MONEY - 1;
-        EXPECT_THROW((CTransaction(mtx)), std::ios_base::failure);
-        UNSAFE_CTransaction tx(mtx);
-        EXPECT_TRUE(tx.IsCoinBase());
-
-        MockCValidationState state;
-        EXPECT_CALL(state, DoS(100, false, REJECT_INVALID, "bad-txns-valuebalance-toolarge", false, "")).Times(1);
-        EXPECT_FALSE(CheckTransactionWithoutProofVerification(tx, state));
-    }
+    auto builder = sapling::new_builder(*chainparams.RustNetwork(), 10);
+    builder->add_recipient(
+        uint256().GetRawBytes(),
+        libzcash::SaplingSpendingKey::random().default_address().GetRawBytes(),
+        1000,
+        libzcash::Memo::ToBytes(std::nullopt));
+    mtx.saplingBundle = sapling::apply_bundle_signatures(sapling::build_bundle(std::move(builder), 10), {});
 
-    mtx.valueBalanceSapling = -1000;
     CTransaction tx(mtx);
     EXPECT_TRUE(tx.IsCoinBase());
 
diff --git a/src/gtest/test_coins.cpp b/src/gtest/test_coins.cpp
index f6cbbb180df..1042ee02d7a 100644
--- a/src/gtest/test_coins.cpp
+++ b/src/gtest/test_coins.cpp
@@ -279,6 +279,7 @@ class TxWithNullifiers
 {
 public:
     CTransaction tx;
+    CTransaction txV5;
     uint256 sproutNullifier;
     uint256 saplingNullifier;
     uint256 orchardNullifier;
@@ -286,25 +287,31 @@ class TxWithNullifiers
     TxWithNullifiers()
     {
         CMutableTransaction mutableTx;
+        CMutableTransaction mutableTxV5;
+
+        mutableTxV5.fOverwintered = true;
+        mutableTxV5.nVersion = ZIP225_TX_VERSION;
+        mutableTxV5.nVersionGroupId = ZIP225_VERSION_GROUP_ID;
+        mutableTxV5.nConsensusBranchId = NetworkUpgradeInfo[Consensus::UPGRADE_NU5].nBranchId;
 
         sproutNullifier = GetRandHash();
         JSDescription jsd;
         jsd.nullifiers[0] = sproutNullifier;
         mutableTx.vJoinSplit.emplace_back(jsd);
 
-        SpendDescription sd = RandomInvalidSpendDescription();
-        saplingNullifier = sd.nullifier();
-        mutableTx.vShieldedSpend.push_back(sd);
+        mutableTxV5.saplingBundle = sapling::test_only_invalid_bundle(1, 0, 0);
+        saplingNullifier = uint256::FromRawBytes(mutableTxV5.saplingBundle.GetDetails().spends()[0].nullifier());
 
         // The Orchard bundle builder always pads to two Actions, so we can just
         // use an empty builder to create a dummy Orchard bundle.
         uint256 orchardAnchor;
         uint256 dataToBeSigned;
         auto builder = orchard::Builder(true, true, orchardAnchor);
-        mutableTx.orchardBundle = builder.Build().value().ProveAndSign({}, dataToBeSigned).value();
-        orchardNullifier = mutableTx.orchardBundle.GetNullifiers().at(0);
+        mutableTxV5.orchardBundle = builder.Build().value().ProveAndSign({}, dataToBeSigned).value();
+        orchardNullifier = mutableTxV5.orchardBundle.GetNullifiers().at(0);
 
         tx = CTransaction(mutableTx);
+        txV5 = CTransaction(mutableTxV5);
     }
 };
 
@@ -375,11 +382,13 @@ TEST(CoinsTests, NullifierRegression)
 
         // Insert a nullifier into the base.
         cache1.SetNullifiers(txWithNullifiers.tx, true);
+        cache1.SetNullifiers(txWithNullifiers.txV5, true);
         checkNullifierCache(cache1, txWithNullifiers, true);
         cache1.Flush(); // Flush to base.
 
         // Remove the nullifier from cache
         cache1.SetNullifiers(txWithNullifiers.tx, false);
+        cache1.SetNullifiers(txWithNullifiers.txV5, false);
 
         // The nullifier now should be `false`.
         checkNullifierCache(cache1, txWithNullifiers, false);
@@ -395,11 +404,13 @@ TEST(CoinsTests, NullifierRegression)
 
         // Insert a nullifier into the base.
         cache1.SetNullifiers(txWithNullifiers.tx, true);
+        cache1.SetNullifiers(txWithNullifiers.txV5, true);
         checkNullifierCache(cache1, txWithNullifiers, true);
         cache1.Flush(); // Flush to base.
 
         // Remove the nullifier from cache
         cache1.SetNullifiers(txWithNullifiers.tx, false);
+        cache1.SetNullifiers(txWithNullifiers.txV5, false);
         cache1.Flush(); // Flush to base.
 
         // The nullifier now should be `false`.
@@ -415,6 +426,7 @@ TEST(CoinsTests, NullifierRegression)
         // Insert a nullifier into the base.
         TxWithNullifiers txWithNullifiers;
         cache1.SetNullifiers(txWithNullifiers.tx, true);
+        cache1.SetNullifiers(txWithNullifiers.txV5, true);
         checkNullifierCache(cache1, txWithNullifiers, true);
         cache1.Flush(); // Empties cache.
 
@@ -424,6 +436,7 @@ TEST(CoinsTests, NullifierRegression)
             CCoinsViewCacheTest cache2(&cache1);
             checkNullifierCache(cache2, txWithNullifiers, true);
             cache1.SetNullifiers(txWithNullifiers.tx, false);
+            cache1.SetNullifiers(txWithNullifiers.txV5, false);
             cache2.Flush(); // Empties cache, flushes to cache1.
         }
 
@@ -440,6 +453,7 @@ TEST(CoinsTests, NullifierRegression)
         // Insert a nullifier into the base.
         TxWithNullifiers txWithNullifiers;
         cache1.SetNullifiers(txWithNullifiers.tx, true);
+        cache1.SetNullifiers(txWithNullifiers.txV5, true);
         cache1.Flush(); // Empties cache.
 
         // Create cache on top.
@@ -447,6 +461,7 @@ TEST(CoinsTests, NullifierRegression)
             // Remove the nullifier.
             CCoinsViewCacheTest cache2(&cache1);
             cache2.SetNullifiers(txWithNullifiers.tx, false);
+            cache2.SetNullifiers(txWithNullifiers.txV5, false);
             cache2.Flush(); // Empties cache, flushes to cache1.
         }
 
@@ -667,6 +682,7 @@ TEST(CoinsTests, NullifiersTest)
         checkNullifierCache(cache, txWithNullifiers, false);
     }
     cache.SetNullifiers(txWithNullifiers.tx, true);
+    cache.SetNullifiers(txWithNullifiers.txV5, true);
     {
         SCOPED_TRACE("cache with spent nullifiers");
         checkNullifierCache(cache, txWithNullifiers, true);
@@ -680,6 +696,7 @@ TEST(CoinsTests, NullifiersTest)
         checkNullifierCache(cache2, txWithNullifiers, true);
     }
     cache2.SetNullifiers(txWithNullifiers.tx, false);
+    cache2.SetNullifiers(txWithNullifiers.txV5, false);
     {
         SCOPED_TRACE("cache2 with unspent nullifiers");
         checkNullifierCache(cache2, txWithNullifiers, false);
diff --git a/src/gtest/test_dynamicusage.cpp b/src/gtest/test_dynamicusage.cpp
index 4235f5edf56..26e425524c9 100644
--- a/src/gtest/test_dynamicusage.cpp
+++ b/src/gtest/test_dynamicusage.cpp
@@ -23,15 +23,16 @@ TEST(RecursiveDynamicUsageTests, TestTransactionTransparent)
     auto scriptPubKey = GetScriptForDestination(tsk.GetPubKey().GetID());
     CTxDestination taddr = tsk.GetPubKey().GetID();
 
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
     builder.SetFee(10000);
-    builder.AddTransparentInput(COutPoint(), scriptPubKey, 50000);
+    builder.AddTransparentInput(
+        COutPoint(uint256S("7777777777777777777777777777777777777777777777777777777777777777"), 0),
+        scriptPubKey, 50000);
     builder.AddTransparentOutput(taddr, 40000);
 
     auto tx = builder.Build().GetTxOrThrow();
     // 1 vin + 1 vout
-    // (96 + 128) + 64
-    EXPECT_EQ(288, RecursiveDynamicUsage(tx));
+    EXPECT_EQ((96 + 128) + 64, RecursiveDynamicUsage(tx));
 
     RegtestDeactivateSapling();
 }
@@ -44,8 +45,9 @@ TEST(RecursiveDynamicUsageTests, TestTransactionJoinSplit)
 
     auto wtx = GetValidSproutReceive(sproutSk, 25000, true);
     // 2 vin + 1 vJoinSplit + 1 vShieldedOutput
-    // 160 + 1856 + 976
-    EXPECT_EQ(2992, RecursiveDynamicUsage(wtx));
+    EXPECT_EQ(0, wtx.GetSaplingSpendsCount());
+    EXPECT_EQ(1, wtx.GetSaplingOutputsCount());
+    EXPECT_EQ(160 + 1856 + 1200, RecursiveDynamicUsage(wtx));
 
     RegtestDeactivateSapling();
 }
@@ -56,18 +58,22 @@ TEST(RecursiveDynamicUsageTests, TestTransactionSaplingToSapling)
 
     auto consensusParams = RegtestActivateSapling();
 
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto testNote = GetTestSaplingNote(sk.default_address(), 50000);
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
+    auto testNote = GetTestSaplingNote(pa, 50000);
 
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
     builder.SetFee(10000);
-    builder.AddSaplingSpend(sk.expanded_spending_key(), testNote.note, testNote.tree.root(), testNote.tree.witness());
-    builder.AddSaplingOutput(sk.full_viewing_key().ovk, sk.default_address(), 5000, {});
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
+    builder.AddSaplingOutput(fvk.ovk, pa, 5000, {});
 
     auto tx = builder.Build().GetTxOrThrow();
     // 1 vShieldedSpend + 2 vShieldedOutput
-    // 400 + 1920
-    EXPECT_EQ(2320, RecursiveDynamicUsage(tx));
+    EXPECT_EQ(1, tx.GetSaplingSpendsCount());
+    EXPECT_EQ(2, tx.GetSaplingOutputsCount());
+    EXPECT_EQ(400 + 2520, RecursiveDynamicUsage(tx));
 
     RegtestDeactivateSapling();
 }
@@ -83,15 +89,18 @@ TEST(RecursiveDynamicUsageTests, TestTransactionTransparentToSapling)
     auto scriptPubKey = GetScriptForDestination(tsk.GetPubKey().GetID());
     auto sk = libzcash::SaplingSpendingKey::random();
 
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
     builder.SetFee(10000);
-    builder.AddTransparentInput(COutPoint(), scriptPubKey, 50000);
+    builder.AddTransparentInput(
+        COutPoint(uint256S("7777777777777777777777777777777777777777777777777777777777777777"), 0),
+        scriptPubKey, 50000);
     builder.AddSaplingOutput(sk.full_viewing_key().ovk, sk.default_address(), 40000, {});
 
     auto tx = builder.Build().GetTxOrThrow();
     // 1 vin + 1 vShieldedOutput
-    // (96 + 128) + 976
-    EXPECT_EQ(1200, RecursiveDynamicUsage(tx));
+    EXPECT_EQ(0, tx.GetSaplingSpendsCount());
+    EXPECT_EQ(1, tx.GetSaplingOutputsCount());
+    EXPECT_EQ((96 + 128) + 1200, RecursiveDynamicUsage(tx));
 
     RegtestDeactivateSapling();
 }
@@ -105,18 +114,19 @@ TEST(RecursiveDynamicUsageTests, TestTransactionSaplingToTransparent)
     CBasicKeyStore keystore;
     CKey tsk = AddTestCKeyToKeyStore(keystore);
     CTxDestination taddr = tsk.GetPubKey().GetID();
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto testNote = GetTestSaplingNote(sk.default_address(), 50000);
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto testNote = GetTestSaplingNote(sk.ToXFVK().DefaultAddress(), 50000);
 
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
     builder.SetFee(10000);
-    builder.AddSaplingSpend(sk.expanded_spending_key(), testNote.note, testNote.tree.root(), testNote.tree.witness());
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
     builder.AddTransparentOutput(taddr, 40000);
 
     auto tx = builder.Build().GetTxOrThrow();
-    // 1 vShieldedSpend + 1 vout
-    // 400 + 64
-    EXPECT_EQ(464, RecursiveDynamicUsage(tx));
+    // 1 vShieldedSpend + 2 vShieldedOutput + 1 vout
+    EXPECT_EQ(1, tx.GetSaplingSpendsCount());
+    EXPECT_EQ(2, tx.GetSaplingOutputsCount());
+    EXPECT_EQ(400 + 2520 + 64, RecursiveDynamicUsage(tx));
 
     RegtestDeactivateSapling();
 }
diff --git a/src/gtest/test_mempoollimit.cpp b/src/gtest/test_mempoollimit.cpp
index fb08bb1156a..9abffbf2d4c 100644
--- a/src/gtest/test_mempoollimit.cpp
+++ b/src/gtest/test_mempoollimit.cpp
@@ -114,15 +114,19 @@ TEST(MempoolLimitTests, MempoolCostAndEvictionWeight)
     // test_transaction_builder.cpp/TEST(TransactionBuilder, SetFee)
     auto consensusParams = RegtestActivateSapling();
 
-    auto sk = libzcash::SaplingSpendingKey::random();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
+
     CAmount funds = 66000;
-    auto testNote = GetTestSaplingNote(sk.default_address(), funds);
+    auto testNote = GetTestSaplingNote(pa, funds);
 
     // Default fee
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(sk.expanded_spending_key(), testNote.note, testNote.tree.root(), testNote.tree.witness());
-        builder.AddSaplingOutput(sk.full_viewing_key().ovk, sk.default_address(), 25000, {});
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
+        builder.AddSaplingOutput(fvk.ovk, pa, 25000, {});
 
         auto [cost, evictionWeight] = MempoolCostAndEvictionWeight(builder.Build().GetTxOrThrow(), MINIMUM_FEE);
         EXPECT_EQ(MIN_TX_COST, cost);
@@ -131,9 +135,9 @@ TEST(MempoolLimitTests, MempoolCostAndEvictionWeight)
 
     // Lower than standard fee
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(sk.expanded_spending_key(), testNote.note, testNote.tree.root(), testNote.tree.witness());
-        builder.AddSaplingOutput(sk.full_viewing_key().ovk, sk.default_address(), 25000, {});
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
+        builder.AddSaplingOutput(fvk.ovk, pa, 25000, {});
         static_assert(MINIMUM_FEE == 10000);
         builder.SetFee(MINIMUM_FEE-1);
 
@@ -144,10 +148,10 @@ TEST(MempoolLimitTests, MempoolCostAndEvictionWeight)
 
     // Larger Tx
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(sk.expanded_spending_key(), testNote.note, testNote.tree.root(), testNote.tree.witness());
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
         for (int i = 0; i < 10; i++) {
-            builder.AddSaplingOutput(sk.full_viewing_key().ovk, sk.default_address(), 1000, {});
+            builder.AddSaplingOutput(fvk.ovk, pa, 1000, {});
         }
 
         auto result = builder.Build();
diff --git a/src/gtest/test_noteencryption.cpp b/src/gtest/test_noteencryption.cpp
index 38ea5f39019..0cffa5028fb 100644
--- a/src/gtest/test_noteencryption.cpp
+++ b/src/gtest/test_noteencryption.cpp
@@ -46,56 +46,42 @@ TEST(NoteEncryption, NotePlaintext)
     for (int ver = 0; ver < zip_212_enabled.size(); ver++){
         auto params = (*activations[ver])();
 
-        SaplingNote note(addr, 39393, zip_212_enabled[ver]);
-        auto cmu_opt = note.cmu();
-        if (!cmu_opt) {
-            FAIL();
-        }
-        uint256 cmu = cmu_opt.value();
-        SaplingNotePlaintext pt(note, Memo::FromBytes(memo));
-
-        auto res = pt.encrypt(addr.pk_d);
-        if (!res) {
-            FAIL();
-        }
-
-        auto enc = res.value();
-
-        auto ct = enc.first;
-        auto encryptor = enc.second;
-        auto epk = encryptor.get_epk();
-
-        SaplingOutgoingPlaintext out_pt;
-        out_pt.pk_d = note.pk_d;
-        out_pt.esk = encryptor.get_esk();
-
         auto ovk = random_uint256();
-        auto cv = random_uint256();
-        auto cm = random_uint256();
-
-        auto out_ct = out_pt.encrypt(
-            ovk,
-            cv,
-            cm,
-            encryptor
-        );
-
-        auto decrypted_out_ct = out_pt.decrypt(
-            out_ct,
-            ovk,
-            cv,
-            cm,
-            encryptor.get_epk()
-        );
-
-        if (!decrypted_out_ct) {
-            FAIL();
+        auto value = 39393;
+
+        auto builder = sapling::new_builder(*Params().RustNetwork(), 10);
+        builder->add_recipient(
+            ovk.GetRawBytes(),
+            addr.GetRawBytes(),
+            value,
+            memo);
+        auto bundle = sapling::apply_bundle_signatures(sapling::build_bundle(std::move(builder), 10), {});
+        auto outputs = bundle->outputs();
+
+        std::optional<SaplingOutgoingPlaintext> decrypted_out_ct;
+        uint256 cmu;
+        uint256 epk;
+        libzcash::SaplingEncCiphertext ct;
+        for (auto& output : outputs) {
+            decrypted_out_ct = SaplingOutgoingPlaintext::decrypt(
+                output.out_ciphertext(),
+                ovk,
+                uint256::FromRawBytes(output.cv()),
+                uint256::FromRawBytes(output.cmu()),
+                uint256::FromRawBytes(output.ephemeral_key()));
+            if (decrypted_out_ct) {
+                cmu = uint256::FromRawBytes(output.cmu());
+                epk = uint256::FromRawBytes(output.ephemeral_key());
+                ct = output.enc_ciphertext();
+                break;
+            }
         }
 
+        ASSERT_TRUE(decrypted_out_ct.has_value());
+
         auto decrypted_out_ct_unwrapped = decrypted_out_ct.value();
 
-        ASSERT_TRUE(decrypted_out_ct_unwrapped.pk_d == out_pt.pk_d);
-        ASSERT_TRUE(decrypted_out_ct_unwrapped.esk == out_pt.esk);
+        ASSERT_TRUE(decrypted_out_ct_unwrapped.pk_d == addr.pk_d);
 
         // Test sender won't accept invalid commitments
         ASSERT_FALSE(
@@ -127,10 +113,9 @@ TEST(NoteEncryption, NotePlaintext)
 
         auto bar = foo.value();
 
-        ASSERT_TRUE(bar.value() == pt.value());
-        ASSERT_TRUE(bar.memo() == pt.memo());
-        ASSERT_TRUE(bar.d == pt.d);
-        ASSERT_TRUE(bar.rcm() == pt.rcm());
+        ASSERT_TRUE(bar.value() == value);
+        ASSERT_TRUE(bar.memo() == Memo::FromBytes(memo));
+        ASSERT_TRUE(bar.d == addr.d);
 
         (*deactivations[ver])();
     }
diff --git a/src/gtest/test_transaction_builder.cpp b/src/gtest/test_transaction_builder.cpp
index 8ed370414d7..82fabc0bddf 100644
--- a/src/gtest/test_transaction_builder.cpp
+++ b/src/gtest/test_transaction_builder.cpp
@@ -40,7 +40,7 @@ TEST(TransactionBuilder, TransparentToSapling)
 
     // Create a shielding transaction from transparent to Sapling
     // 0.00005 t-ZEC in, 0.00004 z-ZEC out, default fee
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
     builder.AddTransparentInput(COutPoint(uint256S("1234"), 0), scriptPubKey, 5000);
     builder.AddSaplingOutput(fvk_from.ovk, pk, 4000, {});
     auto tx = builder.Build().GetTxOrThrow();
@@ -65,21 +65,22 @@ TEST(TransactionBuilder, SaplingToSapling) {
 
     auto consensusParams = RegtestActivateSapling();
 
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto expsk = sk.expanded_spending_key();
-    auto fvk = sk.full_viewing_key();
-    auto pa = sk.default_address();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
 
     auto testNote = GetTestSaplingNote(pa, 4000);
 
     // Create a Sapling-only transaction
     // 0.00004 z-ZEC in, 0.000025 z-ZEC out, default fee, 0.000005 z-ZEC change
-    auto builder = TransactionBuilder(consensusParams, 2, std::nullopt);
-    builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+    auto builder = TransactionBuilder(Params(), 2, std::nullopt);
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
 
     // Check that trying to add a different anchor fails
     // TODO: the following check can be split out in to another test
-    ASSERT_THROW(builder.AddSaplingSpend(expsk, testNote.note, uint256(), testNote.tree.witness()), UniValue);
+    testNote.tree.append(uint256());
+    ASSERT_THROW(builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness()), rust::Error);
 
     builder.AddSaplingOutput(fvk.ovk, pa, 2500, {});
     auto tx = builder.Build().GetTxOrThrow();
@@ -104,9 +105,10 @@ TEST(TransactionBuilder, SaplingToSprout) {
 
     auto consensusParams = RegtestActivateSapling();
 
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto expsk = sk.expanded_spending_key();
-    auto pa = sk.default_address();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
 
     auto testNote = GetTestSaplingNote(pa, 4000);
 
@@ -117,8 +119,8 @@ TEST(TransactionBuilder, SaplingToSprout) {
     // - 0.00004 Sapling-ZEC in     - 0.000025 Sprout-ZEC out
     //                              - 0.000005 Sapling-ZEC change
     //                              - default t-ZEC fee
-    auto builder = TransactionBuilder(consensusParams, 2, std::nullopt, nullptr);
-    builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+    auto builder = TransactionBuilder(Params(), 2, std::nullopt, nullptr);
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
     builder.AddSproutOutput(sproutAddr, 2500, std::nullopt);
     auto tx = builder.Build().GetTxOrThrow();
 
@@ -128,7 +130,7 @@ TEST(TransactionBuilder, SaplingToSprout) {
     EXPECT_EQ(tx.vJoinSplit[0].vpub_old, 2500);
     EXPECT_EQ(tx.vJoinSplit[0].vpub_new, 0);
     EXPECT_EQ(tx.GetSaplingSpendsCount(), 1);
-    EXPECT_EQ(tx.GetSaplingOutputsCount(), 1);
+    EXPECT_EQ(tx.GetSaplingOutputsCount(), 2);
     EXPECT_EQ(tx.GetValueBalanceSapling(), 3500);
 
     CValidationState state;
@@ -171,7 +173,7 @@ TEST(TransactionBuilder, SproutToSproutAndSapling) {
     //                              - 0.00005 Sprout-ZEC change
     //                              - 0.00005 Sapling-ZEC out
     //                              - 0.00005 t-ZEC fee
-    auto builder = TransactionBuilder(consensusParams, 2, std::nullopt, nullptr, &view);
+    auto builder = TransactionBuilder(Params(), 2, std::nullopt, nullptr, &view);
     builder.SetFee(5000);
     builder.AddSproutInput(sproutSk, sproutNote, sproutWitness);
     builder.AddSproutOutput(sproutAddr, 6000, std::nullopt);
@@ -245,7 +247,7 @@ TEST(TransactionBuilder, TransparentToOrchard)
 
     // Create a shielding transaction from transparent to Orchard
     // 0.00005 t-ZEC in, 0.00004 z-ZEC out, default fee
-    auto builder = TransactionBuilder(consensusParams, 1, orchardAnchor, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, orchardAnchor, &keystore);
     builder.AddTransparentInput(COutPoint(uint256S("1234"), 0), scriptPubKey, 5000);
     builder.AddOrchardOutput(std::nullopt, recipient, 4000, std::nullopt);
     auto maybeTx = builder.Build();
@@ -277,7 +279,7 @@ TEST(TransactionBuilder, ThrowsOnTransparentInputWithoutKeyStore)
     SelectParams(CBaseChainParams::REGTEST);
     const Consensus::Params& consensusParams = Params().GetConsensus();
 
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
     ASSERT_THROW(builder.AddTransparentInput(COutPoint(), CScript(), 1), std::runtime_error);
 }
 
@@ -288,7 +290,7 @@ TEST(TransactionBuilder, RejectsInvalidTransparentOutput)
 
     // Default CTxDestination type is an invalid address
     CTxDestination taddr;
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
     ASSERT_THROW(builder.AddTransparentOutput(taddr, 50), UniValue);
 }
 
@@ -299,10 +301,10 @@ TEST(TransactionBuilder, FailsWithNegativeChange)
     auto consensusParams = RegtestActivateSapling();
 
     // Generate dummy Sapling address
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto expsk = sk.expanded_spending_key();
-    auto fvk = sk.full_viewing_key();
-    auto pa = sk.default_address();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
 
     // Set up dummy transparent address
     CBasicKeyStore keystore;
@@ -315,23 +317,25 @@ TEST(TransactionBuilder, FailsWithNegativeChange)
 
     // Fail if there is only a Sapling output
     // 0.00005 z-ZEC out, default fee
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
     builder.AddSaplingOutput(fvk.ovk, pa, 5000, {});
     EXPECT_EQ("Change cannot be negative: -0.00006 ZEC", builder.Build().GetError());
 
     // Fail if there is only a transparent output
     // 0.00005 t-ZEC out, default fee
-    builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
+    builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
     builder.AddTransparentOutput(taddr, 5000);
     EXPECT_EQ("Change cannot be negative: -0.00006 ZEC", builder.Build().GetError());
 
     // Fails if there is insufficient input
     // 0.00005 t-ZEC out, default fee, 0.00005999 z-ZEC in
-    builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
     EXPECT_EQ("Change cannot be negative: -0.00000001 ZEC", builder.Build().GetError());
 
     // Succeeds if there is sufficient input
-    builder.AddTransparentInput(COutPoint(), scriptPubKey, 1);
+    builder.AddTransparentInput(
+        COutPoint(uint256S("7777777777777777777777777777777777777777777777777777777777777777"), 0),
+        scriptPubKey, 1);
     EXPECT_TRUE(builder.Build().IsTx());
 
     // Revert to default
@@ -345,9 +349,10 @@ TEST(TransactionBuilder, ChangeOutput)
     auto consensusParams = RegtestActivateSapling();
 
     // Generate dummy Sapling address
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto expsk = sk.expanded_spending_key();
-    auto pa = sk.default_address();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
 
     auto testNote = GetTestSaplingNote(pa, 2500);
 
@@ -362,32 +367,34 @@ TEST(TransactionBuilder, ChangeOutput)
     auto tkeyid = tsk.GetPubKey().GetID();
     auto scriptPubKey = GetScriptForDestination(tkeyid);
 
+    auto fakeCoin = COutPoint(uint256S("7777777777777777777777777777777777777777777777777777777777777777"), 0);
+
     // No change address and no Sapling spends
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
-        builder.AddTransparentInput(COutPoint(), scriptPubKey, 2500);
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
+        builder.AddTransparentInput(fakeCoin, scriptPubKey, 2500);
         EXPECT_EQ("Could not determine change address", builder.Build().GetError());
     }
 
     // Change to the same address as the first Sapling spend
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
-        builder.AddTransparentInput(COutPoint(), scriptPubKey, 2500);
-        builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
+        builder.AddTransparentInput(fakeCoin, scriptPubKey, 2500);
+        builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
         auto tx = builder.Build().GetTxOrThrow();
 
         EXPECT_EQ(tx.vin.size(), 1);
         EXPECT_EQ(tx.vout.size(), 0);
         EXPECT_EQ(tx.vJoinSplit.size(), 0);
         EXPECT_EQ(tx.GetSaplingSpendsCount(), 1);
-        EXPECT_EQ(tx.GetSaplingOutputsCount(), 1);
+        EXPECT_EQ(tx.GetSaplingOutputsCount(), 2);
         EXPECT_EQ(tx.GetValueBalanceSapling(), -1500);
     }
 
     // Change to a Sapling address
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
-        builder.AddTransparentInput(COutPoint(), scriptPubKey, 2500);
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
+        builder.AddTransparentInput(fakeCoin, scriptPubKey, 2500);
         builder.SendChangeTo(zChangeAddr, fvkOut.ovk);
         auto tx = builder.Build().GetTxOrThrow();
 
@@ -401,8 +408,8 @@ TEST(TransactionBuilder, ChangeOutput)
 
     // Change to a transparent address
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
-        builder.AddTransparentInput(COutPoint(), scriptPubKey, 2500);
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
+        builder.AddTransparentInput(fakeCoin, scriptPubKey, 2500);
         builder.SendChangeTo(tkeyid, {});
         auto tx = builder.Build().GetTxOrThrow();
 
@@ -426,17 +433,17 @@ TEST(TransactionBuilder, SetFee)
     auto consensusParams = RegtestActivateSapling();
 
     // Generate dummy Sapling address
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto expsk = sk.expanded_spending_key();
-    auto fvk = sk.full_viewing_key();
-    auto pa = sk.default_address();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto extfvk = sk.ToXFVK();
+    auto fvk = extfvk.fvk;
+    auto pa = extfvk.DefaultAddress();
 
     auto testNote = GetTestSaplingNote(pa, 50000);
 
     // Default fee
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
         builder.AddSaplingOutput(fvk.ovk, pa, 25000, {});
         auto tx = builder.Build().GetTxOrThrow();
 
@@ -451,8 +458,8 @@ TEST(TransactionBuilder, SetFee)
 
     // Configured fee
     {
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
         builder.AddSaplingOutput(fvk.ovk, pa, 25000, {});
         builder.SetFee(20000);
         auto tx = builder.Build().GetTxOrThrow();
@@ -476,12 +483,11 @@ TEST(TransactionBuilder, CheckSaplingTxVersion)
     UpdateNetworkUpgradeParameters(Consensus::UPGRADE_OVERWINTER, Consensus::NetworkUpgrade::ALWAYS_ACTIVE);
     const Consensus::Params& consensusParams = Params().GetConsensus();
 
-    auto sk = libzcash::SaplingSpendingKey::random();
-    auto expsk = sk.expanded_spending_key();
-    auto pk = sk.default_address();
+    auto sk = GetTestMasterSaplingSpendingKey();
+    auto pk = sk.ToXFVK().DefaultAddress();
 
     // Cannot add Sapling outputs to a non-Sapling transaction
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
     try {
         builder.AddSaplingOutput(uint256(), pk, 12345, {});
     } catch (std::runtime_error const & err) {
@@ -494,7 +500,7 @@ TEST(TransactionBuilder, CheckSaplingTxVersion)
     libzcash::SaplingNote note(pk, 50000, libzcash::Zip212Enabled::BeforeZip212);
     SaplingMerkleTree tree;
     try {
-        builder.AddSaplingSpend(expsk, note, uint256(), tree.witness());
+        builder.AddSaplingSpend(sk, note, tree.witness());
     } catch (std::runtime_error const & err) {
         EXPECT_EQ(err.what(), std::string("TransactionBuilder cannot add Sapling spend to pre-Sapling transaction"));
     } catch(...) {
diff --git a/src/gtest/test_validation.cpp b/src/gtest/test_validation.cpp
index eace1e11d0d..04f3953fc08 100644
--- a/src/gtest/test_validation.cpp
+++ b/src/gtest/test_validation.cpp
@@ -162,7 +162,8 @@ TEST(Validation, ContextualCheckInputsDetectsOldBranchId) {
     UpdateNetworkUpgradeParameters(Consensus::UPGRADE_OVERWINTER, 10);
     UpdateNetworkUpgradeParameters(Consensus::UPGRADE_SAPLING, 20);
     UpdateNetworkUpgradeParameters(Consensus::UPGRADE_BLOSSOM, 30);
-    const Consensus::Params& consensusParams = Params(CBaseChainParams::REGTEST).GetConsensus();
+    const CChainParams& params = Params(CBaseChainParams::REGTEST);
+    const Consensus::Params& consensusParams = params.GetConsensus();
 
     auto overwinterBranchId = NetworkUpgradeInfo[Consensus::UPGRADE_OVERWINTER].nBranchId;
     auto saplingBranchId = NetworkUpgradeInfo[Consensus::UPGRADE_SAPLING].nBranchId;
@@ -195,7 +196,7 @@ TEST(Validation, ContextualCheckInputsDetectsOldBranchId) {
 
     // Create a transparent transaction that spends the coin, targeting
     // a height during the Overwinter epoch.
-    auto builder = TransactionBuilder(consensusParams, 15, std::nullopt, &keystore);
+    auto builder = TransactionBuilder(params, 15, std::nullopt, &keystore);
     builder.AddTransparentInput(utxo, scriptPubKey, coinValue);
     builder.AddTransparentOutput(destination, 4000);
     auto tx = builder.Build().GetTxOrThrow();
diff --git a/src/rust/include/rust/test_harness.h b/src/rust/include/rust/test_harness.h
deleted file mode 100644
index a7f586ee450..00000000000
--- a/src/rust/include/rust/test_harness.h
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (c) 2020-2023 The Zcash developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or https://www.opensource.org/licenses/mit-license.php .
-
-#ifndef ZCASH_RUST_INCLUDE_RUST_TEST_HARNESS_H
-#define ZCASH_RUST_INCLUDE_RUST_TEST_HARNESS_H
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/// Generates a random Jubjub base field element.
-///
-/// `ret` must point to a 32-byte array.
-void zcash_test_harness_random_jubjub_base(
-    unsigned char* ret);
-
-/// Generates a random Jubjub point.
-///
-/// `ret` must point to a 32-byte array.
-void zcash_test_harness_random_jubjub_point(
-    unsigned char* ret);
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif // ZCASH_RUST_INCLUDE_RUST_TEST_HARNESS_H
diff --git a/src/rust/src/test_harness_ffi.rs b/src/rust/src/test_harness_ffi.rs
index 196a1982457..2d22d741e20 100644
--- a/src/rust/src/test_harness_ffi.rs
+++ b/src/rust/src/test_harness_ffi.rs
@@ -1,13 +1,155 @@
 use group::{ff::Field, Group, GroupEncoding};
-use jubjub::{ExtendedPoint, Fq};
-use rand_core::OsRng;
+use rand::{thread_rng, Rng};
+use zcash_note_encryption::EphemeralKeyBytes;
+use zcash_primitives::{
+    sapling::{self, redjubjub, value::ValueCommitment},
+    transaction::components::{sapling as sapling_tx, Amount},
+};
 
-#[no_mangle]
-pub extern "C" fn zcash_test_harness_random_jubjub_base(ret: *mut [u8; 32]) {
-    *unsafe { &mut *ret } = Fq::random(OsRng).to_bytes();
+pub(crate) fn test_only_invalid_sapling_bundle(
+    spends: usize,
+    outputs: usize,
+    value_balance: i64,
+) -> Box<crate::sapling::Bundle> {
+    let mut rng = thread_rng();
+
+    fn gen_array<R: Rng, const N: usize>(mut rng: R) -> [u8; N] {
+        let mut tmp = [0; N];
+        rng.fill(&mut tmp[..]);
+        tmp
+    }
+
+    let spends = (0..spends)
+        .map(|_| {
+            let cv = ValueCommitment::from_bytes_not_small_order(
+                &jubjub::ExtendedPoint::random(&mut rng).to_bytes(),
+            )
+            .unwrap();
+            let anchor = jubjub::Base::random(&mut rng);
+            let nullifier = sapling::Nullifier(rng.gen());
+            let rk = redjubjub::PublicKey(jubjub::ExtendedPoint::random(&mut rng));
+            let zkproof = gen_array(&mut rng);
+            let spend_auth_sig = {
+                let tmp = gen_array::<_, 64>(&mut rng);
+                redjubjub::Signature::read(&tmp[..]).unwrap()
+            };
+
+            sapling_tx::SpendDescription::temporary_zcashd_from_parts(
+                cv,
+                anchor,
+                nullifier,
+                rk,
+                zkproof,
+                spend_auth_sig,
+            )
+        })
+        .collect();
+
+    let outputs = (0..outputs)
+        .map(|_| {
+            let cv = ValueCommitment::from_bytes_not_small_order(
+                &jubjub::ExtendedPoint::random(&mut rng).to_bytes(),
+            )
+            .unwrap();
+            let cmu = sapling::note::ExtractedNoteCommitment::from_bytes(
+                &jubjub::Base::random(&mut rng).to_bytes(),
+            )
+            .unwrap();
+            let ephemeral_key =
+                EphemeralKeyBytes(jubjub::ExtendedPoint::random(&mut rng).to_bytes());
+            let enc_ciphertext = gen_array(&mut rng);
+            let out_ciphertext = gen_array(&mut rng);
+            let zkproof = gen_array(&mut rng);
+
+            sapling_tx::OutputDescription::temporary_zcashd_from_parts(
+                cv,
+                cmu,
+                ephemeral_key,
+                enc_ciphertext,
+                out_ciphertext,
+                zkproof,
+            )
+        })
+        .collect();
+
+    let binding_sig = {
+        let tmp = gen_array::<_, 64>(&mut rng);
+        redjubjub::Signature::read(&tmp[..]).unwrap()
+    };
+
+    let bundle = sapling_tx::Bundle::temporary_zcashd_from_parts(
+        spends,
+        outputs,
+        Amount::from_i64(value_balance).unwrap(),
+        sapling_tx::Authorized { binding_sig },
+    );
+    Box::new(crate::sapling::Bundle(Some(bundle)))
+}
+
+pub(crate) fn test_only_replace_sapling_nullifier(
+    bundle: &mut crate::sapling::Bundle,
+    spend_index: usize,
+    nullifier: [u8; 32],
+) {
+    if let Some(bundle) = bundle.0.as_mut() {
+        *bundle = sapling_tx::Bundle::temporary_zcashd_from_parts(
+            bundle
+                .shielded_spends()
+                .iter()
+                .enumerate()
+                .map(|(i, spend)| {
+                    if i == spend_index {
+                        sapling_tx::SpendDescription::temporary_zcashd_from_parts(
+                            spend.cv().clone(),
+                            *spend.anchor(),
+                            sapling::Nullifier(nullifier),
+                            spend.rk().clone(),
+                            *spend.zkproof(),
+                            *spend.spend_auth_sig(),
+                        )
+                    } else {
+                        spend.clone()
+                    }
+                })
+                .collect(),
+            bundle.shielded_outputs().to_vec(),
+            *bundle.value_balance(),
+            *bundle.authorization(),
+        )
+    }
 }
 
-#[no_mangle]
-pub extern "C" fn zcash_test_harness_random_jubjub_point(ret: *mut [u8; 32]) {
-    *unsafe { &mut *ret } = ExtendedPoint::random(OsRng).to_bytes();
+pub(crate) fn test_only_replace_sapling_output_parts(
+    bundle: &mut crate::sapling::Bundle,
+    output_index: usize,
+    cmu: [u8; 32],
+    enc_ciphertext: [u8; 580],
+    out_ciphertext: [u8; 80],
+) {
+    if let Some(bundle) = bundle.0.as_mut() {
+        *bundle = sapling_tx::Bundle::temporary_zcashd_from_parts(
+            bundle.shielded_spends().to_vec(),
+            bundle
+                .shielded_outputs()
+                .iter()
+                .enumerate()
+                .map(|(i, output)| {
+                    if i == output_index {
+                        sapling_tx::OutputDescription::temporary_zcashd_from_parts(
+                            output.cv().clone(),
+                            sapling::note::ExtractedNoteCommitment::from_bytes(&cmu).unwrap(),
+                            output.ephemeral_key().clone(),
+                            enc_ciphertext,
+                            out_ciphertext,
+                            *output.zkproof(),
+                        )
+                    } else {
+                        output.clone()
+                    }
+                })
+                .collect(),
+            *bundle.value_balance(),
+            *bundle.authorization(),
+        )
+    }
 }
diff --git a/src/test/coins_tests.cpp b/src/test/coins_tests.cpp
index d3e8a0b47e3..fd714268cb4 100644
--- a/src/test/coins_tests.cpp
+++ b/src/test/coins_tests.cpp
@@ -287,9 +287,8 @@ class TxWithNullifiers
         jsd.nullifiers[0] = sproutNullifier;
         mutableTx.vJoinSplit.emplace_back(jsd);
 
-        SpendDescription sd = RandomInvalidSpendDescription();
-        saplingNullifier = sd.nullifier();
-        mutableTx.vShieldedSpend.push_back(sd);
+        mutableTx.saplingBundle = sapling::test_only_invalid_bundle(1, 0, 0);
+        saplingNullifier = uint256::FromRawBytes(mutableTx.saplingBundle.GetDetails().spends()[0].nullifier());
 
         // The Orchard bundle builder always pads to two Actions, so we can just
         // use an empty builder to create a dummy Orchard bundle.
diff --git a/src/test/sighash_tests.cpp b/src/test/sighash_tests.cpp
index 0b8a7b55cae..57f131af61e 100644
--- a/src/test/sighash_tests.cpp
+++ b/src/test/sighash_tests.cpp
@@ -22,7 +22,6 @@
 #include <boost/test/unit_test.hpp>
 
 #include <rust/ed25519.h>
-#include <rust/test_harness.h>
 
 #include <univalue.h>
 
@@ -124,8 +123,6 @@ void static RandomTransaction(CMutableTransaction &tx, bool fSingle, uint32_t co
     }
     tx.vin.clear();
     tx.vout.clear();
-    tx.vShieldedSpend.clear();
-    tx.vShieldedOutput.clear();
     tx.vJoinSplit.clear();
     tx.nLockTime = (InsecureRandBool()) ? InsecureRand32() : 0;
     int ins = (InsecureRandBits(2)) + 1;
@@ -148,13 +145,10 @@ void static RandomTransaction(CMutableTransaction &tx, bool fSingle, uint32_t co
         RandomScript(txout.scriptPubKey);
     }
     if (tx.nVersionGroupId == SAPLING_VERSION_GROUP_ID) {
-        tx.valueBalanceSapling = InsecureRandRange(100000000);
-        for (int spend = 0; spend < shielded_spends; spend++) {
-            tx.vShieldedSpend.push_back(RandomInvalidSpendDescription());
-        }
-        for (int out = 0; out < shielded_outs; out++) {
-            tx.vShieldedOutput.push_back(RandomInvalidOutputDescription());
-        }
+        tx.saplingBundle = sapling::test_only_invalid_bundle(
+            shielded_spends,
+            shielded_outs,
+            InsecureRandRange(100000000));
     }
     // We have removed pre-Sapling Sprout support.
     if (tx.fOverwintered && tx.nVersion >= SAPLING_TX_VERSION) {
diff --git a/src/test/transaction_tests.cpp b/src/test/transaction_tests.cpp
index eca1948d788..31ddbab99d3 100644
--- a/src/test/transaction_tests.cpp
+++ b/src/test/transaction_tests.cpp
@@ -309,7 +309,7 @@ void test_simple_sapling_invalidity(uint32_t consensusBranchId, CMutableTransact
         CMutableTransaction newTx(tx);
         CValidationState state;
 
-        newTx.vShieldedSpend.push_back(RandomInvalidSpendDescription());
+        newTx.saplingBundle = sapling::test_only_invalid_bundle(1, 0, 0);
 
         BOOST_CHECK(!CheckTransactionWithoutProofVerification(newTx, state));
         BOOST_CHECK(state.GetRejectReason() == "bad-txns-no-sink-of-funds");
@@ -319,29 +319,17 @@ void test_simple_sapling_invalidity(uint32_t consensusBranchId, CMutableTransact
         CMutableTransaction newTx(tx);
         CValidationState state;
 
-        newTx.vShieldedSpend.push_back(RandomInvalidSpendDescription());
-
-        newTx.vShieldedOutput.push_back(RandomInvalidOutputDescription());
-
-        newTx.vShieldedSpend.push_back(RandomInvalidSpendDescription());
-        newTx.vShieldedSpend[1] = SpendDescription(
-            newTx.vShieldedSpend[1].cv(),
-            newTx.vShieldedSpend[1].anchor(),
-            newTx.vShieldedSpend[0].nullifier(),
-            newTx.vShieldedSpend[1].rk(),
-            newTx.vShieldedSpend[1].zkproof(),
-            newTx.vShieldedSpend[1].spend_auth_sig());
+        newTx.saplingBundle = sapling::test_only_invalid_bundle(2, 1, 0);
+        sapling::test_only_replace_nullifier(
+            newTx.saplingBundle.GetDetailsMut(),
+            1, newTx.saplingBundle.GetDetails().spends()[0].nullifier());
 
         BOOST_CHECK(!CheckTransactionWithoutProofVerification(newTx, state));
         BOOST_CHECK(state.GetRejectReason() == "bad-spend-description-nullifiers-duplicate");
 
-        newTx.vShieldedSpend[1] = SpendDescription(
-            newTx.vShieldedSpend[1].cv(),
-            newTx.vShieldedSpend[1].anchor(),
-            InsecureRand256(),
-            newTx.vShieldedSpend[1].rk(),
-            newTx.vShieldedSpend[1].zkproof(),
-            newTx.vShieldedSpend[1].spend_auth_sig());
+        sapling::test_only_replace_nullifier(
+            newTx.saplingBundle.GetDetailsMut(),
+            1, InsecureRand256().GetRawBytes());
 
         BOOST_CHECK(CheckTransactionWithoutProofVerification(newTx, state));
     }
@@ -357,7 +345,7 @@ void test_simple_sapling_invalidity(uint32_t consensusBranchId, CMutableTransact
         vout.nValue = 1;
         newTx.vout.push_back(vout);
 
-        newTx.vShieldedSpend.push_back(RandomInvalidSpendDescription());
+        newTx.saplingBundle = sapling::test_only_invalid_bundle(1, 0, 0);
 
         BOOST_CHECK(!CheckTransactionWithoutProofVerification(newTx, state));
         BOOST_CHECK(state.GetRejectReason() == "bad-cb-has-spend-description");
diff --git a/src/util/test.cpp b/src/util/test.cpp
index 073e874f4dd..9bb4ee387df 100644
--- a/src/util/test.cpp
+++ b/src/util/test.cpp
@@ -11,7 +11,6 @@
 #include <optional>
 
 #include <rust/ed25519.h>
-#include <rust/test_harness.h>
 
 // Sprout
 CMutableTransaction GetValidSproutReceiveTransaction(
@@ -65,7 +64,7 @@ CMutableTransaction GetValidSproutReceiveTransaction(
     // depend on this happening.
     if (version >= 4) {
         // Shielded Output
-        mtx.vShieldedOutput.push_back(RandomInvalidOutputDescription());
+        mtx.saplingBundle = sapling::test_only_invalid_bundle(0, 1, 0);
     }
 
     // Empty output script.
@@ -334,35 +333,6 @@ CKey AddTestCKeyToKeyStore(CBasicKeyStore& keyStore) {
     return tsk;
 }
 
-SpendDescription RandomInvalidSpendDescription() {
-    uint256 cv;
-    uint256 anchor;
-    uint256 rk;
-    libzcash::GrothProof zkproof;
-    SpendDescription::spend_auth_sig_t spendAuthSig;
-    zcash_test_harness_random_jubjub_point(cv.begin());
-    zcash_test_harness_random_jubjub_base(anchor.begin());
-    zcash_test_harness_random_jubjub_point(rk.begin());
-    GetRandBytes(zkproof.begin(), zkproof.size());
-    return SpendDescription(cv, anchor, GetRandHash(), rk, zkproof, spendAuthSig);
-}
-
-OutputDescription RandomInvalidOutputDescription() {
-    uint256 cv;
-    uint256 cmu;
-    uint256 ephemeralKey;
-    libzcash::SaplingEncCiphertext encCiphertext;
-    libzcash::SaplingOutCiphertext outCiphertext;
-    libzcash::GrothProof zkproof;
-    zcash_test_harness_random_jubjub_point(cv.begin());
-    zcash_test_harness_random_jubjub_base(cmu.begin());
-    zcash_test_harness_random_jubjub_point(ephemeralKey.begin());
-    GetRandBytes(encCiphertext.begin(), encCiphertext.size());
-    GetRandBytes(outCiphertext.begin(), outCiphertext.size());
-    GetRandBytes(zkproof.begin(), zkproof.size());
-    return OutputDescription(cv, cmu, ephemeralKey, encCiphertext, outCiphertext, zkproof);
-}
-
 TestSaplingNote GetTestSaplingNote(const libzcash::SaplingPaymentAddress& pa, CAmount value) {
     // Generate dummy Sapling note
     libzcash::SaplingNote note(pa, value, libzcash::Zip212Enabled::BeforeZip212);
@@ -372,7 +342,7 @@ TestSaplingNote GetTestSaplingNote(const libzcash::SaplingPaymentAddress& pa, CA
     return { note, tree };
 }
 
-CWalletTx GetValidSaplingReceive(const Consensus::Params& consensusParams,
+CWalletTx GetValidSaplingReceive(const CChainParams& params,
                                  CBasicKeyStore& keyStore,
                                  const libzcash::SaplingExtendedSpendingKey &sk,
                                  CAmount value) {
@@ -383,7 +353,7 @@ CWalletTx GetValidSaplingReceive(const Consensus::Params& consensusParams,
     auto fvk = sk.expsk.full_viewing_key();
     auto pa = sk.ToXFVK().DefaultAddress();
 
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keyStore);
+    auto builder = TransactionBuilder(params, 1, std::nullopt, &keyStore);
     builder.SetFee(0);
     builder.AddTransparentInput(COutPoint(), scriptPubKey, value);
     builder.AddSaplingOutput(fvk.ovk, pa, value, {});
diff --git a/src/util/test.h b/src/util/test.h
index 94b16b64265..fb3ef3a4c33 100644
--- a/src/util/test.h
+++ b/src/util/test.h
@@ -5,6 +5,7 @@
 #ifndef ZCASH_UTIL_TEST_H
 #define ZCASH_UTIL_TEST_H
 
+#include "chainparams.h"
 #include "coins.h"
 #include "key_io.h"
 #include "wallet/wallet.h"
@@ -122,15 +123,12 @@ libzcash::SaplingExtendedSpendingKey GetTestMasterSaplingSpendingKey();
 
 CKey AddTestCKeyToKeyStore(CBasicKeyStore& keyStore);
 
-SpendDescription RandomInvalidSpendDescription();
-OutputDescription RandomInvalidOutputDescription();
-
 /**
  * Generate a dummy SaplingNote and a SaplingMerkleTree with that note's commitment.
  */
 TestSaplingNote GetTestSaplingNote(const libzcash::SaplingPaymentAddress& pa, CAmount value);
 
-CWalletTx GetValidSaplingReceive(const Consensus::Params& consensusParams,
+CWalletTx GetValidSaplingReceive(const CChainParams& consensusParams,
                                  CBasicKeyStore& keyStore,
                                  const libzcash::SaplingExtendedSpendingKey &sk,
                                  CAmount value);
diff --git a/src/wallet/gtest/test_orchard_wallet.cpp b/src/wallet/gtest/test_orchard_wallet.cpp
index 850536dd23b..64ec43da265 100644
--- a/src/wallet/gtest/test_orchard_wallet.cpp
+++ b/src/wallet/gtest/test_orchard_wallet.cpp
@@ -32,7 +32,7 @@ CTransaction FakeOrchardTx(const OrchardSpendingKey& sk, libzcash::diversifier_i
 
     // Create a shielding transaction from transparent to Orchard
     // 0.0005 t-ZEC in, 0.0004 z-ZEC out, 0.0001 fee
-    auto builder = TransactionBuilder(Params().GetConsensus(), 1, orchardAnchor, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, orchardAnchor, &keystore);
     builder.SetFee(10000);
     builder.AddTransparentInput(COutPoint(uint256S("1234"), 0), scriptPubKey, 50000);
     builder.AddOrchardOutput(std::nullopt, recipient, 40000, std::nullopt);
@@ -129,7 +129,7 @@ TEST(TransactionBuilder, OrchardToOrchard) {
 
     // Create an Orchard-only transaction
     // 0.0004 z-ZEC in, 0.00025 z-ZEC out, default fee, 0.00014 z-ZEC change
-    auto builder = TransactionBuilder(consensusParams, 2, orchardAnchor);
+    auto builder = TransactionBuilder(Params(), 2, orchardAnchor);
     EXPECT_TRUE(builder.AddOrchardSpend(sk, std::move(spendInfo[0].second)));
     builder.AddOrchardOutput(std::nullopt, recipient, 25000, std::nullopt);
     auto maybeTx = builder.Build();
diff --git a/src/wallet/gtest/test_rpc_wallet.cpp b/src/wallet/gtest/test_rpc_wallet.cpp
index 29409d03b98..710238d2ab0 100644
--- a/src/wallet/gtest/test_rpc_wallet.cpp
+++ b/src/wallet/gtest/test_rpc_wallet.cpp
@@ -37,7 +37,7 @@ CWalletTx FakeWalletTx() {
 void ExpectConsistentFee(const TransactionStrategy& strategy, const TransactionEffects& effects)
 {
     auto buildResult = effects.ApproveAndBuild(
-            Params().GetConsensus(),
+            Params(),
             *pwalletMain,
             chainActive,
             strategy);
@@ -326,40 +326,46 @@ TEST(WalletRPCTests, RPCZsendmanyTaddrToSapling)
     CDataStream ss(ParseHex(hexTx), SER_NETWORK, PROTOCOL_VERSION);
     CTransaction tx;
     ss >> tx;
-    ASSERT_FALSE(tx.GetSaplingOutputsCount() == 0);
+    ASSERT_NE(tx.GetSaplingOutputsCount(), 0);
+
+    auto outputs = tx.GetSaplingOutputs();
+    auto out_ciphertext = outputs[0].out_ciphertext();
+    auto cv = uint256::FromRawBytes(outputs[0].cv());
+    auto cmu = uint256::FromRawBytes(outputs[0].cmu());
+    auto ephemeral_key = uint256::FromRawBytes(outputs[0].ephemeral_key());
 
     // We shouldn't be able to decrypt with the empty ovk
     EXPECT_FALSE(AttemptSaplingOutDecryption(
-        tx.GetSaplingOutputs()[0].out_ciphertext(),
+        out_ciphertext,
         uint256(),
-        tx.GetSaplingOutputs()[0].cv(),
-        tx.GetSaplingOutputs()[0].cmu(),
-        tx.GetSaplingOutputs()[0].ephemeral_key()));
+        cv,
+        cmu,
+        ephemeral_key));
 
     // We shouldn't be able to decrypt with a random ovk
     EXPECT_FALSE(AttemptSaplingOutDecryption(
-        tx.GetSaplingOutputs()[0].out_ciphertext(),
+        out_ciphertext,
         random_uint256(),
-        tx.GetSaplingOutputs()[0].cv(),
-        tx.GetSaplingOutputs()[0].cmu(),
-        tx.GetSaplingOutputs()[0].ephemeral_key()));
+        cv,
+        cmu,
+        ephemeral_key));
 
     auto accountKey = pwalletMain->GetLegacyAccountKey().ToAccountPubKey();
     auto ovks = accountKey.GetOVKsForShielding();
     // We should not be able to decrypt with the internal change OVK for shielding
     EXPECT_FALSE(AttemptSaplingOutDecryption(
-        tx.GetSaplingOutputs()[0].out_ciphertext(),
+        out_ciphertext,
         ovks.first,
-        tx.GetSaplingOutputs()[0].cv(),
-        tx.GetSaplingOutputs()[0].cmu(),
-        tx.GetSaplingOutputs()[0].ephemeral_key()));
+        cv,
+        cmu,
+        ephemeral_key));
     // We should be able to decrypt with the external OVK for shielding
     EXPECT_TRUE(AttemptSaplingOutDecryption(
-        tx.GetSaplingOutputs()[0].out_ciphertext(),
+        out_ciphertext,
         ovks.second,
-        tx.GetSaplingOutputs()[0].cv(),
-        tx.GetSaplingOutputs()[0].cmu(),
-        tx.GetSaplingOutputs()[0].ephemeral_key()));
+        cv,
+        cmu,
+        ephemeral_key));
 
     // Tear down
     chainActive.SetTip(NULL);
diff --git a/src/wallet/gtest/test_wallet.cpp b/src/wallet/gtest/test_wallet.cpp
index 336aaf44bdb..5f206e521e4 100644
--- a/src/wallet/gtest/test_wallet.cpp
+++ b/src/wallet/gtest/test_wallet.cpp
@@ -17,8 +17,6 @@
 #include "zcash/Note.hpp"
 #include "zcash/NoteEncryption.hpp"
 
-#include <rust/test_harness.h>
-
 #include <optional>
 
 using ::testing::Return;
@@ -422,15 +420,14 @@ TEST(WalletTests, SetSaplingNoteAddrsInCWalletTx) {
         auto cm = note.cmu().value();
         SaplingMerkleTree tree;
         tree.append(cm);
-        auto anchor = tree.root();
         auto witness = tree.witness();
 
         auto nf = note.nullifier(fvk, witness.position());
         ASSERT_TRUE(nf);
         uint256 nullifier = nf.value();
 
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(expsk, note, anchor, witness);
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, note, witness);
         builder.AddSaplingOutput(fvk.ovk, pk, 50000, {});
         builder.SetFee(0);
         auto tx = builder.Build().GetTxOrThrow();
@@ -566,8 +563,8 @@ TEST(WalletTests, FindMySaplingNotes) {
     auto testNote = GetTestSaplingNote(pa, 50000);
 
     // Generate transaction
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-    builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
     builder.AddSaplingOutput(extfvk.fvk.ovk, pa, 25000, {});
     auto tx = builder.Build().GetTxOrThrow();
 
@@ -710,12 +707,11 @@ TEST(WalletTests, GetConflictedSaplingNotes) {
 
         MerkleFrontiers frontiers;
         frontiers.sapling.append(cm);
-        auto anchor = frontiers.sapling.root();
         auto witness = frontiers.sapling.witness();
 
         // Generate tx to create output note B
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(expsk, note, anchor, witness);
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, note, witness);
         builder.AddSaplingOutput(extfvk.fvk.ovk, pk, 35000, {});
         auto tx = builder.Build().GetTxOrThrow();
         CWalletTx wtx {&wallet, tx};
@@ -760,18 +756,16 @@ TEST(WalletTests, GetConflictedSaplingNotes) {
         ASSERT_EQ(static_cast<bool>(maybe_nf), true);
         auto nullifier2 = maybe_nf.value();
 
-        anchor = frontiers.sapling.root();
-
         // Create transaction to spend note B
-        auto builder2 = TransactionBuilder(consensusParams, 2, std::nullopt);
-        builder2.AddSaplingSpend(expsk, note2, anchor, spend_note_witness);
-        builder2.AddSaplingOutput(extfvk.fvk.ovk, pk, 20000, {});
+        auto builder2 = TransactionBuilder(Params(), 2, std::nullopt);
+        builder2.AddSaplingSpend(sk, note2, spend_note_witness);
+        builder2.AddSaplingOutput(extfvk.fvk.ovk, pk, 2000, {});
         auto tx2 = builder2.Build().GetTxOrThrow();
 
         // Create conflicting transaction which also spends note B
-        auto builder3 = TransactionBuilder(consensusParams, 2, std::nullopt);
-        builder3.AddSaplingSpend(expsk, note2, anchor, spend_note_witness);
-        builder3.AddSaplingOutput(extfvk.fvk.ovk, pk, 19999, {});
+        auto builder3 = TransactionBuilder(Params(), 2, std::nullopt);
+        builder3.AddSaplingSpend(sk, note2, spend_note_witness);
+        builder3.AddSaplingOutput(extfvk.fvk.ovk, pk, 1999, {});
         auto tx3 = builder3.Build().GetTxOrThrow();
 
         CWalletTx wtx2 {&wallet, tx2};
@@ -827,7 +821,7 @@ TEST(WalletTests, GetConflictedOrchardNotes) {
     auto scriptPubKey = GetScriptForDestination(tkeyid);
 
     // Generate a bundle containing output note A.
-    auto builder = TransactionBuilder(consensusParams, 1, orchardAnchor, &keystore);
+    auto builder = TransactionBuilder(Params(), 1, orchardAnchor, &keystore);
     builder.AddTransparentInput(COutPoint(uint256(), 0), scriptPubKey, 5000);
     builder.AddOrchardOutput(std::nullopt, recipient, 4000, {});
     auto maybeTx = builder.Build();
@@ -881,7 +875,7 @@ TEST(WalletTests, GetConflictedOrchardNotes) {
     auto recipient2 = ivk.Address(j2);
 
     // Generate tx to spend note A
-    auto builder2 = TransactionBuilder(consensusParams, 2, orchardTree.root());
+    auto builder2 = TransactionBuilder(Params(), 2, orchardTree.root());
     auto noteToSpend = std::move(wallet.GetOrchardSpendInfo(orchardEntries, orchardTree.root())[0]);
     builder2.AddOrchardSpend(std::move(noteToSpend.first), std::move(noteToSpend.second));
     auto maybeTx2 = builder2.Build();
@@ -895,7 +889,7 @@ TEST(WalletTests, GetConflictedOrchardNotes) {
 
     // Generate conflicting tx to spend note A
     auto noteToSpend2 = std::move(wallet.GetOrchardSpendInfo(orchardEntries, orchardTree.root())[0]);
-    auto builder3 = TransactionBuilder(consensusParams, 2, orchardTree.root());
+    auto builder3 = TransactionBuilder(Params(), 2, orchardTree.root());
     builder3.AddOrchardSpend(std::move(noteToSpend2.first), std::move(noteToSpend2.second));
     auto maybeTx3 = builder3.Build();
     EXPECT_TRUE(maybeTx3.IsTx());
@@ -997,8 +991,8 @@ TEST(WalletTests, SaplingNullifierIsSpent) {
     auto testNote = GetTestSaplingNote(pa, 50000);
 
     // Generate transaction
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-    builder.AddSaplingSpend(expsk,  testNote.note, testNote.tree.root(), testNote.tree.witness());
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+    builder.AddSaplingSpend(sk,  testNote.note, testNote.tree.witness());
     builder.AddSaplingOutput(extfvk.fvk.ovk, pa, 25000, {});
     auto tx = builder.Build().GetTxOrThrow();
 
@@ -1084,8 +1078,8 @@ TEST(WalletTests, NavigateFromSaplingNullifierToNote) {
     auto testNote = GetTestSaplingNote(pa, 50000);
 
     // Generate transaction
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-    builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
     builder.AddSaplingOutput(extfvk.fvk.ovk, pa, 25000, {});
     auto tx = builder.Build().GetTxOrThrow();
 
@@ -1149,7 +1143,7 @@ TEST(WalletTests, NavigateFromSaplingNullifierToNote) {
         EXPECT_EQ(hash, op.hash);
         EXPECT_EQ(1, nd.witnesses.size());
         ASSERT_TRUE(nd.nullifier);
-        auto nf = nd.nullifier.value();
+        auto nf = nd.nullifier->GetRawBytes();
         EXPECT_EQ(1, wallet.mapSaplingNullifiersToNotes.count(nf));
         EXPECT_EQ(op.hash, wallet.mapSaplingNullifiersToNotes[nf].hash);
         EXPECT_EQ(op.n, wallet.mapSaplingNullifiersToNotes[nf].n);
@@ -1215,17 +1209,16 @@ TEST(WalletTests, SpentSaplingNoteIsFromMe) {
         auto pk = extfvk.DefaultAddress();
 
         // Generate Sapling note A
-        libzcash::SaplingNote note(pk, 5000, zip_212_enabled[ver]);
+        libzcash::SaplingNote note(pk, 50000, zip_212_enabled[ver]);
         auto cm = note.cmu().value();
         MerkleFrontiers frontiers;
         frontiers.sapling.append(cm);
-        auto anchor = frontiers.sapling.root();
         auto witness = frontiers.sapling.witness();
 
         // Generate transaction, which sends funds to note B
-        auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-        builder.AddSaplingSpend(expsk, note, anchor, witness);
-        builder.AddSaplingOutput(extfvk.fvk.ovk, pk, 2500, {});
+        auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+        builder.AddSaplingSpend(sk, note, witness);
+        builder.AddSaplingOutput(extfvk.fvk.ovk, pk, 25000, {});
         auto tx = builder.Build().GetTxOrThrow();
 
         CWalletTx wtx {&wallet, tx};
@@ -1267,7 +1260,7 @@ TEST(WalletTests, SpentSaplingNoteIsFromMe) {
         // Manually compute the nullifier and check map entry does not exist
         auto nf = note.nullifier(extfvk.fvk, witness.position());
         ASSERT_TRUE(nf);
-        ASSERT_FALSE(wallet.mapSaplingNullifiersToNotes.count(nf.value()));
+        ASSERT_FALSE(wallet.mapSaplingNullifiersToNotes.count(nf->GetRawBytes()));
 
         // Decrypt note B
         auto maybe_pt = wtx.DecryptSaplingNote(Params(), SaplingOutPoint(wtx.GetHash(), 0));
@@ -1283,14 +1276,10 @@ TEST(WalletTests, SpentSaplingNoteIsFromMe) {
         ASSERT_EQ(static_cast<bool>(maybe_nf), true);
         auto nullifier2 = maybe_nf.value();
 
-        // NOTE: Not updating the anchor results in a core dump.  Shouldn't builder just return error?
-        // *** Error in `./zcash-gtest': double free or corruption (out): 0x00007ffd8755d990 ***
-        anchor = frontiers.sapling.root();
-
         // Create transaction to spend note B
-        auto builder2 = TransactionBuilder(consensusParams, 2, std::nullopt);
-        builder2.AddSaplingSpend(expsk, note2, anchor, spend_note_witness);
-        builder2.AddSaplingOutput(extfvk.fvk.ovk, pk, 1250, {});
+        auto builder2 = TransactionBuilder(Params(), 2, std::nullopt);
+        builder2.AddSaplingSpend(sk, note2, spend_note_witness);
+        builder2.AddSaplingOutput(extfvk.fvk.ovk, pk, 12500, {});
         auto tx2 = builder2.Build().GetTxOrThrow();
         EXPECT_EQ(tx2.vin.size(), 0);
         EXPECT_EQ(tx2.vout.size(), 0);
@@ -1326,7 +1315,7 @@ TEST(WalletTests, SpentSaplingNoteIsFromMe) {
 
         // Verify note B belongs to wallet.
         EXPECT_TRUE(wallet.IsFromMe(wtx2));
-        ASSERT_TRUE(wallet.mapSaplingNullifiersToNotes.count(nullifier2));
+        ASSERT_TRUE(wallet.mapSaplingNullifiersToNotes.count(nullifier2.GetRawBytes()));
 
         // Tear down
         chainActive.SetTip(NULL);
@@ -1922,7 +1911,7 @@ TEST(WalletTests, SetBestChainIgnoresTxsWithoutShieldedData) {
     mtxSapling.fOverwintered = true;
     mtxSapling.nVersion = SAPLING_TX_VERSION;
     mtxSapling.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
-    mtxSapling.vShieldedOutput.push_back(RandomInvalidOutputDescription());
+    mtxSapling.saplingBundle = sapling::test_only_invalid_bundle(0, 1, 0);
     CWalletTx wtxSapling {nullptr, mtxSapling};
     SetSaplingNoteData(wtxSapling);
     wallet.LoadWalletTx(wtxSapling);
@@ -1932,7 +1921,7 @@ TEST(WalletTests, SetBestChainIgnoresTxsWithoutShieldedData) {
     mtxSaplingTransparent.fOverwintered = true;
     mtxSaplingTransparent.nVersion = SAPLING_TX_VERSION;
     mtxSaplingTransparent.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
-    mtxSaplingTransparent.vShieldedOutput.push_back(RandomInvalidOutputDescription());
+    mtxSaplingTransparent.saplingBundle = sapling::test_only_invalid_bundle(0, 1, 0);
     CWalletTx wtxSaplingTransparent {nullptr, mtxSaplingTransparent};
     wallet.LoadWalletTx(wtxSaplingTransparent);
 
@@ -2070,8 +2059,8 @@ TEST(WalletTests, UpdatedSaplingNoteData) {
     auto testNote = GetTestSaplingNote(pa, 50000);
 
     // Generate transaction
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt);
-    builder.AddSaplingSpend(expsk, testNote.note, testNote.tree.root(), testNote.tree.witness());
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt);
+    builder.AddSaplingSpend(sk, testNote.note, testNote.tree.witness());
     builder.AddSaplingOutput(extfvk.fvk.ovk, pa2, 25000, {});
     auto tx = builder.Build().GetTxOrThrow();
 
@@ -2216,8 +2205,10 @@ TEST(WalletTests, MarkAffectedSaplingTransactionsDirty) {
 
     // Generate shielding tx from transparent to Sapling
     // 0.0005 t-ZEC in, 0.0004 z-ZEC out, default fee
-    auto builder = TransactionBuilder(consensusParams, 1, std::nullopt, &keystore);
-    builder.AddTransparentInput(COutPoint(), scriptPubKey, 5000);
+    auto builder = TransactionBuilder(Params(), 1, std::nullopt, &keystore);
+    builder.AddTransparentInput(
+        COutPoint(uint256S("7777777777777777777777777777777777777777777777777777777777777777"), 0),
+        scriptPubKey, 5000);
     builder.AddSaplingOutput(extfvk.fvk.ovk, pk, 4000, {});
     auto tx1 = builder.Build().GetTxOrThrow();
 
@@ -2270,8 +2261,8 @@ TEST(WalletTests, MarkAffectedSaplingTransactionsDirty) {
 
     // Create a Sapling-only transaction
     // 0.0004 z-ZEC in, 0.00025 z-ZEC out, default fee, 0.00005 z-ZEC change
-    auto builder2 = TransactionBuilder(consensusParams, 2, std::nullopt);
-    builder2.AddSaplingSpend(expsk, note, anchor, witness);
+    auto builder2 = TransactionBuilder(Params(), 2, std::nullopt);
+    builder2.AddSaplingSpend(sk, note, witness);
     builder2.AddSaplingOutput(extfvk.fvk.ovk, pk, 2500, {});
     auto tx2 = builder2.Build().GetTxOrThrow();
 
diff --git a/src/wallet/test/rpc_wallet_tests.cpp b/src/wallet/test/rpc_wallet_tests.cpp
index c2880485c15..37339247667 100644
--- a/src/wallet/test/rpc_wallet_tests.cpp
+++ b/src/wallet/test/rpc_wallet_tests.cpp
@@ -1204,7 +1204,7 @@ BOOST_AUTO_TEST_CASE(rpc_z_sendmany_parameters)
     // Mutable tx containing contextual information we need to build tx
     UniValue retValue = CallRPC("getblockcount");
     int nHeight = retValue.get_int();
-    TransactionBuilder builder(Params().GetConsensus(), nHeight + 1, std::nullopt, pwalletMain);
+    TransactionBuilder builder(Params(), nHeight + 1, std::nullopt, pwalletMain);
 }
 
 BOOST_AUTO_TEST_CASE(asyncrpcoperation_sign_send_raw_transaction) {