diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd332e9..a7228fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,12 @@ jobs: --collect:"XPlat Code Coverage" --results-directory ./TestResults + - name: Remote signer contract tests + run: > + dotnet test tests/NSpark.RemoteSigner.Tests/NSpark.RemoteSigner.Tests.csproj + --configuration Release --no-build --no-restore + --framework ${{ matrix.tfm }} + - name: Upload test results if: always() uses: actions/upload-artifact@v7 diff --git a/NSpark.slnx b/NSpark.slnx index 6269dba..1724de7 100644 --- a/NSpark.slnx +++ b/NSpark.slnx @@ -1,9 +1,11 @@ + + diff --git a/docs/remote-signer.md b/docs/remote-signer.md new file mode 100644 index 0000000..01cc88c --- /dev/null +++ b/docs/remote-signer.md @@ -0,0 +1,39 @@ +# Remote signer contract (Elenpay fork) + +`src/NSpark.RemoteSigner` lets a Spark wallet run with its keys in a separate service, for +example an AWS Lambda that KMS-decrypts the seed, while the wallet (NSpark's `SparkWallet`) +runs elsewhere. It is not part of the NSpark package. + +| Piece | Side | Role | +|---|---|---| +| `SparkSignerContract` | both | `POST /spark/{op}` routes, request records, `{result,error}` envelope, JSON settings, `Version` | +| `RemoteSparkSigner` | client | `ISparkSigner` that forwards every operation over an `ISparkSignerTransport` | +| `SparkSignerHttpHandler` | service | path → operation → `ISparkSigner`, with status codes 200/400/403/404/500 | +| `SparkSignerDispatcher` + `SparkSignerPolicy` | service | the checks a blind signer can make | + +## What a remote signer does and doesn't serve + +Served: identity/deposit/leaf/static-deposit public keys, identity ECDSA signatures, one-shot +leaf FROST shares, send/claim key-tweak batches and Lightning preimage shares, plus `info` +(identity, policy and contract version, for the caller to verify before use). + +Not served, by design: two-phase FROST nonces, static-deposit private-key export, adaptor keys +(SSP leaf swaps) and ECIES decryption. `RemoteSparkSigner` throws `NotSupportedException` for +them without a round trip. Static deposits and amount-exact exits that need a leaf swap are +therefore unavailable; withdraw-all cooperative exits, exits of given leaves (`WithdrawLeavesAsync`, +for example what one inbound transfer brought in), Lightning receives and claims work. + +## Policy + +- Tweak and preimage shares go only to the pinned operator set, at the pinned threshold. +- Outbound key tweaks go only to an allowed receiver (the SSP) or back to the signer's own identity. +- Adaptor signatures are refused. + +The signer still signs FROST shares and identity hashes blindly: policy bounds where leaves can +go, not what the SSP does with them. Keep balances transit-only. + +## Contract changes + +`tests/NSpark.RemoteSigner.Tests/Fixtures/*.json` pin the request body of every operation; the +signer service's tests read the same files. Regenerate with `NSPARK_UPDATE_FIXTURES=1 dotnet test`, +review the diff, and bump `SparkSignerContract.Version` on an incompatible change. diff --git a/docs/withdrawals.md b/docs/withdrawals.md index d8109f9..666a373 100644 --- a/docs/withdrawals.md +++ b/docs/withdrawals.md @@ -44,6 +44,22 @@ Console.WriteLine($"Fee: {quote.FeeSats} sats"); `WithdrawAsync` fetches the same quote internally and uses it as the fee bound unless you pass `maxFeeSats`. Show it to the user before confirming. +## Exit speed + +Every withdrawal takes an `exitSpeed` (`ExitSpeed.Fast`, the default, `Medium` or +`Slow`). The SSP quotes each speed separately: the same user fee, plus an L1 +broadcast fee at the fee rate that speed targets, so `Slow` is the cheapest and +confirms last. Pass the same speed to `GetFeeQuoteAsync` and to the withdrawal, +which uses it for the fee bound and asks it of the SSP: + +```csharp +var slowFee = await wallet.GetFeeQuoteAsync(leafIds, "bc1q...", ExitSpeed.Slow); +var exit = await wallet.WithdrawLeavesAsync(leafIds, "bc1q...", exitSpeed: ExitSpeed.Slow); +``` + +The leaves stay the wallet's (locked, counted in `SatsBalance.Owned`) until the +exit transaction confirms, which at `Slow` can take hours when fees run high. + ## Withdraw ```csharp @@ -165,6 +181,29 @@ operators did not renew this time; a later attempt can move them), `LockedSats` (held by an in-flight operation) and, on the result, `UnclaimedSats` (inbound sats that could not be claimed first). +## Withdraw what one transfer brought in + +A claimed inbound transfer lists the leaves it moved (`SparkTransfer.Leaves`), and those leaves +already add up to what it brought in. `WithdrawLeavesAsync` exits exactly them, without a leaf +swap, so a remote signer can sign it too (see [remote-signer.md](remote-signer.md)): + +```csharp +var request = await wallet.GetLightningReceiveRequestAsync(invoice.RequestId!); // TransferId once paid +var claim = await wallet.ClaimPendingTransfersAsync(); +var transfer = claim.ClaimedTransfers.Single(t => t.Id == request!.TransferId); + +var exit = await wallet.WithdrawLeavesAsync(transfer.Leaves.Select(l => l.Id).ToList(), "bc1q...", + maxFeeSats: 20_000); +Console.WriteLine($"{exit.Txid}: sent {exit.SentSats}, paid {exit.PayoutSats}, fee {exit.FeeSats}"); +``` + +Every leaf must be owned and spendable. Renewable leaves are renewed first; a renewal keeps the +leaf's id (the operators update the leaf in place, and the split node a node renewal adds is a new +parent, not a new leaf), so the ids a transfer reported stay the ones to exit. A leaf that is spent, +locked, frozen, or due a renewal that failed is not spendable: `SparkLeavesNotSpendableException` +names those leaves, and nothing moves. The fee comes out of the leaves' total, with the +same response verification and fee bound as `WithdrawAsync`. + ## Errors | Exception | Cause | diff --git a/src/NSpark.RemoteSigner/NSpark.RemoteSigner.csproj b/src/NSpark.RemoteSigner/NSpark.RemoteSigner.csproj new file mode 100644 index 0000000..ea776e9 --- /dev/null +++ b/src/NSpark.RemoteSigner/NSpark.RemoteSigner.csproj @@ -0,0 +1,25 @@ + + + + + net8.0;net9.0;net10.0 + NSpark.RemoteSigner + NSpark.RemoteSigner + false + true + $(NoWarn);CS1591 + + + + + + + + + + + diff --git a/src/NSpark.RemoteSigner/RemoteSparkSigner.cs b/src/NSpark.RemoteSigner/RemoteSparkSigner.cs new file mode 100644 index 0000000..e2f0a3b --- /dev/null +++ b/src/NSpark.RemoteSigner/RemoteSparkSigner.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using NSpark.Signer; + +namespace NSpark.RemoteSigner; + +/// How a reaches the signer service. +public interface ISparkSignerTransport +{ + /// POSTs to ; returns the HTTP status and body. + public Task<(int Status, string Body)> InvokeAsync(string path, string jsonBody, CancellationToken ct); +} + +/// A remote signer refused or failed a request. +public sealed class SparkRemoteSignerException(string op, int status, string? error) + : Exception($"remote Spark signer {op} -> {status}: {error}") +{ + public string Operation { get; } = op; + + public int Status { get; } = status; +} + +/// +/// Client side of : an that holds no key +/// material and forwards every operation to the signer service. Operations outside the contract +/// throw without a round trip. +/// +public sealed class RemoteSparkSigner : ISparkSigner +{ + private readonly ISparkSignerTransport _transport; + private readonly string _wallet; + private byte[]? _identity; + + /// Transport to the signer service. + /// Id of the seed the signer should use (e.g. its master fingerprint). + public RemoteSparkSigner(ISparkSignerTransport transport, string wallet) + { + ArgumentNullException.ThrowIfNull(transport); + ArgumentException.ThrowIfNullOrEmpty(wallet); + _transport = transport; + _wallet = wallet; + } + + /// The signer's identity and policy, to verify before using it. + public Task GetInfoAsync(CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.Info, new WalletRequest(_wallet), ct); + + private async Task CallAsync(string op, object request, CancellationToken ct) + { + var (status, body) = await _transport + .InvokeAsync(SparkSignerContract.PathOf(op), JsonSerializer.Serialize(request, SparkSignerContract.Json), ct) + .ConfigureAwait(false); + + SparkSignerEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(string.IsNullOrEmpty(body) ? "{}" : body, SparkSignerContract.Json); + } + catch (JsonException) + { + // Not a Spark signer (e.g. a signer that predates the Spark routes answering as a PSBT signer). + throw new SparkRemoteSignerException(op, status, "response is not a Spark signer envelope"); + } + + if (status != 200 || envelope?.Error is not null || envelope?.Result is null) + { + throw new SparkRemoteSignerException(op, status, envelope?.Error ?? "empty result"); + } + + return envelope.Result.Value.Deserialize(SparkSignerContract.Json) + ?? throw new SparkRemoteSignerException(op, status, "empty result"); + } + + public async Task GetIdentityPublicKeyAsync(CancellationToken ct = default) => + _identity ??= await CallAsync(SparkSignerContract.Ops.IdentityPublicKey, new WalletRequest(_wallet), ct).ConfigureAwait(false); + + public Task SignWithIdentityKeyAsync(byte[] messageHash, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.SignWithIdentity, new HashRequest(_wallet, messageHash), ct); + + public Task SignCompactWithIdentityKeyAsync(byte[] messageHash, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.SignCompactWithIdentity, new HashRequest(_wallet, messageHash), ct); + + public Task DecryptEciesWithIdentityKeyAsync(byte[] ciphertext, CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer does not release ECIES plaintexts."); + + public Task GetDepositPublicKeyAsync(CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.DepositPublicKey, new WalletRequest(_wallet), ct); + + public Task GetLeafPublicKeyAsync(string leafId, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.LeafPublicKey, new LeafRequest(_wallet, leafId), ct); + + public Task SignLeafFrostAsync(string leafId, byte[] message, byte[] verifyingKey, + IReadOnlyDictionary soCommitments, byte[]? adaptorPublicKey = null, + CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.SignLeafFrost, + new SignLeafFrostRequest(_wallet, leafId, message, verifyingKey, + soCommitments.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal), adaptorPublicKey), ct); + + public Task GenerateLeafFrostNonceAsync(string leafId, CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer does not serve two-phase FROST nonces."); + + public Task SignLeafFrostWithNonceAsync(string leafId, byte[] nonceHandle, byte[] message, + byte[] verifyingKey, IReadOnlyDictionary soCommitments, + byte[]? adaptorPublicKey = null, CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer does not serve two-phase FROST nonces."); + + public Task BuildEncryptedSendTweaksAsync(IReadOnlyList leaves, + IReadOnlyList soTargets, string transferId, uint threshold, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.BuildSendTweaks, + new BuildSendTweaksRequest(_wallet, leaves.ToList(), soTargets.ToList(), transferId, threshold), ct); + + public Task BuildEncryptedClaimTweaksAsync(IReadOnlyList leaves, + IReadOnlyList soTargets, uint threshold, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.BuildClaimTweaks, + new BuildClaimTweaksRequest(_wallet, leaves.ToList(), soTargets.ToList(), threshold), ct); + + public Task GetStaticDepositPublicKeyAsync(int index = 0, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.StaticDepositPublicKey, new IndexRequest(_wallet, index), ct); + + public Task ExportStaticDepositPrivateKeyAsync(int index = 0, CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer never exports private keys."); + + public Task GenerateStaticDepositFrostNonceAsync(int index, CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer does not serve static-deposit nonces."); + + public Task SignStaticDepositFrostWithNonceAsync(int index, byte[] nonceHandle, byte[] message, + byte[] verifyingKey, IReadOnlyDictionary soCommitments, + CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer does not serve static-deposit nonces."); + + public Task BuildEncryptedPreimageSharesAsync(string transferId, + IReadOnlyList soTargets, uint threshold, CancellationToken ct = default) => + CallAsync(SparkSignerContract.Ops.BuildPreimageShares, + new BuildPreimageSharesRequest(_wallet, transferId, soTargets.ToList(), threshold), ct); + + public Task GenerateAdaptorKeyAsync(CancellationToken ct = default) => + throw new NotSupportedException("A remote Spark signer does not serve adaptor keys (SSP leaf swaps)."); +} diff --git a/src/NSpark.RemoteSigner/SparkSignerContract.cs b/src/NSpark.RemoteSigner/SparkSignerContract.cs new file mode 100644 index 0000000..cd5cc67 --- /dev/null +++ b/src/NSpark.RemoteSigner/SparkSignerContract.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NSpark.Signer; + +namespace NSpark.RemoteSigner; + +/// +/// HTTP contract for an served remotely: POST /spark/{op} with a +/// JSON body {"wallet": "<seed id>", ...}, answered with a . +/// byte[] values travel as base64. +/// +/// +/// Only single-round operations are part of the contract. Two-phase FROST nonces, static-deposit +/// key export, adaptor keys (SSP leaf swaps) and ECIES decryption are deliberately absent: a remote +/// signer never hands secret material, or a way to recover it, to its caller. +/// +public static class SparkSignerContract +{ + /// Bumped on any incompatible change; reported by . + public const int Version = 1; + + /// Every operation lives under this path prefix. + public const string RoutePrefix = "/spark/"; + + public static class Ops + { + public const string Info = "info"; + public const string IdentityPublicKey = "identity-public-key"; + public const string SignWithIdentity = "sign-with-identity"; + public const string SignCompactWithIdentity = "sign-compact-with-identity"; + public const string DepositPublicKey = "deposit-public-key"; + public const string LeafPublicKey = "leaf-public-key"; + public const string StaticDepositPublicKey = "static-deposit-public-key"; + public const string SignLeafFrost = "sign-leaf-frost"; + public const string BuildSendTweaks = "build-send-tweaks"; + public const string BuildClaimTweaks = "build-claim-tweaks"; + public const string BuildPreimageShares = "build-preimage-shares"; + } + + /// Serializer settings both sides must use. + public static JsonSerializerOptions Json { get; } = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// The path of , without a leading base path. + public static string PathOf(string op) => RoutePrefix + op; +} + +public sealed record WalletRequest(string Wallet); + +public sealed record HashRequest(string Wallet, byte[] MessageHash); + +public sealed record LeafRequest(string Wallet, string LeafId); + +public sealed record IndexRequest(string Wallet, int Index); + +public sealed record SignLeafFrostRequest( + string Wallet, + string LeafId, + byte[] Message, + byte[] VerifyingKey, + Dictionary SoCommitments, + byte[]? AdaptorPublicKey); + +public sealed record BuildSendTweaksRequest( + string Wallet, + List Leaves, + List SoTargets, + string TransferId, + uint Threshold); + +public sealed record BuildClaimTweaksRequest( + string Wallet, + List Leaves, + List SoTargets, + uint Threshold); + +public sealed record BuildPreimageSharesRequest( + string Wallet, + string TransferId, + List SoTargets, + uint Threshold); + +/// What a signer serves: lets the caller verify identity and policy before using it. +public sealed record SparkSignerInfo( + int ContractVersion, + string IdentityPublicKey, + IReadOnlyList OperatorKeys, + uint Threshold, + IReadOnlyList AllowedReceivers); + +/// Response body: exactly one of and is set. +public sealed record SparkSignerEnvelope(JsonElement? Result, string? Error); diff --git a/src/NSpark.RemoteSigner/SparkSignerDispatcher.cs b/src/NSpark.RemoteSigner/SparkSignerDispatcher.cs new file mode 100644 index 0000000..b989928 --- /dev/null +++ b/src/NSpark.RemoteSigner/SparkSignerDispatcher.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using NSpark.Signer; + +namespace NSpark.RemoteSigner; + +/// +/// The only checks a blind Spark signer can make. Key tweaks and preimage shares may only be +/// built for the pinned operator set at the pinned threshold; outbound key tweaks may only go to +/// an allowed receiver (the SSP) or back to the signer's own identity. +/// +/// Compressed identity keys (hex) of the signing operators. +/// Operator signing threshold. +/// Compressed identity keys (hex) allowed to receive leaves. +public sealed record SparkSignerPolicy( + IReadOnlySet OperatorKeys, + uint Threshold, + IReadOnlySet AllowedReceivers) +{ + /// Builds a policy, normalizing keys to lowercase hex. + public static SparkSignerPolicy Create(IEnumerable operatorKeys, uint threshold, IEnumerable allowedReceivers) => + new(Normalize(operatorKeys), threshold, Normalize(allowedReceivers)); + + private static HashSet Normalize(IEnumerable keys) => + keys.Select(k => k.Trim().ToLowerInvariant()).Where(k => k.Length > 0).ToHashSet(StringComparer.Ordinal); +} + +/// The request was refused by policy (HTTP 403). +public sealed class SparkSignerPolicyException(string message) : Exception(message); + +/// The operation does not exist (HTTP 404). +public sealed class SparkSignerUnknownOperationException(string op) : Exception($"unknown Spark signer operation '{op}'"); + +/// The request body is malformed (HTTP 400). +public sealed class SparkSignerBadRequestException(string message) : Exception(message); + +/// +/// Server side of : maps an operation and its JSON body onto an +/// call, enforcing . Seed selection and +/// the HTTP envelope are the host's job. +/// +public static class SparkSignerDispatcher +{ + /// Reads the wallet id of a request body before the host selects the seed. + public static string ReadWallet(string body) => + Parse(body).Wallet is { Length: > 0 } wallet + ? wallet + : throw new SparkSignerBadRequestException("missing wallet"); + + /// Runs and returns the value to put in the envelope's result. + public static async Task DispatchAsync( + string op, string body, ISparkSigner signer, SparkSignerPolicy policy, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(signer); + ArgumentNullException.ThrowIfNull(policy); + + switch (op) + { + case SparkSignerContract.Ops.Info: + { + var identity = await signer.GetIdentityPublicKeyAsync(ct).ConfigureAwait(false); + return new SparkSignerInfo( + SparkSignerContract.Version, + Convert.ToHexString(identity).ToLowerInvariant(), + policy.OperatorKeys.Order(StringComparer.Ordinal).ToList(), + policy.Threshold, + policy.AllowedReceivers.Order(StringComparer.Ordinal).ToList()); + } + + case SparkSignerContract.Ops.IdentityPublicKey: + return await signer.GetIdentityPublicKeyAsync(ct).ConfigureAwait(false); + + case SparkSignerContract.Ops.SignWithIdentity: + return await signer.SignWithIdentityKeyAsync(Hash32(Parse(body).MessageHash), ct).ConfigureAwait(false); + + case SparkSignerContract.Ops.SignCompactWithIdentity: + return await signer.SignCompactWithIdentityKeyAsync(Hash32(Parse(body).MessageHash), ct).ConfigureAwait(false); + + case SparkSignerContract.Ops.DepositPublicKey: + return await signer.GetDepositPublicKeyAsync(ct).ConfigureAwait(false); + + case SparkSignerContract.Ops.LeafPublicKey: + return await signer.GetLeafPublicKeyAsync(Parse(body).LeafId, ct).ConfigureAwait(false); + + case SparkSignerContract.Ops.StaticDepositPublicKey: + return await signer.GetStaticDepositPublicKeyAsync(Parse(body).Index, ct).ConfigureAwait(false); + + case SparkSignerContract.Ops.SignLeafFrost: + { + var r = Parse(body); + if (r.AdaptorPublicKey is not null) + { + throw new SparkSignerPolicyException("adaptor signatures (SSP leaf swaps) are not served"); + } + + return await signer.SignLeafFrostAsync(r.LeafId, r.Message, r.VerifyingKey, r.SoCommitments, null, ct).ConfigureAwait(false); + } + + case SparkSignerContract.Ops.BuildSendTweaks: + { + var r = Parse(body); + RequirePinnedOperators(r.SoTargets, r.Threshold, policy); + var own = Convert.ToHexString(await signer.GetIdentityPublicKeyAsync(ct).ConfigureAwait(false)).ToLowerInvariant(); + foreach (var leaf in r.Leaves) + { + var receiver = Convert.ToHexString(leaf.ReceiverPublicKey).ToLowerInvariant(); + if (receiver != own && !policy.AllowedReceivers.Contains(receiver)) + { + throw new SparkSignerPolicyException($"receiver {receiver} is not allowed"); + } + } + + return await signer.BuildEncryptedSendTweaksAsync(r.Leaves, r.SoTargets, r.TransferId, r.Threshold, ct).ConfigureAwait(false); + } + + case SparkSignerContract.Ops.BuildClaimTweaks: + { + var r = Parse(body); + RequirePinnedOperators(r.SoTargets, r.Threshold, policy); + return await signer.BuildEncryptedClaimTweaksAsync(r.Leaves, r.SoTargets, r.Threshold, ct).ConfigureAwait(false); + } + + case SparkSignerContract.Ops.BuildPreimageShares: + { + var r = Parse(body); + RequirePinnedOperators(r.SoTargets, r.Threshold, policy); + return await signer.BuildEncryptedPreimageSharesAsync(r.TransferId, r.SoTargets, r.Threshold, ct).ConfigureAwait(false); + } + + default: + throw new SparkSignerUnknownOperationException(op); + } + } + + private static void RequirePinnedOperators(List targets, uint threshold, SparkSignerPolicy policy) + { + if (policy.OperatorKeys.Count == 0) + { + throw new SparkSignerPolicyException("no operator set is configured"); + } + + var given = targets.Select(t => Convert.ToHexString(t.IdentityPublicKey).ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); + if (targets.Count != policy.OperatorKeys.Count || !given.SetEquals(policy.OperatorKeys)) + { + throw new SparkSignerPolicyException("operator targets do not match the pinned operator set"); + } + + if (threshold != policy.Threshold) + { + throw new SparkSignerPolicyException($"threshold {threshold} does not match the pinned threshold {policy.Threshold}"); + } + } + + private static byte[] Hash32(byte[]? hash) => + hash is { Length: 32 } ? hash : throw new SparkSignerBadRequestException("messageHash must be 32 bytes"); + + private static T Parse(string body) + { + try + { + return JsonSerializer.Deserialize(string.IsNullOrEmpty(body) ? "{}" : body, SparkSignerContract.Json) + ?? throw new SparkSignerBadRequestException("empty request body"); + } + catch (JsonException e) + { + throw new SparkSignerBadRequestException($"malformed request: {e.Message}"); + } + } +} diff --git a/src/NSpark.RemoteSigner/SparkSignerHttpHandler.cs b/src/NSpark.RemoteSigner/SparkSignerHttpHandler.cs new file mode 100644 index 0000000..f7c826a --- /dev/null +++ b/src/NSpark.RemoteSigner/SparkSignerHttpHandler.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using NSpark.Signer; + +namespace NSpark.RemoteSigner; + +/// +/// Host glue for a signer service: turns a POST /spark/{op} request into an HTTP status and +/// a body. The host only supplies seed selection and policy. +/// +public static class SparkSignerHttpHandler +{ + /// True when belongs to the Spark signer (and nothing else does). + public static bool IsSparkPath(string? path) => + Normalize(path).StartsWith(SparkSignerContract.RoutePrefix, StringComparison.Ordinal); + + /// + /// Handles one request. maps the request's wallet id to a signer; + /// throw from it to refuse a wallet. + /// + public static async Task<(int Status, string Body)> HandleAsync( + string? path, + string? body, + Func> resolveSigner, + SparkSignerPolicy policy, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(resolveSigner); + ArgumentNullException.ThrowIfNull(policy); + + var normalized = Normalize(path); + if (!normalized.StartsWith(SparkSignerContract.RoutePrefix, StringComparison.Ordinal)) + { + return Error(404, $"not a Spark signer path: {path}"); + } + + var op = normalized[SparkSignerContract.RoutePrefix.Length..].TrimEnd('/'); + try + { + var wallet = SparkSignerDispatcher.ReadWallet(body ?? string.Empty); + var signer = await resolveSigner(wallet, ct).ConfigureAwait(false); + var result = await SparkSignerDispatcher.DispatchAsync(op, body ?? string.Empty, signer, policy, ct).ConfigureAwait(false); + return (200, JsonSerializer.Serialize(new { result }, SparkSignerContract.Json)); + } + catch (SparkSignerBadRequestException e) + { + return Error(400, e.Message); + } + catch (SparkSignerPolicyException e) + { + return Error(403, e.Message); + } + catch (SparkSignerUnknownOperationException e) + { + return Error(404, e.Message); + } + catch (Exception e) when (e is not OperationCanceledException) + { + return Error(500, $"{e.GetType().Name}: {e.Message}"); + } + } + + /// Collapses repeated slashes so //spark/info and /spark/info match. + internal static string Normalize(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return "/"; + } + + var collapsed = string.Join('/', path.Split('/', StringSplitOptions.RemoveEmptyEntries)); + return "/" + collapsed + (path.EndsWith('/') && collapsed.Length > 0 ? "/" : string.Empty); + } + + private static (int, string) Error(int status, string message) => + (status, JsonSerializer.Serialize(new { error = message }, SparkSignerContract.Json)); +} diff --git a/src/NSpark/Exceptions/SparkTransferException.cs b/src/NSpark/Exceptions/SparkTransferException.cs index 3a193cb..f3757e5 100644 --- a/src/NSpark/Exceptions/SparkTransferException.cs +++ b/src/NSpark/Exceptions/SparkTransferException.cs @@ -57,3 +57,22 @@ public SparkWithdrawalException(string operation, string message, Exception inne { } } + +/// +/// Leaves asked for by id are not owned or not spendable (spent, locked, frozen, or due a renewal +/// that failed; a renewal itself keeps the leaf's id). Nothing was moved. +/// +public sealed class SparkLeavesNotSpendableException : SparkException +{ + /// The requested leaves that are not owned or not spendable. + public IReadOnlyList LeafIds { get; } + + /// Creates the exception for . + /// The operation that asked for the leaves. + /// The requested leaves that are not owned or not spendable. + public SparkLeavesNotSpendableException(string operation, IReadOnlyList leafIds) + : base(operation, $"{leafIds.Count} requested leaves are not owned or not spendable: {string.Join(", ", leafIds)}.") + { + LeafIds = leafIds; + } +} diff --git a/src/NSpark/GraphQL/Mutations.cs b/src/NSpark/GraphQL/Mutations.cs index 5b6226e..dea0030 100644 --- a/src/NSpark/GraphQL/Mutations.cs +++ b/src/NSpark/GraphQL/Mutations.cs @@ -102,6 +102,14 @@ query CoopExitFeeEstimate( user_fee { original_value original_unit } l1_broadcast_fee { original_value original_unit } } + speed_medium { + user_fee { original_value original_unit } + l1_broadcast_fee { original_value original_unit } + } + speed_slow { + user_fee { original_value original_unit } + l1_broadcast_fee { original_value original_unit } + } } } """; diff --git a/src/NSpark/GraphQL/Queries.cs b/src/NSpark/GraphQL/Queries.cs index 6f7478f..be710d5 100644 --- a/src/NSpark/GraphQL/Queries.cs +++ b/src/NSpark/GraphQL/Queries.cs @@ -15,7 +15,7 @@ query GetLightningInvoice($paymentHash: String!) { """; public const string GetUserRequest = """ - query GetUserRequest($request_id: ID!) { + query UserRequest($request_id: ID!) { user_request(request_id: $request_id) { __typename ... on LightningReceiveRequest { @@ -38,6 +38,26 @@ ... on LightningSendRequest { } """; + /// A Lightning receive request with the transfer that paid it, once the SSP has sent it. + public const string GetLightningReceiveRequest = """ + query UserRequest($request_id: ID!) { + user_request(request_id: $request_id) { + __typename + ... on LightningReceiveRequest { + lightning_receive_request_id: id + lightning_receive_request_status: status + lightning_receive_request_transfer: transfer { + transfer_spark_id: spark_id + transfer_total_amount: total_amount { + original_value + original_unit + } + } + } + } + } + """; + public const string LightningSendFeeEstimate = """ query LightningSendFeeEstimate($encoded_invoice: String!, $amount_sats: Long) { lightning_send_fee_estimate(input: { diff --git a/src/NSpark/GraphQL/ResponseTypes.cs b/src/NSpark/GraphQL/ResponseTypes.cs index 2153b2c..9ff2033 100644 --- a/src/NSpark/GraphQL/ResponseTypes.cs +++ b/src/NSpark/GraphQL/ResponseTypes.cs @@ -55,7 +55,9 @@ internal sealed record CoopExitFeeEstimateResponse( [property: JsonPropertyName("coop_exit_fee_estimates")] CoopExitFeeEstimateData CoopExitFeeEstimates); internal sealed record CoopExitFeeEstimateData( - [property: JsonPropertyName("speed_fast")] CoopExitSpeedData SpeedFast); + [property: JsonPropertyName("speed_fast")] CoopExitSpeedData? SpeedFast, + [property: JsonPropertyName("speed_medium")] CoopExitSpeedData? SpeedMedium = null, + [property: JsonPropertyName("speed_slow")] CoopExitSpeedData? SpeedSlow = null); internal sealed record CoopExitSpeedData( [property: JsonPropertyName("user_fee")] CoopExitFeeValue UserFee, @@ -158,6 +160,19 @@ internal sealed record UserRequestData( [JsonIgnore] public string? Id => ReceiveId ?? SendId; } +internal sealed record GetLightningReceiveRequestResponse( + [property: JsonPropertyName("user_request")] ReceiveUserRequestData? UserRequest); + +internal sealed record ReceiveUserRequestData( + [property: JsonPropertyName("__typename")] string TypeName, + [property: JsonPropertyName("lightning_receive_request_id")] string? Id, + [property: JsonPropertyName("lightning_receive_request_status")] string? Status, + [property: JsonPropertyName("lightning_receive_request_transfer")] LightningReceiveTransferData? Transfer); + +internal sealed record LightningReceiveTransferData( + [property: JsonPropertyName("transfer_spark_id")] string? SparkId, + [property: JsonPropertyName("transfer_total_amount")] CurrencyAmountData? TotalAmount); + internal sealed record CurrencyAmountData( [property: JsonPropertyName("original_value")] long OriginalValue, [property: JsonPropertyName("original_unit")] string? OriginalUnit); diff --git a/src/NSpark/Models/ExitSpeed.cs b/src/NSpark/Models/ExitSpeed.cs new file mode 100644 index 0000000..e2da74f --- /dev/null +++ b/src/NSpark/Models/ExitSpeed.cs @@ -0,0 +1,13 @@ +namespace NSpark.Models; + +/// +/// How fast the SSP has a cooperative exit confirm on-chain. The speed sets the fee rate of the exit transaction, +/// and with it the L1 broadcast fee the SSP charges: the slower, the cheaper. A slow exit can take hours to +/// confirm, and its leaves stay the wallet's (locked) until it does. +/// +public enum ExitSpeed +{ + Fast, + Medium, + Slow, +} diff --git a/src/NSpark/Models/LightningReceiveRequest.cs b/src/NSpark/Models/LightningReceiveRequest.cs new file mode 100644 index 0000000..dad8d65 --- /dev/null +++ b/src/NSpark/Models/LightningReceiveRequest.cs @@ -0,0 +1,18 @@ +namespace NSpark.Models; + +/// +/// The SSP's view of a Lightning receive request (CreateLightningInvoiceAsync), including the +/// Spark transfer that paid it once the SSP has sent it. +/// +/// The SSP's request id (). +/// The request's status, e.g. "TRANSFER_COMPLETED". +/// +/// The Spark transfer that paid the invoice, once the SSP has sent it. It is the id of a claimed +/// , whose are what the payment brought in. +/// +/// The transfer's total, as the SSP reports it. +public sealed record LightningReceiveRequest( + string Id, + string? Status, + string? TransferId, + long? TransferAmountSats); diff --git a/src/NSpark/Models/SparkTransfer.cs b/src/NSpark/Models/SparkTransfer.cs index 2329c41..c2b0a61 100644 --- a/src/NSpark/Models/SparkTransfer.cs +++ b/src/NSpark/Models/SparkTransfer.cs @@ -21,4 +21,11 @@ public sealed record SparkTransfer( { /// The Spark invoice this transfer pays, if any. public string? SparkInvoice { get; init; } + + /// + /// The leaves the transfer moves, as the operators reported them. A claimed inbound transfer's + /// leaves become this wallet's: exiting exactly them (WithdrawLeavesAsync) moves exactly + /// what the transfer brought in, without a leaf swap. + /// + public IReadOnlyList Leaves { get; init; } = []; } diff --git a/src/NSpark/Models/WithdrawAll.cs b/src/NSpark/Models/WithdrawAll.cs index 5ea2ee8..568a566 100644 --- a/src/NSpark/Models/WithdrawAll.cs +++ b/src/NSpark/Models/WithdrawAll.cs @@ -60,3 +60,13 @@ public sealed record WithdrawAllResult( /// SSP fee actually taken. public long FeeSats => SentSats - PayoutSats; } + +/// Outcome of WithdrawLeavesAsync. +/// L1 transaction id of the cooperative exit (display order). +/// Sats handed to the SSP: the exited leaves' total. +/// Sats the verified exit transaction pays to the destination. +public sealed record WithdrawLeavesResult(string Txid, long SentSats, long PayoutSats) +{ + /// SSP fee actually taken, the exit's miner fee included. + public long FeeSats => SentSats - PayoutSats; +} diff --git a/src/NSpark/PublicAPI.Unshipped.txt b/src/NSpark/PublicAPI.Unshipped.txt index 3d3a82b..d7fa37d 100644 --- a/src/NSpark/PublicAPI.Unshipped.txt +++ b/src/NSpark/PublicAPI.Unshipped.txt @@ -1148,3 +1148,45 @@ virtual NSpark.Exceptions.SparkException.IsRetryable.get -> bool virtual NSpark.Models.SparkEvent.EqualityContract.get -> System.Type! virtual NSpark.Models.SparkEvent.Equals(NSpark.Models.SparkEvent? other) -> bool virtual NSpark.Models.SparkEvent.PrintMembers(System.Text.StringBuilder! builder) -> bool +NSpark.Exceptions.SparkLeavesNotSpendableException +NSpark.Exceptions.SparkLeavesNotSpendableException.LeafIds.get -> System.Collections.Generic.IReadOnlyList! +NSpark.Exceptions.SparkLeavesNotSpendableException.SparkLeavesNotSpendableException(string! operation, System.Collections.Generic.IReadOnlyList! leafIds) -> void +NSpark.Models.LightningReceiveRequest +NSpark.Models.LightningReceiveRequest.$() -> NSpark.Models.LightningReceiveRequest! +NSpark.Models.LightningReceiveRequest.Deconstruct(out string! Id, out string? Status, out string? TransferId, out long? TransferAmountSats) -> void +NSpark.Models.LightningReceiveRequest.Equals(NSpark.Models.LightningReceiveRequest? other) -> bool +NSpark.Models.LightningReceiveRequest.Id.get -> string! +NSpark.Models.LightningReceiveRequest.Id.init -> void +NSpark.Models.LightningReceiveRequest.LightningReceiveRequest(string! Id, string? Status, string? TransferId, long? TransferAmountSats) -> void +NSpark.Models.LightningReceiveRequest.Status.get -> string? +NSpark.Models.LightningReceiveRequest.Status.init -> void +NSpark.Models.LightningReceiveRequest.TransferAmountSats.get -> long? +NSpark.Models.LightningReceiveRequest.TransferAmountSats.init -> void +NSpark.Models.LightningReceiveRequest.TransferId.get -> string? +NSpark.Models.LightningReceiveRequest.TransferId.init -> void +NSpark.Models.SparkTransfer.Leaves.get -> System.Collections.Generic.IReadOnlyList! +NSpark.Models.SparkTransfer.Leaves.init -> void +NSpark.Models.WithdrawLeavesResult +NSpark.Models.WithdrawLeavesResult.$() -> NSpark.Models.WithdrawLeavesResult! +NSpark.Models.WithdrawLeavesResult.Deconstruct(out string! Txid, out long SentSats, out long PayoutSats) -> void +NSpark.Models.WithdrawLeavesResult.Equals(NSpark.Models.WithdrawLeavesResult? other) -> bool +NSpark.Models.WithdrawLeavesResult.FeeSats.get -> long +NSpark.Models.WithdrawLeavesResult.PayoutSats.get -> long +NSpark.Models.WithdrawLeavesResult.PayoutSats.init -> void +NSpark.Models.WithdrawLeavesResult.SentSats.get -> long +NSpark.Models.WithdrawLeavesResult.SentSats.init -> void +NSpark.Models.WithdrawLeavesResult.Txid.get -> string! +NSpark.Models.WithdrawLeavesResult.Txid.init -> void +NSpark.Models.WithdrawLeavesResult.WithdrawLeavesResult(string! Txid, long SentSats, long PayoutSats) -> void +override NSpark.Models.LightningReceiveRequest.Equals(object? obj) -> bool +override NSpark.Models.LightningReceiveRequest.GetHashCode() -> int +override NSpark.Models.LightningReceiveRequest.ToString() -> string! +override NSpark.Models.WithdrawLeavesResult.Equals(object? obj) -> bool +override NSpark.Models.WithdrawLeavesResult.GetHashCode() -> int +override NSpark.Models.WithdrawLeavesResult.ToString() -> string! +static NSpark.Models.LightningReceiveRequest.operator !=(NSpark.Models.LightningReceiveRequest? left, NSpark.Models.LightningReceiveRequest? right) -> bool +static NSpark.Models.LightningReceiveRequest.operator ==(NSpark.Models.LightningReceiveRequest? left, NSpark.Models.LightningReceiveRequest? right) -> bool +static NSpark.Models.WithdrawLeavesResult.operator !=(NSpark.Models.WithdrawLeavesResult? left, NSpark.Models.WithdrawLeavesResult? right) -> bool +static NSpark.Models.WithdrawLeavesResult.operator ==(NSpark.Models.WithdrawLeavesResult? left, NSpark.Models.WithdrawLeavesResult? right) -> bool +static NSpark.Services.LightningService.GetLightningReceiveRequestAsync(this NSpark.SparkWallet! wallet, string! requestId, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static NSpark.Services.WithdrawalService.WithdrawLeavesAsync(this NSpark.SparkWallet! wallet, System.Collections.Generic.IReadOnlyCollection! leafIds, string! onChainAddress, long? maxFeeSats = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/NSpark/Services/LightningService.cs b/src/NSpark/Services/LightningService.cs index e28a95b..ae33791 100644 --- a/src/NSpark/Services/LightningService.cs +++ b/src/NSpark/Services/LightningService.cs @@ -168,6 +168,42 @@ public static async Task CreateLightningInvoiceAsync( : null; } + /// + /// The SSP's Lightning receive request by its request id, with the Spark transfer that paid it + /// once the SSP has sent it: match with a claimed + /// transfer to know exactly which leaves the payment brought in. Returns null if the SSP has no + /// record of the request, or if the id resolves to another request type. + /// + public static async Task GetLightningReceiveRequestAsync( + this SparkWallet wallet, + string requestId, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(wallet); + ArgumentException.ThrowIfNullOrWhiteSpace(requestId); + var response = await wallet.SspClient.ExecuteAsync( + Queries.GetLightningReceiveRequest, + new Dictionary { ["request_id"] = requestId }, + ct).ConfigureAwait(false); + + return ToLightningReceiveRequest(response.UserRequest, requestId); + } + + /// The receive variant of a user request, or null for anything else. + internal static LightningReceiveRequest? ToLightningReceiveRequest(ReceiveUserRequestData? data, string requestId) + { + if (data is null || !string.Equals(data.TypeName, "LightningReceiveRequest", StringComparison.Ordinal)) + { + return null; + } + + var transferId = string.IsNullOrWhiteSpace(data.Transfer?.SparkId) ? null : data.Transfer.SparkId; + long? transferAmount = data.Transfer?.TotalAmount is { } amount + ? TransferMapping.ReportedSats((ulong)Math.Max(0, CurrencyAmountExtensions.ToSats(amount.OriginalValue, amount.OriginalUnit))) + : null; + return new LightningReceiveRequest(data.Id ?? requestId, data.Status, transferId, transferId is null ? null : transferAmount); + } + /// /// Query the SSP for the status of an outgoing Lightning payment by its SSP request id (the /// string returned from ). Returns null if the SSP has diff --git a/src/NSpark/Services/TransferMapping.cs b/src/NSpark/Services/TransferMapping.cs index 9b17bc6..6aec021 100644 --- a/src/NSpark/Services/TransferMapping.cs +++ b/src/NSpark/Services/TransferMapping.cs @@ -28,6 +28,10 @@ internal static class TransferMapping Type: transfer.Type.ToString()) { SparkInvoice = string.IsNullOrEmpty(transfer.SparkInvoice) ? null : transfer.SparkInvoice, + Leaves = transfer.Leaves + .Where(l => l.Leaf is not null) + .Select(l => new SparkLeaf(l.Leaf.Id, l.Leaf.TreeId, ReportedSats(l.Leaf.Value), l.Leaf.Status) { Node = l.Leaf }) + .ToList(), }; } diff --git a/src/NSpark/Services/WithdrawalService.cs b/src/NSpark/Services/WithdrawalService.cs index 4402a18..3803c62 100644 --- a/src/NSpark/Services/WithdrawalService.cs +++ b/src/NSpark/Services/WithdrawalService.cs @@ -17,13 +17,15 @@ public static class WithdrawalService private const string Operation = "withdrawal.withdraw"; /// - /// Get a fee estimate for an on-chain withdrawal (cooperative exit). Each fee is read in the - /// unit the SSP reports it in; any other unit is refused. + /// Get a fee estimate for an on-chain withdrawal (cooperative exit) at : the SSP's + /// user fee plus its L1 broadcast fee for that speed. Each fee is read in the unit the SSP reports it in; any + /// other unit is refused. /// public static async Task GetFeeQuoteAsync( this SparkWallet wallet, string[] leafIds, string onChainAddress, + ExitSpeed exitSpeed = ExitSpeed.Fast, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(wallet); @@ -36,25 +38,44 @@ public static async Task GetFeeQuoteAsync( var response = await wallet.SspClient.ExecuteAsync( Mutations.CoopExitFeeEstimate, variables, ct).ConfigureAwait(false); - var fast = response.CoopExitFeeEstimates?.SpeedFast - ?? throw new SparkUntrustedResponseException("withdrawal.fee", "The SSP returned no fast exit estimate."); + return new FeeQuote(FeeSats: FeeSatsAt(response.CoopExitFeeEstimates, exitSpeed), FeeRateSatsPerVbyte: 0); + } + + /// The total fee of the SSP's estimate for ; refused when it has none. + internal static long FeeSatsAt(CoopExitFeeEstimateData? estimates, ExitSpeed exitSpeed) + { + var estimate = exitSpeed switch + { + ExitSpeed.Fast => estimates?.SpeedFast, + ExitSpeed.Medium => estimates?.SpeedMedium, + ExitSpeed.Slow => estimates?.SpeedSlow, + _ => throw new ArgumentOutOfRangeException(nameof(exitSpeed), exitSpeed, "Unknown exit speed."), + } ?? throw new SparkUntrustedResponseException( + "withdrawal.fee", $"The SSP returned no {exitSpeed.ToString().ToLowerInvariant()} exit estimate."); + var userFee = CurrencyAmountExtensions.ToFeeSats( - fast.UserFee?.OriginalValue ?? -1, fast.UserFee?.OriginalUnit, "cooperative exit user fee"); + estimate.UserFee?.OriginalValue ?? -1, estimate.UserFee?.OriginalUnit, "cooperative exit user fee"); var l1Fee = CurrencyAmountExtensions.ToFeeSats( - fast.L1BroadcastFee?.OriginalValue ?? -1, fast.L1BroadcastFee?.OriginalUnit, "cooperative exit broadcast fee"); - long totalFeeSats; + estimate.L1BroadcastFee?.OriginalValue ?? -1, estimate.L1BroadcastFee?.OriginalUnit, "cooperative exit broadcast fee"); try { - totalFeeSats = checked(userFee + l1Fee); + return checked(userFee + l1Fee); } catch (OverflowException ex) { throw new SparkUntrustedResponseException("withdrawal.fee", "The SSP's cooperative exit fees overflow.", ex); } - - return new FeeQuote(FeeSats: totalFeeSats, FeeRateSatsPerVbyte: 0); } + /// The SSP's spelling of (ExitSpeed enum). + internal static string GraphQLName(ExitSpeed exitSpeed) => exitSpeed switch + { + ExitSpeed.Fast => "FAST", + ExitSpeed.Medium => "MEDIUM", + ExitSpeed.Slow => "SLOW", + _ => throw new ArgumentOutOfRangeException(nameof(exitSpeed), exitSpeed, "Unknown exit speed."), + }; + /// /// Withdraw from Spark to an on-chain Bitcoin address via a cooperative exit brokered by /// the SSP. @@ -88,6 +109,7 @@ public static async Task GetFeeQuoteAsync( /// leaves is used as the bound. Throws if the quote is /// above the cap, or if the cap would consume the whole amount. /// + /// How fast the exit should confirm; the slower, the cheaper (see ). /// Cancellation token. /// The L1 transaction id of the cooperative exit (display order, big-endian hex). public static async Task WithdrawAsync( @@ -95,6 +117,7 @@ public static async Task WithdrawAsync( string onChainAddress, long amountSats, long? maxFeeSats = null, + ExitSpeed exitSpeed = ExitSpeed.Fast, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(wallet); @@ -119,14 +142,83 @@ public static async Task WithdrawAsync( // Bound the fee before asking the SSP to build the exit. var quote = await wallet.GetFeeQuoteAsync( - selectedLeaves.Select(l => l.Id).ToArray(), onChainAddress, ct).ConfigureAwait(false); + selectedLeaves.Select(l => l.Id).ToArray(), onChainAddress, exitSpeed, ct).ConfigureAwait(false); var feeCap = CoopExitValidator.ResolveFeeCap(quote.FeeSats, maxFeeSats, amountSats); var exit = await PerformCooperativeExitAsync( - wallet, selectedLeaves, amountSats, feeCap, onChainAddress, ct).ConfigureAwait(false); + wallet, selectedLeaves, amountSats, feeCap, onChainAddress, exitSpeed, ct).ConfigureAwait(false); return exit.Txid; } + /// + /// Withdraw exactly to in one + /// cooperative exit, without a leaf swap. + /// + /// + /// + /// Use it to exit what one inbound transfer brought in (): the + /// leaves already add up to that amount, so the SSP is never asked to re-denominate them. That + /// leaf swap is the only step of an amount-exact withdrawal that needs adaptor signatures, which + /// a remote signer does not serve; this exit needs none. + /// + /// + /// Renewable leaves are renewed first, as on every spend path. A renewal keeps the leaf's id (the + /// operators update the leaf in place; the split node a node renewal adds is a new parent, not + /// a new leaf), so the ids a transfer reported stay the ones to exit. Every requested leaf must be + /// owned and spendable, or names the ones that are + /// not (spent, locked, frozen, or not renewed) and nothing moves. The SSP's fee comes out of the leaves' total; the same response verification + /// and fee bound as apply. + /// + /// + /// The Spark wallet. + /// The leaves to exit, all of them owned and spendable. + /// Destination Bitcoin address on the wallet's network. + /// Highest fee the caller accepts; null uses the SSP's own quote. + /// How fast the exit should confirm; the slower, the cheaper (see ). + /// Cancellation token. + /// A requested leaf is not owned or not spendable. + /// The fee would consume the whole amount or exceed the cap. + public static async Task WithdrawLeavesAsync( + this SparkWallet wallet, + IReadOnlyCollection leafIds, + string onChainAddress, + long? maxFeeSats = null, + ExitSpeed exitSpeed = ExitSpeed.Fast, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(wallet); + ArgumentNullException.ThrowIfNull(leafIds); + ArgumentException.ThrowIfNullOrWhiteSpace(onChainAddress); + if (leafIds.Count == 0) + { + throw new ArgumentException("At least one leaf is needed.", nameof(leafIds)); + } + + if (leafIds.Distinct(StringComparer.Ordinal).Count() != leafIds.Count) + { + throw new ArgumentException("A leaf is listed twice.", nameof(leafIds)); + } + + _ = CoopExitValidator.ScriptPubKeyFor(onChainAddress, wallet.Options.Network); + + var spendable = (await wallet.GetSpendableLeavesAsync(ct).ConfigureAwait(false)) + .ToDictionary(l => l.Id, StringComparer.Ordinal); + var missing = leafIds.Where(id => !spendable.ContainsKey(id)).ToList(); + if (missing.Count > 0) + { + throw new SparkLeavesNotSpendableException(Operation, missing); + } + + var selected = leafIds.Select(id => spendable[id]).ToList(); + var amountSats = selected.Sum(l => l.ValueSats); + var quote = await wallet.GetFeeQuoteAsync(leafIds.ToArray(), onChainAddress, exitSpeed, ct).ConfigureAwait(false); + var feeCap = CoopExitValidator.ResolveFeeCap(quote.FeeSats, maxFeeSats, amountSats); + + var exit = await PerformCooperativeExitAsync( + wallet, selected, amountSats, feeCap, onChainAddress, exitSpeed, ct).ConfigureAwait(false); + return new WithdrawLeavesResult(exit.Txid, amountSats, exit.PayoutSats); + } + /// /// Everything would do, without doing it: claims pending /// inbound transfers, renews renewable leaves, and quotes the SSP fee for every spendable @@ -136,10 +228,11 @@ public static async Task WithdrawAsync( public static async Task QuoteWithdrawAllAsync( this SparkWallet wallet, string onChainAddress, + ExitSpeed exitSpeed = ExitSpeed.Fast, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(wallet); - var plan = await DrainPlanAsync(wallet, onChainAddress, ct).ConfigureAwait(false); + var plan = await DrainPlanAsync(wallet, onChainAddress, exitSpeed, ct).ConfigureAwait(false); return new WithdrawAllQuote( SpendableSats: plan.SpendableSats, QuotedFeeSats: plan.QuotedFeeSats, @@ -166,6 +259,7 @@ public static async Task QuoteWithdrawAllAsync( /// The Spark wallet. /// Destination Bitcoin address on the wallet's network. /// Highest fee the caller accepts; null uses the SSP's own quote. + /// How fast the exit should confirm; the slower, the cheaper (see ). /// Cancellation token. /// Nothing is spendable. /// The fee would consume the whole balance or exceed the cap. @@ -173,10 +267,11 @@ public static async Task WithdrawAllAsync( this SparkWallet wallet, string onChainAddress, long? maxFeeSats = null, + ExitSpeed exitSpeed = ExitSpeed.Fast, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(wallet); - var plan = await DrainPlanAsync(wallet, onChainAddress, ct).ConfigureAwait(false); + var plan = await DrainPlanAsync(wallet, onChainAddress, exitSpeed, ct).ConfigureAwait(false); if (plan.SpendableSats <= 0) { throw new SparkWithdrawalException(Operation, "Nothing is spendable: need at least 1 sat, have 0."); @@ -184,7 +279,7 @@ public static async Task WithdrawAllAsync( var feeCap = CoopExitValidator.ResolveFeeCap(plan.QuotedFeeSats, maxFeeSats, plan.SpendableSats); var exit = await PerformCooperativeExitAsync( - wallet, plan.Leaves, plan.SpendableSats, feeCap, onChainAddress, ct).ConfigureAwait(false); + wallet, plan.Leaves, plan.SpendableSats, feeCap, onChainAddress, exitSpeed, ct).ConfigureAwait(false); return new WithdrawAllResult( Txid: exit.Txid, SentSats: plan.SpendableSats, @@ -213,7 +308,8 @@ private sealed record DrainPlan(IReadOnlyList Leaves, SatsBalance Bal /// validate the destination, claim what is pending, renew what the coordinator will renew, /// and quote the fee for the rest. /// - private static async Task DrainPlanAsync(SparkWallet wallet, string onChainAddress, CancellationToken ct) + private static async Task DrainPlanAsync(SparkWallet wallet, string onChainAddress, ExitSpeed exitSpeed, + CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(onChainAddress); _ = CoopExitValidator.ScriptPubKeyFor(onChainAddress, wallet.Options.Network); @@ -232,7 +328,8 @@ private static async Task DrainPlanAsync(SparkWallet wallet, string o long quotedFee = 0; if (spendable > 0) { - quotedFee = (await wallet.GetFeeQuoteAsync(leaves.Select(l => l.Id).ToArray(), onChainAddress, ct).ConfigureAwait(false)).FeeSats; + quotedFee = (await wallet.GetFeeQuoteAsync(leaves.Select(l => l.Id).ToArray(), onChainAddress, exitSpeed, ct) + .ConfigureAwait(false)).FeeSats; } return new DrainPlan(leaves, balance, spendable, quotedFee); @@ -253,6 +350,7 @@ private static async Task PerformCooperativeExitAsync( long amountSats, long feeCap, string onChainAddress, + ExitSpeed exitSpeed, CancellationToken ct) { var options = wallet.Options; @@ -271,7 +369,7 @@ private static async Task PerformCooperativeExitAsync( { ["leaf_external_ids"] = leafIds, ["withdrawal_address"] = onChainAddress, - ["exit_speed"] = "FAST", + ["exit_speed"] = GraphQLName(exitSpeed), ["withdraw_all"] = true, ["user_outbound_transfer_external_id"] = transferId, }, @@ -418,8 +516,8 @@ await wallet.SspClient.ExecuteAsync( internal sealed record ConnectorRefund(byte[] Tx, byte[] Sighash); /// - /// The three connector refunds of one leaf. is absent for zero-timelock - /// nodes and leaves without a direct node transaction. + /// The three connector refunds of one leaf. is absent only for a leaf + /// without a direct node transaction. /// internal sealed record ConnectorRefunds(ConnectorRefund Cpfp, ConnectorRefund? Direct, ConnectorRefund DirectFromCpfp); @@ -427,8 +525,17 @@ internal sealed record ConnectorRefunds(ConnectorRefund Cpfp, ConnectorRefund? D /// The leaf's next refund transactions with the connector output appended as a second /// input, and their two-input sighashes (BIP-341, prevouts = node output + connector /// output). This is what the user signs for a cooperative exit; mirrors the reference SDK's - /// createConnectorRefundTxs + signRefundsForCoopExit. + /// createConnectorRefundTxs + signRefundsForCoopExit, except for a zero-timelock + /// node with a direct node transaction (below). /// + /// + /// A leaf with a direct node transaction gets a direct refund spending it, even when its node + /// transaction is at timelock 0, which is what a zero-timelock renewal leaves. The operators' + /// cooperative-exit check (validateSendLeafRefundTxs, requireDirectTx set) takes + /// both direct refunds or none for such a leaf and requires them, and its connector validation + /// has no zero-node rule. Only a claim exempts a zero-timelock node from the direct refund, which + /// is what the reference SDK applies here too, so its exit of a renewed root leaf is refused. + /// internal static ConnectorRefunds BuildConnectorRefunds( TreeNode node, byte[] receiverPubKey, @@ -438,11 +545,19 @@ internal static ConnectorRefunds BuildConnectorRefunds( { var (cpfpSequence, directSequence) = TimelockHelper.ComputeNextSequences( node.RefundTx.ToByteArray(), Operation, node.Id); - var directNodeTx = TimelockHelper.DirectNodeTxForRefund(node); + var directNodeTx = node.DirectTx.IsEmpty ? null : node.DirectTx.ToByteArray(); // The SSP validates all three refund outputs on coop-exit and rejects with // "expected value X on output 0" if the standard fee isn't deducted. - var trio = TimelockHelper.LeafRefundTrio(node, receiverPubKey, networkStr, cpfpSequence, directSequence); + var trio = SparkTxBuilder.BuildRefundTxTrio( + cpfpNodeTx: node.NodeTx.ToByteArray(), + directNodeTx: directNodeTx, + vout: 0, + receivingPublicKey: receiverPubKey, + network: networkStr, + sequence: cpfpSequence, + directSequence: directSequence, + feeSats: SparkConstants.DefaultRefundFeeSats); var connectorOutput = connectorTx.OutputAt(connectorVout); var connectorTxid = connectorTx.Txid; diff --git a/tests/NSpark.IntegrationTests/HardeningIntegrationTests.cs b/tests/NSpark.IntegrationTests/HardeningIntegrationTests.cs index 09ada27..d6ca730 100644 --- a/tests/NSpark.IntegrationTests/HardeningIntegrationTests.cs +++ b/tests/NSpark.IntegrationTests/HardeningIntegrationTests.cs @@ -300,7 +300,7 @@ public async Task Frozen_sats_are_exactly_the_leaves_below_the_renewal_minimum_a // The quote claims, renews what the operators will renew, and fetches a fee quote; // nothing leaves the wallet. var destination = (await other.GetStaticDepositAddressAsync(ct)).Address; - var quote = await wallet.QuoteWithdrawAllAsync(destination, ct); + var quote = await wallet.QuoteWithdrawAllAsync(destination, ct: ct); var after = (await wallet.GetBalanceAsync(ct)).SatsBalance; TestContext.Out.WriteLine( $"[{label}] available {balance.SatsBalance.Available} frozen {balance.SatsBalance.Frozen} -> quote spendable {quote.SpendableSats} " + @@ -383,7 +383,7 @@ public async Task WithdrawAll_drains_every_spendable_sat_with_a_verified_payout_ var ct = cts.Token; var pair = await MakePairAsync(ct); var destination = await DestinationAsync(pair, ct); - var quote = await pair.Sender.QuoteWithdrawAllAsync(destination, ct); + var quote = await pair.Sender.QuoteWithdrawAllAsync(destination, ct: ct); TestContext.Out.WriteLine( $"[{pair.SenderLabel}] quote: spendable {quote.SpendableSats} fee {quote.QuotedFeeSats} payout≈{quote.EstimatedPayoutSats} " + $"frozen {quote.FrozenSats} locked {quote.LockedSats} incoming {quote.IncomingSats} leaves {quote.LeafCount}"); diff --git a/tests/NSpark.IntegrationTests/SparkWalletIntegrationTests.cs b/tests/NSpark.IntegrationTests/SparkWalletIntegrationTests.cs index b9fe8f1..b582984 100644 --- a/tests/NSpark.IntegrationTests/SparkWalletIntegrationTests.cs +++ b/tests/NSpark.IntegrationTests/SparkWalletIntegrationTests.cs @@ -1023,7 +1023,7 @@ public async Task ShouldGetWithdrawalFeeEstimate() var fee = await _wallet.GetFeeQuoteAsync( leaves.Select(l => l.Id).ToArray(), "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - cts.Token); + ct: cts.Token); // Guard against the alpha.5 unit-mismatch regression: a cooperative exit on mainnet // always carries an L1 miner fee that's at least a couple hundred sats. If the quote @@ -1056,7 +1056,7 @@ public async Task CoopExitResponseFromTheSspValidatesWithoutSigning() var amount = leaves.Sum(l => l.ValueSats); var leafIds = leaves.Select(l => l.Id).ToArray(); var destination = (await _wallet.GetStaticDepositAddressAsync(cts.Token)).Address; - var quote = await _wallet.GetFeeQuoteAsync(leafIds, destination, cts.Token); + var quote = await _wallet.GetFeeQuoteAsync(leafIds, destination, ct: cts.Token); TestContext.Out.WriteLine($"{leaves.Count} leaves, {amount} sats, SSP quote {quote.FeeSats} sats, destination {destination}"); if (quote.FeeSats >= amount) { @@ -1150,7 +1150,7 @@ public async Task ShouldWithdrawToOnChainAddress() var before = await _wallet.GetBalanceAsync(cts.Token); TestContext.Out.WriteLine($"Withdrawing {amount} of {before.SatsBalance.Available} available sats to {destination} (fee cap {maxFeeSats?.ToString() ?? "SSP quote"})"); - var txid = await _wallet.WithdrawAsync(destination, amount, maxFeeSats, cts.Token); + var txid = await _wallet.WithdrawAsync(destination, amount, maxFeeSats, ct: cts.Token); Assert.That(txid, Has.Length.EqualTo(64)); TestContext.Out.WriteLine($"Withdrawal txid: {txid}"); diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/build-claim-tweaks.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/build-claim-tweaks.json new file mode 100644 index 0000000..2869922 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/build-claim-tweaks.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","leaves":[{"leafId":"leaf-1","senderSecretCipher":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="}],"soTargets":[{"soId":"0000000000000000000000000000000000000000000000000000000000000001","shareIndex":1,"identityPublicKey":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"}],"threshold":2} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/build-preimage-shares.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/build-preimage-shares.json new file mode 100644 index 0000000..f94a06b --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/build-preimage-shares.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","transferId":"019a0000-0000-7000-8000-000000000002","soTargets":[{"soId":"0000000000000000000000000000000000000000000000000000000000000001","shareIndex":1,"identityPublicKey":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"}],"threshold":2} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/build-send-tweaks.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/build-send-tweaks.json new file mode 100644 index 0000000..4a8df27 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/build-send-tweaks.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","leaves":[{"leafId":"leaf-1","receiverPublicKey":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"}],"soTargets":[{"soId":"0000000000000000000000000000000000000000000000000000000000000001","shareIndex":1,"identityPublicKey":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"}],"transferId":"019a0000-0000-7000-8000-000000000001","threshold":2} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/deposit-public-key.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/deposit-public-key.json new file mode 100644 index 0000000..5d231bf --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/deposit-public-key.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8"} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/identity-public-key.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/identity-public-key.json new file mode 100644 index 0000000..5d231bf --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/identity-public-key.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8"} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/info.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/info.json new file mode 100644 index 0000000..5d231bf --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/info.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8"} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/leaf-public-key.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/leaf-public-key.json new file mode 100644 index 0000000..321ca48 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/leaf-public-key.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","leafId":"leaf-1"} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-compact-with-identity.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-compact-with-identity.json new file mode 100644 index 0000000..a512db3 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-compact-with-identity.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","messageHash":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-leaf-frost.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-leaf-frost.json new file mode 100644 index 0000000..8072592 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-leaf-frost.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","leafId":"leaf-1","message":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","verifyingKey":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g","soCommitments":{"0000000000000000000000000000000000000000000000000000000000000001":{"hiding":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g","binding":"AgECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"}}} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-with-identity.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-with-identity.json new file mode 100644 index 0000000..a512db3 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/sign-with-identity.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","messageHash":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} diff --git a/tests/NSpark.RemoteSigner.Tests/Fixtures/static-deposit-public-key.json b/tests/NSpark.RemoteSigner.Tests/Fixtures/static-deposit-public-key.json new file mode 100644 index 0000000..4b0227d --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/Fixtures/static-deposit-public-key.json @@ -0,0 +1 @@ +{"wallet":"ed0210c8","index":2} diff --git a/tests/NSpark.RemoteSigner.Tests/GoldenFixtureTests.cs b/tests/NSpark.RemoteSigner.Tests/GoldenFixtureTests.cs new file mode 100644 index 0000000..0911063 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/GoldenFixtureTests.cs @@ -0,0 +1,79 @@ +using NSpark.RemoteSigner; +using NSpark.Signer; + +namespace NSpark.RemoteSigner.Tests; + +/// +/// Pins the wire format: the exact request body sends for each +/// operation. The signer service's tests parse the same files, so a change here is a contract +/// change on both sides (bump if incompatible). +/// Regenerate with NSPARK_UPDATE_FIXTURES=1 dotnet test and review the diff. +/// +public class GoldenFixtureTests +{ + private const string Wallet = "ed0210c8"; + + private static readonly byte[] Hash = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + private static readonly byte[] Key33 = [0x02, .. Enumerable.Range(1, 32).Select(i => (byte)i)]; + private static readonly SoTarget Target = new("0000000000000000000000000000000000000000000000000000000000000001", 1, Key33); + + private static IEnumerable Calls() + { + yield return Case(SparkSignerContract.Ops.Info, s => s.GetInfoAsync()); + yield return Case(SparkSignerContract.Ops.IdentityPublicKey, s => s.GetIdentityPublicKeyAsync()); + yield return Case(SparkSignerContract.Ops.SignWithIdentity, s => s.SignWithIdentityKeyAsync(Hash)); + yield return Case(SparkSignerContract.Ops.SignCompactWithIdentity, s => s.SignCompactWithIdentityKeyAsync(Hash)); + yield return Case(SparkSignerContract.Ops.DepositPublicKey, s => s.GetDepositPublicKeyAsync()); + yield return Case(SparkSignerContract.Ops.LeafPublicKey, s => s.GetLeafPublicKeyAsync("leaf-1")); + yield return Case(SparkSignerContract.Ops.StaticDepositPublicKey, s => s.GetStaticDepositPublicKeyAsync(2)); + yield return Case(SparkSignerContract.Ops.SignLeafFrost, s => s.SignLeafFrostAsync("leaf-1", Hash, Key33, + new Dictionary { [Target.SoId] = new(Key33, Key33) })); + yield return Case(SparkSignerContract.Ops.BuildSendTweaks, s => s.BuildEncryptedSendTweaksAsync( + [new SendTweakLeafDescriptor("leaf-1", Key33)], [Target], "019a0000-0000-7000-8000-000000000001", 2)); + yield return Case(SparkSignerContract.Ops.BuildClaimTweaks, s => s.BuildEncryptedClaimTweaksAsync( + [new ClaimTweakLeafDescriptor("leaf-1", Hash)], [Target], 2)); + yield return Case(SparkSignerContract.Ops.BuildPreimageShares, s => s.BuildEncryptedPreimageSharesAsync( + "019a0000-0000-7000-8000-000000000002", [Target], 2)); + } + + [TestCaseSource(nameof(Calls))] + public async Task RequestBody_MatchesTheFixture(string op, Func call) + { + var transport = new CapturingTransport(); + await call(new RemoteSparkSigner(transport, Wallet)); + + transport.Path.Should().Be("/spark/" + op); + var fixture = Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", op + ".json"); + if (Environment.GetEnvironmentVariable("NSPARK_UPDATE_FIXTURES") == "1") + { + var source = Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "Fixtures", op + ".json"); + await File.WriteAllTextAsync(source, transport.Body + "\n"); + Assert.Inconclusive($"fixture {op}.json rewritten"); + } + + transport.Body.Should().Be((await File.ReadAllTextAsync(fixture)).TrimEnd('\n')); + } + + private static TestCaseData Case(string op, Func call) => + new TestCaseData(op, call).SetName($"RequestBody_MatchesTheFixture({op})"); + + /// Captures the request and answers with an envelope whose result fits any operation. + private sealed class CapturingTransport : ISparkSignerTransport + { + public string Path { get; private set; } = string.Empty; + + public string Body { get; private set; } = string.Empty; + + public Task<(int Status, string Body)> InvokeAsync(string path, string jsonBody, CancellationToken ct) + { + Path = path; + Body = jsonBody; + var result = path.EndsWith("/" + SparkSignerContract.Ops.Info, StringComparison.Ordinal) + ? """{"contractVersion":1,"identityPublicKey":"00","operatorKeys":[],"threshold":2,"allowedReceivers":[]}""" + : path.Contains("tweaks", StringComparison.Ordinal) || path.Contains("preimage", StringComparison.Ordinal) || path.Contains("frost", StringComparison.Ordinal) + ? "{}" + : "\"AA==\""; + return Task.FromResult((200, $"{{\"result\":{result}}}")); + } + } +} diff --git a/tests/NSpark.RemoteSigner.Tests/NSpark.RemoteSigner.Tests.csproj b/tests/NSpark.RemoteSigner.Tests/NSpark.RemoteSigner.Tests.csproj new file mode 100644 index 0000000..b0e2157 --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/NSpark.RemoteSigner.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0;net9.0;net10.0 + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/NSpark.RemoteSigner.Tests/RemoteSignerContractTests.cs b/tests/NSpark.RemoteSigner.Tests/RemoteSignerContractTests.cs new file mode 100644 index 0000000..b6230de --- /dev/null +++ b/tests/NSpark.RemoteSigner.Tests/RemoteSignerContractTests.cs @@ -0,0 +1,217 @@ +using System.Text; +using NBitcoin; +using NSpark.RemoteSigner; +using NSpark.Signer; + +namespace NSpark.RemoteSigner.Tests; + +/// +/// End-to-end contract tests: (client) over an in-memory transport +/// into (server) backed by a real in-process +/// . Uses the public BIP-39 test mnemonic; never fund it. +/// +public class RemoteSignerContractTests +{ + private const string Mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + private const string Wallet = "testwallet"; + + private static readonly byte[][] OperatorKeys = [Pub(1), Pub(2), Pub(3)]; + private static readonly byte[] Ssp = Pub(9); + + private static readonly IReadOnlyList SoTargets = + [ + new("0000000000000000000000000000000000000000000000000000000000000001", 1, OperatorKeys[0]), + new("0000000000000000000000000000000000000000000000000000000000000002", 2, OperatorKeys[1]), + new("0000000000000000000000000000000000000000000000000000000000000003", 3, OperatorKeys[2]), + ]; + + private SparkSigner _local = null!; + private InMemoryTransport _transport = null!; + private RemoteSparkSigner _remote = null!; + + [SetUp] + public void SetUp() + { + _local = SparkSigner.FromMnemonic(Mnemonic, 0); + var policy = SparkSignerPolicy.Create(OperatorKeys.Select(Hex), 2, [Hex(Ssp)]); + _transport = new InMemoryTransport(policy, wallet => + wallet == Wallet ? _local : throw new SparkSignerPolicyException($"unknown wallet {wallet}")); + _remote = new RemoteSparkSigner(_transport, Wallet); + } + + [Test] + public async Task Info_ReportsTheIdentityAndThePolicy() + { + var info = await _remote.GetInfoAsync(); + + info.ContractVersion.Should().Be(SparkSignerContract.Version); + info.IdentityPublicKey.Should().Be(Hex(await _local.GetIdentityPublicKeyAsync())); + info.OperatorKeys.Should().BeEquivalentTo(OperatorKeys.Select(Hex)); + info.Threshold.Should().Be(2); + info.AllowedReceivers.Should().Equal(Hex(Ssp)); + } + + [Test] + public async Task PublicKeys_MatchTheInProcessSigner() + { + (await _remote.GetIdentityPublicKeyAsync()).Should().Equal(await _local.GetIdentityPublicKeyAsync()); + (await _remote.GetDepositPublicKeyAsync()).Should().Equal(await _local.GetDepositPublicKeyAsync()); + (await _remote.GetLeafPublicKeyAsync("leaf-1")).Should().Equal(await _local.GetLeafPublicKeyAsync("leaf-1")); + (await _remote.GetStaticDepositPublicKeyAsync(2)).Should().Equal(await _local.GetStaticDepositPublicKeyAsync(2)); + } + + [Test] + public async Task IdentitySignatures_MatchTheInProcessSigner() + { + var hash = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + + (await _remote.SignWithIdentityKeyAsync(hash)).Should().Equal(await _local.SignWithIdentityKeyAsync(hash)); + (await _remote.SignCompactWithIdentityKeyAsync(hash)).Should().Equal(await _local.SignCompactWithIdentityKeyAsync(hash)); + } + + [Test] + public async Task SendTweaks_ToTheAllowedReceiver_AreBuilt() + { + var batch = await _remote.BuildEncryptedSendTweaksAsync([new SendTweakLeafDescriptor("leaf-1", Ssp)], SoTargets, + "019a0000-0000-7000-8000-000000000001", 2); + + batch.EncryptedPackageBySoId.Keys.Should().BeEquivalentTo(SoTargets.Select(t => t.SoId)); + } + + [Test] + public async Task SendTweaks_BackToTheOwnIdentity_AreBuilt() + { + var own = await _local.GetIdentityPublicKeyAsync(); + + var batch = await _remote.BuildEncryptedSendTweaksAsync([new SendTweakLeafDescriptor("leaf-1", own)], SoTargets, + "019a0000-0000-7000-8000-000000000002", 2); + + batch.EncryptedPackageBySoId.Should().HaveCount(3); + } + + [Test] + public async Task SendTweaks_ToAnyOtherReceiver_AreRefused() + { + var act = () => _remote.BuildEncryptedSendTweaksAsync([new SendTweakLeafDescriptor("leaf-1", Pub(42))], SoTargets, + "019a0000-0000-7000-8000-000000000003", 2); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Test] + public async Task Tweaks_ForAnotherOperatorSet_AreRefused() + { + var foreign = SoTargets.Take(2).Append(new SoTarget(SoTargets[2].SoId, 3, Pub(7))).ToList(); + + var claim = () => _remote.BuildEncryptedClaimTweaksAsync([], foreign, 2); + var preimage = () => _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000004", foreign, 2); + + (await claim.Should().ThrowAsync()).Which.Status.Should().Be(403); + (await preimage.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Test] + public async Task Tweaks_AtAnotherThreshold_AreRefused() + { + var act = () => _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000005", SoTargets, 3); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Test] + public async Task PreimageShares_ForThePinnedOperators_AreBuilt() + { + var bundle = await _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000006", SoTargets, 2); + + bundle.PaymentHash.Should().HaveCount(32); + } + + [Test] + public async Task AdaptorSignatures_AreRefused() + { + var act = () => _remote.SignLeafFrostAsync("leaf-1", new byte[32], Pub(5), + new Dictionary(), adaptorPublicKey: Pub(6)); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Test] + public async Task UnknownWallet_IsRefused() + { + var other = new RemoteSparkSigner(_transport, "otherwallet"); + + var act = () => other.GetIdentityPublicKeyAsync(); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Test] + public async Task OperationsOutsideTheContract_NeverReachTheTransport() + { + var calls = _transport.Requests.Count; + + await FluentActions.Awaiting(() => _remote.ExportStaticDepositPrivateKeyAsync()).Should().ThrowAsync(); + await FluentActions.Awaiting(() => _remote.DecryptEciesWithIdentityKeyAsync([1])).Should().ThrowAsync(); + await FluentActions.Awaiting(() => _remote.GenerateLeafFrostNonceAsync("leaf-1")).Should().ThrowAsync(); + await FluentActions.Awaiting(() => _remote.GenerateAdaptorKeyAsync()).Should().ThrowAsync(); + + _transport.Requests.Should().HaveCount(calls); + } + + [Test] + public async Task UnknownOperation_Is404() + { + var (status, body) = await SparkSignerHttpHandler.HandleAsync("/spark/export-everything", $"{{\"wallet\":\"{Wallet}\"}}", + (_, _) => Task.FromResult(_local), SparkSignerPolicy.Create([], 2, [])); + + status.Should().Be(404); + body.Should().Contain("\"error\""); + } + + [TestCase("/spark/info", true)] + [TestCase("//spark/info", true)] + [TestCase("/spark/", true)] + [TestCase("/", false)] + [TestCase("", false)] + [TestCase(null, false)] + [TestCase("/spark", false)] + [TestCase("/sparkle/info", false)] + [TestCase("/sign", false)] + public void OnlyTheSparkPrefix_IsASparkPath(string? path, bool expected) => + SparkSignerHttpHandler.IsSparkPath(path).Should().Be(expected); + + [Test] + public async Task ANonEnvelopeResponse_IsReportedAsNotASparkSigner() + { + // A signer that predates the Spark routes answers every path as a PSBT signer. + var legacy = new RemoteSparkSigner(new FixedTransport(500, "Object reference not set to an instance of an object."), Wallet); + + var act = () => legacy.GetInfoAsync(); + + (await act.Should().ThrowAsync()).WithMessage("*not a Spark signer envelope*"); + } + + private static byte[] Pub(byte seed) => new Key(Enumerable.Repeat(seed, 32).ToArray()).PubKey.ToBytes(); + + private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); + + internal sealed class InMemoryTransport(SparkSignerPolicy policy, Func resolve) : ISparkSignerTransport + { + public List<(string Path, string Body)> Requests { get; } = []; + + public Task<(int Status, string Body)> InvokeAsync(string path, string jsonBody, CancellationToken ct) + { + Requests.Add((path, jsonBody)); + return SparkSignerHttpHandler.HandleAsync(path, jsonBody, (wallet, _) => Task.FromResult(resolve(wallet)), policy, ct); + } + } + + private sealed class FixedTransport(int status, string body) : ISparkSignerTransport + { + public Task<(int Status, string Body)> InvokeAsync(string path, string jsonBody, CancellationToken ct) => + Task.FromResult((status, body)); + } + + internal static string Utf8(byte[] bytes) => Encoding.UTF8.GetString(bytes); +} diff --git a/tests/NSpark.UnitTests/Services/ExitSpeedTests.cs b/tests/NSpark.UnitTests/Services/ExitSpeedTests.cs new file mode 100644 index 0000000..50bba3c --- /dev/null +++ b/tests/NSpark.UnitTests/Services/ExitSpeedTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using NSpark.Exceptions; +using NSpark.GraphQL; +using NSpark.Models; +using NSpark.Services; + +namespace NSpark.UnitTests.Services; + +/// +/// The exit speed of a cooperative exit: the fee quoted for it (the SSP's coop_exit_fee_estimates has one +/// estimate per speed) and the speed asked of request_coop_exit. +/// +[TestFixture] +public class ExitSpeedTests +{ + // The SSP's estimates, as open-ssp returns them for 2-, 6- and 12-block fee targets + private const string Estimates = """ + { + "coop_exit_fee_estimates": { + "speed_fast": { "user_fee": { "original_value": 750, "original_unit": "SATOSHI" }, + "l1_broadcast_fee": { "original_value": 2500, "original_unit": "SATOSHI" } }, + "speed_medium": { "user_fee": { "original_value": 750, "original_unit": "SATOSHI" }, + "l1_broadcast_fee": { "original_value": 1000000, "original_unit": "MILLISATOSHI" } }, + "speed_slow": { "user_fee": { "original_value": 750, "original_unit": "SATOSHI" }, + "l1_broadcast_fee": { "original_value": 250, "original_unit": "SATOSHI" } } + } + } + """; + + private static CoopExitFeeEstimateData? Parse(string json) => + JsonSerializer.Deserialize(json)!.CoopExitFeeEstimates; + + [TestCase(ExitSpeed.Fast, 3_250L)] + [TestCase(ExitSpeed.Medium, 1_750L)] + [TestCase(ExitSpeed.Slow, 1_000L)] + public void The_quote_is_the_chosen_speeds_user_fee_plus_its_broadcast_fee(ExitSpeed speed, long feeSats) + { + WithdrawalService.FeeSatsAt(Parse(Estimates), speed).Should().Be(feeSats); + } + + [Test] + public void A_speed_the_SSP_has_no_estimate_for_is_refused() + { + var onlyFast = """ + { "coop_exit_fee_estimates": { "speed_fast": { "user_fee": { "original_value": 750, "original_unit": "SATOSHI" }, + "l1_broadcast_fee": { "original_value": 2500, "original_unit": "SATOSHI" } } } } + """; + + var act = () => WithdrawalService.FeeSatsAt(Parse(onlyFast), ExitSpeed.Slow); + + act.Should().Throw().WithMessage("*no slow exit estimate*"); + WithdrawalService.FeeSatsAt(Parse(onlyFast), ExitSpeed.Fast).Should().Be(3_250); + } + + [TestCase(ExitSpeed.Fast, "FAST")] + [TestCase(ExitSpeed.Medium, "MEDIUM")] + [TestCase(ExitSpeed.Slow, "SLOW")] + public void The_speed_is_asked_of_the_SSP_in_its_own_spelling(ExitSpeed speed, string graphQl) + { + WithdrawalService.GraphQLName(speed).Should().Be(graphQl); + } +} diff --git a/tests/NSpark.UnitTests/Services/LeafExitTests.cs b/tests/NSpark.UnitTests/Services/LeafExitTests.cs new file mode 100644 index 0000000..cb5b6d1 --- /dev/null +++ b/tests/NSpark.UnitTests/Services/LeafExitTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using Google.Protobuf; +using NSpark.Exceptions; +using NSpark.GraphQL; +using NSpark.Proto; +using NSpark.Services; +using NSpark.UnitTests.TestSupport; + +namespace NSpark.UnitTests.Services; + +/// +/// Exiting what one inbound transfer brought in: the transfer's leaves, the receive request that +/// names the transfer, and the leaf exit's refusals before anything moves. +/// +[TestFixture] +public sealed class LeafExitTests +{ + private const string Address = "bcrt1qcg6gkhg76snuvxu6l795yw3fx8dg8088r7zq82u60qauh7tr2kjquxfd2m"; + + [Test] + public void A_transfer_reports_the_leaves_it_moves() + { + var transfer = new Transfer { Id = "transfer-1", TotalValue = 300, Status = TransferStatus.Completed }; + transfer.Leaves.Add(new TransferLeaf { Leaf = Node("leaf-a", 256, 2000) }); + transfer.Leaves.Add(new TransferLeaf { Leaf = Node("leaf-b", 44, 2000) }); + transfer.Leaves.Add(new TransferLeaf()); + + var model = TransferMapping.ToModel(transfer); + + model.Leaves.Select(l => (l.Id, l.ValueSats)).Should().Equal(("leaf-a", 256L), ("leaf-b", 44L)); + model.Leaves.Sum(l => l.ValueSats).Should().Be(model.TotalValueSats); + } + + [Test] + public void A_receive_request_names_the_transfer_that_paid_it() + { + var paid = Parse(""" + {"user_request": {"__typename": "LightningReceiveRequest", "lightning_receive_request_id": "req-1", + "lightning_receive_request_status": "TRANSFER_COMPLETED", + "lightning_receive_request_transfer": {"transfer_spark_id": "transfer-1", + "transfer_total_amount": {"original_value": 300000, "original_unit": "MILLISATOSHI"}}}} + """); + var unpaid = Parse(""" + {"user_request": {"__typename": "LightningReceiveRequest", "lightning_receive_request_id": "req-2", + "lightning_receive_request_status": "INVOICE_CREATED", "lightning_receive_request_transfer": null}} + """); + var send = Parse("""{"user_request": {"__typename": "LightningSendRequest"}}"""); + + LightningService.ToLightningReceiveRequest(paid.UserRequest, "req-1").Should() + .Be(new NSpark.Models.LightningReceiveRequest("req-1", "TRANSFER_COMPLETED", "transfer-1", 300)); + LightningService.ToLightningReceiveRequest(unpaid.UserRequest, "req-2").Should() + .Be(new NSpark.Models.LightningReceiveRequest("req-2", "INVOICE_CREATED", null, null)); + LightningService.ToLightningReceiveRequest(send.UserRequest, "req-3").Should().BeNull(); + LightningService.ToLightningReceiveRequest(null, "req-4").Should().BeNull(); + } + + [Test] + [CancelAfter(60_000)] + public async Task A_leaf_exit_refuses_leaves_that_are_not_spendable_before_anything_moves(CancellationToken ct) + { + var state = FakeOperatorState.Accepting(); + state.SetNodes([Node("spendable", 256, 2000), Node("frozen", 44, 50)]); + + await FakeOperator.RunAsync(state, async wallet => + { + var exit = () => wallet.WithdrawLeavesAsync(["spendable", "frozen", "gone"], Address, ct: ct); + + var refusal = (await exit.Should().ThrowAsync()).Which; + refusal.LeafIds.Should().Equal("frozen", "gone"); + }); + } + + [Test] + [CancelAfter(60_000)] + public async Task A_leaf_exit_needs_distinct_leaves_and_an_address_on_the_wallets_network(CancellationToken ct) + { + var state = FakeOperatorState.Accepting(); + + await FakeOperator.RunAsync(state, async wallet => + { + await wallet.Invoking(w => w.WithdrawLeavesAsync([], Address, ct: ct)).Should().ThrowAsync(); + await wallet.Invoking(w => w.WithdrawLeavesAsync(["a", "a"], Address, ct: ct)).Should().ThrowAsync(); + await wallet.Invoking(w => w.WithdrawLeavesAsync(["a"], "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", ct: ct)) + .Should().ThrowAsync(); + }); + + state.Methods.Should().NotContain("query_nodes"); + } + + private static GetLightningReceiveRequestResponse Parse(string json) => + JsonSerializer.Deserialize(json)!; + + private static TreeNode Node(string id, ulong value, uint refundSequence) => new() + { + Id = id, + TreeId = "tree", + Value = value, + Status = "AVAILABLE", + NodeTx = ByteString.CopyFrom(MakeRawTx(0)), + RefundTx = ByteString.CopyFrom(MakeRawTx(refundSequence)), + }; + + /// Minimal single-input legacy tx: version, one input with an empty script and the given nSequence. + private static byte[] MakeRawTx(uint sequence) + { + var tx = new byte[4 + 1 + 36 + 1 + 4 + 1 + 4]; + tx[0] = 0x02; + tx[4] = 0x01; + BitConverter.GetBytes(sequence).CopyTo(tx, 42); + return tx; + } +} diff --git a/tests/NSpark.UnitTests/Services/RefundAndRenewalTransactionTests.cs b/tests/NSpark.UnitTests/Services/RefundAndRenewalTransactionTests.cs index 99a78ca..f376060 100644 --- a/tests/NSpark.UnitTests/Services/RefundAndRenewalTransactionTests.cs +++ b/tests/NSpark.UnitTests/Services/RefundAndRenewalTransactionTests.cs @@ -101,6 +101,41 @@ public void A_leaf_without_a_direct_node_transaction_gets_no_direct_refund() TimelockHelper.DirectNodeTxForRefund(node).Should().BeNull(); Trio(node).DirectRefund.Should().BeNull(); } + + private static WithdrawalService.ConnectorRefunds ExitRefunds(TreeNode node) + { + var connectorTx = RawTransaction.Parse(TestTransactions.Tx(0xFFFFFFFD, 0x44, + new RawTransaction.Output(354, TestTransactions.P2trScript(0x55)), + new RawTransaction.Output(354, TestTransactions.P2trScript(0x66)))); + return WithdrawalService.BuildConnectorRefunds(node, s_receiver, connectorTx, 1, "mainnet"); + } + + [Test] + public void A_cooperative_exit_signs_a_direct_refund_for_a_zero_timelock_node_with_a_direct_node_transaction() + { + // What a zero-timelock renewal leaves. Unlike a claim, the operators' exit check takes both direct refunds or + // none for a leaf with a direct node transaction, and requires them + var renewed = Leaf(0, withDirect: true); + + var refunds = ExitRefunds(renewed); + + refunds.Direct.Should().NotBeNull(); + var direct = RawTransaction.Parse(refunds.Direct!.Tx); + direct.Inputs.Should().HaveCount(2, "it spends the direct node transaction and the leaf's connector output"); + direct.Inputs[0].PreviousTxid.Should().Equal(RawTransaction.Parse(renewed.DirectTx.Span).Txid); + direct.Inputs[1].PreviousIndex.Should().Be(1u); + refunds.Direct.Sighash.Should().HaveCount(32); + RawTransaction.Parse(refunds.DirectFromCpfp.Tx).Inputs[0].PreviousTxid.Should().Equal(RawTransaction.Parse(renewed.NodeTx.Span).Txid); + } + + [Test] + public void A_cooperative_exit_of_a_leaf_without_a_direct_node_transaction_signs_no_direct_refund() + { + var refunds = ExitRefunds(Leaf(0, withDirect: false)); + + refunds.Direct.Should().BeNull(); + RawTransaction.Parse(refunds.DirectFromCpfp.Tx).Inputs.Should().HaveCount(2); + } } ///