Published on

Stellar Smart Accounts, Mercury and Blend: Building a DeFi Bot.

Authors

Bots and on-chain automation are essential components of any DeFi ecosystem, enabling you to monitor positions and take necessary actions like rebalancing and participating in auctions. However, building and deploying complex workflows in this space can be challenging, especially when they require access to historical data, ledger information, and real-time execution.

Mercury simplifies these challenges by offering a platform for creating workflows that leverage network data. With a suite of utilities, abstractions, and infrastructure features, Mercury streamlines the development process. This is largely due to the ZephyrVM, the virtual machine powering Mercury's cloud environment, which is specifically designed for handling these types of tasks. You can learn more about Mercury and Zephyr we recommend checking out the website or the documentation.

In this post we'll explore a few things:

  • What stellar smart account are
  • Why they are perfect for building DeFi on-chain cloud actions.
  • How to create a simple smart account to this purpose.
  • How to interact with protocols (Blend in this case) with the smart account. This section specifically will walk you through all of the nuances of working with smart accounts and how to submit transactions with custom auth that actually get accepted by the network, one of the sections we feel there is more lack of documentation.
  • Writing a Zephyr program that supplies XLM when there is a new USDC borrow. Note that this strategy is purposefully not proficient and it's just a way of showing how to build on-chain actions with Zephyr.

What are smart accounts on Stellar?

Smart accounts are smart contracts that act as a wallet which however lives on chain and has its own set of rules defined in its own code. Of course, since the wallet still needs to be controlled by some kind of autority in order for the owner to use its funds/identity, the smart account needs to somehow authenticate and authorize operations. But before we dive into that, let's take a closer look at Stellar's auth.

Simply put, in Stellar we have two kinds of authorization, one that is designed to authorize Soroban actions and one that is for authorizing Stellar transactions and classic operations. Within soroban authentication, we need to distinguish between stellar accounts and smart contracts.

A general rule of thumb is that stellar accounts authorize soroban actions by signing the hash of the auth stack generated by the soroban auth framework during simulation (or that you can manually build), while smart contracts inherently grant their authorization to the contracts they directly invoke, or they can specify in-code deeper authorizations.

Now, back to "the smart account needs to somehow authenticate and authorize operations". Since the Soroban VM (SVM) has native support for account abstraction this process becomes much easier and doesn't involve really any complex logic. The soroban auth framework enables smart contracts to use the reserved and non-externally-callable __check_auth() function to define the logic to follow when the smart contract's authorization to perform a certain operation is required. Since we can define our own auth logic within a contract, and due to the SVM abstracting accounts and contracts into the generic Address type we effectively end up with a "chain-controlled" account (chain-controlled because the auth logic is on-chain).

This means we can easily setup multi-sig accounts, passkey-powered accounts, or even just a single master signer-controlled account with extra security measures (e.g a timelock before a transfer). This is all incredibly cool and simple to setup on Stellar, but what does it have to do with Mercury and on-chain actions?

Why use smart accounts for cloud on-chain actions?

When working with efficient on-chain actions, we either have two complications:

  1. We need to setup a server, run a node, build infrastructure to interact with it (getting real-time updates, reading the ledger, db connection setup and logic, etc). The advantage here is that you own the execution of the actions and can generally trust it with your secret key.
  2. Rely on a fully-featured and customizable cloud infra service like Mercury's cloud execution environment, which keeps the very same efficiency of the above solution while abstracting away from the developer anything that doesn't strictly have to do with how the data is processed and when the action should be triggered. The downside here is that you don't own the execution, and while you may trust Xycloo as an organization, trusting it with your secret key is a completely different thing which we definitely don't encourage doing!

However, since we now know that smart accounts can define both custom authentication and authorization as well, we can use this to our advantage. What about a smart wallet that enables one signer to only perform a limited set of operation under a set of conditions? Thanks to smart accounts this is possible and can perfectly blend in a cloud on-chain actions executor: you give the deployed code your special limited signer's secret, and your on-chain logic decides whether the signer's signature is valid or not depending on the performed action.

Onto a more practical example, let's assume that the possible on-chain actions we have defined in a Zephyr programs are monitoring a liquidity pool and automatically rebalancing our position based on historical performances and the current state. While incorrectly rebalancing a pool can surely lead to some impermanent loss, it's surely a much more underprivileged action than withdrawing the funds to a different wallet for instance. Using the __check_auth() function we can thus setup a signer to only be able to rebalance a certain pool and by a certain limited price range and then provide the signer's secret to our Zephyr program.

We will end up with an efficient bot built and deployed with ease on the cloud, while having the guarantee that in case Mercury got critically hacked and the signer's secre leaked the worst that can happen is a slighly incorrectly balanced lp position (and later on also remove that rogue signer).

In our opinion, this is a game changer that combined with the efficiency and developer experience of the Mercury Cloud environment can lead to safe and efficient DeFi portfolio management services.

Note that Zephyr programs can potentially also be self-hosted since the VM is open-source!


Defining a (meaningless) strategy.

Before we setup anything we need to understand which on-chain action we want to perform. As I mentioned, for the scope of this post we will be choosing a strategy that is purposefully non profitable. Mainly, we will monitor for USDC borrows to happen on a blend pool and when these happen supply XLM.

Defining our custom auth.

What we're targeting is enabling a single ed25519 signer to be able to perform a submit operation on Blend's testnet fixed usdc-xlm pool. Note that the authorization scheme I will use below is not safe. A comprehensive check would inspect also the remaining auth contexts and the arguments of the submit function as well. But since defining a standard for authorizing on-chain actions depending on the auth context stack is not in the scope of the article, we'll just use a simple and authorization-wise unsafe check:

#[contracttype]
#[derive(Clone)]
pub struct Signature {
    pub public_key: BytesN<32>,
    pub signature: BytesN<64>,
}

#[contractimpl]
impl CustomAccountInterface for AccountContract {
    type Signature = Signature;
    type Error = AccError;

    #[allow(non_snake_case)]
    fn __check_auth(
        env: Env,
        signature_payload: Hash<32>,
        signature: Signature,
        auth_contexts: Vec<Context>,
    ) -> Result<(), AccError> {
        authenticate(&env, &signature_payload, &signature)?;

        // Note that this is actually unsafe and should generally not be used
        // in production. A valid signer could include the Blend submit operation
        // as part of the call stack but perform other malicious operations too.
        let mut result = Err(AccError::InvalidContext);
        
        for context in auth_contexts.iter() {
            match context {
                Context::Contract(c) => {
                    if c.fn_name == Symbol::new(&env, "submit")
                        && c.contract == env.storage().instance().get(&DataKey::BlendPool).unwrap()
                    {
                        result = Ok(());
                    }
                }
                _ => {}
            };
        };

        result
    }
}

Note that here authenticate(&env, &signature_payload, &signature) verifies the validity of the signature that is provided to the Soroban auth framework and that it belongs to a signer that has the authorization we just discussed.

User-defined Signature

Before we proceed with the full reference implementation, I want you to focus on the fact that the accepted signature in __check_auth is user-defined:

#[contracttype]
#[derive(Clone)]
pub struct Signature {
    pub public_key: BytesN<32>,
    pub signature: BytesN<64>,
}

#[contractimpl]
impl CustomAccountInterface for AccountContract {
    type Signature = Signature;

    #[allow(non_snake_case)]
    fn __check_auth(
        env: Env,
        signature_payload: Hash<32>,
        signature: Signature,
        auth_contexts: Vec<Context>,
    ) -> Result<(), AccError> {}
// ...
}

Being able to use custom signatures is exactly what makes this all possible: depending on a signature object that acts as a proof, we need to be able to verify that the proof is tied to the signature_payload, i.e the hash of the authorized call stack. This will all be clearer when we will actually invoke Blend's submit function with as source our smart wallet, but it's a crucial concept to understand.

Here's a reference implementation of the whole contract, which should be extended to be actually used in any meaningful way by at least adding funcitonality to add and remove signers, withdraw funds etc:

#![no_std]

use soroban_sdk::{
    auth::{Context, CustomAccountInterface},
    contract, contracterror, contractimpl, contracttype,
    crypto::Hash,
    Address, BytesN, Env, Symbol, Vec,
};

#[contract]
struct AccountContract;

#[contracttype]
#[derive(Clone)]
pub struct Signature {
    pub public_key: BytesN<32>,
    pub signature: BytesN<64>,
}

#[contracttype]
#[derive(Clone)]
enum DataKey {
    Signer(BytesN<32>),
    BlendPool,
}

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum AccError {
    NotEnoughSigners = 1,
    NegativeAmount = 2,
    BadSignatureOrder = 3,
    UnknownSigner = 4,
    InvalidContext = 5,
}

#[contractimpl]
impl AccountContract {
    // Add other init params here.
    pub fn init(env: Env, signer: BytesN<32>, blend_pool: Address) {
        env.storage().instance().set(&DataKey::Signer(signer), &());
        env.storage()
            .instance()
            .set(&DataKey::BlendPool, &blend_pool);
    }
}

#[contractimpl]
impl CustomAccountInterface for AccountContract {
    type Signature = Signature;
    type Error = AccError;

    #[allow(non_snake_case)]
    fn __check_auth(
        env: Env,
        signature_payload: Hash<32>,
        signature: Signature,
        auth_contexts: Vec<Context>,
    ) -> Result<(), AccError> {
        authenticate(&env, &signature_payload, &signature)?;
        //Ok(())

        // Note that this is actually unsafe and should generally not be used
        // in production. A valid signer could include the Blend submit operation
        // as part of the call stack but perform other malicious operations too.
        let mut result = Err(AccError::InvalidContext);
        
        for context in auth_contexts.iter() {
            match context {
                Context::Contract(c) => {
                    if c.fn_name == Symbol::new(&env, "submit")
                        && c.contract == env.storage().instance().get(&DataKey::BlendPool).unwrap()
                    {
                        result = Ok(());
                    }
                }
                _ => {}
            };
        };

        result
    }
}

fn authenticate(
    env: &Env,
    signature_payload: &Hash<32>,
    signature: &Signature,
) -> Result<(), AccError> {
    if !env
        .storage()
        .instance()
        .has(&DataKey::Signer(signature.public_key.clone())) {
            return Err(AccError::UnknownSigner)
        }

    env.crypto().ed25519_verify(
        &signature.public_key,
        &signature_payload.clone().into(),
        &signature.signature,
    );

    Ok(())
}

Building the Bot

This section is split in five parts:

  1. Monitoring the network and understanding when we should tigger the on-chain action.
  2. Building and simulating the transaction to supply XLM.
  3. Sign the auth payload.
  4. Adjusting the footprint
  5. Submitting the transaction.

Monitoring the Network

This is the simples section and doesn't need much of an explanation:

#[no_mangle]
pub extern "C" fn on_close() {
    let env = EnvClient::new();
    let ybx_contract = stellar_strkey::Contract::from_string(&CONTRACT).unwrap().0;
    let searched_events: Vec<PrettyContractEvent> = {
        let events = env.reader().pretty().soroban_events();
        events
            .iter()
            .filter_map(|x| {
                if x.contract == ybx_contract {
                    Some(x.clone())
                } else {
                    None
                }
            })
            .collect()
    };

    for event in searched_events {
        let action: Symbol = env.from_scval(&event.topics[0]);
        let token: Address = env.from_scval(&event.topics[1]);

        if action == Symbol::new(env.soroban(), "borrow")
            && &soroban_string_to_alloc_string(&env, token.to_string())
                == "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU"
        {
            execute_transaction(&env);
        }
    }
}

Here we are basically saying our Zephyr program to execute the execute_transaction function whenever we notice a new USDC (CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU) borrow happens.

Building and simulating the transaction

If you are already fairly familiar with Mercury and Zephyr, you'll know that it's possible to simulate transactions and send web requests from within a Zephyr program.

This means that our workflow here will be to build a valid transaction leveraging also simulation and then sending it to any horizon instance (in-program tx submission from our nodes coming soon!).

For transaction simulation and building, the steps we need to take are three:

  1. Get our submitting account's sequence number (we're using the limited signer as tx submission account).
  2. Build the submit invocation.
  3. Simulate it.
fn execute_transaction(env: &EnvClient) {
    // 1. Get the account sequence number.
    let account = stellar_strkey::ed25519::PublicKey::from_string(&SOURCE_ACCOUNT)
        .unwrap()
        .0;
    let contract = stellar_strkey::Contract::from_string(&CONTRACT).unwrap().0;
    let sequence = env
        .read_account_from_ledger(account)
        .unwrap()
        .unwrap()
        .seq_num;

        env.log().debug("Got sequence", None);

    // 2. Build invocation parameters.
    let map: Map<Symbol, Val> = map![
        &env.soroban(),
        (
            Symbol::new(&env.soroban(), "request_type"),
            2_u32.into_val(env.soroban()),
        ),
        (
            Symbol::new(&env.soroban(), "address"),
            Address::from_string(&zephyr_sdk::soroban_sdk::String::from_str(
                &env.soroban(),
                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", // XLM address
            ))
            .into_val(env.soroban()),
        ),
        (
            Symbol::new(&env.soroban(), "amount"),
            100_000_000_i128.into_val(env.soroban()),
        )
    ];
    let args: soroban_sdk::Vec<Val> = vec![
        &env.soroban(),
        address_from_str(env, ACCOUNT).into_val(env.soroban()),
        address_from_str(env, ACCOUNT).into_val(env.soroban()),
        address_from_str(env, ACCOUNT).into_val(env.soroban()),
        vec![&env.soroban(), map].into_val(env.soroban()),
    ];

    // 3. Simulate and build the transaction.
    let tx = env.simulate_contract_call_to_tx(
        SOURCE_ACCOUNT.into(),
        sequence as i64 + 1,
        contract,
        Symbol::new(&env.soroban(), "submit"),
        args,
    );

    // ...
}

Sign the auth payload.

Here comes the complex part. Now that simulation has returned us with the assembled transaction that includes all the auth entries that need to be signed. In our case, we will only need one signature (the one of our so-called limited signer) so that simplifies things a little, but this step still requires low-level XDR modifications:

let mut tx_with_signed_auth = sign_auth_entries(
    env,
    TransactionEnvelope::from_xdr_base64(tx.unwrap().tx.unwrap(), Limits::none()).unwrap(),
);

This is simple, but the sign_auth_entries isn't as simple, below I will try to break it down.

Signing the auth entry.

Before getting into the code, let's understand what we need to do here. Simulation has returned us a transaction that contains one operation, an invoke host function operation. This host function now also contains an auth field, which is the set of authorizations that should come attached to the invocation:

// From the generated XDR.
pub struct InvokeHostFunctionOp {
    pub host_function: HostFunction,
    pub auth: VecM<SorobanAuthorizationEntry>,
}

Again, in our case we really just need one as I mentioned above. But how should we craft the SorobanAuthorizationEntry?

To understand that let's take a look at its structure first:

// From the generated XDR.
pub struct SorobanAuthorizationEntry {
    pub credentials: SorobanCredentials,
    pub root_invocation: SorobanAuthorizedInvocation,
}

pub enum SorobanCredentials {
    SourceAccount,
    Address(SorobanAddressCredentials),
}

root_invocation is the host function's root invocation which subsequently contains all its sub-invocations. It's basically the set of "soroban operations" (contract calls) that will be executed.

On the other hand, credentials is either SourceAccount i.e the source of the Stellar operation (in this case providing custom proofs isn't required/possible) or SorobanAddressCredentials:

pub struct SorobanAddressCredentials {
    pub address: ScAddress,
    pub nonce: i64,
    pub signature_expiration_ledger: u32,
    pub signature: ScVal,
}

By default after simulation the fields address and nonce are already set, and we need to set the signature_expiration_ledger (currently set to 0 at this point of execution) and the signature (currently set to Void at this point of execution).

Building the signature is where you need to be careful. Remember the [User-defined Signature](#User-defined Signature) section of the post?

The content of signature should be an ScVal that correctly respects the definition of our custom-defined signature type:

#[contracttype]
#[derive(Clone)]
pub struct Signature {
    pub public_key: BytesN<32>,
    pub signature: BytesN<64>,
}

Here public_key is the signer's public key and signature is the result of having the signer sign the so-called "authorization preimage". To build the preimage we export a convenient sdk function:

let preimage = build_authorization_preimage(
    credentials.nonce,
    new_sequence,
    auth.clone().root_invocation,
);

We can now hash the preimage with another SDK helper and sign it with yet another helper:

let payload = sha256(&preimage.to_xdr(Limits::none()).unwrap());
let (public, signature) = ed25519_sign(&SECRET, &payload);
let public = public.to_bytes();

We are now ready to build our signature object:

let signature: Map<Val, Val> = map![
    &env.soroban(),
    (
        Symbol::new(&env.soroban(), "signature").into_val(env.soroban()),
        BytesN::from_array(&env.soroban(), &signature).into_val(env.soroban())
    ),
    (
        Symbol::new(&env.soroban(), "public_key").into_val(env.soroban()),
        BytesN::from_array(&env.soroban(), &public).into_val(env.soroban())
    ),
];

Now that we have the signature, the last missing piece of the auth entry is the signature expiration ledger. In our case, we've decided for a signature validity of 100 ledgers starting from the current and then finish setting up the correct credentials:

let new_sequence = env.reader().ledger_sequence() + SIGNATURE_DURATION;
credentials.signature_expiration_ledger = new_sequence;
credentials.signature = env.to_scval(signature);

Below is the full code which also contains all the XDR extractions and modifiers:

fn sign_auth_entries(env: &EnvClient, tx: TransactionEnvelope) -> Transaction {
    let new_sequence = env.reader().ledger_sequence() + SIGNATURE_DURATION;
    let TransactionEnvelope::Tx(TransactionV1Envelope { mut tx, .. }) = tx else {
        panic!()
    };
    let source = tx.operations.to_vec()[0].source_account.clone();
    let xdr::OperationBody::InvokeHostFunction(mut host_function) =
        tx.operations.to_vec()[0].body.clone()
    else {
        panic!()
    };
    let mut auth = host_function.auth.to_vec()[0].clone();
    let xdr::SorobanCredentials::Address(mut credentials) = auth.clone().credentials else {
        panic!()
    };

    let preimage = build_authorization_preimage(
        credentials.nonce,
        new_sequence,
        auth.clone().root_invocation,
    );
    let payload = sha256(&preimage.to_xdr(Limits::none()).unwrap());
    let (public, signature) = ed25519_sign(&SECRET, &payload);
    let public = public.to_bytes();

    let signature: Map<Val, Val> = map![
        &env.soroban(),
        (
            Symbol::new(&env.soroban(), "signature").into_val(env.soroban()),
            BytesN::from_array(&env.soroban(), &signature).into_val(env.soroban())
        ),
        (
            Symbol::new(&env.soroban(), "public_key").into_val(env.soroban()),
            BytesN::from_array(&env.soroban(), &public).into_val(env.soroban())
        ),
    ];

    credentials.signature_expiration_ledger = new_sequence;
    credentials.signature = env.to_scval(signature);
    auth.credentials = xdr::SorobanCredentials::Address(credentials);
    host_function.auth = std::vec![auth].try_into().unwrap();

    tx.operations = std::vec![xdr::Operation {
        source_account: source,
        body: xdr::OperationBody::InvokeHostFunction(host_function)
    }]
    .try_into()
    .unwrap();

    tx
}

Adjusting the footprint.

The complex part is finally over but we still have some work to do:

  1. Since simulation doesn't account for calls to __check_auth since those are internal calls reserved solely to the auth framework, we need to include the smart account contract in the footprint (so both instance and code).
  2. Since Zephyr is currently running a master branch of soroban simulationa and we need to also account for signature verficiation which isn't included when simulating in recording mode, we also need to adjust our fee and resources configuration.

Adding the smart account to the footprint.

This step is crucial since generally we expect simulation to actually cover anything footprint-related but this isn't the case at the moment for smart accounts. If we ignore the fact that simulation doesn't account for the smart wallet since it wasn't directly invoked in the call stack this is what we end up with:

So let's now add the instance and code to the footprint. We actually do this thorugh another useful helper SDK function:

let TransactionExt::V1(mut v1ext) = tx_with_signed_auth.ext else {
    panic!()
};
let mut r = v1ext.resources;
let mut footprint = r.footprint;
add_contract_to_footprint(
    &mut footprint,
    &ACCOUNT,
    &hex::decode(ACCOUNT_HASH).unwrap(),
);
r.footprint = footprint;

Adjusting resources and fee.

This is quite straightforward and in any production setup we recommend checking that these are optimized:

r.instructions += INSTRUCTIONS_FIX;
r.write_bytes += WRITE_BYTES_FIX;
r.read_bytes += READ_BYTES_FIX;
v1ext.resource_fee += RESOURCE_FEE_FIX;
v1ext.resources = r;
tx_with_signed_auth.ext = TransactionExt::V1(v1ext);
tx_with_signed_auth.fee += FEE_FIX;

Signing and Submitting the transaction.

We've signed our auth entries, but hey someone has got to pay for the transaction fees right?

Luckily, we also have a very handy SDK helper for this situation, sign_transaction:

let signed = sign_transaction(tx_with_signed_auth, &NETWORK, &SECRET);
env.send_web_request(AgnosticRequest {
    body: Some(format!("tx={}", encode(&signed))),
    url: "https://horizon-testnet.stellar.org/transactions".to_string(),
    method: zephyr_sdk::Method::Post,
    headers: std::vec![(
        "Content-Type".to_string(),
        "application/x-www-form-urlencoded".to_string()
    )],
});

The web request will also sound familiar if you've already worked directly with Horizon. That said, beware that a prioritized tx submission host function which directly submits from our node is being developed!


The program is now complete! You can find the complete codebase (smart wallet + zephyr program) in the zephyr examples repo.

Stellar's native account abstraction features offer incredible potential that remains largely untapped. We hope this in-depth post will serve as a valuable resource for other developers looking to explore this exciting field.

The combination of smart wallets and Zephyr-powered DeFi bots is especially powerful, and we plan to continue exploring this synergy. We’ll also be adding new functionalities as needed. This post offers just a glimpse into the many use cases within the broader DeFi ecosystem, particularly when your bot runs on Mercury. With Mercury, you gain access not only to real-time data but also to historical data, ledger information, and even custom-indexed tables like the Blend index or the soroswap index.

We’re excited to hear your feedback and explore new ideas together. If you have any thoughts or suggestions, please don’t hesitate to reach out!