executeBuy (race condition): - Multiple WebSocket events for the same market can arrive concurrently. All calls saw no existing position and all proceeded to buy → 3x fills. - Added _buyQueue (Map<conditionId, Promise>) that chains each buy for the same market after the previous one. Second call now sees the filled position and respects maxPositionSize. redeemer (gas estimation error): - Gamma API can return resolved=true before payoutDenominator is written on-chain. Calling redeemPositions when payoutDenominator==0 causes the contract to revert → UNPREDICTABLE_GAS_LIMIT from ethers.js. - Now always verify on-chain payout after the API check. If on-chain payout not set yet, skip and retry next interval. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.4 KiB
JavaScript
176 lines
6.4 KiB
JavaScript
import { ethers } from 'ethers';
|
|
import config from '../config/index.js';
|
|
import { getPolygonProvider } from './client.js';
|
|
import { execSafeCall, CTF_ADDRESS, USDC_ADDRESS } from './ctf.js';
|
|
import { getOpenPositions, removePosition } from './position.js';
|
|
import { recordSimResult } from '../utils/simStats.js';
|
|
import logger from '../utils/logger.js';
|
|
|
|
// CTF ABI (minimal — read-only calls only; writes go through execSafeCall)
|
|
const CTF_ABI = [
|
|
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
|
|
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
|
|
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
|
|
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
|
|
];
|
|
|
|
/**
|
|
* Check if a market has been resolved via Gamma API
|
|
*/
|
|
async function checkMarketResolution(conditionId) {
|
|
try {
|
|
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
|
const response = await fetch(url);
|
|
if (!response.ok) return null;
|
|
|
|
const markets = await response.json();
|
|
if (!markets || markets.length === 0) return null;
|
|
|
|
const market = markets[0];
|
|
return {
|
|
resolved: market.closed || market.resolved || false,
|
|
active: market.active,
|
|
question: market.question,
|
|
};
|
|
} catch (err) {
|
|
logger.error('Failed to check market resolution:', err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check on-chain payout fractions for a condition
|
|
* Returns: { resolved: bool, payouts: [yes_fraction, no_fraction] }
|
|
*/
|
|
async function checkOnChainPayout(conditionId) {
|
|
try {
|
|
const provider = await getPolygonProvider();
|
|
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
|
|
|
const denominator = await ctf.payoutDenominator(conditionId);
|
|
if (denominator.isZero()) return { resolved: false, payouts: [] };
|
|
|
|
const payouts = [];
|
|
for (let i = 0; i < 2; i++) {
|
|
const numerator = await ctf.payoutNumerators(conditionId, i);
|
|
payouts.push(numerator.toNumber() / denominator.toNumber());
|
|
}
|
|
|
|
return { resolved: true, payouts };
|
|
} catch {
|
|
return { resolved: false, payouts: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redeem winning position on-chain via the Gnosis Safe proxy wallet.
|
|
* Uses execSafeCall (same as MM bot) so:
|
|
* - tx is signed by the EOA but executed FROM the proxy wallet
|
|
* - Polygon 30 Gwei minimum tip is enforced
|
|
* - automatic retry on transient errors
|
|
*/
|
|
async function redeemPosition(conditionId) {
|
|
try {
|
|
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
|
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
|
USDC_ADDRESS,
|
|
ethers.constants.HashZero,
|
|
conditionId,
|
|
[1, 2],
|
|
]);
|
|
|
|
const label = conditionId.slice(0, 12) + '...';
|
|
logger.info(`Redeeming position: ${label}`);
|
|
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
|
logger.success(`Redeemed in block ${receipt.blockNumber}`);
|
|
return true;
|
|
} catch (err) {
|
|
logger.error(`Failed to redeem: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simulate redemption: determine win/loss and record stats
|
|
*/
|
|
async function simulateRedeem(position) {
|
|
// Need on-chain payout to know who actually won
|
|
const onChain = await checkOnChainPayout(position.conditionId);
|
|
|
|
if (!onChain.resolved) {
|
|
logger.info(`[SIM] Market resolved via API but payout not on-chain yet: ${position.market}`);
|
|
return false; // check again next interval
|
|
}
|
|
|
|
// outcome index: YES = 0, NO = 1
|
|
const outcomeStr = (position.outcome || 'yes').toLowerCase();
|
|
const outcomeIdx = outcomeStr === 'yes' ? 0 : 1;
|
|
const payoutFraction = onChain.payouts[outcomeIdx] ?? 0;
|
|
|
|
// In Polymarket, winning shares redeem at $1 each
|
|
const returned = payoutFraction * position.shares;
|
|
const pnl = returned - position.totalCost;
|
|
|
|
if (payoutFraction > 0) {
|
|
logger.money(
|
|
`[SIM] WIN! "${position.market}" | ${position.outcome} won` +
|
|
` | +$${pnl.toFixed(2)} (+${((pnl / position.totalCost) * 100).toFixed(1)}%)`,
|
|
);
|
|
recordSimResult(position, 'WIN', pnl, returned);
|
|
} else {
|
|
logger.error(
|
|
`[SIM] LOSS: "${position.market}" | ${position.outcome} lost` +
|
|
` | -$${position.totalCost.toFixed(2)} (-100%)`,
|
|
);
|
|
recordSimResult(position, 'LOSS', pnl, returned);
|
|
}
|
|
|
|
removePosition(position.conditionId);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check all open positions for resolved markets and redeem/simulate
|
|
*/
|
|
export async function checkAndRedeemPositions() {
|
|
const positions = getOpenPositions();
|
|
if (positions.length === 0) return;
|
|
|
|
logger.info(`Checking ${positions.length} position(s) for resolution...`);
|
|
|
|
for (const position of positions) {
|
|
try {
|
|
// 1. Quick check via Gamma API (low cost)
|
|
const resolution = await checkMarketResolution(position.conditionId);
|
|
const apiResolved = resolution?.resolved;
|
|
|
|
if (!apiResolved) continue; // Not resolved yet — check again next interval
|
|
|
|
logger.info(`Market resolved via API: ${position.market}`);
|
|
|
|
// 2. ALWAYS verify on-chain payout before calling redeemPositions.
|
|
// Gamma API can report "resolved" before payoutDenominator is written
|
|
// on-chain. Calling redeemPositions with payoutDenominator == 0 causes
|
|
// the contract to revert → gas estimation failure.
|
|
const onChain = await checkOnChainPayout(position.conditionId);
|
|
if (!onChain.resolved) {
|
|
logger.info(`On-chain payout not set yet for ${position.market} — will retry next interval`);
|
|
continue;
|
|
}
|
|
|
|
// 3. Simulate or execute real redeem
|
|
if (config.dryRun) {
|
|
await simulateRedeem(position);
|
|
} else {
|
|
const success = await redeemPosition(position.conditionId);
|
|
if (success) {
|
|
removePosition(position.conditionId);
|
|
logger.money(`Redeemed: ${position.market} → USDC recovered`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.error(`Error checking ${position.market}:`, err.message);
|
|
}
|
|
}
|
|
}
|