Skip to main content

Building a Confidential ERC-20

A confidential ERC-20 hides two things that a standard ERC-20 leaks: balances (only the holder can read theirs) and transfer amounts (only the sender knows). This is the archetypal OXN application.

The design​

  • Hidden balances. balanceOf(user) returns 0 to everyone except the user themselves (or someone they've approved to see).
  • Hidden amounts. transfer(recipient, amount) puts amount in encrypted calldata — observers see that a transfer happened between two addresses but not how much.
  • Compatibility. Standard IERC20 interface where reasonable, so wallets and other tools still work.

Contract​

contracts/ConfidentialToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract ConfidentialToken {
string public name = "ConfidentialToken";
string public symbol = "CONF";
uint8 public decimals = 18;
uint256 public totalSupply;

mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
mapping(address => mapping(address => bool)) private _readApprovals;

event Transfer(address indexed from, address indexed to); // no amount
event Approval(address indexed owner, address indexed spender); // no amount

constructor(uint256 initialSupply) {
totalSupply = initialSupply;
_balances[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender);
}

// Only visible to the account owner or approved readers
function balanceOf(address account) external view returns (uint256) {
if (msg.sender == account || _readApprovals[account][msg.sender]) {
return _balances[account];
}
return 0;
}

function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}

function approve(address spender, uint256 amount) external returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender);
return true;
}

function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = _allowances[from][msg.sender];
require(allowed >= amount, "insufficient allowance");
_allowances[from][msg.sender] = allowed - amount;
_transfer(from, to, amount);
return true;
}

function allowance(address owner, address spender) external view returns (uint256) {
if (msg.sender == owner || _readApprovals[owner][msg.sender]) {
return _allowances[owner][spender];
}
return 0;
}

function approveReader(address reader) external {
_readApprovals[msg.sender][reader] = true;
}

function _transfer(address from, address to, uint256 amount) internal {
require(_balances[from] >= amount, "insufficient balance");
_balances[from] -= amount;
_balances[to] += amount;
emit Transfer(from, to); // no amount emitted
}
}

What's confidential and what isn't​

Confidential:

  • Individual balances
  • Transfer amounts
  • Approval amounts

Visible:

  • Which addresses transacted
  • Whether a transfer succeeded (status)
  • Total supply (public state)

For most privacy needs, this is a good balance — the actors are visible (which is anyway leaked by tx.from / tx.to), but the values are hidden.

Client-side interaction​

import { ethers } from "ethers";
import { wrapEthersProvider, wrapEthersSigner } from "@oasisprotocol/sapphire-ethers-v6";

const raw = new ethers.JsonRpcProvider("https://rpc.bout.network");
const provider = wrapEthersProvider(raw);
const signer = wrapEthersSigner(new ethers.Wallet(PRIVATE_KEY, raw));

const token = new ethers.Contract(tokenAddress, abi, signer); // signer, not provider

// Signed query — returns the caller's actual balance
const myBalance = await token.balanceOf(await signer.getAddress());

// Transfer — amount is encrypted in calldata
await token.transfer(recipient, ethers.parseUnits("100", 18), { gasLimit: 500_000n });

Notice the contract is constructed with the signer, not the provider. This ensures balanceOf queries are signed queries so msg.sender is populated.

Compatibility with standard tooling​

Most wallets and explorers won't see balances or amounts. That's the point — but it means:

  • MetaMask token detection may show 0 balance for this token by default.
  • Explorer "Read Contract" UI will return 0 for balanceOf because it uses plain eth_call.
  • Token indexers cannot track balances across users.

If you need a wallet UI to display balances, the wallet must issue signed queries for the holder's own balance. The OXN Web Wallet handles this correctly for tokens it recognizes.

Recipient notification​

Since Transfer events don't include the amount and are only decryptable by the sender, recipients don't automatically know they got a transfer. Options are the same as in Deploying an ERC-20.

Full example​

See the OXN sample repository for a runnable Hardhat project with tests.

Next steps​