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
154 changes: 154 additions & 0 deletions src/contracts-api/orderbookAction.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>) {
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",
);
});
});
84 changes: 84 additions & 0 deletions src/contracts-api/orderbookAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
MarketValidation,
OrderBookEntry,
UserPosition,
WalletPosition,
DepthLevel,
BestPrices,
UserCollateral,
Expand All @@ -33,6 +34,7 @@ import {
RawMarketSummary,
RawOrderBookEntry,
RawUserPosition,
RawWalletPosition,
RawDepthLevel,
RawBestPrices,
RawUserCollateral,
Expand All @@ -52,6 +54,7 @@ import {
validateBridge,
validateMaxSpread,
validateSettleTime,
validateWalletHex,
settledFilterToBoolean,
} from "../util/orderbookHelpers";

Expand Down Expand Up @@ -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<WalletPosition[]> {
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<UserCollateral> {
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
// ==========================================
Expand Down
1 change: 1 addition & 0 deletions src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export type {
MarketValidation,
OrderBookEntry,
UserPosition,
WalletPosition,
DepthLevel,
BestPrices,
UserCollateral,
Expand Down
34 changes: 34 additions & 0 deletions src/types/orderbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions src/util/orderbookHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading