Skip to content
Open
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
3 changes: 3 additions & 0 deletions ts-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ const ob = useSyncExternalStore(
`.backstopTransfers(account)`, `.candles(id, resolution, range)` (a
`SeriesResource` with `setWindow`/`loadOlder`), `.orders(account, query)`,
`.bridgeConfig`, `.withdrawals(account)`.
- **Activity (ADR 0057):** `client.activity(account, query)` — one account's
orders and the money it moved in one cursor-paged, `onEvent`-observable feed;
`.orders(account)` is the narrower view it replaces.
- **Charting:** `createPodDatafeed(client)` returns an `IDatafeedChartApi`-shaped
object for the TradingView Charting Library (framework-agnostic, no React).

Expand Down
4 changes: 2 additions & 2 deletions ts-sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ts-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pod-network/trade-sdk",
"version": "0.10.0",
"version": "0.11.0",
"description": "Read/stream TypeScript SDK for the pod trading indexer: cacheable REST seeds + one multiplexed WebSocket, kept in memory. Framework-agnostic.",
"type": "module",
"sideEffects": false,
Expand Down
36 changes: 36 additions & 0 deletions ts-sdk/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Resource memoisation. Two calls that ask for the same feed must hand back the
// same instance: a second one is a second REST seed and a second socket
// subscription for a list the app already has.

import { describe, expect, it } from "vitest";

import { PodTradeClient } from "./client.js";
import type { Address } from "./types/public.js";

const ACCOUNT = "0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" as Address;

class FakeWebSocket {
constructor(public url: string) {}
close(): void {}
send(): void {}
}

const client = () => new PodTradeClient({
restUrl: "http://node.test/v1",
wsUrl: "ws://node.test/v1",
WebSocket: FakeWebSocket as never,
});

describe("PodTradeClient.activity", () => {
it("reads no filter and an empty filter as one feed", () => {
const c = client();
try {
expect(c.activity(ACCOUNT, { types: [] })).toBe(c.activity(ACCOUNT));
// Key order is how the caller typed the object, not part of the question.
expect(c.activity(ACCOUNT, { to: 2, from: 1 })).toBe(c.activity(ACCOUNT, { from: 1, to: 2 }));
expect(c.activity(ACCOUNT, { from: 1 })).not.toBe(c.activity(ACCOUNT));
} finally {
c.close();
}
});
});
21 changes: 20 additions & 1 deletion ts-sdk/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {
Address, BackstopTransfer, Balances, Bar, BridgeConfig, Market,
ActivityQuery, Address, BackstopTransfer, Balances, Bar, BridgeConfig, Market,
MarketId, PositionsSnapshot, Resolution, Status, TimeRange, Trigger, TriggersQuery, TxExplorer,
OrdersQuery, Withdrawal,
} from "./types/public.js";
Expand All @@ -13,6 +13,7 @@ import {
import { withdrawalsSource } from "./sync/withdrawals.js";
import { CandleSeries, candleTailFrom, fetchCandleHistory } from "./sync/candles.js";
import { OrderHistory } from "./sync/orders.js";
import { ActivityHistory, normalizeActivityQuery } from "./sync/activity.js";
import { enrichPositions } from "./sync/positions-live.js";
import { fetchPnlHistory, streamPnlHistory, PnlHistoryCache, type PnlHistory, type PnlHistoryChunk, type PnlHistoryQuery } from "./sync/pnl-history.js";

Expand Down Expand Up @@ -306,6 +307,24 @@ export class PodTradeClient {
}
}

/**
* One account's whole activity, newest first (ADR 0057): its orders, the
* backstop legs it was swept into, and every bridge transfer and transfer that
* moved its money.
*
* `orders` is a separate stream (`pod_orders_v2`), with its own cursor and its
* own row shape — not this feed narrowed to orders. It is to be retired in
* favour of this one, so new code should start here.
*/
activity(account: Address, query?: ActivityQuery): ActivityHistory {
// The constructor normalises too, so the key must — otherwise `{}` and
// `{ types: [] }` ask for the same feed and get two of them, each with its own
// socket subscription. Sorted, since key order is an argument's accident.
const q = normalizeActivityQuery(query);
const key = `activity:${account.toLowerCase()}:${JSON.stringify(q, Object.keys(q).sort())}`;
return this.memo(key, () => new ActivityHistory(this.ctx, account, query));
}

orders(account: Address, query?: OrdersQuery): OrderHistory {
const key = `orders:${account}:${query ? JSON.stringify(query) : ""}`;
return this.memo(key, () => new OrderHistory(this.ctx, account, query));
Expand Down
281 changes: 281 additions & 0 deletions ts-sdk/src/codec/activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
// The activity seed's entry union and the `pod_activity` frame fold. None of
// it is reachable from `typecheck`: an entry is `unknown` off REST and a frame is
// `unknown` off the socket, so every field mapping is only ever checked here.
//
// Shapes are written from the node: `ActivityEntry` in `node/src/rpc/types.rs`,
// `ActivityFrame`/`MoneyEvent` in `node/src/rpc/activity.rs`, and the JSON its
// own tests assert (`node/tests/activity_frame.rs`, `clob_indexer::rest::tests`).
// Encodings follow from those types: a `U256` is `0x` hex, an `I256` a signed
// decimal string, a `WireDec` a signed decimal string, and an absent field is
// absent rather than null.

import { describe, expect, it } from "vitest";

import type { ActivityEntry, Address, MarketId } from "../types/public.js";
import type { WireActivityEntry, WireActivityFrame } from "../types/wire.js";
import { decodeActivityEntry } from "./decode.js";
import { applyActivityFrame } from "./activity.js";
import { WAD } from "./units.js";

const ALICE = "0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" as Address;
const TOKEN = "0x7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e" as Address;
const BOOK = "0x000000000000000000000000000000000000000000000000000000000000000a" as MarketId;
const TICK = 5_000_000;

const hex = (n: bigint) => `0x${n.toString(16)}`;
const units = (n: bigint) => n * WAD;

/** `{ activity_type: "order", ts, ...OrderResponse }`. */
const ORDER_ENTRY: WireActivityEntry = {
activity_type: "order",
ts: 8_000_000,
orderbook_id: BOOK,
market_type: "perpetual",
kind: "user_signed",
order_id: "0x0101010101010101010101010101010101010101010101010101010101010101",
tx_hash: "0x6565656565656565656565656565656565656565656565656565656565656565",
bidder: ALICE,
nonce: 1,
order_type: "limit",
status: "active",
side: "buy",
price: hex(units(100n)),
initial_size: units(2n).toString(),
filled_base_amount: hex(units(1n)),
filled_quote_amount: hex(units(100n)),
fee: "0x7",
deadline: 8_000_000,
end: 3_605_000_000,
included_batch: TICK,
effective_price: hex(units(100n)),
fills: [{
base_amount: hex(units(1n)),
quote_amount: hex(units(100n)),
timestamp: TICK,
price: hex(units(100n)),
}],
reduce_only: false,
ioc: false,
};

/** The money entries name their fields exactly as the stream's money events do. */
const BACKSTOP_ENTRY: WireActivityEntry = {
activity_type: "backstop",
ts: TICK,
book: BOOK,
size: (-units(2n)).toString(),
cash: "0",
mark: units(100n).toString(),
equity: (-units(5n)).toString(),
pnl: (-units(1n)).toString(),
};

const BRIDGE_ENTRY: WireActivityEntry = {
activity_type: "bridge_transfer",
ts: TICK,
tx: "0x0000000000000000000000000000000000000000000000000000000000000001",
idx: 1,
token: TOKEN,
amount: "-900",
error: "insufficient_balance",
};

const TRANSFER_ENTRY: WireActivityEntry = {
activity_type: "transfer",
ts: TICK,
id: "0x0000000000000000000000000000000000000000000000000000000000000003",
token: TOKEN,
amount: "1700",
};

describe("decodeActivityEntry", () => {
it("carries an order through with its fills", () => {
const entry = decodeActivityEntry(ORDER_ENTRY);
if (entry?.activityType !== "order") throw new Error("expected an order");
// The node timestamps an order entry by its SIGNED deadline, not the batch it
// landed in, so `timeMs` and `order.includedMs` are deliberately different.
expect(entry.timeMs).toBe(8_000);
expect(entry.order.includedMs).toBe(5_000);
expect(entry.order.marketType).toBe("perp");
expect(entry.order.price).toBe(units(100n));
expect(entry.order.initialSize).toBe(units(2n));
expect(entry.order.fills).toHaveLength(1);
});

it("carries a backstop leg with its realized PnL", () => {
const entry = decodeActivityEntry(BACKSTOP_ENTRY);
if (entry?.activityType !== "backstop") throw new Error("expected a backstop");
expect(entry.timeMs).toBe(5_000);
expect(entry.orderbookId).toBe(BOOK);
expect(entry.size).toBe(-units(2n));
expect(entry.markPrice).toBe(units(100n));
expect(entry.equity).toBe(-units(5n));
expect(entry.realizedPnl).toBe(-units(1n));
});

it("keeps money signed from the account's side, with its refusal", () => {
const bridge = decodeActivityEntry(BRIDGE_ENTRY);
if (bridge?.activityType !== "bridge_transfer") throw new Error("expected a bridge transfer");
expect(bridge.txHash).toBe(BRIDGE_ENTRY.tx);
// One tx can carry several deposits, so the hash alone does not identify a row.
expect(bridge.idx).toBe(1);
expect(bridge.amount).toBe(-900n);
expect(bridge.error).toBe("insufficient_balance");

const transfer = decodeActivityEntry(TRANSFER_ENTRY);
if (transfer?.activityType !== "transfer") throw new Error("expected a transfer");
expect(transfer.transferId).toBe(TRANSFER_ENTRY.id);
expect(transfer.amount).toBe(1700n);
expect(transfer.error).toBeUndefined();
});
});

/** The shape `the_wire_is_tagged_stringly_and_never_null` asserts: one frame per
* tick, no `book` on the frame, books first and then the tick's money. */
const FRAME: WireActivityFrame = {
batch: TICK,
orders: [{
id: "0x0101010101010101010101010101010101010101010101010101010101010101",
tx: "0x6565656565656565656565656565656565656565656565656565656565656565",
book: BOOK,
n: 1,
px: units(100n).toString(),
sz: units(2n).toString(),
end: 3_605_000_000,
}],
events: [
{ k: "new", o: 0 },
{
k: "fill",
o: 0,
b: units(1n).toString(),
q: units(100n).toString(),
tb: units(1n).toString(),
tq: units(100n).toString(),
tf: "7",
},
{
k: "backstop",
book: BOOK,
size: (-units(2n)).toString(),
cash: "0",
mark: units(100n).toString(),
equity: (-units(5n)).toString(),
pnl: (-units(1n)).toString(),
},
{
k: "bridge_transfer",
tx: "0x0000000000000000000000000000000000000000000000000000000000000001",
idx: 1,
token: TOKEN,
amount: units(10n).toString(),
},
{
k: "transfer",
id: "0x0000000000000000000000000000000000000000000000000000000000000005",
token: TOKEN,
amount: (-units(5n)).toString(),
error: "insufficient_balance",
},
],
};

const emptyState = () => ({ orders: new Map(), entries: [] as ActivityEntry[] });

describe("applyActivityFrame", () => {
it("folds the order flow and collects the money the tick moved", () => {
const state = emptyState();
const events = applyActivityFrame(FRAME, state, { account: ALICE });

expect(events.map((e) => ("activityType" in e ? e.activityType : e.kind)))
.toEqual(["new", "fill", "backstop", "bridge_transfer", "transfer"]);

const order = state.orders.get(FRAME.orders[0]!.id);
expect(order?.status).toBe("active");
expect(order?.filledBase).toBe(units(1n));
// `book` moved from the frame onto the entity; there is no frame-level one.
expect(order?.orderbookId).toBe(BOOK);
// A frame with no `accts` covers exactly one account.
expect(order?.bidder).toBe(ALICE);

expect(state.entries.map((e) => e.activityType))
.toEqual(["backstop", "bridge_transfer", "transfer"]);
// Every money entry is timed by the batch that moved it.
expect(state.entries.every((e) => e.timeMs === 5_000)).toBe(true);
});

it("decodes each money kind into the entry the seed would have served", () => {
const state = emptyState();
applyActivityFrame(FRAME, state, { account: ALICE });
const [backstop, bridge, transfer] = state.entries;

if (backstop?.activityType !== "backstop") throw new Error("expected a backstop entry");
expect(backstop.orderbookId).toBe(BOOK);
expect(backstop.size).toBe(-units(2n));
expect(backstop.markPrice).toBe(units(100n));
expect(backstop.realizedPnl).toBe(-units(1n));

if (bridge?.activityType !== "bridge_transfer") throw new Error("expected a bridge entry");
expect(bridge.txHash).toBe("0x0000000000000000000000000000000000000000000000000000000000000001");
expect(bridge.idx).toBe(1);
expect(bridge.token).toBe(TOKEN);
expect(bridge.amount).toBe(units(10n));
expect(bridge.error).toBeUndefined();

if (transfer?.activityType !== "transfer") throw new Error("expected a transfer entry");
expect(transfer.amount).toBe(-units(5n));
expect(transfer.error).toBe("insufficient_balance");
});

it("ignores a kind it does not know, on either side of the union", () => {
const state = emptyState();
const events = applyActivityFrame(
{ ...FRAME, events: [{ k: "liquidation_notice", o: 0 }, { k: "airdrop", amount: "1" } as never] },
state,
{ account: ALICE },
);
expect(events).toEqual([]);
expect(state.entries).toEqual([]);
// The entity still lands: the frame said the order exists.
expect(state.orders.size).toBe(1);
});
});

const BOOK_B = "0x000000000000000000000000000000000000000000000000000000000000000b" as MarketId;

const sweep = (book: MarketId) => ({
k: "backstop" as const,
book,
size: (-units(2n)).toString(),
cash: "0",
mark: units(100n).toString(),
equity: (-units(5n)).toString(),
pnl: (-units(1n)).toString(),
});

describe("applyActivityFrame interleaving", () => {
it("emits order and money events in the node's wire order", () => {
const state = emptyState();
const events = applyActivityFrame({
batch: TICK,
orders: [
{ id: "0x0a", tx: "0xaa", book: BOOK, n: 1, px: units(100n).toString(), sz: units(1n).toString() },
{ id: "0x0b", tx: "0xbb", book: BOOK_B, n: 2, px: units(100n).toString(), sz: units(1n).toString() },
],
events: [{ k: "new", o: 0 }, sweep(BOOK), { k: "new", o: 1 }, sweep(BOOK_B)],
}, state, { account: ALICE });

// Folding the orders first and appending the money would report the tick as
// two placements followed by two sweeps, which is not what happened.
expect(events.map((e) => ("activityType" in e ? e.activityType : e.kind)))
.toEqual(["new", "backstop", "new", "backstop"]);
expect(state.entries.map((e) => e.activityType === "backstop" ? e.orderbookId : e.activityType))
.toEqual([BOOK, BOOK_B]);
});
});

describe("decodeActivityEntry on an unknown kind", () => {
it("returns undefined rather than an untagged row", () => {
expect(decodeActivityEntry({ activity_type: "airdrop", ts: TICK } as never)).toBeUndefined();
});
});
Loading
Loading