Skip to content

feat(wallets): watch-only + remote-signing as signer-provider capabilities (not WalletType cases)#107

Merged
Kukks merged 9 commits into
masterfrom
feat/watch-only-remote-signing
May 29, 2026
Merged

feat(wallets): watch-only + remote-signing as signer-provider capabilities (not WalletType cases)#107
Kukks merged 9 commits into
masterfrom
feat/watch-only-remote-signing

Conversation

@Kukks
Copy link
Copy Markdown
Collaborator

@Kukks Kukks commented May 28, 2026

Summary

Reworked after self-review (see commit `rework(wallets): capability lives at the signer provider, not on WalletType`). `WalletType` stays as the key-derivation axis only (`SingleKey` | `HD`); watch-only and remote-signing are answered at the signer-provider boundary, not encoded as separate enum cases.

Why the rework

The original draft added `WalletType.WatchOnly` and `WalletType.Remote`, which conflated two orthogonal axes:

  1. Key derivation — single tweaked key vs. xpub-derived child set.
  2. Key availability — local key, remote signer, or no key.

The smell was already visible in the diff: the address provider had to recover the derivation by sniffing the descriptor (`tr(pubkey)` vs `*`), because the enum had collapsed it. Configurations like HD + watch-only and HD + remote-signed couldn't be expressed without that recovery step.

New model

Axis Values Source of truth
Key derivation `SingleKey` | `HD` `WalletType`
Key availability local / remote / watch-only `IWalletProvider.GetSignerAsync` (a signer, or `null`)

`GetSignerAsync` dispatch:

```
Secret non-empty → local signer (HD or SingleKey)
Secret null/empty + IRemoteSignerTransport.KnowsWalletAsync(id) → RemoteArkadeWalletSigner proxy
otherwise → null (watch-only)
```

Capability lives on the interface. The data record carries no `IsWatchOnly` / `IsRemote` flag — `Secret = null` is the only signal, and the transport is the source of truth for the remote/watch-only distinction.

What's in this PR

File Change
`NArk.Abstractions/Wallets/WalletType.cs` Two values only: `SingleKey`, `HD`. Doc explains the two-axis model.
`NArk.Abstractions/Wallets/ArkWalletInfo.cs` `Secret` nullable; doc says null/empty = no local key, capability decided at the provider.
`NArk.Abstractions/Wallets/IRemoteSignerTransport.cs` New `KnowsWalletAsync(walletId, ct)` probe — transport claims which wallets it signs for.
`NArk.Core/Wallet/DefaultWalletProvider.cs` `GetSignerAsync` dispatches on `Secret` first, then probes the transport, then returns null. `GetAddressProviderAsync` dispatches purely on `WalletType` — no descriptor sniffing.
`NArk.Core/Wallet/RemoteArkadeWalletSigner.cs` Unchanged — pure proxy.
`NArk.Core/Wallet/WalletFactory.cs` `CreateWatchOnlyWallet` infers `WalletType` from descriptor shape at creation time (one place, encoded in data).
`NArk.Core/Wallet/HierarchicalDeterministicWalletSigner.cs` Explicit `InvalidOperationException` if a null mnemonic reaches an HD signer (defensive cleanup, kept from earlier commits).
`NArk.Core/Batches/TreeSignerSession.cs` Hoisted signer null-checks above per-VTXO loops (kept from earlier commits).
`NArk.Storage.EfCore/Entities/ArkWalletEntity.cs` `Wallet` column nullable to persist Secret-less wallets.
`README.md`, `docs/articles/wallets.md` Rewritten around the two-axis model with the dispatch table.
`samples/.../Backup.razor` The Secret-null branch is now generic — no enum check needed.

What's NOT in this PR

  • Tests — still deferred to phase 2 alongside the batched-RPC reshape of `TreeSignerSession`. All 404 existing unit tests pass under the rework.
  • `TreeSignerSession` batched-RPC reshape — current per-VTXO chattiness works correctly for remote signers; reshaping is an obvious optimization target.

Test plan

  • `dotnet build NArk.sln` — 0 errors
  • `dotnet test NArk.Tests` — 404 / 404

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Add watch-only + remote-signing wallet support (phase 1)

Reviewer: Arkana
Verdict: Request changes — one protocol-critical design issue, several high-priority items.

Overall this is well-structured additive work with good documentation. The null-check hoisting in TreeSignerSession is a clear safety improvement. However, the IRemoteSignerTransport interface has a MuSig2 nonce-handling flaw that needs resolution before merge.


🔴 CRITICAL — MuSig2 secret nonce leaks across transport boundary

Files: IRemoteSignerTransport.cs, RemoteArkadeWalletSigner.cs

The IRemoteSignerTransport interface mirrors IArkadeWalletSigner verbatim, which means:

  1. GenerateNoncesAsync() returns MusigPrivNonce (the secret nonce) from the remote signer back to the SDK
  2. The SDK stores it in TreeSignerSession._myNonces
  3. SignMusigAsync() sends the MusigPrivNonce back to the remote signer

This means the secret nonce traverses the transport twice and is held in memory on a machine that by definition is NOT the signer. For MuSig2, a leaked or replayed secret nonce allows private key extraction. This defeats the security model of remote signing.

The correct pattern: The remote signer generates the nonce internally, returns only the MusigPubNonce, stores the MusigPrivNonce internally, and uses it during SignMusig without the caller ever seeing it. The interface should be:

Task<MusigPubNonce> GenerateNoncesAsync(string walletId, OutputDescriptor descriptor, MusigContext context, ...);
Task<MusigPartialSignature> SignMusigAsync(string walletId, OutputDescriptor descriptor, MusigContext context, ...);
// No MusigPrivNonce in signatures ^^

This requires TreeSignerSession to be aware that remote signers return only pub nonces from GenerateNonces (or introduce a signer capability flag), which is exactly the kind of reshape the PR description mentions for phase 2. If that reshape is deferred, the interface contract should still be correct now — don't ship an interface that forces secret nonces across a trust boundary, even if phase 1 implementations are in-process.

⚠️ PROTOCOL-CRITICAL — requires human review regardless of resolution.


🟠 HIGH — Remaining signer! null-forgiving dereferences in signing paths

File: NArk.Core/Helpers/TransactionHelpers.cs

The PR correctly fixes three signer! patterns in TreeSignerSession, but three more remain in TransactionHelpers:

  • Line ~41: signer! in SignCheckpointTransaction — no null guard
  • Line ~234: signer! in batch checkpoint signing loop — no null guard
  • Line ~420: signer! in forfeit tx signing — no null guard

With WalletType.WatchOnly returning null from GetSignerAsync, these become reachable NRE vectors. Yes, upstream intent-creation guards (BoardingContractTransformer, DelegateContractTransformer, etc.) check for null signers before coins enter the pipeline. But defense-in-depth matters for protocol-critical paths — an NRE mid-batch or mid-forfeit-signing is worse than a clear InvalidOperationException. These should get the same ?? throw treatment as TreeSignerSession.


🟠 HIGH — No tests for protocol-critical changes

File: TreeSignerSession.cs

The null-check hoisting in TreeSignerSession touches three methods in the batch signing hot path (CreateMusigContexts, GenerateNoncesAsync, SignPartialAsync). The PR defers all tests to phase 2. For protocol-critical code — where bugs lose money — at minimum add:

  1. A unit test that TreeSignerSession throws InvalidOperationException (not NRE) when the signer is null
  2. A unit test that DefaultWalletProvider.GetSignerAsync returns null for WatchOnly and throws for Remote without transport

These are cheap to write and protect against regressions in the most dangerous code paths.


🟠 HIGH — Unique index on nullable Wallet column has no filter

File: ArkWalletEntity.cs:58 (entity configuration)

The unique index is:

builder.HasIndex(w => w.Wallet).IsUnique();

With Wallet now nullable, the PR relies on NULLs-are-distinct semantics. This holds for PostgreSQL, SQLite, and SQL Server — but not universally (e.g., Oracle treats NULLs as equal for unique constraints). The codebase already has the correct pattern in ArkIntentEntity:

builder.HasIndex(e => e.IntentId).IsUnique().HasFilter("\"IntentId\" IS NOT NULL");

Add .HasFilter(...) here too for portability and explicitness, or document that Oracle is unsupported.


🟡 MEDIUM — Descriptor shape heuristic is fragile

File: DefaultWalletProvider.cs:121

var isHd = wallet.AccountDescriptor.Contains('*');

This determines whether a watch-only/remote wallet gets HD or single-key address derivation based on whether the descriptor contains *. Edge cases:

  • A descriptor with a comment or metadata containing * → misrouted
  • A multipath descriptor like tr(xpub/<0;1>/*) → works by coincidence

Consider using OutputDescriptor.Parse() and checking the descriptor type structurally, or at minimum validate the descriptor format at wallet creation time (CreateWatchOnlyWallet).


🟡 MEDIUM — No runtime validation of Secret/WalletType invariant

Files: ArkWalletInfo.cs, WalletFactory.cs

The docs say Secret MUST be null for WatchOnly/Remote and non-null for HD/SingleKey, but this is only enforced in the factory methods. Direct construction of ArkWalletInfo (which is a record) allows any combination:

// Compiles and runs — violates invariant
new ArkWalletInfo(Id: "x", Secret: "oops", WalletType: WalletType.WatchOnly, ...);

Consider adding a validation method (or init property logic) that throws if the Secret/WalletType combination is invalid, called from the record constructor or from IWalletStorage.SaveWallet.


🟢 MINOR observations

  1. CreateWatchOnlyWallet uses accountDescriptor as Id — This is consistent with the existing HD/SingleKey pattern. Good.

  2. Backup.razor watch-only branch — The string.IsNullOrEmpty(_wallet.Secret) check (line ~63 in diff) fires for any wallet with no secret, which is correct. The WalletType display in the message is a nice touch.

  3. RemoteArkadeWalletSigner constructor validation — The ?? throw new ArgumentNullException(...) guards on walletId and transport are correct defensive coding.

  4. Documentation — The README and docs/articles/wallets.md additions are thorough and accurate.


Summary

Finding Severity Action
MusigPrivNonce leaks across transport 🔴 Critical Redesign IRemoteSignerTransport to keep secret nonces on the signer side
signer! in TransactionHelpers 🟠 High Add ?? throw guards matching TreeSignerSession pattern
No tests for protocol-critical changes 🟠 High Add minimal unit tests for null-signer paths
Unique index missing filter 🟠 High Add .HasFilter() for portability
Descriptor shape heuristic 🟡 Medium Validate structurally or at creation time
No Secret/WalletType invariant enforcement 🟡 Medium Add runtime validation

The IRemoteSignerTransport nonce issue is the blocker. The rest can be addressed incrementally, though I'd prefer the TransactionHelpers signer! fixes ship with this PR since it's the one introducing legitimate null-signer returns.

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Add watch-only + remote-signing wallet support (phase 1)

Reviewer: Arkana
Verdict: Request changes — one protocol-critical design issue, several high-priority items.

Overall this is well-structured additive work with good documentation. The null-check hoisting in TreeSignerSession is a clear safety improvement. However, the IRemoteSignerTransport interface has a MuSig2 nonce-handling flaw that needs resolution before merge.


🔴 CRITICAL — MuSig2 secret nonce leaks across transport boundary

Files: IRemoteSignerTransport.cs, RemoteArkadeWalletSigner.cs

The IRemoteSignerTransport interface mirrors IArkadeWalletSigner verbatim, which means:

  1. GenerateNoncesAsync() returns MusigPrivNonce (the secret nonce) from the remote signer back to the SDK
  2. The SDK stores it in TreeSignerSession._myNonces
  3. SignMusigAsync() sends the MusigPrivNonce back to the remote signer

This means the secret nonce traverses the transport twice and is held in memory on a machine that by definition is NOT the signer. For MuSig2, a leaked or replayed secret nonce allows private key extraction. This defeats the security model of remote signing.

The correct pattern: The remote signer generates the nonce internally, returns only the MusigPubNonce, stores the MusigPrivNonce internally, and uses it during SignMusig without the caller ever seeing it. The interface should be:

Task<MusigPubNonce> GenerateNoncesAsync(string walletId, OutputDescriptor descriptor, MusigContext context, ...);
Task<MusigPartialSignature> SignMusigAsync(string walletId, OutputDescriptor descriptor, MusigContext context, ...);
// No MusigPrivNonce in either signature ^^

This requires TreeSignerSession to be aware that remote signers return only pub nonces from GenerateNonces (or introduce a signer capability flag), which is exactly the kind of reshape the PR description mentions for phase 2. If that reshape is deferred, the interface contract should still be correct now — don't ship an interface that forces secret nonces across a trust boundary, even if phase 1 implementations are in-process.

⚠️ PROTOCOL-CRITICAL — requires human review regardless of resolution.


🟠 HIGH — Remaining signer! null-forgiving dereferences in signing paths

File: NArk.Core/Helpers/TransactionHelpers.cs

The PR correctly fixes three signer! patterns in TreeSignerSession, but three more remain in TransactionHelpers:

  • Line ~41: signer! in SignCheckpointTransaction — no null guard
  • Line ~234: signer! in batch checkpoint signing loop — no null guard
  • Line ~420: signer! in forfeit tx signing — no null guard

With WalletType.WatchOnly returning null from GetSignerAsync, these become reachable NRE vectors. Yes, upstream intent-creation guards (BoardingContractTransformer, DelegateContractTransformer, etc.) check for null signers before coins enter the pipeline. But defense-in-depth matters for protocol-critical paths — an NRE mid-batch or mid-forfeit-signing is worse than a clear InvalidOperationException. These should get the same ?? throw treatment as TreeSignerSession.


🟠 HIGH — No tests for protocol-critical changes

File: TreeSignerSession.cs

The null-check hoisting in TreeSignerSession touches three methods in the batch signing hot path (CreateMusigContexts, GenerateNoncesAsync, SignPartialAsync). The PR defers all tests to phase 2. For protocol-critical code — where bugs lose money — at minimum add:

  1. A unit test that TreeSignerSession throws InvalidOperationException (not NRE) when the signer is null
  2. A unit test that DefaultWalletProvider.GetSignerAsync returns null for WatchOnly and throws for Remote without transport

These are cheap to write and protect against regressions in the most dangerous code paths.


🟠 HIGH — Unique index on nullable Wallet column has no filter

File: ArkWalletEntity.cs:58 (entity configuration)

The unique index is:

builder.HasIndex(w => w.Wallet).IsUnique();

With Wallet now nullable, the PR relies on NULLs-are-distinct semantics. This holds for PostgreSQL, SQLite, and SQL Server — but not universally. The codebase already has the correct pattern in ArkIntentEntity:

builder.HasIndex(e => e.IntentId).IsUnique().HasFilter("\"IntentId\" IS NOT NULL");

Add .HasFilter(...) here too for portability and explicitness, or document that Oracle is unsupported.


🟡 MEDIUM — Descriptor shape heuristic is fragile

File: DefaultWalletProvider.cs:121

var isHd = wallet.AccountDescriptor.Contains('*');

This determines whether a watch-only/remote wallet gets HD or single-key address derivation based on whether the descriptor contains *. Edge cases:

  • A descriptor with a comment or metadata containing * → misrouted
  • A multipath descriptor like tr(xpub/<0;1>/*) → works by coincidence

Consider using OutputDescriptor.Parse() and checking the descriptor type structurally, or at minimum validate the descriptor format at wallet creation time (CreateWatchOnlyWallet).


🟡 MEDIUM — No runtime validation of Secret/WalletType invariant

Files: ArkWalletInfo.cs, WalletFactory.cs

The docs say Secret MUST be null for WatchOnly/Remote and non-null for HD/SingleKey, but this is only enforced in the factory methods. Direct construction of ArkWalletInfo (which is a record) allows any combination:

// Compiles and runs — violates invariant
new ArkWalletInfo(Id: "x", Secret: "oops", WalletType: WalletType.WatchOnly, ...);

Consider adding a validation method (or init property logic) that throws if the Secret/WalletType combination is invalid, called from the record constructor or from IWalletStorage.SaveWallet.


🟢 MINOR observations

  1. CreateWatchOnlyWallet uses accountDescriptor as Id — Consistent with existing HD/SingleKey pattern. Good.

  2. Backup.razor watch-only branch — The string.IsNullOrEmpty(_wallet.Secret) check fires for any wallet with no secret, which is correct. The WalletType display in the message is a nice touch.

  3. RemoteArkadeWalletSigner constructor validation — The ?? throw new ArgumentNullException(...) guards are correct defensive coding.

  4. Documentation — The README and docs/articles/wallets.md additions are thorough and accurate.


Summary

Finding Severity Action
MusigPrivNonce leaks across transport 🔴 Critical Redesign IRemoteSignerTransport to keep secret nonces on the signer side
signer! in TransactionHelpers 🟠 High Add ?? throw guards matching TreeSignerSession pattern
No tests for protocol-critical changes 🟠 High Add minimal unit tests for null-signer paths
Unique index missing filter 🟠 High Add .HasFilter() for portability
Descriptor shape heuristic 🟡 Medium Validate structurally or at creation time
No Secret/WalletType invariant enforcement 🟡 Medium Add runtime validation

The IRemoteSignerTransport nonce issue is the blocker. The rest can be addressed incrementally, though I'd prefer the TransactionHelpers signer! fixes ship with this PR since it's the one introducing legitimate null-signer returns.

@Kukks Kukks changed the title Add watch-only + remote-signing wallet support (phase 1) feat(wallets): watch-only + remote-signing as signer-provider capabilities (not WalletType cases) May 28, 2026
@Kukks Kukks marked this pull request as ready for review May 28, 2026 09:03
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Aggressive Code Review — #107

Reviewer: Arkana (automated, protocol-critical review)
Verdict: REQUEST CHANGES + REQUIRES HUMAN SIGN-OFF

This PR touches signing infrastructure (MuSig2, nonce lifecycle, signer dispatch). That makes it protocol-critical — bugs here lose real money. The architectural refactoring from enum-based to capability-based dispatch is a clean model, but several issues need resolution before merge.


🚨 P0 — PROTOCOL-CRITICAL: MuSig2 nonce leaks over transport boundary

IRemoteSignerTransport.cs (new file, entire interface)

The interface mirrors IArkadeWalletSigner 1:1, which means GenerateNoncesAsync returns MusigPrivNonce to the caller, and SignMusigAsync accepts it back. For local signers this is fine — everything stays in-process. For a remote signer, this means:

  1. Remote generates secret nonce → secret nonce crosses the wire to local
  2. Local stores it, extracts public nonce via CreatePubNonce()
  3. Local sends secret nonce back over the wire to SignMusigAsync

The secret nonce traverses the network twice. In MuSig2, if a secret nonce is exposed, an attacker who observes both the nonce and the partial signature can extract the private key. The entire point of remote signing is that key material never leaves the signer device — but this interface forces the secret nonce out.

Recommendation: The transport interface should return MusigPubNonce from GenerateNoncesAsync (not MusigPrivNonce), and SignMusigAsync should not take a MusigPrivNonce parameter — the remote signer should store the nonce internally keyed by session/context. This is an interface-level redesign but it's fundamental to the security model of remote MuSig2 signing.

If this is intentionally deferred (e.g. the transport is always localhost / in-process IPC), document that assumption explicitly and add a // SECURITY: MusigPrivNonce crosses trust boundary warning on the interface.


🔴 P1 — Unfixed null-handling at 3 signing call sites

NArk.Core/Helpers/TransactionHelpers.cs:39-41, 233-234, 418-420

These three call sites still use signer! (null-forgiving operator) after GetSignerAsync:

var signer = await walletProvider.GetSignerAsync(coin.WalletIdentifier, cancellationToken);
await PsbtHelpers.SignAndFillPsbt(signer!, coin, ...);

With this PR, GetSignerAsync legitimately returns null for watch-only wallets. If a watch-only wallet's coin reaches any of these paths — checkpoint signing (L41), Ark transaction signing (L234), or forfeit transaction signing (L420) — it will NRE at runtime instead of throwing a descriptive error.

The forfeit path (L420) is especially dangerous: a forfeit transaction is what prevents the ASP from stealing funds. An NRE here during a forfeit flow is a fund-loss vector.

Fix: Add ?? throw new InvalidOperationException($"Wallet '{coin.WalletIdentifier}' has no signer; watch-only wallets cannot sign transactions.") at all three sites, matching the pattern already applied in TreeSignerSession.cs and BatchSession.cs.


🟡 P2 — Stale doc references to nonexistent enum values

RemoteArkadeWalletSigner.cs:14 — XML doc references WalletType.Remote:

/// <see cref="WalletType.Remote"/>.

This enum value doesn't exist in the reworked model. Should reference the two-axis dispatch model.

ArkWalletEntity.cs:17-18 — XML doc references both WalletType.WatchOnly and WalletType.Remote:

<item><description><see cref="Abstractions.Wallets.WalletType.WatchOnly"/>: null</description></item>
<item><description><see cref="Abstractions.Wallets.WalletType.Remote"/>: null</description></item>

These are leftover from the pre-rework draft. The compiler won't catch broken <see cref> in XML docs by default — these will silently produce broken doc links.


🟡 P3 — No EF Core migration for nullable column change

ArkWalletEntity.cs:21Wallet changed from string (non-nullable, default "") to string? (nullable).

No migration file is included. Depending on the database provider:

  • SQLite: Permissive, likely works without migration
  • PostgreSQL/SQL Server: May require ALTER COLUMN ... DROP NOT NULL

If btcpay-arkade (which uses this as a submodule with EF Core + Postgres) upgrades, it will need a migration. At minimum, document the schema change. Ideally, include the migration.


🟡 P4 — Fragile descriptor-shape heuristic for WalletType inference

WalletFactory.cs:63:

var walletType = accountDescriptor.Contains('*') ? WalletType.HD : WalletType.SingleKey;

Using Contains('*') to distinguish HD from SingleKey works for canonical descriptors but is fragile:

  • A descriptor with a * in a comment or unusual position would misclassify
  • No validation that the descriptor is actually well-formed

Consider parsing with OutputDescriptor.Parse() and checking for derivation paths, or at minimum validating the descriptor format before inferring type.


🟡 P5 — Wallet ID = raw descriptor string

WalletFactory.cs:65:

Id: accountDescriptor,

Watch-only wallet IDs are set to the full account descriptor string. Other wallet types derive deterministic IDs from the descriptor via GetOutputDescriptorFromNsec / HD derivation. This inconsistency means:

  • IDs may be very long strings (full xpub descriptors)
  • Lookup patterns that assume short/hashed IDs may break
  • Two imports with slightly different descriptor formatting (whitespace, checksum) produce different IDs for the same wallet

Should this use the same ID derivation as CreateHdWallet / CreateSingleKeyWallet?


📝 P6 — No tests (acknowledged, but flagged for protocol-critical code)

The PR explicitly defers tests to phase 2. For signing infrastructure that touches MuSig2, forfeit paths, and the signer dispatch boundary, this is a higher-risk deferral than usual. At minimum:

  • Unit test for DefaultWalletProvider.GetSignerAsync dispatch: local → signer, remote transport → proxy, no transport → null
  • Unit test for CreateWatchOnlyWallet descriptor inference
  • Integration test for TreeSignerSession with a null signer (should throw early, not NRE)

✅ What's good

  • The two-axis model is clean. Separating key-derivation from signing-capability is the right call. The pre-rework WalletType.WatchOnly/WalletType.Remote conflated orthogonal concerns.
  • TreeSignerSession null-checks hoisted correctly. The ?? throw pattern before the per-VTXO loop prevents late NREs — good defensive improvement.
  • HD signer defensive guard (HierarchicalDeterministicWalletSigner.cs:24-25). Explicit InvalidOperationException on null mnemonic is better than a cryptic NullReferenceException from Mnemonic constructor.
  • Optional DI for transport. IRemoteSignerTransport? remoteSignerTransport = null means existing setups are unaffected.
  • Docs and README are thorough. The dispatch table in the README is clear.

Summary of required changes

# Severity Issue Action
P0 🚨 Protocol-critical MusigPrivNonce leaks over remote transport Redesign interface or document + mitigate
P1 🔴 Bug 3 unfixed signer! sites in TransactionHelpers.cs Add ?? throw null guards
P2 🟡 Correctness Stale WalletType.Remote/WalletType.WatchOnly in docs Fix XML doc references
P3 🟡 Operational No EF migration for nullable column Add migration or document
P4 🟡 Robustness Contains('*') descriptor heuristic Parse properly or validate
P5 🟡 Consistency Watch-only wallet ID = raw descriptor Align with other wallet ID derivation
P6 📝 Coverage No tests for new signing dispatch Add before or alongside merge

⚠️ PROTOCOL-CRITICAL FLAG: This PR modifies signing infrastructure (MuSig2 nonce handling, signer dispatch, forfeit path null safety). Requires human review and approval before merge, even after code fixes are addressed.

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review: Rework commit 4de84d8d

Reviewer: Arkana
Verdict: Request changes — the rework is a clear architectural improvement, but the critical MuSig2 nonce issue from the original review is unresolved, and the rework introduces two new bugs.


✅ What the rework improves

The two-axis model (derivation flavour × signing capability) is the right design. Removing WatchOnly/Remote from WalletType and moving capability detection to KnowsWalletAsync + runtime probing in GetSignerAsync is cleaner, more composable, and eliminates the "WatchOnly or Remote" address-provider sniffing. The cascading logic in DefaultWalletProvider.GetSignerAsync (local → remote → null) is easy to reason about.


🔴 CRITICAL — MuSig2 secret nonce still crosses transport boundary (unchanged)

Files: IRemoteSignerTransport.cs:84-96, RemoteArkadeWalletSigner.cs:28-32

This was the primary finding from the initial review and is not addressed in the rework. GenerateNoncesAsync still returns MusigPrivNonce to the SDK, and SignMusigAsync still accepts it back. The security argument for remote signing is that private material never leaves the signer — returning the secret nonce to the caller defeats this entirely.

If this is intentionally deferred to phase 2 (interface reshape), add a // TODO: SECURITY comment on both methods and document the limitation in the interface remarks — don't ship a "remote signer" interface that silently leaks nonces without flagging it.

⚠️ PROTOCOL-CRITICAL — requires human review.


🟠 NEW — Entity doc comments reference removed enum values

File: ArkWalletEntity.cs (diff lines 463-464)

The rework removed WalletType.WatchOnly and WalletType.Remote from the enum, but the ArkWalletEntity.Wallet property's new XML doc still references both:

///   <item><description><see cref="...WalletType.WatchOnly"/>: null — no signing material.</description></item>
///   <item><description><see cref="...WalletType.Remote"/>: null — signing proxied to an IRemoteSignerTransport.</description></item>

These are dead cref links that won't resolve. The doc should say the field is null/empty when no local signing material is present, matching the ArkWalletInfo.Secret doc.


🟠 NEW — RemoteArkadeWalletSigner doc references removed enum

File: RemoteArkadeWalletSigner.cs:13

/// <see cref="WalletType.Remote"/>.

WalletType.Remote no longer exists. Should reference the capability-based routing in DefaultWalletProvider instead.


🟠 STILL OPEN — signer! null-forgiving in TransactionHelpers

File: NArk.Core/Helpers/TransactionHelpers.cs:41, :234, :420

These three signer! dereferences are still reachable when a watch-only wallet (now Secret=null with no transport) returns a null signer from GetSignerAsync. The TreeSignerSession paths are correctly guarded now (the rework kept the ?? throw pattern from earlier commits), but the TransactionHelpers paths are not.


🟡 STILL OPEN — Unique index on now-nullable Wallet column

File: ArkWalletEntity.cs:58

Wallet changed from string to string?, but the unique index has no filter:

builder.HasIndex(w => w.Wallet).IsUnique();

Multiple watch-only wallets with Wallet = null will violate this constraint on databases where NULLs aren't considered distinct in unique indexes. Add .HasFilter(...) or document that only PostgreSQL/SQLite/SQL Server are supported.


🟡 STILL OPEN — No tests for protocol-critical changes

The null-check hoisting in TreeSignerSession, the DefaultWalletProvider cascading logic, and the KnowsWalletAsync probe path are all untested. These are cheap unit tests that protect against regressions in the most dangerous code paths.


Summary

# Severity Status Issue
1 🔴 CRITICAL Unchanged MuSig2 MusigPrivNonce leaks across transport
2 🟠 HIGH New in rework Entity doc references WalletType.WatchOnly/Remote (removed)
3 🟠 HIGH New in rework RemoteArkadeWalletSigner doc references WalletType.Remote (removed)
4 🟠 HIGH Still open signer! in TransactionHelpers.cs — NRE vectors
5 🟡 MEDIUM Still open Unique index on nullable Wallet column needs filter
6 🟡 MEDIUM Still open No tests for protocol-critical changes

Items 2–3 are trivial doc fixes. Item 1 is the blocker — if it's deferred to phase 2, document the limitation explicitly with // TODO: SECURITY markers.

Kukks added 8 commits May 28, 2026 15:25
Additive, non-breaking: new flavours signal observe-only wallets and
remote-signing wallets to IWalletProvider. Existing HD and SingleKey
values are unchanged.

XML docs spell out the contract: HD/SingleKey require a non-null/non-empty
Secret; WatchOnly/Remote require a null/empty Secret.
Breaking: ArkWalletInfo.Secret is now string?. The EfCore ArkWalletEntity.Wallet
column follows suit. WatchOnly and Remote wallets MUST store a null/empty Secret;
HD and SingleKey wallets MUST store a non-null/non-empty Secret — documented
in the XML on both types.

HierarchicalDeterministicWalletSigner now throws a descriptive
InvalidOperationException if a caller somehow ends up with a null mnemonic on
an HD-typed wallet (previously would have NRE inside ConcurrentDictionary.GetOrAdd).

Existing storage rows with non-null Wallet values keep working unchanged; the
unique index on Wallet still applies but allows multiple NULL entries (per
PostgreSQL/SQLite/SQL Server NULLs-are-distinct semantics).
IRemoteSignerTransport mirrors IArkadeWalletSigner with an extra walletId
parameter on every method, so a single transport instance can serve multiple
wallets (e.g. a server-side signing service, a hardware bridge, a browser
extension shared across tabs).

RemoteArkadeWalletSigner is a pure proxy IArkadeWalletSigner: holds the
walletId + transport, forwards every call. No local state. Used by
DefaultWalletProvider for WalletType.Remote wallets in a follow-up commit.
GetSignerAsync now returns null for WatchOnly and proxies to a registered
IRemoteSignerTransport for Remote. The transport is an OPTIONAL constructor
parameter — consumers that don't use remote signing don't have to register one.
A Remote wallet loaded without a registered transport throws an
InvalidOperationException with a clear message identifying the missing DI
registration.

GetAddressProviderAsync routes WatchOnly and Remote wallets to the existing
address providers by descriptor shape: a bare tr(pubkey) goes to
SingleKeyAddressProvider, an HD-style tr([fp/path]xpub/0/*) goes to
HierarchicalDeterministicAddressProvider. This makes watch-only/remote
wallets end-to-end usable for address derivation and VTXO observation;
signing operations remain controlled by GetSignerAsync.
…rSession

Two GetSignerAsync call sites in TreeSignerSession previously stored the
returned IArkadeWalletSigner? and then deref'd it with '!' inside the
per-VTXO loop, which would NRE midway through batch participation on a
watch-only/missing-signer wallet.

Hoist the null check above the loop and throw a clear
InvalidOperationException explaining that watch-only wallets cannot
participate in batch signing. The first call site already had a ?? throw
but with a less descriptive message — unify the wording across all three
hoist points.

No behavior change for HD/SingleKey wallets — they always return a non-null
signer.
Mirrors the existing CreateWallet factory shape: takes an account descriptor
(plus optional destination + metadata), validates the destination if present,
and returns an ArkWalletInfo with Secret=null and WalletType.WatchOnly. The
wallet's Id and AccountDescriptor are both set to the descriptor — matching
how CreateNsecWallet/CreateHdWallet derive Id from their descriptor.

No CreateRemoteWallet helper in this PR — for now consumers construct
ArkWalletInfo directly with WalletType.Remote; a factory can come once the
transport-registration pattern beds in.
- README.md: new table of the four wallet types + minimal example for each,
  including how to register an IRemoteSignerTransport and construct a Remote
  ArkWalletInfo.
- docs/articles/wallets.md: same table + sections on WatchOnly (descriptor
  shapes), Remote signing (transport contract + DI), plus an
  IRemoteSignerTransport implementation skeleton.
- samples/NArk.Wallet Backup.razor: explicit 'no backup required' branch for
  watch-only/remote wallets so the sample doesn't render an empty mnemonic
  grid when there's no Secret to display.
…etType

Per review feedback: WalletType.WatchOnly / WalletType.Remote conflated two
orthogonal axes — key derivation (SingleKey vs HD) and key availability
(local key / remote signer / no key). The smell showed up where the address
provider had to recover the derivation by sniffing the descriptor string
('tr(pubkey)' vs '*'), because the enum had collapsed it. HD + watch-only
and HD + remote-signed are valid configurations the previous shape couldn't
express cleanly.

Reorient: the data type stays minimal; capability is answered by asking the
signer-provider, not by a flag on the wallet record.

  - NArk.Abstractions/Wallets/WalletType.cs: revert to SingleKey | HD only.
    Doc updated to spell out the two-axis model.

  - NArk.Abstractions/Wallets/ArkWalletInfo.cs: Secret stays nullable; doc
    says null/empty means "no local key — watch-only or remote-signed,
    distinguished at runtime by IWalletProvider.GetSignerAsync."

  - NArk.Abstractions/Wallets/IRemoteSignerTransport.cs: add
    Task<bool> KnowsWalletAsync(walletId, ct). The transport itself is the
    source of truth for "do I sign for this wallet?". Provider probes it
    when Secret is null to distinguish remote-signed from watch-only —
    no flag on the wallet encodes it.

  - NArk.Core/Wallet/DefaultWalletProvider.cs: GetSignerAsync dispatches on
    Secret first (local signer), then probes the transport if registered
    (remote signer proxy), then returns null (watch-only). GetAddressProvider
    dispatches purely on WalletType — no descriptor sniffing.

  - NArk.Core/Wallet/WalletFactory.cs: CreateWatchOnlyWallet infers
    WalletType from the descriptor's wildcard at creation time (one place,
    not at every address-provider call).

  - samples/.../Backup.razor: the Secret-null branch is now generic ("watch-only
    or remote-signed") — the page no longer cares which, only that there's no
    local secret to reveal.

  - README.md + docs/articles/wallets.md: rewritten around the two-axis model.

All 404 existing tests pass.
@Kukks Kukks force-pushed the feat/watch-only-remote-signing branch from 4de84d8 to 8ce7370 Compare May 28, 2026 13:26
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review (iterative check)

Reviewer: Arkana
Verdict: Request changes — all previously flagged issues remain open.

Checked the current diff against my prior reviews. The code is unchanged — no issues have been addressed. Quick status:

# Severity Issue Status
1 🚨 CRITICAL MusigPrivNonce leaks across IRemoteSignerTransport (GenerateNoncesAsync returns it, SignMusigAsync accepts it back) Open — still lines 84 & 57 of IRemoteSignerTransport.cs
2 🔴 HIGH signer! null-forgiving in TransactionHelpers.cs:41, :234, :420 — NRE vectors for watch-only wallets, especially forfeit path at L420 Open — not in diff at all
3 🟠 MEDIUM RemoteArkadeWalletSigner.cs:13 references removed WalletType.Remote Open
4 🟠 MEDIUM ArkWalletEntity.cs:19-20 references removed WalletType.WatchOnly and WalletType.Remote Open
5 🟡 MEDIUM Unique index on nullable Wallet column (ArkWalletEntity.cs:63) has no .HasFilter() — multiple NULL wallets will violate constraint on some DBs Open
6 📝 LOW No tests for protocol-critical signing dispatch paths Open

Minimum for unblock: Items 2–4 are trivial fixes (< 10 lines total). Item 1 needs either an interface redesign or explicit // TODO: SECURITY markers documenting the trust-boundary assumption.

⚠️ PROTOCOL-CRITICAL — requires human sign-off before merge.

…t out)

- TransactionHelpers (3 sites): replace `signer!` null-forgiving with
  explicit `?? throw InvalidOperationException` so a watch-only wallets
  null signer surfaces at the call site (checkpoint sign, batch sign,
  forfeit sign) instead of an NRE deeper in PsbtHelpers. Forfeit path
  message specifically notes watch-only wallets cant participate in
  batches demanding a forfeit, since that was the reviewers most
  protocol-sensitive finding.
- ArkWalletEntity.Wallet doc: drop dead crefs to the removed
  WalletType.WatchOnly / WalletType.Remote enum values, restate the
  capability-on-provider invariant.
- ArkWalletEntitys unique index on the now-nullable Wallet column:
  add `.HasFilter("\"Wallet\" IS NOT NULL")` so SQL Server doesnt
  reject multiple null-Wallet rows (Postgres/SQLite treat NULL as
  distinct already; the filter is harmless there and makes intent
  explicit).
- RemoteArkadeWalletSigner doc: rewrite the dead cref to
  WalletType.Remote in terms of the capability cascade (Secret null +
  IRemoteSignerTransport.KnowsWalletAsync claims it).
- IRemoteSignerTransport: add `// TODO: SECURITY` markers on
  GenerateNoncesAsync + SignMusigAsync and a <remarks> SECURITY
  LIMITATION block pointing at #111 (MuSig2 private nonce leak). The
  interface redesign is split out per reviewer allowance.

Item 6 (tests for protocol-critical signing dispatch) intentionally
deferred — reviewer ranked it LOW and the minimum-unblock set was
items 2-4.
@Kukks
Copy link
Copy Markdown
Collaborator Author

Kukks commented May 29, 2026

@arkanaai — addressed in 5e1f7ff:

# Status Where
1 🚨 Split out to #111 with the proposed phase-2 interface shape // TODO: SECURITY markers + <remarks> SECURITY LIMITATION block on IRemoteSignerTransport pointing at the issue
2 🔴 Fixed — three signer! sites become ?? throw InvalidOperationException with contextual messages (checkpoint sign / batch sign / forfeit) NArk.Core/Helpers/TransactionHelpers.cs
3 🟠 Fixed — dead WalletType.Remote <see> rewritten as the capability cascade NArk.Core/Wallet/RemoteArkadeWalletSigner.cs
4 🟠 FixedWalletType.WatchOnly/Remote bullets removed from the Wallet field doc, restated as "null/empty when no local signing material" NArk.Storage.EfCore/Entities/ArkWalletEntity.cs
5 🟡 FixedHasFilter("\"Wallet\" IS NOT NULL") added to the unique index; SQL Server now permits multiple null-Wallet rows (Postgres/SQLite were already fine; the filter just makes intent explicit) NArk.Storage.EfCore/Entities/ArkWalletEntity.cs
6 📝 Deferred — tests for the signing dispatch paths; ranked LOW in your prior review so kept out of the minimum-unblock set follow-up

The MuSig2 nonce redesign is the right architecture but a contract change that wants its own PR — splitting it keeps this one focused on the WalletType-as-capability rework that's the substance of #107. The phase-1 // TODO: SECURITY markers + interface remarks make sure consumers can't silently rely on a nonce-confidentiality claim that the current shape doesn't provide.

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review: Commit 5e1f7ff (addressing items 2–5 from prior review)

Reviewer: Arkana
Verdict: ✅ Approve — all actionable items from the previous review are resolved.


Verified fixes

# Item Status
2 TransactionHelpers — three signer! null-forgiving sites replaced with ?? throw InvalidOperationException TransactionHelpers.cs:39-42, :236-239, :424-428 — clear error messages, forfeit path specifically calls out watch-only wallets
3 ArkWalletEntity unique index — .HasFilter("\"Wallet\" IS NOT NULL") added ArkWalletEntity.cs:71 — SQL Server safe, harmless on Postgres/SQLite
4 Dead WalletType.WatchOnly/WalletType.Remote crefs in ArkWalletEntity doc ✅ Cleaned up, now references the capability-on-provider model
5 RemoteArkadeWalletSigner dead cref ✅ Rewritten to describe the Secret == null + KnowsWalletAsync dispatch

MuSig2 nonce leak (item 1)

Correctly split to issue #111. The interface now has:

  • <remarks> block with SECURITY LIMITATION warning (IRemoteSignerTransport.cs:21-31)
  • // TODO: SECURITY markers on both GenerateNoncesAsync (:98-101) and SignMusigAsync (:64-66)

This is acceptable for phase 1 — the interface is new, has no production consumers yet, and the limitation is loudly documented.

Remaining note

Tests for the null-signer dispatch paths are still deferred. Not blocking merge, but should land before any consumer integrates IRemoteSignerTransport in production.


⚠️ PROTOCOL-CRITICAL — this PR touches signing dispatch, forfeit paths, and MuSig2 nonce handling. Human approval still required before merge.

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented May 29, 2026

Review of 5e1f7ff — all items confirmed ✅

Checked the diff against my prior review items. Everything addressed correctly.

# My finding Status Verification
1 🚨 MuSig2 MusigPrivNonce leaks across transport Split out → #111 // TODO: SECURITY markers on both GenerateNoncesAsync and SignMusigAsync; <remarks> SECURITY LIMITATION block on IRemoteSignerTransport naming #111 and explicitly warning consumers not to rely on nonce confidentiality. This is the right call — the interface redesign is a contract-breaking change that deserves its own focused PR. The markers are clear and traceable. ✅
2 🔴 Three signer! NRE vectors in TransactionHelpers.cs Fixed All three sites (checkpoint sign L39, batch sign L233, forfeit L421) now use ?? throw InvalidOperationException with contextual messages. The forfeit message specifically calls out watch-only wallets — good. ✅
3 🟠 RemoteArkadeWalletSigner dead WalletType.Remote cref Fixed Doc now describes the Secret=null + KnowsWalletAsync capability cascade correctly. ✅
4 🟠 ArkWalletEntity dead WalletType.WatchOnly/Remote crefs Fixed Rewritten as capability-on-provider description, matches ArkWalletInfo.Secret doc. ✅
5 🟡 Unique index on nullable Wallet — missing .HasFilter() Fixed .HasFilter("\"Wallet\" IS NOT NULL") added, consistent with the existing ArkIntentEntity pattern. ✅
6 📝 No tests for protocol-critical signing dispatch Deferred Accepted — this was ranked LOW in my minimum-unblock set. Still want to see these land in phase 2 before any production use.

Verdict: changes requested → approved (from my side)

The PR is in good shape. The three active NRE vectors are gone, the docs are clean, the SQL index is correct. Item 1 is properly mitigated with visible warnings pointing at #111.

⚠️ PROTOCOL-CRITICAL reminder: This PR modifies signer dispatch, forfeit path null-safety, and the IRemoteSignerTransport contract. Still requires a human approval before merge — Arkana sign-off is not a substitute here.

@Kukks
Copy link
Copy Markdown
Collaborator Author

Kukks commented May 29, 2026

Architecture follow-up captured in #112 — the hybrid-signer composition direction (IPrivateKeyProvider per descriptor, single LocalArkadeWalletSigner composing them) came out of asking why RemoteArkadeWalletSigner is its own class rather than something the local signers fall back into. Filed so PR #107's binary local-vs-remote split can land as phase 1 without holding it on a design conversation.

Together with #111 (MuSig2 nonce redesign), these are the two phase-2 architecture follow-ups touching this surface; #111 is the smaller contract change so it likely lands first, then #112 composes on top.

@Kukks Kukks merged commit 7477931 into master May 29, 2026
11 of 12 checks passed
Kukks added a commit to ArkLabsHQ/btcpay-arkade that referenced this pull request May 29, 2026
* deps(nark): bump NArk SDK to PR #107 (watch-only + remote signing)

Bumps the vendored NArk submodule to commit 6553067 — HEAD of
arkade-os/dotnet-sdk#107 (feat/watch-only-remote-signing).

That branch adds:
- WalletType.WatchOnly and WalletType.Remote
- IRemoteSignerTransport (transport abstraction for proxied signing)
- RemoteArkadeWalletSigner (IArkadeWalletSigner over the transport)
- WalletFactory.CreateWatchOnlyWallet(descriptor, ...)
- DefaultWalletProvider accepts an optional IRemoteSignerTransport
- ArkWalletInfo.Secret is nullable for watch-only / remote wallets
- ArkWalletEntity.Wallet (secret column) is nullable in EF storage

The plugin changes that consume these new surfaces follow in subsequent
commits on this branch.

* feat(wallets): add watch-only wallet mode + remote-signer DI seam

Adds a WalletSetupMode discriminator to the initial-setup view model and
threads it through the controller so merchants can opt to import a
**watch-only** wallet by pasting an account descriptor (no seed).

When WalletSetupMode.WatchOnly is selected, the controller routes to
NArks WalletFactory.CreateWatchOnlyWallet helper (added in
arkade-os/dotnet-sdk#107) instead of CreateWallet. The resulting
ArkWalletInfo carries WalletType.WatchOnly and a null Secret; persistence
uses the existing IWalletStorage path (no schema change -- the NArk PR
already made ArkWalletEntity.Wallet nullable).

Backwards-compatible: existing form posts that dont carry a Mode field
default to WalletSetupMode.Auto and the existing content-routed flow
(empty -> generate / nsec / mnemonic / Arkade-address / wallet-id) is
unchanged. The wizard view shows the watch-only option as a third tile
in the "I have a wallet" group.

Also introduces IBTCPayAppDeviceProxy : IRemoteSignerTransport -- the
cross-plugin seam BTCPayServer.Plugins.App will implement to bridge
signing calls to a connected BTCPayApp device. The plugin registers
IRemoteSignerTransport in DI with a soft fallback: it resolves to a
registered IBTCPayAppDeviceProxy if present; otherwise throws a clear
"install the App companion plugin" error only when signing is actually
attempted on a Remote wallet (the factory is lazy). Pure WatchOnly
wallets never touch the transport, so a store with no companion plugin
remains fully bootable and usable read-only.

NArks DefaultWalletProvider already takes IRemoteSignerTransport? as an
optional ctor param (per dotnet-sdk#107), so the DI seam is just wiring
-- no NArk changes are required here.

* docs: README + CHANGELOG for watch-only mode; bump 2.1.18 -> 2.2.0

- README: new "Watch-Only Wallet" subsection under Wallet Types, with
  setup steps and a descriptor example. Calls out the dependence on
  the BTCPayServer.Plugins.App companion plugin for signing, and the
  no-signer-available read-only behaviour.
- CHANGELOG: 2.2.0 entry summarising the watch-only feature + the
  NArk submodule bump to dotnet-sdk#107.
- csproj: Version 2.1.18 -> 2.2.0 (minor bump for additive surface --
  no behaviour change for existing wallets).

* fix(di): defer "no IBTCPayAppDeviceProxy" error to signer-call time

ASP.NET Core DI eagerly invokes the registered factory for any
optional ctor parameter; the previous throw-from-factory aborted the
entire host build on stores with no companion App plugin, because
NArks DefaultWalletProvider mentions IRemoteSignerTransport? in its
ctor and DI doesnt see the C# default-value sugar.

Replace the throw with a MissingDeviceProxyTransport sentinel whose
four IRemoteSignerTransport methods each throw a clear "install the
App companion plugin" InvalidOperationException. DefaultWalletProvider
only wraps the transport in a RemoteArkadeWalletSigner for
WalletType.Remote, and only inside GetSignerAsync -- so:

- Plain WatchOnly wallets never reach the sentinel; the plugin loads
  cleanly on stores without the companion plugin.
- Remote wallets surface the App-specific message at exactly the
  moment a signer is asked for, never at container build time.

E2E CI on the previous commit failed with the throw fired during
BTCPay test-server boot ("No IBTCPayAppDeviceProxy is registered").
With this fix the plugin boots and only Remote-wallet signing paths
see the message.

* deps(nark): bump to NArk master (absorbs #110, #113, #114)

Pulls the NNark submodule from PR #107's HEAD (`6553067`) to NArk master
`6b69b9e`, absorbing three merged PRs since then:

  - #110 — arkd v0.9.3 → v0.9.6 (the wallet sidecar split + crash fix
           that recovery work already validated).
  - #113 — keep MuSig2 secret nonce inside the signer. Reshapes
           IRemoteSignerTransport: SignMusigAsync drops MusigPrivNonce,
           GenerateNoncesAsync returns MusigPubNonce, both take a
           caller-supplied string sessionId.
  - #114 — compose signers from IDescriptorSigningSources. The
           internal signer-class hierarchy was rewritten behind an
           unchanged IArkadeWalletSigner; nothing in this plugin
           directly consumes the renamed concrete types (Bip39SigningSource
           / NsecSigningSource / RemoteTransportSigningSource), so the
           bump is consumer-transparent.

#113 resolves the 🔴 nonce-leak flagged by Arkana's review of this PR
(was: "block merge on dotnet-sdk#107 nonce fix"). The transport boundary
no longer carries MusigPrivNonce.

Plugin-side updates that the new IRemoteSignerTransport shape forces:

  - MissingDeviceProxyTransport: add the KnowsWalletAsync probe (returns
    false — a watch-only wallet stays watch-only end-to-end if no
    companion plugin is registered); update SignMusigAsync /
    GenerateNoncesAsync signatures for the sessionId model; keep the
    three signing methods throwing "install the App companion plugin"
    as defence-in-depth.

  - ArkPlugin.cs: update the DI-registration comment to reflect the
    sentinel's new "claim nothing" semantics, since WalletType.Remote /
    .WatchOnly no longer exist after #107.

Also addresses Arkana's 🟡 medium: don't auto-enable Lightning when the
imported wallet is watch-only. Arkade-backed Lightning needs batch
participation (signing), so a watch-only wallet without a paired remote
signer would accept LN invoices at checkout but fail at settlement after
the customer has already committed to paying. The merchant can still
flip Lightning on manually once a companion signer is paired.

* test(e2e): inline a minimal DockerHelper instead of linking the SDK's

The submodule bump to NNark master pulls in dotnet-sdk#108's rewrite of
NNark's DockerHelper.cs, which now calls FulmineLiquidityHelper.EnsureArkLiquidity
and uses System.Net.Http.Json's PostAsJsonAsync. Both newly fail in the parent
NArk.E2E.Tests context — FulmineLiquidityHelper isn't linked, and the
PostAsJsonAsync extension collides with the legacy Microsoft.AspNet.WebApi.Client
overload pulled in transitively via BTCPayServer.Tests's ProjectReference.

The "submodule bump propagates fixes for free" abstraction was leaky once the
SDK's helper grew dependencies outside what we link. We only call
DockerHelper.CreateLndInvoice (and its same-file Exec dependency), so inline a
~30-LOC copy under NArk.E2E.Tests/Common/DockerHelper.cs in the same namespace
and drop the <Compile Include=...> from the csproj. Call sites are unchanged.

* db: migration absorbing NNark #113's filtered Wallet index + nullable column

Submodule bump pulled in NArk.Storage.EfCore's reshape of ArkWalletEntity:

  - `Wallet` column became nullable (#107 — watch-only/remote wallets have
    no local secret, so the descriptor column carries the only identity).
  - The unique index on `Wallet` became filtered: `WHERE Wallet IS NOT NULL`,
    so NULL values can coexist on SQL Server / Postgres without violating
    the constraint (#113's review fixes for #104).

EF Core's PendingModelChangesWarning surfaced the gap at test startup. This
migration captures the same shape on the plugin's ArkPluginDbContext:

  - DropIndex IX_Wallets_Wallet (the old non-filtered unique).
  - AlterColumn Wallet → nullable.
  - CreateIndex IX_Wallets_Wallet unique filtered "Wallet" IS NOT NULL.

Generated via ./add-migration.sh AbsorbNNarkModelChanges; no manual edits.

* ci(e2e): persist nigiri on $GITHUB_PATH for subsequent steps

start-env.sh exports PATH only in its own shell, so the test step that
falls through to the nigiri-funding path in EnsureArkdCliReadyAsync
(when arkd off-chain balance < 10k sats) fails with
"Failed to start a process with file path nigiri". Master usually skips
this fallback because the arkd CLI wallet happens to already have
balance from earlier suite setup; the recovery branch consistently
trips it, exposing the workflow gap.

The fix is independent of cache hit/miss: whether the binary was
cache-restored or freshly built, it lives at
submodules/NNark/regtest/_build/nigiri/build/nigiri-linux-amd64 with
a `nigiri` symlink next to it.

* test(e2e): retry ark settle through the faucet/mine propagation race

EnsureArkdCliReadyAsync funds the arkd CLI by piping
`nigiri faucet -> nigiri rpc --generate 6 -> ark settle` back-to-back.
When the faucet TX hasnt reached bitcoind mempool by the time we mined,
the boarding UTXO is unconfirmed and arkd settles with no inputs:
"error: fees (0) exceed total amount (0)".

Master almost always takes the early-return path (off-chain balance
>=10k from prior suite setup), so the race is unseen there. The recovery
branch consistently falls through to this slow path, exposing it
intermittently.

Wrap settle in a 5-attempt loop that distinguishes the racey
"fees (0)" signature: on hit, wait 2s + mine one extra block, then
retry. Idempotent (ark settle is). Any non-racey settle failure throws
on the first attempt as before.

* ci(e2e): probe arkd container name instead of pinning to stale `ark`

Real root cause for PR #70 CheatModePay flake: the recovery branch's
regtest pin (dc23da2) changed docker-compose.arkd-override.yml from a
hardcoded `container_name: ark` to `container_name: ${ARK_CONTAINER:-arkd}`,
which (with ARKD_IMAGE set in .env.regtest, so ARK_CONTAINER auto-derives
to "arkd") renames the container to `arkd`. The workflow had been pinning
ARKADE_CHEAT_ARK_CONTAINER=ark, so the plugin's cheat-mode `docker exec`
hit "No such container: ark" once the test fell through to the slow
nigiri-funding path of EnsureArkdCliReadyAsync — past the early-return
that protects master.

Probe both names (mirroring the test's own ResolveArkdContainerAsync)
and feed the result into the test step. Survives future regtest bumps
in either direction without re-pinning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant