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
146 changes: 146 additions & 0 deletions internal/migrations/051-order-book-portfolio-by-wallet.sql
Original file line number Diff line number Diff line change
@@ -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;
};
249 changes: 249 additions & 0 deletions tests/streams/order_book/portfolio_by_wallet_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading