Class ArkadeIntentsService
- Namespace
- NArk.ArkadeIntents.Services
- Assembly
- NArk.ArkadeIntents.dll
One entry point for every kind of Arkade intent swap.
public sealed class ArkadeIntentsService
- Inheritance
-
ArkadeIntentsService
- Inherited Members
Remarks
The corridors are genuinely different underneath — an asset swap settles against an offer on the stream, the Lightning legs negotiate by RFQ against a covenant — but they all end up as the same ArkadeSwapIntent and are all watched by the same monitor. Callers should not have to know which of three classes owns a given swap in order to list it, or to do the obvious thing to it.
The part that is more than a facade is AdvanceAsync(string, CancellationToken). The monitor already moves a swap to Claimable or Refundable, but until now nothing acted on that — the status was a fact with no consequence, and the consequence is where the money is. This closes that loop while refusing to guess: see ArkadeIntentPolicy for the line between something that follows and something that is the caller's call.
Constructors
ArkadeIntentsService(AssetIntentsManager, LightningIntentsClient, IArkadeIntentStorage, IVtxoStorage, IClientTransport, OnchainIntentsClient?, TimeProvider?, ILogger<ArkadeIntentsService>?)
Creates the service.
public ArkadeIntentsService(AssetIntentsManager assets, LightningIntentsClient lightning, IArkadeIntentStorage intentStorage, IVtxoStorage vtxoStorage, IClientTransport transport, OnchainIntentsClient? onchain = null, TimeProvider? time = null, ILogger<ArkadeIntentsService>? logger = null)
Parameters
assetsAssetIntentsManagerThe asset-swap corridors.
lightningLightningIntentsClientBoth Lightning corridors.
intentStorageIArkadeIntentStorageWhere every kind of swap is recorded.
vtxoStorageIVtxoStorageThe chain view reconciliation compares against.
transportIClientTransportonchainOnchainIntentsClientThe off-board corridor, or
null. Absent, an off-board swap's L1 leg is never acted on — which the advance pass reports rather than hides.timeTimeProviderClock for the timelock comparisons; defaults to the system clock.
loggerILogger<ArkadeIntentsService>Optional logger.
Methods
AdvanceAllAsync(string?, CancellationToken)
Advance every swap that calls for it.
public Task<IReadOnlyList<ArkadeIntentAdvance>> AdvanceAllAsync(string? walletId = null, CancellationToken cancellationToken = default)
Parameters
walletIdstringNarrow to one wallet.
cancellationTokenCancellationTokenCancels between swaps.
Returns
- Task<IReadOnlyList<ArkadeIntentAdvance>>
One result per swap that needed something, in the order attempted.
Remarks
Both statuses this acts on are time-bounded — a claim window closes, a refund competes with nothing but is still money left lying about — so this is meant to be run on a timer, not once.
AdvanceAsync(string, CancellationToken)
Do whatever this swap's kind and status call for, if anything.
public Task<ArkadeIntentAdvance> AdvanceAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap.
cancellationTokenCancellationTokenCancels before the spend.
Returns
- Task<ArkadeIntentAdvance>
What was decided and whether it ran.
Remarks
Failures are returned rather than thrown, because the useful caller is a loop over many
swaps and one that cannot proceed must not stop the others. A swap that needs nothing comes
back with None and Acted: false, which is a normal
answer rather than a problem.
Composition-owned swaps are also returned as no-ops: ComposedSwapExecutionClient
owns their recovery, refund, and validated non-interactive transitions under the route lock.
Exceptions
- InvalidOperationException
No such swap.
CancelAssetSwapAsync(string, CancellationToken)
Cancel a pending asset swap and take the deposit back.
public Task<ArkadeSwapIntent> CancelAssetSwapAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap to cancel.
cancellationTokenCancellationTokenCancels before spending.
Returns
- Task<ArkadeSwapIntent>
The updated intent.
Remarks
Deliberately not something AdvanceAsync(string, CancellationToken) will ever do on its own: a pending swap is waiting to be filled, which is what was asked for.
ClaimLightningReceiveAsync(string, CancellationToken)
Claim a funded Lightning receive swap, publishing the preimage.
public Task<ArkadeSwapIntent> ClaimLightningReceiveAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap to claim.
cancellationTokenCancellationTokenCancels before the spend.
Returns
- Task<ArkadeSwapIntent>
The updated intent.
Remarks
Exposed directly for the same reason as the refund, and with more urgency: the window closes when the solver's own reclaim path opens, so a caller who wants to take delivery now should not have to go through a sweep to do it.
ClaimLightningReceiveNonInteractiveAsync(string, CancellationToken)
Claims a funded receive through the emulator without a wallet signer, paying the covenant's pinned destination.
public Task<ArkadeSwapIntent> ClaimLightningReceiveNonInteractiveAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe recorded receive swap, including its stored preimage for watch-only wallets.
cancellationTokenCancellationTokenCancels before submission; submission reveals the preimage.
Returns
- Task<ArkadeSwapIntent>
The fulfilled intent after the emulator submits the claim.
Remarks
Linked routes require exact funding and validated H/M-to-L linkage; submission discloses P even if it fails.
ClaimOnchainReceiveAsync(string, CancellationToken)
Claim a funded on-board, publishing the preimage.
public Task<ArkadeSwapIntent> ClaimOnchainReceiveAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap to claim.
cancellationTokenCancellationTokenCancels before the spend.
Returns
- Task<ArkadeSwapIntent>
The updated intent.
Remarks
Exposed directly for the same reason the Lightning claim is, and with the same urgency: the window closes when the solver's own reclaim opens.
ClaimOnchainReceiveNonInteractiveAsync(string, CancellationToken)
Claims the funded Arkade side of an on-board without a wallet signer, through its pinned covenant payout.
public Task<ArkadeSwapIntent> ClaimOnchainReceiveNonInteractiveAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe recorded on-board, including its stored preimage for watch-only wallets.
cancellationTokenCancellationTokenCancels before submission; submission reveals the preimage.
Returns
- Task<ArkadeSwapIntent>
The fulfilled intent after the emulator submits the claim.
Remarks
Linked routes require exact funding and validated H/M-to-L linkage; submission discloses P even if it fails.
CreateAssetSwapAsync(CreateSwapRequest, CancellationToken)
Deposit BTC for an Arkade asset, or the reverse.
public Task<ArkadeSwapIntent> CreateAssetSwapAsync(CreateSwapRequest request, CancellationToken cancellationToken = default)
Parameters
requestCreateSwapRequestThe swap to offer.
cancellationTokenCancellationTokenCancels before funding.
Returns
- Task<ArkadeSwapIntent>
The recorded intent.
GetAsync(string, CancellationToken)
One swap by id, whatever corridor it belongs to.
public Task<ArkadeSwapIntent?> GetAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe correlation id.
cancellationTokenCancellationTokenCancels the query.
Returns
- Task<ArkadeSwapIntent>
The swap, or
null.
GetSolverStatusAsync<TStatusProfile>(string, IRfqTransport, CancellationToken)
Ask the solver where it thinks a negotiation stands.
public Task<RfqStatus<TStatusProfile>?> GetSolverStatusAsync<TStatusProfile>(string swapId, IRfqTransport rfqTransport, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe correlation id the negotiation was opened under.
rfqTransportIRfqTransportHow to reach the solver.
cancellationTokenCancellationTokenCancels the round trip.
Returns
Type Parameters
TStatusProfileThe corridor's status-profile shape.
Remarks
Diagnostic only, and never the money path — a funded swap is observable on-chain whether or not the solver answers, which is why nothing here acts on the reply. What it adds is the one thing the chain cannot express: WHY nothing has happened. Refused, expired and not-yet all look identical from our side, and only the counterparty can tell them apart.
ListAsync(ArkadeSwapIntentStatus?, string?, CancellationToken)
Every swap, whatever corridor it belongs to.
public Task<IReadOnlyCollection<ArkadeSwapIntent>> ListAsync(ArkadeSwapIntentStatus? status = null, string? walletId = null, CancellationToken cancellationToken = default)
Parameters
statusArkadeSwapIntentStatus?Narrow to one status.
walletIdstringNarrow to one wallet.
cancellationTokenCancellationTokenCancels the query.
Returns
- Task<IReadOnlyCollection<ArkadeSwapIntent>>
The matching swaps.
ReadLockupFateAsync(string, CancellationToken)
Read what the chain says became of a swap's lockup, without acting on it.
public Task<LockupFateResult> ReadLockupFateAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap.
cancellationTokenCancellationTokenCancels the lookups.
Returns
- Task<LockupFateResult>
The verdict, with its proof or its stuck outputs.
Remarks
Corridor-neutral: all four HTLC corridors settle into the same covenant, so what a spend of it means does not depend on which one negotiated the swap.
Exceptions
- InvalidOperationException
No such swap, or it records no payment hash.
ReceiveFromLightningAsync(string, long, IRfqTransport, string?, SolverCard?, RfqAmountSide, CancellationToken)
Be paid over Lightning and take delivery on Arkade.
public Task<PendingLightningReceive> ReceiveFromLightningAsync(string walletId, long amountSats, IRfqTransport rfqTransport, string? covclaimdPubKey, SolverCard? solverCard = null, RfqAmountSide amountSide = RfqAmountSide.To, CancellationToken cancellationToken = default)
Parameters
walletIdstringThe wallet taking delivery.
amountSatslongThe size to ask for, in sats — of the leg
amountSidenames.rfqTransportIRfqTransportHow to reach a solver.
covclaimdPubKeystringcovclaimd's key, read live, or
nullto send no claim packet.solverCardSolverCardThe solver's published card, when there is one.
amountSideRfqAmountSideWhich leg
amountSatspins, and so who absorbs the solver's spread — what lands on Arkade (To, the default) or what the payer is billed (From). A merchant minting an invoice for an order total wants the latter.cancellationTokenCancellationTokenCancels the negotiation.
Returns
- Task<PendingLightningReceive>
The invoice to hand to a payer, and what is needed to claim.
ReceiveFromOnchainAsync(string, long, IRfqTransport, string?, BitcoinAddress, RfqAmountSide, SolverCard?, ArkContract?, CancellationToken)
On-board Bitcoin L1 sats into an Arkade balance.
public Task<PendingOnchainReceive> ReceiveFromOnchainAsync(string walletId, long amountSats, IRfqTransport rfqTransport, string? covclaimdPubKey, BitcoinAddress l1RefundAddress, RfqAmountSide amountSide = RfqAmountSide.From, SolverCard? solverCard = null, ArkContract? payoutContract = null, CancellationToken cancellationToken = default)
Parameters
walletIdstringThe wallet taking delivery.
amountSatslongThe size to ask for, on the leg
amountSidenames.rfqTransportIRfqTransportHow to reach a solver.
covclaimdPubKeystringcovclaimd's key, read live, or
nullto send no claim packet.l1RefundAddressBitcoinAddressWhere the L1 HTLC pays if it has to be taken back.
amountSideRfqAmountSideWhich leg
amountSatspins, and so who absorbs the solver's spread — what we send on L1 (From, the default) or what lands on Arkade.solverCardSolverCardThe solver's published card, when there is one.
payoutContractArkContractA contract to take the payout key from instead of deriving a fresh one, so the swap costs no HD index of its own. See the corridor's own remarks for why that matters to recovery.
cancellationTokenCancellationTokenCancels the negotiation.
Returns
- Task<PendingOnchainReceive>
The L1 address to fund, and what is needed to claim afterwards.
Remarks
Funds nothing itself: the L1 funding transaction belongs to the caller's own Bitcoin wallet, since the sats being on-boarded are by definition not on Arkade yet.
Exceptions
- InvalidOperationException
No onchain corridor is registered.
ReconcileAsync(string?, CancellationToken)
Re-derive every open swap's status from the chain, and report what was behind.
public Task<ArkadeReconciliation> ReconcileAsync(string? walletId = null, CancellationToken cancellationToken = default)
Parameters
walletIdstringNarrow to one wallet.
cancellationTokenCancellationTokenCancels between swaps.
Returns
- Task<ArkadeReconciliation>
What was corrected, and what is still unconfirmed.
Remarks
The monitor only ever reacts to changes it is present for, so anything that happened while this process was down is missed permanently — a claim window can open and close in that gap. Run this at startup, before the first AdvanceAllAsync(string?, CancellationToken), or the sweep acts on a picture that stopped being true when the process did.
RefundIfUnresolvedAsync(string, CancellationToken)
Resolve an unfinished send swap: read what the chain says, and refund only if nothing else already ended it.
public Task<RefundOutcome> RefundIfUnresolvedAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap.
cancellationTokenCancellationTokenCancels before the spend.
Returns
- Task<RefundOutcome>
What was found and what was done about it.
Remarks
The recovery entry point, as distinct from RefundLightningSendAsync(string, CancellationToken), which is the action. Use this one when the local picture may be stale — after downtime, or from a wallet whose rows were rebuilt: pushing a refund at a lockup the counterparty already claimed is a wasted fee, and reading the fate first is what makes that decidable without trusting anyone's account of it.
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(string, BitcoinAddress?, CancellationToken) on L1.
RefundLightningSendAsync(string, CancellationToken)
Push a Lightning send swap's refund back to our own address.
public Task<ArkadeSwapIntent> RefundLightningSendAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap to refund.
cancellationTokenCancellationTokenCancels before the spend.
Returns
- Task<ArkadeSwapIntent>
The updated intent.
Remarks
Takes refundWithoutReceiver: our key plus the Arkade server, once
refund_locktime has passed. Of the covenant's several refund leaves it is the only one
a client can start on its own, and that is worth being precise about rather than discovering
at the moment it is needed:
refund(immediate) andrefundWithoutServer(after a CSV) both need the solver's signature, and the RFQ protocol carries no message asking for one — the solver may push a refund of its own accord, but that is an operator action on its side, not something we can request.nonInteractiveRefundis the solver's to push, not ours.unilateralRefundWithoutReceiverneeds nobody, but reaching it means unrolling the VTXO to the chain, and an eight-leaf covenant lands there carrying enough script data that the exit costs more than it recovers.
So this is the recourse, and it is exposed directly rather than only through AdvanceAsync(string, CancellationToken), because wanting the money back now is the caller's call to make at whatever moment they like — a sweep on a timer is a convenience, not the only way in.
RefundNonInteractiveAsync(string, CancellationToken)
Refunds an outgoing Lightning, onchain or EVM corridor through its ninth covenant leaf without a wallet signer or preimage.
public Task<ArkadeSwapIntent> RefundNonInteractiveAsync(string swapId, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe recorded outgoing swap; its funded contract must include the ninth leaf.
cancellationTokenCancellationTokenCancels before submission.
Returns
- Task<ArkadeSwapIntent>
The cancelled intent after the emulator submits the pinned refund.
RefundOnchainReceiveAsync(string, BitcoinAddress?, CancellationToken)
Take back an on-board's L1 funding once its refund leaf has matured.
public Task<OnchainRefundOutcome> RefundOnchainReceiveAsync(string swapId, BitcoinAddress? refundAddress = null, CancellationToken cancellationToken = default)
Parameters
swapIdstringThe swap to refund.
refundAddressBitcoinAddressWhere to pay, overriding the address recorded at negotiation.
cancellationTokenCancellationTokenCancels before the broadcast.
Returns
- Task<OnchainRefundOutcome>
What the attempt found; not refunding yet is an ordinary answer.
Remarks
The on-board's only recourse. Unlike the send corridors there is no Arkade covenant to refund — nothing of ours was ever funded there — so if the solver never delivers, this is the way the sats come home.
RestoreAssetSwapsAsync(string, IReadOnlyCollection<string>, CancellationToken)
Rebuild asset-swap rows from the chain after the store that held them is gone.
public Task<OfferRestoreResult> RestoreAssetSwapsAsync(string walletId, IReadOnlyCollection<string> candidateTxids, CancellationToken cancellationToken = default)
Parameters
walletIdstringThe wallet the rebuilt rows belong to.
candidateTxidsIReadOnlyCollection<string>Transactions worth looking at — a wallet's sent history, minus whatever an earlier pass already answered.
cancellationTokenCancellationTokenCancels between transactions.
Returns
- Task<OfferRestoreResult>
What was rebuilt, what was answered, and what still has no outcome.
Remarks
The companion to ReconcileAsync(string?, CancellationToken) and not a substitute for it: reconciliation corrects rows that exist, and this one exists for when they do not. Run it after a wallet restore, then reconcile.
A restored swap can be watched and filled but not cancelled — the wire offer carries the maker's x-only key, which rebuilds the address and cannot sign. See OfferRestore.
SendToLightningAsync(string, string, IRfqTransport, SolverCard?, CancellationToken)
Pay a BOLT11 out of an Arkade balance.
public Task<FundedLightningSend> SendToLightningAsync(string walletId, string invoice, IRfqTransport rfqTransport, SolverCard? solverCard = null, CancellationToken cancellationToken = default)
Parameters
walletIdstringThe wallet paying.
invoicestringThe BOLT11 to pay.
rfqTransportIRfqTransportHow to reach a solver.
solverCardSolverCardThe solver's published card, when there is one. Supplying it holds the solver to its own advertised limits and fee: a quote is whatever arrived on a socket, while the card is signed and tied to a discoverable identity, and only comparing the two catches a solver quoting differently from how it advertises. Omitting it is not a check skipped but a check that does not apply — a deployment naming one solver outright has no published terms to hold it to.
cancellationTokenCancellationTokenCancels before funding.
Returns
- Task<FundedLightningSend>
The funded swap.
SendToOnchainAsync(string, BitcoinAddress, long, IRfqTransport, RfqAmountSide, SolverCard?, CancellationToken)
Off-board an Arkade balance to Bitcoin L1.
public Task<FundedOnchainSend> SendToOnchainAsync(string walletId, BitcoinAddress payoutAddress, long amountSats, IRfqTransport rfqTransport, RfqAmountSide amountSide = RfqAmountSide.To, SolverCard? solverCard = null, CancellationToken cancellationToken = default)
Parameters
walletIdstringThe wallet paying, and receiving any refund.
payoutAddressBitcoinAddressThe Bitcoin L1 address the off-board pays out to.
amountSatslongThe size, on the leg
amountSidenames.rfqTransportIRfqTransportHow to reach a solver.
amountSideRfqAmountSideWhich leg
amountSatspins, and so who absorbs the solver's spread — what lands on L1 (To, the default) or what leaves the Arkade balance.solverCardSolverCardThe solver's published card, when there is one.
cancellationTokenCancellationTokenCancels before funding.
Returns
- Task<FundedOnchainSend>
The funded swap.
Remarks
The corridor's own entry point was reachable only by resolving OnchainIntentsClient directly, while its claim and refund were already driven through here — so the one facade that exists to spare callers knowing which class owns a swap could start every corridor but this one.
Exceptions
- InvalidOperationException
No onchain corridor is registered.