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:
co-authored by
Claude Opus 4.8
parent
7e189fec54
commit
3be75c0f05
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
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;
|
||||
|
||||
namespace PolyTrader.Core.Streaming
|
||||
{
|
||||
/// <summary>
|
||||
/// EVM/Alchemy-WSS-Verbindungsklasse (eth_subscribe "logs"). Domänen-agnostisch:
|
||||
/// Contract/Topics/Adress-Filter kommen vom Aufrufer über die Subscription.
|
||||
/// </summary>
|
||||
public class AlchemyWssClient : IBlockchainWssClient
|
||||
{
|
||||
public bool IsHealthy { get; private set; }
|
||||
|
||||
public async Task ConnectAndListenAsync(string rpcUrl, BlockchainWssSubscription subscription,
|
||||
Action<BlockchainLogEvent> onLog, CancellationToken ct)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
var wssUrl = rpcUrl.Replace("https://", "wss://").Replace("http://", "ws://");
|
||||
|
||||
await ws.ConnectAsync(new Uri(wssUrl), ct);
|
||||
IsHealthy = true;
|
||||
|
||||
try
|
||||
{
|
||||
int reqId = 1;
|
||||
foreach (var filter in subscription.Filters)
|
||||
{
|
||||
var payload = BuildSubscribePayload(reqId++, filter);
|
||||
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(payload)),
|
||||
WebSocketMessageType.Text, true, ct);
|
||||
}
|
||||
|
||||
var buffer = new byte[1024 * 64];
|
||||
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close) break;
|
||||
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
try
|
||||
{
|
||||
var evt = TryParseLog(message);
|
||||
if (evt != null) onLog(evt);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Resilient: eine fehlerhafte Nachricht oder ein Handler-Fehler
|
||||
// darf die Session nicht beenden (Verhalten wie zuvor).
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsHealthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSubscribePayload(int id, LogSubscriptionFilter f)
|
||||
{
|
||||
// Positionsbasiertes Topics-Array; trailing-Wildcards (null) werden abgeschnitten,
|
||||
// exakt wie im ursprünglichen Handbuild (Sells: 3 Positionen, Buys: 4).
|
||||
var positions = new List<string>
|
||||
{
|
||||
JsonSerializer.Serialize(f.Topic0),
|
||||
f.Topic1 != null ? JsonSerializer.Serialize(f.Topic1) : "null",
|
||||
f.Topic2 != null ? JsonSerializer.Serialize(f.Topic2) : "null",
|
||||
f.Topic3 != null ? JsonSerializer.Serialize(f.Topic3) : "null"
|
||||
};
|
||||
while (positions.Count > 1 && positions[^1] == "null") positions.RemoveAt(positions.Count - 1);
|
||||
|
||||
string topicsJson = "[" + string.Join(",", positions) + "]";
|
||||
return $@"{{""jsonrpc"":""2.0"",""id"":{id},""method"":""eth_subscribe"",""params"":[""logs"",{{""address"":""{f.Address}"",""topics"":{topicsJson}}}]}}";
|
||||
}
|
||||
|
||||
private static BlockchainLogEvent? TryParseLog(string jsonStr)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("params", out var paramsEl)) return null;
|
||||
if (!paramsEl.TryGetProperty("result", out var resultEl)) return null;
|
||||
if (!resultEl.TryGetProperty("topics", out var topicsEl)) return null;
|
||||
|
||||
var topics = topicsEl.EnumerateArray().Select(t => t.GetString()).ToList();
|
||||
string txHash = resultEl.TryGetProperty("transactionHash", out var th) ? th.GetString() ?? "" : "";
|
||||
string address = resultEl.TryGetProperty("address", out var ad) ? ad.GetString() ?? "" : "";
|
||||
string data = resultEl.TryGetProperty("data", out var dt) ? dt.GetString() ?? "" : "";
|
||||
|
||||
return new BlockchainLogEvent { Topics = topics, TransactionHash = txHash, Address = address, Data = data };
|
||||
}
|
||||
}
|
||||
|
||||
public class AlchemyWssClientFactory : IBlockchainWssClientFactory
|
||||
{
|
||||
public IBlockchainWssClient Create() => new AlchemyWssClient();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PolyTrader.Core.Streaming
|
||||
{
|
||||
/// <summary>
|
||||
/// Ein einzelner eth_subscribe("logs")-Filter. Positionsbasiert wie bei Alchemy:
|
||||
/// Topic0 = Event-Signaturen (OR-Array), Topic1..3 = indizierte Parameter
|
||||
/// (z.B. Adress-Filter). null an einer Position = Wildcard.
|
||||
/// </summary>
|
||||
public class LogSubscriptionFilter
|
||||
{
|
||||
public string Address { get; set; } = string.Empty;
|
||||
public List<string> Topic0 { get; set; } = new();
|
||||
public List<string>? Topic1 { get; set; }
|
||||
public List<string>? Topic2 { get; set; }
|
||||
public List<string>? Topic3 { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Beschreibt, was ein WSS-Client abonniert. Der Aufrufer (Modul) baut die Filter
|
||||
/// aus seinen eigenen Daten (z.B. Wallets der getrackten Master-Trader) und batcht sie.
|
||||
/// </summary>
|
||||
public class BlockchainWssSubscription
|
||||
{
|
||||
public List<LogSubscriptionFilter> Filters { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein dekodiertes Log-Event aus dem Stream (domänen-agnostisch).
|
||||
/// Die fachliche Auswertung (welche Adresse, welche Reaktion) macht der Aufrufer.
|
||||
/// </summary>
|
||||
public class BlockchainLogEvent
|
||||
{
|
||||
public IReadOnlyList<string?> Topics { get; init; } = Array.Empty<string?>();
|
||||
public string TransactionHash { get; init; } = string.Empty;
|
||||
public string Address { get; init; } = string.Empty;
|
||||
public string Data { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wiederverwendbare WSS-Verbindungsfähigkeit (Core-Infrastruktur). Kapselt Connect,
|
||||
/// eth_subscribe, Empfangs-Loop und Decode. Reconnect-Policy, Gating und der Filterinhalt
|
||||
/// liegen beim Aufrufer, damit jedes Modul eine eigene, gefilterte Instanz mit eigenem
|
||||
/// Key betreiben kann (kein geteilter, gefluteter Singleton-Stream).
|
||||
/// </summary>
|
||||
public interface IBlockchainWssClient
|
||||
{
|
||||
/// <summary>True während einer aktiven, verbundenen Session.</summary>
|
||||
bool IsHealthy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Baut EINE Session auf: verbindet, sendet die Subscriptions, liest bis Close/Cancel/Fehler
|
||||
/// und ruft <paramref name="onLog"/> je dekodiertem Log. Wirft bei Verbindungsfehlern —
|
||||
/// die Reconnect-Entscheidung trifft der Aufrufer.
|
||||
/// </summary>
|
||||
Task ConnectAndListenAsync(string rpcUrl, BlockchainWssSubscription subscription,
|
||||
Action<BlockchainLogEvent> onLog, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt pro Aufrufer/Modul eine eigene WSS-Client-Instanz.</summary>
|
||||
public interface IBlockchainWssClientFactory
|
||||
{
|
||||
IBlockchainWssClient Create();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user