diff --git a/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs b/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs index f9172534..55b40f4d 100644 --- a/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs +++ b/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs @@ -12,6 +12,7 @@ using BTCPayServer.Plugins.ArkPayServer.Payouts.Ark; using BTCPayServer.Plugins.ArkPayServer.Services; using BTCPayServer.Plugins.ArkPayServer.Services.WalletLogger; +using BTCPayServer.Services.Rates; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -39,8 +40,10 @@ namespace BTCPayServer.Plugins.ArkPayServer; public class ArkadePlugin : BaseBTCPayServerPlugin { internal const string CheckoutBodyComponentName = "arkadeCheckoutBody"; + internal const string AssetCheckoutBodyComponentName = "arkadeAssetCheckoutBody"; internal static readonly PaymentMethodId ArkadePaymentMethodId = new("ARKADE"); + internal static readonly PaymentMethodId ArkadeAssetPaymentMethodId = new("ARKADE-ASSET"); internal static readonly PayoutMethodId ArkadePayoutMethodId = PayoutMethodId.Parse("ARKADE"); public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = @@ -90,6 +93,14 @@ private static void RegisterBtcPayServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + // Dedicated Arkade Asset payment method (payer picks an asset at checkout). + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); @@ -97,6 +108,7 @@ private static void RegisterBtcPayServices(IServiceCollection services) services.AddSingleton(sp => sp.GetRequiredService()); services.AddDefaultPrettyName(ArkadePaymentMethodId, "Arkade"); + services.AddDefaultPrettyName(ArkadeAssetPaymentMethodId, "Arkade Asset"); } private static void RegisterDatabase(IServiceCollection services) @@ -264,6 +276,20 @@ private static void RegisterPluginServices(IServiceCollection services) services.AddSingleton(); + // Caches Arkade asset metadata (name/ticker/decimals) from the + // indexer for balance display and checkout. + services.AddSingleton(); + + // Prices a merchant-accepted Arkade asset against an invoice's BTC + // amount due, routing the reference-currency leg through BTCPay's + // own rate pipeline. + services.AddSingleton(); + + // Surfaces each store's tracked-asset codes as BTCPay currencies, and + // refreshes the currency table after a tracked-asset CRUD op. + services.AddSingleton(); + services.AddSingleton(); + // Remote-signer transport seam. // // NArk's DefaultWalletProvider takes IRemoteSignerTransport? as an @@ -315,6 +341,7 @@ private static void RegisterPluginServices(IServiceCollection services) private static void RegisterUIExtensions(IServiceCollection services) { services.AddUIExtension("checkout-end", "Arkade/ArkadeMethodCheckout"); + services.AddUIExtension("checkout-end", "Arkade/ArkadeAssetMethodCheckout"); services.AddUIExtension("dashboard-setup-guide-payment", "/Views/Ark/DashboardSetupGuidePayment.cshtml"); services.AddUIExtension("store-invoices-payments", "/Views/Ark/ArkPaymentData.cshtml"); services.AddUIExtension("store-wallets-nav", "/Views/Ark/ArkWalletNav.cshtml"); diff --git a/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs b/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs index 14da2758..8e823164 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs +++ b/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs @@ -18,6 +18,7 @@ using BTCPayServer.Plugins.ArkPayServer.Payouts.Ark; using BTCPayServer.Plugins.ArkPayServer.Services; using BTCPayServer.Plugins.ArkPayServer.Services.WalletLogger; +using BTCPayServer.Rating; using BTCPayServer.Security; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; @@ -74,6 +75,8 @@ public class ArkController( IIntentStorage intentStorage, IWalletProvider walletProvider, ISpendingService arkadeSpender, + AssetMetadataService assetMetadataService, + AssetCurrencyRegistrar assetCurrencyRegistrar, IFeeEstimator feeEstimator, IContractService contractService, IBitcoinBlockchain bitcoinTimeChainProvider, @@ -411,6 +414,19 @@ public async Task StoreOverview(CancellationToken cancellationTok // Silently ignore - swaps section will show empty } + // Tracked assets (managed list) — Ticker/Name/Decimals are cached on + // the config, so the list renders without indexer round-trips. + var trackedAssetRows = config.Assets.Select(a => new TrackedAssetRow + { + AssetId = a.AssetId, + CurrencyCode = a.CurrencyCode, + Ticker = a.Ticker, + Name = a.Name, + Decimals = a.Decimals, + RateScript = a.RateScript, + Enabled = a.Enabled, + }).ToList(); + return View(new StoreOverviewViewModel { StoreId = store!.Id, @@ -424,6 +440,7 @@ public async Task StoreOverview(CancellationToken cancellationTok AllowSubDustAmounts = config.AllowSubDustAmounts, BoardingEnabled = config.BoardingEnabled, MinBoardingAmountSats = config.MinBoardingAmountSats, + TrackedAssets = trackedAssetRows, Wallet = wallet?.Secret, WalletType = wallet?.WalletType ?? WalletType.SingleKey, CanManagePrivateKeys = canManagePrivateKeys, @@ -2094,9 +2111,118 @@ public async Task UpdateWalletConfig(string storeId, StoreOvervie return RedirectWithSuccess(nameof(StoreOverview), "Boarding disabled.", new { storeId }); } + if (command is "add-asset" or "edit-asset") + { + var row = model.AssetForm; + var asset = new TrackedArkadeAsset( + row.AssetId?.Trim() ?? "", + row.CurrencyCode?.Trim().ToUpperInvariant() ?? "", + string.IsNullOrWhiteSpace(row.Ticker) ? null : row.Ticker.Trim(), + string.IsNullOrWhiteSpace(row.Name) ? null : row.Name.Trim(), + row.Decimals, + row.RateScript?.Trim() ?? "", + row.Enabled); + + if (!asset.IsValid(out var validationError)) + return RedirectWithError(nameof(StoreOverview), validationError!, new { storeId }); + if (!RateRules.TryParse(asset.RateScript, out _, out var scriptErrors)) + return RedirectWithError(nameof(StoreOverview), + $"Rate script does not compile: {string.Join("; ", scriptErrors)}", new { storeId }); + + var assets = config!.Assets.ToList(); + var existingIdx = assets.FindIndex(a => a.AssetId.Equals(asset.AssetId, StringComparison.OrdinalIgnoreCase)); + + // Currency code must be unique within the store (it registers as a BTCPay currency). + if (assets.Any(a => !a.AssetId.Equals(asset.AssetId, StringComparison.OrdinalIgnoreCase) + && a.CurrencyCode.Equals(asset.CurrencyCode, StringComparison.OrdinalIgnoreCase))) + return RedirectWithError(nameof(StoreOverview), + $"Currency code {asset.CurrencyCode} is already used by another tracked asset.", new { storeId }); + + if (command == "add-asset") + { + if (existingIdx >= 0) + return RedirectWithError(nameof(StoreOverview), $"Asset {asset.AssetId} is already tracked.", new { storeId }); + // Confirm the asset exists on the indexer before tracking it. + if (await assetMetadataService.GetAssetDetailsAsync(asset.AssetId, cancellationToken) is null) + return RedirectWithError(nameof(StoreOverview), + $"Asset '{asset.AssetId}' was not found on the Arkade indexer. Check the asset id.", new { storeId }); + assets.Add(asset); + } + else // edit-asset + { + if (existingIdx < 0) + return RedirectWithError(nameof(StoreOverview), $"Asset {asset.AssetId} is not tracked.", new { storeId }); + assets[existingIdx] = asset; + } + + var newConfig = config with { TrackedAssets = assets }; + store!.SetPaymentMethodConfig(paymentMethodHandlerDictionary[ArkadePlugin.ArkadePaymentMethodId], newConfig); + SyncAssetPaymentMethod(store, newConfig); + await storeRepository.UpdateStore(store); + await assetCurrencyRegistrar.RefreshAsync(cancellationToken); + return RedirectWithSuccess(nameof(StoreOverview), + $"{(command == "add-asset" ? "Added" : "Updated")} tracked asset {asset.CurrencyCode}.", new { storeId }); + } + + if (command == "remove-asset") + { + var assetId = model.AssetForm.AssetId?.Trim() ?? ""; + var assets = config!.Assets.Where(a => !a.AssetId.Equals(assetId, StringComparison.OrdinalIgnoreCase)).ToList(); + var newConfig = config with { TrackedAssets = assets }; + store!.SetPaymentMethodConfig(paymentMethodHandlerDictionary[ArkadePlugin.ArkadePaymentMethodId], newConfig); + SyncAssetPaymentMethod(store, newConfig); + await storeRepository.UpdateStore(store); + await assetCurrencyRegistrar.RefreshAsync(cancellationToken); + return RedirectWithSuccess(nameof(StoreOverview), "Removed tracked asset.", new { storeId }); + } + return RedirectToAction(nameof(StoreOverview), new { storeId }); } + /// + /// Looks up an Arkade asset's metadata on the arkd indexer so the add-asset + /// form can prefill ticker/name/decimals from just the asset id. + /// + [HttpGet("stores/{storeId}/asset-metadata")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task FetchAssetMetadata(string storeId, string? assetId, CancellationToken cancellationToken) + { + assetId = assetId?.Trim() ?? ""; + if (string.IsNullOrEmpty(assetId)) + return Json(new AssetMetadataResult { Found = false }); + + var details = await assetMetadataService.GetAssetDetailsAsync(assetId, cancellationToken); + if (details is null) + return Json(new AssetMetadataResult { Found = false, AssetId = assetId }); + + return Json(new AssetMetadataResult + { + Found = true, + AssetId = assetId, + Ticker = assetMetadataService.GetTicker(details), + Name = assetMetadataService.GetName(details), + Decimals = assetMetadataService.GetDecimals(details), + }); + } + + /// + /// Enables the dedicated ARKADE-ASSET payment method exactly when the store + /// has at least one enabled tracked asset; clears it otherwise. The asset + /// method's config is thin (just the wallet id) — the asset list lives on + /// the it reads at prompt time. + /// Mutates in place; the caller persists via + /// UpdateStore. + /// + private void SyncAssetPaymentMethod(StoreData store, ArkadePaymentMethodConfig arkadeConfig) + { + var assetPmi = ArkadePlugin.ArkadeAssetPaymentMethodId; + if (arkadeConfig.WalletId is { } walletId && arkadeConfig.Assets.Any(a => a.Enabled)) + store.SetPaymentMethodConfig( + paymentMethodHandlerDictionary[assetPmi], new ArkadeAssetPaymentMethodConfig(walletId)); + else + store.SetPaymentMethodConfig(assetPmi, null); + } + [HttpGet("stores/{storeId}/contracts")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task Contracts( @@ -2541,6 +2667,7 @@ public async Task ClearWallet(string storeId) var lnEnabled = lnConfig?.ConnectionString?.StartsWith("type=arkade", StringComparison.InvariantCultureIgnoreCase) is true; store.SetPaymentMethodConfig(ArkadePlugin.ArkadePaymentMethodId, null); + store.SetPaymentMethodConfig(ArkadePlugin.ArkadeAssetPaymentMethodId, null); if (lnEnabled) store.SetPaymentMethodConfig(GetLightningPaymentMethod(), null); @@ -2993,6 +3120,31 @@ public async Task GetArkBalances(string walletId, Cancella .Where(coin => !coin.Unrolled && lockedSet.Contains(coin.Outpoint)) .Sum(coin => coin.Amount.Satoshi); + // Aggregate Arkade asset holdings across spendable (non-recoverable, + // non-boarding) coins. Asset data rides on VTXOs via the SDK + // (ArkCoin.Assets); enrich each with cached indexer metadata. + var assetTotals = new Dictionary(); + foreach (var coin in coinsByRecoverableStatus[false].Where(c => !c.Unrolled)) + { + if (coin.Assets is not { Count: > 0 } assets) continue; + foreach (var a in assets) + assetTotals[a.AssetId] = assetTotals.GetValueOrDefault(a.AssetId) + a.Amount; + } + + var assetBalances = await Task.WhenAll(assetTotals.Select(async kv => + { + var details = await assetMetadataService.GetAssetDetailsAsync(kv.Key, cancellationToken); + return new AssetBalanceViewModel + { + AssetId = kv.Key, + Name = assetMetadataService.GetName(details), + Ticker = assetMetadataService.GetTicker(details), + Decimals = assetMetadataService.GetDecimals(details), + Amount = kv.Value, + FormattedAmount = assetMetadataService.FormatAmount(kv.Value, details), + }; + })); + return new ArkBalancesViewModel { AvailableBalance = availableBalance - lockedBalance, @@ -3000,6 +3152,9 @@ public async Task GetArkBalances(string walletId, Cancella RecoverableBalance = recoverableBalance, UnspendableBalance = unspendableBalance, BoardingBalance = boardingBalance, + AssetBalances = assetBalances + .OrderBy(a => a.Ticker ?? a.AssetId, StringComparer.OrdinalIgnoreCase) + .ToList(), }; } diff --git a/BTCPayServer.Plugins.ArkPayServer/Models/ArkBalancesViewModel.cs b/BTCPayServer.Plugins.ArkPayServer/Models/ArkBalancesViewModel.cs index bae51c08..72590c94 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Models/ArkBalancesViewModel.cs +++ b/BTCPayServer.Plugins.ArkPayServer/Models/ArkBalancesViewModel.cs @@ -7,4 +7,28 @@ public class ArkBalancesViewModel public decimal RecoverableBalance { get; set; } public decimal UnspendableBalance { get; set; } public decimal BoardingBalance { get; set; } + + /// + /// Arkade asset holdings carried on spendable VTXOs, aggregated per + /// asset id and enriched with indexer metadata. Empty when the wallet + /// holds no assets. + /// + public IReadOnlyList AssetBalances { get; set; } = []; +} + +/// +/// One Arkade asset's spendable balance with display metadata. +/// +public class AssetBalanceViewModel +{ + public required string AssetId { get; init; } + public string? Name { get; init; } + public string? Ticker { get; init; } + public int Decimals { get; init; } + + /// Raw base-unit amount. + public ulong Amount { get; init; } + + /// Amount formatted with the asset's declared decimals. + public required string FormattedAmount { get; init; } } diff --git a/BTCPayServer.Plugins.ArkPayServer/Models/ArkadeListenedContract.cs b/BTCPayServer.Plugins.ArkPayServer/Models/ArkadeListenedContract.cs deleted file mode 100644 index 9c9a0c81..00000000 --- a/BTCPayServer.Plugins.ArkPayServer/Models/ArkadeListenedContract.cs +++ /dev/null @@ -1,5 +0,0 @@ -using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; - -namespace BTCPayServer.Plugins.ArkPayServer.Models; - -internal record ArkadeListenedContract(ArkadePromptDetails Details, string InvoiceId); \ No newline at end of file diff --git a/BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs b/BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs index d743348b..9e53492f 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs +++ b/BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs @@ -1,4 +1,5 @@ using BTCPayServer.Plugins.ArkPayServer.Data; +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; using BTCPayServer.Plugins.ArkPayServer.Services; using NArk.Abstractions.Contracts; using NArk.Abstractions.VTXOs; @@ -22,6 +23,14 @@ public class StoreOverviewViewModel public bool BoardingEnabled { get; set; } public long MinBoardingAmountSats { get; set; } + // --- Tracked Arkade assets (managed list; accepted for payment via the + // dedicated "Arkade Asset" payment method, priced by a per-asset rate + // script). Empty = BTC-VTXO only. + public List TrackedAssets { get; set; } = []; + + /// Bound form for the add/edit-asset modal. + public TrackedAssetRow AssetForm { get; set; } = new(); + /// /// The type of wallet (SingleKey/legacy or HD/mnemonic). /// diff --git a/BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs b/BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs new file mode 100644 index 00000000..6585fdd2 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs @@ -0,0 +1,23 @@ +namespace BTCPayServer.Plugins.ArkPayServer.Models; + +/// Result of the add-by-id metadata fetch (indexer prefill). +public class AssetMetadataResult +{ + public bool Found { get; set; } + public string AssetId { get; set; } = ""; + public string? Ticker { get; set; } + public string? Name { get; set; } + public int Decimals { get; set; } +} + +/// A tracked-asset row for the store-settings list + add/edit form binding. +public class TrackedAssetRow +{ + public string AssetId { get; set; } = ""; + public string CurrencyCode { get; set; } = ""; + public string? Ticker { get; set; } + public string? Name { get; set; } + public int Decimals { get; set; } + public string RateScript { get; set; } = ""; + public bool Enabled { get; set; } = true; +} diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetCheckoutModelExtension.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetCheckoutModelExtension.cs new file mode 100644 index 00000000..46cf2414 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetCheckoutModelExtension.cs @@ -0,0 +1,43 @@ +using BTCPayServer.Payments; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +/// +/// Checkout model for the Arkade Asset method: selects the multi-asset picker +/// component and hands it one option per enabled asset (ticker + amount due + +/// BIP-321 URI) so the payer chooses which asset to send. All options settle to +/// the same Ark address (the prompt's Destination, surfaced as model.address). +/// +public class ArkadeAssetCheckoutModelExtension(ArkadeAssetPaymentMethodHandler handler) : ICheckoutModelExtension +{ + public PaymentMethodId PaymentMethodId => ArkadePlugin.ArkadeAssetPaymentMethodId; + + public string Image => "arkade.svg"; + + public string Badge => ""; + + public void ModifyCheckoutModel(CheckoutModelContext context) + { + if (context is not { Handler: ArkadeAssetPaymentMethodHandler }) + return; + + context.Model.CheckoutBodyComponentName = ArkadePlugin.AssetCheckoutBodyComponentName; + context.Model.ShowRecommendedFee = false; + + if (context.Prompt.Details is null) + return; + + var details = handler.ParsePaymentPromptDetails(context.Prompt.Details); + context.Model.AdditionalData["arkadeAssetOptions"] = JToken.FromObject( + details.Options.Select(o => new + { + assetId = o.AssetId, + currencyCode = o.CurrencyCode, + ticker = o.Ticker, + decimals = o.Decimals, + formattedDue = o.FormattedDue, + bip321Uri = o.Bip321Uri, + }).ToList()); + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentLinkExtension.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentLinkExtension.cs new file mode 100644 index 00000000..f25c7b16 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentLinkExtension.cs @@ -0,0 +1,24 @@ +using BTCPayServer.Payments; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +/// +/// Payment link for the Arkade Asset method: the BIP-321 URI of the first +/// offered asset option. The checkout component lets the payer switch between +/// options client-side, so the link is just a sensible default (and what +/// non-checkout consumers, e.g. the invoice API, surface). +/// +public class ArkadeAssetPaymentLinkExtension(ArkadeAssetPaymentMethodHandler handler) : IPaymentLinkExtension +{ + public PaymentMethodId PaymentMethodId { get; } = ArkadePlugin.ArkadeAssetPaymentMethodId; + + public string? GetPaymentLink(PaymentPrompt prompt, IUrlHelper? urlHelper) + { + if (prompt.Details is null) + return null; + var details = handler.ParsePaymentPromptDetails(prompt.Details); + return details.Options.FirstOrDefault()?.Bip321Uri; + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentMethodConfig.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentMethodConfig.cs new file mode 100644 index 00000000..9be3ebd0 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentMethodConfig.cs @@ -0,0 +1,11 @@ +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +/// +/// Enables the dedicated ARKADE-ASSET payment method on a store. Thin by +/// design: the wallet and the tracked-asset list live on the BTC-VTXO +/// (single source of truth). This +/// config's mere presence is what makes BTCPay offer the asset method — it is +/// written when the store has at least one enabled tracked asset and cleared +/// otherwise (see ArkController.SyncAssetPaymentMethod). +/// +public record ArkadeAssetPaymentMethodConfig(string WalletId); diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentMethodHandler.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentMethodHandler.cs new file mode 100644 index 00000000..d277fc77 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPaymentMethodHandler.cs @@ -0,0 +1,157 @@ +using BTCPayServer.Data; +using BTCPayServer.Payments; +using BTCPayServer.Plugins.ArkPayServer.Services; +using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; +using NArk.Abstractions.Wallets; +using NArk.Core.Services; +using NArk.Core.Transport; +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +/// +/// The dedicated Arkade Asset payment method. Unlike the BTC-VTXO ARKADE method, +/// this prices the invoice in every enabled tracked asset and exposes one option +/// per asset (each a BIP-321 URI with the same Ark address + that asset's id + +/// amount due). The payer picks which asset to send; settlement is detected by +/// which asset actually arrives (see ArkContractInvoiceListener). +/// +public class ArkadeAssetPaymentMethodHandler( + BTCPayServerEnvironment btcPayServerEnvironment, + IContractService contractService, + IClientTransport clientTransport, + AssetRateResolver assetRateResolver +) : IPaymentMethodHandler +{ + public PaymentMethodId PaymentMethodId => ArkadePlugin.ArkadeAssetPaymentMethodId; + + public JsonSerializer Serializer { get; } = BlobSerializer.CreateSerializer().Serializer; + + public async Task ConfigurePrompt(PaymentMethodContext context) + { + try + { + _ = await clientTransport.GetServerInfoAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); + } + catch + { + throw new PaymentMethodUnavailableException("Ark operator unavailable"); + } + + var store = context.Store; + + // The asset method is enabled by its own thin config; the wallet and the + // tracked-asset list live on the BTC-VTXO ARKADE config (single source of truth). + // Parse both via this handler's serializer (both methods use the same + // BlobSerializer) so we never inject PaymentMethodHandlerDictionary — a + // handler depending on the dictionary it belongs to is a DI cycle that + // hangs startup. + var configs = store.GetPaymentMethodConfigs(); + if (configs.GetValueOrDefault(PaymentMethodId)?.ToObject(Serializer) is null) + throw new PaymentMethodUnavailableException("Arkade Asset payment method not configured"); + + if (configs.GetValueOrDefault(ArkadePlugin.ArkadePaymentMethodId) + ?.ToObject(Serializer) is not { WalletId: { } walletId } arkadeConfig) + throw new PaymentMethodUnavailableException("Arkade wallet not configured"); + + var enabledAssets = arkadeConfig.Assets.Where(a => a.Enabled).ToList(); + if (enabledAssets.Count == 0) + throw new PaymentMethodUnavailableException("No Arkade asset is enabled for payment"); + + // Derive a dedicated Ark receive address for this invoice (same path the + // BTC method uses); all asset options settle to this one address. + var contract = await contractService.DeriveContract( + walletId, + NextContractPurpose.Receive, + metadata: new Dictionary { ["Source"] = $"invoice:{context.InvoiceEntity.Id}" }, + cancellationToken: CancellationToken.None); + var address = contract.GetArkAddress(); + var arkAddress = address.ToString(btcPayServerEnvironment.NetworkType == ChainName.Mainnet); + + var dueSats = (long)Money.Coins(context.Prompt.Calculate().Due).Satoshi; + var options = new List(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + foreach (var asset in enabledAssets) + { + AssetAmountDue due; + try + { + due = await assetRateResolver.ResolveAsync(store, asset, dueSats, cts.Token); + } + catch (Exception ex) when (ex is InvalidOperationException or OperationCanceledException) + { + // An asset whose rate can't be evaluated simply isn't offered — never a hard failure. + context.Logs.Write( + $"Arkade asset {asset.CurrencyCode} unavailable for this invoice: {ex.Message}", + InvoiceEventData.EventSeverity.Warning); + continue; + } + + var uri = ArkadeBip21Builder.Create() + .WithArkAddress(arkAddress) + .WithAsset(asset.AssetId, due.DisplayUnits) + .Build(); + options.Add(new ArkadeAssetOption( + asset.AssetId, asset.CurrencyCode, asset.Ticker, asset.Decimals, + due.BaseUnits, due.FormattedAmount, uri)); + } + + if (options.Count == 0) + throw new PaymentMethodUnavailableException("No Arkade asset is available for this invoice."); + + context.Prompt.Destination = arkAddress; + context.Prompt.PaymentMethodFee = 0m; + context.TrackedDestinations.Add(arkAddress); + context.TrackedDestinations.Add(address.ScriptPubKey.PaymentScript.ToHex()); + + context.Prompt.Details = JObject.FromObject(new ArkadeAssetPromptDetails + { + WalletId = walletId, + ArkAddress = arkAddress, + ContractString = contract.ToString(), + Options = options, + }, Serializer); + } + + public Task BeforeFetchingRates(PaymentMethodContext context) + { + context.Prompt.Currency = "BTC"; + context.Prompt.Divisibility = 8; + return Task.CompletedTask; + } + + public ArkadeAssetPromptDetails ParsePaymentPromptDetails(JToken details) + { + return details.ToObject(Serializer) ?? + throw new FormatException($"Invalid {nameof(ArkadeAssetPromptDetails)}"); + } + + object IPaymentMethodHandler.ParsePaymentPromptDetails(JToken details) + { + return ParsePaymentPromptDetails(details); + } + + public object ParsePaymentMethodConfig(JToken config) + { + return config.ToObject(Serializer) ?? + throw new FormatException($"Invalid {nameof(ArkadeAssetPaymentMethodHandler)}"); + } + + public ArkadePaymentData ParsePaymentDetails(JToken details) + { + return details.ToObject(Serializer) ?? + throw new FormatException($"Invalid {nameof(ArkadePaymentData)}"); + } + + object IPaymentMethodHandler.ParsePaymentDetails(JToken details) + { + return ParsePaymentDetails(details); + } + + public void StripDetailsForNonOwner(object details) + { + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPromptDetails.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPromptDetails.cs new file mode 100644 index 00000000..1d4b5519 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeAssetPromptDetails.cs @@ -0,0 +1,35 @@ +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +/// +/// One selectable asset option on an Arkade Asset invoice. +/// +/// The Arkade asset id (issuance txid + group index). +/// The store currency code the asset is registered under. +/// Display ticker, if known. +/// The asset's declared decimals. +/// Raw base-unit amount the payer must send to settle. +/// Amount due rendered to the asset's decimals. +/// The asset-only BIP-321 URI (ark + asset + amount). +public record ArkadeAssetOption( + string AssetId, + string CurrencyCode, + string? Ticker, + int Decimals, + ulong BaseUnitsDue, + string FormattedDue, + string Bip321Uri); + +/// +/// Payment prompt details for the dedicated Arkade Asset payment method: one +/// shared Ark receive address plus one option per enabled tracked asset. The +/// payer picks which asset to send; settlement is detected by which asset +/// actually arrives at (matched against the options), +/// so no payer choice is persisted server-side. +/// +public class ArkadeAssetPromptDetails +{ + public string WalletId { get; set; } = ""; + public string ArkAddress { get; set; } = ""; + public string? ContractString { get; set; } + public List Options { get; set; } = []; +} diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeBip21Builder.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeBip21Builder.cs index 4233095a..456a604a 100644 --- a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeBip21Builder.cs +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeBip21Builder.cs @@ -14,6 +14,7 @@ public class ArkadeBip21Builder private string? _arkAddress; private string? _lightningInvoice; private decimal? _amount; + private string? _assetId; private readonly Dictionary _customParameters = new(); /// /// Raw key=value entries pulled from another extension's BIP21 @@ -63,6 +64,24 @@ public ArkadeBip21Builder WithAmount(decimal? amount) return this; } + /// + /// Marks this URI as an Arkade-asset payment: adds asset=<assetId> + /// and sets the amount parameter to the asset amount due expressed in + /// the asset's own display units (per the Arkade BIP-321 convention, when an + /// asset is present amount denominates that asset, not BTC). + /// Asset options are asset-only, so callers omit , + /// and . + /// + public ArkadeBip21Builder WithAsset(string assetId, decimal amountDisplayUnits) + { + if (string.IsNullOrWhiteSpace(assetId)) + throw new ArgumentException("Asset id cannot be null or empty", nameof(assetId)); + + _assetId = assetId; + _amount = amountDisplayUnits; + return this; + } + /// /// Adds a custom parameter to the BIP21 URI. /// @@ -137,7 +156,13 @@ public string Build() // Add Ark address (always included) — no URL-encoding needed, bech32m is URL-safe parameters.Add($"ark={_arkAddress}"); - + + // Add asset id if this is an asset payment (amount above already denominates the asset) + if (!string.IsNullOrWhiteSpace(_assetId)) + { + parameters.Add($"asset={HttpUtility.UrlEncode(_assetId)}"); + } + // Add lightning if provided if (!string.IsNullOrWhiteSpace(_lightningInvoice)) { diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeCheckoutModelExtension.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeCheckoutModelExtension.cs index 069d2a78..41a429e3 100644 --- a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeCheckoutModelExtension.cs +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadeCheckoutModelExtension.cs @@ -93,7 +93,8 @@ void ICheckoutModelExtension.ModifyCheckoutModel(CheckoutModelContext context) // case-sensitive payloads (PayJoin's onion URLs, Branta's base64 secrets, etc.). context.Model.InvoiceBitcoinUrlQR = AppendQuery(UpperCaseQrUri(paymentLink), extraForQr); - // Pass boarding flag to checkout component + // Pass the boarding flag to the checkout component. Asset acceptance now + // lives on the dedicated ARKADE-ASSET method, not on this BTC-VTXO prompt. if (context.Prompt.Details is not null) { var details = _handler.ParsePaymentPromptDetails(context.Prompt.Details); diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs index e169c954..c5d972a3 100644 --- a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs @@ -5,9 +5,41 @@ public record ArkadePaymentMethodConfig( bool GeneratedByStore = false, bool AllowSubDustAmounts = false, bool BoardingEnabled = true, - long MinBoardingAmountSats = ArkadePaymentMethodConfig.DefaultMinBoardingAmountSats) + long MinBoardingAmountSats = ArkadePaymentMethodConfig.DefaultMinBoardingAmountSats, + IReadOnlyList? TrackedAssets = null) { public const long P2trDustLimitSats = 330L; public const long DefaultMinBoardingAmountSats = 5000L; -} \ No newline at end of file + + /// Tracked assets, never null (empty when none configured). + public IReadOnlyList Assets => TrackedAssets ?? []; +} + +/// +/// A store-tracked Arkade asset the merchant accepts as payment. The rate is +/// merchant-declared via a free-form BTCPay rate-rule +/// (Arkade assets aren't exchange-listed). Ticker/Name/Decimals are cached from +/// the arkd indexer for display and settlement math. The asset is registered as +/// a BTCPay currency under . +/// +public record TrackedArkadeAsset( + string AssetId, + string CurrencyCode, + string? Ticker, + string? Name, + int Decimals, + string RateScript, + bool Enabled) +{ + /// Validates the tracked-asset config is internally consistent. + public bool IsValid(out string? error) + { + if (string.IsNullOrWhiteSpace(AssetId)) { error = "An asset id is required."; return false; } + if (string.IsNullOrWhiteSpace(CurrencyCode)) { error = "A currency code is required."; return false; } + if (Decimals is < 0 or > 18) { error = "Decimals must be between 0 and 18."; return false; } + if (string.IsNullOrWhiteSpace(RateScript)) { error = "A rate script is required."; return false; } + error = null; + return true; + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs index d01be426..042dfcb8 100644 --- a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs @@ -1,5 +1,6 @@ using BTCPayServer.Data; using BTCPayServer.Payments; +using BTCPayServer.Plugins.ArkPayServer.Services; using BTCPayServer.Services; using NArk.Core; using NArk.Abstractions.Wallets; diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/ArkContractInvoiceListener.cs b/BTCPayServer.Plugins.ArkPayServer/Services/ArkContractInvoiceListener.cs index d42b13b8..300775a8 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Services/ArkContractInvoiceListener.cs +++ b/BTCPayServer.Plugins.ArkPayServer/Services/ArkContractInvoiceListener.cs @@ -2,6 +2,7 @@ using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; +using BTCPayServer.Payments; using BTCPayServer.Plugins.ArkPayServer.Models; using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; using BTCPayServer.Services.Invoices; @@ -26,6 +27,7 @@ public class ArkContractInvoiceListener( IMemoryCache memoryCache, InvoiceRepository invoiceRepository, ArkadePaymentMethodHandler arkadePaymentMethodHandler, + ArkadeAssetPaymentMethodHandler arkadeAssetPaymentMethodHandler, IClientTransport clientTransport, EventAggregator eventAggregator, IContractStorage contractStorage, @@ -118,6 +120,21 @@ private async void OnVtxoChanged(object? sender, ArkVtxo vtxo) paymentDestination = address.ToString(network.ChainName == ChainName.Mainnet); inv = await invoiceRepository.GetInvoiceFromAddress( ArkadePlugin.ArkadePaymentMethodId, paymentDestination); + + // The dedicated Arkade Asset method derives its own address, so a + // VTXO arriving there matches no ARKADE invoice. Try the asset + // method and, if the arriving asset is one this invoice offered, + // settle that instead. + if (inv is null) + { + var assetInvoice = await invoiceRepository.GetInvoiceFromAddress( + ArkadePlugin.ArkadeAssetPaymentMethodId, paymentDestination); + if (assetInvoice is not null) + { + await HandleAssetPayment(assetInvoice, vtxo, paymentDestination); + return; + } + } } if (inv is null) @@ -135,7 +152,8 @@ private async void OnVtxoChanged(object? sender, ArkVtxo vtxo) Script = vtxo.Script, SeenAt = vtxo.CreatedAt }; - await HandlePaymentData(vtxoEntity, inv, arkadePaymentMethodHandler, paymentDestination, isConfirmed, isBoarding); + await HandlePaymentData(vtxoEntity, inv, arkadePaymentMethodHandler, + ArkadePlugin.ArkadePaymentMethodId, paymentDestination, isConfirmed, isBoarding); } catch (Exception ex) { @@ -143,6 +161,65 @@ private async void OnVtxoChanged(object? sender, ArkVtxo vtxo) } } + /// + /// Settles a payment on the dedicated Arkade Asset method. The invoice offered + /// one option per enabled asset (each a fixed base-unit amount due); match the + /// arriving VTXO's assets against those options by asset id and credit BTC in + /// proportion to the asset received, so BTCPay's accounting (partial / settled + /// / overpaid) stays correct. A VTXO carrying none of the offered assets is + /// ignored — it can't settle the invoice. + /// + private async Task HandleAssetPayment(InvoiceEntity invoice, ArkVtxo vtxo, string destination) + { + var prompt = invoice.GetPaymentPrompt(ArkadePlugin.ArkadeAssetPaymentMethodId); + if (prompt?.Details is null) + return; + var details = arkadeAssetPaymentMethodHandler.ParsePaymentPromptDetails(prompt.Details); + + // Match strictly by asset id against the offered options. arkd returns + // asset ids as lowercase hex; the option stores the merchant-entered id, + // so compare case-insensitively. + ArkadeAssetOption? matched = null; + ulong received = 0UL; + foreach (var option in details.Options) + { + var amount = vtxo.Assets? + .Where(a => string.Equals(a.AssetId, option.AssetId, StringComparison.OrdinalIgnoreCase)) + .Aggregate(0UL, (sum, a) => sum + a.Amount) ?? 0UL; + if (amount > 0UL) + { + matched = option; + received = amount; + break; + } + } + if (matched is null || matched.BaseUnitsDue == 0UL) + return; // no offered asset arrived on this VTXO + + // Credit BTC strictly proportional to the asset received — NOT capped at + // 100%. An over-payment in the asset must surface as an over-payment in + // BTCPay's books (refund / reconciliation depend on it). TotalDue is fixed + // at invoice creation, so reading it from the pre-lock prompt snapshot is safe. + var ratio = (decimal)received / matched.BaseUnitsDue; + var creditBtc = prompt.Calculate().TotalDue * ratio; + logger.LogInformation( + "Invoice {invoiceId}: Arkade asset {assetId} received {received} (due {due}) → crediting {btc} BTC", + invoice.Id, matched.AssetId, received, matched.BaseUnitsDue, creditBtc); + + // Asset VTXOs are off-chain Arkade VTXOs (never boarding) → confirmed on arrival. + var vtxoEntity = new VtxoEntity + { + TransactionId = vtxo.TransactionId, + TransactionOutputIndex = (int)vtxo.TransactionOutputIndex, + Amount = (long)vtxo.Amount, + Script = vtxo.Script, + SeenAt = vtxo.CreatedAt + }; + await HandlePaymentData(vtxoEntity, invoice, arkadeAssetPaymentMethodHandler, + ArkadePlugin.ArkadeAssetPaymentMethodId, destination, isConfirmed: true, isBoarding: false, + amountOverrideBtc: creditBtc); + } + private Task ReceivedPayment(InvoiceEntity invoice, PaymentEntity payment) { logger.LogInformation("Invoice {invoiceId} received payment {amount} {currency} {paymentId}", @@ -153,9 +230,8 @@ private Task ReceivedPayment(InvoiceEntity invoice, PaymentEntity payment) return Task.CompletedTask; } - private async Task HandlePaymentData(VtxoEntity vtxo, InvoiceEntity invoice, ArkadePaymentMethodHandler handler, string? destination = null, bool isConfirmed = true, bool isBoarding = false) + private async Task HandlePaymentData(VtxoEntity vtxo, InvoiceEntity invoice, IPaymentMethodHandler handler, PaymentMethodId pmi, string? destination = null, bool isConfirmed = true, bool isBoarding = false, decimal? amountOverrideBtc = null) { - var pmi = ArkadePlugin.ArkadePaymentMethodId; var details = new ArkadePaymentData($"{vtxo.TransactionId}:{vtxo.TransactionOutputIndex}", destination, isBoarding); var status = isConfirmed ? PaymentStatus.Settled : PaymentStatus.Processing; @@ -171,7 +247,7 @@ private async Task HandlePaymentData(VtxoEntity vtxo, InvoiceEntity invoice, Ark var paymentData = new PaymentData { Status = status, - Amount = Money.Satoshis(vtxo.Amount).ToDecimal(MoneyUnit.BTC), + Amount = amountOverrideBtc ?? Money.Satoshis(vtxo.Amount).ToDecimal(MoneyUnit.BTC), Created = vtxo.SeenAt, Id = details.Outpoint, Currency = "BTC", @@ -235,21 +311,18 @@ public async Task ToggleArkadeContract(InvoiceEntity invoice) var activityState = invoice.Status == InvoiceStatus.New ? ContractActivityState.Active : ContractActivityState.Inactive; - var listenedContract = GetListenedArkadeInvoice(invoice); - if (listenedContract is null) + var walletId = GetArkadeInvoiceWalletId(invoice); + if (walletId is null) { return; } - // ConfigurePrompt tags BOTH the Payment contract (the offchain Arkade - // address) and, when boarding is enabled, the Boarding contract with - // Source = "invoice:{id}". The previous implementation only toggled - // the Payment one (derived from the prompt's details), so the - // boarding contract stayed Active forever after settlement. Find every - // contract carrying this invoice's source tag and toggle them all. - // HTLC contracts use a different "swap:{id}" Source tag and are - // driven by OnSwapChanged based on swap state, not invoice state. - var walletId = listenedContract.Details.WalletId; + // ConfigurePrompt tags every contract it derives for this invoice with + // Source = "invoice:{id}": the BTC-VTXO Payment contract, the Boarding + // contract (when enabled), and the Arkade Asset method's receive + // contract. Find every contract carrying this invoice's source tag and + // toggle them all. HTLC contracts use a different "swap:{id}" Source tag + // and are driven by OnSwapChanged based on swap state, not invoice state. var invoiceSource = $"invoice:{invoice.Id}"; var contracts = await contractStorage.GetContracts( walletIds: [walletId], @@ -260,15 +333,24 @@ public async Task ToggleArkadeContract(InvoiceEntity invoice) } } - private ArkadeListenedContract? GetListenedArkadeInvoice(InvoiceEntity invoice) + /// + /// Resolves the Arkade wallet id backing an invoice from whichever Arkade + /// prompt has been activated — the BTC-VTXO method or the dedicated asset + /// method. Both derive their contracts from the same wallet and tag them + /// Source = "invoice:{id}", so either id is sufficient to find and toggle + /// every contract for the invoice. + /// + private string? GetArkadeInvoiceWalletId(InvoiceEntity invoice) { - var prompt = invoice.GetPaymentPrompt(ArkadePlugin.ArkadePaymentMethodId); - if (prompt?.Details is null) - return null; + var arkPrompt = invoice.GetPaymentPrompt(ArkadePlugin.ArkadePaymentMethodId); + if (arkPrompt?.Details is not null) + return arkadePaymentMethodHandler.ParsePaymentPromptDetails(arkPrompt.Details).WalletId; - return new ArkadeListenedContract( - arkadePaymentMethodHandler.ParsePaymentPromptDetails(prompt.Details), - invoice.Id); + var assetPrompt = invoice.GetPaymentPrompt(ArkadePlugin.ArkadeAssetPaymentMethodId); + if (assetPrompt?.Details is not null) + return arkadeAssetPaymentMethodHandler.ParsePaymentPromptDetails(assetPrompt.Details).WalletId; + + return null; } private static DateTimeOffset GetExpiration(InvoiceEntity invoice) @@ -279,7 +361,7 @@ private static DateTimeOffset GetExpiration(InvoiceEntity invoice) private string GetCacheKey(string invoiceId) { - return $"{nameof(GetListenedArkadeInvoice)}-{invoiceId}"; + return $"ArkadeInvoice-{invoiceId}"; } private Task GetInvoice(string invoiceId) @@ -297,14 +379,22 @@ private Task GetInvoice(string invoiceId) private async Task QueueMonitoredInvoices(CancellationToken cancellation) { - foreach (var invoice in await invoiceRepository.GetMonitoredInvoices(ArkadePlugin.ArkadePaymentMethodId, - cancellation)) + // Scan both Arkade methods: an invoice may have the BTC-VTXO ARKADE + // method excluded but the dedicated ARKADE-ASSET method active, so a + // single-method scan would miss it and never re-activate its contracts. + var arkadeInvoices = await invoiceRepository.GetMonitoredInvoices( + ArkadePlugin.ArkadePaymentMethodId, cancellation); + var assetInvoices = await invoiceRepository.GetMonitoredInvoices( + ArkadePlugin.ArkadeAssetPaymentMethodId, cancellation); + + var queued = new HashSet(); + foreach (var invoice in arkadeInvoices.Concat(assetInvoices)) { - if (GetListenedArkadeInvoice(invoice) is null) continue; + if (!queued.Add(invoice.Id)) continue; // dedupe across both scans + if (GetArkadeInvoiceWalletId(invoice) is null) continue; _checkInvoices.Writer.TryWrite(invoice.Id); memoryCache.Set(GetCacheKey(invoice.Id), invoice, GetExpiration(invoice)); } - } private async Task PollAllInvoices(CancellationToken cancellation) diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/ArkadeAssetCurrencyDataProvider.cs b/BTCPayServer.Plugins.ArkPayServer/Services/ArkadeAssetCurrencyDataProvider.cs new file mode 100644 index 00000000..6a100ef7 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/ArkadeAssetCurrencyDataProvider.cs @@ -0,0 +1,66 @@ +using BTCPayServer.Data; +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +/// +/// Exposes every store's tracked Arkade assets to BTCPay's +/// as first-class currencies, so a tracked +/// asset's code (e.g. USDARK) is a valid pricing/display currency +/// without shipping it in BTCPay's static currency list. +/// +/// Codes are unique within a store (enforced on CRUD); across stores the +/// first occurrence wins (), which +/// is harmless because each store prices against its own asset record. +/// +/// +/// DI note: dependencies are resolved lazily through +/// inside — NOT +/// injected into the constructor. takes +/// IEnumerable<CurrencyDataProvider>, so injecting +/// here would force the whole +/// payment-handler graph to build while is +/// still constructing — and core handlers depend back on +/// , forming a DI cycle that hangs startup. +/// Resolving at load time (after the table is constructed) sidesteps it, the +/// same way ArkadeCheckoutModelExtension does for its own cycle. +/// +/// +public class ArkadeAssetCurrencyDataProvider(IServiceProvider serviceProvider) : CurrencyDataProvider +{ + public async Task LoadCurrencyData(CancellationToken cancellationToken) + { + var stores = serviceProvider.GetRequiredService(); + var handlers = serviceProvider.GetRequiredService(); + + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var store in await stores.GetStores()) + { + var cfg = store.GetPaymentMethodConfig( + ArkadePlugin.ArkadePaymentMethodId, handlers); + if (cfg is null) + continue; + + foreach (var asset in cfg.Assets) + { + if (string.IsNullOrWhiteSpace(asset.CurrencyCode)) + continue; + + seen.TryAdd(asset.CurrencyCode, new CurrencyData + { + Code = asset.CurrencyCode, + Name = asset.Name ?? asset.Ticker ?? asset.CurrencyCode, + Divisibility = asset.Decimals, + Symbol = asset.Ticker ?? asset.CurrencyCode, + Crypto = true, + }); + } + } + + return seen.Values.ToArray(); + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/AssetAmount.cs b/BTCPayServer.Plugins.ArkPayServer/Services/AssetAmount.cs new file mode 100644 index 00000000..b1152cbc --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/AssetAmount.cs @@ -0,0 +1,59 @@ +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +/// +/// Canonical Arkade-asset base-unit ↔ display conversions. One +/// implementation so the rate resolver () +/// and the metadata/display formatter () +/// can never disagree on a divisor or on rendering. +/// +public static class AssetAmount +{ + /// + /// 10^ as an exact , with + /// clamped to 0..18 (the asset-decimals range). + /// Uses a decimal loop, not : for + /// exp > 15, 10^exp exceeds the mantissa + /// (2^53 ≈ 9e15) and casting the result to decimal would bake in the + /// rounding error. + /// + public static decimal Pow10(int exp) + { + exp = System.Math.Clamp(exp, 0, 18); + decimal result = 1m; + for (var i = 0; i < exp; i++) + result *= 10m; + return result; + } + + /// + /// Converts a whole-unit amount due into raw base units for settlement: + /// rounds up (the merchant must never be underpaid) and clamps to at + /// least one base unit (a zero-amount asset output is meaningless). Returns + /// the base units and the actual display amount they represent. Pure — no + /// rate lookup — so it's unit-testable independently of the rate pipeline. + /// + public static (ulong BaseUnits, decimal ActualDisplay) BaseUnitsDue(decimal displayUnits, int decimals) + { + var scale = Pow10(decimals); + var baseUnitsExact = System.Math.Max(1m, System.Math.Ceiling(displayUnits * scale)); + var baseUnits = (ulong)baseUnitsExact; + return (baseUnits, baseUnitsExact / scale); + } + + /// + /// Formats a raw base-unit amount using the asset's declared decimals: + /// no trailing zeros, no trailing dot, and always at least the integer + /// part (150 with decimals=2 → "1.5"; 100 with decimals=0 → "100"; + /// 100 base units with decimals=8 → "0.000001"). + /// + public static string Format(ulong baseUnits, int decimals) + { + if (decimals <= 0) + return baseUnits.ToString(System.Globalization.CultureInfo.InvariantCulture); + + var value = baseUnits / Pow10(decimals); + return value.ToString( + "0." + new string('#', System.Math.Clamp(decimals, 1, 18)), + System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/AssetCurrencyRegistrar.cs b/BTCPayServer.Plugins.ArkPayServer/Services/AssetCurrencyRegistrar.cs new file mode 100644 index 00000000..d095b025 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/AssetCurrencyRegistrar.cs @@ -0,0 +1,16 @@ +using BTCPayServer.Services.Rates; + +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +/// +/// Refreshes BTCPay's currency table after a tracked-asset CRUD operation so a +/// newly-added (or removed) asset code is recognised immediately, without a +/// process restart. Wraps , +/// which re-runs every — including +/// . +/// +public class AssetCurrencyRegistrar(CurrencyNameTable currencies) +{ + public Task RefreshAsync(CancellationToken cancellationToken = default) => + currencies.ReloadCurrencyData(cancellationToken); +} diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/AssetMetadataService.cs b/BTCPayServer.Plugins.ArkPayServer/Services/AssetMetadataService.cs new file mode 100644 index 00000000..68bfa249 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/AssetMetadataService.cs @@ -0,0 +1,90 @@ +using System.Collections.Concurrent; +using NArk.Core.Transport; +using NArk.Core.Transport.Models; + +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +/// +/// Caches Arkade asset metadata (name, ticker, decimals, icon) resolved +/// from the arkd indexer. Asset metadata is immutable after issuance, so a +/// process-lifetime in-memory cache avoids repeated indexer round-trips on +/// every dashboard/checkout render. +/// +/// The success cache is unbounded and never evicted: this is intentional — +/// asset metadata is immutable, and a BTCPay store realistically configures +/// a handful of accepted assets (not thousands), so the entry count is +/// naturally small. Failed lookups are negatively cached for a short TTL so +/// an indexer outage doesn't turn every render into N× network timeouts. +/// +/// +public class AssetMetadataService(IClientTransport clientTransport) +{ + private readonly ConcurrentDictionary _cache = new(); + + /// Asset ids whose last lookup failed, with the time it failed. + private readonly ConcurrentDictionary _negativeCache = new(); + + /// How long a failed lookup is remembered before we retry the indexer. + private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromSeconds(60); + + /// + /// Resolves details for , caching the result. + /// Returns null if the indexer has no record (or is unreachable) — the + /// caller falls back to showing the raw asset id. A null result is + /// negatively cached for so a down + /// indexer isn't hit again on every subsequent render. + /// + public async Task GetAssetDetailsAsync( + string assetId, CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(assetId, out var cached)) + return cached; + + if (_negativeCache.TryGetValue(assetId, out var failedAt) && + DateTimeOffset.UtcNow - failedAt < NegativeCacheTtl) + return null; + + try + { + var details = await clientTransport.GetAssetDetailsAsync(assetId, cancellationToken); + if (details is null) + { + _negativeCache[assetId] = DateTimeOffset.UtcNow; + return null; + } + _cache.TryAdd(assetId, details); + _negativeCache.TryRemove(assetId, out _); + return details; + } + catch + { + // Indexer miss/unreachable: not fatal — UI degrades to raw id. + // Remember the failure briefly so we don't re-hit a down indexer + // on every dashboard/checkout/prompt render. + _negativeCache[assetId] = DateTimeOffset.UtcNow; + return null; + } + } + + public string? GetName(ArkAssetDetails? details) => + details?.Metadata is { } m && m.TryGetValue("name", out var v) ? v : null; + + public string? GetTicker(ArkAssetDetails? details) => + details?.Metadata is { } m && m.TryGetValue("ticker", out var v) ? v : null; + + /// Decimal precision for display. Defaults to 0 when unset. + public int GetDecimals(ArkAssetDetails? details) => + details?.Metadata is { } m && m.TryGetValue("decimals", out var s) && + int.TryParse(s, out var d) && d is >= 0 and <= 18 + ? d + : 0; + + /// + /// Formats a base-unit amount for display using the asset's declared + /// decimals (e.g. 150 with decimals=2 → "1.5"). Delegates to the + /// canonical so display and the rate + /// resolver never diverge on the divisor. + /// + public string FormatAmount(ulong amount, ArkAssetDetails? details) => + AssetAmount.Format(amount, GetDecimals(details)); +} diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs b/BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs new file mode 100644 index 00000000..8e6f7b30 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs @@ -0,0 +1,102 @@ +using BTCPayServer.Data; +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using BTCPayServer.Rating; +using BTCPayServer.Services.Rates; + +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +/// +/// The Arkade-asset amount a customer must pay to settle an invoice. +/// +/// +/// Raw amount carried on the asset VTXO (what coin selection / payment +/// matching compares against). Always rounded up so the merchant is never +/// underpaid, and never below one base unit. +/// +/// +/// expressed in whole asset units +/// (BaseUnits / 10^decimals) — the value the customer sees. +/// +/// +/// rendered to the asset's declared decimals. +/// +/// +/// Human-readable account of how the amount was derived (for invoice logs). +/// +public record AssetAmountDue( + ulong BaseUnits, + decimal DisplayUnits, + string FormattedAmount, + string RateDescription); + +/// +/// Resolves how many units of a merchant-tracked Arkade asset settle an +/// invoice, given the invoice's bitcoin amount due. +/// +/// Arkade assets aren't quoted on exchanges, so the price is merchant-declared +/// via a free-form BTCPay rate-rule +/// (e.g. USDARK_USD = 1; or MYA_BTC = 100000;). The script is +/// combined with the store's own rate rules and evaluated through BTCPay's +/// for the BTC → asset pair — so store +/// spread/fallback/rate-source config keeps applying, and chained legs (the +/// asset priced in a real currency, then that currency in BTC) resolve. The +/// store's global RateScript is never mutated. +/// +/// +public class AssetRateResolver(RateFetcher rateFetcher, DefaultRulesCollection defaultRules) +{ + /// + /// Computes the asset amount due for an invoice. + /// + /// The invoice's store (for its rate rules). + /// The tracked asset to price in. + /// Invoice amount due, in satoshis (BTC leg). + /// + /// The config is invalid, the rate script doesn't compile, or the rate + /// could not be evaluated. The caller translates this into the invoice + /// simply not offering the asset (never a hard failure). + /// + public async Task ResolveAsync( + StoreData store, TrackedArkadeAsset asset, long dueSats, CancellationToken cancellationToken) + { + if (!asset.IsValid(out var configError)) + throw new InvalidOperationException($"Invalid tracked asset: {configError}"); + if (dueSats <= 0) + throw new InvalidOperationException("Invoice amount due must be positive to price an asset."); + + if (!RateRules.TryParse(asset.RateScript, out var assetRules, out var parseErrors)) + throw new InvalidOperationException( + $"Invalid rate script for {asset.CurrencyCode}: {string.Join("; ", parseErrors)}"); + + // Combine the asset's rule into the store's existing rules — both the + // primary and fallback legs — so chained legs resolve (e.g. the asset + // priced in USD, then USD→BTC via the store's configured source) and the + // store's primary/fallback rate-source order keeps applying. The store's + // persisted RateScript is NOT modified. + var storeRules = store.GetStoreBlob().GetRateRules(defaultRules); + var combined = new RateRulesCollection( + RateRules.Combine([assetRules, storeRules.Primary]), + storeRules.Fallback is null + ? null + : RateRules.Combine([assetRules, storeRules.Fallback])); + + var pair = new CurrencyPair("BTC", asset.CurrencyCode); // units of asset per 1 BTC + var rate = await rateFetcher.FetchRate(pair, combined, new StoreIdRateContext(store.Id), cancellationToken); + if (rate.BidAsk is null || rate.Errors is { Count: > 0 }) + throw new InvalidOperationException( + $"Unable to evaluate rate for {pair}" + + (rate.Errors is { Count: > 0 } ? $" ({string.Join(", ", rate.Errors)})" : "")); + + var dueBtc = dueSats / 100_000_000m; + var unitsPerBtc = rate.BidAsk.Center; + var displayUnits = dueBtc * unitsPerBtc; + + var (baseUnits, actualDisplay) = AssetAmount.BaseUnitsDue(displayUnits, asset.Decimals); + var rateDescription = + $"{pair} = {unitsPerBtc}; {dueBtc} BTC = {displayUnits} {asset.CurrencyCode} " + + $"→ {AssetAmount.Format(baseUnits, asset.Decimals)} {asset.CurrencyCode}"; + + return new AssetAmountDue(baseUnits, actualDisplay, + AssetAmount.Format(baseUnits, asset.Decimals), rateDescription); + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml b/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml index 712c45dc..784720bb 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml +++ b/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml @@ -150,6 +150,23 @@ } +
+ Asset Payments + +
+
Diagnostic Log } + + + + + } else @@ -158,6 +167,29 @@ }
} + @if (Model.AssetBalances.Count > 0) + { +
+
Arkade assets
+ @foreach (var asset in Model.AssetBalances) + { +
+
+ + @(asset.Name ?? asset.Ticker ?? "Asset") + @if (!string.IsNullOrEmpty(asset.Ticker) && asset.Name != null) + { + @asset.Ticker + } + @(asset.AssetId.Length > 12 ? asset.AssetId[..12] + "…" : asset.AssetId) +
+ + @asset.FormattedAmount@(string.IsNullOrEmpty(asset.Ticker) ? "" : " " + asset.Ticker) + +
+ } +
+ } @: } } diff --git a/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_VtxoTable.cshtml b/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_VtxoTable.cshtml index 79e4c712..a02eecea 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_VtxoTable.cshtml +++ b/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_VtxoTable.cshtml @@ -9,6 +9,7 @@ @inject ArkNetworkConfig ArkNetworkConfig @inject IBitcoinBlockchain ChainTimeProvider @inject IClientTransport ClientTransport +@inject BTCPayServer.Plugins.ArkPayServer.Services.AssetMetadataService AssetMetadata @model IEnumerable @{ @@ -103,6 +104,18 @@ } @Money.Satoshis((long)vtxo.Amount).ToDecimal(MoneyUnit.BTC) BTC + @if (vtxo.Assets is { Count: > 0 }) + { + foreach (var asset in vtxo.Assets) + { + var assetDetails = await AssetMetadata.GetAssetDetailsAsync(asset.AssetId); + var label = AssetMetadata.GetTicker(assetDetails) ?? (asset.AssetId.Length > 10 ? asset.AssetId[..10] + "…" : asset.AssetId); +
+ + @AssetMetadata.FormatAmount(asset.Amount, assetDetails) @label + + } + } @if (vtxo.ExpiresAt.HasValue) diff --git a/NArk.E2E.Tests/ArkadeBip21AssetTests.cs b/NArk.E2E.Tests/ArkadeBip21AssetTests.cs new file mode 100644 index 00000000..cc2e9826 --- /dev/null +++ b/NArk.E2E.Tests/ArkadeBip21AssetTests.cs @@ -0,0 +1,36 @@ +using System; +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using Xunit; + +namespace NArk.E2E.Tests; + +/// +/// BIP-321 asset-URI contract for +/// (pure; no infra). An asset option is ark-only and carries the asset id plus +/// the amount due denominated in the asset's own display units. +/// +[Trait("Category", "Unit")] +public class ArkadeBip21AssetTests +{ + [Fact] + public void WithAsset_appends_asset_id_and_asset_amount() + { + var uri = ArkadeBip21Builder.Create() + .WithArkAddress("tark1qexample") + .WithAsset("deadbeef", 1.5m) + .Build(); + + Assert.StartsWith("bitcoin:?", uri); // ark-only: no onchain address in the path + Assert.Contains("ark=tark1qexample", uri); + Assert.Contains("asset=deadbeef", uri); + Assert.Contains("amount=1.5", uri); // amount denominates the asset when asset is present + Assert.DoesNotContain("lightning=", uri); + } + + [Fact] + public void WithAsset_requires_non_empty_asset_id() + { + Assert.Throws(() => + ArkadeBip21Builder.Create().WithArkAddress("tark1qexample").WithAsset("", 1m)); + } +} diff --git a/NArk.E2E.Tests/AssetAcceptanceTests.cs b/NArk.E2E.Tests/AssetAcceptanceTests.cs new file mode 100644 index 00000000..f505b69f --- /dev/null +++ b/NArk.E2E.Tests/AssetAcceptanceTests.cs @@ -0,0 +1,107 @@ +using Microsoft.Playwright; +using Xunit; +using Xunit.Abstractions; + +namespace NArk.E2E.Tests; + +/// +/// End-to-end coverage for the store-level tracked-Arkade-asset configuration: +/// the overview "Asset Payments" row + the add/edit modal render, and the +/// add-asset path round-trips through the controller's validation and the arkd +/// indexer existence check. A full add → pay-with-asset settlement flow needs an +/// issued asset funded into the buyer wallet (heavier infra); the deterministic +/// config/validation paths are covered here, the money math is unit-tested in +/// , and the BIP-321 asset URI in +/// . +/// +[Collection("Arkade Plugin Tests")] +public class AssetAcceptanceTests : PlaywrightBaseTest +{ + private readonly SharedPluginTestFixture _fixture; + + public AssetAcceptanceTests(SharedPluginTestFixture fixture, ITestOutputHelper helper) + : base(helper) + { + _fixture = fixture; + } + + /// + /// The Asset Payments row defaults to "Disabled"; opening the modal and + /// adding an asset id the indexer doesn't know must be rejected with the + /// indexer-not-found error (config is not persisted, so the row stays + /// "Disabled"). + /// + [Fact] + [Trait("Category", "Integration")] + public async Task TrackedAsset_UnknownAssetId_RejectedWithIndexerError() + { + _fixture.Initialize(this); + await InitializePlaywright(_fixture.ServerTester!); + + await GoToUrl("/register"); + await RegisterNewUser(isAdmin: true); + + var storeId = await CreateStoreWithArkWalletAsync(GenerateRandomNsec()); + await GoToUrl($"/plugins/ark/stores/{storeId}/overview"); + + // Defaults to disabled before any asset is tracked. + var triggerText = await Page!.InnerTextAsync("[data-testid='asset-acceptance-btn']"); + Assert.Contains("Disabled", triggerText); + + // Open the modal and wait for the add-asset form to be interactable. + await Page.ClickAsync("[data-testid='asset-acceptance-btn']"); + await Page.WaitForSelectorAsync( + "[data-testid='asset-form-asset-id']", + new() { State = WaitForSelectorState.Visible }); + + // A well-formed rate script keeps this independent of indexer rate data; + // the asset id simply doesn't exist, so the indexer check must reject it. + await Page.FillAsync("[data-testid='asset-form-asset-id']", "deadbeefnope00"); + await Page.FillAsync("[data-testid='asset-form-currency-code']", "NOPE"); + await Page.FillAsync("[data-testid='asset-form-rate-script']", "NOPE_USD = 1;"); + await Page.ClickAsync("[data-testid='asset-form-submit']"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var body = await Page.InnerTextAsync("body"); + Assert.Contains("was not found on the Arkade indexer", body, StringComparison.OrdinalIgnoreCase); + + // Rejected config must not have been persisted. + await GoToUrl($"/plugins/ark/stores/{storeId}/overview"); + var afterText = await Page.InnerTextAsync("[data-testid='asset-acceptance-btn']"); + Assert.Contains("Disabled", afterText); + } + + /// + /// An add-asset submission with an empty rate script must be rejected by the + /// controller's TrackedArkadeAsset.IsValid check before any indexer + /// lookup (the server is the authority, regardless of client-side hints). + /// + [Fact] + [Trait("Category", "Integration")] + public async Task TrackedAsset_EmptyRateScript_Rejected() + { + _fixture.Initialize(this); + await InitializePlaywright(_fixture.ServerTester!); + + await GoToUrl("/register"); + await RegisterNewUser(isAdmin: true); + + var storeId = await CreateStoreWithArkWalletAsync(GenerateRandomNsec()); + await GoToUrl($"/plugins/ark/stores/{storeId}/overview"); + + await Page!.ClickAsync("[data-testid='asset-acceptance-btn']"); + await Page.WaitForSelectorAsync( + "[data-testid='asset-form-asset-id']", + new() { State = WaitForSelectorState.Visible }); + + await Page.FillAsync("[data-testid='asset-form-asset-id']", "deadbeefnope00"); + await Page.FillAsync("[data-testid='asset-form-currency-code']", "NOPE"); + // Deliberately leave the rate script empty, then submit. + await Page.FillAsync("[data-testid='asset-form-rate-script']", ""); + await Page.ClickAsync("[data-testid='asset-form-submit']"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var body = await Page.InnerTextAsync("body"); + Assert.Contains("rate script is required", body, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/NArk.E2E.Tests/AssetAmountTests.cs b/NArk.E2E.Tests/AssetAmountTests.cs new file mode 100644 index 00000000..c97273f8 --- /dev/null +++ b/NArk.E2E.Tests/AssetAmountTests.cs @@ -0,0 +1,43 @@ +using BTCPayServer.Plugins.ArkPayServer.Services; +using Xunit; + +namespace NArk.E2E.Tests; + +/// +/// Pins the canonical base-unit ↔ display conversion shared by the rate +/// resolver and the metadata/display formatter. The high-decimals cases +/// are the regression guard for the old (decimal)Math.Pow(10, n) +/// approach, which loses precision for n > 15 (10^n exceeds the double +/// mantissa) — the decimal-loop is exact. +/// +[Trait("Category", "Unit")] +public class AssetAmountTests +{ + [Theory] + [InlineData(0, "1")] + [InlineData(1, "10")] + [InlineData(8, "100000000")] + [InlineData(15, "1000000000000000")] + [InlineData(18, "1000000000000000000")] + public void Pow10_IsExact(int exp, string expected) + => Assert.Equal(expected, AssetAmount.Pow10(exp).ToString( + System.Globalization.CultureInfo.InvariantCulture)); + + [Fact] + public void Pow10_ClampsToAssetRange() + { + Assert.Equal(1m, AssetAmount.Pow10(-3)); // < 0 → 10^0 + Assert.Equal(AssetAmount.Pow10(18), AssetAmount.Pow10(25)); // > 18 → 10^18 + } + + [Theory] + [InlineData(100UL, 0, "100")] // whole, no decimals (not "1") + [InlineData(150UL, 2, "1.5")] // trailing zero trimmed + [InlineData(100UL, 8, "0.000001")] + [InlineData(8_000_000UL, 3, "8000")] + [InlineData(1UL, 18, "0.000000000000000001")] // 1e-18 exact — Math.Pow would drift + [InlineData(1_000_000_000_000_000_000UL, 18, "1")] + [InlineData(0UL, 6, "0")] + public void Format_RendersExpected(ulong baseUnits, int decimals, string expected) + => Assert.Equal(expected, AssetAmount.Format(baseUnits, decimals)); +} diff --git a/NArk.E2E.Tests/AssetRateResolverTests.cs b/NArk.E2E.Tests/AssetRateResolverTests.cs new file mode 100644 index 00000000..9a753299 --- /dev/null +++ b/NArk.E2E.Tests/AssetRateResolverTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using BTCPayServer.Plugins.ArkPayServer.Services; +using Xunit; + +namespace NArk.E2E.Tests; + +/// +/// Settlement money-math contract for Arkade assets, and the resolver's input +/// guards. The money-math (round-up, clamp-to-one-base-unit, decimal scaling) +/// is pure (), and the guards reject +/// before any rate lookup, so these run standalone (no fixture / no docker). +/// The rate-fetch leg (free-form script → BTC→asset rate via RateFetcher) is +/// exercised end-to-end by the integration suite against a real store. +/// +[Trait("Category", "Unit")] +public class AssetRateResolverTests +{ + [Theory] + // displayUnits (whole asset units due), decimals => base units, formatted + [InlineData("100", 0, 100UL, "100")] + [InlineData("100", 2, 10000UL, "100")] + [InlineData("100.5", 0, 101UL, "101")] // round UP — never underpay the merchant + [InlineData("0.5", 0, 1UL, "1")] // clamp to >= 1 base unit + [InlineData("0.000001", 8, 100UL, "0.000001")] + [InlineData("8000", 3, 8000000UL, "8000")] + public void BaseUnitsDue_RoundsUp_ClampsToOne_ScalesByDecimals( + string displayUnits, int decimals, ulong expectedBaseUnits, string expectedFormatted) + { + var (baseUnits, _) = AssetAmount.BaseUnitsDue( + decimal.Parse(displayUnits, CultureInfo.InvariantCulture), decimals); + + Assert.Equal(expectedBaseUnits, baseUnits); + Assert.Equal(expectedFormatted, AssetAmount.Format(baseUnits, decimals)); + } + + [Fact] + public async Task InvalidAsset_Throws_BeforeAnyRateLookup() + { + // Empty rate script fails IsValid; the resolver must reject before it + // ever touches the (null) RateFetcher. + var bad = new TrackedArkadeAsset("deadbeef00", "MYA", "MYA", "My Asset", + Decimals: 0, RateScript: "", Enabled: true); + + await Assert.ThrowsAsync(() => + new AssetRateResolver(null!, null!).ResolveAsync( + new StoreData(), bad, dueSats: 1000, CancellationToken.None)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task NonPositiveDue_Throws_BeforeAnyRateLookup(long dueSats) + { + var asset = new TrackedArkadeAsset("deadbeef00", "MYA", "MYA", "My Asset", + Decimals: 0, RateScript: "MYA_BTC = 100000;", Enabled: true); + + await Assert.ThrowsAsync(() => + new AssetRateResolver(null!, null!).ResolveAsync( + new StoreData(), asset, dueSats, CancellationToken.None)); + } +} diff --git a/NArk.E2E.Tests/TrackedArkadeAssetTests.cs b/NArk.E2E.Tests/TrackedArkadeAssetTests.cs new file mode 100644 index 00000000..b3b26fad --- /dev/null +++ b/NArk.E2E.Tests/TrackedArkadeAssetTests.cs @@ -0,0 +1,41 @@ +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using Xunit; + +namespace NArk.E2E.Tests; + +/// Validation contract for (pure; no infra). +[Trait("Category", "Unit")] +public class TrackedArkadeAssetTests +{ + private static TrackedArkadeAsset Valid() => + new("abc123", "USDARK", "USDARK", "USD Arkade", Decimals: 2, + RateScript: "USDARK_USD = 1;", Enabled: true); + + [Fact] + public void Valid_config_passes() + { + Assert.True(Valid().IsValid(out var err)); + Assert.Null(err); + } + + [Fact] + public void Missing_asset_id_fails() + { + Assert.False((Valid() with { AssetId = "" }).IsValid(out var err)); + Assert.Contains("asset id", err); + } + + [Fact] + public void Missing_currency_code_fails() => + Assert.False((Valid() with { CurrencyCode = " " }).IsValid(out _)); + + [Fact] + public void Empty_rate_script_fails() => + Assert.False((Valid() with { RateScript = "" }).IsValid(out _)); + + [Theory] + [InlineData(-1)] + [InlineData(19)] + public void Out_of_range_decimals_fails(int decimals) => + Assert.False((Valid() with { Decimals = decimals }).IsValid(out _)); +} diff --git a/docs/superpowers/plans/2026-05-22-arkade-asset-management.md b/docs/superpowers/plans/2026-05-22-arkade-asset-management.md new file mode 100644 index 00000000..06a35f91 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-arkade-asset-management.md @@ -0,0 +1,732 @@ +# Arkade Asset Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace single-asset acceptance with a managed list of tracked Arkade assets (add-by-id with indexer prefill, free-form per-asset rate script, currency registration) and surface asset holdings in the VTXO table and compact balance. + +**Architecture:** Each store's payment-method config holds `List`. A plugin `CurrencyDataProvider` registers each asset's code as a BTCPay currency (union across stores; `CurrencyNameTable.ReloadCurrencyData` after CRUD). `AssetRateResolver` evaluates the asset's free-form rate-rule script via `RateRules.Combine(assetRules, storeRules)` + `RateFetcher` — the global `StoreBlob.RateScript` is never mutated. Display views read `ArkVtxo.Assets` / `AssetMetadataService`. + +**Tech Stack:** C# / ASP.NET Core (BTCPay plugin), Razor views, NUnit (`NArk.E2E.Tests`), BTCPay `RateRules`/`RateFetcher`/`CurrencyNameTable`. + +**Branch:** `feat/arkade-assets-payment` (extends PR #55; ships as one PR). Spec: `docs/superpowers/specs/2026-05-22-arkade-asset-management-design.md`. + +**Pre-req baseline:** `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj` is green; asset unit tests run via `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~AssetRateResolver|FullyQualifiedName~AssetAmount"`. + +--- + +## File Structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `PaymentHandler/ArkadePaymentMethodConfig.cs` | `TrackedArkadeAsset` record + `TrackedAssets` list; drop `ArkadeAssetAcceptance`/`AssetRateMode` | Modify | +| `Services/AssetRateResolver.cs` | Evaluate free-form rate script → asset units | Modify | +| `Services/ArkadeAssetCurrencyDataProvider.cs` | Register tracked-asset codes as BTCPay currencies | Create | +| `Services/AssetCurrencyRegistrar.cs` | Trigger `CurrencyNameTable.ReloadCurrencyData` after CRUD | Create | +| `Models/TrackedAssetViewModels.cs` | List/add/edit view models + fetch-result DTO | Create | +| `Controllers/ArkController.cs` | CRUD command handlers + fetch-metadata endpoint | Modify | +| `Views/Ark/StoreOverview.cshtml` | Tracked-assets list + add/edit modal (replaces single-asset modal) | Modify | +| `Views/Ark/Vtxos.cshtml`, `Views/Shared/_VtxoTable.cshtml` | Asset badge under Amount | Modify | +| `Views/Shared/_ArkBalances.cshtml` | Asset section in compact branch | Modify | +| `ArkPlugin.cs` | Register `CurrencyDataProvider` + `AssetCurrencyRegistrar` | Modify | +| `NArk.E2E.Tests/AssetRateResolverTests.cs` | Rate-script eval tests | Modify | +| `NArk.E2E.Tests/TrackedArkadeAssetTests.cs` | Validation tests | Create | + +--- + +## Task 1: TrackedArkadeAsset data model + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs` +- Create: `NArk.E2E.Tests/TrackedArkadeAssetTests.cs` + +- [ ] **Step 1: Write failing validation tests** + +Create `NArk.E2E.Tests/TrackedArkadeAssetTests.cs`: + +```csharp +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using NUnit.Framework; + +namespace NArk.E2E.Tests; + +[TestFixture] +public class TrackedArkadeAssetTests +{ + private static TrackedArkadeAsset Make(string code = "USDARK", string script = "USDARK_USD = 1;") => + new(AssetId: "abc123", CurrencyCode: code, Ticker: "USDARK", Name: "USD Arkade", + Decimals: 2, RateScript: script, Enabled: true); + + [Test] + public void Valid_config_passes() + { + Assert.That(Make().IsValid(out var err), Is.True); + Assert.That(err, Is.Null); + } + + [Test] + public void Missing_asset_id_fails() + { + var a = Make() with { AssetId = "" }; + Assert.That(a.IsValid(out var err), Is.False); + Assert.That(err, Does.Contain("asset id")); + } + + [Test] + public void Missing_currency_code_fails() + { + Assert.That((Make() with { CurrencyCode = " " }).IsValid(out _), Is.False); + } + + [Test] + public void Empty_rate_script_fails() + { + Assert.That((Make() with { RateScript = "" }).IsValid(out _), Is.False); + } + + [Test] + public void Negative_decimals_fails() + { + Assert.That((Make() with { Decimals = -1 }).IsValid(out _), Is.False); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~TrackedArkadeAssetTests"` +Expected: FAIL — `TrackedArkadeAsset` does not exist / no `TrackedAssets`. + +- [ ] **Step 3: Replace the single-asset model with TrackedArkadeAsset** + +Rewrite `ArkadePaymentMethodConfig.cs` — remove `AssetAcceptance` param, `AssetRateMode` enum, and `ArkadeAssetAcceptance` record; add `TrackedAssets` + `TrackedArkadeAsset`: + +```csharp +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +public record ArkadePaymentMethodConfig( + string WalletId, + bool GeneratedByStore = false, + bool AllowSubDustAmounts = false, + bool BoardingEnabled = true, + long MinBoardingAmountSats = ArkadePaymentMethodConfig.DefaultMinBoardingAmountSats, + IReadOnlyList? TrackedAssets = null) +{ + public const long P2trDustLimitSats = 330L; + public const long DefaultMinBoardingAmountSats = 5000L; + + /// Tracked assets, never null (empty when none configured). + public IReadOnlyList Assets => TrackedAssets ?? []; +} + +/// +/// A store-tracked Arkade asset the merchant accepts as payment. The rate is +/// merchant-declared via a free-form BTCPay rate-rule +/// (assets aren't exchange-listed). Ticker/Name/Decimals are cached from the +/// arkd indexer for display and settlement math. +/// +public record TrackedArkadeAsset( + string AssetId, + string CurrencyCode, + string? Ticker, + string? Name, + int Decimals, + string RateScript, + bool Enabled) +{ + public bool IsValid(out string? error) + { + if (string.IsNullOrWhiteSpace(AssetId)) { error = "An asset id is required."; return false; } + if (string.IsNullOrWhiteSpace(CurrencyCode)) { error = "A currency code is required."; return false; } + if (Decimals is < 0 or > 18) { error = "Decimals must be between 0 and 18."; return false; } + if (string.IsNullOrWhiteSpace(RateScript)) { error = "A rate script is required."; return false; } + error = null; + return true; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~TrackedArkadeAssetTests"` +Expected: PASS (5 tests). Build of the plugin will currently fail elsewhere (callers of the removed `AssetAcceptance`) — that's fixed in Tasks 2/4/5; do not commit yet. + +- [ ] **Step 5: Commit after Task 2 (the resolver) compiles** — the model + resolver are one coherent compile unit. + +--- + +## Task 2: AssetRateResolver — evaluate free-form rate script + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs` +- Modify: `NArk.E2E.Tests/AssetRateResolverTests.cs` + +- [ ] **Step 1: Read the current resolver + its tests** + +Read `Services/AssetRateResolver.cs` and `NArk.E2E.Tests/AssetRateResolverTests.cs` fully so the new tests mirror existing setup (how `StoreData`, `RateFetcher`, `DefaultRulesCollection` are constructed/mocked). + +- [ ] **Step 2: Write failing test for script-driven resolution** + +Replace the body of `AssetRateResolverTests.cs` tests to pass a `TrackedArkadeAsset` (with a `RateScript`) instead of `ArkadeAssetAcceptance`. Add a self-contained case (no external rate) — script pegs the asset directly to BTC: + +```csharp +[Test] +public async Task SatsPeg_script_resolves_units_without_external_rate() +{ + // 1 unit = 1000 sats ⇒ MYA_BTC = 100000 (units per BTC, since 1 BTC = 1e8 sats / 1000) + var asset = new TrackedArkadeAsset("id", "MYA", "MYA", "My Asset", + Decimals: 0, RateScript: "MYA_BTC = 100000;", Enabled: true); + var store = BuildStore(); // existing test helper + var resolver = new AssetRateResolver(BuildRateFetcher(), DefaultRules()); + + var due = await resolver.ResolveAsync(store, asset, dueSats: 50_000, CancellationToken.None); + + // 50_000 sats = 0.0005 BTC; 0.0005 * 100000 = 50 units + Assert.That(due.DisplayUnits, Is.EqualTo(50m)); + Assert.That(due.BaseUnits, Is.EqualTo(50UL)); +} +``` + +(Keep/adjust existing money-math cases — round-up, min-one-base-unit — passing a `TrackedArkadeAsset`. Decimals now come from `asset.Decimals`.) + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~AssetRateResolverTests"` +Expected: FAIL — `ResolveAsync` signature still takes `ArkadeAssetAcceptance`. + +- [ ] **Step 4: Rewrite `ResolveAsync` to compile + evaluate the script** + +Replace the `switch` over `AssetRateMode` with rate-script evaluation. New signature drops `acceptance`/`assetDecimals`, takes the asset: + +```csharp +using BTCPayServer.Rating; // RateRules, CurrencyPair + +public async Task ResolveAsync( + StoreData store, TrackedArkadeAsset asset, long dueSats, CancellationToken cancellationToken) +{ + if (!asset.IsValid(out var configError)) + throw new InvalidOperationException($"Invalid tracked asset: {configError}"); + if (dueSats <= 0) + throw new InvalidOperationException("Invoice amount due must be positive to price an asset."); + + if (!RateRules.TryParse(asset.RateScript, out var assetRules, out var parseErrors)) + throw new InvalidOperationException( + $"Invalid rate script for {asset.CurrencyCode}: {string.Join("; ", parseErrors)}"); + + // Combine the asset's rule with the store's existing rules so chained legs + // (e.g. MYA_USD plus the store's BTC_USD) resolve. Store RateScript is NOT mutated. + var storeRules = store.GetStoreBlob().GetRateRules(defaultRules); + var combined = RateRules.Combine([assetRules, storeRules]); + + var pair = new CurrencyPair("BTC", asset.CurrencyCode); // units of asset per 1 BTC + var rule = combined.GetRuleFor(pair); + var rate = await rateFetcher.FetchRate(rule, new StoreIdRateContext(store.Id), cancellationToken); + if (rate.BidAsk is null || rate.Errors is { Count: > 0 }) + throw new InvalidOperationException( + $"Unable to evaluate rate for {pair}" + + (rate.Errors is { Count: > 0 } ? $" ({string.Join(", ", rate.Errors)})" : "")); + + var dueBtc = dueSats / 100_000_000m; + var unitsPerBtc = rate.BidAsk.Center; + var displayUnits = dueBtc * unitsPerBtc; + var rateDescription = $"{pair} = {unitsPerBtc}; {dueBtc} BTC = {displayUnits} {asset.CurrencyCode}"; + + // base-unit math (unchanged): round UP, clamp to >= 1 base unit + var scale = AssetAmount.Pow10(asset.Decimals); + var baseUnitsExact = Math.Max(1m, Math.Ceiling(displayUnits * scale)); + var baseUnits = (ulong)baseUnitsExact; + return new AssetAmountDue(baseUnits, baseUnitsExact / scale, + AssetAmount.Format(baseUnits, asset.Decimals), rateDescription); +} +``` + +Update the `AssetRateResolver` class doc comment to describe the script model (drop the `AssetRateMode` prose). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~AssetRateResolverTests"` +Expected: PASS. + +- [ ] **Step 6: Remove the asset block from the existing Arkade (BTC-VTXO) handler** + +In `PaymentHandler/ArkadePaymentMethodHandler.cs`, **delete** the `if (arkadePaymentMethodConfig.AssetAcceptance is { } acceptance) { … }` block (≈ lines 116–156, ending before `context.Prompt.Details = JObject.FromObject(details, Serializer);`) and any now-unused `AssetId/AssetName/AssetTicker/AssetDecimals/AssetBaseUnitsDue/AssetFormattedAmountDue` writes on the BTC prompt details. Asset pricing now lives entirely on the new `ARKADE-ASSET` method (Task 10). The BTC-VTXO Arkade prompt becomes asset-free again. `AssetRateResolver` is now called only by the new handler (Task 10), so it compiles but is unreferenced until then — that's expected. + +- [ ] **Step 7: Build (resolver + model compile; old asset callers removed)** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors (the StoreOverview view/controller asset-acceptance references are removed in Tasks 5–6; if building before those, temporarily comment them — but prefer doing Tasks 1–6 before this build). + +- [ ] **Step 8: Commit Tasks 1+2** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs \ + BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs \ + BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs \ + NArk.E2E.Tests/TrackedArkadeAssetTests.cs NArk.E2E.Tests/AssetRateResolverTests.cs +git commit -m "feat(assets): tracked-asset model + free-form rate-script resolver" +``` + +--- + +## Task 3: Currency registration + +**Files:** +- Create: `BTCPayServer.Plugins.ArkPayServer/Services/ArkadeAssetCurrencyDataProvider.cs` +- Create: `BTCPayServer.Plugins.ArkPayServer/Services/AssetCurrencyRegistrar.cs` +- Modify: `BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs` + +- [ ] **Step 1: Implement the CurrencyDataProvider** + +`ArkadeAssetCurrencyDataProvider.cs` — reads every store's Arkade config (via `store.GetPaymentMethodConfig(...)`, the same accessor `ArkController` uses) and exposes each tracked asset's code as a `CurrencyData`: + +```csharp +using BTCPayServer.Payments; +using BTCPayServer.Plugins.ArkPayServer.PaymentHandler; +using BTCPayServer.Rating; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; + +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +public class ArkadeAssetCurrencyDataProvider( + StoreRepository stores, + PaymentMethodHandlerDictionary handlers) : CurrencyDataProvider +{ + public async Task LoadCurrencyData(CancellationToken cancellationToken) + { + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var store in await stores.GetStores()) + { + var cfg = store.GetPaymentMethodConfig( + ArkadePlugin.ArkadePaymentMethodId, handlers); + if (cfg is null) continue; + foreach (var a in cfg.Assets) + { + if (string.IsNullOrWhiteSpace(a.CurrencyCode)) continue; + seen.TryAdd(a.CurrencyCode, new CurrencyData // first-wins on cross-store collision + { + Code = a.CurrencyCode, + Name = a.Name ?? a.Ticker ?? a.CurrencyCode, + Divisibility = a.Decimals, + Symbol = a.Ticker ?? a.CurrencyCode, + Crypto = true, + }); + } + } + return seen.Values.ToArray(); + } +} +``` + +- [ ] **Step 2: Implement the reload helper** + +`AssetCurrencyRegistrar.cs`: + +```csharp +using BTCPayServer.Rating; +namespace BTCPayServer.Plugins.ArkPayServer.Services; + +/// Refreshes BTCPay's currency table after a tracked-asset CRUD op so a +/// newly-added asset code is recognised without a process restart. +public class AssetCurrencyRegistrar(CurrencyNameTable currencies) +{ + public Task RefreshAsync(CancellationToken ct = default) => currencies.ReloadCurrencyData(ct); +} +``` + +- [ ] **Step 3: Register in DI** + +In `ArkPlugin.cs` `RegisterPluginServices` (near the other `AddSingleton`s): + +```csharp +services.AddSingleton(); +services.AddSingleton(); +``` + +- [ ] **Step 4: Build** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/Services/ArkadeAssetCurrencyDataProvider.cs \ + BTCPayServer.Plugins.ArkPayServer/Services/AssetCurrencyRegistrar.cs \ + BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs +git commit -m "feat(assets): register tracked assets as BTCPay currencies" +``` + +--- + +## Task 4: Metadata fetch endpoint (add-by-id prefill) + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs` +- Create: `BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs` (DTO) + +- [ ] **Step 1: Define the fetch-result DTO** + +In `Models/TrackedAssetViewModels.cs`: + +```csharp +namespace BTCPayServer.Plugins.ArkPayServer.Models; + +public class AssetMetadataResult +{ + public bool Found { get; set; } + public string AssetId { get; set; } = ""; + public string? Ticker { get; set; } + public string? Name { get; set; } + public int Decimals { get; set; } +} +``` + +- [ ] **Step 2: Add the controller endpoint** + +In `ArkController.cs` (inject `AssetMetadataService` if not already), add an authorized JSON action: + +```csharp +[HttpGet("stores/{storeId}/ark/asset-metadata")] +[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] +public async Task FetchAssetMetadata(string storeId, string assetId, CancellationToken ct) +{ + assetId = assetId?.Trim() ?? ""; + if (string.IsNullOrEmpty(assetId)) return Json(new AssetMetadataResult { Found = false }); + var details = await assetMetadataService.GetAssetDetailsAsync(assetId, ct); + if (details is null) return Json(new AssetMetadataResult { Found = false, AssetId = assetId }); + return Json(new AssetMetadataResult + { + Found = true, + AssetId = assetId, + Ticker = assetMetadataService.GetTicker(details), + Name = assetMetadataService.GetName(details), + Decimals = assetMetadataService.GetDecimals(details), + }); +} +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs \ + BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs +git commit -m "feat(assets): asset-metadata fetch endpoint for add-by-id prefill" +``` + +--- + +## Task 5: CRUD command handlers + view models + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs` +- Modify: `BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs` +- Modify: `BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs` + +- [ ] **Step 1: Add view models** + +Append to `Models/TrackedAssetViewModels.cs`: + +```csharp +public class TrackedAssetRow +{ + public string AssetId { get; set; } = ""; + public string CurrencyCode { get; set; } = ""; + public string? Ticker { get; set; } + public string? Name { get; set; } + public int Decimals { get; set; } + public string RateScript { get; set; } = ""; + public bool Enabled { get; set; } +} +``` + +Add to `StoreOverviewViewModel`: `public List TrackedAssets { get; set; } = [];` (replacing the `AssetAcceptance*` scalar fields), and populate it in the GET action from `config.Assets`. + +- [ ] **Step 2: Replace `save-asset-acceptance` with CRUD commands** + +In the `StoreOverview` POST handler, replace the `command == "save-asset-acceptance"` branch with `add-asset` / `edit-asset` / `remove-asset` (the model carries one `TrackedAssetRow` for add/edit and an `assetId` for remove). Each: load config, mutate `TrackedAssets`, validate via `TrackedArkadeAsset.IsValid`, persist, then `await assetCurrencyRegistrar.RefreshAsync()`. Example add: + +```csharp +if (command == "add-asset") +{ + var row = model.AssetForm; // bound TrackedAssetRow + var asset = new TrackedArkadeAsset(row.AssetId.Trim(), row.CurrencyCode.Trim().ToUpperInvariant(), + row.Ticker, row.Name, row.Decimals, row.RateScript.Trim(), row.Enabled); + if (!asset.IsValid(out var err)) + return RedirectWithError(nameof(StoreOverview), err!, new { storeId }); + var list = config.Assets.ToList(); + if (list.Any(a => a.CurrencyCode.Equals(asset.CurrencyCode, StringComparison.OrdinalIgnoreCase))) + return RedirectWithError(nameof(StoreOverview), $"Currency code {asset.CurrencyCode} already tracked.", new { storeId }); + // Verify the asset exists on the indexer (parity with the old save-asset-acceptance check). + if (await assetMetadataService.GetAssetDetailsAsync(asset.AssetId, HttpContext.RequestAborted) is null) + return RedirectWithError(nameof(StoreOverview), + $"Asset '{asset.AssetId}' not found on the Arkade indexer.", new { storeId }); + list.Add(asset); + var newConfig = config! with { TrackedAssets = list }; + store!.SetPaymentMethodConfig(paymentMethodHandlerDictionary[ArkadePlugin.ArkadePaymentMethodId], newConfig); + await storeRepository.UpdateStore(store); + await assetCurrencyRegistrar.RefreshAsync(HttpContext.RequestAborted); + return RedirectWithSuccess(nameof(StoreOverview), $"Asset {asset.CurrencyCode} added.", new { storeId }); +} +``` + +(`edit-asset`: replace the matching `AssetId` in the list; `remove-asset`: filter it out. Both persist via the same `SetPaymentMethodConfig` + `storeRepository.UpdateStore(store)` pair shown above — identical to the old `save-asset-acceptance` path — then `await assetCurrencyRegistrar.RefreshAsync(...)`.) + +- [ ] **Step 3: Build** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/Models/ BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs +git commit -m "feat(assets): tracked-asset CRUD command handlers + view models" +``` + +--- + +## Task 6: CRUD UI (StoreOverview) + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml` + +- [ ] **Step 1: Replace the single-asset modal with a tracked-assets list + add/edit modal** + +Replace the `Asset Payments` row (around line 152) and `#assetAcceptanceModal` (around line 496) with: +- A **table** of `Model.TrackedAssets` (Code · Ticker/Name · Decimals · Enabled · rate-script preview · Edit/Remove buttons), each row posting `remove-asset` / opening the edit modal prefilled. +- An **add/edit modal** with: Asset ID input + a **Fetch** button (JS `fetch('stores/{storeId}/ark/asset-metadata?assetId=...')` → fills Ticker/Name/Decimals/suggested Code), Currency Code, Decimals, Rate Script textarea, Enabled checkbox; posts `add-asset`/`edit-asset`. + +Mirror the existing modal markup/anti-forgery/`asp-for` conventions in this same file (the old `#assetAcceptanceModal`, lines ~496–540) for styling and form wiring. + +- [ ] **Step 2: Build + visual check** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors. (Manual UI verification deferred to the run-through at Task 9.) + +- [ ] **Step 3: Commit** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml +git commit -m "feat(assets): tracked-asset CRUD UI with add-by-id fetch" +``` + +--- + +## Task 7: VTXO asset badge + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/Views/Ark/Vtxos.cshtml` +- Modify: `BTCPayServer.Plugins.ArkPayServer/Views/Shared/_VtxoTable.cshtml` + +- [ ] **Step 1: Render asset holdings under the Amount cell** + +In both views, where the Amount cell renders (`Vtxos.cshtml` line ~186; `_VtxoTable.cshtml` line ~105), append for asset-carrying VTXOs: + +```razor +@if (vtxo.Assets is { Count: > 0 }) +{ + foreach (var asset in vtxo.Assets) + { + var d = await AssetMetadata.GetAssetDetailsAsync(asset.AssetId); + var label = AssetMetadata.GetTicker(d) ?? (asset.AssetId.Length > 10 ? asset.AssetId[..10] + "…" : asset.AssetId); + + @AssetMetadata.FormatAmount(asset.Amount, d) @label + + } +} +``` + +Inject `@inject BTCPayServer.Plugins.ArkPayServer.Services.AssetMetadataService AssetMetadata` at the top of each view. (`_VtxoTable.cshtml` already does async indexer calls in `@{ }`, so `await` in the view is fine; match its existing pattern.) + +- [ ] **Step 2: Build** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/Views/Ark/Vtxos.cshtml BTCPayServer.Plugins.ArkPayServer/Views/Shared/_VtxoTable.cshtml +git commit -m "feat(assets): show asset holdings on asset-carrying VTXOs" +``` + +--- + +## Task 8: Compact balance assets + +**Files:** +- Modify: `BTCPayServer.Plugins.ArkPayServer/Views/Shared/_ArkBalances.cshtml` + +- [ ] **Step 1: Add an asset section to the compact branch** + +In the `if (compactMode)` block (lines ~17–53), after the BTC balance items, mirror the full-mode asset section (lines ~161–183) in compact form: + +```razor +@if (Model.AssetBalances.Count > 0) +{ + foreach (var asset in Model.AssetBalances) + { +
+ @(asset.Ticker ?? asset.Name ?? "Asset"): + + @asset.FormattedAmount@(string.IsNullOrEmpty(asset.Ticker) ? "" : " " + asset.Ticker) + +
+ } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add BTCPayServer.Plugins.ArkPayServer/Views/Shared/_ArkBalances.cshtml +git commit -m "feat(assets): show asset balances in compact balance (dashboard)" +``` + +--- + +## Task 9: Part 1 checkpoint (foundation + display) + +- [ ] **Step 1:** `dotnet build BTCPayServer.Plugins.ArkPayServer/BTCPayServer.Plugins.ArkPayServer.csproj -clp:ErrorsOnly` → 0 errors. +- [ ] **Step 2:** `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~AssetRateResolver|FullyQualifiedName~AssetAmount|FullyQualifiedName~TrackedArkadeAsset"` → PASS. +- [ ] **Step 3:** Confirm Tasks 1–8 are committed (foundation + currency + CRUD + display). Part 1 is now self-contained. + +--- + +# Part 2 — Arkade Asset payment method + +> Mirror the existing **`ARKADE`** method's files for all BTCPay scaffolding (registration, handler `ConfigurePrompt`, link extension, checkout component) — they are the concrete template. Each task names the exact file to mirror. Spikes (explicitly marked) are bounded "read the ARKADE equivalent + confirm" steps for the integration points the spec flagged as risks. + +## Task 10: `ARKADE-ASSET` payment method scaffolding + +**Files:** +- Create: `PaymentHandler/ArkadeAssetPromptDetails.cs` +- Create: `PaymentHandler/ArkadeAssetPaymentMethodHandler.cs` +- Modify: `ArkPlugin.cs` + +- [ ] **Step 1: Prompt details type** + +`ArkadeAssetPromptDetails.cs`: + +```csharp +namespace BTCPayServer.Plugins.ArkPayServer.PaymentHandler; + +public record ArkadeAssetOption( + string AssetId, string CurrencyCode, string? Ticker, int Decimals, + ulong BaseUnitsDue, string FormattedDue, string Bip321Uri); + +public class ArkadeAssetPromptDetails +{ + public string ArkAddress { get; set; } = ""; + public List Options { get; set; } = []; +} +``` + +- [ ] **Step 2: Register the method (mirror `ArkPlugin.cs` lines 40–98 / 274–275)** + +```csharp +internal const string AssetCheckoutBodyComponentName = "arkadeAssetCheckoutBody"; +internal static readonly PaymentMethodId ArkadeAssetPaymentMethodId = new("ARKADE-ASSET"); +// in RegisterPluginServices, beside the ARKADE registrations: +services.AddSingleton(); +services.AddSingleton(sp => sp.GetRequiredService()); +services.AddSingleton(); // Task 12 +services.AddSingleton(sp => sp.GetRequiredService()); +services.AddDefaultPrettyName(ArkadeAssetPaymentMethodId, "Arkade Asset"); +services.AddSingleton(); // Task 12 +services.AddSingleton(sp => sp.GetRequiredService()); +``` + +- [ ] **Step 3: Handler — read `PaymentHandler/ArkadePaymentMethodHandler.cs` fully, then mirror its `ConfigurePrompt`** + +The asset handler's `ConfigurePrompt` reuses the **same Ark receive address derivation** the BTC handler uses (Spike: copy that exact address path — do not reinvent it), then: + +```csharp +var cfg = context.StorePaymentMethodConfig(); // mirror how ARKADE reads its config +var dueSats = Money.Coins(context.Prompt.Calculate().Due).Satoshi; +var options = new List(); +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); +foreach (var a in cfg.Assets.Where(a => a.Enabled)) +{ + AssetAmountDue due; + try { due = await assetRateResolver.ResolveAsync(context.Store, a, dueSats, cts.Token); } + catch (Exception ex) when (ex is InvalidOperationException or OperationCanceledException) + { context.Logs.Write($"Asset {a.CurrencyCode} unavailable: {ex.Message}", InvoiceEventData.EventSeverity.Warning); continue; } + var uri = ArkadeBip21Builder.Create().WithArkAddress(arkAddress) + .WithAsset(a.AssetId, due.BaseUnits).Build(); // Task 11 + options.Add(new ArkadeAssetOption(a.AssetId, a.CurrencyCode, a.Ticker, a.Decimals, + due.BaseUnits, due.FormattedAmount, uri)); +} +if (options.Count == 0) + throw new PaymentMethodUnavailableException("No Arkade asset is available for this invoice."); +context.Prompt.Destination = arkAddress; +context.Prompt.Details = JObject.FromObject(new ArkadeAssetPromptDetails { ArkAddress = arkAddress, Options = options }, Serializer); +``` + +(`ParsePaymentPromptDetails`/`ParsePaymentMethodConfig`/`GetPaymentLinkExtension` etc.: mirror the ARKADE handler's interface members verbatim, swapping the details type.) + +- [ ] **Step 4:** Build → 0 errors. Commit `feat(assets): ARKADE-ASSET payment method + prompt`. + +## Task 11: BIP-321 asset URI + +**Files:** Modify the Arkade BIP-21 builder (`Services/ArkadeBip21Builder.cs` — confirm path); Test: `NArk.E2E.Tests/ArkadeBip21AssetTests.cs` (create). + +- [ ] **Step 1: Failing test** + +```csharp +[Test] +public void WithAsset_appends_asset_id_and_amount() +{ + var uri = ArkadeBip21Builder.Create().WithArkAddress("ark1q...").WithAsset("deadbeef", 150).Build(); + Assert.That(uri, Does.Contain("asset=deadbeef")); // key confirmed in Step 3 +} +``` + +- [ ] **Step 2:** Run → FAIL (no `WithAsset`). +- [ ] **Step 3: Implement `WithAsset(string assetId, ulong baseUnitsDue)`** on the builder, appending the asset id (+ amount) to the URI query. **Spike:** confirm the exact param key against the Arkade ts-sdk / wallet convention — `grep -ri "asset" submodules/NNark` and the ts-sdk fixtures under `NArk.Tests/Assets/Fixtures`; if no canonical key exists, use `asset` and note it in the test. Wire `baseUnitsDue` into the existing amount param the builder already emits. +- [ ] **Step 4:** Run → PASS. Commit. + +## Task 12: Multi-asset checkout (payer picks) + +**Files:** +- Create: `PaymentHandler/ArkadeAssetPaymentLinkExtension.cs` (mirror `ArkadePaymentLinkExtension.cs`) +- Create: `PaymentHandler/ArkadeAssetCheckoutModelExtension.cs` (mirror `ArkadeCheckoutModelExtension.cs`) +- Create: checkout component view for `arkadeAssetCheckoutBody` (mirror `Views/Shared/Arkade/ArkadeMethodCheckout.cshtml` + its view-component registration) + +- [ ] **Step 1: Link extension** — `GetPaymentLink` returns the first option's `Bip321Uri` from the parsed `ArkadeAssetPromptDetails`. Mirror `ArkadePaymentLinkExtension` structure. +- [ ] **Step 2: Checkout model extension** — set `context.Model.CheckoutBodyComponentName = ArkadePlugin.AssetCheckoutBodyComponentName` and expose `Options` to the component. **Spike:** read `ArkadeCheckoutModelExtension.ModifyCheckoutModel` + the `arkadeCheckoutBody` component to confirm how the body component receives its data (`context.Model.AdditionalData[...]` vs a typed model) and mirror that exactly. +- [ ] **Step 3: Checkout component** — render a selectable list of options; each option shows ticker/name + `FormattedDue` + a `` and a copy field. Mirror the markup/conventions in `ArkadeMethodCheckout.cshtml`. +- [ ] **Step 4:** Build → 0 errors. Commit `feat(assets): multi-asset Arkade Asset checkout`. + +## Task 13: Settlement wiring + +**Files:** Modify `Services/ArkContractInvoiceListener.cs`. + +- [ ] **Step 1:** The listener already credits asset arrivals proportionally (`vtxo.Assets`). Extend `OnVtxoChanged` so that when the matched invoice has an `ARKADE-ASSET` prompt whose `Options` contains an arriving asset id, it registers/settles the **`ARKADE-ASSET`** payment (reuse `HandlePaymentData` with the BTC-equivalent amount override, `isBoarding: false`). Match strictly by asset id against `ArkadeAssetPromptDetails.Options[].AssetId`; ignore assets not offered by the invoice. Leave the BTC-VTXO `ARKADE` settlement path untouched. +- [ ] **Step 2:** Build → 0 errors. Commit `feat(assets): settle Arkade Asset payments on matching asset arrival`. + +## Task 14: Final verification + PR + +- [ ] **Step 1:** Full build → 0 errors. +- [ ] **Step 2:** `dotnet test NArk.E2E.Tests --filter "FullyQualifiedName~AssetRateResolver|FullyQualifiedName~AssetAmount|FullyQualifiedName~TrackedArkadeAsset|FullyQualifiedName~ArkadeBip21Asset"` → PASS. +- [ ] **Step 3:** Extend `NArk.E2E.Tests/AssetAcceptanceTests.cs`: add-by-id → list → edit → remove; and an asset-checkout + settlement happy path (create invoice → `ARKADE-ASSET` offers the asset → pay the asset → invoice settles). +- [ ] **Step 4: Manual checklist (record in PR):** add asset by id (prefill + currency registered); the Arkade Asset checkout shows one option per enabled asset with per-asset QR/amount; paying the chosen asset settles the invoice; VTXO table shows the asset badge; dashboard (compact) shows the asset balance. +- [ ] **Step 5:** Update CHANGELOG; push; refresh PR #55 description; iterate CI to green. + +--- + +## Notes / risks (from spec) + +- **Rate-rule compile/merge:** uses `RateRules.TryParse` + `RateRules.Combine([assetRules, storeRules])` + `GetRuleFor` + `RateFetcher.FetchRate` — verified APIs; no `StoreBlob.RateScript` mutation. +- **Currency reload:** `CurrencyNameTable.ReloadCurrencyData` after CRUD; the format-provider cache repopulates lazily, so late-added codes fall back to default formatting (acceptable). +- **Code collisions:** unique within a store (validated); cross-store first-wins in the provider (logged), acceptable because rate eval uses the store's own asset record. diff --git a/docs/superpowers/specs/2026-05-18-arkade-assets-payment-method-design.md b/docs/superpowers/specs/2026-05-18-arkade-assets-payment-method-design.md new file mode 100644 index 00000000..e84eb930 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-arkade-assets-payment-method-design.md @@ -0,0 +1,173 @@ +# Arkade Assets as a BTCPay payment method — design, parity & decisions + +**Date:** 2026-05-18 +**Mode:** autonomous overnight session (no user blocking) +**Builds on:** PR #25 `quick-beacon` (`docs/plans/2026-02-15-arkade-assets-receive-send.md`) +**Status:** implemented — see "Implemented" section at end + +## Implemented (plugin PR #55, NNark PR #94) + +Branch `feat/arkade-assets-payment` off current `master`; supersedes #25 +(closed). NNark submodule pinned at `bbcd960` — the squash-merge of +PR #94 on NNark `master` (the `assets-parity` branch / its pre-squash +commits `8c0fe77`+`5509d8a` were orphaned by the squash and deleted; +pinning the durable master commit keeps fresh CI clones fetchable). + +| Slice | Commit(s) | What | +|---|---|---| +| NNark GAP B+C | NNark `bbcd960` (squash of PR #94, merged to master) | deterministic group ordering + ts-sdk fixtures; 393/393; README determinism note | +| Balances | `54e3858` | per-asset spendable balances on dashboard (`AssetMetadataService`) | +| Config | `37cd63a` | `ArkadeAssetAcceptance` (additive, serialization-safe) + `IsValid` | +| Rate resolver | `66bcd2b`, `71a2f5a` | `AssetRateResolver` (SatsPerUnit self-contained; FixedReferenceCurrency via store `RateFetcher`); round-up never-underpay; 11 unit tests; fixed a "100"→"1" format bug | +| Settings UI | `ca31f1e` | overview row + modal; save/disable commands; indexer existence check | +| Checkout | `b1370fc` | prompt resolves asset due; "Pay {amount} {ticker}" notice | +| Settlement | `1894e4f` | listener settles asset invoice on asset arrival (BTC credited ∝ asset received, capped 100%); stray BTC VTXO ignored | +| E2E | `82a4f42` | config modal + server-validation round-trip tests | + +**Not built (decided):** GAP A (mint-new-control-asset-same-tx) — ts-sdk +canonical is id-only; rust-only convenience, arkd-unverifiable. Full +pay-with-asset settlement e2e (needs issued-asset funding infra) — +money math is unit-tested instead. + +--- +_original research notes below_ + +## Goal (from user) + +1. Audit Arkade asset support across go-sdk / rust-sdk / ts-sdk; bring NNark (.NET SDK) to **full feature + test parity**. +2. Update the BTCPay plugin assets PR (#25, `quick-beacon`) and bring git up to date. +3. Let merchants **configure a store to accept an Arkade asset as payment**. +4. Let merchants **specify a rate** for that asset (integrate with BTCPay's rate system). +5. Full UX: store config, checkout, UI — everything. Tests at parity with the SDKs. + +## Ground truth — NNark current asset surface (verified locally) + +- **Operations:** `IAssetManager` = `IssueAsync`, `ReissueAsync`, `BurnAsync` (`AssetManager.cs`). Transfer/send is via `SpendingService` + `AssetRequirement` coin selection (not on `IAssetManager`). Receive = VTXO sync maps `VtxoAsset`. +- **Controlled issuance/reissuance:** supported via `AssetRef.FromId` (arkd verifies control asset exists). +- **Serialization stack:** `AssetId`, `AssetRef`, `AssetGroup`, `AssetInput`, `AssetOutput`, `AssetMetadata`, `MetadataList`, `Packet`, `AssetPacketBuilder`, `BufferReader/Writer`, `Extension` (`NArk.Core/Assets/`). +- **Transport:** `GrpcClientTransport.Assets.cs` / `RestClientTransport.Assets.cs` → `GetAssetDetailsAsync` (indexer `GetAsset`). `ArkAssetDetails` model. +- **Unit tests (~129 methods, NArk.Tests/Assets/):** AssetGroup 14, AssetId 11, AssetInputOutput 24, AssetRef 10, Buffer 10, Extension 9, Fixture 4, Metadata 16, Packet 16; +AssetPacketBuilder 7, +MergeAssets 8. Shared JSON fixtures: `asset_group_fixtures.json`, `asset_id_fixtures.json`, `extension_fixtures.json`, `packet_fixtures.json`. +- **E2E tests (NArk.Tests.End2End/AssetTests.cs, 7 ordered):** CanIssueAsset, CanTransferAssetBetweenWallets, CanBurnAsset, AssetsSurviveBatchSettlement, CanIssueAssetWithControlAsset, CanReissueAssetWithControlAsset, CanIssueAssetWithMetadata. +- **NNark submodule:** local `master` @ `a89d47a`, **behind** origin/master (upstream has more, incl. swaps fixes). Parity work must target current upstream → sync required. + +## SDK parity matrix (findings) + +**Format parity: PROVEN.** `dotnet test NArk.Tests --filter Assets` → **131/131 +pass**. NNark's `FixtureTests`/`ExtensionTests` are driven by JSON vectors +sourced from `arkade-os/ts-sdk/test/fixtures/`; identical hex for every +shared vector = byte-level parity with ts-sdk/go-sdk (all SDKs implement one +wire spec). + +**rust-sdk (`arkade-os/rust-sdk`, ark-core/ark-client) vs NNark:** + +| Capability | rust-sdk | NNark | Verdict | +|---|---|---|---| +| Magic/packet-type/presence bits | ✓ ARK/0x00/0x01-04 | ✓ identical | parity | +| Fresh issuance | ✓ | ✓ `IssueAsync` | parity | +| Controlled issuance — existing ctrl by id | ✓ `ControlAssetConfig::existing` | ✓ `IssuanceParams.ControlAssetId` | parity | +| Controlled issuance — **mint NEW ctrl same-tx** | ✓ `ControlAssetConfig::New{amount}` | serialization supports `AssetRef.FromGroupIndex`, but **not exposed on `IAssetManager.IssueAsync`** | **GAP A** | +| Reissuance | ✓ | ✓ `ReissueAsync` | parity | +| Burn | ✓ | ✓ `BurnAsync` | parity | +| Transfer/send | ✓ | ✓ SpendingService + `ArkTxOut.Assets` | parity | +| Asset coin selection + change | ✓ | ✓ `AssetRequirement` | parity | +| Batch settlement preservation | ✓ | ✓ (e2e `AssetsSurviveBatchSettlement`) | parity | +| Deterministic group ordering in send packet | ✓ sorts by (txid,groupIndex) | **verify** | **GAP B?** | +| Indexer GetAsset gRPC | ✓ | ✓ | parity | +| Indexer GetAsset **REST** | ✗ absent | ✓ `RestClientTransport.Assets` | NNark ahead | +| Metadata wire decode (hex binary) | ✗ raw string | ✓ `MetadataList.FromString` | NNark ahead | +| Shared JSON fixture vectors | ✗ inline only | ✓ ts-sdk fixtures | NNark ahead | +| BIP-341 taptree over groups | ✗ not found | ✓ PR #16 | NNark ahead | +| `LeafTxPacket` intent conversion | ✗ not found | ✓ | NNark ahead | + +**ts-sdk:** agent still running (canonical fixture source; refs ts-sdk#279, +arkd#814). NNark already passes its fixtures → format parity holds; await +agent for any higher-level API/test scenarios to mirror. + +**ts-sdk (canonical, `@arkade-os/sdk` v0.4.27, fixture source) vs NNark — final verdict:** + +- Format/ops parity: **proven** (131/131; NNark passes ts-sdk shared vectors). +- ts-sdk `AssetManager.issue()` is **also id-only** (no same-tx new-control) + → **GAP A DROPPED**: NNark matches the canonical SDK; rust's + `ControlAssetConfig::New` is a rust-only convenience, not the cross-SDK + contract. Adding an arkd-unverifiable public API "for parity" would + violate it. Documented, intentionally not implemented. +- ts-sdk has **zero asset e2e tests**; NNark has **7** → NNark far ahead. +- **GAP B (real, fix):** NNark `AssetPacketBuilder.Build` orders groups via + `HashSet` → non-deterministic. rust-sdk sorts by + (txid,groupIndex). Deterministic packets are correct regardless + (reproducibility, fixture stability). → sort groups by AssetId + test. +- **GAP C (real, fix — directly answers "same level of tests"):** ts-sdk + ships 4 cross-SDK fixture files NNark's dir lacks: + `asset_ref_fixtures.json`, `asset_input_fixtures.json`, + `asset_output_fixtures.json`, `metadata_fixtures.json`. NNark has the + types + hand-written tests but not the shared vectors. → import the 4 + files, add fixture-driven tests consuming them (mirrors ts-sdk + `test/asset.test.ts`), align error strings to fixture-expected wording. + +**Net:** NNark meets/exceeds canonical (ts-sdk) asset parity. Bounded +actions: GAP B + GAP C (both unit-testable, no infra). Then plugin. + +## BTCPay plugin — PR #25 current state + +`quick-beacon` (4 commits, ~1981 ins): asset UI (balance display, VTXO +badges, send wizard, metadata cache), `AssetsJson` VTXO column + migration, +`AssetMetadataService`, design+plan docs. **Scope was receive+send+display +only.** It does NOT cover accept-as-payment, rates, or checkout — that is +the new scope this session adds. + +Stale ~3 months; base `master` moved a lot (deadlock fix, e2e harness, +etc.). Needs rebase onto current master. + +## Decision: asset-as-payment-method + rate model + +**Problem:** Arkade assets are arbitrary tokens, not on Kraken/Coinbase, so +BTCPay's exchange rate providers can't price them. But BTCPay's rate +*scripting* supports constant & derived expressions and composing with +fiat (`FOO_BTC = FOO_USD * USD_BTC`). + +**Decision (made autonomously; rationale recorded):** add per-asset rate +config to the Arkade store settings. Each accepted asset declares its price +as **either**: +- (a) a fixed price in a reference currency (e.g. `1 unit = 0.01 USD`) — + plugin converts invoice amount → asset units using BTCPay's existing + fiat rate pipeline for the reference leg, then the configured asset + price; **or** +- (b) a direct sats-per-unit price (no external rate needed). + +This leverages BTCPay's `RateRules`/currency engine for the fiat leg +(spread, fallback, existing invoice rate flow all keep working) instead of +bypassing it, while keeping the asset-specific price merchant-controlled. +Stablecoin-style assets use (a) with their pegged currency. + +**Why not pure rate-script pseudo-currency:** would require the merchant to +hand-author rate rules and invent currency codes; assets have no exchange +feed so the rule is always a constant anyway — config UI is clearer and +less error-prone for the same result, while still routing the fiat leg +through BTCPay rates. + +## Implementation plan (sequenced, each step independently committable) + +1. **Sync NNark** submodule to upstream master; branch `assets-parity`. +2. **NNark parity**: close feature gaps from the matrix; add tests + mirroring sibling-SDK scenarios; reuse shared fixtures. Unit + e2e. + Keep `dotnet test NArk.Tests` green; gate e2e on infra. +3. **Plugin rebase**: `quick-beacon` onto current `master`; resolve + conflicts; build green. +4. **Accept-as-payment**: store-scoped Arkade-asset payment method config + (which asset id(s), rate model a/b, decimals from metadata). +5. **Rate integration**: invoice creation resolves asset price via the + chosen model; reference-currency leg through BTCPay rates. +6. **Checkout**: asset payment method tab — amount in asset units, address + = asset-carrying Arkade address, QR/BIP21 with asset params, paid + detection via asset-aware VTXO sync. +7. **UI**: store settings page for asset acceptance + rate; checkout + rendering; balance/VTXO badges already in #25. +8. **Tests**: plugin e2e for configure-asset-acceptance, invoice-in-asset, + pay-asset-invoice → settled. Iterate CI green. + +## Constraints honored + +- No stubs/placeholders/TODOs; no skipped tests; branch off master; iterate + CI green; mention commit hashes; no Co-Authored-By; "Arkade"/"batch" + vocabulary in NNark user-facing strings; update NNark README/docs/sample + on public API changes (NNark CLAUDE.md). diff --git a/docs/superpowers/specs/2026-05-22-arkade-asset-management-design.md b/docs/superpowers/specs/2026-05-22-arkade-asset-management-design.md new file mode 100644 index 00000000..8cc5a670 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-arkade-asset-management-design.md @@ -0,0 +1,141 @@ +# Arkade asset management + asset payment method — design + +**Date:** 2026-05-22 +**Branch:** `feat/arkade-assets-payment` (extends PR #55; ships as one PR) +**Status:** approved design, pre-implementation + +## Problem + +The asset-acceptance feature (PR #55, unmerged) lets a store accept **one** Arkade asset, configured by hand (asset id + a fixed `FixedReferenceCurrency`/`SatsPerUnit` enum + price), priced onto the existing **Arkade (BTC-VTXO)** payment prompt. Gaps: + +1. **Adding an asset is manual** — nothing is fetched from the indexer. +2. **Rates are constrained to two hard-coded models**, and the asset isn't a first-class BTCPay currency. +3. **Only one asset per store**, no management. +4. **No real multi-asset checkout** — the single asset is bolted onto the BTC prompt; there's no payer-facing choice and no asset-aware payment URI. + +Plus two display gaps: asset-carrying VTXOs show only their dust sat amount; the **compact** balance panel (dashboard + widget) omits asset balances the full panel shows. + +## Goals + +- A **managed list of tracked assets** (CRUD), each added **by id with indexer prefill** (ticker/name/decimals). +- Each asset carries a **free-form BTCPay rate-rule script**, evaluated via the store's rate engine (never mutating the global rate script). +- Register each tracked asset as a **BTCPay currency** (code/decimals/symbol). +- A **dedicated "Arkade Asset" payment method** with a **multi-asset checkout**: one option per enabled asset, each a **BIP-321 URI carrying the Ark address + asset id + amount due**; the payer picks which asset to pay in. +- Surface asset holdings in the **VTXO table** and **compact balance**. + +## Non-goals + +- Asset issuance/minting UI (SDK already supports issuance). +- Auto-discovering rates externally — assets aren't exchange-listed; rates stay merchant-declared. +- Mutating the store's global `StoreBlob.RateScript`. +- Dynamic per-asset BTCPay `PaymentMethodId`s (BTCPay methods are static registrations) — one fixed `ARKADE-ASSET` method hosts all assets. + +## Decisions (resolved during brainstorming) + +- **Rate mechanism:** register the asset as a currency *and* keep the rate rule plugin-owned — evaluate via `RateFetcher` against the store's rules; don't edit `StoreBlob.RateScript`. +- **Rate input:** **free-form** BTCPay rate-rule script per asset (e.g. `USDARK_USD = 1;`). +- **Multi-asset settlement:** **payer picks at checkout**, surfaced through **one dedicated `ARKADE-ASSET` payment method** (not per-asset methods; not on the BTC prompt). Each enabled asset is a selectable option with its own BIP-321 (Ark address + asset id) URI + amount due. +- **VTXO display:** inline badge under the Amount cell. +- **Scope:** foundation + asset payment method + display, all in one spec/plan/PR. +- **No migration:** asset acceptance was never shipped (releases v2.1.15–v2.1.18 exclude it), so the single `AssetAcceptance` is removed outright. + +## Part 1 — Asset foundation + +### A. Data model + +Replace the single nullable `ArkadeAssetAcceptance` on `ArkadePaymentMethodConfig` with: + +```csharp +public record ArkadePaymentMethodConfig( + ..., IReadOnlyList? TrackedAssets = null) +{ + public IReadOnlyList Assets => TrackedAssets ?? []; +} + +public record TrackedArkadeAsset( + string AssetId, string CurrencyCode, string? Ticker, string? Name, + int Decimals, string RateScript, bool Enabled); // + IsValid(out error) +``` + +- Stored in the existing per-store payment-method config blob — no new table. +- The `AssetRateMode` enum and `ArkadeAssetAcceptance` record are deleted. + +### B. CRUD flow (Arkade store settings) + +A **"Tracked assets"** section (table) with Add / Edit / Remove. **Add:** type asset id → **Fetch** (`AssetMetadataService.GetAssetDetailsAsync`) prefills ticker/name/decimals + suggests a `CurrencyCode` → merchant enters the rate-rule script → save (validate id exists on indexer + code unique within store + script compiles). On every CRUD op, reload the currency table (C). + +### C. Currency registration + +A plugin `ArkadeAssetCurrencyDataProvider : CurrencyDataProvider` exposes the union of all stores' tracked-asset codes as BTCPay currencies (`Code`, `Divisibility = Decimals`, `Symbol = Ticker`, `Crypto = true`); `AssetCurrencyRegistrar` calls `CurrencyNameTable.ReloadCurrencyData()` after CRUD. Cross-store code collisions are first-wins (logged); within-store uniqueness is validated. + +### D. Rate resolution + +`AssetRateResolver` compiles the asset's free-form `RateScript` (`RateRules.TryParse`), `RateRules.Combine([assetRules, storeRules])`, `GetRuleFor(new CurrencyPair("BTC", asset.CurrencyCode))`, and `RateFetcher.FetchRate` → units-per-BTC → asset units due. Settlement math (round-up, min one base unit) unchanged. A missing/uncompilable/unfetchable rate makes that asset simply not be offered (never a hard failure). + +## Part 2 — Arkade Asset payment method + +### E. The `ARKADE-ASSET` payment method + +A second, fixed payment method registered alongside `ARKADE`, mirroring its wiring in `ArkPlugin.cs`: + +- `ArkadePlugin.ArkadeAssetPaymentMethodId = new("ARKADE-ASSET")`; `AddDefaultPrettyName(…, "Arkade Asset")`. +- `ArkadeAssetPaymentMethodHandler : IPaymentMethodHandler` — configures the prompt: the **same Ark receive address** as the BTC Arkade method (assets settle to the same VTXO script), plus per-asset amounts. Prompt details carry the Ark address + a list of `{ AssetId, CurrencyCode, Ticker, Decimals, BaseUnitsDue, FormattedDue, Bip321Uri }` for each **enabled** tracked asset (priced via D). If no enabled asset prices successfully, the method is unavailable (`PaymentMethodUnavailableException`). +- `ArkadeAssetPaymentLinkExtension : IPaymentLinkExtension` — default link = the first/selected asset's BIP-321 URI. +- Checkout component `arkadeAssetCheckoutBody` + `ArkadeAssetCheckoutModelExtension : ICheckoutModelExtension`. +- **Availability:** the method is auto-enabled for a store when the wallet is configured (same lifecycle as `ARKADE`); at prompt time it's shown only if ≥1 enabled asset prices successfully. + +### F. Multi-asset checkout (payer picks) + +The `arkadeAssetCheckoutBody` component renders **one selectable option per enabled asset** (ticker/name + amount due + QR/URI). Selecting an asset shows its BIP-321 URI and the amount in that asset's units. All options settle to the same Ark address; the difference is which asset the payer sends. (No payer state is persisted server-side — settlement is detected by which asset actually arrives; see H.) + +### G. BIP-321 asset URI + +Extend `ArkadeBip21Builder` with `WithAsset(string assetId, ulong baseUnitsDue)` producing the unified URI carrying the **Ark address + `asset=` + amount** (param shape per the Arkade BIP-321/ts-sdk convention — confirm exact key during implementation). BTC/lightning params are omitted for the asset options (an asset option is asset-only). + +### H. Settlement + +The invoice listener already credits **asset arrivals** (`vtxo.Assets`) proportionally to the BTC amount due. Extend it to also register/settle the **`ARKADE-ASSET`** payment when the arriving asset matches one of the invoice's offered assets (match by asset id against the prompt's per-asset list), crediting the BTC-equivalent for accounting. The existing BTC-VTXO Arkade settlement path is untouched. + +## Part 3 — Display + +### I. VTXO table + compact balance + +- **VTXO table** (`Vtxos.cshtml`, `_VtxoTable.cshtml`): inline badge under Amount per `vtxo.Assets` entry, `formattedAmount TICKER` (via `AssetMetadataService`). +- **Compact `_ArkBalances.cshtml`:** mirror the full-mode "Arkade assets" section into the compact branch. + +## Components & boundaries + +| Unit | Responsibility | +|------|----------------| +| `TrackedArkadeAsset`, `ArkadePaymentMethodConfig.TrackedAssets` | Per-asset config + list | +| `AssetMetadataService` (exists) | Indexer fetch + cache (prefill + display) | +| `ArkadeAssetCurrencyDataProvider` / `AssetCurrencyRegistrar` (new) | Currency registration + reload | +| `AssetRateResolver` (generalized) | Compile + evaluate per-asset rate script | +| Arkade store-settings controller/views | Tracked-asset CRUD + fetch endpoint | +| `ArkadeAssetPaymentMethodHandler` + prompt details (new) | `ARKADE-ASSET` prompt: Ark address + per-asset amounts/URIs | +| `ArkadeAssetCheckoutModelExtension` + `arkadeAssetCheckoutBody` (new) | Multi-asset picker checkout | +| `ArkadeAssetPaymentLinkExtension` + `ArkadeBip21Builder.WithAsset` (new/extended) | Per-asset BIP-321 URI | +| `ArkContractInvoiceListener` (extended) | Settle `ARKADE-ASSET` on matching asset arrival | +| `Vtxos.cshtml` / `_VtxoTable.cshtml` / `_ArkBalances.cshtml` | Display surfacing | + +## Data flow (asset payment) + +1. Merchant tracks asset(s) (B) → registered as currencies (C). +2. Invoice created → `ARKADE-ASSET` handler prices each enabled asset (D) → prompt holds Ark address + per-asset amounts + BIP-321 URIs (E/G). +3. Checkout shows the asset picker (F); payer sends the chosen asset to the Ark address. +4. Asset VTXO arrives → listener matches asset id → settles the `ARKADE-ASSET` payment (H). + +## Testing + +- **Unit (NArk.E2E.Tests, runnable):** `AssetRateResolver` script compilation + money math; `TrackedArkadeAsset` validation; `ArkadeBip21Builder.WithAsset` URI shape. +- **E2E:** extend `AssetAcceptanceTests` to cover add-by-id/list/edit/remove; an asset-checkout + settlement happy path. +- **Display/UI:** build verification + manual checkout/dashboard/VTXO-table check. + +## Risks / validate during implementation + +1. **Rate-rule compile/merge** — `RateRules.TryParse` + `Combine` + `GetRuleFor` + `RateFetcher` (verified APIs); no `StoreBlob` mutation. +2. **CurrencyNameTable reload** — `ReloadCurrencyData` after CRUD; format-provider cache repopulates lazily (late codes fall back to default formatting; acceptable). +3. **Payment-method availability/enablement** — confirm how `ARKADE-ASSET` is auto-enabled per store (mirror `ARKADE`'s lifecycle) and hidden when no asset prices. +4. **Checkout component data flow** — confirm how `arkadeAssetCheckoutBody` receives the per-asset model (mirror `arkadeCheckoutBody`). +5. **BIP-321 asset param key** — confirm the exact `asset=` convention against the Arkade ts-sdk / wallet expectations. +6. **Cross-store currency-code collisions** — first-wins (logged); revisit if problematic.