Skip to content

Commit cb2c43e

Browse files
committed
API server: fix duplicate token ids in token listing queries
The Postgres fungible_token and nft_issuance tables keep one row per token state change, but get_token_ids/get_token_ids_by_ticker selected ids without collapsing history, so every token appeared once per state change. Deduplicate both halves of the union, count distinct tokens (not rows) when computing the NFT half's offset, and clamp its limit to the requested page size, which used to be exceeded when the offset landed past the fungible tokens. Add a storage-test-suite trial covering dedup and full page walks on both backends, and a stack-test-suite HTTP test for the /token and /token/ticker/:ticker endpoints. Fixes #1982
1 parent 82cdd44 commit cb2c43e

3 files changed

Lines changed: 424 additions & 16 deletions

File tree

api-server/api-server-common/src/storage/impls/postgres/queries.rs

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2523,26 +2523,24 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> {
25232523
) -> Result<Vec<TokenId>, ApiServerStorageError> {
25242524
let len = len as i64;
25252525
let offset = offset as i64;
2526+
// both tables have one row per token state change, hence the DISTINCTs
25262527
self.tx
25272528
.query(
25282529
r#"
25292530
WITH count_tokens AS (
2530-
SELECT count(token_id) FROM ml.fungible_token
2531+
SELECT count(DISTINCT token_id) FROM ml.fungible_token
25312532
)
2532-
(SELECT token_id
2533+
(SELECT DISTINCT token_id
25332534
FROM ml.fungible_token
25342535
ORDER BY token_id
25352536
OFFSET $1
25362537
LIMIT $2)
25372538
UNION ALL
2538-
(SELECT nft_id
2539+
(SELECT DISTINCT nft_id
25392540
FROM ml.nft_issuance
25402541
ORDER BY nft_id
25412542
OFFSET GREATEST($1 - (SELECT * FROM count_tokens), 0)
2542-
LIMIT CASE
2543-
WHEN ($1 - (SELECT * FROM count_tokens) >= -$2)
2544-
THEN ($2 + $1 - (SELECT * FROM count_tokens))
2545-
ELSE 0 END);
2543+
LIMIT GREATEST(LEAST($2, $2 + $1 - (SELECT * FROM count_tokens)), 0));
25462544
"#,
25472545
&[&offset, &len],
25482546
)
@@ -2572,24 +2570,21 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> {
25722570
.query(
25732571
r#"
25742572
WITH count_tokens AS (
2575-
SELECT count(token_id) FROM ml.fungible_token WHERE ticker ILIKE $3
2573+
SELECT count(DISTINCT token_id) FROM ml.fungible_token WHERE ticker ILIKE $3
25762574
)
2577-
(SELECT token_id
2575+
(SELECT DISTINCT token_id
25782576
FROM ml.fungible_token
25792577
WHERE ticker ILIKE $3
25802578
ORDER BY token_id
25812579
OFFSET $1
25822580
LIMIT $2)
25832581
UNION ALL
2584-
(SELECT nft_id
2582+
(SELECT DISTINCT nft_id
25852583
FROM ml.nft_issuance
25862584
WHERE ticker ILIKE $3
25872585
ORDER BY nft_id
25882586
OFFSET GREATEST($1 - (SELECT * FROM count_tokens), 0)
2589-
LIMIT CASE
2590-
WHEN ($1 - (SELECT * FROM count_tokens) >= -$2)
2591-
THEN ($2 + $1 - (SELECT * FROM count_tokens))
2592-
ELSE 0 END);
2587+
LIMIT GREATEST(LEAST($2, $2 + $1 - (SELECT * FROM count_tokens)), 0));
25932588
"#,
25942589
&[&offset, &len, &ticker_patern],
25952590
)

api-server/stack-test-suite/tests/v2/token_ids.rs

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// limitations under the License.
1515

1616
use common::chain::{
17-
make_token_id,
17+
AccountCommand, AccountNonce, UtxoOutPoint, make_token_id,
1818
tokens::{IsTokenFreezable, NftIssuance, TokenIssuance, TokenIssuanceV1, TokenTotalSupply},
1919
};
2020

@@ -254,3 +254,199 @@ async fn ok(#[case] seed: Seed) {
254254

255255
task.abort();
256256
}
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

Comments
 (0)