Lesson 2: Advanced Patterns — Approvals, Double Protection & Testing
Duration: ~60 minutes | Prerequisites: Lesson 1: Confidential Token | Contract: src/ConfidentialERC20.sol
Learning Objectives
By the end of this lesson, you will:
- Implement encrypted
approvewith dual-party permissions (owner and spender) - Understand
transferFromwith double protection — two layered silent-zero checks on allowance and balance - Work with nested encrypted mappings:
mapping(address => mapping(address => euint64)) - Reason about gas costs for FHE operations and how to minimize them
- Walk through the full 8-test suite and understand what each test verifies
- Run the tests locally and interpret the output
1. Encrypted Approvals
In a standard ERC20, approve(spender, amount) sets a public allowance. Anyone can call allowance(owner, spender) and see the exact amount.
In our confidential token, the allowance is encrypted. But there's a key design question: who should be able to see the allowance?
The approve Function
function approve(address spender, externalEuint64 encAmount, bytes calldata inputProof) external {
euint64 amount = FHE.fromExternal(encAmount, inputProof);
_allowances[msg.sender][spender] = amount;
FHE.allowThis(_allowances[msg.sender][spender]);
FHE.allow(_allowances[msg.sender][spender], msg.sender); // Owner can see allowance
FHE.allow(_allowances[msg.sender][spender], spender); // Spender can see allowance
}Dual-Party Permissions
Notice the three permission calls:
| Call | Purpose |
|---|---|
FHE.allowThis(...) | Contract can use this allowance in future transferFrom calls |
FHE.allow(..., msg.sender) | The owner can decrypt to see how much they've approved |
FHE.allow(..., spender) | The spender can decrypt to know their spending limit |
This matches standard ERC20 semantics where allowance(owner, spender) is a public view function. In the confidential version, both parties can see it — but nobody else can.
Why Two allow Calls?
In Week 2's vault, each balance had one owner. In the allowance case, the same value is meaningful to two parties:
┌──────────────────────────────────────────────────────────────┐
│ _allowances[alice][bob] = Handle_X │
│ │
│ FHE.allowThis(Handle_X) │
│ → Contract uses X in transferFrom │
│ │
│ FHE.allow(Handle_X, alice) [OWNER] │
│ → Alice can see: "I approved Bob for 500 tokens" │
│ │
│ FHE.allow(Handle_X, bob) [SPENDER] │
│ → Bob can see: "Alice approved me for 500 tokens" │
│ │
│ Charlie cannot decrypt Handle_X │
└──────────────────────────────────────────────────────────────┘2. Nested Encrypted Mappings
The allowance storage uses a nested mapping:
mapping(address => mapping(address => euint64)) private _allowances;This maps owner → spender → encrypted allowance. Each unique (owner, spender) pair has its own independent encrypted value.
Storage Layout
_allowances[alice][bob] = euint64 (Alice approved Bob)
_allowances[alice][charlie] = euint64 (Alice approved Charlie)
_allowances[bob][alice] = euint64 (Bob approved Alice)Each of these is a separate encrypted handle with its own permissions. Approving Bob doesn't affect Charlie's allowance, and Alice's approval of Bob is independent of Bob's approval of Alice.
Solidity Mechanics
Nested mappings with encrypted types work exactly like regular nested mappings — the outer key selects the inner mapping, the inner key selects the value. The only difference is that the stored value is an encrypted handle (bytes32) rather than a plaintext number.
3. transferFrom with Double Protection
Here's the most sophisticated function in the contract. It combines two silent-zero checks in sequence:
function transferFrom(address from, address to, externalEuint64 encAmount, bytes calldata inputProof) external {
euint64 amount = FHE.fromExternal(encAmount, inputProof);
// Check 1: Does spender have enough allowance?
ebool hasAllowance = FHE.le(amount, _allowances[from][msg.sender]);
euint64 actualAmount = FHE.select(hasAllowance, amount, FHE.asEuint64(0));
// Deduct from allowance
_allowances[from][msg.sender] = FHE.sub(_allowances[from][msg.sender], actualAmount);
// Check 2: _transfer internally checks if sender has enough balance
_transfer(from, to, actualAmount);
}The Double Protection Pattern
There are two silent-zero checks that fire in sequence:
┌──────────────────────────────────────────────────────────────┐
│ transferFrom Double Protection │
│ │
│ Check 1: Allowance Guard (in transferFrom) │
│ amount <= allowance? │
│ YES → actualAmount = amount │
│ NO → actualAmount = 0 (silent zero) │
│ │
│ Check 2: Balance Guard (inside _transfer) │
│ actualAmount <= balance? │
│ YES → transfer actualAmount │
│ NO → transfer 0 (silent zero) │
│ │
│ Both checks are ENCRYPTED │
│ Neither the spender nor any observer knows │
│ which check (if any) blocked the transfer │
└──────────────────────────────────────────────────────────────┘Trace: Successful transferFrom
Alice approved Bob for 500. Alice has 1000 tokens. Bob calls transferFrom(alice, charlie, 300):
| Step | Operation | Result |
|---|---|---|
| 1 | FHE.le(300, 500) | ebool(true) — allowance sufficient |
| 2 | FHE.select(true, 300, 0) | euint64(300) — use full amount |
| 3 | FHE.sub(500, 300) | Allowance updated: 200 remaining |
| 4 | _transfer(alice, charlie, 300) | |
| 4a | FHE.le(300, 1000) | ebool(true) — balance sufficient |
| 4b | FHE.select(true, 300, 0) | euint64(300) — transfer proceeds |
| 4c | Alice: 1000 - 300 = 700 | |
| 4d | Charlie: 0 + 300 = 300 |
Trace: Insufficient Allowance
Alice approved Bob for 100. Alice has 1000 tokens. Bob calls transferFrom(alice, charlie, 500):
| Step | Operation | Result |
|---|---|---|
| 1 | FHE.le(500, 100) | ebool(false) — allowance insufficient |
| 2 | FHE.select(false, 500, 0) | euint64(0) — zeroed out |
| 3 | FHE.sub(100, 0) | Allowance unchanged: 100 |
| 4 | _transfer(alice, charlie, 0) | |
| 4a | FHE.le(0, 1000) | ebool(true) — 0 ≤ anything |
| 4b | FHE.select(true, 0, 0) | euint64(0) — nothing to transfer |
| 4c | Alice: 1000 - 0 = 1000 | Unchanged |
| 4d | Charlie: 0 + 0 = 0 | Unchanged |
Trace: Sufficient Allowance, Insufficient Balance
Alice approved Bob for 5000. Alice has 100 tokens. Bob calls transferFrom(alice, charlie, 3000):
| Step | Operation | Result |
|---|---|---|
| 1 | FHE.le(3000, 5000) | ebool(true) — allowance sufficient |
| 2 | FHE.select(true, 3000, 0) | euint64(3000) — passes allowance check |
| 3 | FHE.sub(5000, 3000) | Allowance updated: 2000 |
| 4 | _transfer(alice, charlie, 3000) | |
| 4a | FHE.le(3000, 100) | ebool(false) — balance insufficient |
| 4b | FHE.select(false, 3000, 0) | euint64(0) — zeroed out by balance check |
| 4c | Alice: 100 - 0 = 100 | Unchanged |
| 4d | Charlie: 0 + 0 = 0 | Unchanged |
Important: In this scenario the allowance was deducted (5000 → 2000) even though the transfer didn't happen. This is a design tradeoff — reverting based on the balance check would leak information about Alice's balance. The allowance deduction is a privacy cost.
Why Not Just One Check?
You might wonder: why not check both conditions in a single select? Because the two checks serve different purposes and operate on different data:
- Allowance check — protects the owner from unauthorized spending
- Balance check — protects against underflow in the owner's balance
Both must be encrypted and both must silent-zero to avoid leaking whether it was the allowance or the balance that blocked the transfer.
Allowance Permission Update
After deducting the allowance, transferFrom needs to update permissions. In the current implementation, the allowance permission dance happens implicitly because the allowance mapping is updated and the _transfer function handles balance permissions. A production implementation would also re-grant allowance permissions:
// After allowance deduction
FHE.allowThis(_allowances[from][msg.sender]);
FHE.allow(_allowances[from][msg.sender], from); // Owner can still see
FHE.allow(_allowances[from][msg.sender], msg.sender); // Spender can still see4. Gas Considerations for FHE Operations
FHE operations are computationally expensive. Understanding gas costs helps you design efficient contracts:
| Operation Type | Approximate Gas Cost | Notes |
|---|---|---|
| Plaintext ERC20 transfer | ~50k gas | Standard Solidity |
Confidential _transfer | ~300-500k gas | 4-6 FHE operations |
Confidential transferFrom | ~500-800k gas | 7-10 FHE operations (double protection) |
Single FHE.add / FHE.sub | ~50-80k gas | Basic arithmetic |
FHE.le (comparison) | ~80-100k gas | Returns ebool |
FHE.select | ~80-100k gas | Encrypted ternary |
FHE.asEuint64 | ~30-50k gas | Trivial encryption |
FHE.allowThis / FHE.allow | ~20-30k gas | ACL updates |
Optimization Tips
- Minimize FHE operations — Each encrypted operation adds significant gas. Batch where possible.
- Keep public what can be public —
totalSupplyis public because minting is a transparent event. Don't encrypt values that don't need privacy. - Use
FHE.asEuint64(0)instead of storing zero — Trivial encryption of 0 is cheaper than maintaining a stored encrypted zero. - Consider batch operations — If you need to perform multiple transfers, a batch function can amortize some overhead.
5. The Test Suite Walkthrough
The ConfidentialERC20 contract comes with a comprehensive 8-test suite. Let's walk through the key tests.
Running the Tests
npm run test:week3Test 1: Mint and Check Balance
it("updates a holder balance on mint", async function () {
await token.mint(signers.alice.address, 1_000_000);
expect(await decrypt64(await token.connect(signers.alice).balanceOf(), tokenAddress, signers.alice)).to.equal(1_000_000n);
});What it verifies: Minting creates encrypted balances correctly. After minting 1,000,000 tokens to Alice, she can decrypt her balance and see the correct amount. This validates:
FHE.asEuint64()(trivial encryption)FHE.add()on encrypted balances- Correct
FHE.allowgrants (Alice can decrypt)
Test 2: Transfer with Sufficient Balance
it("moves balances on transfer", async function () {
await token.mint(signers.alice.address, 1_000_000);
const transfer = await encrypt64(tokenAddress, signers.alice, 400_000);
await token.connect(signers.alice).transfer(signers.bob.address, transfer.handle, transfer.inputProof);
expect(await decrypt64(await token.connect(signers.alice).balanceOf(), tokenAddress, signers.alice)).to.equal(600_000n);
expect(await decrypt64(await token.connect(signers.bob).balanceOf(), tokenAddress, signers.bob)).to.equal(400_000n);
});What it verifies: A normal transfer works correctly. Alice sends 400,000 to Bob, ending up with 600,000. Both parties' balances are correctly updated and decryptable. This validates:
- The full
_transferflow FHE.subon sender,FHE.addon receiver- Permission dance on both parties
Test 3: Transfer with Insufficient Balance (Silent Fail)
it("silently zeroes transfers above the sender balance", async function () {
await token.mint(signers.alice.address, 100);
const transfer = await encrypt64(tokenAddress, signers.alice, 999);
await token.connect(signers.alice).transfer(signers.bob.address, transfer.handle, transfer.inputProof);
expect(await decrypt64(await token.connect(signers.alice).balanceOf(), tokenAddress, signers.alice)).to.equal(100n);
expect(await decrypt64(await token.connect(signers.bob).balanceOf(), tokenAddress, signers.bob)).to.equal(0n);
});What it verifies: The silent-zero pattern works — the transaction does not revert, Alice's balance is unchanged, and Bob receives nothing. This is the most important privacy property: an observer cannot tell whether the transfer had sufficient funds.
Test 4: Approve and TransferFrom
This test validates the full approval + delegated transfer flow:
it("supports approve and transferFrom", async function () {
await token.mint(signers.alice.address, 1_000_000);
const approval = await encrypt64(tokenAddress, signers.alice, 500_000);
await token.connect(signers.alice).approve(signers.bob.address, approval.handle, approval.inputProof);
const transfer = await encrypt64(tokenAddress, signers.bob, 300_000);
await token
.connect(signers.bob)
.transferFrom(signers.alice.address, signers.charlie.address, transfer.handle, transfer.inputProof);
expect(await decrypt64(await token.connect(signers.alice).balanceOf(), tokenAddress, signers.alice)).to.equal(700_000n);
expect(await decrypt64(await token.connect(signers.charlie).balanceOf(), tokenAddress, signers.charlie)).to.equal(300_000n);
});What it verifies: The complete delegated transfer cycle:
- Alice approves Bob (encrypted allowance)
- Bob calls
transferFrom(double protection: allowance + balance) - Alice's balance decreases, Charlie receives tokens
- Both the allowance check and balance check passed
The Full 8-Test Suite
| # | Test | What It Verifies |
|---|---|---|
| 1 | test_mintUpdatesBalance | Minting creates correct encrypted balances |
| 2 | test_mintUpdatesTotalSupply | Public totalSupply tracks minted amounts |
| 3 | test_transferMovesBalance | Normal transfer updates both sender and receiver |
| 4 | test_transferInsufficientBalanceSilentFails | Silent-zero on insufficient balance (no revert) |
| 5 | test_approveAndTransferFrom | Full approve → transferFrom cycle works |
| 6 | test_transferFromInsufficientAllowance | Silent-zero when allowance is too low |
| 7 | test_transferFromInsufficientBalance | Silent-zero when balance is too low (even if allowance is high) |
| 8 | test_multipleTransfersAccumulate | Sequential transfers correctly accumulate |
The Test Pattern (Recap)
Every test follows the same rhythm established in Weeks 1 and 2:
1. Setup -> token.mint(user, amount)
2. Encrypt -> encrypt64(contractAddress, signer, value)
3. Call -> token.connect(signer).function(args)
4. Decrypt -> decrypt64(token.balanceOf(), contractAddress, signer)
5. Assert -> expect(decrypted).to.equal(expected)The signer used for connect(...) controls msg.sender, which is how the contract knows which balance or allowance path applies.
6. Running the Full Suite
Run the complete test suite:
npm run test:week3Expected output: 8 tests pass. If any test fails, check:
- Permission errors — Did you forget
allowThisorallowsomewhere? - Wrong balance — Is the silent-zero logic selecting the right branch?
- Revert on transfer — Are you accidentally reverting instead of silent-zeroing?
What -vvv Shows You
The verbose flag shows you the FHE operations being executed:
- Each
FHE.add,FHE.sub,FHE.le,FHE.selectcall and its arguments - The
FHE.allowThisandFHE.allowACL grants - Gas used per test (helpful for understanding FHE costs)
Key Concepts Introduced
| Concept | What It Does |
|---|---|
| Encrypted approve | Set allowances readable by both owner and spender — dual FHE.allow |
| Double protection | Two layered silent-zero checks (allowance + balance) in transferFrom |
mapping(a => mapping(b => euint64)) | Nested encrypted mappings for per-owner-per-spender allowances |
| Gas: ~300-500k per transfer | FHE operations cost 5-10x more than plaintext — design accordingly |
| Allowance deduction tradeoff | Allowance may be consumed even if balance check fails — privacy cost |
Key Takeaways
- Encrypted approvals need dual-party permissions — both the owner and spender must be able to decrypt the allowance, requiring two
FHE.allowcalls transferFromuses double protection — two sequential silent-zero checks (allowance, then balance) ensure neither check leaks information about which one failed- Nested encrypted mappings (
mapping(a => mapping(b => euint64))) work just like regular nested mappings — but each stored value is an encrypted handle - FHE gas costs are 5-10x higher than plaintext — plan your contract design to minimize encrypted operations
- The allowance deduction tradeoff means allowance may be consumed even when balance is insufficient — this is intentional for privacy
- The 8-test suite covers: minting, transfers, silent-zero on insufficient balance, approvals, transferFrom with double protection, and accumulation across multiple operations
Exercise: Encrypted Total Supply
Before moving to the homework, try this extension. Modify ConfidentialERC20 to track totalSupply as an encrypted value:
euint64 private _encryptedTotalSupply;
function mint(address to, uint64 amount) external onlyOwner {
euint64 encAmount = FHE.asEuint64(amount);
_balances[to] = FHE.add(_balances[to], encAmount);
_encryptedTotalSupply = FHE.add(_encryptedTotalSupply, encAmount);
// Allow anyone to see total supply (or restrict to owner)
FHE.allowThis(_encryptedTotalSupply);
FHE.allow(_encryptedTotalSupply, owner);
// ... rest of permissions
}Think about:
- Should
_encryptedTotalSupplybe visible to everyone or just the owner? - How does encrypting the total supply change the gas cost of
mint? - What privacy does this add? (Hint: observers can no longer see how many tokens exist)
Next: Homework: Extended ConfidentialERC20 — Extend the token with encrypted total supply, burn functionality, and more advanced features.