Phase 5.3-WSS (1/2): Core-WSS-Verbindungsklasse extrahiert

- Neu in PolyTrader.Core.Streaming: IBlockchainWssClient + AlchemyWssClient
  (+ Factory) und Modelle (BlockchainWssSubscription/LogSubscriptionFilter/
  BlockchainLogEvent). Domänen-agnostisch: Connect, eth_subscribe, Receive-Loop,
  Decode; Reconnect/Gating/Filter bleiben beim Aufrufer.
- AlchemyWebsocketService (weiter in App) nutzt jetzt den Core-Client via Factory:
  baut Filter aus getrackten Trader-Wallets, konsumiert OnLog, triggert Poll.
  ExecuteAsync-Reconnect/Backoff/Gating unverändert. Verhaltensneutral.
- Factory in DI registriert.
- Build 0 Fehler.

Naechster Teil: Modul-Listener (eigener Key+Filter) nach Modul-Umzug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-01 22:15:34 +02:00
co-authored by Claude Opus 4.8
parent 7e189fec54
commit 3be75c0f05
4 changed files with 224 additions and 104 deletions
+51 -104
View File
@@ -1,13 +1,11 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Streaming;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
@@ -23,19 +21,22 @@ namespace PolyTraderSharp.Services
private readonly ServerSettings _settings;
private readonly TraderMonitorService _traderMonitor;
private readonly TerminalLogger _logger;
private readonly IBlockchainWssClientFactory _wssFactory;
public AlchemyWebsocketService(
TradingState state,
CopyTradingState copyState,
ServerSettings settings,
TraderMonitorService traderMonitor,
TerminalLogger logger)
TerminalLogger logger,
IBlockchainWssClientFactory wssFactory)
{
_state = state;
_copyState = copyState;
_settings = settings;
_traderMonitor = traderMonitor;
_logger = logger;
_wssFactory = wssFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -51,7 +52,7 @@ namespace PolyTraderSharp.Services
while (!stoppingToken.IsCancellationRequested)
{
if (_state.GlobalTradingPaused ||
if (_state.GlobalTradingPaused ||
(_state.LiveTradingMode == TradingMode.Inactive && _state.DemoTradingMode == TradingMode.Inactive))
{
_state.IsAlchemyHealthy = false;
@@ -88,86 +89,39 @@ namespace PolyTraderSharp.Services
}
}
/// <summary>
/// Baut EINE WSS-Session über den Core-Client auf. Consumer-Verantwortung: Filter aus den
/// getrackten Master-Tradern bauen, auf Trader-Listen-Änderungen re-subscriben, Health spiegeln.
/// </summary>
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
var wssUrl = _settings.PolygonRpcUrl.Replace("https://", "wss://").Replace("http://", "ws://");
_logger.Info($"Connecting to Alchemy WebSocket: {wssUrl.Substring(0, Math.Min(35, wssUrl.Length))}...");
await ws.ConnectAsync(new Uri(wssUrl), stoppingToken);
_state.IsAlchemyHealthy = true;
_logger.Info("✅ Alchemy WSS Connected. Dynamic API-Throttling activated.");
var activeTraders = _copyState.Traders.Values.Where(t => t.IsActive).ToList();
var activeStateHash = string.Join(",", activeTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
if (activeTraders.Count > 0)
{
var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
int batchSize = 3; // Alchemy limits Topic arrays to a max of 3/4 entries
int reqId = 1;
for (int i = 0; i < paddedAddresses.Count; i += batchSize)
{
var chunk = paddedAddresses.Skip(i).Take(batchSize).ToList();
var addrJson = JsonSerializer.Serialize(chunk);
// Buys: Master Trader is the receiver (Topic 3)
var subscribeBuysStr = $@"{{
""jsonrpc"": ""2.0"",
""id"": {reqId++},
""method"": ""eth_subscribe"",
""params"": [
""logs"",
{{
""address"": ""{CtfContractAddress}"",
""topics"": [
[""{TransferSingleTopic}"", ""{TransferBatchTopic}""],
null,
null,
{addrJson}
]
}}
]
}}";
// Sells: Master Trader is the sender (Topic 2)
var subscribeSellsStr = $@"{{
""jsonrpc"": ""2.0"",
""id"": {reqId++},
""method"": ""eth_subscribe"",
""params"": [
""logs"",
{{
""address"": ""{CtfContractAddress}"",
""topics"": [
[""{TransferSingleTopic}"", ""{TransferBatchTopic}""],
null,
{addrJson}
]
}}
]
}}";
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribeBuysStr)), WebSocketMessageType.Text, true, stoppingToken);
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribeSellsStr)), WebSocketMessageType.Text, true, stoppingToken);
}
}
else
var subscription = BuildSubscription(activeTraders);
if (subscription.Filters.Count == 0)
{
_logger.Info("Keine aktiven Master-Trader. Socket läuft im Standby...");
_state.IsAlchemyHealthy = false;
await Task.Delay(5000, stoppingToken);
return;
}
var buffer = new byte[1024 * 64];
var client = _wssFactory.Create();
_logger.Info("Connecting to Alchemy WebSocket (Core WSS client)...");
var loopCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var monitorTask = Task.Run(async () =>
using var loopCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
// Überwacht Trader-Listen-Änderungen (Re-Subscribe) und spiegelt das Health-Flag.
var monitorTask = Task.Run(async () =>
{
while (!loopCts.IsCancellationRequested)
{
await Task.Delay(5000, loopCts.Token);
try { await Task.Delay(5000, loopCts.Token); }
catch (OperationCanceledException) { break; }
_state.IsAlchemyHealthy = client.IsHealthy;
var currentTraders = _copyState.Traders.Values.Where(t => t.IsActive).ToList();
var currentHash = string.Join(",", currentTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
if (currentHash != activeStateHash)
@@ -181,49 +135,43 @@ namespace PolyTraderSharp.Services
try
{
while (ws.State == WebSocketState.Open && !loopCts.IsCancellationRequested)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), loopCts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
try
{
ProcessMessage(message);
}
catch (Exception ex)
{
_logger.Error($"Error parsing WSS msg: {ex.Message}");
}
}
await client.ConnectAndListenAsync(_settings.PolygonRpcUrl, subscription, OnLog, loopCts.Token);
}
catch (OperationCanceledException)
{
// Expected when reconnecting due to trader list change
// Erwartet beim Re-Subscribe (Trader-Liste geändert) oder beim Shutdown.
}
finally
{
_state.IsAlchemyHealthy = false;
if (!loopCts.IsCancellationRequested) loopCts.Cancel();
}
}
private void ProcessMessage(string jsonStr)
private BlockchainWssSubscription BuildSubscription(List<TrackedTrader> activeTraders)
{
using var doc = JsonDocument.Parse(jsonStr);
var root = doc.RootElement;
var subscription = new BlockchainWssSubscription();
var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
var topic0 = new List<string> { TransferSingleTopic, TransferBatchTopic };
if (!root.TryGetProperty("params", out var paramsEl)) return;
if (!paramsEl.TryGetProperty("result", out var resultEl)) return;
if (!resultEl.TryGetProperty("topics", out var topicsEl) || topicsEl.GetArrayLength() < 4) return;
const int batchSize = 3; // Alchemy begrenzt Topic-Arrays auf max. 3-4 Einträge
for (int i = 0; i < paddedAddresses.Count; i += batchSize)
{
var chunk = paddedAddresses.Skip(i).Take(batchSize).ToList();
// Buys: Master-Trader ist Empfänger (Topic 3)
subscription.Filters.Add(new LogSubscriptionFilter { Address = CtfContractAddress, Topic0 = topic0, Topic3 = chunk });
// Sells: Master-Trader ist Sender (Topic 2)
subscription.Filters.Add(new LogSubscriptionFilter { Address = CtfContractAddress, Topic0 = topic0, Topic2 = chunk });
}
return subscription;
}
var topics = topicsEl.EnumerateArray().Select(t => t.GetString()).ToList();
var fromTopic = topics[2]?.ToLowerInvariant();
var toTopic = topics[3]?.ToLowerInvariant();
private void OnLog(BlockchainLogEvent evt)
{
if (evt.Topics.Count < 4) return;
var fromTopic = evt.Topics[2]?.ToLowerInvariant();
var toTopic = evt.Topics[3]?.ToLowerInvariant();
if (fromTopic == null || toTopic == null) return;
var activeTraders = _copyState.Traders.Values.Where(t => t.IsActive).ToList();
@@ -241,8 +189,7 @@ namespace PolyTraderSharp.Services
if (!string.IsNullOrEmpty(triggeredAddress))
{
string txHash = resultEl.TryGetProperty("transactionHash", out var th) ? th.GetString() ?? "unknown" : "unknown";
_traderMonitor.TriggerFastBlockchainPoll(txHash, _settings.PolygonRpcUrl, triggeredAddress);
_traderMonitor.TriggerFastBlockchainPoll(evt.TransactionHash, _settings.PolygonRpcUrl, triggeredAddress);
}
}