Skip to main content
The RedemptionWatcher monitors wallets for redeemable positions. It combines wallet position data from REST with real-time condition_resolution events from the oracle stream. When a market resolves, any tracked wallet holding that condition gets an instant alert with full payout details. One class, one WebSocket connection, zero polling.

Install

npm install polynode-sdk

Quick Start

import { RedemptionWatcher } from 'polynode-sdk';

const watcher = new RedemptionWatcher({ apiKey: 'pn_live_...' });

watcher.on('alert', (alert) => {
  if (alert.isWinner) {
    console.log(`${alert.wallet} can redeem ${alert.outcome} on "${alert.marketTitle}"`);
    console.log(`Payout: $${alert.estimatedPayoutUsd}`);
  }
});

watcher.on('ready', () => {
  console.log(`Tracking ${watcher.size} positions across ${watcher.wallets.length} wallets`);
});

await watcher.start([
  '0xabc...', // user wallet 1
  '0xdef...', // user wallet 2
]);

How It Works

  1. start(wallets) fetches positions for each wallet via REST (parallel), builds an internal index keyed by condition_id, then subscribes to the oracle WebSocket stream
  2. When a condition_resolution event arrives, the watcher cross-references event.condition_id against the position index
  3. For each matched position, a RedeemableAlert is emitted with wallet address, win/loss status, payout estimate, and full market metadata
  4. After alerts fire, the resolved condition is evicted from memory. Positions that drop to zero size (full sell) are also evicted. This keeps memory bounded to only active, non-zero positions regardless of how long the watcher runs.
  5. If trackPositionChanges is enabled (default), position sizes stay accurate in real-time via the wallets WebSocket stream. If a wallet enters a new market after start(), the watcher automatically picks it up from the enriched transfer event and adds it to the index.
  6. A periodic REST refresh (default: every 5 minutes) re-fetches all wallet positions as a safety net, catching any positions the stream may have missed during brief disconnections.
condition_resolution is the moment positions become redeemable on the Conditional Tokens contract. For neg-risk markets (the majority on Polymarket), this fires in a separate transaction after the UMA resolution event. See Oracle Events for the full resolution lifecycle.

Lifecycle

1. Construct

const watcher = new RedemptionWatcher({
  apiKey: 'pn_live_...',
  trackPositionChanges: true,  // live position + new market tracking (default)
});

2. Register Handlers

Register handlers before calling start() so you don’t miss the ready event.
watcher.on('alert', (alert) => {
  pushNotification(alert.wallet, {
    title: alert.isWinner ? 'Position Redeemable!' : 'Market Resolved',
    body: `${alert.marketTitle}${alert.winningOutcome} wins`,
    payout: alert.estimatedPayoutUsd,
  });
});

watcher.on('ready', () => {
  console.log(`Tracking ${watcher.size} positions`);
});

watcher.on('error', (err) => {
  console.error('Watcher error:', err.message);
});

3. Start

await watcher.start(['0x02227b...', '0xc2e780...']);
start() fetches positions for all wallets in parallel, indexes them by condition_id, subscribes to the oracle stream, and emits ready. Typical startup takes 100-300ms depending on wallet count. Real output from start() with one wallet:
Tracking 16 positions across 1 wallet(s)
Conditions: 16

4. Add/Remove Wallets at Runtime

// New user signs up — fetch their positions and start tracking
await watcher.addWallets(['0xefbc5f...']);
// wallets: 2, size: 55

// User leaves — purge state and update subscriptions
watcher.removeWallets(['0xefbc5f...']);
// wallets: 1, size: 16

5. Query State

watcher.wallets;                    // all tracked addresses
watcher.conditions;                 // all tracked condition IDs
watcher.size;                       // total position count
watcher.positionsFor('0x02227b...'); // positions for one wallet

6. Close

watcher.close();
Unsubscribes from oracle and wallet streams, stops the refresh timer, disconnects the WebSocket, and resolves any pending async iterators.

Async Iterator

Consume alerts sequentially with for await:
for await (const alert of watcher) {
  await processRedemption(alert);
  // backpressure: next alert waits until this one is processed
}
The iterator terminates when watcher.close() is called.

Alert Object

When a condition_resolution event matches a tracked position, the watcher emits a RedeemableAlert:
interface RedeemableAlert {
  wallet: string;           // which tracked wallet holds this position
  conditionId: string;      // Polymarket condition ID (hex)
  tokenId: string;          // conditional token ID held by the wallet
  outcome: string;          // which outcome the wallet holds ("Over", "Yes", etc.)
  winningOutcome: string;   // which outcome won ("Under", "No", etc.)
  isWinner: boolean;        // whether this position pays out
  size: number;             // position size in tokens
  estimatedPayoutUsd: number; // $1 per token if winner, $0 if loser
  marketTitle: string;      // human-readable market question
  marketSlug: string;       // URL slug on polymarket.com
  marketImage?: string;     // market image URL
  resolvedPrice: number;    // UMA resolved price (1 = first outcome, 0 = second)
  payouts: number[];        // payout array from the contract ([1, 0] or [0, 1])
  blockNumber: number;      // Polygon block number
  timestamp: number;        // block timestamp in ms
}
Example alert (if a wallet held 500 “Over” tokens on the Dota 2 kills market below):
{
  "wallet": "0x02227b8f5a9636e895607edd3185ed6ee5598ff7",
  "conditionId": "0xf5fbfb50f2ad1f61569fa475b8883d2f1ee10fdceab24f069528b717a40cc101",
  "tokenId": "57488988409414421964789033691537502121286921656702379301876764465477206797606",
  "outcome": "Over",
  "winningOutcome": "Over",
  "isWinner": true,
  "size": 500,
  "estimatedPayoutUsd": 500,
  "marketTitle": "Total Kills Over/Under 45.5 in Game 1?",
  "marketSlug": "dota2-ty-mouz-2026-03-25-game1-kill-over-45pt5",
  "marketImage": "https://polymarket-upload.s3.us-east-2.amazonaws.com/dota2-7ffacddb21.jpg",
  "resolvedPrice": 1,
  "payouts": [1, 0],
  "blockNumber": 84667307,
  "timestamp": 1774454649000
}

Tracked Positions

Each position loaded from the REST API is stored as a TrackedPosition:
interface TrackedPosition {
  wallet: string;
  tokenId: string;
  conditionId: string;
  outcome: string;       // "Yes", "No", "Over", "Under", team names, etc.
  size: number;          // position size in tokens
  marketTitle: string;
  marketSlug: string;
  marketImage?: string;
  outcomes: string[];    // all outcomes for this market
  tokenIds: string[];    // all token IDs for this market
  negRisk?: boolean;     // neg-risk framework (multi-outcome markets)
}
Real output from positionsFor():
[
  {
    "wallet": "0x02227b8f...",
    "tokenId": "80628887006942228330415332521239838709...",
    "conditionId": "0xf88a2140ac54353c2f51b2ee20de43c72b0d...",
    "outcome": "No",
    "size": 3440212.886182,
    "marketTitle": "Will Manchester City FC win on 2026-02-21?",
    "marketSlug": "epl-mac-new-2026-02-21-mac",
    "negRisk": true
  },
  {
    "wallet": "0x02227b8f...",
    "tokenId": "11424825366156360215190655536940393847...",
    "conditionId": "0x18f34febea506bed46b77c3126369eb9cc00...",
    "outcome": "Yes",
    "size": 1625004.089452,
    "marketTitle": "Will Toulouse FC win on 2026-02-15?",
    "marketSlug": "fl1-hac-tou-2026-02-15-tou",
    "negRisk": true
  }
]

The Event That Triggers Alerts

The watcher listens for condition_resolution oracle events. Here’s a real one from a Dota 2 esports market:
{
  "event_type": "oracle",
  "oracle_type": "condition_resolution",
  "block_number": 84667307,
  "timestamp": 1774454649000,
  "tx_hash": "0x863f3e30d4d3e84c80682565d40502257ec1580e34f212838e6954d32b738f7b",
  "condition_id": "0xf5fbfb50f2ad1f61569fa475b8883d2f1ee10fdceab24f069528b717a40cc101",
  "question_id": "0x22d5713c949303f38b5add45b0dd694cb6914fcdd33d35e908ef47f918fa2147",
  "adapter_address": "0x65070be91477460d8a7aeeb94ef92fe056c2f2a7",
  "resolved_price": 1,
  "resolved_outcome": "Over",
  "payouts": [1, 0],
  "market_title": "Total Kills Over/Under 45.5 in Game 1?",
  "market_slug": "dota2-ty-mouz-2026-03-25-game1-kill-over-45pt5",
  "event_title": "Dota 2: Team Yandex vs MOUZ (BO2) - ESL One Birmingham Group A",
  "market_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/dota2-7ffacddb21.jpg",
  "outcomes": ["Over", "Under"],
  "token_ids": [
    "57488988409414421964789033691537502121286921656702379301876764465477206797606",
    "44113030389329339383080791624849973102915570622427276234797737004302338372036"
  ],
  "neg_risk": false
}
Winner detection: The watcher finds the wallet’s tokenId in event.token_ids, checks the corresponding index in event.payouts. If payouts[index] > 0, the position is a winner and pays out $1 × size.

Configuration

interface RedemptionWatcherConfig {
  apiKey: string;         // required — PolyNode API key
  baseUrl?: string;       // REST API base (default: https://api.polynode.dev)
  wsUrl?: string;         // WebSocket URL (default: wss://ws.polynode.dev/ws)
  compress?: boolean;     // zlib compression (default: true via PolyNodeWS)
  autoReconnect?: boolean; // auto-reconnect on disconnect (default: true)
  trackPositionChanges?: boolean; // live position delta tracking (default: true)
  refreshInterval?: number;       // periodic REST refresh in ms (default: 300000 = 5 min)
}

trackPositionChanges

When enabled (default), the watcher subscribes to the wallets WebSocket stream. This does two things:
  1. Size tracking — updates position sizes in real-time as ERC-1155 transfers occur. If a user buys or sells tokens after start(), the size field stays accurate for payout calculations.
  2. New market discovery — when a wallet enters a market the watcher hasn’t seen before, the enriched transfer event carries full market metadata (condition_id, market_title, outcomes, token_ids). The watcher creates a new tracked position automatically. No gaps.

refreshInterval

Periodic REST re-fetch of all wallet positions. Acts as a safety net in case a transfer event is missed due to a brief WebSocket disconnection, ensuring the watcher eventually picks up any positions the stream didn’t catch. Default: 300_000 (5 minutes). Set to 0 to disable, or lower (e.g. 60_000) if you need faster recovery.

Full Example: Notification Service

import { RedemptionWatcher } from 'polynode-sdk';

const watcher = new RedemptionWatcher({
  apiKey: process.env.POLYNODE_API_KEY!,
});

watcher.on('error', (err) => console.error('[watcher]', err.message));

watcher.on('ready', () => {
  console.log(`Monitoring ${watcher.size} positions across ${watcher.wallets.length} wallets`);
});

// Load wallets from your database
const userWallets = await db.query('SELECT wallet FROM users WHERE active = true');
await watcher.start(userWallets.map(u => u.wallet));

// Process alerts
for await (const alert of watcher) {
  await db.insert('redemption_alerts', {
    wallet: alert.wallet,
    market: alert.marketTitle,
    outcome: alert.outcome,
    is_winner: alert.isWinner,
    payout_usd: alert.estimatedPayoutUsd,
    condition_id: alert.conditionId,
    block_number: alert.blockNumber,
  });

  if (alert.isWinner) {
    await sendPushNotification(alert.wallet, {
      title: 'Position Redeemable!',
      body: `"${alert.marketTitle}" resolved ${alert.winningOutcome}. Redeem ~$${alert.estimatedPayoutUsd.toLocaleString()}.`,
    });
  }
}

Memory Management

The watcher is designed to run indefinitely with bounded memory, even when tracking thousands of wallets.
  • Resolved conditions are evicted after alerts fire. A condition only resolves once, so there’s nothing left to watch.
  • Zero-size positions are evicted when a wallet fully sells out of a market. If they buy back in, the position is re-created from the next position_change event or the periodic REST refresh.
  • The periodic REST refresh (default: every 5 minutes) acts as a safety net. Even if a stream event is missed, the refresh catches it within one cycle.
Memory usage is proportional to the number of active, non-zero positions across all tracked wallets at any given moment. Historical positions, resolved markets, and fully-sold positions do not accumulate.

API Reference

Constructor

new RedemptionWatcher(config: RedemptionWatcherConfig)

Methods

MethodReturnsDescription
start(wallets)Promise<void>Fetch positions, subscribe to streams, begin watching
addWallets(wallets)Promise<void>Add wallets at runtime, fetch their positions
removeWallets(wallets)voidRemove wallets, clean up state
positionsFor(wallet)TrackedPosition[]Get tracked positions for one wallet
close()voidUnsubscribe, disconnect, clean up

Properties

PropertyTypeDescription
walletsstring[]All tracked wallet addresses
conditionsstring[]All tracked condition IDs
sizenumberTotal tracked position count

Events

EventPayloadDescription
alertRedeemableAlertA tracked position’s condition resolved
ready(none)Positions loaded and streams connected
errorErrorREST fetch failed or stream error