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}