TEE-Coordinated Private Atomic Swap

Pen & paper protocol walkthrough — click each phase to step through

Party A (USD seller)
Party B (BOND seller)
TEE Coordinator
On-chain
Verification check
Protocol Flow (High Level)
0
Intent Matching (off-chain)
A & B agree on swap terms → both independently compute swapId
1a
Party A: Lock USD on Network 1
Create time-locked note → stealth addr for B, fallback to A. Submit deposit proof on-chain. Send (swapId, R_A, enc_salt_A, pk_meta_B, noteDetails_A) to TEE.
1b
Party B: Lock BOND on Network 2
Create time-locked note → stealth addr for A, fallback to B. Submit deposit proof on-chain. Send (swapId, R_B, enc_salt_B, pk_meta_A, noteDetails_B) to TEE.
2
TEE: Verify both locks
Open binding commitments h_swap, h_R, h_meta, h_enc. Recompute swapId from noteDetails. Verify terms match. No EC operations — only hashes.
3
TEE: Atomic reveal
announceSwap(swapId, R_A, R_B, enc_salt_A, enc_salt_B) — single tx, both or neither.
4a
Party A: Claim BOND on Network 2
Derive sk_stealth_A from R_B, decrypt salt_B, reconstruct note, prove & spend.
4b
Party B: Claim USD on Network 1
Derive sk_stealth_B from R_A, decrypt salt_A, reconstruct note, prove & spend.
Refund path (timeout)
If TEE never reveals → after timeout, each party refunds their own note using fallbackOwner key. Same nullifier either way → no double spend.
Key insight: What the TEE knows vs. what it can do
TEE sees: swap amounts, asset types, party identities (privacy loss if HW compromised).
TEE cannot: derive stealth keys (needs sk_meta), forge proofs, steal funds.
Worst case = censorship, not theft.
Phase 0: Setup & Intent Matching

Party A (has USD, wants BOND)

A's key material
sk_meta_Asecret — never shared
pk_meta_Apublic — shared with B & TEE

Party B (has BOND, wants USD)

B's key material
sk_meta_Bsecret — never shared
pk_meta_Bpublic — shared with A & TEE

Swap ID derivation (both compute independently)

swapId = H("tee_swap.swap_id",
  valueA, assetIdA, chainIdA,
  valueB, assetIdB, chainIdB,
  timeout, pk_meta_A, pk_meta_B, nonce
)
Whiteboard check
Both parties MUST arrive at the same swapId. If any term differs → different hash → TEE rejects in Phase 2.
Phase 1: Lock Notes to Stealth Addresses
Core idea
Each party locks funds in a note that only the counterparty can claim (via stealth address), with a timeout fallback to themselves. The deposit proof includes binding commitments so the TEE can verify without doing any EC math.

Party A locks USD on Network 1

1
Generate ephemerals
r_A (secret), R_A = r_A·G, random salt_A
2
ECDH + stealth
ss = r_A · pk_meta_B
pk_stealth_B = pk_meta_B + H("stealth", ss)·G
enc_salt = salt_A ⊕ H("salt_enc", ss)
3
Build Note
owner = pk_stealth_B
fallback = pk_A
timeout = now + 48h
4
Deposit proof → Network 1
Public: commitment, chainId, timeout, pk_stealth_B
+ 4 bindings: h_swap, h_R, h_meta, h_enc
5
Send to TEE
(swapId, nonce, R_A, enc_salt_A, pk_meta_B, noteDetails_A)

Party B locks BOND on Network 2

1
Generate ephemerals
r_B (secret), R_B = r_B·G, random salt_B
2
ECDH + stealth
ss = r_B · pk_meta_A
pk_stealth_A = pk_meta_A + H("stealth", ss)·G
enc_salt = salt_B ⊕ H("salt_enc", ss)
3
Build Note
owner = pk_stealth_A
fallback = pk_B
timeout = now + 48h
4
Deposit proof → Network 2
Public: commitment, chainId, timeout, pk_stealth_A
+ 4 bindings: h_swap, h_R, h_meta, h_enc
5
Send to TEE
(swapId, nonce, R_B, enc_salt_B, pk_meta_A, noteDetails_B)

Deposit circuit (6 constraints)

C1: commitment == H("commitment", chainId, value, assetId, pk_stealth, fallback, timeout, salt)
C2: pk_stealth == pk_meta_cpty + H("stealth", r · pk_meta_cpty)·G
C3: h_swap == H("bind_swap", swapId, salt)
C4: h_R == H("bind_R", r·G)
C5: h_meta == H("bind_meta", pk_meta_cpty, salt)
C6: h_enc == H("bind_enc", salt ⊕ H("salt_enc", r · pk_meta_cpty))
Why R must stay secret until Phase 3
R is the stealth "unlock". If R were public at deposit time, the counterparty could claim immediately — before locking their own note. Only H("bind_R", R) is on-chain. R is revealed atomically in Phase 3.
Phase 2: TEE Verification
TEE's job
The ZK verifier already proved circuit correctness on-chain. The TEE just opens binding commitments (hash comparisons) and checks that off-chain data matches on-chain public inputs.

TEE receives (off-chain, attested channel)

From AswapId, nonce, R_A, enc_salt_A, pk_meta_B, noteDetails_A
From BswapId, nonce, R_B, enc_salt_B, pk_meta_A, noteDetails_B

TEE reads (on-chain public inputs)

Net 1commitment_A, chainId_A, pk_stealth_B, h_swap_A, h_R_A, h_meta_A, h_enc_A
Net 2commitment_B, chainId_B, pk_stealth_A, h_swap_B, h_R_B, h_meta_B, h_enc_B
7 Verification Steps
Security argument for binding commitments
On-chain ZK verifier proved: stealth addr derivation (C2), swap binding (C3), R binding (C4), meta binding (C5), enc_salt binding (C6).
TEE just opens these bindings with hash checks. A malicious party can't produce a valid proof for different values than what the hashes commit to. Circuit enforces consistency internally.
RPC trust assumption
TEE reads on-chain state via RPC (Infura/Alchemy). Compromised RPC → false state → TEE approves against non-existent commitment. Mitigation: run Helios light client inside TEE.
Phase 3: Atomic Revelation
1
TEE calls announceSwap()
Single atomic transaction:
announceSwap(swapId, R_A, R_B, enc_salt_A, enc_salt_B)

What gets revealed on-chain

R_A — ephemeral public key (random curve point, no semantic meaning)
R_B — ephemeral public key (random curve point)
enc_salt_A — encrypted salt (random-looking 32 bytes)
enc_salt_B — encrypted salt (random-looking 32 bytes)
No amounts, asset types, or party identities revealed.

Party A receives

R_B→ derive sk_stealth_A
enc_salt_B→ decrypt salt_B → compute nullifier

Party B receives

R_A→ derive sk_stealth_B
enc_salt_A→ decrypt salt_A → compute nullifier
Atomicity source
Both keys + encrypted salts in single operation, or neither. This is TEE atomicity, not blockchain consensus. Works identically for same-chain and cross-chain.
Anti-replay
require(!announcements[swapId].revealed) — prevents duplicate announcements even if TEE state is rolled back.
TEE key management (EIP-4337)
Smart Accountstable address, decoupled from TEE key type
Signatureenclave key (P-256, RSA, etc.) — not limited to secp256k1
Gaspaymaster sponsors UserOps — TEE never holds ETH
RotationrotateSigner() via old key — address unchanged
Phase 4: Claim or Refund

Party B claims USD (Network 1)

1
Read announcement
Get R_A and enc_salt_A
2
ECDH shared secret
ss = sk_meta_B · R_A
3
Decrypt salt
salt_A = enc_salt_A ⊕ H("salt_enc", ss)
4
Derive stealth key
sk_stealth_B = sk_meta_B + H("stealth", ss)
5
Reconstruct note
All fields known from swap terms + decrypted salt
6
Prove & spend
ZK proof: commitment preimage, Merkle inclusion, nullifier, ownership (sk_stealth_B).
Nullifier added to NullifierSet. B receives USD.

Party A claims BOND (Network 2)

1
Read announcement
Get R_B and enc_salt_B
2
ECDH shared secret
ss = sk_meta_A · R_B
3
Decrypt salt
salt_B = enc_salt_B ⊕ H("salt_enc", ss)
4
Derive stealth key
sk_stealth_A = sk_meta_A + H("stealth", ss)
5
Reconstruct note
All fields known from swap terms + decrypted salt
6
Prove & spend
ZK proof: commitment preimage, Merkle inclusion, nullifier, ownership (sk_stealth_A).
Nullifier added to NullifierSet. A receives BOND.
Refund Path (TEE failed to reveal)
!
Wait for timeout
block.timestamp > timeout
R
Party A refunds USD (Network 1)
A already knows salt_A (they created the note).
Prove: commitment preimage + Merkle inclusion + nullifier + sk_A·G == fallbackOwner
timeout is public output → verifier checks block.timestamp > timeout
R
Party B refunds BOND (Network 2)
Same process, mirrored. B knows salt_B.
Double-spend protection
If B already claimed → nullifier is in set → A's refund attempt rejected. Same nullifier regardless of spending path: H("nullifier", commitment, salt). This is the core safety invariant.
Privacy hygiene
Stagger claims: random delay (minutes to hours) after announcement to avoid timing correlation.
Re-pot: after claiming swap note (timeout > 0), spend into fresh note with timeout = 0 to rejoin the general anonymity set.
Protocol Invariants & Threat Summary
Cryptographic guarantees
  • No fund theft by TEE
    TEE never receives spending keys (sk_meta, r kept by parties)
  • Timeout refund always available
    fallbackOwner path activates after timeout, uses same nullifier
  • No double spend
    Canonical nullifier = H("nullifier", commitment, salt) — same for claim & refund path
  • Stealth address protection
    Only recipient with sk_meta can derive sk_stealth from R
  • Atomic swap
    TEE reveals both R + enc_salt in single op, or neither
  • Binding commitment integrity
    Circuit enforces h_swap, h_R, h_meta, h_enc internally — TEE just opens them
Trust assumptions
  • T2: HW manufacturer
    Can see plaintext (privacy loss), cannot steal. Accepted by institutions (same HSM vendors)
  • T5: TEE censorship
    Can refuse to reveal → DoS. Timeout refund is the escape hatch
  • RPC trust for on-chain reads. Mitigated by running Helios light client in TEE
  • T7: Timing correlation
    Deposit timing + matching timeouts can link swap legs. Amounts/identities still hidden. Re-potting dissolves post-settlement
Data Flow Summary — Who Knows What
Data Party A Party B TEE On-chain
sk_meta_A
r_A (ephemeral secret)
R_A (ephemeral public) after P3 H(R_A) at P1, R_A at P3
salt_A after P3 (decrypt) ✓ (plaintext) ✗ (encrypted)
swap amounts/assets
pk_stealth
link(pk_stealth → identity) ✗ (needs R)
Hash Domain Tags (cheat sheet)
tee_swap.commitment — note commitment
tee_swap.nullifier — nullifier (canonical, path-independent)
tee_swap.stealth — stealth address key derivation
tee_swap.salt_enc — salt encryption key (ECDH → XOR)
tee_swap.bind_swap — bind deposit to specific swapId
tee_swap.bind_R — bind to ephemeral public key
tee_swap.bind_meta — bind to intended counterparty
tee_swap.bind_enc — bind to encrypted salt
tee_swap.swap_id — deterministic swap identifier