Confidential Transactions
Every Liquid transaction hides the amount and asset type by default. This is called Confidential Transactions (CT). This page explains how CT works under the hood, why it matters, and how to unblind transactions you control.
The problem CT solves
Bitcoin transactions are public: anyone can see which addresses sent how much BTC to which addresses. For individuals this is a privacy concern. For businesses it's a competitive disclosure problem: transaction volumes, counterparties, and flows are all visible to competitors.
For an exchange, transparent transactions mean:
- Competitors can estimate your reserves by watching known hot wallets.
- Deposit and withdrawal volumes are public, revealing business metrics.
- User balances held on the exchange can be inferred from the chain.
Confidential Transactions fix this by cryptographically hiding amounts and asset types on chain while preserving the ability to verify that transactions are valid (no inflation, inputs equal outputs, all assets in the input set are accounted for in the output set).
What's hidden vs what's visible
When you look at a Liquid transaction on an explorer, you'll see:
Hidden (blinded):
- The exact amount in each output
- Which asset type each output carries
Still visible:
- The transaction graph (which inputs are spent by which transaction)
- The number of inputs and outputs
- The addresses involved
- The fee amount (always unblinded, always in LBTC)
- The timestamp and block information
This is a strong privacy property: even if someone knows one of the addresses belongs to you, they can't tell whether a given transaction moved 1 satoshi or 1 million LBTC, or whether the asset was LBTC or USDt.
How CT works (technical)
CT combines three cryptographic primitives to make blinding possible while still preventing inflation:
1. Pedersen commitments for amounts
Instead of writing the amount directly into the output, Liquid writes a Pedersen commitment:
C = v*H + r*G
Where:
vis the amountris a random blinding factorGandHare generator points on the secp256k1 curve
The commitment C looks random to anyone without r. Only parties who know the blinding factor can "open" the commitment and recover v.
Pedersen commitments are additively homomorphic: if you add two commitments together, you get a commitment to the sum of the amounts. This lets the network verify that sum(inputs) = sum(outputs) (including fee outputs) without seeing the actual amounts.
2. Range proofs (Bulletproofs)
A commitment to an amount could hide a negative number, which would allow inflation. To prevent this, each output includes a range proof showing that the committed amount is within a valid range (0 to 2^64).
Liquid uses Bulletproofs, a compact zero knowledge range proof scheme. Bulletproofs let verifiers confirm "the amount is in range" without learning the amount.
3. Asset surjection proofs
Each output also commits to an asset type (a 32 byte asset ID). To prove that outputs spend the same assets as inputs (no asset type confusion), each output includes an asset surjection proof showing its asset commitment matches one of the input asset commitments.
This preserves the invariant that you can't create LBTC out of USDt, or vice versa, even though both are blinded.
Blinding keys
The sender chooses the blinding factor r when constructing the transaction. For the receiver to later spend the output, they need to know r. This is handled through blinding keys:
- Every Liquid address includes the recipient's blinding public key along with the usual scriptPubKey.
- When sending, the sender uses the recipient's blinding public key to encrypt the blinding factor into the transaction.
- The recipient uses their corresponding blinding private key to decrypt and reveal the amount and asset type.
This means confidential addresses are longer than unconfidential ones:
# Unconfidential (testnet):
tex1qkns5nenwgr6zswqv8zyhh4qsz2r0fzlfls7y2w
# Confidential (testnet, same scriptPubKey but includes a blinding pubkey):
tlq1qqf3d5f9h0z7nvpqxl8dk0cnpfc2s3qzq6wyzfczf5nssr9k5jzw7fv0yzxnv56zwqr8vgsddw5qgy2x7j9lfc77g3m0j8ae
The blinding pubkey is appended to the encoded scriptPubKey and included in the address checksum.
SLIP-77 master blinding key
For HD wallets, each address needs its own blinding keypair. Generating and tracking separate blinding keys for every address would be cumbersome.
SLIP 77 solves this with a deterministic scheme:
- Derive a single master blinding key from the wallet's seed.
- For any given scriptPubKey, compute the per address blinding key as
HMAC(master_blinding_key, scriptPubKey).
This means a wallet only needs to back up the seed (which gives it the master blinding key), and all per address blinding keys can be regenerated deterministically.
CT descriptors include the SLIP-77 key so that wallets can derive the blinding keys for every address:
ct(slip77(ab5824f4477b2c8...),elwpkh([3d970d04/87h/1h/0h]tpub.../<0;1>/*))
The slip77(...) wrapper tells the wallet "use this master blinding key to derive per address blinding keys."
ELIP-151: deterministic descriptor blinding keys
SLIP-77 works well for single signature wallets, where the entire wallet is controlled by one BIP32 seed. But it breaks down in two important cases:
- Multisig wallets where there are multiple xpubs (and thus multiple seeds), so it's not clear which seed the master blinding key should come from.
- Multiple BIP44 accounts from the same seed, which would all end up sharing the same master blinding key (undesirable, since accounts should have distinct blinding).
ELIP-151 solves this by defining a way to deterministically derive the descriptor blinding key from the ordinary descriptor itself, rather than from a seed. The result: any participant who shares the descriptor can derive the same blinding key, without needing an out of band protocol to agree on one.
The ct(elip151, ...) syntax
ct(elip151, ...) syntaxELIP-151 introduces a new wrapper value for the blinding key slot in a CT descriptor:
ct(elip151,<DESCRIPTOR>)
This is equivalent to:
ct(<KEY>,<DESCRIPTOR>)
Where <KEY> is a blinding key deterministically computed from <DESCRIPTOR>.
How the key is derived
The derivation algorithm (from ELIP-151) works like this:
- Take the ordinary descriptor (the part inside
ct(...)). - For each single path descriptor (expanding multi-path if needed), compute the
scriptPubKeyat the last non hardened index2^31 - 1. This index is chosen because wallets use addresses starting from index 0, so it'll never appear in a real transaction. - Encode each
scriptPubKeywith anOP_INVALIDOPCODEprefix (consensus encoding), then concatenate all of them. - Compute a BIP-340 tagged hash with the tag
Deterministic-View-Key/1.0over the concatenation. - Reduce the hash modulo the secp256k1 curve order
n. The result (in hex) is the descriptor blinding key.
Using a tagged hash prevents the key from being reinterpreted in a different context. Using the scriptPubKey (rather than the descriptor string directly) makes the derivation invariant to descriptor metadata that doesn't affect the actual spending conditions, like changing an xpub fingerprint.
When to use ELIP-151
ELIP-151 is the right choice for:
- Multi participant wallets, including 2-of-2 multisig like AMP2 venue wallets (see Supporting AMP2 Assets, where
ct(elip151)is the standard). - Wallets with multiple accounts from the same seed where you want each account to have a distinct blinding key.
- Shared watch only setups, where participants can reconstruct the CT descriptor from just the ordinary descriptor.
The trade off: anyone who has the ordinary descriptor can derive the blinding key and unblind all of your outputs. For single-sig use cases where you want view key secrecy, SLIP-77 or a random blinding key may be more appropriate.
Example
Take the ordinary descriptor:
elwpkh(xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8/<0;1>/*)
Using the algorithm above, this yields the blinding key:
b3baf94d60cf8423cd257283575997a2c00664ced3e8de00f8726703142b1989
Which can be written equivalently as:
ct(elip151,elwpkh(xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8/<0;1>/*))
Or explicitly:
ct(b3baf94d60cf8423cd257283575997a2c00664ced3e8de00f8726703142b1989,elwpkh(xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8/<0;1>/*))
Both forms produce the same addresses and are interchangeable.
Restrictions
ELIP-151 requires the descriptor to have wildcards. It's not valid to use ct(elip151, ...) with:
- Descriptors without wildcards (fixed address derivation)
combo(...)at the top level- Multi-path descriptors with mismatched path counts
See the ELIP-151 specification for the full list of test vectors and edge cases.
Unblinding transactions
To see what's in a transaction that belongs to you, you need to unblind its outputs. LWK handles this automatically when you sync a wollet (wollet is an LWK primitive named from Watch-Only Wallet). Conceptually, here's what happens:
- For each output in the transaction, the wallet checks if the scriptPubKey matches one of its own addresses.
- If it does, it derives the blinding private key (via SLIP-77) for that address.
- Using the blinding private key and the encrypted blinding factor embedded in the transaction, it decrypts
v(amount) and the asset type. - It verifies the decrypted values match the on chain commitments and range proofs.
In code, after syncing you just see the unblinded values:
let balances = wollet.balance().unwrap();
for (asset_id, amount) in &balances {
println!("Asset: {} | Amount: {}", asset_id, amount);
}Behind the scenes, LWK has unblinded every output you own.
Sharing visibility (audit and compliance)
CT doesn't mean "nobody can ever see your transactions." You can selectively share visibility:
- Share the master blinding key with an auditor or regulator to give them full read-only access to your wallet.
- Share a per transaction blinding factor to prove what a specific transaction moved (useful for compliance attestations).
- Export a watch-only descriptor that includes the SLIP-77 key, letting another system track your wallet without being able to spend.
This "optional transparency" is a key compliance property: default privacy, but the ability to disclose when needed.
Unconfidential outputs
It's possible to create outputs that are not confidential. These outputs have their amount and asset visible on chain, like a Bitcoin transaction.
Unconfidential outputs are rare in normal use but appear in:
- Peg-out transactions (the peg-out output is unblinded so the Liquid network can verify the burn)
- Fee outputs (always unblinded LBTC)
- Intentionally transparent transactions (e.g., proof-of-reserves)
If someone sends to an unconfidential address, the transaction amount and asset will be visible. For exchange operations, always use confidential addresses generated from a CT descriptor.
Performance
CT adds some overhead:
- Transactions are larger than equivalent Bitcoin transactions (range proofs add bytes).
- Signing and verification are more computationally expensive.
- Storage requirements for full nodes are higher.
In practice this isn't a problem for application level integration. LWK handles all the CT math internally and the operations feel no slower than a Bitcoin wallet.
Summary
| Concept | Role |
|---|---|
| Pedersen commitment | Hides the amount, lets the network check that inputs equal outputs |
| Range proof (Bulletproof) | Prevents negative/inflated amounts |
| Asset surjection proof | Prevents creating one asset from another |
| Blinding key | Per address key that unlocks the amount and asset for the receiver |
| SLIP-77 master blinding key | Deterministic derivation of per address blinding keys from a single seed (single sig) |
| ELIP-151 descriptor blinding key | Deterministic derivation of the blinding key from the descriptor itself (multisig, multi account) |
For deeper reading: