diff --git a/.env.example b/.env.example index b17294b..51403cd 100644 --- a/.env.example +++ b/.env.example @@ -147,3 +147,11 @@ SNIPER_PRICE=0.01 # Shares per side — minimum Polymarket order size is 5 shares # At $0.01/share: 5 shares = $0.05 per side, $0.10 per market SNIPER_SHARES=5 + +# ───────────────────────────────────────────── +# PROXY (Polymarket API only, NOT Polygon RPC) +# Supports HTTP/HTTPS/SOCKS5 proxies +# Leave empty to connect directly (no proxy) +# Example: http://user:pass@proxy.example.com:8080 +# ───────────────────────────────────────────── +PROXY_URL= diff --git a/package-lock.json b/package-lock.json index 86c9d18..a94cb80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "polymarket-copy", + "name": "polymarket-terminal", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "polymarket-copy", + "name": "polymarket-terminal", "version": "1.0.0", "license": "ISC", "dependencies": { @@ -13,6 +13,7 @@ "blessed": "^0.1.81", "dotenv": "^16.4.7", "ethers": "^5.8.0", + "https-proxy-agent": "^7.0.6", "ws": "^8.19.0" }, "devDependencies": { @@ -913,6 +914,15 @@ "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1083,7 +1093,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1477,6 +1486,19 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1620,7 +1642,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nodemon": { diff --git a/package.json b/package.json index 29d1a34..aaf7d1e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,10 @@ "mm-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm-bot.js", "sniper": "DRY_RUN=false node src/sniper.js", "sniper-sim": "DRY_RUN=true node src/sniper.js", - "sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js" + "sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js", + "sniper-tui": "DRY_RUN=false node src/sniper-tui.js", + "sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js", + "sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js" }, "keywords": [ "polymarket", @@ -32,9 +35,10 @@ "blessed": "^0.1.81", "dotenv": "^16.4.7", "ethers": "^5.8.0", + "https-proxy-agent": "^7.0.6", "ws": "^8.19.0" }, "devDependencies": { "nodemon": "^3.1.9" } -} \ No newline at end of file +} diff --git a/src/config/index.js b/src/config/index.js index 67b01a3..7ad80ca 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -54,33 +54,37 @@ const config = { gtcFallbackTimeout: parseInt(process.env.GTC_FALLBACK_TIMEOUT || '60', 10), // ── Market Maker ────────────────────────────────────────────── - mmAssets: (process.env.MM_ASSETS || 'btc') - .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), - mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m' - mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side - mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target - mmCutLossTime: parseInt( process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close - mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down', - mmEntryWindow: parseInt( process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open - mmPollInterval: parseInt( process.env.MM_POLL_INTERVAL || '10', 10) * 1000, - mmAdaptiveCL: process.env.MM_ADAPTIVE_CL !== 'false', // true = adaptive, false = legacy immediate market-sell - mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit - mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10), + mmAssets: (process.env.MM_ASSETS || 'btc') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m' + mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side + mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target + mmCutLossTime: parseInt(process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close + mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down', + mmEntryWindow: parseInt(process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open + mmPollInterval: parseInt(process.env.MM_POLL_INTERVAL || '10', 10) * 1000, + mmAdaptiveCL: process.env.MM_ADAPTIVE_CL !== 'false', // true = adaptive, false = legacy immediate market-sell + mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit + mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10), // ── Recovery Buy (after cut-loss) ───────────────────────────── // When enabled: after cutting loss, monitor prices for 10s and // market-buy the dominant side if it's above threshold and rising/stable. - mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true', + mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true', mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify - mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize + mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize // ── Orderbook Sniper ─────────────────────────────────────────── // Places tiny GTC limit BUY orders at a very low price on each side // of ETH/SOL/XRP 5-minute markets — catches panic dumps near $0. sniperAssets: (process.env.SNIPER_ASSETS || 'eth,sol,xrp') - .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), - sniperPrice: parseFloat(process.env.SNIPER_PRICE || '0.01'), // $ per share + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + sniperPrice: parseFloat(process.env.SNIPER_PRICE || '0.01'), // $ per share sniperShares: parseFloat(process.env.SNIPER_SHARES || '5'), // shares per side + + // ── Proxy (Polymarket API only, NOT Polygon RPC) ────────────── + // Supports HTTP/HTTPS/SOCKS5. Example: http://user:pass@host:port + proxyUrl: process.env.PROXY_URL || '', }; // Validation for copy-trade bot diff --git a/src/services/client.js b/src/services/client.js index 7af4041..e5abac8 100644 --- a/src/services/client.js +++ b/src/services/client.js @@ -2,6 +2,7 @@ import { ClobClient } from '@polymarket/clob-client'; import { Wallet } from 'ethers'; import config from '../config/index.js'; import logger from '../utils/logger.js'; +import { setupAxiosProxy, testProxy } from '../utils/proxy.js'; let clobClient = null; let signer = null; @@ -11,6 +12,16 @@ let signer = null; * Auto-derives API credentials if not provided in .env */ export async function initClient() { + // ── Set up proxy (if configured) BEFORE any Polymarket API calls ── + await setupAxiosProxy(); + + // Test proxy connectivity + const proxyOk = await testProxy(); + if (!proxyOk) { + logger.error('Proxy test failed — cannot reach Polymarket. Exiting.'); + process.exit(1); + } + logger.info('Initializing Polymarket CLOB client...'); signer = new Wallet(config.privateKey); diff --git a/src/services/ctf.js b/src/services/ctf.js index 45db1ea..8482752 100644 --- a/src/services/ctf.js +++ b/src/services/ctf.js @@ -12,6 +12,7 @@ import { ethers } from 'ethers'; import config from '../config/index.js'; import { getSigner, getPolygonProvider } from './client.js'; import logger from '../utils/logger.js'; +import { proxyFetch } from '../utils/proxy.js'; // ── Contract addresses (Polygon mainnet) ────────────────────────────────────── @@ -19,6 +20,7 @@ export const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; export const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e export const CTF_EXCHANGE = '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E'; export const NEG_RISK_EXCHANGE = '0xC5d563A36AE78145C45a50134d48A1215220f80a'; +export const MULTISEND_ADDRESS = '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D'; // Gnosis Safe MultiSend (Polygon) // ── ABIs (minimal) ──────────────────────────────────────────────────────────── @@ -59,8 +61,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); * Strips the lengthy internal stack info that ethers appends. */ function parseOnchainError(err) { - const msg = err?.message || String(err); - const reason = err?.reason || err?.error?.reason || ''; + const msg = err?.message || String(err); + const reason = err?.reason || err?.error?.reason || ''; if (msg.includes('insufficient funds') || msg.includes('insufficient balance')) return 'Insufficient MATIC balance for gas fees'; @@ -105,23 +107,23 @@ let _txQueue = Promise.resolve(); * Calls are serialized via an internal queue so nonces never collide. * Retries up to MAX_RETRIES times on transient errors. */ -export function execSafeCall(to, data, description = '') { +export function execSafeCall(to, data, description = '', operation = 0) { // Enqueue: this call will only start after the previous one resolves/rejects - const result = _txQueue.then(() => _doExecSafeCall(to, data, description)); + const result = _txQueue.then(() => _doExecSafeCall(to, data, description, operation)); // Don't let a failure poison the queue for subsequent calls - _txQueue = result.catch(() => {}); + _txQueue = result.catch(() => { }); return result; } -async function _doExecSafeCall(to, data, description = '') { +async function _doExecSafeCall(to, data, description = '', operation = 0) { if (description) logger.info(`MM: exec safe tx — ${description}`); let lastErr; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const provider = await getPolygonProvider(); - const wallet = getSigner().connect(provider); - const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet); + const wallet = getSigner().connect(provider); + const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet); const nonce = await safe.nonce(); @@ -142,22 +144,47 @@ async function _doExecSafeCall(to, data, description = '') { // Sign the raw hash with the EOA signing key (no EIP-191 prefix) // Gnosis Safe v1.3.0 treats plain ECDSA signatures (v=27/28) on the tx hash directly const signingKey = new ethers.utils.SigningKey(config.privateKey); - const rawSig = signingKey.signDigest(txHash); - const signature = ethers.utils.joinSignature(rawSig); + const rawSig = signingKey.signDigest(txHash); + const signature = ethers.utils.joinSignature(rawSig); // Polygon requires maxPriorityFeePerGas ≥ 25 Gwei. // Some RPC nodes (e.g. lava.build) return a stale low estimate, so we enforce a floor. - const feeData = await provider.getFeeData(); - const MIN_TIP = ethers.utils.parseUnits('30', 'gwei'); - const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP; + const feeData = await provider.getFeeData(); + const MIN_TIP = ethers.utils.parseUnits('30', 'gwei'); + const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP; const gasFeeCap = feeData.maxFeePerGas ?? ethers.utils.parseUnits('500', 'gwei'); - const tx = await safe.execTransaction( - to, 0, data, 0, 0, 0, 0, + // Estimate gas with a timeout — serves as validation that the tx will succeed. + // If estimation reverts → tx would fail anyway, so we throw immediately. + // If estimation hangs (RPC timeout) → fallback to a safe limit. + const txArgs = [ + to, 0, data, operation ?? 0, 0, 0, 0, ethers.constants.AddressZero, ethers.constants.AddressZero, signature, - { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap }, + ]; + + let gasLimit; + try { + const estimatePromise = safe.estimateGas.execTransaction(...txArgs); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('gas estimation timeout')), 10_000) + ); + const estimated = await Promise.race([estimatePromise, timeoutPromise]); + gasLimit = estimated.mul(120).div(100); // +20% buffer + } catch (estErr) { + if (estErr.message === 'gas estimation timeout') { + logger.warn(`Gas estimation timed out — using fallback 500k gasLimit`); + gasLimit = 500_000; + } else { + // Gas estimation reverted → tx will fail, don't waste gas + throw estErr; + } + } + + const tx = await safe.execTransaction( + ...txArgs, + { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap, gasLimit }, ); const receipt = await tx.wait(); @@ -332,7 +359,7 @@ export async function cleanupOpenPositions(clobClient) { let dataPositions = []; try { const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`; - const resp = await fetch(url); + const resp = await proxyFetch(url); if (resp.ok) dataPositions = await resp.json(); if (!Array.isArray(dataPositions)) dataPositions = []; } catch (err) { @@ -424,7 +451,7 @@ export async function redeemMMPositions() { // 1. Query Data API for all positions held by the proxy wallet let dataPositions = []; try { - const resp = await fetch(`${config.dataHost}/positions?user=${config.proxyWallet}`); + const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`); if (resp.ok) dataPositions = await resp.json(); if (!Array.isArray(dataPositions)) dataPositions = []; } catch { @@ -441,12 +468,12 @@ export async function redeemMMPositions() { const byCondition = new Map(); for (const pos of dataPositions) { const cid = pos.conditionId || pos.condition_id; - const tid = pos.asset || pos.tokenId || pos.token_id; + const tid = pos.asset || pos.tokenId || pos.token_id; if (!cid || !tid) continue; if (!byCondition.has(cid)) byCondition.set(cid, []); byCondition.get(cid).push({ tokenId: String(tid), - size: parseFloat(pos.size || pos.currentValue || '0'), + size: parseFloat(pos.size || pos.currentValue || '0'), }); } @@ -507,3 +534,163 @@ export async function redeemMMPositions() { logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`); } } + +// ── Sniper-specific redeemer ────────────────────────────────────────────────── + +/** + * Encode a single call for Gnosis Safe MultiSend. + * Format: [operation:uint8][to:address][value:uint256][dataLength:uint256][data:bytes] + */ +function encodeMultiSendCall(to, data) { + const operation = 0; // CALL + return ethers.utils.solidityPack( + ['uint8', 'address', 'uint256', 'uint256', 'bytes'], + [operation, to, 0, ethers.utils.hexDataLength(data), data], + ); +} + +/** + * Redeem ONLY winning sniper positions via Gnosis Safe. + * - Skips positions with $0 payout (losers) + * - Batches multiple redemptions into a single MultiSend transaction + * - Uses explicit gasLimit to prevent gas estimation hangs + */ +export async function redeemSniperPositions() { + // 1. Query Data API for all positions held by the proxy wallet + let dataPositions = []; + try { + const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`); + if (resp.ok) dataPositions = await resp.json(); + if (!Array.isArray(dataPositions)) dataPositions = []; + } catch { + return; // silent — will retry next interval + } + + if (dataPositions.length === 0) return; + + const provider = await getPolygonProvider(); + const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider); + const ctfIface = new ethers.utils.Interface(CTF_ABI); + + // Group tokens by conditionId + const byCondition = new Map(); + for (const pos of dataPositions) { + const cid = pos.conditionId || pos.condition_id; + const tid = pos.asset || pos.tokenId || pos.token_id; + if (!cid || !tid) continue; + if (!byCondition.has(cid)) byCondition.set(cid, []); + byCondition.get(cid).push({ + tokenId: String(tid), + size: parseFloat(pos.size || pos.currentValue || '0'), + }); + } + + // 2. Filter to only winning positions and collect redeemable conditionIds + const redeemBatch = []; // { conditionId, expectedUsdc, totalShares } + + for (const [conditionId, tokens] of byCondition) { + try { + // Skip unresolved markets + const denominator = await ctf.payoutDenominator(conditionId); + if (denominator.isZero()) continue; + + // Check actual on-chain token balances + const balances = await Promise.all( + tokens.map(({ tokenId }) => + ctf.balanceOf(config.proxyWallet, tokenId) + .then((b) => parseFloat(ethers.utils.formatUnits(b, 6))) + ) + ); + const totalShares = balances.reduce((a, b) => a + b, 0); + if (totalShares < 0.001) continue; + + // Calculate expected payout + const payoutFractions = await Promise.all( + [0, 1].map((i) => + ctf.payoutNumerators(conditionId, i) + .then((n) => n.toNumber() / denominator.toNumber()) + ) + ); + const expectedUsdc = balances.reduce( + (sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0 + ); + + // ── WIN-ONLY: skip if payout is ~$0 (loser) ── + if (expectedUsdc < 0.01) { + logger.info(`SNIPER redeemer: skip ${conditionId.slice(0, 12)}... — $0 payout (loss)`); + continue; + } + + redeemBatch.push({ conditionId, expectedUsdc, totalShares }); + } catch (err) { + logger.error(`SNIPER redeemer: error checking ${conditionId.slice(0, 12)}... — ${parseOnchainError(err)}`); + } + } + + if (redeemBatch.length === 0) return; + + // 3. Dry-run logging + if (config.dryRun) { + for (const { conditionId, expectedUsdc, totalShares } of redeemBatch) { + logger.money(`SNIPER[SIM] redeem: ${conditionId.slice(0, 12)}... — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`); + } + return; + } + + // 4. Build batch: if >1 position, use MultiSend; otherwise single call + if (redeemBatch.length === 1) { + // Single redeem — direct call + const { conditionId, expectedUsdc } = redeemBatch[0]; + const label = conditionId.slice(0, 12) + '...'; + const data = ctfIface.encodeFunctionData('redeemPositions', [ + USDC_ADDRESS, ethers.constants.HashZero, conditionId, [1, 2], + ]); + try { + await execSafeCall(CTF_ADDRESS, data, `SNIPER redeemPositions ${label}`); + logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅`); + } catch (err) { + logger.error(`SNIPER redeemer: failed ${label} — ${parseOnchainError(err)}`); + } + } else { + // Bulk redeem via Gnosis Safe MultiSend + logger.info(`SNIPER redeemer: batching ${redeemBatch.length} winning redemptions into MultiSend...`); + + const encodedCalls = redeemBatch.map(({ conditionId }) => { + const callData = ctfIface.encodeFunctionData('redeemPositions', [ + USDC_ADDRESS, ethers.constants.HashZero, conditionId, [1, 2], + ]); + return encodeMultiSendCall(CTF_ADDRESS, callData); + }); + + // MultiSend ABI: multiSend(bytes transactions) + const multiSendIface = new ethers.utils.Interface([ + 'function multiSend(bytes transactions)', + ]); + const packedCalls = ethers.utils.hexlify(ethers.utils.concat(encodedCalls)); + const multiSendData = multiSendIface.encodeFunctionData('multiSend', [packedCalls]); + + const totalExpected = redeemBatch.reduce((s, r) => s + r.expectedUsdc, 0); + + try { + // operation = 1 (DELEGATECALL) for MultiSend + await execSafeCall(MULTISEND_ADDRESS, multiSendData, `SNIPER bulk redeem (${redeemBatch.length} positions)`, 1); + logger.money(`SNIPER redeemer: bulk redeemed ${redeemBatch.length} positions → ~$${totalExpected.toFixed(2)} USDC total ✅`); + } catch (err) { + logger.error(`SNIPER redeemer: bulk redeem failed — ${parseOnchainError(err)}`); + // Fallback: try one by one + logger.warn('SNIPER redeemer: falling back to individual redemptions...'); + for (const { conditionId, expectedUsdc } of redeemBatch) { + const label = conditionId.slice(0, 12) + '...'; + const data = ctfIface.encodeFunctionData('redeemPositions', [ + USDC_ADDRESS, ethers.constants.HashZero, conditionId, [1, 2], + ]); + try { + await execSafeCall(CTF_ADDRESS, data, `SNIPER redeemPositions ${label}`); + logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅`); + } catch (err2) { + logger.error(`SNIPER redeemer: failed ${label} — ${parseOnchainError(err2)}`); + } + } + } + } +} diff --git a/src/services/mmDetector.js b/src/services/mmDetector.js index 9bc3cd9..3757b75 100644 --- a/src/services/mmDetector.js +++ b/src/services/mmDetector.js @@ -12,13 +12,14 @@ import config from '../config/index.js'; import logger from '../utils/logger.js'; +import { proxyFetch } from '../utils/proxy.js'; // Slot size in seconds (300 for 5m, 900 for 15m) const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300; -let pollTimer = null; -let onMarketCb = null; -const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled +let pollTimer = null; +let onMarketCb = null; +const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled // ── Slot helpers ────────────────────────────────────────────────────────────── @@ -35,7 +36,7 @@ function nextSlot() { async function fetchBySlug(asset, slotTimestamp) { const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`; try { - const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`); + const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`); if (!resp.ok) return null; const data = await resp.json(); return data?.conditionId ? data : null; @@ -61,7 +62,7 @@ function extractMarketData(market, asset) { [yesTokenId, noTokenId] = tokenIds; } else if (Array.isArray(market.tokens) && market.tokens.length >= 2) { yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId; - noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId; + noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId; } if (!yesTokenId || !noTokenId) return null; @@ -69,13 +70,13 @@ function extractMarketData(market, asset) { return { asset, conditionId, - question: market.question || market.title || '', - endTime: market.endDate || market.end_date_iso || market.endDateIso, + question: market.question || market.title || '', + endTime: market.endDate || market.end_date_iso || market.endDateIso, eventStartTime: market.eventStartTime || market.event_start_time, - yesTokenId: String(yesTokenId), - noTokenId: String(noTokenId), - negRisk: market.negRisk ?? market.neg_risk ?? false, - tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'), + yesTokenId: String(yesTokenId), + noTokenId: String(noTokenId), + negRisk: market.negRisk ?? market.neg_risk ?? false, + tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'), }; } @@ -98,7 +99,7 @@ async function scheduleAsset(asset, slotTimestamp) { seenKeys.add(key); // Refuse to enter a market already well into its window (e.g., bot restart mid-slot) - const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000; + const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000; const elapsedSec = Math.round((Date.now() - openAt) / 1000); if (elapsedSec > 15) { logger.info(`MM: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping, will catch next`); diff --git a/src/services/redeemer.js b/src/services/redeemer.js index 698bcc9..a896939 100644 --- a/src/services/redeemer.js +++ b/src/services/redeemer.js @@ -5,6 +5,7 @@ import { execSafeCall, CTF_ADDRESS, USDC_ADDRESS } from './ctf.js'; import { getOpenPositions, removePosition } from './position.js'; import { recordSimResult } from '../utils/simStats.js'; import logger from '../utils/logger.js'; +import { proxyFetch } from '../utils/proxy.js'; // CTF ABI (minimal — read-only calls only; writes go through execSafeCall) const CTF_ABI = [ @@ -20,7 +21,7 @@ const CTF_ABI = [ async function checkMarketResolution(conditionId) { try { const url = `${config.gammaHost}/markets?condition_id=${conditionId}`; - const response = await fetch(url); + const response = await proxyFetch(url); if (!response.ok) return null; const markets = await response.json(); diff --git a/src/services/schedule.js b/src/services/schedule.js new file mode 100644 index 0000000..1549285 --- /dev/null +++ b/src/services/schedule.js @@ -0,0 +1,136 @@ +/** + * schedule.js + * Hardcoded trading session schedule for the sniper bot. + * + * All times are defined in UTC+8 and converted to UTC internally. + * Assets outside their session window are skipped by the sniper detector. + * + * Schedule (UTC+8): + * BTC: 19:40–22:40, 03:40–06:10 + * ETH: 11:40–15:40, 16:40–19:40 + * SOL: 09:40–12:40, 21:40–23:40 + * XRP: 18:40–20:40, 08:40–09:50 + */ + +import logger from '../utils/logger.js'; + +// UTC+8 offset in hours +const UTC8_OFFSET = 8; + +/** + * Schedule definition in UTC+8 times. + * Each asset has an array of sessions with { startUtc8, endUtc8 } strings (HH:MM). + * Internally we store startMinUTC and endMinUTC for fast comparison. + */ +const SCHEDULE_UTC8 = { + btc: [ + { startUtc8: '19:40', endUtc8: '22:40' }, + { startUtc8: '03:40', endUtc8: '06:10' }, + ], + eth: [ + { startUtc8: '11:40', endUtc8: '15:40' }, + { startUtc8: '16:40', endUtc8: '19:40' }, + ], + sol: [ + { startUtc8: '09:40', endUtc8: '12:40' }, + { startUtc8: '21:40', endUtc8: '23:40' }, + ], + xrp: [ + { startUtc8: '18:40', endUtc8: '20:40' }, + { startUtc8: '08:40', endUtc8: '09:50' }, + ], +}; + +/** + * Convert HH:MM in UTC+8 to minutes-since-midnight in UTC. + * Result is always in [0, 1440). + */ +function utc8ToUtcMinutes(hhmm) { + const [h, m] = hhmm.split(':').map(Number); + let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60); + if (totalMin < 0) totalMin += 1440; + if (totalMin >= 1440) totalMin -= 1440; + return totalMin; +} + +// Pre-compute UTC ranges for fast lookup +const SCHEDULE_UTC = {}; +for (const [asset, sessions] of Object.entries(SCHEDULE_UTC8)) { + SCHEDULE_UTC[asset] = sessions.map((s) => ({ + startUtc8: s.startUtc8, + endUtc8: s.endUtc8, + startMin: utc8ToUtcMinutes(s.startUtc8), + endMin: utc8ToUtcMinutes(s.endUtc8), + })); +} + +/** + * Get current time as minutes since midnight UTC. + */ +function nowMinutesUTC() { + const d = new Date(); + return d.getUTCHours() * 60 + d.getUTCMinutes(); +} + +/** + * Check if `nowMin` falls within range [start, end). + * Handles overnight wrap (e.g. 22:00–02:00). + */ +function inRange(nowMin, startMin, endMin) { + if (startMin <= endMin) { + // Normal range (e.g. 11:40 – 14:40) + return nowMin >= startMin && nowMin < endMin; + } else { + // Overnight wrap (e.g. 19:40 – 22:10 where UTC wraps past midnight) + return nowMin >= startMin || nowMin < endMin; + } +} + +/** + * Check if an asset is currently within its trading session. + * Returns true if asset has no schedule (always active). + */ +export function isAssetInSession(asset) { + const sessions = SCHEDULE_UTC[asset.toLowerCase()]; + if (!sessions) return true; // no schedule = always active + + const now = nowMinutesUTC(); + return sessions.some((s) => inRange(now, s.startMin, s.endMin)); +} + +/** + * Get human-readable time until the next session opens. + * Returns string like "2h 15m" or null if currently in session. + */ +export function getNextSessionInfo(asset) { + const sessions = SCHEDULE_UTC[asset.toLowerCase()]; + if (!sessions) return null; + + const now = nowMinutesUTC(); + + // If currently in session, return null + if (sessions.some((s) => inRange(now, s.startMin, s.endMin))) return null; + + // Find the nearest upcoming session start + let minWait = Infinity; + for (const s of sessions) { + let wait = s.startMin - now; + if (wait <= 0) wait += 1440; // wrap to next day + if (wait < minWait) minWait = wait; + } + + if (minWait === Infinity) return null; + + const hours = Math.floor(minWait / 60); + const mins = minWait % 60; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +/** + * Get the full schedule definition (for display in TUI / console). + * Returns the SCHEDULE_UTC8 object with startUtc8 and endUtc8 strings. + */ +export function getSchedule() { + return SCHEDULE_UTC8; +} diff --git a/src/services/sniperDetector.js b/src/services/sniperDetector.js index 61a53b8..2592ecb 100644 --- a/src/services/sniperDetector.js +++ b/src/services/sniperDetector.js @@ -14,10 +14,12 @@ import config from '../config/index.js'; import logger from '../utils/logger.js'; +import { isAssetInSession, getNextSessionInfo } from './schedule.js'; +import { proxyFetch } from '../utils/proxy.js'; const SLOT_SEC = 5 * 60; // 300 seconds -let pollTimer = null; +let pollTimer = null; let onMarketCb = null; const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled @@ -36,7 +38,7 @@ function nextSlot() { async function fetchBySlug(asset, slotTimestamp) { const slug = `${asset}-updown-5m-${slotTimestamp}`; try { - const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`); + const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`); if (!resp.ok) return null; const data = await resp.json(); return data?.conditionId ? data : null; @@ -61,7 +63,7 @@ function extractMarketData(market, asset) { [yesTokenId, noTokenId] = tokenIds; } else if (Array.isArray(market.tokens) && market.tokens.length >= 2) { yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId; - noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId; + noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId; } if (!yesTokenId || !noTokenId) return null; @@ -69,13 +71,13 @@ function extractMarketData(market, asset) { return { asset, conditionId, - question: market.question || market.title || '', - endTime: market.endDate || market.end_date_iso || market.endDateIso, + question: market.question || market.title || '', + endTime: market.endDate || market.end_date_iso || market.endDateIso, eventStartTime: market.eventStartTime || market.event_start_time, - yesTokenId: String(yesTokenId), - noTokenId: String(noTokenId), - negRisk: market.negRisk ?? market.neg_risk ?? false, - tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'), + yesTokenId: String(yesTokenId), + noTokenId: String(noTokenId), + negRisk: market.negRisk ?? market.neg_risk ?? false, + tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'), }; } @@ -99,8 +101,8 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) { if (isCurrent) { // Current slot: only place orders if there's at least 30 seconds of market left - const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000; - const secsLeft = Math.round((endAt - Date.now()) / 1000); + const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000; + const secsLeft = Math.round((endAt - Date.now()) / 1000); if (secsLeft < 30) { logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`); return; @@ -108,7 +110,7 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) { logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`); } else { // Next slot: market hasn't opened yet - const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000; + const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000; const secsUntilOpen = Math.round((openAt - Date.now()) / 1000); logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? ` — ${secsUntilOpen}s before open` : ''}`); } @@ -122,8 +124,25 @@ async function poll() { try { const curr = currentSlot(); const next = nextSlot(); + + // Filter assets by trading session schedule + const activeAssets = config.sniperAssets.filter((asset) => { + if (!isAssetInSession(asset)) { + const nextInfo = getNextSessionInfo(asset); + const key = `skip-${asset}-${Math.floor(Date.now() / 60000)}`; // log once per minute + if (!seenKeys.has(key)) { + seenKeys.add(key); + logger.info(`SNIPER: ${asset.toUpperCase()} outside session window${nextInfo ? ` — next in ${nextInfo}` : ''}`); + } + return false; + } + return true; + }); + + if (activeAssets.length === 0) return; + // Check current active market AND the upcoming next one, in parallel for each asset - await Promise.all(config.sniperAssets.flatMap((asset) => [ + await Promise.all(activeAssets.flatMap((asset) => [ scheduleAsset(asset, curr, true), // current market (if still has time left) scheduleAsset(asset, next, false), // next upcoming market ])); diff --git a/src/services/watcher.js b/src/services/watcher.js index 368039d..772bf78 100644 --- a/src/services/watcher.js +++ b/src/services/watcher.js @@ -1,6 +1,7 @@ import config from '../config/index.js'; import logger from '../utils/logger.js'; import { readState, writeState } from '../utils/state.js'; +import { proxyFetch } from '../utils/proxy.js'; const PROCESSED_FILE = 'processed_trades.json'; @@ -11,7 +12,7 @@ const PROCESSED_FILE = 'processed_trades.json'; async function fetchTraderActivity() { const url = `${config.dataHost}/activity?user=${config.traderAddress}`; try { - const response = await fetch(url); + const response = await proxyFetch(url); if (!response.ok) { throw new Error(`Data API returned ${response.status}`); } @@ -116,7 +117,7 @@ export { markTradeProcessed }; export async function fetchMarketInfo(conditionId) { try { const url = `${config.gammaHost}/markets?condition_id=${conditionId}`; - const response = await fetch(url); + const response = await proxyFetch(url); if (!response.ok) return null; const markets = await response.json(); return markets && markets.length > 0 ? markets[0] : null; @@ -132,7 +133,7 @@ export async function fetchMarketInfo(conditionId) { export async function fetchMarketByTokenId(tokenId) { try { const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`; - const response = await fetch(url); + const response = await proxyFetch(url); if (!response.ok) return null; const markets = await response.json(); return markets && markets.length > 0 ? markets[0] : null; diff --git a/src/sniper-tui.js b/src/sniper-tui.js new file mode 100644 index 0000000..8387cfd --- /dev/null +++ b/src/sniper-tui.js @@ -0,0 +1,165 @@ +/** + * sniper-tui.js + * TUI version of the Orderbook Sniper bot (blessed dashboard). + * Places tiny GTC BUY orders at a low price on both sides of 5-min markets. + * + * Run with: npm run sniper-tui (live) + * npm run sniper-tui-sim (simulation) + */ + +import { validateMMConfig } from './config/index.js'; +import config from './config/index.js'; +import logger from './utils/logger.js'; +import { initClient } from './services/client.js'; +import { getUsdcBalance } from './services/client.js'; +import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js'; +import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js'; +import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js'; +import { redeemSniperPositions } from './services/ctf.js'; +import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js'; + +// ── Validate config ──────────────────────────────────────────────────────────── + +try { + validateMMConfig(); +} catch (err) { + console.error(`Config error: ${err.message}`); + process.exit(1); +} + +if (config.sniperAssets.length === 0) { + console.error('SNIPER_ASSETS is empty. Set e.g. SNIPER_ASSETS=eth,sol,xrp in .env'); + process.exit(1); +} + +// ── Init TUI ────────────────────────────────────────────────────────────────── + +initDashboard(); +logger.setOutput(appendLog); + +// ── Init CLOB client ────────────────────────────────────────────────────────── + +try { + await initClient(); +} catch (err) { + logger.error(`Client init error: ${err.message}`); + process.exit(1); +} + +// ── Status panel ────────────────────────────────────────────────────────────── + +async function buildStatusContent() { + const lines = []; + + // Balance + let balance = '?'; + if (!config.dryRun) { + try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ } + } else { + balance = '{yellow-fg}SIM{/yellow-fg}'; + } + lines.push('{bold}BALANCE{/bold}'); + lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`); + lines.push(''); + + lines.push('{bold}MODE{/bold}'); + lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`); + lines.push(''); + + lines.push('{bold}SNIPER CONFIG{/bold}'); + lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`); + lines.push(` Price : $${config.sniperPrice} per share`); + lines.push(` Shares : ${config.sniperShares} per side`); + lines.push(` Cost : $${(config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3)} per slot`); + lines.push(''); + + // Session schedule + lines.push('{bold}SESSION SCHEDULE (UTC+8){/bold}'); + const schedule = getSchedule(); + for (const asset of config.sniperAssets) { + const sessions = schedule[asset]; + const active = isAssetInSession(asset); + const statusTag = active + ? '{green-fg}● ACTIVE{/green-fg}' + : '{red-fg}○ IDLE{/red-fg}'; + if (sessions) { + const sessionStr = sessions.map(s => `${s.startUtc8}–${s.endUtc8}`).join(', '); + lines.push(` ${asset.toUpperCase()} ${statusTag} ${sessionStr}`); + if (!active) { + const next = getNextSessionInfo(asset); + if (next) lines.push(` {gray-fg}Next in ${next}{/gray-fg}`); + } + } else { + lines.push(` ${asset.toUpperCase()} {yellow-fg}NO SCHEDULE{/yellow-fg} (always active)`); + } + } + lines.push(''); + + // Recent snipe orders + const snipes = getActiveSnipes(); + lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`); + + if (snipes.length === 0) { + lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}'); + } else { + // Show last 10 orders (most recent first) + const recent = snipes.slice(-10).reverse(); + for (const s of recent) { + const payout = s.potentialPayout.toFixed(2); + lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`); + } + } + + return '\n' + lines.join('\n'); +} + +let refreshTimer = null; +let redeemTimer = null; + +function startRefresh() { + refreshTimer = setInterval(async () => { + if (!isDashboardActive()) return; + updateStatus(await buildStatusContent()); + }, 3000); + buildStatusContent().then(updateStatus); +} + +function startRedeemer() { + redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)); + redeemTimer = setInterval( + () => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)), + config.redeemInterval, + ); + logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`); +} + +// ── Market handler ──────────────────────────────────────────────────────────── + +async function handleNewMarket(market) { + executeSnipe(market).catch((err) => + logger.error(`SNIPER execute error (${market.asset}): ${err.message}`) + ); +} + +// ── Graceful shutdown ───────────────────────────────────────────────────────── + +function shutdown() { + logger.warn('SNIPER: shutting down...'); + stopSniperDetector(); + if (refreshTimer) clearInterval(refreshTimer); + if (redeemTimer) clearInterval(redeemTimer); + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// ── Start ───────────────────────────────────────────────────────────────────── + +const costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3); +logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`); +logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`); + +startRefresh(); +startRedeemer(); +startSniperDetector(handleNewMarket); diff --git a/src/sniper.js b/src/sniper.js index d8b7cc8..ab2d83d 100644 --- a/src/sniper.js +++ b/src/sniper.js @@ -1,21 +1,22 @@ /** * sniper.js - * Entry point for the Orderbook Sniper bot. - * Places tiny GTC BUY orders at $0.01 on both sides of ETH/SOL/XRP 5-min markets. + * Console-only entry point for the Orderbook Sniper bot. + * Places tiny GTC BUY orders at a low price on both sides of 5-min markets. * - * Run with: npm run sniper (live) - * npm run sniper-sim (simulation) + * Run with: npm run sniper (live, console) + * npm run sniper-sim (simulation, console) + * + * For the TUI dashboard version, use: npm run sniper-tui */ import { validateMMConfig } from './config/index.js'; import config from './config/index.js'; import logger from './utils/logger.js'; import { initClient } from './services/client.js'; -import { getUsdcBalance } from './services/client.js'; -import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js'; import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js'; -import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js'; -import { redeemMMPositions } from './services/ctf.js'; +import { executeSnipe } from './services/sniperExecutor.js'; +import { redeemSniperPositions } from './services/ctf.js'; +import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js'; // ── Validate config ──────────────────────────────────────────────────────────── @@ -31,11 +32,6 @@ if (config.sniperAssets.length === 0) { process.exit(1); } -// ── Init TUI ────────────────────────────────────────────────────────────────── - -initDashboard(); -logger.setOutput(appendLog); - // ── Init CLOB client ────────────────────────────────────────────────────────── try { @@ -45,66 +41,37 @@ try { process.exit(1); } -// ── Status panel ────────────────────────────────────────────────────────────── +// ── Log session schedule ────────────────────────────────────────────────────── -async function buildStatusContent() { - const lines = []; - - // Balance - let balance = '?'; - if (!config.dryRun) { - try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ } - } else { - balance = '{yellow-fg}SIM{/yellow-fg}'; - } - lines.push('{bold}BALANCE{/bold}'); - lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`); - lines.push(''); - - lines.push('{bold}MODE{/bold}'); - lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`); - lines.push(''); - - lines.push('{bold}SNIPER CONFIG{/bold}'); - lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`); - lines.push(` Price : $${config.sniperPrice} per share`); - lines.push(` Shares : ${config.sniperShares} per side`); - lines.push(` Cost : $${(config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3)} per slot`); - lines.push(''); - - // Recent snipe orders - const snipes = getActiveSnipes(); - lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`); - - if (snipes.length === 0) { - lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}'); - } else { - // Show last 10 orders (most recent first) - const recent = snipes.slice(-10).reverse(); - for (const s of recent) { - const payout = s.potentialPayout.toFixed(2); - lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`); +function logSchedule() { + const schedule = getSchedule(); + logger.info('─── Session Schedule (UTC+8) ───'); + for (const asset of config.sniperAssets) { + const sessions = schedule[asset]; + const active = isAssetInSession(asset); + const status = active ? '● ACTIVE' : '○ IDLE'; + if (sessions) { + const sessionStr = sessions.map(s => `${s.startUtc8}–${s.endUtc8}`).join(', '); + logger.info(` ${asset.toUpperCase()} [${status}] ${sessionStr}`); + if (!active) { + const next = getNextSessionInfo(asset); + if (next) logger.info(` → Next in ${next}`); + } + } else { + logger.info(` ${asset.toUpperCase()} [NO SCHEDULE] (always active)`); } } - - return '\n' + lines.join('\n'); + logger.info('────────────────────────────────'); } -let refreshTimer = null; -let redeemTimer = null; +// ── Redeemer ────────────────────────────────────────────────────────────────── -function startRefresh() { - refreshTimer = setInterval(async () => { - if (!isDashboardActive()) return; - updateStatus(await buildStatusContent()); - }, 3000); - buildStatusContent().then(updateStatus); -} +let redeemTimer = null; function startRedeemer() { - redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)); + redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)); redeemTimer = setInterval( - () => redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)), + () => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)), config.redeemInterval, ); logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`); @@ -123,12 +90,11 @@ async function handleNewMarket(market) { function shutdown() { logger.warn('SNIPER: shutting down...'); stopSniperDetector(); - if (refreshTimer) clearInterval(refreshTimer); - if (redeemTimer) clearInterval(redeemTimer); + if (redeemTimer) clearInterval(redeemTimer); process.exit(0); } -process.on('SIGINT', shutdown); +process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); // ── Start ───────────────────────────────────────────────────────────────────── @@ -137,6 +103,6 @@ const costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.snipe logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`); logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`); -startRefresh(); +logSchedule(); startRedeemer(); startSniperDetector(handleNewMarket); diff --git a/src/utils/proxy.js b/src/utils/proxy.js new file mode 100644 index 0000000..bc1fdb5 --- /dev/null +++ b/src/utils/proxy.js @@ -0,0 +1,140 @@ +/** + * proxy.js + * Proxy support for Polymarket API calls only. + * + * - CLOB API: uses axios internally (via @polymarket/clob-client) → + * we set axios.defaults to use the proxy agent globally. + * - Gamma / Data API: uses native fetch → we provide proxyFetch() wrapper. + * - Polygon RPC: NOT proxied (separate ethers.js provider). + * + * Set PROXY_URL in .env to enable. Supports HTTP/HTTPS/SOCKS5 proxies. + * Example: PROXY_URL=http://user:pass@proxy.example.com:8080 + */ + +import config from '../config/index.js'; +import logger from './logger.js'; + +let proxyAgent = null; + +/** + * Initialize the proxy agent from config.proxyUrl. + * Returns the agent or null if no proxy is configured. + */ +async function createAgent() { + if (!config.proxyUrl) return null; + + try { + const { HttpsProxyAgent } = await import('https-proxy-agent'); + return new HttpsProxyAgent(config.proxyUrl); + } catch (err) { + logger.error(`Failed to create proxy agent: ${err.message}`); + logger.error('Make sure https-proxy-agent is installed: npm i https-proxy-agent'); + return null; + } +} + +/** + * Set up axios defaults so that the @polymarket/clob-client's + * internal axios calls go through the proxy. + * Call this BEFORE creating ClobClient. + */ +export async function setupAxiosProxy() { + if (!config.proxyUrl) { + logger.info('No PROXY_URL set — Polymarket API calls will be direct'); + return; + } + + proxyAgent = await createAgent(); + if (!proxyAgent) return; + + try { + const axiosModule = await import('axios'); + const axios = axiosModule.default || axiosModule; + + // Disable axios built-in proxy (env vars) and use our agent instead + axios.defaults.proxy = false; + axios.defaults.httpAgent = proxyAgent; + axios.defaults.httpsAgent = proxyAgent; + + logger.info(`Axios proxy configured → ${maskProxyUrl(config.proxyUrl)}`); + } catch (err) { + logger.error(`Failed to configure axios proxy: ${err.message}`); + } +} + +/** + * Get the proxy agent for use with native fetch(). + */ +export function getProxyAgent() { + return proxyAgent; +} + +/** + * Proxy-aware fetch wrapper. + * Drop-in replacement for global fetch() — injects the proxy agent + * when PROXY_URL is configured. + * Use this for Gamma API and Data API calls. + */ +export async function proxyFetch(url, opts = {}) { + if (proxyAgent) { + // Node 18+ fetch supports the 'dispatcher' option for undici, + // but the standard approach for http/https agent is via the agent option. + // We use the node-fetch compatible approach via the agent option. + opts.agent = proxyAgent; + } + return fetch(url, opts); +} + +/** + * Test that the proxy works by making a simple request to the CLOB API. + * Call this at startup to fail fast if the proxy is misconfigured. + */ +export async function testProxy() { + if (!config.proxyUrl) return true; // no proxy = nothing to test + + logger.info(`Testing proxy connection → ${maskProxyUrl(config.proxyUrl)} ...`); + + try { + // Ensure agent is created + if (!proxyAgent) { + proxyAgent = await createAgent(); + } + if (!proxyAgent) { + throw new Error('Proxy agent creation failed'); + } + + // Test with a simple GET to the CLOB time endpoint + const resp = await fetch(`${config.clobHost}/time`, { + agent: proxyAgent, + signal: AbortSignal.timeout(15000), + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + } + + logger.success(`Proxy test passed — connected via ${maskProxyUrl(config.proxyUrl)}`); + return true; + } catch (err) { + logger.error(`Proxy test FAILED: ${err.message}`); + logger.error('Check PROXY_URL in .env. Bot cannot reach Polymarket without a working proxy.'); + return false; + } +} + +/** + * Mask credentials in proxy URL for safe logging. + * http://user:pass@host:port → http://***:***@host:port + */ +function maskProxyUrl(url) { + try { + const u = new URL(url); + if (u.username || u.password) { + u.username = '***'; + u.password = '***'; + } + return u.toString(); + } catch { + return '(invalid URL)'; + } +}