Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
68d52c6
docs(assets): payment-method design, parity plan & rate decision
Kukks May 18, 2026
7b4e55a
docs(assets): finalize SDK parity verdict (GAP A dropped, B+C fixed)
Kukks May 18, 2026
76ae787
feat(assets): surface Arkade asset balances on the store dashboard
Kukks May 18, 2026
6efec25
feat(assets): add ArkadeAssetAcceptance config to payment method
Kukks May 18, 2026
70ecb4f
feat(assets): resolve asset amount due via BTCPay rate pipeline
Kukks May 18, 2026
37ddffd
feat(assets): store settings UI to accept an asset as payment
Kukks May 18, 2026
635bbc6
test(assets): AssetRateResolver money-math tests + fix amount formatting
Kukks May 18, 2026
31430cf
feat(assets): show asset amount due on checkout
Kukks May 18, 2026
6d30c9f
feat(assets): settle asset-priced invoices on asset arrival
Kukks May 18, 2026
d57dccc
test(e2e): asset-acceptance config UI + validation round-trip
Kukks May 18, 2026
9122eb6
docs(assets): record implemented state in design doc
Kukks May 18, 2026
a2b5b4a
fix(assets): address Arkana review (P0/P1/P2)
Kukks May 19, 2026
ad1a707
chore(assets): re-pin NNark to merged master (bbcd960, PR #94 squashed)
Kukks May 19, 2026
f151f67
docs(assets): design for asset management + dedicated Arkade Asset pa…
Kukks May 25, 2026
c260f7d
docs(assets): implementation plan (foundation + Arkade Asset payment …
Kukks May 25, 2026
620cd7b
feat(assets): tracked-asset foundation — model, rate-script resolver,…
Kukks May 25, 2026
5d4165a
feat(assets): show asset holdings on VTXOs and in the compact balance
Kukks May 25, 2026
b60de5c
feat(assets): BIP-321 WithAsset(assetId, amountDisplayUnits) for asse…
Kukks May 25, 2026
af29e46
feat(assets): ARKADE-ASSET payment method (handler + thin config + en…
Kukks May 25, 2026
49266dc
feat(assets): multi-asset Arkade Asset checkout (picker + per-asset QR)
Kukks May 25, 2026
ed67322
feat(assets): settle Arkade Asset payments on matching asset arrival
Kukks May 25, 2026
dc93d20
refactor(assets): address review — drop dead deps, monitor both Arkad…
Kukks May 25, 2026
617f2ee
test(assets): rewrite asset E2E for the tracked-asset CRUD UI
Kukks May 25, 2026
4dd0f3a
fix(assets): break DI cycle that hung BTCPay startup (CurrencyDataPro…
Kukks May 25, 2026
00eeff1
fix(assets): break second DI cycle — handler must not inject the hand…
Kukks May 25, 2026
4fbe1cc
Merge remote-tracking branch 'origin/master' into feat/arkade-assets-…
Kukks Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; } =
Expand Down Expand Up @@ -90,13 +93,22 @@ private static void RegisterBtcPayServices(IServiceCollection services)
services.AddSingleton<ArkadePaymentLinkExtension>();
services.AddSingleton<IPaymentLinkExtension>(sp => sp.GetRequiredService<ArkadePaymentLinkExtension>());

// Dedicated Arkade Asset payment method (payer picks an asset at checkout).
services.AddSingleton<ArkadeAssetPaymentMethodHandler>();
services.AddSingleton<IPaymentMethodHandler>(sp => sp.GetRequiredService<ArkadeAssetPaymentMethodHandler>());
services.AddSingleton<ArkadeAssetPaymentLinkExtension>();
services.AddSingleton<IPaymentLinkExtension>(sp => sp.GetRequiredService<ArkadeAssetPaymentLinkExtension>());
services.AddSingleton<ArkadeAssetCheckoutModelExtension>();
services.AddSingleton<ICheckoutModelExtension>(sp => sp.GetRequiredService<ArkadeAssetCheckoutModelExtension>());

services.AddSingleton<ArkPayoutHandler>();
services.AddSingleton<IPayoutHandler>(sp => sp.GetRequiredService<ArkPayoutHandler>());

services.AddSingleton<ArkAutomatedPayoutSenderFactory>();
services.AddSingleton<IPayoutProcessorFactory>(sp => sp.GetRequiredService<ArkAutomatedPayoutSenderFactory>());

services.AddDefaultPrettyName(ArkadePaymentMethodId, "Arkade");
services.AddDefaultPrettyName(ArkadeAssetPaymentMethodId, "Arkade Asset");
}

private static void RegisterDatabase(IServiceCollection services)
Expand Down Expand Up @@ -264,6 +276,20 @@ private static void RegisterPluginServices(IServiceCollection services)

services.AddSingleton<ArkadeSpendingService>();

// Caches Arkade asset metadata (name/ticker/decimals) from the
// indexer for balance display and checkout.
services.AddSingleton<AssetMetadataService>();

// 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<AssetRateResolver>();

// Surfaces each store's tracked-asset codes as BTCPay currencies, and
// refreshes the currency table after a tracked-asset CRUD op.
services.AddSingleton<CurrencyDataProvider, ArkadeAssetCurrencyDataProvider>();
services.AddSingleton<AssetCurrencyRegistrar>();

// Remote-signer transport seam.
//
// NArk's DefaultWalletProvider takes IRemoteSignerTransport? as an
Expand Down Expand Up @@ -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");
Expand Down
155 changes: 155 additions & 0 deletions BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,6 +75,8 @@ public class ArkController(
IIntentStorage intentStorage,
IWalletProvider walletProvider,
ISpendingService arkadeSpender,
AssetMetadataService assetMetadataService,
AssetCurrencyRegistrar assetCurrencyRegistrar,
IFeeEstimator feeEstimator,
IContractService contractService,
IBitcoinBlockchain bitcoinTimeChainProvider,
Expand Down Expand Up @@ -411,6 +414,19 @@ public async Task<IActionResult> 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,
Expand All @@ -424,6 +440,7 @@ public async Task<IActionResult> StoreOverview(CancellationToken cancellationTok
AllowSubDustAmounts = config.AllowSubDustAmounts,
BoardingEnabled = config.BoardingEnabled,
MinBoardingAmountSats = config.MinBoardingAmountSats,
TrackedAssets = trackedAssetRows,
Wallet = wallet?.Secret,
WalletType = wallet?.WalletType ?? WalletType.SingleKey,
CanManagePrivateKeys = canManagePrivateKeys,
Expand Down Expand Up @@ -2094,9 +2111,118 @@ public async Task<IActionResult> 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 });
}

/// <summary>
/// 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.
/// </summary>
[HttpGet("stores/{storeId}/asset-metadata")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> 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),
});
}

/// <summary>
/// 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 <see cref="ArkadePaymentMethodConfig"/> it reads at prompt time.
/// Mutates <paramref name="store"/> in place; the caller persists via
/// <c>UpdateStore</c>.
/// </summary>
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<IActionResult> Contracts(
Expand Down Expand Up @@ -2541,6 +2667,7 @@ public async Task<IActionResult> 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);

Expand Down Expand Up @@ -2993,13 +3120,41 @@ public async Task<ArkBalancesViewModel> 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<string, ulong>();
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,
LockedBalance = lockedBalance,
RecoverableBalance = recoverableBalance,
UnspendableBalance = unspendableBalance,
BoardingBalance = boardingBalance,
AssetBalances = assetBalances
.OrderBy(a => a.Ticker ?? a.AssetId, StringComparer.OrdinalIgnoreCase)
.ToList(),
};
}

Expand Down
24 changes: 24 additions & 0 deletions BTCPayServer.Plugins.ArkPayServer/Models/ArkBalancesViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,28 @@ public class ArkBalancesViewModel
public decimal RecoverableBalance { get; set; }
public decimal UnspendableBalance { get; set; }
public decimal BoardingBalance { get; set; }

/// <summary>
/// Arkade asset holdings carried on spendable VTXOs, aggregated per
/// asset id and enriched with indexer metadata. Empty when the wallet
/// holds no assets.
/// </summary>
public IReadOnlyList<AssetBalanceViewModel> AssetBalances { get; set; } = [];
}

/// <summary>
/// One Arkade asset's spendable balance with display metadata.
/// </summary>
public class AssetBalanceViewModel
{
public required string AssetId { get; init; }
public string? Name { get; init; }
public string? Ticker { get; init; }
public int Decimals { get; init; }

/// <summary>Raw base-unit amount.</summary>
public ulong Amount { get; init; }

/// <summary>Amount formatted with the asset's declared decimals.</summary>
public required string FormattedAmount { get; init; }
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<TrackedAssetRow> TrackedAssets { get; set; } = [];

/// <summary>Bound form for the add/edit-asset modal.</summary>
public TrackedAssetRow AssetForm { get; set; } = new();

/// <summary>
/// The type of wallet (SingleKey/legacy or HD/mnemonic).
/// </summary>
Expand Down
23 changes: 23 additions & 0 deletions BTCPayServer.Plugins.ArkPayServer/Models/TrackedAssetViewModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace BTCPayServer.Plugins.ArkPayServer.Models;

/// <summary>Result of the add-by-id metadata fetch (indexer prefill).</summary>
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; }
}

/// <summary>A tracked-asset row for the store-settings list + add/edit form binding.</summary>
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;
}
Loading
Loading