Summary
utxos_by_tag on the fjall state-store backend does a prefix scan over the state-tags keyspace. For most dimensions (address, payment credential, stake credential, policy ID) the lookup key is a fixed-length credential, so prefix-scan and exact-match coincide and this is harmless. For the asset dimension, the lookup key is policy_id (28 bytes, fixed) ++ asset_name (0–32 bytes, variable) with no delimiter before the following txo_ref field — so a query for asset X also matches any stored asset whose name has X as a byte-prefix.
Where
crates/fjall/src/state/tags.rs:
build_utxo_tag_key — key format [dim_hash:8][lookup_key:var][txo_ref:36], no length prefix or terminator between lookup_key and txo_ref.
get_by_key — builds prefix = [dim_hash:8][lookup_key] and does readable.prefix(keyspace, prefix), a raw byte-prefix scan.
Asset subject construction: crates/cardano/src/indexes/delta.rs:107-109 (and the archive-side equivalent at :182-184) — subject = policy.to_vec() ++ asset.name().
state/README.md documents the tags keyspace as "Prefix scans by dimension and lookup key" and says the lookup key is "stored verbatim... because a live-UTxO query knows the key it is asking about and wants the exact refs back" — i.e. the intent is exact-match semantics achieved via prefix-scan-over-fixed-length-keys. That assumption holds for every other dimension but not for asset, where the key has a variable-length component. No test in the module covers the "shorter key is a byte-prefix of a longer one" case — the existing test_utxo_tag_prefix test only checks that a key starts with its own prefix.
Confirmed this is still present on main, not something already fixed since a released tag.
Repro
Mint two native assets under the same policy where one name is a byte-prefix of the other, e.g. bob (hex 626f62) and bob2 (hex 626f6232). Querying UTxOs holding asset bob (via utxos_by_asset, surfaced through UTxORPC SearchUtxos) returns both UTxOs — the one actually holding bob and the one holding bob2 — because the stored key for bob2's tag entry is [dim_hash][policy||"bob2"][txo_ref], which starts with the queried prefix [dim_hash][policy||"bob"]. The spurious entry reports quantity 0 for the asset actually requested when its value is inspected.
The redb3 backend does not exhibit this — its get_by_key does a MultimapTable::get(key) exact lookup, not a range/prefix scan.
Impact
Any caller asking "which UTxOs hold exact asset X" via a fjall-backed store gets extra, unrelated UTxOs back whenever another asset under the same policy has a name that is a byte-prefix of X — silently, no error. For short human-readable asset names (incrementing/numbered identifiers being an obvious case: bob, bob2, bob3), this isn't an edge case restricted to adversarial input.
Suggested fix
Delimit the variable-length lookup_key from the fixed-length txo_ref suffix in the stored key — e.g. a length prefix ([dim_hash:8][key_len:2][lookup_key:var][txo_ref:36]) or a reserved separator byte that cannot appear in lookup_key — so a prefix scan on [dim_hash][lookup_key] can no longer match a longer stored lookup_key. Worth checking whether slots_by_tag (archive side, same key shape) needs the identical fix — not checked here.
Summary
utxos_by_tagon thefjallstate-store backend does a prefix scan over thestate-tagskeyspace. For most dimensions (address, payment credential, stake credential, policy ID) the lookup key is a fixed-length credential, so prefix-scan and exact-match coincide and this is harmless. For the asset dimension, the lookup key ispolicy_id (28 bytes, fixed) ++ asset_name (0–32 bytes, variable)with no delimiter before the followingtxo_reffield — so a query for assetXalso matches any stored asset whose name hasXas a byte-prefix.Where
crates/fjall/src/state/tags.rs:build_utxo_tag_key— key format[dim_hash:8][lookup_key:var][txo_ref:36], no length prefix or terminator betweenlookup_keyandtxo_ref.get_by_key— buildsprefix = [dim_hash:8][lookup_key]and doesreadable.prefix(keyspace, prefix), a raw byte-prefix scan.Asset subject construction:
crates/cardano/src/indexes/delta.rs:107-109(and the archive-side equivalent at:182-184) —subject = policy.to_vec() ++ asset.name().state/README.mddocuments the tags keyspace as "Prefix scans by dimension and lookup key" and says the lookup key is "stored verbatim... because a live-UTxO query knows the key it is asking about and wants the exact refs back" — i.e. the intent is exact-match semantics achieved via prefix-scan-over-fixed-length-keys. That assumption holds for every other dimension but not for asset, where the key has a variable-length component. No test in the module covers the "shorter key is a byte-prefix of a longer one" case — the existingtest_utxo_tag_prefixtest only checks that a key starts with its own prefix.Confirmed this is still present on
main, not something already fixed since a released tag.Repro
Mint two native assets under the same policy where one name is a byte-prefix of the other, e.g.
bob(hex626f62) andbob2(hex626f6232). Querying UTxOs holding assetbob(viautxos_by_asset, surfaced through UTxORPCSearchUtxos) returns both UTxOs — the one actually holdingboband the one holdingbob2— because the stored key forbob2's tag entry is[dim_hash][policy||"bob2"][txo_ref], which starts with the queried prefix[dim_hash][policy||"bob"]. The spurious entry reports quantity 0 for the asset actually requested when its value is inspected.The
redb3backend does not exhibit this — itsget_by_keydoes aMultimapTable::get(key)exact lookup, not a range/prefix scan.Impact
Any caller asking "which UTxOs hold exact asset X" via a
fjall-backed store gets extra, unrelated UTxOs back whenever another asset under the same policy has a name that is a byte-prefix of X — silently, no error. For short human-readable asset names (incrementing/numbered identifiers being an obvious case:bob,bob2,bob3), this isn't an edge case restricted to adversarial input.Suggested fix
Delimit the variable-length
lookup_keyfrom the fixed-lengthtxo_refsuffix in the stored key — e.g. a length prefix ([dim_hash:8][key_len:2][lookup_key:var][txo_ref:36]) or a reserved separator byte that cannot appear inlookup_key— so a prefix scan on[dim_hash][lookup_key]can no longer match a longer storedlookup_key. Worth checking whetherslots_by_tag(archive side, same key shape) needs the identical fix — not checked here.