Merge branch 'feature/bot-upgrade'

# Conflicts:
#	Tests/CodexTests/BasicTests/MarketplaceTests.cs
This commit is contained in:
benbierens 2024-04-01 15:32:43 +02:00
commit f5291517c1
No known key found for this signature in database
GPG Key ID: 877D2C2E09A22F3A
33 changed files with 527 additions and 228 deletions

View File

@ -21,11 +21,11 @@ namespace DiscordRewards
}),
// Finished a sizable slot
new RewardConfig(1202286218738405418, $"{Tag} finished their first 1GB-24h slot!", new CheckConfig
new RewardConfig(1202286218738405418, $"{Tag} finished their first 1GB-24h slot! (10mb/5mins for test)", new CheckConfig
{
Type = CheckType.FinishedSlot,
MinSlotSize = 1.GB(),
MinDuration = TimeSpan.FromHours(24.0),
MinSlotSize = 10.MB(),
MinDuration = TimeSpan.FromMinutes(5.0),
}),
// Posted any contract
@ -41,12 +41,12 @@ namespace DiscordRewards
}),
// Started a sizable contract
new RewardConfig(1202286381670608909, $"A large contract created by {Tag} reached Started state for the first time!", new CheckConfig
new RewardConfig(1202286381670608909, $"A large contract created by {Tag} reached Started state for the first time! (10mb/5mins for test)", new CheckConfig
{
Type = CheckType.FinishedSlot,
Type = CheckType.StartedContract,
MinNumberOfHosts = 4,
MinSlotSize = 1.GB(),
MinDuration = TimeSpan.FromHours(24.0),
MinSlotSize = 10.MB(),
MinDuration = TimeSpan.FromMinutes(5.0),
})
};
}

View File

@ -35,5 +35,7 @@
if (!entries.TryGetValue(number, out BlockTimeEntry? value)) return null;
return value;
}
public int Size { get { return entries.Count; } }
}
}

View File

@ -24,7 +24,7 @@ namespace NethereumWorkflow.BlockUtils
if (moment <= bounds.Genesis.Utc) return null;
if (moment >= bounds.Current.Utc) return bounds.Current.BlockNumber;
return Search(bounds.Genesis, bounds.Current, moment, HighestBeforeSelector);
return Log(() => Search(bounds.Genesis, bounds.Current, moment, HighestBeforeSelector));
}
public ulong? GetLowestBlockNumberAfter(DateTime moment)
@ -33,7 +33,16 @@ namespace NethereumWorkflow.BlockUtils
if (moment >= bounds.Current.Utc) return null;
if (moment <= bounds.Genesis.Utc) return bounds.Genesis.BlockNumber;
return Search(bounds.Genesis, bounds.Current, moment, LowestAfterSelector);
return Log(()=> Search(bounds.Genesis, bounds.Current, moment, LowestAfterSelector)); ;
}
private ulong Log(Func<ulong> operation)
{
var sw = Stopwatch.Begin(log, nameof(BlockTimeFinder));
var result = operation();
sw.End($"(Bounds: [{bounds.Genesis.BlockNumber}-{bounds.Current.BlockNumber}] Cache: {cache.Size})");
return result;
}
private ulong Search(BlockTimeEntry lower, BlockTimeEntry upper, DateTime target, Func<DateTime, BlockTimeEntry, bool> isWhatIwant)
@ -70,7 +79,7 @@ namespace NethereumWorkflow.BlockUtils
{
var next = GetBlock(entry.BlockNumber + 1);
return
entry.Utc < target &&
entry.Utc <= target &&
next.Utc > target;
}
@ -78,7 +87,7 @@ namespace NethereumWorkflow.BlockUtils
{
var previous = GetBlock(entry.BlockNumber - 1);
return
entry.Utc > target &&
entry.Utc >= target &&
previous.Utc < target;
}

View File

@ -89,26 +89,9 @@ namespace NethereumWorkflow
}
}
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, TimeRange timeRange) where TEvent : IEventDTO, new()
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, BlockInterval blockRange) where TEvent : IEventDTO, new()
{
var wrapper = new Web3Wrapper(web3, log);
var blockTimeFinder = new BlockTimeFinder(blockCache, wrapper, log);
var fromBlock = blockTimeFinder.GetLowestBlockNumberAfter(timeRange.From);
var toBlock = blockTimeFinder.GetHighestBlockNumberBefore(timeRange.To);
if (!fromBlock.HasValue)
{
log.Error("Failed to find lowest block for time range: " + timeRange);
throw new Exception("Failed");
}
if (!toBlock.HasValue)
{
log.Error("Failed to find highest block for time range: " + timeRange);
throw new Exception("Failed");
}
return GetEvents<TEvent>(address, fromBlock.Value, toBlock.Value);
return GetEvents<TEvent>(address, blockRange.From, blockRange.To);
}
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new()
@ -119,5 +102,24 @@ namespace NethereumWorkflow
var blockFilter = Time.Wait(eventHandler.CreateFilterBlockRangeAsync(from, to));
return Time.Wait(eventHandler.GetAllChangesAsync(blockFilter));
}
public BlockInterval ConvertTimeRangeToBlockRange(TimeRange timeRange)
{
var wrapper = new Web3Wrapper(web3, log);
var blockTimeFinder = new BlockTimeFinder(blockCache, wrapper, log);
var fromBlock = blockTimeFinder.GetLowestBlockNumberAfter(timeRange.From);
var toBlock = blockTimeFinder.GetHighestBlockNumberBefore(timeRange.To);
if (fromBlock == null || toBlock == null)
{
throw new Exception("Failed to convert time range to block range.");
}
return new BlockInterval(
from: fromBlock.Value,
to: toBlock.Value
);
}
}
}

View File

@ -0,0 +1,27 @@
namespace Utils
{
public class BlockInterval
{
public BlockInterval(ulong from, ulong to)
{
if (from < to)
{
From = from;
To = to;
}
else
{
From = to;
To = from;
}
}
public ulong From { get; }
public ulong To { get; }
public override string ToString()
{
return $"[{From} - {To}]";
}
}
}

View File

@ -19,13 +19,13 @@ namespace CodexContractsPlugin
TestToken GetTestTokenBalance(IHasEthAddress owner);
TestToken GetTestTokenBalance(EthAddress ethAddress);
Request[] GetStorageRequests(TimeRange timeRange);
Request[] GetStorageRequests(BlockInterval blockRange);
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);
RequestFulfilledEventDTO[] GetRequestFulfilledEvents(BlockInterval blockRange);
RequestCancelledEventDTO[] GetRequestCancelledEvents(BlockInterval blockRange);
SlotFilledEventDTO[] GetSlotFilledEvents(BlockInterval blockRange);
SlotFreedEventDTO[] GetSlotFreedEvents(BlockInterval blockRange);
}
public enum RequestState
@ -77,9 +77,9 @@ namespace CodexContractsPlugin
return balance.TestTokens();
}
public Request[] GetStorageRequests(TimeRange timeRange)
public Request[] GetStorageRequests(BlockInterval blockRange)
{
var events = gethNode.GetEvents<StorageRequestedEventDTO>(Deployment.MarketplaceAddress, timeRange);
var events = gethNode.GetEvents<StorageRequestedEventDTO>(Deployment.MarketplaceAddress, blockRange);
var i = StartInteraction();
return events
.Select(e =>
@ -93,9 +93,9 @@ namespace CodexContractsPlugin
.ToArray();
}
public RequestFulfilledEventDTO[] GetRequestFulfilledEvents(TimeRange timeRange)
public RequestFulfilledEventDTO[] GetRequestFulfilledEvents(BlockInterval blockRange)
{
var events = gethNode.GetEvents<RequestFulfilledEventDTO>(Deployment.MarketplaceAddress, timeRange);
var events = gethNode.GetEvents<RequestFulfilledEventDTO>(Deployment.MarketplaceAddress, blockRange);
return events.Select(e =>
{
var result = e.Event;
@ -104,9 +104,9 @@ namespace CodexContractsPlugin
}).ToArray();
}
public RequestCancelledEventDTO[] GetRequestCancelledEvents(TimeRange timeRange)
public RequestCancelledEventDTO[] GetRequestCancelledEvents(BlockInterval blockRange)
{
var events = gethNode.GetEvents<RequestCancelledEventDTO>(Deployment.MarketplaceAddress, timeRange);
var events = gethNode.GetEvents<RequestCancelledEventDTO>(Deployment.MarketplaceAddress, blockRange);
return events.Select(e =>
{
var result = e.Event;
@ -115,9 +115,9 @@ namespace CodexContractsPlugin
}).ToArray();
}
public SlotFilledEventDTO[] GetSlotFilledEvents(TimeRange timeRange)
public SlotFilledEventDTO[] GetSlotFilledEvents(BlockInterval blockRange)
{
var events = gethNode.GetEvents<SlotFilledEventDTO>(Deployment.MarketplaceAddress, timeRange);
var events = gethNode.GetEvents<SlotFilledEventDTO>(Deployment.MarketplaceAddress, blockRange);
return events.Select(e =>
{
var result = e.Event;
@ -127,9 +127,9 @@ namespace CodexContractsPlugin
}).ToArray();
}
public SlotFreedEventDTO[] GetSlotFreedEvents(TimeRange timeRange)
public SlotFreedEventDTO[] GetSlotFreedEvents(BlockInterval blockRange)
{
var events = gethNode.GetEvents<SlotFreedEventDTO>(Deployment.MarketplaceAddress, timeRange);
var events = gethNode.GetEvents<SlotFreedEventDTO>(Deployment.MarketplaceAddress, blockRange);
return events.Select(e =>
{
var result = e.Event;

View File

@ -35,6 +35,12 @@ namespace CodexDiscordBotPlugin
return StartContainer(workflow, config);
}
public RunningContainers DeployRewarder(RewarderBotStartupConfig config)
{
var workflow = tools.CreateWorkflow();
return StartRewarderContainer(workflow, config);
}
private RunningContainers StartContainer(IStartupWorkflow workflow, DiscordBotStartupConfig config)
{
var startupConfig = new StartupConfig();
@ -42,5 +48,12 @@ namespace CodexDiscordBotPlugin
startupConfig.Add(config);
return workflow.Start(1, new DiscordBotContainerRecipe(), startupConfig);
}
private RunningContainers StartRewarderContainer(IStartupWorkflow workflow, RewarderBotStartupConfig config)
{
var startupConfig = new StartupConfig();
startupConfig.Add(config);
return workflow.Start(1, new RewarderBotContainerRecipe(), startupConfig);
}
}
}

View File

@ -10,6 +10,11 @@ namespace CodexDiscordBotPlugin
return Plugin(ci).Deploy(config);
}
public static RunningContainers DeployRewarderBot(this CoreInterface ci, RewarderBotStartupConfig config)
{
return Plugin(ci).DeployRewarder(config);
}
private static CodexDiscordBotPlugin Plugin(CoreInterface ci)
{
return ci.GetPlugin<CodexDiscordBotPlugin>();

View File

@ -7,7 +7,9 @@ namespace CodexDiscordBotPlugin
public class DiscordBotContainerRecipe : ContainerRecipeFactory
{
public override string AppName => "discordbot-bibliotech";
public override string Image => "thatbenbierens/codex-discordbot:initial";
public override string Image => "codexstorage/codex-discordbot:sha-8c64352";
public static string RewardsPort = "bot_rewards_port";
protected override void Initialize(StartupConfig startupConfig)
{
@ -19,6 +21,7 @@ namespace CodexDiscordBotPlugin
AddEnvVar("SERVERNAME", config.ServerName);
AddEnvVar("ADMINROLE", config.AdminRoleName);
AddEnvVar("ADMINCHANNELNAME", config.AdminChannelName);
AddEnvVar("REWARDSCHANNELNAME", config.RewardChannelName);
AddEnvVar("KUBECONFIG", "/opt/kubeconfig.yaml");
AddEnvVar("KUBENAMESPACE", config.KubeNamespace);
@ -30,13 +33,13 @@ namespace CodexDiscordBotPlugin
AddEnvVar("CODEXCONTRACTS_TOKENADDRESS", gethInfo.TokenAddress);
AddEnvVar("CODEXCONTRACTS_ABI", gethInfo.Abi);
AddInternalPortAndVar("REWARDAPIPORT", RewardsPort);
if (!string.IsNullOrEmpty(config.DataPath))
{
AddEnvVar("DATAPATH", config.DataPath);
AddVolume(config.DataPath, 1.GB());
}
AddVolume(name: "kubeconfig", mountPath: "/opt/kubeconfig.yaml", subPath: "kubeconfig.yaml", secret: "discordbot-sa-kubeconfig");
}
}
}

View File

@ -2,7 +2,7 @@
{
public class DiscordBotStartupConfig
{
public DiscordBotStartupConfig(string name, string token, string serverName, string adminRoleName, string adminChannelName, string kubeNamespace, DiscordBotGethInfo gethInfo)
public DiscordBotStartupConfig(string name, string token, string serverName, string adminRoleName, string adminChannelName, string kubeNamespace, DiscordBotGethInfo gethInfo, string rewardChannelName)
{
Name = name;
Token = token;
@ -11,6 +11,7 @@
AdminChannelName = adminChannelName;
KubeNamespace = kubeNamespace;
GethInfo = gethInfo;
RewardChannelName = rewardChannelName;
}
public string Name { get; }
@ -18,11 +19,32 @@
public string ServerName { get; }
public string AdminRoleName { get; }
public string AdminChannelName { get; }
public string RewardChannelName { get; }
public string KubeNamespace { get; }
public DiscordBotGethInfo GethInfo { get; }
public string? DataPath { get; set; }
}
public class RewarderBotStartupConfig
{
public RewarderBotStartupConfig(string discordBotHost, int discordBotPort, string interval, DateTime historyStartUtc, DiscordBotGethInfo gethInfo, string? dataPath)
{
DiscordBotHost = discordBotHost;
DiscordBotPort = discordBotPort;
Interval = interval;
HistoryStartUtc = historyStartUtc;
GethInfo = gethInfo;
DataPath = dataPath;
}
public string DiscordBotHost { get; }
public int DiscordBotPort { get; }
public string Interval { get; }
public DateTime HistoryStartUtc { get; }
public DiscordBotGethInfo GethInfo { get; }
public string? DataPath { get; set; }
}
public class DiscordBotGethInfo
{
public DiscordBotGethInfo(string host, int port, string privKey, string marketplaceAddress, string tokenAddress, string abi)

View File

@ -0,0 +1,39 @@
using KubernetesWorkflow.Recipe;
using KubernetesWorkflow;
using Utils;
namespace CodexDiscordBotPlugin
{
public class RewarderBotContainerRecipe : ContainerRecipeFactory
{
public override string AppName => "discordbot-rewarder";
public override string Image => "codexstorage/codex-rewarderbot:sha-2ab84e2";
protected override void Initialize(StartupConfig startupConfig)
{
var config = startupConfig.Get<RewarderBotStartupConfig>();
SetSchedulingAffinity(notIn: "false");
AddEnvVar("DISCORDBOTHOST", config.DiscordBotHost);
AddEnvVar("DISCORDBOTPORT", config.DiscordBotPort.ToString());
AddEnvVar("INTERVALMINUTES", config.Interval);
var offset = new DateTimeOffset(config.HistoryStartUtc);
AddEnvVar("CHECKHISTORY", offset.ToUnixTimeSeconds().ToString());
var gethInfo = config.GethInfo;
AddEnvVar("GETH_HOST", gethInfo.Host);
AddEnvVar("GETH_HTTP_PORT", gethInfo.Port.ToString());
AddEnvVar("GETH_PRIVATE_KEY", gethInfo.PrivKey);
AddEnvVar("CODEXCONTRACTS_MARKETPLACEADDRESS", gethInfo.MarketplaceAddress);
AddEnvVar("CODEXCONTRACTS_TOKENADDRESS", gethInfo.TokenAddress);
AddEnvVar("CODEXCONTRACTS_ABI", gethInfo.Abi);
if (!string.IsNullOrEmpty(config.DataPath))
{
AddEnvVar("DATAPATH", config.DataPath);
AddVolume(config.DataPath, 1.GB());
}
}
}
}

View File

@ -24,8 +24,9 @@ namespace GethPlugin
decimal? GetSyncedBlockNumber();
bool IsContractAvailable(string abi, string contractAddress);
GethBootstrapNode GetBootstrapRecord();
List<EventLog<TEvent>> GetEvents<TEvent>(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new();
List<EventLog<TEvent>> GetEvents<TEvent>(string address, BlockInterval blockRange) where TEvent : IEventDTO, new();
List<EventLog<TEvent>> GetEvents<TEvent>(string address, TimeRange timeRange) where TEvent : IEventDTO, new();
BlockInterval ConvertTimeRangeToBlockRange(TimeRange timeRange);
}
public class DeploymentGethNode : BaseGethNode, IGethNode
@ -144,14 +145,19 @@ namespace GethPlugin
return StartInteraction().IsContractAvailable(abi, contractAddress);
}
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, ulong fromBlockNumber, ulong toBlockNumber) where TEvent : IEventDTO, new()
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, BlockInterval blockRange) where TEvent : IEventDTO, new()
{
return StartInteraction().GetEvents<TEvent>(address, fromBlockNumber, toBlockNumber);
return StartInteraction().GetEvents<TEvent>(address, blockRange);
}
public List<EventLog<TEvent>> GetEvents<TEvent>(string address, TimeRange timeRange) where TEvent : IEventDTO, new()
{
return StartInteraction().GetEvents<TEvent>(address, timeRange);
return StartInteraction().GetEvents<TEvent>(address, ConvertTimeRangeToBlockRange(timeRange));
}
public BlockInterval ConvertTimeRangeToBlockRange(TimeRange timeRange)
{
return StartInteraction().ConvertTimeRangeToBlockRange(timeRange);
}
protected abstract NethereumInteraction StartInteraction();

View File

@ -0,0 +1,110 @@
using CodexContractsPlugin;
using CodexDiscordBotPlugin;
using CodexPlugin;
using GethPlugin;
using NUnit.Framework;
using Utils;
namespace CodexTests.BasicTests
{
[TestFixture]
public class DiscordBotTests : AutoBootstrapDistTest
{
[Test]
public void BotRewardTest()
{
var myAccount = EthAccount.GenerateNew();
var sellerInitialBalance = 234.TestTokens();
var buyerInitialBalance = 100000.TestTokens();
var fileSize = 11.MB();
var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth"));
var contracts = Ci.StartCodexContracts(geth);
// start bot and rewarder
var gethInfo = new DiscordBotGethInfo(
host: geth.Container.GetInternalAddress(GethContainerRecipe.HttpPortTag).Host,
port: geth.Container.GetInternalAddress(GethContainerRecipe.HttpPortTag).Port,
privKey: geth.StartResult.Account.PrivateKey,
marketplaceAddress: contracts.Deployment.MarketplaceAddress,
tokenAddress: contracts.Deployment.TokenAddress,
abi: contracts.Deployment.Abi
);
var bot = Ci.DeployCodexDiscordBot(new DiscordBotStartupConfig(
name: "bot",
token: "aaa",
serverName: "ThatBen's server",
adminRoleName: "bottest-admins",
adminChannelName: "admin-channel",
rewardChannelName: "rewards-channel",
kubeNamespace: "notneeded",
gethInfo: gethInfo
));
var botContainer = bot.Containers.Single();
Ci.DeployRewarderBot(new RewarderBotStartupConfig(
//discordBotHost: "http://" + botContainer.GetAddress(GetTestLog(), DiscordBotContainerRecipe.RewardsPort).Host,
//discordBotPort: botContainer.GetAddress(GetTestLog(), DiscordBotContainerRecipe.RewardsPort).Port,
discordBotHost: botContainer.GetInternalAddress(DiscordBotContainerRecipe.RewardsPort).Host,
discordBotPort: botContainer.GetInternalAddress(DiscordBotContainerRecipe.RewardsPort).Port,
interval: "60",
historyStartUtc: GetTestRunTimeRange().From - TimeSpan.FromMinutes(3),
gethInfo: gethInfo,
dataPath: null
));
var numberOfHosts = 3;
for (var i = 0; i < numberOfHosts; i++)
{
var seller = AddCodex(s => s
.WithName("Seller")
.WithLogLevel(CodexLogLevel.Trace, new CodexLogCustomTopics(CodexLogLevel.Error, CodexLogLevel.Error, CodexLogLevel.Warn)
{
ContractClock = CodexLogLevel.Trace,
})
.WithStorageQuota(11.GB())
.EnableMarketplace(geth, contracts, m => m
.WithAccount(myAccount)
.WithInitial(10.Eth(), sellerInitialBalance)
.AsStorageNode()
.AsValidator()));
var availability = new StorageAvailability(
totalSpace: 10.GB(),
maxDuration: TimeSpan.FromMinutes(30),
minPriceForTotalSpace: 1.TestTokens(),
maxCollateral: 20.TestTokens()
);
seller.Marketplace.MakeStorageAvailable(availability);
}
var testFile = GenerateTestFile(fileSize);
var buyer = AddCodex(s => s
.WithName("Buyer")
.EnableMarketplace(geth, contracts, m => m
.WithAccount(myAccount)
.WithInitial(10.Eth(), buyerInitialBalance)));
var contentId = buyer.UploadFile(testFile);
var purchase = new StoragePurchaseRequest(contentId)
{
PricePerSlotPerSecond = 2.TestTokens(),
RequiredCollateral = 10.TestTokens(),
MinRequiredNumberOfNodes = 5,
NodeFailureTolerance = 2,
ProofProbability = 5,
Duration = TimeSpan.FromMinutes(6),
Expiry = TimeSpan.FromMinutes(5)
};
var purchaseContract = buyer.Marketplace.RequestStorage(purchase);
purchaseContract.WaitForStorageContractStarted();
purchaseContract.WaitForStorageContractFinished();
}
}
}

View File

@ -2,7 +2,6 @@
using CodexContractsPlugin.Marketplace;
using CodexPlugin;
using GethPlugin;
using Nethereum.Hex.HexConvertors.Extensions;
using NUnit.Framework;
using Utils;
@ -71,29 +70,26 @@ namespace CodexTests.BasicTests
var purchaseContract = client.Marketplace.RequestStorage(purchase);
WaitForAllSlotFilledEvents(contracts, purchase);
WaitForAllSlotFilledEvents(contracts, purchase, geth);
purchaseContract.WaitForStorageContractStarted();
//AssertBalance(contracts, host, Is.LessThan(hostInitialBalance), "Collateral was not placed.");
var request = GetOnChainStorageRequest(contracts);
var request = GetOnChainStorageRequest(contracts, geth);
AssertStorageRequest(request, purchase, contracts, client);
//AssertSlotFilledEvents(contracts, purchase, request, host);
//AssertContractSlot(contracts, request, 0, host);
AssertContractSlot(contracts, request, 0);
purchaseContract.WaitForStorageContractFinished();
//AssertBalance(contracts, host, Is.GreaterThan(hostInitialBalance), "Seller was not paid for storage.");
AssertBalance(contracts, client, Is.LessThan(clientInitialBalance), "Buyer was not charged for storage.");
Assert.That(contracts.GetRequestState(request), Is.EqualTo(RequestState.Finished));
}
private void WaitForAllSlotFilledEvents(ICodexContracts contracts, StoragePurchaseRequest purchase)
private void WaitForAllSlotFilledEvents(ICodexContracts contracts, StoragePurchaseRequest purchase, IGethNode geth)
{
Time.Retry(() =>
{
var slotFilledEvents = contracts.GetSlotFilledEvents(GetTestRunTimeRange());
var blockRange = geth.ConvertTimeRangeToBlockRange(GetTestRunTimeRange());
var slotFilledEvents = contracts.GetSlotFilledEvents(blockRange);
Log($"SlotFilledEvents: {slotFilledEvents.Length} - NumSlots: {purchase.MinRequiredNumberOfNodes}");
@ -101,24 +97,6 @@ namespace CodexTests.BasicTests
}, Convert.ToInt32(purchase.Duration.TotalSeconds / 5) + 10, TimeSpan.FromSeconds(5), "Checking SlotFilled events");
}
private void AssertSlotFilledEvents(ICodexContracts contracts, StoragePurchaseRequest purchase, Request request, ICodexNode seller)
{
// Expect 1 fulfilled event for the purchase.
var requestFulfilledEvents = contracts.GetRequestFulfilledEvents(GetTestRunTimeRange());
Assert.That(requestFulfilledEvents.Length, Is.EqualTo(1));
CollectionAssert.AreEqual(request.RequestId, requestFulfilledEvents[0].RequestId);
// Expect 1 filled-slot event for each slot in the purchase.
var filledSlotEvents = contracts.GetSlotFilledEvents(GetTestRunTimeRange());
Assert.That(filledSlotEvents.Length, Is.EqualTo(purchase.MinRequiredNumberOfNodes));
for (var i = 0; i < purchase.MinRequiredNumberOfNodes; i++)
{
var filledSlotEvent = filledSlotEvents.Single(e => e.SlotIndex == i);
Assert.That(filledSlotEvent.RequestId.ToHex(), Is.EqualTo(request.RequestId.ToHex()));
Assert.That(filledSlotEvent.Host, Is.EqualTo(seller.EthAddress));
}
}
private void AssertStorageRequest(Request request, StoragePurchaseRequest purchase, ICodexContracts contracts, ICodexNode buyer)
{
Assert.That(contracts.GetRequestState(request), Is.EqualTo(RequestState.Started));
@ -126,17 +104,17 @@ namespace CodexTests.BasicTests
Assert.That(request.Ask.Slots, Is.EqualTo(purchase.MinRequiredNumberOfNodes));
}
private Request GetOnChainStorageRequest(ICodexContracts contracts)
private Request GetOnChainStorageRequest(ICodexContracts contracts, IGethNode geth)
{
var requests = contracts.GetStorageRequests(GetTestRunTimeRange());
var requests = contracts.GetStorageRequests(geth.ConvertTimeRangeToBlockRange(GetTestRunTimeRange()));
Assert.That(requests.Length, Is.EqualTo(1));
return requests.Single();
}
private void AssertContractSlot(ICodexContracts contracts, Request request, int contractSlotIndex, ICodexNode expectedSeller)
private void AssertContractSlot(ICodexContracts contracts, Request request, int contractSlotIndex)
{
var slotHost = contracts.GetSlotHost(request, contractSlotIndex);
Assert.That(slotHost, Is.EqualTo(expectedSeller.EthAddress));
Assert.That(slotHost?.Address, Is.Not.Null);
}
}
}

View File

@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\ProjectPlugins\CodexContractsPlugin\CodexContractsPlugin.csproj" />
<ProjectReference Include="..\..\ProjectPlugins\CodexDiscordBotPlugin\CodexDiscordBotPlugin.csproj" />
<ProjectReference Include="..\..\ProjectPlugins\CodexPlugin\CodexPlugin.csproj" />
<ProjectReference Include="..\..\ProjectPlugins\GethPlugin\GethPlugin.csproj" />
<ProjectReference Include="..\..\ProjectPlugins\MetricsPlugin\MetricsPlugin.csproj" />

View File

@ -117,6 +117,41 @@ namespace FrameworkTests.NethereumWorkflow
Assert.That(notFound, Is.Null);
}
[Test]
public void FailsToFindBlockBeforeFrontOfChain_history()
{
var first = blocks.First().Value;
var notFound = finder.GetHighestBlockNumberBefore(first.JustBefore);
Assert.That(notFound, Is.Null);
}
[Test]
public void FailsToFindBlockAfterTailOfChain_future()
{
var last = blocks.Last().Value;
var notFound = finder.GetLowestBlockNumberAfter(last.JustAfter);
Assert.That(notFound, Is.Null);
}
[Test]
public void RunThrough()
{
foreach (var pair in blocks)
{
finder.GetHighestBlockNumberBefore(pair.Value.JustBefore);
finder.GetHighestBlockNumberBefore(pair.Value.Time);
finder.GetHighestBlockNumberBefore(pair.Value.JustAfter);
finder.GetLowestBlockNumberAfter(pair.Value.JustBefore);
finder.GetLowestBlockNumberAfter(pair.Value.Time);
finder.GetLowestBlockNumberAfter(pair.Value.JustAfter);
}
}
}
public class Block
@ -131,5 +166,10 @@ namespace FrameworkTests.NethereumWorkflow
public DateTime Time { get; }
public DateTime JustBefore { get { return Time.AddSeconds(-1); } }
public DateTime JustAfter { get { return Time.AddSeconds(1); } }
public override string ToString()
{
return $"[{Number}]";
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>

View File

@ -26,12 +26,10 @@ 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());
Program.RoleDriver = new RoleDriver(client);
var builders = commands.Select(c =>
{
@ -62,8 +60,6 @@ namespace BiblioTech
var json = JsonConvert.SerializeObject(exception.Errors, Formatting.Indented);
Program.Log.Error(json);
}
rewardsApi.Start();
}
private async Task SlashCommandHandler(SocketSlashCommand command)

View File

@ -6,8 +6,6 @@ namespace BiblioTech.Commands
{
public class MintCommand : BaseGethCommand
{
private readonly Ether defaultEthToSend = 10.Eth();
private readonly TestToken defaultTestTokensToMint = 1024.TestTokens();
private readonly UserOption optionalUser = new UserOption(
description: "If set, mint tokens for this user. (Optional, admin-only)",
isRequired: false);
@ -47,9 +45,10 @@ namespace BiblioTech.Commands
{
if (ShouldMintTestTokens(contracts, addr))
{
var transaction = contracts.MintTestTokens(addr, defaultTestTokensToMint);
report.Add($"Minted {defaultTestTokensToMint} {FormatTransactionLink(transaction)}");
return new Transaction<TestToken>(defaultTestTokensToMint, transaction);
var tokens = Program.Config.MintTT.TestTokens();
var transaction = contracts.MintTestTokens(addr, tokens);
report.Add($"Minted {tokens} {FormatTransactionLink(transaction)}");
return new Transaction<TestToken>(tokens, transaction);
}
report.Add("TestToken balance over threshold. (No TestTokens minted.)");
@ -60,9 +59,10 @@ namespace BiblioTech.Commands
{
if (ShouldSendEth(gethNode, addr))
{
var transaction = gethNode.SendEth(addr, defaultEthToSend);
report.Add($"Sent {defaultEthToSend} {FormatTransactionLink(transaction)}");
return new Transaction<Ether>(defaultEthToSend, transaction);
var eth = Program.Config.SendEth.Eth();
var transaction = gethNode.SendEth(addr, eth);
report.Add($"Sent {eth} {FormatTransactionLink(transaction)}");
return new Transaction<Ether>(eth, transaction);
}
report.Add("Eth balance is over threshold. (No Eth sent.)");
return null;
@ -71,13 +71,13 @@ namespace BiblioTech.Commands
private bool ShouldMintTestTokens(ICodexContracts contracts, EthAddress addr)
{
var testTokens = contracts.GetTestTokenBalance(addr);
return testTokens.Amount < 64m;
return testTokens.Amount < Program.Config.MintTT;
}
private bool ShouldSendEth(IGethNode gethNode, EthAddress addr)
{
var eth = gethNode.GetEthBalance(addr);
return eth.Eth < 1.0m;
return eth.Eth < Program.Config.SendEth;
}
private string FormatTransactionLink(string transaction)

View File

@ -19,9 +19,17 @@ 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.")]
[Uniform("rewards-channel-name", "rc", "REWARDSCHANNELNAME", false, "Name of the Discord server channel where participation rewards will be announced.")]
public string RewardsChannelName { get; set; } = "";
[Uniform("reward-api-port", "rp", "REWARDAPIPORT", false, "TCP listen port for the reward API.")]
public int RewardApiPort { get; set; } = 31080;
[Uniform("send-eth", "se", "SENDETH", false, "Amount of Eth send by the mint command. Default: 10.")]
public int SendEth { get; set; } = 10;
[Uniform("mint-tt", "mt", "MINTTT", false, "Amount of TestTokens minted by the mint command. Default: 1073741824")]
public int MintTT { get; set; } = 1073741824;
public string EndpointsPath
{

View File

@ -1,5 +1,6 @@
using ArgsUniform;
using BiblioTech.Commands;
using BiblioTech.Rewards;
using Discord;
using Discord.WebSocket;
using Logging;
@ -13,6 +14,7 @@ namespace BiblioTech
public static Configuration Config { get; private set; } = null!;
public static UserRepo UserRepo { get; } = new UserRepo();
public static AdminChecker AdminChecker { get; private set; } = null!;
public static IDiscordRoleDriver RoleDriver { get; set; } = null!;
public static ILog Log { get; private set; } = null!;
public static Task Main(string[] args)
@ -29,10 +31,10 @@ namespace BiblioTech
EnsurePath(Config.UserDataPath);
EnsurePath(Config.EndpointsPath);
return new Program().MainAsync();
return new Program().MainAsync(args);
}
public async Task MainAsync()
public async Task MainAsync(string[] args)
{
Log.Log("Starting Codex Discord Bot...");
client = new DiscordSocketClient();
@ -52,10 +54,19 @@ namespace BiblioTech
await client.LoginAsync(TokenType.Bot, Config.ApplicationToken);
await client.StartAsync();
AdminChecker = new AdminChecker();
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel((context, options) =>
{
options.ListenAnyIP(Config.RewardApiPort);
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
Log.Log("Running...");
await app.RunAsync();
await Task.Delay(-1);
}

View File

@ -0,0 +1,12 @@
{
"profiles": {
"BiblioTech": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:52960;http://localhost:52961"
}
}
}

View File

@ -0,0 +1,35 @@
using DiscordRewards;
using Microsoft.AspNetCore.Mvc;
namespace BiblioTech.Rewards
{
public interface IDiscordRoleDriver
{
Task GiveRewards(GiveRewardsCommand rewards);
}
[Route("api/[controller]")]
[ApiController]
public class RewardController : ControllerBase
{
[HttpGet]
public string Ping()
{
return "Pong";
}
[HttpPost]
public async Task<string> Give(GiveRewardsCommand cmd)
{
try
{
await Program.RoleDriver.GiveRewards(cmd);
}
catch (Exception ex)
{
Program.Log.Error("Exception: " + ex);
}
return "OK";
}
}
}

View File

@ -1,92 +0,0 @@
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<GiveRewardsCommand>(content);
if (rewards != null)
{
await ProcessRewards(rewards);
}
}
private async Task ProcessRewards(GiveRewardsCommand rewards)
{
await roleController.GiveRewards(rewards);
}
}
}

View File

@ -4,13 +4,13 @@ using DiscordRewards;
namespace BiblioTech.Rewards
{
public class RoleController : IDiscordRoleController
public class RoleDriver : IDiscordRoleDriver
{
private readonly DiscordSocketClient client;
private readonly SocketTextChannel? rewardsChannel;
private readonly RewardRepo repo = new RewardRepo();
public RoleController(DiscordSocketClient client)
public RoleDriver(DiscordSocketClient client)
{
this.client = client;
@ -107,7 +107,13 @@ namespace BiblioTech.Rewards
private SocketGuild GetGuild()
{
return client.Guilds.Single(g => g.Name == Program.Config.ServerName);
var guild = client.Guilds.SingleOrDefault(g => g.Name == Program.Config.ServerName);
if (guild == null)
{
throw new Exception($"Unable to find guild by name: '{Program.Config.ServerName}'. " +
$"Known guilds: [{string.Join(",", client.Guilds.Select(g => g.Name))}]");
}
return guild;
}
}

View File

@ -116,6 +116,9 @@ namespace CodexNetDeployer
[Uniform("dbot-adminchannelname", "dbotacn", "DBOTADMINCHANNELNAME", false, "Required if discord-bot is true. Name of the Discord channel in which admin commands are allowed.")]
public string DiscordBotAdminChannelName { get; set; } = string.Empty;
[Uniform("dbot-rewardchannelname", "dbotrcn", "DBOTREWARDCHANNELNAME", false, "Required if discord-bot is true. Name of the Discord channel in which reward updates are posted.")]
public string DiscordBotRewardChannelName { get; set; } = string.Empty;
[Uniform("dbot-datapath", "dbotdp", "DBOTDATAPATH", false, "Optional. Path in container where bot will save all data.")]
public string DiscordBotDataPath { get; set; } = string.Empty;
@ -163,6 +166,7 @@ namespace CodexNetDeployer
StringIsSet(nameof(DiscordBotServerName), DiscordBotServerName, errors);
StringIsSet(nameof(DiscordBotAdminRoleName), DiscordBotAdminRoleName, errors);
StringIsSet(nameof(DiscordBotAdminChannelName), DiscordBotAdminChannelName, errors);
StringIsSet(nameof(DiscordBotRewardChannelName), DiscordBotRewardChannelName, errors);
}
return errors;

View File

@ -145,7 +145,8 @@ namespace CodexNetDeployer
adminRoleName: config.DiscordBotAdminRoleName,
adminChannelName: config.DiscordBotAdminChannelName,
kubeNamespace: config.KubeNamespace,
gethInfo: info)
gethInfo: info,
rewardChannelName: config.DiscordBotRewardChannelName)
{
DataPath = config.DiscordBotDataPath
});

View File

@ -1,6 +1,8 @@
using DiscordRewards;
using CodexContractsPlugin.Marketplace;
using DiscordRewards;
using Logging;
using Newtonsoft.Json;
using System.Net.Http.Json;
namespace TestNetRewarder
{
@ -17,21 +19,41 @@ namespace TestNetRewarder
public async Task<bool> IsOnline()
{
return await HttpPost("Ping") == "Ping";
var result = await HttpGet();
log.Log("Is DiscordBot online: " + result);
return result == "Pong";
}
public async Task SendRewards(GiveRewardsCommand command)
public async Task<bool> SendRewards(GiveRewardsCommand command)
{
if (command == null || command.Rewards == null || !command.Rewards.Any()) return;
await HttpPost(JsonConvert.SerializeObject(command));
if (command == null || command.Rewards == null || !command.Rewards.Any()) return false;
var result = await HttpPostJson(command);
log.Log("Reward response: " + result);
return result == "OK";
}
private async Task<string> HttpPost(string content)
private async Task<string> HttpGet()
{
try
{
var client = new HttpClient();
var response = await client.PostAsync(GetUrl(), new StringContent(content));
var response = await client.GetAsync(GetUrl());
return await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
log.Error(ex.ToString());
return string.Empty;
}
}
private async Task<string> HttpPostJson<T>(T body)
{
try
{
using var client = new HttpClient();
using var content = JsonContent.Create(body);
using var response = await client.PostAsync(GetUrl(), content);
return await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
@ -43,7 +65,7 @@ namespace TestNetRewarder
private string GetUrl()
{
return $"{configuration.DiscordHost}:{configuration.DiscordPort}";
return $"{configuration.DiscordHost}:{configuration.DiscordPort}/api/reward";
}
}
}

View File

@ -8,18 +8,18 @@ namespace TestNetRewarder
{
private readonly HistoricState historicState;
public ChainState(HistoricState historicState, ICodexContracts contracts, TimeRange timeRange)
public ChainState(HistoricState historicState, ICodexContracts contracts, BlockInterval blockRange)
{
NewRequests = contracts.GetStorageRequests(timeRange);
NewRequests = contracts.GetStorageRequests(blockRange);
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);
RequestFulfilledEvents = contracts.GetRequestFulfilledEvents(blockRange);
RequestCancelledEvents = contracts.GetRequestCancelledEvents(blockRange);
SlotFilledEvents = contracts.GetSlotFilledEvents(blockRange);
SlotFreedEvents = contracts.GetSlotFreedEvents(blockRange);
this.historicState = historicState;
}

View File

@ -11,30 +11,51 @@ namespace TestNetRewarder
private static readonly HistoricState historicState = new HistoricState();
private static readonly RewardRepo rewardRepo = new RewardRepo();
private readonly ILog log;
private BlockInterval? lastBlockRange;
public Processor(ILog log)
{
this.log = log;
}
public async Task ProcessTimeSegment(TimeRange range)
public async Task ProcessTimeSegment(TimeRange timeRange)
{
var connector = GethConnector.GethConnector.Initialize(log);
if (connector == null) throw new Exception("Invalid Geth information");
try
{
var connector = GethConnector.GethConnector.Initialize(log);
if (connector == null) return;
var chainState = new ChainState(historicState, connector.CodexContracts, range);
await ProcessTimeSegment(chainState);
var blockRange = connector.GethNode.ConvertTimeRangeToBlockRange(timeRange);
if (!IsNewBlockRange(blockRange))
{
log.Log($"Block range {blockRange} was previously processed. Skipping...");
return;
}
var chainState = new ChainState(historicState, connector.CodexContracts, blockRange);
await ProcessChainState(chainState);
}
catch (Exception ex)
{
log.Error("Exception processing time segment: " + ex);
throw;
}
}
private async Task ProcessTimeSegment(ChainState chainState)
private bool IsNewBlockRange(BlockInterval blockRange)
{
if (lastBlockRange == null ||
lastBlockRange.From != blockRange.From ||
lastBlockRange.To != blockRange.To)
{
lastBlockRange = blockRange;
return true;
}
return false;
}
private async Task ProcessChainState(ChainState chainState)
{
var outgoingRewards = new List<RewardUsersCommand>();
foreach (var reward in rewardRepo.Rewards)
@ -42,13 +63,17 @@ namespace TestNetRewarder
ProcessReward(outgoingRewards, reward, chainState);
}
log.Log($"Found {outgoingRewards.Count} rewards to send.");
if (outgoingRewards.Any())
{
await SendRewardsCommand(outgoingRewards);
if (!await SendRewardsCommand(outgoingRewards))
{
log.Error("Failed to send reward command.");
}
}
}
private async Task SendRewardsCommand(List<RewardUsersCommand> outgoingRewards)
private async Task<bool> SendRewardsCommand(List<RewardUsersCommand> outgoingRewards)
{
var cmd = new GiveRewardsCommand
{
@ -56,12 +81,16 @@ namespace TestNetRewarder
};
log.Debug("Sending rewards: " + JsonConvert.SerializeObject(cmd));
await Program.BotClient.SendRewards(cmd);
return await Program.BotClient.SendRewards(cmd);
}
private void ProcessReward(List<RewardUsersCommand> outgoingRewards, RewardConfig reward, ChainState chainState)
{
var winningAddresses = PerformCheck(reward, chainState);
foreach (var win in winningAddresses)
{
log.Log($"Address '{win.Address}' wins '{reward.Message}'");
}
if (winningAddresses.Any())
{
outgoingRewards.Add(new RewardUsersCommand

View File

@ -1,5 +1,4 @@
using ArgsUniform;
using GethConnector;
using Logging;
using Utils;
@ -12,6 +11,7 @@ namespace TestNetRewarder
public static CancellationToken CancellationToken { get; private set; }
public static BotClient BotClient { get; private set; } = null!;
private static Processor processor = null!;
private static DateTime lastCheck = DateTime.MinValue;
public static Task Main(string[] args)
{
@ -47,7 +47,7 @@ namespace TestNetRewarder
{
await EnsureBotOnline();
await segmenter.WaitForNextSegment(processor.ProcessTimeSegment);
await Task.Delay(1000, CancellationToken);
await Task.Delay(100, CancellationToken);
}
}
@ -59,11 +59,15 @@ namespace TestNetRewarder
var blockNumber = gc.GethNode.GetSyncedBlockNumber();
if (blockNumber == null || blockNumber < 1) throw new Exception("Geth connection failed.");
Log.Log("Geth OK. Block number: " + blockNumber);
}
private static async Task EnsureBotOnline()
{
var start = DateTime.UtcNow;
var timeSince = start - lastCheck;
if (timeSince.TotalSeconds < 30.0) return;
while (! await BotClient.IsOnline() && !CancellationToken.IsCancellationRequested)
{
await Task.Delay(5000);
@ -76,6 +80,8 @@ namespace TestNetRewarder
throw new Exception(msg);
}
}
lastCheck = start;
}
private static void PrintHelp()

View File

@ -31,10 +31,12 @@ namespace TestNetRewarder
if (end > now)
{
// Wait for the entire time segment to be in the past.
var delay = (end - now).Add(TimeSpan.FromSeconds(3));
var delay = end - now;
waited = true;
log.Log($"Waiting till time segment is in the past... {Time.FormatDuration(delay)}");
await Task.Delay(delay, Program.CancellationToken);
}
await Task.Delay(TimeSpan.FromSeconds(3), Program.CancellationToken);
if (Program.CancellationToken.IsCancellationRequested) return;

View File

@ -0,0 +1,2 @@
docker build -f docker/Dockerfile -t thatbenbierens/codex-rewardbot:initial ../..
docker push thatbenbierens/codex-rewardbot:initial