|
14 | 14 | // limitations under the License. |
15 | 15 |
|
16 | 16 | use common::chain::{ |
17 | | - make_token_id, |
| 17 | + AccountCommand, AccountNonce, UtxoOutPoint, make_token_id, |
18 | 18 | tokens::{IsTokenFreezable, NftIssuance, TokenIssuance, TokenIssuanceV1, TokenTotalSupply}, |
19 | 19 | }; |
20 | 20 |
|
@@ -254,3 +254,199 @@ async fn ok(#[case] seed: Seed) { |
254 | 254 |
|
255 | 255 | task.abort(); |
256 | 256 | } |
| 257 | + |
| 258 | +// Tokens with multiple state changes must appear only once in the response (issue #1982) |
| 259 | +#[rstest] |
| 260 | +#[trace] |
| 261 | +#[case(Seed::from_entropy())] |
| 262 | +#[tokio::test] |
| 263 | +async fn no_duplicate_ids_for_tokens_with_state_changes(#[case] seed: Seed) { |
| 264 | + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 265 | + let addr = listener.local_addr().unwrap(); |
| 266 | + |
| 267 | + let (tx, rx) = tokio::sync::oneshot::channel(); |
| 268 | + |
| 269 | + let task = tokio::spawn(async move { |
| 270 | + let web_server_state = { |
| 271 | + let mut rng = make_seedable_rng(seed); |
| 272 | + let chain_config = create_unit_test_config(); |
| 273 | + |
| 274 | + let chainstate_blocks = { |
| 275 | + let mut tf = TestFramework::builder(&mut rng) |
| 276 | + .with_chain_config(chain_config.clone()) |
| 277 | + .build(); |
| 278 | + |
| 279 | + // AnyoneCanSpend authority so the mint input can go unsigned |
| 280 | + let token_issuance = TokenIssuanceV1 { |
| 281 | + token_ticker: "XXXX".as_bytes().to_vec(), |
| 282 | + number_of_decimals: rng.random_range(1..18), |
| 283 | + metadata_uri: "http://uri".as_bytes().to_vec(), |
| 284 | + total_supply: TokenTotalSupply::Unlimited, |
| 285 | + authority: Destination::AnyoneCanSpend, |
| 286 | + is_freezable: IsTokenFreezable::No, |
| 287 | + }; |
| 288 | + |
| 289 | + let genesis_outpoint = UtxoOutPoint::new(tf.best_block_id().into(), 0); |
| 290 | + let genesis_coins = chainstate_test_framework::get_output_value( |
| 291 | + tf.chainstate.utxo(&genesis_outpoint).unwrap().unwrap().output(), |
| 292 | + ) |
| 293 | + .unwrap() |
| 294 | + .coin_amount() |
| 295 | + .unwrap(); |
| 296 | + |
| 297 | + let issuance_fee = chain_config.fungible_token_issuance_fee(); |
| 298 | + let supply_change_fee = chain_config.token_supply_change_fee(BlockHeight::zero()); |
| 299 | + |
| 300 | + // issue a token |
| 301 | + let coins_after_issue = (genesis_coins - issuance_fee).unwrap(); |
| 302 | + let issue_tx = TransactionBuilder::new() |
| 303 | + .add_input(genesis_outpoint.into(), InputWitness::NoSignature(None)) |
| 304 | + .add_output(TxOutput::Transfer( |
| 305 | + OutputValue::Coin(coins_after_issue), |
| 306 | + Destination::AnyoneCanSpend, |
| 307 | + )) |
| 308 | + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( |
| 309 | + token_issuance.clone(), |
| 310 | + )))) |
| 311 | + .build(); |
| 312 | + let minted_token_id = |
| 313 | + make_token_id(&chain_config, tf.next_block_height(), issue_tx.inputs()) |
| 314 | + .unwrap(); |
| 315 | + let issue_tx_id = issue_tx.transaction().get_id(); |
| 316 | + tf.make_block_builder() |
| 317 | + .add_transaction(issue_tx) |
| 318 | + .build_and_process(&mut rng) |
| 319 | + .unwrap() |
| 320 | + .unwrap(); |
| 321 | + |
| 322 | + // mint it, giving the token a second state row |
| 323 | + let amount_to_mint = Amount::from_atoms(rng.random_range(100..1000)); |
| 324 | + let coins_after_mint = (coins_after_issue - supply_change_fee).unwrap(); |
| 325 | + let mint_tx = TransactionBuilder::new() |
| 326 | + .add_input( |
| 327 | + TxInput::from_command( |
| 328 | + AccountNonce::new(0), |
| 329 | + AccountCommand::MintTokens(minted_token_id, amount_to_mint), |
| 330 | + ), |
| 331 | + InputWitness::NoSignature(None), |
| 332 | + ) |
| 333 | + .add_input( |
| 334 | + TxInput::from_utxo(issue_tx_id.into(), 0), |
| 335 | + InputWitness::NoSignature(None), |
| 336 | + ) |
| 337 | + .add_output(TxOutput::Transfer( |
| 338 | + OutputValue::Coin(coins_after_mint), |
| 339 | + Destination::AnyoneCanSpend, |
| 340 | + )) |
| 341 | + .add_output(TxOutput::Transfer( |
| 342 | + OutputValue::TokenV1(minted_token_id, amount_to_mint), |
| 343 | + Destination::AnyoneCanSpend, |
| 344 | + )) |
| 345 | + .build(); |
| 346 | + let mint_tx_id = mint_tx.transaction().get_id(); |
| 347 | + tf.make_block_builder() |
| 348 | + .add_transaction(mint_tx) |
| 349 | + .build_and_process(&mut rng) |
| 350 | + .unwrap() |
| 351 | + .unwrap(); |
| 352 | + |
| 353 | + // issue a second token with a single state row and the same ticker |
| 354 | + let coins_after_second_issue = (coins_after_mint - issuance_fee).unwrap(); |
| 355 | + let second_issue_tx = TransactionBuilder::new() |
| 356 | + .add_input( |
| 357 | + TxInput::from_utxo(mint_tx_id.into(), 0), |
| 358 | + InputWitness::NoSignature(None), |
| 359 | + ) |
| 360 | + .add_output(TxOutput::Transfer( |
| 361 | + OutputValue::Coin(coins_after_second_issue), |
| 362 | + Destination::AnyoneCanSpend, |
| 363 | + )) |
| 364 | + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( |
| 365 | + token_issuance, |
| 366 | + )))) |
| 367 | + .build(); |
| 368 | + let single_row_token_id = make_token_id( |
| 369 | + &chain_config, |
| 370 | + tf.next_block_height(), |
| 371 | + second_issue_tx.inputs(), |
| 372 | + ) |
| 373 | + .unwrap(); |
| 374 | + tf.make_block_builder() |
| 375 | + .add_transaction(second_issue_tx) |
| 376 | + .build_and_process(&mut rng) |
| 377 | + .unwrap() |
| 378 | + .unwrap(); |
| 379 | + |
| 380 | + _ = tx.send((minted_token_id, single_row_token_id)); |
| 381 | + |
| 382 | + tf.block_indexes |
| 383 | + .iter() |
| 384 | + .map(|idx| tf.block(tf.to_chain_block_id(idx.block_id().into()))) |
| 385 | + .collect::<Vec<_>>() |
| 386 | + }; |
| 387 | + |
| 388 | + let storage = { |
| 389 | + let mut storage = TransactionalApiServerInMemoryStorage::new(&chain_config); |
| 390 | + |
| 391 | + let mut db_tx = storage.transaction_rw().await.unwrap(); |
| 392 | + db_tx.reinitialize_storage(&chain_config).await.unwrap(); |
| 393 | + db_tx.commit().await.unwrap(); |
| 394 | + |
| 395 | + storage |
| 396 | + }; |
| 397 | + |
| 398 | + let chain_config = Arc::new(chain_config); |
| 399 | + |
| 400 | + let mut local_node = BlockchainState::new(Arc::clone(&chain_config), storage); |
| 401 | + local_node.scan_genesis(chain_config.genesis_block()).await.unwrap(); |
| 402 | + local_node.scan_blocks(BlockHeight::new(0), chainstate_blocks).await.unwrap(); |
| 403 | + |
| 404 | + ApiServerWebServerState { |
| 405 | + db: Arc::new(local_node.storage().clone_storage().await), |
| 406 | + chain_config: Arc::clone(&chain_config), |
| 407 | + rpc: Arc::new(DummyRPC {}), |
| 408 | + cached_values: Arc::new(CachedValues { |
| 409 | + feerate_points: RwLock::new((get_time(), vec![])), |
| 410 | + }), |
| 411 | + time_getter: Default::default(), |
| 412 | + } |
| 413 | + }; |
| 414 | + |
| 415 | + web_server(listener, web_server_state, false).await |
| 416 | + }); |
| 417 | + |
| 418 | + let chain_config = create_unit_test_config(); |
| 419 | + let (minted_token_id, single_row_token_id) = rx.await.unwrap(); |
| 420 | + let minted_token_address = Address::new(&chain_config, minted_token_id).unwrap().into_string(); |
| 421 | + let single_row_token_address = |
| 422 | + Address::new(&chain_config, single_row_token_id).unwrap().into_string(); |
| 423 | + |
| 424 | + for url in [ |
| 425 | + "/api/v2/token?offset=0&items=10".to_string(), |
| 426 | + "/api/v2/token/ticker/XXXX?offset=0&items=10".to_string(), |
| 427 | + ] { |
| 428 | + let response = reqwest::get(format!("http://{}:{}{url}", addr.ip(), addr.port())) |
| 429 | + .await |
| 430 | + .unwrap(); |
| 431 | + |
| 432 | + assert_eq!(response.status(), 200, "Failed getting token ids"); |
| 433 | + |
| 434 | + let body = response.text().await.unwrap(); |
| 435 | + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); |
| 436 | + let ids = body.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(); |
| 437 | + |
| 438 | + assert_eq!( |
| 439 | + ids.iter().filter(|id| **id == minted_token_address).count(), |
| 440 | + 1 |
| 441 | + ); |
| 442 | + assert_eq!( |
| 443 | + ids.iter().filter(|id| **id == single_row_token_address).count(), |
| 444 | + 1 |
| 445 | + ); |
| 446 | + |
| 447 | + let unique_ids = ids.iter().copied().collect::<std::collections::BTreeSet<_>>(); |
| 448 | + assert_eq!(unique_ids.len(), ids.len(), "duplicate token ids: {ids:?}"); |
| 449 | + } |
| 450 | + |
| 451 | + task.abort(); |
| 452 | +} |
0 commit comments