diff --git a/Cargo.lock b/Cargo.lock index 4e5c967..9f283ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "satoshi-bridge" -version = "0.8.3" +version = "0.8.4" dependencies = [ "bitcoin", "bs58 0.5.1", diff --git a/contracts/satoshi-bridge/Cargo.toml b/contracts/satoshi-bridge/Cargo.toml index a9b5c2f..8e3ccd4 100644 --- a/contracts/satoshi-bridge/Cargo.toml +++ b/contracts/satoshi-bridge/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "satoshi-bridge" -version = "0.8.3" +version = "0.8.4" edition.workspace = true publish.workspace = true repository.workspace = true diff --git a/contracts/satoshi-bridge/src/psbt.rs b/contracts/satoshi-bridge/src/psbt.rs index 6a782ea..252168c 100644 --- a/contracts/satoshi-bridge/src/psbt.rs +++ b/contracts/satoshi-bridge/src/psbt.rs @@ -1,6 +1,8 @@ +use near_plugins::AccessControllable; + use crate::{ - env, network::Address, psbt_wrapper::PsbtWrapper, require, Amount, Contract, Event, ScriptBuf, - TxOut, U128, VUTXO, + env, network::Address, psbt_wrapper::PsbtWrapper, require, Amount, Contract, Event, Role, + ScriptBuf, TxOut, U128, VUTXO, }; impl Contract { @@ -169,12 +171,15 @@ impl Contract { withdraw_fee: u128, ) -> (usize, usize, u128, u128) { let config = self.internal_config(); - let input_amounts = vutxos.iter().map(|vutxo| u128::from(vutxo.get_amount())); - let min_input_amount = input_amounts.clone().min().unwrap(); - let total_input_amount = input_amounts.sum::(); + let (min_input_amount, total_input_amount) = vutxos + .iter() + .map(|vutxo| u128::from(vutxo.get_amount())) + .fold((u128::MAX, 0u128), |(min, sum), v| (min.min(v), sum + v)); let mut total_output_amount = 0; let mut actual_received_amounts = vec![]; let mut change_amounts = vec![]; + let signer_is_unrestricted = + self.acl_has_role(Role::UnrestrictedRelayer.into(), env::signer_account_id()); if !psbt.get_output().is_empty() { let target_address_script_pubkey = self @@ -192,8 +197,8 @@ impl Contract { "The change amount is too small" ); require!( - output_value < min_input_amount, - "The change amount must be less than all inputs" + signer_is_unrestricted || output_value < min_input_amount, + "The change amount must be less than the smallest input, or the caller must have the UnrestrictedRelayer role" ); change_amounts.push(output_value); } else { diff --git a/contracts/satoshi-bridge/tests/setup/context.rs b/contracts/satoshi-bridge/tests/setup/context.rs index 2cf1d15..0cc8511 100644 --- a/contracts/satoshi-bridge/tests/setup/context.rs +++ b/contracts/satoshi-bridge/tests/setup/context.rs @@ -518,6 +518,23 @@ impl Context { .await } + pub async fn bridge_acl_revoke_role( + &self, + caller: &str, + role: &str, + account_id: &AccountId, + ) -> Result { + self.get_account_by_name(caller) + .call(self.bridge_contract.id(), "acl_revoke_role") + .args_json(json!({ + "role": role, + "account_id": account_id + })) + .max_gas() + .transact() + .await + } + pub async fn bridge_add_super_admin( &self, caller: &str, diff --git a/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs b/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs index 817463c..a061219 100644 --- a/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs +++ b/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs @@ -1588,50 +1588,57 @@ async fn test_utxo_passive_management() { ); check!(context.set_passive_management_limit(0, u32::MAX)); let total_change = 500000 + 60000 - (withdraw_amount - withdraw_fee) as u64; + let make_withdraw_msg = || TokenReceiverMessage::Withdraw { + target_btc_address: TARGET_ADDRESS.to_string(), + input: vec![ + OutPoint { + txid: utxo500000[0].parse().unwrap(), + vout: utxo500000[1].parse().unwrap(), + }, + OutPoint { + txid: utxo60000[0].parse().unwrap(), + vout: utxo60000[1].parse().unwrap(), + }, + ], + output: vec![ + TxOut { + value: Amount::from_sat((withdraw_amount - btc_gas_fee - withdraw_fee) as u64), + script_pubkey: Address::parse(TARGET_ADDRESS, get_chain()) + .expect("Invalid btc address") + .script_pubkey() + .expect("Failed to get script pubkey"), + }, + TxOut { + value: Amount::from_sat(total_change), + script_pubkey: Address::parse(withdraw_change_address.as_str(), get_chain()) + .expect("Invalid btc address") + .script_pubkey() + .expect("Failed to get script pubkey"), + }, + ], + max_gas_fee: None, + chain_specific_data: None, + }; + + // Without the UnrestrictedRelayer role the `change < smallest input` rule still fires + // (here change = 360_000 + change_fee_adjustment, smallest input = 60_000). + check!(context.bridge_acl_revoke_role( + "root", + "UnrestrictedRelayer", + &context.get_account_by_name("alice").sdk_id() + )); check!( - context.do_withdraw( - "alice", - "bridge", - withdraw_amount, - TokenReceiverMessage::Withdraw { - target_btc_address: TARGET_ADDRESS.to_string(), - input: vec![ - OutPoint { - txid: utxo500000[0].parse().unwrap(), - vout: utxo500000[1].parse().unwrap(), - }, - OutPoint { - txid: utxo60000[0].parse().unwrap(), - vout: utxo60000[1].parse().unwrap(), - } - ], - output: vec![ - TxOut { - value: Amount::from_sat( - (withdraw_amount - btc_gas_fee - withdraw_fee) as u64 - ), - script_pubkey: Address::parse(TARGET_ADDRESS, get_chain()) - .expect("Invalid btc address") - .script_pubkey() - .expect("Failed to get script pubkey") - }, - TxOut { - value: Amount::from_sat(total_change), - script_pubkey: Address::parse( - withdraw_change_address.as_str(), - get_chain() - ) - .expect("Invalid btc address") - .script_pubkey() - .expect("Failed to get script pubkey") - } - ], - max_gas_fee: None, - chain_specific_data: None, - } - ), - "The change amount must be less than all inputs" + context.do_withdraw("alice", "bridge", withdraw_amount, make_withdraw_msg()), + "The change amount must be less than the smallest input" ); + + // Granting UnrestrictedRelayer back bypasses the rule and the same PSBT succeeds. + check!(context.bridge_acl_grant_role( + "root", + "UnrestrictedRelayer", + &context.get_account_by_name("alice").sdk_id() + )); + check!(context.do_withdraw("alice", "bridge", withdraw_amount, make_withdraw_msg())); } #[tokio::test]