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:
co-authored by
Claude Sonnet 4.6
parent
2692694309
commit
89a7803aa8
+43
-12
@@ -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 ?? [];
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
Reference in New Issue
Block a user