Soda Labs
Soda Labs Docs
User Interface

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:

NetworkHardhat nameChain IDRPC override
Sepoliasepolia11155111SEPOLIA_RPC_URL
Arbitrum Sepoliasepolia-arbitrum421614SEPOLIA_ARBITRUM_RPC_URL
Ethereumethereum1ETHEREUM_RPC_URL
Arbitrumarbitrum42161ARBITRUM_RPC_URL
Polygonpolygon137POLYGON_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 install

2. 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 sepolia

Swap 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)
OK

The 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.

On this page