Tutorial: Building private smart accounts

A step-by-step programming tutorial using Cubist Confidential Cloud Functions

Intro

In this tutorial, we’ll build smart accounts whose programmable rules are enforced by private smart contracts running on Cubist Confidential Cloud Functions (C2F), rather than by on-chain smart contracts.

In this article

Smart accounts let developers define custom rules that determine when an account is allowed to sign. Traditionally, this validation lives on-chain, in a smart contract. In this tutorial, we’ll build the same kind of programmable validation—but instead of deploying contracts, we’ll implement a private smart contract that runs as a Cubist Confidential Cloud Function (C2F). This gives you a smart account with programmable rules while simultaneously keeping your signing logic private.

What we’re building

We’ll implement a smart account that enforces ERC-20 transfer limits. Our private smart contract will: parse an EVM transaction, decode ERC-20 transfers, look up the allowed maximum spend for a given token, and determine whether the account is allowed to sign. This example works for EVM chains, but you can use your favorite transaction parsing library to re-create it for the chain of your choice—Solana, Sui, or even chains without native smart contract support, like Bitcoin or Zcash.

How private smart accounts work with CubeSigner + Cubist C2F

In this example, we use a key stored in CubeSigner, a hardware-backed key management system, as our EOA. Then, to turn this EOA into a smart account, we attach a Confidential Cloud Function with spending rules to the key; the cloud function encodes our ERC-20 spending limit. Together, the key and the cloud function are a private smart contract wallet.

Tutorial: Enforcing ERC-20 transfer limits with a private smart account

Below, you'll implement the core of the smart account: the private validation logic that parses an EVM transaction and decides whether the account is permitted to sign.

1. Set up a new project for the C2F function

Create a new binary with cargo:

cargo new --bin erc-limit
cd erc-limit

Navigate to the erc-limit directory, and execute the rest of the instructions in this tutorial from there.

To compile your C2F function, you'll need to add the C2F SDK as a dependency; contact us to be added to the git repo. You'll also need to compile your project to WebAssembly, which is what C2F executes. Add the following to your Cargo config (.cargo/config.toml):

[build]
target = "wasm32-wasip2"

2. Store the per-token limits as private secrets

First, before writing our C2F code, we'll configure the per-token transfer limits by creating a secret called ERC20_VALUE_LIMITS.

This secret will store the value limits for each token address in a map. Let's say we only want to allow interactions with the UCDC ERC-20 token on Avalanche testnet, with transfers of 1,000,000 (0xf4240) or less:

cs org policy-secrets set { "0x5425890298aed601595a70AB815c96711a31Bc65": "0xf4240" }

This command assumes you have access to the CubeSigner CLI; contact us if you do not.

3. Retrieve the limit for a given token

Next, we’ll write a function,  value_limit_for, that retrieves the ERC-20 transfer limit for a given token. Open up src/main.rs and add the following code:

use std::collections::HashMap;

use cubist_policy_sdk::config;
use ethers_core::types::{Address, U256};

/// Return the value limit for the given ERC-20 contract, if one exists.
fn value_limit_for(contract: &Address) -> Result<U256> {
    // Fetch the limits secret
    let limits = config::get("ERC20_VALUE_LIMITS")?
        .ok_or("ERC-20 value limits not found")?;
    let mut limits: HashMap<Address, U256> = serde_json::from_str(&limits)?;
    let limit = limits
        .remove(contract)
        .ok_or(format!("EVM contract not recognized: {contract}"))?;
    Ok(limit)
}

4. Extract transfer amount

To implement our ERC-20 transfer limit, we need to compare the private limit to the amount of the transfer being attempted. We will examine the EVM sign request and parse the transfer method call data in the below parse_transfer_value function:

use ethers_core::abi::{FunctionExt as _, parse_abi};

/// Parse the transaction data as an ERC-20 "transfer" function.
fn parse_transfer_value(sign_request: &EvmSignRequest) -> Result<U256> {
    // Get the ABI for the transfer function.
    let mut abi = parse_abi(&[
        "function transfer(address _to, uint256 _value) public returns (bool success)",
    ])
    .unwrap();
    let func = abi.functions.remove("transfer").unwrap().pop().unwrap();

    // Get the transaction data
    let data = sign_request
        .tx
        .data()
        .ok_or("transaction data missing")?
        .iter()
        .as_slice();

    // Check the selector.
    if data[0..4] != func.selector() {
        Err("Invalid 'transfer' call: invalid selector")?;
    }

    // Try to decode the input from the data.
    let mut inputs = func
        .decode_input(&data[4..])
        .map_err(|err| format!("Invalid 'transfer' call: {err}"))?;

    // Get the transfer value
    let value = inputs
        .pop()
        .unwrap()
        .into_uint()
        .ok_or("Invalid 'transfer' call: invalid amount")?;

    Ok(value)
}

5. Implement the smart account validation logic: check the transfer value against the limit

This function is the private smart account's validation logic. It lives in the main function in src/main.rs. If it returns AccessDecision::Allow, our key will sign the transaction; if it returns AccessDecision::Deny, our key will refuse to sign the transaction.

use cubist_policy_sdk::{AccessDecision, AccessRequest, error::Result, policy};
use ethers_core::types::NameOrAddress;

/// Only allow ERC-20 `transfer` calls to known
/// contracts, and limits each transfer to a given amount.
#[policy]
async fn main(req: AccessRequest) -> Result<AccessDecision> {
    // Get the sign request, assuming it's an EVM transaction.
    let tx = req.evm_sign_request()?;

    // Get the value limit from the policy secrets.
    let contract_address = tx
        .tx
        .to()
        .and_then(NameOrAddress::as_address)
        .ok_or("to address missing")?;
    let value_limit = value_limit_for(contract_address)?;
    let value = parse_transfer_value(&tx)?;

    // Enforce the rule
    if value > value_limit {
        Ok(AccessDecision::Deny(format!(
            "Transfer value '{value}' is above the limit '{value_limit}'"
        )))
    } else {
        Ok(AccessDecision::Allow)
    }
}

6. Deploy the private smart contract

Now that we've written our private smart contract, we can build it with:

cargo build --release

And we can upload that code as a Confidential Cloud Function:

cs c2f create --type wasm --name "erc_limit" target/wasm32-wasip2/release/erc-limit.wasm

Finally, to attach our smart account logic to a specific EOA, we do the following:

cs key set-policy -k $KEY_ID --policy '"erc_limit/latest"'

The smart account will now enforce ERC-20 transfer limits privately, per-signature.

Beyond ERC-20 limits: Why private smart accounts matter

This approach gives you the ergonomics of smart accounts without deploying smart contracts or exposing your signing rules publicly. As in this example, you can write rules that interact with private data without revealing that data. You can also interact with external applications (e.g., transaction monitoring services, compliance/risk systems, etc.), interact with data on different (and/or multiple) chains, and use any existing Rust library you like. This makes it dramatically easier to, for example, manage a fleet of (private) smart contracts across many chains.

Keep reading

About

Blog & Updates

Explore Related Blog Posts