diff --git a/full-service/migrations/2024-05-21-035622_subaddress-only-account/down.sql b/full-service/migrations/2024-05-21-035622_subaddress-only-account/down.sql new file mode 100644 index 000000000..b70d56702 --- /dev/null +++ b/full-service/migrations/2024-05-21-035622_subaddress-only-account/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE accounts DROP COLUMN require_spend_subaddress; \ No newline at end of file diff --git a/full-service/migrations/2024-05-21-035622_subaddress-only-account/up.sql b/full-service/migrations/2024-05-21-035622_subaddress-only-account/up.sql new file mode 100644 index 000000000..ea368e4b3 --- /dev/null +++ b/full-service/migrations/2024-05-21-035622_subaddress-only-account/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +ALTER TABLE accounts + ADD COLUMN require_spend_subaddress BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/full-service/src/db/account.rs b/full-service/src/db/account.rs index 54db84161..9c8d4aede 100644 --- a/full-service/src/db/account.rs +++ b/full-service/src/db/account.rs @@ -82,6 +82,7 @@ pub trait AccountModel { ///| `name` | The display name for the account. | A label can have duplicates, but it is not recommended. | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | If enabled, this mode requires all transactions to spend from a provided subaddress | | /// /// # Returns: /// * (account_id, main_subaddress_b58) @@ -94,6 +95,7 @@ pub trait AccountModel { name: &str, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result<(AccountID, String), WalletDbError>; @@ -110,6 +112,7 @@ pub trait AccountModel { ///| `name` | The display name for the account. | A label can have duplicates, but it is not recommended. | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | If enabled, this mode requires all transactions to spend from a provided subaddress | | ///| `conn` | An reference to the pool connection of wallet database | | /// /// # Returns: @@ -123,6 +126,7 @@ pub trait AccountModel { name: &str, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result<(AccountID, String), WalletDbError>; @@ -140,6 +144,7 @@ pub trait AccountModel { ///| `next_subaddress_index` | This index represents the next subaddress to be assigned as an address. | This is useful information in case the account is imported elsewhere. | ///| `name` | The display name for the account. | A label can have duplicates, but it is not recommended. | ///| `fog_enabled` | Indicate if fog server is enabled or disabled | | + ///| `require_spend_subaddress` | If enabled, this mode requires all transactions to spend from a provided subaddress | | ///| `conn` | An reference to the pool connection of wallet database | | /// /// # Returns: @@ -154,6 +159,7 @@ pub trait AccountModel { next_subaddress_index: Option, name: &str, fog_enabled: bool, + require_spend_subaddress: bool, conn: Conn, ) -> Result<(AccountID, String), WalletDbError>; @@ -170,6 +176,7 @@ pub trait AccountModel { ///| `next_subaddress_index` | This index represents the next subaddress to be assigned as an address. | This is useful information in case the account is imported elsewhere. | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | If enabled, this mode requires all transactions to spend from a provided subaddress | | ///| `conn` | An reference to the pool connection of wallet database | | /// /// # Returns: @@ -183,6 +190,7 @@ pub trait AccountModel { next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result; @@ -199,6 +207,7 @@ pub trait AccountModel { ///| `next_subaddress_index` | This index represents the next subaddress to be assigned as an address. | This is useful information in case the account is imported elsewhere. | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | If enabled, this mode requires all transactions to spend from a provided subaddress | | ///| `conn` | An reference to the pool connection of wallet database | | /// /// # Returns: @@ -212,6 +221,7 @@ pub trait AccountModel { next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result; @@ -228,6 +238,7 @@ pub trait AccountModel { ///| `first_block_index` | Index of the first block when this account may have received funds. | Defaults to 0 if not provided | ///| `next_subaddress_index` | This index represents the next subaddress to be assigned as an address. | This is useful information in case the account is imported elsewhere. | ///| `managed_by_hardware_wallet` | Whether the account is managed by a hardware wallet. | | + ///| `require_spend_subaddress` | If enabled, this mode requires all transactions to spend from a provided subaddress | | ///| `conn` | An reference to the pool connection of wallet database | | /// /// # Returns: @@ -240,6 +251,7 @@ pub trait AccountModel { first_block_index: Option, next_subaddress_index: Option, managed_by_hardware_wallet: bool, + require_spend_subaddress: bool, conn: Conn, ) -> Result; @@ -249,6 +261,7 @@ pub trait AccountModel { import_block_index: u64, first_block_index: Option, default_public_address: &PublicAddress, + require_spend_subaddress: bool, conn: Conn, ) -> Result; @@ -303,7 +316,7 @@ pub trait AccountModel { ) -> Result, WalletDbError>; /// Update the account name for current account. - /// * The only updatable field is the name. Any other desired update requires adding a new account, and deleting the existing if desired. + /// * The only updatable fields are the name and require_spend_subaddress. Any other desired update requires adding a new account, and deleting the existing if desired. /// /// # Arguments ///| Name | Purpose | Notes | @@ -319,6 +332,23 @@ pub trait AccountModel { conn: Conn ) -> Result<(), WalletDbError>; + /// Update the account's require_spend_subaddress mode. + /// * The only updatable fields are the name and require_spend_subaddress. Any other desired update requires adding a new account, and deleting the existing if desired. + /// + /// # Arguments + ///| Name | Purpose | Notes | + ///|------------|----------------------------------------------------------|-------| + ///| `require_spend_subaddress` | The new account name used to perform this update action. | | + ///| `conn` | An reference to the pool connection of wallet database | | + /// + /// # Returns: + /// * unit + fn update_require_spend_subaddress( + &self, + require_spend_subaddress: bool, + conn: Conn, + ) -> Result<(), WalletDbError>; + /// Update the next block index in current account that needs to sync. /// /// # Arguments @@ -453,6 +483,7 @@ impl AccountModel for Account { name: &str, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result<(AccountID, String), WalletDbError> { let fog_enabled = !fog_report_url.is_empty(); @@ -474,6 +505,7 @@ impl AccountModel for Account { next_subaddress_index, name, fog_enabled, + require_spend_subaddress, conn, ) } @@ -486,6 +518,7 @@ impl AccountModel for Account { name: &str, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result<(AccountID, String), WalletDbError> { let fog_enabled = !fog_report_url.is_empty(); @@ -507,6 +540,7 @@ impl AccountModel for Account { next_subaddress_index, name, fog_enabled, + require_spend_subaddress, conn, ) } @@ -520,6 +554,7 @@ impl AccountModel for Account { next_subaddress_index: Option, name: &str, fog_enabled: bool, + require_spend_subaddress: bool, conn: Conn, ) -> Result<(AccountID, String), WalletDbError> { use crate::db::schema::accounts; @@ -548,6 +583,7 @@ impl AccountModel for Account { fog_enabled, view_only: false, managed_by_hardware_wallet: false, + require_spend_subaddress, }; diesel::insert_into(accounts::table) @@ -583,6 +619,7 @@ impl AccountModel for Account { next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result { let (account_id, _public_address_b58) = Account::create_from_mnemonic( @@ -593,6 +630,7 @@ impl AccountModel for Account { &name.unwrap_or_default(), fog_report_url, fog_authority_spki, + require_spend_subaddress, conn, )?; Account::get(&account_id, conn) @@ -606,6 +644,7 @@ impl AccountModel for Account { next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, conn: Conn, ) -> Result { let (account_id, _public_address_b58) = Account::create_from_root_entropy( @@ -616,6 +655,7 @@ impl AccountModel for Account { &name.unwrap_or_default(), fog_report_url, fog_authority_spki, + require_spend_subaddress, conn, )?; Account::get(&account_id, conn) @@ -628,6 +668,7 @@ impl AccountModel for Account { first_block_index: Option, next_subaddress_index: Option, managed_by_hardware_wallet: bool, + require_spend_subaddress: bool, conn: Conn, ) -> Result { use crate::db::schema::accounts; @@ -656,6 +697,7 @@ impl AccountModel for Account { fog_enabled: false, view_only: true, managed_by_hardware_wallet, + require_spend_subaddress, }; diesel::insert_into(accounts::table) @@ -701,6 +743,7 @@ impl AccountModel for Account { import_block_index: u64, first_block_index: Option, default_public_address: &PublicAddress, + require_spend_subaddress: bool, conn: Conn, ) -> Result { use crate::db::schema::accounts; @@ -726,6 +769,7 @@ impl AccountModel for Account { fog_enabled: true, view_only: true, managed_by_hardware_wallet: true, + require_spend_subaddress, }; diesel::insert_into(accounts::table) @@ -804,6 +848,19 @@ impl AccountModel for Account { Ok(()) } + fn update_require_spend_subaddress( + &self, + require_spend_subaddress: bool, + conn: Conn, + ) -> Result<(), WalletDbError> { + use crate::db::schema::accounts; + + diesel::update(accounts::table.filter(accounts::id.eq(&self.id))) + .set(accounts::require_spend_subaddress.eq(require_spend_subaddress)) + .execute(conn)?; + Ok(()) + } + fn update_next_block_index( &self, next_block_index: u64, @@ -964,6 +1021,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -995,6 +1053,7 @@ mod tests { view_only: false, managed_by_hardware_wallet: false, resyncing: false, + require_spend_subaddress: false, }; assert_eq!(expected_account, acc); @@ -1037,6 +1096,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, wallet_db.get_pooled_conn().unwrap().deref_mut(), ) .unwrap(); @@ -1062,6 +1122,7 @@ mod tests { view_only: false, managed_by_hardware_wallet: false, resyncing: false, + require_spend_subaddress: false, }; assert_eq!(expected_account_secondary, acc_secondary); @@ -1125,6 +1186,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -1160,6 +1222,7 @@ mod tests { "Alice's FOG Account", "fog//some.fog.url".to_string(), "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvnB9wTbTOT5uoizRYaYbw7XIEkInl8E7MGOAQj+xnC+F1rIXiCnc/t1+5IIWjbRGhWzo7RAwI5sRajn2sT4rRn9NXbOzZMvIqE4hmhmEzy1YQNDnfALAWNQ+WBbYGW+Vqm3IlQvAFFjVN1YYIdYhbLjAPdkgeVsWfcLDforHn6rR3QBZYZIlSBQSKRMY/tywTxeTCvK2zWcS0kbbFPtBcVth7VFFVPAZXhPi9yy1AvnldO6n7KLiupVmojlEMtv4FQkk604nal+j/dOplTATV8a9AJBbPRBZ/yQg57EG2Y2MRiHOQifJx0S5VbNyMm9bkS8TD7Goi59aCW6OT1gyeotWwLg60JRZTfyJ7lYWBSOzh0OnaCytRpSWtNZ6barPUeOnftbnJtE8rFhF7M4F66et0LI/cuvXYecwVwykovEVBKRF4HOK9GgSm17mQMtzrD7c558TbaucOWabYR04uhdAc3s10MkuONWG0wIQhgIChYVAGnFLvSpp2/aQEq3xrRSETxsixUIjsZyWWROkuA0IFnc8d7AmcnUBvRW7FT/5thWyk5agdYUGZ+7C1o69ihR1YxmoGh69fLMPIEOhYh572+3ckgl2SaV4uo9Gvkz8MMGRBcMIMlRirSwhCfozV2RyT5Wn1NgPpyc8zJL7QdOhL7Qxb+5WjnCVrQYHI2cCAwEAAQ==".to_string(), + false, conn, ) .unwrap(); @@ -1229,6 +1292,7 @@ mod tests { view_only: false, managed_by_hardware_wallet: false, resyncing: false, + require_spend_subaddress: false, }; assert_eq!(expected_account, acc); } @@ -1256,6 +1320,7 @@ mod tests { None, None, false, + false, conn, ) .unwrap() @@ -1287,6 +1352,7 @@ mod tests { view_only: true, managed_by_hardware_wallet: false, resyncing: false, + require_spend_subaddress: false, }; assert_eq!(expected_account, account); } @@ -1319,6 +1385,7 @@ mod tests { 12, None, &default_public_address, + false, conn, ) .unwrap() @@ -1347,6 +1414,7 @@ mod tests { view_only: true, managed_by_hardware_wallet: true, resyncing: false, + require_spend_subaddress: false, }; // Check to make sure the account in the database is correct diff --git a/full-service/src/db/models.rs b/full-service/src/db/models.rs index f790fd0d8..284275947 100644 --- a/full-service/src/db/models.rs +++ b/full-service/src/db/models.rs @@ -40,6 +40,8 @@ pub struct Account { /// account. pub managed_by_hardware_wallet: bool, pub resyncing: bool, + /// If true, this account is only allowed to spend from subaddresses. + pub require_spend_subaddress: bool, } /// A structure that can be inserted to create a new entity in the `accounts` @@ -58,6 +60,7 @@ pub struct NewAccount<'a> { pub fog_enabled: bool, pub view_only: bool, pub managed_by_hardware_wallet: bool, + pub require_spend_subaddress: bool, } /// A transaction output entity that either was received to an Account in this diff --git a/full-service/src/db/schema.rs b/full-service/src/db/schema.rs index 29a7243ca..e1a9c9a5b 100644 --- a/full-service/src/db/schema.rs +++ b/full-service/src/db/schema.rs @@ -14,6 +14,7 @@ diesel::table! { view_only -> Bool, managed_by_hardware_wallet -> Bool, resyncing -> Bool, + require_spend_subaddress -> Bool, } } @@ -116,6 +117,7 @@ diesel::table! { diesel::joinable!(assigned_subaddresses -> accounts (account_id)); diesel::joinable!(authenticated_sender_memos -> txos (txo_id)); +diesel::joinable!(destination_memos -> txos (txo_id)); diesel::joinable!(transaction_input_txos -> transaction_logs (transaction_log_id)); diesel::joinable!(transaction_input_txos -> txos (txo_id)); diesel::joinable!(transaction_logs -> accounts (account_id)); diff --git a/full-service/src/db/txo.rs b/full-service/src/db/txo.rs index 08a95a378..70294ad74 100644 --- a/full-service/src/db/txo.rs +++ b/full-service/src/db/txo.rs @@ -2481,6 +2481,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -2825,6 +2826,7 @@ mod tests { "Bob's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -2930,6 +2932,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -3012,6 +3015,7 @@ mod tests { "Exchange Account", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -3120,6 +3124,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3248,6 +3253,7 @@ mod tests { "Exchange Account", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -3357,6 +3363,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3424,6 +3431,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3482,6 +3490,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3557,6 +3566,7 @@ mod tests { "Alice", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3719,6 +3729,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3770,6 +3781,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -3861,6 +3873,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -3917,6 +3930,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -3970,6 +3984,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -4059,6 +4074,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -4159,6 +4175,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -4346,6 +4363,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -4498,6 +4516,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); @@ -4601,6 +4620,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -4688,6 +4708,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); @@ -4816,6 +4837,7 @@ mod tests { "", "".to_string(), "".to_string(), + false, conn, ) .unwrap(); diff --git a/full-service/src/error.rs b/full-service/src/error.rs index 368be1f80..0decd9eb1 100644 --- a/full-service/src/error.rs +++ b/full-service/src/error.rs @@ -314,7 +314,7 @@ pub enum WalletTransactionBuilderError { /// Error interacting with fog: {0} FogError(String), - /// Attempting to build a transaction from a TXO without a subaddress: {0} + /// Subaddress is required for spending on this account: {0} NullSubaddress(String), /// Error executing diesel transaction: {0} diff --git a/full-service/src/json_rpc/v1/api/wallet.rs b/full-service/src/json_rpc/v1/api/wallet.rs index 6472a3275..3538ac21c 100644 --- a/full-service/src/json_rpc/v1/api/wallet.rs +++ b/full-service/src/json_rpc/v1/api/wallet.rs @@ -181,7 +181,7 @@ where subaddress_index: None, }, None, - None, // Note: Not including subaddress_to_spend_from in V1 API + None, // Note: Not including spend_subaddress in V1 API ) .await .map_err(format_error)?; @@ -298,7 +298,7 @@ where subaddress_index: None, }, None, - None, // Note: not including subaddress_to_spend_from in V1 API + None, // Note: not including spend_subaddress in V1 API ) .await .map_err(format_error)?; @@ -383,6 +383,7 @@ where name, fog_report_url.unwrap_or_default(), fog_authority_spki.unwrap_or_default(), + false, // not exposed in V1 API ) .map_err(format_error)?; let next_subaddress_index = service @@ -1005,6 +1006,7 @@ where ns, fog_report_url.unwrap_or_default(), fog_authority_spki.unwrap_or_default(), + false, ) .map_err(format_error)?; @@ -1045,6 +1047,7 @@ where ns, fog_report_url.unwrap_or_default(), fog_authority_spki.unwrap_or_default(), + false, ) .map_err(format_error)?; diff --git a/full-service/src/json_rpc/v1/models/txo.rs b/full-service/src/json_rpc/v1/models/txo.rs index e4addc8b0..762248ddd 100644 --- a/full-service/src/json_rpc/v1/models/txo.rs +++ b/full-service/src/json_rpc/v1/models/txo.rs @@ -237,6 +237,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); diff --git a/full-service/src/json_rpc/v2/api/request.rs b/full-service/src/json_rpc/v2/api/request.rs index 885a1492d..5890d64fb 100644 --- a/full-service/src/json_rpc/v2/api/request.rs +++ b/full-service/src/json_rpc/v2/api/request.rs @@ -57,7 +57,7 @@ pub enum JsonCommandRequest { block_version: Option, sender_memo_credential_subaddress_index: Option, payment_request_id: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, }, build_burn_transaction { account_id: String, @@ -69,7 +69,7 @@ pub enum JsonCommandRequest { tombstone_block: Option, max_spendable_value: Option, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, }, build_transaction { account_id: String, @@ -84,7 +84,7 @@ pub enum JsonCommandRequest { block_version: Option, sender_memo_credential_subaddress_index: Option, payment_request_id: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, }, build_unsigned_burn_transaction { account_id: String, @@ -96,7 +96,7 @@ pub enum JsonCommandRequest { tombstone_block: Option, max_spendable_value: Option, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, }, build_unsigned_transaction { account_id: String, @@ -109,7 +109,7 @@ pub enum JsonCommandRequest { tombstone_block: Option, max_spendable_value: Option, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, }, check_b58_type { b58_code: String, @@ -121,6 +121,8 @@ pub enum JsonCommandRequest { create_account { name: Option, fog_info: Option, + #[serde(default = "bool::default")] // default is false + require_spend_subaddress: bool, }, create_payment_request { account_id: String, @@ -226,6 +228,8 @@ pub enum JsonCommandRequest { first_block_index: Option, next_subaddress_index: Option, fog_info: Option, + #[serde(default = "bool::default")] // default is false + require_spend_subaddress: bool, }, import_account { mnemonic: String, @@ -233,6 +237,8 @@ pub enum JsonCommandRequest { first_block_index: Option, next_subaddress_index: Option, fog_info: Option, + #[serde(default = "bool::default")] // default is false + require_spend_subaddress: bool, }, import_view_only_account { view_private_key: String, @@ -240,11 +246,15 @@ pub enum JsonCommandRequest { name: Option, first_block_index: Option, next_subaddress_index: Option, + #[serde(default = "bool::default")] // default is false + require_spend_subaddress: bool, }, import_view_only_account_from_hardware_wallet { name: Option, first_block_index: Option, fog_info: Option, + #[serde(default = "bool::default")] // default is false + require_spend_subaddress: bool, }, remove_account { account_id: String, @@ -259,6 +269,10 @@ pub enum JsonCommandRequest { search_ledger { query: String, }, + set_require_spend_subaddress { + account_id: String, + require_spend_subaddress: bool, + }, submit_transaction { tx_proposal: TxProposal, comment: Option, diff --git a/full-service/src/json_rpc/v2/api/response.rs b/full-service/src/json_rpc/v2/api/response.rs index 9367a1c07..3336cf703 100644 --- a/full-service/src/json_rpc/v2/api/response.rs +++ b/full-service/src/json_rpc/v2/api/response.rs @@ -200,6 +200,9 @@ pub enum JsonCommandResponse { search_ledger { results: Vec, }, + set_require_spend_subaddress { + account: Account, + }, submit_transaction { transaction_log: Option, }, diff --git a/full-service/src/json_rpc/v2/api/wallet.rs b/full-service/src/json_rpc/v2/api/wallet.rs index d348194d7..a46fea985 100644 --- a/full-service/src/json_rpc/v2/api/wallet.rs +++ b/full-service/src/json_rpc/v2/api/wallet.rs @@ -172,7 +172,7 @@ where block_version, sender_memo_credential_subaddress_index, payment_request_id, - subaddress_to_spend_from, + spend_subaddress, } => { // The user can specify a list of addresses and values, // or a single address and a single value. @@ -219,7 +219,7 @@ where comment, transaction_memo, block_version, - subaddress_to_spend_from, + spend_subaddress, ) .await .map_err(format_error)?; @@ -243,7 +243,7 @@ where tombstone_block, max_spendable_value, block_version, - subaddress_to_spend_from, + spend_subaddress, } => { let mut memo_data = [0; BurnRedemptionMemo::MEMO_DATA_LEN]; if let Some(redemption_memo_hex) = redemption_memo_hex { @@ -279,7 +279,7 @@ where max_spendable_value, TransactionMemo::BurnRedemption(memo_data), block_version, - subaddress_to_spend_from, + spend_subaddress, ) .await .map_err(format_error)?; @@ -302,7 +302,7 @@ where block_version, sender_memo_credential_subaddress_index, payment_request_id, - subaddress_to_spend_from, + spend_subaddress, } => { // The user can specify a list of addresses and values, // or a single address and a single value. @@ -348,7 +348,7 @@ where max_spendable_value, transaction_memo, block_version, - subaddress_to_spend_from, + spend_subaddress, ) .await .map_err(format_error)?; @@ -368,7 +368,7 @@ where tombstone_block, max_spendable_value, block_version, - subaddress_to_spend_from, + spend_subaddress, } => { let mut memo_data = [0; BurnRedemptionMemo::MEMO_DATA_LEN]; if let Some(redemption_memo_hex) = redemption_memo_hex { @@ -404,7 +404,7 @@ where max_spendable_value, TransactionMemo::BurnRedemption(memo_data), block_version, - subaddress_to_spend_from, + spend_subaddress, ) .map_err(format_error)?) .try_into() @@ -426,7 +426,7 @@ where input_txo_ids, max_spendable_value, block_version, - subaddress_to_spend_from, + spend_subaddress, } => { let mut addresses_and_amounts = addresses_and_amounts.unwrap_or_default(); if let (Some(address), Some(amount)) = (recipient_public_address, amount) { @@ -452,7 +452,7 @@ where max_spendable_value, TransactionMemo::Empty, block_version, - subaddress_to_spend_from, + spend_subaddress, ) .map_err(format_error)?) .try_into() @@ -504,11 +504,20 @@ where txo: txo_status_and_memo.map(|txo_info| (&txo_info).into()), } } - JsonCommandRequest::create_account { name, fog_info } => { + JsonCommandRequest::create_account { + name, + fog_info, + require_spend_subaddress, + } => { let fog_info = fog_info.unwrap_or_default(); let account = service - .create_account(name, fog_info.report_url, fog_info.authority_spki) + .create_account( + name, + fog_info.report_url, + fog_info.authority_spki, + require_spend_subaddress, + ) .map_err(format_error)?; let next_subaddress_index = service @@ -1109,6 +1118,7 @@ where first_block_index, next_subaddress_index, fog_info, + require_spend_subaddress, } => { let fb = first_block_index .map(|fb| fb.parse::()) @@ -1129,6 +1139,7 @@ where ns, fog_info.report_url, fog_info.authority_spki, + require_spend_subaddress, ) .map_err(format_error)?; @@ -1156,6 +1167,7 @@ where first_block_index, next_subaddress_index, fog_info, + require_spend_subaddress, } => { let fb = first_block_index .map(|fb| fb.parse::()) @@ -1176,6 +1188,7 @@ where ns, fog_info.report_url, fog_info.authority_spki, + require_spend_subaddress, ) .map_err(format_error)?; @@ -1203,6 +1216,7 @@ where name, first_block_index, next_subaddress_index, + require_spend_subaddress, } => { let fb = first_block_index .map(|fb| fb.parse::()) @@ -1232,6 +1246,7 @@ where name, fb, ns, + require_spend_subaddress, ) .map_err(format_error)?; let next_subaddress_index = service @@ -1254,6 +1269,7 @@ where name, first_block_index, fog_info, + require_spend_subaddress, } => { let fb = first_block_index .map(|fb| fb.parse::()) @@ -1261,7 +1277,12 @@ where .map_err(format_error)?; let account = service - .import_view_only_account_from_hardware_wallet(name, fb, fog_info) + .import_view_only_account_from_hardware_wallet( + name, + fb, + fog_info, + require_spend_subaddress, + ) .await .map_err(format_error)?; @@ -1346,6 +1367,29 @@ where results: results.iter().map(Into::into).collect(), } } + JsonCommandRequest::set_require_spend_subaddress { + account_id, + require_spend_subaddress, + } => { + let account_id = AccountID(account_id); + let account = service + .update_require_spend_subaddress(&account_id, require_spend_subaddress) + .map_err(format_error)?; + let next_subaddress_index = service + .get_next_subaddress_index_for_account(&AccountID(account.id.clone())) + .map_err(format_error)?; + let main_public_address: mc_account_keys::PublicAddress = (&service + .get_address_for_account( + &account.id.clone().into(), + DEFAULT_SUBADDRESS_INDEX as i64, + ) + .map_err(format_error)?) + .try_into() + .map_err(format_error)?; + let account = Account::new(&account, &main_public_address, next_subaddress_index) + .map_err(format_error)?; + JsonCommandResponse::set_require_spend_subaddress { account } + } JsonCommandRequest::submit_transaction { tx_proposal, comment, diff --git a/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/mod.rs b/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/mod.rs index 5e70bd4b9..109641134 100644 --- a/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/mod.rs +++ b/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/mod.rs @@ -1,6 +1,6 @@ mod build_and_submit; -mod build_and_submit_with_subaddress_to_spend_from; mod build_then_submit; mod build_unsigned; mod large_transaction; mod multiple_outlay; +mod spend_subaddress; diff --git a/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/build_and_submit_with_subaddress_to_spend_from.rs b/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/spend_subaddress.rs similarity index 58% rename from full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/build_and_submit_with_subaddress_to_spend_from.rs rename to full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/spend_subaddress.rs index 1d34d18ad..b8e749de3 100644 --- a/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/build_and_submit_with_subaddress_to_spend_from.rs +++ b/full-service/src/json_rpc/v2/e2e_tests/transaction/build_submit/spend_subaddress.rs @@ -15,7 +15,7 @@ mod e2e_transaction { util::b58::b58_decode_public_address, }; - use mc_common::logger::{test_with_logger, Logger}; + use mc_common::logger::{log, test_with_logger, Logger}; use mc_ledger_db::Ledger; use mc_rand::rand_core::RngCore; use mc_transaction_core::{ring_signature::KeyImage, tokens::Mob, Token}; @@ -31,7 +31,7 @@ mod e2e_transaction { // respectively from an external source. Bob sends 42 MOB to Alice, and the // balances should end up as [Alice: 142 MOB, Bob: 158 MOB, Carol: 300 MOB]. #[test_with_logger] - fn test_build_and_submit_transaction_with_subaddress_to_spend_from(logger: Logger) { + fn test_build_and_submit_transaction_with_spend_subaddress(logger: Logger) { let mut rng: StdRng = SeedableRng::from_seed([3u8; 32]); let (client, mut ledger_db, db_ctx, _network_state) = setup(&mut rng, logger.clone()); @@ -42,6 +42,7 @@ mod e2e_transaction { "method": "create_account", "params": { "name": "Exchange Main Account", + "require_spend_subaddress": true, } }); let res = dispatch(&client, body, &logger); @@ -112,7 +113,7 @@ mod e2e_transaction { ); assert_eq!(ledger_db.num_blocks().unwrap(), 15); - // Get balance for the exchange, which should include all three suabddress + // Get balance for the exchange, which should include all three subaddress // balances. The state of the wallet should be: // // Overall Balance: 600 MOB @@ -181,8 +182,8 @@ mod e2e_transaction { "params": { "account_id": account_id, "recipient_public_address": alice_b58_public_address, - "amount": { "value": "42000000000000", "token_id": "0" }, // 42.0 MOB - "subaddress_to_spend_from": bob_b58_public_address, + "amount": { "value": (42 * MOB).to_string(), "token_id": "0" }, + "spend_subaddress": bob_b58_public_address, } }); let res = dispatch(&client, body, &logger); @@ -307,4 +308,229 @@ mod e2e_transaction { assert_eq!(secreted, "0"); assert_eq!(orphaned, "0"); } + + #[test_with_logger] + fn test_build_and_submit_transaction_with_require_spend_subaddress_mismatch_fails_if_set( + logger: Logger, + ) { + use crate::error::WalletTransactionBuilderError::NullSubaddress as transaction_error; + let mut rng: StdRng = SeedableRng::from_seed([3u8; 32]); + let (client, mut ledger_db, db_ctx, _network_state) = setup(&mut rng, logger.clone()); + + // Add an account + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "create_account", + "params": { + "name": "Exchange Main Account", + "require_spend_subaddress": true, + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let account_obj = result.get("account").unwrap(); + let account_id = account_obj.get("id").unwrap().as_str().unwrap(); + + let ( + (_alice_public_address, alice_b58_public_address), + (bob_public_address, _bob_b58_public_address), + ) = ["Subaddress for Alice", "Subaddress for Bob"] + .iter() + .map(|metadata| { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "assign_address_for_account", + "params": { + "account_id": account_id, + "metadata": metadata, + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let address = result.get("address").unwrap(); + let b58_address = address.get("public_address_b58").unwrap().as_str().unwrap(); + let public_address = b58_decode_public_address(b58_address).unwrap(); + (public_address, b58_address.to_string()) + }) + .collect_tuple() + .unwrap(); + + // Add a block with a txo for Bob + add_block_to_ledger_db( + &mut ledger_db, + &vec![bob_public_address.clone()], + 200 * MOB, + &[KeyImage::from(rng.next_u64())], + &mut rng, + ); + + manually_sync_account( + &ledger_db, + &db_ctx.get_db_instance(logger.clone()), + &AccountID(account_id.to_string()), + &logger, + ); + + // Imagine that Bob is sending 42.0 MOB to Alice + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "build_and_submit_transaction", + "params": { + "account_id": account_id, + "recipient_public_address": alice_b58_public_address, + "amount": { "value": (42 * MOB).to_string(), "token_id": "0" }, + } + }); + let res = dispatch(&client, body, &logger); + let error = res.get("error").unwrap(); + let data = error.get("data").unwrap(); + let details = data.get("details").unwrap(); + assert!(details + .to_string() + .contains(&transaction_error("This account requires subaddresses be specified when spending. Please provide a subaddress to spend from.".to_string()).to_string())); + } + + #[test_with_logger] + fn test_build_and_submit_without_require_spend_subaddress_allows_spending_from_subaddress( + logger: Logger, + ) { + let mut rng: StdRng = SeedableRng::from_seed([3u8; 32]); + let (client, mut ledger_db, db_ctx, _network_state) = setup(&mut rng, logger.clone()); + + // Add an account + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "create_account", + "params": { + "name": "Exchange Main Account", + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let account_obj = result.get("account").unwrap(); + let account_id = account_obj.get("id").unwrap().as_str().unwrap(); + + let ( + (_alice_public_address, alice_b58_public_address), + (bob_public_address, bob_b58_public_address), + ) = ["Subaddress for Alice", "Subaddress for Bob"] + .iter() + .map(|metadata| { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "assign_address_for_account", + "params": { + "account_id": account_id, + "metadata": metadata, + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let address = result.get("address").unwrap(); + let b58_address = address.get("public_address_b58").unwrap().as_str().unwrap(); + let public_address = b58_decode_public_address(b58_address).unwrap(); + (public_address, b58_address.to_string()) + }) + .collect_tuple() + .unwrap(); + + // Add a block with a txo for Bob + add_block_to_ledger_db( + &mut ledger_db, + &vec![bob_public_address.clone()], + 200 * MOB, + &[KeyImage::from(rng.next_u64())], + &mut rng, + ); + + manually_sync_account( + &ledger_db, + &db_ctx.get_db_instance(logger.clone()), + &AccountID(account_id.to_string()), + &logger, + ); + + // Imagine that Bob is sending 42.0 MOB to Alice + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "build_and_submit_transaction", + "params": { + "account_id": account_id, + "recipient_public_address": alice_b58_public_address, + "amount": { "value": (42 * MOB).to_string(), "token_id": "0" }, + "spend_subaddress": bob_b58_public_address, + } + }); + let res = dispatch(&client, body, &logger); + assert!(res.get("result").is_some()); + } + + #[test_with_logger] + fn test_enable_and_disable_require_spend_subaddress(logger: Logger) { + let mut rng: StdRng = SeedableRng::from_seed([3u8; 32]); + let (client, _ledger_db, _db_ctx, _network_state) = setup(&mut rng, logger.clone()); + + // Add an account + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "create_account", + "params": { + "name": "Exchange Main Account", + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let account_obj = result.get("account").unwrap(); + let account_id = account_obj.get("id").unwrap().as_str().unwrap(); + + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "set_require_spend_subaddress", + "params": { + "account_id": account_id.to_string(), + "require_spend_subaddress": true, + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let account_obj = result.get("account").unwrap(); + log::info!(logger, "account_obj: {:?}", account_obj); + assert_eq!( + account_obj + .get("require_spend_subaddress") + .unwrap() + .as_bool() + .unwrap(), + true + ); + + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "set_require_spend_subaddress", + "params": { + "account_id": account_id.to_string(), + "require_spend_subaddress": false, + } + }); + let res = dispatch(&client, body, &logger); + let result = res.get("result").unwrap(); + let account_obj = result.get("account").unwrap(); + assert_eq!( + account_obj + .get("require_spend_subaddress") + .unwrap() + .as_bool() + .unwrap(), + false + ); + } } diff --git a/full-service/src/json_rpc/v2/models/account.rs b/full-service/src/json_rpc/v2/models/account.rs index 0b9dfe330..ce83d0166 100644 --- a/full-service/src/json_rpc/v2/models/account.rs +++ b/full-service/src/json_rpc/v2/models/account.rs @@ -59,6 +59,11 @@ pub struct Account { /// A flag that indicates if this account's private spend key is managed by /// a hardware wallet. pub managed_by_hardware_wallet: bool, + + /// A flag that indicates that the account requires a spend_subaddress be + /// specified when building a transaction in order to keep subaddress + /// balances correct. + pub require_spend_subaddress: bool, } impl Account { @@ -82,6 +87,7 @@ impl Account { fog_enabled: src.fog_enabled, view_only: src.view_only, managed_by_hardware_wallet: src.managed_by_hardware_wallet, + require_spend_subaddress: src.require_spend_subaddress, }) } } diff --git a/full-service/src/json_rpc/v2/models/account_secrets.rs b/full-service/src/json_rpc/v2/models/account_secrets.rs index 3d9747c66..7d30b7480 100644 --- a/full-service/src/json_rpc/v2/models/account_secrets.rs +++ b/full-service/src/json_rpc/v2/models/account_secrets.rs @@ -42,6 +42,11 @@ pub struct AccountSecrets { /// Private keys for receiving and spending MobileCoin. #[serde(serialize_with = "expose_secret")] pub view_account_key: Secret>, + + /// Indicates that the account requires a spend_subaddress be + /// specified when building a transaction in order to keep subaddress + /// balances correct. + pub require_spend_subaddress: bool, } impl TryFrom<&Account> for AccountSecrets { @@ -62,6 +67,7 @@ impl TryFrom<&Account> for AccountSecrets { key_derivation_version: src.key_derivation_version.to_string(), account_key: Secret::new(None), view_account_key: Secret::new(Some(ViewAccountKey::from(&view_account_key))), + require_spend_subaddress: src.require_spend_subaddress, }) } else { let account_key: mc_account_keys::AccountKey = mc_util_serial::decode(&src.account_key) @@ -99,6 +105,7 @@ impl TryFrom<&Account> for AccountSecrets { }, )?)), view_account_key: Secret::new(None), + require_spend_subaddress: src.require_spend_subaddress, }) } } diff --git a/full-service/src/json_rpc/v2/models/txo.rs b/full-service/src/json_rpc/v2/models/txo.rs index 353e32905..af954c095 100644 --- a/full-service/src/json_rpc/v2/models/txo.rs +++ b/full-service/src/json_rpc/v2/models/txo.rs @@ -133,6 +133,7 @@ mod tests { "Alice's Main Account", "".to_string(), "".to_string(), + false, &mut wallet_db.get_pooled_conn().unwrap(), ) .unwrap(); diff --git a/full-service/src/service/account.rs b/full-service/src/service/account.rs index 8f6c0ec4c..c9279b873 100644 --- a/full-service/src/service/account.rs +++ b/full-service/src/service/account.rs @@ -162,12 +162,14 @@ pub trait AccountService { ///| `name` | A label for this account. | A label can have duplicates, but it is not recommended. | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | Spend only from subaddress. | Only allow the account to spend from give subaddresses. | /// fn create_account( &self, name: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, ) -> Result; /// Import an existing account to the wallet using the mnemonic. @@ -182,6 +184,7 @@ pub trait AccountService { ///| `next_subaddress_index` | The next known unused subaddress index for the account. | | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | Spend only from subaddress. | Only allow the account to spend from give subaddresses. | /// #[allow(clippy::too_many_arguments)] fn import_account( @@ -192,6 +195,7 @@ pub trait AccountService { next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, ) -> Result; /// Import an existing account to the wallet using the entropy. @@ -206,6 +210,7 @@ pub trait AccountService { ///| `next_subaddress_index` | The next known unused subaddress index for the account. | | ///| `fog_report_url` | Fog Report server url. | Applicable only if user has Fog service, empty string otherwise. | ///| `fog_authority_spki` | Fog Authority Subject Public Key Info. | Applicable only if user has Fog service, empty string otherwise. | + ///| `require_spend_subaddress` | Spend only from subaddress. | Only allow the account to spend from give subaddresses. | /// #[allow(clippy::too_many_arguments)] fn import_account_from_legacy_root_entropy( @@ -216,6 +221,7 @@ pub trait AccountService { next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, ) -> Result; /// Import an existing account to the wallet using the mnemonic. @@ -237,6 +243,7 @@ pub trait AccountService { name: Option, first_block_index: Option, next_subaddress_index: Option, + require_spend_subaddress: bool, ) -> Result; async fn import_view_only_account_from_hardware_wallet( @@ -244,6 +251,7 @@ pub trait AccountService { name: Option, first_block_index: Option, fog_info: Option, + require_spend_subaddress: bool, ) -> Result; /// Re-create sync request for a view only account @@ -328,6 +336,21 @@ pub trait AccountService { name: String, ) -> Result; + /// Update the require_spend_subaddress field for an account. + /// + /// # Arguments + /// + ///| Name | Purpose | Notes | + ///|--------------|----------------------------------------------|-----------------------------------| + ///| `account_id` | The account on which to perform this action. | Account must exist in the wallet. | + ///| `require_spend_subaddress` | Whether to enable require_spend_subaddress mode | | + /// + fn update_require_spend_subaddress( + &self, + account_id: &AccountID, + require_spend_subaddress: bool, + ) -> Result; + /// complete a sync request for a view only account /// /// # Arguments @@ -371,6 +394,7 @@ where name: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, ) -> Result { log::info!(self.logger, "Creating account {:?}", name,); @@ -408,6 +432,7 @@ where &name.unwrap_or_default(), fog_report_url, fog_authority_spki, + require_spend_subaddress, conn, )?; let account = Account::get(&account_id, conn)?; @@ -423,6 +448,7 @@ where next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, ) -> Result { log::info!( self.logger, @@ -456,6 +482,7 @@ where next_subaddress_index, fog_report_url, fog_authority_spki, + require_spend_subaddress, conn, )?) }) @@ -469,6 +496,7 @@ where next_subaddress_index: Option, fog_report_url: String, fog_authority_spki: String, + require_spend_subaddress: bool, ) -> Result { log::info!( self.logger, @@ -495,6 +523,7 @@ where next_subaddress_index, fog_report_url, fog_authority_spki, + require_spend_subaddress, conn, )?) }) @@ -507,6 +536,7 @@ where name: Option, first_block_index: Option, next_subaddress_index: Option, + require_spend_subaddress: bool, ) -> Result { log::info!( self.logger, @@ -530,6 +560,7 @@ where first_block_index, next_subaddress_index, false, + require_spend_subaddress, conn, )?) }) @@ -540,6 +571,7 @@ where name: Option, first_block_index: Option, fog_info: Option, + require_spend_subaddress: bool, ) -> Result { let view_account = get_view_only_account_keys().await?; @@ -571,6 +603,7 @@ where import_block_index, first_block_index, &default_public_address, + require_spend_subaddress, conn, )?) }) @@ -583,6 +616,7 @@ where first_block_index, None, true, + false, conn, )?) }), @@ -618,7 +652,8 @@ where spend_public_key: hex::encode(spend_public_key.to_bytes()), name: Some(account.name.clone()), first_block_index: Some(account.first_block_index.to_string()), - next_subaddress_index: Some(account.next_subaddress_index(conn)?.to_string()), + next_subaddress_index: Some(account.clone().next_subaddress_index(conn)?.to_string()), + require_spend_subaddress: account.require_spend_subaddress, }; let src_json: serde_json::Value = serde_json::json!(json_command_request); @@ -676,6 +711,18 @@ where Ok(Account::get(account_id, conn)?) } + fn update_require_spend_subaddress( + &self, + account_id: &AccountID, + require_spend_subaddress: bool, + ) -> Result { + let mut pooled_conn = self.get_pooled_conn()?; + let conn = pooled_conn.deref_mut(); + Account::get(account_id, conn)? + .update_require_spend_subaddress(require_spend_subaddress, conn)?; + Ok(Account::get(account_id, conn)?) + } + fn sync_account( &self, account_id: &AccountID, @@ -847,6 +894,7 @@ mod tests { None, "".to_string(), "".to_string(), + false, ) .unwrap(); let account_id = AccountID(account.id); @@ -874,7 +922,7 @@ mod tests { // create an account that has its first_block_index set to later in the ledger let account2 = service - .create_account(None, "".to_string(), "".to_string()) + .create_account(None, "".to_string(), "".to_string(), false) .unwrap(); assert_eq!( account2.first_block_index as u64, @@ -945,6 +993,7 @@ mod tests { None, "".to_string(), "".to_string(), + false, ) .unwrap(); let account_a_id = AccountID(account_a.id.clone()); @@ -957,6 +1006,7 @@ mod tests { None, "".to_string(), "".to_string(), + false, ) .unwrap(); let account_b_id = AccountID(account_b.id.clone()); @@ -1095,7 +1145,7 @@ mod tests { // Create an account. let account = service - .create_account(Some("A".to_string()), "".to_string(), "".to_string()) + .create_account(Some("A".to_string()), "".to_string(), "".to_string(), false) .unwrap(); // Add a transaction, with transaction status. @@ -1151,7 +1201,7 @@ mod tests { // Create an account. let account = service - .create_account(Some("A".to_string()), "".to_string(), "".to_string()) + .create_account(Some("A".to_string()), "".to_string(), "".to_string(), false) .unwrap(); // Even though we don't have a network connection, it sets the block indices @@ -1183,7 +1233,7 @@ mod tests { // Create an account. let account = service - .create_account(Some("A".to_string()), "".to_string(), "".to_string()) + .create_account(Some("A".to_string()), "".to_string(), "".to_string(), false) .unwrap(); // The block indices are set to zero because we have no ledger information @@ -1233,6 +1283,7 @@ mod tests { None, None, None, + false, ) .unwrap(); diff --git a/full-service/src/service/address.rs b/full-service/src/service/address.rs index 525b59b4e..b09721a78 100644 --- a/full-service/src/service/address.rs +++ b/full-service/src/service/address.rs @@ -220,7 +220,7 @@ mod tests { // Create an account. let account = service - .create_account(None, "".to_string(), "".to_string()) + .create_account(None, "".to_string(), "".to_string(), false) .unwrap(); assert_eq!(account.clone().next_subaddress_index(conn).unwrap(), 2); @@ -250,7 +250,14 @@ mod tests { // Create an account. let account = service - .import_view_only_account(&view_private_key, &spend_public_key, None, None, None) + .import_view_only_account( + &view_private_key, + &spend_public_key, + None, + None, + None, + false, + ) .unwrap(); assert_eq!(account.clone().next_subaddress_index(conn).unwrap(), 2); diff --git a/full-service/src/service/balance.rs b/full-service/src/service/balance.rs index ec51a4960..ac8c860fc 100644 --- a/full-service/src/service/balance.rs +++ b/full-service/src/service/balance.rs @@ -493,6 +493,7 @@ mod tests { None, "".to_string(), "".to_string(), + false, ) .expect("Could not import account entropy"); diff --git a/full-service/src/service/gift_code.rs b/full-service/src/service/gift_code.rs index 5bbd2bd65..7a5dcf8b3 100644 --- a/full-service/src/service/gift_code.rs +++ b/full-service/src/service/gift_code.rs @@ -544,7 +544,7 @@ where subaddress_index: None, }, None, - None, /* NOTE: Assuming for now that we will not support subaddress_to_spend_from + None, /* NOTE: Assuming for now that we will not support spend_subaddress * in gift_code construction */ )?; @@ -890,6 +890,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -1006,6 +1007,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); manually_sync_account( @@ -1071,6 +1073,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); diff --git a/full-service/src/service/models/tx_proposal.rs b/full-service/src/service/models/tx_proposal.rs index 16318870a..d2c0fdea1 100644 --- a/full-service/src/service/models/tx_proposal.rs +++ b/full-service/src/service/models/tx_proposal.rs @@ -489,6 +489,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -517,6 +518,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); diff --git a/full-service/src/service/receipt.rs b/full-service/src/service/receipt.rs index da66ad421..90524ab8f 100644 --- a/full-service/src/service/receipt.rs +++ b/full-service/src/service/receipt.rs @@ -387,6 +387,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -412,6 +413,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_addresses = service @@ -520,6 +522,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -545,6 +548,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_addresses = service @@ -645,6 +649,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -670,6 +675,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_addresses = service @@ -791,6 +797,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -816,6 +823,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_addresses = service diff --git a/full-service/src/service/sync.rs b/full-service/src/service/sync.rs index 0cad193e9..5c0c24289 100644 --- a/full-service/src/service/sync.rs +++ b/full-service/src/service/sync.rs @@ -501,6 +501,7 @@ mod tests { None, "".to_string(), "".to_string(), + false, ) .expect("Could not import account entropy"); diff --git a/full-service/src/service/transaction.rs b/full-service/src/service/transaction.rs index 9184e302a..9bd5fffdc 100644 --- a/full-service/src/service/transaction.rs +++ b/full-service/src/service/transaction.rs @@ -342,7 +342,7 @@ pub trait TransactionService { ///| `max_spendable_value` | The maximum amount for an input TXO selected for this transaction | | ///| `memo` | Memo for the transaction | | ///| `block_version` | The block version to build this transaction for. | Defaults to the network block version | - ///| `subaddress_to_spend_from` | The subaddress index to spend from. | (optional) ONLY use this parameter if you will ALWAYS use this parameter when spending, or else you may get unexpected balances because normal spending can pull any account txos no matter which subaddress they were received at | + ///| `spend_subaddress` | The subaddress index to spend from. | (optional) ONLY use this parameter if you will ALWAYS use this parameter when spending, or else you may get unexpected balances because normal spending can pull any account txos no matter which subaddress they were received at | /// #[allow(clippy::too_many_arguments)] fn build_transaction( @@ -356,7 +356,7 @@ pub trait TransactionService { max_spendable_value: Option, memo: TransactionMemo, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, ) -> Result; /// Build a transaction and sign it before submitting it to the network. @@ -374,7 +374,7 @@ pub trait TransactionService { ///| `max_spendable_value` | The maximum amount for an input TXO selected for this transaction | | ///| `memo` | Memo for the transaction | | ///| `block_version` | The block version to build this transaction for. | Defaults to the network block version | - ///| `subaddress_to_spend_from` | The subaddress index to spend from. | (optional) ONLY use this parameter if you will ALWAYS use this parameter when spending, or else you may get unexpected balances because normal spending can pull any account txos no matter which subaddress they were received at | + ///| `spend_subaddress` | The subaddress index to spend from. | | /// #[allow(clippy::too_many_arguments)] async fn build_and_sign_transaction( @@ -388,7 +388,7 @@ pub trait TransactionService { max_spendable_value: Option, memo: TransactionMemo, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, ) -> Result; /// Submits a pre-built TxProposal to the MobileCoin Consensus Network. @@ -423,7 +423,7 @@ pub trait TransactionService { ///| `max_spendable_value` | The maximum amount for an input TXO selected for this transaction | | ///| `memo` | Memo for the transaction | | ///| `block_version` | The block version to build this transaction for. | Defaults to the network block version | - ///| `subaddress_to_spend_from` | The subaddress index to spend from. | (optional) ONLY use this parameter if you will ALWAYS use this parameter when spending, or else you may get unexpected balances because normal spending can pull any account txos no matter which subaddress they were received at | + ///| `spend_subaddress` | The subaddress index to spend from. | | /// #[allow(clippy::too_many_arguments)] async fn build_sign_and_submit_transaction( @@ -438,7 +438,7 @@ pub trait TransactionService { comment: Option, memo: TransactionMemo, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, ) -> Result<(TransactionLog, AssociatedTxos, ValueMap, TxProposal), TransactionServiceError>; } @@ -459,7 +459,7 @@ where max_spendable_value: Option, memo: TransactionMemo, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, ) -> Result { validate_number_inputs(input_txo_ids.unwrap_or(&Vec::new()).len() as u64)?; validate_number_outputs(addresses_and_amounts.len() as u64)?; @@ -468,6 +468,15 @@ where let conn = pooled_conn.deref_mut(); exclusive_transaction(conn, |conn| { + if Account::get(&AccountID(account_id_hex.to_string()), conn)?.require_spend_subaddress + { + if spend_subaddress.is_none() { + return Err(TransactionServiceError::TransactionBuilder(WalletTransactionBuilderError::NullSubaddress( + "This account requires subaddresses be specified when spending. Please provide a subaddress to spend from.".to_string() + ))); + } + } + let mut builder = WalletTransactionBuilder::new( account_id_hex.to_string(), self.ledger_db.clone(), @@ -520,12 +529,10 @@ where if let Some(inputs) = input_txo_ids { builder.set_txos(conn, inputs)?; } else { - if let Some(subaddress) = subaddress_to_spend_from { + if let Some(subaddress) = spend_subaddress { let assigned_subaddress = AssignedSubaddress::get(&subaddress, conn)?; // Ensure the builder will filter to txos only from the specified subaddress - builder.set_subaddress_to_spend_from( - assigned_subaddress.subaddress_index as u64, - )?; + builder.set_spend_subaddress(assigned_subaddress.subaddress_index as u64)?; } let max_spendable = if let Some(msv) = max_spendable_value { @@ -553,7 +560,7 @@ where max_spendable_value: Option, memo: TransactionMemo, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, ) -> Result { let unsigned_tx_proposal = self.build_transaction( account_id_hex, @@ -565,7 +572,7 @@ where max_spendable_value, memo, block_version, - subaddress_to_spend_from, + spend_subaddress, )?; let mut pooled_conn = self.get_pooled_conn()?; @@ -654,7 +661,7 @@ where comment: Option, memo: TransactionMemo, block_version: Option, - subaddress_to_spend_from: Option, + spend_subaddress: Option, ) -> Result<(TransactionLog, AssociatedTxos, ValueMap, TxProposal), TransactionServiceError> { let tx_proposal = self @@ -668,7 +675,7 @@ where max_spendable_value, memo, block_version, - subaddress_to_spend_from, + spend_subaddress, ) .await?; @@ -754,6 +761,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -802,6 +810,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_account_key: AccountKey = @@ -924,6 +933,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -959,6 +969,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_account_key: AccountKey = @@ -1166,6 +1177,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -1228,6 +1240,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -1339,6 +1352,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -1374,6 +1388,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_account_key: AccountKey = @@ -1527,6 +1542,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -1562,6 +1578,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); let bob_account_key: AccountKey = @@ -1713,7 +1730,7 @@ mod tests { // transaction change arrives back to that subaddress. // This is a long, complicated test, so I'll list out the steps here for // readability: - // 1. Create exchange account + // 1. Create exchange account with require_subaddress // 2. Create subaddresses for Alice and Bob // 3. Add a block with a transaction for 100 MOB from some external source for // Alice. Balances [Alice: 100, Bob: 0] @@ -1731,7 +1748,7 @@ mod tests { // confirm it fails. [Alice -> 58 (+fee) -> Bob (Fails)] // 11. Confirm final balances [Alice: 58, Bob: 242] #[async_test_with_logger] - async fn test_send_transaction_with_subaddress_to_spend_from(logger: Logger) { + async fn test_send_transaction_with_spend_subaddress(logger: Logger) { let mut rng: StdRng = SeedableRng::from_seed([20u8; 32]); let known_recipients: Vec = Vec::new(); @@ -1747,6 +1764,7 @@ mod tests { Some("Exchange's Main Account".to_string()), "".to_string(), "".to_string(), + true, ) .unwrap(); let exchange_account_key: AccountKey = diff --git a/full-service/src/service/transaction_builder.rs b/full-service/src/service/transaction_builder.rs index 49cae246e..cb85917ce 100644 --- a/full-service/src/service/transaction_builder.rs +++ b/full-service/src/service/transaction_builder.rs @@ -101,7 +101,7 @@ impl WalletTransactionBuilder { } /// Sets the subaddress from which to restrict TXOs for spending. - pub fn set_subaddress_to_spend_from( + pub fn set_spend_subaddress( &mut self, subaddress_index: u64, ) -> Result<(), WalletTransactionBuilderError> { @@ -158,7 +158,7 @@ impl WalletTransactionBuilder { 0 }; - let subaddress_to_spend_from = + let spend_subaddress = if let Some(subaddress_index_to_spend_from) = self.subaddress_index_to_spend_from { let account = Account::get(&AccountID(self.account_id_hex.clone()), conn)?; let subaddress = account.public_address(subaddress_index_to_spend_from)?; @@ -172,7 +172,7 @@ impl WalletTransactionBuilder { &self.account_id_hex, target_value, max_spendable_value, - subaddress_to_spend_from.as_deref(), + spend_subaddress.as_deref(), *token_id, fee_value, conn, @@ -460,7 +460,7 @@ impl WalletTransactionBuilder { // Send the change back to the subaddress that is spending the inputs. // In the future, we may want to allow this to be a bit more configurable let change_address = account.public_address(subaddress_index_to_spend_from)?; - let reserved_subaddresses_for_spend_from_subaddress_mode = + let reserved_subaddresses_for_spend_subaddress_mode = ReservedSubaddresses::from_subaddress_index( &account.account_key()?, self.subaddress_index_to_spend_from, @@ -471,7 +471,7 @@ impl WalletTransactionBuilder { // inputs, with the DestinationMemo properly constructed as a Change Output let tx_out_context = transaction_builder.add_change_output( change_amount, - &reserved_subaddresses_for_spend_from_subaddress_mode, + &reserved_subaddresses_for_spend_subaddress_mode, &mut rng, )?; diff --git a/full-service/src/service/transaction_log.rs b/full-service/src/service/transaction_log.rs index d66531f74..5f5c54a1f 100644 --- a/full-service/src/service/transaction_log.rs +++ b/full-service/src/service/transaction_log.rs @@ -157,6 +157,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); diff --git a/full-service/src/service/txo.rs b/full-service/src/service/txo.rs index cb26784cf..abe2364f4 100644 --- a/full-service/src/service/txo.rs +++ b/full-service/src/service/txo.rs @@ -381,6 +381,7 @@ mod tests { Some("Alice's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); @@ -431,6 +432,7 @@ mod tests { Some("Bob's Main Account".to_string()), "".to_string(), "".to_string(), + false, ) .unwrap(); diff --git a/full-service/src/test_utils.rs b/full-service/src/test_utils.rs index 50eaab464..9b15f7579 100644 --- a/full-service/src/test_utils.rs +++ b/full-service/src/test_utils.rs @@ -565,6 +565,7 @@ pub fn random_account_with_seed_values( &format!("SeedAccount{}", rng.next_u32()), "".to_string(), "".to_string(), + false, wallet_db.get_pooled_conn().unwrap().deref_mut(), ) .unwrap();