diff --git a/.github/workflows/docker-rewarder.yml b/.github/workflows/docker-rewarder.yml new file mode 100644 index 00000000..a2bcfaa9 --- /dev/null +++ b/.github/workflows/docker-rewarder.yml @@ -0,0 +1,26 @@ +name: Docker - Rewarder Bot + +on: + push: + branches: + - master + tags: + - 'v*.*.*' + paths: + - 'Tools/TestNetRewarder/**' + - '!Tools/TestNetRewarder/docker/docker-compose.yaml' + - 'Framework/**' + - 'ProjectPlugins/**' + - .github/workflows/docker-rewarder.yml + - .github/workflows/docker-reusable.yml + workflow_dispatch: + +jobs: + build-and-push: + name: Build and Push + uses: ./.github/workflows/docker-reusable.yml + with: + docker_file: Tools/TestNetRewarder/docker/Dockerfile + docker_repo: codexstorage/codex-rewarderbot + secrets: inherit + diff --git a/Framework/DiscordRewards/CheckConfig.cs b/Framework/DiscordRewards/CheckConfig.cs new file mode 100644 index 00000000..9c4fccb0 --- /dev/null +++ b/Framework/DiscordRewards/CheckConfig.cs @@ -0,0 +1,21 @@ +using Utils; + +namespace DiscordRewards +{ + public class CheckConfig + { + public CheckType Type { get; set; } + public ulong MinNumberOfHosts { get; set; } + public ByteSize MinSlotSize { get; set; } = 0.Bytes(); + public TimeSpan MinDuration { get; set; } = TimeSpan.Zero; + } + + public enum CheckType + { + Uninitialized, + FilledSlot, + FinishedSlot, + PostedContract, + StartedContract, + } +} diff --git a/Framework/DiscordRewards/DiscordRewards.csproj b/Framework/DiscordRewards/DiscordRewards.csproj new file mode 100644 index 00000000..71c56d44 --- /dev/null +++ b/Framework/DiscordRewards/DiscordRewards.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + enable + enable + + + + + + + diff --git a/Framework/DiscordRewards/GiveRewardsCommand.cs b/Framework/DiscordRewards/GiveRewardsCommand.cs new file mode 100644 index 00000000..03bc835c --- /dev/null +++ b/Framework/DiscordRewards/GiveRewardsCommand.cs @@ -0,0 +1,13 @@ +namespace DiscordRewards +{ + public class GiveRewardsCommand + { + public RewardUsersCommand[] Rewards { get; set; } = Array.Empty(); + } + + public class RewardUsersCommand + { + public ulong RewardId { get; set; } + public string[] UserAddresses { get; set; } = Array.Empty(); + } +} diff --git a/Framework/DiscordRewards/RewardConfig.cs b/Framework/DiscordRewards/RewardConfig.cs new file mode 100644 index 00000000..dda0dfe1 --- /dev/null +++ b/Framework/DiscordRewards/RewardConfig.cs @@ -0,0 +1,18 @@ +namespace DiscordRewards +{ + public class RewardConfig + { + public const string UsernameTag = ""; + + public RewardConfig(ulong roleId, string message, CheckConfig checkConfig) + { + RoleId = roleId; + Message = message; + CheckConfig = checkConfig; + } + + public ulong RoleId { get; } + public string Message { get; } + public CheckConfig CheckConfig { get; } + } +} diff --git a/Framework/DiscordRewards/RewardRepo.cs b/Framework/DiscordRewards/RewardRepo.cs new file mode 100644 index 00000000..97b80a16 --- /dev/null +++ b/Framework/DiscordRewards/RewardRepo.cs @@ -0,0 +1,53 @@ +using Utils; + +namespace DiscordRewards +{ + public class RewardRepo + { + private static string Tag => RewardConfig.UsernameTag; + + public RewardConfig[] Rewards { get; } = new RewardConfig[] + { + // Filled any slot + new RewardConfig(1187039439558541498, $"{Tag} successfully filled their first slot!", new CheckConfig + { + Type = CheckType.FilledSlot + }), + + // Finished any slot + new RewardConfig(1202286165630390339, $"{Tag} successfully finished their first slot!", new CheckConfig + { + Type = CheckType.FinishedSlot + }), + + // Finished a sizable slot + new RewardConfig(1202286218738405418, $"{Tag} finished their first 1GB-24h slot!", new CheckConfig + { + Type = CheckType.FinishedSlot, + MinSlotSize = 1.GB(), + MinDuration = TimeSpan.FromHours(24.0), + }), + + // Posted any contract + new RewardConfig(1202286258370383913, $"{Tag} posted their first contract!", new CheckConfig + { + Type = CheckType.PostedContract + }), + + // Started any contract + new RewardConfig(1202286330873126992, $"A contract created by {Tag} reached Started state for the first time!", new CheckConfig + { + Type = CheckType.StartedContract + }), + + // Started a sizable contract + new RewardConfig(1202286381670608909, $"A large contract created by {Tag} reached Started state for the first time!", new CheckConfig + { + Type = CheckType.FinishedSlot, + MinNumberOfHosts = 4, + MinSlotSize = 1.GB(), + MinDuration = TimeSpan.FromHours(24.0), + }) + }; + } +} diff --git a/Framework/GethConnector/GethConnector.cs b/Framework/GethConnector/GethConnector.cs new file mode 100644 index 00000000..c7c7687c --- /dev/null +++ b/Framework/GethConnector/GethConnector.cs @@ -0,0 +1,39 @@ +using CodexContractsPlugin; +using GethPlugin; +using Logging; + +namespace GethConnector +{ + public class GethConnector + { + public IGethNode GethNode { get; } + public ICodexContracts CodexContracts { get; } + + public static GethConnector? Initialize(ILog log) + { + if (!string.IsNullOrEmpty(GethInput.LoadError)) + { + var msg = "Geth input incorrect: " + GethInput.LoadError; + log.Error(msg); + return null; + } + + var contractsDeployment = new CodexContractsDeployment( + marketplaceAddress: GethInput.MarketplaceAddress, + abi: GethInput.ABI, + tokenAddress: GethInput.TokenAddress + ); + + var gethNode = new CustomGethNode(log, GethInput.GethHost, GethInput.GethPort, GethInput.PrivateKey); + var contracts = new CodexContractsAccess(log, gethNode, contractsDeployment); + + return new GethConnector(gethNode, contracts); + } + + private GethConnector(IGethNode gethNode, ICodexContracts codexContracts) + { + GethNode = gethNode; + CodexContracts = codexContracts; + } + } +} diff --git a/Framework/GethConnector/GethConnector.csproj b/Framework/GethConnector/GethConnector.csproj new file mode 100644 index 00000000..57715aa2 --- /dev/null +++ b/Framework/GethConnector/GethConnector.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + + + + + + + + + diff --git a/Framework/GethConnector/GethInput.cs b/Framework/GethConnector/GethInput.cs new file mode 100644 index 00000000..e38af8a4 --- /dev/null +++ b/Framework/GethConnector/GethInput.cs @@ -0,0 +1,52 @@ +namespace GethConnector +{ + public static class GethInput + { + private const string GethHostVar = "GETH_HOST"; + private const string GethPortVar = "GETH_HTTP_PORT"; + private const string GethPrivKeyVar = "GETH_PRIVATE_KEY"; + private const string MarketplaceAddressVar = "CODEXCONTRACTS_MARKETPLACEADDRESS"; + private const string TokenAddressVar = "CODEXCONTRACTS_TOKENADDRESS"; + private const string AbiVar = "CODEXCONTRACTS_ABI"; + + static GethInput() + { + var error = new List(); + var gethHost = GetEnvVar(error, GethHostVar); + var gethPort = Convert.ToInt32(GetEnvVar(error, GethPortVar)); + var privateKey = GetEnvVar(error, GethPrivKeyVar); + var marketplaceAddress = GetEnvVar(error, MarketplaceAddressVar); + var tokenAddress = GetEnvVar(error, TokenAddressVar); + var abi = GetEnvVar(error, AbiVar); + + if (error.Any()) + { + LoadError = string.Join(", ", error); + } + else + { + GethHost = gethHost!; + GethPort = gethPort; + PrivateKey = privateKey!; + MarketplaceAddress = marketplaceAddress!; + TokenAddress = tokenAddress!; + ABI = abi!; + } + } + + public static string GethHost { get; } = string.Empty; + public static int GethPort { get; } + public static string PrivateKey { get; } = string.Empty; + public static string MarketplaceAddress { get; } = string.Empty; + public static string TokenAddress { get; } = string.Empty; + public static string ABI { get; } = string.Empty; + public static string LoadError { get; } = string.Empty; + + private static string? GetEnvVar(List error, string name) + { + var result = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrEmpty(result)) error.Add($"'{name}' is not set."); + return result; + } + } +} diff --git a/Framework/NethereumWorkflow/BlockTimeEntry.cs b/Framework/NethereumWorkflow/BlockTimeEntry.cs new file mode 100644 index 00000000..03817ae4 --- /dev/null +++ b/Framework/NethereumWorkflow/BlockTimeEntry.cs @@ -0,0 +1,22 @@ +namespace NethereumWorkflow +{ + public partial class BlockTimeFinder + { + public class BlockTimeEntry + { + public BlockTimeEntry(ulong blockNumber, DateTime utc) + { + BlockNumber = blockNumber; + Utc = utc; + } + + public ulong BlockNumber { get; } + public DateTime Utc { get; } + + public override string ToString() + { + return $"[{BlockNumber}] @ {Utc.ToString("o")}"; + } + } + } +} diff --git a/Framework/NethereumWorkflow/BlockTimeFinder.cs b/Framework/NethereumWorkflow/BlockTimeFinder.cs new file mode 100644 index 00000000..e7b8c4a6 --- /dev/null +++ b/Framework/NethereumWorkflow/BlockTimeFinder.cs @@ -0,0 +1,280 @@ +using Logging; +using Nethereum.RPC.Eth.DTOs; +using Nethereum.Web3; +using Utils; + +namespace NethereumWorkflow +{ + public partial class BlockTimeFinder + { + private const ulong FetchRange = 6; + private const int MaxEntries = 1024; + private static readonly Dictionary entries = new Dictionary(); + private readonly Web3 web3; + private readonly ILog log; + + public BlockTimeFinder(Web3 web3, ILog log) + { + this.web3 = web3; + this.log = log; + } + + public ulong GetHighestBlockNumberBefore(DateTime moment) + { + log.Log("Looking for highest block before " + moment.ToString("o")); + AssertMomentIsInPast(moment); + Initialize(); + + return GetHighestBlockBefore(moment); + } + + public ulong GetLowestBlockNumberAfter(DateTime moment) + { + log.Log("Looking for lowest block after " + moment.ToString("o")); + AssertMomentIsInPast(moment); + Initialize(); + + return GetLowestBlockAfter(moment); + } + + private ulong GetHighestBlockBefore(DateTime moment) + { + var closestBefore = FindClosestBeforeEntry(moment); + var closestAfter = FindClosestAfterEntry(moment); + + if (closestBefore != null && + closestAfter != null && + closestBefore.Utc < moment && + closestAfter.Utc > moment && + closestBefore.BlockNumber + 1 == closestAfter.BlockNumber) + { + log.Log("Found highest-Before: " + closestBefore); + return closestBefore.BlockNumber; + } + + FetchBlocksAround(moment); + return GetHighestBlockBefore(moment); + } + + private ulong GetLowestBlockAfter(DateTime moment) + { + var closestBefore = FindClosestBeforeEntry(moment); + var closestAfter = FindClosestAfterEntry(moment); + + if (closestBefore != null && + closestAfter != null && + closestBefore.Utc < moment && + closestAfter.Utc > moment && + closestBefore.BlockNumber + 1 == closestAfter.BlockNumber) + { + log.Log("Found lowest-after: " + closestAfter); + return closestAfter.BlockNumber; + } + + FetchBlocksAround(moment); + return GetLowestBlockAfter(moment); + } + + private void FetchBlocksAround(DateTime moment) + { + var timePerBlock = EstimateTimePerBlock(); + log.Debug("Fetching blocks around " + moment.ToString("o") + " timePerBlock: " + timePerBlock.TotalSeconds); + + EnsureRecentBlockIfNecessary(moment, timePerBlock); + + var max = entries.Keys.Max(); + var blockDifference = CalculateBlockDifference(moment, timePerBlock, max); + + FetchUp(max, blockDifference); + FetchDown(max, blockDifference); + } + + private void FetchDown(ulong max, ulong blockDifference) + { + var target = max - blockDifference - 1; + var fetchDown = FetchRange; + while (fetchDown > 0) + { + if (!entries.ContainsKey(target)) + { + var newBlock = AddBlockNumber(target); + if (newBlock == null) return; + fetchDown--; + } + target--; + if (target <= 0) return; + } + } + + private void FetchUp(ulong max, ulong blockDifference) + { + var target = max - blockDifference; + var fetchUp = FetchRange; + while (fetchUp > 0) + { + if (!entries.ContainsKey(target)) + { + var newBlock = AddBlockNumber(target); + if (newBlock == null) return; + fetchUp--; + } + target++; + if (target >= max) return; + } + } + + private ulong CalculateBlockDifference(DateTime moment, TimeSpan timePerBlock, ulong max) + { + var latest = entries[max]; + var timeDifference = latest.Utc - moment; + double secondsDifference = Math.Abs(timeDifference.TotalSeconds); + double secondsPerBlock = timePerBlock.TotalSeconds; + + double numberOfBlocksDifference = secondsDifference / secondsPerBlock; + var blockDifference = Convert.ToUInt64(numberOfBlocksDifference); + if (blockDifference < 1) blockDifference = 1; + return blockDifference; + } + + private void EnsureRecentBlockIfNecessary(DateTime moment, TimeSpan timePerBlock) + { + var max = entries.Keys.Max(); + var latest = entries[max]; + var maxRetry = 10; + while (moment > latest.Utc) + { + var newBlock = AddCurrentBlock(); + if (newBlock == null || newBlock.BlockNumber == latest.BlockNumber) + { + maxRetry--; + if (maxRetry == 0) throw new Exception("Unable to fetch recent block after 10x tries."); + Thread.Sleep(timePerBlock); + } + max = entries.Keys.Max(); + latest = entries[max]; + } + } + + private BlockTimeEntry? AddBlockNumber(decimal blockNumber) + { + return AddBlockNumber(Convert.ToUInt64(blockNumber)); + } + + private BlockTimeEntry? AddBlockNumber(ulong blockNumber) + { + if (entries.ContainsKey(blockNumber)) + { + return entries[blockNumber]; + } + + if (entries.Count > MaxEntries) + { + log.Debug("Entries cleared!"); + entries.Clear(); + Initialize(); + } + + var time = GetTimestampFromBlock(blockNumber); + if (time == null) + { + log.Log("Failed to get block for number: " + blockNumber); + return null; + } + var entry = new BlockTimeEntry(blockNumber, time.Value); + log.Debug("Found block " + entry.BlockNumber + " at " + entry.Utc.ToString("o")); + entries.Add(blockNumber, entry); + return entry; + } + + private TimeSpan EstimateTimePerBlock() + { + var min = entries.Keys.Min(); + var max = entries.Keys.Max(); + var clippedMin = Math.Max(max - 100, min); + var minTime = entries[min].Utc; + var clippedMinBlock = AddBlockNumber(clippedMin); + if (clippedMinBlock != null) minTime = clippedMinBlock.Utc; + + var maxTime = entries[max].Utc; + var elapsedTime = maxTime - minTime; + + double elapsedSeconds = elapsedTime.TotalSeconds; + double numberOfBlocks = max - min; + double secondsPerBlock = elapsedSeconds / numberOfBlocks; + + var result = TimeSpan.FromSeconds(secondsPerBlock); + if (result.TotalSeconds < 1.0) result = TimeSpan.FromSeconds(1.0); + return result; + } + + private void Initialize() + { + if (!entries.Any()) + { + AddCurrentBlock(); + AddBlockNumber(entries.Single().Key - 1); + } + } + + private static void AssertMomentIsInPast(DateTime moment) + { + if (moment > DateTime.UtcNow) throw new Exception("Moment must be UTC and must be in the past."); + } + + private BlockTimeEntry? AddCurrentBlock() + { + var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync()); + var blockNumber = number.ToDecimal(); + return AddBlockNumber(blockNumber); + } + + private DateTime? GetTimestampFromBlock(ulong blockNumber) + { + try + { + var block = Time.Wait(web3.Eth.Blocks.GetBlockWithTransactionsByNumber.SendRequestAsync(new BlockParameter(blockNumber))); + if (block == null) return null; + return DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(block.Timestamp.ToDecimal())).UtcDateTime; + } + catch (Exception ex) + { + int i = 0; + throw; + } + } + + private BlockTimeEntry? FindClosestBeforeEntry(DateTime moment) + { + BlockTimeEntry? result = null; + foreach (var entry in entries.Values) + { + if (result == null) + { + if (entry.Utc < moment) result = entry; + } + else + { + if (entry.Utc > result.Utc && entry.Utc < moment) result = entry; + } + } + return result; + } + + private BlockTimeEntry? FindClosestAfterEntry(DateTime moment) + { + BlockTimeEntry? result = null; + foreach (var entry in entries.Values) + { + if (result == null) + { + if (entry.Utc > moment) result = entry; + } + else + { + if (entry.Utc < result.Utc && entry.Utc > moment) result = entry; + } + } + return result; + } + } +} diff --git a/Framework/NethereumWorkflow/NethereumInteraction.cs b/Framework/NethereumWorkflow/NethereumInteraction.cs index 1efef816..0a8c2e42 100644 --- a/Framework/NethereumWorkflow/NethereumInteraction.cs +++ b/Framework/NethereumWorkflow/NethereumInteraction.cs @@ -1,7 +1,9 @@ using Logging; +using Nethereum.ABI.FunctionEncoding.Attributes; using Nethereum.Contracts; using Nethereum.RPC.Eth.DTOs; using Nethereum.Web3; +using System.Runtime.CompilerServices; using Utils; namespace NethereumWorkflow @@ -54,6 +56,12 @@ namespace NethereumWorkflow return receipt.TransactionHash; } + public Transaction GetTransaction(string transactionHash) + { + log.Debug(); + return Time.Wait(web3.Eth.Transactions.GetTransactionByHash.SendRequestAsync(transactionHash)); + } + public decimal? GetSyncedBlockNumber() { log.Debug(); @@ -77,5 +85,24 @@ namespace NethereumWorkflow return false; } } + + public List> GetEvents(string address, TimeRange timeRange) where TEvent : IEventDTO, new() + { + var blockTimeFinder = new BlockTimeFinder(web3, log); + + var fromBlock = blockTimeFinder.GetLowestBlockNumberAfter(timeRange.From); + var toBlock = blockTimeFinder.GetHighestBlockNumberBefore(timeRange.To); + + return GetEvents(address, fromBlock, toBlock); + } + + public List> GetEvents(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new() + { + var eventHandler = web3.Eth.GetEvent(address); + var from = new BlockParameter(fromBlockNumber); + var to = new BlockParameter(toBlockNumber); + var blockFilter = Time.Wait(eventHandler.CreateFilterBlockRangeAsync(from, to)); + return Time.Wait(eventHandler.GetAllChangesAsync(blockFilter)); + } } } diff --git a/Tests/CodexContinuousTests/TaskFactory.cs b/Framework/Utils/TaskFactory.cs similarity index 97% rename from Tests/CodexContinuousTests/TaskFactory.cs rename to Framework/Utils/TaskFactory.cs index e0be06e2..44ff489e 100644 --- a/Tests/CodexContinuousTests/TaskFactory.cs +++ b/Framework/Utils/TaskFactory.cs @@ -1,4 +1,4 @@ -namespace ContinuousTests +namespace Utils { public class TaskFactory { diff --git a/Framework/Utils/Time.cs b/Framework/Utils/Time.cs index ca4e115d..82a836e6 100644 --- a/Framework/Utils/Time.cs +++ b/Framework/Utils/Time.cs @@ -13,6 +13,11 @@ return task.Result; } + public static void Wait(Task task) + { + task.Wait(); + } + public static string FormatDuration(TimeSpan d) { var result = ""; diff --git a/Framework/Utils/TimeRange.cs b/Framework/Utils/TimeRange.cs new file mode 100644 index 00000000..35578d17 --- /dev/null +++ b/Framework/Utils/TimeRange.cs @@ -0,0 +1,24 @@ +namespace Utils +{ + public class TimeRange + { + public TimeRange(DateTime from, DateTime to) + { + if (from < to) + { + From = from; + To = to; + } + else + { + From = to; + To = from; + } + Duration = To - From; + } + + public DateTime From { get; } + public DateTime To { get; } + public TimeSpan Duration { get; } + } +} diff --git a/ProjectPlugins/CodexContractsPlugin/CodexContractsAccess.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsAccess.cs index be22f3ec..97083fef 100644 --- a/ProjectPlugins/CodexContractsPlugin/CodexContractsAccess.cs +++ b/ProjectPlugins/CodexContractsPlugin/CodexContractsAccess.cs @@ -1,5 +1,11 @@ -using GethPlugin; +using CodexContractsPlugin.Marketplace; +using GethPlugin; using Logging; +using Nethereum.ABI; +using Nethereum.Hex.HexTypes; +using Nethereum.Util; +using NethereumWorkflow; +using Utils; namespace CodexContractsPlugin { @@ -12,6 +18,23 @@ namespace CodexContractsPlugin string MintTestTokens(EthAddress ethAddress, TestToken testTokens); TestToken GetTestTokenBalance(IHasEthAddress owner); TestToken GetTestTokenBalance(EthAddress ethAddress); + + Request[] GetStorageRequests(TimeRange timeRange); + EthAddress? GetSlotHost(Request storageRequest, decimal slotIndex); + RequestState GetRequestState(Request request); + RequestFulfilledEventDTO[] GetRequestFulfilledEvents(TimeRange timeRange); + RequestCancelledEventDTO[] GetRequestCancelledEvents(TimeRange timeRange); + SlotFilledEventDTO[] GetSlotFilledEvents(TimeRange timeRange); + SlotFreedEventDTO[] GetSlotFreedEvents(TimeRange timeRange); + } + + public enum RequestState + { + New, + Started, + Cancelled, + Finished, + Failed } public class CodexContractsAccess : ICodexContracts @@ -30,8 +53,7 @@ namespace CodexContractsPlugin public bool IsDeployed() { - var interaction = new ContractInteractions(log, gethNode); - return !string.IsNullOrEmpty(interaction.GetTokenName(Deployment.TokenAddress)); + return !string.IsNullOrEmpty(StartInteraction().GetTokenName(Deployment.TokenAddress)); } public string MintTestTokens(IHasEthAddress owner, TestToken testTokens) @@ -41,8 +63,7 @@ namespace CodexContractsPlugin public string MintTestTokens(EthAddress ethAddress, TestToken testTokens) { - var interaction = new ContractInteractions(log, gethNode); - return interaction.MintTestTokens(ethAddress, testTokens.Amount, Deployment.TokenAddress); + return StartInteraction().MintTestTokens(ethAddress, testTokens.Amount, Deployment.TokenAddress); } public TestToken GetTestTokenBalance(IHasEthAddress owner) @@ -52,9 +73,108 @@ namespace CodexContractsPlugin public TestToken GetTestTokenBalance(EthAddress ethAddress) { - var interaction = new ContractInteractions(log, gethNode); - var balance = interaction.GetBalance(Deployment.TokenAddress, ethAddress.Address); + var balance = StartInteraction().GetBalance(Deployment.TokenAddress, ethAddress.Address); return balance.TestTokens(); } + + public Request[] GetStorageRequests(TimeRange timeRange) + { + var events = gethNode.GetEvents(Deployment.MarketplaceAddress, timeRange); + var i = StartInteraction(); + return events + .Select(e => + { + var requestEvent = i.GetRequest(Deployment.MarketplaceAddress, e.Event.RequestId); + var result = requestEvent.ReturnValue1; + result.BlockNumber = e.Log.BlockNumber.ToUlong(); + result.RequestId = e.Event.RequestId; + return result; + }) + .ToArray(); + } + + public RequestFulfilledEventDTO[] GetRequestFulfilledEvents(TimeRange timeRange) + { + var events = gethNode.GetEvents(Deployment.MarketplaceAddress, timeRange); + return events.Select(e => + { + var result = e.Event; + result.BlockNumber = e.Log.BlockNumber.ToUlong(); + return result; + }).ToArray(); + } + + public RequestCancelledEventDTO[] GetRequestCancelledEvents(TimeRange timeRange) + { + var events = gethNode.GetEvents(Deployment.MarketplaceAddress, timeRange); + return events.Select(e => + { + var result = e.Event; + result.BlockNumber = e.Log.BlockNumber.ToUlong(); + return result; + }).ToArray(); + } + + public SlotFilledEventDTO[] GetSlotFilledEvents(TimeRange timeRange) + { + var events = gethNode.GetEvents(Deployment.MarketplaceAddress, timeRange); + return events.Select(e => + { + var result = e.Event; + result.BlockNumber = e.Log.BlockNumber.ToUlong(); + result.Host = GetEthAddressFromTransaction(e.Log.TransactionHash); + return result; + }).ToArray(); + } + + public SlotFreedEventDTO[] GetSlotFreedEvents(TimeRange timeRange) + { + var events = gethNode.GetEvents(Deployment.MarketplaceAddress, timeRange); + return events.Select(e => + { + var result = e.Event; + result.BlockNumber = e.Log.BlockNumber.ToUlong(); + return result; + }).ToArray(); + } + + public EthAddress? GetSlotHost(Request storageRequest, decimal slotIndex) + { + var encoder = new ABIEncode(); + var encoded = encoder.GetABIEncoded( + new ABIValue("bytes32", storageRequest.RequestId), + new ABIValue("uint256", slotIndex.ToBig()) + ); + + var hashed = Sha3Keccack.Current.CalculateHash(encoded); + + var func = new GetHostFunction + { + SlotId = hashed + }; + var address = gethNode.Call(Deployment.MarketplaceAddress, func); + if (string.IsNullOrEmpty(address)) return null; + return new EthAddress(address); + } + + public RequestState GetRequestState(Request request) + { + var func = new RequestStateFunction + { + RequestId = request.RequestId + }; + return gethNode.Call(Deployment.MarketplaceAddress, func); + } + + private EthAddress GetEthAddressFromTransaction(string transactionHash) + { + var transaction = gethNode.GetTransaction(transactionHash); + return new EthAddress(transaction.From); + } + + private ContractInteractions StartInteraction() + { + return new ContractInteractions(log, gethNode); + } } } diff --git a/ProjectPlugins/CodexContractsPlugin/ContractInteractions.cs b/ProjectPlugins/CodexContractsPlugin/ContractInteractions.cs index 4af2ac6d..93045db2 100644 --- a/ProjectPlugins/CodexContractsPlugin/ContractInteractions.cs +++ b/ProjectPlugins/CodexContractsPlugin/ContractInteractions.cs @@ -1,7 +1,9 @@ -using GethPlugin; +using CodexContractsPlugin.Marketplace; +using GethPlugin; using Logging; using Nethereum.ABI.FunctionEncoding.Attributes; using Nethereum.Contracts; +using Nethereum.Hex.HexConvertors.Extensions; using NethereumWorkflow; using System.Numerics; @@ -59,6 +61,17 @@ namespace CodexContractsPlugin return gethNode.Call(tokenAddress, function).ToDecimal(); } + public GetRequestOutputDTO GetRequest(string marketplaceAddress, byte[] requestId) + { + + log.Debug($"({marketplaceAddress}) {requestId.ToHex(true)}"); + var func = new GetRequestFunction + { + RequestId = requestId + }; + return gethNode.Call(marketplaceAddress, func); + } + public bool IsSynced(string marketplaceAddress, string marketplaceAbi) { log.Debug(); diff --git a/ProjectPlugins/CodexContractsPlugin/Marketplace/Customizations.cs b/ProjectPlugins/CodexContractsPlugin/Marketplace/Customizations.cs new file mode 100644 index 00000000..96490b75 --- /dev/null +++ b/ProjectPlugins/CodexContractsPlugin/Marketplace/Customizations.cs @@ -0,0 +1,35 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +using GethPlugin; + +namespace CodexContractsPlugin.Marketplace +{ + public partial class Request : RequestBase + { + public ulong BlockNumber { get; set; } + public byte[] RequestId { get; set; } + + public EthAddress ClientAddress { get { return new EthAddress(Client); } } + } + + public partial class RequestFulfilledEventDTO + { + public ulong BlockNumber { get; set; } + } + + public partial class RequestCancelledEventDTO + { + public ulong BlockNumber { get; set; } + } + + public partial class SlotFilledEventDTO + { + public ulong BlockNumber { get; set; } + public EthAddress Host { get; set; } + } + + public partial class SlotFreedEventDTO + { + public ulong BlockNumber { get; set; } + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. diff --git a/ProjectPlugins/CodexContractsPlugin/Marketplace/Marketplace.cs b/ProjectPlugins/CodexContractsPlugin/Marketplace/Marketplace.cs new file mode 100644 index 00000000..507334ab --- /dev/null +++ b/ProjectPlugins/CodexContractsPlugin/Marketplace/Marketplace.cs @@ -0,0 +1,517 @@ +using Nethereum.ABI.FunctionEncoding.Attributes; +using Nethereum.Contracts; +using System.Numerics; + +// Generated code, do not modify. + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +namespace CodexContractsPlugin.Marketplace +{ + public partial class ConfigFunction : ConfigFunctionBase { } + + [Function("config", typeof(ConfigOutputDTO))] + public class ConfigFunctionBase : FunctionMessage + { + + } + + public partial class FillSlotFunction : FillSlotFunctionBase { } + + [Function("fillSlot")] + public class FillSlotFunctionBase : FunctionMessage + { + [Parameter("bytes32", "requestId", 1)] + public virtual byte[] RequestId { get; set; } + [Parameter("uint256", "slotIndex", 2)] + public virtual BigInteger SlotIndex { get; set; } + [Parameter("bytes", "proof", 3)] + public virtual byte[] Proof { get; set; } + } + + public partial class FreeSlotFunction : FreeSlotFunctionBase { } + + [Function("freeSlot")] + public class FreeSlotFunctionBase : FunctionMessage + { + [Parameter("bytes32", "slotId", 1)] + public virtual byte[] SlotId { get; set; } + } + + public partial class GetActiveSlotFunction : GetActiveSlotFunctionBase { } + + [Function("getActiveSlot", typeof(GetActiveSlotOutputDTO))] + public class GetActiveSlotFunctionBase : FunctionMessage + { + [Parameter("bytes32", "slotId", 1)] + public virtual byte[] SlotId { get; set; } + } + + public partial class GetChallengeFunction : GetChallengeFunctionBase { } + + [Function("getChallenge", "bytes32")] + public class GetChallengeFunctionBase : FunctionMessage + { + [Parameter("bytes32", "id", 1)] + public virtual byte[] Id { get; set; } + } + + public partial class GetHostFunction : GetHostFunctionBase { } + + [Function("getHost", "address")] + public class GetHostFunctionBase : FunctionMessage + { + [Parameter("bytes32", "slotId", 1)] + public virtual byte[] SlotId { get; set; } + } + + public partial class GetPointerFunction : GetPointerFunctionBase { } + + [Function("getPointer", "uint8")] + public class GetPointerFunctionBase : FunctionMessage + { + [Parameter("bytes32", "id", 1)] + public virtual byte[] Id { get; set; } + } + + public partial class GetRequestFunction : GetRequestFunctionBase { } + + [Function("getRequest", typeof(GetRequestOutputDTO))] + public class GetRequestFunctionBase : FunctionMessage + { + [Parameter("bytes32", "requestId", 1)] + public virtual byte[] RequestId { get; set; } + } + + public partial class IsProofRequiredFunction : IsProofRequiredFunctionBase { } + + [Function("isProofRequired", "bool")] + public class IsProofRequiredFunctionBase : FunctionMessage + { + [Parameter("bytes32", "id", 1)] + public virtual byte[] Id { get; set; } + } + + public partial class MarkProofAsMissingFunction : MarkProofAsMissingFunctionBase { } + + [Function("markProofAsMissing")] + public class MarkProofAsMissingFunctionBase : FunctionMessage + { + [Parameter("bytes32", "slotId", 1)] + public virtual byte[] SlotId { get; set; } + [Parameter("uint256", "period", 2)] + public virtual BigInteger Period { get; set; } + } + + public partial class MissingProofsFunction : MissingProofsFunctionBase { } + + [Function("missingProofs", "uint256")] + public class MissingProofsFunctionBase : FunctionMessage + { + [Parameter("bytes32", "slotId", 1)] + public virtual byte[] SlotId { get; set; } + } + + public partial class MyRequestsFunction : MyRequestsFunctionBase { } + + [Function("myRequests", "bytes32[]")] + public class MyRequestsFunctionBase : FunctionMessage + { + + } + + public partial class MySlotsFunction : MySlotsFunctionBase { } + + [Function("mySlots", "bytes32[]")] + public class MySlotsFunctionBase : FunctionMessage + { + + } + + public partial class RequestEndFunction : RequestEndFunctionBase { } + + [Function("requestEnd", "uint256")] + public class RequestEndFunctionBase : FunctionMessage + { + [Parameter("bytes32", "requestId", 1)] + public virtual byte[] RequestId { get; set; } + } + + public partial class RequestStateFunction : RequestStateFunctionBase { } + + [Function("requestState", "uint8")] + public class RequestStateFunctionBase : FunctionMessage + { + [Parameter("bytes32", "requestId", 1)] + public virtual byte[] RequestId { get; set; } + } + + public partial class RequestStorageFunction : RequestStorageFunctionBase { } + + [Function("requestStorage")] + public class RequestStorageFunctionBase : FunctionMessage + { + [Parameter("tuple", "request", 1)] + public virtual Request Request { get; set; } + } + + public partial class SlotStateFunction : SlotStateFunctionBase { } + + [Function("slotState", "uint8")] + public class SlotStateFunctionBase : FunctionMessage + { + [Parameter("bytes32", "slotId", 1)] + public virtual byte[] SlotId { get; set; } + } + + public partial class SubmitProofFunction : SubmitProofFunctionBase { } + + [Function("submitProof")] + public class SubmitProofFunctionBase : FunctionMessage + { + [Parameter("bytes32", "id", 1)] + public virtual byte[] Id { get; set; } + [Parameter("bytes", "proof", 2)] + public virtual byte[] Proof { get; set; } + } + + public partial class TokenFunction : TokenFunctionBase { } + + [Function("token", "address")] + public class TokenFunctionBase : FunctionMessage + { + + } + + public partial class WillProofBeRequiredFunction : WillProofBeRequiredFunctionBase { } + + [Function("willProofBeRequired", "bool")] + public class WillProofBeRequiredFunctionBase : FunctionMessage + { + [Parameter("bytes32", "id", 1)] + public virtual byte[] Id { get; set; } + } + + public partial class WithdrawFundsFunction : WithdrawFundsFunctionBase { } + + [Function("withdrawFunds")] + public class WithdrawFundsFunctionBase : FunctionMessage + { + [Parameter("bytes32", "requestId", 1)] + public virtual byte[] RequestId { get; set; } + } + + public partial class ProofSubmittedEventDTO : ProofSubmittedEventDTOBase { } + + [Event("ProofSubmitted")] + public class ProofSubmittedEventDTOBase : IEventDTO + { + [Parameter("bytes32", "id", 1, false)] + public virtual byte[] Id { get; set; } + [Parameter("bytes", "proof", 2, false)] + public virtual byte[] Proof { get; set; } + } + + public partial class RequestCancelledEventDTO : RequestCancelledEventDTOBase { } + + [Event("RequestCancelled")] + public class RequestCancelledEventDTOBase : IEventDTO + { + [Parameter("bytes32", "requestId", 1, true)] + public virtual byte[] RequestId { get; set; } + } + + public partial class RequestFailedEventDTO : RequestFailedEventDTOBase { } + + [Event("RequestFailed")] + public class RequestFailedEventDTOBase : IEventDTO + { + [Parameter("bytes32", "requestId", 1, true)] + public virtual byte[] RequestId { get; set; } + } + + public partial class RequestFulfilledEventDTO : RequestFulfilledEventDTOBase { } + + [Event("RequestFulfilled")] + public class RequestFulfilledEventDTOBase : IEventDTO + { + [Parameter("bytes32", "requestId", 1, true)] + public virtual byte[] RequestId { get; set; } + } + + public partial class SlotFilledEventDTO : SlotFilledEventDTOBase { } + + [Event("SlotFilled")] + public class SlotFilledEventDTOBase : IEventDTO + { + [Parameter("bytes32", "requestId", 1, true)] + public virtual byte[] RequestId { get; set; } + [Parameter("uint256", "slotIndex", 2, false)] + public virtual BigInteger SlotIndex { get; set; } + } + + public partial class SlotFreedEventDTO : SlotFreedEventDTOBase { } + + [Event("SlotFreed")] + public class SlotFreedEventDTOBase : IEventDTO + { + [Parameter("bytes32", "requestId", 1, true)] + public virtual byte[] RequestId { get; set; } + [Parameter("uint256", "slotIndex", 2, false)] + public virtual BigInteger SlotIndex { get; set; } + } + + public partial class StorageRequestedEventDTO : StorageRequestedEventDTOBase { } + + [Event("StorageRequested")] + public class StorageRequestedEventDTOBase : IEventDTO + { + [Parameter("bytes32", "requestId", 1, false)] + public virtual byte[] RequestId { get; set; } + [Parameter("tuple", "ask", 2, false)] + public virtual Ask Ask { get; set; } + [Parameter("uint256", "expiry", 3, false)] + public virtual BigInteger Expiry { get; set; } + } + + public partial class ConfigOutputDTO : ConfigOutputDTOBase { } + + [FunctionOutput] + public class ConfigOutputDTOBase : IFunctionOutputDTO + { + [Parameter("tuple", "collateral", 1)] + public virtual CollateralConfig Collateral { get; set; } + [Parameter("tuple", "proofs", 2)] + public virtual ProofConfig Proofs { get; set; } + } + + + + + + public partial class GetActiveSlotOutputDTO : GetActiveSlotOutputDTOBase { } + + [FunctionOutput] + public class GetActiveSlotOutputDTOBase : IFunctionOutputDTO + { + [Parameter("tuple", "", 1)] + public virtual ActiveSlot ReturnValue1 { get; set; } + } + + public partial class GetChallengeOutputDTO : GetChallengeOutputDTOBase { } + + [FunctionOutput] + public class GetChallengeOutputDTOBase : IFunctionOutputDTO + { + [Parameter("bytes32", "", 1)] + public virtual byte[] ReturnValue1 { get; set; } + } + + public partial class GetHostOutputDTO : GetHostOutputDTOBase { } + + [FunctionOutput] + public class GetHostOutputDTOBase : IFunctionOutputDTO + { + [Parameter("address", "", 1)] + public virtual string ReturnValue1 { get; set; } + } + + public partial class GetPointerOutputDTO : GetPointerOutputDTOBase { } + + [FunctionOutput] + public class GetPointerOutputDTOBase : IFunctionOutputDTO + { + [Parameter("uint8", "", 1)] + public virtual byte ReturnValue1 { get; set; } + } + + public partial class GetRequestOutputDTO : GetRequestOutputDTOBase { } + + [FunctionOutput] + public class GetRequestOutputDTOBase : IFunctionOutputDTO + { + [Parameter("tuple", "", 1)] + public virtual Request ReturnValue1 { get; set; } + } + + public partial class IsProofRequiredOutputDTO : IsProofRequiredOutputDTOBase { } + + [FunctionOutput] + public class IsProofRequiredOutputDTOBase : IFunctionOutputDTO + { + [Parameter("bool", "", 1)] + public virtual bool ReturnValue1 { get; set; } + } + + + + public partial class MissingProofsOutputDTO : MissingProofsOutputDTOBase { } + + [FunctionOutput] + public class MissingProofsOutputDTOBase : IFunctionOutputDTO + { + [Parameter("uint256", "", 1)] + public virtual BigInteger ReturnValue1 { get; set; } + } + + public partial class MyRequestsOutputDTO : MyRequestsOutputDTOBase { } + + [FunctionOutput] + public class MyRequestsOutputDTOBase : IFunctionOutputDTO + { + [Parameter("bytes32[]", "", 1)] + public virtual List ReturnValue1 { get; set; } + } + + public partial class MySlotsOutputDTO : MySlotsOutputDTOBase { } + + [FunctionOutput] + public class MySlotsOutputDTOBase : IFunctionOutputDTO + { + [Parameter("bytes32[]", "", 1)] + public virtual List ReturnValue1 { get; set; } + } + + public partial class RequestEndOutputDTO : RequestEndOutputDTOBase { } + + [FunctionOutput] + public class RequestEndOutputDTOBase : IFunctionOutputDTO + { + [Parameter("uint256", "", 1)] + public virtual BigInteger ReturnValue1 { get; set; } + } + + public partial class RequestStateOutputDTO : RequestStateOutputDTOBase { } + + [FunctionOutput] + public class RequestStateOutputDTOBase : IFunctionOutputDTO + { + [Parameter("uint8", "", 1)] + public virtual byte ReturnValue1 { get; set; } + } + + + + public partial class SlotStateOutputDTO : SlotStateOutputDTOBase { } + + [FunctionOutput] + public class SlotStateOutputDTOBase : IFunctionOutputDTO + { + [Parameter("uint8", "", 1)] + public virtual byte ReturnValue1 { get; set; } + } + + + + public partial class TokenOutputDTO : TokenOutputDTOBase { } + + [FunctionOutput] + public class TokenOutputDTOBase : IFunctionOutputDTO + { + [Parameter("address", "", 1)] + public virtual string ReturnValue1 { get; set; } + } + + public partial class WillProofBeRequiredOutputDTO : WillProofBeRequiredOutputDTOBase { } + + [FunctionOutput] + public class WillProofBeRequiredOutputDTOBase : IFunctionOutputDTO + { + [Parameter("bool", "", 1)] + public virtual bool ReturnValue1 { get; set; } + } + + + + public partial class CollateralConfig : CollateralConfigBase { } + + public class CollateralConfigBase + { + [Parameter("uint8", "repairRewardPercentage", 1)] + public virtual byte RepairRewardPercentage { get; set; } + [Parameter("uint8", "maxNumberOfSlashes", 2)] + public virtual byte MaxNumberOfSlashes { get; set; } + [Parameter("uint16", "slashCriterion", 3)] + public virtual ushort SlashCriterion { get; set; } + [Parameter("uint8", "slashPercentage", 4)] + public virtual byte SlashPercentage { get; set; } + } + + public partial class ProofConfig : ProofConfigBase { } + + public class ProofConfigBase + { + [Parameter("uint256", "period", 1)] + public virtual BigInteger Period { get; set; } + [Parameter("uint256", "timeout", 2)] + public virtual BigInteger Timeout { get; set; } + [Parameter("uint8", "downtime", 3)] + public virtual byte Downtime { get; set; } + } + + public partial class MarketplaceConfig : MarketplaceConfigBase { } + + public class MarketplaceConfigBase + { + [Parameter("tuple", "collateral", 1)] + public virtual CollateralConfig Collateral { get; set; } + [Parameter("tuple", "proofs", 2)] + public virtual ProofConfig Proofs { get; set; } + } + + public partial class Ask : AskBase { } + + public class AskBase + { + [Parameter("uint64", "slots", 1)] + public virtual ulong Slots { get; set; } + [Parameter("uint256", "slotSize", 2)] + public virtual BigInteger SlotSize { get; set; } + [Parameter("uint256", "duration", 3)] + public virtual BigInteger Duration { get; set; } + [Parameter("uint256", "proofProbability", 4)] + public virtual BigInteger ProofProbability { get; set; } + [Parameter("uint256", "reward", 5)] + public virtual BigInteger Reward { get; set; } + [Parameter("uint256", "collateral", 6)] + public virtual BigInteger Collateral { get; set; } + [Parameter("uint64", "maxSlotLoss", 7)] + public virtual ulong MaxSlotLoss { get; set; } + } + + public partial class Content : ContentBase { } + + public class ContentBase + { + [Parameter("string", "cid", 1)] + public virtual string Cid { get; set; } + [Parameter("bytes32", "merkleRoot", 2)] + public virtual byte[] MerkleRoot { get; set; } + } + + public partial class Request : RequestBase { } + + public class RequestBase + { + [Parameter("address", "client", 1)] + public virtual string Client { get; set; } + [Parameter("tuple", "ask", 2)] + public virtual Ask Ask { get; set; } + [Parameter("tuple", "content", 3)] + public virtual Content Content { get; set; } + [Parameter("uint256", "expiry", 4)] + public virtual BigInteger Expiry { get; set; } + [Parameter("bytes32", "nonce", 5)] + public virtual byte[] Nonce { get; set; } + } + + public partial class ActiveSlot : ActiveSlotBase { } + + public class ActiveSlotBase + { + [Parameter("tuple", "request", 1)] + public virtual Request Request { get; set; } + [Parameter("uint256", "slotIndex", 2)] + public virtual BigInteger SlotIndex { get; set; } + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. diff --git a/ProjectPlugins/CodexContractsPlugin/Marketplace/README.md b/ProjectPlugins/CodexContractsPlugin/Marketplace/README.md new file mode 100644 index 00000000..bfb23ce1 --- /dev/null +++ b/ProjectPlugins/CodexContractsPlugin/Marketplace/README.md @@ -0,0 +1 @@ +This code was generated using the Nethereum code generator, here: http://playground.nethereum.com diff --git a/ProjectPlugins/CodexPlugin/CodexSetup.cs b/ProjectPlugins/CodexPlugin/CodexSetup.cs index a491fb32..ccac2527 100644 --- a/ProjectPlugins/CodexPlugin/CodexSetup.cs +++ b/ProjectPlugins/CodexPlugin/CodexSetup.cs @@ -27,6 +27,13 @@ namespace CodexPlugin public class CodexLogCustomTopics { + public CodexLogCustomTopics(CodexLogLevel discV5, CodexLogLevel libp2p, CodexLogLevel blockExchange) + { + DiscV5 = discV5; + Libp2p = libp2p; + BlockExchange = blockExchange; + } + public CodexLogCustomTopics(CodexLogLevel discV5, CodexLogLevel libp2p) { DiscV5 = discV5; @@ -35,6 +42,7 @@ namespace CodexPlugin public CodexLogLevel DiscV5 { get; set; } public CodexLogLevel Libp2p { get; set; } + public CodexLogLevel? BlockExchange { get; } } public class CodexSetup : CodexStartupConfig, ICodexSetup diff --git a/ProjectPlugins/CodexPlugin/CodexStartupConfig.cs b/ProjectPlugins/CodexPlugin/CodexStartupConfig.cs index ed840d0a..12109684 100644 --- a/ProjectPlugins/CodexPlugin/CodexStartupConfig.cs +++ b/ProjectPlugins/CodexPlugin/CodexStartupConfig.cs @@ -52,12 +52,31 @@ namespace CodexPlugin "connection", "connmanager", "websock", - "ws-session" + "ws-session", + "dialer", + "muxedupgrade", + "upgrade", + "identify" + }; + var blockExchangeTopics = new[] + { + "codex", + "pendingblocks", + "peerctxstore", + "discoveryengine", + "blockexcengine", + "blockexcnetwork", + "blockexcnetworkpeer" }; level = $"{level};" + $"{CustomTopics.DiscV5.ToString()!.ToLowerInvariant()}:{string.Join(",", discV5Topics)};" + $"{CustomTopics.Libp2p.ToString()!.ToLowerInvariant()}:{string.Join(",", libp2pTopics)}"; + + if (CustomTopics.BlockExchange != null) + { + level += $";{CustomTopics.BlockExchange.ToString()!.ToLowerInvariant()}:{string.Join(",", blockExchangeTopics)}"; + } } return level; } diff --git a/ProjectPlugins/GethPlugin/EthAddress.cs b/ProjectPlugins/GethPlugin/EthAddress.cs index 2954c488..803a1f7a 100644 --- a/ProjectPlugins/GethPlugin/EthAddress.cs +++ b/ProjectPlugins/GethPlugin/EthAddress.cs @@ -9,11 +9,22 @@ { public EthAddress(string address) { - Address = address; + Address = address.ToLowerInvariant(); } public string Address { get; } + public override bool Equals(object? obj) + { + return obj is EthAddress address && + Address == address.Address; + } + + public override int GetHashCode() + { + return HashCode.Combine(Address); + } + public override string ToString() { return Address; diff --git a/ProjectPlugins/GethPlugin/GethNode.cs b/ProjectPlugins/GethPlugin/GethNode.cs index d2e2122a..2ae834d5 100644 --- a/ProjectPlugins/GethPlugin/GethNode.cs +++ b/ProjectPlugins/GethPlugin/GethNode.cs @@ -1,8 +1,11 @@ using Core; using KubernetesWorkflow.Types; using Logging; +using Nethereum.ABI.FunctionEncoding.Attributes; using Nethereum.Contracts; +using Nethereum.RPC.Eth.DTOs; using NethereumWorkflow; +using Utils; namespace GethPlugin { @@ -17,9 +20,12 @@ namespace GethPlugin string SendEth(EthAddress account, Ether eth); TResult Call(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); string SendTransaction(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); + Transaction GetTransaction(string transactionHash); decimal? GetSyncedBlockNumber(); bool IsContractAvailable(string abi, string contractAddress); GethBootstrapNode GetBootstrapRecord(); + List> GetEvents(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new(); + List> GetEvents(string address, TimeRange timeRange) where TEvent : IEventDTO, new(); } public class DeploymentGethNode : BaseGethNode, IGethNode @@ -123,6 +129,11 @@ namespace GethPlugin return StartInteraction().SendTransaction(contractAddress, function); } + public Transaction GetTransaction(string transactionHash) + { + return StartInteraction().GetTransaction(transactionHash); + } + public decimal? GetSyncedBlockNumber() { return StartInteraction().GetSyncedBlockNumber(); @@ -133,6 +144,16 @@ namespace GethPlugin return StartInteraction().IsContractAvailable(abi, contractAddress); } + public List> GetEvents(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new() + { + return StartInteraction().GetEvents(address, fromBlockNumber, toBlockNumber); + } + + public List> GetEvents(string address, TimeRange timeRange) where TEvent : IEventDTO, new() + { + return StartInteraction().GetEvents(address, timeRange); + } + protected abstract NethereumInteraction StartInteraction(); } } diff --git a/Tests/CodexContinuousTests/ContinuousTestRunner.cs b/Tests/CodexContinuousTests/ContinuousTestRunner.cs index b591d305..c79fc713 100644 --- a/Tests/CodexContinuousTests/ContinuousTestRunner.cs +++ b/Tests/CodexContinuousTests/ContinuousTestRunner.cs @@ -3,6 +3,7 @@ using DistTestCore.Logs; using Logging; using Newtonsoft.Json; using Utils; +using TaskFactory = Utils.TaskFactory; namespace ContinuousTests { diff --git a/Tests/CodexContinuousTests/SingleTestRun.cs b/Tests/CodexContinuousTests/SingleTestRun.cs index 39b45e51..d7ccd802 100644 --- a/Tests/CodexContinuousTests/SingleTestRun.cs +++ b/Tests/CodexContinuousTests/SingleTestRun.cs @@ -6,6 +6,7 @@ using CodexPlugin; using DistTestCore.Logs; using Core; using KubernetesWorkflow.Types; +using TaskFactory = Utils.TaskFactory; namespace ContinuousTests { diff --git a/Tests/CodexContinuousTests/TestLoop.cs b/Tests/CodexContinuousTests/TestLoop.cs index 7e44f73f..46b4e29b 100644 --- a/Tests/CodexContinuousTests/TestLoop.cs +++ b/Tests/CodexContinuousTests/TestLoop.cs @@ -1,5 +1,6 @@ using DistTestCore.Logs; using Logging; +using TaskFactory = Utils.TaskFactory; namespace ContinuousTests { diff --git a/Tests/CodexTests/BasicTests/ExampleTests.cs b/Tests/CodexTests/BasicTests/ExampleTests.cs index 3976d46c..c73a913b 100644 --- a/Tests/CodexTests/BasicTests/ExampleTests.cs +++ b/Tests/CodexTests/BasicTests/ExampleTests.cs @@ -3,6 +3,7 @@ using CodexPlugin; using DistTestCore; using GethPlugin; using MetricsPlugin; +using Nethereum.Hex.HexConvertors.Extensions; using NUnit.Framework; using Utils; @@ -59,6 +60,7 @@ namespace CodexTests.BasicTests var contracts = Ci.StartCodexContracts(geth); var seller = AddCodex(s => s + .WithLogLevel(CodexLogLevel.Trace, new CodexLogCustomTopics(CodexLogLevel.Error, CodexLogLevel.Error, CodexLogLevel.Warn)) .WithStorageQuota(11.GB()) .EnableMarketplace(geth, contracts, initialEth: 10.Eth(), initialTokens: sellerInitialBalance, isValidator: true) .WithSimulateProofFailures(failEveryNProofs: 3)); @@ -88,14 +90,40 @@ namespace CodexTests.BasicTests purchaseContract.WaitForStorageContractStarted(fileSize); + var requests = contracts.GetStorageRequests(GetTestRunTimeRange()); + Assert.That(requests.Length, Is.EqualTo(1)); + var request = requests.Single(); + Assert.That(contracts.GetRequestState(request), Is.EqualTo(RequestState.Started)); + Assert.That(request.ClientAddress, Is.EqualTo(buyer.EthAddress)); + Assert.That(request.Ask.Slots, Is.EqualTo(1)); + AssertBalance(contracts, seller, Is.LessThan(sellerInitialBalance), "Collateral was not placed."); + var requestFulfilledEvents = contracts.GetRequestFulfilledEvents(GetTestRunTimeRange()); + Assert.That(requestFulfilledEvents.Length, Is.EqualTo(1)); + CollectionAssert.AreEqual(request.RequestId, requestFulfilledEvents[0].RequestId); + var filledSlotEvents = contracts.GetSlotFilledEvents(GetTestRunTimeRange()); + Assert.That(filledSlotEvents.Length, Is.EqualTo(1)); + var filledSlotEvent = filledSlotEvents.Single(); + Assert.That(filledSlotEvent.SlotIndex.IsZero); + Assert.That(filledSlotEvent.RequestId.ToHex(), Is.EqualTo(request.RequestId.ToHex())); + Assert.That(filledSlotEvent.Host, Is.EqualTo(seller.EthAddress)); + + var slotHost = contracts.GetSlotHost(request, 0); + Assert.That(slotHost, Is.EqualTo(seller.EthAddress)); + purchaseContract.WaitForStorageContractFinished(); AssertBalance(contracts, seller, Is.GreaterThan(sellerInitialBalance), "Seller was not paid for storage."); AssertBalance(contracts, buyer, Is.LessThan(buyerInitialBalance), "Buyer was not charged for storage."); + Assert.That(contracts.GetRequestState(request), Is.EqualTo(RequestState.Finished)); - CheckLogForErrors(seller, buyer); + var log = Ci.DownloadLog(seller); + log.AssertLogContains("Received a request to store a slot!"); + log.AssertLogContains("Received proof challenge"); + log.AssertLogContains("Collecting input for proof"); + + //CheckLogForErrors(seller, buyer); } [Test] diff --git a/Tests/DistTestCore/DistTest.cs b/Tests/DistTestCore/DistTest.cs index 06014e0c..5237bbc9 100644 --- a/Tests/DistTestCore/DistTest.cs +++ b/Tests/DistTestCore/DistTest.cs @@ -143,6 +143,11 @@ namespace DistTestCore Stopwatch.Measure(Get().Log, name, action); } + protected TimeRange GetTestRunTimeRange() + { + return new TimeRange(Get().TestStart, DateTime.UtcNow); + } + protected virtual void Initialize(FixtureLog fixtureLog) { } diff --git a/Tests/DistTestCore/TestLifecycle.cs b/Tests/DistTestCore/TestLifecycle.cs index 45cfff48..3ed08bb8 100644 --- a/Tests/DistTestCore/TestLifecycle.cs +++ b/Tests/DistTestCore/TestLifecycle.cs @@ -11,7 +11,6 @@ namespace DistTestCore public class TestLifecycle : IK8sHooks { private const string TestsType = "dist-tests"; - private readonly DateTime testStart; private readonly EntryPoint entryPoint; private readonly Dictionary metadata; private readonly List runningContainers = new List(); @@ -21,7 +20,7 @@ namespace DistTestCore Log = log; Configuration = configuration; TimeSet = timeSet; - testStart = DateTime.UtcNow; + TestStart = DateTime.UtcNow; entryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, this, testNamespace), configuration.GetFileManagerFolder(), timeSet); metadata = entryPoint.GetPluginMetadata(); @@ -30,6 +29,7 @@ namespace DistTestCore log.WriteLogTag(); } + public DateTime TestStart { get; } public TestLog Log { get; } public Configuration Configuration { get; } public ITimeSet TimeSet { get; } @@ -60,7 +60,7 @@ namespace DistTestCore public TimeSpan GetTestDuration() { - return DateTime.UtcNow - testStart; + return DateTime.UtcNow - TestStart; } public void OnContainersStarted(RunningContainers rc) diff --git a/Tools/BiblioTech/BaseGethCommand.cs b/Tools/BiblioTech/BaseGethCommand.cs index ecbdfecf..49069953 100644 --- a/Tools/BiblioTech/BaseGethCommand.cs +++ b/Tools/BiblioTech/BaseGethCommand.cs @@ -1,87 +1,18 @@ using BiblioTech.Options; using CodexContractsPlugin; using GethPlugin; -using Logging; namespace BiblioTech { - public static class GethInput - { - private const string GethHostVar = "GETH_HOST"; - private const string GethPortVar = "GETH_HTTP_PORT"; - private const string GethPrivKeyVar = "GETH_PRIVATE_KEY"; - private const string MarketplaceAddressVar = "CODEXCONTRACTS_MARKETPLACEADDRESS"; - private const string TokenAddressVar = "CODEXCONTRACTS_TOKENADDRESS"; - private const string AbiVar = "CODEXCONTRACTS_ABI"; - - static GethInput() - { - var error = new List(); - var gethHost = GetEnvVar(error, GethHostVar); - var gethPort = Convert.ToInt32(GetEnvVar(error, GethPortVar)); - var privateKey = GetEnvVar(error, GethPrivKeyVar); - var marketplaceAddress = GetEnvVar(error, MarketplaceAddressVar); - var tokenAddress = GetEnvVar(error, TokenAddressVar); - var abi = GetEnvVar(error, AbiVar); - - if (error.Any()) - { - LoadError = string.Join(", ", error); - } - else - { - GethHost = gethHost!; - GethPort = gethPort; - PrivateKey = privateKey!; - MarketplaceAddress = marketplaceAddress!; - TokenAddress = tokenAddress!; - ABI = abi!; - } - } - - public static string GethHost { get; } = string.Empty; - public static int GethPort { get; } - public static string PrivateKey { get; } = string.Empty; - public static string MarketplaceAddress { get; } = string.Empty; - public static string TokenAddress { get; } = string.Empty; - public static string ABI { get; } = string.Empty; - public static string LoadError { get; } = string.Empty; - - private static string? GetEnvVar(List error, string name) - { - var result = Environment.GetEnvironmentVariable(name); - if (string.IsNullOrEmpty(result)) error.Add($"'{name}' is not set."); - return result; - } - } - public abstract class BaseGethCommand : BaseCommand { protected override async Task Invoke(CommandContext context) { - if (!string.IsNullOrEmpty(GethInput.LoadError)) - { - var msg = "Geth input incorrect: " + GethInput.LoadError; - Program.Log.Error(msg); - if (IsInAdminChannel(context.Command)) - { - await context.Followup(msg); - } - else - { - await context.Followup("I'm sorry, there seems to be a configuration error."); - } - return; - } + var gethConnector = GethConnector.GethConnector.Initialize(Program.Log); - var contractsDeployment = new CodexContractsDeployment( - marketplaceAddress: GethInput.MarketplaceAddress, - abi: GethInput.ABI, - tokenAddress: GethInput.TokenAddress - ); - - var gethNode = new CustomGethNode(Program.Log, GethInput.GethHost, GethInput.GethPort, GethInput.PrivateKey); - var contracts = new CodexContractsAccess(Program.Log, gethNode, contractsDeployment); + if (gethConnector == null) return; + var gethNode = gethConnector.GethNode; + var contracts = gethConnector.CodexContracts; if (!contracts.IsDeployed()) { diff --git a/Tools/BiblioTech/BiblioTech.csproj b/Tools/BiblioTech/BiblioTech.csproj index 9c32ad43..19422e44 100644 --- a/Tools/BiblioTech/BiblioTech.csproj +++ b/Tools/BiblioTech/BiblioTech.csproj @@ -10,6 +10,8 @@ + + diff --git a/Tools/BiblioTech/CommandHandler.cs b/Tools/BiblioTech/CommandHandler.cs index 800ac4b5..f21e486a 100644 --- a/Tools/BiblioTech/CommandHandler.cs +++ b/Tools/BiblioTech/CommandHandler.cs @@ -2,6 +2,7 @@ using Discord.WebSocket; using Discord; using Newtonsoft.Json; +using BiblioTech.Rewards; namespace BiblioTech { @@ -25,6 +26,9 @@ namespace BiblioTech Program.AdminChecker.SetGuild(guild); Program.Log.Log($"Initializing for guild: '{guild.Name}'"); + var roleController = new RoleController(client); + var rewardsApi = new RewardsApi(roleController); + var adminChannels = guild.TextChannels.Where(Program.AdminChecker.IsAdminChannel).ToArray(); if (adminChannels == null || !adminChannels.Any()) throw new Exception("No admin message channel"); Program.AdminChecker.SetAdminChannel(adminChannels.First()); @@ -58,6 +62,8 @@ namespace BiblioTech var json = JsonConvert.SerializeObject(exception.Errors, Formatting.Indented); Program.Log.Error(json); } + + rewardsApi.Start(); } private async Task SlashCommandHandler(SocketSlashCommand command) diff --git a/Tools/BiblioTech/Commands/NotifyCommand.cs b/Tools/BiblioTech/Commands/NotifyCommand.cs new file mode 100644 index 00000000..3e0bd152 --- /dev/null +++ b/Tools/BiblioTech/Commands/NotifyCommand.cs @@ -0,0 +1,24 @@ +using BiblioTech.Options; + +namespace BiblioTech.Commands +{ + public class NotifyCommand : BaseCommand + { + private readonly BoolOption boolOption = new BoolOption(name: "enabled", description: "Controls whether the bot will @-mention you.", isRequired: false); + + public override string Name => "notify"; + public override string StartingMessage => RandomBusyMessage.Get(); + public override string Description => "Enable or disable notifications from the bot."; + public override CommandOption[] Options => new CommandOption[] { boolOption }; + + protected override async Task Invoke(CommandContext context) + { + var user = context.Command.User; + var enabled = await boolOption.Parse(context); + if (enabled == null) return; + + Program.UserRepo.SetUserNotificationPreference(user, enabled.Value); + await context.Followup("Done!"); + } + } +} diff --git a/Tools/BiblioTech/Commands/UserAssociateCommand.cs b/Tools/BiblioTech/Commands/UserAssociateCommand.cs index c23718ce..81e46467 100644 --- a/Tools/BiblioTech/Commands/UserAssociateCommand.cs +++ b/Tools/BiblioTech/Commands/UserAssociateCommand.cs @@ -4,6 +4,12 @@ namespace BiblioTech.Commands { public class UserAssociateCommand : BaseCommand { + public UserAssociateCommand(NotifyCommand notifyCommand) + { + this.notifyCommand = notifyCommand; + } + + private readonly NotifyCommand notifyCommand; private readonly EthAddressOption ethOption = new EthAddressOption(isRequired: false); private readonly UserOption optionalUser = new UserOption( description: "If set, associates Ethereum address for another user. (Optional, admin-only)", @@ -30,7 +36,12 @@ namespace BiblioTech.Commands var result = Program.UserRepo.AssociateUserWithAddress(user, data); if (result) { - await context.Followup("Done! Thank you for joining the test net!"); + await context.Followup(new string[] + { + "Done! Thank you for joining the test net!", + "By default, the bot will @-mention you with test-net reward related notifications.", + $"You can enable/disable this behavior with the '/{notifyCommand.Name}' command." + }); } else { diff --git a/Tools/BiblioTech/Configuration.cs b/Tools/BiblioTech/Configuration.cs index bce62eab..f324d23a 100644 --- a/Tools/BiblioTech/Configuration.cs +++ b/Tools/BiblioTech/Configuration.cs @@ -19,6 +19,10 @@ namespace BiblioTech [Uniform("admin-channel-name", "ac", "ADMINCHANNELNAME", true, "Name of the Discord server channel where admin commands are allowed.")] public string AdminChannelName { get; set; } = "admin-channel"; + [Uniform("rewards-channel-name", "ac", "REWARDSCHANNELNAME", false, "Name of the Discord server channel where participation rewards will be announced.")] + public string RewardsChannelName { get; set; } = ""; + + public string EndpointsPath { get diff --git a/Tools/BiblioTech/Options/BoolOption.cs b/Tools/BiblioTech/Options/BoolOption.cs new file mode 100644 index 00000000..6318ee16 --- /dev/null +++ b/Tools/BiblioTech/Options/BoolOption.cs @@ -0,0 +1,23 @@ +using Discord; + +namespace BiblioTech.Options +{ + public class BoolOption : CommandOption + { + public BoolOption(string name, string description, bool isRequired) + : base(name, description, type: ApplicationCommandOptionType.Boolean, isRequired) + { + } + + public async Task Parse(CommandContext context) + { + var bData = context.Options.SingleOrDefault(o => o.Name == Name); + if (bData == null || !(bData.Value is bool)) + { + await context.Followup("Bool option not received."); + return null; + } + return (bool) bData.Value; + } + } +} diff --git a/Tools/BiblioTech/Program.cs b/Tools/BiblioTech/Program.cs index 22e68f8b..7d6d6900 100644 --- a/Tools/BiblioTech/Program.cs +++ b/Tools/BiblioTech/Program.cs @@ -21,7 +21,7 @@ namespace BiblioTech Config = uniformArgs.Parse(); Log = new LogSplitter( - new FileLog(Path.Combine(Config.LogPath, "discordbot.log")), + new FileLog(Path.Combine(Config.LogPath, "discordbot")), new ConsoleLog() ); @@ -38,13 +38,15 @@ namespace BiblioTech client = new DiscordSocketClient(); client.Log += ClientLog; - var associateCommand = new UserAssociateCommand(); + var notifyCommand = new NotifyCommand(); + var associateCommand = new UserAssociateCommand(notifyCommand); var sprCommand = new SprCommand(); var handler = new CommandHandler(client, new GetBalanceCommand(associateCommand), new MintCommand(associateCommand), sprCommand, associateCommand, + notifyCommand, new AdminCommand(sprCommand) ); diff --git a/Tools/BiblioTech/Rewards/RewardsApi.cs b/Tools/BiblioTech/Rewards/RewardsApi.cs new file mode 100644 index 00000000..b5344907 --- /dev/null +++ b/Tools/BiblioTech/Rewards/RewardsApi.cs @@ -0,0 +1,92 @@ +using DiscordRewards; +using Newtonsoft.Json; +using System.Net; +using TaskFactory = Utils.TaskFactory; + +namespace BiblioTech.Rewards +{ + public interface IDiscordRoleController + { + Task GiveRewards(GiveRewardsCommand rewards); + } + + public class RewardsApi + { + private readonly HttpListener listener = new HttpListener(); + private readonly TaskFactory taskFactory = new TaskFactory(); + private readonly IDiscordRoleController roleController; + private CancellationTokenSource cts = new CancellationTokenSource(); + + public RewardsApi(IDiscordRoleController roleController) + { + this.roleController = roleController; + } + + public void Start() + { + cts = new CancellationTokenSource(); + listener.Prefixes.Add($"http://*:31080/"); + listener.Start(); + taskFactory.Run(ConnectionDispatcher, nameof(ConnectionDispatcher)); + } + + public void Stop() + { + listener.Stop(); + cts.Cancel(); + taskFactory.WaitAll(); + } + + private void ConnectionDispatcher() + { + while (!cts.Token.IsCancellationRequested) + { + var wait = listener.GetContextAsync(); + wait.Wait(cts.Token); + if (wait.IsCompletedSuccessfully) + { + taskFactory.Run(() => + { + var context = wait.Result; + try + { + HandleConnection(context).Wait(); + } + catch (Exception ex) + { + Program.Log.Error("Exception during HTTP handler: " + ex); + } + // Whatever happens, everything's always OK. + context.Response.StatusCode = 200; + context.Response.OutputStream.Close(); + }, nameof(HandleConnection)); + } + } + } + + private async Task HandleConnection(HttpListenerContext context) + { + using var reader = new StreamReader(context.Request.InputStream); + var content = reader.ReadToEnd(); + + if (content == "Ping") + { + using var writer = new StreamWriter(context.Response.OutputStream); + writer.Write("Pong"); + return; + } + + if (!content.StartsWith("{")) return; + var rewards = JsonConvert.DeserializeObject(content); + if (rewards != null) + { + await ProcessRewards(rewards); + } + } + + private async Task ProcessRewards(GiveRewardsCommand rewards) + { + await roleController.GiveRewards(rewards); + } + } +} diff --git a/Tools/BiblioTech/Rewards/RoleController.cs b/Tools/BiblioTech/Rewards/RoleController.cs new file mode 100644 index 00000000..e59e8045 --- /dev/null +++ b/Tools/BiblioTech/Rewards/RoleController.cs @@ -0,0 +1,218 @@ +using Discord; +using Discord.WebSocket; +using DiscordRewards; + +namespace BiblioTech.Rewards +{ + public class RoleController : IDiscordRoleController + { + private readonly DiscordSocketClient client; + private readonly SocketTextChannel? rewardsChannel; + private readonly RewardRepo repo = new RewardRepo(); + + public RoleController(DiscordSocketClient client) + { + this.client = client; + + if (!string.IsNullOrEmpty(Program.Config.RewardsChannelName)) + { + rewardsChannel = GetGuild().TextChannels.SingleOrDefault(c => c.Name == Program.Config.RewardsChannelName); + } + } + + public async Task GiveRewards(GiveRewardsCommand rewards) + { + var guild = GetGuild(); + // We load all role and user information first, + // so we don't ask the server for the same info multiple times. + var context = new RewardContext( + await LoadAllUsers(guild), + LookUpAllRoles(guild, rewards), + rewardsChannel); + + await context.ProcessGiveRewardsCommand(LookUpUsers(rewards)); + } + + private async Task> LoadAllUsers(SocketGuild guild) + { + var result = new Dictionary(); + var users = guild.GetUsersAsync(); + await foreach (var ulist in users) + { + foreach (var u in ulist) + { + result.Add(u.Id, u); + } + } + return result; + } + + private Dictionary LookUpAllRoles(SocketGuild guild, GiveRewardsCommand rewards) + { + var result = new Dictionary(); + foreach (var r in rewards.Rewards) + { + if (!result.ContainsKey(r.RewardId)) + { + var rewardConfig = repo.Rewards.SingleOrDefault(rr => rr.RoleId == r.RewardId); + if (rewardConfig == null) + { + Program.Log.Log($"No Reward is configured for id '{r.RewardId}'."); + } + else + { + var socketRole = guild.GetRole(r.RewardId); + if (socketRole == null) + { + Program.Log.Log($"Guild Role by id '{r.RewardId}' not found."); + } + else + { + result.Add(r.RewardId, new RoleReward(socketRole, rewardConfig)); + } + } + } + } + + return result; + } + + private UserReward[] LookUpUsers(GiveRewardsCommand rewards) + { + return rewards.Rewards.Select(LookUpUserData).ToArray(); + } + + private UserReward LookUpUserData(RewardUsersCommand command) + { + return new UserReward(command, + command.UserAddresses + .Select(LookUpUserDataForAddress) + .Where(d => d != null) + .Cast() + .ToArray()); + } + + private UserData? LookUpUserDataForAddress(string address) + { + try + { + return Program.UserRepo.GetUserDataForAddress(new GethPlugin.EthAddress(address)); + } + catch (Exception ex) + { + Program.Log.Error("Error during UserData lookup: " + ex); + return null; + } + } + + private SocketGuild GetGuild() + { + return client.Guilds.Single(g => g.Name == Program.Config.ServerName); + } + } + + public class RoleReward + { + public RoleReward(SocketRole socketRole, RewardConfig reward) + { + SocketRole = socketRole; + Reward = reward; + } + + public SocketRole SocketRole { get; } + public RewardConfig Reward { get; } + } + + public class UserReward + { + public UserReward(RewardUsersCommand rewardCommand, UserData[] users) + { + RewardCommand = rewardCommand; + Users = users; + } + + public RewardUsersCommand RewardCommand { get; } + public UserData[] Users { get; } + } + + public class RewardContext + { + private readonly Dictionary users; + private readonly Dictionary roles; + private readonly SocketTextChannel? rewardsChannel; + + public RewardContext(Dictionary users, Dictionary roles, SocketTextChannel? rewardsChannel) + { + this.users = users; + this.roles = roles; + this.rewardsChannel = rewardsChannel; + } + + public async Task ProcessGiveRewardsCommand(UserReward[] rewards) + { + foreach (var rewardCommand in rewards) + { + if (roles.ContainsKey(rewardCommand.RewardCommand.RewardId)) + { + var role = roles[rewardCommand.RewardCommand.RewardId]; + await ProcessRewardCommand(role, rewardCommand); + } + } + } + + private async Task ProcessRewardCommand(RoleReward role, UserReward reward) + { + foreach (var user in reward.Users) + { + await GiveReward(role, user); + } + } + + private async Task GiveReward(RoleReward role, UserData user) + { + if (!users.ContainsKey(user.DiscordId)) + { + Program.Log.Log($"User by id '{user.DiscordId}' not found."); + return; + } + + var guildUser = users[user.DiscordId]; + + var alreadyHas = guildUser.RoleIds.ToArray(); + if (alreadyHas.Any(r => r == role.Reward.RoleId)) return; + + await GiveRole(guildUser, role.SocketRole); + await SendNotification(role, user, guildUser); + await Task.Delay(1000); + } + + private async Task GiveRole(IGuildUser user, SocketRole role) + { + try + { + Program.Log.Log($"Giving role {role.Name}={role.Id} to user {user.DisplayName}"); + await user.AddRoleAsync(role); + } + catch (Exception ex) + { + Program.Log.Error($"Failed to give role '{role.Name}' to user '{user.DisplayName}': {ex}"); + } + } + + private async Task SendNotification(RoleReward reward, UserData userData, IGuildUser user) + { + try + { + if (userData.NotificationsEnabled && rewardsChannel != null) + { + var msg = reward.Reward.Message.Replace(RewardConfig.UsernameTag, $"<@{user.Id}>"); + await rewardsChannel.SendMessageAsync(msg); + } + } + catch (Exception ex) + { + Program.Log.Error($"Failed to notify user '{user.DisplayName}' about role '{reward.SocketRole.Name}': {ex}"); + } + } + } +} diff --git a/Tools/BiblioTech/UserRepo.cs b/Tools/BiblioTech/UserRepo.cs index 23b25d53..01afbe90 100644 --- a/Tools/BiblioTech/UserRepo.cs +++ b/Tools/BiblioTech/UserRepo.cs @@ -25,6 +25,14 @@ namespace BiblioTech } } + public void SetUserNotificationPreference(IUser user, bool enableNotifications) + { + lock (repoLock) + { + SetUserNotification(user, enableNotifications); + } + } + public void AddMintEventForUser(IUser user, EthAddress usedAddress, Transaction? eth, Transaction? tokens) { lock (repoLock) @@ -96,6 +104,29 @@ namespace BiblioTech return userData.CreateOverview(); } + public UserData? GetUserDataForAddress(EthAddress? address) + { + if (address == null) return null; + + // If this becomes a performance problem, switch to in-memory cached list. + var files = Directory.GetFiles(Program.Config.UserDataPath); + foreach (var file in files) + { + try + { + var user = JsonConvert.DeserializeObject(File.ReadAllText(file))!; + if (user.CurrentAddress != null && + user.CurrentAddress.Address == address.Address) + { + return user; + } + } + catch { } + } + + return null; + } + private bool SetUserAddress(IUser user, EthAddress? address) { if (GetUserDataForAddress(address) != null) @@ -110,6 +141,14 @@ namespace BiblioTech return true; } + private void SetUserNotification(IUser user, bool notifyEnabled) + { + var userData = GetUserData(user); + if (userData == null) return; + userData.NotificationsEnabled = notifyEnabled; + SaveUserData(userData); + } + private UserData? GetUserData(IUser user) { var filename = GetFilename(user); @@ -132,34 +171,11 @@ namespace BiblioTech private UserData CreateAndSaveNewUserData(IUser user) { - var newUser = new UserData(user.Id, user.GlobalName, DateTime.UtcNow, null, new List(), new List()); + var newUser = new UserData(user.Id, user.GlobalName, DateTime.UtcNow, null, new List(), new List(), true); SaveUserData(newUser); return newUser; } - private UserData? GetUserDataForAddress(EthAddress? address) - { - if (address == null) return null; - - // If this becomes a performance problem, switch to in-memory cached list. - var files = Directory.GetFiles(Program.Config.UserDataPath); - foreach (var file in files) - { - try - { - var user = JsonConvert.DeserializeObject(File.ReadAllText(file))!; - if (user.CurrentAddress != null && - user.CurrentAddress.Address == address.Address) - { - return user; - } - } - catch { } - } - - return null; - } - private void SaveUserData(UserData userData) { var filename = GetFilename(userData); @@ -185,7 +201,7 @@ namespace BiblioTech public class UserData { - public UserData(ulong discordId, string name, DateTime createdUtc, EthAddress? currentAddress, List associateEvents, List mintEvents) + public UserData(ulong discordId, string name, DateTime createdUtc, EthAddress? currentAddress, List associateEvents, List mintEvents, bool notificationsEnabled) { DiscordId = discordId; Name = name; @@ -193,6 +209,7 @@ namespace BiblioTech CurrentAddress = currentAddress; AssociateEvents = associateEvents; MintEvents = mintEvents; + NotificationsEnabled = notificationsEnabled; } public ulong DiscordId { get; } @@ -201,6 +218,7 @@ namespace BiblioTech public EthAddress? CurrentAddress { get; set; } public List AssociateEvents { get; } public List MintEvents { get; } + public bool NotificationsEnabled { get; set; } public string[] CreateOverview() { diff --git a/Tools/TestNetRewarder/BotClient.cs b/Tools/TestNetRewarder/BotClient.cs new file mode 100644 index 00000000..91e6c834 --- /dev/null +++ b/Tools/TestNetRewarder/BotClient.cs @@ -0,0 +1,49 @@ +using DiscordRewards; +using Logging; +using Newtonsoft.Json; + +namespace TestNetRewarder +{ + public class BotClient + { + private readonly Configuration configuration; + private readonly ILog log; + + public BotClient(Configuration configuration, ILog log) + { + this.configuration = configuration; + this.log = log; + } + + public async Task IsOnline() + { + return await HttpPost("Ping") == "Ping"; + } + + public async Task SendRewards(GiveRewardsCommand command) + { + if (command == null || command.Rewards == null || !command.Rewards.Any()) return; + await HttpPost(JsonConvert.SerializeObject(command)); + } + + private async Task HttpPost(string content) + { + try + { + var client = new HttpClient(); + var response = await client.PostAsync(GetUrl(), new StringContent(content)); + return await response.Content.ReadAsStringAsync(); + } + catch (Exception ex) + { + log.Error(ex.ToString()); + return string.Empty; + } + } + + private string GetUrl() + { + return $"{configuration.DiscordHost}:{configuration.DiscordPort}"; + } + } +} diff --git a/Tools/TestNetRewarder/ChainState.cs b/Tools/TestNetRewarder/ChainState.cs new file mode 100644 index 00000000..13356fcf --- /dev/null +++ b/Tools/TestNetRewarder/ChainState.cs @@ -0,0 +1,35 @@ +using CodexContractsPlugin; +using CodexContractsPlugin.Marketplace; +using Utils; + +namespace TestNetRewarder +{ + public class ChainState + { + private readonly HistoricState historicState; + + public ChainState(HistoricState historicState, ICodexContracts contracts, TimeRange timeRange) + { + NewRequests = contracts.GetStorageRequests(timeRange); + historicState.ProcessNewRequests(NewRequests); + historicState.UpdateStorageRequests(contracts); + + StartedRequests = historicState.StorageRequests.Where(r => r.RecentlyStarted).ToArray(); + FinishedRequests = historicState.StorageRequests.Where(r => r.RecentlyFininshed).ToArray(); + RequestFulfilledEvents = contracts.GetRequestFulfilledEvents(timeRange); + RequestCancelledEvents = contracts.GetRequestCancelledEvents(timeRange); + SlotFilledEvents = contracts.GetSlotFilledEvents(timeRange); + SlotFreedEvents = contracts.GetSlotFreedEvents(timeRange); + this.historicState = historicState; + } + + public Request[] NewRequests { get; } + public StorageRequest[] AllRequests => historicState.StorageRequests; + public StorageRequest[] StartedRequests { get; private set; } + public StorageRequest[] FinishedRequests { get; private set; } + public RequestFulfilledEventDTO[] RequestFulfilledEvents { get; } + public RequestCancelledEventDTO[] RequestCancelledEvents { get; } + public SlotFilledEventDTO[] SlotFilledEvents { get; } + public SlotFreedEventDTO[] SlotFreedEvents { get; } + } +} diff --git a/Tools/TestNetRewarder/Checks.cs b/Tools/TestNetRewarder/Checks.cs new file mode 100644 index 00000000..43102e06 --- /dev/null +++ b/Tools/TestNetRewarder/Checks.cs @@ -0,0 +1,141 @@ +using CodexContractsPlugin.Marketplace; +using GethPlugin; +using NethereumWorkflow; +using Utils; + +namespace TestNetRewarder +{ + public interface ICheck + { + EthAddress[] Check(ChainState state); + } + + public class FilledAnySlotCheck : ICheck + { + public EthAddress[] Check(ChainState state) + { + return state.SlotFilledEvents.Select(e => e.Host).ToArray(); + } + } + + public class FinishedSlotCheck : ICheck + { + private readonly ByteSize minSize; + private readonly TimeSpan minDuration; + + public FinishedSlotCheck(ByteSize minSize, TimeSpan minDuration) + { + this.minSize = minSize; + this.minDuration = minDuration; + } + + public EthAddress[] Check(ChainState state) + { + return state.FinishedRequests + .Where(r => + MeetsSizeRequirement(r) && + MeetsDurationRequirement(r)) + .SelectMany(r => r.Hosts) + .ToArray(); + } + + private bool MeetsSizeRequirement(StorageRequest r) + { + var slotSize = r.Request.Ask.SlotSize.ToDecimal(); + decimal min = minSize.SizeInBytes; + return slotSize >= min; + } + + private bool MeetsDurationRequirement(StorageRequest r) + { + var duration = TimeSpan.FromSeconds((double)r.Request.Ask.Duration); + return duration >= minDuration; + } + } + + public class PostedContractCheck : ICheck + { + private readonly ulong minNumberOfHosts; + private readonly ByteSize minSlotSize; + private readonly TimeSpan minDuration; + + public PostedContractCheck(ulong minNumberOfHosts, ByteSize minSlotSize, TimeSpan minDuration) + { + this.minNumberOfHosts = minNumberOfHosts; + this.minSlotSize = minSlotSize; + this.minDuration = minDuration; + } + + public EthAddress[] Check(ChainState state) + { + return state.NewRequests + .Where(r => + MeetsNumSlotsRequirement(r) && + MeetsSizeRequirement(r) && + MeetsDurationRequirement(r)) + .Select(r => r.ClientAddress) + .ToArray(); + } + + private bool MeetsNumSlotsRequirement(Request r) + { + return r.Ask.Slots >= minNumberOfHosts; + } + + private bool MeetsSizeRequirement(Request r) + { + var slotSize = r.Ask.SlotSize.ToDecimal(); + decimal min = minSlotSize.SizeInBytes; + return slotSize >= min; + } + + private bool MeetsDurationRequirement(Request r) + { + var duration = TimeSpan.FromSeconds((double)r.Ask.Duration); + return duration >= minDuration; + } + } + + public class StartedContractCheck : ICheck + { + private readonly ulong minNumberOfHosts; + private readonly ByteSize minSlotSize; + private readonly TimeSpan minDuration; + + public StartedContractCheck(ulong minNumberOfHosts, ByteSize minSlotSize, TimeSpan minDuration) + { + this.minNumberOfHosts = minNumberOfHosts; + this.minSlotSize = minSlotSize; + this.minDuration = minDuration; + } + + public EthAddress[] Check(ChainState state) + { + return state.StartedRequests + .Where(r => + MeetsNumSlotsRequirement(r) && + MeetsSizeRequirement(r) && + MeetsDurationRequirement(r)) + .Select(r => r.Request.ClientAddress) + .ToArray(); + } + + private bool MeetsNumSlotsRequirement(StorageRequest r) + { + return r.Request.Ask.Slots >= minNumberOfHosts; + } + + private bool MeetsSizeRequirement(StorageRequest r) + { + var slotSize = r.Request.Ask.SlotSize.ToDecimal(); + decimal min = minSlotSize.SizeInBytes; + return slotSize >= min; + } + + private bool MeetsDurationRequirement(StorageRequest r) + { + var duration = TimeSpan.FromSeconds((double)r.Request.Ask.Duration); + return duration >= minDuration; + } + } +} diff --git a/Tools/TestNetRewarder/Configuration.cs b/Tools/TestNetRewarder/Configuration.cs new file mode 100644 index 00000000..02401d2e --- /dev/null +++ b/Tools/TestNetRewarder/Configuration.cs @@ -0,0 +1,30 @@ +using ArgsUniform; + +namespace TestNetRewarder +{ + public class Configuration + { + [Uniform("datapath", "dp", "DATAPATH", false, "Root path where all data files will be saved.")] + public string DataPath { get; set; } = "datapath"; + + [Uniform("discordbot-host", "dh", "DISCORDBOTHOST", true, "http address of the discord bot.")] + public string DiscordHost { get; set; } = "host"; + + [Uniform("discordbot-port", "dp", "DISCORDBOTPORT", true, "port number of the discord bot reward API. (31080 by default)")] + public int DiscordPort { get; set; } = 31080; + + [Uniform("interval-minutes", "im", "INTERVALMINUTES", false, "time in minutes between reward updates. (default 15)")] + public int Interval { get; set; } = 15; + + [Uniform("check-history", "ch", "CHECKHISTORY", true, "Unix epoc timestamp of a moment in history on which processing begins. Required for hosting rewards. Should be 'launch of the testnet'.")] + public int CheckHistoryTimestamp { get; set; } = 0; + + public string LogPath + { + get + { + return Path.Combine(DataPath, "logs"); + } + } + } +} diff --git a/Tools/TestNetRewarder/HistoricState.cs b/Tools/TestNetRewarder/HistoricState.cs new file mode 100644 index 00000000..b1ac6996 --- /dev/null +++ b/Tools/TestNetRewarder/HistoricState.cs @@ -0,0 +1,68 @@ +using CodexContractsPlugin; +using CodexContractsPlugin.Marketplace; +using GethPlugin; + +namespace TestNetRewarder +{ + public class HistoricState + { + private readonly List storageRequests = new List(); + + public StorageRequest[] StorageRequests { get { return storageRequests.ToArray(); } } + + public void ProcessNewRequests(Request[] requests) + { + storageRequests.AddRange(requests.Select(r => new StorageRequest(r))); + } + + public void UpdateStorageRequests(ICodexContracts contracts) + { + foreach (var r in storageRequests) r.Update(contracts); + } + } + + public class StorageRequest + { + public StorageRequest(Request request) + { + Request = request; + Hosts = Array.Empty(); + } + + public Request Request { get; } + public EthAddress[] Hosts { get; private set; } + public RequestState State { get; private set; } + public bool RecentlyStarted { get; private set; } + public bool RecentlyFininshed { get; private set; } + + public void Update(ICodexContracts contracts) + { + Hosts = GetHosts(contracts); + + var newState = contracts.GetRequestState(Request); + + RecentlyStarted = + State == RequestState.New && + newState == RequestState.Started; + + RecentlyFininshed = + State == RequestState.Started && + newState == RequestState.Finished; + + State = newState; + } + + private EthAddress[] GetHosts(ICodexContracts contracts) + { + var result = new List(); + + for (decimal i = 0; i < Request.Ask.Slots; i++) + { + var host = contracts.GetSlotHost(Request, i); + if (host != null) result.Add(host); + } + + return result.ToArray(); + } + } +} diff --git a/Tools/TestNetRewarder/Processor.cs b/Tools/TestNetRewarder/Processor.cs new file mode 100644 index 00000000..e56486bb --- /dev/null +++ b/Tools/TestNetRewarder/Processor.cs @@ -0,0 +1,98 @@ +using DiscordRewards; +using GethPlugin; +using Logging; +using Newtonsoft.Json; +using Utils; + +namespace TestNetRewarder +{ + public class Processor + { + private static readonly HistoricState historicState = new HistoricState(); + private static readonly RewardRepo rewardRepo = new RewardRepo(); + private readonly ILog log; + + public Processor(ILog log) + { + this.log = log; + } + + public async Task ProcessTimeSegment(TimeRange range) + { + try + { + var connector = GethConnector.GethConnector.Initialize(log); + if (connector == null) return; + + var chainState = new ChainState(historicState, connector.CodexContracts, range); + await ProcessTimeSegment(chainState); + + } + catch (Exception ex) + { + log.Error("Exception processing time segment: " + ex); + } + } + + private async Task ProcessTimeSegment(ChainState chainState) + { + var outgoingRewards = new List(); + foreach (var reward in rewardRepo.Rewards) + { + ProcessReward(outgoingRewards, reward, chainState); + } + + if (outgoingRewards.Any()) + { + await SendRewardsCommand(outgoingRewards); + } + } + + private async Task SendRewardsCommand(List outgoingRewards) + { + var cmd = new GiveRewardsCommand + { + Rewards = outgoingRewards.ToArray() + }; + + log.Debug("Sending rewards: " + JsonConvert.SerializeObject(cmd)); + await Program.BotClient.SendRewards(cmd); + } + + private void ProcessReward(List outgoingRewards, RewardConfig reward, ChainState chainState) + { + var winningAddresses = PerformCheck(reward, chainState); + if (winningAddresses.Any()) + { + outgoingRewards.Add(new RewardUsersCommand + { + RewardId = reward.RoleId, + UserAddresses = winningAddresses.Select(a => a.Address).ToArray() + }); + } + } + + private EthAddress[] PerformCheck(RewardConfig reward, ChainState chainState) + { + var check = GetCheck(reward.CheckConfig); + return check.Check(chainState); + } + + private ICheck GetCheck(CheckConfig config) + { + switch (config.Type) + { + case CheckType.FilledSlot: + return new FilledAnySlotCheck(); + case CheckType.FinishedSlot: + return new FinishedSlotCheck(config.MinSlotSize, config.MinDuration); + case CheckType.PostedContract: + return new PostedContractCheck(config.MinNumberOfHosts, config.MinSlotSize, config.MinDuration); + case CheckType.StartedContract: + return new StartedContractCheck(config.MinNumberOfHosts, config.MinSlotSize, config.MinDuration); + } + + throw new Exception("Unknown check type: " + config.Type); + } + } +} diff --git a/Tools/TestNetRewarder/Program.cs b/Tools/TestNetRewarder/Program.cs new file mode 100644 index 00000000..3d10cff2 --- /dev/null +++ b/Tools/TestNetRewarder/Program.cs @@ -0,0 +1,92 @@ +using ArgsUniform; +using GethConnector; +using Logging; +using Utils; + +namespace TestNetRewarder +{ + public class Program + { + public static Configuration Config { get; private set; } = null!; + public static ILog Log { get; private set; } = null!; + public static CancellationToken CancellationToken { get; private set; } + public static BotClient BotClient { get; private set; } = null!; + private static Processor processor = null!; + + public static Task Main(string[] args) + { + var cts = new CancellationTokenSource(); + CancellationToken = cts.Token; + Console.CancelKeyPress += (sender, args) => cts.Cancel(); + + var uniformArgs = new ArgsUniform(PrintHelp, args); + Config = uniformArgs.Parse(true); + + Log = new LogSplitter( + new FileLog(Path.Combine(Config.LogPath, "testnetrewarder")), + new ConsoleLog() + ); + + BotClient = new BotClient(Config, Log); + processor = new Processor(Log); + + EnsurePath(Config.DataPath); + EnsurePath(Config.LogPath); + + return new Program().MainAsync(); + } + + public async Task MainAsync() + { + EnsureGethOnline(); + + Log.Log("Starting TestNet Rewarder..."); + var segmenter = new TimeSegmenter(Log, Config); + + while (!CancellationToken.IsCancellationRequested) + { + await EnsureBotOnline(); + await segmenter.WaitForNextSegment(processor.ProcessTimeSegment); + await Task.Delay(1000, CancellationToken); + } + } + + private static void EnsureGethOnline() + { + Log.Log("Checking Geth..."); + var gc = GethConnector.GethConnector.Initialize(Log); + if (gc == null) throw new Exception("Geth input incorrect"); + + var blockNumber = gc.GethNode.GetSyncedBlockNumber(); + if (blockNumber == null || blockNumber < 1) throw new Exception("Geth connection failed."); + } + + private static async Task EnsureBotOnline() + { + var start = DateTime.UtcNow; + while (! await BotClient.IsOnline() && !CancellationToken.IsCancellationRequested) + { + await Task.Delay(5000); + + var elapsed = DateTime.UtcNow - start; + if (elapsed.TotalMinutes > 10) + { + var msg = "Unable to connect to bot for " + Time.FormatDuration(elapsed); + Log.Error(msg); + throw new Exception(msg); + } + } + } + + private static void PrintHelp() + { + Log.Log("TestNet Rewarder"); + } + + private static void EnsurePath(string path) + { + if (Directory.Exists(path)) return; + Directory.CreateDirectory(path); + } + } +} diff --git a/Tools/TestNetRewarder/TestNetRewarder.csproj b/Tools/TestNetRewarder/TestNetRewarder.csproj new file mode 100644 index 00000000..e4e74633 --- /dev/null +++ b/Tools/TestNetRewarder/TestNetRewarder.csproj @@ -0,0 +1,17 @@ + + + + Exe + net7.0 + enable + enable + + + + + + + + + + diff --git a/Tools/TestNetRewarder/TimeSegmenter.cs b/Tools/TestNetRewarder/TimeSegmenter.cs new file mode 100644 index 00000000..a9ad71a8 --- /dev/null +++ b/Tools/TestNetRewarder/TimeSegmenter.cs @@ -0,0 +1,51 @@ +using Logging; +using Utils; + +namespace TestNetRewarder +{ + public class TimeSegmenter + { + private readonly ILog log; + private readonly TimeSpan segmentSize; + private DateTime start; + + public TimeSegmenter(ILog log, Configuration configuration) + { + this.log = log; + + if (configuration.Interval < 0) configuration.Interval = 15; + if (configuration.CheckHistoryTimestamp == 0) throw new Exception("'check-history' unix timestamp is required. Set it to the start/launch moment of the testnet."); + + segmentSize = TimeSpan.FromSeconds(configuration.Interval); + start = DateTimeOffset.FromUnixTimeSeconds(configuration.CheckHistoryTimestamp).UtcDateTime; + + log.Log("Starting time segments at " + start); + log.Log("Segment size: " + Time.FormatDuration(segmentSize)); + } + + public async Task WaitForNextSegment(Func onSegment) + { + var now = DateTime.UtcNow; + var end = start + segmentSize; + var waited = false; + if (end > now) + { + // Wait for the entire time segment to be in the past. + var delay = (end - now).Add(TimeSpan.FromSeconds(3)); + waited = true; + await Task.Delay(delay, Program.CancellationToken); + } + + if (Program.CancellationToken.IsCancellationRequested) return; + + var postfix = "(Catching up...)"; + if (waited) postfix = "(Real-time)"; + + log.Log($"Time segment [{start} to {end}] {postfix}"); + var range = new TimeRange(start, end); + start = end; + + await onSegment(range); + } + } +} diff --git a/Tools/TestNetRewarder/docker/Dockerfile b/Tools/TestNetRewarder/docker/Dockerfile new file mode 100644 index 00000000..1a96c3f2 --- /dev/null +++ b/Tools/TestNetRewarder/docker/Dockerfile @@ -0,0 +1,13 @@ +# Variables +ARG IMAGE=mcr.microsoft.com/dotnet/sdk:7.0 +ARG APP_HOME=/app + +# Create +FROM ${IMAGE} +ARG APP_HOME + +WORKDIR ${APP_HOME} +COPY ./Tools/TestNetRewarder ./Tools/TestNetRewarder +COPY ./Framework ./Framework +COPY ./ProjectPlugins ./ProjectPlugins +CMD ["dotnet", "run", "--project", "Tools/TestNetRewarder"] diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 6b879f19..3a403fe3 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -53,6 +53,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DeployAndRunPlugin", "Proje EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FrameworkTests", "Tests\FrameworkTests\FrameworkTests.csproj", "{25E72004-4D71-4D1E-A193-FC125D12FF96}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestNetRewarder", "Tools\TestNetRewarder\TestNetRewarder.csproj", "{570C0DBE-0EF1-47B5-9A3B-E1F7895722A5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GethConnector", "Framework\GethConnector\GethConnector.csproj", "{F730DA73-1C92-4107-BCFB-D33759DAB0C3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordRewards", "Framework\DiscordRewards\DiscordRewards.csproj", "{B07820C4-309F-4454-BCC1-1D4902C9C67B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -143,6 +149,18 @@ Global {25E72004-4D71-4D1E-A193-FC125D12FF96}.Debug|Any CPU.Build.0 = Debug|Any CPU {25E72004-4D71-4D1E-A193-FC125D12FF96}.Release|Any CPU.ActiveCfg = Release|Any CPU {25E72004-4D71-4D1E-A193-FC125D12FF96}.Release|Any CPU.Build.0 = Release|Any CPU + {570C0DBE-0EF1-47B5-9A3B-E1F7895722A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {570C0DBE-0EF1-47B5-9A3B-E1F7895722A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {570C0DBE-0EF1-47B5-9A3B-E1F7895722A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {570C0DBE-0EF1-47B5-9A3B-E1F7895722A5}.Release|Any CPU.Build.0 = Release|Any CPU + {F730DA73-1C92-4107-BCFB-D33759DAB0C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F730DA73-1C92-4107-BCFB-D33759DAB0C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F730DA73-1C92-4107-BCFB-D33759DAB0C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F730DA73-1C92-4107-BCFB-D33759DAB0C3}.Release|Any CPU.Build.0 = Release|Any CPU + {B07820C4-309F-4454-BCC1-1D4902C9C67B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B07820C4-309F-4454-BCC1-1D4902C9C67B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B07820C4-309F-4454-BCC1-1D4902C9C67B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B07820C4-309F-4454-BCC1-1D4902C9C67B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -169,6 +187,9 @@ Global {3E38A906-C2FC-43DC-8CA2-FC07C79CF3CA} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3} {1CC5AF82-8924-4C7E-BFF1-3125D86E53FB} = {8F1F1C2A-E313-4E0C-BE40-58FB0BA91124} {25E72004-4D71-4D1E-A193-FC125D12FF96} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} + {570C0DBE-0EF1-47B5-9A3B-E1F7895722A5} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3} + {F730DA73-1C92-4107-BCFB-D33759DAB0C3} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {B07820C4-309F-4454-BCC1-1D4902C9C67B} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {237BF0AA-9EC4-4659-AD9A-65DEB974250C}