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
7 changes: 4 additions & 3 deletions api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ Read [`../AGENTS.md`](../AGENTS.md) first for shared addresses/mechanics and ind
- Root discovery route: `/`
- WebSocket endpoint: `/ws`
- message types: `subscribe`, `unsubscribe`, `ping`
- channels: `summit`, `event`
- subscribe payload: `{"type":"subscribe","channels":["summit","event"]}`
- channels: `summit`, `event`, `consumables`, `supply`
- subscribe payload: `{"type":"subscribe","channels":["summit","event","consumables","supply"]}`
- `supply` channel payload: `{"ATTACK": 236483, "REVIVE": 82604, "EXTRA LIFE": 15538, "POISON": 320004}` (per-ERC20 keyed)

Query/pagination rules agents usually need:
- `/beasts/all`: `limit` default `25`, max `100`; `offset`; filters `prefix`, `suffix`, `beast_id`, `name`, `owner`; `sort` in `summit_held_seconds|level`.
Expand All @@ -55,7 +56,7 @@ Behavior details that affect integration:
- `/` includes debug endpoint hints in development mode (`NODE_ENV != production`), but handlers are not implemented in this service file.

## Real-Time Pattern
`Indexer writes -> PostgreSQL NOTIFY (summit_update, summit_log_insert) -> SubscriptionHub LISTEN -> WS broadcast`
`Indexer writes -> PostgreSQL NOTIFY (summit_update, summit_log_insert, consumables_update) -> SubscriptionHub LISTEN -> WS broadcast`

## Middleware and Runtime Patterns
- Middleware in `src/index.ts`: logger, compress, CORS.
Expand Down
4 changes: 2 additions & 2 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,8 +685,8 @@ app.get("/", (c) => {
},
websocket: {
endpoint: "WS /ws",
channels: ["summit", "event"],
subscribe: '{"type":"subscribe","channels":["summit","event"]}',
channels: ["summit", "event", "consumables", "supply"],
subscribe: '{"type":"subscribe","channels":["summit","event","consumables","supply"]}',
},
};

Expand Down
83 changes: 83 additions & 0 deletions api/src/ws/subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,4 +313,87 @@ describe("SubscriptionHub", () => {
expect(hub.getStatus().clientCount).toBe(1);
});
});

describe("consumables channel", () => {
it("should subscribe a client to consumables channel", () => {
const { ws, messages } = createMockWs();
hub.addClient("client-1", ws);

hub.handleMessage(
"client-1",
JSON.stringify({ type: "subscribe", channels: ["consumables"] })
);

expect(messages.length).toBe(1);
const response = JSON.parse(messages[0]);
expect(response.type).toBe("subscribed");
expect(response.channels).toEqual(["consumables"]);
});

it("should subscribe to all four channels", () => {
const { ws, messages } = createMockWs();
hub.addClient("client-1", ws);

hub.handleMessage(
"client-1",
JSON.stringify({ type: "subscribe", channels: ["summit", "event", "consumables", "supply"] })
);

expect(messages.length).toBe(1);
const response = JSON.parse(messages[0]);
expect(response.type).toBe("subscribed");
expect(response.channels).toEqual(["summit", "event", "consumables", "supply"]);
});

it("should unsubscribe from consumables channel", () => {
const { ws, messages } = createMockWs();
hub.addClient("client-1", ws);

hub.subscribe("client-1", ["consumables"]);

hub.handleMessage(
"client-1",
JSON.stringify({ type: "unsubscribe", channels: ["consumables"] })
);

expect(messages.length).toBe(1);
const response = JSON.parse(messages[0]);
expect(response.type).toBe("unsubscribed");
expect(response.channels).toEqual(["consumables"]);
});
});

describe("supply channel", () => {
it("should subscribe a client to supply channel", () => {
const { ws, messages } = createMockWs();
hub.addClient("client-1", ws);

hub.handleMessage(
"client-1",
JSON.stringify({ type: "subscribe", channels: ["supply"] })
);

expect(messages.length).toBe(1);
const response = JSON.parse(messages[0]);
expect(response.type).toBe("subscribed");
expect(response.channels).toEqual(["supply"]);
});

it("should unsubscribe from supply channel", () => {
const { ws, messages } = createMockWs();
hub.addClient("client-1", ws);

hub.subscribe("client-1", ["supply"]);

hub.handleMessage(
"client-1",
JSON.stringify({ type: "unsubscribe", channels: ["supply"] })
);

expect(messages.length).toBe(1);
const response = JSON.parse(messages[0]);
expect(response.type).toBe("unsubscribed");
expect(response.channels).toEqual(["supply"]);
});
});
});
27 changes: 24 additions & 3 deletions api/src/ws/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* Channels:
* - summit: Beast stats updates for summit beast
* - event: Activity feed from summit_log
* - consumables: Potion balance updates per owner
* - supply: Aggregate player-held supply per ERC20 token
*/

import { pool } from "../db/client.js";
Expand All @@ -18,7 +20,7 @@ interface WebSocketLike {
OPEN?: number;
}

export type Channel = "summit" | "event";
export type Channel = "summit" | "event" | "consumables" | "supply";

interface ClientSubscription {
ws: WebSocketLike;
Expand Down Expand Up @@ -64,6 +66,17 @@ interface EventPayload {
created_at: string;
}

interface ConsumablesPayload {
owner: string;
xlife_count: number;
attack_count: number;
revive_count: number;
poison_count: number;
}

/** Per-ERC20 token supply keyed by token name (e.g. "ATTACK", "REVIVE") */
type SupplyPayload = Record<string, number>;

export class SubscriptionHub {
private clients: Map<string, ClientSubscription> = new Map();
private pgClient: pg.PoolClient | null = null;
Expand Down Expand Up @@ -103,8 +116,10 @@ export class SubscriptionHub {

await this.pgClient.query("LISTEN summit_update");
await this.pgClient.query("LISTEN summit_log_insert");
await this.pgClient.query("LISTEN consumables_update");
await this.pgClient.query("LISTEN consumables_supply");

console.log("[SubscriptionHub] Listening on: summit_update, summit_log_insert");
console.log("[SubscriptionHub] Listening on: summit_update, summit_log_insert, consumables_update, consumables_supply");
} catch (error) {
console.error("[SubscriptionHub] Failed to connect:", error);
this.reconnect();
Expand Down Expand Up @@ -155,13 +170,19 @@ export class SubscriptionHub {
case "summit_log_insert":
this.broadcast("event", payload as EventPayload);
break;
case "consumables_update":
this.broadcast("consumables", payload as ConsumablesPayload);
break;
Comment on lines +173 to +175

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The consumables channel broadcasts potion balance updates for all users to every client subscribed to the channel. While the client-side GameDirector filters these messages to only process updates for the connected wallet (line 422), a malicious client can subscribe to the channel and monitor the real-time balance changes of all players. This information exposure can be used for tactical advantage in a competitive game. To remediate this, implement server-side filtering: clients should provide the owner address they are interested in when subscribing, and the broadcast method should only send updates to clients whose interested address matches the owner in the payload.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one — this follows the same broadcast pattern as summit and event channels, which also send all data to all subscribers. Token balances are on-chain public data (anyone can call balanceOf), so there's no real information exposure. Adding server-side owner filtering would introduce significant complexity (subscription state per-owner, address normalization in the hub) for no meaningful security gain. If we decide to add per-owner filtering later, it should apply to all three channels uniformly.

Comment on lines +173 to +175
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new consumables_update notifications are broadcast to every client subscribed to the consumables channel, meaning each subscriber receives balance updates for all owners (and must filter client-side). Given consumables updates can be frequent, this increases bandwidth/CPU and may unnecessarily expose per-owner balance data to all connected clients. Consider adding server-side filtering (e.g. owner-scoped subscriptions or including an owner filter in the subscribe message) so clients only receive updates relevant to them.

Copilot uses AI. Check for mistakes.
case "consumables_supply":
this.broadcast("supply", payload as SupplyPayload);
break;
}
} catch (error) {
console.error("[SubscriptionHub] Failed to parse notification:", error);
}
}

private broadcast(channel: Channel, data: SummitPayload | EventPayload): void {
private broadcast(channel: Channel, data: SummitPayload | EventPayload | ConsumablesPayload | SupplyPayload): void {
const message = JSON.stringify({ type: channel, data });

let sentCount = 0;
Expand Down
72 changes: 68 additions & 4 deletions client/src/contexts/GameDirector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GameAction, selection } from "@/types/game";

const hoisted = vi.hoisted(() => ({
useWebSocketMock: vi.fn(),
useAccountMock: vi.fn((): { account: { address: string } | undefined } => ({ account: undefined })),
getSummitDataMock: vi.fn(async () => null),
getDiplomacyMock: vi.fn(async () => []),
executeActionMock: vi.fn(async () => []),
Expand Down Expand Up @@ -35,9 +37,7 @@ const hoisted = vi.hoisted(() => ({
}));

vi.mock("@starknet-react/core", () => ({
useAccount: () => ({
account: undefined,
}),
useAccount: hoisted.useAccountMock,
}));

vi.mock("./starknet", () => ({
Expand All @@ -49,7 +49,7 @@ vi.mock("./starknet", () => ({
}));

vi.mock("@/hooks/useWebSocket", () => ({
useWebSocket: vi.fn(),
useWebSocket: hoisted.useWebSocketMock,
}));

vi.mock("@/api/starknet", () => ({
Expand Down Expand Up @@ -170,3 +170,67 @@ describe("GameDirector executeGameAction", () => {
expect(capturedDirector.pauseUpdates).toBe(false);
});
});

describe("GameDirector consumables handler", () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.getSummitDataMock.mockResolvedValue(null);
});

it("should update token balances when consumables update matches connected wallet", async () => {
hoisted.useAccountMock.mockReturnValue({
account: { address: "0x123" },
});

await renderProvider();

// Capture the onConsumables callback from useWebSocket call
const wsCall = hoisted.useWebSocketMock.mock.calls[0][0];
expect(wsCall.channels).toContain("consumables");
expect(wsCall.onConsumables).toBeDefined();

act(() => {
wsCall.onConsumables({
owner: "0x123",
xlife_count: 5,
attack_count: 3,
revive_count: 1,
poison_count: 2,
});
});

expect(hoisted.setTokenBalancesMock).toHaveBeenCalled();
const updater = hoisted.setTokenBalancesMock.mock.calls[0][0];
const result = typeof updater === "function" ? updater({ SKULL: 10, CORPSE: 5 }) : updater;
expect(result).toEqual({
SKULL: 10,
CORPSE: 5,
"EXTRA LIFE": 5,
ATTACK: 3,
REVIVE: 1,
POISON: 2,
});
});

it("should ignore consumables update for different wallet", async () => {
hoisted.useAccountMock.mockReturnValue({
account: { address: "0x456" },
});

await renderProvider();

const wsCall = hoisted.useWebSocketMock.mock.calls[0][0];

act(() => {
wsCall.onConsumables({
owner: "0x999",
xlife_count: 10,
attack_count: 10,
revive_count: 10,
poison_count: 10,
});
});

expect(hoisted.setTokenBalancesMock).not.toHaveBeenCalled();
});
});
18 changes: 16 additions & 2 deletions client/src/contexts/GameDirector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useSummitApi } from "@/api/summitApi";
import { useSound } from "@/contexts/sound";
import { useSystemCalls } from "@/dojo/useSystemCalls";
import type { TranslatedGameEvent } from "@/dojo/useSystemCalls";
import type { EventData, SummitData } from "@/hooks/useWebSocket";
import type { ConsumablesData, EventData, SummitData } from "@/hooks/useWebSocket";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useAutopilotStore } from "@/stores/autopilotStore";
import { useGameStore } from "@/stores/gameStore";
Expand Down Expand Up @@ -417,12 +417,26 @@ export const GameDirector = ({ children }: PropsWithChildren) => {
}
};

const handleConsumables = (data: ConsumablesData) => {
if (!account?.address) return;
if (addAddressPadding(account.address) !== addAddressPadding(data.owner)) return;

setTokenBalances((prev: Record<string, number>) => ({
...prev,
"EXTRA LIFE": data.xlife_count,
ATTACK: data.attack_count,
REVIVE: data.revive_count,
POISON: data.poison_count,
}));
};

// WebSocket subscription
useWebSocket({
url: currentNetworkConfig.wsUrl,
channels: ["summit", "event"],
channels: ["summit", "event", "consumables"],
onSummit: handleSummit,
onEvent: handleEvent,
onConsumables: handleConsumables,
onConnectionChange: (state) => {
console.log("[GameDirector] WebSocket connection state:", state);
},
Expand Down
45 changes: 44 additions & 1 deletion client/src/contexts/Statistics.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { act, create } from "react-test-renderer";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { getSwapQuoteMock, getBeastCountsMock, getQuestRewardsTotalMock, mockNetworkConfig } = vi.hoisted(() => ({
const { getSwapQuoteMock, getBeastCountsMock, getConsumablesSupplyMock, getQuestRewardsTotalMock, useWebSocketMock, mockNetworkConfig } = vi.hoisted(() => ({
getSwapQuoteMock: vi.fn(async () => ({ total: "-2000000", totalDisplay: -2e6 })),
getBeastCountsMock: vi.fn(async () => ({ total: 12, alive: 5, dead: 7 })),
getConsumablesSupplyMock: vi.fn(async () => ({ xlife: 0, attack: 0, revive: 0, poison: 0 })),
getQuestRewardsTotalMock: vi.fn(async () => 25),
useWebSocketMock: vi.fn(),
mockNetworkConfig: {
wsUrl: "wss://test.invalid",
tokens: {
erc20: [
{ name: "ATTACK", address: "0xattack" },
Expand All @@ -22,10 +25,15 @@ vi.mock("@/api/ekubo", () => ({
vi.mock("@/api/summitApi", () => ({
useSummitApi: () => ({
getBeastCounts: getBeastCountsMock,
getConsumablesSupply: getConsumablesSupplyMock,
getQuestRewardsTotal: getQuestRewardsTotalMock,
}),
}));

vi.mock("@/hooks/useWebSocket", () => ({
useWebSocket: useWebSocketMock,
}));

vi.mock("./starknet", () => ({
useDynamicConnector: () => ({
currentNetworkConfig: mockNetworkConfig,
Expand Down Expand Up @@ -90,4 +98,39 @@ describe("StatisticsProvider", () => {

expect(getSwapQuoteMock).toHaveBeenCalledTimes(2);
});

it("subscribes to supply channel and updates consumablesSupply on message", async () => {
await renderProvider();
await flushEffects();

// useWebSocket should have been called with supply channel
expect(useWebSocketMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://test.invalid",
channels: ["supply"],
onSupply: expect.any(Function),
}),
);

// Simulate a supply WS message via the captured onSupply callback
const callArgs = useWebSocketMock.mock.calls[0][0] as {
onSupply: (data: Record<string, number>) => void;
};

await act(async () => {
callArgs.onSupply({
"EXTRA LIFE": 100,
ATTACK: 200,
REVIVE: 300,
POISON: 400,
});
});

expect(capturedStatistics.consumablesSupply).toEqual({
xlife: 100,
attack: 200,
revive: 300,
poison: 400,
});
});
});
Loading
Loading