From 68d52c6f02ed0af0b2fa9d6c3af9c5e92aa1db1c Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 18 May 2026 23:06:14 +0200 Subject: [PATCH 01/25] docs(assets): payment-method design, parity plan & rate decision --- ...-18-arkade-assets-payment-method-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-arkade-assets-payment-method-design.md 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..2c2abb8d --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-arkade-assets-payment-method-design.md @@ -0,0 +1,102 @@ +# 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:** living doc — research in progress, decisions recorded as made + +## 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 (PENDING — 3 research agents running) + +go-sdk / ts-sdk / rust-sdk asset inventories are being gathered by parallel +`ark-researcher` agents (deepwiki + GitHub). go-sdk `pkg/ark-lib/asset/` is +the canonical byte-layout reference per PR #25. Fill on agent return: + +| Capability | go-sdk | ts-sdk | rust-sdk | NNark | Gap/action | +|---|---|---|---|---|---| +| _(matrix filled when agents report)_ | | | | | | + +Then: close every NNark feature gap, and match their test scenarios +(reusing shared JSON fixture vectors where they exist). + +## 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). From 7b4e55a34ed3de6f14a11db4bc367e5e7e1f91a1 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 18 May 2026 23:19:47 +0200 Subject: [PATCH 02/25] docs(assets): finalize SDK parity verdict (GAP A dropped, B+C fixed) --- ...-18-arkade-assets-payment-method-design.md | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) 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 index 2c2abb8d..18194669 100644 --- 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 @@ -23,18 +23,62 @@ - **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 (PENDING — 3 research agents running) - -go-sdk / ts-sdk / rust-sdk asset inventories are being gathered by parallel -`ark-researcher` agents (deepwiki + GitHub). go-sdk `pkg/ark-lib/asset/` is -the canonical byte-layout reference per PR #25. Fill on agent return: - -| Capability | go-sdk | ts-sdk | rust-sdk | NNark | Gap/action | -|---|---|---|---|---|---| -| _(matrix filled when agents report)_ | | | | | | - -Then: close every NNark feature gap, and match their test scenarios -(reusing shared JSON fixture vectors where they exist). +## 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 From 76ae787364f8f1a0470aafd1a4b3d386fef7fb8b Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 18 May 2026 23:27:39 +0200 Subject: [PATCH 03/25] feat(assets): surface Arkade asset balances on the store dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #25's asset display intent re-applied onto current master (its storage/migration commits are obsolete — VTXO storage moved into the NNark SDK, which already persists ArkVtxo.Assets end-to-end). - AssetMetadataService: process-lifetime cache over the indexer's GetAssetDetailsAsync; exposes name/ticker/decimals + decimals-aware amount formatting. - ArkBalancesViewModel.AssetBalances + AssetBalanceViewModel. - GetArkBalances aggregates ArkCoin.Assets across spendable coins, enriches via the metadata cache, sorted by ticker/id. - _ArkBalances.cshtml renders an "Arkade assets" section in the detailed balance card (data-testid=asset-balances/asset-balance). - DI registration in RegisterPluginServices. Plugin builds clean (0 errors) on current master with the asset-aware SDK (NNark @ assets-parity 8c0fe77). --- .../ArkPlugin.cs | 4 ++ .../Controllers/ArkController.cs | 29 ++++++++ .../Models/ArkBalancesViewModel.cs | 24 +++++++ .../Services/AssetMetadataService.cs | 66 +++++++++++++++++++ .../Views/Shared/_ArkBalances.cshtml | 23 +++++++ 5 files changed, 146 insertions(+) create mode 100644 BTCPayServer.Plugins.ArkPayServer/Services/AssetMetadataService.cs diff --git a/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs b/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs index 31792ee4..c025f79c 100644 --- a/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs +++ b/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs @@ -260,6 +260,10 @@ 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(); + services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs b/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs index 6485d117..a1bb30de 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs +++ b/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs @@ -72,6 +72,7 @@ public class ArkController( IIntentStorage intentStorage, IWalletProvider walletProvider, ISpendingService arkadeSpender, + AssetMetadataService assetMetadataService, IFeeEstimator feeEstimator, IContractService contractService, IBitcoinBlockchain bitcoinTimeChainProvider, @@ -2909,6 +2910,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, @@ -2916,6 +2942,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/Services/AssetMetadataService.cs b/BTCPayServer.Plugins.ArkPayServer/Services/AssetMetadataService.cs new file mode 100644 index 00000000..ffed81e8 --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/AssetMetadataService.cs @@ -0,0 +1,66 @@ +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. +/// +public class AssetMetadataService(IClientTransport clientTransport) +{ + private readonly ConcurrentDictionary _cache = new(); + + /// + /// 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. + /// + public async Task GetAssetDetailsAsync( + string assetId, CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(assetId, out var cached)) + return cached; + + try + { + var details = await clientTransport.GetAssetDetailsAsync(assetId, cancellationToken); + _cache.TryAdd(assetId, details); + return details; + } + catch + { + // Indexer miss/unreachable: not fatal — UI degrades to raw id. + 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.50"). + /// + public string FormatAmount(ulong amount, ArkAssetDetails? details) + { + var decimals = GetDecimals(details); + if (decimals == 0) return amount.ToString(System.Globalization.CultureInfo.InvariantCulture); + var divisor = (decimal)Math.Pow(10, decimals); + return (amount / divisor).ToString( + "0." + new string('#', decimals), System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_ArkBalances.cshtml b/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_ArkBalances.cshtml index 2f11731a..1bbd67c1 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_ArkBalances.cshtml +++ b/BTCPayServer.Plugins.ArkPayServer/Views/Shared/_ArkBalances.cshtml @@ -158,6 +158,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) + +
+ } +
+ } @: } } From 6efec254bf343bbca614ed9a5f19b6c30ac69ee1 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 18 May 2026 23:31:02 +0200 Subject: [PATCH 04/25] feat(assets): add ArkadeAssetAcceptance config to payment method Extends ArkadePaymentMethodConfig with an optional trailing AssetAcceptance record (additive, serialization-safe). Introduces AssetRateMode (FixedReferenceCurrency | SatsPerUnit) and ArkadeAssetAcceptance with internal-consistency validation. Null = asset acceptance off, BTC-VTXO behaviour unchanged. --- .../ArkadePaymentMethodConfig.cs | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs index e169c954..a016895c 100644 --- a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodConfig.cs @@ -5,9 +5,66 @@ public record ArkadePaymentMethodConfig( bool GeneratedByStore = false, bool AllowSubDustAmounts = false, bool BoardingEnabled = true, - long MinBoardingAmountSats = ArkadePaymentMethodConfig.DefaultMinBoardingAmountSats) + long MinBoardingAmountSats = ArkadePaymentMethodConfig.DefaultMinBoardingAmountSats, + ArkadeAssetAcceptance? AssetAcceptance = null) { public const long P2trDustLimitSats = 330L; public const long DefaultMinBoardingAmountSats = 5000L; -} \ No newline at end of file +} + +/// +/// How a merchant prices an accepted Arkade asset. Arkade assets aren't on +/// exchanges, so the price is merchant-declared. Two models: +/// +/// — 1 asset unit costs +/// PricePerUnit of ReferenceCurrency (e.g. a USD-pegged +/// stablecoin: 1 unit = 1 USD). The invoice→reference-currency leg goes +/// through BTCPay's existing rate pipeline. +/// — 1 asset unit costs PricePerUnit +/// satoshis. Only BTCPay's BTC rate for the invoice currency is needed. +/// +/// +public enum AssetRateMode +{ + FixedReferenceCurrency, + SatsPerUnit +} + +/// +/// Store-scoped configuration making the Arkade payment method settle an +/// invoice in a specific Arkade asset at a merchant-declared rate. +/// Null on = asset acceptance off +/// (BTC-VTXO behaviour unchanged). +/// +public record ArkadeAssetAcceptance( + string AssetId, + AssetRateMode RateMode, + decimal PricePerUnit, + string? ReferenceCurrency = null) +{ + /// + /// Validates the acceptance config is internally consistent. + /// + public bool IsValid(out string? error) + { + if (string.IsNullOrWhiteSpace(AssetId)) + { + error = "An asset id is required."; + return false; + } + if (PricePerUnit <= 0m) + { + error = "Price per unit must be greater than zero."; + return false; + } + if (RateMode == AssetRateMode.FixedReferenceCurrency && + string.IsNullOrWhiteSpace(ReferenceCurrency)) + { + error = "A reference currency is required for the fixed-currency rate model."; + return false; + } + error = null; + return true; + } +} From 70ecb4f911ff7be5706419a8717209aa34f3fd42 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 18 May 2026 23:38:57 +0200 Subject: [PATCH 05/25] feat(assets): resolve asset amount due via BTCPay rate pipeline AssetRateResolver prices a merchant-accepted asset against an invoice's BTC amount due. SatsPerUnit is self-contained; FixedReferenceCurrency routes the BTC->reference leg through the store's own RateFetcher rules (same path payouts use), so spread/fallback/rate-source config keep applying. Base units rounded up (never underpay), clamped to >=1. ConfigurePrompt resolves and stashes asset id/ticker/decimals/amount-due into ArkadePromptDetails when AssetAcceptance is set; on rate failure the asset method is simply not offered (PaymentMethodUnavailable), the BTC destination/expiry machinery untouched. --- .../ArkPlugin.cs | 5 + .../ArkadePaymentMethodHandler.cs | 46 +++++- .../PaymentHandler/ArkadePromptDetails.cs | 15 ++ .../Services/AssetRateResolver.cs | 152 ++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs diff --git a/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs b/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs index c025f79c..bed9cad7 100644 --- a/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs +++ b/BTCPayServer.Plugins.ArkPayServer/ArkPlugin.cs @@ -264,6 +264,11 @@ private static void RegisterPluginServices(IServiceCollection services) // 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(); + services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePaymentMethodHandler.cs index d01be426..3a894d65 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; @@ -17,7 +18,9 @@ public class ArkadePaymentMethodHandler( IContractService contractService, IClientTransport clientTransport, BoardingUtxoSyncService boardingUtxoSyncService, - IWalletStorage walletStorage + IWalletStorage walletStorage, + AssetMetadataService assetMetadataService, + AssetRateResolver assetRateResolver ) : IPaymentMethodHandler { public PaymentMethodId PaymentMethodId => ArkadePlugin.ArkadePaymentMethodId; @@ -105,6 +108,47 @@ await contractService.ImportContract( _ = Task.Run(() => boardingUtxoSyncService.SyncAsync(CancellationToken.None)); } + // Arkade asset acceptance: when the store prices this invoice in a + // merchant-accepted asset, compute the asset amount due and surface + // it on the prompt. The BTC destination/expiry plumbing above is + // untouched — the asset amount is an additional, displayed + // settlement requirement against the same Ark address. + if (arkadePaymentMethodConfig.AssetAcceptance is { } acceptance) + { + if (!acceptance.IsValid(out var cfgErr)) + throw new PaymentMethodUnavailableException($"Asset acceptance misconfigured: {cfgErr}"); + + var assetDetails = await assetMetadataService.GetAssetDetailsAsync( + acceptance.AssetId, CancellationToken.None); + var assetDecimals = assetMetadataService.GetDecimals(assetDetails); + var dueSats = Money.Coins(context.Prompt.Calculate().Due).Satoshi; + + AssetAmountDue assetDue; + try + { + assetDue = await assetRateResolver.ResolveAsync( + context.Store, acceptance, dueSats, assetDecimals, CancellationToken.None); + } + catch (InvalidOperationException ex) + { + throw new PaymentMethodUnavailableException($"Asset pricing unavailable: {ex.Message}"); + } + + context.Logs.Write( + $"Asset {acceptance.AssetId}: {assetDue.RateDescription}", + InvoiceEventData.EventSeverity.Info); + + details = details with + { + AssetId = acceptance.AssetId, + AssetName = assetMetadataService.GetName(assetDetails), + AssetTicker = assetMetadataService.GetTicker(assetDetails), + AssetDecimals = assetDecimals, + AssetBaseUnitsDue = assetDue.BaseUnits, + AssetFormattedAmountDue = assetDue.FormattedAmount + }; + } + context.Prompt.Details = JObject.FromObject(details, Serializer); } diff --git a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePromptDetails.cs b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePromptDetails.cs index 2239c7bd..b9a4d260 100644 --- a/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePromptDetails.cs +++ b/BTCPayServer.Plugins.ArkPayServer/PaymentHandler/ArkadePromptDetails.cs @@ -39,6 +39,21 @@ public ArkadePromptDetails() public string? BoardingAddress { get; init; } public string? BoardingContractString { get; init; } + // --- Arkade asset acceptance (null unless the store accepts an asset + // for this payment method; additive, so existing prompts deserialize + // unchanged). When set, the customer settles by sending this many + // base units of to the Ark address above. + public string? AssetId { get; init; } + public string? AssetName { get; init; } + public string? AssetTicker { get; init; } + public int AssetDecimals { get; init; } + + /// Raw base-unit asset amount the customer must send. + public ulong AssetBaseUnitsDue { get; init; } + + /// Asset amount due, formatted to the asset's decimals. + public string? AssetFormattedAmountDue { get; init; } + /// /// Parses the contract with the specified network. /// diff --git a/BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs b/BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs new file mode 100644 index 00000000..2a89b35a --- /dev/null +++ b/BTCPayServer.Plugins.ArkPayServer/Services/AssetRateResolver.cs @@ -0,0 +1,152 @@ +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-accepted 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 (). Two models: +/// +/// — self-contained: one +/// asset unit is worth PricePerUnit satoshis, so the BTC amount due +/// divides straight through. +/// — one asset +/// unit is worth PricePerUnit of a real currency; the BTC→reference +/// leg goes through BTCPay's own rate pipeline (same path payouts use), so +/// store spread/fallback/rate-source config all keep applying. +/// +/// +/// +public class AssetRateResolver(RateFetcher rateFetcher, DefaultRulesCollection defaultRules) +{ + /// + /// Computes the asset amount due for an invoice. + /// + /// The invoice's store (for its rate rules). + /// The store's asset-acceptance config. + /// Invoice amount due, in satoshis (BTC leg). + /// Asset decimals from indexer metadata. + /// + /// The reference-currency rate could not be fetched, or the config is + /// internally inconsistent. The caller translates this into the invoice + /// simply not offering the asset (never a hard failure). + /// + public async Task ResolveAsync( + StoreData store, + ArkadeAssetAcceptance acceptance, + long dueSats, + int assetDecimals, + CancellationToken cancellationToken) + { + if (!acceptance.IsValid(out var configError)) + throw new InvalidOperationException($"Invalid asset acceptance config: {configError}"); + if (dueSats <= 0) + throw new InvalidOperationException("Invoice amount due must be positive to price an asset."); + + var dueBtc = dueSats / 100_000_000m; + + decimal displayUnits; + string rateDescription; + + switch (acceptance.RateMode) + { + case AssetRateMode.SatsPerUnit: + { + // 1 asset unit = PricePerUnit sats. No external rate needed. + displayUnits = dueSats / acceptance.PricePerUnit; + rateDescription = + $"{acceptance.PricePerUnit} sats/unit → {dueSats} sats = {displayUnits} units"; + break; + } + case AssetRateMode.FixedReferenceCurrency: + { + // 1 asset unit = PricePerUnit of ReferenceCurrency. Convert + // the BTC amount due into the reference currency through the + // store's configured rate pipeline, then divide by the + // merchant's per-unit price. + var pair = new CurrencyPair("BTC", acceptance.ReferenceCurrency!); + var storeBlob = store.GetStoreBlob(); + var rule = storeBlob.GetRateRules(defaultRules).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 fetch {pair} rate for asset pricing" + + (rate.Errors is { Count: > 0 } + ? $" ({string.Join(", ", rate.Errors)})" + : "")); + + var btcToRef = rate.BidAsk.Center; + var dueInRef = dueBtc * btcToRef; + displayUnits = dueInRef / acceptance.PricePerUnit; + rateDescription = + $"1 BTC = {btcToRef} {acceptance.ReferenceCurrency}; " + + $"{dueBtc} BTC = {dueInRef} {acceptance.ReferenceCurrency}; " + + $"@ {acceptance.PricePerUnit} {acceptance.ReferenceCurrency}/unit = {displayUnits} units"; + break; + } + default: + throw new InvalidOperationException( + $"Unsupported asset rate mode {acceptance.RateMode}"); + } + + // Convert whole units → raw base units. Round UP and clamp to at + // least one base unit: the merchant must never be underpaid, and a + // zero-amount asset output is meaningless. + var scale = Pow10(assetDecimals); + var baseUnitsExact = Math.Ceiling(displayUnits * scale); + if (baseUnitsExact < 1m) + baseUnitsExact = 1m; + var baseUnits = (ulong)baseUnitsExact; + + var actualDisplay = baseUnitsExact / scale; + var formatted = actualDisplay.ToString( + "0." + new string('0', Math.Clamp(assetDecimals, 0, 18)), + System.Globalization.CultureInfo.InvariantCulture) + .TrimEnd('0').TrimEnd('.'); + if (formatted.Length == 0) + formatted = "0"; + + return new AssetAmountDue(baseUnits, actualDisplay, formatted, rateDescription); + } + + /// 10^exp as a decimal (exp clamped to a sane asset range). + private static decimal Pow10(int exp) + { + exp = Math.Clamp(exp, 0, 18); + decimal result = 1m; + for (var i = 0; i < exp; i++) + result *= 10m; + return result; + } +} From 37ddffd50c7844049685f0454fe052dc7c3ba783 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 18 May 2026 23:41:28 +0200 Subject: [PATCH 06/25] feat(assets): store settings UI to accept an asset as payment Adds an Asset Payments row + modal on the Arkade store overview: asset id, pricing model (FixedReferenceCurrency | SatsPerUnit), price/unit, and reference currency (shown only for the fixed-currency model). save-asset-acceptance validates the config and verifies the asset exists on the indexer before enabling; disable-asset-acceptance clears it. StoreOverviewViewModel carries the current config + resolved label. --- .../Controllers/ArkController.cs | 59 +++++++++ .../Models/StoreOverviewViewModel.cs | 12 ++ .../Views/Ark/StoreOverview.cshtml | 114 ++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs b/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs index a1bb30de..a1d20176 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs +++ b/BTCPayServer.Plugins.ArkPayServer/Controllers/ArkController.cs @@ -350,6 +350,16 @@ public async Task StoreOverview(CancellationToken cancellationTok // Silently ignore - swaps section will show empty } + // Resolve a friendly label for the accepted asset (id → name/ticker) + string? assetAcceptanceLabel = null; + if (config.AssetAcceptance is { } acceptanceCfg) + { + var ad = await assetMetadataService.GetAssetDetailsAsync(acceptanceCfg.AssetId, cancellationToken); + assetAcceptanceLabel = assetMetadataService.GetName(ad) + ?? assetMetadataService.GetTicker(ad) + ?? acceptanceCfg.AssetId; + } + return View(new StoreOverviewViewModel { StoreId = store!.Id, @@ -363,6 +373,12 @@ public async Task StoreOverview(CancellationToken cancellationTok AllowSubDustAmounts = config.AllowSubDustAmounts, BoardingEnabled = config.BoardingEnabled, MinBoardingAmountSats = config.MinBoardingAmountSats, + AssetAcceptanceEnabled = config.AssetAcceptance is not null, + AssetAcceptanceAssetId = config.AssetAcceptance?.AssetId, + AssetAcceptanceRateMode = config.AssetAcceptance?.RateMode ?? AssetRateMode.FixedReferenceCurrency, + AssetAcceptancePricePerUnit = config.AssetAcceptance?.PricePerUnit ?? 0m, + AssetAcceptanceReferenceCurrency = config.AssetAcceptance?.ReferenceCurrency, + AssetAcceptanceAssetLabel = assetAcceptanceLabel, Wallet = wallet?.Secret, WalletType = wallet?.WalletType ?? WalletType.SingleKey, CanManagePrivateKeys = canManagePrivateKeys, @@ -2032,6 +2048,49 @@ public async Task UpdateWalletConfig(string storeId, StoreOvervie return RedirectWithSuccess(nameof(StoreOverview), "Boarding disabled.", new { storeId }); } + if (command == "save-asset-acceptance") + { + var assetId = model.AssetAcceptanceAssetId?.Trim(); + var referenceCurrency = string.IsNullOrWhiteSpace(model.AssetAcceptanceReferenceCurrency) + ? null + : model.AssetAcceptanceReferenceCurrency.Trim().ToUpperInvariant(); + + var acceptance = new ArkadeAssetAcceptance( + assetId ?? string.Empty, + model.AssetAcceptanceRateMode, + model.AssetAcceptancePricePerUnit, + referenceCurrency); + + if (!acceptance.IsValid(out var validationError)) + return RedirectWithError(nameof(StoreOverview), validationError!, new { storeId }); + + // Confirm the asset exists on the indexer before accepting it — + // otherwise checkout would price against an unknown asset. + var assetDetails = await assetMetadataService.GetAssetDetailsAsync(acceptance.AssetId, cancellationToken); + if (assetDetails is null) + return RedirectWithError(nameof(StoreOverview), + $"Asset '{acceptance.AssetId}' was not found on the Ark indexer. Check the asset id.", + new { storeId }); + + var newConfig = config! with { AssetAcceptance = acceptance }; + store!.SetPaymentMethodConfig(paymentMethodHandlerDictionary[ArkadePlugin.ArkadePaymentMethodId], newConfig); + await storeRepository.UpdateStore(store); + + var label = assetMetadataService.GetName(assetDetails) + ?? assetMetadataService.GetTicker(assetDetails) + ?? acceptance.AssetId; + return RedirectWithSuccess(nameof(StoreOverview), + $"Now accepting {label} as payment.", new { storeId }); + } + + if (command == "disable-asset-acceptance") + { + var newConfig = config! with { AssetAcceptance = null }; + store!.SetPaymentMethodConfig(paymentMethodHandlerDictionary[ArkadePlugin.ArkadePaymentMethodId], newConfig); + await storeRepository.UpdateStore(store); + return RedirectWithSuccess(nameof(StoreOverview), "Asset acceptance disabled.", new { storeId }); + } + return RedirectToAction(nameof(StoreOverview), new { storeId }); } diff --git a/BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs b/BTCPayServer.Plugins.ArkPayServer/Models/StoreOverviewViewModel.cs index 1a78589e..c5a32b31 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 NArk.Abstractions.Contracts; using NArk.Abstractions.VTXOs; using NArk.Abstractions.Wallets; @@ -21,6 +22,17 @@ public class StoreOverviewViewModel public bool BoardingEnabled { get; set; } public long MinBoardingAmountSats { get; set; } + // --- Arkade asset acceptance (settle invoices in a merchant-declared + // Arkade asset at a merchant-declared rate). Disabled = BTC-VTXO only. + public bool AssetAcceptanceEnabled { get; set; } + public string? AssetAcceptanceAssetId { get; set; } + public AssetRateMode AssetAcceptanceRateMode { get; set; } = AssetRateMode.FixedReferenceCurrency; + public decimal AssetAcceptancePricePerUnit { get; set; } + public string? AssetAcceptanceReferenceCurrency { get; set; } + + /// Resolved display name/ticker for the accepted asset, if known. + public string? AssetAcceptanceAssetLabel { get; set; } + /// /// The type of wallet (SingleKey/legacy or HD/mnemonic). /// diff --git a/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml b/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml index 0074c23f..3100093f 100644 --- a/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml +++ b/BTCPayServer.Plugins.ArkPayServer/Views/Ark/StoreOverview.cshtml @@ -148,6 +148,23 @@ } +
+ Asset Payments + +
+
Diagnostic Log } + + + + +