diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs
index 4179c3cd9c..f07b871946 100644
--- a/zcash_client_backend/src/data_api/wallet.rs
+++ b/zcash_client_backend/src/data_api/wallet.rs
@@ -1,6 +1,7 @@
 use std::num::NonZeroU32;
 
 use shardtree::{error::ShardTreeError, store::ShardStore, ShardTree};
+
 use zcash_primitives::{
     consensus::{self, BlockHeight, NetworkUpgrade},
     memo::MemoBytes,
@@ -212,29 +213,31 @@ where
     DbT: WalletWrite + WalletCommitmentTrees,
     DbT::NoteRef: Copy + Eq + Ord,
 {
-    let req = zip321::TransactionRequest::new(vec![Payment {
-        recipient_address: to.clone(),
+    let account = wallet_db
+        .get_account_for_ufvk(&usk.to_unified_full_viewing_key())
+        .map_err(Error::DataSource)?
+        .ok_or(Error::KeyNotRecognized)?;
+
+    #[allow(deprecated)]
+    let proposal = propose_standard_transfer_to_address(
+        wallet_db,
+        params,
+        StandardFeeRule::PreZip313,
+        account,
+        min_confirmations,
+        to,
         amount,
         memo,
-        label: None,
-        message: None,
-        other_params: vec![],
-    }])
-    .expect(
-        "It should not be possible for this to violate ZIP 321 request construction invariants.",
-    );
+        change_memo,
+    )?;
 
-    #[allow(deprecated)]
-    let fee_rule = StandardFeeRule::PreZip313;
-    let change_strategy = fees::standard::SingleOutputChangeStrategy::new(fee_rule, change_memo);
-    spend(
+    create_proposed_transaction(
         wallet_db,
         params,
         prover,
-        &GreedyInputSelector::<DbT, _>::new(change_strategy, DustOutputPolicy::default()),
         usk,
-        req,
         ovk_policy,
+        proposal,
         min_confirmations,
     )
 }
@@ -373,6 +376,77 @@ where
         .map_err(Error::from)
 }
 
+/// Proposes a transaction paying the specified address from the given account.
+///
+/// Returns the proposal, which may then be executed using [`create_proposed_transaction`]
+///
+/// Parameters:
+/// * `wallet_db`: A read/write reference to the wallet database
+/// * `params`: Consensus parameters
+/// * `fee_rule`: The fee rule to use in creating the transaction.
+/// * `spend_from_account`: The unified account that controls the funds that will be spent
+///   in the resulting transaction. This procedure will return an error if the
+///   account ID does not correspond to an account known to the wallet.
+/// * `min_confirmations`: The minimum number of confirmations that a previously
+///   received note must have in the blockchain in order to be considered for being
+///   spent. A value of 10 confirmations is recommended and 0-conf transactions are
+///   not supported.
+/// * `to`: The address to which `amount` will be paid.
+/// * `amount`: The amount to send.
+/// * `memo`: A memo to be included in the output to the recipient.
+/// * `change_memo`: A memo to be included in any change output that is created.
+#[allow(clippy::too_many_arguments)]
+#[allow(clippy::type_complexity)]
+pub fn propose_standard_transfer_to_address<DbT, ParamsT, CommitmentTreeErrT>(
+    wallet_db: &mut DbT,
+    params: &ParamsT,
+    fee_rule: StandardFeeRule,
+    spend_from_account: AccountId,
+    min_confirmations: NonZeroU32,
+    to: &RecipientAddress,
+    amount: NonNegativeAmount,
+    memo: Option<MemoBytes>,
+    change_memo: Option<MemoBytes>,
+) -> Result<
+    Proposal<StandardFeeRule, DbT::NoteRef>,
+    Error<
+        DbT::Error,
+        CommitmentTreeErrT,
+        GreedyInputSelectorError<Zip317FeeError, DbT::NoteRef>,
+        Zip317FeeError,
+    >,
+>
+where
+    ParamsT: consensus::Parameters + Clone,
+    DbT: WalletWrite,
+    DbT::NoteRef: Copy + Eq + Ord,
+{
+    let request = zip321::TransactionRequest::new(vec![Payment {
+        recipient_address: to.clone(),
+        amount,
+        memo,
+        label: None,
+        message: None,
+        other_params: vec![],
+    }])
+    .expect(
+        "It should not be possible for this to violate ZIP 321 request construction invariants.",
+    );
+
+    let change_strategy = fees::standard::SingleOutputChangeStrategy::new(fee_rule, change_memo);
+    let input_selector =
+        GreedyInputSelector::<DbT, _>::new(change_strategy, DustOutputPolicy::default());
+
+    propose_transfer(
+        wallet_db,
+        params,
+        spend_from_account,
+        &input_selector,
+        request,
+        min_confirmations,
+    )
+}
+
 #[cfg(feature = "transparent-inputs")]
 #[allow(clippy::too_many_arguments)]
 #[allow(clippy::type_complexity)]
diff --git a/zcash_client_sqlite/src/testing.rs b/zcash_client_sqlite/src/testing.rs
index 06dc2a4564..acce89adf5 100644
--- a/zcash_client_sqlite/src/testing.rs
+++ b/zcash_client_sqlite/src/testing.rs
@@ -14,6 +14,7 @@ use tempfile::NamedTempFile;
 #[cfg(feature = "unstable")]
 use tempfile::TempDir;
 
+use zcash_client_backend::fees::{standard, DustOutputPolicy};
 #[allow(deprecated)]
 use zcash_client_backend::{
     address::RecipientAddress,
@@ -22,8 +23,10 @@ use zcash_client_backend::{
         chain::{scan_cached_blocks, BlockSource, ScanSummary},
         wallet::{
             create_proposed_transaction, create_spend_to_address,
-            input_selection::{GreedyInputSelectorError, InputSelector, Proposal},
-            propose_transfer, spend,
+            input_selection::{
+                GreedyInputSelector, GreedyInputSelectorError, InputSelector, Proposal,
+            },
+            propose_standard_transfer_to_address, propose_transfer, spend,
         },
         AccountBalance, AccountBirthday, WalletRead, WalletSummary, WalletWrite,
     },
@@ -38,7 +41,7 @@ use zcash_note_encryption::Domain;
 use zcash_primitives::{
     block::BlockHash,
     consensus::{self, BlockHeight, Network, NetworkUpgrade, Parameters},
-    memo::MemoBytes,
+    memo::{Memo, MemoBytes},
     sapling::{
         note_encryption::{sapling_note_encryption, SaplingDomain},
         util::generate_random_rseed,
@@ -47,7 +50,7 @@ use zcash_primitives::{
     },
     transaction::{
         components::amount::NonNegativeAmount,
-        fees::{zip317::FeeError as Zip317FeeError, FeeRule},
+        fees::{zip317::FeeError as Zip317FeeError, FeeRule, StandardFeeRule},
         Transaction, TxId,
     },
     zip32::{sapling::DiversifiableFullViewingKey, DiversifierIndex},
@@ -524,6 +527,43 @@ impl<Cache> TestState<Cache> {
         )
     }
 
+    /// Invokes [`propose_standard_transfer`] with the given arguments.
+    #[allow(clippy::type_complexity)]
+    #[allow(clippy::too_many_arguments)]
+    pub(crate) fn propose_standard_transfer<CommitmentTreeErrT>(
+        &mut self,
+        spend_from_account: AccountId,
+        fee_rule: StandardFeeRule,
+        min_confirmations: NonZeroU32,
+        to: &RecipientAddress,
+        amount: NonNegativeAmount,
+        memo: Option<MemoBytes>,
+        change_memo: Option<MemoBytes>,
+    ) -> Result<
+        Proposal<StandardFeeRule, ReceivedNoteId>,
+        data_api::error::Error<
+            SqliteClientError,
+            CommitmentTreeErrT,
+            GreedyInputSelectorError<Zip317FeeError, ReceivedNoteId>,
+            Zip317FeeError,
+        >,
+    > {
+        let params = self.network();
+        let result = propose_standard_transfer_to_address::<_, _, CommitmentTreeErrT>(
+            &mut self.db_data,
+            &params,
+            fee_rule,
+            spend_from_account,
+            min_confirmations,
+            to,
+            amount,
+            memo,
+            change_memo,
+        );
+
+        result
+    }
+
     /// Invokes [`propose_shielding`] with the given arguments.
     #[cfg(feature = "transparent-inputs")]
     #[allow(clippy::type_complexity)]
@@ -993,3 +1033,15 @@ impl TestCache for FsBlockCache {
         meta
     }
 }
+
+pub(crate) fn input_selector(
+    fee_rule: StandardFeeRule,
+    change_memo: Option<&str>,
+) -> GreedyInputSelector<
+    WalletDb<rusqlite::Connection, Network>,
+    standard::SingleOutputChangeStrategy,
+> {
+    let change_memo = change_memo.map(|m| MemoBytes::from(m.parse::<Memo>().unwrap()));
+    let change_strategy = standard::SingleOutputChangeStrategy::new(fee_rule, change_memo);
+    GreedyInputSelector::new(change_strategy, DustOutputPolicy::default())
+}
diff --git a/zcash_client_sqlite/src/wallet/sapling.rs b/zcash_client_sqlite/src/wallet/sapling.rs
index 8a9c809a07..c0cc7cf983 100644
--- a/zcash_client_sqlite/src/wallet/sapling.rs
+++ b/zcash_client_sqlite/src/wallet/sapling.rs
@@ -458,9 +458,7 @@ pub(crate) mod tests {
         transaction::{
             components::{amount::NonNegativeAmount, Amount},
             fees::{
-                fixed::FeeRule as FixedFeeRule,
-                zip317::{FeeError as Zip317FeeError, FeeRule as Zip317FeeRule},
-                StandardFeeRule,
+                fixed::FeeRule as FixedFeeRule, zip317::FeeError as Zip317FeeError, StandardFeeRule,
             },
             Transaction,
         },
@@ -478,7 +476,7 @@ pub(crate) mod tests {
             WalletWrite,
         },
         decrypt_transaction,
-        fees::{fixed, standard, zip317, DustOutputPolicy},
+        fees::{fixed, standard, DustOutputPolicy},
         keys::UnifiedSpendingKey,
         wallet::OvkPolicy,
         zip321::{self, Payment, TransactionRequest},
@@ -486,7 +484,7 @@ pub(crate) mod tests {
 
     use crate::{
         error::SqliteClientError,
-        testing::{AddressType, BlockCache, TestBuilder, TestState},
+        testing::{input_selector, AddressType, BlockCache, TestBuilder, TestState},
         wallet::{
             block_max_scanned, commitment_tree, sapling::select_spendable_sapling_notes,
             scanning::tests::test_with_canopy_birthday,
@@ -689,7 +687,7 @@ pub(crate) mod tests {
             .with_test_account(AccountBirthday::from_sapling_activation)
             .build();
 
-        let (_, usk, _) = st.test_account().unwrap();
+        let (account, _, _) = st.test_account().unwrap();
         let dfvk = st.test_account_sapling().unwrap();
         let to = dfvk.default_address().1.into();
 
@@ -698,13 +696,13 @@ pub(crate) mod tests {
 
         // We cannot do anything if we aren't synchronised
         assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+            st.propose_standard_transfer::<Infallible>(
+                account,
+                StandardFeeRule::PreZip313,
+                NonZeroU32::new(1).unwrap(),
                 &to,
                 NonNegativeAmount::const_from_u64(1),
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
                 None
             ),
             Err(data_api::error::Error::ScanRequired)
@@ -712,7 +710,7 @@ pub(crate) mod tests {
     }
 
     #[test]
-    fn create_to_address_fails_on_unverified_notes() {
+    fn spend_fails_on_unverified_notes() {
         let mut st = TestBuilder::new()
             .with_block_cache()
             .with_test_account(AccountBirthday::from_sapling_activation)
@@ -765,13 +763,13 @@ pub(crate) mod tests {
         let extsk2 = ExtendedSpendingKey::master(&[]);
         let to = extsk2.default_address().1.into();
         assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+            st.propose_standard_transfer::<Infallible>(
+                account,
+                StandardFeeRule::Zip317,
+                NonZeroU32::new(10).unwrap(),
                 &to,
                 NonNegativeAmount::const_from_u64(70000),
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(10).unwrap(),
                 None
             ),
             Err(data_api::error::Error::InsufficientFunds {
@@ -794,13 +792,13 @@ pub(crate) mod tests {
 
         // Spend still fails
         assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+            st.propose_standard_transfer::<Infallible>(
+                account,
+                StandardFeeRule::Zip317,
+                NonZeroU32::new(10).unwrap(),
                 &to,
                 NonNegativeAmount::const_from_u64(70000),
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(10).unwrap(),
                 None
             ),
             Err(data_api::error::Error::InsufficientFunds {
@@ -824,20 +822,31 @@ pub(crate) mod tests {
             (value * 9).unwrap()
         );
 
-        // Spend should now succeed
+        // Should now be able to generate a proposal
         let amount_sent = NonNegativeAmount::from_u64(70000).unwrap();
-        let txid = st
-            .create_spend_to_address(
-                &usk,
+        let min_confirmations = NonZeroU32::new(10).unwrap();
+        let proposal = st
+            .propose_standard_transfer::<Infallible>(
+                account,
+                StandardFeeRule::Zip317,
+                min_confirmations,
                 &to,
                 amount_sent,
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(10).unwrap(),
                 None,
             )
             .unwrap();
 
+        // Executing the proposal should succeed
+        let txid = st
+            .create_proposed_transaction::<Infallible, _>(
+                &usk,
+                OvkPolicy::Sender,
+                proposal,
+                min_confirmations,
+            )
+            .unwrap();
+
         let (h, _) = st.generate_next_block_including(txid);
         st.scan_cached_blocks(h, 1);
 
@@ -851,7 +860,7 @@ pub(crate) mod tests {
     }
 
     #[test]
-    fn create_to_address_fails_on_locked_notes() {
+    fn spend_fails_on_locked_notes() {
         let mut st = TestBuilder::new()
             .with_block_cache()
             .with_test_account(AccountBirthday::from_sapling_activation)
@@ -860,6 +869,11 @@ pub(crate) mod tests {
         let (account, usk, _) = st.test_account().unwrap();
         let dfvk = st.test_account_sapling().unwrap();
 
+        // TODO: This test was originally written to use the pre-zip-313 fee rule
+        // and has not yet been updated.
+        #[allow(deprecated)]
+        let fee_rule = StandardFeeRule::PreZip313;
+
         // Add funds to the wallet in a single note
         let value = NonNegativeAmount::const_from_u64(50000);
         let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
@@ -872,28 +886,39 @@ pub(crate) mod tests {
         // Send some of the funds to another address, but don't mine the tx.
         let extsk2 = ExtendedSpendingKey::master(&[]);
         let to = extsk2.default_address().1.into();
-        assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+        let min_confirmations = NonZeroU32::new(1).unwrap();
+        let proposal = st
+            .propose_standard_transfer::<Infallible>(
+                account,
+                fee_rule,
+                min_confirmations,
                 &to,
                 NonNegativeAmount::const_from_u64(15000),
                 None,
+                None,
+            )
+            .unwrap();
+
+        // Executing the proposal should succeed
+        assert_matches!(
+            st.create_proposed_transaction::<Infallible, _>(
+                &usk,
                 OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
-                None
+                proposal,
+                min_confirmations
             ),
             Ok(_)
         );
 
-        // A second spend fails because there are no usable notes
+        // A second proposal fails because there are no usable notes
         assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+            st.propose_standard_transfer::<Infallible>(
+                account,
+                fee_rule,
+                NonZeroU32::new(1).unwrap(),
                 &to,
                 NonNegativeAmount::const_from_u64(2000),
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
                 None
             ),
             Err(data_api::error::Error::InsufficientFunds {
@@ -914,15 +939,15 @@ pub(crate) mod tests {
         }
         st.scan_cached_blocks(h1 + 1, 41);
 
-        // Second spend still fails
+        // Second proposal still fails
         assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+            st.propose_standard_transfer::<Infallible>(
+                account,
+                fee_rule,
+                NonZeroU32::new(1).unwrap(),
                 &to,
                 NonNegativeAmount::const_from_u64(2000),
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
                 None
             ),
             Err(data_api::error::Error::InsufficientFunds {
@@ -945,19 +970,29 @@ pub(crate) mod tests {
         assert_eq!(st.get_spendable_balance(account, 1), value);
 
         // Second spend should now succeed
-        let amount_sent2 = NonNegativeAmount::from_u64(2000).unwrap();
-        let txid2 = st
-            .create_spend_to_address(
-                &usk,
+        let amount_sent2 = NonNegativeAmount::const_from_u64(2000);
+        let min_confirmations = NonZeroU32::new(1).unwrap();
+        let proposal = st
+            .propose_standard_transfer::<Infallible>(
+                account,
+                fee_rule,
+                min_confirmations,
                 &to,
                 amount_sent2,
                 None,
-                OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
                 None,
             )
             .unwrap();
 
+        let txid2 = st
+            .create_proposed_transaction::<Infallible, _>(
+                &usk,
+                OvkPolicy::Sender,
+                proposal,
+                min_confirmations,
+            )
+            .unwrap();
+
         let (h, _) = st.generate_next_block_including(txid2);
         st.scan_cached_blocks(h, 1);
 
@@ -992,6 +1027,11 @@ pub(crate) mod tests {
         let addr2 = extsk2.default_address().1;
         let to = addr2.into();
 
+        // TODO: This test was originally written to use the pre-zip-313 fee rule
+        // and has not yet been updated.
+        #[allow(deprecated)]
+        let fee_rule = StandardFeeRule::PreZip313;
+
         #[allow(clippy::type_complexity)]
         let send_and_recover_with_policy = |st: &mut TestState<BlockCache>,
                                             ovk_policy|
@@ -1004,16 +1044,21 @@ pub(crate) mod tests {
                 Zip317FeeError,
             >,
         > {
-            let txid = st.create_spend_to_address(
-                &usk,
+            let min_confirmations = NonZeroU32::new(1).unwrap();
+            let proposal = st.propose_standard_transfer(
+                account,
+                fee_rule,
+                min_confirmations,
                 &to,
                 NonNegativeAmount::const_from_u64(15000),
                 None,
-                ovk_policy,
-                NonZeroU32::new(1).unwrap(),
                 None,
             )?;
 
+            // Executing the proposal should succeed
+            let txid =
+                st.create_proposed_transaction(&usk, ovk_policy, proposal, min_confirmations)?;
+
             // Fetch the transaction from the database
             let raw_tx: Vec<_> = st
                 .wallet()
@@ -1071,7 +1116,7 @@ pub(crate) mod tests {
     }
 
     #[test]
-    fn create_to_address_succeeds_to_t_addr_zero_change() {
+    fn spend_succeeds_to_t_addr_zero_change() {
         let mut st = TestBuilder::new()
             .with_block_cache()
             .with_test_account(AccountBirthday::from_sapling_activation)
@@ -1089,24 +1134,40 @@ pub(crate) mod tests {
         assert_eq!(st.get_total_balance(account), value);
         assert_eq!(st.get_spendable_balance(account, 1), value);
 
+        // TODO: This test was originally written to use the pre-zip-313 fee rule
+        // and has not yet been updated.
+        #[allow(deprecated)]
+        let fee_rule = StandardFeeRule::PreZip313;
+
         // TODO: generate_next_block_from_tx does not currently support transparent outputs.
         let to = TransparentAddress::PublicKey([7; 20]).into();
-        assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+        let min_confirmations = NonZeroU32::new(1).unwrap();
+        let proposal = st
+            .propose_standard_transfer::<Infallible>(
+                account,
+                fee_rule,
+                min_confirmations,
                 &to,
                 NonNegativeAmount::const_from_u64(50000),
                 None,
+                None,
+            )
+            .unwrap();
+
+        // Executing the proposal should succeed
+        assert_matches!(
+            st.create_proposed_transaction::<Infallible, _>(
+                &usk,
                 OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
-                None
+                proposal,
+                min_confirmations
             ),
             Ok(_)
         );
     }
 
     #[test]
-    fn create_to_address_spends_a_change_note() {
+    fn change_note_spends_succeed() {
         let mut st = TestBuilder::new()
             .with_block_cache()
             .with_test_account(AccountBirthday::from_sapling_activation)
@@ -1131,17 +1192,33 @@ pub(crate) mod tests {
             NonNegativeAmount::ZERO
         );
 
+        // TODO: This test was originally written to use the pre-zip-313 fee rule
+        // and has not yet been updated.
+        #[allow(deprecated)]
+        let fee_rule = StandardFeeRule::PreZip313;
+
         // TODO: generate_next_block_from_tx does not currently support transparent outputs.
         let to = TransparentAddress::PublicKey([7; 20]).into();
-        assert_matches!(
-            st.create_spend_to_address(
-                &usk,
+        let min_confirmations = NonZeroU32::new(1).unwrap();
+        let proposal = st
+            .propose_standard_transfer::<Infallible>(
+                account,
+                fee_rule,
+                min_confirmations,
                 &to,
                 NonNegativeAmount::const_from_u64(50000),
                 None,
+                None,
+            )
+            .unwrap();
+
+        // Executing the proposal should succeed
+        assert_matches!(
+            st.create_proposed_transaction::<Infallible, _>(
+                &usk,
                 OvkPolicy::Sender,
-                NonZeroU32::new(1).unwrap(),
-                None
+                proposal,
+                min_confirmations
             ),
             Ok(_)
         );
@@ -1205,6 +1282,7 @@ pub(crate) mod tests {
         ])
         .unwrap();
 
+        #[allow(deprecated)]
         let fee_rule = FixedFeeRule::standard();
         let input_selector = GreedyInputSelector::new(
             fixed::SingleOutputChangeStrategy::new(fee_rule, None),
@@ -1298,10 +1376,7 @@ pub(crate) mod tests {
         assert_eq!(st.get_total_balance(account), total);
         assert_eq!(st.get_spendable_balance(account, 1), total);
 
-        let input_selector = GreedyInputSelector::new(
-            zip317::SingleOutputChangeStrategy::new(Zip317FeeRule::standard(), None),
-            DustOutputPolicy::default(),
-        );
+        let input_selector = input_selector(StandardFeeRule::Zip317, None);
 
         // This first request will fail due to insufficient non-dust funds
         let req = TransactionRequest::new(vec![Payment {