diff --git a/api-server/api-server-common/src/storage/impls/mod.rs b/api-server/api-server-common/src/storage/impls/mod.rs index f5eedd806..da84c75fc 100644 --- a/api-server/api-server-common/src/storage/impls/mod.rs +++ b/api-server/api-server-common/src/storage/impls/mod.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -pub const CURRENT_STORAGE_VERSION: u32 = 20; +pub const CURRENT_STORAGE_VERSION: u32 = 21; pub mod in_memory; pub mod postgres; diff --git a/api-server/api-server-common/src/storage/storage_api/mod.rs b/api-server/api-server-common/src/storage/storage_api/mod.rs index d3e8b2249..0401cc7ad 100644 --- a/api-server/api-server-common/src/storage/storage_api/mod.rs +++ b/api-server/api-server-common/src/storage/storage_api/mod.rs @@ -464,6 +464,7 @@ pub struct FungibleTokenData { pub is_locked: bool, pub frozen: IsTokenFrozen, pub authority: Destination, + pub next_nonce: AccountNonce, } impl FungibleTokenData { @@ -481,38 +482,45 @@ impl FungibleTokenData { } } - pub fn mint_tokens(mut self, amount: Amount) -> Self { + pub fn mint_tokens(mut self, amount: Amount, nonce: AccountNonce) -> Self { self.circulating_supply = (self.circulating_supply + amount).expect("no overflow"); + self.next_nonce = nonce.increment().expect("no overflow"); self } - pub fn unmint_tokens(mut self, amount: Amount) -> Self { + pub fn unmint_tokens(mut self, amount: Amount, nonce: AccountNonce) -> Self { self.circulating_supply = (self.circulating_supply - amount).expect("no underflow"); + self.next_nonce = nonce.increment().expect("no overflow"); self } - pub fn freeze(mut self, is_token_unfreezable: IsTokenUnfreezable) -> Self { + pub fn freeze(mut self, is_token_unfreezable: IsTokenUnfreezable, nonce: AccountNonce) -> Self { self.frozen = IsTokenFrozen::Yes(is_token_unfreezable); + self.next_nonce = nonce.increment().expect("no overflow"); self } - pub fn unfreeze(mut self) -> Self { + pub fn unfreeze(mut self, nonce: AccountNonce) -> Self { self.frozen = IsTokenFrozen::No(IsTokenFreezable::Yes); + self.next_nonce = nonce.increment().expect("no overflow"); self } - pub fn lock(mut self) -> Self { + pub fn lock(mut self, nonce: AccountNonce) -> Self { self.is_locked = true; + self.next_nonce = nonce.increment().expect("no overflow"); self } - pub fn change_authority(mut self, authority: Destination) -> Self { + pub fn change_authority(mut self, authority: Destination, nonce: AccountNonce) -> Self { self.authority = authority; + self.next_nonce = nonce.increment().expect("no overflow"); self } - pub fn change_metadata_uri(mut self, metadata_uri: Vec) -> Self { + pub fn change_metadata_uri(mut self, metadata_uri: Vec, nonce: AccountNonce) -> Self { self.metadata_uri = metadata_uri; + self.next_nonce = nonce.increment().expect("no overflow"); self } } diff --git a/api-server/scanner-lib/src/blockchain_state/mod.rs b/api-server/scanner-lib/src/blockchain_state/mod.rs index 76285680a..d9a4e10b4 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -1032,12 +1032,12 @@ async fn update_tables_from_transaction_inputs( for input in inputs { match input { - TxInput::AccountCommand(_, cmd) => match cmd { + TxInput::AccountCommand(nonce, cmd) => match cmd { AccountCommand::MintTokens(token_id, amount) => { let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.mint_tokens(*amount); + let issuance = issuance.mint_tokens(*amount, *nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; increase_statistic_amount( db_tx, @@ -1072,7 +1072,7 @@ async fn update_tables_from_transaction_inputs( let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.unmint_tokens(total_burned); + let issuance = issuance.unmint_tokens(total_burned, *nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; let amount = chain_config.token_supply_change_fee(block_height); increase_statistic_amount( @@ -1096,7 +1096,7 @@ async fn update_tables_from_transaction_inputs( let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.freeze(*is_unfreezable); + let issuance = issuance.freeze(*is_unfreezable, *nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; let amount = chain_config.token_freeze_fee(block_height); increase_statistic_amount( @@ -1120,7 +1120,7 @@ async fn update_tables_from_transaction_inputs( let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.unfreeze(); + let issuance = issuance.unfreeze(*nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; let amount = chain_config.token_freeze_fee(block_height); increase_statistic_amount( @@ -1144,7 +1144,7 @@ async fn update_tables_from_transaction_inputs( let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.lock(); + let issuance = issuance.lock(*nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; let amount = chain_config.token_supply_change_fee(block_height); increase_statistic_amount( @@ -1168,7 +1168,7 @@ async fn update_tables_from_transaction_inputs( let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.change_authority(destination.clone()); + let issuance = issuance.change_authority(destination.clone(), *nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; let amount = chain_config.token_change_authority_fee(block_height); increase_statistic_amount( @@ -1192,7 +1192,7 @@ async fn update_tables_from_transaction_inputs( let issuance = db_tx.get_fungible_token_issuance(*token_id).await?.expect("must exist"); - let issuance = issuance.change_metadata_uri(metadata_uri.clone()); + let issuance = issuance.change_metadata_uri(metadata_uri.clone(), *nonce); db_tx.set_fungible_token_data(*token_id, block_height, issuance).await?; let amount = chain_config.token_change_metadata_uri_fee(); increase_statistic_amount( @@ -1549,6 +1549,7 @@ async fn update_tables_from_transaction_outputs( is_locked: false, frozen: IsTokenFrozen::No(issuance.is_freezable), authority: issuance.authority.clone(), + next_nonce: AccountNonce::new(0), }, }; db_tx.set_fungible_token_issuance(token_id, block_height, issuance).await?; diff --git a/api-server/stack-test-suite/tests/v2/token.rs b/api-server/stack-test-suite/tests/v2/token.rs index 1ab8b1637..8d3b788e0 100644 --- a/api-server/stack-test-suite/tests/v2/token.rs +++ b/api-server/stack-test-suite/tests/v2/token.rs @@ -22,6 +22,7 @@ use common::{ IsTokenFreezable, IsTokenFrozen, TokenId, TokenIssuance, TokenIssuanceV1, TokenTotalSupply, }, + AccountNonce, }, primitives::H256, }; @@ -136,6 +137,7 @@ async fn ok(#[case] seed: Seed) { is_locked: false, frozen: IsTokenFrozen::No(token_issuance.is_freezable), authority: token_issuance.authority.clone(), + next_nonce: AccountNonce::new(0), }; _ = tx.send([( @@ -153,6 +155,7 @@ async fn ok(#[case] seed: Seed) { "frozen": false, "is_token_freezable": false, "is_token_unfreezable": None::, + "next_nonce": token_data.next_nonce, }), )]); diff --git a/api-server/stack-test-suite/tests/v2/transaction.rs b/api-server/stack-test-suite/tests/v2/transaction.rs index 43ef4dec0..e0e651b27 100644 --- a/api-server/stack-test-suite/tests/v2/transaction.rs +++ b/api-server/stack-test-suite/tests/v2/transaction.rs @@ -196,7 +196,7 @@ async fn multiple_tx_in_same_block(#[case] seed: Seed) { "is_replaceable": transaction.is_replaceable(), "flags": transaction.flags(), "inputs": transaction.inputs().iter().zip(utxos).map(|(inp, utxo)| json!({ - "input": tx_input_to_json(inp, &chain_config), + "input": tx_input_to_json(inp, &TokenDecimals::Single(None), &chain_config), "utxo": utxo.as_ref().map(|txo| txoutput_to_json(txo, &chain_config, &TokenDecimals::Single(None))), })).collect::>(), "outputs": transaction.outputs() @@ -342,7 +342,7 @@ async fn ok(#[case] seed: Seed) { "is_replaceable": transaction.is_replaceable(), "flags": transaction.flags(), "inputs": transaction.inputs().iter().zip(utxos).map(|(inp, utxo)| json!({ - "input": tx_input_to_json(inp, &chain_config), + "input": tx_input_to_json(inp, &TokenDecimals::Single(None), &chain_config), "utxo": utxo.as_ref().map(|txo| txoutput_to_json(txo, &chain_config, &TokenDecimals::Single(None))), })).collect::>(), "outputs": transaction.outputs() @@ -434,3 +434,186 @@ async fn ok(#[case] seed: Seed) { task.abort(); } + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test] +async fn mint_tokens(#[case] seed: Seed) { + use chainstate_test_framework::empty_witness; + use common::chain::{ + make_token_id, + tokens::{TokenIssuance, TokenTotalSupply}, + AccountCommand, AccountNonce, UtxoOutPoint, + }; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (tx, rx) = tokio::sync::oneshot::channel(); + + let task = tokio::spawn(async move { + let web_server_state = { + let mut rng = make_seedable_rng(seed); + let chain_config = create_unit_test_config(); + + let chainstate_blocks = { + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config(chain_config.clone()) + .build(); + + let token_issuance_fee = + tf.chainstate.get_chain_config().fungible_token_issuance_fee(); + + let issuance = test_utils::nft_utils::random_token_issuance_v1( + tf.chain_config(), + Destination::AnyoneCanSpend, + &mut rng, + ); + let amount_to_mint = match issuance.total_supply { + TokenTotalSupply::Fixed(limit) => { + Amount::from_atoms(rng.gen_range(1..=limit.into_atoms())) + } + TokenTotalSupply::Lockable | TokenTotalSupply::Unlimited => { + Amount::from_atoms(rng.gen_range(100..1000)) + } + }; + let mint_amount_decimal = + amount_to_mint.into_fixedpoint_str(issuance.number_of_decimals); + + let genesis_outpoint = UtxoOutPoint::new(tf.best_block_id().into(), 0); + let genesis_coins = chainstate_test_framework::get_output_value( + tf.chainstate.utxo(&genesis_outpoint).unwrap().unwrap().output(), + ) + .unwrap() + .coin_amount() + .unwrap(); + let coins_after_issue = (genesis_coins - token_issuance_fee).unwrap(); + + // Issue token + let tx1 = TransactionBuilder::new() + .add_input(genesis_outpoint.into(), empty_witness(&mut rng)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_after_issue), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( + issuance, + )))) + .build(); + let token_id = make_token_id( + &chain_config, + BlockHeight::new(1), + tx1.transaction().inputs(), + ) + .unwrap(); + let tx1_id = tx1.transaction().get_id(); + let block1 = tf.make_block_builder().add_transaction(tx1).build(&mut rng); + + tf.process_block(block1.clone(), chainstate::BlockSource::Local).unwrap(); + + // Mint tokens + let token_supply_change_fee = + tf.chainstate.get_chain_config().token_supply_change_fee(BlockHeight::zero()); + let coins_after_mint = (coins_after_issue - token_supply_change_fee).unwrap(); + + let tx2 = TransactionBuilder::new() + .add_input( + TxInput::from_command( + AccountNonce::new(0), + AccountCommand::MintTokens(token_id, amount_to_mint), + ), + empty_witness(&mut rng), + ) + .add_input( + TxInput::from_utxo(tx1_id.into(), 0), + empty_witness(&mut rng), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_after_mint), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, amount_to_mint), + Destination::AnyoneCanSpend, + )) + .build(); + + let tx2_id = tx2.transaction().get_id(); + let block2 = tf.make_block_builder().add_transaction(tx2).build(&mut rng); + + tf.process_block(block2.clone(), chainstate::BlockSource::Local).unwrap(); + + _ = tx.send(( + tx2_id.to_hash().encode_hex::(), + mint_amount_decimal, + Address::new(&chain_config, token_id).expect("no error").into_string(), + )); + + vec![block1, block2] + }; + + let storage = { + let mut storage = TransactionalApiServerInMemoryStorage::new(&chain_config); + + let mut db_tx = storage.transaction_rw().await.unwrap(); + db_tx.reinitialize_storage(&chain_config).await.unwrap(); + db_tx.commit().await.unwrap(); + + storage + }; + + let chain_config = Arc::new(chain_config); + let mut local_node = BlockchainState::new(Arc::clone(&chain_config), storage); + local_node.scan_genesis(chain_config.genesis_block()).await.unwrap(); + local_node.scan_blocks(BlockHeight::new(0), chainstate_blocks).await.unwrap(); + + ApiServerWebServerState { + db: Arc::new(local_node.storage().clone_storage().await), + chain_config: Arc::clone(&chain_config), + rpc: Arc::new(DummyRPC {}), + cached_values: Arc::new(CachedValues { + feerate_points: RwLock::new((get_time(), vec![])), + }), + time_getter: Default::default(), + } + }; + + web_server(listener, web_server_state, true).await + }); + + let (transaction_id, mint_amount, token_id) = rx.await.unwrap(); + let url = format!("/api/v2/transaction/{transaction_id}"); + + // Given that the listener port is open, this will block until a + // response is made (by the web server, which takes the listener + // over) + let response = reqwest::get(format!("http://{}:{}{url}", addr.ip(), addr.port())) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + let body = body.as_object().unwrap(); + + let inputs = body.get("inputs").unwrap().as_array().unwrap(); + assert_eq!(inputs.len(), 2); + let mint_inp = inputs.first().unwrap().as_object().unwrap().get("input").unwrap(); + assert_eq!( + mint_inp.as_object().unwrap().get("command").unwrap().as_str().unwrap(), + "MintTokens" + ); + assert_eq!( + mint_inp.as_object().unwrap().get("token_id").unwrap().as_str().unwrap(), + token_id, + ); + let amount = mint_inp.as_object().unwrap().get("amount").unwrap().as_object().unwrap(); + assert_eq!( + amount.get("decimal").unwrap().as_str().unwrap(), + mint_amount + ); + + task.abort(); +} diff --git a/api-server/storage-test-suite/src/basic.rs b/api-server/storage-test-suite/src/basic.rs index f7d9b7a3c..832593e52 100644 --- a/api-server/storage-test-suite/src/basic.rs +++ b/api-server/storage-test-suite/src/basic.rs @@ -1203,6 +1203,7 @@ where is_locked: false, frozen: IsTokenFrozen::No(IsTokenFreezable::Yes), authority: random_destination.clone(), + next_nonce: AccountNonce::new(0), }; let mut db_tx = storage.transaction_rw().await.unwrap(); @@ -1227,8 +1228,11 @@ where let locked_token_data = token_data .clone() - .mint_tokens(Amount::from_atoms(rng.gen_range(1..1000))) - .lock(); + .mint_tokens( + Amount::from_atoms(rng.gen_range(1..1000)), + token_data.next_nonce, + ) + .lock(token_data.next_nonce.increment().unwrap()); db_tx .set_fungible_token_data( @@ -1265,8 +1269,9 @@ where let (_, pk2) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); let random_destination2 = Destination::PublicKeyHash(PublicKeyHash::from(&pk2)); - let token_change_authority = - locked_token_data.clone().change_authority(random_destination2.clone()); + let token_change_authority = locked_token_data + .clone() + .change_authority(random_destination2.clone(), locked_token_data.next_nonce); db_tx .set_fungible_token_data( @@ -1332,6 +1337,7 @@ where is_locked: false, frozen: IsTokenFrozen::No(IsTokenFreezable::Yes), authority: random_destination, + next_nonce: AccountNonce::new(0), }; let block_height = BlockHeight::new(rng.gen_range(1..100)); diff --git a/api-server/web-server/src/api/json_helpers.rs b/api-server/web-server/src/api/json_helpers.rs index ae7322c72..fe0dc4450 100644 --- a/api-server/web-server/src/api/json_helpers.rs +++ b/api-server/web-server/src/api/json_helpers.rs @@ -287,7 +287,11 @@ pub fn utxo_outpoint_to_json(utxo: &UtxoOutPoint) -> serde_json::Value { } } -pub fn tx_input_to_json(inp: &TxInput, chain_config: &ChainConfig) -> serde_json::Value { +pub fn tx_input_to_json( + inp: &TxInput, + token_decimals: &TokenDecimals, + chain_config: &ChainConfig, +) -> serde_json::Value { match inp { TxInput::Utxo(utxo) => match utxo.source_id() { OutPointSourceId::Transaction(tx_id) => { @@ -349,7 +353,7 @@ pub fn tx_input_to_json(inp: &TxInput, chain_config: &ChainConfig) -> serde_json "input_type": "AccountCommand", "command": "MintTokens", "token_id": Address::new(chain_config, *token_id).expect("addressable").to_string(), - "amount": amount_to_json(*amount, chain_config.coin_decimals()), + "amount": amount_to_json(*amount, token_decimals.get(token_id)), "nonce": nonce, }) } @@ -432,20 +436,21 @@ pub fn tx_to_json( additional_info: &TxAdditionalInfo, chain_config: &ChainConfig, ) -> serde_json::Value { + let token_decimals = &(&additional_info.token_decimals).into(); json!({ - "id": tx.get_id().to_hash().encode_hex::(), - "version_byte": tx.version_byte(), - "is_replaceable": tx.is_replaceable(), - "flags": tx.flags(), - "fee": amount_to_json(additional_info.fee, chain_config.coin_decimals()), - "inputs": tx.inputs().iter().zip(additional_info.input_utxos.iter()).map(|(inp, utxo)| json!({ - "input": tx_input_to_json(inp, chain_config), - "utxo": utxo.as_ref().map(|txo| txoutput_to_json(txo, chain_config, &(&additional_info.token_decimals).into())), - })).collect::>(), - "outputs": tx.outputs() - .iter() - .map(|out| txoutput_to_json(out, chain_config, &(&additional_info.token_decimals).into())) - .collect::>() + "id": tx.get_id().to_hash().encode_hex::(), + "version_byte": tx.version_byte(), + "is_replaceable": tx.is_replaceable(), + "flags": tx.flags(), + "fee": amount_to_json(additional_info.fee, chain_config.coin_decimals()), + "inputs": tx.inputs().iter().zip(additional_info.input_utxos.iter()).map(|(inp, utxo)| json!({ + "input": tx_input_to_json(inp, token_decimals, chain_config), + "utxo": utxo.as_ref().map(|txo| txoutput_to_json(txo, chain_config, token_decimals)), + })).collect::>(), + "outputs": tx.outputs() + .iter() + .map(|out| txoutput_to_json(out, chain_config, token_decimals)) + .collect::>() }) } diff --git a/api-server/web-server/src/api/v2.rs b/api-server/web-server/src/api/v2.rs index bb6c6022c..b3bf0c767 100644 --- a/api-server/web-server/src/api/v2.rs +++ b/api-server/web-server/src/api/v2.rs @@ -1121,6 +1121,7 @@ pub async fn token( "frozen": frozen, "is_token_unfreezable": unfreezable, "is_token_freezable": freezable, + "next_nonce": token.next_nonce, }))) }