FATF Travel Rule compliance on Liquid using SimplicityHL. Encrypted travel rule data is embedded in the transaction witness, with an on-chain Simplicity program proving the encryption is correct.
When an exchange sends funds on behalf of a user, regulations (the FATF Travel Rule) require transmitting identifying information about the sender and recipient. On Liquid, there's no native way to attach this data to a transaction such that:
- Only authorized parties can read it
- The chain can verify it was encrypted correctly (not garbage)
This project solves both.
| Role | Who | Can decrypt? |
|---|---|---|
| Sender | The exchange processing the withdrawal | Yes |
| Observer | A legal/regulatory entity | Yes |
| Everyone else | Public | No |
1. User requests withdrawal from exchange
2. Exchange prepares a Simplicity program
Parameters baked in:
- SenderPubKey (exchange's secp256k1 key)
- ObserverPubKey (legal entity's secp256k1 key)
3. TX1: Exchange sends from reserve to Simplicity address
(This locks the funds under the Simplicity program)
4. TX2: Exchange spends from Simplicity address to user
The witness for this transaction contains:
- Encrypted travel rule data
- Proof that the encryption is correct
- Spending signature
The travel rule payload is serialized with MessagePack (compact binary) and encrypted with ChaCha20-Poly1305:
{
"originator": {
"full_name": "Alice Smith",
"wallet_address": "lq1qq...",
"physical_address": "123 Main St",
"customer_id": "CUST-001"
},
"beneficiary": {
"full_name": "Bob Jones",
"wallet_address": "lq1qq..."
},
"transaction": {
"amount": "1.5 L-BTC",
"datetime": "2026-04-08T10:00:00Z",
"originating_institution": "ExampleExchange"
}
}The TX2 witness contains all the data the Simplicity program needs to verify, plus the encrypted payload:
Witness contents:
R Ephemeral curve point (r·G, shared nonce)
C_sender ElGamal ciphertext for sender (M + r·P_sender)
C_observer ElGamal ciphertext for observer (M + r·P_observer)
e, s DLEQ proof (challenge and response)
commitment SHA256(encrypted_data)
encrypted_data 512-byte blob: [nonce:12][len:2][ciphertext][zero-pad]
signature BIP340 Schnorr signature authorizing the spend
The encrypted_data blob is self-contained: it packs the ChaCha20 nonce, ciphertext length, and padded ciphertext into a fixed 512 bytes. This means anyone with the right key can decrypt directly from the on-chain witness data.
A hybrid approach: ElGamal on secp256k1 encrypts a symmetric key, ChaCha20-Poly1305 encrypts the actual data.
Encryption (off-chain, by the exchange):
1. Generate random scalar k
M = k·G (message point on the curve)
symmetric_key = SHA256(M) (derive 32-byte key from point)
2. ElGamal encrypt M to both keys using shared nonce r:
R = r·G
C_sender = M + r·P_sender
C_observer = M + r·P_observer
3. DLEQ proof that same r was used for both:
Proves: log_G(R) == log_{P_s - P_o}(C_s - C_o)
This guarantees both ciphertexts contain the same M
4. Encrypt travel rule data with symmetric_key:
ciphertext = ChaCha20-Poly1305(symmetric_key, msgpack(travel_rule_data))
5. Pack into fixed-size blob:
encrypted_data = [nonce:12][ciphertext_len:2][ciphertext][zero-pad to 512 bytes]
6. Commit:
commitment = SHA256(encrypted_data)
Decryption (off-chain, by sender or observer):
1. Recover M from the ElGamal ciphertext:
Sender: M = C_sender - sk_sender · R
Observer: M = C_observer - sk_observer · R
2. Derive symmetric key:
symmetric_key = SHA256(M)
3. Unpack encrypted_data blob:
nonce = encrypted_data[0..12]
ciphertext_len = encrypted_data[12..14] (big-endian u16)
ciphertext = encrypted_data[14..14+ciphertext_len]
4. Decrypt:
travel_rule_data = msgpack_decode(ChaCha20-Poly1305_decrypt(symmetric_key, nonce, ciphertext))
The Simplicity program does NOT decrypt anything. It verifies the witness is well-formed:
| Check | What it proves |
|---|---|
| DLEQ proof | Same nonce r used for both ciphertexts, so both encrypt the same symmetric key. Keys are normalized to even y (BIP340 convention) for consistency with the on-chain verifier. |
| Commitment | SHA256(encrypted_data) == commitment — the encrypted data blob matches the committed hash |
| Key binding | The public keys in the DLEQ match SenderPubKey and ObserverPubKey baked into the program |
| Signature | A valid BIP340 signature from the sender authorizes the spend |
This means: if TX2 is valid on-chain, the encrypted travel rule data is guaranteed to be decryptable by both the sender and the observer. The Simplicity program makes it impossible to submit garbage encryption.
simplicity-travel-rule generate-keyssimplicity-travel-rule prepare-withdrawal \
--sender-pubkey <HEX> \
--observer-pubkey <HEX> \
--network testnetsimplicity-travel-rule complete-withdrawal \
--sender-pubkey <HEX> \
--observer-pubkey <HEX> \
--travel-rule-data travel_rule.jsonsimplicity-travel-rule spend \
--sender-secret-key <HEX> \
--observer-pubkey <HEX> \
--encrypted-package encrypted.json \
--faucet-txid <TXID> \
--destination <ADDRESS> \
--network testnetsimplicity-travel-rule decrypt \
--secret-key <HEX> \
--txid <SPENDING_TXID> \
--role observersimplicity-travel-rule decrypt \
--secret-key <HEX> \
--encrypted-package encrypted.json \
--role sendersimplicity-travel-rule decrypt \
--secret-key <HEX> \
--witness witness.json \
--role observerThe witness JSON contains 4 fields: ephemeral, c_sender, c_observer, encrypted_data (all hex).
Run the end-to-end demo on Liquid testnet:
.\demo.ps1This walks through all 9 steps: key generation, program compilation, faucet funding, encryption, on-chain spend, and decryption by both parties. All artifacts are saved to temp/.
cargo build
cargo test # 17 tests covering encrypt/decrypt roundtrips, DLEQ proofs,
# witness decoding, tamper detection, and full spend flow- secp256k1 — Elliptic curve operations
- chacha20poly1305 — Symmetric encryption (AEAD)
- rmp-serde — MessagePack serialization (compact binary encoding)
- lwk_simplicity — SimplicityHL compiler and runtime
- lwk_common — Liquid network parameters
- ureq — HTTP client (Blockstream API for UTXO lookup and broadcast)
- clap — CLI argument parsing