using System; using MongoDB.Driver; using PolyTraderSharp.Extensions; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Nethereum.Signer; using Nethereum.Signer.EIP712; using Nethereum.ABI.FunctionEncoding.Attributes; using Nethereum.ABI.EIP712; using Nethereum.Util; using PolyTraderSharp.Models; namespace PolyTraderSharp.Services { [Struct("EIP712Domain")] public class ClobDomain { [Parameter("string", "name", 1)] public string Name { get; set; } = string.Empty; [Parameter("string", "version", 2)] public string Version { get; set; } = ""; [Parameter("uint256", "chainId", 3)] public System.Numerics.BigInteger ChainId { get; set; } } [Struct("EIP712Domain")] public class CtfDomain { [Parameter("string", "name", 1)] public string Name { get; set; } = string.Empty; [Parameter("string", "version", 2)] public string Version { get; set; } = string.Empty; [Parameter("uint256", "chainId", 3)] public ulong ChainId { get; set; } [Parameter("address", "verifyingContract", 4)] public string VerifyingContract { get; set; } = string.Empty; } [Struct("ClobAuth")] public class ClobAuth { [Parameter("address", "address", 1)] public string Address { get; set; } = string.Empty; [Parameter("string", "timestamp", 2)] public string Timestamp { get; set; } = ""; [Parameter("uint256", "nonce", 3)] public System.Numerics.BigInteger Nonce { get; set; } [Parameter("string", "message", 4)] public string Message { get; set; } = string.Empty; } [Struct("Order")] public class CtfOrder { [Parameter("uint256", "salt", 1)] public System.Numerics.BigInteger Salt { get; set; } [Parameter("address", "maker", 2)] public string Maker { get; set; } = string.Empty; [Parameter("address", "signer", 3)] public string Signer { get; set; } = string.Empty; [Parameter("address", "taker", 4)] public string Taker { get; set; } = string.Empty; [Parameter("uint256", "tokenId", 5)] public System.Numerics.BigInteger TokenId { get; set; } [Parameter("uint256", "makerAmount", 6)] public System.Numerics.BigInteger MakerAmount { get; set; } [Parameter("uint256", "takerAmount", 7)] public System.Numerics.BigInteger TakerAmount { get; set; } [Parameter("uint256", "expiration", 8)] public System.Numerics.BigInteger Expiration { get; set; } [Parameter("uint256", "nonce", 9)] public System.Numerics.BigInteger Nonce { get; set; } [Parameter("uint256", "feeRateBps", 10)] public System.Numerics.BigInteger FeeRateBps { get; set; } [Parameter("uint8", "side", 11)] public byte Side { get; set; } [Parameter("uint8", "signatureType", 12)] public byte SignatureType { get; set; } } public class PolymarketClobClient { private readonly HttpClient _httpClient; private readonly TerminalLogger _logger; private const string ClobHost = "https://clob.polymarket.com"; private const int ChainId = 137; private static readonly object _fileLock = new object(); public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient) { _logger = logger; _httpClient = httpClient; } /// /// Creates an HMAC signature for authenticated requests to the Polymarket CLOB. /// private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "") { string payload = timestamp + method + requestPath + body; // Convert URL-Safe Base64 back to Standard Base64 string b64 = secret.Replace('-', '+').Replace('_', '/'); switch (b64.Length % 4) { case 2: b64 += "=="; break; case 3: b64 += "="; break; } byte[] secretBytes = Convert.FromBase64String(b64); byte[] payloadBytes = Encoding.UTF8.GetBytes(payload); using var hmac = new HMACSHA256(secretBytes); byte[] hash = hmac.ComputeHash(payloadBytes); string signature = Convert.ToBase64String(hash); return signature.Replace('+', '-').Replace('/', '_'); } private static long _serverTimeDeltaSeconds = 0; private static DateTime _lastTimeSync = DateTime.MinValue; public async Task SyncServerTimeAsync() { if ((DateTime.UtcNow - _lastTimeSync).TotalMinutes < 15) return; try { using var response = await _httpClient.GetAsync($"{ClobHost}/time"); if (response.IsSuccessStatusCode) { string jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); long epochSecs = 0; if (doc.RootElement.ValueKind == JsonValueKind.Number) { epochSecs = doc.RootElement.GetInt64(); if (epochSecs > 1000000000000) epochSecs /= 1000; DateTime serverTime = DateTimeOffset.FromUnixTimeSeconds(epochSecs).UtcDateTime; _serverTimeDeltaSeconds = (long)(serverTime - DateTime.UtcNow).TotalSeconds; _lastTimeSync = DateTime.UtcNow; _logger.Info($"🕒 CLOB Server Time Sync: Offset ist {_serverTimeDeltaSeconds} Sekunden."); } else if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("iso", out var isoProp) && DateTime.TryParse(isoProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime serverTime)) { serverTime = serverTime.ToUniversalTime(); _serverTimeDeltaSeconds = (long)(serverTime - DateTime.UtcNow).TotalSeconds; _lastTimeSync = DateTime.UtcNow; _logger.Info($"🕒 CLOB Server Time Sync: Offset ist {_serverTimeDeltaSeconds} Sekunden."); } } } catch (Exception ex) { _logger.Warning($"🕒 Time Sync Error: {ex.Message}"); } } private string GetClobTimestamp() { // Background fire-and-forget sync if expired if ((DateTime.UtcNow - _lastTimeSync).TotalMinutes > 15) { _ = SyncServerTimeAsync(); } return (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _serverTimeDeltaSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture); } /// /// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key. /// public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress) { try { var signer = new Eip712TypedDataSigner(); var key = new EthECKey(privateKey); string computedAddress = key.GetPublicAddress(); string timestamp = GetClobTimestamp(); var typedData = new TypedData { Domain = new ClobDomain { Name = "ClobAuthDomain", Version = "1", ChainId = new System.Numerics.BigInteger(ChainId) }, Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)), PrimaryType = "ClobAuth" }; var clobAuth = new ClobAuth { Address = computedAddress, Timestamp = timestamp, Nonce = new System.Numerics.BigInteger(0), Message = "This message attests that I control the given wallet" }; var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder(); var rawData = encoder.EncodeTypedData(clobAuth, typedData); _logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}"); string signature = signer.SignTypedDataV4(clobAuth, typedData, key); _logger.Warning($"DEBUG_CS_SIG: {signature}"); var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key"); request.Headers.Add("POLY_ADDRESS", computedAddress); request.Headers.Add("POLY_SIGNATURE", signature); request.Headers.Add("POLY_TIMESTAMP", timestamp); request.Headers.Add("POLY_NONCE", "0"); using (var response = await _httpClient.SendAsync(request)) { if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? ""; string secret = doc.RootElement.GetProperty("secret").GetString() ?? ""; string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? ""; return (apiKey, secret, passphrase); } } _logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead..."); using (var request2 = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key")) { request2.Headers.Add("POLY_ADDRESS", computedAddress); request2.Headers.Add("POLY_SIGNATURE", signature); request2.Headers.Add("POLY_TIMESTAMP", timestamp); request2.Headers.Add("POLY_NONCE", "0"); using (var response2 = await _httpClient.SendAsync(request2)) { if (response2.IsSuccessStatusCode) { var jsonStr = await response2.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? ""; string secret = doc.RootElement.GetProperty("secret").GetString() ?? ""; string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? ""; return (apiKey, secret, passphrase); } else { string err = await response2.Content.ReadAsStringAsync(); _logger.Error($"Failed to execute L1 Auth: {response2.StatusCode} {err}"); } } } } catch (Exception ex) { _logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}"); } return (string.Empty, string.Empty, string.Empty); } public async Task GetUsdcBalanceAsync(AccountState acc, bool isRetry = false) { if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey)) { _logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}"); return 0; } try { string endpoint = "/balance-allowance"; string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2"; string timestamp = GetClobTimestamp(); // Python SDK signs ONLY the base path, not the query params string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint); var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}"); var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", "")); request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress()); request.Headers.Add("POLY_API_KEY", acc.ApiKey); request.Headers.Add("POLY_SIGNATURE", signature); request.Headers.Add("POLY_TIMESTAMP", timestamp); request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase); using var response = await _httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); _logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}"); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp)) { var balanceStr = balProp.GetString(); if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw)) { decimal finalBal = balRaw / 1_000_000m; _logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})"); return finalBal; } } _logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}"); } else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) { string errStr = await response.Content.ReadAsStringAsync(); _logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey..."); if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress)) { var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", "")); var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress()); if (!string.IsNullOrEmpty(newKeys.ApiKey)) { acc.ApiKey = newKeys.ApiKey; acc.ApiSecret = newKeys.ApiSecret; acc.ApiPassphrase = newKeys.ApiPassphrase; _logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s..."); // Await propagation of new keys inside Polymarket's Gamma backend await Task.Delay(2500); // Retry recursively strictly once return await GetUsdcBalanceAsync(acc, true); } } _logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}"); } else { string errStr = await response.Content.ReadAsStringAsync(); _logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}"); } } catch (Exception ex) { _logger.Error($"CLOB Balance Fetch Error: {ex.Message}"); } return 0; } public async Task> GetOpenOrdersAsync(AccountState acc, string assetId) { var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>(); if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey)) return result; try { string endpoint = "/data/orders"; string requestUrl = $"{endpoint}?asset_id={assetId}"; string timestamp = GetClobTimestamp(); string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint); var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}"); var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", "")); request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress()); request.Headers.Add("POLY_API_KEY", acc.ApiKey); request.Headers.Add("POLY_SIGNATURE", signature); request.Headers.Add("POLY_TIMESTAMP", timestamp); request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase); using var response = await _httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(jsonStr); if (doc.RootElement.TryGetProperty("data", out var dataArr) && dataArr.ValueKind == JsonValueKind.Array) { foreach (var orderLine in dataArr.EnumerateArray()) { if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid)) { string idStr = oid.GetString() ?? ""; string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : ""; string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0"; decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec); if (!string.IsNullOrEmpty(idStr)) result.Add((idStr, sideStr, priceDec)); } } } else if (doc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var orderLine in doc.RootElement.EnumerateArray()) { if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid)) { string idStr = oid.GetString() ?? ""; string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : ""; string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0"; decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec); if (!string.IsNullOrEmpty(idStr)) result.Add((idStr, sideStr, priceDec)); } } } } else { string errStr = await response.Content.ReadAsStringAsync(); _logger.Warning($"Failed to GET open orders for {assetId}: {response.StatusCode} {errStr}"); } } catch (Exception ex) { _logger.Error($"GetOpenOrdersAsync Error: {ex.Message}"); } return result; } public async Task CancelOrderAsync(AccountState acc, string orderId) { if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey)) return false; try { string endpoint = "/order"; var reqBody = new { orderID = orderId }; string jsonBody = JsonSerializer.Serialize(reqBody); string timestamp = GetClobTimestamp(); string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "DELETE", endpoint, jsonBody); using var request = new HttpRequestMessage(HttpMethod.Delete, $"{ClobHost}{endpoint}"); var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", "")); request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress()); request.Headers.Add("POLY_API_KEY", acc.ApiKey); request.Headers.Add("POLY_SIGNATURE", signature); request.Headers.Add("POLY_TIMESTAMP", timestamp); request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase); request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); using var response = await _httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { _logger.Info($"🚮 [{acc.Name}] Stornierung erfolgreich. OrderID: {orderId}"); return true; } else { string errStr = await response.Content.ReadAsStringAsync(); _logger.Warning($"Failed to cancel order {orderId}: {response.StatusCode} {errStr}"); return false; } } catch (Exception ex) { _logger.Error($"CancelOrderAsync Error: {ex.Message}"); return false; } } public async Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr) { var openOrders = await GetOpenOrdersAsync(acc, assetId); if (openOrders.Count > 0) { var tasks = new System.Collections.Generic.List(); foreach (var order in openOrders) { bool shouldCancel = false; if (sideStr.Equals("SELL", StringComparison.OrdinalIgnoreCase)) { shouldCancel = true; _logger.Info($"⚠️ [{acc.Name}] Storniere Order {order.Id} wegen Verkaufs-Signal des Master-Traders."); } else if (sideStr.Equals("BUY", StringComparison.OrdinalIgnoreCase) && order.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase)) { if (Math.Abs(order.Price - newPrice) > 0.001m) { shouldCancel = true; _logger.Info($"⚠️ [{acc.Name}] Storniere veraltete Order {order.Id} (Alter Preis: {order.Price:F3}, Neuer Preis: {newPrice:F3})"); } else { _logger.Info($"✅ [{acc.Name}] Behalte bestehende Order {order.Id} (Preis identisch: {order.Price:F3})"); } } if (shouldCancel) { tasks.Add(CancelOrderAsync(acc, order.Id)); } } if (tasks.Count > 0) { await Task.WhenAll(tasks); // Minimal delay to ensure rapid executions don't conflict with in-flight deletions await Task.Delay(150); } } } private static System.Numerics.BigInteger GenerateSalt() { // Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number) long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000); return new System.Numerics.BigInteger(t * 10000 + r); } public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null) { decimal tickSize = overrideTickSize ?? 0.001m; int priceDec, sizeDec, amtDec; if (tickSize >= 0.1m) { priceDec = 1; sizeDec = 2; amtDec = 3; } else if (tickSize >= 0.01m) { priceDec = 2; sizeDec = 2; amtDec = 4; } else if (tickSize >= 0.001m) { priceDec = 3; sizeDec = 2; amtDec = 5; } else { priceDec = 4; sizeDec = 2; amtDec = 6; } decimal priceRounded = Math.Round(limitPrice > 0 ? limitPrice : rawPrice, priceDec, MidpointRounding.AwayFromZero); if (priceRounded < tickSize) priceRounded = tickSize; decimal executedShares = 0m; decimal executedUsdc = 0m; decimal finalMakerAmountRaw = 0m; decimal finalTakerAmountRaw = 0m; if (sideStr.ToUpper() == "BUY") { decimal rawTakerShares = investAmountUsd / priceRounded; decimal multiplier = (decimal)Math.Pow(10, sizeDec); decimal takerShares = Math.Floor(rawTakerShares * multiplier) / multiplier; if (takerShares <= 0) return (-1, -1, 0, 0); decimal makerUsd = 0m; // Polymarket strictly enforces $1.00 minimum for MARKET BUYS and verifies it against the supported shares. // We increment takerShares until the floored USDC amount supports the exact shares without dropping below $1.00. decimal step = 1.0m / multiplier; while (takerShares > 0) { makerUsd = takerShares * priceRounded; int actDec = BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2]; if (actDec > amtDec) { decimal mul2 = (decimal)Math.Pow(10, amtDec + 4); makerUsd = Math.Ceiling(makerUsd * mul2) / mul2; if (BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2] > amtDec) { decimal mul3 = (decimal)Math.Pow(10, amtDec); makerUsd = Math.Floor(makerUsd * mul3) / mul3; } } decimal supportedShares = Math.Floor((makerUsd / priceRounded) * multiplier) / multiplier; if (makerUsd >= 1.0m && supportedShares >= takerShares) break; takerShares += step; } finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m); finalMakerAmountRaw = Math.Round(makerUsd * 1_000_000m); executedShares = takerShares; executedUsdc = makerUsd; } else { decimal sharesRaw = investAmountUsd / priceRounded; decimal multiplier = (decimal)Math.Pow(10, sizeDec); decimal makerShares = Math.Floor(sharesRaw * multiplier) / multiplier; decimal takerUsd = makerShares * priceRounded; int actDec = BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2]; if (actDec > amtDec) { decimal mul2 = (decimal)Math.Pow(10, amtDec + 4); takerUsd = Math.Ceiling(takerUsd * mul2) / mul2; if (BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2] > amtDec) { decimal mul3 = (decimal)Math.Pow(10, amtDec); takerUsd = Math.Floor(takerUsd * mul3) / mul3; } } finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m); finalTakerAmountRaw = Math.Round(takerUsd * 1_000_000m); executedShares = makerShares; executedUsdc = takerUsd; } return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw); } /// /// Executes a native EIP-712 signed order (default Fill-Or-Kill) /// public async Task PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null) { if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey)) return "Error: Missing API or Private Keys"; try { var signer = new Eip712TypedDataSigner(); var key = new EthECKey(account.PrivateKey); var typedData = new TypedData { Domain = new CtfDomain { Name = "Polymarket CTF Exchange", Version = "1", ChainId = ChainId, VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" }, Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)), PrimaryType = "Order" }; var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals); if (amounts.shares <= 0) return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching"; decimal makerAmountRaw = amounts.makerRaw; decimal takerAmountRaw = amounts.takerRaw; System.Numerics.BigInteger parsedTokenId; if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c))) { parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value; } else { parsedTokenId = System.Numerics.BigInteger.Parse(tokenId); } var ctfOrder = new CtfOrder { Salt = GenerateSalt(), Maker = account.WalletAddress, Signer = key.GetPublicAddress(), Taker = "0x0000000000000000000000000000000000000000", TokenId = parsedTokenId, MakerAmount = new System.Numerics.BigInteger(makerAmountRaw), TakerAmount = new System.Numerics.BigInteger(takerAmountRaw), Expiration = orderType == "GTD" ? long.Parse(GetClobTimestamp()) + 300 : 0, Nonce = 0, FeeRateBps = new System.Numerics.BigInteger(actualFeeBps), Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1, SignatureType = 2 }; string signature = signer.SignTypedDataV4(ctfOrder, typedData, key); var reqBody = new { order = new { salt = (long)ctfOrder.Salt, maker = ctfOrder.Maker.ToLower(), signer = ctfOrder.Signer.ToLower(), taker = ctfOrder.Taker.ToLower(), tokenId = ctfOrder.TokenId.ToString(), makerAmount = ctfOrder.MakerAmount.ToString(), takerAmount = ctfOrder.TakerAmount.ToString(), expiration = ctfOrder.Expiration.ToString(), nonce = ctfOrder.Nonce.ToString(), feeRateBps = ctfOrder.FeeRateBps.ToString(), side = ctfOrder.Side == 0 ? "BUY" : "SELL", signatureType = ctfOrder.SignatureType, signature = signature }, owner = account.ApiKey, orderType = orderType }; string jsonBody = JsonSerializer.Serialize(reqBody); string timestamp = GetClobTimestamp(); string requestPath = "/order"; string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody); using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}"); var keyObj = new EthECKey(account.PrivateKey.Replace("0x", "")); request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress()); request.Headers.Add("POLY_API_KEY", account.ApiKey); request.Headers.Add("POLY_TIMESTAMP", timestamp); request.Headers.Add("POLY_SIGNATURE", hmacSig); request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase); request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); if (debugPayloadLog) { _logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}"); } using var response = await _httpClient.SendAsync(request); var responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed"); if (isFokFail && sideStr == "BUY") { // Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size. // We skip it silently. return "SKIPPED_LIQUIDITY"; } lock (_fileLock) { System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody); } if (isFokFail && sideStr == "SELL") { _logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten."); return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK)."; } else { var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)"); if (tickMatch.Success && overrideTickSize == null) { if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize)) { _logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert..."); return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals); } } var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals"); if (decMatch.Success && overrideMakerDecimals == null) { if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker)) { _logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet..."); return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker); } } // Check if error is "invalid fee rate" -> Extract required fee -> Retry! var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)"); if (match.Success && actualFeeBps == 0) // Only retry once { if (int.TryParse(match.Groups[1].Value, out int newFeeBps)) { _logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert..."); return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize); } } // Check if error is "Size lower than minimum 5" -> Fallback to MARKET var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)"); if (sizeMatch.Success) { if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq)) { if (orderType != "MARKET") { _logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert..."); return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals); } else if (sideStr == "SELL") { _logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen."); return $"Börsenlimit: Mindestens {minReq} Shares erforderlich."; } } } var balMatch1 = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)"); var balMatch2 = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), order amount: (\d+)"); if ((balMatch1.Success || balMatch2.Success) && sideStr == "SELL") { decimal totalBal = 0m, activeOrders = 0m; if (balMatch1.Success) { _ = decimal.TryParse(balMatch1.Groups[1].Value, out totalBal); _ = decimal.TryParse(balMatch1.Groups[2].Value, out activeOrders); } else if (balMatch2.Success) { _ = decimal.TryParse(balMatch2.Groups[1].Value, out totalBal); activeOrders = 0m; } decimal availableSharesRaw = totalBal - activeOrders; decimal availableShares = availableSharesRaw / 1_000_000m; decimal requiredShares = investAmountUsd / limitPrice; if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares) { decimal newInvestAmount = availableShares * limitPrice; _logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Reale Balance: {totalBal / 1000000m} / Aktive Orders: {activeOrders / 1000000m} Shares). Verkaufe exakte {availableShares} Shares..."); return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals); } } _logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}"); } return "ERROR"; } if (response.IsSuccessStatusCode) { _logger.Info($"\u2705 Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}"); if (orderType == "GTC" || orderType == "GTD") { account.HasOpenLimitOrders = true; } return "OK"; } else { _logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}"); return responseContent; } } catch (Exception ex) { _logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}"); return ex.Message; } } } }