Putting it together
Deploy a confidential ERC-20, transfer an amount that never appears on-chain, and read both balances back.
Everything on the previous pages in one run: a confidential ERC-20 deployed to a live Bubble network, a transfer whose amount never appears on-chain, and the resulting balances read back by the only two people entitled to see them.
You need Node 18 or newer, and a funded account on a supported chain — supplied as a mnemonic phrase, not a private key, since the script derives the recipient and master accounts from it.
Sepolia is used below. To use another network, pass its Hardhat name with --network:
| Network | Hardhat name | Chain ID | RPC override |
|---|---|---|---|
| Sepolia | sepolia | 11155111 | SEPOLIA_RPC_URL |
| Arbitrum Sepolia | sepolia-arbitrum | 421614 | SEPOLIA_ARBITRUM_RPC_URL |
| Ethereum | ethereum | 1 | ETHEREUM_RPC_URL |
| Arbitrum | arbitrum | 42161 | ARBITRUM_RPC_URL |
| Polygon | polygon | 137 | POLYGON_RPC_URL |
Note the Hardhat name for Arbitrum Sepolia is sepolia-arbitrum, reversed from how the chain is
usually written. Networks default to Alchemy and need ALCHEMY_API_KEY; set the override for a
network to use your own endpoint instead.
1. Get the example
git clone https://github.com/soda-mpc/bubble-app-contracts
cd bubble-app-contracts/wallet
npm install2. Run the flow
Set MNEMONIC to a funded account on a supported chain:
MNEMONIC="your twelve word phrase ..." \
npx hardhat run scripts/run-private-erc20-live.ts --network sepoliaSwap sepolia for any Hardhat name in the table above. (npm run demo:live is a shorthand for
the Sepolia case only — npm cannot forward --network to hardhat, so use the form above for
other networks.)
The run prints progress from the transport helpers — four lines per onboarding from
[getUserKeyViaProxy], and two per balance read tagged [decryptValueViaProxy]. That logging is
expected; the sample below has it removed for readability.
The Attempt N failed, retrying line is kept, because it is expected too and worth recognising:
a value computed by MPC is not readable the instant the transaction confirms, so the helper
retries until the network has finished. It is not an error.
chain 11155111 — deployer 0x71956609E2987C156c1fB1df2e6392E340718D4B
onboarded — AES user key acquired
deployed private token at 0x55dC5a89a88644B0aA0D89F0bFe586DC22618a36
balance before: 10000
transferred 1000 to 0x2414f5C5D43847C48a01E6924B4548a96359266C — amount encrypted, not in calldata
Attempt 1 failed, retrying in 1000ms...
balance after: 9000
recipient balance: 1000 (decrypted with the recipient’s own key)
OKThe last balance line is the point of the demo: the recipient decrypts its own balance with its own AES key, and neither party can read the other's.
What just happened
The contract deployed against the Bubble contracts that already exist. You do not deploy
GCACL, GCHandler or GCDecryptionVerifier — Soda Labs operates them. MpcCore selects the
right ones for your chain by block.chainid, which is why the same source works everywhere.
The deployer was onboarded. Onboard a user exchanges an RSA public key for two encrypted shares of an AES key, which the SDK reassembles locally. That AES key is what makes encrypted values readable to you and nobody else. It never leaves your machine.
Shielding turned a public balance into an encrypted handle. The script mints an ordinary
ERC-20 and shields it into the private token. The contract calls MpcCore.permitThis and
MpcCore.permit on the resulting handle — without those grants the balance would exist but be
readable by no one, including the contract that created it.
The transfer amount was encrypted before it was sent. prepareMessageForBubble256 encrypts it with your AES
key, and the contract turns the result into a handle with MpcCore.validateCiphertext. The
transaction calldata carries the sender, the recipient and two ciphertext words — the amount
appears nowhere in it. See Prepare private inputs.
The balances were read back through the HTTP API. Read an encrypted value returns each balance encrypted to its owner's AES key. Sender and recipient each decrypt their own; neither can read the other's.
Nothing was taken on trust. Every response carries mpc_signatures from the MPC evaluators.
The script recovers the signers and checks them against getSigners() on the on-chain decryption
verifier before decrypting anything.
Two things that will catch you out
A handle is unusable until it is permitted. Not even by the contract that created it. If
reading a value returns user is not permitted to make this operation, a MpcCore.permit call is
missing — nothing on-chain will tell you that.
A handle is not readable the instant a transaction confirms. While the network is still
computing, reading returns unknown handle, then computation is pending, before the value
becomes available. Retry for a few seconds rather than treating it as a failure.
This applies to any value the network has not computed before, including ones created with
MpcCore.setPublic* — a first-time value still needs to be garbled. A value that has been
computed before is available immediately, so the same call can look instant in one run and take
several seconds in the next.
Your own contract
Quick Start for Contract Devs covers installing the library and writing a contract against it. How to Transform Your Smart Contract walks a public contract through to a Bubble one.