Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5229033
nit: mark bitcoind dependent sign tests
macgyver13 Mar 5, 2026
940ab93
bump submodule versions
macgyver13 Apr 21, 2026
6633e15
BIP-352: implement Silent Payments cryptographic primitives
macgyver13 Nov 25, 2025
baac8dd
BIP-374: implement DLEQ proof generation and verification
macgyver13 Nov 25, 2025
90fbe6d
SP: add SilentPaymentsMixin
macgyver13 Mar 19, 2026
b2506b5
fix: (psbt v2) structural changes
macgyver13 Mar 8, 2026
441295c
fix: (usb_test) Unify local and globals dict to single namespace
macgyver13 Mar 20, 2026
33f890b
BIP-375: add test vectors
macgyver13 Nov 25, 2025
dcb06b2
BIP-352: add test vectors
macgyver13 Apr 10, 2026
faeef4c
BIP-375: implement Silent Payments PSBT signing workflows
macgyver13 Nov 25, 2025
59798e9
BIP-376: handle spending silent payments outputs
macgyver13 Dec 23, 2025
c186494
SP: add Silent Payments integration tests
macgyver13 Mar 5, 2026
ea54a41
SP: introduce partial share contribution workflow
macgyver13 Mar 25, 2026
8459a91
SP: introduce UI change output validation
macgyver13 Mar 25, 2026
b1d2e61
SP: add sp spend derivation path tests
macgyver13 Apr 26, 2026
900e650
SP: add docs/silentpayments.md
macgyver13 Apr 17, 2026
20eac9c
SP: implement bip352 generic format export
macgyver13 Apr 23, 2026
c8be414
SP: add more integration tests
macgyver13 Apr 30, 2026
3371646
SP (refactor): test hygiene
macgyver13 May 1, 2026
88e9294
SP: improve sp spend without sp outputs and change detection
macgyver13 May 1, 2026
106990f
fix (SP): apply sp output sort correctly when scan key is the same fo…
macgyver13 Jun 3, 2026
16aa5cb
feat (MuSig2 + SP): partial field parse & serialize plumbing
macgyver13 May 20, 2026
0795ff7
feat (MuSig2 + SP): calculate stable nonce_msg using sp_v0_info
macgyver13 May 20, 2026
52cb49e
feat (MuSig2 + SP): build musig reusable helpers
macgyver13 Jun 26, 2026
8c7c00c
feat (MuSig2 + SP): compute sum(IL) with musig_derive_keyagg_cache
macgyver13 Jun 26, 2026
1285d1d
fix (psbt): handle unpacking taproot_subpaths with cached coordinates
macgyver13 Jun 30, 2026
2b81587
feat (MuSig2 + SP): partial ECDH share contribution and signing UX
macgyver13 May 20, 2026
c5bd643
test (MuSig2 + SP): two round integration tests and fixtures
macgyver13 May 20, 2026
6f819bc
test (MuSig2 + SP): full two round - three simulators/signers
macgyver13 Jun 8, 2026
4b2cba8
docs (MuSig2 + SP): Add MuSig2 to SP documentation
macgyver13 May 26, 2026
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
134 changes: 134 additions & 0 deletions docs/silentpayments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Silent Payments

**COLDCARD<sup>&reg;</sup>** `EDGE` versions support [Silent Payments](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki) from version `TBD`.

COLDCARD implements the following BIPs:

* Silent Payments [BIP-352](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki)
* Sending Silent Payments with PSBTs [BIP-375](https://github.com/bitcoin/bips/blob/master/bip-0375.mediawiki)
* Spending Silent Payment outputs with PSBTs [BIP-376](https://github.com/bitcoin/bips/blob/master/bip-0376.mediawiki)

## Why Silent Payments?

**Automatic privacy by default** — share one static address publicly; each payment produces a unique on-chain output

## How It Works

### Silent Payments Address Generation

* the sender collects the input public key(s) from the transaction being built
* a shared secret is derived via ECDH between the combined input key and the receiver's scan public key (part of the SP Address)
* the final output is a Taproot address derived from tweaking the receiver's spend public key (part of the SP Address) and shared secret
* only the holder of the scan private key can detect incoming payments; only the holder of the spend private key can sign for them

### ECDH Shares & DLEQ Proofs

The shared secret can be computed from either side:

* **sender side**: `shared_secret = (a_1 + a_2 + ...) * B_scan` — sum of input private keys * receiver's scan public key
* **receiver side**: `shared_secret = (A_1 + A_2 + ...) * b_scan` — sum of input public keys * receiver's scan private key

In single-signer flows, COLDCARD performs the full sender-side ECDH internally.

In multi-signer flows (multiple input owners), each signer computes a *partial ECDH share*:

* `share_i = a_i * B_scan` — the signer's input private key * the receiver's scan public key
* the coordinator sums all shares: `sum(share_i) = (a_1 + a_2 + ...) * B_scan => shared_secret`
* each share is accompanied by a **DLEQ proof** (Discrete Log Equality) so the coordinator or signers can verify the shares were computed from the correct input secret key

### SP Output Computation Before Signing

* before any signing round, the coordinator must derive the correct output script from the SP address and the transaction inputs
* this requires the full set of input public keys and the complete shared secret (assembled from partial ECDH shares)
* the computed output is inserted into the PSBT; signers verify that the output in the PSBT matches the expected tweak before signing
* a signer that cannot recompute the expected output MUST refuse to sign

## Limitations

* one of the transaction inputs must be eligible for ["Shared Secret Derivation"](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#user-content-Inputs_For_Shared_Secret_Derivation)
* MuSig2 spends are supported for taproot key-spend inputs (`tr(musig(...))`) - see [MuSig2 + Silent Payments](#musig2-silent-payments). FROST is not supported yet.

## Example

SP Address encodes two public keys: a *scan key* and a *spend key*

```plain
sp1qqw5jexmu4358tr090qld3egjxkvwftgnwzg7g2v86wad3gywxkln6qcc0kmh5k03cheul53fd7r7h4lg9y3xkrmz3k00ujulyg2pfcaevu9nurf3
```

### Partial Signing (Collaborative Inputs - Multiple Signers)

Round 1 — ECDH share collection

* Coordinator builds PSBT with inputs from each participant and partial outputs (output script not yet finalized)
* Each input owner contributes their partial ECDH share a_i * B_scan and DLEQ proof into the PSBT
* Last contributor verifies all DLEQ proofs, combines partial shares → computes shared secret → computes final output script, updates PSBT

Round 2 — Sign

* Each signer verifies the output scripts in the PSBT then signs their inputs normally
* Coordinator finalizes and broadcasts

## MuSig2 + Silent Payments

A MuSig2 wallet (e.g. 3-of-3 `tr(musig(A,B,C)/0/*)`) can pay Silent Payments recipients. The aggregate secret key to unlock the on-chain taproot output key Q is split across all signers, so no single party can compute the ECDH shared secret alone - this requires an extension to MuSig2 PSBT Fields (`PSBT_IN_MUSIG2_PARTIAL_ECDH_SHARE` and `PSBT_IN_MUSIG2_PARTIAL_DLEQ`) to support Silent Payments ouput script computation.

To keep the total number of PSBT rounds to a minimum the MuSig2 nonce and ECDH share collection are combined in the first round:

* Round 1 - Contribute: Partial ECDH share, DLEQ proof, and fresh MuSig2 pubnonce from every signer (last signer computes Silent Payment output scripts from the aggregated ECDH share).
* Round 2 - Sign: Each signer independently re-verifies Silent Payment output scripts then contributes partial MuSig2 signature completing the MuSig2 aggregation and PSBT finalization.

### Call Tree

Signing entry is `psbtObject.sign_it()` (`shared/psbt.py`), `process_silent_payment_outputs()` runs on every round: it validates ECDH coverage, contributes this signer's share, and computes the output scripts via `_compute_silent_payment_output_scripts()`. The last contributor sets the scripts at the end of Round 1. Round 2 every signer re-runs that same path to re-compute and validate the scripts already in the PSBT (mismatch aborts) before contributing a partial signature in `musig_process_input()`.

```text
sign_it() psbt.py:2861,2944
├─ process_silent_payment_outputs() silentpayments.py:372 (runs every round)
│ │
│ ├─ _validate_ecdh_coverage() :690 (required shares + DLEQ present)
│ │
│ ├─ _compute_and_store_ecdh_shares() :776
│ │ └─ _contribute_musig_ecdh_shares() :1091
│ │ ├─ _compute_ecdh_share() :112
│ │ └─ generate_dleq_proof() dleq.py:41
│ │
│ └─ _compute_silent_payment_output_scripts() :846
│ │ Round 1 (last contributor): compute + set output scripts
│ │ Round 2 (every signer): re-compute + validate vs PSBT (mismatch → abort)
│ └─ _get_ecdh_and_pubkey() :476
│ └─ _musig_input_ecdh_share() :1199
│ ├─ _musig_sp_ecdh_factors() :1160
│ │ └─ psbt.musig_keyagg_context() psbt.py:2678
│ │ ├─ musig_build_cache() :2658
│ │ ├─ musig_derive_keyagg_cache() :2641
│ │ └─ musig_taproot_tweak() :2668
│ │ → MusigEcdhFactors(negation_factor, total_tweak)
│ └─ _musig_aggregate_shares() :242
│ └─ _musig_keyagg_coefficient() :273
└─ musig_process_input() psbt.py:2713
Round 1: generate this signer's pubnonce
Round 2: contribute partial MuSig2 signature
```

## Development

### start simulator

```sh
cd unix
% ./simulator.py --q1
```

### execute tests

```sh
cd testing
pytest test_bip352_vectors.py
pytest test_bip375_vectors.py
pytest test_silentpayments.py
pytest test_musig2_silentpayments.py
pytest test_musig2_sp_signers.py
```
2 changes: 1 addition & 1 deletion external/ckcc-protocol
Submodule ckcc-protocol updated 2 files
+118 −0 ckcc/cli.py
+12 −0 ckcc/constants.py
2 changes: 1 addition & 1 deletion external/libngu
Submodule libngu updated 3 files
+19 −1 bech32.patch
+65 −0 ngu/codecs.c
+126 −0 ngu/k1.c
52 changes: 50 additions & 2 deletions shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,44 @@ async def interact(self):

ccc_c_xfp = CCCFeature.get_xfp() # can be None
args = self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)

# Silent Payments: Validate and pre-process Silent Payments outputs to make preview useful
with stash.SensitiveValues() as sv:
if self.psbt.has_silent_payment_inputs():
self.psbt.validate_silent_payment_inputs(sv)

if self.psbt.has_silent_payment_outputs():
if not self.psbt.process_silent_payment_outputs(sv):
# Coverage incomplete: shares computed but waiting on other signers
# Skip normal approval flow — prompt user to contribute shares, then save
ch = await ux_show_story(
"Silent payment ECDH shares will be added to this transaction.\n\n"
"Other signers must contribute their shares before signing can proceed.\n\n"
"Press %s to contribute shares. %s to abort." % (OK, X),
title="CONTRIBUTE SHARES?"
)
if ch != 'y':
self.refused = True
await ux_dramatic_pause("Refused.", 1)
del args, self.psbt
self.done()
return

# Handle nonce generation for MuSig2 + SP inputs
if self.psbt.has_musig_sp_inputs():
self.psbt.consider_outputs(*args, cosign_xfp=ccc_c_xfp)
del args
if self.psbt.session:
self.psbt.session.update(pack('<I', self.psbt.lock_time))

self.psbt.sign_it() # generates nonces

await done_signing(self.psbt, self, self.input_method,
self.filename, self.output_encoder,
finalize=False)
self.done()
return

self.psbt.consider_outputs(*args, cosign_xfp=ccc_c_xfp)
del args # not needed anymore
# we can properly assess sighash only after we know
Expand Down Expand Up @@ -706,14 +744,19 @@ def output_summary_text(self, msg):
has_change = True
total_change += tx_out.nValue
if len(largest_change) < MAX_VISIBLE_CHANGE:
largest_change.append((tx_out.nValue, self.chain.render_address(tx_out.scriptPubKey)))
addr = self.chain.render_address(tx_out.scriptPubKey)
if outp.sp_v0_info:
addr += '\n' + self.psbt.render_silent_payment_output_string(outp)
largest_change.append((tx_out.nValue, addr))
if len(largest_change) == MAX_VISIBLE_CHANGE:
largest_change = sorted(largest_change, key=lambda x: x[0], reverse=True)
continue

else:
if len(largest_outs) < MAX_VISIBLE_OUTPUTS:
rendered, _ = self.render_output(tx_out)
if outp.sp_v0_info:
rendered += self.psbt.render_silent_payment_output_string(outp)
largest_outs.append((tx_out.nValue, rendered))
if len(largest_outs) == MAX_VISIBLE_OUTPUTS:
# descending sort from the biggest value to lowest (sort on out.nValue)
Expand All @@ -732,7 +775,10 @@ def output_summary_text(self, msg):

largest.pop(-1)
if outp.is_change:
ret = (here, self.chain.render_address(tx_out.scriptPubKey))
addr = self.chain.render_address(tx_out.scriptPubKey)
if outp.sp_v0_info:
addr += '\n' + self.psbt.render_silent_payment_output_string(outp)
ret = (here, addr)
else:
rendered, _ = self.render_output(tx_out)
ret = (here, rendered)
Expand Down Expand Up @@ -1733,6 +1779,8 @@ def yield_item(self, offset, end, qr_items, change_idxs):
outp = self.user_auth_action.psbt.outputs[idx]
item = "Output %d%s:\n\n" % (idx, " (change)" if outp.is_change else "")
msg, addr_or_script = self.user_auth_action.render_output(out)
if outp.sp_v0_info:
msg += self.user_auth_action.psbt.render_silent_payment_output_string(outp)
item += msg
qr_items.append(addr_or_script)
if outp.is_change:
Expand Down
3 changes: 3 additions & 0 deletions shared/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class ChainsBase:
curve = 'secp256k1'
menu_name = None # use 'name' if this isn't defined
ccc_min_block = 0
sp_hrp = 'sp' # BIP-352 Silent Payment address HRP (default mainnet)

# b44_cointype comes from
# <https://github.com/satoshilabs/slips/blob/master/slip-0044.md>
Expand Down Expand Up @@ -341,6 +342,7 @@ class BitcoinTestnet(ChainsBase):
}

bech32_hrp = 'tb'
sp_hrp = 'tsp' # BIP-352 Silent Payment testnet HRP

b58_addr = bytes([111])
b58_script = bytes([196])
Expand All @@ -353,6 +355,7 @@ class BitcoinRegtest(BitcoinTestnet):
ctype = 'XRT'
name = 'Bitcoin Regtest'
bech32_hrp = 'bcrt'
sp_hrp = 'tsp' # BIP-352 Silent Payment regtest HRP


def get_chain(short_name):
Expand Down
Loading