From 89a7803aa85c1c7a8e6e98eb1e2d0a23936738d5 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Tue, 24 Feb 2026 14:20:57 +0700 Subject: [PATCH] feat(oneshot): add auto-redemption via RedeemEngine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a position's market expires, RedeemEngine automatically polls the CTF contract and redeems the winning position on-chain — no manual redeem needed. Flow: 1. expirePosition() queues the expired position into RedeemEngine 2. RedeemEngine polls every 30s (ONESHOT_REDEEM_POLL_MS) 3. Checks Gamma API first, then CTF.payoutDenominator() on-chain 4. When settled: emits redemption:complete event with final P&L 5. Orchestrator passes P&L to RiskEngine DRY_RUN=true: simulates by reading on-chain payouts and logging win/loss DRY_RUN=false: submits real redeemPositions() tx on Polygon (gasLimit 300k) Also stores conditionId and negRisk in PositionEngine state so the expired position has all data needed for redemption without extra lookups. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 + src/oneshot.js | 55 +++++-- src/oneshot/PositionEngine.js | 28 ++-- src/oneshot/RedeemEngine.js | 300 ++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 src/oneshot/RedeemEngine.js diff --git a/.env.example b/.env.example index ec2dca4..f21bb6e 100644 --- a/.env.example +++ b/.env.example @@ -188,6 +188,10 @@ ONESHOT_DAILY_LOSS_CAP=20 # Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE) ONESHOT_FILL_TIMEOUT_MS=800 +# How often (ms) to poll for on-chain redemption after market expiry. +# 5m markets typically settle on-chain within 2–5 minutes after close. +ONESHOT_REDEEM_POLL_MS=30000 + # Enable verbose debug logging (discovery probes, gate results, scoring, heartbeat) # Can also be enabled with: npm run oneshot-debug # Or on the command line: ONESHOT_DEBUG=true npm run oneshot diff --git a/src/oneshot.js b/src/oneshot.js index d9189f9..e76d978 100644 --- a/src/oneshot.js +++ b/src/oneshot.js @@ -34,6 +34,7 @@ import { ExecutionEngine } from './oneshot/ExecutionEngine.js'; import { RiskEngine } from './oneshot/RiskEngine.js'; import { PositionEngine } from './oneshot/PositionEngine.js'; import { Telemetry } from './oneshot/Telemetry.js'; +import { RedeemEngine } from './oneshot/RedeemEngine.js'; import { State, Signal, ReasonCode } from './oneshot/constants.js'; import { DEBUG, dbg } from './oneshot/debug.js'; @@ -54,6 +55,7 @@ const cfg = { cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10), dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'), fillTimeoutMs: parseInt(process.env.ONESHOT_FILL_TIMEOUT_MS || '800', 10), + redeemPollMs: parseInt(process.env.ONESHOT_REDEEM_POLL_MS || '30000', 10), dryRun: process.env.DRY_RUN !== 'false', }; @@ -70,6 +72,7 @@ let signalEngine; let execEngine; let riskEngine; let posEngine; +let redeemEngine; let telemetry; // ── Entry point ─────────────────────────────────────────────────────────────── @@ -89,7 +92,12 @@ async function main() { await initClient(); const client = getClient(); - telemetry = new Telemetry(); + telemetry = new Telemetry(); + redeemEngine = new RedeemEngine({ + dryRun: cfg.dryRun, + pollIntervalMs: cfg.redeemPollMs, + eventBus, + }); riskEngine = new RiskEngine({ maxConsecLosses: cfg.maxConsecLosses, cooldownRounds: cfg.cooldownRounds, @@ -119,7 +127,15 @@ async function main() { eventBus.on('snapshot', onSnapshotForPositionMgmt); eventBus.on('state:transition', onStateTransition); + redeemEngine.start(); await feedService.start(); + + // Report final P&L when a redemption settles + eventBus.on('redemption:complete', ({ marketSlug, won, pnl }) => { + riskEngine.recordResult(pnl); + logger.info(`[REDEEM] ${marketSlug} settled | ${won ? 'WIN' : 'LOSS'} | pnl=${won ? '+' : ''}$${pnl.toFixed(4)}`); + }); + logger.success('OneShot Engine running — waiting for dominant side signals...'); if (DEBUG) { @@ -236,11 +252,13 @@ async function onSignal(evt) { if (result.status === 'filled') { posEngine.open(marketSlug, { - tokenId: bookSide.tokenId, + tokenId: bookSide.tokenId, side, - shares: result.filledSize, - entryPrice: result.avgFillPrice || entryPrice, - tickSize: snapshot.tickSize, + shares: result.filledSize, + entryPrice: result.avgFillPrice || entryPrice, + tickSize: snapshot.tickSize, + conditionId: snapshot.conditionId, + negRisk: snapshot.negRisk, }); sm.transition(State.POSITION_OPEN, 'fill_confirmed'); logger.success( @@ -252,11 +270,13 @@ async function onSignal(evt) { } else if (result.status === 'partial' && result.filledSize > 0) { // Accept partial fill and hold to expiry posEngine.open(marketSlug, { - tokenId: bookSide.tokenId, + tokenId: bookSide.tokenId, side, - shares: result.filledSize, - entryPrice: result.avgFillPrice || entryPrice, - tickSize: snapshot.tickSize, + shares: result.filledSize, + entryPrice: result.avgFillPrice || entryPrice, + tickSize: snapshot.tickSize, + conditionId: snapshot.conditionId, + negRisk: snapshot.negRisk, }); sm.transition(State.POSITION_OPEN, 'partial_fill_accepted'); logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares | holding to expiry`); @@ -320,7 +340,7 @@ async function expirePosition(marketSlug, pos) { logger.success( `OneShot: market EXPIRED | ${marketSlug} | ` + `${pos.shares} shares of ${pos.side.toUpperCase()} @ entry $${pos.entryPrice.toFixed(4)} | ` + - `pending on-chain redemption`, + `queuing for auto-redemption`, ); posEngine.closeExpired(marketSlug); @@ -329,11 +349,21 @@ async function expirePosition(marketSlug, pos) { marketSlug, exitReason: ReasonCode.EXIT_EXPIRED, entryPx: pos.entryPrice, - exitPx: null, // unknown until redemption settles - pnl: null, // settled on-chain by redeemer.js + exitPx: null, // settled on-chain — see redemption:complete event + pnl: null, shares: pos.shares, }); + // Hand off to RedeemEngine — it will poll until settled and report final P&L + redeemEngine.queueRedemption({ + conditionId: pos.conditionId, + marketSlug, + side: pos.side, + shares: pos.shares, + entryPrice: pos.entryPrice, + negRisk: pos.negRisk, + }); + if (sm.canTransitionTo(State.IDLE)) { sm.transition(State.IDLE, ReasonCode.EXIT_EXPIRED); } @@ -408,6 +438,7 @@ function getOrCreateSM(marketSlug) { async function shutdown() { logger.warn('OneShot: shutting down...'); feedService?.stop(); + redeemEngine?.stop(); // Report any positions still open at shutdown const markets = feedService?.activeMarkets ?? []; diff --git a/src/oneshot/PositionEngine.js b/src/oneshot/PositionEngine.js index 912f23a..dacaa81 100644 --- a/src/oneshot/PositionEngine.js +++ b/src/oneshot/PositionEngine.js @@ -41,13 +41,15 @@ export class PositionEngine { * * @param {string} marketSlug * @param {Object} data - * @param {string} data.tokenId + * @param {string} data.tokenId * @param {'up'|'down'} data.side - * @param {number} data.shares - * @param {number} data.entryPrice - * @param {number} data.tickSize + * @param {number} data.shares + * @param {number} data.entryPrice + * @param {number} data.tickSize + * @param {string} [data.conditionId] - Required for auto-redemption + * @param {boolean} [data.negRisk] - Which CTF contract to use for redemption */ - open(marketSlug, { tokenId, side, shares, entryPrice, tickSize }) { + open(marketSlug, { tokenId, side, shares, entryPrice, tickSize, conditionId = null, negRisk = false }) { this._positions.set(marketSlug, { marketSlug, tokenId, @@ -55,6 +57,8 @@ export class PositionEngine { shares, entryPrice, tickSize, + conditionId, + negRisk, openedAt: Date.now(), }); } @@ -137,11 +141,13 @@ export class PositionEngine { /** * @typedef {Object} PositionState - * @property {string} marketSlug - * @property {string} tokenId + * @property {string} marketSlug + * @property {string} tokenId * @property {'up'|'down'} side - * @property {number} shares - * @property {number} entryPrice - * @property {number} tickSize - * @property {number} openedAt + * @property {number} shares + * @property {number} entryPrice + * @property {number} tickSize + * @property {string|null} conditionId - CTF condition ID for on-chain redemption + * @property {boolean} negRisk - Whether to use NegRisk CTF contract + * @property {number} openedAt */ diff --git a/src/oneshot/RedeemEngine.js b/src/oneshot/RedeemEngine.js new file mode 100644 index 0000000..96c35d1 --- /dev/null +++ b/src/oneshot/RedeemEngine.js @@ -0,0 +1,300 @@ +/** + * RedeemEngine.js + * Auto-redemption service for the OneShot Dominant Side Hold engine. + * + * When a market expires and the position is cleared, this service queues the + * position and polls at a regular interval until the CTF contract shows a + * non-zero payout denominator (i.e. the market has been resolved on-chain). + * It then either: + * - DRY_RUN=true → simulates the outcome, logs win/loss P&L + * - DRY_RUN=false → submits a real redeemPositions() transaction on Polygon + * + * Resolution flow: + * 1. Gamma API check → market.closed || market.resolved + * 2. On-chain check → CTF.payoutDenominator(conditionId) > 0 + * 3. Compute payout → payouts[0] for UP (YES), payouts[1] for DOWN (NO) + * 4. Execute / log + * 5. Emit 'redemption:complete' on EventBus with final P&L + * + * Payout index mapping: + * side === 'up' → outcome index 0 (YES / Up token) + * side === 'down' → outcome index 1 (NO / Down token) + */ + +import { ethers } from 'ethers'; +import config from '../config/index.js'; +import logger from '../utils/logger.js'; +import { getPolygonProvider } from '../services/client.js'; +import { dbg } from './debug.js'; + +// ── On-chain constants ──────────────────────────────────────────────────────── + +const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; +const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a'; +const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; + +const CTF_ABI = [ + 'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)', + 'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)', + 'function payoutDenominator(bytes32 conditionId) view returns (uint256)', +]; + +export class RedeemEngine { + /** + * @param {Object} opts + * @param {boolean} opts.dryRun - If true, simulate instead of real tx + * @param {number} [opts.pollIntervalMs] - How often to check pending queue (ms) + * @param {import('./EventBus.js').default} opts.eventBus + */ + constructor({ dryRun, pollIntervalMs = 30_000, eventBus }) { + this._dryRun = dryRun; + this._pollMs = pollIntervalMs; + this._eventBus = eventBus; + this._pollTimer = null; + + /** + * @type {Map} + * Key: conditionId + */ + this._queue = new Map(); + + /** Prevent concurrent processing of the same conditionId */ + this._processing = new Set(); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + start() { + this._pollTimer = setInterval(() => this._poll().catch(() => {}), this._pollMs); + logger.info(`RedeemEngine: started | poll every ${this._pollMs / 1000}s | dryRun=${this._dryRun}`); + } + + stop() { + clearInterval(this._pollTimer); + if (this._queue.size > 0) { + logger.warn(`RedeemEngine: stopped — ${this._queue.size} position(s) still pending redemption:`); + for (const [, item] of this._queue) { + logger.warn(` → ${item.marketSlug} | ${item.side.toUpperCase()} | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`); + } + } else { + logger.info('RedeemEngine: stopped — no pending redemptions'); + } + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Add an expired position to the redemption queue. + * Safe to call multiple times — duplicate conditionIds are ignored. + * + * @param {Object} data + * @param {string} data.conditionId + * @param {string} data.marketSlug + * @param {'up'|'down'} data.side + * @param {number} data.shares + * @param {number} data.entryPrice + * @param {boolean} data.negRisk + */ + queueRedemption({ conditionId, marketSlug, side, shares, entryPrice, negRisk }) { + if (!conditionId) { + logger.warn(`RedeemEngine: missing conditionId for ${marketSlug} — skipping queue`); + return; + } + if (this._queue.has(conditionId)) return; + + this._queue.set(conditionId, { + conditionId, + marketSlug, + side, + shares, + entryPrice, + negRisk: negRisk ?? false, + queuedAt: Date.now(), + }); + + logger.info( + `RedeemEngine: queued ${marketSlug} | ${side.toUpperCase()} | ` + + `${shares} shares @ $${entryPrice.toFixed(4)} | pending on-chain resolution`, + ); + + // Trigger an immediate check rather than waiting for the first poll tick + this._checkAndRedeem(this._queue.get(conditionId)).catch(() => {}); + } + + /** Number of positions waiting to be redeemed */ + get pendingCount() { + return this._queue.size; + } + + // ── Poll loop ───────────────────────────────────────────────────────────── + + async _poll() { + if (this._queue.size === 0) return; + dbg('REDEEM', `poll — ${this._queue.size} pending: [${[...this._queue.keys()].map((id) => id.slice(0, 8) + '...').join(', ')}]`); + + for (const [, item] of this._queue) { + if (this._processing.has(item.conditionId)) continue; + this._processing.add(item.conditionId); + this._checkAndRedeem(item) + .catch((err) => logger.error(`RedeemEngine: error on ${item.marketSlug} — ${err.message}`)) + .finally(() => this._processing.delete(item.conditionId)); + } + } + + // ── Resolution check ────────────────────────────────────────────────────── + + async _checkAndRedeem(item) { + // Always use on-chain as ground truth for payout data + const onChain = await this._checkOnChainPayout(item.conditionId); + + if (!onChain.resolved) { + // Gamma API as a secondary status check (informational only) + const gammaResolved = await this._checkGammaResolution(item.conditionId); + const secWaiting = Math.floor((Date.now() - item.queuedAt) / 1000); + + dbg('REDEEM', + `${item.marketSlug} | not yet settled on-chain | ` + + `gammaResolved=${gammaResolved} | waited=${secWaiting}s`, + ); + return; // retry on next poll tick + } + + await this._settle(item, onChain.payouts); + } + + // ── Settlement ──────────────────────────────────────────────────────────── + + async _settle(item, payouts) { + // UP token = outcome index 0 (YES), DOWN token = outcome index 1 (NO) + const outcomeIdx = item.side === 'up' ? 0 : 1; + const payoutFraction = payouts[outcomeIdx] ?? 0; + const won = payoutFraction > 0; + const received = payoutFraction * item.shares; // USDC back from CTF + const cost = item.entryPrice * item.shares; // USDC paid at entry + const pnl = received - cost; + + if (this._dryRun) { + // Simulate: just log the outcome without touching the chain + this._logSettlement(item, won, pnl, received, cost); + } else { + // Real redemption: submit on-chain tx + const success = await this._executeRedeem(item); + if (!success) { + // tx failed — keep in queue, retry on next poll + logger.warn(`RedeemEngine: redemption tx failed for ${item.marketSlug} — will retry`); + return; + } + this._logSettlement(item, won, pnl, received, cost); + } + + // Clear from queue and notify orchestrator + this._queue.delete(item.conditionId); + + this._eventBus.emit('redemption:complete', { + conditionId: item.conditionId, + marketSlug: item.marketSlug, + side: item.side, + won, + pnl, + shares: item.shares, + entryPrice: item.entryPrice, + }); + } + + _logSettlement(item, won, pnl, received, cost) { + const tag = this._dryRun ? '[SIM]' : ''; + + if (won) { + const pct = cost > 0 ? ((pnl / cost) * 100).toFixed(1) : '0.0'; + logger.money( + `${tag} RedeemEngine WIN | ${item.marketSlug} | ${item.side.toUpperCase()} won | ` + + `+$${pnl.toFixed(4)} (+${pct}%) | ` + + `${item.shares} shares: paid $${cost.toFixed(4)} → received $${received.toFixed(4)}`, + ); + } else { + logger.error( + `${tag} RedeemEngine LOSS | ${item.marketSlug} | ${item.side.toUpperCase()} lost | ` + + `-$${cost.toFixed(4)} (-100%) | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`, + ); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + async _checkGammaResolution(conditionId) { + try { + const url = `${config.gammaHost}/markets?condition_id=${conditionId}`; + const resp = await fetch(url); + if (!resp.ok) return false; + const markets = await resp.json(); + if (!Array.isArray(markets) || markets.length === 0) return false; + const m = markets[0]; + return !!(m.closed || m.resolved); + } catch { + return false; + } + } + + /** + * Read payoutNumerators and payoutDenominator from the CTF contract. + * Returns resolved=true only when denominator > 0 (market has been settled). + */ + async _checkOnChainPayout(conditionId) { + try { + const provider = await getPolygonProvider(); + const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider); + + const denom = await ctf.payoutDenominator(conditionId); + if (denom.isZero()) return { resolved: false, payouts: [] }; + + const payouts = []; + for (let i = 0; i < 2; i++) { + const num = await ctf.payoutNumerators(conditionId, i); + payouts.push(num.toNumber() / denom.toNumber()); + } + return { resolved: true, payouts }; + } catch { + return { resolved: false, payouts: [] }; + } + } + + /** Submit redeemPositions() transaction on Polygon */ + async _executeRedeem(item) { + try { + const provider = await getPolygonProvider(); + const wallet = new ethers.Wallet(config.privateKey, provider); + const ctfAddress = item.negRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS; + const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet); + + logger.info(`RedeemEngine: submitting redeem tx | ${item.marketSlug}...`); + + const tx = await ctf.redeemPositions( + USDC_ADDRESS, + ethers.constants.HashZero, // parentCollectionId = 0x000... + item.conditionId, + [1, 2], // indexSets: claim both outcomes (CTF discards the losing side) + { gasLimit: 300_000 }, + ); + + logger.info(`RedeemEngine: tx submitted | hash=${tx.hash}`); + const receipt = await tx.wait(); + logger.success(`RedeemEngine: confirmed | block=${receipt.blockNumber} | ${item.marketSlug}`); + return true; + + } catch (err) { + logger.error(`RedeemEngine: tx error | ${item.marketSlug} — ${err.message}`); + return false; + } + } +} + +/** + * @typedef {Object} PendingRedemption + * @property {string} conditionId + * @property {string} marketSlug + * @property {'up'|'down'} side + * @property {number} shares + * @property {number} entryPrice + * @property {boolean} negRisk + * @property {number} queuedAt - timestamp when queued + */