case study · 1st prize
Project Keter
Proving you’re allowed to trade a regulated token, without saying who you are.
The brief
Security tokens are shares and bonds that live on a blockchain. Regulation says the issuer has to know who holds them; a public blockchain shows every transfer to everyone. Put the two together and every investor’s identity ends up on display.
We had a weekend to try a way out: let each investor prove they meet the rules (an EU country, an approved investor type, a personal limit) without revealing which investor they are. The token only checks the proof.
The approach
The bank approves each investor’s KYC and stores a fingerprint of their details in a tree; only the tree’s root goes on-chain. To make a transfer, the investor’s browser builds a zero-knowledge proof that their details are in the tree and pass the rules, and the token contract checks that proof before moving anything.
The public chain sees a yes or a no, never the person. The bank is different: it approved the KYC, so it knows its investors. The privacy is from everyone else, not from the bank.
What holds
- A proof can’t be reused: each one leaves a one-time mark that the contract records.
- The contract checks that the proof matches the actual sender, recipient and amount before accepting it.
What doesn’t, yet
What I found re-reading the code after the event. It was built in a weekend, not audited.
- The check can be skipped. The token’s ordinary transfer functions move tokens with no proof at all; only the proof-carrying transfer checks one. That alone would stop this from going anywhere real.
- One investor’s proof says nothing about the person receiving. Checking the recipient needs a second proof, and only one of the transfer paths asks for it.
- The amount check can be fooled: amounts are squeezed into 64 bits before they’re compared with the limit, so a huge amount could wrap around and pass.
- Removing one investor means publishing a new root, which breaks every pending proof at once.
- The tree only fits 16 investors, and age is stored but never checked.
How a transfer flows
Built with
- Proofs
- Noir circuits, generated in the browser with NoirJS
- On-chain
- Solidity verifier and token contracts on Sepolia
- Token standard
- ERC-1400 security tokens
- App
- Next.js, MetaMask
Show the circuit
The heart of the circuit my teammate wrote (circuit/src/main.nr, condensed). The investor’s details stay private; only the root, the one-time mark, sender, recipient and amount are public.
let leaf = hash_8([name, surname, age, address, wallet, country_code, investor_type, kyc_face]);
let leaf_final = hash_2([hash_2([leaf, salt]), max_amount]);
assert(nullifier == hash_2([salt, nonce]), "InvalidNullifier");
assert(wallet == sender, "UnauthorizedSender");
// … walk the Merkle path up to merkle_root …
assert(country_valid, "InvalidCountryCode");
assert(amount as u64 <= max as u64, "AllocationLimitExceeded");