Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5f2db0c
nit: mark bitcoind dependent sign tests
macgyver13 Mar 5, 2026
e004001
bump submodule version
macgyver13 Jul 29, 2026
4be2f2d
BIP-352: implement Silent Payments cryptographic primitives
macgyver13 Nov 25, 2025
e556787
BIP-374: implement DLEQ proof generation and verification
macgyver13 Nov 25, 2025
b245672
SP: add SilentPaymentsMixin
macgyver13 Mar 19, 2026
05ada7c
fix: (psbt v2) structural changes
macgyver13 Mar 8, 2026
52720fe
fix: (usb_test) Unify local and globals dict to single namespace
macgyver13 Mar 20, 2026
c541308
BIP-375: add test vectors
macgyver13 Nov 25, 2025
5cbf736
BIP-352: add test vectors
macgyver13 Apr 10, 2026
41d5a77
BIP-375: implement Silent Payments PSBT signing workflows
macgyver13 Nov 25, 2025
4f62e66
BIP-376: handle spending silent payments outputs
macgyver13 Dec 23, 2025
fb07d2f
SP: add Silent Payments integration tests
macgyver13 Mar 5, 2026
56a595b
SP: introduce partial share contribution workflow
macgyver13 Mar 25, 2026
c63e578
SP: introduce UI change output validation
macgyver13 Mar 25, 2026
6e6d18c
SP: add sp spend derivation path tests
macgyver13 Apr 26, 2026
22847be
SP: add docs/silentpayments.md
macgyver13 Apr 17, 2026
d48f5a5
SP: implement bip352 generic format export
macgyver13 Apr 23, 2026
1d7daa3
SP: add more integration tests
macgyver13 Apr 30, 2026
fc6e5d3
SP (refactor): test hygiene
macgyver13 May 1, 2026
23c365b
SP: improve sp spend without sp outputs and change detection
macgyver13 May 1, 2026
564178e
fix (SP): apply sp output sort correctly when scan key is the same fo…
macgyver13 Jun 3, 2026
812d7c6
feat (SP): hide export finalize txn option for silent payment outputs
macgyver13 Jul 16, 2026
fad381d
bump ckcc submodule sp partial fields
macgyver13 Jul 17, 2026
e8d1faf
feat (MuSig2 + SP): partial field parse & serialize plumbing
macgyver13 Jul 29, 2026
a1274fb
feat (MuSig2 + SP): calculate stable nonce_msg using sp_v0_info
macgyver13 May 20, 2026
85dcbce
feat (MuSig2 + SP): build musig reusable helpers
macgyver13 Jun 26, 2026
62b0579
feat (MuSig2 + SP): compute sum(IL) with musig_derive_keyagg_cache
macgyver13 Jun 26, 2026
d8bf144
fix (psbt): handle unpacking taproot_subpaths with cached coordinates
macgyver13 Jun 30, 2026
a22f47a
feat (MuSig2 + SP): partial ECDH share contribution and signing UX
macgyver13 May 20, 2026
fa5a9f4
test (MuSig2 + SP): two round integration tests and fixtures
macgyver13 May 20, 2026
f8f607a
test (MuSig2 + SP): full two round - three simulators/signers
macgyver13 Jun 8, 2026
24c49c7
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_SP_PARTIAL_ECDH_SHARE` and `PSBT_IN_SP_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
+17 −0 ckcc/constants.py
2 changes: 1 addition & 1 deletion external/libngu
79 changes: 72 additions & 7 deletions shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
TXN_INPUT_OFFSET = 0
TXN_OUTPUT_OFFSET = MAX_TXN_LEN

SP_EXPORT_NOTE = '''\
Silent Payment output detected - finalize signed PSBT on wallet coordinator to broadcast.
Direct broadcast requires opt-in via Danger Zone > SP Final Export.'''

class UserAuthorizedAction:
active_request = None

Expand Down Expand Up @@ -430,6 +434,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 +748,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 +779,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 @@ -815,6 +865,7 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
# User authorized PSBT for signing, and we added signatures.
# - allow PushTX if enabled (first thing)
# - can save final TXN out to SD card/VirtDisk, share by NFC, QR.
# - silent payments should discourage save final txn by default

from glob import PSRAM, hsm_active
from sffile import SFFile
Expand All @@ -826,17 +877,25 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
base_title = "PSBT " + ("Signed" if psbt.sig_added else "Updated")

is_complete = psbt.is_complete()
sp_export_hint = None
if finalize is not None:
# USB case - user can choose whether to attempt finalization
is_complete = finalize
elif is_complete and psbt.has_silent_payment_outputs():
from glob import settings
if not settings.get('spfin', False):
is_complete = False
sp_export_hint = SP_EXPORT_NOTE

with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
if is_complete:
txid = psbt.finalize(psram)
noun = "Finalized TX ready for broadcast"
else:
psbt.serialize(psram)
noun = "Partly Signed PSBT"
# not finalizing: either genuinely partial (needs more sigs) or an SP tx we
# kept as PSBT on purpose - only the latter (sp_export_hint) is fully signed.
noun = "Signed PSBT" if sp_export_hint else "Partly Signed PSBT"
txid = None

data_len = psram.tell()
Expand Down Expand Up @@ -951,13 +1010,14 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
# typical case: save to SD card, show filenames we used
assert isinstance(ch, dict)
msg = await _save_to_disk(psbt, txid, ch, is_complete, data_len,
output_encoder, filename)
output_encoder, filename, sp_export_hint)

input_method = None
first_time = False
title = base_title

async def _save_to_disk(psbt, txid, save_options, is_complete, data_len, output_encoder, filename=None):
async def _save_to_disk(psbt, txid, save_options, is_complete, data_len, output_encoder,
filename=None, sp_note=None):
# Saving a PSBT from PSRAM to something disk-like.
# - handle save-to-SD/VirtDisk cases. With re-attempt when no card, etc.
assert isinstance(save_options, dict) # from import_export_prompt
Expand Down Expand Up @@ -993,7 +1053,7 @@ def _chunk_write(file_d, ofs, chunk=4096):

while 1:
# try to put back into same spot, but also do top-of-card
if not is_complete:
if not is_complete and not sp_note:
# keep the filename under control during multiple passes
target_fname = base.replace('-part', '') + '-part.psbt'
else:
Expand Down Expand Up @@ -1073,14 +1133,17 @@ def _chunk_write(file_d, ofs, chunk=4096):
# Done, show the filenames we used.
if out_fn:
msg = "Updated PSBT is:\n\n%s" % out_fn
if out2_fn:
if out2_fn or sp_note:
msg += '\n\n'
else:
# del_after is probably set
msg = ''

if out2_fn:
msg += 'Finalized transaction (ready for broadcast):\n\n%s' % out2_fn
elif sp_note:
# in place of the finalized-txn line: explain why it was not produced
msg += sp_note

return msg

Expand Down Expand Up @@ -1733,6 +1796,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