From e82c67824c64fa4392b0eec8c3008b1bf1807b06 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Wed, 25 Feb 2026 03:29:10 +0700 Subject: [PATCH] fix: resolve 'not enough balance or allowance' on sell orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes fixed: 1. Missing ERC-1155 setApprovalForAll for copy-trade flow - executeBuy: call ensureExchangeApproval() after a successful fill so the CTF Exchange is authorised to move tokens before the next sell - executeSell: call ensureExchangeApproval() before every sell attempt; the function is idempotent (checks isApprovedForAll first, only sends a Safe tx if approval is actually missing) 2. Stored shares can exceed real on-chain balance (fee deductions, float drift across partial FAK fills) - executeSell: query ctf.balanceOf(proxyWallet, tokenId) before selling - If on-chain balance is 0 → position is already gone, remove it and skip - If on-chain balance < stored shares → adjust down and log the delta - Round sell amount to 4 decimal places to avoid sub-unit precision errors Also add getOnChainTokenBalance() helper using a minimal CTF ABI slice. Co-Authored-By: Claude Sonnet 4.6 --- src/services/executor.js | 60 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/services/executor.js b/src/services/executor.js index e01fd1e..e176a88 100644 --- a/src/services/executor.js +++ b/src/services/executor.js @@ -1,12 +1,32 @@ import { Side, OrderType } from '@polymarket/clob-client'; +import { ethers } from 'ethers'; import config from '../config/index.js'; -import { getClient, getUsdcBalance } from './client.js'; +import { getClient, getUsdcBalance, getPolygonProvider } from './client.js'; import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js'; import { fetchMarketByTokenId } from './watcher.js'; import { placeAutoSell } from './autoSell.js'; +import { ensureExchangeApproval, CTF_ADDRESS } from './ctf.js'; import { recordSimBuy } from '../utils/simStats.js'; import logger from '../utils/logger.js'; +const CTF_ABI_BALANCE = ['function balanceOf(address account, uint256 id) view returns (uint256)']; + +/** + * Fetch the actual on-chain ERC-1155 balance for a conditional token. + * Returns shares as a plain float (6-decimal conversion). + */ +async function getOnChainTokenBalance(tokenId) { + try { + const provider = await getPolygonProvider(); + const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI_BALANCE, provider); + const raw = await ctf.balanceOf(config.proxyWallet, tokenId); + return parseFloat(ethers.utils.formatUnits(raw, 6)); + } catch (err) { + logger.warn(`Could not fetch on-chain token balance: ${err.message}`); + return null; + } +} + /** * Calculate trade size for our entry — independent of the individual fill event. * @@ -237,6 +257,13 @@ export async function executeBuy(trade) { outcome: trade.outcome, }); + // Ensure the CTF Exchange is approved to move our ERC-1155 tokens (needed for future sells) + try { + await ensureExchangeApproval(marketOpts.negRisk); + } catch (err) { + logger.warn(`Could not verify ERC-1155 approval: ${err.message}`); + } + // Auto-sell only on initial entry, not on accumulation if (config.autoSellEnabled) { await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts); @@ -296,6 +323,31 @@ export async function executeSell(trade) { marketOpts = await getMarketOptions(tokenId); } + // Ensure ERC-1155 approval so the exchange can transfer our tokens + try { + await ensureExchangeApproval(marketOpts.negRisk); + } catch (err) { + logger.warn(`Could not verify ERC-1155 approval: ${err.message}`); + } + + // Reconcile stored shares with actual on-chain balance to prevent "not enough balance" errors. + // The stored amount can be higher than on-chain due to fee deductions or precision drift. + const onChain = await getOnChainTokenBalance(tokenId); + let sharesToSell = position.shares; + if (onChain !== null) { + if (onChain < 0.0001) { + logger.warn(`On-chain balance is 0 for ${position.market} — position already sold or redeemed`); + removePosition(effectiveConditionId); + return; + } + if (onChain < sharesToSell) { + logger.info(`Adjusting sell amount: stored ${sharesToSell.toFixed(6)} → on-chain ${onChain.toFixed(6)} shares`); + sharesToSell = onChain; + } + } + // Round down to 4 decimal places to avoid sub-unit precision errors + sharesToSell = Math.floor(sharesToSell * 10000) / 10000; + const client = getClient(); let filled = false; @@ -303,13 +355,13 @@ export async function executeSell(trade) { try { if (config.sellMode === 'market') { // Market sell (FAK) — takes what's available at 2% slippage - logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`); + logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${sharesToSell}`); const response = await client.createAndPostMarketOrder( { tokenID: tokenId, side: Side.SELL, - amount: position.shares, + amount: sharesToSell, price: Math.max(price * 0.98, 0.01), // 2% slippage, min 0.01 }, { @@ -339,7 +391,7 @@ export async function executeSell(trade) { { tokenID: tokenId, price: price, - size: position.shares, + size: sharesToSell, side: Side.SELL, }, {