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)'];
|
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.
|
* Fetch the actual on-chain ERC-1155 balance for a conditional token.
|
||||||
* Returns shares as a plain float (6-decimal conversion).
|
* 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)
|
* Execute a BUY trade (copy trader's buy).
|
||||||
* @param {Object} trade - Trade info from watcher
|
* 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) {
|
export function executeBuy(trade) {
|
||||||
const { tokenId, conditionId, market, price, size } = trade;
|
const { tokenId, conditionId } = trade;
|
||||||
|
|
||||||
// Get market options first to resolve conditionId + end time
|
// Resolve conditionId to use as queue key.
|
||||||
const marketOpts = await getMarketOptions(tokenId);
|
// getMarketOptions is a read-only fetch — safe to run outside the queue.
|
||||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
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 ────────────────────────────────────────────────────
|
// ── Market expiry guard ────────────────────────────────────────────────────
|
||||||
if (!marketOpts.active || !marketOpts.acceptingOrders) {
|
if (!marketOpts.active || !marketOpts.acceptingOrders) {
|
||||||
@@ -132,8 +162,11 @@ export async function executeBuy(trade) {
|
|||||||
tradeSize = Math.min(tradeSize, config.maxPositionSize);
|
tradeSize = Math.min(tradeSize, config.maxPositionSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tradeSize < config.minTradeSize) {
|
// Polymarket enforces a hard $1 minimum per market order.
|
||||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,14 +212,11 @@ export async function executeBuy(trade) {
|
|||||||
let totalSharesFilled = 0;
|
let totalSharesFilled = 0;
|
||||||
let totalCostFilled = 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++) {
|
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const remainingAmount = tradeSize - totalCostFilled;
|
const remainingAmount = tradeSize - totalCostFilled;
|
||||||
if (remainingAmount < Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC)) {
|
if (remainingAmount < effectiveMin) {
|
||||||
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $1 minimum — stopping`);
|
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $${effectiveMin} minimum — stopping`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +246,7 @@ export async function executeBuy(trade) {
|
|||||||
totalCostFilled += costFilled || (sharesFilled * price);
|
totalCostFilled += costFilled || (sharesFilled * price);
|
||||||
filled = true;
|
filled = true;
|
||||||
// If remainder is below $1 minimum, stop; otherwise loop for partial fill
|
// 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 {
|
} else {
|
||||||
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,17 +140,22 @@ export async function checkAndRedeemPositions() {
|
|||||||
|
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
try {
|
try {
|
||||||
// 1. Check via Gamma API
|
// 1. Quick check via Gamma API (low cost)
|
||||||
const resolution = await checkMarketResolution(position.conditionId);
|
const resolution = await checkMarketResolution(position.conditionId);
|
||||||
const isResolved = resolution?.resolved;
|
const apiResolved = resolution?.resolved;
|
||||||
|
|
||||||
if (!isResolved) {
|
if (!apiResolved) continue; // Not resolved yet — check again next interval
|
||||||
// 2. Fallback: on-chain check
|
|
||||||
const onChain = await checkOnChainPayout(position.conditionId);
|
logger.info(`Market resolved via API: ${position.market}`);
|
||||||
if (!onChain.resolved) continue;
|
|
||||||
logger.info(`Market resolved on-chain: ${position.market}`);
|
// 2. ALWAYS verify on-chain payout before calling redeemPositions.
|
||||||
} else {
|
// Gamma API can report "resolved" before payoutDenominator is written
|
||||||
logger.info(`Market resolved: ${position.market}`);
|
// 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
|
// 3. Simulate or execute real redeem
|
||||||
|
|||||||
Reference in New Issue
Block a user