Encrypted, Not Anonymous: A Hands-On Introduction to Confidential Solidity

Devcon 8 · 5 Nov 2026 · 48:38

Aryeh Greenberg, co-author of the ERC-7984 confidential token standard and a core maintainer of OpenZeppelin Contracts, introduces confidential Solidity built on fully homomorphic encryption with Zama. The workshop shows how encrypted values are handles, why contracts use select instead of require so a failed transfer moves zero, and how the access control list decides who can use or decrypt a value. Participants write the deposit function of a confidential vault with Foundry, then attack it, in exercises based on real advisories such as trusting handles from other contracts and unchecked arithmetic.

Speakers

Topics

From Devcon 8, hosted by Ethereum Foundation.

Transcript

[music] everyone. Um, today we're going to be doing a workshop, hopefully a little interactive. If you plan to participate, please come forward so we don't have too many people, so it'll be a little bit more collaborative. If you are trying to participate, please clone the repo that I have on the screen. I'll have it a couple other times. You'll have a couple minutes to get that ready. [snorts] Um, so without further ado, in confidential confidentiality things work differently. Um, imagine Alice has a 100 tokens and she sends Bob a thousand tokens that are confidential.

Um, what happens? Does this [snorts] revert? Does it not revert? Can we get a show of hands? Who thinks it would revert? Okay, no one. That's good. Um, so we'll be do a very short demo of actually doing this. Um, we have we'll be running this on Sabolia and we'll be sending a thousand tokens from Alice to Bob. Um, and all of you who didn't raise your hands, we'll see how the internet works here. Um, so we're waiting for Sabolia to validate this transaction.

Okay, so the transaction succeeded. There was no revert. She sent more tokens than she had, but nothing happened. Um, so we'll decrypt the values of each balance and see how many tokens each of them have. So Alice has 100 tokens and Bob has zero tokens. Um, as you may have suspected, confidentially this transaction failed. Um and nothing happened. But to the observer, you can see over here, it emits the confidential transfer event with an amount that you can't really decipher as an onlooker. So to anyone outside of Alice and Bob, this looks the exact same

as any other transfer that would transfer significant amount of assets. There's no way to know whatsoever. Um, so the premise is the sender is public, the recipient is public, time is public, but the amount is a handle. So encrypted, not anonymous. Um, we'll be doing a hands-on introduction to using confidential solidity. Note that this is all pointerbased confidentiality. There's many different ways to do confidential anonymous solidity. Today we're being we're talking about pointerbased solidity. Um I'm Ari and I'm an open source developer at open zeppelin.

We're going to first intro confidential solidity how encrypted state works. Then we're going to talk about ERC 7984, a standard that we built along with many other industry players um to have a token built on these premises. Um then we're going to build a vault hands-on. We'll write the deposit function from scratch. We'll have a lot of uh boiler plate to make it a little bit faster. Um and then we'll attack our vault. So we'll have two attacks. Um, and then these attacks are actually very closely correlated to the advisories that we had on our confidential contracts repo. So, we'll be teaching you firsthand about mistakes that we learned that we made >> [snorts]

>> um so you don't make them. Confidential solidity. The idea with confidential solidity, the way that we do it is it's all pointerbased. So, encrypted operations are running offchain. What is running onchain is your contract stores handles which we'll get to in a moment what these handles actually are what they mean. Um and your contract interacts with uh host contracts. These host contracts issue handles and emit events to an off-chain system that will be observing the chain and actually operating on these handles as dictated by your contract.

offchain co-processors compute the cipher text run the FHE operations TE operations um and create new cipher text behind these handles um and then there's a KMS so specifically with Zama which is what we've been building on top of there's a key management system that holds the decryption key for the FH cipher texts um today everything we're going to be running is on Foundry So there's no real FHE. There's no real encryption going on, but the actual front-end contracts that you're going to be using are the exact same. The back end, what's happening behind the scenes is much faster and cheaper to run. Um,

so handles, as I mentioned before, uh, in plain text solidity, everyone knows. So with the ERC20, imagine you have 100, you add 100. So you receive 100. Um, so you just have the hexadesimal representations. you know how to add this very simply. Um with confidentiality it's a bit of a different story. So all of our different confidential values or confidential handles are UDVTs userdefined value types on top of bytes 32. So a handle right if we have an eent 64 it's in solidity we'll have 64 but it's simply bytes 32 behind the scenes. Um, and

essentially to the naked eye, it's just a random string of numbers. And there's no actual encryption here. This is more so a storage slot, right? A pointer to a slot that's held offchain um in some sort of co-processor system. Again, we try to be implementation agnostic. Um, but this handle is uh using the Zama system. Um, so right, if you're trying to do an addition of the handle on top and the handle on bottom, they're both the same.

Uh, I encrypted 100. You would have to run f.add and then in the offchain systems, it would actually add the cipher text behind the scenes to the handle that it maps to. Um, and you may be asking, why are we not putting the actual handles on chain, the actual cipher text on chain? There's two main reasons. Uh reason one is the cipher text are often uh magnitudes of uh orders of magnitude larger than bytes 32. So it just be incredibly inefficient to store on [snorts] uh on Ethereum. So could be in the size of kilobytes. Um and then secondly, some technology that is built on top of this requires

different execution environments s such as trusted execution environments and that's incompatible with executing on Ethereum. Um so another concept we have in encrypted uh solidity is require becomes select right we can't require anymore >> [snorts] >> uh if you take the plain text equivalence um require balance is greater than or equal to amount and then right you just have a boolean in the require revert if the boolean's false um on the right side you can see we get an encrypted boolean so again right the encrypted boolean would look something like the bottom. It's not something you can put in a requirement in a require

statement. Um, so we have to select over everything and this is what happened in the opening, right? We can't require based on insufficient balance. We select and we change the transferred amount based on if the balance is greater than or equal to the amount that's being tried to transfer. Um, again just really echoing this, we can't revert based on any confidential state ever. We're always going to be selecting and to an on onlooker it looks the exact [snorts] same.

Um, earlier on I should have mentioned ACL. I don't know if I did. Um, but the ACL is access control list. Um, so we have all these different handles pointing to cipher text stored offchain. Um, but how do we know who's actually able to utilize a handle? Right? If I go and take um, James's balance and say add my balance to James balance and then I get the result of that uh, operation then I'm able to see James' balance indirectly. Right? [snorts] So we have an ACL that's stored on chain and the access control list stores who is able to operate on every single cipher text that is created. So this includes balances, this includes total supplies

and some of them might only be operationally usable by the the contract that issued it. But again, right, you can decide this in your solidity. So we have four different versions. We have allow this which is the sender will retain permanent permission to operate on the cipher text. We have [snorts] allow where you specify a user to retain permanent permission. We have allow transient where you specify uh transient approval. So transient allowance. So that means using T-store. So only until the transaction ends do they get allowance. And then we also have a way to query allowance. Note that transient allowance is equivalent permissionwise to permanent allowance.

And if you give a trans a contract transient allowance, that contract if they get um uh into the execution in that transaction can turn that into permanent allowance themselves via allow this. Um every contract that computes a handle whenever a handle is computed they get transient approvals um until the end of the transaction automatically if they want to retain it till then they have to do allow this for that approval to live past the end of the transaction. Um if you try to use a handle that you're not allowed with right so the way that an operation works again is you would do multiply on the host contract.

You'll pass in two handles. If either of those handles you aren't allowed for the transaction will revert. So this is public who is able to access a handle. It's not encrypted whatsoever. Um [snorts] and then allowances can't be revoked. If you do allow this or you allow a user that persists forever. Um you may be asking okay so how do I get values onchain and offchain? Right? So, I know that I need allowances, but what if there's no allowances that exist for a specific input? Um, any new input won't exist on chain. Uh, so for a new input, you take the public key of the uh FHE encryption key, uh, FH decryption

public key, and you encrypt it locally with that. Then you'll pass your cipher text to the co-processor offchain and the co-processor will give you a handle and an input proof which is uh proof that you know the value of this input and then you'll pass that onchain and we'll mint new transient approvals to you and the contract. This input proof is bound to the user to the handle and the contract. So there's no front running there's no uh concerns over there. [snorts] Decryption uh works in the opposite direction.

Um you go directly to the co-processor offchain and you request decryption of a certain value. The co-processor will consult the ACL access control list and see that you're allowed to decrypt this value. If you are, it will decrypt it using the um KMS system and then [snorts] re-encrypt it with your public key so that only you can see actual result. Okay, so that was brief overview of confidential solidity. Now we'll use some of those primitives to build 7984 very briefly and then we'll get into uh the hands-on part.

So with 7984 balances are of course handles. Uh we selected ewint 64s for our implementation that we have in the confidential contracts library. But something to remember is that all ewints encrypted values that we use are just udvts on top of bytes 32. So in the actual interface in the standard it's simply bytes 32. It's very implementation agnostic. People can do whatever they'd like there. Um, so anyone can read this handle. This this isn't a gated function. This isn't revert if it's not user X. Um, the ACL decides who can decrypt it. So by default, anyone can decrypt. No, by default, no one except for the holder

can decrypt their balance. Some extensions allow other users such as auditors um if that's necessary. you know, uh, RWAS possibly you need, uh, an auditor to be able to decrypt this. Um, but by default, just the address can decrypt their balance. Um, as we saw in the opener, a transfer that you can't afford moves zero. So, we have the confidential transfer function. uh you pass in the amount and if your balance covers the requested amount then the transferred amount is equal to what you wanted. If [snorts] your balance is too low then transferred

is zero. Um so we do filler kill um and there's no partial transfers. This is what happened at the cold at the opening with Ellis. Um, we've switched to use operators instead of uh amount based allowances like in ERC20. Um, so in ERC20 you have approve spender and the amount you spend up to that amount. Um, we've switched to use something more similar to what's in 1155 and 721 where you set an operator and then we also chose to set a deadline um because we don't want lingering approvals especially in the confidential world. We want to lean more towards the conservative side on setting

approvals than permissive. Um, but an operator can move any amount until a predefined deadline. Um, something to keep in mind is we're exploring adding uh further permissions here to try to make it even safer. Um, there's a good chance we'll have amountbased approvals shortly. Um, initially we had more constraints on processing power for doing FHE operations um, and more concern on the complexity of that. But over time as the tech has gotten better, the concern is less. So we will likely add amount based approvals soon.

Um, something that we were talking about before with the ACL, right? When we call confidential transfer, the first thing that we ever do is we check that you are allowed to utilize the amount that you pass in, right? Because otherwise you could pass in someone else's balance and then understand what that is. Okay, so let's get to building the vault. Um, anyone who wants to participate, please go and clone this repo locally. Um, you're going to need Foundry installed already. It if you have Foundry installed, you should be able to just run make ready and then everything will be green. If you don't

have some of the dependencies, which I think is just Foundry, um, read the read me and it will point you in the right direction. I'll give you guys a minute to set that up. with

our our confidential contracts library is built for EVM. Um, of course, there are uh organizations in the pointerbased um confidentiality space that are expanding to nonvm chains as well. Yeah. Okay. You can see my screen. Um I am going to live demonstrate writing the withdraw function in the repo that you're all cloning. This is already written. Um and

then once I'm done with this you will go ahead and write the deposit function. But the first thing we have to do is we have to take right we have external eent 64. we have to turn that into an EU 64 and this works by checking the input proof right so say amount equals FHE from external crypted amount input proof so this this way we are issuing FHE um ACL allowances we're checking that everything is good on the ACL that's that all happens automatically

um the Next step in the withdraw is we have to check that the user has sufficient funds to withdraw. So, I'm going to use a helper that we wrote actually um in the safe math library. [snorts] Um and this will be f safemath. And we'll try to decrease um from the confidential balance of the user on this specific token. Um, I guess I should give a little bit more context on what we're writing here. This is just a very simple vault. Um, where you can deposit

7984 tokens and you can withdraw 7984 tokens from it. Um, the idea is that if you deposit 100 tokens, you can withdraw up to 100 tokens and it should keep everything confidential. Um again the use cases here are limited but I'm trying to keep it as simple as possible so that we can have a very easy intro on how to just write some simple code here. Um so we're going to try to withdraw from our balance. We have our balance stored over here for a specific token and for a specific user we get from external we get if it's successful we get our amount to transfer we tried decrease. So for message sender then we have our amount to transfer

uh then amount then we have to um store the new oh sorry this is new balance we'll store the new balance so we'll do set balance for the token for the user to the new balance. Then we'll calculate the amount to actually send. So amount to send will be again right we have our select statement. So based on the success of the decrease of the balance we'll choose what to send.

So if it's successful we'll send amount. Otherwise we'll do FHE.S640. has 640. So we'll transfer zero. Um and then recall we have the ACL. So we have to allow transient the token. So um we put the handle first and then the address of the token and then we can call the token itself to transfer out the amount to the user. So we'll call token.conidential confidential transfer to message sender and then amount to send.

So that's just a quick walk through of the primitives that you'll be using to build the deposit function. Um now it's your turn. Take a few moments and try to write the deposit function. You can run make test to see if your deposit function is working properly. There's a couple tests. They should all be passing. So, I'll give you guys a couple minutes to do this. If someone wants help, you can raise your hand. I'll come over.

For anyone who's coming late, we're working on the repo that's in the bottom right corner. You can clone that supposed to be muted. No.

Has anyone got the tests all working? Not yet. Okay, I'll move on to walking through an

example that would set all the tests to be green. You don't necessarily need to write this all down. I have another branch set up for you. you just switch to the checkpoint branch and that has uh exactly this in the deposit function. Um but I'll just walk through something that would make the test screen. Um we take our amount and use f from external the same as we did on withdraw. So we're checking the ACL ensuring that user has access to this amount that they're passing. um we allow the token to transiently access this. We use a confidential transfer from to [snorts] pull the tokens from the user

into the contract. Um and then we set the balance of the user to the amount and then the addition. Um note that I'm not saying this is perfect and there are problems with this which we will see shortly. Um, is everyone able to switch to the checkpoint branch and run make test successfully? We have two, three. Okay, I'm getting nods. Um, so we'll move into the next segment where we attack the vault.

We have two attack setups. Um, you can run make attacks to run the attacks on the vault. Um, one of the the attacks is robbing the vault. Mallerie steals Bob's deposit. And then the second attack we have set up is reading Bob's balance. Um, so we have to go into the attacks um file and fill in the blank. So you have to choose which cipher text you want to or which handle you want to try to read. Um, so these are the next two things. You can run make attacks and you can try to patch the issue where um Mallalerie is robbing the vault and then you can try to get

attack number two to work um and then patch attack number two. So I'll let you guys do that for a few minutes. If anyone has any questions, please raise your hand. So, for the first step, you want to stop

the attack, attack number one. So, if you're on the checkpoint branch and you run make attacks, um you'll have one attack that's succeeding and one that's skipping. First, you want to block attack number one so that Mallerie can't rob the vault anymore. And then you're going to move on to attack number two. You're going to enable it by filling in a blank in the uh attacks tests. Um, and then you're going to stop it as well. And I'll walk through that after a couple of minutes. If you happen to finish the first stage

and all your tests were green and then you run the attacks and you're already blocking one of them, you can switch to the checkpoint branch so that the attacks run successfully. Um maybe someone wrote the code initially without having any exploits. Um so you can switch to checkpoint and exploit it with us. When you run make attacks, you'll see

failing. So, exploits in red and it will say what's happening. So, Mallerie robbed the vault means you have to figure out how to make it that Mallerie can't rob the vault. And then once you get number two to work, you have to make it that Mallerie can't steal Bob's balance. Just a hint for attack number two. Um,

right. Every handle is public. You can see interactions on chain. What handle would Mallerie most like to see? And you should fill that in for the blank on attack number Okay. I'm going to uh demonstrate what people

should have seen. So you run make test and this will have three working tests when you're on the checkpoint branch. you run make attacks will have initially one um attack that's exploiting the other attack will be skipped. So we'll have to fill in the blank. Um so I don't know if everyone figured out why the attack is succeeding and what we have to change. Um, this is again very similar to what happened in the opening.

We have this confidential transfer and we're just assuming that it succeeds, which is something that people will often do in regular solidity because it would revert if it doesn't succeed. Here, this won't revert. So, Mallerie calls deposit and it fails without reverting. So, amount received, if we take the amount received and credit that instead and then run the attacks, right? Then Mallerie's no longer able to rob the vault.

So, that is something that's absolutely critical to do when you're building on pointerbased confidential systems. You have to ensure that operations that you're doing succeed. And the far majority of the time it's not enough to just assume that something is succeeding. You have to check. So we'll go to the slide here. Um right, we updated our transfer call, our confidential transfer call to take the transferred amount instead of simply applying the desired amount without checking the

transfer. Um right, this will return zero if it fails. So we would credit zero, which is the amount actually transferred and everything is good over there. For attack number two, we'll go back here. Um, which handle would we want to pull um into Mallalerie? So, the way that attack number two works is we have a malicious token and this malicious token is returning a handle that it actually doesn't have access to. Um so if we go into the code here and we see right we get a handle which is amount received and we're operating

on that naively but this token is passed in by the user right so this token can be a malicious token so what Mallalerie is doing is passing a malicious token into the deposit function I can show what this malicious token is Right? It's very simply has a confidential transfer header and it just returns whatever Mallerie decides to return. Um, so what would we like to return over here? Any guesses?

Did anyone fill that out? And the blank in the test? No. Okay. So yeah, skipping the blank. I'm sorry. I don't I don't understand. We have to fill in the blank right? Yes. Right now it's skipping the test if blank meaning you have to fill in the

test. So that's what I'm saying. What should be we fill in the test with? Um what I would fill in the test with is we go over here and we have the blank handle. Um we can say vault.confidential confidential balance of and then we can pull any handle, right? So, what handle would Mallerie be interested in knowing? She'd be interested in knowing the handle for Bob on this specific token. Um, and then we can run attacks again. And now, right, Mallerie is able to read Bob's balance.

So, that's what I was looking for over here. And then again the mitigation is what we spoke about a bunch of times is checking the ACL. So anytime you receive a new cipher text, a new handle, you have to check the ACL to know if the person passing it in is able to access it. And it's not just external inputs. It's not just what's initiating, but any contract that you interact along the way that's giving you a handle is claiming I'm allowed to use this handle. and you can't trust that contract.

Um, so right, the vault is able to use any handle that it has access to. So there could be hundreds, thousands of handles that the vault has access to on the ACL. And if the vault naively allows a token to use any return value, it can return anything the vault has access to. Um, so I can go and fix this. Um, and we'll go over here and simply we do FHE.allowed and put that in a requirement require statement. So, oh sorry um is allowed uh the token address of the token.

So, we're just going to revert if the tokens it is not allowed to use this value and we can run the attacks again. And now Mallerie is unable to read Bob's balance. Exactly. Exactly. So the ACL keeps track of all of this for us. We don't have to maintain um a parallel list of who's able to access handles. The ACL does that globally for everything onchain. Um and we just have to check the ACL when necessary to ensure that we're keeping up with what's allowed.

Again, something to keep in mind, ACL allowances can't be taken back. So if we patch this issue in an upgrade, any handles that Mallerie managed to gain access to in the past, she still has access to. This just patches the issue going forwards. But secrets leaked in the past are still leaked. But again, right, if you think about this logically, trying to pull back a secret doesn't mean much because once someone knows it, they can replicate it and store it elsewhere. So, what did Mallerie know before attacking um say attack number two?

Before being able to uh get Bob's balance within the protocol, what did Mallerie have to know or what did Mallerie know? Um this is back to the beginning. Mallerie knew that Bob interacted with the vault. Mallerie knew when Bob interacted with the vault. Mallerie didn't know how much Bob used the vault or if it was even more than zero. All [snorts] Mallerie was able to see was a handle. Um, but using this exploit, Mallerie was able to uncover what the handle was and make everything known for herself.

So, some of you just wrote our advisories. Um these two exploits were close parallels to issues that we published in the confidential contracts library. Um in January we had an issue with silent failures in the wrapper. Um, we have a ERC20 to ERC 7984 token wrapper where users can deposit plain text tokens and then receive confidential tokens in exchange for their to those tokens and then of course unwrap at their uh leisure. Um, so ERC7984 tracks the total supply as an e64 and then of course if an action would result in overflow of the total

supply it fails. So if you run mint at a time when total supply would fail, the mint actually doesn't issue anything. So what we were doing was we were running uh in a wrap function and also we were using uh callback. So on transfer received [snorts] um if a user was transferring an ERC20 tokens and that would result in the total supply overflowing, we would fail silently. we wouldn't obser we didn't observe the result of the mint uh function and the tokens were lost. So with the default rates this occurs after 18.4 trillion tokens. Um [snorts] it didn't occur anywhere before being

patched but this is again the assumption of a set of a confidential event succeeding can lead to problems as it did here. Um, and then that was exactly what attack number one was, right? We assumed that Mallalerie's transfer would go through. It didn't go through and as such we lost funds. Uh, exploit number two or advisory number two. Um, we have contracts trusting handles given to it by other contracts where the user can inject their own contract and be malicious, possibly pass handles that they don't have access to. Uh we had this in two instances in our repo uh with the vesting wallet.

The vesting wallet um had a release function where you could release um any token uh up to right it would calculate what percentage is vested and then send out that percentage of the balance of the vesting wallet. Any user could initialize this with any token. So it would call the confidential balance of for a specific token and operate on it and then transfer that amount on the token. So the uh malicious token would return a different balance, a different amount that the vesting wallet had access to and get access to a slightly perturbed value. Um but they would be able to basically detect um different values that they weren't

supposed to. Then in the ERC 7984 callback, this is something which I didn't get into too much. Um the refund logic would take an eBool from the recipient contract and again that wasn't checked. Um this has all been fixed. So again on every single handle that you didn't create, ensure that you do fisallowed and you know that the user the source is able to access this handle. Um here's a little bit of a chart of different habits and what replaces them. Um in plain text you'll have failed transfers reverting again confidentially. You never have that right. We credit the return

transferred re transferred amount. That's critical exploit number one. Um number two we return we consume return data very naively in uh solidity. We'll get the return data from a token and we'll say this is what it is here. We always have to check fisallowed before consuming return data. Exploit number two. Um, normally we would just have a requirement statement that the balance is greater than or equal to the amount. Here we have to use select and choose either the success branch or zero. Um, we don't have checked arithmetic. We can overflow and it will all be silent. Um

in our library we in our library we have a safemat library and you can use a try carry the flag and ensure that things work properly. Um and then finally we have returns and we emit values in solidity. Of course it's important to allow relevant parties to access that before returning or emitting so that it's usable. Thank you very much. Um, here's a QR code for the confidential contracts library. Open Zeppelin wizard. We already have it configured, so you [snorts] can deploy contracts, play around with our different extensions that we built already. Um, and yeah, have them running on mainet or SIA on

top of Zama. [music]