feat: add p2pk_signing_keys to SendOptions#1835
Conversation
|
see #1750 for the first PR attempt |
lescuer97
left a comment
There was a problem hiding this comment.
some comments around the code and proper flow
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
060ff2b to
04c584e
Compare
|
thanks y'all. i rebased against main and all comments addressed. ready for another look |
|
@thesimplekid any ETA on this? should i rebase? |
| continue; | ||
| }; | ||
|
|
||
| let Ok(data_key) = crate::nuts::PublicKey::from_str(secret.secret_data().data()) else { |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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.
thesimplekid
left a comment
There was a problem hiding this comment.
Noticed a few things and yes could you squash and rebase and we can get it merged thanks.
Add
p2pk_signing_keys: Vec<SecretKey>andallow_locked_proofs: booltoSendOptionsso wallets holding P2PK-locked proofs can spend them through the standardprepare_send/confirmflow without a separate manual swap.Behaviour
p2pk_signing_keysis non-empty andallow_locked_proofsis false (the default), all selected proofs are routed through a swap so the resulting token contains fresh, unconditioned proofs.allow_locked_proofs: trueopts 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::SigAllis incompatible with passthrough signing: the signature would need to commit to swap outputs that do not exist at signing time.confirm()now callsenforce_sig_flag()on the proofs-to-send set and returnsnut11::Error::SigAllNotSupportedHereearly rather than silently producing an unspendable token.Changes
cdk-common: addp2pk_signing_keysandallow_locked_proofstoSendOptions, with doc comments explaining the SigAll incompatibility.cdk/wallet/util.rs: extractsign_proofs()with four unit tests covering correct key, wrong key, plain proof, and empty-keys cases.cdk/wallet/send/saga/mod.rs: shadowforce_swaptotrueininternal_preparewhen signing keys are provided and passthrough is not opted in; callsign_proofs()inconfirm()for bothproofs_to_swap(default path) andproofs_to_send(passthrough path); add SigAll check with an explanatory block comment before the passthrough signing block.cdk-ffi: addp2pk_signing_keysandallow_locked_proofsto FFISendOptionsand update bothFromconversions.Description
Add
p2pk_signing_keys: Vec<SecretKey>andallow_locked_proofs: booltoSendOptionsso wallets holding P2PK-locked proofs can spend them through the standardprepare_send/confirmflow 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::SigAllis incompatible with passthrough because the signature would need to commit to swap outputs that don't exist at signing time.confirm()detects this viaenforce_sig_flag()and returnsnut11::Error::SigAllNotSupportedHereearly 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 passtest_p2pk_send_options_signing_keys— normal signing flowtest_p2pk_signing_keys_exact_denomination_short_circuit— force-swap regressiontest_p2pk_allow_locked_proofs_passthrough— passthrough opt-intest_p2pk_allow_locked_proofs_rejects_sig_all— SigAll rejectionNotes to the reviewers
Suggested CHANGELOG Updates
CHANGED
ADDED
REMOVED
FIXED
Checklist
just final-checkbefore committing