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 <noreply@anthropic.com>
This commit is contained in:
+52
-6
@@ -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++;
|
||||
|
||||
+89
-13
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user