Tutorial: Building cross-chain atomic swaps with private smart contracts

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

Intro

In this tutorial, we'll build a cross-chain atomic swap—but instead of using bridges and escrow smart contracts on intermediary chains, we'll use Cubist Confidential Cloud Functions (C2F).

In this article

Cross-chain atomic swaps allow multiple parties---e.g., Alice and Bob---to exchange tokens on one chain (e.g., BTC.b on Avalanche) for tokens on another (e.g., USDT on Ethereum) atomically, i.e., if Alice sends Bob BTC.b she's guaranteed to receive Bob's USDT and vice versa. Traditionally, cross-chain atomic swaps are implemented using a trusted party (e.g., a CEX) or using smart contracts on an intermediary blockchain. This introduces risk: counterparty risk, bridge security risk, and market-volatility risk (e.g., because the on-chain swap takes many seconds to minutes).

In this tutorial, we'll use Cubist C2F to implement:

(1) a simplified private order book that allows two parties to initiate the swap, and

(2) a private smart contract that is tied to the parties' accounts (i.e., their signing keys), ensuring each party completes their side of the swap (i.e., they transfer the funds they owe).

While our tutorial focuses on a simplified implementation, the primitives we'll build can serve as a starting point for implementing a product that allows users to swap any token on any chain---chains with and without smart contracts.

What we're building

We'll implement a two-party atomic swap of ERC-20 tokens across two EVM chains.

First, we'll implement a C2F "order book" function that is used to initiate the swap: the function records the desired swap order as debts---e.g., Alice owes Bob 1 BTC.b (on Avalanche), Bob owes Alice 90,484 USDT (on Ethereum)---after verifying that each party's wallet has the corresponding funds and no outstanding debts. Then, we'll implement a C2F private smart contract, which is attached to Alice and Bob's wallets, turning each wallet into a smart account that allows them to pay their debt---but only allows them to sign other transactions after verifying on-chain that they paid their debt (and clearing the debt from the local order book).  This example works for EVM chains and ERC-20 tokens, but you can extend it to native tokens and chains of your choice—Solana, Sui, or even chains without native smart contract support, like Bitcoin or Zcash.

The Order Placement Confidential Cloud Function

Below, we define a C2F that records swap orders in an internal order book, keeping track of the "debt" owed by each wallet in a swap order, i.e., the amount they are required to pay as part of the swap. The function is invoked with the order details, and then:

  1. Verifies that each party signed the order.
  2. Checks on-chain that each party has the funds to fulfill the order (and, e.g., doesn't have any third-party allowed spenders).
  3. Fetches the `nonce` for each wallet.  This nonce will be used to prevent doublespending and ensure that the swap debt payment is the only allowed transaction for _next_ nonce value.
  4. Saves the swap order and `nonce` in the "order_book" key-value store.

Steps 2 - 4 can be executed atomically by creating an entry in key-value store that serves as a lock for each wallet; we elide this for simplicity.  Instead, we focus on a simplified handle_order function that implements the core logic:

pub async fn handle_order(order: SwapOrder) -> Result<()> {
    let order_book = keyvalue::open("order_book")?;

    // 1. Verify that each party signed the swap order, agreeing to fulfill
    // their side of the swap.
    verify_order_signatures(&order).await?;

    // 2. Verify that each party has sufficient funds and no spenders
    check_balance_on_chain(&order.first_party).await?;
    check_balance_on_chain(&order.second_party).await?;

    // 3. Fetch nonces for each wallet.
    let first_wallet_nonce = fetch_wallet_nonce(order.first_party.from, order.first_party.chain_id).await?;
    let second_wallet_nonce = fetch_wallet_nonce(order.second_party.from, order.second_party.chain_id).await?;
    let second_wallet_nonce = fetch_wallet_nonce(second_wallet).await?;

    // 4. Record the order as a debt for each wallet.
    //
    // The `IfExists::Deny` asserts that neither wallet has a previous
    // outstanding order/debt.
    order_book.transaction_write(&[
        WriteAction::Set((
            order.first_party.from.to_string(),
            WalletState {
                debt: order.first_party,
                nonce: first_wallet_nonce,
            }
            .into(),
            IfExists::Deny,
        )),
        WriteAction::Set((
            order.second_party.from.to_string(),
            WalletState {
                debt: order.second_party,
                nonce: second_wallet_nonce,
            }
            .into(),
            IfExists::Deny,
        )),
    ])?;

    Ok(())
}

This is a normal Rust function that has access to a C2F transaction key-value store and an HTTP API. We use the key-value store to track all the swap orders, as shown above.  We use the HTTP API to verify on-chain data by making requests to an RPC provider; for example, our nonce fetching function as use above is simply:

/// Fetch the current nonce for a wallet on the given chain.
///
/// It calls the `eth_getTransactionCount` JSON-RPC method for the given wallet.
async fn fetch_wallet_nonce(wallet: Address, chain_id: u64) -> Result<U256> {
    let client = json_rpc::Client::new(rpc_url(chain_id)?)?;
    let nonce =
        rpc_call!(client, "eth_getTransactionCount", wallet, "latest").await?;
    Ok(nonce)
}

The order-placement C2F is used to initiate a swap order by invoking it via the Cubist API, CLI or SDK. But to actually ensure the parties pay their debts we need to turn the parties' wallets into smart accounts---by attaching a C2F function to their keys. We describe this function next.

Smart account validation logic: ensure swap is fulfilled

This function is the private smart account's validation logic.  Unlike our order-placement Confidential Cloud Function, this function is tied to each party's key as a policy---and returns AccessDecision::Allow, to allow the party to sign the transaction, and AccessDecision::Deny otherwise.  Our logic restricts the use of the wallet if it has an outstanding debt. Specifically it only allows a party to sign a transaction if there is no outstanding debt; otherwise, it only allows signing a transaction that pays the outstanding debt. A party can provide proof that they've paid their debt by attaching metadata to a signing request---specifically the transaction hash that points to the on-chain payment. We implement this below:

#[policy]
pub async fn main(req: AccessRequest) -> Result<AccessDecision> {
    let order_book = keyvalue::open("order_book")?;

    // Get and deserialize any metadata attached to the signing request
    let metadata: Option<Metadata> =
        req.metadata().map(serde_json::from_value).transpose()?;

    // Get the address of the key this request is tied to
    let wallet = Address::from_str(&req.key_material_id)?;
    // Check the order_book for any debts tied to the current key
    match (
        order_book
            .get(&req.key_material_id)?
            .map(WalletState::try_from)
            .transpose()?,
        &metadata.and_then(|m| m.order_payment_tx_hash),
    ) {
        (Some(state), None) => { // The wallet has outstanding debt.
            let tx = req.evm_sign_request()?;
            // Decode the EVM transaction, verifying it's an ERC-20 transfer.
            let payment = Payment::try_from(wallet, &tx)?;

            // Check that this payment transaction pays that outstanding debt.
            if payment != state.debt || tx.tx.nonce() != Some(&state.nonce) {
                return Ok(AccessDecision::Deny(format!(
                    "Wallet must pay existing debt before other transactions are allowed: {state:?}"
                )));
            }

            Ok(AccessDecision::Allow)
        }
        (Some(state), Some(tx_hash)) => { // The wallet has outstanding debt.
            // Confirm that the given transaction hash provided as metadata
            // settles the debt, and update the state.
            if !check_payment_on_chain(wallet, &state, tx_hash).await? {
                return Ok(AccessDecision::Deny(format!(
                    "Wallet must pay existing debt before other transactions are allowed: {state:?}"
                )));
            }

            // Remove the debt from the order book and allow the transaction
            order_book.delete(&req.key_material_id)?;
            Ok(AccessDecision::Allow)
        }
        // The wallet has no outstanding debt. Allow any transaction.
        (None, _) => Ok(AccessDecision::Allow),
    }
}

This function is invoked on each signing request. To verify if the signing request corresponds to the debt payment (or another transaction) we decode the transaction into a Payment request using try_from:

/// Try to decode an EVM signing request into a Payment request
impl TryFrom<&EvmSignRequest> for Payment {
    type Error = PolicySdkError;

    /// Return the Payment details if the given transaction data is an
    /// ERC-20 `transfer` call.
    fn try_from(from: Address, value: &EvmSignRequest) -> Result<Payment, Self::Error> {
        // Decode the transaction
        let (to, amount) = decode_erc20_transfer(
            value.tx.data().ok_or("Transaction 'data' missing")?,
        )?;
        // Extract the token address
        let token = *value
            .tx
            .to()
            .and_then(NameOrAddress::as_address)
            .ok_or("Transaction 'to' address missing")?;
        // Return the well-typed payment request
        Ok(Payment {
            chain_id: value.chain_id,
            from,
            to,
            token: token.into(),
            amount,
        })
    }
}

Internally, try_from uses the ethers_core library to decode the transaction data given the (relevant) ERC-20 ABI:

/// Try to decode the given transaction data as an ERC-20 `transfer` call,
/// and return the `to` and `amount`.
fn decode_erc20_transfer(data: &Bytes) -> Result<(Address, U256)> {
    let data = data.iter().as_slice();

    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();

    // 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 two arguments as tokens
    let amount = inputs
        .pop()
        .unwrap()
        .into_uint()
        .ok_or("Invalid 'transfer' call: invalid amount")?;
    let to = inputs
        .pop()
        .unwrap()
        .into_address()
        .ok_or("Invalid 'transfer' call: invalid to address")?;

    Ok((to.into(), amount))
}

We also use this function to decode on-chain transactions. Specifically, we use it in check_payment_on_chain; this function fetches the payment transaction (corresponding to user-provided the transaction hash tx_hash) and verifies the transfer corresponds to the wallet's outstanding debt.

/// Check that the given transaction matches the given payment.
async fn check_payment_on_chain(
    wallet: Address,
    state: &WalletState,
    tx_hash: &str,
) -> Result<bool> {
    let client = json_rpc::Client::new(rpc_url(state.debt.chain_id)?)?;
    // Fetch transaction give the hash
    let tx: Transaction =
        rpc_call!(client, "eth_getTransactionByHash", tx_hash).await?;
    // Decode the transfer
    let (to, amount) = decode_erc20_transfer(&tx.input)?;

    // Verify the wallet nonce and debt details
    Ok(tx.from == wallet.0
        && tx.nonce == state.nonce
        && tx.to == Some(state.debt.token.into())
        && to == state.debt.to
        && amount == state.debt.amount)
}

Build and deploy the C2F code

As we describe in the smart account tutorial, we compile C2F functions to Wasm:

cargo build --release

And then upload both functions to Cubist's C2F environment:

cs c2f create --type wasm --name "place_order" target/wasm32-wasip2/release/place-order.wasm
cs c2f create --type wasm --name "pay_debt" target/wasm32-wasip2/release/pay-debt.wasm

We can invoke the order-placing function using the CLI (or API/SDK):

cs c2f invoke --name "place_order/latest" ....

Finally, we attach our smart account logic to ensures the parties can only sign arbitrary transaction once they've paid their debt:

# as first party:
cs key set-policy -k $PARTY_1_KEY_ID --policy '"pay_debt/latest"'
# as second party:
cs key set-policy -k $PARTY_2_KEY_ID --policy '"pay_debt/latest"'

The smart account will now ensure the swap order is fulfilled.

In practice, we would use CubeSigner roles to allow either party to sign the swap-fulfilling transaction, use governance policies to ensure that both parties approve any policy changes, and verify the policies attached to each party's key.

Beyond cross-chain atomic swaps

Confidential Cloud Functions make it easy to write and deploy code that runs in a TEE (e.g., the swap order-placing function we wrote in this tutorial) without standing up and managing your own infrastructure. When attached to keys, C2F also gives you the ergonomics of smart accounts without deploying smart contracts or exposing your signing rules publicly. As in this example, you can write functions that interact with private data without revealing that data (e.g., swap orders).  You can also interact with external APIs (e.g., our example queries different RPCs to verify on-chain payments), interact with data on different (and/or multiple) chains (e.g., we decode and sign transactions on multiple EVM chains), and use existing, mature and battle-tested Rust libraries. Cross-chain atomic swaps is only one of many functions C2F unlocks.

Keep reading

About

Blog & Updates

Explore Related Blog Posts