An erc20 token with EIP7503 (zkwormholes) style private transfers.
Using storage proofs to track the balances of the burn addresses instead of commitments in a merkle tree.
Try it out here: https://scrollzkwormholes.jimjim.dev/
Or on ipfs: https://bafybeichjrwquiabspceeyvz5mkysszjno5cgj6a4vrfp2sthsfiszehjy.ipfs.dweb.link/
https://sepolia.scrollscan.com/address/0x12b65F787D7A4672218cA4375f79133564328B28
This version is not mainnet ready since it uses a workaround that allows ANYONE to mint free tokens.
This because the BLOCKHASH opcode isn't ready yet on scroll.
See: https://github.com/jimjimvalkema/scrollZkWormholes/blob/main/contracts/Token.sol#L27
EIP7503 doesn't reveal if an user sends a private transfer. Instead it looks like a "normal" transfer.
In contrast to protocols like zcash, tornadocash, etc, where it's publicly "announced" when a user with public money protects their privacy.
In EIP7503, such actions look like normal transfers to fresh new wallets. Under the hood these wallets do not have a private key, so these funds are burned. The protocol then allows the user to "re-mint" these burned coins to an unlinked address, only if they can provide the zk-proof that no private key exist for the burn address.
The gas cost of burning tokens is the same as a vanilla erc20 transfer. The equivalent is a deposit in ex tornado cash, which can cost up to 16x more.
This is because we reused the state tree as our merkle tree of commitments with storage proofs. Unlike in tornadocash and railgun where the contract builds it's own merkle tree.
The storage proofs are used as a private input to prove that an address with a balance (a public input) exist onchain.
The storage proofs are done on scroll which a zk-rollup. Which means its state trie uses zk friendly hash functions. Which makes it a lott faster to prove then Ethereums state trie!
The burn address is a private input, generated by hashing a secret with the poseidon
hash function and then striping of the last 12 bytes. This means that there is no private-public key pair of that hash since it calculated "wrong". But there is a secret which can be proven the user knows in a zk-circuit without revealing it!
function hashBurnAddress(secret) {
const hash = ethers.toBeArray(poseidon1([secret]))
const burnAddress = hash.slice(0,20)
return ethers.zeroPadValue(ethers.hexlify(burnAddress),20)
}
at line 152-157 in scripts/getProofInputs.js
https://github.com/jimjimvalkema/scrollZkWormholes/blob/main/scripts/getProofInputs.js#L152
The nullifier a public input, which is a hash of the secret + hash of secret. So: poseidon(secret, poseidon(secret))
.
This is used to as a identifier to prevent double spending the same secret again. Without revealing the burnaddress that is nullified.
function hashNullifier(secret) {
const hashedSecret = poseidon1([secret])
const nullifier = poseidon2([secret,hashedSecret])
return ethers.zeroPadValue(ethers.toBeHex(nullifier),32)
}
at line 146-150 in scripts/getProofInputs.js
https://github.com/jimjimvalkema/scrollZkWormholes/blob/main/scripts/getProofInputs.js#L146
The original EIP doesn't specify this but there are a number of features i like to add.
re-usable burn address + partial re-mints: Might be possible by tracking totalAmountReminted
inside the nullifier pre-image. Then also adding storage proof of the prev nullifier.
full shielding txs: Like in zcash and railgun, etc.
improve proving time: with proof recursion and a historicStorageRoots
in a public mapping in the contract.
yarn install;
yarn install-submodules && yarn install-vite;
yarn dev
yarn build
yarn hardhat vars set PRIVATE_KEY; #<=deployment key
yarn hardhat vars set SEPOLIA_SCROLL_ETHERSCAN_KEY;
yarn compile-contracts;
yarn hardhat run scripts/deploy.cjs --network scrollSepolia;
yarn hardhat ignition deploy ignition/modules/Token.cjs --network scrollSepolia --verify #couldnt verify within deploy.cjs so this is a hacky work around
yarn hardhat vars set RECIPIENT_PRIVATE_KEY;
yarn hardhat run scripts/proofAndRemint.js
Install noir
curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash
noirup -v 0.31.0
Run test
cd circuits/smolProver;
nargo test;
Compile circuit (verifier contracts are created in scripts/deploy.cjs
)
yarn compile-circuits
The contract uses 2 verifiers: smolprover and fullProver. This is because noirjs has a limit on ram (4gb), so smollProver uses smaller parameters that assume the chain doesnt becomes larger.
FullProver is there just incase the chain grows outside of the parameters of smolprover.
More specifically full prover is able to go down the full 248 tree depth and can handle a blockheader up to 850 bytes.
While smolprover is set to tree depth of 26 and 650 bytes for the blockheader
global MAX_HASH_PATH_SIZE = 26;
global MAX_RLP_SIZE = 650;
at line 13-14 in circuits/smolprover/src/main.nr https://github.com/jimjimvalkema/scrollZkWormholes/blob/main/circuits/smolProver/src/main.nr#L13
The BLOCKHASH opcode on scroll isnt ready yet so the contract relies on a trusted oracle (the deployer) to tell the contract what blockhash is valid.
sepolia scroll block header is different then scroll mainnet. It includes baseFeePerGas, while mainnet scroll doesnt yet.
modify ../scripts/getScrollProof.js
at getBlockHeaderRlp() for mainnet
const headerData = [
block.parentHash,
block.sha3Uncles,
block.miner,
block.stateRoot,
block.transactionsRoot,
block.receiptsRoot,
block.logsBloom,
block.difficulty,
block.number,
block.gasLimit,
block.gasUsed,
block.timestamp,
block.extraData,
block.mixHash,
block.nonce,
// is in scroll-sepolia but not mainnet yet
block.baseFeePerGas,
// in neither chains
// block.withdrawalsRoot,
// block.blobGasUsed,
// block.excessBlobGas,
// block.parentBeaconBlockRoot
]
at line 259-283 ../scripts/getScrollProof.js https://github.com/jimjimvalkema/scrollZkStorageProofs/blob/main/scripts/getScrollProof.js#L259