feat(oneshot): add auto-redemption via RedeemEngine

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 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-02-24 14:20:57 +07:00
co-authored by Claude Sonnet 4.6
parent 2692694309
commit 89a7803aa8
4 changed files with 364 additions and 23 deletions
+43 -12
View File
@@ -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 ?? [];
+17 -11
View File
@@ -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
*/
+300
View File
@@ -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<string, PendingRedemption>}
* 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
*/