From 7f697812b994a9b92362a2958d5f7ee1d6f66834 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Tue, 23 Jun 2026 16:00:55 +0700 Subject: [PATCH] feat: read any wallet's order-book positions and collateral --- .../051-order-book-portfolio-by-wallet.sql | 146 ++++++++++ .../order_book/portfolio_by_wallet_test.go | 249 ++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 internal/migrations/051-order-book-portfolio-by-wallet.sql create mode 100644 tests/streams/order_book/portfolio_by_wallet_test.go diff --git a/internal/migrations/051-order-book-portfolio-by-wallet.sql b/internal/migrations/051-order-book-portfolio-by-wallet.sql new file mode 100644 index 00000000..d284de42 --- /dev/null +++ b/internal/migrations/051-order-book-portfolio-by-wallet.sql @@ -0,0 +1,146 @@ +/* + * MIGRATION 051: ORDER-BOOK PORTFOLIO BY WALLET + * + * Address-parameterized, read-only getters for a wallet's current portfolio: + * positions (holdings + open orders) and locked collateral. + * + * The 038 portfolio getters (get_user_positions / get_user_collateral) read + * @caller, so they only ever return the signer's own rows. That is the wrong + * shape for monitoring a wallet you do not sign for, e.g. an owner (or a bot) + * watching an agent wallet (MAA) a delegated key operates: the positions are + * recorded under the MAA's participant id, not the signer's. 050 already did + * this for order-event history; these two complete the set for the live + * portfolio an inventory-aware market maker needs. + * + * Bodies mirror 038 exactly except the participant is resolved from the + * $wallet_address argument (normalized like 050) instead of @caller. Because + * $bridge is a required argument here — not an env-defaulted one — this file + * references no bridge namespace literal and therefore needs no dev/prod twin. + * It only adds new PUBLIC VIEW reads over existing tables (no ALTER), so it is + * safe to apply against a live, settling node. + */ + +-- ============================================================================= +-- get_positions_by_wallet: a wallet's holdings + open orders across all markets. +-- Mirrors get_user_positions (038) but for an arbitrary address. Returns an +-- empty result if the wallet never participated. +-- ============================================================================= +CREATE OR REPLACE ACTION get_positions_by_wallet($wallet_address TEXT) +PUBLIC VIEW RETURNS TABLE( + query_id INT, + outcome BOOL, + price INT, + amount INT8, + position_type TEXT +) { + -- Normalize the wallet: accept with/without a 0x prefix; require 40 hex chars + -- (decode rejects non-hex digits). Mirrors the 050 getter normalization. + $wallet_hex TEXT := LOWER($wallet_address); + if substring($wallet_hex, 1, 2) = '0x' { $wallet_hex := substring($wallet_hex, 3, length($wallet_hex)); } + if length($wallet_hex) != 40 { ERROR('wallet_address must be a 20-byte hex address (40 hex chars, optional 0x prefix)'); } + $wallet_bytes BYTEA := decode($wallet_hex, 'hex'); + + -- Resolve the participant id; a wallet that never traded has no row -> empty result. + $participant_id INT; + for $row in SELECT id FROM ob_participants WHERE wallet_address = $wallet_bytes { + $participant_id := $row.id; + } + if $participant_id IS NULL { + RETURN; + } + + -- Return all positions for this wallet (same shape/order as get_user_positions). + for $pos in + SELECT + query_id, + outcome, + price, + amount, + CASE + WHEN price = 0 THEN 'holding' + WHEN price < 0 THEN 'buy_order' + ELSE 'sell_order' + END as position_type + FROM ob_positions + WHERE participant_id = $participant_id + ORDER BY query_id, outcome, price DESC + { + RETURN NEXT $pos.query_id, $pos.outcome, $pos.price, $pos.amount, $pos.position_type; + } +}; + +-- ============================================================================= +-- get_collateral_by_wallet: a wallet's locked collateral on one bridge. +-- Mirrors get_user_collateral (038) but for an arbitrary address. $bridge is +-- required (per-bridge decimals), which keeps this twin-free. Returns zeros if +-- the wallet never participated. +-- ============================================================================= +CREATE OR REPLACE ACTION get_collateral_by_wallet($wallet_address TEXT, $bridge TEXT) +PUBLIC VIEW RETURNS ( + total_locked NUMERIC(78, 0), + buy_orders_locked NUMERIC(78, 0), + shares_value NUMERIC(78, 0) +) { + -- Bridge token base units per $1.00 (single source of per-bridge decimals). + $units_per_dollar NUMERIC(78, 0) := get_bridge_units_per_dollar($bridge); + + -- Normalize the wallet: accept with/without a 0x prefix; require 40 hex chars. + $wallet_hex TEXT := LOWER($wallet_address); + if substring($wallet_hex, 1, 2) = '0x' { $wallet_hex := substring($wallet_hex, 3, length($wallet_hex)); } + if length($wallet_hex) != 40 { ERROR('wallet_address must be a 20-byte hex address (40 hex chars, optional 0x prefix)'); } + $wallet_bytes BYTEA := decode($wallet_hex, 'hex'); + + -- Lookup participant ID + $participant_id INT; + for $row in SELECT id FROM ob_participants WHERE wallet_address = $wallet_bytes { + $participant_id := $row.id; + } + + -- Return zeros if participant doesn't exist + if $participant_id IS NULL { + RETURN 0::NUMERIC(78, 0), 0::NUMERIC(78, 0), 0::NUMERIC(78, 0); + } + + -- Buy order collateral, in bridge token base units: + -- ($amount * |price| * units_per_dollar) / 100 + -- Restricted to markets that use $bridge. + $buy_locked NUMERIC(78, 0); + for $row in + SELECT COALESCE( + SUM(abs(p.price)::NUMERIC(78, 0) * p.amount::NUMERIC(78, 0))::NUMERIC(78, 0), + 0::NUMERIC(78, 0) + ) as price_amount_sum + FROM ob_positions p + JOIN ob_queries q ON p.query_id = q.id + WHERE p.participant_id = $participant_id + AND p.price < 0 + AND q.bridge = $bridge + { + $buy_locked := ($row.price_amount_sum * $units_per_dollar) / 100::NUMERIC(78, 0); + } + + -- Shares value (holdings + open sells), valued at $1.00 per share, in + -- bridge token base units. Restricted to markets that use $bridge. + -- + -- KNOWN ISSUE (inherited from get_user_collateral): this sums YES + NO + -- unconditionally, so a paired holding (100 YES + 100 NO from a split mint) + -- reports 200 × $1 instead of the 100 × $1 collateral that actually backs + -- it. Correct per-market netting needs MAX(net_yes, net_no); kept identical + -- to get_user_collateral so both getters agree until that coordinated change. + $shares_value NUMERIC(78, 0); + for $row in + SELECT COALESCE(SUM(p.amount)::NUMERIC(78, 0), 0::NUMERIC(78, 0)) as amount_sum + FROM ob_positions p + JOIN ob_queries q ON p.query_id = q.id + WHERE p.participant_id = $participant_id + AND p.price >= 0 + AND q.bridge = $bridge + { + $shares_value := $row.amount_sum * $units_per_dollar; + } + + -- Total locked collateral + $total_locked NUMERIC(78, 0) := $buy_locked + $shares_value; + + RETURN $total_locked, $buy_locked, $shares_value; +}; diff --git a/tests/streams/order_book/portfolio_by_wallet_test.go b/tests/streams/order_book/portfolio_by_wallet_test.go new file mode 100644 index 00000000..81964f1c --- /dev/null +++ b/tests/streams/order_book/portfolio_by_wallet_test.go @@ -0,0 +1,249 @@ +//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" + "github.com/trufnetwork/sdk-go/core/util" +) + +// TestPortfolioByWallet exercises the address-parameterized portfolio getters +// (get_positions_by_wallet / get_collateral_by_wallet) added in migration 051. +// +// Unlike get_user_positions / get_user_collateral (which read @caller), these +// take the wallet as an argument so an owner can read an agent wallet's (MAA) +// portfolio without holding its key. Every test signs the read as a DIFFERENT +// wallet than the one whose portfolio it asks for — that is the property the +// feature exists for. +func TestPortfolioByWallet(t *testing.T) { + owner := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "ORDER_BOOK_PORTFOLIO_BY_WALLET", + SeedStatements: migrations.GetSeedScriptStatements(), + Owner: owner.Address(), + FunctionTests: []kwilTesting.TestFunc{ + testGetPositionsByWalletEmpty(t), + testGetPositionsByWalletReadsAnotherWallet(t), + testGetPositionsByWalletAcceptsBareHex(t), + testGetPositionsByWalletRejectsMalformedAddress(t), + testGetCollateralByWalletEmpty(t), + testGetCollateralByWalletReadsAnotherWallet(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// An address that never traded returns an empty result, even though the reader +// is a different (also-non-participant) wallet. +func testGetPositionsByWalletEmpty(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + reader := util.Unsafe_NewEthereumAddressFromString("0xC1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1") + neverTraded := "0xD2D2D2D2D2D2D2D2D2D2D2D2D2D2D2D2D2D2D2D2" + + var positions []UserPosition + err = callGetPositionsByWallet(ctx, platform, &reader, neverTraded, func(row *common.Row) error { + positions = append(positions, UserPosition{}) + return nil + }) + require.NoError(t, err) + require.Empty(t, positions, "a wallet that never traded should have no positions") + + return nil + } +} + +// A trader places a buy order; a DIFFERENT reader asks for the trader's +// positions by address and sees the buy order — while the reader's own +// @caller-scoped positions stay empty. +func testGetPositionsByWalletReadsAnotherWallet(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + trader := util.Unsafe_NewEthereumAddressFromString("0xC3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3") + reader := util.Unsafe_NewEthereumAddressFromString("0xC4C4C4C4C4C4C4C4C4C4C4C4C4C4C4C4C4C4C4C4") + + err = giveBalanceQueries(ctx, platform, trader.Address(), "500000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &trader) + + err = callPlaceBuyOrderQueries(ctx, platform, &trader, queryID, true, 55, 100, nil) + require.NoError(t, err) + + // The reader (a different wallet) reads the trader's positions by address. + var positions []UserPosition + err = callGetPositionsByWallet(ctx, platform, &reader, trader.Address(), func(row *common.Row) error { + positions = append(positions, UserPosition{ + QueryID: int(row.Values[0].(int64)), + PositionType: row.Values[4].(string), + }) + return nil + }) + require.NoError(t, err) + require.Len(t, positions, 1, "reader should see the trader's single buy order") + require.Equal(t, "buy_order", positions[0].PositionType) + require.Equal(t, queryID, positions[0].QueryID) + + // Proof this is NOT @caller-scoped: the reader's own portfolio is empty. + var readerOwn []UserPosition + err = callGetUserPositions(ctx, platform, &reader, func(row *common.Row) error { + readerOwn = append(readerOwn, UserPosition{}) + return nil + }) + require.NoError(t, err) + require.Empty(t, readerOwn, "the reader has no positions of its own") + + return nil + } +} + +// The getter accepts the wallet with OR without a 0x prefix (the bot may hold +// either form), returning the same positions. +func testGetPositionsByWalletAcceptsBareHex(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + trader := util.Unsafe_NewEthereumAddressFromString("0xC9C9C9C9C9C9C9C9C9C9C9C9C9C9C9C9C9C9C9C9") + reader := util.Unsafe_NewEthereumAddressFromString("0xCACACACACACACACACACACACACACACACACACACACA") + + err = giveBalanceQueries(ctx, platform, trader.Address(), "500000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &trader) + + err = callPlaceBuyOrderQueries(ctx, platform, &trader, queryID, true, 55, 100, nil) + require.NoError(t, err) + + // Strip the 0x prefix — the getter must still resolve the same wallet. + bareHex := trader.Address()[2:] + + var positions []UserPosition + err = callGetPositionsByWallet(ctx, platform, &reader, bareHex, func(row *common.Row) error { + positions = append(positions, UserPosition{PositionType: row.Values[4].(string)}) + return nil + }) + require.NoError(t, err) + require.Len(t, positions, 1, "bare hex (no 0x) must resolve the same wallet") + require.Equal(t, "buy_order", positions[0].PositionType) + + return nil + } +} + +// A malformed wallet argument is rejected (the normalization must not silently +// resolve a garbage address to an empty portfolio). +func testGetPositionsByWalletRejectsMalformedAddress(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + reader := util.Unsafe_NewEthereumAddressFromString("0xCBCBCBCBCBCBCBCBCBCBCBCBCBCBCBCBCBCBCBCB") + + err = callGetPositionsByWallet(ctx, platform, &reader, "0x1234", func(row *common.Row) error { + return nil + }) + require.Error(t, err, "a too-short wallet address must be rejected") + + return nil + } +} + +// An address that never traded reports zero collateral, read by a different wallet. +func testGetCollateralByWalletEmpty(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + reader := util.Unsafe_NewEthereumAddressFromString("0xC5C5C5C5C5C5C5C5C5C5C5C5C5C5C5C5C5C5C5C5") + neverTraded := "0xD6D6D6D6D6D6D6D6D6D6D6D6D6D6D6D6D6D6D6D6" + + var total string + err = callGetCollateralByWallet(ctx, platform, &reader, neverTraded, testExtensionNameQueries, func(row *common.Row) error { + total = row.Values[0].(*types.Decimal).String() + return nil + }) + require.NoError(t, err) + require.Equal(t, "0", total, "a wallet that never traded has no locked collateral") + + return nil + } +} + +// A trader locks collateral in a buy order; a different reader reads the +// trader's collateral by address and sees the locked amount. +func testGetCollateralByWalletReadsAnotherWallet(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + trader := util.Unsafe_NewEthereumAddressFromString("0xC7C7C7C7C7C7C7C7C7C7C7C7C7C7C7C7C7C7C7C7") + reader := util.Unsafe_NewEthereumAddressFromString("0xC8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8C8") + + err = giveBalanceQueries(ctx, platform, trader.Address(), "500000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &trader) + + // Buy 100 @ 55 = 55 tokens locked. + err = callPlaceBuyOrderQueries(ctx, platform, &trader, queryID, true, 55, 100, nil) + require.NoError(t, err) + + var total, buyLocked, shareValue string + err = callGetCollateralByWallet(ctx, platform, &reader, trader.Address(), testExtensionNameQueries, func(row *common.Row) error { + total = row.Values[0].(*types.Decimal).String() + buyLocked = row.Values[1].(*types.Decimal).String() + shareValue = row.Values[2].(*types.Decimal).String() + return nil + }) + require.NoError(t, err) + require.Equal(t, "55000000000000000000", buyLocked, "55 tokens locked in the buy order") + require.Equal(t, "0", shareValue, "no shares held") + require.Equal(t, "55000000000000000000", total, "total equals buy-order collateral") + + return nil + } +} + +// ============================================================================ +// Helpers for the address-parameterized getters +// ============================================================================ + +func callGetPositionsByWallet(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, walletAddress string, resultFn func(*common.Row) error) error { + return callActionQueries(ctx, platform, signer, "get_positions_by_wallet", []any{walletAddress}, resultFn) +} + +func callGetCollateralByWallet(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, walletAddress string, bridge string, resultFn func(*common.Row) error) error { + return callActionQueries(ctx, platform, signer, "get_collateral_by_wallet", []any{walletAddress, bridge}, resultFn) +}