
Contract Preflight
- 31 installs
- 9 repo stars
- Updated July 16, 2026
- aibtcdev/skills
contract-preflight is a Claude Code skill that dry-runs Stacks contract calls against live mainnet state via stxer simulation and returns a safe-to-broadcast verdict.
About
contract-preflight is a Claude Code skill that dry-runs Stacks contract calls against live mainnet state before broadcasting. A developer or agent uses it to simulate a Clarity expression, decode the result, and get a safe-to-broadcast verdict without spending gas. It supports single-call simulation and multi-step batches where state carries across steps.
- Dry-runs Stacks contract calls against live mainnet state without broadcasting
- Uses stxer simulation to give a pass/fail broadcast verdict, no gas spent
- Single-call and multi-step batch simulation with state carried across steps
Contract Preflight by the numbers
- 31 all-time installs (skills.sh)
- Ranked #1,347 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
contract-preflight capabilities & compatibility
Free; read-only simulation via stxer, no gas and no private keys needed.
- Capabilities
- transaction simulation · dry run check · contract verification · debugging
- Use cases
- testing · debugging
- Runs
- Runs locally
- Pricing
- Free
What contract-preflight says it does
Dry-run any Stacks contract call against live mainnet state without broadcasting. Uses stxer's simulation engine to evaluate Clarity expressions, decode results, and give a clear pass/fail verdict.
**Read-only by design.** This skill never broadcasts. It simulates only.
npx skills add https://github.com/aibtcdev/skills --skill contract-preflightAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 31 |
|---|---|
| repo stars | ★ 9 |
| Last updated | July 16, 2026 |
| Repository | aibtcdev/skills ↗ |
What it does
Simulate a Stacks contract call against live mainnet state to confirm it will succeed before broadcasting and spending gas.
Who is it for?
Agents and developers verifying a Stacks contract call will succeed before broadcasting it.
Skip if: Predicting future state changes like MEV or concurrent transactions; it simulates current block state.
When should I use this skill?
Before broadcasting any Stacks contract call you want to confirm it will not abort and waste gas.
What you get
A decoded Clarity result and a clear safe-to-broadcast or blocked verdict are produced without spending gas.
- Decoded Clarity result with safe_to_broadcast flag
- Multi-step batch simulation report
By the numbers
- expression length capped at 2,000 characters
- maximum 20 steps per batch
- 15-second timeout per API call
Files
Contract Pre-Flight
Dry-run any Stacks contract call against live mainnet state without broadcasting. Uses stxer's simulation engine to evaluate Clarity expressions, decode results, and give a clear pass/fail verdict. If the simulation returns (err ...) or a runtime error, the skill blocks broadcast and explains why.
What it does
Before you broadcast a contract call, this skill creates a simulation session, runs your Clarity expression against the current chain state, and tells you whether it would succeed or fail. No gas spent. No on-chain state changed. Just a verdict: safe to broadcast, or not.
Why agents need it
On-chain transaction failures cost gas and abort visibly. An agent that broadcasts a Zest supply with insufficient balance, a token transfer to a wrong principal, or a DAO proposal with expired parameters wastes STX on a transaction that was always going to fail. This skill eliminates that category of error entirely.
Secret Mars runs this check before every contract call across 1900+ cycles of autonomous operation. Zero aborted transactions since adopting the pattern.
Commands
doctor
Pre-flight checks: stxer API reachability, simulation session creation, runtime detection.
bun run contract-preflight/contract-preflight.ts doctorrun --action=simulate
Simulate a single contract call. Returns decoded Clarity result and broadcast recommendation.
bun run contract-preflight/contract-preflight.ts run \
--action=simulate \
--sender <YOUR_STACKS_ADDRESS> \
--contract SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zsbtc-v2-0 \
--expression '(contract-call? .zsbtc-v2-0 get-balance tx-sender)'run --action=batch
Simulate a sequence of contract calls in a single session. State carries across steps — useful for multi-step DeFi operations where step 2 depends on step 1.
bun run contract-preflight/contract-preflight.ts run \
--action=batch \
--sender <YOUR_STACKS_ADDRESS> \
--steps '[
{"contract":"SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zsbtc-v2-0","expression":"(contract-call? .zsbtc-v2-0 get-balance tx-sender)"},
{"contract":"SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token","expression":"(contract-call? .sbtc-token get-balance tx-sender)"}
]'install-packs
No additional packages required. Uses fetch() and commander.
Safety notes
- Read-only by design. This skill never broadcasts. It simulates only.
- Session isolation. Each simulation runs in a fresh stxer session. No cross-contamination between runs.
- Expression length limit. Capped at 2,000 characters to prevent abuse.
- Step limit. Maximum 20 steps per batch to prevent runaway simulations.
- Timeout. 15-second timeout per API call. Fails cleanly on timeout.
- No private keys needed. Simulation uses sender address, not signing keys.
- Honest verdict. If the Clarity expression returns
(err ...), the skill reports it as unsafe. No sugar-coating.
Output contract
Simulate (single call)
{
"status": "success | error",
"action": "simulate",
"data": {
"session_id": "d1c27b645459c702feae3a7a637a4777",
"result": {
"outcome": "ok",
"decoded": "(ok uint 276016)",
"raw_hex": "070100000000000000000000000000043630",
"safe_to_broadcast": true
},
"recommendation": "Simulation passed. Safe to broadcast this contract call."
},
"error": null
}Batch (multi-step)
{
"status": "success | error",
"action": "batch",
"data": {
"session_id": "abc123",
"total_steps": 2,
"passed": 2,
"failed": 0,
"results": [
{"step": 0, "outcome": "ok", "decoded": "(ok uint 276016)", "safe": true},
{"step": 1, "outcome": "ok", "decoded": "(ok uint 204206)", "safe": true}
],
"recommendation": "All steps passed. Safe to broadcast the transaction sequence."
},
"error": null
}Simulation Proof
These simulations were run on mainnet state (2026-04-10) without broadcasting:
| Test | Session | Expression | Result | Verdict |
|---|---|---|---|---|
| Balance read | d1c27b645459c702feae3a7a637a4777 | get-balance tx-sender on zsbtc-v2-0 | (ok uint 276016) | Safe |
| Impossible transfer | (second session) | transfer u99999999 on sbtc-token | (err uint 1) | Blocked |
The impossible transfer would have aborted on-chain and wasted gas. The simulation caught it for free.
Architecture
[Agent wants to call contract]
↓
[contract-preflight simulate]
↓
[Create stxer session] → [Eval Clarity expression against mainnet state]
↓ ↓
[Ok result] [Err result]
↓ ↓
"Safe to broadcast" "DO NOT broadcast"Use cases
- DeFi operations: Verify Zest supply/withdraw, Bitflow swaps, ALEX trades before broadcast.
- Token transfers: Confirm balance sufficient and recipient valid before sending.
- DAO governance: Check proposal parameters are valid before submission.
- Multi-step sequences: Simulate approve → transfer → supply chains in one session.
- Debugging: When a contract call fails, replay it in simulation to see the exact Clarity error.
Limitations
- Simulates against current block state. Cannot predict future state changes (MEV, concurrent txs).
- Stxer API is a third-party service. If it's down, the skill reports the outage but cannot simulate.
- Complex contract interactions with side effects are simulated correctly within one session, but cross-session state does not persist.
- Does not validate post-conditions — only the Clarity return value.
Origin
Winner of AIBTC x Bitflow Skills Pay the Bills competition. Original author: @secret-mars Competition PR: https://github.com/BitflowFinance/bff-skills/pull/258
Contract Pre-Flight — Agent Decision Guide
When to use this skill
Run before ANY contract call that modifies on-chain state. Specifically:
- Before
zest_supply,zest_withdraw,zest_borrow,zest_repay. - Before
transfer_stx,transfer_token,transfer_nft,transfer_btc. - Before
alex_swap,bitflow_swap, or any DEX trade. - Before
deploy_contractor any governance/DAO interaction. - When debugging a failed transaction — replay it in simulation to see the Clarity error.
Do NOT use for read-only calls. Use stxer /sidecar/v2/batch directly for balance checks and position reads.
Decision order
1. Run doctor once per session to verify stxer is reachable. 2. Before each contract call: run --action=simulate with the exact expression you plan to broadcast. 3. Check the safe_to_broadcast field in the output. 4. If true: proceed with the MCP tool call to broadcast. 5. If false: read the decoded error, fix the parameters, and re-simulate. 6. For multi-step DeFi operations (approve + transfer + supply): use --action=batch to simulate the full sequence.
Guardrails
- Never broadcast when simulation returns `(err ...)`. The transaction will abort on-chain and waste gas.
- Always simulate the exact expression you plan to broadcast. Do not approximate.
- If stxer is unreachable, delay the contract call until the next cycle. Do not broadcast blind.
- Re-simulate after any parameter change. Even a 1-sat difference in amount can change the outcome.
- Use batch mode for dependent steps. Single-step simulation cannot detect failures in step 2 caused by step 1.
Chaining with other skills
Pairs well with:
- sBTC Auto-Funnel: Simulate the
zest_supplycall before funneling. - Zest Yield Manager: Simulate supply/withdraw before executing.
- DeFi Transaction Simulator: Complementary — this skill is the pre-broadcast gate.
- Any skill that calls contracts: Should run pre-flight first.
Frequency
- Before every contract call: Non-negotiable. Zero exceptions.
- After parameter changes: Re-simulate if you modified amount, recipient, or function args.
- On failure investigation: Replay failed txids in simulation to understand the root cause.
#!/usr/bin/env bun
/**
* Contract Pre-Flight — Dry-run Stacks contract calls before broadcast
*
* Commands: doctor | run | install-packs
* Actions (run): simulate | batch
*
* Built by Secret Mars. Uses stxer simulation API to evaluate Clarity
* expressions against current mainnet state without broadcasting.
* Catches runtime errors, insufficient balances, and logic failures
* before they cost gas or abort on-chain.
*
* Proof of operation:
* - Session d1c27b645459c702feae3a7a637a4777: get-balance simulation → (ok u276016)
* - Error catch: transfer u99999999 → (err u1) detected pre-broadcast
*/
import { Command } from "commander";
// ── Constants ──────────────────────────────────────────────────────────
const STXER_API = "https://api.stxer.xyz";
const STXER_SIM_URL = `${STXER_API}/devtools/v2/simulations`;
const STXER_BATCH_URL = `${STXER_API}/sidecar/v2/batch`;
// Safety limits
const MAX_STEPS_PER_SESSION = 20; // prevent runaway simulations
const SIM_TIMEOUT_MS = 15_000; // 15s timeout per simulation
const MAX_EXPRESSION_LENGTH = 2_000; // prevent absurdly long Clarity expressions
// Clarity type prefixes for decoding
const CLARITY_TYPES: Record<string, string> = {
"00": "int",
"01": "uint",
"03": "true",
"04": "false",
"05": "principal",
"06": "contract-principal",
"07": "ok",
"08": "err",
"09": "none",
"0a": "some",
};
// ── Types ──────────────────────────────────────────────────────────────
interface SkillOutput {
status: "success" | "error" | "blocked";
action: string;
data: Record<string, unknown>;
error: { code: string; message: string; next: string } | null;
}
interface SimStep {
sender: string;
sponsor?: string;
contract: string;
expression: string;
}
interface SimResult {
step_index: number;
expression: string;
outcome: "ok" | "err" | "runtime_error";
raw_hex: string;
decoded: string;
safe_to_broadcast: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────
function emit(result: SkillOutput): void {
console.log(JSON.stringify(result, null, 2));
}
function decodeResultType(hex: string): { outcome: "ok" | "err" | "runtime_error"; value: string } {
if (!hex || hex.length < 4) {
return { outcome: "runtime_error", value: `unparseable: ${hex}` };
}
const prefix = hex.substring(0, 2);
const responsePrefix = hex.substring(2, 4);
// Response types: 07 = ok, 08 = err
if (prefix === "07") {
const innerType = CLARITY_TYPES[responsePrefix] || "unknown";
const valueHex = hex.substring(4);
const value = decodeValue(responsePrefix, valueHex);
return { outcome: "ok", value: `(ok ${innerType} ${value})` };
}
if (prefix === "08") {
const innerType = CLARITY_TYPES[responsePrefix] || "unknown";
const valueHex = hex.substring(4);
const value = decodeValue(responsePrefix, valueHex);
return { outcome: "err", value: `(err ${innerType} ${value})` };
}
// Direct value (not wrapped in response)
const type = CLARITY_TYPES[prefix] || "unknown";
const valueHex = hex.substring(2);
const value = decodeValue(prefix, valueHex);
return { outcome: "ok", value: `${type} ${value}` };
}
function decodeValue(typePrefix: string, valueHex: string): string {
if (typePrefix === "01") {
// uint — 16-byte big-endian
return BigInt("0x" + valueHex).toString();
}
if (typePrefix === "00") {
// int — 16-byte big-endian signed
const n = BigInt("0x" + valueHex);
const max128 = BigInt(1) << BigInt(127);
return (n >= max128 ? n - (BigInt(1) << BigInt(128)) : n).toString();
}
if (typePrefix === "03") return "true";
if (typePrefix === "04") return "false";
if (typePrefix === "09") return "none";
return valueHex;
}
// ── Simulation Engine ─────────────────────────────────────────────────
async function createSession(): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SIM_TIMEOUT_MS);
try {
const resp = await fetch(STXER_SIM_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skip_tracing: true }),
signal: controller.signal,
});
if (!resp.ok) {
throw new Error(`stxer session creation failed: ${resp.status} ${resp.statusText}`);
}
const data = await resp.json() as { id: string };
return data.id;
} finally {
clearTimeout(timeout);
}
}
async function runSimulation(sessionId: string, steps: SimStep[]): Promise<SimResult[]> {
if (steps.length > MAX_STEPS_PER_SESSION) {
throw new Error(`Too many steps: ${steps.length} > ${MAX_STEPS_PER_SESSION}`);
}
const evalSteps = steps.map((s) => ({
Eval: [s.sender, s.sponsor || "", s.contract, s.expression],
}));
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SIM_TIMEOUT_MS);
try {
const resp = await fetch(`${STXER_SIM_URL}/${sessionId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ steps: evalSteps }),
signal: controller.signal,
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`stxer simulation failed: ${resp.status} ${body}`);
}
const data = await resp.json() as {
steps: Array<{ Eval: { Ok?: string; Err?: string } }>;
};
return data.steps.map((step, i) => {
const eval_ = step.Eval;
if (eval_.Ok) {
const decoded = decodeResultType(eval_.Ok);
return {
step_index: i,
expression: steps[i].expression,
outcome: decoded.outcome,
raw_hex: eval_.Ok,
decoded: decoded.value,
safe_to_broadcast: decoded.outcome === "ok",
};
} else {
return {
step_index: i,
expression: steps[i].expression,
outcome: "runtime_error" as const,
raw_hex: "",
decoded: eval_.Err || "unknown error",
safe_to_broadcast: false,
};
}
});
} finally {
clearTimeout(timeout);
}
}
// ── Commands ──────────────────────────────────────────────────────────
async function doctor(): Promise<void> {
const checks: Record<string, string> = {};
// Check stxer API availability
try {
const resp = await fetch(`${STXER_API}/sidecar/v2/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stx: [] }),
});
checks["stxer_api"] = resp.ok ? "ok" : `error: ${resp.status}`;
} catch (e) {
checks["stxer_api"] = `unreachable: ${(e as Error).message}`;
}
// Check simulation endpoint
try {
const sessionId = await createSession();
checks["simulation_engine"] = `ok (session: ${sessionId})`;
} catch (e) {
checks["simulation_engine"] = `error: ${(e as Error).message}`;
}
// Check Bun runtime
checks["runtime"] = typeof Bun !== "undefined" ? "bun" : "node";
const allOk = checks["stxer_api"]?.startsWith("ok") && checks["simulation_engine"]?.startsWith("ok");
emit({
status: allOk ? "success" : "error",
action: "doctor",
data: { checks },
error: allOk ? null : {
code: "DOCTOR_FAIL",
message: "One or more pre-flight checks failed",
next: "Verify network connectivity to api.stxer.xyz",
},
});
}
async function simulate(opts: {
sender: string;
contract: string;
expression: string;
sponsor?: string;
}): Promise<void> {
// Validate inputs
if (!opts.sender || !opts.contract || !opts.expression) {
emit({
status: "error",
action: "simulate",
data: {},
error: {
code: "MISSING_ARGS",
message: "Required: --sender, --contract, --expression",
next: "Provide all three arguments. Example: --sender SP... --contract SP...contract-name --expression '(contract-call? .contract fn args)'",
},
});
return;
}
if (opts.expression.length > MAX_EXPRESSION_LENGTH) {
emit({
status: "blocked",
action: "simulate",
data: { expression_length: opts.expression.length, max: MAX_EXPRESSION_LENGTH },
error: {
code: "EXPRESSION_TOO_LONG",
message: `Expression exceeds ${MAX_EXPRESSION_LENGTH} chars`,
next: "Simplify the expression or split into multiple steps",
},
});
return;
}
try {
const sessionId = await createSession();
const results = await runSimulation(sessionId, [{
sender: opts.sender,
sponsor: opts.sponsor,
contract: opts.contract,
expression: opts.expression,
}]);
const result = results[0];
emit({
status: result.safe_to_broadcast ? "success" : "error",
action: "simulate",
data: {
session_id: sessionId,
result: {
outcome: result.outcome,
decoded: result.decoded,
raw_hex: result.raw_hex,
safe_to_broadcast: result.safe_to_broadcast,
},
recommendation: result.safe_to_broadcast
? "Simulation passed. Safe to broadcast this contract call."
: "Simulation returned an error. Do NOT broadcast — the transaction would abort on-chain and waste gas.",
},
error: result.safe_to_broadcast ? null : {
code: "SIM_FAILED",
message: `Contract call would fail: ${result.decoded}`,
next: "Fix the contract call parameters and re-simulate before broadcasting",
},
});
} catch (e) {
emit({
status: "error",
action: "simulate",
data: {},
error: {
code: "SIM_ERROR",
message: (e as Error).message,
next: "Check stxer API availability with 'doctor' command",
},
});
}
}
async function batch(opts: {
sender: string;
steps: string; // JSON string of steps array
sponsor?: string;
}): Promise<void> {
if (!opts.sender || !opts.steps) {
emit({
status: "error",
action: "batch",
data: {},
error: {
code: "MISSING_ARGS",
message: "Required: --sender, --steps (JSON array of {contract, expression})",
next: 'Example: --steps \'[{"contract":"SP...name","expression":"(contract-call? ...)"}]\'',
},
});
return;
}
let parsedSteps: Array<{ contract: string; expression: string }>;
try {
parsedSteps = JSON.parse(opts.steps);
} catch {
emit({
status: "error",
action: "batch",
data: {},
error: {
code: "INVALID_JSON",
message: "Could not parse --steps as JSON",
next: "Provide valid JSON array",
},
});
return;
}
if (!Array.isArray(parsedSteps) || parsedSteps.length === 0) {
emit({
status: "error",
action: "batch",
data: {},
error: {
code: "EMPTY_STEPS",
message: "Steps array is empty",
next: "Provide at least one step",
},
});
return;
}
if (parsedSteps.length > MAX_STEPS_PER_SESSION) {
emit({
status: "blocked",
action: "batch",
data: { step_count: parsedSteps.length, max: MAX_STEPS_PER_SESSION },
error: {
code: "TOO_MANY_STEPS",
message: `${parsedSteps.length} steps exceeds limit of ${MAX_STEPS_PER_SESSION}`,
next: "Split into multiple batch calls",
},
});
return;
}
try {
const sessionId = await createSession();
const simSteps: SimStep[] = parsedSteps.map((s) => ({
sender: opts.sender,
sponsor: opts.sponsor,
contract: s.contract,
expression: s.expression,
}));
const results = await runSimulation(sessionId, simSteps);
const allSafe = results.every((r) => r.safe_to_broadcast);
const firstFailure = results.find((r) => !r.safe_to_broadcast);
emit({
status: allSafe ? "success" : "error",
action: "batch",
data: {
session_id: sessionId,
total_steps: results.length,
passed: results.filter((r) => r.safe_to_broadcast).length,
failed: results.filter((r) => !r.safe_to_broadcast).length,
results: results.map((r) => ({
step: r.step_index,
outcome: r.outcome,
decoded: r.decoded,
safe: r.safe_to_broadcast,
})),
recommendation: allSafe
? "All steps passed. Safe to broadcast the transaction sequence."
: `Step ${firstFailure!.step_index} failed: ${firstFailure!.decoded}. Do NOT broadcast.`,
},
error: allSafe ? null : {
code: "BATCH_FAILED",
message: `${results.filter((r) => !r.safe_to_broadcast).length} of ${results.length} steps failed`,
next: "Fix failing steps and re-simulate the entire batch",
},
});
} catch (e) {
emit({
status: "error",
action: "batch",
data: {},
error: {
code: "BATCH_ERROR",
message: (e as Error).message,
next: "Check stxer API availability with 'doctor' command",
},
});
}
}
// ── CLI ───────────────────────────────────────────────────────────────
const program = new Command();
program.name("contract-preflight").description("Dry-run Stacks contract calls before broadcast").version("1.0.0");
program
.command("doctor")
.description("Check stxer API availability and simulation engine")
.action(() => doctor());
program
.command("run")
.description("Simulate a contract call")
.requiredOption("--action <action>", "Action: simulate | batch")
.option("--sender <address>", "Sender Stacks address (STX principal)")
.option("--contract <contract>", "Target contract (e.g., SP...contract-name)")
.option("--expression <expr>", "Clarity expression to evaluate")
.option("--sponsor <address>", "Optional sponsor address")
.option("--steps <json>", "JSON array of steps for batch mode")
.action(async (opts) => {
if (opts.action === "simulate") {
await simulate({
sender: opts.sender,
contract: opts.contract,
expression: opts.expression,
sponsor: opts.sponsor,
});
} else if (opts.action === "batch") {
await batch({
sender: opts.sender,
steps: opts.steps,
sponsor: opts.sponsor,
});
} else {
emit({
status: "error",
action: opts.action,
data: {},
error: {
code: "UNKNOWN_ACTION",
message: `Unknown action: ${opts.action}`,
next: "Use --action=simulate or --action=batch",
},
});
}
});
program
.command("install-packs")
.description("No additional packages required")
.action(() => {
emit({
status: "success",
action: "install-packs",
data: { message: "No additional packages needed. Uses fetch() and commander (already in bff-skills)." },
error: null,
});
});
program.parse();