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
64 changes: 64 additions & 0 deletions core/contractsapi/order_book_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,70 @@ func (o *OrderBook) GetUserCollateral(ctx context.Context) (*types.UserCollatera
return collateral, nil
}

// GetPositionsByWallet retrieves a wallet's portfolio by address
// Maps to: get_positions_by_wallet($wallet_address)
// Migration: 051-order-book-portfolio-by-wallet.sql
//
// Unlike GetUserPositions (which reads @caller), this reads the wallet passed in,
// so an owner can read an agent wallet's (MAA) positions without holding its key.
func (o *OrderBook) GetPositionsByWallet(ctx context.Context, input types.GetPositionsByWalletInput) ([]types.UserPosition, error) {
if err := input.Validate(); err != nil {
return nil, errors.WithStack(err)
}

args := []any{input.WalletHex}
result, err := o.call(ctx, "get_positions_by_wallet", args)
if err != nil {
return nil, errors.WithStack(err)
}

var positions []types.UserPosition
for _, row := range result.Values {
position, err := parseUserPositionRow(row)
if err != nil {
return nil, errors.WithStack(err)
}
positions = append(positions, position)
}

return positions, nil
}

// GetCollateralByWallet returns a wallet's total locked collateral by address
// Maps to: get_collateral_by_wallet($wallet_address, $bridge)
// Migration: 051-order-book-portfolio-by-wallet.sql
//
// Unlike GetUserCollateral (which reads @caller), this reads the wallet passed in.
// bridge is required (per-bridge decimals).
func (o *OrderBook) GetCollateralByWallet(ctx context.Context, input types.GetCollateralByWalletInput) (*types.UserCollateral, error) {
if err := input.Validate(); err != nil {
return nil, errors.WithStack(err)
}

args := []any{input.WalletHex, input.Bridge}
result, err := o.call(ctx, "get_collateral_by_wallet", args)
if err != nil {
return nil, errors.WithStack(err)
}

if len(result.Values) == 0 {
// No positions, return zeros
return &types.UserCollateral{
TotalLocked: "0",
BuyOrdersLocked: "0",
SharesValue: "0",
}, nil
}

row := result.Values[0]
collateral, err := parseUserCollateralRow(row)
if err != nil {
return nil, errors.WithStack(err)
}

return collateral, nil
}

// ═══════════════════════════════════════════════════════════════
// PARSING HELPERS
// ═══════════════════════════════════════════════════════════════
Expand Down
55 changes: 55 additions & 0 deletions core/types/order_book_portfolio_by_wallet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package types

import "testing"

// The address-parameterized portfolio getters accept a wallet with OR without a
// 0x prefix (the node action normalizes either form), so validation must too.

func TestGetPositionsByWalletInput_Validate(t *testing.T) {
cases := []struct {
name string
input GetPositionsByWalletInput
wantErr bool
}{
{"0x-prefixed", GetPositionsByWalletInput{WalletHex: "0x12aae9a9cf034cb71cbf17cfa1e9612cda8e8a87"}, false},
{"bare hex (no 0x)", GetPositionsByWalletInput{WalletHex: "12aae9a9cf034cb71cbf17cfa1e9612cda8e8a87"}, false},
{"empty", GetPositionsByWalletInput{WalletHex: ""}, true},
{"too short", GetPositionsByWalletInput{WalletHex: "0x1234"}, true},
{"non-hex", GetPositionsByWalletInput{WalletHex: "0xZZae9a9cf034cb71cbf17cfa1e9612cda8e8a87"}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.input.Validate()
if c.wantErr && err == nil {
t.Fatalf("Validate(%q) = nil, want error", c.input.WalletHex)
}
if !c.wantErr && err != nil {
t.Fatalf("Validate(%q) = %v, want nil", c.input.WalletHex, err)
}
})
}
}

func TestGetCollateralByWalletInput_Validate(t *testing.T) {
cases := []struct {
name string
input GetCollateralByWalletInput
wantErr bool
}{
{"valid 0x + bridge", GetCollateralByWalletInput{WalletHex: "0x12aae9a9cf034cb71cbf17cfa1e9612cda8e8a87", Bridge: "hoodi_tt"}, false},
{"valid bare + bridge", GetCollateralByWalletInput{WalletHex: "12aae9a9cf034cb71cbf17cfa1e9612cda8e8a87", Bridge: "eth_truf"}, false},
{"missing bridge", GetCollateralByWalletInput{WalletHex: "0x12aae9a9cf034cb71cbf17cfa1e9612cda8e8a87", Bridge: ""}, true},
{"bad wallet", GetCollateralByWalletInput{WalletHex: "nope", Bridge: "hoodi_tt"}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.input.Validate()
if c.wantErr && err == nil {
t.Fatalf("Validate(%+v) = nil, want error", c.input)
}
if !c.wantErr && err != nil {
t.Fatalf("Validate(%+v) = %v, want nil", c.input, err)
}
})
}
}
58 changes: 58 additions & 0 deletions core/types/order_book_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ type IOrderBook interface {
// Migration: 038-order-book-queries.sql:279-359
GetUserCollateral(ctx context.Context) (*UserCollateral, error)

// GetPositionsByWallet retrieves a wallet's portfolio by address (not @caller)
// Maps to: get_positions_by_wallet($wallet_address)
// Migration: 051-order-book-portfolio-by-wallet.sql
GetPositionsByWallet(ctx context.Context, input GetPositionsByWalletInput) ([]UserPosition, error)

// GetCollateralByWallet returns a wallet's locked collateral by address (not @caller)
// Maps to: get_collateral_by_wallet($wallet_address, $bridge)
// Migration: 051-order-book-portfolio-by-wallet.sql
GetCollateralByWallet(ctx context.Context, input GetCollateralByWalletInput) (*UserCollateral, error)

// ═══════════════════════════════════════════════════════════════
// SETTLEMENT & REWARDS
// ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -585,6 +595,54 @@ func (g *GetParticipantRewardHistoryInput) Validate() error {
return nil
}

// validateWalletHexArg validates a wallet-address argument for the
// address-parameterized portfolio getters: 40 hex characters, with or without a
// "0x"/"0X" prefix (the node action normalizes either form, so callers may pass
// whichever they hold).
func validateWalletHexArg(wallet string) error {
if wallet == "" {
return fmt.Errorf("wallet address is required")
}
h := wallet
if len(h) >= 2 && h[0] == '0' && (h[1] == 'x' || h[1] == 'X') {
h = h[2:]
}
if len(h) != 40 {
return fmt.Errorf("wallet address must be 40 hex characters (optionally 0x-prefixed), got %q", wallet)
}
if _, err := hex.DecodeString(h); err != nil {
return fmt.Errorf("wallet address contains invalid hex characters: %w", err)
}
return nil
}

// GetPositionsByWalletInput contains parameters for reading a wallet's positions by address.
type GetPositionsByWalletInput struct {
WalletHex string // Ethereum address, with or without a 0x prefix
}

// Validate checks if GetPositionsByWalletInput is valid
func (g *GetPositionsByWalletInput) Validate() error {
return validateWalletHexArg(g.WalletHex)
}

// GetCollateralByWalletInput contains parameters for reading a wallet's locked collateral by address.
type GetCollateralByWalletInput struct {
WalletHex string // Ethereum address, with or without a 0x prefix
Bridge string // Bridge namespace (e.g. eth_truf / hoodi_tt); required (per-bridge decimals)
}

// Validate checks if GetCollateralByWalletInput is valid
func (g *GetCollateralByWalletInput) Validate() error {
if err := validateWalletHexArg(g.WalletHex); err != nil {
return err
}
if g.Bridge == "" {
return fmt.Errorf("bridge is required")
}
return nil
}

// ═══════════════════════════════════════════════════════════════
// OUTPUT TYPES - MARKET OPERATIONS
// ═══════════════════════════════════════════════════════════════
Expand Down
Loading