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
UserObjective
s 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
ofexecuteAndVerify
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 atlib/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
appId
s. - Balance check: ensure
senderBalances[sender]
is likely sufficient (optional via on-chain read / off-chain heuristics). - Complexity: number of
CallObject
s, presence ofverifiable=true
, large return paths.
De-dup / idempotency: track seen requestId
s and execution attempts per objective.
3) DAG Planning & Order of Execution
CallBreaker executes flat indices across one or more UserObjective
s. 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
andcallObj.returnvalue
is empty, the solver must supply the corresponding actual return inreturnsBytes[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 bygetReturnValue(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 requiresvalidatorSignature
overabi.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 ofUserObjective
s → 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
- Detect
UserObjectivePushed
with two calls:approve
(skippable) andswap
(verifiable, exposeReturn). - Plan order:
[approve, swap]
(or skip approve if allowance sufficient). - Build
returnsBytes
: empty for approve; empty for swap (app didn’t provide expected return, so on-chain compares against the one we return). - Attach user signature to
UserObjective
. - 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.