Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/release-notes/release-notes-0.8.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@
clients to distinguish grouped fungible burns from grouped collectible
burns.

- [PR#2130](https://github.com/lightninglabs/taproot-assets/pull/2130)
Add `asset_genesis` and `decimal_display` fields to `AssetGroupBalance`
in `ListBalances`. When using `group_by=group_key` mode, clients now
receive asset metadata (name, type, decimal display) alongside grouped
balances without requiring additional RPC calls.

- [PR#2100](https://github.com/lightninglabs/taproot-assets/pull/2100)
Add pagination support (offset, limit, direction) to the `AssetLeaves`
RPC endpoint, and add `MaxPageSize` validation to `AssetRoots`.
Expand Down
26 changes: 25 additions & 1 deletion itest/multi_asset_group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,40 @@ func testMintMultiAssetGroups(t *harnessTest) {
require.NoError(t.t, err)

// For each group minted, we check that the total balance for each
// group matches our minting requests.
// group matches our minting requests, and that genesis info is
// populated correctly.
var singleAssetGroupKey, normalGroupKey, collectGroupKey string
for groupKey, groupBalance := range balancesResp.AssetGroupBalances {
// Verify genesis info is populated for all group balances.
require.NotNil(t.t, groupBalance.AssetGenesis,
"group %s missing genesis info", groupKey)
require.NotEmpty(t.t, groupBalance.AssetGenesis.Name,
"group %s missing asset name", groupKey)
require.NotEmpty(t.t, groupBalance.AssetGenesis.AssetId,
"group %s missing asset ID", groupKey)
require.NotEmpty(t.t, groupBalance.AssetGenesis.GenesisPoint,
"group %s missing genesis point", groupKey)

switch groupBalance.Balance {
case issuableAsset.Asset.Amount:
singleAssetGroupKey = groupKey

case normalGroupSum:
normalGroupKey = groupKey

// Verify the normal group has NORMAL asset type.
require.Equal(t.t, taprpc.AssetType_NORMAL,
groupBalance.AssetGenesis.AssetType,
"normal group has wrong asset type")

case collectGroupSum:
collectGroupKey = groupKey

// Verify the collectible group has COLLECTIBLE type.
require.Equal(t.t, taprpc.AssetType_COLLECTIBLE,
groupBalance.AssetGenesis.AssetType,
"collectible group has wrong asset type")

default:
t.t.Fatalf("minted group %v has unexpected balance %v",
groupKey, groupBalance.Balance)
Expand Down
41 changes: 38 additions & 3 deletions rpcserver/rpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/davecgh/go-spew/spew"
proxy "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
Expand Down Expand Up @@ -1483,11 +1484,45 @@ func (r *RPCServer) listBalancesByGroupKey(ctx context.Context,
groupKey = balance.GroupKey.SerializeCompressed()
}

// Create the genesis info for the representative issuance.
genesisInfo := &taprpc.GenesisInfo{
GenesisPoint: balance.GenesisPoint.String(),
AssetType: taprpc.AssetType(balance.Type),
Name: balance.Tag,
MetaHash: balance.MetaHash[:],
AssetId: balance.ID[:],
OutputIndex: balance.OutputIndex,
}

groupKeyString := hex.EncodeToString(groupKey)
resp.AssetGroupBalances[groupKeyString] = &taprpc.AssetGroupBalance{
GroupKey: groupKey,
Balance: balance.Balance,
groupBalance := &taprpc.AssetGroupBalance{
GroupKey: groupKey,
Balance: balance.Balance,
AssetGenesis: genesisInfo,
}

// Fetch the decimal display for this asset ID. We don't fail
// the request if this lookup fails since decimal display is
// optional metadata.
decDisplay, err := r.cfg.AddrBook.DecDisplayForAssetID(
ctx, balance.ID,
)
Comment on lines +1507 to +1509
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Fetching the decimal display for each asset ID inside the loop results in an N+1 query pattern. If the number of asset groups is large, this will significantly degrade the performance of the ListBalances RPC. Consider batching these lookups by collecting all unique asset IDs first and performing a single query to the AddrBook component.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DecDisplayForAssetID method has an internal cache that pre-populates with ALL asset metadata on first call (address/book.go line 482: FetchAllAssetMeta). So the N+1 concern is largely mitigated, after the first lookup, subsequent calls are cache hits.

if err == nil {
groupBalance.DecimalDisplay = fn.MapOptionZ(
decDisplay,
func(d uint32) *taprpc.DecimalDisplay {
return &taprpc.DecimalDisplay{
DecimalDisplay: d,
}
},
)
} else {
rpcsLog.DebugS(ctx, "Failed to fetch decimal display",
btclog.Fmt("asset_id", "%x", balance.ID[:]),
"error", err)
}

resp.AssetGroupBalances[groupKeyString] = groupBalance
}

// We will also report the number of unconfirmed transfers. This is
Expand Down
40 changes: 34 additions & 6 deletions tapdb/assets_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,20 @@ type AssetBalance struct {
GroupKey []byte
}

// AssetGroupBalance holds abalance query result for a particular asset group
// or all asset groups tracked by this daemon.
// AssetGroupBalance holds a balance query result for a particular asset group
// or all asset groups tracked by this daemon. It includes the genesis info of
// a representative issuance in the group.
type AssetGroupBalance struct {
GroupKey *btcec.PublicKey
Balance uint64

// Genesis info fields for the representative issuance.
ID asset.ID
Tag string
MetaHash [asset.MetaHashLen]byte
Type asset.Type
GenesisPoint wire.OutPoint
OutputIndex uint32
}

// cacheableTimestamp is a wrapper around an int32 that can be used as a
Expand Down Expand Up @@ -1219,11 +1228,30 @@ func (a *AssetStore) QueryAssetBalancesByGroup(ctx context.Context,
}
}

serializedKey := asset.ToSerialized(groupKey)
balances[serializedKey] = AssetGroupBalance{
GroupKey: groupKey,
Balance: uint64(groupBalance.Balance),
assetGroupBalance := AssetGroupBalance{
GroupKey: groupKey,
Balance: uint64(groupBalance.Balance),
Tag: groupBalance.AssetTag,
Type: asset.Type(groupBalance.AssetType),
OutputIndex: uint32(groupBalance.OutputIndex),
}

// Parse the genesis point from the raw bytes.
err = readOutPoint(
bytes.NewReader(groupBalance.PrevOut),
0, 0, &assetGroupBalance.GenesisPoint,
)
if err != nil {
return err
}

// Copy the asset ID and meta hash.
copy(assetGroupBalance.ID[:], groupBalance.AssetID)
copy(assetGroupBalance.MetaHash[:],
groupBalance.MetaHash)

serializedKey := asset.ToSerialized(groupKey)
balances[serializedKey] = assetGroupBalance
}

return err
Expand Down
49 changes: 45 additions & 4 deletions tapdb/sqlc/assets.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 29 additions & 3 deletions tapdb/sqlc/queries/assets.sql
Original file line number Diff line number Diff line change
Expand Up @@ -362,20 +362,43 @@ GROUP BY assets.genesis_id, genesis_info_view.asset_id,
genesis_info_view.prev_out, key_group_info_view.tweaked_group_key;

-- name: QueryAssetBalancesByGroup :many
WITH group_anchor AS (
-- Select a representative genesis for each group. We use MIN(gen_asset_id)
-- as a deterministic way to pick one genesis per group. For fungible assets
-- (the primary use case), all tranches in a group share the same name and
-- type, so any genesis provides correct metadata. This is a heuristic that
-- works for locally-minted assets where the anchor is inserted first.
SELECT
tweaked_group_key,
MIN(gen_asset_id) as anchor_gen_id
FROM key_group_info_view
GROUP BY tweaked_group_key
)
SELECT
key_group_info_view.tweaked_group_key, SUM(amount) balance
key_group_info_view.tweaked_group_key,
SUM(amount) balance,
genesis_info_view.asset_id,
genesis_info_view.asset_tag,
genesis_info_view.meta_hash,
genesis_info_view.asset_type,
genesis_info_view.output_index,
genesis_info_view.prev_out
FROM assets
JOIN key_group_info_view
ON assets.genesis_id = key_group_info_view.gen_asset_id AND
(key_group_info_view.tweaked_group_key = sqlc.narg('key_group_filter') OR
sqlc.narg('key_group_filter') IS NULL)
JOIN group_anchor
ON key_group_info_view.tweaked_group_key = group_anchor.tweaked_group_key
JOIN genesis_info_view
ON group_anchor.anchor_gen_id = genesis_info_view.gen_asset_id
JOIN managed_utxos utxos
ON assets.anchor_utxo_id = utxos.utxo_id AND
CASE
WHEN sqlc.narg('leased') = true THEN
(utxos.lease_owner IS NOT NULL AND utxos.lease_expiry > @now)
WHEN sqlc.narg('leased') = false THEN
(utxos.lease_owner IS NULL OR
(utxos.lease_owner IS NULL OR
utxos.lease_expiry IS NULL OR
utxos.lease_expiry <= @now)
ELSE TRUE
Expand All @@ -387,7 +410,10 @@ WHERE spent = FALSE AND
-- query will return no results.
COALESCE(script_keys.key_type, 0) IN
(sqlc.slice('script_key_type')/*SLICE:script_key_type*/)
GROUP BY key_group_info_view.tweaked_group_key;
GROUP BY key_group_info_view.tweaked_group_key,
genesis_info_view.asset_id, genesis_info_view.asset_tag,
genesis_info_view.meta_hash, genesis_info_view.asset_type,
genesis_info_view.output_index, genesis_info_view.prev_out;

-- name: FetchGroupedAssets :many
SELECT
Expand Down
Loading
Loading