diff --git a/src/contracts-api/orderbookAction.test.ts b/src/contracts-api/orderbookAction.test.ts new file mode 100644 index 0000000..5e29492 --- /dev/null +++ b/src/contracts-api/orderbookAction.test.ts @@ -0,0 +1,154 @@ +import { KwilSigner, NodeKwil } from "@trufnetwork/kwil-js"; +import { describe, it, expect, vi } from "vitest"; +import { OrderbookAction } from "./orderbookAction"; + +/** + * Pure-unit tests for the address-parameterized portfolio getters (migration 051): + * getPositionsByWallet / getCollateralByWallet. These read a wallet BY ADDRESS (not the signer's + * @caller), so an owner — or a delegated market-maker bot — can monitor an agent wallet's (MAA) + * inventory without holding its key. The kwil client is mocked; this layer only forwards the call + * with the right action name + named params and maps the returned rows. The on-chain behaviour is + * covered by the node integration tests (tests/streams/order_book/portfolio_by_wallet_test.go). + */ + +const mockSigner = { signatureType: "secp256k1_ep" } as unknown as KwilSigner; + +const ok = (result: unknown) => ({ status: 200, data: { result } }); + +function makeAction(call: ReturnType) { + const mockKwil = { call } as unknown as NodeKwil; + return new OrderbookAction(mockKwil, mockSigner); +} + +const wallet = "0x" + "ab".repeat(20); +const bareWallet = "ab".repeat(20); + +describe("OrderbookAction.getPositionsByWallet", () => { + it("calls get_positions_by_wallet with the wallet as a named param and maps the rows", async () => { + const call = vi.fn().mockResolvedValue( + ok([ + { query_id: 7, outcome: true, price: -55, amount: 100, position_type: "buy_order" }, + { query_id: 9, outcome: false, price: 0, amount: 40, position_type: "holding" }, + ]), + ); + const action = makeAction(call); + + const positions = await action.getPositionsByWallet(wallet); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + { namespace: "main", name: "get_positions_by_wallet", inputs: { $wallet_address: wallet } }, + mockSigner, + ); + expect(positions).toEqual([ + { queryId: 7, outcome: true, price: -55, amount: 100, positionType: "buy_order" }, + { queryId: 9, outcome: false, price: 0, amount: 40, positionType: "holding" }, + ]); + }); + + it("accepts a bare-hex wallet (no 0x prefix)", async () => { + const call = vi.fn().mockResolvedValue(ok([])); + const action = makeAction(call); + + await action.getPositionsByWallet(bareWallet); + + expect(call.mock.calls[0][0].inputs).toEqual({ $wallet_address: bareWallet }); + }); + + it("returns an empty array for a wallet that has never traded", async () => { + const call = vi.fn().mockResolvedValue(ok([])); + const action = makeAction(call); + + expect(await action.getPositionsByWallet(wallet)).toEqual([]); + }); + + it("rejects a malformed wallet address before calling", async () => { + const call = vi.fn(); + const action = makeAction(call); + + await expect(action.getPositionsByWallet("0x1234")).rejects.toThrow( + "wallet address must be 40 hex characters", + ); + expect(call).not.toHaveBeenCalled(); + }); + + it("throws on a non-200 status", async () => { + const call = vi.fn().mockResolvedValue({ status: 500, data: {} }); + const action = makeAction(call); + + await expect(action.getPositionsByWallet(wallet)).rejects.toThrow( + "Failed to get positions by wallet: 500", + ); + }); +}); + +describe("OrderbookAction.getCollateralByWallet", () => { + it("calls get_collateral_by_wallet with wallet + bridge named params and maps the row", async () => { + const call = vi.fn().mockResolvedValue( + ok([ + { + total_locked: "55000000000000000000", + buy_orders_locked: "55000000000000000000", + shares_value: "0", + }, + ]), + ); + const action = makeAction(call); + + const collateral = await action.getCollateralByWallet(wallet, "hoodi_tt2"); + + expect(call).toHaveBeenCalledWith( + { + namespace: "main", + name: "get_collateral_by_wallet", + inputs: { $wallet_address: wallet, $bridge: "hoodi_tt2" }, + }, + mockSigner, + ); + expect(collateral).toEqual({ + totalLocked: "55000000000000000000", + buyOrdersLocked: "55000000000000000000", + sharesValue: "0", + }); + }); + + it("returns zeros for a wallet that has never traded", async () => { + const call = vi.fn().mockResolvedValue(ok([])); + const action = makeAction(call); + + expect(await action.getCollateralByWallet(wallet, "hoodi_tt2")).toEqual({ + totalLocked: "0", + buyOrdersLocked: "0", + sharesValue: "0", + }); + }); + + it("rejects an invalid or empty order-book bridge before calling", async () => { + const call = vi.fn(); + const action = makeAction(call); + + // hoodi_tt is the funding/fee bridge, not a valid order-book collateral bridge. + await expect(action.getCollateralByWallet(wallet, "hoodi_tt")).rejects.toThrow("Invalid bridge"); + await expect(action.getCollateralByWallet(wallet, "")).rejects.toThrow("Invalid bridge"); + expect(call).not.toHaveBeenCalled(); + }); + + it("rejects a malformed wallet address before calling", async () => { + const call = vi.fn(); + const action = makeAction(call); + + await expect(action.getCollateralByWallet("nothex", "hoodi_tt2")).rejects.toThrow( + "wallet address", + ); + expect(call).not.toHaveBeenCalled(); + }); + + it("throws on a non-200 status", async () => { + const call = vi.fn().mockResolvedValue({ status: 503, data: {} }); + const action = makeAction(call); + + await expect(action.getCollateralByWallet(wallet, "hoodi_tt2")).rejects.toThrow( + "Failed to get collateral by wallet: 503", + ); + }); +}); diff --git a/src/contracts-api/orderbookAction.ts b/src/contracts-api/orderbookAction.ts index adc8105..4fcd22f 100644 --- a/src/contracts-api/orderbookAction.ts +++ b/src/contracts-api/orderbookAction.ts @@ -12,6 +12,7 @@ import { MarketValidation, OrderBookEntry, UserPosition, + WalletPosition, DepthLevel, BestPrices, UserCollateral, @@ -33,6 +34,7 @@ import { RawMarketSummary, RawOrderBookEntry, RawUserPosition, + RawWalletPosition, RawDepthLevel, RawBestPrices, RawUserCollateral, @@ -52,6 +54,7 @@ import { validateBridge, validateMaxSpread, validateSettleTime, + validateWalletHex, settledFilterToBoolean, } from "../util/orderbookHelpers"; @@ -693,6 +696,87 @@ export class OrderbookAction { }; } + /** + * Gets a wallet's positions across all markets BY ADDRESS (get_positions_by_wallet, migration 051). + * + * Unlike getUserPositions (which reads the signer's own @caller positions), this reads the wallet + * you pass in — so an owner, or a delegated market-maker bot, can monitor an agent wallet's (MAA) + * inventory without holding its key. + * + * @param walletHex - The wallet to read, as a 20-byte hex address (with or without a 0x prefix). + * @returns The wallet's positions (an empty array if it has never traded). + */ + async getPositionsByWallet(walletHex: string): Promise { + validateWalletHex(walletHex); + + const result = await this.kwilClient.call( + { + namespace: "main", + name: "get_positions_by_wallet", + inputs: { $wallet_address: walletHex }, + }, + this.kwilSigner + ); + + if (result.status !== 200) { + throw new Error(`Failed to get positions by wallet: ${result.status}`); + } + + const rows = (result.data?.result as RawWalletPosition[]) || []; + return rows.map((row) => ({ + queryId: row.query_id, + outcome: row.outcome, + price: row.price, + amount: row.amount, + positionType: row.position_type as WalletPosition["positionType"], + })); + } + + /** + * Gets a wallet's total locked collateral on one bridge BY ADDRESS (get_collateral_by_wallet, + * migration 051). + * + * Unlike getUserCollateral (which reads the signer), this reads the wallet you pass in. The bridge + * is required (per-bridge token decimals) — use the bridge the order-book markets settle in + * (e.g. hoodi_tt2 / eth_usdc), not the wallet's funding/fee bridge. + * + * @param walletHex - The wallet to read, as a 20-byte hex address (with or without a 0x prefix). + * @param bridge - The order-book collateral bridge namespace (required). + * @returns The wallet's collateral breakdown (all zeros if it has never traded). + */ + async getCollateralByWallet( + walletHex: string, + bridge: string + ): Promise { + validateWalletHex(walletHex); + validateBridge(bridge); + + const result = await this.kwilClient.call( + { + namespace: "main", + name: "get_collateral_by_wallet", + inputs: { $wallet_address: walletHex, $bridge: bridge }, + }, + this.kwilSigner + ); + + if (result.status !== 200) { + throw new Error(`Failed to get collateral by wallet: ${result.status}`); + } + + const rows = result.data?.result as RawUserCollateral[]; + if (!rows || rows.length === 0) { + return { totalLocked: "0", buyOrdersLocked: "0", sharesValue: "0" }; + } + + const row = rows[0]; + return { + totalLocked: row.total_locked, + buyOrdersLocked: row.buy_orders_locked, + sharesValue: row.shares_value, + }; + } + // ========================================== // Settlement & Rewards // ========================================== diff --git a/src/internal.ts b/src/internal.ts index 5db66cb..1b800a9 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -112,6 +112,7 @@ export type { MarketValidation, OrderBookEntry, UserPosition, + WalletPosition, DepthLevel, BestPrices, UserCollateral, diff --git a/src/types/orderbook.ts b/src/types/orderbook.ts index 1ff7f11..d2e417b 100644 --- a/src/types/orderbook.ts +++ b/src/types/orderbook.ts @@ -133,6 +133,31 @@ export interface UserPosition { lastUpdated: number; } +/** + * A wallet's position in a market, read BY ADDRESS via get_positions_by_wallet (migration 051). + * + * Unlike UserPosition (the @caller-scoped get_user_positions), this carries a position_type + * ('holding' | 'buy_order' | 'sell_order') instead of a last-updated timestamp — it mirrors the + * columns the node's get_positions_by_wallet action returns. + */ +export interface WalletPosition { + /** Market identifier */ + queryId: number; + /** Outcome: true=YES, false=NO */ + outcome: boolean; + /** + * Price in cents: + * - Negative: Buy order + * - Zero: Holding + * - Positive: Sell order + */ + price: number; + /** Number of shares */ + amount: number; + /** Kind of position: 'holding' | 'buy_order' | 'sell_order' */ + positionType: "holding" | "buy_order" | "sell_order"; +} + /** Aggregated depth at a price level */ export interface DepthLevel { /** Price level in cents */ @@ -391,6 +416,15 @@ export interface RawUserPosition { last_updated: number; } +/** @internal Raw wallet position from get_positions_by_wallet (migration 051) */ +export interface RawWalletPosition { + query_id: number; + outcome: boolean; + price: number; + amount: number; + position_type: string; +} + /** @internal Raw depth level from database */ export interface RawDepthLevel { price: number | string; diff --git a/src/util/orderbookHelpers.ts b/src/util/orderbookHelpers.ts index 6a17af9..f1c060e 100644 --- a/src/util/orderbookHelpers.ts +++ b/src/util/orderbookHelpers.ts @@ -397,6 +397,30 @@ export function validateAmount(amount: number, operation: string): void { * @param bridge - Bridge identifier to validate * @throws Error if bridge is invalid */ +/** + * Validates a wallet address argument for the by-wallet portfolio getters (migration 051): a + * 20-byte hex address, with or without a 0x prefix, matching the node's get_positions_by_wallet / + * get_collateral_by_wallet normalization. + * + * @param wallet - The wallet address to validate. + * @throws Error if it is empty, not 40 hex characters (after an optional 0x prefix), or non-hex. + */ +export function validateWalletHex(wallet: string): void { + if (!wallet) { + throw new Error("wallet address is required"); + } + const hex = + wallet.startsWith("0x") || wallet.startsWith("0X") ? wallet.slice(2) : wallet; + if (hex.length !== 40) { + throw new Error( + `wallet address must be 40 hex characters (after an optional 0x prefix), got ${hex.length}` + ); + } + if (!/^[0-9a-fA-F]{40}$/.test(hex)) { + throw new Error("wallet address contains invalid hex characters"); + } +} + export function validateBridge(bridge: string): void { const validBridges = [ "eth_usdc",