diff --git a/.env.regtest b/.env.regtest
index b757e7ed..ef0dd381 100644
--- a/.env.regtest
+++ b/.env.regtest
@@ -39,7 +39,12 @@ INTENT_SOLVER_IMAGE=ghcr.io/arkade-os/intent-solver:0.3.2
# in. The onchain suites read their claim key off :7271 before they negotiate, so without this
# every one of them dies on a refused connection rather than on anything about a corridor.
#
-# rc.4, not the compose fallback rc.1: the corridors build VHTLC **v2**, whose preimage leaf
+# rc.5, not the compose fallback rc.1: the corridors build VHTLC **v2**, whose preimage leaf
# carries `OP_SIZE 32 OP_EQUALVERIFY` ahead of the hash check, and rc.3 is where covclaimd's
# `findClaimClosure` started matching that shape byte for byte.
-COVCLAIMD_IMAGE=ghcr.io/arkade-os/covclaimd:v0.0.1-rc.4
+#
+# rc.5 rather than rc.4 since the receive suites started asserting a claim the DAEMON lands: rc.5
+# attaches `PrevArkTx` to its claim transactions (covclaimd#8), which is what the emulator reads for
+# the introspection opcodes in that leaf. On rc.4 the registration is accepted and the claim then
+# fails inside the emulator, which reads as covclaimd having done nothing at all.
+COVCLAIMD_IMAGE=ghcr.io/arkade-os/covclaimd:v0.0.1-rc.5
diff --git a/NArk.Abstractions/Helpers/PsbtHelpers.cs b/NArk.Abstractions/Helpers/PsbtHelpers.cs
index 99300d28..669a0c76 100644
--- a/NArk.Abstractions/Helpers/PsbtHelpers.cs
+++ b/NArk.Abstractions/Helpers/PsbtHelpers.cs
@@ -135,10 +135,19 @@ ex is EndOfStreamException or FormatException or OverflowException
}
- // Encodes taproot script leaves per PSBT spec: {depth version script_length script}* (no leaf count prefix).
- // Param: leaves — array of tapscript byte arrays.
- /// Encoded taproot tree as byte array
- private static byte[] EncodeTaprootTree(TapScript[] leaves)
+ ///
+ /// Encodes taproot script leaves per the PSBT spec: {depth version script_length script}* ,
+ /// with no leaf-count prefix.
+ ///
+ /// The tapscript leaves, in the order that fixes the merkle root.
+ /// The encoded taproot tree.
+ ///
+ /// Public because covclaimd takes this same encoding in a reveal, where it binds a registered
+ /// claim to its address. Every leaf is encoded at depth 1, so this is only correct for a flat
+ /// taptree — which every Arkade covenant is; a nested one would encode to something that does
+ /// not hash to its address, first visible as a counterparty refusing a well-formed-looking reveal.
+ ///
+ public static byte[] EncodeTaprootTree(TapScript[] leaves)
{
return leaves.SelectMany(EncodeLeaf).ToArray();
diff --git a/NArk.ArkadeIntents/Covclaim/CovclaimdClient.cs b/NArk.ArkadeIntents/Covclaim/CovclaimdClient.cs
new file mode 100644
index 00000000..43ec2791
--- /dev/null
+++ b/NArk.ArkadeIntents/Covclaim/CovclaimdClient.cs
@@ -0,0 +1,249 @@
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NArk.Abstractions.Helpers;
+using NArk.ArkadeIntents.Lightning;
+using NBitcoin;
+using NBitcoin.Secp256k1;
+
+namespace NArk.ArkadeIntents.Covclaim;
+
+/// over covclaimd's REST gateway.
+///
+/// Every failure surfaces as , including transport noise: callers on
+/// this path catch and carry on, which is only reasonable if the failures have one shape.
+///
+public sealed class CovclaimdClient : ICovclaimdClient
+{
+ private const string KeysPath = "v1/preimage/covclaimd-pubkey";
+ private const string RevealPath = "v1/reveal";
+ private const int PreimageLength = 32;
+
+ private readonly HttpClient _httpClient;
+ private readonly CovclaimdOptions _options;
+ private readonly IAesGcmCipher _cipher;
+ private readonly ILogger? _logger;
+ private readonly SemaphoreSlim _keysLock = new(1, 1);
+ private CovclaimdKeys? _cachedKeys;
+
+ /// Creates the client.
+ /// Transport. Its base address is set from the options when unset.
+ /// Where the daemon is, and how patient to be with it.
+ ///
+ /// Supplies AES-GCM for sealing. Defaults to the platform's, which is right everywhere except a
+ /// browser — see .
+ ///
+ /// Optional log sink.
+ public CovclaimdClient(
+ HttpClient httpClient,
+ IOptions options,
+ IAesGcmCipher? cipher = null,
+ ILogger? logger = null)
+ {
+ ArgumentNullException.ThrowIfNull(httpClient);
+ ArgumentNullException.ThrowIfNull(options);
+
+ _httpClient = httpClient;
+ _options = options.Value;
+ _cipher = cipher ?? new AesGcmCipher();
+ _logger = logger;
+
+ _httpClient.BaseAddress ??= _options.BaseAddress
+ ?? throw new InvalidOperationException(
+ $"{nameof(CovclaimdOptions)}.{nameof(CovclaimdOptions.BaseAddress)} must be set.");
+
+ AssertTransportSecure(_httpClient.BaseAddress, _options.AllowInsecureHttp);
+ }
+
+ ///
+ /// Refuse a daemon reachable only over plain HTTP, unless it is on this machine or the caller
+ /// has said otherwise.
+ ///
+ ///
+ /// The trust anchor, not transport hygiene: whatever answers this address serves the key every
+ /// preimage is sealed to, and a leaked preimage settles the payer's invoice without this wallet
+ /// ever claiming. Loopback is exempt — nothing crosses a wire to intercept.
+ ///
+ private static void AssertTransportSecure(Uri baseAddress, bool allowInsecureHttp)
+ {
+ if (baseAddress.Scheme == Uri.UriSchemeHttps || allowInsecureHttp || baseAddress.IsLoopback)
+ {
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"covclaimd must be reached over https (got {baseAddress.Scheme}://{baseAddress.Host}): " +
+ "the key it serves is the one every preimage gets sealed to. Set " +
+ $"{nameof(CovclaimdOptions)}.{nameof(CovclaimdOptions.AllowInsecureHttp)} to override.");
+ }
+
+ ///
+ public async Task GetKeysAsync(CancellationToken cancellationToken = default)
+ {
+ if (_options.CacheKeys && _cachedKeys is { } cached)
+ {
+ return cached;
+ }
+
+ await _keysLock.WaitAsync(cancellationToken);
+ try
+ {
+ if (_options.CacheKeys && _cachedKeys is { } raced)
+ {
+ return raced;
+ }
+
+ var body = await SendAsync(async ct =>
+ {
+ using var response = await _httpClient.GetAsync(KeysPath, ct);
+ await EnsureSuccessAsync(response, "fetch daemon keys", ct);
+ return await response.Content.ReadFromJsonAsync(ct);
+ }, "fetch daemon keys", cancellationToken)
+ ?? throw new CovclaimdException("covclaimd returned an empty key response.");
+
+ var keys = new CovclaimdKeys(
+ ParsePubKey(body.CovclaimdPubKey, "covclaimd_pub_key"),
+ ParsePubKey(body.EmulatorPubKey, "emulator_pub_key"));
+
+ if (_options.CacheKeys)
+ {
+ _cachedKeys = keys;
+ }
+ return keys;
+ }
+ finally
+ {
+ _keysLock.Release();
+ }
+ }
+
+ ///
+ public async Task RevealAsync(
+ string swapAddress,
+ byte[] preimage,
+ byte[] arkadeScript,
+ TapScript[] taptree,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(swapAddress);
+ ArgumentNullException.ThrowIfNull(preimage);
+ ArgumentNullException.ThrowIfNull(arkadeScript);
+ ArgumentNullException.ThrowIfNull(taptree);
+
+ if (preimage.Length != PreimageLength)
+ {
+ throw new ArgumentException(
+ $"a preimage is {PreimageLength} bytes, got {preimage.Length}", nameof(preimage));
+ }
+ if (arkadeScript.Length == 0)
+ {
+ throw new ArgumentException("the covenant's claim script is empty", nameof(arkadeScript));
+ }
+ if (taptree.Length == 0)
+ {
+ throw new ArgumentException(
+ "a taptree with no leaves cannot hash to the address it claims to describe",
+ nameof(taptree));
+ }
+
+ var keys = await GetKeysAsync(cancellationToken);
+
+ // Only the preimage is sealed: the arkade script says where the claim must pay, which the
+ // solver already knows.
+ var sealed_ = await ClaimPacket.SealAsync(
+ preimage,
+ Convert.ToHexString(keys.CovclaimdPubKey.ToBytes(true)).ToLowerInvariant(),
+ _cipher,
+ cancellationToken);
+
+ var request = new RevealRequestBody
+ {
+ SwapAddress = swapAddress,
+ Packet = new RevealPacketBody
+ {
+ Ciphertext = sealed_.Packet,
+ ArkadeScript = Convert.ToBase64String(arkadeScript),
+ },
+ Taptree = Convert.ToHexString(PsbtHelpers.EncodeTaprootTree(taptree)).ToLowerInvariant(),
+ };
+
+ await SendAsync(async ct =>
+ {
+ using var response = await _httpClient.PostAsJsonAsync(RevealPath, request, ct);
+ await EnsureSuccessAsync(response, $"register a claim for {swapAddress}", ct);
+ return null;
+ }, $"register a claim for {swapAddress}", cancellationToken);
+
+ _logger?.LogInformation(
+ "covclaimd accepted a claim registration for {SwapAddress} ({LeafCount} taptree leaves)",
+ swapAddress, taptree.Length);
+ }
+
+ private async Task SendAsync(
+ Func> send, string operation, CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(_options.RequestTimeout);
+ try
+ {
+ return await send(timeoutCts.Token);
+ }
+ // The caller's own cancellation is theirs to see; only our timeout speaks for covclaimd.
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (OperationCanceledException ex)
+ {
+ throw new CovclaimdException(
+ $"covclaimd did not answer within {_options.RequestTimeout.TotalSeconds:0.#}s " +
+ $"while trying to {operation}.", null, ex);
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new CovclaimdException($"covclaimd is unreachable: could not {operation}.", null, ex);
+ }
+ }
+
+ private static async Task EnsureSuccessAsync(
+ HttpResponseMessage response, string operation, CancellationToken cancellationToken)
+ {
+ if (response.IsSuccessStatusCode)
+ {
+ return;
+ }
+
+ var status = (int)response.StatusCode;
+ string? detail = null;
+ try
+ {
+ var error = await response.Content.ReadFromJsonAsync(cancellationToken);
+ detail = error?.Error;
+ }
+ catch
+ {
+ // A gateway that fails without a JSON body still has a status, which is the half that
+ // says whether to retry.
+ }
+
+ throw new CovclaimdException(
+ $"covclaimd refused to {operation}: HTTP {status}{(detail is null ? "" : $" — {detail}")}",
+ status);
+ }
+
+ private static ECPubKey ParsePubKey(string? hex, string field)
+ {
+ if (hex is not { Length: > 0 })
+ {
+ throw new CovclaimdException($"covclaimd returned no {field}.");
+ }
+ try
+ {
+ return ECPubKey.Create(Convert.FromHexString(hex));
+ }
+ catch (Exception ex) when (ex is FormatException or ArgumentException)
+ {
+ throw new CovclaimdException($"covclaimd returned an unparseable {field}: '{hex}'.", null, ex);
+ }
+ }
+}
diff --git a/NArk.ArkadeIntents/Covclaim/CovclaimdModels.cs b/NArk.ArkadeIntents/Covclaim/CovclaimdModels.cs
new file mode 100644
index 00000000..7102fe89
--- /dev/null
+++ b/NArk.ArkadeIntents/Covclaim/CovclaimdModels.cs
@@ -0,0 +1,64 @@
+using System.Text.Json.Serialization;
+using NBitcoin;
+using NBitcoin.Secp256k1;
+
+namespace NArk.ArkadeIntents.Covclaim;
+
+/// The two keys a covclaimd instance publishes.
+///
+/// What a preimage is sealed to. Read live: the daemon generates it at startup.
+///
+///
+/// The covenant co-signer the daemon will claim through. Read even though this SDK pins its own per
+/// network: a daemon on a different emulator cannot claim anything we build, and comparing the two
+/// turns that into an error at registration rather than a lockup nobody touches.
+///
+public sealed record CovclaimdKeys(ECPubKey CovclaimdPubKey, ECPubKey EmulatorPubKey)
+{
+ /// The emulator key in the x-only form the covenant commits to.
+ public TaprootPubKey EmulatorTaprootPubKey => new(EmulatorPubKey.ToXOnlyPubKey().ToBytes());
+}
+
+/// Wire shape of GET /v1/preimage/covclaimd-pubkey .
+internal sealed class CovclaimdKeysResponse
+{
+ [JsonPropertyName("covclaimd_pub_key")]
+ public string? CovclaimdPubKey { get; set; }
+
+ [JsonPropertyName("emulator_pub_key")]
+ public string? EmulatorPubKey { get; set; }
+}
+
+/// Wire shape of POST /v1/reveal , mirroring covclaimd.v1.RevealRequest .
+internal sealed class RevealRequestBody
+{
+ [JsonPropertyName("swap_address")]
+ public string SwapAddress { get; set; } = "";
+
+ [JsonPropertyName("packet")]
+ public RevealPacketBody Packet { get; set; } = new();
+
+ [JsonPropertyName("taptree")]
+ public string Taptree { get; set; } = "";
+}
+
+/// The claim packet a reveal carries; both fields are standard base64.
+///
+/// Two fields and no third: the extension path's packet also names which covclaimd may open it,
+/// because there the packet is found on a public stream. Here there is one named daemon.
+///
+internal sealed class RevealPacketBody
+{
+ [JsonPropertyName("ciphertext")]
+ public string Ciphertext { get; set; } = "";
+
+ [JsonPropertyName("arkade_script")]
+ public string ArkadeScript { get; set; } = "";
+}
+
+/// Error body returned by the REST gateway on a non-2xx response.
+internal sealed class CovclaimdErrorBody
+{
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+}
diff --git a/NArk.ArkadeIntents/Covclaim/CovclaimdOptions.cs b/NArk.ArkadeIntents/Covclaim/CovclaimdOptions.cs
new file mode 100644
index 00000000..6ed656e8
--- /dev/null
+++ b/NArk.ArkadeIntents/Covclaim/CovclaimdOptions.cs
@@ -0,0 +1,46 @@
+namespace NArk.ArkadeIntents.Covclaim;
+
+/// How to reach the covclaimd instance this wallet reveals its claims to.
+///
+/// Optional on every corridor: without it a receive still works, because the wallet claims its own
+/// lockup once the monitor sees it funded. This configures a second claimant, not a dependency.
+///
+public sealed class CovclaimdOptions
+{
+ /// The named the client resolves.
+ public const string HttpClientName = "covclaimd";
+
+ /// Base address of the daemon's REST gateway.
+ public Uri? BaseAddress { get; set; }
+
+ ///
+ /// How long a reveal stays registered before the daemon drops it. An upper bound only — the
+ /// daemon holds reveals in memory, so a restart loses them. Renewal paces itself off this value.
+ ///
+ public TimeSpan RegistrationTtl { get; set; } = TimeSpan.FromMinutes(15);
+
+ ///
+ /// How long any one request may take. Short on purpose: a second claimant must never be the
+ /// reason a receive about to hand out an invoice stalls.
+ ///
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10);
+
+ /// Whether to remember the daemon's keys for the process's lifetime.
+ ///
+ /// Off by default: covclaimd generates its encryption key at startup, and sealing to a stale one
+ /// fails silently — only the daemon can tell the AEAD tag does not check out, so the renewal loop
+ /// keeps reporting success with the second claimant gone. Turn it on only where the daemon's
+ /// lifetime is known to outlive this process's.
+ ///
+ public bool CacheKeys { get; set; }
+
+ ///
+ /// Allow plain HTTP to a non-loopback daemon. Off by default, and rarely the right answer.
+ ///
+ ///
+ /// Whoever can answer for this endpoint substitutes their own key and reads every secret revealed
+ /// to it — and a preimage in someone else's hands settles the payer's invoice while the lockup is
+ /// reclaimed at refund_locktime . Loopback is exempt: nothing crosses a wire.
+ ///
+ public bool AllowInsecureHttp { get; set; }
+}
diff --git a/NArk.ArkadeIntents/Covclaim/CovclaimdRegistration.cs b/NArk.ArkadeIntents/Covclaim/CovclaimdRegistration.cs
new file mode 100644
index 00000000..5aebf115
--- /dev/null
+++ b/NArk.ArkadeIntents/Covclaim/CovclaimdRegistration.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.Logging;
+using NArk.Arkade.Contracts;
+
+namespace NArk.ArkadeIntents.Covclaim;
+
+/// Hands one lockup to covclaimd, and never lets that failure become the swap's failure.
+///
+/// Shared by both receive corridors on purpose: the daemon binds a registration to the address, the
+/// arkade script and the taptree together, so two call sites drifting apart would show up as one
+/// corridor whose claims silently stop being registered.
+///
+public static class CovclaimdRegistration
+{
+ /// Register a lockup's claim with covclaimd, if there is a daemon configured at all.
+ /// The daemon, or null when none is configured.
+ /// The lockup, which supplies both the claim script and the taptree.
+ /// The lockup's address, as the daemon will see it funded.
+ /// The secret, sealed to the daemon inside the client.
+ /// Optional log sink.
+ /// Cancels the request.
+ /// true when the daemon accepted the registration.
+ public static async Task TryRegisterAsync(
+ ICovclaimdClient? client,
+ VHTLCv2Contract contract,
+ string lockupAddress,
+ byte[] preimage,
+ ILogger? logger = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (client is null)
+ {
+ return false;
+ }
+
+ if (contract.NonInteractiveClaim is null)
+ {
+ logger?.LogDebug(
+ "skipping covclaimd registration for {LockupAddress}: the lockup carries no " +
+ "non-interactive claim leaf", lockupAddress);
+ return false;
+ }
+
+ try
+ {
+ await client.RevealAsync(
+ lockupAddress,
+ preimage,
+ contract.NonInteractiveClaimArkadeScript,
+ contract.GetTapScriptList(),
+ cancellationToken);
+ return true;
+ }
+ catch (CovclaimdException ex)
+ {
+ // Swallowed: the wallet's own claimer still races for this lockup, so what was lost is
+ // redundancy, not the swap.
+ logger?.LogWarning(
+ ex, "covclaimd did not register a claim for {LockupAddress}; this wallet remains " +
+ "the only claimant", lockupAddress);
+ return false;
+ }
+ }
+}
diff --git a/NArk.ArkadeIntents/Covclaim/CovclaimdRenewalService.cs b/NArk.ArkadeIntents/Covclaim/CovclaimdRenewalService.cs
new file mode 100644
index 00000000..85114ff2
--- /dev/null
+++ b/NArk.ArkadeIntents/Covclaim/CovclaimdRenewalService.cs
@@ -0,0 +1,134 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NArk.Abstractions.Contracts;
+using NArk.Arkade.Contracts;
+using NArk.ArkadeIntents.Lightning;
+using NArk.ArkadeIntents.Models;
+using NArk.Core.Transport;
+
+namespace NArk.ArkadeIntents.Covclaim;
+
+/// Re-reveals every live receive to covclaimd before the daemon forgets it.
+///
+/// A registration disappears when its TTL lapses or the daemon restarts, and nothing announces
+/// either — the wallet's own claimer carries on as before. So this re-asserts every live receive
+/// rather than tracking expiry, at half the TTL, which covers one missed pass without a gap.
+///
+public sealed class CovclaimdRenewalService : BackgroundService
+{
+ private readonly ICovclaimdClient _covclaimd;
+ private readonly IArkadeIntentStorage _intentStorage;
+ private readonly IContractStorage _contractStorage;
+ private readonly IClientTransport _transport;
+ private readonly TimeSpan _interval;
+ private readonly ILogger? _logger;
+
+ /// Creates the service.
+ /// The daemon to re-reveal to.
+ /// Where the live swaps are found.
+ /// Where each lockup's covenant is read back from.
+ /// Supplies the network a stored covenant is rebuilt against.
+ /// Supplies the TTL this paces itself against.
+ /// Optional log sink.
+ public CovclaimdRenewalService(
+ ICovclaimdClient covclaimd,
+ IArkadeIntentStorage intentStorage,
+ IContractStorage contractStorage,
+ IClientTransport transport,
+ IOptions? options = null,
+ ILogger? logger = null)
+ {
+ _covclaimd = covclaimd;
+ _intentStorage = intentStorage;
+ _contractStorage = contractStorage;
+ _transport = transport;
+ _logger = logger;
+
+ var ttl = (options?.Value ?? new CovclaimdOptions()).RegistrationTtl;
+ _interval = ttl > TimeSpan.Zero ? ttl / 2 : TimeSpan.FromMinutes(7.5);
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(_interval);
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await RenewAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ // A pass that threw must not end the loop that would have retried it.
+ _logger?.LogError(ex, "a covclaimd renewal pass failed");
+ }
+
+ if (!await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
+ {
+ return;
+ }
+ }
+ }
+
+ /// Re-reveal every receive that can still be claimed.
+ /// Cancels the pass.
+ /// How many registrations the daemon accepted.
+ /// Public so a host without a background loop can drive the pass on its own schedule.
+ public async Task RenewAsync(CancellationToken cancellationToken = default)
+ {
+ var live = await _intentStorage.GetArkadeSwapIntents(
+ statuses: [ArkadeSwapIntentStatus.Pending, ArkadeSwapIntentStatus.Claimable],
+ cancellationToken: cancellationToken);
+
+ var receives = live
+ .Where(i => i.Type is ArkadeSwapIntentType.LightningToBtc or ArkadeSwapIntentType.OnchainToBtc)
+ .ToList();
+ if (receives.Count == 0)
+ {
+ return 0;
+ }
+
+ var network = (await _transport.GetServerInfoAsync(cancellationToken)).Network;
+ var renewed = 0;
+
+ foreach (var intent in receives)
+ {
+ if (intent.Metadata.GetValueOrDefault(ArkadeSwapMetadataKeys.Preimage) is not { Length: > 0 } hex)
+ {
+ continue;
+ }
+
+ VHTLCv2Contract contract;
+ try
+ {
+ contract = await LightningCorridor.LoadLockupAsync(
+ _contractStorage, intent.SwapPkScript, intent.Id, network, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(
+ ex, "could not rebuild the lockup for swap {SwapId}; skipping its renewal", intent.Id);
+ continue;
+ }
+
+ if (await CovclaimdRegistration.TryRegisterAsync(
+ _covclaimd, contract, intent.SwapAddress, Convert.FromHexString(hex),
+ _logger, cancellationToken))
+ {
+ renewed++;
+ }
+ }
+
+ if (renewed > 0)
+ {
+ _logger?.LogDebug("re-revealed {Count} live receives to covclaimd", renewed);
+ }
+ return renewed;
+ }
+}
diff --git a/NArk.ArkadeIntents/Covclaim/ICovclaimdClient.cs b/NArk.ArkadeIntents/Covclaim/ICovclaimdClient.cs
new file mode 100644
index 00000000..f4009314
--- /dev/null
+++ b/NArk.ArkadeIntents/Covclaim/ICovclaimdClient.cs
@@ -0,0 +1,59 @@
+using NBitcoin;
+
+namespace NArk.ArkadeIntents.Covclaim;
+
+///
+/// A covclaimd instance this wallet reveals its claims to, so a funded lockup is collected even
+/// while the wallet is not running.
+///
+///
+/// The daemon is a second claimant, never the only one. It spends the covenant's
+/// non-interactive claim leaf, which pins the payout to the script this wallet already gave the
+/// solver, so the worst a hostile or broken daemon can do is fail to claim.
+///
+public interface ICovclaimdClient
+{
+ /// Read the daemon's published keys.
+ /// Cancels the request.
+ /// The encryption key a preimage is sealed to, and the emulator it claims through.
+ /// The daemon is unreachable or answered badly.
+ Task GetKeysAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Register a claim for a lockup, so the daemon collects it once it is funded. Repeat calls for
+ /// the same address are safe, and are how a registration outlives the daemon's own TTL.
+ ///
+ /// The lockup's bech32m Arkade address.
+ /// The 32-byte secret, sealed to the daemon before it leaves here.
+ ///
+ /// The covenant's own claim script, taken from the contract rather than rebuilt: the covenant
+ /// commits to a hash of it, and a copy rebuilt here strands the claim whenever the two disagree.
+ ///
+ /// The lockup's leaves, including the claim leaf.
+ /// Cancels the request.
+ /// The daemon is unreachable, or refused the registration.
+ Task RevealAsync(
+ string swapAddress,
+ byte[] preimage,
+ byte[] arkadeScript,
+ TapScript[] taptree,
+ CancellationToken cancellationToken = default);
+}
+
+/// Thrown when covclaimd cannot be reached, or answers with something unusable.
+///
+/// Worth catching rather than letting escape: the daemon is a convenience, and a swap that refused
+/// to proceed because a second claimant was down would be worse off than one with only its own.
+///
+public sealed class CovclaimdException : Exception
+{
+ /// Creates the exception.
+ /// What failed.
+ /// The HTTP status, when the daemon answered at all.
+ /// The transport failure underneath, if any.
+ public CovclaimdException(string message, int? statusCode = null, Exception? innerException = null)
+ : base(message, innerException) => StatusCode = statusCode;
+
+ /// The HTTP status the daemon answered with, or null if it never answered.
+ public int? StatusCode { get; }
+}
diff --git a/NArk.ArkadeIntents/Hosting/ArkadeIntentsCollectionExtensions.cs b/NArk.ArkadeIntents/Hosting/ArkadeIntentsCollectionExtensions.cs
index d4bc9566..ca822df4 100644
--- a/NArk.ArkadeIntents/Hosting/ArkadeIntentsCollectionExtensions.cs
+++ b/NArk.ArkadeIntents/Hosting/ArkadeIntentsCollectionExtensions.cs
@@ -10,6 +10,8 @@
using NArk.ArkadeIntents.Assets;
using NArk.ArkadeIntents.Evm;
using NArk.Abstractions.Blockchain;
+using NArk.ArkadeIntents.Covclaim;
+using Microsoft.Extensions.Options;
namespace NArk.ArkadeIntents.Hosting;
public static class ArkadeIntentsCollectionExtensions
@@ -81,4 +83,49 @@ public static IServiceCollection AddArkadeIntentsServices(
services.AddHostedService();
return services;
}
+
+ ///
+ /// Points the receive corridors at a covclaimd instance, so every receive is revealed to it and
+ /// the daemon races this wallet for the claim.
+ ///
+ /// The container.
+ /// Where the daemon is, and how patient to be with it.
+ /// The same container, for chaining.
+ ///
+ /// Optional and additive: without it a receive is still claimed by
+ /// once the monitor sees the lockup funded. What it buys
+ /// is a second claimant for the window this wallet is not running, spending the same leaf pinned
+ /// to the same payout script. Call it after .
+ ///
+ public static IServiceCollection AddCovclaimd(
+ this IServiceCollection services,
+ Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ services.Configure(configure);
+
+ // Named, not typed: the client is a singleton, and a typed one would pin a single handler for
+ // the life of the process and never see a DNS change.
+ services.AddHttpClient(CovclaimdOptions.HttpClientName)
+ .SetHandlerLifetime(Timeout.InfiniteTimeSpan)
+ .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5),
+ // A redirect would move the key fetch off the address the caller vetted.
+ AllowAutoRedirect = false,
+ });
+
+ services.TryAddSingleton(sp => new CovclaimdClient(
+ sp.GetRequiredService().CreateClient(CovclaimdOptions.HttpClientName),
+ sp.GetRequiredService>(),
+ sp.GetService(),
+ sp.GetService>()));
+
+ // Registrations live in the daemon's memory, so "revealed once" would otherwise mean
+ // "revealed until covclaimd next restarts".
+ services.AddHostedService();
+ return services;
+ }
}
diff --git a/NArk.ArkadeIntents/Lightning/LightningIntentsClient.Receive.cs b/NArk.ArkadeIntents/Lightning/LightningIntentsClient.Receive.cs
index 9a52ad7c..0d0bf261 100644
--- a/NArk.ArkadeIntents/Lightning/LightningIntentsClient.Receive.cs
+++ b/NArk.ArkadeIntents/Lightning/LightningIntentsClient.Receive.cs
@@ -8,6 +8,7 @@
using NArk.Abstractions;
using NArk.Arkade.Contracts;
using NArk.Arkade.Emulator;
+using NArk.ArkadeIntents.Covclaim;
using NArk.ArkadeIntents.Models;
using NArk.ArkadeIntents.Rfq.Profiles.Lightning;
using NArk.ArkadeIntents.Rfq;
@@ -279,6 +280,11 @@ await _contractService.ImportContract(
ComposedRouteExecutionGuard.Bind(receiveIntent, outgoingSwapId, payoutArkAddress.ScriptPubKey.ToHex());
await _intentStorage.SaveArkadeSwapIntent(receiveIntent, cancellationToken);
+ // After the row, so a registration that half-happened still leaves a findable swap — and
+ // best-effort, because covclaimd is a second claimant racing ours rather than a dependency.
+ await CovclaimdRegistration.TryRegisterAsync(
+ _covclaimd, contract, lockupAddress, preimage, _logger, cancellationToken);
+
_logger?.LogInformation(
"Receive swap {RfqId} negotiated: {Amount} sats to {Payout}, lockup {Lockup}",
request.RfqId, amountSats, payoutAddress, lockupAddress);
diff --git a/NArk.ArkadeIntents/Lightning/LightningIntentsClient.cs b/NArk.ArkadeIntents/Lightning/LightningIntentsClient.cs
index 8c9fc5f5..266855cc 100644
--- a/NArk.ArkadeIntents/Lightning/LightningIntentsClient.cs
+++ b/NArk.ArkadeIntents/Lightning/LightningIntentsClient.cs
@@ -9,6 +9,7 @@
using NArk.Abstractions;
using NArk.Arkade.Contracts;
using NArk.Arkade.Emulator;
+using NArk.ArkadeIntents.Covclaim;
using NArk.ArkadeIntents.Models;
using NArk.ArkadeIntents.Rfq.Profiles.Lightning;
using NArk.ArkadeIntents.Rfq;
@@ -68,6 +69,9 @@ public sealed partial class LightningIntentsClient : Composition.ILightningIngre
private readonly TimeProvider _time;
private readonly ILogger? _logger;
+ /// The claim daemon to reveal receives to, or null when none is configured.
+ private readonly ICovclaimdClient? _covclaimd;
+
/// Creates the client.
/// The Arkade server connection.
/// Derives and imports contracts.
@@ -90,6 +94,10 @@ public sealed partial class LightningIntentsClient : Composition.ILightningIngre
///
/// Clock for the deadline comparisons; defaults to the system clock.
/// Optional logger.
+ ///
+ /// Optional. Supplied, every receive is also revealed to this daemon, which then races this
+ /// wallet's own claimer for the lockup; absent, the wallet is the only claimant.
+ ///
public LightningIntentsClient(
IClientTransport transport,
IContractService contractService,
@@ -102,7 +110,8 @@ public LightningIntentsClient(
IBitcoinBlockchain? blockchain = null,
IOptions? options = null,
TimeProvider? time = null,
- ILogger? logger = null)
+ ILogger? logger = null,
+ ICovclaimdClient? covclaimd = null)
{
_transport = transport;
_contractService = contractService;
@@ -117,6 +126,7 @@ public LightningIntentsClient(
_emulatorPubkeyOverride = resolved.EmulatorPubkeyOverride;
_maxPayAmountSats = resolved.MaxPayAmountSats;
_time = time ?? TimeProvider.System;
+ _covclaimd = covclaimd;
_logger = logger;
}
diff --git a/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.Receive.cs b/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.Receive.cs
index b5395d1d..8efe7380 100644
--- a/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.Receive.cs
+++ b/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.Receive.cs
@@ -8,6 +8,7 @@
using NArk.Arkade.Emulator;
using NArk.ArkadeIntents.Composition;
using NArk.ArkadeIntents.Lightning;
+using NArk.ArkadeIntents.Covclaim;
using NArk.ArkadeIntents.Models;
using NArk.ArkadeIntents.Rfq;
using NArk.ArkadeIntents.Rfq.Profiles.Onchain;
@@ -325,6 +326,11 @@ await contractService.ImportContract(
ComposedRouteExecutionGuard.Bind(intent, outgoingSwapId, payoutArkAddress.ScriptPubKey.ToHex());
await intentStorage.SaveArkadeSwapIntent(intent, cancellationToken);
+ // The Lightning receive leg's rule, on the leg that funds the same covenant: register after
+ // the row exists, and never let the daemon's absence fail the swap.
+ await CovclaimdRegistration.TryRegisterAsync(
+ covclaimd, contract, lockupAddress, preimage, logger, cancellationToken);
+
logger?.LogInformation(
"On-board {RfqId} negotiated: fund {Sats} sats to {Htlc}, lockup {Lockup} pays {Payout}",
rfqId, quote.FromAmount, htlc.Address, lockupAddress, payoutAddress);
diff --git a/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.cs b/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.cs
index 1b98e13b..5b9156ab 100644
--- a/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.cs
+++ b/NArk.ArkadeIntents/Onchain/OnchainIntentsClient.cs
@@ -11,6 +11,7 @@
using NArk.Arkade.Contracts;
using NArk.Arkade.Emulator;
using NArk.ArkadeIntents.Lightning;
+using NArk.ArkadeIntents.Covclaim;
using NArk.ArkadeIntents.Models;
using NArk.ArkadeIntents.Rfq;
using NArk.ArkadeIntents.Rfq.Profiles.Onchain;
@@ -76,7 +77,8 @@ public sealed partial class OnchainIntentsClient(
IAesGcmCipher? cipher = null,
IOptions? options = null,
TimeProvider? time = null,
- ILogger? logger = null) : Composition.IOnchainIngressQuoteClient
+ ILogger? logger = null,
+ ICovclaimdClient? covclaimd = null) : Composition.IOnchainIngressQuoteClient
{
private readonly TimeProvider _time = time ?? TimeProvider.System;
private readonly ArkadeIntentsOptions _options = options?.Value ?? new ArkadeIntentsOptions();
diff --git a/NArk.Tests.End2End/Arkade/ArkadeLightningTests.cs b/NArk.Tests.End2End/Arkade/ArkadeLightningTests.cs
index 3df369f7..8132ff81 100644
--- a/NArk.Tests.End2End/Arkade/ArkadeLightningTests.cs
+++ b/NArk.Tests.End2End/Arkade/ArkadeLightningTests.cs
@@ -7,6 +7,7 @@
using NArk.Arkade.Emulator;
using NArk.ArkadeIntents;
using NArk.ArkadeIntents.Lightning;
+using NArk.ArkadeIntents.Covclaim;
using NArk.ArkadeIntents.Models;
using NArk.ArkadeIntents.Rfq;
using NArk.ArkadeIntents.Services;
@@ -207,6 +208,55 @@ public async Task Receive_TakesDeliveryOnArkade_AfterThePayerSettles()
await payment;
}
+ // ─── the second claimant ──────────────────────────────────────────
+
+ /// covclaimd collects a receive this wallet never claims.
+ ///
+ /// The property is an absence: nothing here claims and no advance loop runs, so only the daemon
+ /// can spend the lockup. A spent lockup and a settled invoice prove the whole registration path,
+ /// across three implementations of one wire format that the unit tests reach only the first of.
+ ///
+ [Test]
+ [Category("Covclaim")]
+ public async Task Receive_IsClaimedByCovclaimd_WhileThisWalletNeverDoes()
+ {
+ var ctx = await SetUpAsync(withCovclaimd: true);
+ var covclaimdKey = await ctx.ReadCovclaimdPubKeyAsync();
+
+ var pending = await ctx.Intents.ReceiveFromLightningAsync(
+ ctx.WalletId, SwapSats, ctx.Rfq, covclaimdKey);
+
+ if (pending.Quote.Profile?.SolverRefundPkScript is { Length: > 0 } solverScript)
+ {
+ var serverInfo = await ctx.Transport.GetServerInfoAsync();
+ if (!await SolverLiquidityHelper.EnsureBtcFloat(
+ serverInfo.SignerKey.ToXOnlyPubKey(), solverScript, (ulong)SwapSats * 2))
+ {
+ Assert.Ignore(
+ "the solver has no unencumbered float to fund the lockup with, and topping it " +
+ "up did not land — its balance frees on the operator's settlement schedule.");
+ }
+ }
+
+ // Fired, not awaited: the hold settles once somebody publishes the preimage, and the whole
+ // point is that the somebody is not us.
+ var payment = Task.Run(() => ctx.Lnd.Pay(pending.Invoice));
+
+ var lockupScript = pending.Contract.GetScriptPubKey().ToHex();
+ var lockup = await WaitForVtxo(ctx, lockupScript);
+ Assert.That(lockup.Amount, Is.GreaterThanOrEqualTo((ulong)pending.Quote.ToAmount),
+ "the solver funded at least what it quoted");
+
+ // No sync service and no advance loop: this wallet is blind from here on.
+ var spent = await Poll(async () => (await GetVtxo(ctx, lockupScript))?.SpentByTransactionId is
+ { Length: > 0 }, SolverTimeout);
+ Assert.That(spent, Is.True, "covclaimd claimed the lockup without this wallet doing anything");
+
+ Assert.That(await Task.WhenAny(payment, Task.Delay(SolverTimeout)), Is.SameAs(payment),
+ "the payer's invoice settled on the preimage covclaimd published");
+ await payment;
+ }
+
///
/// A swap nobody funds stays unclaimable, and claiming it fails rather than half-completing.
///
@@ -246,9 +296,7 @@ private sealed record Ctx(
IVtxoStorage VtxoStorage,
IArkadeIntentStorage IntentStorage)
{
- ///
- /// Reads covclaimd's key from the solver's own endpoint rather than hardcoding it.
- ///
+ /// Reads covclaimd's key from the solver's own endpoint rather than hardcoding it.
///
/// covclaimd generates its key at startup, so a rebuilt stack invalidates any copy. A stale
/// one seals the preimage to a daemon that cannot open it, which nothing on the wire
@@ -262,7 +310,12 @@ public async Task ReadCovclaimdPubKeyAsync()
}
}
- private static async Task SetUpAsync()
+ /// Builds the corridor under test.
+ ///
+ /// Attach the stack's covclaimd, so every receive is also revealed to it. Off by default: a
+ /// second claimant racing the other tests would make a passing run say less than it looks like.
+ ///
+ private static async Task SetUpAsync(bool withCovclaimd = false)
{
var solverUrl = Env("ARKADE_LN_SOLVER_URL");
if (solverUrl is null)
@@ -301,9 +354,17 @@ private static async Task SetUpAsync()
// One client for both directions now, so the two constructions that used to differ by a
// parameter cannot differ at all.
+ var covclaimdUrl = new Uri(Env("ARKADE_COVCLAIMD_URL") ?? "http://localhost:7271");
+ var covclaimdClient = withCovclaimd
+ ? new CovclaimdClient(
+ new HttpClient { BaseAddress = covclaimdUrl },
+ Microsoft.Extensions.Options.Options.Create(new CovclaimdOptions()))
+ : null;
+
var lightning = new LightningIntentsClient(
w.clientTransport, w.contractService, spendingService,
- intentStorage, w.contracts, w.vtxoStorage, w.walletProvider);
+ intentStorage, w.contracts, w.vtxoStorage, w.walletProvider,
+ covclaimd: covclaimdClient);
// The asset corridor is not exercised here, so its client is left out rather than
// constructed to satisfy a signature.
@@ -317,9 +378,8 @@ private static async Task SetUpAsync()
var solver = new Uri(solverUrl!.EndsWith('/') ? solverUrl : solverUrl + "/");
var rfq = new HttpRfqTransport(new HttpClient(), solver);
- var covclaimd = new Uri(Env("ARKADE_COVCLAIMD_URL") ?? "http://localhost:7271");
- return new Ctx(w.walletIdentifier, w.clientTransport, intents, rfq, lnd, covclaimd,
+ return new Ctx(w.walletIdentifier, w.clientTransport, intents, rfq, lnd, covclaimdUrl,
w.vtxoStorage, intentStorage);
}
diff --git a/NArk.Tests/ArkadeIntents/Covclaim/CovclaimdClientTests.cs b/NArk.Tests/ArkadeIntents/Covclaim/CovclaimdClientTests.cs
new file mode 100644
index 00000000..b271031a
--- /dev/null
+++ b/NArk.Tests/ArkadeIntents/Covclaim/CovclaimdClientTests.cs
@@ -0,0 +1,195 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Options;
+using NArk.ArkadeIntents.Covclaim;
+using NBitcoin;
+
+namespace NArk.Tests.ArkadeIntents.Covclaim;
+
+///
+/// What this wallet puts on the wire when it reveals a claim, and how it fails when the daemon
+/// cannot be reached. Field by field, because covclaimd refuses a body whose three parts do not
+/// agree — at the one moment the wallet has stopped watching.
+///
+[TestFixture]
+public class CovclaimdClientTests
+{
+ private const string CovclaimdPubKey = "02474c9c627ad19d0ae5c70050703dc62ebbee99885d054623f59f8f79a277904e";
+ private const string EmulatorPubKey = "02bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2";
+
+ [Test]
+ public async Task Reveal_SendsTheAddress_TheSealedPreimage_AndTheTreeAsHex()
+ {
+ var handler = new RecordingHandler();
+ var client = Client(handler);
+ var taptree = new[] { Leaf(0x51), Leaf(0x52) };
+
+ await client.RevealAsync("tark1qexample", new byte[32], [0x6a, 0x51], taptree);
+
+ var body = JsonNode.Parse(handler.LastBody!)!.AsObject();
+ Assert.Multiple(() =>
+ {
+ Assert.That(handler.LastPath, Is.EqualTo("/v1/reveal"));
+ Assert.That((string)body["swap_address"]!, Is.EqualTo("tark1qexample"));
+ // Sealed preimage: 93 bytes once decoded.
+ Assert.That(Convert.FromBase64String((string)body["packet"]!["arkade_script"]!),
+ Is.EqualTo(new byte[] { 0x6a, 0x51 }));
+ Assert.That(Convert.FromBase64String((string)body["packet"]!["ciphertext"]!).Length,
+ Is.EqualTo(93));
+ Assert.That((string)body["taptree"]!, Does.Match("^[0-9a-f]+$"));
+ });
+ }
+
+ [Test]
+ public async Task Reveal_SendsNoThirdPacketField()
+ {
+ var handler = new RecordingHandler();
+
+ await Client(handler).RevealAsync("tark1qexample", new byte[32], [0x51], [Leaf(0x51)]);
+
+ var packet = JsonNode.Parse(handler.LastBody!)!["packet"]!.AsObject();
+ Assert.That(packet.Select(p => p.Key), Is.EquivalentTo(new[] { "ciphertext", "arkade_script" }));
+ }
+
+ [Test]
+ public void Reveal_WithAPreimageOfTheWrongLength_IsRefusedBeforeAnyRequest()
+ {
+ var handler = new RecordingHandler();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(() => Client(handler).RevealAsync("tark1q", new byte[31], [0x51], [Leaf(0x51)]),
+ Throws.TypeOf());
+ Assert.That(handler.Requests, Is.Zero);
+ });
+ }
+
+ [Test]
+ public void Reveal_WithAnEmptyTaptree_IsRefusedBeforeAnyRequest()
+ {
+ // Refused here, where the caller can still read which argument was empty.
+ Assert.That(() => Client(new RecordingHandler()).RevealAsync("tark1q", new byte[32], [0x51], []),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void ADaemonThatRefuses_SurfacesItsStatus()
+ {
+ var handler = new RecordingHandler(
+ (HttpStatusCode.BadRequest, """{"error":"taptree does not hash to swap_address"}"""));
+
+ var ex = Assert.ThrowsAsync(
+ () => Client(handler).RevealAsync("tark1q", new byte[32], [0x51], [Leaf(0x51)]));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(ex!.StatusCode, Is.EqualTo(400));
+ // The daemon's own words, because "it refused" without them is a support ticket.
+ Assert.That(ex.Message, Does.Contain("taptree does not hash"));
+ });
+ }
+
+ [Test]
+ public void AnUnreachableDaemon_IsACovclaimdException_NotATransportOne()
+ {
+ var handler = new RecordingHandler { Throw = new HttpRequestException("connection refused") };
+
+ Assert.That(() => Client(handler).GetKeysAsync(), Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task TheDaemonsKeys_AreReReadEveryTime_ByDefault()
+ {
+ var handler = new RecordingHandler();
+ var client = Client(handler);
+
+ await client.GetKeysAsync();
+ await client.GetKeysAsync();
+
+ Assert.That(handler.Requests, Is.EqualTo(2));
+ }
+
+ [Test]
+ public async Task TheDaemonsKeys_AreReadOnce_WhenCachingIsAskedFor()
+ {
+ var handler = new RecordingHandler();
+ var client = Client(handler, cacheKeys: true);
+
+ await client.GetKeysAsync();
+ await client.GetKeysAsync();
+
+ Assert.That(handler.Requests, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void ARemoteDaemonOverPlainHttp_IsRefused()
+ {
+ Assert.That(() => new CovclaimdClient(
+ new HttpClient { BaseAddress = new Uri("http://covclaimd.example") },
+ Options.Create(new CovclaimdOptions())),
+ Throws.TypeOf().With.Message.Contains("https"));
+ }
+
+ [Test]
+ public void ARemoteDaemonOverHttps_IsAccepted()
+ {
+ Assert.That(() => new CovclaimdClient(
+ new HttpClient { BaseAddress = new Uri("https://covclaimd.example") },
+ Options.Create(new CovclaimdOptions())),
+ Throws.Nothing);
+ }
+
+ [Test]
+ public void ARemoteDaemonOverPlainHttp_IsAcceptedOnAnExplicitOptOut()
+ {
+ // A private network the operator vouches for.
+ Assert.That(() => new CovclaimdClient(
+ new HttpClient { BaseAddress = new Uri("http://covclaimd.example") },
+ Options.Create(new CovclaimdOptions { AllowInsecureHttp = true })),
+ Throws.Nothing);
+ }
+
+ private static CovclaimdClient Client(RecordingHandler handler, bool cacheKeys = false) =>
+ new(new HttpClient(handler) { BaseAddress = new Uri("http://localhost:7271") },
+ Options.Create(new CovclaimdOptions { CacheKeys = cacheKeys }));
+
+ private static TapScript Leaf(byte op) => new Script(new[] { op }).ToTapScript(TapLeafVersion.C0);
+
+ private sealed class RecordingHandler((HttpStatusCode Status, string Body)? failure = null)
+ : HttpMessageHandler
+ {
+ public int Requests { get; private set; }
+ public string? LastBody { get; private set; }
+ public string? LastPath { get; private set; }
+ public Exception? Throw { get; init; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests++;
+ if (Throw is not null) throw Throw;
+
+ LastPath = request.RequestUri!.AbsolutePath;
+ if (request.Content is not null)
+ {
+ LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
+ }
+
+ if (LastPath.EndsWith("covclaimd-pubkey", StringComparison.Ordinal))
+ {
+ return Json(HttpStatusCode.OK, JsonSerializer.Serialize(new
+ {
+ covclaimd_pub_key = CovclaimdPubKey,
+ emulator_pub_key = EmulatorPubKey,
+ }));
+ }
+
+ return failure is { } f ? Json(f.Status, f.Body) : new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
+ new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
+ }
+}
diff --git a/NArk.Tests/ArkadeIntents/Covclaim/CovclaimdRegistrationTests.cs b/NArk.Tests/ArkadeIntents/Covclaim/CovclaimdRegistrationTests.cs
new file mode 100644
index 00000000..dcc82504
--- /dev/null
+++ b/NArk.Tests/ArkadeIntents/Covclaim/CovclaimdRegistrationTests.cs
@@ -0,0 +1,96 @@
+using NArk.Arkade.Contracts;
+using NArk.ArkadeIntents.Covclaim;
+using NBitcoin;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+
+namespace NArk.Tests.ArkadeIntents.Covclaim;
+
+/// The rule that makes a second claimant safe to add: it can fail, and the swap does not.
+///
+/// True only while the registration is best-effort: a receive that refused to proceed because the
+/// daemon was down would have traded a redundant claimant for no swap at all.
+///
+[TestFixture]
+public class CovclaimdRegistrationTests
+{
+ [Test]
+ public async Task WithNoDaemonConfigured_NothingIsRegisteredAndNothingFails()
+ {
+ // The default deployment: this wallet claims alone, exactly as before.
+ var registered = await CovclaimdRegistration.TryRegisterAsync(
+ client: null, Contract(), "tark1q", new byte[32]);
+
+ Assert.That(registered, Is.False);
+ }
+
+ [Test]
+ public async Task ADaemonThatIsDown_DoesNotFailTheSwap()
+ {
+ var client = Substitute.For();
+ client.RevealAsync(default!, default!, default!, default!, default)
+ .ThrowsAsyncForAnyArgs(new CovclaimdException("unreachable"));
+
+ var registered = await CovclaimdRegistration.TryRegisterAsync(
+ client, Contract(), "tark1q", new byte[32]);
+
+ Assert.That(registered, Is.False);
+ }
+
+ [Test]
+ public async Task AnUnexpectedFailure_IsNotSwallowed()
+ {
+ // Only the daemon's own failure mode is absorbed; a bug on this side surfaces.
+ var client = Substitute.For();
+ client.RevealAsync(default!, default!, default!, default!, default)
+ .ThrowsAsyncForAnyArgs(new InvalidOperationException("bug"));
+
+ Assert.That(() => CovclaimdRegistration.TryRegisterAsync(client, Contract(), "tark1q", new byte[32]),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task ALockupWithNoClaimLeaf_IsNotRegistered()
+ {
+ // Without that leaf the daemon has no closure to spend, so it would refuse on arrival.
+ var client = Substitute.For();
+
+ var registered = await CovclaimdRegistration.TryRegisterAsync(
+ client, Contract(withClaimLeaf: false), "tark1q", new byte[32]);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(registered, Is.False);
+ Assert.That(client.ReceivedCalls(), Is.Empty);
+ });
+ }
+
+ [Test]
+ public async Task TheScriptAndTreeComeFromTheContractItself()
+ {
+ var client = Substitute.For();
+ var contract = Contract();
+
+ await CovclaimdRegistration.TryRegisterAsync(client, contract, "tark1qaddress", new byte[32]);
+
+ await client.Received(1).RevealAsync(
+ "tark1qaddress",
+ Arg.Any(),
+ Arg.Is(s => s.SequenceEqual(contract.NonInteractiveClaimArkadeScript)),
+ Arg.Is(t => t.Length == contract.GetTapScriptList().Length),
+ Arg.Any());
+ }
+
+ private static VHTLCv2Contract Contract(bool withClaimLeaf = true) => new(
+ LockupShapes.RandomDescriptor(),
+ LockupShapes.RandomDescriptor(),
+ LockupShapes.RandomDescriptor(),
+ new uint160(System.Security.Cryptography.RandomNumberGenerator.GetBytes(20), false),
+ new LockTime(1_800_600_000),
+ new Sequence(TimeSpan.FromSeconds(512)),
+ new Sequence(TimeSpan.FromSeconds(512)),
+ new Sequence(TimeSpan.FromSeconds(1024)),
+ withClaimLeaf
+ ? new VHTLCv2NonInteractiveClaim(LockupShapes.RandomP2trPkScript(), LockupShapes.RandomXOnly())
+ : null);
+}
diff --git a/NArk.Tests/ArkadeIntents/Onchain/OnchainLinkedReceiveTests.cs b/NArk.Tests/ArkadeIntents/Onchain/OnchainLinkedReceiveTests.cs
index 388f96b8..4f98f877 100644
--- a/NArk.Tests/ArkadeIntents/Onchain/OnchainLinkedReceiveTests.cs
+++ b/NArk.Tests/ArkadeIntents/Onchain/OnchainLinkedReceiveTests.cs
@@ -21,6 +21,8 @@
using NBitcoin.Scripting;
using NBitcoin.Secp256k1;
using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using NArk.ArkadeIntents.Covclaim;
namespace NArk.Tests.ArkadeIntents.Onchain;
@@ -145,7 +147,42 @@ private static Task Receive(Harness ctx, string? rfqId =
ctx.Client.ReceiveFromOnchainIntoAsync("wallet-1", amount, ctx.Rfq, KeyFor(11).PubKey.Compress().ToHex(),
RefundAddress, ctx.Secret, ctx.Outgoing, ctx.Receiver, OutgoingRfqId, rfqId: rfqId);
- private static Harness Context(long quoteDelta = 0)
+ [Test]
+ public async Task ASuccessfulReceive_RevealsTheLockupToCovclaimd()
+ {
+ // The wiring itself, which the component tests either side of it cannot see: a receive that
+ // completed must have handed the daemon the address it just derived, with the covenant's own
+ // claim script and the tree that hashes to it.
+ var ctx = Context();
+
+ var pending = await Receive(ctx);
+
+ var imported = ctx.Imported.Single();
+ await ctx.Covclaimd.Received(1).RevealAsync(
+ pending.LockupAddress,
+ Arg.Is(p => p.SequenceEqual(pending.Preimage)),
+ Arg.Is(a => a.SequenceEqual(imported.NonInteractiveClaimArkadeScript)),
+ Arg.Is(t => t.Length == imported.GetTapScriptList().Length),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task ADaemonThatRefuses_DoesNotFailTheReceive()
+ {
+ // The property that makes a second claimant safe to add at all. A receive that threw here
+ // would have traded a redundant claimant for no swap — and the invoice is already out.
+ var ctx = Context(covclaimdFails: true);
+
+ var pending = await Receive(ctx);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(pending.LockupAddress, Is.Not.Empty);
+ Assert.That(ctx.Saved, Is.Not.Empty, "the swap was still recorded");
+ });
+ }
+
+ private static Harness Context(long quoteDelta = 0, bool covclaimdFails = false)
{
var server = TestServerInfo.WithSeconds(4096);
var receiver = new ArkPaymentContract(server.SignerKey, new Sequence(TimeSpan.FromSeconds(4096)), Descriptor(4));
@@ -228,11 +265,18 @@ private static Harness Context(long quoteDelta = 0)
};
});
var wallets = Substitute.For();
+ var covclaimd = Substitute.For();
+ if (covclaimdFails)
+ {
+ covclaimd.RevealAsync(default!, default!, default!, default!, default)
+ .ThrowsAsyncForAnyArgs(new CovclaimdException("unreachable"));
+ }
var client = new OnchainIntentsClient(transport, contracts, Substitute.For(), intents,
Substitute.For(), Substitute.For(), wallets, Substitute.For(),
options: Options.Create(new ArkadeIntentsOptions { EmulatorPubkeyOverride = KeyFor(11).PubKey.Compress().ToHex() }),
- time: new TestClock());
- return new Harness(client, rfq, contracts, wallets, receiver, outgoing,
+ time: new TestClock(),
+ covclaimd: covclaimd);
+ return new Harness(client, rfq, contracts, wallets, receiver, outgoing, covclaimd,
SwapLinkSecret.FromPreimage(Enumerable.Repeat((byte)0x42, 32).ToArray()), events, requests, imported, saved);
}
@@ -241,7 +285,8 @@ private static Harness Context(long quoteDelta = 0)
private static OutputDescriptor Descriptor(byte seed) => KeyExtensions.ParseOutputDescriptor(KeyFor(seed).PubKey.ToHex(), Network.RegTest);
private static string KeyHex(byte seed) => Convert.ToHexString(KeyFor(seed).PubKey.TaprootInternalKey.ToBytes()).ToLowerInvariant();
private sealed record Harness(OnchainIntentsClient Client, IRfqTransport Rfq, IContractService Contracts,
- IWalletProvider Wallets, ArkContract Receiver, ArkAddress Outgoing, SwapLinkSecret Secret, List Events,
+ IWalletProvider Wallets, ArkContract Receiver, ArkAddress Outgoing, ICovclaimdClient Covclaimd,
+ SwapLinkSecret Secret, List Events,
List> Requests, List Imported, List Saved);
private sealed class TestClock : TimeProvider
{
diff --git a/NArk.Tests/ArkadeIntents/Onchain/OnchainReceiveOrchestrationTests.cs b/NArk.Tests/ArkadeIntents/Onchain/OnchainReceiveOrchestrationTests.cs
index ea9b986d..3d12f217 100644
--- a/NArk.Tests/ArkadeIntents/Onchain/OnchainReceiveOrchestrationTests.cs
+++ b/NArk.Tests/ArkadeIntents/Onchain/OnchainReceiveOrchestrationTests.cs
@@ -7,6 +7,7 @@
using NArk.Abstractions.Wallets;
using NArk.Arkade.Contracts;
using NArk.ArkadeIntents;
+using NArk.ArkadeIntents.Covclaim;
using NArk.ArkadeIntents.Models;
using NArk.ArkadeIntents.Onchain;
using NArk.ArkadeIntents.Rfq;
@@ -455,7 +456,8 @@ private sealed record Harness(
IContractService Contracts,
IArkadeIntentStorage Intents,
IBitcoinBlockchain Blockchain,
- IWalletProvider Wallets);
+ IWalletProvider Wallets,
+ ICovclaimdClient Covclaimd);
private static Harness Ctx(ArkadeSwapIntent? intent = null, long now = Now)
{
@@ -482,13 +484,15 @@ private static Harness Ctx(ArkadeSwapIntent? intent = null, long now = Now)
var blockchain = Substitute.For();
+ var covclaimd = Substitute.For();
var client = new OnchainIntentsClient(
transport, contracts, spending, intents, contractStorage,
Substitute.For(), wallets, blockchain,
options: Options.Create(new ArkadeIntentsOptions()),
- time: new FixedClock(now));
+ time: new FixedClock(now),
+ covclaimd: covclaimd);
- return new Harness(client, spending, contracts, intents, blockchain, wallets);
+ return new Harness(client, spending, contracts, intents, blockchain, wallets, covclaimd);
}
/// The lockup the refund path reads back to recover our key.
diff --git a/README.md b/README.md
index 67f97cf1..03a845e0 100644
--- a/README.md
+++ b/README.md
@@ -1610,6 +1610,29 @@ covclaimd is optional. Both corridors work without it; what it adds is a daemon
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.
+Point the corridors at one with `AddCovclaimd`, after the intents registration:
+
+```csharp
+services.AddArkadeIntentsServices();
+services.AddCovclaimd(o => o.BaseAddress = new Uri("http://localhost:7271"));
+```
+
+From then on every receive is **revealed** to that daemon: the preimage is sealed to its key, and
+the registration carries the covenant's own claim script and the lockup's taptree, which is what
+binds it to the funded address. The daemon watches arkd's stream and claims the moment the lockup
+appears. It spends the same non-interactive claim leaf the wallet would, pinned to the same payout
+script, so the two racing cannot disagree about where the money goes — and whichever loses simply
+finds the output already spent.
+
+Registration is **best-effort by construction**: a daemon that is down or refuses leaves a warning
+in the log and the swap proceeds, because trading a redundant claimant for no swap at all would be
+the worse outcome. `CovclaimdRenewalService` re-reveals every live receive every half TTL, since the
+daemon holds registrations in memory and loses them on restart.
+
+Nothing here is required for a server-side wallet that stays up: `ArkadeIntentAdvanceService`
+already claims a funded receive the moment the monitor marks it Claimable. The daemon covers the
+window where the wallet is not running.
+
> **Both corridors settle end to end against a live solver.** `ArkadeLightningTests` in
> `NArk.Tests.End2End` drives 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
diff --git a/docs/articles/lightning-corridors.md b/docs/articles/lightning-corridors.md
index d43a4ccd..8c147491 100644
--- a/docs/articles/lightning-corridors.md
+++ b/docs/articles/lightning-corridors.md
@@ -121,7 +121,31 @@ travels to the solver as opaque bytes it forwards.
The packet is **optional**. Pass no covclaimd key and no packet is sent at all, which is the honest
encoding of "nobody else can claim this": you keep the preimage and claim the lockup yourself.
Sealing to a key generated and dropped on the spot would instead advertise an offline claim path
-that no daemon can walk. What the daemon buys is the receive corridor's
+that no daemon can walk.
+
+## Revealing a claim to covclaimd
+
+There are two ways a daemon can come to hold your packet, and this SDK takes the direct one. With
+`AddCovclaimd` registered, every receive is **revealed**: the sealed preimage goes straight to one
+named daemon over its own endpoint, together with the covenant's claim script and the lockup's
+taptree. The daemon validates that the tree hashes to the address and carries the closure for that
+script — so a registration it cannot bind to the address is refused there and then, rather than
+discovered at claim time — and from then on it watches arkd's stream for the lockup being funded.
+
+The arkade script travels with the registration rather than being rebuilt by either side, and that
+is deliberate: the covenant commits to a hash of it, so the party that built the contract holds the
+only copy guaranteed to match. This SDK takes it from
+[`VHTLCv2Contract.NonInteractiveClaimArkadeScript`](xref:NArk.Arkade.Contracts.VHTLCv2Contract.NonInteractiveClaimArkadeScript)
+for exactly that reason.
+
+What the daemon can do with it is bounded by the leaf it spends: `nonInteractiveClaim` pays the
+receiver script the covenant already pins, which is yours. So the daemon is a second claimant racing
+your own — never a party that can redirect anything. Registration failures are logged and swallowed
+for the same reason: what a failed registration costs is redundancy, not the swap.
+
+Registrations live in the daemon's memory, so they lapse on a TTL and vanish on a restart.
+`CovclaimdRenewalService` re-reveals every live receive every half TTL; repeat reveals for one
+address are safe by design, which is why it re-asserts rather than tracking expiry. What the daemon buys is the receive corridor's
answer to the problem the send corridor solves with a locktime: if you go offline between minting
the invoice and the solver funding, covclaimd holds the only other copy of your preimage and can
push the covenant's `nonInteractiveClaim` leaf on your behalf. That leaf is pinned to *your*