-
Notifications
You must be signed in to change notification settings - Fork 3
feat: rank a token's wallets by balance #1407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+395
−0
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.