feat: sniper bot updates — TUI separation, session scheduling, proxy support, win-only redeem
- Separate TUI from sniper: npm run sniper (console), npm run sniper-tui (TUI) - Add trading session scheduling per asset (BTC/ETH/SOL/XRP) in UTC+8 - Add PROXY_URL support for Polymarket CLOB/Gamma/Data APIs (not Polygon RPC) - Create redeemSniperPositions: win-only filter, bulk MultiSend batching - Add gas estimation with 10s timeout in execSafeCall - Replace native fetch with proxyFetch in all Polymarket API services
This commit is contained in:
+20
-16
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+207
-20
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
+34
-68
@@ -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);
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user