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)putsamountin encrypted calldata — observers see that a transfer happened between two addresses but not how much. - Compatibility. Standard
IERC20interface where reasonable, so wallets and other tools still work.
Contract
// 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
balanceOfbecause it uses plaineth_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.