Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e7816e4
nit: mark bitcoind dependent sign tests
macgyver13 Mar 5, 2026
853bb5c
bump submodule version
macgyver13 Jul 29, 2026
2b3f17e
BIP-352: implement Silent Payments cryptographic primitives
macgyver13 Nov 25, 2025
8bd6b81
BIP-374: implement DLEQ proof generation and verification
macgyver13 Nov 25, 2025
648a906
SP: add SilentPaymentsMixin
macgyver13 Mar 19, 2026
3608ca4
fix: (psbt v2) structural changes
macgyver13 Mar 8, 2026
3c9d378
fix: (usb_test) Unify local and globals dict to single namespace
macgyver13 Mar 20, 2026
ab83284
BIP-375: add test vectors
macgyver13 Nov 25, 2025
87dc43e
BIP-352: add test vectors
macgyver13 Apr 10, 2026
374c423
BIP-375: implement Silent Payments PSBT signing workflows
macgyver13 Nov 25, 2025
7953b91
BIP-376: handle spending silent payments outputs
macgyver13 Dec 23, 2025
0fc9d8d
SP: add Silent Payments integration tests
macgyver13 Mar 5, 2026
c20e7ea
SP: introduce partial share contribution workflow
macgyver13 Mar 25, 2026
a0a04c0
SP: introduce UI change output validation
macgyver13 Mar 25, 2026
7508f9f
SP: add sp spend derivation path tests
macgyver13 Apr 26, 2026
2b632a1
SP: add docs/silentpayments.md
macgyver13 Apr 17, 2026
d7e6105
SP: implement bip352 generic format export
macgyver13 Apr 23, 2026
280d336
SP: add more integration tests
macgyver13 Apr 30, 2026
c292793
SP (refactor): test hygiene
macgyver13 May 1, 2026
cfff9f5
SP: improve sp spend without sp outputs and change detection
macgyver13 May 1, 2026
d2310c1
fix (SP): apply sp output sort correctly when scan key is the same fo…
macgyver13 Jun 3, 2026
6c16649
feat (SP): hide export finalize txn option for silent payment outputs
macgyver13 Jul 16, 2026
0075b19
fix (SP): add dleq.py and silentpayments.py to manifest
macgyver13 Aug 3, 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
82 changes: 82 additions & 0 deletions docs/silentpayments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 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 x 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 and FROST are not supported yet

## Example

SP Address encodes two public keys: a *scan key* and a *spend key*
```
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

## Development

### start simulator
```
cd unix
% ./simulator.py --q1
```

### execute tests
```
cd testing
pytest test_bip352_vectors.py
pytest test_bip375_vectors.py
pytest test_silentpayments.py
```
2 changes: 1 addition & 1 deletion external/ckcc-protocol
73 changes: 66 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,38 @@ 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
del args
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 self.psbt
self.done()
return
try:
await done_signing(self.psbt, self, self.input_method,
self.filename, self.output_encoder,
finalize=False)
self.done()
except BaseException as exc:
return await self.failure("PSBT output failed", exc)
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 +742,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 +773,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 +859,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 +871,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 +1004,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 +1047,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 +1127,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 +1790,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