using System; using MongoDB.Driver; using PolyTraderSharp.Extensions; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; namespace PolyTraderSharp.Services { public class PolymarketApiService { private readonly TerminalLogger _logger; private readonly HttpClient _httpClient; private readonly string _dataHost = "https://data-api.polymarket.com"; private readonly string _clobHost = "https://clob.polymarket.com"; // Per-endpoint rate tracking (10-second windows matching Polymarket limits) // Data API has per-endpoint limits that are stricter than the general 1000/10s private readonly ConcurrentQueue _dataActivityTimestamps = new(); // /activity β†’ General 1000/10s private readonly ConcurrentQueue _dataPositionsTimestamps = new(); // /positions β†’ 150/10s private readonly ConcurrentQueue _gammaApiTimestamps = new(); // /events β†’ 500/10s private readonly ConcurrentQueue _clobApiTimestamps = new(); // General 9000/10s private long _lastPingMs = 0; // Polymarket documented rate limits per 10 seconds (per endpoint we use) public static readonly Dictionary RateLimits = new() { { "Activity", 1000 }, // Data API /activity (General limit, no specific) { "Positions", 150 }, // Data API /positions (specific endpoint limit!) { "Gamma", 500 }, // Gamma API /events (specific endpoint limit) { "CLOB", 9000 } // CLOB API General }; public PolymarketApiService(TerminalLogger logger, HttpClient httpClient) { _logger = logger; _httpClient = httpClient; _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); _httpClient.Timeout = TimeSpan.FromSeconds(30); } public long GetLastPing() => _lastPingMs; /// /// Returns per-endpoint request counts in the last 10 seconds. /// Keys match the RateLimits dictionary. /// public Dictionary GetRateLimitsPerTenSeconds() { var cutoff = DateTime.UtcNow.AddSeconds(-10); return new Dictionary { { "Activity", CountRecent(_dataActivityTimestamps, cutoff) }, { "Positions", CountRecent(_dataPositionsTimestamps, cutoff) }, { "Gamma", CountRecent(_gammaApiTimestamps, cutoff) }, { "CLOB", CountRecent(_clobApiTimestamps, cutoff) } }; } private static int CountRecent(ConcurrentQueue queue, DateTime cutoff) { int count = 0; foreach (var dt in queue.ToArray()) { if (dt >= cutoff) count++; } return count; } private void TrackRequest(string apiType) { var queue = apiType switch { "Activity" => _dataActivityTimestamps, "Positions" => _dataPositionsTimestamps, "Gamma" => _gammaApiTimestamps, "CLOB" => _clobApiTimestamps, _ => _clobApiTimestamps }; queue.Enqueue(DateTime.UtcNow); while (queue.TryPeek(out DateTime oldest) && oldest < DateTime.UtcNow.AddSeconds(-10)) { queue.TryDequeue(out _); } } public async Task MeasurePingAsync() { try { TrackRequest("CLOB"); var sw = Stopwatch.StartNew(); using var response = await _httpClient.GetAsync($"{_clobHost}/time"); sw.Stop(); if (response.IsSuccessStatusCode) { _lastPingMs = sw.ElapsedMilliseconds; return (int)_lastPingMs; } } catch { } return -1; } private async Task GetWithRetryAsync(string url) { int maxRetries = 3; for (int i = 0; i < maxRetries; i++) { try { var response = await _httpClient.GetAsync(url); if ((int)response.StatusCode == 429) // Rate limit { var delay = Math.Pow(2, i + 1); _logger.Warning($"API Rate-Limit (429) auf {url}. Retry in {delay}s..."); await Task.Delay(TimeSpan.FromSeconds(delay)); continue; } return response; } catch (TaskCanceledException) { if (i == maxRetries - 1) throw; var delay = Math.Pow(2, i + 1); _logger.Warning($"API Timeout auf {url}. Retry in {delay}s..."); await Task.Delay(TimeSpan.FromSeconds(delay)); } catch (HttpRequestException) { if (i == maxRetries - 1) throw; var delay = Math.Pow(2, i + 1); _logger.Warning($"Netzwerkfehler auf {url}. Retry in {delay}s..."); await Task.Delay(TimeSpan.FromSeconds(delay)); } } return await _httpClient.GetAsync(url); //Fallback } /// /// Fetches the recent trading activity for a given wallet address. /// public async Task> GetTraderActivityAsync(string walletAddress, int limit = 50) { TrackRequest("Activity"); try { long cb = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string url = $"{_dataHost}/activity?limit={limit}&user={walletAddress}&type=TRADE&_cb={cb}"; using var response = await GetWithRetryAsync(url); if (!response.IsSuccessStatusCode) { _logger.Warning($"API returned {response.StatusCode} for {walletAddress}"); return new List(); } var jsonStr = await response.Content.ReadAsStringAsync(); using var document = JsonDocument.Parse(jsonStr); var list = new List(); if (document.RootElement.ValueKind == JsonValueKind.Array) { foreach (var element in document.RootElement.EnumerateArray()) { list.Add(element.Clone()); } } return list; } catch (Exception ex) { _logger.Error($"Failed to fetch activity for {walletAddress}: {ex.Message}"); return new List(); } } /// /// Fetches the current best price from the CLOB orderbook for a given token. /// For SELL: returns the best bid (highest buy offer). /// For BUY: returns the best ask (lowest sell offer). /// public async Task GetOrderBookPriceAsync(string tokenId, string side = "SELL") { TrackRequest("CLOB"); try { string url = $"{_clobHost}/book?token_id={tokenId}"; using var response = await GetWithRetryAsync(url); if (!response.IsSuccessStatusCode) { _logger.Warning($"Orderbook request failed: {response.StatusCode}"); return null; } var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); // For SELL we want the best bid (buyer's highest price) // For BUY we want the best ask (seller's lowest price) string bookSide = side.ToUpper() == "SELL" ? "bids" : "asks"; if (doc.RootElement.TryGetProperty(bookSide, out var orders) && orders.ValueKind == JsonValueKind.Array && orders.GetArrayLength() > 0) { var priceList = new List(); foreach (var order in orders.EnumerateArray()) { if (order.TryGetProperty("price", out var priceProp)) { string priceStr = priceProp.GetString() ?? ""; if (decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal p)) { priceList.Add(p); } } } if (priceList.Count > 0) { // Seller wants the highest bid. Buyer wants the lowest ask. if (side.ToUpper() == "SELL") return priceList.Max(); else return priceList.Min(); } } _logger.Warning($"Orderbook leer oder kein Preis gefunden fΓΌr Token {tokenId}"); return null; } catch (Exception ex) { _logger.Error($"GetOrderBookPriceAsync Fehler: {ex.Message}"); return null; } } public async Task GetUsdcBalanceAsync(string walletAddress) { if (string.IsNullOrEmpty(walletAddress)) return 0; decimal totalBalance = 0; try { string addressObj = walletAddress.Replace("0x", "").PadLeft(64, '0'); string data = "0x70a08231" + addressObj; string[] rpcs = { "https://polygon-rpc.com", "https://polygon.llamarpc.com", "https://rpc.ankr.com/polygon" }; string[] contracts = { "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" }; foreach (var usdcContract in contracts) { bool success = false; foreach (var rpcUrl in rpcs) { var payload = new { jsonrpc = "2.0", method = "eth_call", @params = new object[] { new { to = usdcContract, data }, "latest" }, id = 1 }; try { var content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json"); using var response = await _httpClient.PostAsync(rpcUrl, content); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("result", out var res) && res.ValueKind == JsonValueKind.String) { string hexBal = res.GetString() ?? "0x0"; if (hexBal.StartsWith("0x")) hexBal = hexBal.Substring(2); if (!string.IsNullOrEmpty(hexBal)) { long rawBalance = Convert.ToInt64(hexBal, 16); decimal pVal = (decimal)rawBalance / 1_000_000m; totalBalance += pVal; if (pVal > 0) _logger.Info($"🌐 [{walletAddress.Substring(0, 6)}...] Balance gefunden: ${pVal:F2} auf Contract {usdcContract}"); } } success = true; break; } } catch (Exception exInner) { _logger.Error($"USDC Balance RPC Exception on {rpcUrl}: {exInner.Message}"); } } if (!success) _logger.Warning($"Fehler beim Abruf von USDC Token {usdcContract}"); } } catch (Exception ex) { _logger.Error($"USDC Balance fetch failed for {walletAddress}: {ex.Message}"); } return totalBalance; } public async Task<(bool isResolved, bool isWinner)> CheckMarketResolutionAsync(string slug, string tokenId) { if (string.IsNullOrEmpty(tokenId)) return (false, false); try { TrackRequest("Gamma"); using var response = await GetWithRetryAsync($"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}"); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0) { var mkt = doc.RootElement[0]; bool mktClosed = mkt.TryGetProperty("closed", out var mc) && mc.GetBoolean(); if (!mktClosed) return (false, false); if (mkt.TryGetProperty("clobTokenIds", out var cIdsStr) && mkt.TryGetProperty("outcomePrices", out var pricesStr)) { using var cDoc = JsonDocument.Parse(cIdsStr.GetString() ?? "[]"); using var pDoc = JsonDocument.Parse(pricesStr.GetString() ?? "[]"); var ids = cDoc.RootElement.EnumerateArray().ToList(); var prices = pDoc.RootElement.EnumerateArray().ToList(); for (int i = 0; i < ids.Count; i++) { if (ids[i].GetString() == tokenId) { if (i < prices.Count) { if (decimal.TryParse(prices[i].GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var px) && px >= 0.99m) return (true, true); else return (true, false); } } } } } } } catch (Exception ex) { _logger.Error($"Error checking market resolution for token {tokenId}: {ex.Message}"); } return (false, false); } public async Task?> SyncOpenPositionsAsync(string walletAddress) { if (string.IsNullOrEmpty(walletAddress)) return new List(); try { var allPositions = new List(); int limit = 500; int offset = 0; while (true) { TrackRequest("Positions"); using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/positions?user={walletAddress}&limit={limit}&offset={offset}"); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind == JsonValueKind.Array) { int count = 0; foreach (var el in doc.RootElement.EnumerateArray()) { allPositions.Add(el.Clone()); count++; } if (count < limit) break; // Reached the end offset += limit; } else { break; } } else { _logger.Error($"Failed to fetch open positions (HTTP {(int)response.StatusCode}): {response.ReasonPhrase}"); break; } } return allPositions.Count > 0 ? allPositions : null; } catch (Exception ex) { _logger.Error($"Failed to fetch open positions for {walletAddress}: {ex.Message}"); } return null; } /// /// Fetches the current position sizes a master trader holds for a set of token IDs. /// Returns a Dictionary mapping TokenId -> Shares held. Only includes tokens with size > 0. /// public async Task> GetTraderPositionSizesAsync(string walletAddress, HashSet relevantTokenIds) { var result = new Dictionary(); if (string.IsNullOrEmpty(walletAddress) || relevantTokenIds.Count == 0) return result; try { int limit = 500; int offset = 0; while (true) { TrackRequest("Positions"); using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/positions?user={walletAddress}&limit={limit}&offset={offset}&sizeThreshold=0.1"); if (!response.IsSuccessStatusCode) break; var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Array) break; int count = 0; foreach (var el in doc.RootElement.EnumerateArray()) { count++; string asset = el.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : ""; if (!string.IsNullOrEmpty(asset) && relevantTokenIds.Contains(asset)) { decimal size = 0; if (el.TryGetProperty("size", out var sp)) { if (sp.ValueKind == JsonValueKind.Number) size = sp.GetDecimal(); else if (sp.ValueKind == JsonValueKind.String) decimal.TryParse(sp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out size); } if (size > 0) result[asset] = size; } } if (count < limit) break; // Reached end offset += limit; if (offset > 5000) break; // Safety cap } } catch (Exception ex) { _logger.Error($"Failed to fetch trader positions for {walletAddress}: {ex.Message}"); } return result; } public async Task> SyncClosedPositionsAsync(string walletAddress, int limit = 100) { if (string.IsNullOrEmpty(walletAddress)) return new List(); try { TrackRequest("Positions"); using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/closed-positions?user={walletAddress}&limit={limit}&sortBy=TIMESTAMP&sortDirection=DESC"); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); var list = new List(); if (doc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var el in doc.RootElement.EnumerateArray()) list.Add(el.Clone()); } return list; } else { _logger.Error($"Failed to fetch closed positions (HTTP {(int)response.StatusCode}): {response.ReasonPhrase}"); } } catch (Exception ex) { _logger.Error($"Failed to fetch closed positions for {walletAddress}: {ex.Message}"); } return new List(); } /// /// Future placeholder for Live trading (Requires CLOB credentials context). /// public async Task PlaceOrderAsync(int accountId, string tokenId, decimal price, decimal size, string side) { TrackRequest("CLOB"); _logger.Info($"Placing {side} order on Account {accountId} for Token {tokenId}. Size: {size} @ {price}"); await Task.Delay(100); return true; } public async Task> GetRecentMarketsAsync(int limit = 1000) { TrackRequest("Gamma"); var results = new List(); try { string url = $"https://gamma-api.polymarket.com/markets?limit={limit}&order=id&ascending=false"; using var response = await GetWithRetryAsync(url); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var mkt in doc.RootElement.EnumerateArray()) { var md = new PolyTraderSharp.Models.MarketData(); md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : ""; md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : ""; md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : ""; md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean(); md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean(); md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? p7.GetString() ?? "" : ""; md.Slug = mkt.TryGetProperty("slug", out var p4) ? p4.GetString() ?? "" : ""; if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0) { var evSlug = evts[0].TryGetProperty("slug", out var evp) ? evp.GetString() : ""; if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug; md.NegRisk = evts[0].TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True; } if (mkt.TryGetProperty("endDate", out var ep) && ep.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(ep.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt)) { md.EndDate = endDt.ToUniversalTime(); } } results.Add(md); } } } } catch (Exception ex) { _logger.Error($"Failed to fetch recent markets: {ex.Message}"); } return results; } public async Task GetMarketByTokenIdAsync(string tokenId) { TrackRequest("Gamma"); if (string.IsNullOrEmpty(tokenId)) return null; try { // Must use clob_token_ids! If you use clobTokenIds it ignores it and returns the oldest market (Joe Biden) string url = $"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}"; using var response = await GetWithRetryAsync(url); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0) { var mkt = doc.RootElement[0]; var md = new PolyTraderSharp.Models.MarketData(); md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : ""; md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : ""; md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : ""; md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean(); md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean(); md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? (p7.ValueKind == JsonValueKind.String ? p7.GetString() ?? "" : p7.GetRawText()) : ""; md.Outcomes = mkt.TryGetProperty("outcomes", out var p8) ? (p8.ValueKind == JsonValueKind.String ? p8.GetString() ?? "" : p8.GetRawText()) : ""; md.Slug = mkt.TryGetProperty("slug", out var p4) ? p4.GetString() ?? "" : ""; if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0) { var evt = evts[0]; var evSlug = evt.TryGetProperty("slug", out var evp) ? evp.GetString() : ""; if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug; if (evt.TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True) { md.NegRisk = true; } if (evt.TryGetProperty("endDate", out var et) && DateTime.TryParse(et.GetString(), out var dt)) { md.EndDate = DateTime.SpecifyKind(dt, DateTimeKind.Utc); } } else { if (mkt.TryGetProperty("endDate", out var et) && DateTime.TryParse(et.GetString(), out var dt)) { md.EndDate = DateTime.SpecifyKind(dt, DateTimeKind.Utc); } } // Security Validation: Ensure the API actually returned the market we asked for! if (string.IsNullOrEmpty(md.ClobTokenIds) || !md.ClobTokenIds.Contains(tokenId)) { _logger.Warning($"GetMarketByTokenIdAsync: API returned a mismatching market '{md.Question}' for Token {tokenId}. Skipping."); return null; } return md; } } } catch (Exception ex) { _logger.Error($"Failed to fetch market by token ID ({tokenId}): {ex.Message}"); } return null; } public async Task> GetMarketsByEventSlugAsync(string slug) { TrackRequest("Gamma"); var results = new List(); if (string.IsNullOrEmpty(slug)) return results; try { string url = $"https://gamma-api.polymarket.com/events?slug={slug}"; using var response = await GetWithRetryAsync(url); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0) { var ev = doc.RootElement[0]; if (ev.TryGetProperty("markets", out var marketsArr) && marketsArr.ValueKind == JsonValueKind.Array) { foreach (var mkt in marketsArr.EnumerateArray()) { var md = new PolyTraderSharp.Models.MarketData(); md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : ""; md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : ""; md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : ""; md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean(); md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean(); md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? (p7.ValueKind == JsonValueKind.String ? p7.GetString() ?? "" : p7.GetRawText()) : ""; md.Outcomes = mkt.TryGetProperty("outcomes", out var p8) ? (p8.ValueKind == JsonValueKind.String ? p8.GetString() ?? "" : p8.GetRawText()) : ""; md.Slug = slug; md.NegRisk = ev.TryGetProperty("enableNegRisk", out var evNeg) && evNeg.ValueKind == JsonValueKind.True; if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0) { var evSlug = evts[0].TryGetProperty("slug", out var evp) ? evp.GetString() : ""; if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug; // if missing on event root but present in nested events (rare), fallback to it if (!md.NegRisk && evts[0].TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True) { md.NegRisk = true; } } if (mkt.TryGetProperty("endDate", out var ep) && ep.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(ep.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt)) { md.EndDate = endDt.ToUniversalTime(); } } results.Add(md); } } } } } catch (Exception ex) { _logger.Error($"Failed to fetch markets by slug ({slug}): {ex.Message}"); } return results; } public async Task ResolveEventSlugAsync(string fallbackSlug, string tokenId) { if (string.IsNullOrEmpty(tokenId)) return fallbackSlug; try { // Gamma API will resolve the market object along with its parent event properties string url = $"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}"; using var response = await GetWithRetryAsync(url); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0) { var mkt = doc.RootElement[0]; if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0) { var evSlug = evts[0].TryGetProperty("slug", out var evs) ? evs.GetString() : ""; if (!string.IsNullOrEmpty(evSlug)) return evSlug; } } } } catch (Exception ex) { _logger.Error($"Error resolving Event Slug for Token {tokenId}: {ex.Message}"); } return fallbackSlug; } public async Task> ParseBlockchainTransactionAsync(string txHash, string rpcUrl, string masterWallet) { var results = new List(); try { // Convert wss:// to https:// if (rpcUrl.StartsWith("wss://")) rpcUrl = "https://" + rpcUrl.Substring(6); var rpcPayload = new { jsonrpc = "2.0", method = "eth_getTransactionReceipt", @params = new object[] { txHash }, id = 1 }; using var request = new HttpRequestMessage(HttpMethod.Post, rpcUrl); request.Content = new StringContent(JsonSerializer.Serialize(rpcPayload), System.Text.Encoding.UTF8, "application/json"); using var response = await _httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) return results; var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (!doc.RootElement.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object) return results; if (!result.TryGetProperty("logs", out var logs) || logs.ValueKind != JsonValueKind.Array) return results; string rxFrom = ""; if (result.TryGetProperty("from", out var fVal) && fVal.ValueKind == JsonValueKind.String) rxFrom = fVal.GetString()?.ToLowerInvariant() ?? ""; decimal usdcAmount = 0m; string action = ""; // Track parsed CTF transfers: Dictionary var parsedTransfers = new Dictionary(); string masterWalletLower = masterWallet.ToLowerInvariant().Replace("0x", ""); string masterWalletPadded = "0x000000000000000000000000" + masterWalletLower; string ctfExchangePadded = "0x0000000000000000000000004bfb41d5b3570defd03c39a9a4d8de6bd8b8982e"; bool isMasterTxOwner = rxFrom == ("0x" + masterWalletLower); foreach (var log in logs.EnumerateArray()) { string address = log.GetProperty("address").GetString()?.ToLowerInvariant() ?? ""; if (!log.TryGetProperty("topics", out var topicsArr) || topicsArr.ValueKind != JsonValueKind.Array || topicsArr.GetArrayLength() == 0) continue; var topics = topicsArr.EnumerateArray().Select(t => t.GetString()?.ToLowerInvariant()).ToList(); string data = log.GetProperty("data").GetString()?.ToLowerInvariant() ?? "0x"; string topic0 = topics[0] ?? ""; // USDC Transfer (or USDC.e) if (topic0 == "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef") { if (address == "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" || address == "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359") { if (topics.Count >= 3) { string fromTopic = topics[1] ?? ""; string toTopic = topics[2] ?? ""; if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded || fromTopic == ctfExchangePadded || toTopic == ctfExchangePadded) { string cleanData = data.Replace("0x", ""); if (cleanData.Length >= 64) { var amountBI = System.Numerics.BigInteger.Parse("0" + cleanData.Substring(0, 64), System.Globalization.NumberStyles.HexNumber); decimal amount = (decimal)amountBI / 1_000_000m; // 6 decimals USDC usdcAmount = Math.Max(usdcAmount, amount); if (toTopic == ctfExchangePadded) action = "BUY"; else if (fromTopic == ctfExchangePadded) action = "SELL"; else if (fromTopic == masterWalletPadded) action = "BUY"; else if (toTopic == masterWalletPadded) action = "SELL"; } } } } } // CTF TransferSingle if (topic0 == "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62") { if (address == "0x4d97dcd97ec945f40cf65f87097ace5ea0476045") { if (topics.Count >= 4) { string fromTopic = topics[2] ?? ""; string toTopic = topics[3] ?? ""; if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded) { string cleanData = data.Replace("0x", ""); if (cleanData.Length >= 128) { string idHex = cleanData.Substring(0, 64); string valueHex = cleanData.Substring(64, 64); var idBI = System.Numerics.BigInteger.Parse("0" + idHex, System.Globalization.NumberStyles.HexNumber); var valueBI = System.Numerics.BigInteger.Parse("0" + valueHex, System.Globalization.NumberStyles.HexNumber); string tid = idBI.ToString(); decimal sh = (decimal)valueBI / 1_000_000m; // 6 decimals CTF if (parsedTransfers.ContainsKey(tid)) parsedTransfers[tid] += sh; else parsedTransfers[tid] = sh; } } } } } // CTF TransferBatch if (topic0 == "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce") { if (address == "0x4d97dcd97ec945f40cf65f87097ace5ea0476045") { if (topics.Count >= 4) { string fromTopic = topics[2] ?? ""; string toTopic = topics[3] ?? ""; if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded) { string cleanData = data.Replace("0x", ""); if (cleanData.Length >= 256) { try { var chunks = Enumerable.Range(0, cleanData.Length / 64).Select(i => cleanData.Substring(i * 64, 64)).ToList(); if (chunks.Count >= 4) { int idsOffsetWord = int.Parse(chunks[0], System.Globalization.NumberStyles.HexNumber) / 32; int valsOffsetWord = int.Parse(chunks[1], System.Globalization.NumberStyles.HexNumber) / 32; if (idsOffsetWord < chunks.Count && valsOffsetWord < chunks.Count) { int idsLen = int.Parse(chunks[idsOffsetWord], System.Globalization.NumberStyles.HexNumber); int valsLen = int.Parse(chunks[valsOffsetWord], System.Globalization.NumberStyles.HexNumber); int maxLen = Math.Min(idsLen, valsLen); for (int i = 0; i < maxLen; i++) { if (idsOffsetWord + 1 + i < chunks.Count && valsOffsetWord + 1 + i < chunks.Count) { string idHex = chunks[idsOffsetWord + 1 + i]; string valHex = chunks[valsOffsetWord + 1 + i]; var idBI = System.Numerics.BigInteger.Parse("0" + idHex, System.Globalization.NumberStyles.HexNumber); var valueBI = System.Numerics.BigInteger.Parse("0" + valHex, System.Globalization.NumberStyles.HexNumber); string tid = idBI.ToString(); decimal sh = (decimal)valueBI / 1_000_000m; if (parsedTransfers.ContainsKey(tid)) parsedTransfers[tid] += sh; else parsedTransfers[tid] = sh; } } } } } catch (Exception ex) { _logger.Error($"Error parsing TransferBatch for TX {txHash}: {ex.Message}"); } } } } } } } if (parsedTransfers.Count > 0 && !string.IsNullOrEmpty(action) && usdcAmount > 0) { decimal totalSharesForAllTokens = parsedTransfers.Values.Sum(); decimal globalAvgPrice = totalSharesForAllTokens > 0 ? usdcAmount / totalSharesForAllTokens : 0; // Filter: Redeems yield exactly $1.00 USD per share. Merges also yield $1.00 USD for a full set. // If the master trader "sells" at >= 0.99 on-chain, it is guaranteed to be a Redeem/Winnings Claim, NOT an orderbook trade. // We must filter this out so the copy trading engine doesn't dump our tickets at market price! if (action == "SELL" && globalAvgPrice >= 0.99m) { _logger.Debug($"FastTrack Parser: Ignored Fake SELL (Redeem/Merge) with Return Price ${globalAvgPrice:F3} for TX {txHash}"); return results; } if (globalAvgPrice > 0.999m) globalAvgPrice = 0.99m; foreach (var pt in parsedTransfers) { var signal = new PolyTraderSharp.Models.CopySignal { TokenId = pt.Key, Side = action, Size = pt.Value, Price = globalAvgPrice, Timestamp = DateTime.UtcNow }; results.Add(signal); } } } catch (Exception ex) { _logger.Error($"Blockchain Parser Error: {ex.Message}"); } return results; } } }