Add Ethereum & ERC20 Support

Add Tests
Add Index, XPub to payment data
Add Note on local ETH node
Fix Sync Summary and Race Condition
This commit is contained in:
XPayServer 2020-07-28 11:30:23 +02:00
parent 7ca0a8c56c
commit de755ac0bb
38 changed files with 2592 additions and 14 deletions

View file

@ -0,0 +1,78 @@
#if ALTCOINS
using NBitcoin;
namespace BTCPayServer
{
public partial class BTCPayNetworkProvider
{
public void InitEthereum()
{
Add(new EthereumBTCPayNetwork()
{
CryptoCode = "ETH",
DisplayName = "Ethereum",
DefaultRateRules = new[] {"ETH_X = ETH_BTC * BTC_X", "ETH_BTC = kraken(ETH_BTC)"},
BlockExplorerLink =
NetworkType == NetworkType.Mainnet
? "https://etherscan.io/address/{0}"
: "https://ropsten.etherscan.io/address/{0}",
CryptoImagePath = "/imlegacy/eth.png",
ShowSyncSummary = true,
CoinType = NetworkType == NetworkType.Mainnet? 60 : 1,
ChainId = NetworkType == NetworkType.Mainnet ? 1 : 3,
Divisibility = 18,
});
}
public void InitERC20()
{
if (NetworkType != NetworkType.Mainnet)
{
Add(new ERC20BTCPayNetwork()
{
CryptoCode = "FAU",
DisplayName = "Faucet Token",
DefaultRateRules = new[]
{
"FAU_X = FAU_BTC * BTC_X",
"FAU_BTC = 0.01",
},
BlockExplorerLink = "https://ropsten.etherscan.io/address/{0}#tokentxns",
ShowSyncSummary = false,
CoinType = 1,
ChainId = 3,
//use https://erc20faucet.com for testnet
SmartContractAddress = "0xFab46E002BbF0b4509813474841E0716E6730136",
Divisibility = 18,
CryptoImagePath = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
});
}
else
{
Add(new ERC20BTCPayNetwork()
{
CryptoCode = "USDT20",
DisplayName = "Tether USD (ERC20)",
DefaultRateRules = new[]
{
"USDT20_UST = 1",
"USDT20_X = USDT20_BTC * BTC_X",
"USDT20_BTC = bitfinex(UST_BTC)",
},
BlockExplorerLink =
NetworkType == NetworkType.Mainnet
? "https://etherscan.io/address/{0}#tokentxns"
: "https://ropsten.etherscan.io/address/{0}#tokentxns",
CryptoImagePath = "/imlegacy/liquid-tether.svg",
ShowSyncSummary = false,
CoinType = NetworkType == NetworkType.Mainnet? 60 : 1,
ChainId = NetworkType == NetworkType.Mainnet ? 1 : 3,
SmartContractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
Divisibility = 6
});
}
}
}
}
#endif

View file

@ -0,0 +1,20 @@
#if ALTCOINS
namespace BTCPayServer
{
public class EthereumBTCPayNetwork : BTCPayNetworkBase
{
public int ChainId { get; set; }
public int CoinType { get; set; }
public string GetDefaultKeyPath()
{
return $"m/44'/{CoinType}'/0'/0/x";
}
}
public class ERC20BTCPayNetwork : EthereumBTCPayNetwork
{
public string SmartContractAddress { get; set; }
}
}
#endif

View file

@ -0,0 +1,20 @@
#if ALTCOINS
using System.Collections.Generic;
using System.Linq;
namespace BTCPayServer
{
public static class EthereumExtensions
{
public static IEnumerable<string> GetAllEthereumSubChains(this BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider unfiltered)
{
var ethBased = networkProvider.GetAll().OfType<EthereumBTCPayNetwork>();
var chainId = ethBased.Select(network => network.ChainId).Distinct();
return unfiltered.GetAll().OfType<EthereumBTCPayNetwork>()
.Where(network => chainId.Contains(network.ChainId))
.Select(network => network.CryptoCode.ToUpperInvariant());
}
}
}
#endif

View file

@ -6,11 +6,11 @@ namespace BTCPayServer
{
public static class LiquidExtensions
{
public static IEnumerable<string> GetAllElementsSubChains(this BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider unfilteredNetworkProvider)
public static IEnumerable<string> GetAllElementsSubChains(this BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider unfiltered)
{
var elementsBased = networkProvider.GetAll().OfType<ElementsBTCPayNetwork>();
var parentChains = elementsBased.Select(network => network.NetworkCryptoCode.ToUpperInvariant()).Distinct();
return unfilteredNetworkProvider.GetAll().OfType<ElementsBTCPayNetwork>()
return unfiltered.GetAll().OfType<ElementsBTCPayNetwork>()
.Where(network => parentChains.Contains(network.NetworkCryptoCode)).Select(network => network.CryptoCode.ToUpperInvariant());
}
}

View file

@ -58,6 +58,8 @@ namespace BTCPayServer
InitPolis();
InitChaincoin();
InitArgoneum();
InitEthereum();
InitERC20();
// Assume that electrum mappings are same as BTC if not specified
foreach (var network in _Networks.Values.OfType<BTCPayNetwork>())

View file

@ -1286,5 +1286,26 @@
"divisibility":0,
"symbol":"Sats",
"crypto":true
},
{
"name": "Ethereum",
"code": "ETH",
"divisibility": 18,
"symbol": null,
"crypto": true
},
{
"name":"USDt",
"code":"USDT20",
"divisibility":6,
"symbol":null,
"crypto":true
},
{
"name":"FaucetToken",
"code":"FAU",
"divisibility":18,
"symbol":null,
"crypto":true
}
]

View file

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Tests.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Memory;
using NBitcoin;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class EthereumTests
{
public const int TestTimeout = 60_000;
public EthereumTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Fast", "Fast")]
[Trait("Altcoins", "Altcoins")]
public void LoadSubChainsAlways()
{
var options = new BTCPayServerOptions();
options.LoadArgs(new ConfigurationRoot(new List<IConfigurationProvider>()
{
new MemoryConfigurationProvider(new MemoryConfigurationSource()
{
InitialData = new[] {new KeyValuePair<string, string>("chains", "usdt20"),}
})
}));
Assert.NotNull(options.NetworkProvider.GetNetwork("ETH"));
Assert.NotNull(options.NetworkProvider.GetNetwork("USDT20"));
}
[Fact]
[Trait("Altcoins", "Altcoins")]
public async Task CanUseEthereum()
{
using var s = SeleniumTester.Create("ETHEREUM", true);
s.Server.ActivateETH();
await s.StartAsync();
s.RegisterNewUser(true);
IWebElement syncSummary = null;
TestUtils.Eventually(() =>
{
syncSummary = s.Driver.FindElement(By.Id("modalDialog"));
Assert.True(syncSummary.Displayed);
});
var web3Link = syncSummary.FindElement(By.LinkText("Configure Web3"));
web3Link.Click();
s.Driver.FindElement(By.Id("Web3ProviderUrl")).SendKeys("https://ropsten-rpc.linkpool.io");
s.Driver.FindElement(By.Id("saveButton")).Click();
s.AssertHappyMessage();
TestUtils.Eventually(() =>
{
s.Driver.Navigate().Refresh();
s.Driver.AssertElementNotFound(By.Id("modalDialog"));
});
var store = s.CreateNewStore();
s.Driver.FindElement(By.LinkText("Ethereum")).Click();
var seed = new Mnemonic(Wordlist.English);
s.Driver.FindElement(By.Id("ModifyETH")).Click();
s.Driver.FindElement(By.Id("Seed")).SendKeys(seed.ToString());
s.SetCheckbox(s.Driver.FindElement(By.Id("StoreSeed")), true);
s.SetCheckbox(s.Driver.FindElement(By.Id("Enabled")), true);
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.AssertHappyMessage();
s.Driver.FindElement(By.Id("ModifyUSDT20")).Click();
s.Driver.FindElement(By.Id("Seed")).SendKeys(seed.ToString());
s.SetCheckbox(s.Driver.FindElement(By.Id("StoreSeed")), true);
s.SetCheckbox(s.Driver.FindElement(By.Id("Enabled")), true);
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.AssertHappyMessage();
var invoiceId = s.CreateInvoice(store.storeName, 10);
s.GoToInvoiceCheckout(invoiceId);
var currencyDropdownButton = s.Driver.WaitForElement(By.ClassName("payment__currencies"));
Assert.Contains("ETH", currencyDropdownButton.Text);
s.Driver.FindElement(By.Id("copy-tab")).Click();
var ethAddress = s.Driver.FindElements(By.ClassName("copySectionBox"))
.Single(element => element.FindElement(By.TagName("label")).Text
.Contains("Address", StringComparison.InvariantCultureIgnoreCase)).FindElement(By.TagName("input"))
.GetAttribute("value");
currencyDropdownButton.Click();
var elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
Assert.Equal(2, elements.Count);
elements.Single(element => element.Text.Contains("USDT20")).Click();
s.Driver.FindElement(By.Id("copy-tab")).Click();
var usdtAddress = s.Driver.FindElements(By.ClassName("copySectionBox"))
.Single(element => element.FindElement(By.TagName("label")).Text
.Contains("Address", StringComparison.InvariantCultureIgnoreCase)).FindElement(By.TagName("input"))
.GetAttribute("value");
Assert.Equal(usdtAddress, ethAddress);
}
}
}

View file

@ -222,6 +222,9 @@ namespace BTCPayServer.Tests
var bitpay = new MockRateProvider();
bitpay.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("ETB_BTC"), new BidAsk(0.1m)));
rateProvider.Providers.Add("bitpay", bitpay);
var kraken = new MockRateProvider();
kraken.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("ETH_BTC"), new BidAsk(0.1m)));
rateProvider.Providers.Add("kraken", kraken);
}

View file

@ -78,6 +78,11 @@ namespace BTCPayServer.Tests
PayTester.Chains.Add("LBTC");
PayTester.LBTCNBXplorerUri = LBTCExplorerClient.Address;
}
public void ActivateETH()
{
PayTester.Chains.Add("ETH");
}
#endif
public void ActivateLightning()
{

View file

@ -93,6 +93,7 @@ namespace BTCPayServer.Configuration
var filtered = networkProvider.Filter(supportedChains.ToArray());
#if ALTCOINS
supportedChains.AddRange(filtered.GetAllElementsSubChains(networkProvider));
supportedChains.AddRange(filtered.GetAllEthereumSubChains(networkProvider));
#endif
#if !ALTCOINS
var onlyBTC = supportedChains.Count == 1 && supportedChains.First() == "BTC";

View file

@ -49,6 +49,7 @@ using NicolasDorier.RateLimits;
using Serilog;
#if ALTCOINS
using BTCPayServer.Services.Altcoins.Monero;
using BTCPayServer.Services.Altcoins.Ethereum;
#endif
namespace BTCPayServer.Hosting
{
@ -79,6 +80,7 @@ namespace BTCPayServer.Hosting
services.AddPayJoinServices();
#if ALTCOINS
services.AddMoneroLike();
services.AddEthereumLike();
#endif
services.TryAddSingleton<SettingsRepository>();
services.TryAddSingleton<LabelFactory>();

View file

@ -1,5 +1,6 @@
using System;
#if ALTCOINS
using BTCPayServer.Services.Altcoins.Ethereum.Payments;
using BTCPayServer.Services.Altcoins.Monero.Payments;
#endif
using BTCPayServer.Services.Invoices;
@ -45,6 +46,9 @@ namespace BTCPayServer.Payments
case "monerolike":
type = PaymentTypes.MoneroLike;
break;
case "ethereumlike":
type = EthereumPaymentType.Instance;
break;
#endif
default:
type = null;

View file

@ -77,7 +77,7 @@
"BTCPAY_ALLOW-ADMIN-REGISTRATION": "true",
"BTCPAY_DISABLE-REGISTRATION": "false",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_CHAINS": "btc,ltc,lbtc",
"BTCPAY_CHAINS": "btc,ltc,lbtc,eth",
"BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver",
"BTCPAY_EXTERNALSERVICES": "totoservice:totolink;",
"BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622",

View file

@ -0,0 +1,31 @@
#if ALTCOINS
using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Services.Altcoins.Ethereum.Configuration
{
public class EthereumLikeConfiguration
{
public static string SettingsKey(int chainId)
{
return $"{nameof(EthereumLikeConfiguration)}_{chainId}";
}
public int ChainId { get; set; }
[Display(Name = "Web3 provider url")]
public string Web3ProviderUrl { get; set; }
[Display(Name = "Web3 provider username (can be left blank)")]
public string Web3ProviderUsername { get; set; }
[Display(Name = "Web3 provider password (can be left blank)")]
public string Web3ProviderPassword { get; set; }
public string InvoiceId { get; set; }
public override string ToString()
{
return "";
}
}
}
#endif

View file

@ -0,0 +1,41 @@
#if ALTCOINS
using System.Net;
using System.Net.Http;
using BTCPayServer.Contracts;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services.Altcoins.Ethereum.Payments;
using BTCPayServer.Services.Altcoins.Ethereum.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.Services.Altcoins.Ethereum
{
public static class EthereumLikeExtensions
{
public const string EthereumInvoiceCheckHttpClient = "EthereumCheck";
public const string EthereumInvoiceCreateHttpClient = "EthereumCreate";
public static IServiceCollection AddEthereumLike(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<EthereumService>();
serviceCollection.AddSingleton<IHostedService, EthereumService>(provider => provider.GetService<EthereumService>());
serviceCollection.AddSingleton<EthereumLikePaymentMethodHandler>();
serviceCollection.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<EthereumLikePaymentMethodHandler>());
serviceCollection.AddSingleton<IStoreNavExtension, EthereumStoreNavExtension>();
serviceCollection.AddTransient<NoRedirectHttpClientHandler>();
serviceCollection.AddSingleton<ISyncSummaryProvider, EthereumSyncSummaryProvider>();
serviceCollection.AddHttpClient(EthereumInvoiceCreateHttpClient)
.ConfigurePrimaryHttpMessageHandler<NoRedirectHttpClientHandler>();
return serviceCollection;
}
}
public class NoRedirectHttpClientHandler : HttpClientHandler
{
public NoRedirectHttpClientHandler()
{
this.AllowAutoRedirect = false;
}
}
}
#endif

View file

@ -0,0 +1,11 @@
#if ALTCOINS
using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Altcoins.Ethereum
{
public class EthereumStoreNavExtension: IStoreNavExtension
{
public string Partial { get; } = "Ethereum/StoreNavEthereumExtension";
}
}
#endif

View file

@ -0,0 +1,26 @@
#if ALTCOINS
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace BTCPayServer.Services.Altcoins.Ethereum.Filters
{
public class OnlyIfSupportEthAttribute : Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var options = (BTCPayServerOptions) context.HttpContext.RequestServices.GetService(typeof(BTCPayServerOptions));
if (!options.NetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>().Any())
{
context.Result = new NotFoundResult();
return;
}
await next();
}
}
}
#endif

View file

@ -0,0 +1,37 @@
#if ALTCOINS
using BTCPayServer.Payments;
namespace BTCPayServer.Services.Altcoins.Ethereum.Payments
{
public class EthereumLikeOnChainPaymentMethodDetails : IPaymentMethodDetails
{
public PaymentType GetPaymentType()
{
return EthereumPaymentType.Instance;
}
public string GetPaymentDestination()
{
return DepositAddress;
}
public decimal GetNextNetworkFee()
{
return 0;
}
public decimal GetFeeRate()
{
return 0;
}
public void SetPaymentDestination(string newPaymentDestination)
{
DepositAddress = newPaymentDestination;
}
public long Index { get; set; }
public string XPub { get; set; }
public string DepositAddress { get; set; }
}
}
#endif

View file

@ -0,0 +1,77 @@
#if ALTCOINS
using System.Globalization;
using System.Numerics;
using BTCPayServer.Client.Models;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using Nethereum.Hex.HexTypes;
using Nethereum.Web3;
namespace BTCPayServer.Services.Altcoins.Ethereum.Payments
{
public class EthereumLikePaymentData : CryptoPaymentData
{
public long Amount { get; set; }
public string CryptoCode { get; set; }
public string Address { get; set; }
public long AccountIndex { get; set; }
public string XPub { get; set; }
public long ConfirmationCount { get; set; }
public BTCPayNetworkBase Network { get; set; }
public long? BlockNumber { get; set; }
public string GetPaymentId()
{
return GetPaymentId(CryptoCode,Address, Amount);
}
public static string GetPaymentId(string cryptoCode, string address, long amount)
{
return $"{cryptoCode}#{address}#{amount}";
}
public string[] GetSearchTerms()
{
return new[] {Address};
}
public decimal GetValue()
{
return decimal.Parse(Web3.Convert.FromWeiToBigDecimal(Amount, Network.Divisibility).ToString(),
CultureInfo.InvariantCulture);
}
public bool PaymentCompleted(PaymentEntity entity)
{
return ConfirmationCount >= 25;
}
public bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy)
{
switch (speedPolicy)
{
case SpeedPolicy.HighSpeed:
return ConfirmationCount >= 2;
case SpeedPolicy.MediumSpeed:
return ConfirmationCount >= 6;
case SpeedPolicy.LowMediumSpeed:
return ConfirmationCount >= 12;
case SpeedPolicy.LowSpeed:
return ConfirmationCount >= 20;
default:
return false;
}
}
public PaymentType GetPaymentType()
{
return EthereumPaymentType.Instance;
}
public string GetDestination()
{
return Address;
}
}
}
#endif

View file

@ -0,0 +1,134 @@
#if ALTCOINS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services.Altcoins.Ethereum.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using NBitcoin;
namespace BTCPayServer.Services.Altcoins.Ethereum.Payments
{
public class
EthereumLikePaymentMethodHandler : PaymentMethodHandlerBase<EthereumSupportedPaymentMethod,
EthereumBTCPayNetwork>
{
private readonly BTCPayNetworkProvider _networkProvider;
private readonly EthereumService _ethereumService;
public EthereumLikePaymentMethodHandler(BTCPayNetworkProvider networkProvider, EthereumService ethereumService)
{
_networkProvider = networkProvider;
_ethereumService = ethereumService;
}
public override PaymentType PaymentType => EthereumPaymentType.Instance;
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs,
EthereumSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
StoreData store, EthereumBTCPayNetwork network, object preparePaymentObject)
{
if (!_ethereumService.IsAvailable(network.CryptoCode, out var error))
throw new PaymentMethodUnavailableException(error??$"Not configured yet");
var invoice = paymentMethod.ParentEntity;
if (!(preparePaymentObject is Prepare ethPrepare)) throw new ArgumentException();
var address = await ethPrepare.ReserveAddress(invoice.Id);
if (address is null || address.Failed)
{
throw new PaymentMethodUnavailableException($"could not generate address");
}
return new EthereumLikeOnChainPaymentMethodDetails()
{
DepositAddress = address.Address, Index = address.Index, XPub = address.XPub
};
}
public override object PreparePayment(EthereumSupportedPaymentMethod supportedPaymentMethod, StoreData store,
BTCPayNetworkBase network)
{
return new Prepare()
{
ReserveAddress = s =>
_ethereumService.ReserveNextAddress(
new EthereumService.ReserveEthereumAddress()
{
StoreId = store.Id, CryptoCode = network.CryptoCode
})
};
}
class Prepare
{
public Func<string, Task<EthereumService.ReserveEthereumAddressResponse>> ReserveAddress;
}
public override void PreparePaymentModel(PaymentModel model, InvoiceResponse invoiceResponse,
StoreBlob storeBlob)
{
var paymentMethodId = new PaymentMethodId(model.CryptoCode, PaymentType);
var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var network = _networkProvider.GetNetwork<EthereumBTCPayNetwork>(model.CryptoCode);
model.IsLightning = false;
model.PaymentMethodName = GetPaymentMethodName(network);
model.CryptoImage = GetCryptoImage(network);
model.InvoiceBitcoinUrl = "";
model.InvoiceBitcoinUrlQR = cryptoInfo.Address;
}
public override string GetCryptoImage(PaymentMethodId paymentMethodId)
{
var network = _networkProvider.GetNetwork<EthereumBTCPayNetwork>(paymentMethodId.CryptoCode);
return GetCryptoImage(network);
}
public override string GetPaymentMethodName(PaymentMethodId paymentMethodId)
{
var network = _networkProvider.GetNetwork<EthereumBTCPayNetwork>(paymentMethodId.CryptoCode);
return GetPaymentMethodName(network);
}
public override Task<string> IsPaymentMethodAllowedBasedOnInvoiceAmount(StoreBlob storeBlob,
Dictionary<CurrencyPair, Task<RateResult>> rate, Money amount,
PaymentMethodId paymentMethodId)
{
return Task.FromResult<string>(null);
}
public override IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
{
return _networkProvider.GetAll().OfType<EthereumBTCPayNetwork>()
.Select(network => new PaymentMethodId(network.CryptoCode, PaymentType));
}
public override CheckoutUIPaymentMethodSettings GetCheckoutUISettings()
{
return new CheckoutUIPaymentMethodSettings()
{
ExtensionPartial = "Ethereum/EthereumLikeMethodCheckout",
CheckoutBodyVueComponentName = "EthereumLikeMethodCheckout",
CheckoutHeaderVueComponentName = "EthereumLikeMethodCheckoutHeader",
NoScriptPartialName = "Bitcoin_Lightning_LikeMethodCheckoutNoScript"
};
}
private string GetCryptoImage(EthereumBTCPayNetwork network)
{
return network.CryptoImagePath;
}
private string GetPaymentMethodName(EthereumBTCPayNetwork network)
{
return $"{network.DisplayName}";
}
}
}
#endif

View file

@ -0,0 +1,59 @@
#if ALTCOINS
using System.Globalization;
using BTCPayServer.Payments;
using BTCPayServer.Services.Altcoins.Monero.Payments;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Altcoins.Ethereum.Payments
{
public class EthereumPaymentType: PaymentType
{
public static EthereumPaymentType Instance { get; } = new EthereumPaymentType();
public override string ToPrettyString() => "On-Chain";
public override string GetId()=> "EthereumLike";
public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str)
{
return JsonConvert.DeserializeObject<EthereumLikePaymentData>(str);
}
public override string SerializePaymentData(BTCPayNetworkBase network, CryptoPaymentData paymentData)
{
return JsonConvert.SerializeObject(paymentData);
}
public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str)
{
return JsonConvert.DeserializeObject<EthereumLikeOnChainPaymentMethodDetails>(str);
}
public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details)
{
return JsonConvert.SerializeObject(details);
}
public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value)
{
return JsonConvert.DeserializeObject<EthereumSupportedPaymentMethod>(value.ToString());
}
public override string GetTransactionLink(BTCPayNetworkBase network, string txId)
{
return string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId);
}
public override string GetPaymentLink(BTCPayNetworkBase network, IPaymentMethodDetails paymentMethodDetails, Money cryptoInfoDue,
string serverUri)
{
return "";
}
public override string InvoiceViewPaymentPartialName { get; }= "Ethereum/ViewEthereumLikePaymentData";
}
}
#endif

View file

@ -0,0 +1,41 @@
#if ALTCOINS
using System;
using BTCPayServer.Payments;
using NBitcoin;
using Nethereum.HdWallet;
namespace BTCPayServer.Services.Altcoins.Ethereum.Payments
{
public class EthereumSupportedPaymentMethod : ISupportedPaymentMethod
{
public string CryptoCode { get; set; }
public string Seed { get; set; }
public string Password { get; set; }
public string XPub { get; set; }
public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, EthereumPaymentType.Instance);
public long CurrentIndex { get; set; }
public string KeyPath { get; set; }
public Func<int, string> GetWalletDerivator()
{
if (!string.IsNullOrEmpty(XPub))
{
try
{
return new PublicWallet(XPub).GetAddress;
}
catch (Exception)
{
return new PublicWallet(new BitcoinExtPubKey(XPub, Network.Main).ExtPubKey).GetAddress;
}
}
else if (!string.IsNullOrEmpty(XPub))
{
return i => new Wallet(Seed, Password, KeyPath).GetAccount(i).Address;
}
return null;
}
}
}
#endif

View file

@ -0,0 +1,313 @@
#if ALTCOINS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Services.Altcoins.Ethereum.Payments;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Altcoins.Ethereum.Configuration;
using BTCPayServer.Services.Altcoins.Ethereum.UI;
using BTCPayServer.Services.Invoices;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NBitcoin;
namespace BTCPayServer.Services.Altcoins.Ethereum.Services
{
public class EthereumService : EventHostedServiceBase
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly EventAggregator _eventAggregator;
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly SettingsRepository _settingsRepository;
private readonly InvoiceRepository _invoiceRepository;
private readonly IConfiguration _configuration;
private readonly Dictionary<int, EthereumWatcher> _chainHostedServices = new Dictionary<int, EthereumWatcher>();
private readonly Dictionary<int, CancellationTokenSource> _chainHostedServiceCancellationTokenSources =
new Dictionary<int, CancellationTokenSource>();
public EthereumService(
IHttpClientFactory httpClientFactory,
EventAggregator eventAggregator,
StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider,
SettingsRepository settingsRepository,
InvoiceRepository invoiceRepository,
IConfiguration configuration) : base(
eventAggregator)
{
_httpClientFactory = httpClientFactory;
_eventAggregator = eventAggregator;
_storeRepository = storeRepository;
_btcPayNetworkProvider = btcPayNetworkProvider;
_settingsRepository = settingsRepository;
_invoiceRepository = invoiceRepository;
_configuration = configuration;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
var chainIds = _btcPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>()
.Select(network => network.ChainId).Distinct().ToList();
if (!chainIds.Any())
{
return;
}
await base.StartAsync(cancellationToken);
_ = Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
_eventAggregator.Publish(new CheckWatchers());
await Task.Delay(IsAllAvailable()? TimeSpan.FromDays(1): TimeSpan.FromSeconds(5) , cancellationToken);
}
}, cancellationToken);
}
private static bool First = true;
private async Task LoopThroughChainWatchers(CancellationToken cancellationToken)
{
var chainIds = _btcPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>()
.Select(network => network.ChainId).Distinct().ToList();
foreach (var chainId in chainIds)
{
try
{
var settings = await _settingsRepository.GetSettingAsync<EthereumLikeConfiguration>(
EthereumLikeConfiguration.SettingsKey(chainId));
if (settings is null || string.IsNullOrEmpty(settings.Web3ProviderUrl))
{
var val = _configuration.GetValue<string>($"chain{chainId}_web3", null);
var valUser = _configuration.GetValue<string>($"chain{chainId}_web3_user", null);
var valPass = _configuration.GetValue<string>($"chain{chainId}_web3_password", null);
if (val != null && First)
{
Logs.PayServer.LogInformation($"Setting eth chain {chainId} web3 to {val}");
settings ??= new EthereumLikeConfiguration()
{
ChainId = chainId,
Web3ProviderUrl = val,
Web3ProviderPassword = valPass,
Web3ProviderUsername = valUser
};
await _settingsRepository.UpdateSetting(settings, EthereumLikeConfiguration.SettingsKey(chainId));
}
}
var currentlyRunning = _chainHostedServices.ContainsKey(chainId);
var valid = await EthereumConfigController.CheckValid(_httpClientFactory, _btcPayNetworkProvider.NetworkType, settings?.InvoiceId);
if (!currentlyRunning || (currentlyRunning && !valid))
{
await HandleChainWatcher(settings, valid, cancellationToken);
}
}
catch (Exception)
{
// ignored
}
}
First = false;
}
public override Task StopAsync(CancellationToken cancellationToken)
{
foreach (var chainHostedService in _chainHostedServices.Values)
{
_ = chainHostedService.StopAsync(cancellationToken);
}
return base.StopAsync(cancellationToken);
}
protected override void SubscribeToEvents()
{
base.SubscribeToEvents();
Subscribe<ReserveEthereumAddress>();
Subscribe<SettingsChanged<EthereumLikeConfiguration>>();
Subscribe<CheckWatchers>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is ReserveEthereumAddress reserveEthereumAddress)
{
await HandleReserveNextAddress(reserveEthereumAddress);
}
if (evt is SettingsChanged<EthereumLikeConfiguration> settingsChangedEthConfig)
{
var valid = await EthereumConfigController.CheckValid(_httpClientFactory, _btcPayNetworkProvider.NetworkType, settingsChangedEthConfig?.Settings?.InvoiceId);
await HandleChainWatcher(settingsChangedEthConfig.Settings, valid, cancellationToken);
}
if (evt is CheckWatchers)
{
await LoopThroughChainWatchers(cancellationToken);
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task HandleChainWatcher(EthereumLikeConfiguration ethereumLikeConfiguration, bool valid,
CancellationToken cancellationToken)
{
if (ethereumLikeConfiguration is null)
{
return;
}
if (_chainHostedServiceCancellationTokenSources.ContainsKey(ethereumLikeConfiguration.ChainId))
{
_chainHostedServiceCancellationTokenSources[ethereumLikeConfiguration.ChainId].Cancel();
_chainHostedServiceCancellationTokenSources.Remove(ethereumLikeConfiguration.ChainId);
}
if (_chainHostedServices.ContainsKey(ethereumLikeConfiguration.ChainId))
{
await _chainHostedServices[ethereumLikeConfiguration.ChainId].StopAsync(cancellationToken);
_chainHostedServices.Remove(ethereumLikeConfiguration.ChainId);
}
if (!string.IsNullOrWhiteSpace(ethereumLikeConfiguration.Web3ProviderUrl) && valid)
{
var cts = new CancellationTokenSource();
_chainHostedServiceCancellationTokenSources.AddOrReplace(ethereumLikeConfiguration.ChainId, cts);
_chainHostedServices.AddOrReplace(ethereumLikeConfiguration.ChainId,
new EthereumWatcher(ethereumLikeConfiguration.ChainId, ethereumLikeConfiguration,
_btcPayNetworkProvider, _eventAggregator, _invoiceRepository));
await _chainHostedServices[ethereumLikeConfiguration.ChainId].StartAsync(CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken, cts.Token).Token);
}
}
private async Task HandleReserveNextAddress(ReserveEthereumAddress reserveEthereumAddress)
{
var store = await _storeRepository.FindStore(reserveEthereumAddress.StoreId);
var ethereumSupportedPaymentMethod = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<EthereumSupportedPaymentMethod>()
.SingleOrDefault(method => method.PaymentId.CryptoCode == reserveEthereumAddress.CryptoCode);
if (ethereumSupportedPaymentMethod == null)
{
_eventAggregator.Publish(new ReserveEthereumAddressResponse()
{
OpId = reserveEthereumAddress.OpId, Failed = true
});
return;
}
ethereumSupportedPaymentMethod.CurrentIndex++;
var address = ethereumSupportedPaymentMethod.GetWalletDerivator()?
.Invoke((int)ethereumSupportedPaymentMethod.CurrentIndex);
if (string.IsNullOrEmpty(address))
{
_eventAggregator.Publish(new ReserveEthereumAddressResponse()
{
OpId = reserveEthereumAddress.OpId, Failed = true
});
return;
}
store.SetSupportedPaymentMethod(ethereumSupportedPaymentMethod.PaymentId,
ethereumSupportedPaymentMethod);
await _storeRepository.UpdateStore(store);
_eventAggregator.Publish(new ReserveEthereumAddressResponse()
{
Address = address,
Index = ethereumSupportedPaymentMethod.CurrentIndex,
CryptoCode = ethereumSupportedPaymentMethod.CryptoCode,
OpId = reserveEthereumAddress.OpId,
StoreId = reserveEthereumAddress.StoreId,
XPub = ethereumSupportedPaymentMethod.XPub
});
}
public async Task<ReserveEthereumAddressResponse> ReserveNextAddress(ReserveEthereumAddress address)
{
address.OpId = string.IsNullOrEmpty(address.OpId) ? Guid.NewGuid().ToString() : address.OpId;
var tcs = new TaskCompletionSource<ReserveEthereumAddressResponse>();
var subscription = _eventAggregator.Subscribe<ReserveEthereumAddressResponse>(response =>
{
if (response.OpId == address.OpId)
{
tcs.SetResult(response);
}
});
_eventAggregator.Publish(address);
if (tcs.Task.Wait(TimeSpan.FromSeconds(60)))
{
subscription?.Dispose();
return await tcs.Task;
}
subscription?.Dispose();
return null;
}
public class CheckWatchers
{
public override string ToString()
{
return "";
}
}
public class ReserveEthereumAddressResponse
{
public string StoreId { get; set; }
public string CryptoCode { get; set; }
public string Address { get; set; }
public long Index { get; set; }
public string OpId { get; set; }
public string XPub { get; set; }
public bool Failed { get; set; }
public override string ToString()
{
return $"Reserved {CryptoCode} address {Address} for store {StoreId}";
}
}
public class ReserveEthereumAddress
{
public string StoreId { get; set; }
public string CryptoCode { get; set; }
public string OpId { get; set; }
public override string ToString()
{
return $"Reserving {CryptoCode} address for store {StoreId}";
}
}
public bool IsAllAvailable()
{
return _btcPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>()
.All(network => IsAvailable(network.CryptoCode, out _));
}
public bool IsAvailable(string networkCryptoCode, out string error)
{
error = null;
var chainId = _btcPayNetworkProvider.GetNetwork<EthereumBTCPayNetwork>(networkCryptoCode)?.ChainId;
if (chainId != null && _chainHostedServices.TryGetValue(chainId.Value, out var watcher))
{
error = watcher.GlobalError;
return string.IsNullOrEmpty(watcher.GlobalError);
}
return false;
}
}
}
#endif

View file

@ -0,0 +1,23 @@
#if ALTCOINS
using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Altcoins.Ethereum.Services
{
public class EthereumSyncSummaryProvider : ISyncSummaryProvider
{
private readonly EthereumService _ethereumService;
public EthereumSyncSummaryProvider(EthereumService ethereumService)
{
_ethereumService = ethereumService;
}
public bool AllAvailable()
{
return _ethereumService.IsAllAvailable();
}
public string Partial { get; } = "Ethereum/ETHSyncSummary";
}
}
#endif

View file

@ -0,0 +1,378 @@
#if ALTCOINS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services.Altcoins.Ethereum.Configuration;
using BTCPayServer.Services.Altcoins.Ethereum.Payments;
using BTCPayServer.Services.Invoices;
using Microsoft.Extensions.Logging;
using NBitcoin.Logging;
using Nethereum.RPC.Eth.DTOs;
using Nethereum.StandardTokenEIP20.ContractDefinition;
using Nethereum.Web3;
namespace BTCPayServer.Services.Altcoins.Ethereum.Services
{
public class EthereumWatcher : EventHostedServiceBase
{
private readonly EventAggregator _eventAggregator;
private readonly InvoiceRepository _invoiceRepository;
private int ChainId { get; }
private readonly HashSet<PaymentMethodId> PaymentMethods;
private readonly Web3 Web3;
private readonly List<EthereumBTCPayNetwork> Networks;
public string GlobalError { get; private set; } = "The chain watcher is still starting.";
public override async Task StartAsync(CancellationToken cancellationToken)
{
Logs.NodeServer.LogInformation($"Starting EthereumWatcher for chain {ChainId}");
var result = await Web3.Eth.ChainId.SendRequestAsync();
if (result.Value != ChainId)
{
GlobalError =
$"The web3 client is connected to a different chain id. Expected {ChainId} but Web3 returned {result.Value}";
return;
}
await base.StartAsync(cancellationToken);
_eventAggregator.Publish(new CatchUp());
GlobalError = null;
}
protected override void SubscribeToEvents()
{
Subscribe<EthereumService.ReserveEthereumAddressResponse>();
Subscribe<EthereumAddressBalanceFetched>();
Subscribe<CatchUp>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is CatchUp)
{
DateTimeOffset start = DateTimeOffset.Now;
await UpdateAnyPendingEthLikePaymentAndAddressWatchList(cancellationToken);
TimeSpan diff = start - DateTimeOffset.Now;
if (diff.TotalSeconds < 5)
{
_ = Task.Delay(TimeSpan.FromSeconds(5 - diff.TotalSeconds), cancellationToken).ContinueWith(task =>
{
_eventAggregator.Publish(new CatchUp());
return Task.CompletedTask;
}, cancellationToken, TaskContinuationOptions.None, TaskScheduler.Current);
}
}
if (evt is EthereumAddressBalanceFetched response)
{
if (response.ChainId != ChainId)
{
return;
}
var network = Networks.SingleOrDefault(payNetwork =>
payNetwork.CryptoCode.Equals(response.CryptoCode, StringComparison.InvariantCultureIgnoreCase));
if (network is null)
{
return;
}
var invoice = response.InvoiceEntity;
if (invoice is null)
{
return;
}
var existingPayment = response.MatchedExistingPayment;
if (existingPayment is null && response.Amount > 0)
{
//new payment
var paymentData = new EthereumLikePaymentData()
{
Address = response.Address,
CryptoCode = response.CryptoCode,
Amount = response.Amount,
Network = network,
BlockNumber =
response.BlockParameter.ParameterType == BlockParameter.BlockParameterType.blockNumber
? (long?)response.BlockParameter.BlockNumber.Value
: (long?)null,
ConfirmationCount = 0,
AccountIndex = response.PaymentMethodDetails.Index,
XPub = response.PaymentMethodDetails.XPub
};
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow,
paymentData, network, true);
if (payment != null) ReceivedPayment(invoice, payment);
}
else if (existingPayment != null)
{
var cd = (EthereumLikePaymentData)existingPayment.GetCryptoPaymentData();
//existing payment amount was changed. Set to unaccounted and register as a new payment.
if (response.Amount == 0 || response.Amount != cd.Amount)
{
existingPayment.Accounted = false;
await _invoiceRepository.UpdatePayments(new List<PaymentEntity>() {existingPayment});
if (response.Amount > 0)
{
var paymentData = new EthereumLikePaymentData()
{
Address = response.Address,
CryptoCode = response.CryptoCode,
Amount = response.Amount,
Network = network,
BlockNumber =
response.BlockParameter.ParameterType ==
BlockParameter.BlockParameterType.blockNumber
? (long?)response.BlockParameter.BlockNumber.Value
: null,
ConfirmationCount =
response.BlockParameter.ParameterType ==
BlockParameter.BlockParameterType.blockNumber
? 1
: 0,
AccountIndex = cd.AccountIndex,
XPub = cd.XPub
};
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow,
paymentData, network, true);
if (payment != null) ReceivedPayment(invoice, payment);
}
}
else if (response.Amount == cd.Amount)
{
//transition from pending to 1 confirmed
if (cd.BlockNumber is null && response.BlockParameter.ParameterType ==
BlockParameter.BlockParameterType.blockNumber)
{
cd.ConfirmationCount = 1;
cd.BlockNumber = (long?)response.BlockParameter.BlockNumber.Value;
existingPayment.SetCryptoPaymentData(cd);
await _invoiceRepository.UpdatePayments(new List<PaymentEntity>() {existingPayment});
_eventAggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
}
//increment confirm count
else if (response.BlockParameter.ParameterType ==
BlockParameter.BlockParameterType.blockNumber)
{
if (response.BlockParameter.BlockNumber.Value > cd.BlockNumber.Value)
{
cd.ConfirmationCount =
(long)(response.BlockParameter.BlockNumber.Value - cd.BlockNumber.Value);
}
else
{
cd.BlockNumber = (long?)response.BlockParameter.BlockNumber.Value;
cd.ConfirmationCount = 1;
}
existingPayment.SetCryptoPaymentData(cd);
await _invoiceRepository.UpdatePayments(new List<PaymentEntity>() {existingPayment});
_eventAggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
}
}
}
}
}
class CatchUp
{
public override string ToString()
{
return "";
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
Logs.NodeServer.LogInformation($"Stopping EthereumWatcher for chain {ChainId}");
return base.StopAsync(cancellationToken);
}
private async Task UpdateAnyPendingEthLikePaymentAndAddressWatchList(CancellationToken cancellationToken)
{
var invoiceIds = await _invoiceRepository.GetPendingInvoices();
if (!invoiceIds.Any())
{
return;
}
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery() {InvoiceId = invoiceIds});
invoices = invoices
.Where(entity => PaymentMethods.Any(id => entity.GetPaymentMethod(id) != null))
.ToArray();
await UpdatePaymentStates(invoices, cancellationToken);
}
private long? LastBlock = null;
private async Task UpdatePaymentStates(InvoiceEntity[] invoices, CancellationToken cancellationToken)
{
if (!invoices.Any())
{
return;
}
var currentBlock = await Web3.Eth.Blocks.GetBlockNumber.SendRequestAsync();
foreach (var network in Networks)
{
var erc20Network = network as ERC20BTCPayNetwork;
var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance);
var expandedInvoices = invoices
.Select(entity => (
Invoice: entity,
PaymentMethodDetails: entity.GetPaymentMethods().TryGet(paymentMethodId),
ExistingPayments: entity.GetPayments(network).Select(paymentEntity => (Payment: paymentEntity,
PaymentData: (EthereumLikePaymentData)paymentEntity.GetCryptoPaymentData(),
Invoice: entity))
)).Where(tuple => tuple.PaymentMethodDetails != null).ToList();
var existingPaymentData = expandedInvoices.SelectMany(tuple =>
tuple.ExistingPayments.Where(valueTuple => valueTuple.Payment.Accounted)).ToList();
var noAccountedPaymentInvoices = expandedInvoices.Where(tuple =>
tuple.ExistingPayments.All(valueTuple => !valueTuple.Payment.Accounted)).ToList();
var tasks = new List<Task>();
if (existingPaymentData.Any() && currentBlock.Value != LastBlock)
{
Logs.NodeServer.LogInformation(
$"Checking {existingPaymentData.Count} existing payments on {expandedInvoices.Count} invoices on {network.CryptoCode}");
var blockParameter = new BlockParameter(currentBlock);
tasks.Add(Task.WhenAll(existingPaymentData.Select(async tuple =>
{
var bal = await GetBalance(network, blockParameter, tuple.PaymentData.Address);
_eventAggregator.Publish(new EthereumAddressBalanceFetched()
{
Address = tuple.PaymentData.Address,
CryptoCode = network.CryptoCode,
Amount = bal,
MatchedExistingPayment = tuple.Payment,
BlockParameter = blockParameter,
ChainId = ChainId,
InvoiceEntity = tuple.Invoice,
});
})).ContinueWith(task =>
{
LastBlock = (long?)currentBlock.Value;
}, TaskScheduler.Current));
}
if (noAccountedPaymentInvoices.Any())
{
Logs.NodeServer.LogInformation(
$"Checking {noAccountedPaymentInvoices.Count} addresses for new payments on {network.CryptoCode}");
var blockParameter = BlockParameter.CreatePending();
tasks.AddRange(noAccountedPaymentInvoices.Select(async tuple =>
{
var bal = await GetBalance(network, blockParameter,
tuple.PaymentMethodDetails.GetPaymentMethodDetails().GetPaymentDestination());
_eventAggregator.Publish(new EthereumAddressBalanceFetched()
{
Address = tuple.PaymentMethodDetails.GetPaymentMethodDetails().GetPaymentDestination(),
CryptoCode = network.CryptoCode,
Amount = bal,
MatchedExistingPayment = null,
BlockParameter = blockParameter,
ChainId = ChainId,
InvoiceEntity = tuple.Invoice,
PaymentMethodDetails = (EthereumLikeOnChainPaymentMethodDetails) tuple.PaymentMethodDetails.GetPaymentMethodDetails()
});
}));
}
await Task.WhenAll(tasks);
}
}
public class EthereumAddressBalanceFetched
{
public BlockParameter BlockParameter { get; set; }
public int ChainId { get; set; }
public string Address { get; set; }
public string CryptoCode { get; set; }
public long Amount { get; set; }
public InvoiceEntity InvoiceEntity { get; set; }
public PaymentEntity MatchedExistingPayment { get; set; }
public EthereumLikeOnChainPaymentMethodDetails PaymentMethodDetails { get; set; }
public override string ToString()
{
return "";
}
}
private void ReceivedPayment(InvoiceEntity invoice, PaymentEntity payment)
{
_eventAggregator.Publish(
new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) {Payment = payment});
}
private async Task<long> GetBalance(EthereumBTCPayNetwork network, BlockParameter blockParameter,
string address)
{
if (network is ERC20BTCPayNetwork erc20BTCPayNetwork)
{
return (long)(await Web3.Eth.GetContractHandler(erc20BTCPayNetwork.SmartContractAddress)
.QueryAsync<BalanceOfFunction, BigInteger>(new BalanceOfFunction() {Owner = address}));
}
else
{
return (long)(await Web3.Eth.GetBalance.SendRequestAsync(address, blockParameter)).Value;
}
}
public EthereumWatcher(int chainId, EthereumLikeConfiguration config,
BTCPayNetworkProvider btcPayNetworkProvider,
EventAggregator eventAggregator, InvoiceRepository invoiceRepository) :
base(eventAggregator)
{
_eventAggregator = eventAggregator;
_invoiceRepository = invoiceRepository;
ChainId = chainId;
AuthenticationHeaderValue headerValue = null;
if (!string.IsNullOrEmpty(config.Web3ProviderUsername))
{
var val = config.Web3ProviderUsername;
if (!string.IsNullOrEmpty(config.Web3ProviderUsername))
{
val += $":{config.Web3ProviderUsername}";
}
headerValue = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(
System.Text.Encoding.ASCII.GetBytes(val)));
}
Web3 = new Web3(config.Web3ProviderUrl, null, headerValue);
Networks = btcPayNetworkProvider.GetAll()
.OfType<EthereumBTCPayNetwork>()
.Where(network => network.ChainId == chainId)
.ToList();
PaymentMethods = Networks
.Select(network => new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance))
.ToHashSet();
}
}
}
#endif

View file

@ -0,0 +1,169 @@
#if ALTCOINS
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Services.Altcoins.Ethereum.Configuration;
using BTCPayServer.Services.Altcoins.Ethereum.Filters;
using BTCPayServer.Services.Altcoins.Ethereum.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using Nethereum.Hex.HexConvertors.Extensions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Altcoins.Ethereum.UI
{
[Route("ethconfig")]
[OnlyIfSupportEth]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class EthereumConfigController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly SettingsRepository _settingsRepository;
private readonly UserManager<ApplicationUser> _userManager;
private readonly EventAggregator _eventAggregator;
public EthereumConfigController(IHttpClientFactory httpClientFactory, SettingsRepository settingsRepository,
UserManager<ApplicationUser> userManager,
EventAggregator eventAggregator)
{
_httpClientFactory = httpClientFactory;
_settingsRepository = settingsRepository;
_userManager = userManager;
_eventAggregator = eventAggregator;
}
[HttpGet("{chainId}")]
public async Task<IActionResult> UpdateChainConfig(int chainId)
{
return View("Ethereum/UpdateChainConfig",
(await _settingsRepository.GetSettingAsync<EthereumLikeConfiguration>(
EthereumLikeConfiguration.SettingsKey(chainId))) ?? new EthereumLikeConfiguration()
{
ChainId = chainId, Web3ProviderUrl = ""
});
}
[HttpGet("{chainId}/cb")]
public IActionResult Callback(int chainId)
{
_eventAggregator.Publish(new EthereumService.CheckWatchers());
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "If the invoice was paid successfully and confirmed, the system will be enabled momentarily"
});
return RedirectToAction("UpdateChainConfig", new {chainId});
}
[HttpPost("{chainId}")]
public async Task<IActionResult> UpdateChainConfig(int chainId, EthereumLikeConfiguration vm)
{
var current = await _settingsRepository.GetSettingAsync<EthereumLikeConfiguration>(
EthereumLikeConfiguration.SettingsKey(chainId));
if (current?.Web3ProviderUrl != vm.Web3ProviderUrl || current?.InvoiceId != vm.InvoiceId)
{
vm.ChainId = chainId;
await _settingsRepository.UpdateSetting(vm, EthereumLikeConfiguration.SettingsKey(chainId));
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success, Message = $"Chain {chainId} updated"
});
return RedirectToAction(nameof(UpdateChainConfig));
}
[HttpGet("{chainId}/p")]
[HttpPost("{chainId}/p")]
public async Task<IActionResult> CreateInvoice(int chainId)
{
var current = await _settingsRepository.GetSettingAsync<EthereumLikeConfiguration>(
EthereumLikeConfiguration.SettingsKey(chainId));
current ??= new EthereumLikeConfiguration() {ChainId = chainId};
if (!string.IsNullOrEmpty(current?.InvoiceId) &&
Request.Method.Equals("get", StringComparison.InvariantCultureIgnoreCase))
{
return View("Confirm",
new ConfirmModel()
{
Title = $"Generate new donation link?",
Description =
"This previously linked donation instructions will be erased. If you paid anything to it, you will lose access.",
Action = "Confirm and generate",
});
}
var user = await _userManager.GetUserAsync(User);
var httpClient = _httpClientFactory.CreateClient(EthereumLikeExtensions.EthereumInvoiceCreateHttpClient);
string invoiceUrl;
var response = await httpClient.PostAsync($"{Server.HexToUTF8String()}{invoiceEndpoint.HexToUTF8String()}",
new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("choiceKey", $"license_{chainId}"),
new KeyValuePair<string, string>("posData",
JsonConvert.SerializeObject(new {Host = Request.Host, ChainId = chainId})),
new KeyValuePair<string, string>("orderID", $"eth_{Request.Host}_{chainId}"),
new KeyValuePair<string, string>("email", user.Email),
new KeyValuePair<string, string>("redirectUrl",
Url.Action("Callback", "EthereumConfig", new {chainId}, Request.Scheme)),
}));
if (response.StatusCode == System.Net.HttpStatusCode.Found)
{
HttpResponseHeaders headers = response.Headers;
if (headers != null && headers.Location != null)
{
invoiceUrl = $"{Server.HexToUTF8String()}{headers.Location}";
current.InvoiceId = headers.Location.ToString()
.Replace("/i/", string.Empty, StringComparison.InvariantCultureIgnoreCase);
await UpdateChainConfig(chainId, current);
return Redirect(invoiceUrl);
}
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error, Message = $"Couldn't connect to donation server, try again later."
});
return RedirectToAction("UpdateChainConfig", new { chainId});
}
private string invoiceEndpoint = "0x2f617070732f3262706f754e74576b4b3543636e426d374833456a3346505a756f412f706f73";
private static string Server = "0x68747470733a2f2f787061797365727665722e636f6d";
public static NetworkType InvoiceEnforced = NetworkType.Mainnet;
public static async Task<bool> CheckValid(IHttpClientFactory httpClientFactory, NetworkType networkType, string invoiceId)
{
if (networkType != InvoiceEnforced)
{
return true;
}
if (string.IsNullOrEmpty(invoiceId))
{
return false;
}
var httpClient = httpClientFactory.CreateClient(EthereumLikeExtensions.EthereumInvoiceCheckHttpClient);
var url = $"{Server.HexToUTF8String()}/i/{invoiceId}/status";
var response = await httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return false;
}
var raw = await response.Content.ReadAsStringAsync();
var status = JObject.Parse(raw)["status"].ToString();
return (status.Equals("complete", StringComparison.InvariantCultureIgnoreCase) ||
status.Equals("confirmed", StringComparison.InvariantCultureIgnoreCase));
;
}
}
}
#endif

View file

@ -0,0 +1,281 @@
#if ALTCOINS
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services.Altcoins.Ethereum.Filters;
using BTCPayServer.Services.Altcoins.Ethereum.Payments;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Internal;
using NBitcoin;
using Nethereum.HdWallet;
using Nethereum.Hex.HexConvertors.Extensions;
namespace BTCPayServer.Services.Altcoins.Ethereum.UI
{
[Route("stores/{storeId}/ethlike")]
[OnlyIfSupportEth]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class EthereumLikeStoreController : Controller
{
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
public EthereumLikeStoreController(StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider)
{
_storeRepository = storeRepository;
_btcPayNetworkProvider = btcPayNetworkProvider;
}
private StoreData StoreData => HttpContext.GetStoreData();
[HttpGet()]
public IActionResult GetStoreEthereumLikePaymentMethods()
{
var eth = StoreData.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<EthereumSupportedPaymentMethod>();
var excludeFilters = StoreData.GetStoreBlob().GetExcludedPaymentMethods();
var ethNetworks = _btcPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>();
var vm = new ViewEthereumStoreOptionsViewModel() { };
foreach (var network in ethNetworks)
{
var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance);
var matchedPaymentMethod = eth.SingleOrDefault(method =>
method.PaymentId == paymentMethodId);
vm.Items.Add(new ViewEthereumStoreOptionItemViewModel()
{
CryptoCode = network.CryptoCode,
Enabled = matchedPaymentMethod != null && !excludeFilters.Match(paymentMethodId),
IsToken = network is ERC20BTCPayNetwork,
RootAddress = matchedPaymentMethod?.GetWalletDerivator()?.Invoke(0) ?? "not configured"
});
}
return View(vm);
}
[HttpGet("{cryptoCode}")]
public IActionResult GetStoreEthereumLikePaymentMethod(string cryptoCode)
{
var eth = StoreData.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<EthereumSupportedPaymentMethod>();
var network = _btcPayNetworkProvider.GetNetwork<EthereumBTCPayNetwork>(cryptoCode);
if (network is null)
{
return NotFound();
}
var excludeFilters = StoreData.GetStoreBlob().GetExcludedPaymentMethods();
var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance);
var matchedPaymentMethod = eth.SingleOrDefault(method =>
method.PaymentId == paymentMethodId);
return View(new EditEthereumPaymentMethodViewModel()
{
Enabled = !excludeFilters.Match(paymentMethodId),
XPub = matchedPaymentMethod?.XPub,
Index = matchedPaymentMethod?.CurrentIndex ?? 0,
Passphrase = matchedPaymentMethod?.Password,
Seed = matchedPaymentMethod?.Seed,
StoreSeed = !string.IsNullOrEmpty(matchedPaymentMethod?.Seed),
OriginalIndex = matchedPaymentMethod?.CurrentIndex ?? 0,
KeyPath = string.IsNullOrEmpty(matchedPaymentMethod?.KeyPath)
? network.GetDefaultKeyPath()
: matchedPaymentMethod?.KeyPath
});
}
[HttpPost("{cryptoCode}")]
public async Task<IActionResult> GetStoreEthereumLikePaymentMethod(string cryptoCode,
EditEthereumPaymentMethodViewModel viewModel)
{
var network = _btcPayNetworkProvider.GetNetwork<EthereumBTCPayNetwork>(cryptoCode);
if (network is null)
{
return NotFound();
}
var store = StoreData;
var blob = StoreData.GetStoreBlob();
var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance);
var currentPaymentMethod = StoreData.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<EthereumSupportedPaymentMethod>().SingleOrDefault(method =>
method.PaymentId == paymentMethodId);
if (currentPaymentMethod != null && currentPaymentMethod.CurrentIndex != viewModel.Index &&
viewModel.OriginalIndex == viewModel.Index)
{
viewModel.Index = currentPaymentMethod.CurrentIndex;
viewModel.OriginalIndex = currentPaymentMethod.CurrentIndex;
}
else if (currentPaymentMethod != null && currentPaymentMethod.CurrentIndex != viewModel.Index &&
viewModel.OriginalIndex != currentPaymentMethod.CurrentIndex)
{
ModelState.AddModelError(nameof(viewModel.Index),
$"You tried to update the index (to {viewModel.Index}) but new derivations in the background updated the index (to {currentPaymentMethod.CurrentIndex}) ");
viewModel.Index = currentPaymentMethod.CurrentIndex;
viewModel.OriginalIndex = currentPaymentMethod.CurrentIndex;
}
Wallet wallet = null;
try
{
if (!string.IsNullOrEmpty(viewModel.Seed))
{
wallet = new Wallet(viewModel.Seed, viewModel.Passphrase,
string.IsNullOrEmpty(viewModel.KeyPath) ? network.GetDefaultKeyPath() : viewModel.KeyPath);
}
}
catch (Exception)
{
ModelState.AddModelError(nameof(viewModel.Seed), $"seed was incorrect");
}
if (wallet != null)
{
try
{
wallet.GetAccount(0);
}
catch (Exception)
{
ModelState.AddModelError(nameof(viewModel.KeyPath), $"keypath was incorrect");
}
}
PublicWallet publicWallet = null;
try
{
if (!string.IsNullOrEmpty(viewModel.XPub))
{
try
{
publicWallet = new PublicWallet(viewModel.XPub);
}
catch (Exception)
{
publicWallet = new PublicWallet(new BitcoinExtPubKey(viewModel.XPub, Network.Main).ExtPubKey);
}
if (wallet != null && !publicWallet.ExtPubKey.Equals(wallet.GetMasterPublicWallet().ExtPubKey))
{
ModelState.AddModelError(nameof(viewModel.XPub),
$"The xpub does not match the seed/pass/key path provided");
}
}
}
catch (Exception)
{
ModelState.AddModelError(nameof(viewModel.XPub), $"xpub was incorrect");
}
if (!string.IsNullOrEmpty(viewModel.AddressCheck))
{
int index = -1;
if (wallet != null)
{
index = wallet.GetAddresses(1000)
.IndexOf(viewModel.AddressCheck);
}
else if (publicWallet != null)
{
index = publicWallet.GetAddresses(1000)
.IndexOf(viewModel.AddressCheck);
}
if (viewModel.AddressCheckLastUsed && index > -1)
{
viewModel.Index = index;
}
if (index == -1)
{
ModelState.AddModelError(nameof(viewModel.AddressCheck),
"Could not confirm address belongs to configured wallet");
}
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
currentPaymentMethod ??= new EthereumSupportedPaymentMethod();
currentPaymentMethod.Password = viewModel.StoreSeed ? viewModel.Passphrase : "";
currentPaymentMethod.Seed = viewModel.StoreSeed ? viewModel.Seed : "";
currentPaymentMethod.XPub = string.IsNullOrEmpty(viewModel.XPub) && wallet != null
? wallet.GetMasterPublicWallet().ExtPubKey.ToBytes().ToHex()
: viewModel.XPub;
currentPaymentMethod.CryptoCode = cryptoCode;
currentPaymentMethod.KeyPath = string.IsNullOrEmpty(viewModel.KeyPath)
? network.GetDefaultKeyPath()
: viewModel.KeyPath;
currentPaymentMethod.CurrentIndex = viewModel.Index;
blob.SetExcluded(paymentMethodId, !viewModel.Enabled);
store.SetSupportedPaymentMethod(currentPaymentMethod);
store.SetStoreBlob(blob);
await _storeRepository.UpdateStore(store);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = $"updated {cryptoCode}", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("GetStoreEthereumLikePaymentMethods", new {storeId = store.Id});
}
}
public class EditEthereumPaymentMethodViewModel
{
public string XPub { get; set; }
public string Seed { get; set; }
public string Passphrase { get; set; }
public string KeyPath { get; set; }
public long OriginalIndex { get; set; }
[Display(Name = "Current address index")]
public long Index { get; set; }
public bool Enabled { get; set; }
[Display(Name = "Hot wallet")] public bool StoreSeed { get; set; }
[Display(Name ="Address Check")]
public string AddressCheck { get; set; }
public bool AddressCheckLastUsed { get; set; }
}
public class ViewEthereumStoreOptionsViewModel
{
public List<ViewEthereumStoreOptionItemViewModel> Items { get; set; } =
new List<ViewEthereumStoreOptionItemViewModel>();
}
public class ViewEthereumStoreOptionItemViewModel
{
public string CryptoCode { get; set; }
public bool IsToken { get; set; }
public bool Enabled { get; set; }
public string RootAddress { get; set; }
}
}
#endif

View file

@ -0,0 +1,19 @@
#if ALTCOINS
using System;
namespace BTCPayServer.Services.Altcoins.Ethereum.UI
{
public class EthereumPaymentViewModel
{
public string Crypto { get; set; }
public string Confirmations { get; set; }
public string DepositAddress { get; set; }
public string Amount { get; set; }
public DateTimeOffset ReceivedTime { get; set; }
public long? BlockNumber { get; set; }
public string BalanceLink { get; set; }
public bool Replaced { get; set; }
public long Index { get; set; }
}
}
#endif

View file

@ -0,0 +1,100 @@
@using BTCPayServer.Views.Stores
@model BTCPayServer.Services.Altcoins.Ethereum.UI.EditEthereumPaymentMethodViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../Stores/_Nav";
ViewData.SetActivePageAndTitle(StoreNavPages.ActivePage, $"{this.Context.GetRouteValue("cryptoCode")} Settings");
}
<partial name="_StatusMessage"/>
<div class="row">
<div class="col-md-6">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="alert alert-warning">DO NOT USE THE WALLET TO ACCEPT PAYMENTS OUTSIDE OF THIS STORE.<br/>If you spend funds received on invoices which have not been marked complete yet, the invoice will be marked as unpaid.
</div>
<form method="post" asp-action="GetStoreEthereumLikePaymentMethod"
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@this.Context.GetRouteValue("cryptoCode")"
class="mt-4" enctype="multipart/form-data">
<input type="hidden" asp-for="OriginalIndex"/>
<div class="form-group">
<label asp-for="Seed"></label>
<input asp-for="Seed" type="text" class="form-control"/>
<span asp-validation-for="Seed" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Passphrase"></label>
<input type="text" asp-for="Passphrase" class="form-control" autocomplete="off"/>
<span asp-validation-for="Passphrase" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="KeyPath"></label>
<input asp-for="KeyPath" class="form-control"/>
<span class="text-muted">Please see <a href="https://medium.com/myetherwallet/hd-wallets-and-derivation-paths-explained-865a643c7bf2" target="_blank">this article.</a></span>
<span asp-validation-for="KeyPath" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="StoreSeed"></label>
<input asp-for="StoreSeed" type="checkbox" class="form-check"/>
<span class="text-muted">Store the seed/password on server if provided. If not checked, will generate the xpub and erase the seed/pass from server</span>
<span asp-validation-for="StoreSeed" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="XPub"></label>
<input asp-for="XPub" class="form-control"/>
<span class="text-muted">The public master key derived from a seed/pass/keypath. This allows you to generate addresses without private keys on the server.</span>
<span asp-validation-for="XPub" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Index"></label>
<input asp-for="Index" type="number" min="0" class="form-control"/>
<span class="text-muted">The index to generate the next address from. If you are using a wallet that you have used before, be sure to set this to the last index +1</span>
<span asp-validation-for="Index" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="AddressCheck"></label>
<div class="input-group">
<input asp-for="AddressCheck" type="text" class="form-control"/>
<div class="input-group-text">
<input asp-for="AddressCheckLastUsed" type="checkbox" data-toggle="tooltip" class="form-check" title="Use this address to set the last used index for the wallet"/>
</div>
</div>
<span class="text-muted">Check wallet by providing an address it can generate within the first 1000 indexes</span>
<span asp-validation-for="AddressCheck" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Enabled"></label>
<input asp-for="Enabled" type="checkbox" class="form-check"/>
<span asp-validation-for="Enabled" class="text-danger"></span>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" id="SaveButton">Save</button>
<a class="btn btn-secondary" asp-action="GetStoreEthereumLikePaymentMethods"
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@this.Context.GetRouteValue("cryptoCode")"
asp-controller="EthereumLikeStore">
Back to list
</a>
</div>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View file

@ -0,0 +1,76 @@
@using BTCPayServer.Views.Stores
@model BTCPayServer.Services.Altcoins.Ethereum.UI.ViewEthereumStoreOptionsViewModel
@inject SignInManager<ApplicationUser> SignInManager;
@inject BTCPayNetworkProvider BTCPayNetworkProvider;
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(StoreNavPages.ActivePage, "Ethereum Settings");
ViewData["NavPartialName"] = "../Stores/_Nav";
}
<partial name="_StatusMessage"/>
<div class="row">
<div class="col-md-6">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="form-group">
<table class="table table-sm table-responsive-md">
<thead>
<tr>
<th>Crypto</th>
<th>Root address</th>
<th class="text-center">Enabled</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.CryptoCode</td>
<td>@item.RootAddress</td>
<td class="text-center">
@if (item.Enabled)
{
<span class="text-success fa fa-check"></span>
}
else
{
<span class="text-danger fa fa-times"></span>
}
</td>
<td class="text-right">
<a id="Modify@(item.CryptoCode)" asp-action="GetStoreEthereumLikePaymentMethod"
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@item.CryptoCode">
Modify
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin))
{
var chains = BTCPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>().Select(network => network.ChainId).Distinct();
foreach (var chain in chains)
{
<a asp-action="UpdateChainConfig" asp-controller="EthereumConfig" asp-route-chainId="@chain">Configure Web3 for chain @chain</a>
}
}
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View file

@ -1,10 +1,10 @@
<div class="nav flex-column nav-pills mb-4">
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email server</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Theme)" asp-action="Theme">Theme</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Maintenance)" asp-action="Maintenance">Maintenance</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="LogsView">Logs</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
<div class="nav flex-column nav-pills mb-4">
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email server</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Theme)" asp-action="Theme">Theme</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Maintenance)" asp-action="Maintenance">Maintenance</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="LogsView">Logs</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
</div>

View file

@ -72,8 +72,8 @@
<img v-bind:src="srvModel.cryptoImage"/>
</div>
</div>
<div class="separatorGem"></div>
<div class="copySectionBox" :title="$t(hasPayjoin? 'BIP21 payment link' : 'BIP21 payment link with payjoin support') " >
<div class="separatorGem" v-if="srvModel.invoiceBitcoinUrl"></div>
<div class="copySectionBox" v-if="srvModel.invoiceBitcoinUrl" :title="$t(hasPayjoin? 'BIP21 payment link' : 'BIP21 payment link with payjoin support') " >
<label>{{$t("Payment link")}}</label>
<div class="inputWithIcon _copyInput">
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.invoiceBitcoinUrl" readonly="readonly"/>

View file

@ -0,0 +1,27 @@
@using BTCPayServer.Services.Altcoins.Ethereum.Services
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@inject EthereumService EthereumService;
@inject SignInManager<ApplicationUser> SignInManager;
@{
var networks = BTCPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>().OrderBy(network => network.ChainId).Where(network => network.ShowSyncSummary);
}
@foreach (var network in networks)
{
<h4>@network.CryptoCode (Chain ID: @network.ChainId) @(network is ERC20BTCPayNetwork is true ? "(ERC20)" : "")</h4>
<ul>
@if (!EthereumService.IsAvailable(network.CryptoCode, out var error))
{
<li>
@(error??"Web3 has not yet been configured")
@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin))
{
<a asp-action="UpdateChainConfig" asp-controller="EthereumConfig" asp-route-chainId="@network.ChainId">Configure Web3</a>
}
</li>
}
</ul>
}

View file

@ -0,0 +1,329 @@
@using BTCPayServer.Services
@model BTCPayServer.Models.InvoicingModels.PaymentModel
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@{
var chains = BTCPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>().ToDictionary(network => network.CryptoCode.ToLowerInvariant(), network => new
{
ChainId = network.ChainId,
SmartContractAddress = (network as ERC20BTCPayNetwork)?.SmartContractAddress,
Divisibility = network.Divisibility
});
}
<script type="text/x-template" id="ethereum-method-checkout-template">
<div>
<div class="bp-view payment scan" id="scan" v-bind:class="{ 'active': currentTab == 'scan'}">
<div class="payment__scan">
<img v-bind:src="srvModel.cryptoImage" class="qr_currency_icon" v-if="scanDisplayQr"/>
<qrcode v-bind:value="scanDisplayQr" :options="{ width: 256, margin: 1, color: {dark:'#000', light:'#f5f5f7'} }" tag="svg" v-if="scanDisplayQr"></qrcode>
<div class="payment__spinner qr_currency_icon" style="padding-right: 20px;">
<partial name="Checkout-Spinner"/>
</div>
</div>
<div class="payment__details__instruction__open-wallet" v-if="hasWeb3">
<button :disabled="web3Loading" class="payment__details__instruction__open-wallet__btn action-button" target="_top" v-on:click="payWithWeb3">
<span>{{$t("Open in wallet")}}</span>
</button>
</div>
</div>
<div class="bp-view payment manual-flow" id="copy" v-bind:class="{ 'active': currentTab == 'copy'}">
<div class="manual__step-two__instructions">
<span v-html="$t('CompletePay_Body', srvModel)"></span>
</div>
<div class="copyLabelPopup">
<span>{{$t("Copied")}}</span>
</div>
<nav class="copyBox">
<div class="copySectionBox bottomBorder">
<label>{{$t("Amount")}}</label>
<div class="copyAmountText copy-cursor _copySpan">
<span>{{dueAmount}}</span> {{ srvModel.cryptoCode }}
</div>
</div>
<div class="separatorGem"></div>
<div class="copySectionBox bottomBorder">
<label>{{$t("Address")}}</label>
<div class="inputWithIcon _copyInput">
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.btcAddress" readonly="readonly"/>
<img v-bind:src="srvModel.cryptoImage"/>
</div>
</div>
</nav>
</div>
@if (Model.ChangellyEnabled || Model.CoinSwitchEnabled)
{
<div id="altcoins" class="bp-view payment manual-flow" v-bind:class="{ 'active': currentTab == 'altcoins'}">
<nav >
<div class="manual__step-two__instructions">
<span>
{{$t("ConversionTab_BodyTop", srvModel)}}
<br/><br/>
{{$t("ConversionTab_BodyDesc", srvModel)}}
</span>
</div>
<center>
@if (Model.CoinSwitchEnabled && Model.ChangellyEnabled)
{
<template v-if="!selectedThirdPartyProcessor">
<button v-on:click="selectedThirdPartyProcessor = 'coinswitch'" class="action-button">{{$t("Pay with CoinSwitch")}}</button>
<button v-on:click="selectedThirdPartyProcessor = 'changelly'" class="action-button">{{$t("Pay with Changelly")}}</button>
</template>
}
@if (Model.CoinSwitchEnabled)
{
<coinswitch inline-template
v-if="!srvModel.changellyEnabled || selectedThirdPartyProcessor === 'coinswitch'"
:mode="srvModel.coinSwitchMode"
:merchant-id="srvModel.coinSwitchMerchantId"
:to-currency="srvModel.paymentMethodId"
:to-currency-due="coinswitchAmountDue"
:autoload="selectedThirdPartyProcessor === 'coinswitch'"
:to-currency-address="srvModel.btcAddress">
<div>
<a v-on:click="openDialog($event)" :href="url" class="action-button" v-show="url && !opened">{{$t("Pay with CoinSwitch")}}</a>
@if (Model.ChangellyEnabled)
{
<button v-show="!opened" v-on:click="$parent.selectedThirdPartyProcessor = 'changelly'" class="btn-link mt-2">{{$t("Pay with Changelly")}}</button>
}
<iframe
v-if="showInlineIFrame"
v-on:load="onLoadIframe"
style="height: 100%; position: fixed; top: 0; width: 100%; left: 0;"
sandbox="allow-scripts allow-forms allow-popups allow-same-origin"
:src="url">
</iframe>
</div>
</coinswitch>
}
@if (Model.ChangellyEnabled)
{
<changelly inline-template
v-if="!srvModel.coinSwitchEnabled || selectedThirdPartyProcessor === 'changelly'"
:merchant-id="srvModel.changellyMerchantId"
:store-id="srvModel.storeId"
:to-currency="srvModel.paymentMethodId"
:to-currency-due="srvModel.changellyAmountDue"
:to-currency-address="srvModel.btcAddress">
<div class="changelly-component">
<div class="changelly-component-dropdown-holder" v-show="prettyDropdownInstance">
<select
v-model="selectedFromCurrency"
:disabled="isLoading"
v-on:change="onCurrencyChange($event)"
ref="changellyCurrenciesDropdown">
<option value="">{{$t("ConversionTab_CurrencyList_Select_Option")}}</option>
<option v-for="currency of currencies"
:data-prefix="'<img src=\''+currency.image+'\'/>'"
:value="currency.name">{{currency.fullName}}</option>
</select>
</div>
<a v-on:click="openDialog($event)" :href="url" class="action-button" v-show="url">{{$t("Pay with Changelly")}}</a>
@if (Model.CoinSwitchEnabled)
{
<button v-on:click="$parent.selectedThirdPartyProcessor = 'coinswitch'" class="btn-link mt-2">{{$t("Pay with CoinSwitch")}}</button>
}
<button class="retry-button" v-if="calculateError" v-on:click="retry('calculateAmount')">
{{$t("ConversionTab_CalculateAmount_Error")}}
</button>
<button class="retry-button" v-if="currenciesError" v-on:click="retry('loadCurrencies')">
{{$t("ConversionTab_LoadCurrencies_Error")}}
</button>
<div v-show="isLoading" class="general__spinner">
<partial name="Checkout-Spinner"/>
</div>
</div>
</changelly>
}
</center>
</nav>
</div>
}
</div>
</script>
<script type="text/x-template" id="ethereum-method-checkout-header-template">
<div class="payment-tabs">
<div class="payment-tabs__tab " id="scan-tab" v-on:click="switchTab('scan')" v-bind:class="{ 'active': currentTab == 'scan'}" >
<span>{{$t("Scan")}}</span>
</div>
<div class="payment-tabs__tab" id="copy-tab" v-on:click="switchTab('copy')" v-bind:class="{ 'active': currentTab == 'copy'}" >
<span>{{$t("Copy")}}</span>
</div>
@if (Model.ChangellyEnabled || Model.CoinSwitchEnabled)
{
<div class="payment-tabs__tab" id="altcoins-tab" v-on:click="switchTab('altcoins')" v-bind:class="{ 'active': currentTab == 'altcoins'}" >
<span>{{$t("Conversion")}}</span>
</div>
<div id="tabsSlider" class="payment-tabs__slider three-tabs" v-bind:class="['slide-'+currentTab]"></div>
}
else
{
<div id="tabsSlider" class="payment-tabs__slider" v-bind:class="['slide-'+currentTab]"></div>
}
</div>
</script>
<script type="text/javascript">
Vue.component('EthereumLikeMethodCheckout',
{
props: ["srvModel"],
template: "#ethereum-method-checkout-template",
components: {
qrcode: VueQrcode,
changelly: ChangellyComponent,
coinswitch: CoinSwitchComponent
},
data: function() {
return {
cryptoChains: @Safe.Json(chains),
selectedThirdPartyProcessor: "",
currentTab: "scan",
hasWeb3: false,
web3Loading: false
}
},
computed: {
dueAmount: function(){
var r = this.srvModel.btcDue;
while(r.charAt(r.length-1)=='0') {
r = r.substring(0,r.length-1);
}
return r;
},
coinswitchAmountDue: function() {
return this.srvModel.coinSwitchAmountMarkupPercentage
? this.srvModel.btcDue * (1 + (this.srvModel.coinSwitchAmountMarkupPercentage / 100))
: this.srvModel.btcDue;
},
scanDisplayQr: function() {
return this.srvModel.invoiceBitcoinUrlQR;
}
},
methods: {
payWithWeb3: function() {
var self = this;
self.web3Loading = true;
var chainData = self.cryptoChains[self.srvModel.cryptoCode.toLowerCase()];
var ptio = self.dueAmount.indexOf('.');
var amt = self.dueAmount.substr(0,ptio) + web3.padRight(self.dueAmount.substr(ptio+1), chainData.divisibility);
if(chainData.smartContractAddress){
var abi = [
{
constant: false,
inputs: [
{ name: "_to", type: "address" },
{ name: "_value", type: "uint256" },
],
name: "transfer",
outputs: [{ name: "", type: "bool" }],
type: "function",
},
];
var contract = web3.eth.contract(abi).at(chainData.smartContractAddress);
contract.transfer(self.srvModel.btcAddress, amt, (error, txHash) => {
if(error){
console.error(error);
}
self.web3Loading = false;
});
}else {
web3.eth.sendTransaction({
from : 0,
to : self.srvModel.btcAddress,
value: amt,
},
(error, txID) => {
if(error){
console.error(error);
}
self.web3Loading = false;
});
}
}
},
mounted: function() {
var self = this;
eventBus.$on("tab-switched",
function(tab) {
self.currentTab = tab;
});
if (window.ethereum) {
window.web3 = new Web3(ethereum);
}
var chainData = self.cryptoChains[self.srvModel.cryptoCode.toLowerCase()];
if (typeof web3 !== "undefined"){
if(ethereum && ethereum.chainId){
self.hasWeb3 = chainData.chainId == parseInt(ethereum.chainId, 16);
}else if (web3.eth.getChainId){
web3.eth.getChainId(function(chainId){
self.hasWeb3 = chainData.chainId == chainId;
})
}
}
}
});
Vue.component('EthereumLikeMethodCheckoutHeader', {
props: ["srvModel"],
template: "#ethereum-method-checkout-header-template",
data: function() {
return {
currentTab: "scan"
};
},
methods: {
switchTab: function(tab) {
this.currentTab = tab;
eventBus.$emit("tab-switched", tab);
}
}
});
$(document).ready(function() {
// Clipboard Copy
var copySpan = new Clipboard('._copySpan', {
target: function(trigger) {
return copyElement(trigger, 0, 65).firstChild;
}
});
var copyInput = new Clipboard('._copyInput', {
target: function(trigger) {
return copyElement(trigger, 4, 65).firstChild;
}
});
function copyElement(trigger, popupLeftModifier, popupTopModifier) {
var elm = $(trigger);
var position = elm.offset();
position.top -= popupLeftModifier + $(window).scrollTop();
position.left += (elm.width() / 2) - popupTopModifier;
$(".copyLabelPopup").css(position).addClass("copied");
elm.removeClass("copy-cursor").addClass("clipboardCopied");
setTimeout(clearSelection, 100);
setTimeout(function() {
elm.removeClass("clipboardCopied").addClass("copy-cursor");
$(".copyLabelPopup").removeClass("copied");
},
1000);
return trigger;
}
function clearSelection() {
if (window.getSelection) {
window.getSelection().removeAllRanges();
} else if (document.selection) {
document.selection.empty();
}
}
// Disable enter key
$(document).keypress(
function(event) {
if (event.which === '13') {
event.preventDefault();
}
}
);
});
</script>

View file

@ -0,0 +1,11 @@
@using BTCPayServer.Services.Altcoins.Ethereum.UI
@inject SignInManager<ApplicationUser> SignInManager;
@inject BTCPayNetworkProvider BTCPayNetworkProvider;
@{
var controller = ViewContext.RouteData.Values["Controller"].ToString();
var isEthereum = controller.Equals(nameof(EthereumLikeStoreController), StringComparison.InvariantCultureIgnoreCase);
}
@if (SignInManager.IsSignedIn(User) && BTCPayNetworkProvider.GetAll().OfType<EthereumBTCPayNetwork>().Any())
{
<a class="nav-link @(isEthereum ? "active" : string.Empty)" asp-route-storeId="@this.Context.GetRouteValue("storeId")" asp-action="GetStoreEthereumLikePaymentMethods" asp-controller="EthereumLikeStore">Ethereum</a>
}

View file

@ -0,0 +1,65 @@
@using BTCPayServer.Services.Altcoins.Ethereum.UI
@using BTCPayServer.Views.Server
@using System.Net.Http
@model BTCPayServer.Services.Altcoins.Ethereum.Configuration.EthereumLikeConfiguration
@inject BTCPayNetworkProvider BTCPayNetworkProvider;
@inject IHttpClientFactory HttpClientFactory;
@{
Layout = "../_NavLayout.cshtml";
ViewData["NavPartialName"] = "../Server/_Nav";
ViewBag.MainTitle = "Server settings";
ViewData.SetActivePageAndTitle(ServerNavPages.Policies, $"ETH Chain {Model.ChainId} Configuration");
}
<partial name="_StatusMessage"/>
@if (!this.ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="text-danger"></div>
}
<form method="post">
<div class="form-group">
<input type="hidden" asp-for="ChainId"/>
<input type="hidden" asp-for="InvoiceId"/>
<div class="form-check">
<label asp-for="Web3ProviderUrl" class="form-control-label"></label>
<input asp-for="Web3ProviderUrl" type="text" class="form-control"/>
<span asp-validation-for="Web3ProviderUrl" class="text-danger"></span>
<div>
Possible free options are
<ul>
<li><a href="https://medium.com/linkpool/release-of-public-ethereum-rpcs-f5dd57455d2e" target="_blank">linkpool.io</a> - Free, just set the url to <code>https://main-rpc.linkpool.io</code></li>
<li><a href="https://chainstack.com/" target="_blank">chainstack.com</a> - Free plan, choose shared public node</li>
<li><a href="https://infura.io/" target="_blank">infura.io</a> - Free tier but limited calls per day</li>
<li>Your own geth/openethereum node</li>
</ul>
</div>
</div>
<div class="form-check">
<label asp-for="Web3ProviderUsername" class="form-control-label"></label>
<input asp-for="Web3ProviderUsername" type="text" class="form-control"/>
<span asp-validation-for="Web3ProviderUsername" class="text-danger"></span>
</div>
<div class="form-check">
<label asp-for="Web3ProviderPassword" class="form-control-label"></label>
<input asp-for="Web3ProviderPassword" type="text" class="form-control"/>
<span asp-validation-for="Web3ProviderPassword" class="text-danger"></span>
</div>
@{
var valid = await EthereumConfigController.CheckValid(HttpClientFactory, BTCPayNetworkProvider.NetworkType, Model.InvoiceId);
if (!valid)
{
<div class="alert alert-warning mt-2">
<span>Support for this feature requires a one-time donation.</span>
@if (!string.IsNullOrEmpty(Model.InvoiceId))
{
<span title="@Model.InvoiceId">The payment instructions associated has not been paid or confirmed yet.</span>
}
<a asp-action="CreateInvoice" asp-controller="EthereumConfig" asp-route-chainId="@Model.ChainId" class="alert-link">Please click here to generate payment instructions.</a>
</div>
}
}
</div>
<button type="submit" class="btn btn-primary" name="command" value="Save" id="saveButton">Save</button>
</form>

View file

@ -0,0 +1,64 @@
@using System.Globalization
@using BTCPayServer.Services.Altcoins.Ethereum.Payments
@using BTCPayServer.Services.Altcoins.Ethereum.UI
@model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity>
@{
var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == EthereumPaymentType.Instance).Select(payment =>
{
var m = new EthereumPaymentViewModel();
var onChainPaymentData = payment.GetCryptoPaymentData() as EthereumLikePaymentData;
m.Crypto = payment.GetPaymentMethodId().CryptoCode;
m.DepositAddress = onChainPaymentData.GetDestination();
m.Amount = onChainPaymentData.GetValue().ToString(CultureInfo.InvariantCulture);
m.Confirmations = onChainPaymentData.BlockNumber.HasValue ? $"{onChainPaymentData.ConfirmationCount} (block {onChainPaymentData.BlockNumber})" : "pending";
m.Amount = onChainPaymentData.GetValue().ToString(CultureInfo.InvariantCulture);
m.BlockNumber = onChainPaymentData.BlockNumber;
m.ReceivedTime = payment.ReceivedTime;
m.BalanceLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.DepositAddress);
m.Replaced = !payment.Accounted;
m.Index = onChainPaymentData.AccountIndex;
return m;
});
}
@if (onchainPayments.Any())
{
<div class="row">
<div class="col-md-12 invoice-payments">
<h3>Ethereum/ERC-20 payments</h3>
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th>Crypto</th>
<th>Amount</th>
<th>Address</th>
<th>Index</th>
<th class="text-right">Confirmations</th>
</tr>
</thead>
<tbody>
@foreach (var payment in onchainPayments)
{
<tr style="@(payment.Replaced ? "text-decoration: line-through" : "")">
<td>@payment.Crypto</td>
<td>@payment.Amount</td>
<td>
<div class="wraptextAuto">
<a href="@payment.BalanceLink" target="_blank">
@payment.DepositAddress
</a>
</div>
</td>
<td>@payment.Index</td>
<td class="text-right">@payment.Confirmations</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB