From 0da77f78fdf0e272b1155bcfab2d4396b1dae5d6 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 04:18:51 +0700 Subject: [PATCH] fix: improve mm-bot reliability - retry fills, parallel sells, smart small remainder handling - isOrderFilled: retry 2x with logging instead of silent error swallow - Place YES/NO limit sells in parallel via Promise.all - Market sell immediately when remaining shares < CLOB minimum (5 shares) - Re-check on-chain balance before every limit/market sell in adaptive CL - Separate redeemer Safe queue priority from strategy (split/merge) to reduce cross-market delays - Cache USDC and exchange approvals in-memory to skip redundant on-chain reads Co-Authored-By: Claude Opus 4.6 --- src/services/ctf.js | 58 ++++++++++++++++++--- src/services/mmExecutor.js | 102 ++++++++++++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 19 deletions(-) diff --git a/src/services/ctf.js b/src/services/ctf.js index d2b855d..6fc751f 100644 --- a/src/services/ctf.js +++ b/src/services/ctf.js @@ -101,14 +101,44 @@ const RETRY_DELAY = 3000; // ms // tx waits for the previous one to fully confirm before starting. let _txQueue = Promise.resolve(); +// Track whether a strategy (split/merge) tx is in progress so the redeemer can defer +let _strategyTxActive = false; + /** * Execute an arbitrary call through the Gnosis Safe proxy wallet. * Calls are serialized via an internal queue so nonces never collide. * Retries up to MAX_RETRIES times on transient errors. + * + * @param {string} to - Contract address + * @param {string} data - Encoded calldata + * @param {string} description - Human-readable label for logging + * @param {object} [opts] - Options + * @param {boolean} [opts.priority=true] - Priority calls (strategy split/merge) run immediately. + * Non-priority calls (redeemer) wait until no strategy tx is active. */ -export function execSafeCall(to, data, description = '') { +export function execSafeCall(to, data, description = '', opts = {}) { + const { priority = true } = opts; + + const job = async () => { + // Non-priority (redeemer): wait if a strategy tx is active + if (!priority && _strategyTxActive) { + logger.info(`MM: deferring non-priority tx (${description}) — strategy tx in progress`); + // Wait until strategy tx finishes (poll every 1s, max 60s) + for (let i = 0; i < 60 && _strategyTxActive; i++) { + await sleep(1000); + } + } + + if (priority) _strategyTxActive = true; + try { + return await _doExecSafeCall(to, data, description); + } finally { + if (priority) _strategyTxActive = false; + } + }; + // Enqueue: this call will only start after the previous one resolves/rejects - const result = _txQueue.then(() => _doExecSafeCall(to, data, description)); + const result = _txQueue.then(job); // Don't let a failure poison the queue for subsequent calls _txQueue = result.catch(() => { }); return result; @@ -208,18 +238,28 @@ async function _doExecSafeCall(to, data, description = '') { // ── Approval helpers ────────────────────────────────────────────────────────── +// In-memory approval cache — avoids redundant on-chain reads after first approval +let _usdcApproved = false; +const _exchangeApproved = new Set(); // exchange addresses already confirmed + /** * Ensure the CTF contract can spend USDC from the proxy wallet. */ async function ensureUsdcApproval(amountWei) { + if (_usdcApproved) return; + const provider = await getPolygonProvider(); const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider); const allowance = await usdc.allowance(config.proxyWallet, CTF_ADDRESS); - if (allowance.gte(amountWei)) return; + if (allowance.gte(amountWei)) { + _usdcApproved = true; + return; + } const iface = new ethers.utils.Interface(ERC20_ABI); const data = iface.encodeFunctionData('approve', [CTF_ADDRESS, ethers.constants.MaxUint256]); await execSafeCall(USDC_ADDRESS, data, 'approve USDC → CTF'); + _usdcApproved = true; logger.success('MM: USDC approved to CTF contract'); } @@ -229,15 +269,21 @@ async function ensureUsdcApproval(amountWei) { */ export async function ensureExchangeApproval(negRisk = false) { const exchange = negRisk ? NEG_RISK_EXCHANGE : CTF_EXCHANGE; + if (_exchangeApproved.has(exchange)) return; + const provider = await getPolygonProvider(); const ctf = new ethers.Contract(CTF_ADDRESS, ERC1155_ABI, provider); const approved = await ctf.isApprovedForAll(config.proxyWallet, exchange); - if (approved) return; + if (approved) { + _exchangeApproved.add(exchange); + return; + } const iface = new ethers.utils.Interface(ERC1155_ABI); const data = iface.encodeFunctionData('setApprovalForAll', [exchange, true]); await execSafeCall(CTF_ADDRESS, data, 'setApprovalForAll → CTF Exchange'); + _exchangeApproved.add(exchange); logger.success(`MM: CTF exchange approved as ERC1155 operator`); } @@ -523,7 +569,7 @@ export async function redeemMMPositions() { conditionId, [1, 2], ]); - await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`); + await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false }); logger.money(`MM redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC`); redeemed++; } catch (err) { @@ -704,7 +750,7 @@ export async function redeemSniperPositions() { conditionId, [1, 2], ]); - const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`); + const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false }); logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅ | tx: ${receipt.transactionHash}`); redeemed++; diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index bb64557..570f340 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -20,6 +20,9 @@ import logger from '../utils/logger.js'; const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)']; +// Polymarket CLOB minimum order size (shares) +const CLOB_MIN_ORDER_SHARES = 5; + /** * Get actual on-chain ERC1155 token balance for the proxy wallet. * Used before market-sell to avoid 'not enough balance' errors from partial fills. @@ -109,15 +112,37 @@ async function marketSell(tokenId, shares, tickSize, negRisk) { async function isOrderFilled(orderId, shares) { if (!orderId || orderId.startsWith('sim-')) return false; + const MAX_FILL_RETRIES = 2; + for (let attempt = 1; attempt <= MAX_FILL_RETRIES; attempt++) { + try { + const client = getClient(); + const order = await client.getOrder(orderId); + if (!order) return false; + if (order.status === 'MATCHED') return true; + const matched = parseFloat(order.size_matched || '0'); + return matched >= shares * 0.99; + } catch (err) { + logger.warn(`MM: isOrderFilled error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`); + if (attempt < MAX_FILL_RETRIES) await sleep(2000); + } + } + return false; +} + +/** + * Get partial fill amount for an order (how many shares already matched). + * Returns 0 on error. + */ +async function getOrderMatched(orderId) { + if (!orderId || orderId.startsWith('sim-')) return 0; try { const client = getClient(); const order = await client.getOrder(orderId); - if (!order) return false; - if (order.status === 'MATCHED') return true; - const matched = parseFloat(order.size_matched || '0'); - return matched >= shares * 0.99; + if (!order) return 0; + if (order.status === 'MATCHED') return parseFloat(order.original_size || order.size || '0'); + return parseFloat(order.size_matched || '0'); } catch { - return false; + return 0; } } @@ -329,6 +354,19 @@ async function adaptiveLegCL(pos, unfilledKey) { return; } + // If remaining shares below CLOB minimum, market sell immediately instead of trying limit + if (sellShares < CLOB_MIN_ORDER_SHARES) { + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${sellShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling immediately`); + const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk); + s.fillPrice = result.fillPrice; + s.filled = true; + pos.status = 'done'; + const pnl = (s.fillPrice - s.entryPrice) * sellShares; + const combined = filledLegPrice + s.fillPrice; + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${sellShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`); + return; + } + logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`); let activeOrderId = null; @@ -410,9 +448,33 @@ async function adaptiveLegCL(pos, unfilledKey) { // ── Place limit only above the profitable floor ───────────────────── if (!activeOrderId) { + // Re-check actual balance — partial fills may have reduced it + const currentBalance = await getTokenBalance(s.tokenId); + const remainingShares = currentBalance !== null ? currentBalance : sellShares; + + if (remainingShares < 0.001) { + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — fully sold via partial fills`); + s.fillPrice = config.mmSellPrice; + s.filled = true; + pos.status = 'done'; + return; + } + + if (remainingShares < CLOB_MIN_ORDER_SHARES) { + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${remainingShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling`); + const result = await marketSell(s.tokenId, remainingShares, tickSize, negRisk); + s.fillPrice = result.fillPrice; + s.filled = true; + pos.status = 'done'; + const pnl = (s.fillPrice - s.entryPrice) * remainingShares; + const combined = filledLegPrice + s.fillPrice; + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${remainingShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`); + return; + } + if (currentPrice >= minAdaptivePrice) { logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`); - const result = await placeLimitSell(s.tokenId, sellShares, targetPrice, tickSize, negRisk); + const result = await placeLimitSell(s.tokenId, remainingShares, targetPrice, tickSize, negRisk); if (result.success) { activeOrderId = result.orderId; activeLimitPrice = targetPrice; @@ -426,12 +488,24 @@ async function adaptiveLegCL(pos, unfilledKey) { } // ── Fallback: market sell at CL time ─────────────────────────────────────── - logger.warn(`MM adaptive CL: CL time reached — market-selling ${sellShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`); - const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk); + // Re-check actual balance before market sell (partial fills may have occurred) + const finalBalance = await getTokenBalance(s.tokenId); + const finalShares = finalBalance !== null ? finalBalance : sellShares; + + if (finalShares < 0.001) { + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 at CL time — already fully sold`); + s.fillPrice = config.mmSellPrice; + s.filled = true; + pos.status = 'done'; + return; + } + + logger.warn(`MM adaptive CL: CL time reached — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`); + const result = await marketSell(s.tokenId, finalShares, tickSize, negRisk); s.fillPrice = result.fillPrice; - const pnl = (s.fillPrice - s.entryPrice) * sellShares; + const pnl = (s.fillPrice - s.entryPrice) * finalShares; const combined = filledLegPrice + s.fillPrice; - logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`); + logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${finalShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`); s.filled = true; pos.status = 'done'; @@ -626,10 +700,12 @@ export async function executeMMStrategy(market) { const entryPrice = 0.50; logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`); - // ── Place limit sells ─────────────────────────────────────── + // ── Place limit sells (parallel) ──────────────────────────── logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`); - const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk); - const noSell = await placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk); + const [yesSell, noSell] = await Promise.all([ + placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk), + placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk), + ]); if (!yesSell.success || !noSell.success) { logger.error(`MM${tag}: failed to place limit sells — cutting immediately`);