NArk.Abstractions
1.0.377-beta
dotnet add package NArk.Abstractions --version 1.0.377-beta
NuGet\Install-Package NArk.Abstractions -Version 1.0.377-beta
<PackageReference Include="NArk.Abstractions" Version="1.0.377-beta" />
<PackageVersion Include="NArk.Abstractions" Version="1.0.377-beta" />
<PackageReference Include="NArk.Abstractions" />
paket add NArk.Abstractions --version 1.0.377-beta
#r "nuget: NArk.Abstractions, 1.0.377-beta"
#:package NArk.Abstractions@1.0.377-beta
#addin nuget:?package=NArk.Abstractions&version=1.0.377-beta&prerelease
#tool nuget:?package=NArk.Abstractions&version=1.0.377-beta&prerelease
NArk .NET SDK
A .NET SDK for building applications on Arkade, an open execution engine for Bitcoin. Arkade makes transactions instant, low-cost, and programmable through virtual outputs, and every transaction it builds is a Bitcoin transaction.
The generated API reference is published at arkade-os.github.io/dotnet-sdk.
Packages
Swap persistence is available through the optional NArk.Storage.EfCore.ArkadeIntents package.
Register it alongside core storage and opt into its tables in your context:
services.AddArkEfCoreStorage<MyDbContext>();
services.AddArkadeEfCoreStorage();
// MyDbContext.OnModelCreating:
modelBuilder.ConfigureArkEntities();
modelBuilder.ConfigureArkadeEntities();
Broadcast asset offers do not authenticate a particular solver; their SolverPubkey() metadata
is absent. RFQ swaps record the solver that quoted them.
| Package | Description |
|---|---|
| NArk.Abstractions | Interfaces and domain types (IVtxoStorage, IContractStorage, IWalletProvider, ArkCoin, ArkVtxo, etc.) |
| NArk.Core | Core services: spending, batch management, VTXO sync, sweeping, wallet infrastructure, gRPC transport |
| NArk.Storage.EfCore | Entity Framework Core storage implementations (provider-agnostic — works with PostgreSQL, SQLite, etc.) |
| NArk | Meta-package that pulls in NArk.Core |
Quick Start
Install
dotnet add package NArk # Core + Swaps
dotnet add package NArk.Storage.EfCore # EF Core persistence
Minimal Setup with Generic Host
using NArk.Hosting;
using NArk.Core.Wallet;
using NArk.Storage.EfCore;
using NArk.Storage.EfCore.Hosting;
var builder = Host.CreateDefaultBuilder(args)
.AddArk()
.WithVtxoStorage<EfCoreVtxoStorage>()
.WithContractStorage<EfCoreContractStorage>()
.WithIntentStorage<EfCoreIntentStorage>()
.WithWalletProvider<DefaultWalletProvider>()
.WithSafetyService<YourSafetyService>()
.WithBlockchain<NBXplorerBlockchain>()
.OnMainnet()
// Register your DbContext and EF Core storage
builder.ConfigureServices((_, services) =>
{
services.AddDbContextFactory<YourDbContext>(opts =>
opts.UseNpgsql(connectionString));
services.AddArkEfCoreStorage<YourDbContext>();
});
var app = builder.Build();
await app.RunAsync();
Setup with IServiceCollection (plugin/non-host scenarios)
using NArk.Hosting;
using NArk.Core.Wallet;
using NArk.Storage.EfCore.Hosting;
services.AddArkCoreServices();
services.AddArkNetwork(ArkNetworkConfig.Mainnet);
services.AddDbContextFactory<YourDbContext>(opts =>
opts.UseNpgsql(connectionString));
services.AddArkEfCoreStorage<YourDbContext>();
// Register remaining required services
services.AddSingleton<IWalletProvider, DefaultWalletProvider>();
services.AddSingleton<ISafetyService, YourSafetyService>();
// Pick the blockchain backend you have a client for. Each helper registers
// a single IBitcoinBlockchain that handles chain time, UTXO lookup at a
// boarding address, broadcast, package broadcast, tx status and fee
// estimation. Last registration wins, so you can swap in a custom impl
// after the helper if you want to override one method.
services.AddNBXplorerBlockchain(network, new Uri("http://localhost:32838"));
// or: services.AddEsploraBlockchain(new Uri("https://mempool.space/api/"));
// or: services.AddRpcBlockchain(rpcClient); // UTXO lookup not supported
Architecture
NArk (meta-package)
├── NArk.Core
│ ├── Services (spending, batches, VTXO sync, sweeping, intents)
│ ├── Wallet (WalletFactory, signers, address providers)
│ ├── Hosting (DI extensions, ArkApplicationBuilder)
│ └── Transport (gRPC client for Arkade server communication)
│
└── NArk.Abstractions
├── Domain types (ArkCoin, ArkVtxo, ArkContract, ArkAddress, etc.)
├── Storage interfaces (IVtxoStorage, IContractStorage, IIntentStorage)
└── Wallet interfaces (IWalletProvider, IArkadeWalletSigner)
NArk.Storage.EfCore (optional, provider-agnostic persistence)
├── EF Core entity mappings
├── Storage implementations
└── DI extension: AddArkEfCoreStorage<TDbContext>()
Wallet Management
Wallets are described along two orthogonal axes; capability is answered by the provider, not by a tag on the data.
Key derivation (WalletType):
WalletType |
Script shape | Use case |
|---|---|---|
HD |
tr([fp/path]xpub/0/*) |
Per-contract derivation, boarding support |
SingleKey |
tr(pubkey) |
Static key, simple integrations |
Signing capability — decided at IWalletProvider.GetSignerAsync time and built by composing one or more IDescriptorSigningSources behind a CompositeArkadeWalletSigner:
ArkWalletInfo.Secret |
IRemoteSignerTransport.KnowsWalletAsync |
Returns | Meaning |
|---|---|---|---|
| non-empty | — | composite with the matching local signing source | sign locally |
| null/empty | true |
composite with RemoteTransportSigningSource only |
sign via transport |
| null/empty | false (or no transport) |
null |
watch-only |
Three signing sources ship in the box — Bip39SigningSource (matches descriptors by master fingerprint), NsecSigningSource (matches by x-only pubkey), RemoteTransportSigningSource (delegates to an IRemoteSignerTransport). Implement IDescriptorSigningSource to plug in anything else (HWI, threshold key share, in-browser session signer, …).
Any combination of the two axes is valid — a watch-only HD, a remote-signed SingleKey, etc.
HD Wallets — BIP-39 mnemonic with BIP-86 taproot derivation (m/86'/cointype'/0'):
var serverInfo = await transport.GetServerInfoAsync();
var wallet = await WalletFactory.CreateWallet(
"abandon abandon abandon ... about", // BIP-39 mnemonic
destination: null,
serverInfo);
// wallet.WalletType == WalletType.HD
Single-Key Wallets — nostr nsec format (Bech32-encoded secp256k1 key):
var wallet = await WalletFactory.CreateWallet(
"nsec1...",
destination: null,
serverInfo);
// wallet.WalletType == WalletType.SingleKey
Watch-Only and Remote-Signed Wallets — same data shape (Secret = null on a normal ArkWalletInfo); the runtime distinction is made by whether an IRemoteSignerTransport is registered and claims the wallet:
// Build the wallet record once. WalletType is inferred from the descriptor shape
// (wildcard → HD, bare → SingleKey).
var wallet = await WalletFactory.CreateWatchOnlyWallet(
accountDescriptor: "tr([abcd1234/86'/1'/0']tpub.../0/*)",
destination: null,
serverInfo);
// wallet.WalletType == WalletType.HD, wallet.Secret == null
await walletStorage.SaveWallet(wallet);
// To make the same wallet remote-signed instead of watch-only, register an
// IRemoteSignerTransport whose KnowsWalletAsync returns true for wallet.Id:
public class MyRemoteSignerTransport : IRemoteSignerTransport
{
public Task<bool> KnowsWalletAsync(string walletId, CancellationToken ct) => _bridge.IsPairedAsync(walletId, ct);
// … sign methods …
}
services.AddSingleton<IRemoteSignerTransport, MyRemoteSignerTransport>();
// GetSignerAsync now returns a CompositeArkadeWalletSigner wrapping a
// RemoteTransportSigningSource for that walletId, instead of null. Same data;
// different signer-source.
Save and load wallets through IWalletStorage:
await walletStorage.SaveWallet(wallet);
var loaded = await walletStorage.LoadWallet(wallet.Id);
var all = await walletStorage.LoadAllWallets();
Spending
Use ISpendingService to send Arkade transactions:
// Automatic coin selection
var txId = await spendingService.Spend(
walletId,
outputs: [new ArkTxOut(recipientAddress, Money.Satoshis(10_000))]);
// Manual coin selection
var coins = await spendingService.GetAvailableCoins(walletId);
var txId = await spendingService.Spend(
walletId,
inputs: coins.Take(2).ToArray(),
outputs: [new ArkTxOut(recipientAddress, Money.Satoshis(5_000))]);
ArkadeCash
ArkadeCash is a bearer instrument: a fresh private key plus the contract parameters
needed to rebuild the Arkade payment contract it funds, packed into one bech32m string
(arkadecash1... on mainnet, tarkadecash1... on testnet/regtest). Whoever holds the
string controls the funds, so value can be handed over without the recipient sharing an
Arkade address first. The encoding matches the ArkadeCash format of the TypeScript SDK,
so a note created by either SDK can be claimed by the other.
The payload is 69 bytes: version (1) + private key (32) + Arkade server public key (32)
- BIP68 CSV sequence (4, big-endian).
using NArk.Abstractions;
using NArk.Core.Extensions;
var serverInfo = await transport.GetServerInfoAsync();
// Create a note with a fresh random key
var cash = ArkadeCash.Generate(
serverInfo.SignerKey.ToXOnlyPubKey(),
serverInfo.UnilateralExit,
"tarkadecash");
// Fund it: send to the address of the contract the note controls
var address = cash.GetAddress(serverInfo.Network);
await spendingService.Spend(
walletId,
[new ArkTxOut(ArkTxOutType.Vtxo, Money.Satoshis(10_000), address)]);
// Hand this string to the recipient — it carries the private key, treat it as a secret
string note = cash.ToString();
Claiming: hand the note and a destination address to ArkadeCashService.
var cashService = sp.GetRequiredService<ArkadeCashService>();
if (!ArkadeCash.TryParse(note, out var claimed) || claimed is null)
throw new FormatException("Not a valid ArkadeCash note");
using (claimed)
{
var destination = (await contractService.DeriveContract(walletId, NextContractPurpose.Receive))
.GetArkAddress();
var result = await cashService.ClaimAsync(claimed, destination);
Console.WriteLine($"Swept {result.Swept} sat");
foreach (var left in result.Unclaimed)
Console.WriteLine($"Left behind {left.Amount} sat at {left.Outpoint}: {left.Reason}");
}
The claim is thin on purpose: nothing is persisted. No contract is imported and the note's key never reaches wallet storage — it signs one offchain transaction per VTXO, in memory, straight to the destination. That is what makes a note claimable at all: importing its contract would only register a script to watch, since the wallet holds no key matching the note's descriptor and could never sign for it.
One transaction per VTXO means a single stale or rejected input dents only its own sweep instead of
sinking the claim. Anything that could not be swept comes back in result.Unclaimed with a reason
(AlreadySpent, ServerSwept, Subdust, AssetBearing, SweepFailed) rather than as an
exception, so claiming an already-claimed note is a report, not a failure.
Because nothing is imported, the claim also does not care whether the Arkade server has rotated its signer since the note was funded: the note is spent under the key it was issued against, which the operator keeps co-signing until that key's deprecation cutoff passes.
ArkadeCash owns its private key and implements IDisposable — dispose it once the note
has been claimed or persisted.
Wallet Recovery
Rebuild a wallet's local state — contracts, the HD derivation index and funds
(VTXOs) — from on-chain and indexer sources, after importing a wallet into empty
storage. Use the unified, wallet-type-agnostic IWalletRecoveryService
(registered by AddArkCoreServices):
var recovery = sp.GetRequiredService<IWalletRecoveryService>();
var report = await recovery.RecoverAsync(walletId);
// report.HdScan, report.ContractsRecovered,
// report.FinalizedPendingTxIds, report.FundsScriptsSynced
It dispatches by wallet type: HD wallets get a gap-limit index scan that discovers contracts across the current and every deprecated server signer — so funds locked under a rotated/legacy server key are still found; SingleKey wallets re-derive their one deterministic contract. Both then finalize any in-flight Arkade transactions and resync funds.
Discovery is pluggable via IContractDiscoveryProvider (indexer / boarding).
To also probe delegate (auto-renewal) scripts during recovery, register a
RecoveryDelegateConfig with the delegate key descriptors.
Assets
The SDK supports issuing, transferring, and burning assets on Arkade. Assets are encoded as AssetGroup entries inside an OP_RETURN output (an "asset packet") attached to each Arkade transaction. The asset ID is derived from {txid, groupIndex} after submission.
Asset packets are deterministic: AssetPacketBuilder emits groups in a stable order (by asset id, then group index) regardless of input order, so the same logical transaction always serializes to identical bytes. This matches the ordering used by the other Arkade SDKs (ts-sdk / rust-sdk) and makes packets reproducible and cross-SDK fixture-comparable.
Issuance
Use IAssetManager to create new assets:
var result = await assetManager.IssueAsync(walletId,
new IssuanceParams(Amount: 1000));
// result.AssetId — the unique asset identifier
// result.ArkTxId — the Arkade transaction that created it
Issue with metadata:
var result = await assetManager.IssueAsync(walletId,
new IssuanceParams(
Amount: 1000,
Metadata: new Dictionary<string, string>
{
{ "name", "My Token" },
{ "ticker", "MTK" },
{ "decimals", "8" }
}));
Controlled Issuance & Reissuance
A control asset acts as a minting key — only the holder can issue more supply:
// Issue a control asset (amount=1, acts as the minting authority)
var control = await assetManager.IssueAsync(walletId,
new IssuanceParams(Amount: 1));
// Issue a token controlled by that asset
var token = await assetManager.IssueAsync(walletId,
new IssuanceParams(Amount: 1000, ControlAssetId: control.AssetId));
// Reissue more supply later (requires holding the control asset)
await assetManager.ReissueAsync(walletId,
new ReissuanceParams(control.AssetId, Amount: 500));
Transfer
Asset transfers use the standard SpendingService.Spend() with ArkTxOut.Assets:
await spendingService.Spend(walletId,
[
new ArkTxOut(ArkTxOutType.Vtxo, serverInfo.Dust, recipientAddress)
{
Assets = [new ArkTxOutAsset(assetId, 400)]
}
]);
// Automatic coin selection handles BTC fees and asset change.
// Sender retains remaining units (e.g. 600 of 1000) as asset change.
Burn
Reduce the circulating supply of an asset:
await assetManager.BurnAsync(walletId,
new BurnParams(assetId, Amount: 400));
// Remaining 600 units are returned as change
Querying Assets
Check asset balances from local VTXO storage:
var coins = await spendingService.GetAvailableCoins(walletId);
foreach (var coin in coins.Where(c => c.Assets is { Count: > 0 }))
{
foreach (var asset in coin.Assets!)
Console.WriteLine($"Asset {asset.AssetId}: {asset.Amount} units");
}
Query asset details from the Arkade server:
var details = await transport.GetAssetDetailsAsync(assetId);
// details.Supply — total circulating supply
// details.AssetId — the asset identifier
// details.Metadata — key-value metadata (if set during issuance)
Delegation
Delegation solves the VTXO liveness problem: VTXOs expire if not renewed. A delegate service (e.g., Fulmine) participates in batches on your behalf, rolling VTXOs over before expiry.
Automated Delegation
When AddArkDelegation is configured, the SDK automatically:
- Derives delegate contracts — HD wallets produce
ArkDelegateContractinstead ofArkPaymentContractfor Receive/SendToSelf operations - Auto-delegates VTXOs — when VTXOs arrive at delegate contract addresses, the SDK builds partially signed intent + ACP forfeit txs and sends them to the delegator
services.AddArkCoreServices();
// Enable automated delegation (Fulmine delegator gRPC endpoint)
services.AddArkDelegation("http://localhost:7012");
// That's it. HD wallets will now:
// - Derive ArkDelegateContract for new receive/change addresses
// - Auto-delegate incoming VTXOs to the delegator on receipt
// nsec wallets (hashlock/note contracts) are unaffected.
The delegate contract has three spending paths, committed to the taproot tree in this order
([CollaborativePath, ExitPath, DelegatePath]) to match the canonical Arkade SDK layout:
- CollaborativePath (User + Server, 2-of-2) — collaborative spending, same as a regular payment contract
- ExitPath (User only, after CSV delay) — unilateral recovery
- DelegatePath (User + Delegate + Server, 3-of-3) — used by the delegator for ACP forfeit txs
The auto-delegation monitor skips any VTXO whose value cannot cover the operator's intent fee and
still leave at least the server dust threshold — the delegation would be rejected with
AMOUNT_TOO_LOW. Consolidate bare-dust VTXOs (typically asset VTXOs) with a larger VTXO to make them
delegable; the skip is not sticky, so the VTXO is re-evaluated on its next storage notification.
Manual Delegation
For fine-grained control, you can manually construct delegate contracts and delegate VTXOs:
// Get delegator info
var info = await delegationService.GetDelegatorInfoAsync();
// Create a delegate contract
var delegateContract = new ArkDelegateContract(
serverInfo.SignerKey,
serverInfo.UnilateralExit,
userKey,
KeyExtensions.ParseOutputDescriptor(info.Pubkey, network),
cltvLocktime: new LockTime(currentHeight + 100)); // optional safety window
// Send VTXOs to the delegate contract address
await spendingService.Spend(walletId,
outputs: [new ArkTxOut(delegateContract.GetArkAddress(), amount)]);
// Delegate to the delegator
await delegationService.DelegateAsync(
intentMessage: intentJson,
intentProof: proofPsbtBase64,
forfeitTxs: forfeitTxHexArray,
rejectReplace: false);
The CLTV locktime is optional — when set, it prevents the delegate from acting before a specific block height, giving the owner a safety window.
Custom Contract Delegation
The SDK uses an IDelegationTransformer pattern to support delegating different contract types. The built-in DelegateContractDelegationTransformer handles ArkDelegateContract VTXOs and is registered by AddArkDelegation. Register additional transformers for other contract types after calling AddArkDelegation:
services.AddArkDelegation("http://localhost:7012");
services.AddTransient<IDelegationTransformer, MyCustomDelegationTransformer>();
Note:
DelegationServiceand the defaultIDelegationTransformerare only registered byAddArkDelegation.AddArkCoreServicesalone does not include delegation services.
Each transformer implements:
CanDelegate(walletId, contract, delegatePubkey)— check eligibilityGetDelegationScriptBuilders(contract)— return (intentScript, forfeitScript) for building delegation artifacts
Collaborative Exits (On-chain)
Move funds from Arkade back to the Bitcoin base layer:
var btcTxId = await onchainService.InitiateCollaborativeExit(
walletId,
new ArkTxOut(bitcoinAddress, Money.Satoshis(50_000)));
Settlement (Threshold-Based Payouts)
Settlement moves value out of a wallet once its balance reaches a configured threshold — to an Arkade address, to on-chain Bitcoin, or to a rail you plug in yourself. It is separate from SweeperService, which consolidates VTXOs back into the same wallet.
Three layers, each replaceable on its own: ISettlementPolicy instances yield plans (when and how much) the way ISweepPolicy yields coins, CompositeSettlementService routes each plan to the first ISettlementService that accepts the destination, and that rail moves the funds. The background SettlementService joins them, re-evaluating a wallet on VTXO changes, intent transitions, and a periodic heartbeat.
services.AddArkSettlement();
// Optional: also settle to on-chain Bitcoin addresses via a collaborative exit.
services.AddArkSettlement(options => options.EnableCollaborativeExit = true);
The engine stays inert until you supply the rules — the SDK persists none of its own:
public class MySettlementConfigProvider(IMySettingsStore store) : ISettlementConfigProvider
{
public async Task<IReadOnlyCollection<SettlementConfig>> GetConfigs(
string? walletId = null,
CancellationToken cancellationToken = default)
{
var settings = await store.GetPayoutSettings(walletId, cancellationToken);
return settings
.Select(setting => new SettlementConfig(
setting.WalletId,
SettlementDestination.Ark(setting.PayoutAddress),
Threshold: 100_000))
.ToArray();
}
}
services.AddSingleton<ISettlementConfigProvider, MySettlementConfigProvider>();
The threshold gates when a settlement fires, not how much moves: a wallet configured at 100 000 sats that reaches 250 000 settles all 250 000. Cap a single settlement with SettlementConfig.MaxAmount.
Settling an Arkade asset
A rule settles one denomination, named by SourceAsset: satoshis for BTC (the default), atomic units for an Arkade-issued asset. Both are measured independently — an asset rule never fires on the wallet's satoshi balance, and the dust an asset VTXO carries never counts towards a BTC rule:
// Pay out USDT0 once the wallet holds 500 000 units of it, whatever its BTC balance is.
new SettlementConfig(
walletId,
SettlementDestination.ArkAsset(payoutAddress, usdt0AssetId),
Threshold: 500_000,
SourceAsset: usdt0AssetId);
ArkAssetSettlementService, registered by AddArkSettlement(), moves the asset to another Arkade address (or consolidates it onto a fresh address of the same wallet when the destination has none), returning the remainder as asset change and topping up the dust carriers from the wallet's BTC coins. The wallet's auto-sweep destination is not applied to asset outputs — they stay where the rule points.
That rail transfers, it does not convert. Settling USDT0 into a different asset is a conversion: register a rail whose CanSettle accepts the destination and read SettlementRequest.SourceAsset for what it is handed.
public class Usdt0ToUsdcSettlementService : ISettlementService
{
public bool CanSettle(SettlementDestination destination) => destination.Is("base", "USDC");
public async Task<SettlementResult> SettleAsync(
SettlementRequest request, CancellationToken cancellationToken = default)
{
// request.SourceAsset is the Arkade asset leaving the wallet,
// request.Amount its atomic units.
var quote = await broker.Convert(request.SourceAsset, request.Amount, cancellationToken);
return new SettlementResult(
quote.Id, request.Amount, 0, quote.FeeSats,
DestinationAtomicAmount: quote.DeliveredUnits);
}
}
A destination is just a network, an asset, and an address — all free-form strings. The SDK defines only the two it settles itself (ark, bitcoin); anything else is a rail you register, and the SDK never has to learn about it:
// A destination the SDK knows nothing about
var destination = new SettlementDestination("base", "USDC", "0x…");
public class UsdcSettlementService : ISettlementService
{
public bool Available => true;
public string? UnavailableReason => null;
public bool CanSettle(SettlementDestination destination) => destination.Is("base", "USDC");
public Task<SettlementResult> SettleAsync(
SettlementRequest request, CancellationToken cancellationToken = default) => /* … */;
}
services.AddSingleton<ISettlementService, UsdcSettlementService>();
The engine serialises settlements per wallet, but does not deduplicate across retries: a settlement broadcast without its result coming back looks the same as one that never ran, and the heartbeat will fire the rule again. Record attempts from PostSettlementActionEvent and gate the wallet with an ISettlementGate while one is in flight — the SDK persists nothing itself.
PostSettlementActionEvent is raised for every attempt. See docs/articles/settlement.md for policies, gates, trigger sources, and options.
Querying Intents by Proof
Retrieve registered intents by proving ownership of any input coin via a BIP-322-style proof:
// Create a signed ownership proof for a coin
var (proof, message) = await IntentProofHelper.CreateIntentOwnershipProofAsync(
coin, signer, network);
// Query arkd for intents registered with this coin
var intents = await transport.GetIntentsByProofAsync(proof, message);
The IntentProofHelper.CreateBip322Psbt and IntentProofHelper.SignBip322Proof building blocks are also available separately for delegation and other proof flows.
Boarding (On-chain → Arkade)
Boarding lets users move onchain Bitcoin UTXOs into the Arkade VTXO tree. The user deposits BTC to a boarding address (a P2TR output with a collaborative spend path and a CSV-locked unilateral exit). Once confirmed, the intent/batch pipeline automatically picks up the boarding UTXO — no manual intervention needed.
1. Derive a Boarding Address
var boardingContract = (ArkBoardingContract)await contractService.DeriveContract(
walletId,
NextContractPurpose.Boarding);
// Get the on-chain P2TR (bc1p...) address for the user to deposit BTC to
var onchainAddress = boardingContract.GetOnchainAddress(network);
2. Sync On-chain UTXOs
BoardingUtxoSyncService polls a blockchain indexer for confirmed UTXOs at your boarding addresses and upserts them into VTXO storage. It depends on IBitcoinBlockchain — register one of the built-in backends:
// Option A: Esplora (mempool.space, Chopsticks, etc.)
// ArkNetworkConfig.{Mainnet,Mutinynet,Regtest} carry per-network
// endpoint defaults that mirror the canonical Arkade ts-sdk:
//
// Network EsploraUri ElectrumWsUri ElectrumTcpUri
// Mainnet https://mempool.arkade.sh/api wss://electrum.arkade.sh tcp://electrum.arkade.sh:50001
// Mutinynet https://mempool.mutinynet.arkade.sh/api wss://electrum.mutinynet.arkade.sh tcp://electrum.mutinynet.arkade.sh:50001
// Regtest http://localhost:3000 ws://localhost:50003 tcp://localhost:50000
//
// ElectrumWsUri is the websocket URL — wss://electrum.arkade.sh
// terminates at the host's port 443. ElectrumTcpUri is verified at the
// protocol layer against `server.version`: public Ark Labs Fulcrum
// instances only expose :50001 (plain Electrum binary protocol). 50002
// TCP+TLS is NOT exposed — for TLS use the WSS endpoint via
// ElectrumWsUri. (ts-sdk's source comment listing 50001/50002/50003 is
// stale.) Regtest uses nigiri's electrs on :50000 for the binary
// protocol — 30000 on the same host is electrs's HTTP REST, a
// different protocol.
services.AddEsploraBlockchain(new Uri(ArkNetworkConfig.Mainnet.EsploraUri!));
// or pass your own URL: services.AddEsploraBlockchain(new Uri("https://mempool.space/api/"));
// Option B: NBXplorer (BTCPay Server, self-hosted)
services.AddNBXplorerBlockchain(network, new Uri("http://localhost:32838"));
// Option C: Bitcoin Core RPC (does NOT support UTXO lookup — chain time
// + broadcast + fee estimation only; pair with one of the above if you
// also need boarding sync)
services.AddRpcBlockchain(rpcClient);
services.AddSingleton<BoardingUtxoSyncService>();
// Register the poll service — automatically polls every 30s
// when unspent boarding VTXOs exist
services.AddSingleton<BoardingUtxoPollService>();
services.AddHostedService(sp => sp.GetRequiredService<BoardingUtxoPollService>());
The BoardingUtxoPollService automatically checks for unspent boarding VTXOs every 30 seconds and syncs confirmation state changes. It complements event-driven sync (e.g., NBXplorer transaction events) to catch missed events during provider reconnects or block confirmations.
Once a boarding UTXO is synced and confirmed, the SDK's IntentGenerationService automatically creates an intent for it. The next batch moves it into the VTXO tree.
While a boarding UTXO is still in the mempool it is stored with Metadata["Confirmed"] = "False". Such VTXOs report ArkVtxo.IsUnconfirmedOnchain() == true, are excluded from SpendingService.GetAvailableCoins, and cannot be settled (arkd rejects unconfirmed boarding inputs). Display them as pending rather than spendable until the funding tx confirms.
3. Handle Expired Boarding UTXOs (Optional)
If a boarding UTXO isn't batched before its CSV timelock expires, OnchainSweepService detects it. Register a custom IOnchainSweepHandler to control what happens:
public class MySweepHandler : IOnchainSweepHandler
{
public async Task<bool> HandleExpiredUtxoAsync(
string walletId, ArkVtxo vtxo, ArkContractEntity contract,
CancellationToken ct)
{
// Sweep to a new boarding address, cold storage, etc.
return true; // true = handled, false = fall back to default
}
}
services.AddSingleton<IOnchainSweepHandler, MySweepHandler>();
Then call SweepExpiredUtxosAsync() periodically:
var sweepService = new OnchainSweepService(
vtxoStorage, contractStorage, chainTimeProvider,
contractService, walletProvider, sweepHandler);
await sweepService.SweepExpiredUtxosAsync(ct);
Unilateral Exit
If the Arkade server goes offline or becomes uncooperative, users can unilaterally exit by broadcasting the chain of virtual transactions from commitment tx to their VTXO leaf, waiting a CSV timelock, then claiming funds onchain.
Setup
services.AddUnilateralExit(
configureVirtualTx: opts =>
{
opts.DefaultMode = VirtualTxMode.Lite; // Default: txids + expiry only; hex fetched on exit
opts.MinExitWorthAmount = 1000; // Skip tiny VTXOs not worth exiting
},
configureWatchtower: opts =>
{
opts.PollInterval = TimeSpan.FromSeconds(60);
});
// Wire the single IBitcoinBlockchain (chain time + UTXO lookup + broadcast +
// package broadcast + tx status + fee estimation) in one call. Pick the
// backend you have a client for: AddNBXplorerBlockchain, AddEsploraBlockchain,
// or AddRpcBlockchain. RPC does not implement UTXO lookup (Bitcoin Core has
// no native address index). Last registration wins — register a custom
// impl afterwards to swap the whole backend.
services.AddNBXplorerBlockchain(explorerClient);
// Opt in to durable EF Core storage for sessions + chains (mirrors the
// payment-tracking entity opt-in). Skip if you'd rather use in-memory
// storage or the stateless one-shot API below.
modelBuilder.ConfigureArkExitEntities();
// Opt in to background pre-fetching of chain data on every VTXO arrival
// (subscribes to IVtxoStorage.VtxosChanged). Without this, chains are
// fetched lazily when StartExitAsync is invoked.
services.AddVirtualTxAutoFetch();
// Optional: run watchtower as background service
services.AddExitWatchtowerBackgroundService();
Starting an Exit
var exitService = serviceProvider.GetRequiredService<UnilateralExitService>();
// Exit specific VTXOs
var sessions = await exitService.StartExitAsync(
walletId,
vtxoOutpoints,
claimAddress, // Bitcoin address to receive claimed funds
cancellationToken);
// Or exit all VTXOs in a wallet
var sessions = await exitService.StartExitForWalletAsync(
walletId, claimAddress, cancellationToken);
Progressing Exits
Call ProgressExitsAsync periodically to advance exit sessions through their state machine:
// Broadcasting → AwaitingCsvDelay → Claimable → Claiming → Completed
await exitService.ProgressExitsAsync(cancellationToken);
The exit watchtower background service does this automatically if registered.
CSV Delay: Block-Based vs Time-Based
The server's unilateral-exit delay (ArkServerInfo.UnilateralExit) is a BIP-68 relative timelock, not a block count. arkd advertises a single integer that it overloads: below 512 it means blocks, 512 or above it means seconds (arkd's production default is 86400, i.e. 24 hours). The SDK decodes that into an NBitcoin.Sequence, and the two encodings mature under completely different rules:
LockType |
Delay read from | Matures when |
|---|---|---|
SequenceLockType.Height |
Sequence.LockHeight (blocks) |
tip height ≥ leaf confirmation height + delay |
SequenceLockType.Time |
Sequence.LockPeriod (512-second units) |
tip median time past ≥ leaf block's MTP + delay |
Never do arithmetic on the raw Sequence.Value: a time-based lock sets SEQUENCE_LOCKTIME_TYPE_FLAG (bit 22), so a 24-hour delay reads as 4194472. Branch on LockType, or use the SDK's helper:
var maturity = await CsvMaturity.EvaluateAsync(
serverInfo.UnilateralExit,
leafConfirmationHeight,
blockchain,
ct);
if (maturity.IsMatured) { /* claim */ }
else logger.LogDebug("CSV not matured: {Progress}", maturity.Progress);
UnilateralExitService uses this internally for both the stateful and stateless paths, so no extra wiring is needed for the built-in flow. Two requirements fall out of it for custom IBitcoinBlockchain implementations:
GetChainTimemust return the tip's median time past (BIP 113) asTimeHeight.Timestamp, not the tip block's ownnTime.GetMedianTimePastAsync(blockHeight)must resolve a historical block's MTP. It has a default implementation that throwsNotSupportedException, so a backend that skips it fails loudly on a time-based delay instead of stalling. All three built-in backends (NBXplorerBlockchain,EsploraBlockchain,RpcBlockchain) implement it.
Virtual Tx Storage Modes
- Lite mode (default): Stores only txids + expiry. Fetches hex on demand when exit is actually started (saves storage, slower exit start). Right default for most wallets — the common case never exits unilaterally.
- Full mode: Fetches and stores raw tx hex on VTXO receive. Ready for instant exit without any indexer round-trip. Opt in via
opts.DefaultMode = VirtualTxMode.Fullwhen offline-exit capability is a hard requirement.
No-Storage Modes
Two ways to use unilateral exit without paying the EF Core schema cost:
1. In-memory storage — same code paths as the durable flow (idempotent re-invocation, watchtower visibility) but state is held in ConcurrentDictionarys and lost on process restart. Right for recovery tooling, plugins, or ephemeral wallets that don't need cross-restart resume.
services.AddUnilateralExit();
services.AddInMemoryExitStorage(); // registers InMemoryExitSessionStorage + InMemoryVirtualTxStorage
// Don't call ConfigureArkExitEntities() — no SQL tables needed
2. Stateless API — UnilateralExitService.BroadcastExitChainAsync + ClaimMaturedExitAsync skip both IExitSessionStorage and IVirtualTxStorage entirely. The SDK persists nothing exit-specific; the caller saves the returned ExitPlan record however they want and feeds it back to claim once the CSV timelock matures.
BroadcastExitChainAsync broadcasts at most one not-yet-onchain tx per call, not the whole chain — call it repeatedly (like ProgressExitsAsync) until the chain is fully confirmed. This is required by Bitcoin Core's TRUC/v3 relay policy (BIP 431): each tx gets its own CPFP child to pay its zero fee, and a v3 tx can have at most 1 unconfirmed descendant, so the next tx in the chain can't be broadcast until the previous one confirms on-chain (the same constraint go-sdk and ts-sdk's unroll sessions are built around).
// Call on an interval until the whole chain is confirmed (mirrors ProgressExitsAsync)
ExitPlan plan;
while (true)
{
plan = await exitService.BroadcastExitChainAsync(
walletId, vtxoOutpoint, claimAddress, ct);
var leafStatus = await blockchain.GetTxStatusAsync(uint256.Parse(plan.LeafTxid), ct);
if (leafStatus.Confirmed) break;
await Task.Delay(pollInterval, ct);
}
// ... persist `plan` somewhere (a JSON blob, a settings entry, etc.) ...
// Later — once the CSV timelock matures:
var claimTxid = await exitService.ClaimMaturedExitAsync(plan, ct);
if (claimTxid is null)
{
// CSV not yet matured; try again later
}
Trade-off vs. the stateful path: no idempotency (re-broadcasting an already-confirmed link is a no-op, but the caller must track when to stop polling), no automatic watchtower progression. The caller owns persistence and time-keeping in their own format.
Virtual tx data is automatically pruned when VTXOs are spent. Sibling VTXOs sharing internal tree nodes naturally deduplicate — shared nodes are only cleaned up when no VTXO references them.
Contracts
Derive receiving addresses and manage contracts:
// Derive a new receive contract (generates a new Arkade address)
var contract = await contractService.DeriveContract(
walletId,
NextContractPurpose.Receive);
// The contract's script can be converted to an ArkAddress for display
Contract scope (onchain / offchain)
Every contract type declares which layer(s) its funds live on via
ArkContract.DefaultScope, a [Flags] ContractScope (Onchain, Offchain, or
both). Boarding contracts are Onchain; payment, VHTLC, delegate, hash-lock,
note and unknown contracts are Offchain. This replaces ad-hoc
Type == "Boarding" checks — sync, sweep and recovery ask the scope instead.
The resolved scope is persisted on each contract (ArkContractEntity.Scope) and
is SQL-queryable. Filter by scope through IContractStorage.GetContracts:
// On-chain contracts to poll/sweep (boarding UTXOs, etc.)
var onchain = await contractStorage.GetContracts(scope: ContractScope.Onchain);
// Off-chain contracts (VTXOs) — also matches dual-scope contracts
var offchain = await contractStorage.GetContracts(scope: ContractScope.Offchain);
The filter translates to a SQL bitwise predicate, so a dual-scope contract
(Onchain | Offchain) is returned by both queries. A per-instance override can
be supplied at persistence time via ArkContract.ToEntity(scopeOverride: …);
when omitted, the type's DefaultScope is used.
EF Core note: query scope with the bitwise form (the SDK does this internally);
Enum.HasFlagdoes not translate to SQL. TheScopecolumn ships via the entity configuration — consumers that manage their own schema with EF migrations should add a migration that creates the column (defaultOffchain) and backfills existing boarding rows toOnchain.
HD Wallet Recovery
When importing an HD wallet from its mnemonic, the SDK has no record of contracts the previous instance derived. HdWalletRecoveryService rebuilds that state by sweeping derivation indices via gap-limit and asking each registered IContractDiscoveryProvider whether it ever saw activity at that index.
The default providers ship with the SDK:
IndexerVtxoDiscoveryProvider(AddArkCoreServices) — asks arkd's indexer for VTXOs at the index's payment script.BoardingUtxoDiscoveryProvider(AddArkCoreServices, opt-in via registering anIBitcoinBlockchainwhoseGetUtxosAsyncis implemented — NBXplorer or Esplora) — asks for historical UTXOs at the index's boarding address.
var recovery = serviceProvider.GetRequiredService<HdWalletRecoveryService>();
var report = await recovery.ScanAsync(walletId);
// or with options:
var deepReport = await recovery.ScanAsync(walletId, new RecoveryOptions(GapLimit: 50));
Console.WriteLine($"Highest used index: {report.HighestUsedIndex}");
Console.WriteLine($"Discovered {report.DiscoveredContracts.Count} contract(s)");
Custom discovery sources are added by implementing IContractDiscoveryProvider and registering it in DI; the orchestrator picks them up automatically. See docs/articles/recovery.md for the full API and tuning guidance.
Arkade Signer-Key Rotation
When an Arkade server operator rotates its signing key, the SDK handles re-enrollment automatically — no consumer code required. Three regimes apply depending on when the rotation is detected relative to the cutoff:
| Regime | Condition | Handled by |
|---|---|---|
| Collaborative sweep | Before cutoff (or no cutoff) | ServerKeyRotationSweepPolicy re-enrolls VTXOs under the current signer via the sweeper |
| Wait | After cutoff, before VTXO expiry | CanSpendOffchain and SimpleIntentScheduler exclude the coin — it still needs a forfeit the operator won't co-sign — until it is forfeit-free (swept/unrolled) |
| Recovery re-enroll | Expired VTXO | Intent scheduler; batch session skips forfeit so the old key is not needed |
ContractReconciliationService keeps every SingleKey wallet's "Default" receive contract aligned with the current signer. It triggers automatically on startup, WalletSaved, and ServerInfoChanged — no extra registration needed beyond AddArkCoreServices.
To react to a rotation event in your own service, inject IServerInfoCacheInvalidation:
public class MyService(IServerInfoCacheInvalidation serverInfoCache)
{
public void Start() =>
serverInfoCache.ServerInfoChanged += (_, e) =>
Console.WriteLine($"Arkade server info changed: {e.Reason}");
}
To react when a wallet's sweep destination is auto-disabled because the signer it was keyed to was rotated away, inject IDestinationSafetyNotifier:
public class MyService(IDestinationSafetyNotifier destinationSafety)
{
public void Start() =>
destinationSafety.DestinationDisabled += (_, e) =>
Console.WriteLine($"Destination disabled for wallet {e.WalletId}: " +
$"address {e.Destination} was keyed to deprecated signer {e.DeprecatedServerKey}. " +
"Ask the user to confirm a new sweep destination.");
}
IDestinationSafetyNotifier is DI-aliased to the same ContractReconciliationService singleton that performs detection, so no extra registration is needed — just inject the interface. While the destination is flagged the SDK automatically routes swept funds to a self-output instead of the stale address; the destination resumes once the user re-confirms a fresh one.
See docs/articles/signer-rotation.md for the full rotation model, detection paths, and version/digest header details.
Pending Arkade Transaction Recovery
Arkade offchain transactions are a two-phase Submit → Finalize flow. If the process crashes between phases, the server holds the inputs as in-flight. It only allows the original pending tx to finalize. Without recovery, those coins stay stuck.
PendingArkTransactionRecoveryService reconciles this on every host startup. It pulls the server's view of pending transactions for each wallet, signs the checkpoint PSBTs locally, and finalizes them. It's registered automatically by AddArkCoreServices and wired into ArkHostedLifecycle, so the hands-off path requires no extra setup.
For deterministic timing (e.g. immediately after a user unlock) call it explicitly per-wallet:
var recovery = serviceProvider.GetRequiredService<PendingArkTransactionRecoveryService>();
var finalizedTxIds = await recovery.FinalizePendingArkTransactionsAsync(walletId, ct);
foreach (var txId in finalizedTxIds)
Console.WriteLine($"Recovered & finalized pending tx {txId}");
A pending transaction comes entirely from the server, so it is authorized locally before anything is signed: every checkpoint must pay the spent input's full value into the checkpoint contract this wallet would itself have built, and the accompanying final ark tx must spend exactly those checkpoint outputs while still carrying this wallet's own signature over it. Anything else is rejected with UnauthorizedPendingArkTransactionException and never signed, so recovery only ever completes a spend the wallet itself built and signed. That exception means the server presented something unauthorized; failures rooted in local state (no matching local VTXO for a checkpoint input, or a coin whose contract names no server key) surface as InvalidOperationException instead and imply nothing about the server.
Per-tx failures are logged + raised on RecoveryFailed and the loop continues — one bad pending tx never blocks the rest, and the next host start retries any leftovers. Subscribe to surface a banner or telemetry:
recovery.RecoveryFailed += (_, e) =>
Logger.LogWarning("Recovery failed for {ArkTxId} on {WalletId}: {Error}",
e.ArkTxId, e.WalletId, e.Exception.Message);
See docs/articles/pending-tx-recovery.md for the full flow, sequence diagram, and edge cases.
EF Core Storage
NArk.Storage.EfCore provides ready-made storage implementations. It is provider-agnostic — no dependency on Npgsql or any specific database driver.
DbContext Setup
In your DbContext.OnModelCreating, call ConfigureArkEntities:
public class MyDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ConfigureArkEntities(opts =>
{
opts.Schema = "ark"; // default
// opts.WalletsTable = "Wallets"; // all table names configurable
});
}
}
Storage Options
ArkStorageOptions controls schema, table names, and provider-specific behavior:
services.AddArkEfCoreStorage<MyDbContext>(opts =>
{
opts.Schema = "my_schema";
// PostgreSQL-specific text search on contract metadata
opts.ContractSearchProvider = (query, searchText) =>
query.Where(c => EF.Functions.ILike(c.Metadata, $"%{searchText}%"));
});
SQLite consumers: enable StoreDateTimeOffsetAsTicks
EF Core's SQLite provider rejects ORDER BY on DateTimeOffset columns, which breaks every paged query in this SDK (GetVtxos, GetContracts, GetIntents, …). Set StoreDateTimeOffsetAsTicks = true in ConfigureArkEntities (and ConfigureArkPaymentEntities if you use payment tracking) to store these columns as INTEGER ticks instead — ORDER BY then works natively.
modelBuilder.ConfigureArkEntities(opts => opts.StoreDateTimeOffsetAsTicks = true);
Off by default to preserve native column types for Postgres/MSSQL consumers. Trade-off: the round-trip strips the original timezone offset (reads back as UTC). See docs/articles/storage.md for migration paths and details.
Entities
| Entity | Table | Primary Key |
|---|---|---|
ArkWalletEntity |
Wallets |
Id |
ArkWalletContractEntity |
WalletContracts |
(Script, WalletId) |
VtxoEntity |
Vtxos |
(TransactionId, TransactionOutputIndex) |
ArkIntentEntity |
Intents |
IntentTxId |
ArkIntentVtxoEntity |
IntentVtxos |
(IntentTxId, VtxoTransactionId, VtxoTransactionOutputIndex) |
Payment-tracking entities (ArkPaymentEntity, ArkPaymentRequestEntity) are opt-in — see Payment Repository below.
Payment Repository
The SDK includes an opt-in payment repository for tracking end-to-end payments — both outbound (sends) and inbound (payment requests). This replaces the need for consumers to build their own payment-to-protocol linkage.
Opt-In Setup
Payment tracking is not wired up by AddArkEfCoreStorage / ConfigureArkEntities — consumers that don't need it carry no extra schema or services. To enable it, call both the DI and model extensions:
// OnModelCreating — alongside ConfigureArkEntities
modelBuilder.ConfigureArkEntities(opts => opts.Schema = "ark");
modelBuilder.ConfigureArkPaymentEntities(opts => opts.Schema = "ark");
// DI — alongside AddArkEfCoreStorage
services.AddArkEfCoreStorage<MyDbContext>();
services.AddArkPaymentTracking();
AddArkPaymentTracking registers IPaymentStorage, IPaymentRequestStorage, and the PaymentTrackingService (as an IHostedService, so its event subscriptions activate on startup). After calling ConfigureArkPaymentEntities, add the corresponding EF Core migration so the Payments and PaymentRequests tables are created.
Outbound Payments (ArkPayment)
Track a payment you're sending, linked to the protocol object that proves it:
var payment = new ArkPayment(
PaymentId: Guid.NewGuid().ToString(),
WalletId: walletId,
Recipient: "tark1q...",
Amount: 50_000,
Method: ArkPaymentMethod.ArkSend,
Status: ArkPaymentStatus.Pending,
FailReason: null,
CreatedAt: DateTimeOffset.UtcNow,
CompletedAt: null)
{
IntentTxId = intentTxId // links to the Arkade intent
};
await paymentStorage.SavePayment(payment);
// Query payments
var pending = await paymentStorage.GetPayments(
walletIds: [walletId],
statuses: [ArkPaymentStatus.Pending]);
Payment methods: ArkSend, CollaborativeExit, SubmarineSwap, ChainSwap.
Proof fields: IntentTxId (Arkade sends), SwapId (swaps), OnchainTxId (collab exits).
Inbound Payment Requests (ArkPaymentRequest)
Generate a payment request with multiple payment options:
var request = new ArkPaymentRequest(
RequestId: Guid.NewGuid().ToString(),
WalletId: walletId,
Amount: 100_000, // null = any amount (donation-style)
Description: "Order #1234",
Status: ArkPaymentRequestStatus.Pending,
ReceivedAmount: 0,
CreatedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1))
{
ArkAddress = "tark1q...",
BoardingAddress = "bcrt1p...",
LightningInvoice = "lnbcrt...",
ContractScripts = [arkScript, boardingScript], // scripts to watch
SwapId = reverseSwapId // if Lightning enabled
};
await paymentRequestStorage.SavePaymentRequest(request);
// Look up by script (for matching incoming VTXOs)
var matched = await paymentRequestStorage.GetPaymentRequestByScript(vtxoScript);
Automatic Status Tracking (PaymentTrackingService)
The PaymentTrackingService subscribes to VtxosChanged, IntentChanged, and SwapsChanged events and automatically updates payment statuses:
- Outbound: When an intent succeeds/fails or a swap settles/fails, the linked
ArkPaymentmoves toCompletedorFailed. - Inbound: When a VTXO arrives on a watched contract script, the
ArkPaymentRequestaccumulatesReceivedAmountand transitions toPaid(orPartiallyPaidfor fixed-amount requests). Overpayment is tracked in theOverpaymentproperty.
It is registered by AddArkPaymentTracking() (see Opt-In Setup above) and runs as an IHostedService, so its event subscriptions activate automatically on application startup — no manual resolution needed.
Fulfillment Rules
- Any-amount requests (
Amount = null):Paidimmediately on first funds received. - Fixed-amount requests:
PaidwhenReceivedAmount >= Amount. No underpayment tolerance. - Overpayment: Tracked via
ArkPaymentRequest.Overpayment(sats above the target). Status is stillPaid. - Expiration: Handled externally (timer/cron), not by the tracking service.
Networks
Pre-configured network environments:
// Fluent builder
builder.AddArk().OnMainnet();
builder.AddArk().OnMutinynet();
builder.AddArk().OnRegtest();
builder.AddArk().OnCustomGrpcArk("http://my-ark-server:7070");
// IServiceCollection
services.AddArkNetwork(ArkNetworkConfig.Mainnet);
services.AddArkNetwork(new ArkNetworkConfig(
ArkUri: "http://my-ark-server:7070",
Batch Expiry Validation
The batch expiry an Arkade server declares becomes the timelock on the sweep leaf — the operator's
only unilateral path out of the batch output. Tree validation can't vouch for it, since the sweep root
it checks against is derived from that same expiry. The SDK bounds it directly, on by default,
before confirming registration; a rejected batch fails the intent with InvalidBatchExpiryException.
| Mainnet / testnet / signet | Regtest | |
|---|---|---|
| Block-typed expiry | rejected | allowed, minimum 10 blocks |
| Seconds-typed expiry | minimum 24 hours | minimum 512 seconds |
Configure only if a server's expiry is legitimately below these:
builder.AddArk().ConfigureBatchExpiry(options =>
{
options.MinimumExpiry = TimeSpan.FromHours(6);
});
Floors can be lowered but not disabled — zero or less throws at startup, as does a MinimumExpiry
below 512 seconds, which would round down to zero and accept anything. See
Batch Expiry Validation for the attack it prevents, the
AllowBlockTypedExpiry escape hatch, and BIP-68 granularity.
Lightning Corridors (NArk.ArkadeIntents)
A second route between Arkade and Lightning, alongside the Boltz integration above. Terms are negotiated over RFQ with any solver serving the pair, and settle into a covenant swap contract that neither side has to stay online for. Full details in docs/articles/lightning-corridors.md.
There is no accept message: funding your own derivation is acceptance. You derive the swap
contract locally, compare it against the solver's lockup_address, and fund only on a match — which
is what makes a wrong or hostile solver able to produce only an address you decline, never one that
traps your funds.
Sending — pay a BOLT11 from an Arkade balance
// One service over every corridor. Register it once and reach all of them through it.
var intents = new ArkadeIntentsService(
assetSwaps, lightningSend, lightningReceive, intentStorage, vtxoStorage, TimeProvider.System);
var funded = await intents.SendToLightningAsync(
walletId: "my-wallet",
invoice: "lnbcrt500000n1p...",
rfqTransport: new HttpRfqTransport(httpClient, new Uri("http://localhost:3000")));
// Or reach a solver that has no inbound port at all, which is how they run in production. Build it
// from the card, so the whole advertised relay set is dialled rather than one entry of it:
// using var relay = NostrRfqTransport.ForCard(card);
// Refund once the locktime passes, if it never filled. Yours to call whenever you want it
// back — `AdvanceAllAsync` will also sweep it, but it is not the only way in:
await intents.RefundLightningSendAsync(funded.RfqId);
Receiving — be paid over Lightning, take delivery on Arkade
var pending = await intents.ReceiveFromLightningAsync(
walletId: "my-wallet",
amountSats: 50_000,
rfqTransport: rfqTransport,
covclaimdPubKey: covclaimdPubKey); // read live from covclaimd, never hardcoded
Console.WriteLine($"have the payer settle: {pending.Invoice}");
// Once the solver funds the lockup — the monitor moves the intent to Claimable:
await intents.ClaimLightningReceiveAsync(pending.RfqId);
On this corridor you choose the secret and send only its hash, plus a copy sealed to covclaimd the solver cannot open. The solver funds the Arkade side before the payment it is owed has settled, so a solver able to open that packet could settle the invoice without ever delivering.
Claiming publishes the preimage, which is also how the solver gets paid — an unclaimed swap is one where it reclaims its lockup and the payer's money was never earned. The preimage is persisted before the invoice goes out, since nothing can re-derive it afterwards.
Finding a solver
SolverDiscoveryService reads the per-network index the registry publishes and hands back the
markets. Which solver to trade with is the caller's decision — this only supplies the facts.
var markets = await discovery.DiscoverMarketsAsync("mutinynet");
var ranked = SolverDiscoveryService.FilterAndRank(
markets, baseAssetId: "arkade:mutinynet/slip44:1",
quoteAssetId: "bolt11:mutinynet/slip44:1", baseAmount: 30_000);
foreach (var m in ranked)
{
// Both halves of the rendezvous travel with the market.
Console.WriteLine($"{m.Solver} {m.PairKey()} fee={m.TotalFeeOn(30_000)} " +
$"key={m.DiscoveryPubkey} relays={string.Join(",", m.Transports?.Nostr?.Relays ?? [])}");
}
Ranking is by the total fee at the size being traded, never by fee_bps alone: a market with a
lower spread and a flat fee is dearer at small sizes and cheaper at large ones.
Discovery accepts v0 and v1 cards, including cards without a display pair. Identity comes from
CAIP-19: AssetDescriptor.CanonicalId prefers caip19_id in a compatibility index over its legacy
id projection. Network-mismatched markets and v0 external-chain markets are excluded. V1 EIP-155
identities remain distinct by chain and token. The EVM send primitives below bind that identity to
the RFQ and chain proof; discovery itself still does not execute a swap.
Use an explicit adapter at the current solver's legacy RFQ boundary:
var from = AssetIdentifier.FromLegacy("btc", "mutinynet", "arkade");
var to = AssetIdentifier.Parse("bolt11:mutinynet/slip44:1");
var pair = LegacyRfqPairAdapter.FromCanonical(from, to);
// pair == "arkade:BTC->lightning:BTC"
The generic adapter refuses a cross-network pair instead of guessing a ticker. For an explicit
EIP-155 ERC20 identity it emits the current solver's legacy arkade:BTC->ethereum:0x… spelling;
native EVM assets remain unsupported.
RFQ AtomicAmount, FromAtomicAmount, ToAtomicAmount and market *AtomicAmount bounds use
BigInteger. They read arbitrary-width canonical decimal strings, accept non-negative safe JSON
integers for compatibility, and write decimal strings. Existing long amount properties remain
checked accessors for sats APIs: they throw on overflow rather than truncate.
Quotes and statuses require RFQ v1 and the requested correlation id; refusals retain diagnostics
through RfqRefusedException.Refusal, including ErrorCode, Field, Actual, Expected and Limit.
Sending Arkade BTC to an ERC20
EvmSendProfile.Request preserves the current wire contract: Arkade satoshis are a JSON number on
the from side, while the quoted ERC20 atomic amount remains a full-width BigInteger decimal
string. Before funding, validate the quote twice: EvmSendQuoteValidator binds the amount, payment
hash, chain, token, swap contract, proof depth/age and cross-chain deadlines;
EvmArkadeLockupValidator rebuilds the VHTLCv2 from live operator data and the local refund script.
Once the Arkade lockup is funded and EVM state proves the solver's matching lock,
EvmSwapChainClient checks swaps(key) at tip and at the configured depth, checks block age and
timeout, and submits claimFor with the client's stored preimage through a host-provided signer.
It accepts delivery only after the receipt, canonical Claim event/preimage, exact configured-token
Transfer event and consumed swap state agree. The public EVM RFQ status route currently returns 404, so
applications must derive progress from Arkade and EVM state rather than inventing solver status.
Server applications can use EvmJsonRpcClient for bounded HTTP JSON-RPC and
EvmLocalTransactionSender for locally signed EIP-1559 transactions. Configure explicit fee and gas
ceilings, a receipt timeout, and the expected address derived from the gas-payer key. Load the 32-byte
key into a short-lived buffer from a secret provider, construct the sender, then clear the caller's
buffer. Never bind the key through application configuration, JSON options, or logging.
builder.Services.AddEvmSwapChainExample(
new Uri(evmRpcUrl),
_ => gasKeyVault.ReadPrivateKey(),
rpcOptions,
senderOptions,
evmSendPolicy,
configureHttpClient: client => client.DefaultRequestHeaders.Authorization = rpcAuthorizationHeader);
The server-only gateway sample contains the full registration factory, including immediate clearing
of the key buffer returned by the secret provider. URI userinfo alone is not an authentication
guarantee: configure authentication headers on HttpClient, or use a provider-issued endpoint query
token. Do not log the configured endpoint, headers, or secret values.
Nonce allocation is serialized by gas-payer address inside one process. Give that key exclusively to
one BTCPay process; multiple processes or external writers require an external nonce coordinator.
RPC error bodies and node error messages are intentionally discarded because an estimate-gas error
can echo claimFor calldata and its preimage. Response bytes and JSON depth are bounded before
parsing. See the server-only gateway sample and EVM send.
The current solver quotes the nine-leaf nonInteractiveRefundWithoutReceiver shape. Setting
RequireEmulatorRefundPath (the default) refuses an older eight-leaf quote. The composed executor
uses the emulator's non-interactive post-locktime refund path; test that configured service before
promising unattended recovery.
Composed Arkade, Lightning, or onchain receive to ERC20
ComposedSwapClient joins independent, client-side RFQs; a solver does not compose them. It first
persists an exact-input Arkade-to-EVM lock L with a fresh preimage P and hash H, then optionally
creates an exact-output Lightning or onchain ingress lock M using that same H and a
non-interactive M-to-L claim. Create a new P/H for each payment rail and invoice renewal.
var route = await composer.CreateLightningAsync(
walletId, amountSats, merchantEvmAddress, evmSendPolicy,
evmRfq, lightningRfq, covclaimdPubkey,
cancellationToken: cancellationToken);
// A watch-only server can advance the non-interactive M-to-L claim and EVM claim.
var progress = await executor.AdvanceAsync(route.Outgoing.RfqId, route.Ingress.RfqId, cancellationToken);
if (progress.EvmClaimTxid is not null)
MarkMerchantPaymentSettled(progress.EvmClaimTxid, progress.DeliveredAmount);
Ingress funding and the M-to-L claim are not settlement. Only a verified ERC20 claimFor receipt
with the exact transfer is final. M-to-L reveals P before the EVM lock necessarily exists, so
this route relies on the outgoing solver's EVM state machine. The executor can non-interactively
refund L after its deadline. It journals the deterministic EVM transaction id before broadcast and
resumes receipt verification after a restart. P lives in SDK intent metadata for that recovery, so
protect intent storage at rest and never log it. See EVM send and the
gateway ComposedEvmSettlementExample sample for the required registrations.
Reaching a solver over its relay set
A corridor card carries discovery_pubkey and a list of relays, and both halves are required —
its rendezvous is live data a maker will actually contact. ForCard uses all of it:
using var rfq = NostrRfqTransport.ForCard(card);
// or explicitly:
using var rfq = new NostrRfqTransport(relayUris, card.DiscoveryPubkey!);
Every relay is dialled at once and the first valid reply wins. That is the point of a relay set rather than an optimisation: a rendezvous is a place both parties happen to be, and neither side controls which entry the other is connected to at this moment, so dialling one is a coin flip. The same signed event goes to all of them, which a solver connected to several sees as duplicates of one request — idempotent by negotiation id.
Non-wss:// entries are dropped rather than dialled, and duplicates collapse to one connection.
Three different silences, which a transport reporting only timeouts would flatten into one:
| meaning | |
|---|---|
NostrRelayException (timeout text) |
somebody was listening and the solver did not answer |
RelayUnavailableException |
no relay was listening, so the silence says nothing about the solver |
TransportClosedException |
we hung up ourselves — a user left the screen, a flow was abandoned |
The middle one is why this matters. Without it a client waits out the full timeout and then blames
the counterparty for an outage on its own side of the wire. RelayUnavailableException.Reasons
keeps each relay's own failure, so an operator can see which of them was actually broken.
What stays watched
The swap store doubles as the IActiveScriptsProvider that tells the shared VTXO sync which
covenant scripts to poll. A script stays in that set while funds can still be at it — Pending,
Refundable, Claimable, and Recoverable.
That last one is terminal, and watching it anyway is the point: terminal describes the negotiation, not the funds. A swept deposit is still your money sitting at that script, and dropping the script is how it stops appearing in the wallet at all — a silent loss, since nothing reports a script nobody is looking at.
Funding is deliberately left out: its lockup may not exist yet, so polling would watch a script that may never be funded. Reconciliation covers that window instead.
All four are asked for in one query, via the store's statuses filter:
var inFlight = await intentStorage.GetArkadeSwapIntents(
statuses: [ArkadeSwapIntentStatus.Pending, ArkadeSwapIntentStatus.Claimable]);
status (one) and statuses (any of) are separate filters and both apply when both are given,
narrowing rather than widening; an empty set constrains nothing. The watch set is recomputed on every
ActiveScriptsChanged notification, so a round-trip per status is three of them spent on a hot path
to learn what the first could have returned. A custom IArkadeIntentStorage must honour it — the
contract's rule is that every filter given is applied, and one quietly dropped is a caller acting on
somebody else's intent.
Indexes are cached in the service for 10 minutes, keyed by registry URL, so register it as a
singleton — AddArkadeIntentsServices() does. An index older than a week is still used, with a
warning: a stale registry is worse than a fresh one and better than none.
Local cards are merged alongside the published ones, which is the way to reach a solver no registry lists:
var markets = await discovery.DiscoverMarketsAsync(
"mutinynet", localCards: [JsonSerializer.Deserialize<SolverCard>(cardJson, opts)!]);
Pricing a spot offer runs off the same market. The maker names what they want, concedes the solver's spread plus a cushion of their own, and funds:
var price = await discovery.FetchPriceAsync(market); // quote atomic per base atomic
// Deposit sats, receive the asset.
var want = SolverDiscoveryService.ComputeWantAmount(
depositAtomic: 1_000_000, price, market.FeeBps, feeFlat: market.FeeFlatAmount);
// Or name the amount you want, and get quoted the deposit. Exact inverse of the above.
var deposit = SolverDiscoveryService.ComputeRequiredDeposit(
wantAmount: want, price, market.FeeBps, feeFlat: market.FeeFlatAmount);
// Depositing the asset instead, to receive sats:
var sats = SolverDiscoveryService.ComputeWantAmount(
depositAtomic: 250, price, market.FeeBps,
give: MarketSide.Quote, feeFlat: market.FeeFlatAmount);
Pass feeFlat wherever the card declares one — the spread applies to the whole deposit and the flat
fee is charged on top, which is the model the solver's own quote uses. Rounding never favours the
maker, and a combination whose answer no amount can hold throws rather than clamping.
Before quoting, hold the solver to what it published. The bound is on the side the solver pays out — the side you receive — so one card can serve a size in one direction and refuse it in the other:
SolverTerms.AssertWithinLimits(card, "lightning:BTC->arkade:BTC", 30_000);
// throws SolverTermsException; .Reason is BelowMinimum, AboveMaximum,
// DirectionNotServed (the solver does not pay out that side at all), or UnservedCorridor
SolverTerms.AssertFeeWithinAdvertised(card, quote);
Bounding what a payer is billed
A receive request pins one leg and leaves the other to the solver. Pin what the payer is billed
(RfqAmountSide.From) and it is fixed exactly. Pin what lands on Arkade (RfqAmountSide.To) and the
payer's side becomes the solver's free variable — MaxPayAmountSats is what bounds it:
services.AddArkadeIntentsServices(new ArkadeIntentsOptions
{
// Refuse a receive quote billing the payer more than this. Unset means no ceiling.
MaxPayAmountSats = 250_000,
});
A quote above it is refused with LightningReceiveRefusalReason.PriceTooHigh, before its invoice
reaches anyone. Nothing is at risk without it — the amount that lands on Arkade is checked
separately — but a customer handed an invoice for more than the order they approved is a payment
their wallet may refuse outright.
Registration copies every supplied ArkadeIntentsOptions value, including
OnchainClaimConfirmations for automatic off-board claims (default: six). An explicit options
object replaces earlier configured values, including null/default values. With no object, previously
configured payer and confirmation limits are retained. The WASM sample uses manual registration
and keeps the default corridor settings; a custom client factory must pass its options explicitly.
The covenant co-signer
Every swap contract on both corridors commits to a co-signer key, and every party to the swap has to
commit to the same one. That key is a property of the network, so this SDK pins it per network
rather than asking a service which key it signs with — an endpoint that answered would be choosing
what your funds are locked to. AddArkadeIntentsServices() needs no emulator registration for this,
and neither corridor makes a call to derive an address.
The consequence worth knowing: a network that rotates its key is invisible here until this SDK ships the new constant. Covenants keep building against the retired key and it surfaces only when a claim is refused. That is what the override is for:
// Normally omitted — the pin is right.
services.AddArkadeIntentsServices(new ArkadeIntentsOptions
{
// 33-byte compressed hex. A malformed value throws rather than being passed through.
EmulatorPubkeyOverride = "03f823b9b2febc81f4af967e77aed2f541cbd3397c6d8f5a72e32eb7b471af889a",
});
Setting it means co-signing with a different service: every covenant built from it is completable by
whoever holds that key and by nobody else. Reach for it when a network rotates before a release
lands, when you run your own emulator, or on a network with no pin at all (signet, testnet).
To diagnose a refused claim, compare the pin against what the deployment reports:
var pinned = EmulatorPubKeys.DefaultFor(serverInfo.NetworkName);
var agrees = EmulatorPubKeys.AgreesWithPin(serverInfo.NetworkName, (await emulator.GetInfoAsync()).SignerPubkey);
That comparison is a diagnostic only — nothing in the corridors reads the reported key.
Explicit watch-only claims and refunds
Register the emulator spend integration alongside the intent clients. The emulator must hold the key committed by the funded contract; registering an endpoint does not change that commitment.
services.AddArkadeIntentsServices();
services.AddArkadeEmulator(o => o.ServerUrl = "http://localhost:7073");
await intents.ClaimLightningReceiveNonInteractiveAsync(lightningSwapId);
await intents.ClaimOnchainReceiveNonInteractiveAsync(onchainSwapId);
await intents.RefundNonInteractiveAsync(outgoingSwapId);
These explicit methods do not request a wallet signer when the receive preimage is stored. They spend the covenant leaf through the emulator and preserve one pinned, full-value payout per input, including split funding. Claims refuse underfunding before submitting the preimage. Asset-bearing, duplicate, subdust, or per-input strict-floor-violating lockups are refused by these BTC corridor APIs. The existing claim/refund methods remain cooperative and require the wallet signer.
Submitting a claim reveals the preimage to the emulator even if the transaction is rejected. Paying another lockup with the same hash does not keep that secret private or guarantee delivery on another leg. Callers must secure any downstream obligations before claiming; these primitives do not verify an EVM lock or orchestrate a composed swap.
The refund needs the funded ninth leaf, nonInteractiveRefundWithoutReceiver, and waits for
its locktime to mature. Eight-leaf contracts cannot acquire this capability after funding. A
watch-only route must verify that leaf before funding; server and emulator availability are still
required. This is not a unilateral onchain exit or EVM execution API.
For custom orchestration, VHTLCv2Contract.ToNonInteractiveClaimCoin(walletId, vtxo, preimage) and
ToNonInteractiveRefundWithoutReceiverCoin(walletId, vtxo) expose the same leaves. These helpers
are BTC-only and reject asset covenants or VTXOs with attached assets before building a witness. Supply one
corresponding payout per input, exactly to the stored claim/refund script; check complete funding
before claiming and chain maturity before refunding. Do not mix indexed and ordinary inputs.
In the sample wallet
samples/NArk.Wallet runs both corridors in the browser — Send pays a BOLT11 or an LNURL address,
Receive mints an invoice, and the Swap page claims and refunds. It is the Boltz submarine and
reverse swaps this sample used to run, replaced; the Boltz chain swaps stay, having no intent
corridor yet.
The Swap page's signerless checkbox explicitly selects the emulator-backed claim/refund methods; it is off by default and reports a missing ninth refund leaf as an error.
The wiring is Services/ArkadeLightningService.cs, and all of it is one options object:
builder.Services.AddSingleton(new ArkadeLightningOptions
{
CovclaimdUrl = new Uri("http://…"), // optional; see below
});
No solver is named. Which ones exist is answered by the public registry at runtime, and the
sample picks one advertising a Lightning corridor on its network — key and relay both come from the
market entry. ArkadeLightningOptions.RelayUrl is only a fallback for an entry naming no relay.
When the registry lists no Lightning market, the Receive page says so instead of offering an option
that cannot work.
covclaimd is optional. Both corridors work without it; what it adds is a daemon that races the wallet's own claim, so a funded receive is still collected while the browser tab is closed — worth having, because the claim window is a couple of hours.
Both corridors settle end to end against a live solver.
ArkadeLightningTestsinNArk.Tests.End2Enddrives each one through funding, fill and claim: on send the solver pays the invoice and takes the lockup with the preimage; on receive the payer settles a hold invoice, the solver funds Arkade, and our claim publishes the preimage that releases it.They are not part of CI — the solver is not in the regtest stack, so run them deliberately with
--filter TestCategory=LightningCorridorsand pointARKADE_LN_SOLVER_URLat a solver you started yourself. They also drive a Lightning node throughdocker exec … lncli(ARKADE_LND_CONTAINER, defaultlnd) to mint and pay the invoices.Every
NArk.ArkadeIntentsE2E fixture also carries the umbrella categoryArkadeIntents—--filter TestCategory=ArkadeIntentsruns the asset corridor and both Lightning corridors together..github/workflows/e2e-arkade-intents.ymlis the CI job for it, written but not enabled: its caller inbuild.ymlis commented out ande2e-coreexcludes the category, so nothing runs it until a solver is part of the stack.
Both corridors build the same eight-leaf VHTLCv2Contract: the six leaves of the reference VHTLC,
plus nonInteractiveClaim and nonInteractiveRefund, whose co-signer is an emulator key tweaked by
a covenant pinning where the spend may pay.
Both of those leaves are optional, so the ladder is six, seven or eight leaves. The covenant can
also be denominated in an Arkade asset (VHTLCv2Asset) or bound to a quoted amount
(VHTLCv2StrictClaim). Each of those is a different leaf set or a different covenant, hence a
different taproot merkle root and a different address, so the option set is part of what the two
sides must agree on — the corridors above agree on the eight-leaf, sat-only, unbounded shape.
var lockup = new VHTLCv2Contract(
serverInfo.SignerKey, sender, receiver,
preimageHash, refundLocktime,
unilateralClaimDelay, unilateralRefundDelay, unilateralRefundWithoutReceiverDelay,
nonInteractiveClaim: new VHTLCv2NonInteractiveClaim(receiverPkScript, emulatorPubKey),
nonInteractiveRefund: new VHTLCv2NonInteractiveRefund(senderPkScript, emulatorPubKey));
var address = lockup.GetArkAddress();
Because the contract is an agreement about bytes with no wire versioning, the derivation is pinned to golden vectors generated from the counterparty's own implementation — for every option set, not only the corridors'. Regenerate them whenever the solver moves to a newer ts-sdk pin:
node NArk.Tests/ArkadeIntents/Fixtures/generate-covenant-vectors.mjs \
<node-project-with-arkade-sdk> > NArk.Tests/ArkadeIntents/Fixtures/covenant_swap.json
dotnet test NArk.Tests --filter VHTLCv2ContractTests
Onchain Corridors (NArk.ArkadeIntents)
The same RFQ negotiation and the same covenant, with Bitcoin L1 on the far side instead of
Lightning. Both directions are served: arkade:BTC->onchain:BTC off-boards an Arkade balance to L1,
and onchain:BTC->arkade:BTC on-boards L1 sats into one. Full details in
docs/articles/onchain-corridors.md.
Two contracts on two rails, linked by one secret. Whoever funds first holds it, so nothing is ever owed on trust — and because the two rails have independent deadlines, their order is the corridor's central safety property. Neither contract enforces it; the client checks it before funding and refuses a quote that gets it wrong.
Both directions need IBitcoinBlockchain registered. AddArkadeIntentsServices() wires the corridor
only when one is present, so a Lightning-only deployment is unaffected.
Off-boarding — Arkade balance out to L1
You fund the Arkade covenant, the solver funds an L1 HTLC paying you, and your L1 claim publishes the preimage that pays the solver. You move first, so you choose the secret.
var funded = await intents.SendToOnchainAsync(
walletId: "my-wallet",
payoutAddress: BitcoinAddress.Create("bcrt1q...", Network.RegTest),
amountSats: 50_000,
amountSide: RfqAmountSide.To, // pin what lands on L1
rfqTransport: rfqTransport);
Console.WriteLine($"solver must fund {funded.HtlcAddress}");
// Claimed automatically by the advance loop once the solver's funding has the quoted
// confirmations. Callable directly too — "not yet" is an ordinary answer, not an error:
var outcome = await intents.AdvanceAsync(funded.RfqId);
Your recourse is the Arkade covenant's refund, which opens after the solver's L1 one — that order is what stops you reclaiming on Arkade while the solver can still reclaim on L1.
On-boarding — L1 sats into an Arkade balance
The mirror, and the exposure mirrors with it: you fund L1 first and the solver funds Arkade against it, collecting only when your claim publishes the preimage. You still choose the secret, for the same reason you do on the Lightning receive leg.
var pending = await intents.ReceiveFromOnchainAsync(
walletId: "my-wallet",
amountSats: 50_000,
rfqTransport: rfqTransport,
covclaimdPubKey: covclaimdPubKey, // read live from covclaimd, never hardcoded
l1RefundAddress: BitcoinAddress.Create("bcrt1q...", Network.RegTest));
// Fund this from your own Bitcoin wallet — the SDK holds an Arkade wallet, and these sats are by
// definition not in it yet. Fund the address derived here, never the one the quote names.
Console.WriteLine($"send {pending.FundAmountSats} sats to {pending.HtlcAddress}");
// After min_confirmations the solver funds the lockup and the monitor moves the intent to
// Claimable; the advance loop claims it, or you can:
await intents.ClaimOnchainReceiveAsync(pending.RfqId);
By default the payout is a freshly derived receive contract. If you already hold one — an invoice
that derived an address to be paid to, say — pass it as payoutContract and the swap costs no HD
index of its own:
var contract = await contractService.DeriveContract(walletId, NextContractPurpose.Receive);
var pending = await intents.ReceiveFromOnchainAsync(
walletId: "my-wallet",
amountSats: 50_000,
rfqTransport: rfqTransport,
covclaimdPubKey: covclaimdPubKey,
l1RefundAddress: BitcoinAddress.Create("bcrt1q...", Network.RegTest),
payoutContract: contract);
This matters to recovery, not just tidiness: an HD wallet is restored by scanning until
RecoveryOptions.GapLimit consecutive indices come back unused, and an offer that is never funded
leaves whatever it derived behind. A caller that spends two indices per offer reaches that limit at
twice the rate, and what lies past it a seed restore does not find. The cost is that one key appears
in both contracts, linking them — they are one payment, so the link exists either way.
The funding must be exact: one output, holding exactly FundAmountSats. The solver matches the
address for a single output equal to the quote, so underfunding cannot be topped up (a second
payment is a second output, and its claim spends one input), and overfunding is simply not taken —
those sats stay at the HTLC and come back through the refund below. A wrong amount that confirms is
refused immediately rather than left to time out. The quote is short-lived too, so quote per payment
rather than per order. Anything that lets a person type the amount, or accepts partial payment,
needs a second path alongside this one.
If the solver never delivers, the L1 HTLC's own refund leaf is the only way home — there is no Arkade covenant of yours to refund, because you never funded one:
// Ordinary answer while the leaf is immature; it matures against the chain's MEDIAN TIME PAST
// (BIP-113), which trails wall clock by about an hour.
var refund = await intents.RefundOnchainReceiveAsync(pending.RfqId);
The advance loop proposes this refund on every pass, including after the Arkade side has been written
off as Resolved — a claim window that shut unused is exactly the case where the solver never learns
the preimage, never claims on L1, and those sats are still yours to collect.
Restore & Recovery (NArk.ArkadeIntents)
Two different questions, and the SDK keeps them apart because the answers differ in kind. The drive
path asks may I act yet; recovery asks what is actually true. Rows that exist are corrected by
ReconcileAsync; rows that no longer exist are rebuilt here.
Rebuilding asset swaps from the chain
Nothing in an asset swap lives only in the store. The funding transaction carries the offer as an extension packet, the covenant VTXO at the offer's script holds the deposit, and that VTXO's spender says what became of it — so the row is recomputable after the storage backend is gone.
// Candidate txids are supplied, not discovered: any history source serves, and an incremental
// caller persists `Scanned` so the same transaction is never fetched twice.
var result = await intents.RestoreAssetSwapsAsync("my-wallet", sentTxids);
foreach (var r in result.Restored)
Console.WriteLine($"{r.Intent.Id} {r.Intent.Status} cancellable={r.Cancellable}");
// Held an offer, outcome not decidable yet — rescan later. Never recorded as a guess.
Console.WriteLine($"unresolved: {string.Join(", ", result.Unresolved)}");
A restored swap cannot be cancelled. The wire offer carries the maker's x-only key, which is
enough to rebuild the address and not enough to sign — the spendable descriptor was only ever local.
That is a property of the offer format, and RestoredOffer.Cancellable reports it in advance rather
than letting it surface as a failure when somebody tries. A restored swap can still be watched, and
still be filled, which is the outcome it was waiting for.
Rows already present are left completely alone, matched by id. A reconstruction knows strictly less than a live row — the maker descriptor above, for one — so overwriting would lose the ability to cancel a swap that still had it.
Reading what became of a deposit
// Classified by the covenant LEAF the spend took, not by what it moved. Once the covenant is a
// registered contract the deposit joins the wallet's own coins, every wallet-level figure becomes a
// net delta, and an asset cancel — asset out, same asset back — nets to zero and reads exactly like
// its fill. Leaves have no such failure mode, and they survive batching.
var kind = OfferRestore.ClassifySpend(offer, serverKey, network, spendPsbt, deposit);
// Fulfilled | Cancelled | Indeterminate
Indeterminate is not a third outcome — it is the absence of one, so the caller rescans rather than
records. A server key rotated since funding rebuilds a different tree and answers Indeterminate
too, rather than describing somebody else's script with confidence.
Deciding what became of a lockup
var fate = await intents.ReadLockupFateAsync(swapId);
// Unknown | Open | Claimed | Returned | Exited | Swept
Decidable without asking the counterparty anything. The claim leaf can only be spent by revealing
the preimage, and every other leaf is a refund — the covenant's non-interactive one is pinned to
your own address, and the rest need your own signature. So "spent, but not by a hash-verified claim"
means the money came back, and Claimed carries the preimage as proof rather than as a hint.
Three readings are deliberately not verdicts:
Unknownis notReturned. No outputs visible, or a spend the indexer cannot produce. An outage and a genuine refund are the same silence, and reading it as a refund reports the money home while it may have been claimed.ExitedoutranksOpen. A unilaterally exited output is unspent, so a naive read calls the swap "still running" — but it sits on-chain under the same script, where no off-chain claim or refund reaches it. It is not a loss: the leaves are unchanged, so finishing the unroll and spending on-chain still ends the swap.SweptoutranksOpentoo, for the same reason on the other cause.
Refunding what is actually still open
var outcome = await intents.RefundIfUnresolvedAsync(swapId);
// Resolved | NotDue | Refunded | NeedsRecovery | Blocked | Unknown
The recovery entry point, as distinct from RefundLightningSendAsync, which is the action. This one
reads the fate first — a caller coming back after downtime does not know whether the counterparty
already claimed, and pushing a refund at a lockup that settled is a wasted fee. Every outcome is
returned rather than thrown, because the useful caller is a loop and "resolved", "not due" and
"needs recovery" are not failures.
Covers both send legs. The on-board is not among them and cannot be — it never funded an Arkade
covenant, so its recourse is RefundOnchainReceiveAsync on L1.
A partial lockup stops the whole push. If any output is swept or exited, the refund is refused
with LockupNeedsRecoveryException naming the outpoints, rather than refunding the rest:
catch (LockupNeedsRecoveryException e)
{
// e.Fate is Swept or Exited; e.Outpoints is what must be dealt with first.
}
Refunding the remainder would report success over money that never moved, and a caller who believes the swap is refunded stops watching the part still sitting there. Neither cause is recoverable at this layer: a swept output goes through the wallet's own recovery path, an exited one needs its unroll finished and then an on-chain spend of the same leaves.
Blocked is the other non-answer worth branching on — the refund is not this wallet's to push at
all (NoSigner, ContractMissing, ContractMismatch, NoLocktime), which does not resolve by
waiting the way NotDue does. Both recovery exceptions derive from InvalidOperationException, so
an advance loop that already catches that type keeps sweeping the other swaps instead of dying.
Reading an L1 HTLC back off the chain
var status = await OnchainHtlcState.ClassifyAsync(blockchain, htlc, minConfirmations);
// Empty | AwaitingConfirmations | Claimable | Refundable
//
// `Empty` is nothing at the address, and deliberately does not guess which of the two reasons that
// is: never funded and already spent are the same answer from an address query. Your own row knows
// which; where a spend is suspected, ExtractPreimage turns the inference into proof.
// Wait for a fill rather than poll by hand. Returns the last status seen when the time runs out,
// so "it never arrived" stays an answer you can branch on.
var filled = await OnchainHtlcState.AwaitFillAsync(
blockchain, htlc, minConfirmations, within: TimeSpan.FromMinutes(30));
// Recover the secret from whatever spent it — the L1 counterpart of SwapPreimageReader, which reads
// Arkade spends through the indexer and cannot answer for a Bitcoin transaction.
var preimage = OnchainHtlcState.ExtractPreimage(spendingTx, paymentHash);
Refundable means the claim window is closed, not that a claim is still available. Reaching it
on a swap you expected to claim means the claim was missed. Maturity is judged against the chain's
median time past (BIP-113), which trails wall clock by about an hour — classifying on a local clock
would call a window closed while a claim could still have landed.
ArkadeScript & Emulator (NArk.Arkade)
The optional NArk.Arkade package adds client-side support for ArkadeScript — a Bitcoin-Script superset (40+ extension opcodes for transaction introspection, asset queries, EC operations, streaming SHA-256, …) that the emulator co-signs only when the script attached to an input passes validation.
Opcode table. Byte values track the deployed Arkade VM in
arkade-os/emulator(pkg/arkade/opcode.go) — the authority on what each byte executes as. They mostly match the ts-sdkARKADE_OPtable, but the two diverge on0xd7–0xe2(ts-sdk lists 64-bit arithmetic / scriptnum conversion; the emulator runs byte-string + EC ops such asOP_NUM2BIN/OP_ECPAIRING). The emulator wins, since it is what actually executes the script.
Install:
dotnet add package NArk.Arkade
Build a script and resolve the emulator-tweaked signing key:
using NArk.Arkade.Crypto;
using NArk.Arkade.Scripts;
using NBitcoin;
using NBitcoin.Secp256k1;
// Compose an ArkadeScript via the opcode enum + ASM helpers
var bytes = ArkadeScript.AsmToBytes(
"OP_0 OP_INSPECTOUTPUTSCRIPTPUBKEY 1 OP_EQUALVERIFY deadbeef OP_EQUAL");
// GET /v1/info returns a compressed (33-byte hex) signerPubkey. Tweak it for the
// script above to get the x-only key the emulator co-signs that input with:
var info = await emulator.GetInfoAsync();
ECPubKey emulatorPubKey = ECPubKey.Create(Convert.FromHexString(info.SignerPubkey));
TaprootPubKey signingKey = ArkadeTweak.Tweak(emulatorPubKey, bytes);
Pin an ArkadeScript leaf to an ArkContract-based VTXO via the multisig wrapper:
using NArk.Arkade.Scripts;
using NArk.Core.Scripts;
protected override IEnumerable<ScriptBuilder> GetScriptBuilders()
{
// Augmented N+1-of-N+1: alice + bob + tweaked emulator key
yield return new ArkadeNofNMultisigTapScript(
arkadeScript: bytes,
baseOwners: [aliceXOnly, bobXOnly],
emulatorKeys: [emulatorPubKey]);
// Plus your existing CSV / collab-path / etc. leaves alongside it
yield return new UnilateralPathArkTapScript(...);
}
Build the emulator REST client through DI and submit intents / transactions for co-signing:
using NArk.Arkade.Hosting;
// One-liner: registers the REST client AND the IBatchSessionExtension that
// transparently co-signs any batch with arkade-bound inputs.
services.AddArkadeEmulator(opts =>
opts.ServerUrl = "http://localhost:7073");
// Or wire the REST client without batch integration, and inject manually:
services.AddEmulatorClient(opts =>
opts.ServerUrl = "http://localhost:7073");
// Inject IEmulatorProvider and call:
var info = await emulator.GetInfoAsync(); // GET /v1/info (signerPubkey + deprecatedSignerPubkeys)
var signed = await emulator.SubmitTxAsync(...); // POST /v1/tx
var sig = await emulator.SubmitIntentAsync(...); // POST /v1/intent
var fin = await emulator.SubmitFinalizationAsync(...);// POST /v1/finalization
var onchn = await emulator.SubmitOnchainTxAsync(...); // POST /v1/onchain-tx (fully on-chain spends)
Previous-transaction fields (prevarktx / prevouttx)
Emulator v0.0.7+ requires each submitted input to carry the transaction that funded it, whether or not that input's ArkadeScript introspects a previous output. A submission missing it is rejected with missing prevout tx for input N.
For offchain spends (/v1/tx) this is automatic: AddArkadeEmulator registers an IPrevArkTxProvider and ArkadeEmulatorSpendSubmitter annotates the Arkade transaction just before submitting. The provider reads from IVirtualTxStorage when the wallet already holds the VTXO's branch, then arkd's indexer, then — for boarding and commitment parents the indexer cannot serve — IBitcoinBlockchain.GetRawTransactionAsync. So a spend usually costs no extra round-trip. The field belongs to the PSBT's unknown map, which no signature commits to, so it is attached after signing; an input already carrying one is left alone, since the emulator rejects an input bearing two.
To annotate a PSBT yourself — an intent proof, or a transaction you build outside the spend path:
using NArk.Arkade.Emulator;
// Offchain Arkade transaction: needs its checkpoints, since the transaction attached to
// Arkade input i is the one funding that input's *checkpoint*, not the checkpoint itself.
await arkTx.AttachPrevArkTxsAsync(checkpoints, prevArkTxProvider);
// BIP322 intent proof: input 0 is the message input (the emulator synthesises its
// prevout); inputs 1..N each get the transaction that created their outpoint.
await intentProof.AttachIntentPrevArkTxsAsync(prevArkTxProvider);
Both throw InvalidOperationException naming the input index and txid when a previous transaction cannot be resolved, rather than letting the emulator reject the submission.
/v1/onchain-tx uses the sibling prevouttx field. Attach each input's previous transaction with PsbtHelpers.SetArkFieldPrevoutTx(input, prevTx) (key type 0xde) before submitting, fetching it via IBitcoinBlockchain.GetRawTransactionAsync.
Or co-sign a PSBT inline once it carries the user's partial sigs:
using NArk.Arkade.Emulator;
if (ArkadePsbtExtensions.RequiresEmulatorCoSigning(spendingCoins))
{
// Append the EmulatorPacket OP_RETURN to the unsigned tx so the
// server can find the script body for each arkade-bound input.
var packetOutput = ArkadePsbtExtensions.BuildEmulatorOutput(spendingCoins);
if (packetOutput is not null) tx.Outputs.Add(packetOutput);
// ...sign locally, then merge the emulator's partial sigs:
psbt = await psbt.CoSignWithEmulatorAsync(emulator);
}
The wire encoding for the emulator's OP_RETURN packet is exposed as EmulatorPacket.Serialize / EmulatorPacket.Parse for callers that need to read or write the TLV directly. Cross-SDK byte-equality is enforced by the unit tests against the canonical fixtures vendored from arkade-os/emulator pkg/arkade/testdata/.
Extensibility Points
The SDK uses a pluggable architecture. Register your implementations for:
| Interface | Purpose | Default |
|---|---|---|
IVtxoStorage |
VTXO persistence | EfCoreVtxoStorage |
IContractStorage |
Contract persistence | EfCoreContractStorage |
IIntentStorage |
Intent persistence | EfCoreIntentStorage |
IWalletStorage |
Wallet persistence | EfCoreWalletStorage |
IWalletProvider |
Wallet signer/address resolution | DefaultWalletProvider |
ISafetyService |
Distributed locking | Must implement |
IBitcoinBlockchain |
Chain time, UTXO lookup, broadcast, fee estimation | NBXplorerBlockchain / EsploraBlockchain / RpcBlockchain |
IFeeEstimator |
Transaction fee estimation | DefaultFeeEstimator |
ICoinSelector |
UTXO selection strategy | DefaultCoinSelector |
ISweepPolicy |
VTXO consolidation rules | Register zero or more |
IContractTransformer |
Custom contract → coin transforms | Register zero or more |
IEventHandler<T> |
React to batch/sweep/spend events | Register zero or more |
Local Development
E2E tests run against a regtest stack — arkd, Bitcoin Core and friends — kept in the
arkade-regtest submodule and driven by a node
script:
git submodule update --init
# Bring the stack up. Profiles select which optional services start; the E2E workflows use
# `boltz,delegate`, `emulator,boltz` and `solver,boltz`.
node regtest/regtest.mjs start --profile boltz,delegate
# Tear it down, wiping containers and volumes.
node regtest/regtest.mjs clean
.github/actions/e2e-setup is the authoritative version of this — it is what CI runs, including
the retry loop that a flaky Docker pull needs.
Running Tests
# Unit tests
dotnet test NArk.Tests
# End-to-end tests (requires Docker)
dotnet test NArk.Tests.End2End
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- NBitcoin (>= 10.0.9)
- NBitcoin.Secp256k1 (>= 4.0.1)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on NArk.Abstractions:
| Package | Downloads |
|---|---|
|
NArk.Core
Core services for the Ark protocol .NET SDK. Includes spending, batch management, VTXO synchronization, sweeping, wallet infrastructure, and gRPC transport. |
|
|
NArk.Hosting
Package Description |
|
|
NArk.Blockchain.NBXplorer
Package Description |
|
|
NArk.Safety.AsyncKeyedLock
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.377-beta | 33 | 9/16/2026 |
| 1.0.372-beta | 75 | 9/3/2026 |
| 1.0.371-beta | 74 | 9/3/2026 |
| 1.0.369-beta | 80 | 9/2/2026 |
| 1.0.368-beta | 78 | 8/28/2026 |
| 1.0.367-beta | 82 | 8/28/2026 |
| 1.0.366-beta | 93 | 8/26/2026 |
| 1.0.364-beta | 91 | 8/25/2026 |
| 1.0.362-beta | 121 | 8/18/2026 |
| 1.0.360-beta | 90 | 8/14/2026 |
| 1.0.359-beta | 91 | 8/14/2026 |
| 1.0.358-beta | 83 | 8/14/2026 |
| 1.0.355-beta | 194 | 8/14/2026 |
| 1.0.354-beta | 107 | 8/14/2026 |
| 1.0.353-beta | 90 | 8/14/2026 |
| 1.0.352-beta | 93 | 8/14/2026 |
| 1.0.351-beta | 103 | 8/1/2026 |
| 1.0.350-beta | 88 | 8/1/2026 |
| 1.0.347-beta | 82 | 7/24/2026 |
| 1.0.344-beta | 84 | 7/15/2026 |