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
@@ -188,6 +188,10 @@ ONESHOT_DAILY_LOSS_CAP=20
|
|||||||
# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE)
|
# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE)
|
||||||
ONESHOT_FILL_TIMEOUT_MS=800
|
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)
|
# Enable verbose debug logging (discovery probes, gate results, scoring, heartbeat)
|
||||||
# Can also be enabled with: npm run oneshot-debug
|
# Can also be enabled with: npm run oneshot-debug
|
||||||
# Or on the command line: ONESHOT_DEBUG=true npm run oneshot
|
# Or on the command line: ONESHOT_DEBUG=true npm run oneshot
|
||||||
|
|||||||
+43
-12
@@ -34,6 +34,7 @@ import { ExecutionEngine } from './oneshot/ExecutionEngine.js';
|
|||||||
import { RiskEngine } from './oneshot/RiskEngine.js';
|
import { RiskEngine } from './oneshot/RiskEngine.js';
|
||||||
import { PositionEngine } from './oneshot/PositionEngine.js';
|
import { PositionEngine } from './oneshot/PositionEngine.js';
|
||||||
import { Telemetry } from './oneshot/Telemetry.js';
|
import { Telemetry } from './oneshot/Telemetry.js';
|
||||||
|
import { RedeemEngine } from './oneshot/RedeemEngine.js';
|
||||||
import { State, Signal, ReasonCode } from './oneshot/constants.js';
|
import { State, Signal, ReasonCode } from './oneshot/constants.js';
|
||||||
import { DEBUG, dbg } from './oneshot/debug.js';
|
import { DEBUG, dbg } from './oneshot/debug.js';
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ const cfg = {
|
|||||||
cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10),
|
cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10),
|
||||||
dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'),
|
dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'),
|
||||||
fillTimeoutMs: parseInt(process.env.ONESHOT_FILL_TIMEOUT_MS || '800', 10),
|
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',
|
dryRun: process.env.DRY_RUN !== 'false',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ let signalEngine;
|
|||||||
let execEngine;
|
let execEngine;
|
||||||
let riskEngine;
|
let riskEngine;
|
||||||
let posEngine;
|
let posEngine;
|
||||||
|
let redeemEngine;
|
||||||
let telemetry;
|
let telemetry;
|
||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
@@ -89,7 +92,12 @@ async function main() {
|
|||||||
await initClient();
|
await initClient();
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
|
|
||||||
telemetry = new Telemetry();
|
telemetry = new Telemetry();
|
||||||
|
redeemEngine = new RedeemEngine({
|
||||||
|
dryRun: cfg.dryRun,
|
||||||
|
pollIntervalMs: cfg.redeemPollMs,
|
||||||
|
eventBus,
|
||||||
|
});
|
||||||
riskEngine = new RiskEngine({
|
riskEngine = new RiskEngine({
|
||||||
maxConsecLosses: cfg.maxConsecLosses,
|
maxConsecLosses: cfg.maxConsecLosses,
|
||||||
cooldownRounds: cfg.cooldownRounds,
|
cooldownRounds: cfg.cooldownRounds,
|
||||||
@@ -119,7 +127,15 @@ async function main() {
|
|||||||
eventBus.on('snapshot', onSnapshotForPositionMgmt);
|
eventBus.on('snapshot', onSnapshotForPositionMgmt);
|
||||||
eventBus.on('state:transition', onStateTransition);
|
eventBus.on('state:transition', onStateTransition);
|
||||||
|
|
||||||
|
redeemEngine.start();
|
||||||
await feedService.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...');
|
logger.success('OneShot Engine running — waiting for dominant side signals...');
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@@ -236,11 +252,13 @@ async function onSignal(evt) {
|
|||||||
|
|
||||||
if (result.status === 'filled') {
|
if (result.status === 'filled') {
|
||||||
posEngine.open(marketSlug, {
|
posEngine.open(marketSlug, {
|
||||||
tokenId: bookSide.tokenId,
|
tokenId: bookSide.tokenId,
|
||||||
side,
|
side,
|
||||||
shares: result.filledSize,
|
shares: result.filledSize,
|
||||||
entryPrice: result.avgFillPrice || entryPrice,
|
entryPrice: result.avgFillPrice || entryPrice,
|
||||||
tickSize: snapshot.tickSize,
|
tickSize: snapshot.tickSize,
|
||||||
|
conditionId: snapshot.conditionId,
|
||||||
|
negRisk: snapshot.negRisk,
|
||||||
});
|
});
|
||||||
sm.transition(State.POSITION_OPEN, 'fill_confirmed');
|
sm.transition(State.POSITION_OPEN, 'fill_confirmed');
|
||||||
logger.success(
|
logger.success(
|
||||||
@@ -252,11 +270,13 @@ async function onSignal(evt) {
|
|||||||
} else if (result.status === 'partial' && result.filledSize > 0) {
|
} else if (result.status === 'partial' && result.filledSize > 0) {
|
||||||
// Accept partial fill and hold to expiry
|
// Accept partial fill and hold to expiry
|
||||||
posEngine.open(marketSlug, {
|
posEngine.open(marketSlug, {
|
||||||
tokenId: bookSide.tokenId,
|
tokenId: bookSide.tokenId,
|
||||||
side,
|
side,
|
||||||
shares: result.filledSize,
|
shares: result.filledSize,
|
||||||
entryPrice: result.avgFillPrice || entryPrice,
|
entryPrice: result.avgFillPrice || entryPrice,
|
||||||
tickSize: snapshot.tickSize,
|
tickSize: snapshot.tickSize,
|
||||||
|
conditionId: snapshot.conditionId,
|
||||||
|
negRisk: snapshot.negRisk,
|
||||||
});
|
});
|
||||||
sm.transition(State.POSITION_OPEN, 'partial_fill_accepted');
|
sm.transition(State.POSITION_OPEN, 'partial_fill_accepted');
|
||||||
logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares | holding to expiry`);
|
logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares | holding to expiry`);
|
||||||
@@ -320,7 +340,7 @@ async function expirePosition(marketSlug, pos) {
|
|||||||
logger.success(
|
logger.success(
|
||||||
`OneShot: market EXPIRED | ${marketSlug} | ` +
|
`OneShot: market EXPIRED | ${marketSlug} | ` +
|
||||||
`${pos.shares} shares of ${pos.side.toUpperCase()} @ entry $${pos.entryPrice.toFixed(4)} | ` +
|
`${pos.shares} shares of ${pos.side.toUpperCase()} @ entry $${pos.entryPrice.toFixed(4)} | ` +
|
||||||
`pending on-chain redemption`,
|
`queuing for auto-redemption`,
|
||||||
);
|
);
|
||||||
|
|
||||||
posEngine.closeExpired(marketSlug);
|
posEngine.closeExpired(marketSlug);
|
||||||
@@ -329,11 +349,21 @@ async function expirePosition(marketSlug, pos) {
|
|||||||
marketSlug,
|
marketSlug,
|
||||||
exitReason: ReasonCode.EXIT_EXPIRED,
|
exitReason: ReasonCode.EXIT_EXPIRED,
|
||||||
entryPx: pos.entryPrice,
|
entryPx: pos.entryPrice,
|
||||||
exitPx: null, // unknown until redemption settles
|
exitPx: null, // settled on-chain — see redemption:complete event
|
||||||
pnl: null, // settled on-chain by redeemer.js
|
pnl: null,
|
||||||
shares: pos.shares,
|
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)) {
|
if (sm.canTransitionTo(State.IDLE)) {
|
||||||
sm.transition(State.IDLE, ReasonCode.EXIT_EXPIRED);
|
sm.transition(State.IDLE, ReasonCode.EXIT_EXPIRED);
|
||||||
}
|
}
|
||||||
@@ -408,6 +438,7 @@ function getOrCreateSM(marketSlug) {
|
|||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
logger.warn('OneShot: shutting down...');
|
logger.warn('OneShot: shutting down...');
|
||||||
feedService?.stop();
|
feedService?.stop();
|
||||||
|
redeemEngine?.stop();
|
||||||
|
|
||||||
// Report any positions still open at shutdown
|
// Report any positions still open at shutdown
|
||||||
const markets = feedService?.activeMarkets ?? [];
|
const markets = feedService?.activeMarkets ?? [];
|
||||||
|
|||||||
@@ -41,13 +41,15 @@ export class PositionEngine {
|
|||||||
*
|
*
|
||||||
* @param {string} marketSlug
|
* @param {string} marketSlug
|
||||||
* @param {Object} data
|
* @param {Object} data
|
||||||
* @param {string} data.tokenId
|
* @param {string} data.tokenId
|
||||||
* @param {'up'|'down'} data.side
|
* @param {'up'|'down'} data.side
|
||||||
* @param {number} data.shares
|
* @param {number} data.shares
|
||||||
* @param {number} data.entryPrice
|
* @param {number} data.entryPrice
|
||||||
* @param {number} data.tickSize
|
* @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, {
|
this._positions.set(marketSlug, {
|
||||||
marketSlug,
|
marketSlug,
|
||||||
tokenId,
|
tokenId,
|
||||||
@@ -55,6 +57,8 @@ export class PositionEngine {
|
|||||||
shares,
|
shares,
|
||||||
entryPrice,
|
entryPrice,
|
||||||
tickSize,
|
tickSize,
|
||||||
|
conditionId,
|
||||||
|
negRisk,
|
||||||
openedAt: Date.now(),
|
openedAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -137,11 +141,13 @@ export class PositionEngine {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PositionState
|
* @typedef {Object} PositionState
|
||||||
* @property {string} marketSlug
|
* @property {string} marketSlug
|
||||||
* @property {string} tokenId
|
* @property {string} tokenId
|
||||||
* @property {'up'|'down'} side
|
* @property {'up'|'down'} side
|
||||||
* @property {number} shares
|
* @property {number} shares
|
||||||
* @property {number} entryPrice
|
* @property {number} entryPrice
|
||||||
* @property {number} tickSize
|
* @property {number} tickSize
|
||||||
* @property {number} openedAt
|
* @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