diff --git a/core-contracts/contracts/helper/simple-ft-l2-no-deposit.clar b/core-contracts/contracts/helper/simple-ft-l2-no-deposit.clar new file mode 100644 index 000000000..409747f00 --- /dev/null +++ b/core-contracts/contracts/helper/simple-ft-l2-no-deposit.clar @@ -0,0 +1,61 @@ +(define-constant ERR_NOT_AUTHORIZED (err u1001)) + +(impl-trait 'ST000000000000000000002AMW42H.subnet.ft-trait) + +(define-fungible-token ft-token) + +;; get the token balance of owner +(define-read-only (get-balance (owner principal)) + (ok (ft-get-balance ft-token owner))) + +;; returns the total number of tokens +(define-read-only (get-total-supply) + (ok (ft-get-supply ft-token))) + +;; returns the token name +(define-read-only (get-name) + (ok "ft-token")) + +;; the symbol or "ticker" for this token +(define-read-only (get-symbol) + (ok "EXFT")) + +;; the number of decimals used +(define-read-only (get-decimals) + (ok u0)) + +;; Transfers tokens to a recipient +(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) + (begin + (asserts! (is-eq tx-sender sender) ERR_NOT_AUTHORIZED) + (try! (ft-transfer? ft-token amount sender recipient)) + (print memo) + (ok true) + ) +) + +(define-read-only (get-token-uri) + (ok none) +) + +(define-public (gift-tokens (amount uint) (recipient principal)) + (begin + (asserts! (is-eq tx-sender recipient) ERR_NOT_AUTHORIZED) + (ft-mint? ft-token amount recipient) + ) +) + +(impl-trait 'ST000000000000000000002AMW42H.subnet.subnet-asset) + +;; Called for deposit from the burnchain to the subnet +(define-public (deposit-from-burnchain (amount uint) (recipient principal)) + ERR_NOT_AUTHORIZED +) + +;; Called for withdrawal from the subnet to the burnchain +(define-public (burn-for-withdrawal (amount uint) (owner principal)) + (begin + (asserts! (is-eq tx-sender owner) ERR_NOT_AUTHORIZED) + (ft-burn? ft-token amount owner) + ) +) diff --git a/src/chainstate/stacks/db/blocks.rs b/src/chainstate/stacks/db/blocks.rs index bd6cc859e..e213cb835 100644 --- a/src/chainstate/stacks/db/blocks.rs +++ b/src/chainstate/stacks/db/blocks.rs @@ -751,6 +751,49 @@ impl Streamer for MicroblockStreamData { } } +enum Token { + Nft { id: u128 }, + Ft { amount: u128 }, +} + +fn make_withdrawal_event( + subnet_contract_id: QualifiedContractIdentifier, + sender: PrincipalData, + token: Token, + mainnet: bool, +) -> StacksTransactionEvent { + let (withdrawal_type, withdrawal_value) = match token { + Token::Nft { id } => ("nft", ("id".into(), Value::UInt(id))), + Token::Ft { amount } => ("ft", ("amount".into(), Value::UInt(amount))), + }; + + let values = vec![ + ("sender".into(), Value::Principal(sender)), + ( + "event".into(), + Value::string_ascii_from_bytes("withdraw".into()) + .expect("Supplied string was not ASCII"), + ), + ( + "type".into(), + Value::string_ascii_from_bytes(withdrawal_type.into()) + .expect("Supplied string was not ASCII"), + ), + ( + "asset-contract".into(), + Value::Principal(PrincipalData::Contract(subnet_contract_id)), + ), + withdrawal_value, + ]; + + StacksTransactionEvent::SmartContractEvent(SmartContractEventData { + key: (boot_code_id("subnet", mainnet), "print".into()), + value: TupleData::from_data(values) + .expect("Failed to create tuple data.") + .into(), + }) +} + impl StacksChainState { fn get_index_block_pathbuf(blocks_dir: &str, index_block_hash: &StacksBlockId) -> PathBuf { let block_hash_bytes = index_block_hash.as_bytes(); @@ -4612,7 +4655,7 @@ impl StacksChainState { clarity_tx.with_temporary_cost_tracker(LimitedCostTracker::new_free(), |clarity_tx| { operations .into_iter() - .filter_map(|deposit_stx_op| { + .map(|deposit_stx_op| { let DepositStxOp { txid: _, amount, @@ -4632,7 +4675,7 @@ impl StacksChainState { // deposits increment the STX liquidity in the layer 2 clarity_tx.increment_ustx_liquid_supply(amount); - Some(StacksTransactionReceipt { + StacksTransactionReceipt { transaction: TransactionOrigin::Burn(deposit_stx_op.into()), events: vec![result], result: Value::okay_true(), @@ -4642,7 +4685,7 @@ impl StacksChainState { execution_cost: ExecutionCost::zero(), microblock_header: None, tx_index: 0, - }) + } }) .collect() }); @@ -4677,7 +4720,7 @@ impl StacksChainState { None, &subnet_contract_id, DEPOSIT_FUNCTION_NAME, - &[Value::UInt(amount), Value::Principal(sender)], + &[Value::UInt(amount), Value::Principal(sender.clone())], |_, _| false, ) }); @@ -4687,17 +4730,40 @@ impl StacksChainState { .expect("BUG: cost declined between executions"); match result { - Ok((value, _, events)) => Some(StacksTransactionReceipt { - transaction: TransactionOrigin::Burn(deposit_ft_op.into()), - events, - result: value, - post_condition_aborted: false, - stx_burned: 0, - contract_analysis: None, - execution_cost, - microblock_header: None, - tx_index: 0, - }), + Ok((value, _, mut events)) => { + // Examine response to see if transaction failed + let deposit_op_failed = match &value { + Value::Response(r) => r.committed == false, + _ => { + // Public functions should always return type `Response` + error!("DepositFt op returned unexpected value"; "value" => %value); + false + } + }; + + // If deposit fails, create a withdrawal event to send NFT back to user + if deposit_op_failed { + info!("DepositFt op failed. Issue withdrawal tx"); + events.push(make_withdrawal_event( + subnet_contract_id, + sender, + Token::Ft { amount }, + mainnet, + )); + }; + + Some(StacksTransactionReceipt { + transaction: TransactionOrigin::Burn(deposit_ft_op.into()), + events, + result: value, + post_condition_aborted: false, + stx_burned: 0, + contract_analysis: None, + execution_cost, + microblock_header: None, + tx_index: 0, + }) + } Err(e) => { info!("DepositFt op processing error."; "error" => ?e, @@ -4710,37 +4776,6 @@ impl StacksChainState { .collect() } - fn make_nft_withdrawal_event( - subnet_contract_id: QualifiedContractIdentifier, - sender: PrincipalData, - id: u128, - mainnet: bool, - ) -> StacksTransactionEvent { - StacksTransactionEvent::SmartContractEvent(SmartContractEventData { - key: (boot_code_id("subnet", mainnet), "print".into()), - value: TupleData::from_data(vec![ - ( - "event".into(), - Value::string_ascii_from_bytes("withdraw".into()) - .expect("Supplied string was not ASCII"), - ), - ( - "type".into(), - Value::string_ascii_from_bytes("nft".into()) - .expect("Supplied string was not ASCII"), - ), - ("sender".into(), Value::Principal(sender)), - ("id".into(), Value::UInt(id)), - ( - "asset-contract".into(), - Value::Principal(PrincipalData::Contract(subnet_contract_id)), - ), - ]) - .expect("Failed to create tuple data.") - .into(), - }) - } - /// Process any deposit NFT operations that haven't been processed in this /// subnet fork yet. pub fn process_deposit_nft_ops( @@ -4748,7 +4783,6 @@ impl StacksChainState { operations: Vec, ) -> Vec { let mainnet = clarity_tx.config.mainnet; - let boot_addr = PrincipalData::from(boot_code_addr(mainnet)); let cost_so_far = clarity_tx.cost_so_far(); // return valid receipts operations @@ -4764,7 +4798,7 @@ impl StacksChainState { } = deposit_nft_op.clone(); let result = clarity_tx.connection().as_transaction(|tx| { tx.run_contract_call( - &boot_addr, + &boot_code_addr(mainnet).into(), None, &subnet_contract_id, DEPOSIT_FUNCTION_NAME, @@ -4783,8 +4817,8 @@ impl StacksChainState { let deposit_op_failed = match &value { Value::Response(r) => r.committed == false, _ => { - // TODO: Do anything for the case where `value` is not of type `Value::Response()`? - warn!("DepositNft op returned unexpected value"; "value" => %value); + // Public functions should always return type `Response` + error!("DepositNft op returned unexpected value"; "value" => %value); false } }; @@ -4792,10 +4826,10 @@ impl StacksChainState { // If deposit fails, create a withdrawal event to send NFT back to user if deposit_op_failed { info!("DepositNft op failed. Issue withdrawal tx"); - events.push(Self::make_nft_withdrawal_event( + events.push(make_withdrawal_event( subnet_contract_id, sender, - id, + Token::Nft { id }, mainnet, )); }; diff --git a/testnet/stacks-node/src/tests/l1_observer_test.rs b/testnet/stacks-node/src/tests/l1_observer_test.rs index 1b1d06344..3c5684880 100644 --- a/testnet/stacks-node/src/tests/l1_observer_test.rs +++ b/testnet/stacks-node/src/tests/l1_observer_test.rs @@ -387,7 +387,7 @@ fn l1_basic_listener_test() { #[test] fn l1_integration_test() { // running locally: - // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_integration_test + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::l1_integration_test if env::var("STACKS_NODE_TEST") != Ok("1".into()) { return; } @@ -458,7 +458,7 @@ fn l1_integration_test() { #[test] fn l1_deposit_and_withdraw_asset_integration_test() { // running locally: - // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_deposit_and_withdraw_asset_integration_test + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::l1_deposit_and_withdraw_asset_integration_test if env::var("STACKS_NODE_TEST") != Ok("1".into()) { return; } @@ -1338,7 +1338,7 @@ fn l1_deposit_and_withdraw_asset_integration_test() { #[test] fn l1_deposit_and_withdraw_stx_integration_test() { // running locally: - // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_deposit_stx_integration_test + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::l1_deposit_stx_integration_test if env::var("STACKS_NODE_TEST") != Ok("1".into()) { return; } @@ -1851,7 +1851,7 @@ fn l2_simple_contract_calls() { #[allow(unused_assignments)] fn nft_deposit_and_withdraw_integration_test() { // running locally: - // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace nft_deposit_and_withdraw_integration_test + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::nft_deposit_and_withdraw_integration_test if env::var("STACKS_NODE_TEST") != Ok("1".into()) { return; } @@ -2656,7 +2656,7 @@ fn nft_deposit_and_withdraw_integration_test() { #[allow(unused_assignments)] fn nft_deposit_failure_and_refund_integration_test() { // running locally: - // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace nft_deposit_failure_and_refund_integration_test + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::nft_deposit_failure_and_refund_integration_test if env::var("STACKS_NODE_TEST") != Ok("1".into()) { return; } @@ -2764,6 +2764,7 @@ fn nft_deposit_failure_and_refund_integration_test() { ); // Publish subnet contract for nft-token + // This contract is modified so that deposits to subnet always fail let subnet_nft_content = include_str!("../../../../core-contracts/contracts/helper/simple-nft-l2-no-deposit.clar"); let subnet_nft_publish = make_contract_publish( @@ -3159,7 +3160,7 @@ fn nft_deposit_failure_and_refund_integration_test() { #[allow(unused_assignments)] fn ft_deposit_and_withdraw_integration_test() { // running locally: - // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace ft_deposit_and_withdraw_integration_test + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::ft_deposit_and_withdraw_integration_test if env::var("STACKS_NODE_TEST") != Ok("1".into()) { return; } @@ -3663,6 +3664,508 @@ fn ft_deposit_and_withdraw_integration_test() { &siblings_val, &ft_withdrawal_entry.siblings, "Sibling hashes should match value returned via RPC" ); + + // TODO: call withdraw from unauthorized principal once leaf verification is added to the subnet contract + + let l1_withdraw_ft_tx = make_contract_call( + &MOCKNET_PRIVATE_KEY_1, + LAYER_1_CHAIN_ID_TESTNET, + l1_nonce, + 1_000_000, + &user_addr, + config.burnchain.contract_identifier.name.as_str(), + "withdraw-ft-asset", + &[ + Value::Principal(PrincipalData::Contract(ft_contract_id.clone())), + Value::UInt(4), + Value::Principal(user_addr.into()), + Value::UInt(0), + Value::UInt(withdrawal_height.into()), + Value::none(), + Value::some(Value::Principal(PrincipalData::Contract( + ft_contract_id.clone(), + ))) + .unwrap(), + Value::buff_from(root_hash.clone()).unwrap(), + Value::buff_from(ft_withdrawal_leaf_hash).unwrap(), + Value::list_from(ft_sib_data).unwrap(), + ], + ); + l1_nonce += 1; + + // Withdraw ft-token from subnet contract on L1 + submit_tx(&l1_rpc_origin, &l1_withdraw_ft_tx); + + // Sleep to give the run loop time to mine a block + wait_for_next_stacks_block(&sortition_db); + wait_for_next_stacks_block(&sortition_db); + + // Check that the user owns the tokens on the L1 chain now + let res = call_read_only( + &l1_rpc_origin, + &user_addr, + "simple-ft", + "get-balance", + vec![Value::Principal(user_addr.into()).serialize()], + ); + assert!(res.get("cause").is_none()); + assert!(res["okay"].as_bool().unwrap()); + let result = res["result"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap() + .to_string(); + let amount = Value::deserialize( + &result, + &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), + ); + assert_eq!(amount, Value::okay(Value::UInt(5)).unwrap()); + + // Check that the subnet contract no longer owns any tokens. It should have + // transferred the 1 that it had, then minted the remaining 3. + let res = call_read_only( + &l1_rpc_origin, + &user_addr, + "simple-ft", + "get-balance", + vec![subnet_contract_principal.serialize()], + ); + assert!(res.get("cause").is_none()); + assert!(res["okay"].as_bool().unwrap()); + let result = res["result"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap() + .to_string(); + let amount = Value::deserialize( + &result, + &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), + ); + assert_eq!(amount, Value::okay(Value::UInt(0)).unwrap()); + + termination_switch.store(false, Ordering::SeqCst); + stacks_l1_controller.kill_process(); + run_loop_thread.join().expect("Failed to join run loop."); +} + +/// This integration test verifies that: +/// (a) When a FT deposit to L2 fails user can unlock it from L1 contract +#[test] +#[allow(unused_assignments)] +fn ft_deposit_failure_and_refund_integration_test() { + // running locally: + // STACKS_BASE_DIR=~/devel/stacks-blockchain/target/release/stacks-node STACKS_NODE_TEST=1 cargo test --workspace l1_observer_test::ft_deposit_failure_and_refund_integration_test + if env::var("STACKS_NODE_TEST") != Ok("1".into()) { + return; + } + + // Start Stacks L1. + let l1_toml_file = "../../contrib/conf/stacks-l1-mocknet.toml"; + let l1_rpc_origin = "http://127.0.0.1:20443"; + let trait_standards_contract_name = "trait-standards"; + + // Start the L2 run loop. + let mut config = super::new_test_conf(); + config.node.mining_key = Some(MOCKNET_PRIVATE_KEY_2.clone()); + let miner_account = to_addr(&MOCKNET_PRIVATE_KEY_2); + let user_addr = to_addr(&MOCKNET_PRIVATE_KEY_1); + config.add_initial_balance(user_addr.to_string(), 10000000); + config.add_initial_balance(miner_account.to_string(), 10000000); + + config.burnchain.first_burn_header_height = 1; + config.burnchain.chain = "stacks_layer_1".to_string(); + config.burnchain.rpc_ssl = false; + config.burnchain.rpc_port = 20443; + config.burnchain.peer_host = "127.0.0.1".into(); + config.node.wait_time_for_microblocks = 10_000; + config.node.rpc_bind = "127.0.0.1:30443".into(); + config.node.p2p_bind = "127.0.0.1:30444".into(); + let l2_rpc_origin = format!("http://{}", &config.node.rpc_bind); + let mut l2_nonce = 0; + + config.burnchain.contract_identifier = + QualifiedContractIdentifier::new(user_addr.into(), "subnet-controller".into()); + + config.node.miner = true; + + config.events_observers.push(EventObserverConfig { + endpoint: format!("localhost:{}", test_observer::EVENT_OBSERVER_PORT), + events_keys: vec![EventKeyType::AnyEvent], + }); + + test_observer::spawn(); + + let mut run_loop = neon::RunLoop::new(config.clone()); + let termination_switch = run_loop.get_termination_switch(); + let run_loop_thread = thread::spawn(move || run_loop.start(None, 0)); + + // Give the run loop time to start. + thread::sleep(Duration::from_millis(2_000)); + + // The burnchain should have registered what the listener recorded. + let burnchain = Burnchain::new(&config.get_burn_db_path(), &config.burnchain.chain).unwrap(); + let (sortition_db, burndb) = burnchain.open_db(true).unwrap(); + + let mut stacks_l1_controller = StacksL1Controller::new(l1_toml_file.to_string(), true); + let _stacks_res = stacks_l1_controller + .start_process() + .expect("stacks l1 controller didn't start"); + let mut l1_nonce = 0; + + // Sleep to give the L1 chain time to start + thread::sleep(Duration::from_millis(10_000)); + wait_for_target_l1_block(&sortition_db, MOCKNET_EPOCH_2_1); + + l1_nonce = publish_subnet_contracts_to_l1( + l1_nonce, + &config, + miner_account.clone().into(), + user_addr.clone().into(), + ); + + // Publish a simple ft onto L1 + let ft_content = include_str!("../../../../core-contracts/contracts/helper/simple-ft.clar"); + let ft_publish = make_contract_publish( + &MOCKNET_PRIVATE_KEY_1, + LAYER_1_CHAIN_ID_TESTNET, + l1_nonce, + 1_000_000, + "simple-ft", + &ft_content, + ); + l1_nonce += 1; + let ft_contract_name = ContractName::from("simple-ft"); + let ft_contract_id = QualifiedContractIdentifier::new(user_addr.into(), ft_contract_name); + + submit_tx(l1_rpc_origin, &ft_publish); + + println!("Submitted ft and Subnet contracts onto L1!"); + + // Sleep to give the run loop time to listen to blocks, + // and start mining L2 blocks + wait_for_next_stacks_block(&sortition_db); + wait_for_next_stacks_block(&sortition_db); + + let tip = burndb + .get_canonical_chain_tip() + .expect("couldn't get chain tip"); + + // Ensure that the tip height has moved beyond height 0. + // We check that we have moved past 3 just to establish we are reliably getting blocks. + assert!(tip.block_height > 3); + + // test the miner's nonce has incremented: this shows that L2 blocks have + // been mined (because the coinbase transactions bump the miner's nonce) + let account = get_account(&l2_rpc_origin, &miner_account); + assert!( + account.nonce >= 2, + "Miner should have produced at least 2 coinbase transactions" + ); + + // Publish subnet contract for ft-token + // This contract is modified so that deposits to subnet always fail + let subnet_simple_ft = + include_str!("../../../../core-contracts/contracts/helper/simple-ft-l2-no-deposit.clar"); + let subnet_ft_publish = make_contract_publish( + &MOCKNET_PRIVATE_KEY_1, + config.node.chain_id, + l2_nonce, + 1_000_000, + "simple-ft", + subnet_simple_ft, + ); + l2_nonce += 1; + let subnet_ft_contract_id = + QualifiedContractIdentifier::new(user_addr.into(), ContractName::from("simple-ft")); + + submit_tx(&l2_rpc_origin, &subnet_ft_publish); + + // Sleep to give the run loop time to mine a block + wait_for_next_stacks_block(&sortition_db); + wait_for_next_stacks_block(&sortition_db); + + // Register the contract with the subnet + let subnet_setup_ft_tx = make_contract_call( + &MOCKNET_PRIVATE_KEY_1, + LAYER_1_CHAIN_ID_TESTNET, + l1_nonce, + 1_000_000, + &user_addr, + config.burnchain.contract_identifier.name.as_str(), + "register-new-ft-contract", + &[ + Value::Principal(PrincipalData::Contract(ft_contract_id.clone())), + Value::Principal(PrincipalData::Contract(subnet_ft_contract_id.clone())), + ], + ); + l1_nonce += 1; + + submit_tx(l1_rpc_origin, &subnet_setup_ft_tx); + + // Mint 10 ft-tokens for user on L1 chain + let l1_mint_ft_tx = make_contract_call( + &MOCKNET_PRIVATE_KEY_1, + LAYER_1_CHAIN_ID_TESTNET, + l1_nonce, + 1_000_000, + &user_addr, + "simple-ft", + "gift-tokens", + &[Value::UInt(10), Value::Principal(user_addr.into())], + ); + l1_nonce += 1; + + submit_tx(l1_rpc_origin, &l1_mint_ft_tx); + + // Sleep to give the run loop time to mine a block + wait_for_next_stacks_block(&sortition_db); + wait_for_next_stacks_block(&sortition_db); + + // Check that user has 0 tokens on L2 + let res = call_read_only( + &l2_rpc_origin, + &user_addr, + "simple-ft", + "get-balance", + vec![Value::Principal(user_addr.into()).serialize()], + ); + assert!(res.get("cause").is_none()); + assert!(res["okay"].as_bool().unwrap()); + let result = res["result"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap() + .to_string(); + let amount = Value::deserialize( + &result, + &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), + ); + assert_eq!(amount, Value::okay(Value::UInt(0)).unwrap()); + + let l1_deposit_ft_tx = make_contract_call( + &MOCKNET_PRIVATE_KEY_1, + LAYER_1_CHAIN_ID_TESTNET, + l1_nonce, + 1_000_000, + &user_addr, + config.burnchain.contract_identifier.name.as_str(), + "deposit-ft-asset", + &[ + Value::Principal(PrincipalData::Contract(ft_contract_id.clone())), + Value::UInt(3), + Value::Principal(user_addr.into()), + Value::none(), + ], + ); + l1_nonce += 1; + + // Deposit 3 ft-tokens into subnet contract on L1 + submit_tx(&l1_rpc_origin, &l1_deposit_ft_tx); + + // Sleep to give the run loop time to mine a block + wait_for_next_stacks_block(&sortition_db); + wait_for_next_stacks_block(&sortition_db); + + // Check that the deposit failed and user still has 0 tokens on L2 + let res = call_read_only( + &l2_rpc_origin, + &user_addr, + "simple-ft", + "get-balance", + vec![Value::Principal(user_addr.into()).serialize()], + ); + assert!(res.get("cause").is_none()); + assert!(res["okay"].as_bool().unwrap()); + let result = res["result"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap() + .to_string(); + let amount = Value::deserialize( + &result, + &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), + ); + assert_eq!(amount, Value::okay(Value::UInt(0)).unwrap()); + + // Check that the user now only owns 7 ft on the L1. Contract should own the rest + let res = call_read_only( + &l1_rpc_origin, + &user_addr, + "simple-ft", + "get-balance", + vec![Value::Principal(user_addr.into()).serialize()], + ); + assert!(res.get("cause").is_none()); + assert!(res["okay"].as_bool().unwrap()); + let result = res["result"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap() + .to_string(); + let amount = Value::deserialize( + &result, + &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), + ); + assert_eq!(amount, Value::okay(Value::UInt(7)).unwrap()); + + // Check that the subnet contract owns 3 ft on the L1 + let subnet_contract_principal = Value::Principal(PrincipalData::Contract( + config.burnchain.contract_identifier.clone(), + )); + let res = call_read_only( + &l1_rpc_origin, + &user_addr, + "simple-ft", + "get-balance", + vec![subnet_contract_principal.serialize()], + ); + assert!(res.get("cause").is_none()); + assert!(res["okay"].as_bool().unwrap()); + let result = res["result"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap() + .to_string(); + let amount = Value::deserialize( + &result, + &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), + ); + assert_eq!(amount, Value::okay(Value::UInt(3)).unwrap()); + + // A withdrawal event should have been automatically created upon failure, find it + let block_data = test_observer::get_blocks(); + let mut withdraw_events = filter_map_events(&block_data, |height, event| { + let ev_type = event.get("type").unwrap().as_str().unwrap(); + if ev_type == "contract_event" { + let contract_event = event.get("contract_event").unwrap(); + let contract_identifier = contract_event + .get("contract_identifier") + .unwrap() + .as_str() + .unwrap(); + let topic = contract_event.get("topic").unwrap().as_str().unwrap(); + match (contract_identifier, topic) { + ("ST000000000000000000002AMW42H.subnet", "print") => { + let value: Value = + serde_json::from_value(contract_event.get("value").unwrap().clone()) + .unwrap(); + let data_map = value.expect_tuple(); + if let Ok(event_type) = data_map.get("event") { + if event_type.clone().expect_ascii() == "withdraw" { + if data_map.get("type").unwrap().clone().expect_ascii() == "ft" { + return Some((height, data_map.clone())); + } + } + } + return None; + } + _ => None, + } + } else { + None + } + }); + assert_eq!(withdraw_events.len(), 1); + + let (withdrawal_height, _withdrawal) = withdraw_events.pop().unwrap(); + + let ft_withdrawal_entry = get_ft_withdrawal_entry( + &l2_rpc_origin, + withdrawal_height, + &user_addr, + 0, + QualifiedContractIdentifier::new(user_addr.into(), ContractName::from("simple-ft")), + 3, + ); + + // Create the withdrawal merkle tree by mocking the ft withdraw event (if the root hash of + // this constructed merkle tree is not identical to the root hash published by the subnet node, + // then the test will fail). + let mut spending_condition = TransactionSpendingCondition::new_singlesig_p2pkh( + StacksPublicKey::from_private(&MOCKNET_PRIVATE_KEY_1), + ) + .expect("Failed to create p2pkh spending condition from public key."); + spending_condition.set_nonce(l2_nonce - 1); + spending_condition.set_tx_fee(1000); + let auth = TransactionAuth::Standard(spending_condition); + let mut ft_withdraw_event = + StacksTransactionEvent::SmartContractEvent(SmartContractEventData { + key: (boot_code_id("subnet", false), "print".into()), + value: Value::Tuple( + TupleData::from_data(vec![ + ( + "type".into(), + Value::string_ascii_from_bytes("ft".to_string().into_bytes()).unwrap(), + ), + ( + "asset-contract".into(), + Value::Principal(PrincipalData::Contract( + QualifiedContractIdentifier::new( + user_addr.into(), + ContractName::from("simple-ft"), + ), + )), + ), + ( + "sender".into(), + Value::Principal(PrincipalData::Standard(user_addr.into())), + ), + ("amount".into(), Value::UInt(3)), + ]) + .expect("Failed to create tuple data."), + ), + }); + let withdrawal_receipt = StacksTransactionReceipt { + transaction: TransactionOrigin::Stacks(StacksTransaction::new( + TransactionVersion::Testnet, + auth.clone(), + TransactionPayload::Coinbase(CoinbasePayload([0u8; 32])), + )), + events: vec![ft_withdraw_event.clone()], + post_condition_aborted: false, + result: Value::err_none(), + stx_burned: 0, + contract_analysis: None, + execution_cost: ExecutionCost::zero(), + microblock_header: None, + tx_index: 0, + }; + let withdrawal_tree = + create_withdrawal_merkle_tree(&mut vec![withdrawal_receipt], withdrawal_height); + let root_hash = withdrawal_tree.root().as_bytes().to_vec(); + + let ft_withdrawal_key = + generate_key_from_event(&mut ft_withdraw_event, 0, withdrawal_height).unwrap(); + let ft_withdrawal_key_bytes = convert_withdrawal_key_to_bytes(&ft_withdrawal_key); + let ft_withdrawal_leaf_hash = + MerkleTree::::get_leaf_hash(ft_withdrawal_key_bytes.as_slice()) + .as_bytes() + .to_vec(); + let ft_path = withdrawal_tree.path(&ft_withdrawal_key_bytes).unwrap(); + + let mut ft_sib_data = Vec::new(); + for (_i, sib) in ft_path.iter().enumerate() { + let sib_hash = Value::buff_from(sib.hash.as_bytes().to_vec()).unwrap(); + // the sibling's side is the opposite of what PathOrder is set to + let sib_is_left = Value::Bool(sib.order == MerklePathOrder::Right); + let curr_sib_data = vec![ + (ClarityName::from("hash"), sib_hash), + (ClarityName::from("is-left-side"), sib_is_left), + ]; + let sib_tuple = Value::Tuple(TupleData::from_data(curr_sib_data).unwrap()); + ft_sib_data.push(sib_tuple); + } + + let root_hash_val = Value::buff_from(root_hash.clone()).unwrap(); + let leaf_hash_val = Value::buff_from(ft_withdrawal_leaf_hash.clone()).unwrap(); + let siblings_val = Value::list_from(ft_sib_data.clone()).unwrap(); + assert_eq!( &root_hash_val, &ft_withdrawal_entry.root_hash, "Root hash should match value returned via RPC" @@ -3688,7 +4191,7 @@ fn ft_deposit_and_withdraw_integration_test() { "withdraw-ft-asset", &[ Value::Principal(PrincipalData::Contract(ft_contract_id.clone())), - Value::UInt(4), + Value::UInt(3), Value::Principal(user_addr.into()), Value::UInt(0), Value::UInt(withdrawal_height.into()), @@ -3731,10 +4234,10 @@ fn ft_deposit_and_withdraw_integration_test() { &result, &TypeSignature::ResponseType(Box::new((TypeSignature::UIntType, TypeSignature::UIntType))), ); - assert_eq!(amount, Value::okay(Value::UInt(5)).unwrap()); + assert_eq!(amount, Value::okay(Value::UInt(10)).unwrap()); - // Check that the subnet contract no longer owns any tokens. It should have - // transferred the 1 that it had, then minted the remaining 3. + // Check that the subnet contract no longer owns any tokens + // It should have transferred the 3 that it had let res = call_read_only( &l1_rpc_origin, &user_addr,