diff --git a/core/contractsapi/order_book_queries.go b/core/contractsapi/order_book_queries.go index d6e3611..4940f8f 100644 --- a/core/contractsapi/order_book_queries.go +++ b/core/contractsapi/order_book_queries.go @@ -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 // ═══════════════════════════════════════════════════════════════ diff --git a/core/types/order_book_portfolio_by_wallet_test.go b/core/types/order_book_portfolio_by_wallet_test.go new file mode 100644 index 0000000..1488d4c --- /dev/null +++ b/core/types/order_book_portfolio_by_wallet_test.go @@ -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) + } + }) + } +} diff --git a/core/types/order_book_types.go b/core/types/order_book_types.go index 9648266..f47f7c6 100644 --- a/core/types/order_book_types.go +++ b/core/types/order_book_types.go @@ -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 // ═══════════════════════════════════════════════════════════════ @@ -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 // ═══════════════════════════════════════════════════════════════