diff --git a/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events.meta b/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events.meta new file mode 100644 index 000000000..ed6f3c3db --- /dev/null +++ b/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f9fd123f0bc24231b09e4b95a69c07a9 +timeCreated: 1727174219 \ No newline at end of file diff --git a/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events/EventExtensionsUnity.cs b/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events/EventExtensionsUnity.cs new file mode 100644 index 000000000..6023af9a7 --- /dev/null +++ b/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events/EventExtensionsUnity.cs @@ -0,0 +1,35 @@ +using ChainSafe.Gaming.RPC.Events; +using ChainSafe.Gaming.Web3.Build; + +namespace ChainSafe.Gaming.EVM.Events +{ + public static class EventExtensionsUnity + { + /// + /// Enable EVM Event Manager for the Web3 client. + /// + /// The Web3 services collection. + /// (Optional) Event Poller configuration. This will only be used for the WebGL platform. + /// + /// + /// For all the platforms that support multithreading the WebSocket strategy will be used. + /// In WebGL this will bind the Polling strategy which will fetch data periodically. + /// + public static IWeb3ServiceCollection UseEvents(this IWeb3ServiceCollection services, PollingEventManagerConfig eventPollerConfig = null) + { +#if !UNITY_WEBGL + services.UseEventsWithWebSocket(); +#else + if (eventPollerConfig == null) + { + services.UseEventsWithPolling(); + } + else + { + services.UseEventsWithPolling(eventPollerConfig); + } +#endif + return services; + } + } +} \ No newline at end of file diff --git a/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events/EventExtensionsUnity.cs.meta b/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events/EventExtensionsUnity.cs.meta new file mode 100644 index 000000000..edf0a4756 --- /dev/null +++ b/Packages/io.chainsafe.web3-unity/Runtime/Scripts/EVM/Events/EventExtensionsUnity.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 45634a8f8b564bdba2d7040fa7116ae0 +timeCreated: 1727174273 \ No newline at end of file diff --git a/src/ChainSafe.Gaming.Mud/MudExtensions.cs b/src/ChainSafe.Gaming.Mud/MudExtensions.cs index 50f707421..5d0a38f73 100644 --- a/src/ChainSafe.Gaming.Mud/MudExtensions.cs +++ b/src/ChainSafe.Gaming.Mud/MudExtensions.cs @@ -57,11 +57,6 @@ public static IWeb3ServiceCollection UseMud(this IWeb3ServiceCollection services // Storage strategies services.AddTransient(); // todo implement OffchainIndexerMudStorage, then register it in the next line - if (!services.IsBound()) - { - services.UseEvents(); - } - if (!services.IsBound()) { services.UseNethereumAdapters(); diff --git a/src/ChainSafe.Gaming.Mud/MudFacade.cs b/src/ChainSafe.Gaming.Mud/MudFacade.cs index dc4503e0d..a7b793bc1 100644 --- a/src/ChainSafe.Gaming.Mud/MudFacade.cs +++ b/src/ChainSafe.Gaming.Mud/MudFacade.cs @@ -28,7 +28,7 @@ public Task BuildWorld(IMudWorldConfig worldConfig) { var stopwatch = Stopwatch.StartNew(); var world = worldFactory.Build(worldConfig); - logWriter.Log($"Loaded world {worldConfig.ContractAddress} in {stopwatch.Elapsed}"); + logWriter.Log($"Loaded MUD world {worldConfig.ContractAddress} in {stopwatch.Elapsed}"); return world; } } diff --git a/src/ChainSafe.Gaming.Mud/Storages/InMemory/InMemoryMudStorage.cs b/src/ChainSafe.Gaming.Mud/Storages/InMemory/InMemoryMudStorage.cs index 476de062c..6fe3705d4 100644 --- a/src/ChainSafe.Gaming.Mud/Storages/InMemory/InMemoryMudStorage.cs +++ b/src/ChainSafe.Gaming.Mud/Storages/InMemory/InMemoryMudStorage.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using ChainSafe.Gaming.Mud.Tables; using ChainSafe.Gaming.RPC.Events; +using ChainSafe.Gaming.Web3; using ChainSafe.Gaming.Web3.Core.Nethereum; using Nethereum.Hex.HexConvertors.Extensions; using Nethereum.Mud; @@ -18,25 +19,34 @@ namespace ChainSafe.Gaming.Mud.Storages.InMemory public class InMemoryMudStorage : IMudStorage { private readonly INethereumWeb3Adapter nWeb3; - private readonly EventManager eventManager; + private readonly IEventManager eventManager; private readonly SemaphoreSlim storeUpdateSemaphore = new(1); private IInMemoryMudStorageConfig config; private InMemoryTableRepository inMemoryRepository; + private string worldAddress; - public InMemoryMudStorage(INethereumWeb3Adapter nWeb3, EventManager eventManager) + public InMemoryMudStorage(INethereumWeb3Adapter nWeb3, IEventManager eventManager) { this.eventManager = eventManager; this.nWeb3 = nWeb3; } + public InMemoryMudStorage(INethereumWeb3Adapter nWeb3) + { + throw new Web3Exception($"{nameof(InMemoryMudStorage)} requires {nameof(IEventManager)} to work. " + + $"Please don't forget to register an {nameof(IEventManager)} in order to use " + + $"{nameof(InMemoryMudStorage)}."); + } + public event RecordSetDelegate RecordSet; public event RecordDeletedDelegate RecordDeleted; public async Task Initialize(IMudStorageConfig mudStorageConfig, string worldAddress) { + this.worldAddress = worldAddress; config = (IInMemoryMudStorageConfig)mudStorageConfig; inMemoryRepository = new InMemoryTableRepository(); var storeLogProcessingService = new StoreEventsLogProcessingService(nWeb3, worldAddress); @@ -46,18 +56,18 @@ await storeLogProcessingService.ProcessAllStoreChangesAsync( null, CancellationToken.None); - await eventManager.Subscribe(OnStoreSetRecord); - await eventManager.Subscribe(OnStoreSpliceStaticData); - await eventManager.Subscribe(OnStoreSpliceDynamicDataEventDTO); - await eventManager.Subscribe(OnStoreDeleteRecord); + await eventManager.Subscribe(OnStoreSetRecord, worldAddress); + await eventManager.Subscribe(OnStoreSpliceStaticData, worldAddress); + await eventManager.Subscribe(OnStoreSpliceDynamicDataEventDTO, worldAddress); + await eventManager.Subscribe(OnStoreDeleteRecord, worldAddress); } public async Task Terminate() { - await eventManager.Unsubscribe(OnStoreSetRecord); - await eventManager.Unsubscribe(OnStoreSpliceStaticData); - await eventManager.Unsubscribe(OnStoreSpliceDynamicDataEventDTO); - await eventManager.Unsubscribe(OnStoreDeleteRecord); + await eventManager.Unsubscribe(OnStoreSetRecord, worldAddress); + await eventManager.Unsubscribe(OnStoreSpliceStaticData, worldAddress); + await eventManager.Unsubscribe(OnStoreSpliceDynamicDataEventDTO, worldAddress); + await eventManager.Unsubscribe(OnStoreDeleteRecord, worldAddress); } public async Task Query(MudTableSchema tableSchema, MudQuery query) diff --git a/src/ChainSafe.Gaming/RPC/Events/EventExtensions.cs b/src/ChainSafe.Gaming/RPC/Events/EventExtensions.cs index 63d42c495..1132728f8 100644 --- a/src/ChainSafe.Gaming/RPC/Events/EventExtensions.cs +++ b/src/ChainSafe.Gaming/RPC/Events/EventExtensions.cs @@ -1,16 +1,42 @@ using ChainSafe.Gaming.Web3.Build; using ChainSafe.Gaming.Web3.Core; using ChainSafe.Gaming.Web3.Core.Chains; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace ChainSafe.Gaming.RPC.Events { public static class EventExtensions { - public static IWeb3ServiceCollection UseEvents(this IWeb3ServiceCollection services) + public static IWeb3ServiceCollection UseEventsWithWebSocket(this IWeb3ServiceCollection services) { - // todo bind EventPoller implementation of IEventManager when running in WebGL build - return services - .AddSingleton(); + services.AssertServiceNotBound(); + services.AddSingleton(); + return services; + } + + public static IWeb3ServiceCollection UseEventsWithPolling(this IWeb3ServiceCollection services) + { + services.AssertServiceNotBound(); + services.AddSingleton(); + return services; + } + + public static IWeb3ServiceCollection UseEventsWithPolling(this IWeb3ServiceCollection services, PollingEventManagerConfig config) + { + services.AssertServiceNotBound(); + services.AssertConfigurationNotBound(); + + services.ConfigureEventsWithPolling(config); + services.UseEventsWithPolling(); + + return services; + } + + public static IWeb3ServiceCollection ConfigureEventsWithPolling(this IWeb3ServiceCollection services, PollingEventManagerConfig pollingConfig) + { + services.Replace(ServiceDescriptor.Singleton(pollingConfig)); + return services; } } } \ No newline at end of file diff --git a/src/ChainSafe.Gaming/RPC/Events/IEventManager.cs b/src/ChainSafe.Gaming/RPC/Events/IEventManager.cs index 00f6bc192..0af77e5ca 100644 --- a/src/ChainSafe.Gaming/RPC/Events/IEventManager.cs +++ b/src/ChainSafe.Gaming/RPC/Events/IEventManager.cs @@ -6,10 +6,10 @@ namespace ChainSafe.Gaming.RPC.Events { public interface IEventManager { - Task Subscribe(Action handler) + Task Subscribe(Action handler, params string[] contractAddresses) where TEvent : IEventDTO, new(); - Task Unsubscribe(Action handler) + Task Unsubscribe(Action handler, params string[] contractAddresses) where TEvent : IEventDTO, new(); } } \ No newline at end of file diff --git a/src/ChainSafe.Gaming/RPC/Events/PollingEventManager.cs b/src/ChainSafe.Gaming/RPC/Events/PollingEventManager.cs new file mode 100644 index 000000000..d57081c92 --- /dev/null +++ b/src/ChainSafe.Gaming/RPC/Events/PollingEventManager.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using ChainSafe.Gaming.Evm.Providers; +using ChainSafe.Gaming.Web3; +using ChainSafe.Gaming.Web3.Core.Chains; +using ChainSafe.Gaming.Web3.Environment; +using Nethereum.ABI.FunctionEncoding.Attributes; +using Nethereum.Contracts; +using Nethereum.Hex.HexTypes; +using Nethereum.RPC.Eth.DTOs; + +namespace ChainSafe.Gaming.RPC.Events +{ + public class PollingEventManager : IEventManager, IChainSwitchHandler + { + private readonly Dictionary<(Type, string[]), Subscription> subscriptions = new(); + private readonly ILogWriter logWriter; + private readonly IRpcProvider rpcProvider; + private readonly PollingEventManagerConfig config; + + private CancellationTokenSource pollLoopCts; + private ulong lastBlockNumber; + + public PollingEventManager(ILogWriter logWriter, IRpcProvider rpcProvider) + : this(new PollingEventManagerConfig(), rpcProvider, logWriter) + { + } + + public PollingEventManager(PollingEventManagerConfig config, IRpcProvider rpcProvider, ILogWriter logWriter) + { + this.rpcProvider = rpcProvider; + this.config = config; + this.logWriter = logWriter; + } + + private bool PollingLoopActive => pollLoopCts != null; + + public Task Subscribe(Action handler, params string[] contractAddresses) + where TEvent : IEventDTO, new() + { + var key = (typeof(TEvent), contractAddresses); + if (!subscriptions.ContainsKey(key)) + { + subscriptions[key] = new Subscription(contractAddresses); + } + + var subscription = (Subscription)subscriptions[key]; + subscription.Handlers.Add(handler); + + if (!PollingLoopActive) + { + SetPollLoopState(true); + } + + return Task.CompletedTask; + } + + public Task Unsubscribe(Action handler, params string[] contractAddresses) + where TEvent : IEventDTO, new() + { + var key = (typeof(TEvent), contractAddresses); + if (!subscriptions.TryGetValue(key, out var rawSubscription)) + { + throw new Web3Exception(contractAddresses.Length == 0 + ? $"Tried unsubscribing but was not subscribed. Event type: \"{typeof(TEvent).Name}\"." + : $"Tried unsubscribing but was not subscribed. Event type: \"{typeof(TEvent).Name}\". " + + $"Contract address filter: {string.Join(", ", contractAddresses)}"); + } + + var subscription = (Subscription)rawSubscription; + + if (!subscription.Handlers.Contains(handler)) + { + throw new Web3Exception( + $"Tried unsubscribing but the handler was not subscribed for this event type. " + + $"Event type \"{typeof(TEvent).Name}\""); + } + + subscription.Handlers.Remove(handler); + + if (subscription.Handlers.Count == 0) + { + subscriptions.Remove(key); + } + + if (subscriptions.Count == 0) + { + SetPollLoopState(false); + } + + return Task.CompletedTask; + } + + public Task HandleChainSwitching() + { + if (!PollingLoopActive) + { + return Task.CompletedTask; + } + + SetPollLoopState(false); + SetPollLoopState(true); // restart the polling loop + + return Task.CompletedTask; + } + + private void SetPollLoopState(bool enabled) + { + switch (enabled) + { + case true when pollLoopCts == null: + logWriter.Log("Starting event polling loop"); + pollLoopCts = new(); + RunPollLoop(pollLoopCts.Token); + break; + + case false when pollLoopCts != null: + // This will eventually cause the poll loop to stop. + // Note that restarting the poll loop will make a new task + // with a new cancellation token source and will not interfere + // with the one we're stopping here. + logWriter.Log("Stopping event polling loop"); + pollLoopCts.Cancel(); + pollLoopCts = null; + break; + } + } + + private async void RunPollLoop(CancellationToken cancellationToken) + { + lastBlockNumber = await FetchCurrentBlockNumber(); + + while (!cancellationToken.IsCancellationRequested) + { + // Since Poll is async, we can't just wait one second. + // Also, we can't just ignore the result of Poll, since + // we don't want to have multiple poll operations happening + // at the same time if the network is unstable and polls + // take longer than PollInterval. So we measure the time + // we would like the next poll to happen before starting + // the current poll. + var nextPollTime = DateTime.Now + config.PollInterval; + + await TickPoll(); + + var now = DateTime.Now; + if (now >= nextPollTime) + { + continue; // skip delay + } + + try + { + await Task.Delay(nextPollTime - now, cancellationToken); + } + catch (TaskCanceledException) + { + // ignore + } + } + } + + private async Task TickPoll() + { + var currentBlockNumber = await FetchCurrentBlockNumber(); + + if (currentBlockNumber == lastBlockNumber) + { + return; + } + + if (currentBlockNumber < lastBlockNumber) + { + throw new Web3Exception( + $"New block number ({currentBlockNumber}) is lower than the last registered ({lastBlockNumber}). " + + "This should never happen."); + } + + var fromBlockNumber = lastBlockNumber; + var toBlockNumber = currentBlockNumber; + lastBlockNumber = currentBlockNumber; + + foreach (var subscription in subscriptions.Values) + { + await subscription.ParseAndRaiseEvents(this, fromBlockNumber, toBlockNumber); + } + } + + private async Task FetchCurrentBlockNumber() => (await rpcProvider.GetBlockNumber()).ToUlong(); + + private abstract class Subscription + { + public abstract Task ParseAndRaiseEvents(PollingEventManager manager, ulong fromBlock, ulong toBlock); + } + + private class Subscription : Subscription + where TEvent : IEventDTO, new() + { + private readonly string[] contractAddresses; + private readonly object topicFilter; + + public Subscription(string[] contractAddresses) + { + this.contractAddresses = contractAddresses; + topicFilter = Nethereum.Contracts.Event.GetEventABI().GetTopicBuilder().GetSignatureTopic(); + } + + public List> Handlers { get; } = new(); + + public override async Task ParseAndRaiseEvents(PollingEventManager manager, ulong fromBlock, ulong toBlock) + { + var logs = await manager.rpcProvider.GetLogs(new NewFilterInput + { + FromBlock = new BlockParameter(new HexBigInteger(new BigInteger(fromBlock + 1))), // skipping one block as it should already be processed + ToBlock = new BlockParameter(new HexBigInteger(new BigInteger(toBlock))), + Address = contractAddresses, + Topics = new[] { topicFilter }, + }); + + foreach (var log in logs) + { + EventLog decoded; + try + { + decoded = Nethereum.Contracts.Event.DecodeEvent(log); + } + catch (Exception ex) + { + throw new Web3Exception($"There was an error processing event log data for event type {nameof(TEvent)}.", ex); + } + + foreach (var handler in Handlers) + { + try + { + handler(decoded.Event); + } + catch (Exception e) + { + manager.logWriter.LogError($"Error occured in one of the {nameof(TEvent)} handlers: {e.Message}\n{e.StackTrace}"); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPollerConfiguration.cs b/src/ChainSafe.Gaming/RPC/Events/PollingEventManagerConfig.cs similarity index 55% rename from src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPollerConfiguration.cs rename to src/ChainSafe.Gaming/RPC/Events/PollingEventManagerConfig.cs index 7f0409ba6..e274bb8ca 100644 --- a/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPollerConfiguration.cs +++ b/src/ChainSafe.Gaming/RPC/Events/PollingEventManagerConfig.cs @@ -1,8 +1,8 @@ using System; -namespace ChainSafe.Gaming.Web3.Evm.EventPoller +namespace ChainSafe.Gaming.RPC.Events { - public class EventPollerConfiguration + public class PollingEventManagerConfig { public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(10); } diff --git a/src/ChainSafe.Gaming/RPC/Events/EventManager.cs b/src/ChainSafe.Gaming/RPC/Events/WebSocketEventManager.cs similarity index 77% rename from src/ChainSafe.Gaming/RPC/Events/EventManager.cs rename to src/ChainSafe.Gaming/RPC/Events/WebSocketEventManager.cs index 1294e3eda..c6b8082df 100644 --- a/src/ChainSafe.Gaming/RPC/Events/EventManager.cs +++ b/src/ChainSafe.Gaming/RPC/Events/WebSocketEventManager.cs @@ -14,15 +14,15 @@ namespace ChainSafe.Gaming.RPC.Events { - public class EventManager : IEventManager, ILifecycleParticipant, IChainSwitchHandler + public class WebSocketEventManager : IEventManager, ILifecycleParticipant, IChainSwitchHandler { private readonly IChainConfig chainConfig; - private readonly Dictionary subscriptions = new(); + private readonly Dictionary<(Type, string[]), Subscription> subscriptions = new(); private readonly ILogWriter logWriter; private StreamingWebSocketClient webSocketClient; - public EventManager(IChainConfig chainConfig, ILogWriter logWriter) + public WebSocketEventManager(IChainConfig chainConfig, ILogWriter logWriter) { this.logWriter = logWriter; this.chainConfig = chainConfig; @@ -43,39 +43,42 @@ public async ValueTask WillStopAsync() { for (var i = subscriptions.Count - 1; i >= 0; i--) { - var type = subscriptions.Last().Key; - await TerminateSubscriptionForType(type); + var (type, addresses) = subscriptions.Last().Key; + await TerminateSubscriptionForType(type, addresses); } webSocketClient?.Dispose(); } - public async Task Subscribe(Action handler) + public async Task Subscribe(Action handler, params string[] contractAddresses) where TEvent : IEventDTO, new() { - if (!subscriptions.TryGetValue(typeof(TEvent), out var rawSubscription)) + if (!subscriptions.TryGetValue((typeof(TEvent), contractAddresses), out var rawSubscription)) { - rawSubscription = await InitializeSubscriptionForType(); + rawSubscription = await InitializeSubscriptionForType(contractAddresses); } var subscription = (Subscription)rawSubscription; subscription.Handlers.Add(handler); } - public async Task Unsubscribe(Action handler) + public async Task Unsubscribe(Action handler, params string[] contractAddresses) where TEvent : IEventDTO, new() { - if (!subscriptions.ContainsKey(typeof(TEvent))) + if (!subscriptions.ContainsKey((typeof(TEvent), contractAddresses))) { - throw new Web3Exception($"Can't unsubscribe. No subscription of type {nameof(TEvent)} was registered."); + throw new Web3Exception(contractAddresses.Length == 0 + ? $"Can't unsubscribe. No subscription of type {nameof(TEvent)} was registered." + : $"Can't unsubscribe. No subscription of type {nameof(TEvent)} was registered with contract filter " + + $"addresses: {string.Join(", ", contractAddresses)}."); } - var subscription = (Subscription)subscriptions[typeof(TEvent)]; + var subscription = (Subscription)subscriptions[(typeof(TEvent), contractAddresses)]; subscription.Handlers.Remove(handler); if (subscription.Handlers.Count == 0) { - await TerminateSubscriptionForType(); + await TerminateSubscriptionForType(contractAddresses); } } @@ -113,11 +116,11 @@ async Task IChainSwitchHandler.HandleChainSwitching() } } - private async Task InitializeSubscriptionForType() + private async Task InitializeSubscriptionForType(string[] contractAddresses) where TEvent : IEventDTO, new() { Subscription rawSubscription = new Subscription(webSocketClient); - rawSubscription.EventFilter = Event.GetEventABI().CreateFilterInput(); + rawSubscription.EventFilter = Event.GetEventABI().CreateFilterInput(contractAddresses); rawSubscription.LogHandleAction = HandleLog; rawSubscription @@ -127,7 +130,7 @@ private async Task InitializeSubscriptionForType() await rawSubscription.NethSubscription.SubscribeAsync(rawSubscription.EventFilter); - subscriptions[typeof(TEvent)] = rawSubscription; + subscriptions[(typeof(TEvent), contractAddresses)] = rawSubscription; return rawSubscription; void HandleLog(FilterLog log) @@ -151,22 +154,22 @@ void HandleLog(FilterLog log) } catch (Exception e) { - logWriter.LogError($"Error occured in one of the {nameof(TEvent)} handlers.\n{e.Message}\n{e.StackTrace}"); + logWriter.LogError($"Error occured in one of the {nameof(TEvent)} handlers: {e.Message}\n{e.StackTrace}"); } } } } - private Task TerminateSubscriptionForType() + private Task TerminateSubscriptionForType(string[] contractAddresses) where TEvent : IEventDTO, new() { - return TerminateSubscriptionForType(typeof(TEvent)); + return TerminateSubscriptionForType(typeof(TEvent), contractAddresses); } - private Task TerminateSubscriptionForType(Type type) + private Task TerminateSubscriptionForType(Type type, string[] contractAddresses) { - var subscription = subscriptions[type]; - subscriptions.Remove(type); + var subscription = subscriptions[(type, contractAddresses)]; + subscriptions.Remove((type, contractAddresses)); return subscription.NethSubscription.UnsubscribeAsync(); } diff --git a/src/ChainSafe.Gaming/Web3/Core/Build/Web3Builder.cs b/src/ChainSafe.Gaming/Web3/Core/Build/Web3Builder.cs index fbaa69868..b263512a1 100644 --- a/src/ChainSafe.Gaming/Web3/Core/Build/Web3Builder.cs +++ b/src/ChainSafe.Gaming/Web3/Core/Build/Web3Builder.cs @@ -6,7 +6,6 @@ using ChainSafe.Gaming.LocalStorage; using ChainSafe.Gaming.Web3.Core; using ChainSafe.Gaming.Web3.Core.Chains; -using ChainSafe.Gaming.Web3.Core.Evm.EventPoller; using ChainSafe.Gaming.Web3.Core.Logout; using ChainSafe.Gaming.Web3.Environment; using Microsoft.Extensions.DependencyInjection; @@ -26,7 +25,6 @@ private Web3Builder() // Bind default services serviceCollection - .UseEventPoller() // todo: remove, make a WebGL IEventManager implementation that utilizes Event Polling .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/ChainSafe.Gaming/Web3/Core/Web3.cs b/src/ChainSafe.Gaming/Web3/Core/Web3.cs index c3aaa7bea..c4019c413 100644 --- a/src/ChainSafe.Gaming/Web3/Core/Web3.cs +++ b/src/ChainSafe.Gaming/Web3/Core/Web3.cs @@ -6,6 +6,7 @@ using ChainSafe.Gaming.Evm.Providers; using ChainSafe.Gaming.Evm.Signers; using ChainSafe.Gaming.LocalStorage; +using ChainSafe.Gaming.RPC.Events; using ChainSafe.Gaming.Web3.Core; using ChainSafe.Gaming.Web3.Core.Chains; using ChainSafe.Gaming.Web3.Core.Evm; @@ -24,7 +25,7 @@ public class Web3 : IAsyncDisposable private readonly IRpcProvider? rpcProvider; private readonly ISigner? signer; private readonly ITransactionExecutor? transactionExecutor; - private readonly IEvmEvents? events; + private readonly IEventManager? events; private readonly ILogoutManager logoutManager; private readonly ILocalStorage localStorage; @@ -37,7 +38,7 @@ internal Web3(ServiceProvider serviceProvider) rpcProvider = serviceProvider.GetService(); signer = serviceProvider.GetService(); transactionExecutor = serviceProvider.GetService(); - events = serviceProvider.GetRequiredService(); + events = serviceProvider.GetService(); Chains = serviceProvider.GetRequiredService(); ContractBuilder = serviceProvider.GetRequiredService(); ProjectConfig = serviceProvider.GetRequiredService(); @@ -77,7 +78,7 @@ internal Web3(ServiceProvider serviceProvider) /// /// Access the Event Service of the Web3 instance, allowing you to subscribe to blockchain events. /// - public IEvmEvents Events => AssertComponentAccessible(events, nameof(Events)); + public IEventManager Events => AssertComponentAccessible(events, nameof(Events)); /// /// Access the Chain Manager of the Web3 instance to switch chains in runtime. diff --git a/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPoller.cs b/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPoller.cs deleted file mode 100644 index 68b4e6200..000000000 --- a/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPoller.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ChainSafe.Gaming.Evm.Network; -using ChainSafe.Gaming.Evm.Providers; -using ChainSafe.Gaming.Web3.Environment; -using ChainSafe.Gaming.Web3.Evm.EventPoller; -using Nethereum.Hex.HexTypes; - -namespace ChainSafe.Gaming.Web3.Core.Evm.EventPoller -{ - internal class EventPoller : IEvmEvents // todo: transform into IEventManager and parse logs for events - { - private readonly EventPollerConfiguration config; - private readonly IRpcProvider rpcProvider; - private readonly Web3Environment environment; - - private IEvmEvents.PollDelegate poll; - private IEvmEvents.NewBlockDelegate newBlock; - - private CancellationTokenSource pollLoopCts; - private ulong nextPollId = 1; - - private ulong blockNumber; - private ulong reportedBlock; - private DateTime blockUpdateTime; - - public EventPoller(EventPollerConfiguration config, IRpcProvider rpcProvider, Web3Environment environment) - { - this.config = config; - this.rpcProvider = rpcProvider; - this.environment = environment; - } - - public event IEvmEvents.PollErrorDelegate PollError; - - public event IEvmEvents.PollDelegate Poll - { - add - { - poll += value; - PollableHandlerAdded(); - } - - remove - { - poll -= value; - PollableHandlerRemoved(); - } - } - - public event IEvmEvents.NewBlockDelegate NewBlock - { - add - { - newBlock += value; - PollableHandlerAdded(); - } - - remove - { - newBlock -= value; - PollableHandlerRemoved(); - } - } - - private MulticastDelegate[] AllPollableDelegates() => - new MulticastDelegate[] - { - poll, - newBlock, - }; - - private void PollableHandlerAdded() - { - if (AllPollableDelegates().Any(d => d != null && d.GetInvocationList().Length > 0)) - { - SetPollLoopState(true); - } - } - - private void PollableHandlerRemoved() - { - if (AllPollableDelegates().All(d => d != null && d.GetInvocationList().Length == 0)) - { - SetPollLoopState(false); - } - } - - private void SetPollLoopState(bool enabled) - { - switch (enabled) - { - case true when pollLoopCts == null: - environment.LogWriter.Log("Starting event poll loop"); - pollLoopCts = new(); - RunPollLoop(pollLoopCts.Token); - break; - - case false when pollLoopCts != null: - // This will eventually cause the poll loop to stop. - // Note that restarting the poll loop will make a new task - // with a new cancellation token source and will not interfere - // with the one we're stopping here. - environment.LogWriter.Log("Stopping event poll loop"); - pollLoopCts.Cancel(); - pollLoopCts = null; - break; - } - } - - private async void RunPollLoop(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - // Since Poll is async, we can't just wait one second. - // Also, we can't just ignore the result of Poll, since - // we don't want to have multiple poll operations happening - // at the same time if the network is unstable and polls - // take longer than PollInterval. So we measure the time - // we would like the next poll to happen before starting - // the current poll. - var nextPollTime = DateTime.Now + config.PollInterval; - - await DoPoll(); - - var now = DateTime.Now; - if (now < nextPollTime) - { - await Task.Delay(nextPollTime - now); - } - } - } - - private async Task GetBlockNumber(TimeSpan maxAge) - { - // Allowing stale data up to maxAge old - if (maxAge > TimeSpan.Zero && blockUpdateTime > DateTime.MinValue) - { - if (DateTime.Now - blockUpdateTime <= maxAge) - { - return blockNumber; - } - } - - var newBlock = (await rpcProvider.GetBlockNumber()).ToUlong(); - if (newBlock < blockNumber) - { - newBlock = blockNumber; - } - - blockUpdateTime = DateTime.Now; - - return newBlock; - } - - private async Task DoPoll() - { - var pollId = nextPollId++; - - try - { - blockNumber = await GetBlockNumber(config.PollInterval / 2); - } - catch (Exception e) - { - environment.LogWriter.LogError(e.ToString()); - PollError?.Invoke(e); - return; - } - - poll?.Invoke(pollId, blockNumber); - - if (reportedBlock == 0) - { - reportedBlock = blockNumber; - newBlock?.Invoke(reportedBlock); - } - else if (reportedBlock > blockNumber || reportedBlock < blockNumber - 1000) - { - PollError?.Invoke(new Exception("network block skew detected")); - newBlock?.Invoke(blockNumber); - } - else - { - for (var i = reportedBlock + 1; i <= blockNumber; i++) - { - newBlock?.Invoke(i); - } - } - } - } -} diff --git a/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPollerExtensions.cs b/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPollerExtensions.cs deleted file mode 100644 index ea15ff20d..000000000 --- a/src/ChainSafe.Gaming/Web3/Evm/EventPoller/EventPollerExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ChainSafe.Gaming.Web3.Build; -using ChainSafe.Gaming.Web3.Evm.EventPoller; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace ChainSafe.Gaming.Web3.Core.Evm.EventPoller -{ - public static class EventPollerExtensions - { - internal static IWeb3ServiceCollection UseEventPoller(this IWeb3ServiceCollection services) - { - return services - .AddSingleton() - .AddSingleton() - as IWeb3ServiceCollection; - } - - public static IWeb3ServiceCollection ConfigureEventPoller(this IWeb3ServiceCollection services, EventPollerConfiguration eventPollerConfiguration) - { - return services - .Replace(ServiceDescriptor.Singleton(eventPollerConfiguration)) - as IWeb3ServiceCollection; - } - } -} \ No newline at end of file diff --git a/src/UnitySampleProject/Assets/Samples/web3.unity SDK MUD/3.0/MudSample.cs b/src/UnitySampleProject/Assets/Samples/web3.unity SDK MUD/3.0/MudSample.cs index cedbb93cb..2a1ff3ecc 100644 --- a/src/UnitySampleProject/Assets/Samples/web3.unity SDK MUD/3.0/MudSample.cs +++ b/src/UnitySampleProject/Assets/Samples/web3.unity SDK MUD/3.0/MudSample.cs @@ -1,12 +1,15 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using ChainSafe.Gaming.Debugging; +using ChainSafe.Gaming.EVM.Events; using ChainSafe.Gaming.Evm.JsonRpc; using ChainSafe.Gaming.Mud; using ChainSafe.Gaming.Mud.Tables; using ChainSafe.Gaming.Mud.Unity; using ChainSafe.Gaming.Mud.Worlds; +using ChainSafe.Gaming.RPC.Events; using ChainSafe.Gaming.UnityPackage; using ChainSafe.Gaming.Wallets; using ChainSafe.Gaming.Web3; @@ -34,12 +37,16 @@ private async void Awake() web3 = await new Web3Builder(ProjectConfigUtilities.Load(), ProjectConfigUtilities.BuildLocalhostConfig()) .Configure(services => { + // Enable basic components services.UseUnityEnvironment(); - services.UseRpcProvider(); - + services.UseRpcProvider(); + // Initializes Wallet as the first account of the locally running Ethereum Node (Anvil). services.Debug().UseJsonRpcWallet(new JsonRpcWalletConfig { AccountIndex = 0 }); - + + // Enable Events as MUD requires them + services.UseEvents(new PollingEventManagerConfig { PollInterval = TimeSpan.FromSeconds(1)}); // the config is only being used for WebGL platform; 1 second poll interval is extremely fast, consider using longer interval in production so that your RPC endpoint doesn't get too overwhelmed + // Enable MUD services.UseMud(mudConfig); }) @@ -62,7 +69,7 @@ private async void Awake() { new("value", "uint32"), }, - KeyColumns = new string[0], // empty - singleton table + KeyColumns = new string[0], // empty key schema - singleton table (one record only) }, }, });