From d89b8ff54d99d4eebaa5997c44947d63fd6d110a Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Wed, 11 Mar 2026 13:59:48 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20correct=20win=20detection=20by=20mapping?= =?UTF-8?q?=20tokenId=E2=86=92outcome=20instead=20of=20array=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/services/ctf.js | 43 ++++++++++++++++++++++++++++------ src/services/sniperExecutor.js | 20 ++++++++++++---- src/sniper-tui.js | 5 ++-- src/sniper.js | 7 +++--- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/services/ctf.js b/src/services/ctf.js index 8d6a6d2..d2b855d 100644 --- a/src/services/ctf.js +++ b/src/services/ctf.js @@ -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', [ diff --git a/src/services/sniperExecutor.js b/src/services/sniperExecutor.js index d51835a..ed3e3bb 100644 --- a/src/services/sniperExecutor.js +++ b/src/services/sniperExecutor.js @@ -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 }, diff --git a/src/sniper-tui.js b/src/sniper-tui.js index 1d9703a..0da21d6 100644 --- a/src/sniper-tui.js +++ b/src/sniper-tui.js @@ -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 ──────────────────────────────────────────────────────────── diff --git a/src/sniper.js b/src/sniper.js index 167ab8b..7aa0b6e 100644 --- a/src/sniper.js +++ b/src/sniper.js @@ -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 ──────────────────────────────────────────────────────