Skip to content
Merged
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
79 changes: 79 additions & 0 deletions internal/migrations/053-order-book-richlist.prod.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* MIGRATION 053 (PROD TWIN): ORDER-BOOK RICHLIST (ORDERED TOKEN BALANCES)
*
* Mainnet twin of 053-order-book-richlist.sql. Identical logic; the only
* difference is the bridge alias each token resolves to: mainnet `eth_truf` /
* `eth_usdc` here vs dev `hoodi_tt` / `hoodi_tt2` in the .sql file. A bridge-alias
* namespace is a compile-time reference, so the token argument is mapped with an
* if/else — which is why this file exists as a separate prod twin.
*
* Only adds a new PUBLIC VIEW read (no ALTER, no writes), so it is safe to apply
* against a live, settling node.
*/

-- =============================================================================
-- get_ordered_balances: a token's wallets ordered by balance (richlist).
-- $token 'TRUF' | 'USDC' (case-insensitive).
-- $ascending false (default) = largest first; true = smallest first.
-- $limit number of rows, clamped to [1, 50] (hard cap 50).
-- $min_balance only wallets with balance >= this (token base units); default 0.
-- Balances are token base units (18 decimals TRUF, 6 decimals USDC). Addresses
-- are returned as '0x'-prefixed lowercase hex. A token with no holders (or none
-- above the threshold) returns an empty result.
-- =============================================================================
CREATE OR REPLACE ACTION get_ordered_balances(
$token TEXT,
$ascending BOOL DEFAULT false,
$limit INT DEFAULT 20,
$min_balance NUMERIC(78, 0) DEFAULT NULL
) PUBLIC VIEW RETURNS TABLE(
address TEXT,
balance NUMERIC(78, 0)
) {
-- Resolve the token's reward_id from its bridge alias (mainnet: eth_truf/eth_usdc).
$reward_id UUID;
$token_key TEXT := lower($token);
if $token_key = 'truf' {
$reward_id := eth_truf.id();
} else if $token_key = 'usdc' {
$reward_id := eth_usdc.id();
} else {
ERROR('unsupported token (want TRUF or USDC): ' || $token);
}

-- Enforce the hard cap of 50 (and a sane floor of 1).
if $limit IS NULL OR $limit < 1 {
$limit := 1;
}
if $limit > 50 {
$limit := 50;
}

-- Threshold in token base units; NULL means no threshold (0).
$threshold NUMERIC(78, 0) := COALESCE($min_balance, 0::NUMERIC(78, 0));

-- Ordered read from the per-token balance ledger. Sort direction can't be
-- parametrized, so branch on $ascending. The address is hex-encoded in the
-- SELECT projection (function calls are not allowed inside a FOR loop body).
if $ascending {
for $row_asc in
SELECT '0x' || encode(address, 'hex') AS addr, balance
FROM kwil_erc20_meta.balances
WHERE reward_id = $reward_id AND balance >= $threshold
ORDER BY balance ASC, address ASC
LIMIT $limit
{
RETURN NEXT $row_asc.addr, $row_asc.balance;
}
} else {
for $row_desc in
SELECT '0x' || encode(address, 'hex') AS addr, balance
FROM kwil_erc20_meta.balances
WHERE reward_id = $reward_id AND balance >= $threshold
ORDER BY balance DESC, address ASC
LIMIT $limit
{
RETURN NEXT $row_desc.addr, $row_desc.balance;
}
}
};
84 changes: 84 additions & 0 deletions internal/migrations/053-order-book-richlist.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* MIGRATION 053: ORDER-BOOK RICHLIST (ORDERED TOKEN BALANCES)
*
* Read-only getter returning a token's wallet balances in balance order — a
* "richlist". Backs trufscan #185 (query ordered balances of multiple wallets).
*
* Reads the node's per-token balance ledger `kwil_erc20_meta.balances` directly
* (address + balance keyed by reward_id). The reward_id for a token is resolved
* from its bridge alias's id() method: dev `hoodi_tt` / `hoodi_tt2`, mainnet
* `eth_truf` / `eth_usdc` (see the .prod.sql twin). A bridge-alias namespace is a
* compile-time reference (you cannot select a namespace from a variable), so the
* token argument is mapped to an alias with an if/else — hence the dev/prod twin.
*
* Only adds a new PUBLIC VIEW read (no ALTER, no writes), so it is safe to apply
* against a live, settling node. The (reward_id, balance) index on `balances`
* (kwil-db erc20 meta schema) keeps the ordering off a sequential scan.
*/

-- =============================================================================
-- get_ordered_balances: a token's wallets ordered by balance (richlist).
-- $token 'TRUF' | 'USDC' (case-insensitive).
-- $ascending false (default) = largest first; true = smallest first.
-- $limit number of rows, clamped to [1, 50] (hard cap 50).
-- $min_balance only wallets with balance >= this (token base units); default 0.
-- Balances are token base units (18 decimals TRUF, 6 decimals USDC). Addresses
-- are returned as '0x'-prefixed lowercase hex. A token with no holders (or none
-- above the threshold) returns an empty result.
-- =============================================================================
CREATE OR REPLACE ACTION get_ordered_balances(
$token TEXT,
$ascending BOOL DEFAULT false,
$limit INT DEFAULT 20,
$min_balance NUMERIC(78, 0) DEFAULT NULL
) PUBLIC VIEW RETURNS TABLE(
address TEXT,
balance NUMERIC(78, 0)
) {
-- Resolve the token's reward_id from its bridge alias (dev: hoodi_tt/hoodi_tt2).
$reward_id UUID;
$token_key TEXT := lower($token);
if $token_key = 'truf' {
$reward_id := hoodi_tt.id();
} else if $token_key = 'usdc' {
$reward_id := hoodi_tt2.id();
} else {
ERROR('unsupported token (want TRUF or USDC): ' || $token);
}

-- Enforce the hard cap of 50 (and a sane floor of 1).
if $limit IS NULL OR $limit < 1 {
$limit := 1;
}
if $limit > 50 {
$limit := 50;
}

-- Threshold in token base units; NULL means no threshold (0).
$threshold NUMERIC(78, 0) := COALESCE($min_balance, 0::NUMERIC(78, 0));

-- Ordered read from the per-token balance ledger. Sort direction can't be
-- parametrized, so branch on $ascending. The address is hex-encoded in the
-- SELECT projection (function calls are not allowed inside a FOR loop body).
if $ascending {
for $row_asc in
SELECT '0x' || encode(address, 'hex') AS addr, balance
FROM kwil_erc20_meta.balances
WHERE reward_id = $reward_id AND balance >= $threshold
ORDER BY balance ASC, address ASC
LIMIT $limit
{
RETURN NEXT $row_asc.addr, $row_asc.balance;
}
} else {
for $row_desc in
SELECT '0x' || encode(address, 'hex') AS addr, balance
FROM kwil_erc20_meta.balances
WHERE reward_id = $reward_id AND balance >= $threshold
ORDER BY balance DESC, address ASC
LIMIT $limit
{
RETURN NEXT $row_desc.addr, $row_desc.balance;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
232 changes: 232 additions & 0 deletions tests/streams/order_book/richlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
//go:build kwiltest

package order_book

import (
"context"
"testing"

"github.com/stretchr/testify/require"
"github.com/trufnetwork/kwil-db/common"
"github.com/trufnetwork/kwil-db/core/types"
erc20bridge "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20"
kwilTesting "github.com/trufnetwork/kwil-db/testing"
"github.com/trufnetwork/node/internal/migrations"
testutils "github.com/trufnetwork/node/tests/streams/utils"
testerc20 "github.com/trufnetwork/node/tests/streams/utils/erc20"
"github.com/trufnetwork/sdk-go/core/util"
)

// hoodi_tt2 (the dev "USDC" bridge) registration values — see
// internal/migrations/erc20-bridge/000-extension.sql. hoodi_tt ("TRUF") reuses the
// testTRUF* constants from market_creation_test.go.
const (
richlistUSDCChain = "hoodi"
richlistUSDCEscrow = "0x80D9B3b6941367917816d36748C88B303f7F1415"
richlistUSDCERC20 = "0x1591DeAa21710E0BA6CC1b15F49620C9F65B2dEd"

// Distinct, unambiguous holder addresses (returned lowercased by the action).
rlWalletA = "0xAAaAAaAaAAaAAaAAAAAAaaAAaAAAAaAaaaAaAaaA"
rlWalletB = "0xBBbBBbBBbBbbBbbbbBbBBBBBBBbBBbbbBBbBBBBb"
rlWalletC = "0xCcCCccCCCCCCCCCcCCcCcCCCCCccCCcCccCCcCCc"
)

// Chained ordered-sync points per bridge instance (reset per test fn).
var (
rlTrufPoint int64 = 7000
rlUsdcPoint int64 = 8000
rlLastTruf *int64
rlLastUsdc *int64
)

func seedTrufBalance(ctx context.Context, platform *kwilTesting.Platform, wallet, amount string) error {
rlTrufPoint++
p := rlTrufPoint
err := testerc20.InjectERC20Transfer(ctx, platform, testTRUFChain, testTRUFEscrow, testTRUFERC20,
wallet, wallet, amount, p, rlLastTruf)
if err == nil {
rlLastTruf = &p
}
return err
}

func seedUsdcBalance(ctx context.Context, platform *kwilTesting.Platform, wallet, amount string) error {
rlUsdcPoint++
p := rlUsdcPoint
err := testerc20.InjectERC20Transfer(ctx, platform, richlistUSDCChain, richlistUSDCEscrow, richlistUSDCERC20,
wallet, wallet, amount, p, rlLastUsdc)
if err == nil {
rlLastUsdc = &p
}
return err
}

type richlistRow struct {
address string
balance string
}

func callGetOrderedBalances(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, token string, ascending bool, limit int, minBalance any) ([]richlistRow, error) {
var rows []richlistRow
err := callActionQueries(ctx, platform, signer, "get_ordered_balances",
[]any{token, ascending, int64(limit), minBalance},
func(row *common.Row) error {
rows = append(rows, richlistRow{
address: row.Values[0].(string),
balance: row.Values[1].(*types.Decimal).String(),
})
return nil
})
return rows, err
}

func richlistReader() *util.EthereumAddress {
// Reads are signed by an arbitrary wallet that holds nothing itself.
r := util.Unsafe_NewEthereumAddressFromString("0xC1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1")
return &r
}

// TestRichlist exercises get_ordered_balances (migration 053, trufscan #185):
// a read-only richlist of a token's wallets ordered by balance.
func TestRichlist(t *testing.T) {
owner := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111")

testutils.RunSchemaTest(t, kwilTesting.SchemaTest{
Name: "ORDER_BOOK_RICHLIST",
SeedStatements: migrations.GetSeedScriptStatements(),
Owner: owner.Address(),
FunctionTests: []kwilTesting.TestFunc{
testRichlistDescending(t),
testRichlistAscending(t),
testRichlistLimit(t),
testRichlistThreshold(t),
testRichlistUsdcIsolatedFromTruf(t),
testRichlistUnknownToken(t),
testRichlistEmpty(t),
},
}, testutils.GetTestOptionsWithCache())
}

func richlistSetup(ctx context.Context, platform *kwilTesting.Platform) error {
rlLastTruf = nil
rlLastUsdc = nil
return erc20bridge.ForTestingInitializeExtension(ctx, platform)
}

// Descending (default): largest balance first.
func testRichlistDescending(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

require.NoError(t, seedTrufBalance(ctx, platform, rlWalletA, "300"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletB, "100"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletC, "200"))

rows, err := callGetOrderedBalances(ctx, platform, richlistReader(), "TRUF", false, 20, nil)
require.NoError(t, err)
require.Len(t, rows, 3)
require.Equal(t, []string{"300", "200", "100"}, []string{rows[0].balance, rows[1].balance, rows[2].balance})
// Address is returned as lowercase 0x-hex.
require.Equal(t, "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", rows[0].address)
return nil
}
}

// Ascending: smallest balance first. Token is case-insensitive.
func testRichlistAscending(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

require.NoError(t, seedTrufBalance(ctx, platform, rlWalletA, "300"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletB, "100"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletC, "200"))

rows, err := callGetOrderedBalances(ctx, platform, richlistReader(), "truf", true, 20, nil)
require.NoError(t, err)
require.Len(t, rows, 3)
require.Equal(t, []string{"100", "200", "300"}, []string{rows[0].balance, rows[1].balance, rows[2].balance})
return nil
}
}

// limit returns exactly the top-N, and a limit above the hard cap does not error.
func testRichlistLimit(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

require.NoError(t, seedTrufBalance(ctx, platform, rlWalletA, "300"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletB, "100"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletC, "200"))

top2, err := callGetOrderedBalances(ctx, platform, richlistReader(), "TRUF", false, 2, nil)
require.NoError(t, err)
require.Len(t, top2, 2)
require.Equal(t, []string{"300", "200"}, []string{top2[0].balance, top2[1].balance})

// A limit above the hard cap of 50 is clamped, not rejected.
all, err := callGetOrderedBalances(ctx, platform, richlistReader(), "TRUF", false, 1000, nil)
require.NoError(t, err)
require.Len(t, all, 3)
return nil
}
}

// min_balance filters out wallets below the threshold.
func testRichlistThreshold(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

require.NoError(t, seedTrufBalance(ctx, platform, rlWalletA, "300"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletB, "100"))
require.NoError(t, seedTrufBalance(ctx, platform, rlWalletC, "200"))

threshold := types.MustParseDecimalExplicit("150", 78, 0)
rows, err := callGetOrderedBalances(ctx, platform, richlistReader(), "TRUF", false, 20, threshold)
require.NoError(t, err)
require.Len(t, rows, 2, "only wallets with balance >= 150")
require.Equal(t, []string{"300", "200"}, []string{rows[0].balance, rows[1].balance})
return nil
}
}

// USDC reads the hoodi_tt2 instance and does not leak TRUF (hoodi_tt) balances —
// the reward_id keys the two ledgers apart.
func testRichlistUsdcIsolatedFromTruf(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

require.NoError(t, seedTrufBalance(ctx, platform, rlWalletA, "999"))
require.NoError(t, seedUsdcBalance(ctx, platform, rlWalletB, "700"))
require.NoError(t, seedUsdcBalance(ctx, platform, rlWalletC, "500"))

rows, err := callGetOrderedBalances(ctx, platform, richlistReader(), "USDC", false, 20, nil)
require.NoError(t, err)
require.Len(t, rows, 2, "only the two USDC holders, not the TRUF holder")
require.Equal(t, []string{"700", "500"}, []string{rows[0].balance, rows[1].balance})
return nil
}
}

// An unrecognized token errors clearly rather than returning an empty list.
func testRichlistUnknownToken(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

_, err := callGetOrderedBalances(ctx, platform, richlistReader(), "DOGE", false, 20, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported token")
return nil
}
}

// A token with no holders returns an empty result (not an error).
func testRichlistEmpty(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
require.NoError(t, richlistSetup(ctx, platform))

rows, err := callGetOrderedBalances(ctx, platform, richlistReader(), "TRUF", false, 20, nil)
require.NoError(t, err)
require.Empty(t, rows)
return nil
}
}
Loading