Skip to content

Add Silent Payments for the Liquid Network - #37

Open
42Pupusas wants to merge 16 commits into
ElementsProject:mainfrom
42Pupusas:elip-silent-payments-liquid
Open

Add Silent Payments for the Liquid Network#37
42Pupusas wants to merge 16 commits into
ElementsProject:mainfrom
42Pupusas:elip-silent-payments-liquid

Conversation

@42Pupusas

Copy link
Copy Markdown

This ELIP specifies BIP-352 Silent Payments adapted to the Liquid Network's Confidential Transactions. Each normative rule is tagged as BIP-352-derived, a Liquid adaptation, or an open design choice (stated preferentially). Includes worked test vectors and abstract data structures.

A Standards Track draft specifying BIP-352 Silent Payments adapted to the
Liquid Network's Confidential Transactions. Each normative rule is tagged
as BIP-352-derived, a Liquid adaptation, or an open design choice (stated
preferentially). Includes worked test vectors and abstract data structures.
Comment thread elip-silent-payments-liquid.mediawiki Outdated

==Reference Implementation==

A reference implementation demonstrating address encoding, input aggregation,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A reference implementation in python would be nice to review this ELIP

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I have working examples using LWK in Rust, is that acceptable? not too familiar with Python

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I attempted to wire in everything together in a Python implementation, but the blinding logic for Confidential Transactions exposed on the Rust crate is not as readily available on python tooling, and my skills in python are not good enough to rewire everything to ensure it will work correctly.

I have tried to clean up the Rust implementation and added more comments throughout mapping to the ELIP documentation. I hope that will sufficient?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Have you tried using wallycore? If that doesnt work maybe we can replace LWK with a more lighter elements crate?

Comment thread elip-silent-payments-liquid.mediawiki Outdated
Comment thread elip-silent-payments-liquid.mediawiki Outdated
Comment thread elip-silent-payments-liquid.mediawiki Outdated
Because <code>bk_k</code> and <code>t_k</code> are outputs of a random oracle (a
tagged hash) evaluated on '''disjoint domains''' over the same secret <code>S</code>,
they are independent: knowledge of one does not assist in recovering the other or
<code>S</code>. The domain tag <code>LiquidSilentPayments/Blind</code> MUST differ

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Would a third person who wants to detect silent payments, be able to compute blinding keys for each transaction and then check if it unblinds to identify Silent payments?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It should not be possible as this is using the same hardness assumption from BIP-352 that the blinding key is computed from the sender+receiver Diffie Hellman shared secret, which should never be computable by third parties.

Comment thread elip-silent-payments-liquid.mediawiki Outdated
42Pupusas added 5 commits June 2, 2026 15:22
Trim BIP-352 re-explanation to focus on Liquid-specific differences:
- Shorten abstract and motivation prose
- Reduce BIP-352-compliant sections to brief restatements
- Remove Rationale section (reasoning is stated inline in each
  [Liquid]/[Choice] design section)
The flow is essentially identical to BIP-352; fold the three subsections
(filter omission, server interface) into one paragraph plus the interface
example.
Switch silent-payment outputs from confidential P2WPKH to confidential
Taproot (OP_1 <x_only(P_k)>), with P_k used directly per BIP-352 (no
script tree, no taptweak). This aligns the output, spend path, and
index-server data with BIP-352's x-only conventions verbatim.

- Output is now Taproot-only; eligible inputs keep BIP-352's full set
  (P2TR, P2WPKH, P2SH-P2WPKH, P2PKH), so an SP output is itself eligible
  as a later input and the even-Y rule applies unchanged.
- Spending is an ordinary BIP-340 key-path spend (even-Y normalized).
- Collapse the now-pure-BIP-352 sections (input aggregation, eligible
  inputs, shared secret, output representation) into one consolidated
  'Reused from BIP-352 unchanged' section.
- Update test vectors (Taproot scriptPubKeys) and reference-implementation
  note (verified against LWK, reproduces vectors byte-for-byte).
…ls section

The previous lq/tlq HRP collided with ordinary Liquid CONFIDENTIAL
addresses (blech32 lq/tlq), defeating the distinct-HRP goal. Switch to
lqsp/tlqsp, which differ from every existing Liquid HRP (ex/tex
unconfidential, lq/tlq confidential) and from Bitcoin's sp/tsp. Update
the test-vector address accordingly (verified against LWK).

Also fold the Labels section into the consolidated BIP-352 reused
section (labels need no Liquid adaptation); the one substantive note —
the blinding key is label-independent — moves to the blinding-key section.
Reduce the Reference Implementation paragraph to what the public
reference covers: it reproduces the test vectors byte-for-byte and
demonstrates non-interactive unblinding of the shared-secret-blinded
output. Wallet integration (scanning, signing, transaction building) is
left to implementations, rather than enumerated here.
non-interactively by the receiver. Wallet integration — scanning, signing, and
transaction building — is left to implementations.

==Acknowledgements==

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

JAN3 should be in the acknowledgements if this BIP is awarded the bounty

Comment thread elip-silent-payments-liquid.mediawiki Outdated
tweaks, which does not exist in common protocols today; until then, silent-payment
outputs are usable with software signing.

==Reference Implementation==

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The point of the reference implementation in the BIP is like a sort of documentation. I will review your rust code but I feel it should be in python, or something thats close to human readable code. You can check https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There should also be implementation of the tweak server and decryption by the wallet

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Had a cursory look at https://github.com/42Pupusas/elip-sp-reference/blob/main/src/lib.rs and it looks good. Im just concerned if rust is human readable enough for a BIP.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ok the wallycore for sure was the easier path in Python. I reused most of the BIP-352 reference implementation that uses libsecp256klab and a bech32 impl from Peter Wuille, then added wallycore blinding functions where appropriate to create a parallel Python reference implementation.

I am not a good judge of whether the Python code is more human readable as I have strong biases against Python and for Rust, but that particular bikeshed is not worth exploring, so I do hope the review is easier with the new Python code.

Comment thread elip-silent-payments-liquid.mediawiki Outdated
# aggregates the private keys of its eligible transaction inputs into a single scalar <code>a</code> and forms <code>A = a·G</code> '''[BIP-352]''';
# computes a transaction-bound <code>input_hash</code> and an ECDH shared secret <code>S</code> with the receiver's scan key '''[BIP-352]''';
# derives, for output index <code>k</code>, a spend public key <code>P_k</code> that only the receiver can later re-derive '''[BIP-352]''';
# places <code>P_k</code> in a Taproot (P2TR) output '''[BIP-352]''', and blinds that output's asset and amount to a blinding key that is itself derived from <code>S</code> '''[Liquid]'''.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actually, we need to think harder on this. On-chain, there is actually some Taproot usage, so SP payments get masked. But on liquid, if there is no usage at all, we might run into issues.

A taproot output will definitely be SP in liquid I think. We need to analyse usage

@42Pupusas 42Pupusas Jun 18, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A quick 500 block scan shows that Taproot usage is very small, around 3% .

So a TR output won't "definitely" be SP, but the anonymity set is quite small.

Is this enough nudge to drop taproot and use p2pkh instead?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I feel P2PKH is better, but I would like to get a second opinion on this. More specifically, understand why the mainchain decided to use Taproot.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I was digging through the old BIP352 threads last weeks to get more clarity for the taproot output being the default.

I was unable to find much discussion around it, but here is some explicit comments on it I found:

Also to consider from the implementation difference is that with TR output , the script is the x-only key, so we only do direct x-coordinate comparison. With P2PKH the scanner has to serialize compressed, HASH160, then compare. The overhead is not immense but should be considered.

All three have their merits I can see, just unsure of the tradeoff with anonymity set size in Liquid.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Also the BIP-352 itself generalizes the choices to just

"^ Why only taproot outputs? Providing too much optionality for the protocol makes it difficult to implement and can be at odds with the goal of providing the best privacy. Limiting to taproot outputs helps simplify the implementation significantly while also putting users in the best eventual anonymity set."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IMO it's more important that SP for elements/liquid be directly derived from BIP-352. This increases security and it inherits all the review from upstream and can help with re-use of tooling etc.

Currently there is much more P2PKH than TR, but that will likely/hopefully change (as it will on Bitcoin with e.g. more usage of L2 systems using TR addresses and LN using TR addresses).

Also, the anonymity of SP addresses within the larger set of TR addresses is a nice-to-have additional feature, and not the intended privacy goal of SP.

Comment thread elip-silent-payments-liquid.mediawiki Outdated
Throughout this document, every normative rule is tagged to make its origin explicit:

* '''[BIP-352]''' — the rule is taken unchanged from BIP-352. Implementations SHOULD reuse existing, reviewed BIP-352 logic for these parts.
* '''[Liquid]''' — the rule is an adaptation made necessary by a structural difference between Liquid and Bitcoin (most importantly, Confidential Transactions and Liquid's deployed output types). These are the substantive technical contributions of this document.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
* '''[Liquid]''' — the rule is an adaptation made necessary by a structural difference between Liquid and Bitcoin (most importantly, Confidential Transactions and Liquid's deployed output types). These are the substantive technical contributions of this document.
* '''[Liquid]''' — the rule is an adaptation due to Confidential Transactions and Liquid's deployed output types.

Theres still a lot of AI generated verboseness, imo. I feel they can be cleaned up and we dont need to add anything new in this BIP than whats absolutely needed.

For me it feels like ===Output blinding key '''[Liquid]'''=== is the only real specification in this doc and the remaining spec is just to use exisiting BIP 352 logic. Everything can be condensed imo

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

will push a condensed revision once a path has been settled for TR vs P2PKH

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please do not wait for me to get back to make the changes as I may take a while to reply and another reviewer might want to review this, etc. Try to make the PR as up to date as possible whenever you get time to work on it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Condensed the ELIP to only the specific changes needed for Liquid, reusing and adoption the notation conventions in BIP-352.

Comment thread elip-silent-payments-liquid.mediawiki Outdated
Comment on lines +155 to +156
bk_k = hashLiquidSilentPayments/Blind( serP(S) || ser32(k) ) (a 32-byte scalar)
BK_k = bk_k·G

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This bit is still not clear to me.

A and S can be computed by a third party. What is k?

Actually what is hashLiquidSilentPayments/Blind( serP(S) || ser32(k) ) ? can we just write the math and not generic fn names

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

k is the output index counter the sender chooses and keeps for a specific SP address (0 first payment, +1 recurring), not transmitted on-chain.

A third party who knows the receiver's address can compute A and S from public data, but to derive the blinding key for a specific output they'd have to brute-force the gap limit × every address × every transaction.

can we just write the math and not generic fn names

is following the notation style in BIP-352 ok?

so changing to:

tag = "LiquidSilentPayments/Blind"
bk_k = int( SHA256(SHA256(tag)||SHA256(tag) || serP(S) || ser32(k) ) ) mod n

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

  • Pin the exact ASCII tag string (LiquidSilentPayments/Blind) verbatim, since interop depends on every implementation hashing the identical bytes. A one-character difference silently breaks unblinding across wallets.

  • Define it the same way BIP-352 defines its own: state the BIP-340 tagged-hash construction once in the conventions section then use the named form hashLiquidSilentPayments/Blind(...). Please note that LiquidSilentPayments/Blind should be in the subscript like BIP-352

Image

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A third party who knows the receiver's address can compute A and S from public data, but to derive the blinding key for a specific output they'd have to brute-force the gap limit × every address × every transaction.

This is wrong. I was wrong earlier, a third party cannot compute S and that's the security. Brute-forcing isn't hard

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The new Privacy section should now be clearer and explicit about where the privacy and security assumptions come from.

Conventions: state the BIP-340 tagged-hash construction once and pin the exact
ASCII tag strings (incl. LiquidSilentPayments/Blind, 26 bytes) in a table;
switch the derivation steps to the named hash_<tag>() subscript form, matching
BIP-352 and removing the define-after-use tag_* inline forms.

Privacy: rewrite the rationale. The previous text wrongly claimed S is
derivable from the public A and B_scan; that is the computational
Diffie-Hellman problem. Privacy rests on the secrecy of b_scan; the address is
not secret and the output index k is not secret.

Align tweak terminology to BIP-352 (partial tweak -> tweak).

README: add the ELIP index row.
@42Pupusas
42Pupusas force-pushed the elip-silent-payments-liquid branch from 276f423 to 28d6c18 Compare June 29, 2026 22:32
spent by an ordinary key-path signature, so existing relay and validation rules apply.
Wallets that don't implement this are unaffected.

==Reference Implementations==

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This should be moved to this repo. Follow the convention in https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py and use MIT license

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Okay ignore my comment about MIT license as other ELIPS are BSD license as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This should be moved to this repo

Do you mean the reference implementations? I noticed all the other ELIPs had external reference implementations so did it that way.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think they are almost always in https://github.com/ElementsProject/elements and thats why. Having them in a personal repo seems wrong or atleast different from the conventions in the bips repo

Move the Rust and Python reference implementations from the standalone
elip-sp-reference repo into elip-silent-payments-liquid/{rust,python}/,
following the bitcoin/bips convention of a folder named after the spec
(e.g. bip-0352/) holding its supporting code.

Update the ELIP's Reference Implementations section to link in-tree
instead of to the external repo.
@tomt1664

tomt1664 commented Jul 6, 2026

Copy link
Copy Markdown
Member

A few comments:

“internal byte order” of the outpoint_L encoding should be defined unambiguously.

specify explicitly handling if bk_k falls outside the valid scalar range (is 0 or > n). BIP-352 has this for t_k (if it is out of range, the output is skipped). Should be the same for bk_k: if bk_k = 0 mod n, the output index k is skipped.

In the final version a BIP-32 path should be specifed (and will be different to BIP-352).

…BIP-32 path

- Define outpoint_L txid 'internal byte order' unambiguously (raw dSHA256
  bytes as serialized in a tx input, reverse of RPC hex display)
- Skip output index k when bk_k is 0 or >= n, mirroring BIP-352's t_k
  handling; update Python (from_bytes_nonzero_checked) and Rust references
- Specify BIP-32 derivation path with SLIP-44 coin type 1776 (Liquid):
  m/352'/1776'/account'/1'/0 (scan), m/352'/1776'/account'/0'/0 (spend)

@delta1 delta1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi @42Pupusas. This ELIP is missing a Copyright section. Some other review comments follow.

Comment thread elip-silent-payments-liquid.mediawiki Outdated
<code>S = input_hash·a·B_scan = b_scan·(input_hash·A)</code>. Computing it needs either the
sender's input secret <code>a</code> or the receiver's secret scan key <code>b_scan</code>,
so this inherits the BIP-352 privacy model. CT adds one thing on top: even someone who
holds <code>b_scan</code> learns only that an output is a silent payment, not its amount or asset.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The claim "even someone who holds b_scan learns only that an output is a silent payment, not its amount or asset" is false.

b_scan is sufficient to derive bk_k and fully unblind amounts. S = b_scan·(input_hash·A) is computable from b_scan alone plus public chain data, and bk_k = hash(serP(S)||k) mod n

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ok I was getting confused I because of the DH secret step, but yes , this is correct, a light wallet/client that hands its b_scan key for non-interactive unblinding, will lose CT confidentiality.

Adding an explicit comment on the tradeoff.

Comment thread elip-silent-payments-liquid.mediawiki Outdated
Comment on lines +52 to +55
** If <code>bk_k = 0</code>, increment <code>k</code> and continue
* Let <code>BK_k = bk_k·G</code>
* Blind the output's asset and amount to <code>BK_k</code>, as for any confidential Liquid output

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ambiguous "increment k and continue" when bk_k = 0.
Unclear whether P_k/scriptPubKey also re-derives with the new k, or only the blinding key recomputes. If misimplemented, sender and receiver derive different blinding keys for that output, making it unblindable.

Maybe clarify that P_k must also be rederived with new k

nonce,
script_pubkey,
witness: TxOutWitness {
surjection_proof: None,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

build_confidential_sp_txout sets surjection_proof: None in the TxOutWitness. Liquid consensus requires a surjection proof on every confidential output; without one the transaction is rejected by every node. The Python counterpart similarly produces no surjection proof in its returned dict. The tests pass because unblinding reads only the rangeproof, not the surjection proof — but any implementer copying this code would produce unbroadcastable transactions. A comment or a stub surjection proof should be added.

eng.input(&lowest_outpoint(outpoints));
eng.input(&a_pubkey.serialize());
Scalar::from_be_bytes(InputsHash::from_engine(eng).to_byte_array())
.expect("input_hash < curve order")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rust panics instead of reducing mod n for hash ≥ n; Python reduces; implementations disagree.
Scalar::from_be_bytes(...).expect("input_hash < curve order") in input_hash (and identically in blinding_secret) panics on the ~2⁻¹²⁸ chance the hash output ≥ n. The Python reference uses Scalar.from_bytes_wrapping() which reduces mod n, matching BIP-352's spec text ("interpreted as a 256-bit big-endian integer mod n"). The two reference implementations have incompatible behavior on this edge case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

After double checking BIP-352 Creating Outputs

If input_hash is not a valid scalar, i.e., if input_hash = 0 or input_hash is larger or equal to the secp256k1 group order, fail

So in this case I think the Rust behaviour is correct and its the Python ref that needs to fail instead of wrapping.

42Pupusas added 3 commits July 7, 2026 16:22
…scan-key privacy, outpoint example

- Harmonize input_hash/t_k out-of-range handling with BIP-352 v1.0.2: fail, don't reduce mod n (Python switched to from_bytes_checked; Rust gains zero checks)
- Document that build_confidential_sp_txout omits the consensus-required asset surjection proof and that wallets must add one at transaction assembly
- Clarify bk_k skip: the whole output index k is skipped and both P_k and bk_k re-derive from the next index
- Correct Privacy section: a b_scan holder can unblind outputs (bk_k derives from S), not merely detect them; local scanning required for confidentiality against a scanning server
- Add a worked byte-level example of outpoint_L internal byte order
build_confidential_sp_txout now takes the transaction inputs' (generator, asset tag, abf) and produces a consensus-required surjection proof instead of omitting it — Rust via secp256k1_zkp::SurjectionProof::new, Python via wally.asset_surjectionproof. The Rust test verifies the proof against the input generators.
…itself

wallycore's asset_surjectionproof is _wrap_bin-wrapped: it takes the 7 input arguments, sizes the proof via asset_surjectionproof_len internally, and returns the buffer. Passing bytes_out ourselves raised TypeError. Confirmed by running the Python suite with wallycore: 5/5 pass, surjection proof round-trips.
@42Pupusas

Copy link
Copy Markdown
Author

Appreciate all the reviews.

  • Internal byte order now defined explicitly:

the raw double-SHA256 bytes as they appear in a serialized tx input, i.e. the reverse of the hex shown by RPC/explorers

  • bk_k = 0 or ≥ n: fixed to match BIP-352's t_k handling. Added a MUST that (P_k, bk_k) always share the same index, since re-deriving only the blinding key would leave the output unblindable.

  • BIP-32 path specified same scheme as BIP-352 with SLIP-44 coin type 1776 (Liquid) m/352'/1776'/account'/1'/0 (scan) and m/352'/1776'/account'/0'/0 (spend), coin type 1' for testnet/regtest. Disjoint from Bitcoin's m/352'/0'/...

  • input_hash / t_k out of range now fail (per BIP-352 v1.0.2) in both references.

  • Both references now generate real asset surjection proofs instead of omitting them.

  • Corrected the Privacy section: a b_scan holder can unblind outputs, not merely detect them, since bk_k derives from S. Wallets wanting confidentiality against a scanning server must scan locally.

Both reference implementations updated to match, test vectors unchanged.

@delta1

delta1 commented Jul 23, 2026

Copy link
Copy Markdown
Member

@42Pupusas please see #42 as well which includes an LWK implementation and I think the efforts could be aligned and possibly split between you two (maybe not 50/50 but there should be some discussion)

@42Pupusas

Copy link
Copy Markdown
Author

@delta1 not sure how to make the determination of the split, either for the work, or the bounty.

while the reference implementation described here was not integrated directly with LWK (uses it as a dep instead) to keep the implementation self contained, the work to integrate has mostly been finished on my end, just didn't push to LWK as I was waiting for feedback on this issue I opened clarifying some details, which has not yet received any traction.

I also don't see any clear deviations on the implementations that I could incorporate in to this spec, but happy to get suggestions on this as your comment on the other thread indicates .

@emjshrx

emjshrx commented Jul 27, 2026

Copy link
Copy Markdown

I will be representing JAN3. This PR is a more well-rounded and reviewed spec, so this should be the one that is merged. But I do see some new ideas in #42, like Input eligibility. @soloking1412 pls add your review to this PR on how it can be improved, and that will be used to judge how the bounty should be split

@42Pupusas pls do not wait for LWK to get back to you, as the team is super busy. Pls make necessary assumptions and push the code. Code can be changed quickly if there is some assumption that was wrong, but waiting for human feedback isn't as quick.

@soloking1412

Copy link
Copy Markdown

Happy to see this merged as the spec — it's the more mature document, and the structure of tagging each rule as BIP-352-derived vs. Liquid adaptation vs. open choice is genuinely good. The internal-byte-order clarification for outpoint_L is a real trap that's easy to get wrong, and the rationale for keeping the blinding tag domain-separated from BIP0352/SharedSecret (so bk_k and t_k don't reveal each other) is correct and important.

@42Pupusas, answering your question about what could be incorporated — below is a review from having implemented this independently in LWK, ordered roughly by severity. I've checked each item against the current branch, after the 7 July round of fixes, so these are all still open. Happy to open PRs against your branch for any of them.

Also FWIW I've already switched the LWK implementation's blinding tag to LiquidSilentPayments/Blind to match this spec, so the two are wire-compatible as of now.


1. Spec is written against BIP-352 v1.0.2 — missing K_max (added v1.1.0), which is a DoS mitigation

python/elip_sp_reference/core.py cites "Per BIP-352 (v1.0.2)" (lines 110 and 142). Current BIP-352 is v1.1.1, and v1.1.0 (2026-03-02) added the per-group recipient limit:

1.1.0 (2026-03-02): Introduce per-group recipient limit K_max to mitigate quadratic scanning behavior for adversarial transactions.

K_max = 2323 is normative on both sides — the sender must fail if any group exceeds it, and the receiver must stop scanning at k == K_max. Per the BIP's rationale, without it an adversary can craft a single transaction with ~23255 outputs targeting one entity, making scanning O(N²) and "taking several minutes on modern systems".

I don't see K_max / 2323 anywhere in the spec or in either reference implementation. This applies to Liquid unchanged and I'd treat it as the highest-priority fix.

2. outpoint_L is still ambiguous on Elements — the peg-in and issuance flags

This is separate from the byte-order fix in the 7 July round. That resolved the endianness of the txid (internal vs. RPC display order), which was the right fix. This is about the vout field, which is still under-specified.

On Elements the vout carries two flags in its high bits: the peg-in flag (2^30) and the issuance flag (2^31). Elements sets these when serializing a transaction and strips them when parsing (see TxIn decoding in rust-elements / CTxIn in Elements Core).

So "the 4-byte little-endian vout" has two different readings for any input carrying an issuance or a peg-in:

  • the raw bytes as they appear in the serialized transaction (flags set), or
  • the parsed index (flags cleared).

A wallet working from a parsed transaction — which is the normal case — will naturally use the second. An implementation hashing raw serialized bytes will use the first. They disagree, the shared secret differs, and the payment is silently unrecoverable.

Suggest specifying explicitly that vout is the index with both flags cleared, i.e. the parsed value, noting that a coinbase vout of 0xffffffff is not masked since it carries no flags. This also has the nice property of making derivation byte-identical to BIP-352 for any transaction without a peg-in or issuance.

3. Input eligibility is undefined for peg-in inputs

"Everything else follows BIP-352 exactly: input eligibility, …" — but BIP-352 has no notion of peg-ins, so this doesn't resolve on Liquid.

A peg-in input spends an output on the Bitcoin chain. A wallet scanning only Liquid cannot read that prevout's scriptPubKey, so it cannot determine which of the BIP-352 extraction rules (P2TR / P2WPKH / P2SH-P2WPKH / P2PKH) would apply, or whether the input is eligible at all.

Suggest making it normative that peg-in inputs are not eligible (contribute no public key), while their outpoints still participate in outpoint_L — both parties can read those from the Liquid transaction. Without this, a light client and a full-node wallet can compute different A for the same transaction.

Since issuances are the other Elements-only input feature, it's probably worth also stating explicitly that an input carrying an issuance or reissuance is eligible under the normal rules (an issuance doesn't change how the input is spent) — otherwise implementers have to guess.

4. The bk_k skip rule diverges from BIP-352, and is stated only on the sender side

The spec says that if bk_k is out of range, index k is skipped entirely and both sender and receiver continue with k+1, and describes this as mirroring "BIP-352's handling of an out-of-range t_k".

BIP-352 doesn't skip — it fails:

If t_k is not a valid scalar, i.e., if t_k = 0 or t_k is larger or equal to the secp256k1 group order, fail

(BIP-352 §Sending and §Scanning.) I think this traces back to the 6 July review comment, which described BIP-352's behaviour as "if it is out of range, the output is skipped" — that reading doesn't match the BIP text.

The result is that the 7 July changelog says both "bk_k = 0 or ≥ n: fixed to match BIP-352's t_k handling" and "input_hash / t_k out of range now fail" — but the spec still specifies skip for bk_k while the reference implementations fail for t_k. So there are currently two different semantics for two scalars derived from the same S, and the document and the code disagree about which applies where.

The bigger concern is placement: the rule appears inside the section beginning "The sender, for each output index k". A receiver implementer following BIP-352's scanning loop — which increments k only on a match and otherwise stops — would stop at k = 0 rather than skipping to k = 1, and miss the payment entirely. The intent is clearly for both sides to skip, but a reader has to infer the receiver-side obligation from a sender-side bullet.

Given P ≈ 2^-128 and that this path can never be exercised by a test, my suggestion is to simply match BIP-352 and fail. That eliminates a class of divergent-loop bugs at zero practical cost. If the skip behaviour is kept deliberately, it should be restated normatively in the receiver's scanning algorithm.

5. The reasoning for dropping BIP-158 filters doesn't hold

The BIP-158 compact-filter step is unnecessary: CT does not blind the scriptPubKey, so a client matches its derived candidate scripts directly against the public output scripts.

scriptPubKeys aren't blinded on Bitcoin either — CT isn't the reason BIP-352 light clients use filters. Filters are a bandwidth mechanism: to match candidate scripts against a block's output scripts you must first have those scripts, which means downloading the full block. Filters let a client rule out a block in ~a few hundred bytes and download only on a hit.

So the conclusion ("unnecessary") doesn't follow from the premise, and as written it pushes light clients toward downloading every block since the wallet birthday — which is the thing they can't do. Elements supports the basic filter (-blockfilterindex=basic), and its element set (every non-empty, non-OP_RETURN output script plus spent output scripts) includes confidential outputs, since a scriptPubKey is never confidential.

Suggest keeping direct matching as valid for a wallet that already has the block, and presenting filters as an optional bandwidth optimization rather than as unnecessary.

6. The index-server reference is a dead link, and "used unchanged" needs a caveat

The citation points at https://github.com/bitcoin/bips/blob/master/bip-0352/index-server.mediawiki, which 404s. The spec lives at https://github.com/silent-payments/BIP0352-index-server-specification.

Relatedly, "The BIP-352 tweak server … is used unchanged" isn't quite true: that specification's /tweaks/:blockheight takes a dustLimit parameter and /getinfo advertises a dust_limit. On Liquid a server cannot implement dust filtering, because output amounts are confidential — it has no way to tell a dust output from any other. Worth stating that dust_limit must be 0 / not applicable, so server implementers don't ship a silently broken filter.

7. tlqsp is shared between testnet and regtest

_HRP = {"liquid": "lqsp", "liquid-testnet": "tlqsp", "liquid-regtest": "tlqsp"}

Elements distinguishes these networks everywhere else (lq / tlq / el for blech32 confidential addresses), and the whole point of a distinct HRP here is to make cross-network payment impossible rather than merely unlikely. As written, a regtest address is indistinguishable from a testnet one. Suggest a third HRP for regtest.

8. Missing the "keep scanning on a cryptographic match" warning

BIP-352 carries an explicit warning that the decision to continue scanning must be based on whether a cryptographic match was found, not on whether the wallet considers the output spendable or relevant (e.g. dust) — stopping early causes subsequent outputs from the same sender to be missed.

This is more important on Liquid, not less: the wallet cannot see an output's amount at all until it has derived bk_k and unblinded it, so a policy-based early stop is even easier to write by accident. Worth making normative.

9. Test vector coverage

The vectors are clear and reproducible, but they only exercise the happy path. The gaps are precisely the Liquid-specific cases:

  • Labels — labels are mentioned once in passing and no labelled/change address appears in the vectors. Since the change label (m = 0) is mandatory to scan for, a vector pinning it would be valuable.
  • A peg-in and an issuance input — exactly the case where the outpoint_L ambiguity in §2 bites. A vector here would make the encoding unambiguous by construction.
  • Testnet/regtest addresses — nothing currently pins the non-mainnet HRPs.

10. Minor: Layer header

Layer: Applications, whereas the comparable wallet-level ELIPs — 150 (CT descriptors), 151 (deterministic blinding keys), 152 (wallet ID) — use Layer: Wallet. This document is about wallet behaviour; Wallet seems the better fit.


One integration note that isn't a defect in this spec, just something implementers will hit: a received output can't be represented as an ELIP-150 CT descriptor today. ct(bk_k, eltr(P_k)) applies the BIP-341 tweak to P_k and yields a different script, since silent payment outputs use P_k directly as the output key. An elrawtr() (a key-spend-only taproot descriptor that uses its key as the output key) would make ct(bk_k, elrawtr(P_k)) the natural representation — probably its own ELIP, but worth a forward-reference so wallet authors know why their descriptor doesn't round-trip.

Happy to send PRs for any of the above, and to carry whatever lands here into the LWK implementation so the code and the spec stay aligned.

@soloking1412

soloking1412 commented Jul 27, 2026

Copy link
Copy Markdown

Should have linked it in the review above: the implementation these findings came from is Blockstream/lwk#170 (renamed since the earlier cross-reference in this thread).

It's integrated into lwk_wollet rather than standalone — silent payment outputs are unblinded with the derived bk_k, exposed as ordinary wallet UTXOs, and spendable through the existing tx builder. It covers address generation and parsing, sending, scanning, labels, and the Tweak Server client (/getinfo, /tweaks/:height, /filters/:height) with BIP-158 filter matching, so the receive path runs end to end without sharing any key with the server.

Conformance: the official BIP-352 send and receive test vectors pass in both directions, plus tests over real blinded, signed Liquid transactions to cover the blinding that BIP-352 doesn't. The blinding tag is already LiquidSilentPayments/Blind, matching this spec.

Each of the findings above is enforced in that code — K_max on both the sender and the scanning loop, the vout flags cleared before hashing, peg-ins contributing their outpoint but not a key, and transactions spending witness version > 1 skipped entirely. Happy to port any of it into this spec's reference implementations.

Comment on lines +3 to +5
Written in the style of the canonical BIP-352 reference, using the pure-Python
``secp256k1lab`` for the curve algebra (so the spec math reads literally) and
``wallycore`` only for the Liquid-specific Confidential Transactions plumbing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
Written in the style of the canonical BIP-352 reference, using the pure-Python
``secp256k1lab`` for the curve algebra (so the spec math reads literally) and
``wallycore`` only for the Liquid-specific Confidential Transactions plumbing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why do we need this file in the reference?

@@ -0,0 +1,292 @@
"""Silent Payments for the Liquid Network — Python reference implementation.

Written in the style of the canonical BIP-352 reference

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pls remove unnecessary comments

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this file seems to be committed by mistake

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lets follow the structure followed in https://github.com/bitcoin/bips/tree/master/bip-0352 . We dont need a toml file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

same. no need of an extra readme for the reference impl

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

can we remove the rust reference



def compute_tweak(input_pubkeys: List[GE], outpoints: List[bytes]) -> Tuple[GE, Scalar, GE]:
"""Compute (T = input_hash * A, input_hash, A) from the eligible input pubkeys."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The tweak does not seem to be specified in the ELIP. Can we add that?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants