Programmable Privacy and Onchain Compliance using Homomorphic Encryption

November 23, 2023
Clément Danjou, Rand Hindi

A few months back, the crypto team at a16z published the Nakamoto Challenge, a list of the most important problems to solve in blockchain. The fourth one in particular caught our attention: “Compliant Programmable Privacy”, as we have been actively thinking about this for some time. Today, we are proposing a first solution using homomorphic encryption and our fhEVM confidential smart contract protocol (if you’re not familiar with the fhEVM, you can read our articles about confidential ERC20 token and blind auctions).

The fhEVM is a regular EVM with some precompiles that enables computing on encrypted states using our TFHE-rs homomorphic encryption library. From the perspective of the developer, there is no cryptography involved: they simply write Solidity code using the encrypted data types we provide (euint32, ebool, etc). One of the big advantages of the fhEVM vs other privacy solutions is that all the data and computation happens onchain. This means you can have the same level of composability and data availability as regular, plaintext contracts.

This property is key to building programmable privacy, as all the access control logic can be defined in the contract itself. There is nothing that needs to be hardcoded into the protocol, and nothing the user has to do offchain to be compliant. The application can enforce compliance directly, with just a few lines of Solidity code!

In this article, we will show how to build a compliant ERC20 token, using onchain DIDs. The source code for this tutorial can be found in the examples folder of the fhEVM repository.

Identity abstraction via onchain, confidential DIDs

A Decentralized Identifier (DID) is a unique digital identity that is issued by an entity such as a government, a registrar, a company or the user itself. This DID can be tied to a cryptographic key that proves the user owns the DID, such as an EVM wallet. But it can also store a whole host of attributes, such as the user’s age, nationality, social security number etc. These attributes in turn can then be used to prove that you satisfy some condition (called an “attestation”), such as being over 18 or not being a Narnia citizen.

Most DIDs are implemented client side, and use zero-knowledge proofs to generate attestations. While this is fine in many cases, it quickly becomes complicated when you have multiple users involved in a transaction, when you have to apply complex rules to the DID, or when you need to keep a common set of rules for everyone to follow. It’s essentially the same tradeoff as in edge vs cloud applications.

Having a centralized DID registry however would solve these issues, as you could then simply ask the registry to check that everyone is compliant. It would also make it simpler to keep track of regulations, as you would only need to implement it in a single place. A blockchain would be a perfect infrastructure for this, as it would enable composability between DIDs and applications that require compliance, as well as composability between regulations themselves.

Problem: everyone would see the identity of everyone!

Fortunately we have a solution: homomorphic encryption, and more specifically the fhEVM! Thanks to the ability to have composability on encrypted state, we can host the user DIDs directly onchain in encrypted form, and have compliant applications verify attributes using a simple contract call. The ability to manage an identity via a smart contract, which we call “Identity Abstraction”, is akin to how one can manage funds via smart contract with Account Abstraction.

This tutorial has 3 parts:

  • Identity Abstraction is done via a registry contract that is responsible for managing identities and attestations. Here we assume the DIDs are official government IDs. The registry itself is managed by a central authority (e.g. the AFNIC) who can create registrars (e.g. KYC companies such as Onfido, Jumio, etc..) who then in turn can create user DIDs. The user then goes through their registrar to manage and update their DIDs.
  • Regulation is defined in a contract that encodes a set of rules for token transfers between individuals, based on information contained in their DIDs. It basically enforces regulation at the contract level rather than the user level.
  • Compliant Confidential Transfers are implemented in a compliant ERC20 contract that uses the regulation contract to enforce compliance in token transfers, without any changes to the ERC20 API itself. In this example we use a confidential ERC20 contract, where balances and amounts are hidden, but it would work just as well with a regular, plaintext ERC20 token.
Architecture of our Onchain Confidential DID Protocol

The identity registry contract

The IdentityRegistry contract is a registry of user DIDs that are issued by registrars and include a set of encrypted identifiers, such as their nationality, age, social security number etc. These identifiers are stored as encrypted 32 bit values (euint32).

The contract also handles permissions, such as:

  • Enabling the contract owner (e.g. AFNIC) to add, remove or update registrars.
  • Enabling registrars to add, remove or update the user DIDs they created.
  • Allowing users to grant smart contracts access to specific attributes of their DIDs. It’s important to note here that the user is responsible for not giving access to malicious contracts, just like they are responsible for not letting malicious contracts spend their tokens.

As a first step, let’s implement the logic for creating and managing DIDs:

// SPDX-License-Identifier: BSD-3-Clause-Clear
pragma solidity 0.8.19;

import "fhevm/lib/TFHE.sol";

contract IdentityRegistry is EIP712WithModifier, Ownable {
    // A mapping from wallet to registrarId
    mapping(address => uint) public registrars;

    // A mapping from wallet to an identity.
    mapping(address => Identity) internal identities;

    struct Identity {
        uint registrarId;
        mapping(string => euint32) identifiers;
    }

    mapping(address => mapping(address => mapping(string => bool))) permissions; // users => contracts => identifiers[]

    event NewRegistrar(address wallet, uint registrarId);
    event NewDid(address wallet);
    event RemoveDid(address wallet);

	constructor() Ownable() EIP712WithModifier("Authorization token", "1") {
        _transferOwnership(msg.sender);
    }

	// Add registrar
	function addRegistrar(address wallet, uint registrarId) public onlyOwner {
        require(registrarId > 0, "registrarId needs to be > 0");
        registrars[wallet] = registrarId;
        emit NewRegistrar(wallet, registrarId);
    }

    function removeRegistrar(address wallet) public onlyOwner {
        delete registrars[wallet];
    }

	function getRegistrar(address wallet) public view returns (uint) {
        return identities[wallet].registrarId;
    }

    // Add user
    function addDid(address wallet) public onlyRegistrar {
        require(identities[wallet].registrarId == 0, "This wallet is already registered");
        Identity storage newIdentity = identities[wallet];
        newIdentity.registrarId = registrars[msg.sender];

        emit NewDid(wallet);
    }

    function removeDid(address wallet) public onlyExistingWallet(wallet) onlyRegistrarOf(wallet) {
        require(identities[wallet].registrarId > 0, "This wallet isn't registered");
        delete identities[wallet];

        emit RemoveDid(wallet);
    }

	modifier onlyExistingWallet(address wallet) {
        require(identities[wallet].registrarId > 0, "This wallet isn't registered");
        _;
    }

    modifier onlyRegistrar() {
        require(registrars[msg.sender] > 0, "You're not a registrar");
        _;
    }

    modifier onlyRegistrarOf(address wallet) {
        uint registrarId = registrars[msg.sender];
        require(identities[wallet].registrarId == registrarId, "You're not managing this identity");
        _;
    }
}

Now the next step is to implement the logic for identifiers and access control.

An identifier is simply a string (e.g. “date of birth”) and an encrypted 32 bit value. It can be created or updated only by the registrar. A user can’t create their own identifiers, as we want them to be certified by the registrar.

Since identifiers are encrypted however, the user needs to give permission to a contract to access specific values, which we will handle through a simple access control mechanism similar to how you can allow a contract to spend your ERC20 tokens.


contract IdentityRegistry is EIP712WithModifier, Ownable {
	...
	mapping(address => mapping(address => mapping(string => bool))) permissions; // users => contracts => identifiers[]

		// Set user's identifiers
    function setIdentifier(address wallet, string calldata identifier, bytes calldata encryptedValue) public {
        euint32 value = TFHE.asEuint32(encryptedValue);
        setIdentifier(wallet, identifier, value);
    }

    function setIdentifier(
        address wallet,
        string calldata identifier,
        euint32 value
    ) internal onlyExistingWallet(wallet) onlyRegistrarOf(wallet) {
        identities[wallet].identifiers[identifier] = value;
    }

 	// User handling permission
	function grantAccess(address allowed, string[] calldata identifiers) public {
		for (uint i = 0; i < identifiers.length; i++) {
			permissions[msg.sender][allowed][identifiers[i]] = true;
		}
	}

    function revokeAccess(address allowed, string[] calldata identifiers) public {
      	for (uint i = 0; i < identifiers.length; i++) {
      		delete permissions[msg.sender][allowed][identifiers[i]];
      	}
    }
...
}

We can now wrap up our identity registry contract by adding the necessary getters, with some conditions and error handling.


contract IdentityRegistry is EIP712WithModifier, Ownable {
		...
	// Get encrypted identifiers
    function reencryptIdentifier(
        address wallet,
        string calldata identifier,
        bytes32 publicKey,
        bytes calldata signature
    ) public view onlySignedPublicKey(publicKey, signature) returns (bytes memory) {
        euint32 ident = _getIdentifier(wallet, identifier);
        require(TFHE.isInitialized(ident), "This identifier is unknown");

        return TFHE.reencrypt(ident, publicKey, 0);
    }

    function getIdentifier(address wallet, string calldata identifier) public view returns (euint32) {
        return _getIdentifier(wallet, identifier);
    }

    function _getIdentifier(
        address wallet,
        string calldata identifier
    ) internal view onlyExistingWallet(wallet) onlyAllowed(wallet, identifier) returns (euint32) {
        return identities[wallet].identifiers[identifier];
    }

	modifier onlyAllowed(address wallet, string memory identifier) {
		require(
		owner() == msg.sender || permissions[wallet][msg.sender][identifier],
		"User didn't give you permission to access this identifier."
		);
		 _;
	}
}

The regulation contract

The next step is to create our regulation contract.

When implementing a set of rules for transfers between two individuals, it's important to recognize that these rules may evolve over time. Having a single smart contract defining all the regulation for a given context such as money transfer means that ERC20 contracts don’t have to keep track of regulation themselves. Governments can simply update this contract, and it will automatically propagate to all tokens that implemented it.

At the core, the regulation contract is just a set of conditions that are matched against encrypted identity attributes. To avoid misuse, users won't directly grant access to the regulation contract, but rather grant access to the ERC20 token contract, who then performs a delegate call to the regulation contract. This approach ensures that only the ERC20 contract, which the user trusts, can access their information. Keep in mind that both the sender and the receiver have to have granted permission to the ERC20 contract before a transfer can happen between them.

In this example, we will implement some basic rules:

  • Transfers within a country are unlimited, but transfers to a foreign country is capped at 10,000 tokens.
  • A blacklisted user can’t transfer or receive tokens.
  • A user cannot transfer tokens to a blacklisted country.

Rather than failing the transaction, which could reveal sensitive information, we will simply set the transfer amount to 0 if one of the conditions is not met. This uses a homomorphic ternary operator called a cmux:  [.c-inline-code]value = TFHE.cmux(encryptedCondition, valueIfTrue, valueIfFalse);[.c-inline-code]

// SPDX-License-Identifier: BSD-3-Clause-Clear

pragma solidity 0.8.19;

import "fhevm/lib/TFHE.sol";
import "./IdentityRegistry.sol";

interface ICompliantERC20 {
    function getIdentifier(address wallet, string calldata identifier) external view returns (euint32);
}

contract ERC20Rules {
    string[] public identifiers;

    mapping(address => uint32) public whitelistedWallets;
    mapping(string => uint8) public countries;
    uint16[] public country2CountryRestrictions;

    constructor() {
        identifiers = ["country", "blacklist"];
        whitelistedWallets[address(0x133725C6461120439E85887C7639316CB27a2D9d)] = 1;
        whitelistedWallets[address(0x4CaCeF78615AFecEf7eF182CfbD243195Fc90a29)] = 2;

        countries["fr"] = 1;
        countries["us"] = 2;

        country2CountryRestrictions = [createRestriction(countries["us"], countries["fr"])];
    }

    function createRestriction(uint16 from, uint16 to) internal pure returns (uint16) {
        return (from << 8) + to;
    }

    function getIdentifiers() public view returns (string[] memory) {
        return identifiers;
    }

    function getC2CRestrictions() public view returns (uint16[] memory) {
        return country2CountryRestrictions;
    }

    function transfer(address from, address to, euint32 amount) public view returns (euint32) {
        ICompliantERC20 erc20 = ICompliantERC20(msg.sender);
        // Condition 1: 10k limit between two different countries
        ebool transferLimitOK = checkLimitTransfer(erc20, from, to, amount);

        ebool condition = transferLimitOK;

        // Condition 2: Check that noone is blacklisted
        ebool blacklistOK = checkBlacklist(erc20, from, to);

        condition = TFHE.and(condition, blacklistOK);

        // Condition 3: Check country to country rules
        ebool c2cOK = checkCountryToCountry(erc20, from, to);

        condition = TFHE.and(condition, c2cOK);

        return TFHE.cmux(condition, amount, TFHE.asEuint32(0));
    }

    function checkLimitTransfer(
        ICompliantERC20 erc20,
        address from,
        address to,
        euint32 amount
    ) internal view returns (ebool) {
        euint8 fromCountry = TFHE.asEuint8(erc20.getIdentifier(from, "country"));
        euint8 toCountry = TFHE.asEuint8(erc20.getIdentifier(to, "country"));
        require(TFHE.isInitialized(fromCountry) && TFHE.isInitialized(toCountry), "You don't have access");
        ebool sameCountry = TFHE.eq(fromCountry, toCountry);
        ebool amountBelow10k = TFHE.le(amount, 10000);

        return TFHE.or(sameCountry, amountBelow10k);
    }

    function checkBlacklist(ICompliantERC20 erc20, address from, address to) internal view returns (ebool) {
        ebool fromBlacklisted = TFHE.asEbool(erc20.getIdentifier(from, "blacklist"));
        ebool toBlacklisted = TFHE.asEbool(erc20.getIdentifier(to, "blacklist"));
        return TFHE.not(TFHE.and(toBlacklisted, fromBlacklisted));
    }

    function checkCountryToCountry(ICompliantERC20 erc20, address from, address to) internal view returns (ebool) {
        // Disallow transfer from country 2 to country 1
        uint16[] memory c2cRestrictions = getC2CRestrictions();

        euint32 fromCountry = erc20.getIdentifier(from, "country");
        euint32 toCountry = erc20.getIdentifier(to, "country");
        require(TFHE.isInitialized(fromCountry) && TFHE.isInitialized(toCountry), "You don't have access");
        euint16 countryToCountry = TFHE.shl(TFHE.asEuint16(fromCountry), 8) + TFHE.asEuint16(toCountry);
        ebool condition = TFHE.asEbool(true);

        // Check all countryToCountry restrictions
        for (uint i = 0; i < c2cRestrictions.length; i++) {
            condition = TFHE.and(condition, TFHE.ne(countryToCountry, c2cRestrictions[i]));
        }

        return condition;
    }
}

The compliant confidential ERC20 contract

Now that we have an identity registry and a regulation contract, we can finally create our compliant, privacy preserving token contract. This contract will be called CompliantERC20 and have the following key features:

  • The user balance and transfer amount are encrypted.
  • Compliance is enforced in transfers by calling the regulation contract.
  • Visibility of certain balances can be granted to whitelisted addresses (eg regulators)
// SPDX-License-Identifier: BSD-3-Clause-Clear

pragma solidity 0.8.19;

import "fhevm/abstracts/EIP712WithModifier.sol";
import "./ERC20Rules.sol";
import "./IdentityRegistry.sol";

abstract contract AbstractCompliantERC20 is EIP712WithModifier {
    IdentityRegistry identityContract;
    ERC20Rules rulesContract;
    mapping(address => euint32) internal balances;

    constructor(address _identityAddr, address _rulesAddr) EIP712WithModifier("Authorization token", "1") {
        identityContract = IdentityRegistry(_identityAddr);
        rulesContract = ERC20Rules(_rulesAddr);
    }

    function identifiers() public view returns (string[] memory) {
        return rulesContract.getIdentifiers();
    }

	function getIdentifier(address wallet, string calldata identifier) external view returns (euint32) {
        require(msg.sender == address(rulesContract), "Access restricted to the current ERC20Rules");
        return identityContract.getIdentifier(wallet, identifier);
    }

	function balanceOf(
        address wallet,
        bytes32 publicKey,
        bytes calldata signature
    ) public view onlySignedPublicKey(publicKey, signature) returns (bytes memory) {
        
		// Users can see their own balances
		if (wallet == msg.sender) {
            return TFHE.reencrypt(balances[msg.sender], publicKey, 0);
        }
				
		// A country can see the balance of all its citizens
        uint32 userCountry = rulesContract.whitelistedWallets(msg.sender);
        require(userCountry > 0, "You're not registered as a whitelisted wallet");

        euint32 walletCountry = identityContract.getIdentifier(wallet, "country");
        ebool sameCountry = TFHE.eq(walletCountry, userCountry);
        euint32 balance = TFHE.isInitialized(balances[wallet]) ? balances[wallet] : TFHE.asEuint32(0);
        balance = TFHE.cmux(sameCountry, balance, TFHE.asEuint32(0));

        return TFHE.reencrypt(balance, publicKey, 0);
    }

    // Transfers an encrypted amount.
	function _transfer(address from, address to, euint32 _amount) internal {
        // Condition 1: hasEnoughFunds
        ebool enoughFund = TFHE.le(_amount, balances[from]);
        euint32 amount = TFHE.cmux(enoughFund, _amount, TFHE.asEuint32(0));

        amount = rulesContract.transfer(from, to, amount);

        balances[to] = balances[to] + amount;
        balances[from] = balances[from] - amount;
    }
}

The regulation contract is invoked via a simple call. This implies that users must provide access to the ERC20 contract before initiating any transfer; otherwise, the transfer will be reverted.

Finally, we can now create our ERC20 contract:

// SPDX-License-Identifier: BSD-3-Clause-Clear
pragma solidity 0.8.19;

import "fhevm/lib/TFHE.sol";
import "./AbstractCompliantERC20.sol";

contract CompliantERC20 is AbstractCompliantERC20 {
	...
	// Transfers an encrypted amount from the message sender address to the `to` address.
    function transfer(address to, bytes calldata encryptedAmount) public {
        transfer(to, TFHE.asEuint32(encryptedAmount));
    }

    // Transfers an amount from the message sender address to the `to` address.
    function transfer(address to, euint32 amount) public {
        _transfer(msg.sender, to, amount);
    }
}

Similarly to how users grant permissions to DeFi protocols to spend their tokens, they will need to grant permission to the contract to access identifiers needed by the regulation contract. This is done via a call to Identity.grantAccess(contractAddress, identifiers), which can be retrieved by calling the ERC20.identifiers() view method. This list comes directly from ERC20Rules contract to allow update of properties.

Compliance and privacy can coexist!

Hopefully this tutorial has shown you that compliance isn’t a difficult thing to build if the rights tools are available. While we initially built the fhEVM to enable privacy in blockchain, we quickly realized that this technology could be used for identity management and thus programmable compliance.

The proposed design here is far from perfect, but we believe it can easily be improved and launched as a real world use case, so that compliance no longer has to be synonymous with surveillance!

Additional links

Read more related posts