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*