fix: correct win detection by mapping tokenId→outcome instead of array index

Problem: redeemer used array index to match balances to payoutFractions,
but Data API token order ≠ on-chain outcome index. This caused false wins
(shares on losing side counted as winning).

Fix:
- Store yesTokenId/noTokenId per conditionId in sniperExecutor
- Match tokens by ID (yes=outcome0, no=outcome1) instead of array position
- Only process positions tracked by sniper (skip MM/other positions)
- Inject token lookup via setSniperConditionLookup to avoid circular imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-03-11 13:59:48 +07:00
parent a8f28c961b
commit d89b8ff54d
4 changed files with 58 additions and 17 deletions
+36 -7
View File
@@ -545,6 +545,10 @@ const _skippedLosses = new Set();
// Callback invoked when a win is detected — receives conditionId
let _onWinCallback = null;
// Function to look up conditionId → { asset, yesTokenId, noTokenId }
// Injected from sniper entry point to avoid circular imports
let _getConditionInfo = null;
/**
* Register a callback to be called when a sniper win is detected.
* Callback signature: (conditionId: string) => void
@@ -553,6 +557,14 @@ export function onSniperWin(cb) {
_onWinCallback = cb;
}
/**
* Register a function to look up sniper condition info (token mapping).
* Used to correctly map token balances to outcome indices.
*/
export function setSniperConditionLookup(fn) {
_getConditionInfo = fn;
}
/**
* Redeem sniper positions via Gnosis Safe.
* Only redeems WINNING positions — skip losses (they can be manually cleared).
@@ -639,19 +651,36 @@ export async function redeemSniperPositions() {
// Determine winning outcome index (the one with payoutFraction > 0)
const winningOutcome = payoutFractions[0] > 0 ? 0 : payoutFractions[1] > 0 ? 1 : -1;
const label = conditionId.slice(0, 12) + '...';
// Map token balances to outcome indices using sniper's token mapping.
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1
const sniperInfo = _getConditionInfo ? _getConditionInfo(conditionId) : null;
// Build outcome→balance mapping (keyed by outcome index, not array index)
const outcomeBalances = [0, 0];
if (sniperInfo) {
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].tokenId === sniperInfo.yesTokenId) outcomeBalances[0] = balances[i];
else if (tokens[i].tokenId === sniperInfo.noTokenId) outcomeBalances[1] = balances[i];
}
} else {
// No sniper mapping — skip (not a sniper position)
continue;
}
// Win = we hold shares on the winning outcome side
const isWin = winningOutcome >= 0 && balances[winningOutcome] > 0;
const expectedUsdc = balances.reduce(
const winShares = winningOutcome >= 0 ? outcomeBalances[winningOutcome] : 0;
const isWin = winShares > 0;
const expectedUsdc = outcomeBalances.reduce(
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
);
const label = conditionId.slice(0, 12) + '...';
// SNIPER: only redeem WINNERS — cache losses to skip next time
if (!isWin) {
_skippedLosses.add(conditionId);
if (config.dryRun) {
logger.info(`SNIPER[SIM] skip loss: ${label} ${totalShares.toFixed(3)} shares, outcome=${winningOutcome} (cached)`);
logger.info(`SNIPER[SIM] skip loss: ${label} — outcome=${winningOutcome}, win_shares=0 (cached)`);
} else {
logger.info(`SNIPER redeemer: skip loss ${label} — outcome=${winningOutcome}, no shares on winner`);
}
@@ -662,11 +691,11 @@ export async function redeemSniperPositions() {
if (_onWinCallback) _onWinCallback(conditionId);
if (config.dryRun) {
logger.money(`SNIPER[SIM] redeem: ${label}${balances[winningOutcome].toFixed(3)} shares on outcome ${winningOutcome} → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
logger.money(`SNIPER[SIM] redeem: ${label}${winShares.toFixed(3)} shares on outcome ${winningOutcome} → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
continue;
}
logger.info(`SNIPER redeemer: ${label} resolved WIN — outcome ${winningOutcome}, ${balances[winningOutcome].toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
logger.info(`SNIPER redeemer: ${label} resolved WIN — outcome ${winningOutcome}, ${winShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
// Call redeemPositions through Safe — winners only
const data = ctfIface.encodeFunctionData('redeemPositions', [
+15 -5
View File
@@ -16,15 +16,20 @@ import { getTimeMultiplier } from './sniperSizing.js';
// In-memory tracking of placed snipe orders
const activeSnipes = [];
// conditionId → asset mapping (for win detection → pause linkage)
const conditionAssetMap = new Map();
// conditionId → { asset, yesTokenId, noTokenId } mapping
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
const conditionInfoMap = new Map();
export function getActiveSnipes() {
return [...activeSnipes];
}
export function getConditionAsset(conditionId) {
return conditionAssetMap.get(conditionId) || null;
return conditionInfoMap.get(conditionId)?.asset || null;
}
export function getConditionInfo(conditionId) {
return conditionInfoMap.get(conditionId) || null;
}
/**
@@ -56,8 +61,13 @@ export async function executeSnipe(market) {
const label = question.slice(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
// Track conditionId → asset for win detection
conditionAssetMap.set(conditionId, asset.toLowerCase());
// Track conditionId → { asset, token IDs } for win detection
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
conditionInfoMap.set(conditionId, {
asset: asset.toLowerCase(),
yesTokenId,
noTokenId,
});
const sides = [
{ name: 'UP', tokenId: yesTokenId },
+3 -2
View File
@@ -17,8 +17,8 @@ import { initClient } from './services/client.js';
import { getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getActiveSnipes, getConditionAsset } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin } from './services/ctf.js';
import { executeSnipe, getActiveSnipes, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
import { getTimeMultiplier } from './services/sniperSizing.js';
@@ -181,6 +181,7 @@ function tickPause(asset) {
}
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Market handler ────────────────────────────────────────────────────────────
+4 -3
View File
@@ -19,8 +19,8 @@ import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getConditionAsset } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin } from './services/ctf.js';
import { executeSnipe, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
import { getTimeMultiplier } from './services/sniperSizing.js';
@@ -93,8 +93,9 @@ function tickPause(asset) {
}
}
// Register win callback
// Register win callback and token lookup for correct outcome mapping
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Log session schedule ──────────────────────────────────────────────────────