Soda Labs
Soda Labs Docs
Developer Guides

How to Transform Your Smart Contract into a Bubble Smart Contract?

Convert a public Solidity contract into a confidential one: encrypted handles, MPC logic, and explicit ACL permissions.

Turning a public Solidity contract into a Bubble-enabled, privacy-preserving one boils down to three themes: represent data as encrypted handles, move secret-dependent logic into MPC, and use the SDK/User-Interactor for encrypt/decrypt at the edges. Follow this checklist.

1. Identify private state & I/O

Decide which fields/parameters/results must be private (e.g., balance, bid, price, health data). These become encrypted instead of plaintext.

  • Public → keep as is.
  • Private → store as encrypted values and operate via MPC. Inputs are transformed to Inputtext™, while other contract' variables are transformed to garbledtexttm

2. Replace plaintext types with encrypted types

Where you previously had uint256, store an encrypted type.

For inputs, use Inputtext™, for other contract's parameter, use garbledtexttm (a handle - ID/pointer to encrypted data).

Keep a clear naming convention:

// Before
mapping(address => uint256) public balance;

// After (handle to encrypted balance)
mapping(address => gtUint256) private balanceHandle; // handle, not value

Inputs are created/encrypted off-chain using the Bubble SDK and passed to the contract as Inputtext™ (encrypted) inputs.

// Before (clear input)
function transfer(address to, uint256 value) public virtual returns (bool)

// After (encrypted input)
function transfer(address _to, itUint256 calldata _it) public returns (gtBool)

3. Remove on-chain control flow over secrets

You can’t branch/loop on ciphertext. Replace:

  • Simple “if” on secret → use mux (encrypted select) to choose between encrypted values without revealing the condition.
  • Complex logic / arithmetic / comparisons → use asynchronous MPC:
    1. Request decrypt (using the Decryption caller requestDecryption function).
    2. Wait for callback (Bubble calls your contract).
    3. Continue with the now-public result (only when truly needed).

Example (compare + conditional update)

Before (public):

if (a > b) { x = a - b; } else { x = b - a; }

After (private):

// MPC computes cmp = (a > b) as an encrypted boolean.
gtBool cmpHandle = MPCCore.gt(aHandle, bHandle)
// Use mux to select without revealing which branch:
uint256 xHandle = mux(cmpHandle, MPCCore.sub(aHandle, bHandle), MPCCore.sub(bHandle, aHandle));
// xHandle is still encrypted; store/pass it as a handle.

4. Redesign functions into request → callback flow (when needed)

Split secret-dependent operations into two transactions:

  • Tx1 (request): store request state, call DecryptionCaller.requestDecryption.... to store request data and emit requestDecryption event.
  • Tx2 (callback): callback function receives the result, verifies request status using the DecryptionCaller and updates state.
function runValidateCiphertext(itUint8 calldata it) public {
    gtUint8 ciphertext = MpcCore.validateCiphertext(it);

    uint256[] memory arr = new uint256[](1);
    arr[0] = gtUint8.unwrap(ciphertext);
    requestDecryption(arr, this.callbackValidateCiphertext.selector);
}

function callbackValidateCiphertext(uint256 decryptID, bytes[] calldata output, bytes[] calldata signatures) public verifyCallback(decryptID, output, signatures){
    // Verify inputs
    ciphertextResult = abi.decode(output[0], (uint8));

    // Continue working using the decryption result...
}

5. Grant permissions

When your contract creates a new encrypted value (a handle), it must explicitly specify who is allowed to use that value. By default, no one, not even the contract, has permission to operate on a handle unless it is granted. Therefore, after producing a new encrypted result, your contract should call MpcCore.permitThis(handle) to allow itself to use the handle in future computations. If the handle represents data owned by a user (e.g., a private balance), also grant the user access with MpcCore.permit(handle, userAddress). This ensures the user can later authorize computations or decrypt results if needed. In cases where the permission should be temporary, a transient permission can be granted instead, limiting access to a single operation or a specific time window. Proper permissioning is essential for securely controlling who can interact with encrypted values in your Bubble-enabled contract.

(gtUint128 x, gtUint128 y) = MpcCore.oprfMint(key, q);
MpcCore.permitThis(x);
MpcCore.permitThis(y);

Detailed explanations on permit functions can be found in the ACL section.


Mini ERC-20 Example (Public → Bubble)

Transfer (public)

mapping(address account => uint256) private _balances;

function transfer(address to, uint256 amount) external {
    address owner = msg.sender;
    uint256 fromBalance = _balances[owner];
    _balances[owner] = fromBalance - amount;
    _balances[to] += amount;

    emit Transfer(owner, to, amount);
    return true;
}

Transfer (Bubble, private balances)

mapping(address => gtUint256) private balances;

function transfer(address _to, itUint256 calldata _it) public returns (gtBool) {
    address owner = msg.sender;
    gtUint256 value = MpcCore.validateCiphertext(_it);
    MpcCore.permitTransient(value, owner); // Give transient permission the the sender, so the contractTransfer check will pass

    // Check if the sender is permitted to use the _value handle
    require(MpcCore.isSenderPermitted(_value));

    gtUint256 fromBalance = balanceOf(owner);
    gtUint256 ToBalance = balanceOf(_to)
    (gtUint256 newFromBalance, gtUint256 newToBalance, gtBool result) = MpcCore.transfer(fromBalance, toBalance, _value);

    // Store the new balances handles in the mapping
    balances[owner] = newFromBalance;
    balances[_to] = newToBalance;

    // Permit the contract and the _from user to use the balance handle
    MpcCore.permitThis(newFromBalance);
    MpcCore.permit(newFromBalance, owner);

    // Permit the contract and the _to to use the balance handle
    MpcCore.permitThis(newToBalance);
    MpcCore.permit(newToBalance, _to);

    emit Transfer(owner, _to);
    return result;
}

Quick Migration Checklist

  • Mark which fields/params/results must be private.
  • Switch those to handles (no plaintext on-chain).
  • Substitute secret-dependent operations with MPC functionalities.
  • Replace secret-dependent branches with mux or async MPC callbacks.
  • Grant access to result handles.

That’s the essence: keep secrets encrypted end-to-end, push secret logic into Bubble operations (using mux for simple selection), and structure your contract around request → MPC → callback.

On this page