Skip to content

stringhandler/simplicity-travel-rule

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Simplicity Travel Rule

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.

The Problem

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.

How It Works

Roles

Role Who Can decrypt?
Sender The exchange processing the withdrawal Yes
Observer A legal/regulatory entity Yes
Everyone else Public No

Withdrawal Flow

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

What Gets Encrypted

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"
  }
}

What Goes in the Witness

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.

Encryption Scheme

A hybrid approach: ElGamal on secp256k1 encrypts a symmetric key, ChaCha20-Poly1305 encrypts the actual data.

Step by step

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))

What the Simplicity Program Verifies On-Chain

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.

CLI Usage

Generate keys

simplicity-travel-rule generate-keys

Prepare a withdrawal (compile program, derive address)

simplicity-travel-rule prepare-withdrawal \
  --sender-pubkey <HEX> \
  --observer-pubkey <HEX> \
  --network testnet

Encrypt travel rule data

simplicity-travel-rule complete-withdrawal \
  --sender-pubkey <HEX> \
  --observer-pubkey <HEX> \
  --travel-rule-data travel_rule.json

Build and broadcast the spending transaction

simplicity-travel-rule spend \
  --sender-secret-key <HEX> \
  --observer-pubkey <HEX> \
  --encrypted-package encrypted.json \
  --faucet-txid <TXID> \
  --destination <ADDRESS> \
  --network testnet

Decrypt from a transaction ID

simplicity-travel-rule decrypt \
  --secret-key <HEX> \
  --txid <SPENDING_TXID> \
  --role observer

Decrypt from an encrypted package file

simplicity-travel-rule decrypt \
  --secret-key <HEX> \
  --encrypted-package encrypted.json \
  --role sender

Decrypt from a witness JSON file

simplicity-travel-rule decrypt \
  --secret-key <HEX> \
  --witness witness.json \
  --role observer

The witness JSON contains 4 fields: ephemeral, c_sender, c_observer, encrypted_data (all hex).

Demo

Run the end-to-end demo on Liquid testnet:

.\demo.ps1

This 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/.

Building

cargo build
cargo test    # 17 tests covering encrypt/decrypt roundtrips, DLEQ proofs,
              # witness decoding, tamper detection, and full spend flow

Dependencies

  • 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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors