Envelope : Sealed payments on Celo

Private payments on Celo where the sender locks tokens in a vault and the recipient claims them later with a zero-knowledge proof, no wallet address needed in advance.

Problem: A normal stablecoin transfer on a public chain is fully transparent. Amount, token, and often the relationship between wallets are easy to read on any explorer. The usual answer is heavy encrypted-balance cryptography, but that path is not practical on Celo today since it has no Zama-style FHE host.

Solution: Vault plus PaymentNFT plus ZK redeem. The sender locks ERC-20 tokens in a vault and a PaymentNFT claim ticket is minted. The NFT can be transferred like any ERC-721 (gift, invoice, handoff), and whoever holds it redeems the funds through a zero-knowledge proof bound to the note, with nullifiers so a claim can never be double-spent.

This is a 4-month build in public. I will post all the updates here as the apps ship, so follow this thread if you want to watch Envelop go from contracts to a live Celo app.

I am the solo dev behind this. You can connect with me on X (@itsNikku876) and LinkedIn (Neeraj Choubisa - n8n | LinkedIn) and follow the code on GitHub at Kali-Decoder (Nikku.Dev) · GitHub.

If you have any questions or feedback, feel free to comment here.

cc : @Prezenti.grants

3 Likes

This makes me think of an escrow account. Questions: can the PaymentNFT claim ticket be sent to a new address through the protocol so there is no connection between the depositor and redeemer? By new address, either the protocol creates a new Celo address and provides the private key to the depositor or redeemer, or the depositor specifies the address during the minting process. Can functionality be built in so the PaymentNFT claim ticket is issued after an agreed upon milestone is hit? and if the milestone is not hit the tokens in the vault are sent or unlocked by the depositor, or even another address built into the protocol.

Great questions, and yes, the escrow comparison is fair. The Vault is effectively an escrow: tokens sit locked in the contract and only move when the claim ticket is redeemed. The difference from a classic escrow is that no trusted third party holds the release decision in the base design whoever holds the PaymentNFT can redeem it because they have commitment + key to prove payment .

1. Sending the claim ticket to a fresh address

Yes, this already works. The PaymentNFT is a standard ERC-721, so it can be minted or transferred to any address, including a brand-new one with no history. The depositor specifies the recipient address at mint time.

One important correction on the private-key idea: the protocol should never generate a private key and hand it to someone. Whoever runs that generation would see the key, which breaks the whole security model. The right pattern is the reverse: the redeemer generates their own fresh address off-chain (a new wallet only they control) and shares that address with the depositor, who mints the NFT straight to it.

One honest caveat: the mint and transfer events themselves are visible on-chain, so an observer can see that address A minted a claim NFT to fresh address B. The identity link is broken (B has no history), but the transfer trail exists. Hiding even that trail needs stealth addresses, which is on our later roadmap.

2. Milestone-gated release with a refund path

Not in the current build, but very buildable on top of it, and a natural extension. The shape would be a milestone escrow module: the deposit locks tokens in the Vault without minting the claim NFT yet. An agreed arbiter or oracle attests the milestone on-chain, which triggers the mint. If the deadline passes with no attestation, the depositor (or a fallback address set at deposit time) can reclaim the tokens.

The Vault’s modular design (separate FeeManager, Treasury, AdminController) means this can be added without touching the ZK redeem path at all.

Nice idea, and a transferable claim ticket gets you gifting and invoices for free. A few things I’d settle early, because they decide how much privacy you actually get:

  1. Who can redeem. If redeem needs both the NFT and the commitment secret, the two can drift apart: someone sells the NFT but keeps a copy of the secret, or the buyer gets the NFT without it. And if it checks ownerOf, the Transfer events already link depositor to holder, so the proof adds little. I’d make one of them the only key and let the other follow it.
  2. Put the recipient and any relayer fee in the proof’s public inputs. Otherwise someone watching the mempool copies a valid proof and redeems to their own address first. You’ll probably want a relayer anyway, since a fresh claim address has nothing to pay gas with.
  3. Amounts. With arbitrary amounts, a 137.42 USDC deposit and a 137.42 USDC claim link themselves. Fixed denominations, or splitting into a few standard notes, keep the anonymity set useful. Same for timing while the pool is small.

On the milestone module, I’d make the attestation valid only before the deadline and the refund only after it, so the arbiter and the depositor can’t both win in the same block.

1. Who can redeem

You are right that two independent keys drift. The fix is to bind them instead of picking one: the note commitment includes the tokenId, and redeem requires the ZK proof plus burning the NFT owned by the caller, atomically in one transaction. A copied secret is worthless without the token, and a bare token cannot produce the proof. Transferring the claim means handing over the NFT plus the note data together, like a sealed envelope with the claim code inside. Once the NFT leaves your wallet, your copy of the secret cannot redeem anything.

Fair point on Transfer events: the transfer graph is visible on-chain, and I will not pretend otherwise. What the proof still buys is that the redeem can be relayed to a fresh address, and the payout stays bound to the note so the proof cannot be repurposed.

2. Recipient and relayer fee in public inputs

Agreed, and this is already the design: recipientDigest is a public input, so a valid proof pays out only to the committed recipient. Copying the proof from the mempool gets an attacker nothing, since they cannot change the destination, and the nullifier makes each proof single-use. And yes, a relayer is needed, since a fresh claim address holds no CELO for gas. The relayer submits the redeem and takes a fee committed in the same payload, so relayers compete on inclusion while the payout stays bound.

3. Amounts

Agreed. Odd amounts like 137.42 are fingerprints while the pool is small. The practical answer for this design: the SDK defaults to standard note sizes and splits larger payments into them, plus guidance against redeeming in the same window as the deposit while volume is low. I am treating this as a Month 3 hardening item once the end-to-end path is live.

4. Milestone module timing

Agreed, cleanest as a strict state machine: attestMilestone callable only before the deadline and only by the arbiter; refund callable only at or after the deadline and only if not attested. One-way transitions, Funded to Released or Funded to Refunded, so the arbiter and the depositor can never both win in the same block.

Binding them through the burn makes sense. Two things to check against the relayer flow though.

If redeem burns the NFT owned by the caller, a relayer can’t submit it unless the holder first approves or transfers the token, and that approval is a transaction the fresh address needs CELO for. A permit style signature from the holder over tokenId, recipient, fee and deadline, checked inside redeem, keeps the burn atomic and the holder never needs gas (ERC-4494 is the 721 version of permit).

On recipientDigest: if it’s fixed in the note at mint, whoever buys the NFT later still pays out to the original recipient. Picking the recipient at redeem time as the public input fixes that, and the seller keeping a copy of the secret doesn’t matter because the burn already covers it.

Both are right, and both are going in.

1. Permit-style burn so the holder never needs gas

Correct, the naive version breaks here: if redeem requires the caller to own the token, the relayer cannot submit it, and making the fresh holder approve first just moves the gas problem one step back.

The fix is exactly what you describe. The holder signs an EIP-712 message off-chain over (tokenId, recipient, fee, deadline). Anyone, in practice the relayer, submits redeem carrying the ZK proof plus that signature. The Vault checks the signer is the current ownerOf(tokenId) and the deadline has not passed, then verifies the proof, burns the token, marks the nullifier spent, and pays the recipient minus the relayer fee, all atomically. The holder never touches gas. ERC-4494 is the right reference; we will implement it as a redeem-scoped authorization rather than a generic approval, so a signature can never authorize anything except that exact payout.

Replay is covered twice over: the nullifier kills the proof, and the burn kills the token.

2. Recipient chosen at redeem time

Also correct, and this matches how the proof is already shaped: recipientDigest is a redeem-time public input, never baked into the note at mint. The note commits to the deposit (amount, token, secret); the prover picks the recipient when redeeming, and the proof binds the payout to exactly that address. So a secondary buyer is never stuck with the original recipient, they just choose their own fresh address at redeem time.

And you are right about the seller-kept secret: it does not matter. Redeeming consumes both the nullifier and the token, and the burn authorization must come from the current owner. A previous owner holding a copy of the secret has neither.

Interesting idea. From the wallet support side, two practical questions about regular users:

  1. If someone has the claim NFT but loses the note data (for example, they switch phones), can they still redeem it, or are the funds stuck? Clear backup guidance would matter a lot here.
  2. Many wallets hide or warn about unknown NFTs because of scam airdrops. How will a recipient know the claim ticket is a real payment and not spam?

1. If someone loses the note data, are the funds stuck?

Yes, with one exception. The claim NFT is the ticket, not the key. To redeem, you need the note data to build the ZK proof, and the NFT burns atomically when the redeem succeeds. That means the NFT alone cannot be turned into funds, and there is no backdoor or admin rescue. That is deliberate: any recovery path controlled by someone else would break the privacy the whole design exists for.

So the backup guidance is blunt, and it will be shown up front in the app: back up the note the moment you receive it, and treat it like a seed phrase. Switching phones without the note means losing access.

Two things soften this in practice. First, the sender created the note, so if they still have it, they can send it to you again. Second, note backup and restore is on the Month 3 roadmap: an encrypted note backup exported at mint, which the Envelop app can import and keep its own encrypted copy of. Until that ships, the honest guidance is “no backup, no claim.”

2. How does a recipient know the claim ticket is real and not a spam NFT?

Check the contract address. The PaymentNFT is minted by the Vault contract, so a real claim ticket always comes from the known Vault deployment. A scam airdrop NFT comes from some random contract. Wallets can verify the collection address against the published Vault address, and that check alone separates a real ticket from spam.

There is a second layer too. A real payment always arrives with note data shared off-chain by the sender, and redemption needs that data. An NFT sitting in your wallet with no note data behind it cannot be redeemed, so there is no sign-to-claim phishing surface here. Nothing real ever asks you to sign a claim blind.

1 Like