diff --git a/src/commands/events.ts b/src/commands/events.ts new file mode 100644 index 0000000..7ccdca7 --- /dev/null +++ b/src/commands/events.ts @@ -0,0 +1,98 @@ +/** + * `/events` command: list recent notifications the bot delivered. + * + * Renders the last N (default 10, max 30) delivered events as one-line + * summaries with explorer links, newest first. Supports `/events market` or + * `/events squad` filtering. Output is split with `splitTelegramMessage` + * when it exceeds Telegram's message ceiling. Memory use is bounded by the + * backing ring buffer (see src/notifications/recent.ts). + */ + +import { type CommandContext, type Context } from "grammy"; +import { escapeMd, splitTelegramMessage, TELEGRAM_MAX_MESSAGE_LENGTH } from "../notifications/format.js"; +import { txExplorerUrl } from "../stellar/client.js"; +import { isUsableTxHash } from "../stellar/decode.js"; +import type { BotConfig } from "../config.js"; +import type { ContractSource } from "../stellar/decode.js"; +import { RecentEvents, recentEvents, type RecentEvent } from "../notifications/recent.js"; + +export const DEFAULT_EVENTS_LIMIT = 10; +export const MAX_EVENTS_LIMIT = 30; + +export interface EventsCommandOptions { + /** "market" | "squad" filter; undefined shows both. */ + source?: ContractSource; + limit?: number; +} + +function normalizeLimit(limit: unknown): number { + const n = typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_EVENTS_LIMIT; + return Math.min(Math.max(n, 1), MAX_EVENTS_LIMIT); +} + +function clipEventName(name: string): string { + const MAX = 80; + return name.length > MAX ? name.slice(0, MAX) : name; +} + +function renderEventLine(event: RecentEvent, config: BotConfig): string { + const name = escapeMd(clipEventName(event.eventName)); + const source = escapeMd(event.source); + let line = `mimir-${source}`; + if (event.marketId !== undefined) line += ` #${event.marketId}`; + line += ` ${name} at ledger ${event.ledger}`; + const url = event.txHash && isUsableTxHash(event.txHash) ? txExplorerUrl(config, event.txHash) : null; + if (url) line += ` · [tx](${url})`; + return line; +} + +/** + * Renders the recent-events message text. Callers split the result with + * `splitTelegramMessage` before sending. + */ +export function eventsMessage( + recent: RecentEvents, + config: BotConfig, + options: EventsCommandOptions = {}, +): string { + const limit = normalizeLimit(options.limit); + const events = recent.list({ source: options.source, limit }); + const lines: string[] = []; + lines.push(options.source ? `*Recent ${options.source} events*` : "*Recent events*"); + lines.push(""); + if (events.length === 0) { + lines.push("No recent events\\."); + return lines.join("\n"); + } + for (const event of events) { + lines.push(`• ${renderEventLine(event, config)}`); + } + return lines.join("\n"); +} + +/** Builds the `/events` command handler bound to a buffer and config. */ +export function createEventsHandler( + recent: RecentEvents, + config: BotConfig, +): (ctx: CommandContext) => Promise { + return async (ctx) => { + const allowed = config.allowedChatIds.length === 0 || config.allowedChatIds.includes(String(ctx.chat.id)); + if (!allowed) return; + const text = ctx.message?.text ?? ""; + const spaceIndex = text.indexOf(" "); + const arg = spaceIndex !== -1 ? text.slice(spaceIndex + 1).trim() : ""; + let source: ContractSource | undefined; + if (arg === "market" || arg === "squad") source = arg; + + const body = eventsMessage(recent, config, { source }); + const parts = splitTelegramMessage(body, TELEGRAM_MAX_MESSAGE_LENGTH); + for (const part of parts) { + await ctx.reply(part, { link_preview_options: { is_disabled: true } }); + } + }; +} + +/** Default handler bound to the process-wide buffer and a resolved config. */ +export function createDefaultEventsHandler(config: BotConfig): (ctx: CommandContext) => Promise { + return createEventsHandler(recentEvents, config); +} diff --git a/src/notifications/recent.ts b/src/notifications/recent.ts new file mode 100644 index 0000000..916ed31 --- /dev/null +++ b/src/notifications/recent.ts @@ -0,0 +1,92 @@ +/** + * Bounded ring buffer of delivered notifications. + * + * Fed by one call (`record`) after each successful Telegram send so the + * `/events` command can scroll back through recent activity. Memory use is + * bounded by a constant: the buffer holds at most `maxEvents` entries and + * evicts the oldest when full. Nothing is persisted across restarts. + */ + +import type { ContractSource, DecodedEvent } from "../stellar/decode.js"; + +export interface RecentEvent { + source: ContractSource; + ledger: number; + eventId: string; + eventName: string; + marketId: number | undefined; + txHash: string; + /** Ledger close time, unix seconds. */ + at: number; +} + +/** Default ceiling on retained events in memory. */ +export const DEFAULT_MAX_EVENTS = 1000; + +export interface RecentEventsOptions { + maxEvents?: number; +} + +/** + * Bounded FIFO ring buffer of delivered events, newest at the end of the + * internal array. `list` returns newest first. + */ +export class RecentEvents { + private readonly buffer: RecentEvent[] = []; + private readonly maxEvents: number; + + constructor(options: RecentEventsOptions = {}) { + this.maxEvents = + typeof options.maxEvents === "number" && options.maxEvents > 0 + ? options.maxEvents + : DEFAULT_MAX_EVENTS; + } + + /** Record a delivered event after a successful send. */ + record(event: DecodedEvent): void { + this.buffer.push({ + source: event.source, + ledger: event.ledger, + eventId: event.eventId, + eventName: + event.payload.name === "unknown" ? event.payload.eventName : event.payload.name, + marketId: this.marketId(event.payload), + txHash: event.txHash, + at: event.at, + }); + if (this.buffer.length > this.maxEvents) this.buffer.shift(); + } + + private marketId(payload: unknown): number | undefined { + if (payload && typeof payload === "object" && "marketId" in payload) { + return (payload as { marketId?: number }).marketId; + } + return undefined; + } + + /** + * Events newest first, optionally filtered by source and capped by `limit`. + * Does not mutate the buffer. + */ + list(options: { source?: ContractSource; limit?: number } = {}): RecentEvent[] { + let limit = + typeof options.limit === "number" && options.limit > 0 ? options.limit : this.buffer.length; + const filtered = options.source + ? this.buffer.filter((e) => e.source === options.source) + : this.buffer; + return filtered.slice().reverse().slice(0, limit); + } + + /** Number of events currently retained. */ + get size(): number { + return this.buffer.length; + } + + /** Clear all retained events (mainly for tests). */ + clear(): void { + this.buffer.length = 0; + } +} + +/** Shared buffer fed by the poller after successful sends and read by /events. */ +export const recentEvents = new RecentEvents(); diff --git a/src/poller.ts b/src/poller.ts index c3f66d8..9d6f9f8 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -93,6 +93,7 @@ import { import { formatSuppressedRepeats, LogSampler } from "./log-sampler.js"; import { Logger } from "./logger.js"; import { isNotificationAllowed } from "./notifications/featureFlags.js"; +import { recentEvents } from "./notifications/recent.js"; import { buildStatusSnapshot, writeStatusFile, type StatusSnapshot } from "./status.js"; import { validateLedgerWindow, type LedgerWindow } from "./stellar/client.js"; import { LedgerCache } from "./stellar/ledger-cache.js"; @@ -2204,6 +2205,15 @@ export function createPoller(deps: PollerDeps) { recordSentIncrement(); metrics?.notificationsSent.inc(); sentThisCycle += 1; + // Feed the bounded ring buffer so /events can scroll recent deliveries. + // Best-effort: a recording failure must not fail a successful send. + try { + recentEvents.record(event); + } catch (err) { + console.error( + `[poller] failed to record delivered event ${event.eventId}: ${safeErrorMessage(err)}`, + ); + } if (sendJournal) { try { await sendJournal.recordDone(event.eventId); diff --git a/src/stellar/squad-pools.ts b/src/stellar/squad-pools.ts new file mode 100644 index 0000000..0da2169 --- /dev/null +++ b/src/stellar/squad-pools.ts @@ -0,0 +1,217 @@ +/** + * Bounded reducer for Mimir squad pools. + * + * Reconstructs per-market, per-side pool balances from delivered `deposited` + * and `withdrawn` events, then verifies them against the `poolA`/`poolB` + * values reported by `resolved` events. Detects decoder or indexing errors + * (for example missed events across a restart gap) by flagging mismatches. + * Markets whose creation was never observed are reported as unverifiable + * rather than mismatched. + * + * Memory is bounded: tracked markets are capped and the oldest market is + * evicted when the cap is reached. + */ + +export type PoolSide = 0 | 1; + +export interface PoolSnapshot { + poolA: bigint; + poolB: bigint; +} + +export interface TrackedMarket { + pools: PoolSnapshot; + /** True when a `market_created` event for this market was observed. */ + created: boolean; + /** Last resolution verification result, undefined until checked. */ + verified: MarketVerification | undefined; +} + +export interface MismatchRecord { + marketId: number; + /** Pools reconstructed from balance events. */ + expected: PoolSnapshot; + /** Pools reported by the resolved event. */ + observed: PoolSnapshot; +} + +export type MarketVerification = "unverifiable" | "ok" | "mismatched"; + +/** Default ceiling on tracked markets in memory. */ +export const DEFAULT_MAX_MARKETS = 256; + +/** Default ceiling on retained mismatch records. */ +export const DEFAULT_MAX_MISMATCHES = 64; + +/** Side 0 accumulates into `poolA`; side 1 into `poolB`. */ +function fieldForSide(side: number): "poolA" | "poolB" { + return side === 1 ? "poolB" : "poolA"; +} + +/** Events the reducer consumes. */ +export interface PoolEvent { + marketId: number; + name: "deposited" | "withdrawn"; + side: number; + amount: bigint; +} + +export interface SquadPoolsOptions { + maxMarkets?: number; + maxMismatches?: number; +} + +/** + * Pure reduction of delivered squad balance events into per-market pool + * snapshots. Same input events always yield the same snapshots, with no side + * effects and no dependence on ambient state. + */ +export function reduceSquadPools(events: readonly PoolEvent[]): Map { + const pools = new Map(); + for (const event of events) { + let snap = pools.get(event.marketId); + if (!snap) { + snap = { poolA: 0n, poolB: 0n }; + pools.set(event.marketId, snap); + } + const previous = { ...snap }; + const field = fieldForSide(event.side); + snap[field] = + event.name === "deposited" ? previous[field] + event.amount : previous[field] - event.amount; + } + return pools; +} + +/** + * Compares reconstructed pools against the values reported by a `resolved` + * event. Side 0 is compared to `poolA`; side 1 to `poolB`. Returns a bounded + * mismatch record when they differ, otherwise undefined. Pure: no side + * effects. + */ +export function compareResolved( + snapshot: PoolSnapshot | undefined, + poolA: bigint, + poolB: bigint, +): { expected: PoolSnapshot; observed: PoolSnapshot } | undefined { + if (!snapshot) return undefined; + if (snapshot.poolA === poolA && snapshot.poolB === poolB) return undefined; + return { expected: { ...snapshot }, observed: { poolA, poolB } }; +} + +/** + * Tracks squad pool balances and verifies them against resolutions. + * + * Layers bounded state and market lifecycle (created / resolved) on top of + * the pure reducer above. + */ +export class SquadPools { + private readonly markets = new Map(); + private readonly order: number[] = []; + private readonly mismatchRecords: MismatchRecord[] = []; + private readonly maxMarkets: number; + private readonly maxMismatches: number; + + constructor(options: SquadPoolsOptions = {}) { + this.maxMarkets = + typeof options.maxMarkets === "number" && options.maxMarkets > 0 + ? options.maxMarkets + : DEFAULT_MAX_MARKETS; + this.maxMismatches = + typeof options.maxMismatches === "number" && options.maxMismatches > 0 + ? options.maxMismatches + : DEFAULT_MAX_MISMATCHES; + } + + /** + * Record a `deposited` or `withdrawn` balance event for a market. + * Side 0 accumulates into `poolA`; side 1 into `poolB`. + */ + record(event: PoolEvent): void { + let market = this.markets.get(event.marketId); + if (!market) { + this.evictIfNeeded(); + market = { pools: { poolA: 0n, poolB: 0n }, created: false, verified: undefined }; + this.markets.set(event.marketId, market); + this.order.push(event.marketId); + } + const field = fieldForSide(event.side); + const previous = market.pools[field]; + market.pools[field] = + event.name === "deposited" ? previous + event.amount : previous - event.amount; + } + + /** Mark that a `market_created` event was observed for this market. */ + observeCreated(marketId: number): void { + let market = this.markets.get(marketId); + if (!market) { + this.evictIfNeeded(); + market = { pools: { poolA: 0n, poolB: 0n }, created: false, verified: undefined }; + this.markets.set(marketId, market); + this.order.push(marketId); + } + market.created = true; + } + + /** + * Verify the reconstructed pools for a market against the values reported + * by its `resolved` event. Side 0 is compared to `poolA`; side 1 to + * `poolB`. Returns the verification status and, on mismatch, a mismatch + * record including the market id and the expected/observed values. + */ + checkResolved(marketId: number, poolA: bigint, poolB: bigint): { + status: MarketVerification; + mismatch: MismatchRecord | undefined; + } { + const market = this.markets.get(marketId); + if (!market) { + return { status: "unverifiable", mismatch: undefined }; + } + if (!market.created) { + market.verified = "unverifiable"; + return { status: "unverifiable", mismatch: undefined }; + } + const mismatch = compareResolved(market.pools, poolA, poolB); + const status: MarketVerification = mismatch ? "mismatched" : "ok"; + market.verified = status; + if (mismatch) { + const record: MismatchRecord = { + marketId, + expected: { ...market.pools }, + observed: { poolA, poolB }, + }; + this.mismatchRecords.push(record); + if (this.mismatchRecords.length > this.maxMismatches) this.mismatchRecords.shift(); + return { status, mismatch: record }; + } + return { status, mismatch: undefined }; + } + + /** Verification status for a market, or undefined when never tracked. */ + status(marketId: number): MarketVerification | undefined { + const market = this.markets.get(marketId); + if (!market) return undefined; + if (!market.created) return "unverifiable"; + return market.verified; + } + + /** Markets currently tracked in memory. */ + get size(): number { + return this.markets.size; + } + + /** Mismatch records retained so far (newest last), bounded by maxMismatches. */ + get mismatches(): readonly MismatchRecord[] { + return this.mismatchRecords; + } + + private evictIfNeeded(): void { + if (this.markets.size < this.maxMarkets) return; + const oldest = this.order.shift(); + if (oldest !== undefined) this.markets.delete(oldest); + } +} + +/** Factory function for creating a SquadPools tracker. */ +export function createSquadPools(options?: SquadPoolsOptions): SquadPools { + return new SquadPools(options); +} diff --git a/tests/events-command.test.mjs b/tests/events-command.test.mjs new file mode 100644 index 0000000..449f95c --- /dev/null +++ b/tests/events-command.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createEventsHandler, eventsMessage } from "../dist/commands/events.js"; +import { RecentEvents } from "../dist/notifications/recent.js"; + +function recentEvent({ source, ledger, eventId, eventName, marketId, txHash, at }) { + return { + source, + contractId: "C" + "A".repeat(55), + version: "v1", + ledger, + txHash, + at, + eventId, + eventType: "contract", + payload: { name: eventName, ...(marketId !== undefined ? { marketId } : {}) }, + }; +} + +function config(overrides = {}) { + return { + botToken: "123456789:TEST-ONLY-TOKEN-NEVER-USE", + chatId: "-1001234567890", + allowedChatIds: [], + ...overrides, + }; +} + +function replyCtx() { + const parts = []; + const ctx = { + message: { text: "/events" }, + chat: { id: 42 }, + reply: async (text) => { + parts.push(text); + }, + }; + return { ctx, parts }; +} + +test("eventsMessage renders newest first with one-line summaries", () => { + const recent = new RecentEvents({ maxEvents: 10 }); + recent.record( + recentEvent({ + source: "market", + ledger: 10, + eventId: "10-0", + eventName: "claim_created", + txHash: "1".repeat(64), + at: 1000, + }), + ); + recent.record( + recentEvent({ + source: "squad", + ledger: 20, + eventId: "20-0", + eventName: "deposited", + marketId: 3, + txHash: "", + at: 2000, + }), + ); + + const text = eventsMessage(recent, config()); + assert.match(text, /\*Recent events\*/); + assert.match(text, /mimir-market claim\\_created at ledger 10/); + assert.match(text, /mimir-squad #3 deposited at ledger 20/); + assert.ok(text.indexOf("ledger 20") < text.indexOf("ledger 10"), "newest first"); + assert.ok(text.includes("· [tx]("), "explorer link should be rendered"); +}); + +test("eventsMessage filters by source", () => { + const recent = new RecentEvents({ maxEvents: 10 }); + recent.record( + recentEvent({ + source: "market", + ledger: 1, + eventId: "1-0", + eventName: "claim_created", + txHash: "", + at: 1, + }), + ); + recent.record( + recentEvent({ + source: "squad", + ledger: 2, + eventId: "2-0", + eventName: "deposited", + marketId: 1, + txHash: "", + at: 2, + }), + ); + + const market = eventsMessage(recent, config(), { source: "market" }); + assert.match(market, /mimir-market/); + assert.ok(!market.includes("mimir-squad")); + + const squad = eventsMessage(recent, config(), { source: "squad" }); + assert.match(squad, /mimir-squad/); + assert.ok(!squad.includes("mimir-market")); +}); + +test("eventsMessage empty buffer", () => { + assert.match(eventsMessage(new RecentEvents(), config()), /No recent events/); +}); + +test("eventsMessage default limit is 10 and caps at 30", () => { + const recent = new RecentEvents({ maxEvents: 100 }); + for (let i = 1; i <= 40; i++) { + recent.record( + recentEvent({ + source: "market", + ledger: i, + eventId: `${i}-0`, + eventName: "claim_created", + txHash: "", + at: i, + }), + ); + } + const text = eventsMessage(recent, config()); + assert.ok(!text.includes("ledger 1"), "oldest should be excluded by default limit"); + assert.ok(text.includes("ledger 40"), "newest should be present"); + + const capped = eventsMessage(recent, config(), { limit: 30 }); + const count = (capped.match(/at ledger /g) || []).length; + assert.equal(count, 30); +}); + +test("RecentEvents evicts oldest when full", () => { + const recent = new RecentEvents({ maxEvents: 3 }); + for (let i = 1; i <= 5; i++) { + recent.record( + recentEvent({ + source: "market", + ledger: i, + eventId: `${i}-0`, + eventName: "claim_created", + txHash: "", + at: i, + }), + ); + } + assert.equal(recent.size, 3); + const listed = recent.list(); + assert.equal(listed.length, 3); + assert.ok(!listed.some((e) => e.ledger === 1), "ledger 1 evicted"); + assert.ok(listed.some((e) => e.ledger === 5)); +}); + +test("createEventsHandler respects chat allowlist", async () => { + const recent = new RecentEvents({ maxEvents: 10 }); + recent.record( + recentEvent({ + source: "market", + ledger: 1, + eventId: "1-0", + eventName: "claim_created", + txHash: "", + at: 1, + }), + ); + const handler = createEventsHandler(recent, config({ allowedChatIds: ["99"] })); + const { ctx, parts } = replyCtx(); + await handler(ctx); + assert.equal(parts.length, 0, "non-allowed chat must not receive a reply"); + + const openHandler = createEventsHandler(recent, config({ allowedChatIds: [] })); + const { ctx: openCtx, parts: openParts } = replyCtx(); + await openHandler(openCtx); + assert.equal(openParts.length, 1, "allowed chat receives a reply"); +}); diff --git a/tests/squad-pools.test.mjs b/tests/squad-pools.test.mjs new file mode 100644 index 0000000..adfd2b9 --- /dev/null +++ b/tests/squad-pools.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { compareResolved, createSquadPools, reduceSquadPools } from "../dist/stellar/squad-pools.js"; + +function balanceEvent({ marketId, name, side, amount }) { + return { + marketId, + name, + side, + amount: typeof amount === "bigint" ? amount : BigInt(amount), + }; +} + +test("reduceSquadPools is pure and accumulates deposits per side", () => { + const events = [ + balanceEvent({ marketId: 1, name: "deposited", side: 0, amount: 100 }), + balanceEvent({ marketId: 1, name: "deposited", side: 1, amount: 50 }), + balanceEvent({ marketId: 1, name: "withdrawn", side: 0, amount: 30 }), + balanceEvent({ marketId: 2, name: "deposited", side: 0, amount: 10 }), + ]; + + const pools = reduceSquadPools(events); + + assert.equal(pools.size, 2); + assert.deepEqual([...pools.entries()].map(([m, p]) => [m, p]), [ + [1, { poolA: 70n, poolB: 50n }], + [2, { poolA: 10n, poolB: 0n }], + ]); +}); + +test("reduceSquadPools: empty input yields an empty map", () => { + assert.equal(reduceSquadPools([]).size, 0); +}); + +test("compareResolved returns undefined when pools match", () => { + assert.equal(compareResolved({ poolA: 100n, poolB: 50n }, 100n, 50n), undefined); +}); + +test("compareResolved returns a mismatch record when pools differ", () => { + const mismatch = compareResolved({ poolA: 100n, poolB: 50n }, 99n, 50n); + assert.ok(mismatch); + assert.equal(mismatch.marketId, undefined); + assert.deepEqual(mismatch.expected, { poolA: 100n, poolB: 50n }); + assert.deepEqual(mismatch.observed, { poolA: 99n, poolB: 50n }); +}); + +test("compareResolved returns undefined for an untracked market", () => { + assert.equal(compareResolved(undefined, 100n, 50n), undefined); +}); + +test("checkResolved: matching resolution is ok", () => { + const pools = createSquadPools(); + pools.observeCreated(1); + pools.record(balanceEvent({ marketId: 1, name: "deposited", side: 0, amount: 100 })); + pools.record(balanceEvent({ marketId: 1, name: "deposited", side: 1, amount: 50 })); + + const result = pools.checkResolved(1, 100n, 50n); + assert.equal(result.status, "ok"); + assert.equal(result.mismatch, undefined); + assert.equal(pools.status(1), "ok"); +}); + +test("checkResolved: mismatched resolution reports expected and observed", () => { + const pools = createSquadPools(); + pools.observeCreated(1); + pools.record(balanceEvent({ marketId: 1, name: "deposited", side: 0, amount: 100 })); + + const result = pools.checkResolved(1, 99n, 50n); + assert.equal(result.status, "mismatched"); + assert.ok(result.mismatch); + assert.equal(result.mismatch.marketId, 1); + assert.deepEqual(result.mismatch.expected, { poolA: 100n, poolB: 0n }); + assert.deepEqual(result.mismatch.observed, { poolA: 99n, poolB: 50n }); + assert.equal(pools.status(1), "mismatched"); + assert.equal(pools.mismatches.length, 1); +}); + +test("checkResolved: market whose creation was never observed is unverifiable", () => { + const pools = createSquadPools(); + pools.record(balanceEvent({ marketId: 1, name: "deposited", side: 0, amount: 100 })); + + const result = pools.checkResolved(1, 100n, 0n); + assert.equal(result.status, "unverifiable"); + assert.equal(result.mismatch, undefined); + assert.equal(pools.status(1), "unverifiable"); +}); + +test("checkResolved: fully unknown market is unverifiable", () => { + const pools = createSquadPools(); + const result = pools.checkResolved(42, 100n, 50n); + assert.equal(result.status, "unverifiable"); + assert.equal(result.mismatch, undefined); +}); + +test("checkResolved: created but not yet resolved has no status", () => { + const pools = createSquadPools(); + pools.observeCreated(1); + assert.equal(pools.status(1), undefined); +}); + +test("mismatches are bounded and evict the oldest when full", () => { + const pools = createSquadPools({ maxMismatches: 2 }); + for (const marketId of [1, 2, 3]) { + pools.observeCreated(marketId); + pools.record(balanceEvent({ marketId, name: "deposited", side: 0, amount: 100 })); + pools.checkResolved(marketId, 99n, 0n); + } + assert.equal(pools.mismatches.length, 2); + assert.equal(pools.mismatches[0].marketId, 2); + assert.equal(pools.mismatches[1].marketId, 3); +}); + +test("tracked markets are bounded and evict the oldest when full", () => { + const pools = createSquadPools({ maxMarkets: 2 }); + pools.observeCreated(1); + pools.observeCreated(2); + pools.observeCreated(3); + assert.equal(pools.size, 2); + assert.equal(pools.status(1), undefined); + assert.equal(pools.status(2), undefined); + assert.equal(pools.status(3), undefined); +});