Skip to content

feat: add p2pk_signing_keys to SendOptions#1835

Open
vnprc wants to merge 5 commits into
cashubtc:mainfrom
vnprc:send-p2pk-signing-keys
Open

feat: add p2pk_signing_keys to SendOptions#1835
vnprc wants to merge 5 commits into
cashubtc:mainfrom
vnprc:send-p2pk-signing-keys

Conversation

@vnprc
Copy link
Copy Markdown
Contributor

@vnprc vnprc commented Apr 3, 2026

Add p2pk_signing_keys: Vec<SecretKey> and allow_locked_proofs: bool to SendOptions so wallets holding P2PK-locked proofs can spend them through the standard prepare_send / confirm flow without a separate manual swap.

Behaviour

  • When p2pk_signing_keys is non-empty and allow_locked_proofs is false (the default), all selected proofs are routed through a swap so the resulting token contains fresh, unconditioned proofs.
  • Setting allow_locked_proofs: true opts in to the less-private passthrough path: proofs are signed and retain their spending conditions in the token. This is useful for multi-hop or offline flows where the next holder intentionally receives signed-but-still-locked proofs.

SIG_ALL incompatibility

SigFlag::SigAll is incompatible with passthrough signing: the signature would need to commit to swap outputs that do not exist at signing time. confirm() now calls enforce_sig_flag() on the proofs-to-send set and returns nut11::Error::SigAllNotSupportedHere early rather than silently producing an unspendable token.

Changes

  • cdk-common: add p2pk_signing_keys and allow_locked_proofs to SendOptions, with doc comments explaining the SigAll incompatibility.
  • cdk/wallet/util.rs: extract sign_proofs() with four unit tests covering correct key, wrong key, plain proof, and empty-keys cases.
  • cdk/wallet/send/saga/mod.rs: shadow force_swap to true in internal_prepare when signing keys are provided and passthrough is not opted in; call sign_proofs() in confirm() for both proofs_to_swap (default path) and proofs_to_send (passthrough path); add SigAll check with an explanatory block comment before the passthrough signing block.
  • cdk-ffi: add p2pk_signing_keys and allow_locked_proofs to FFI SendOptions and update both From conversions.
  • Integration tests: add four pure tests covering the normal signing flow, the exact-denomination short-circuit regression, the passthrough opt-in, and the SigAll rejection.

Description

Add p2pk_signing_keys: Vec<SecretKey> and allow_locked_proofs: bool to SendOptions so wallets holding P2PK-locked proofs can spend them through the standard prepare_send / confirm flow without a separate manual swap.

Default behaviour (allow_locked_proofs: false): all selected proofs are routed through a swap, producing a clean token with no spending conditions. The caller provides signing keys only to authorize the swap inputs.

Passthrough (allow_locked_proofs: true): proofs are signed in-place and retain their spending conditions. Useful for multi-hop or offline flows where the next holder intentionally receives signed-but-still-locked proofs.

SigAll rejection: SigFlag::SigAll is incompatible with passthrough because the signature would need to commit to swap outputs that don't exist at signing time. confirm() detects this via enforce_sig_flag() and returns nut11::Error::SigAllNotSupportedHere early rather than silently producing an unspendable token.

Also fixes a force-swap short-circuit bug where proofs summing exactly to the send amount bypassed the swap entirely, causing locked proofs to escape into the token unsigned.

Test plan

  • cargo test -p cdk wallet::util — 5 unit tests pass
  • test_p2pk_send_options_signing_keys — normal signing flow
  • test_p2pk_signing_keys_exact_denomination_short_circuit — force-swap regression
  • test_p2pk_allow_locked_proofs_passthrough — passthrough opt-in
  • test_p2pk_allow_locked_proofs_rejects_sig_all — SigAll rejection

Notes to the reviewers


Suggested CHANGELOG Updates

CHANGED

ADDED

REMOVED

FIXED


Checklist

@vnprc
Copy link
Copy Markdown
Contributor Author

vnprc commented Apr 3, 2026

see #1750 for the first PR attempt

@thesimplekid thesimplekid requested a review from lescuer97 April 4, 2026 07:55
Comment thread crates/cdk-common/src/wallet/mod.rs
Comment thread crates/cdk/src/wallet/send/saga/mod.rs Outdated
Comment thread crates/cdk/src/wallet/util.rs
Copy link
Copy Markdown
Contributor

@lescuer97 lescuer97 left a comment

Choose a reason for hiding this comment

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

some comments around the code and proper flow

Comment thread crates/cdk-common/src/wallet/mod.rs
Comment thread crates/cdk-common/src/wallet/mod.rs Outdated
Comment thread crates/cdk/src/wallet/send/saga/mod.rs Outdated
Comment thread CHANGELOG.md
@github-project-automation github-project-automation Bot moved this from Backlog to In progress in CDK Apr 5, 2026
vnprc added 5 commits April 10, 2026 09:08
Add `p2pk_signing_keys: Vec<SecretKey>` and `allow_locked_proofs: bool` to
`SendOptions` so wallets holding P2PK-locked proofs can spend them through
the standard `prepare_send` / `confirm` flow without a separate manual swap.

Behaviour
---------
- When `p2pk_signing_keys` is non-empty and `allow_locked_proofs` is false
  (the default), all selected proofs are routed through a swap so the
  resulting token contains fresh, unconditioned proofs.
- Setting `allow_locked_proofs: true` opts in to the less-private passthrough
  path: proofs are signed and retain their spending conditions in the token.
  This is useful for multi-hop or offline flows where the next holder
  intentionally receives signed-but-still-locked proofs.

SIG_ALL incompatibility
-----------------------
`SigFlag::SigAll` is incompatible with passthrough signing: the signature
would need to commit to swap outputs that do not exist at signing time.
`confirm()` now calls `enforce_sig_flag()` on the proofs-to-send set and
returns `nut11::Error::SigAllNotSupportedHere` early rather than silently
producing an unspendable token.

Changes
-------
- `cdk-common`: add `p2pk_signing_keys` and `allow_locked_proofs` to
  `SendOptions`, with doc comments explaining the SigAll incompatibility.
- `cdk/wallet/util.rs`: extract `sign_proofs()` with four unit tests covering
  correct key, wrong key, plain proof, and empty-keys cases.
- `cdk/wallet/send/saga/mod.rs`: shadow `force_swap` to `true` in
  `internal_prepare` when signing keys are provided and passthrough is not
  opted in; call `sign_proofs()` in `confirm()` for both `proofs_to_swap`
  (default path) and `proofs_to_send` (passthrough path); add SigAll check
  with an explanatory block comment before the passthrough signing block.
- `cdk-ffi`: add `p2pk_signing_keys` and `allow_locked_proofs` to FFI
  `SendOptions` and update both `From` conversions.
- Integration tests: add four pure tests covering the normal signing flow,
  the exact-denomination short-circuit regression, the passthrough opt-in,
  and the SigAll rejection.
When `p2pk_signing_keys` is non-empty and `allow_locked_proofs` is false,
the previous implementation set a global `force_swap` flag that routed all
selected proofs — including plain bearer proofs — through the swap. Unlocked
proofs are already bearer and do not need swapping.

Replace the global flag with an explicit partition in `internal_prepare`:

- P2PK-locked proofs (Kind::P2PK) go directly into `proofs_to_swap` and
  are signed before the swap, producing fresh unconditioned outputs.
- Unlocked proofs go through the normal `split_proofs_for_send` path:
  exact-denomination proofs are sent directly; others are swapped for
  denomination matching as usual.

HTLC-locked proofs are intentionally excluded from the forced-swap path.
Spending an HTLC requires a preimage that `p2pk_signing_keys` cannot
supply; routing HTLC proofs to a swap here would cause a mint rejection.
HTLC send support (including `htlc_preimages` on `SendOptions`) is left
for a follow-up PR.

Changes
-------
- `cdk/wallet/util.rs`: add `is_p2pk_locked(&Proof) -> bool` predicate that
  checks `Kind::P2PK` specifically (not any NUT-10 secret).
- `cdk/wallet/send/saga/mod.rs`: replace `force_swap` extension with
  partition logic; add explanatory comment with HTLC TODO breadcrumb.
- Integration tests: add `test_p2pk_signing_keys_mixed_locked_and_unlocked_proofs`
  covering a wallet that holds both proof types.
- Fix `let (token, _) = prepared.confirm(...)` → `let token = ...` in four
  tests (wallet-level `confirm` returns `Token`, not a tuple).
- Fix `test_p2pk_allow_locked_proofs_passthrough`: Bob was re-signing proofs
  that Alice already pre-signed, causing a duplicate-signature rejection.
  Bob now receives with `ReceiveOptions::default()` since Alice's witness is
  already attached and the proof is effectively bearer.
The receive saga already consulted the wallet keyring to discover signing
keys at receive time. The send saga did not — it only used keys explicitly
provided via SendOptions.p2pk_signing_keys.

Add collect_p2pk_pubkeys() to util.rs and a merge_keyring_keys() helper in
the send saga that merges explicit keys with any matching keys found in the
wallet keyring via get_signing_key(). Both sign_proofs() call sites in
confirm() now use the merged key list.

Also broaden the P2PK-locked-proof partition guard from
"!p2pk_signing_keys.is_empty()" to "any proof is P2PK-locked", so the
locked proofs are routed through the swap even when no explicit keys are
provided (keyring keys are discovered at confirm time).

Add integration test test_p2pk_send_keyring_auto_detection: Alice generates
a key via generate_public_key(), receives P2PK-locked proofs for it, then
sends with SendOptions::default() (no explicit signing keys). Bob receives
a clean, unlocked token without any signing keys.
Previously, P2PK-locked proofs with no available signing key (neither in
SendOptions.p2pk_signing_keys nor in the wallet keyring) could be selected
by the proof-selection algorithm. They would then be routed to the swap by
the partition logic, but signing would be skipped, causing the mint to
reject the unsigned input with a confusing error at confirm time.

Add filter_signable_proofs() in the send saga, which removes P2PK-locked
proofs whose data key cannot be satisfied before passing the pool to
select_proofs(). The filter runs at both pool-building sites in prepare():
the initial spending-condition-filtered fetch and the force-swap fallback
fetch. It caches keyring lookups per pubkey to avoid redundant DB reads.

The filter is skipped when allow_locked_proofs=true, where the caller is
intentionally working with locked proofs.

Error behaviour:
- Unsignable proofs excluded, other proofs available  => send succeeds
- Unsignable proofs excluded, nothing else available  => InsufficientFunds

Add two integration tests:
- test_p2pk_unsignable_proof_falls_back_to_bearer: mixed wallet (unknown-key
  locked + bearer), send uses bearer proofs and succeeds
- test_p2pk_unsignable_proof_only_gives_insufficient_funds: wallet holds
  only unknown-key locked proofs, prepare_send returns InsufficientFunds
@vnprc vnprc force-pushed the send-p2pk-signing-keys branch from 060ff2b to 04c584e Compare April 10, 2026 15:30
@vnprc
Copy link
Copy Markdown
Contributor Author

vnprc commented Apr 10, 2026

thanks y'all. i rebased against main and all comments addressed. ready for another look

@thesimplekid thesimplekid self-requested a review April 10, 2026 15:32
@vnprc
Copy link
Copy Markdown
Contributor Author

vnprc commented May 14, 2026

@thesimplekid any ETA on this? should i rebase?

continue;
};

let Ok(data_key) = crate::nuts::PublicKey::from_str(secret.secret_data().data()) else {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This signability filter only checks the P2PK data key, but P2PK conditions can also require additional pubkeys via n_sigs. A proof where the wallet has the data key but not enough additional required keys will pass this filter, then fail later at swap/confirm with a mint rejection instead of being excluded as unsignable. Can this reuse the same requirements logic as verification, or sign and verify before treating the proof as selectable?

&options.p2pk_signing_keys,
)
.await?;
if !keys.is_empty() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In passthrough mode this signs only when a matching key is found, but it does not fail if no key is found or if the proof is only partially signed. That can create a token containing locked proofs that the recipient cannot redeem. Before creating the token, this should verify the passthrough proofs satisfy their spending conditions and return an error if they do not.

p2pk_signing_keys: opts
.p2pk_signing_keys
.into_iter()
.filter_map(|k| k.try_into().ok())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This silently drops invalid FFI secret keys. ReceiveOptions uses a fallible conversion and returns an error for invalid p2pk_signing_keys; send should do the same so callers do not get a later InsufficientFunds or mint rejection caused by a key that was ignored during conversion.

Copy link
Copy Markdown
Collaborator

@thesimplekid thesimplekid left a comment

Choose a reason for hiding this comment

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

Noticed a few things and yes could you squash and rebase and we can get it merged thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants