Confidential Lending Pool
Problem
I want to run a lending market where the positions of individual borrowers are private. Competitors and MEV searchers should not be able to target a borrower for liquidation by reading their collateral ratio from storage. Only the borrower (and the protocol itself, homomorphically) should know the exact numbers.
Solution
sealed contract ConfidentialLending {
field collateral_token: address
field debt_token: address
field oracle: address
field liq_threshold: u256 = 8000 // 80.00% in basis points
field collateral: map<address, encrypted<amount>>
field debt: map<address, encrypted<amount>>
event Borrowed(user: address indexed)
event Repaid(user: address indexed)
event Liquidated(user: address indexed, liquidator: address indexed)
error InsufficientCollateral()
error HealthyPosition()
error InvalidProof()
action deposit_collateral(encrypted_amount: encrypted<amount>) {
collateral[caller] = fhe_add(collateral[caller], encrypted_amount)
// ERC-20 pull happens via a matching plaintext receipt attached off-chain
}
action borrow(encrypted_amount: encrypted<amount>) {
let new_debt = fhe_add(debt[caller], encrypted_amount)
let price = oracle.price(collateral_token)
let max_debt = fhe_mul(collateral[caller], price * liq_threshold / 10000)
// fhe_cmp returns encrypted<bool>; reveal only the single bit
let ok = fhe_cmp(new_debt, max_debt, op::LTE)
given reveal_bit(ok) or revert_with InsufficientCollateral()
debt[caller] = new_debt
emit Borrowed(caller)
}
action repay(encrypted_amount: encrypted<amount>) {
debt[caller] = fhe_sub(debt[caller], encrypted_amount)
emit Repaid(caller)
}
// Liquidators submit a ZK proof that `user` is under-collateralized.
// The circuit takes the ciphertexts as public inputs and the network
// key share as a private witness, and outputs a single bit.
action liquidate(user: address, proof: bytes) {
let public_inputs = [
hash(collateral[user]),
hash(debt[user]),
oracle.price(collateral_token),
liq_threshold,
]
given zk_verify(under_collateralized_circuit, proof, public_inputs)
or revert_with InvalidProof()
let seized = collateral[user]
collateral[user] = encrypted_zero()
debt[user] = encrypted_zero()
transfer_encrypted(collateral_token, caller, seized)
emit Liquidated(user, caller)
}
}
Explanation
- Balances live as BFV ciphertexts.
fhe_addandfhe_subare ~200k gas on L1 but only ~15k pGas on Aster Chain. reveal_bit(ok)uses threshold decryption to expose only the comparison outcome, never the underlying amounts.- Liquidation cannot read the ciphertexts directly, so we require a ZK proof that the under-collateralized predicate holds. The circuit is
under_collateralized_circuit— a Groth16 or Plonk circuit whose public inputs include the ciphertext hashes, price, and threshold. - The liquidator runs the circuit off-chain after asking validators for a decryption share of the inequality bit via the
oracle.fhe_queryside-channel (ERC-8231).
Gas Estimate
| Operation | Gas (L1) | pGas (Aster) |
|---|---|---|
deposit_collateral | ~210,000 | ~18,000 |
borrow | ~950,000 | ~75,000 |
repay | ~240,000 | ~21,000 |
liquidate (Groth16) | ~310,000 | ~28,000 |
Common Pitfalls
- Revealing too much:
reveal_bitis fine;reveal(new_debt)would leak the debt. Audit everyreveal*call. - Stale oracle price inside ciphertext:
fhe_mulwith a plaintext price freezes that price into the ciphertext for the duration of that comparison. Refresh on every action. - Liquidation race: Two liquidators can submit proofs in the same block. Use
nonceper user to make exactly one proof valid per block. - Ciphertext bloat: After ~1000
fhe_addcalls, BFV noise overflows. Callbootstrap(collateral[user])periodically or bundle the whole position in anamnesia { cleanup_phase: 1_000_000 }block. - Front-running via ciphertext size: The encrypted amount has a fixed 4KB on-chain footprint, but the transaction size itself leaks whether this is a borrow vs repay. Use a unified
update_positionentrypoint in hostile environments.
Variations
- Interest accrual: Multiply
debt[user]byfhe_mul(debt, rate_factor)each block. Cheaper: accrue on the plaintextrate_indexand let the user re-encrypt at repay time. - Multi-collateral: Use
map<(address, address), encrypted<amount>>keyed by(user, token)and sum the USD-valued collateral homomorphically. - Fixed-rate tranches: Model the tranches as separate contracts and use
amnesiato garbage-collect matured loans.