Skip to main content

STXN Solver SDK (Solvers + CallBreaker)

Audience: Operators building solvers that listen for STXN objectives, plan DAG execution, and call executeAndVerify on CallBreaker to deliver results and earn tips.

Scope: Event ingestion, objective selection, planning (orderOfExecution), assembling returnsBytes, MEV‑time metadata, signature handling, settlement, and robust operations. App integration is documented separately in the Application SDK.

Key facts

  • A single CallBreaker per chain. All apps push their UserObjectives to the same instance.
  • Push is on‑chain and signature‑free; execution later requires user EOA signatures over (nonce, sender, callObjects).
  • Solvers race/compete to execute; settlement credits the msg.sender of executeAndVerify with gas+tip from users’ funded balances.

0) Quick Start (minimal viable solver)

Solvers are typically built in Rust. Below is a production-friendly Rust skeleton using ethers v2. A TypeScript example remains in §0.2 for teams that prefer Node.

0.1 Rust (ethers-rs) — Listen → Plan → Execute

Prereqs

  • RPC endpoint, per‑chain CallBreaker address
  • A funded solver key (for calldata gas)
  • STXN repo as submodule for ABIs

Install (repo as submodule for ABI/types)

git submodule add https://github.com/smart-transaction/stxn-smart-contracts-v2.git lib/stxn
cd lib/stxn && forge build

Cargo.toml

[package]
name = "stxn-solver"
version = "0.1.0"
edition = "2021"

[dependencies]
eysre = "0.6"
dotenvy = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
ethers = { version = "2", features = ["abigen","ws","rustls","ipc"] }

Generate bindings from ABI (at compile time)

use ethers::{
abi::Bytes,
prelude::*,
types::{Address, U256}
};
use eyre::Result;
use std::sync::Arc;

abigen!(
CallBreaker,
// Path produced by Foundry build in the submodule
"lib/stxn/out/CallBreaker.sol/CallBreaker.json"
);

#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
let rpc = std::env::var("RPC_URL")?;
let pk = std::env::var("SOLVER_PK")?;
let callbreaker_addr: Address = std::env::var("CALLBREAKER_ADDRESS")?.parse()?;

// Provider + wallet
let provider = Provider::<Ws>::connect(rpc).await?; // or Http::new(rpc)
let wallet: LocalWallet = pk.parse::<LocalWallet>()?.with_chain_id(provider.get_chainid().await?.as_u64());
let client = SignerMiddleware::new(provider, wallet);
let client = Arc::new(client);

// Contract handle
let cb = CallBreaker::new(callbreaker_addr, client.clone());

// 1) Stream new objectives
let mut stream = cb
.user_objective_pushed_filter()
.from_block(BlockNumber::Latest)
.stream()
.await?;

while let Some(evt) = stream.next().await {
let evt = evt?;
let request_id = evt.request_id;
let uo = evt.user_objective; // generated struct matching solidity
let mev = evt.mev_time_data;

// 2) Score/select
if !should_execute(&uo) { continue; }

// 3) Plan DAG → order & returns
let (order, returns) = plan(&uo).await?;

// 4) Attach user signature (provided by app/user off-chain)
let signed = attach_signature(uo).await?; // fill .signature

// 5) Build MevTimeData
let mev_data = build_mev_data(mev).await?;

// 6) Execute & Verify
let tx = cb
.execute_and_verify(vec![signed], returns, order, mev_data)
.send()
.await?;
let receipt = tx.await?;
println!("submitted {:?} → {:?}", request_id, receipt.map(|r| r.transaction_hash));
}
Ok(())
}

fn should_execute(uo: call_breaker::UserObjective) -> bool {
// Example: filter by tip, appId allowlist, call count
uo.tip > U256::from(0) && uo.call_objects.len() <= 8
}

async fn plan(uo: call_breaker::UserObjective) -> Result<(Vec<U256>, Vec<Bytes>)> {
// Topological/heuristic order over call_objects
let n = uo.call_objects.len();
let order: Vec<U256> = (0..n as u64).map(U256::from).collect();
let returns: Vec<Bytes> = uo
.call_objects
.iter()
.map(|c| if c.verifiable && c.returnvalue.0.is_empty() { Bytes::from_static(&[]) } else { c.returnvalue.clone() })
.collect();
Ok((order, returns))
}

async fn attach_signature(mut uo: call_breaker::UserObjective) -> Result<call_breaker::UserObjective> {
// Obtain user EOA signature over (nonce, sender, abi.encode(callObjects)) off-chain
// uo.signature = Bytes::from(signature_bytes);
Ok(uo)
}

async fn build_mev_data(vals: Vec<call_breaker::AdditionalData>) -> Result<call_breaker::MevTimeData> {
Ok(call_breaker::MevTimeData { validator_signature: Bytes::from_static(&[]), mev_time_data_values: vals })
}

Note: The abigen! path assumes you’ve built the submodule with Foundry so the JSON ABI exists at lib/stxn/out/.... If your layout differs, adjust the path.

0.2 TypeScript (optional)

A Node/TS reference loop is still included later for teams already running JS infra.


1) Data Shapes (execution-time)

At execution, CallBreaker expects expanded structures (user‑signed) and arrays for planning/verification.

struct CallObject {
uint256 salt; uint256 amount; uint256 gas; address addr; bytes callvalue; bytes returnvalue;
bool skippable; bool verifiable; bool exposeReturn;
}

struct UserObjective { // execution-time variant includes signature
bytes appId; uint256 nonce; uint256 tip; uint256 chainId;
uint256 maxFeePerGas; uint256 maxPriorityFeePerGas; address sender;
CallObject[] callObjects;
bytes signature; // EOA signature over (nonce, sender, abi.encode(callObjects))
}

struct AdditionalData { bytes32 key; bytes value; }

struct MevTimeData {
bytes validatorSignature; // signature over abi.encode(mevTimeDataValues)
AdditionalData[] mevTimeDataValues; // e.g., routing hints, partners, deadlines
}

Signature binding

bytes32 messageHash = callBreaker.getMessageHash(
abi.encode(uo.nonce, uo.sender, abi.encode(uo.callObjects))
);
// require ecrecover(messageHash, v, r, s) == uo.sender

2) Event Ingestion & Selection

Primary feed: UserObjectivePushed (indexed by requestId, appId, chainId).

Selection heuristics (examples):

  • Tip / gas economics: higher tip + low calldata → more attractive.
  • App allowlist: execute only partner appIds.
  • Balance check: ensure senderBalances[sender] is likely sufficient (optional via on-chain read / off-chain heuristics).
  • Complexity: number of CallObjects, presence of verifiable=true, large return paths.

De-dup / idempotency: track seen requestIds and execution attempts per objective.


3) DAG Planning & Order of Execution

CallBreaker executes flat indices across one or more UserObjectives. For a single objective of length N, orderOfExecution is a permutation of [0..N-1]. For batched objectives, indices are flattened in row-major order: uo0.c0, uo0.c1, …, uo1.c0, uo1.c1, ….

Guidelines

  • Place non‑skippable, prerequisite calls earlier.
  • Favor grouping by shared state to improve cache locality.
  • Use expectFutureCall/expectFutureCallAt (read-only helpers) when coordinating with app‑side logic.

Planner output

const plan = async (uo: UserObjective) => {
// Compute topological sort from implicit deps (skippable=false, exposeReturn usage, etc.)
const orderOfExecution = topo(uo.callObjects);

// Prepare expected returns for verifiable calls
const returnsBytes = uo.callObjects.map((c) =>
(c.verifiable && c.returnvalue?.length > 0) ? c.returnvalue : "0x"
);

return { orderOfExecution, returnsBytes };
};

4) Returns: Who supplies what?

For each executed call:

  • If callObj.verifiable == true and callObj.returnvalue is empty, the solver must supply the corresponding actual return in returnsBytes[index].
  • If callObj.returnvalue is provided by the app, CallBreaker will compare on-chain against the actual return and revert on mismatch.
  • If callObj.exposeReturn == true, CallBreaker publishes the actual return into a return bus addressable by getReturnValue(callObj) / getReturnValueHash(callObj).

For very large returns, CallBreaker stores a hash; use getReturnValueHash and verify off-chain.


5) MEV‑Time Data & Validators

  • MevTimeData allows passing execution metadata (partners, deadlines, venues, oracle snapshots).
  • If a validator is set for the appId, CallBreaker requires validatorSignature over abi.encode(mevTimeDataValues).
  • Build mevTimeDataValues deterministically; store keys in a registry to avoid ambiguity.
const mevData = {
validatorSignature: await signValidator(abi.encode(mevPairs)),
mevTimeDataValues: mevPairs, // [{key, value}, ...]
};

6) Execution & Settlement

Call

await walletClient.writeContract({
address: CALLBREAKER_ADDRESS,
abi: CALLBREAKER_ABI,
functionName: "executeAndVerify",
args: [ userObjectives, returnsBytes, orderOfExecution, mevData ],
});

Gas price used for settlement (per user objective):

effectivePrice = min(maxFeePerGas, block.basefee + maxPriorityFeePerGas)
userCost = gasUsed * effectivePrice + tip

The contract debits senderBalances[user] and credits the solver (msg.sender). If a user’s balance is insufficient, execution reverts with OutOfEther() for that path.


7) Batching & Throughput

  • executeAndVerify accepts an array of UserObjectives → batch across users/apps.
  • Flatten calls to a single orderOfExecution vector.
  • Group similar call types to reduce warm/cold access.
  • Retry failed subsets with smaller batches.

8) Reliability Engineering

  • Idempotency keys: store (chainId, requestId) → status (seen, executed, confirmed).
  • Reorg safety: wait N blocks before claiming success; re-check events.
  • Backoff & jitter: exponential backoff on RPC errors; add random jitter.
  • Health metrics: queue depth, picked/finished per minute, revert reasons, effective gas profit.
  • Circuit breakers: pause per appId on abnormal revert spikes.

9) Security Considerations

  • Validate user signatures offline before spending gas.
  • Enforce app allowlists/denylists; validate target addresses.
  • Treat AdditionalData as untrusted input; verify lengths/types.
  • Avoid unbounded CPU in planners; use timeouts per objective.
  • Keep MEV‑time validator keys protected and rotated.

10) Worked Example: Swap + Payout

  1. Detect UserObjectivePushed with two calls: approve (skippable) and swap (verifiable, exposeReturn).
  2. Plan order: [approve, swap] (or skip approve if allowance sufficient).
  3. Build returnsBytes: empty for approve; empty for swap (app didn’t provide expected return, so on-chain compares against the one we return).
  4. Attach user signature to UserObjective.
  5. Execute; settlement credits your solver address.

11) Testing Locally (Foundry + fork)

  • Fork mainnet/testnet and deploy CallBreaker.
  • Emit a mock UserObjectivePushed in a test or push via the ISmartExecute surface.
  • Run a local solver loop against the fork; assert balances and events.

12) FAQs

Where do user signatures come from? From the app/user off-chain flow. Solvers must obtain the user’s signature that binds (nonce, sender, callObjects). If absent, the objective is not executable.

Can I charge additional fees? Tips are inside UserObjective.tip. You can pre‑screen objectives by tip and cost.

How do I avoid double execution? Use (requestId) and your own status DB; ignore duplicates; confirm via finality depth.

What if a call returns huge data? You’ll see a hash via getReturnValueHash. Verify off-chain or avoid making such calls.


13) Change Log

  • v1.0 — First solver guide: ingestion, planning, signatures, MEV‑time, batching, settlement, reliability, and security.