fix: serialize concurrent buys per market + always verify on-chain payout before redeem
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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
526fc7a057
commit
365dc4e164
+45
-15
@@ -11,6 +11,11 @@ import logger from '../utils/logger.js';
|
||||
|
||||
const CTF_ABI_BALANCE = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
|
||||
// Per-market buy queue: prevents concurrent buys for the same market.
|
||||
// Each conditionId maps to the Promise tail of its queue so calls are
|
||||
// chained — the next buy only starts after the previous one finishes.
|
||||
const _buyQueue = new Map();
|
||||
|
||||
/**
|
||||
* Fetch the actual on-chain ERC-1155 balance for a conditional token.
|
||||
* Returns shares as a plain float (6-decimal conversion).
|
||||
@@ -81,15 +86,40 @@ async function getMarketOptions(tokenId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a BUY trade (copy trader's buy)
|
||||
* @param {Object} trade - Trade info from watcher
|
||||
* Execute a BUY trade (copy trader's buy).
|
||||
* Calls are serialized per market — concurrent events for the same market
|
||||
* are queued and processed one at a time to prevent duplicate positions.
|
||||
*/
|
||||
export async function executeBuy(trade) {
|
||||
const { tokenId, conditionId, market, price, size } = trade;
|
||||
export function executeBuy(trade) {
|
||||
const { tokenId, conditionId } = trade;
|
||||
|
||||
// Get market options first to resolve conditionId + end time
|
||||
const marketOpts = await getMarketOptions(tokenId);
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
// Resolve conditionId to use as queue key.
|
||||
// getMarketOptions is a read-only fetch — safe to run outside the queue.
|
||||
const queued = getMarketOptions(tokenId).then((marketOpts) => {
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Chain this buy after the previous one for the same market
|
||||
const prev = _buyQueue.get(effectiveConditionId) ?? Promise.resolve();
|
||||
const current = prev
|
||||
.then(() => _doExecuteBuy(trade, marketOpts, effectiveConditionId))
|
||||
.finally(() => {
|
||||
// Remove from map only if we're still the tail (no newer call queued)
|
||||
if (_buyQueue.get(effectiveConditionId) === current) {
|
||||
_buyQueue.delete(effectiveConditionId);
|
||||
}
|
||||
});
|
||||
_buyQueue.set(effectiveConditionId, current);
|
||||
return current;
|
||||
});
|
||||
|
||||
return queued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: the actual buy logic, guaranteed to run serially per market.
|
||||
*/
|
||||
async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
const { tokenId, conditionId, market, price, size } = trade;
|
||||
|
||||
// ── Market expiry guard ────────────────────────────────────────────────────
|
||||
if (!marketOpts.active || !marketOpts.acceptingOrders) {
|
||||
@@ -132,8 +162,11 @@ export async function executeBuy(trade) {
|
||||
tradeSize = Math.min(tradeSize, config.maxPositionSize);
|
||||
}
|
||||
|
||||
if (tradeSize < config.minTradeSize) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
|
||||
// Polymarket enforces a hard $1 minimum per market order.
|
||||
const CLOB_MIN_ORDER_USDC = 1;
|
||||
const effectiveMin = Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC);
|
||||
if (tradeSize < effectiveMin) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below $${effectiveMin} minimum — skipping buy`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,14 +212,11 @@ export async function executeBuy(trade) {
|
||||
let totalSharesFilled = 0;
|
||||
let totalCostFilled = 0;
|
||||
|
||||
// Polymarket enforces a hard $1 minimum per market order.
|
||||
const CLOB_MIN_ORDER_USDC = 1;
|
||||
|
||||
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||
try {
|
||||
const remainingAmount = tradeSize - totalCostFilled;
|
||||
if (remainingAmount < Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC)) {
|
||||
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $1 minimum — stopping`);
|
||||
if (remainingAmount < effectiveMin) {
|
||||
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $${effectiveMin} minimum — stopping`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -216,7 +246,7 @@ export async function executeBuy(trade) {
|
||||
totalCostFilled += costFilled || (sharesFilled * price);
|
||||
filled = true;
|
||||
// If remainder is below $1 minimum, stop; otherwise loop for partial fill
|
||||
if (tradeSize - totalCostFilled < Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC)) break;
|
||||
if (tradeSize - totalCostFilled < effectiveMin) break;
|
||||
} else {
|
||||
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||
}
|
||||
|
||||
@@ -140,17 +140,22 @@ export async function checkAndRedeemPositions() {
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// 1. Check via Gamma API
|
||||
// 1. Quick check via Gamma API (low cost)
|
||||
const resolution = await checkMarketResolution(position.conditionId);
|
||||
const isResolved = resolution?.resolved;
|
||||
const apiResolved = resolution?.resolved;
|
||||
|
||||
if (!isResolved) {
|
||||
// 2. Fallback: on-chain check
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) continue;
|
||||
logger.info(`Market resolved on-chain: ${position.market}`);
|
||||
} else {
|
||||
logger.info(`Market resolved: ${position.market}`);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user