Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions NSpark.slnx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/NSpark/NSpark.csproj" />
<Project Path="src/NSpark.RemoteSigner/NSpark.RemoteSigner.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/NSpark.UnitTests/NSpark.UnitTests.csproj" />
<Project Path="tests/NSpark.RemoteSigner.Tests/NSpark.RemoteSigner.Tests.csproj" />
<Project Path="tests/NSpark.IntegrationTests/NSpark.IntegrationTests.csproj" />
</Folder>
<Folder Name="/samples/">
Expand Down
39 changes: 39 additions & 0 deletions docs/remote-signer.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions docs/withdrawals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
25 changes: 25 additions & 0 deletions src/NSpark.RemoteSigner/NSpark.RemoteSigner.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
Wire contract for running ISparkSigner behind an HTTP boundary (e.g. an AWS Lambda holding
the seed), shared by the signer service and its client so both sides stay in lockstep.
Fork-only (Elenpay); not part of the NSpark package.
-->
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<RootNamespace>NSpark.RemoteSigner</RootNamespace>
<AssemblyName>NSpark.RemoteSigner</AssemblyName>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\NSpark\NSpark.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="NSpark.RemoteSigner.Tests" />
</ItemGroup>

</Project>
137 changes: 137 additions & 0 deletions src/NSpark.RemoteSigner/RemoteSparkSigner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System.Text.Json;
using NSpark.Signer;

namespace NSpark.RemoteSigner;

/// <summary>How a <see cref="RemoteSparkSigner"/> reaches the signer service.</summary>
public interface ISparkSignerTransport
{
/// <summary>POSTs <paramref name="jsonBody"/> to <paramref name="path"/>; returns the HTTP status and body.</summary>
public Task<(int Status, string Body)> InvokeAsync(string path, string jsonBody, CancellationToken ct);
}

/// <summary>A remote signer refused or failed a request.</summary>
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;
}

/// <summary>
/// Client side of <see cref="SparkSignerContract"/>: an <see cref="ISparkSigner"/> that holds no key
/// material and forwards every operation to the signer service. Operations outside the contract
/// throw <see cref="NotSupportedException"/> without a round trip.
/// </summary>
public sealed class RemoteSparkSigner : ISparkSigner
{
private readonly ISparkSignerTransport _transport;
private readonly string _wallet;
private byte[]? _identity;

/// <param name="transport">Transport to the signer service.</param>
/// <param name="wallet">Id of the seed the signer should use (e.g. its master fingerprint).</param>
public RemoteSparkSigner(ISparkSignerTransport transport, string wallet)
{
ArgumentNullException.ThrowIfNull(transport);
ArgumentException.ThrowIfNullOrEmpty(wallet);
_transport = transport;
_wallet = wallet;
}

/// <summary>The signer's identity and policy, to verify before using it.</summary>
public Task<SparkSignerInfo> GetInfoAsync(CancellationToken ct = default) =>
CallAsync<SparkSignerInfo>(SparkSignerContract.Ops.Info, new WalletRequest(_wallet), ct);

private async Task<T> CallAsync<T>(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<SparkSignerEnvelope>(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<T>(SparkSignerContract.Json)
?? throw new SparkRemoteSignerException(op, status, "empty result");
}

public async Task<byte[]> GetIdentityPublicKeyAsync(CancellationToken ct = default) =>
_identity ??= await CallAsync<byte[]>(SparkSignerContract.Ops.IdentityPublicKey, new WalletRequest(_wallet), ct).ConfigureAwait(false);

public Task<byte[]> SignWithIdentityKeyAsync(byte[] messageHash, CancellationToken ct = default) =>
CallAsync<byte[]>(SparkSignerContract.Ops.SignWithIdentity, new HashRequest(_wallet, messageHash), ct);

public Task<byte[]> SignCompactWithIdentityKeyAsync(byte[] messageHash, CancellationToken ct = default) =>
CallAsync<byte[]>(SparkSignerContract.Ops.SignCompactWithIdentity, new HashRequest(_wallet, messageHash), ct);

public Task<byte[]> DecryptEciesWithIdentityKeyAsync(byte[] ciphertext, CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer does not release ECIES plaintexts.");

public Task<byte[]> GetDepositPublicKeyAsync(CancellationToken ct = default) =>
CallAsync<byte[]>(SparkSignerContract.Ops.DepositPublicKey, new WalletRequest(_wallet), ct);

public Task<byte[]> GetLeafPublicKeyAsync(string leafId, CancellationToken ct = default) =>
CallAsync<byte[]>(SparkSignerContract.Ops.LeafPublicKey, new LeafRequest(_wallet, leafId), ct);

public Task<LeafFrostSignature> SignLeafFrostAsync(string leafId, byte[] message, byte[] verifyingKey,
IReadOnlyDictionary<string, SigningCommitment> soCommitments, byte[]? adaptorPublicKey = null,
CancellationToken ct = default) =>
CallAsync<LeafFrostSignature>(SparkSignerContract.Ops.SignLeafFrost,
new SignLeafFrostRequest(_wallet, leafId, message, verifyingKey,
soCommitments.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal), adaptorPublicKey), ct);

public Task<LeafFrostNonceCommitment> GenerateLeafFrostNonceAsync(string leafId, CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer does not serve two-phase FROST nonces.");

public Task<byte[]> SignLeafFrostWithNonceAsync(string leafId, byte[] nonceHandle, byte[] message,
byte[] verifyingKey, IReadOnlyDictionary<string, SigningCommitment> soCommitments,
byte[]? adaptorPublicKey = null, CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer does not serve two-phase FROST nonces.");

public Task<EncryptedSendTweakBatch> BuildEncryptedSendTweaksAsync(IReadOnlyList<SendTweakLeafDescriptor> leaves,
IReadOnlyList<SoTarget> soTargets, string transferId, uint threshold, CancellationToken ct = default) =>
CallAsync<EncryptedSendTweakBatch>(SparkSignerContract.Ops.BuildSendTweaks,
new BuildSendTweaksRequest(_wallet, leaves.ToList(), soTargets.ToList(), transferId, threshold), ct);

public Task<EncryptedClaimTweakBatch> BuildEncryptedClaimTweaksAsync(IReadOnlyList<ClaimTweakLeafDescriptor> leaves,
IReadOnlyList<SoTarget> soTargets, uint threshold, CancellationToken ct = default) =>
CallAsync<EncryptedClaimTweakBatch>(SparkSignerContract.Ops.BuildClaimTweaks,
new BuildClaimTweaksRequest(_wallet, leaves.ToList(), soTargets.ToList(), threshold), ct);

public Task<byte[]> GetStaticDepositPublicKeyAsync(int index = 0, CancellationToken ct = default) =>
CallAsync<byte[]>(SparkSignerContract.Ops.StaticDepositPublicKey, new IndexRequest(_wallet, index), ct);

public Task<byte[]> ExportStaticDepositPrivateKeyAsync(int index = 0, CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer never exports private keys.");

public Task<LeafFrostNonceCommitment> GenerateStaticDepositFrostNonceAsync(int index, CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer does not serve static-deposit nonces.");

public Task<byte[]> SignStaticDepositFrostWithNonceAsync(int index, byte[] nonceHandle, byte[] message,
byte[] verifyingKey, IReadOnlyDictionary<string, SigningCommitment> soCommitments,
CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer does not serve static-deposit nonces.");

public Task<EncryptedPreimageShareBundle> BuildEncryptedPreimageSharesAsync(string transferId,
IReadOnlyList<SoTarget> soTargets, uint threshold, CancellationToken ct = default) =>
CallAsync<EncryptedPreimageShareBundle>(SparkSignerContract.Ops.BuildPreimageShares,
new BuildPreimageSharesRequest(_wallet, transferId, soTargets.ToList(), threshold), ct);

public Task<AdaptorKeyHandle> GenerateAdaptorKeyAsync(CancellationToken ct = default) =>
throw new NotSupportedException("A remote Spark signer does not serve adaptor keys (SSP leaf swaps).");
}
94 changes: 94 additions & 0 deletions src/NSpark.RemoteSigner/SparkSignerContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using NSpark.Signer;

namespace NSpark.RemoteSigner;

/// <summary>
/// HTTP contract for an <see cref="ISparkSigner"/> served remotely: <c>POST /spark/{op}</c> with a
/// JSON body <c>{"wallet": "&lt;seed id&gt;", ...}</c>, answered with a <see cref="SparkSignerEnvelope"/>.
/// <c>byte[]</c> values travel as base64.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class SparkSignerContract
{
/// <summary>Bumped on any incompatible change; reported by <see cref="Ops.Info"/>.</summary>
public const int Version = 1;

/// <summary>Every operation lives under this path prefix.</summary>
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";
}

/// <summary>Serializer settings both sides must use.</summary>
public static JsonSerializerOptions Json { get; } = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

/// <summary>The path of <paramref name="op"/>, without a leading base path.</summary>
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<string, SigningCommitment> SoCommitments,
byte[]? AdaptorPublicKey);

public sealed record BuildSendTweaksRequest(
string Wallet,
List<SendTweakLeafDescriptor> Leaves,
List<SoTarget> SoTargets,
string TransferId,
uint Threshold);

public sealed record BuildClaimTweaksRequest(
string Wallet,
List<ClaimTweakLeafDescriptor> Leaves,
List<SoTarget> SoTargets,
uint Threshold);

public sealed record BuildPreimageSharesRequest(
string Wallet,
string TransferId,
List<SoTarget> SoTargets,
uint Threshold);

/// <summary>What a signer serves: lets the caller verify identity and policy before using it.</summary>
public sealed record SparkSignerInfo(
int ContractVersion,
string IdentityPublicKey,
IReadOnlyList<string> OperatorKeys,
uint Threshold,
IReadOnlyList<string> AllowedReceivers);

/// <summary>Response body: exactly one of <see cref="Result"/> and <see cref="Error"/> is set.</summary>
public sealed record SparkSignerEnvelope(JsonElement? Result, string? Error);
Loading
Loading