I wanted to test out a theory where I have autonomous agents make trading decisions on Solana without handing them private keys. So I built something to simulate that.
Think of a situation where you're building an agent that decides to buy 0.5 SOL worth of a certain token, and it needs to sign a real transaction on-chain. Ordinarily, there's really no way this would work without you giving the agent access to a private key.
But the issue here is that the moment you give it access to a private key, you've introduced a massive attack surface. A bug in the agent's decision logic, for instance, could drain a wallet. A compromised dependency could also exfiltrate your keys.
Let's see how my simulation played out.
What We're Building With
Autarch, as I strangely named it, is a TypeScript monorepo running on Solana devnet. The stack:
- pnpm workspaces — three packages with strict dependency boundaries
- @solana/kit — Solana's SDK for key derivation and transaction building
- BIP44 HD derivation — one master seed with many isolated wallets
- Vitest — tests as executable security proofs
- Express + SSE — real-time dashboard streaming agent reasoning
The whole idea is: one seed goes in, isolated agent wallets come out, and those agents can sign transactions but cannot access the private key that signs them. Let's walk through how.
Building With Closures as Security Boundaries
This is where it gets interesting because the core trick behind it all is a JavaScript closure.
Instead of building a Wallet class where private keys live as class properties (even private ones with #), Autarch uses a factory function. The seed and all derived keypairs are local variables inside that function. They're captured in a closure scope, and the returned object is frozen:
export function createAutarchWallet(config: WalletConfig): AutarchWallet {
// These variables are trapped in closure scope — unreachable from outside
const seed = new Uint8Array(config.seed);
const keypairCache = new Map<number, CryptoKeyPair>();
// ... more caches
// The returned object has NO properties pointing to key material
return Object.freeze({
getAgent,
getBalance,
transferSol,
// ...
});
}
So why not private class fields?
The thing is that a class instance has an inspectable shape. Object.getOwnPropertyNames, prototype chain traversal, and even debugger access can reveal private field names. A frozen plain object returned from a factory function has none of those attack surfaces.
When an agent requests a wallet handle, it gets just this interface and that's the entire thing:
interface AgentWallet {
readonly address: string;
signTransaction(tx: TransactionToSign): Promise<TransactionResult>;
}
It's just two properties. There's no getPrivateKey() or exportSeed(). Those methods don't exist because there's nothing to call.
Making It Useful Through The Rule Engine
So we've got secure wallets. But agents need to make decisions. I didn't want to throw an LLM at this (more on that in a sec), so I built a JSON rule engine.
Each agent gets a config file like this:
{
"name": "Dip Buyer",
"strategy": "Buy the Dip",
"intervalMs": 5000,
"rules": [
{
"name": "Smart dip buyer",
"conditions": [
{ "field": "price_drop", "operator": ">", "threshold": 5, "logic": "AND" },
{ "field": "volume_spike", "operator": ">", "threshold": 200, "logic": "AND" },
{ "field": "position_size", "operator": "<", "threshold": 50, "logic": "AND" }
],
"action": "buy",
"amount": 0.02,
"weight": 50,
"cooldownSeconds": 60
}
]
}
Rules support compound conditions (AND/OR/NOT), weighted scoring, cooldowns, and even inter-agent dependencies where one agent can check what another agent is doing. Every evaluation cycle produces a DecisionTrace where a complete audit record shows exactly which rules fired, what conditions matched, what scores were computed, and what action was taken.
You can save the file, and the agent will pick up the new config instantly.
The Struggle, However is that Devnet Is... Temperamental
This was the part that almost broke me.
Solana devnet goes down. A lot. Rate limiting, network errors, RPC endpoints just... vanishing. My first version would just crash when the RPC failed, which is obviously not great when you've got three agents running.
The fix was building a three-mode state machine for the RPC client:
- Normal — primary endpoint works, life is good
- Degraded — primary failed, rotating through fallback endpoints with exponential backoff
- Simulation — everything's down, transactions get logged but not sent
The beautiful part here is that Agents have no idea which mode they're in. They call signTransaction() and get back a result with a mode field. The decision trace records whether a trade was confirmed or simulated. The dashboard shows it, but the agent is not required to care.
Building the auto-recovery (health checks every 30 seconds that try the primary endpoint and promote back to normal on success) took more debugging than I'd like to admit. But it means the demo runs gracefully even when devnet is having one of its da
The Win Was a One Command function with Three Agents Live Trading
pnpm run demo
With the above command, your browser opens to a live dashboard with three agents namely Conservative, Dip Buyer, and Momentum, each running a different strategy. You can watch their reasoning traces in real time via SSE. Click an agent card to see exactly which rules were evaluated, which conditions were passed, and what the weighted score was.
The dashboard is completely stateless. It just reads the SSE stream and renders. No client-side state management, no WebSocket sync, no polling. Agent state change fires, SSE pushes it, browser renders it.
If you also want to verify on-chain, every confirmed trade shows a Solana Explorer link. If you want to tweak a strategy, you can edit a JSON file and save. The agent will reload instantly.
Why Not Just Use an LLM?
"Why build a rule engine when you could just prompt GPT?"
LLMs are probabilistic. The same prompt gives different outputs across runs. Research shows hallucination rates up to 27% for financial predictions. When the output is "send 5 SOL to this address," a 27% error rate becomes a dealbreaker.
Autarch's rule engine is deterministic. Same inputs, same trace, same output. Every single time. The audit trail is complete and reproducible. It's not something that should be optional when money is on-chain.
However, it's worth mentioning that the architecture doesn't lock you out of LLMs. The DecisionModule interface is pluggable. You could write a module that calls an LLM for strategy suggestions, then feeds those through the deterministic rule pipeline. If you choose to use this format, you can use rules for execution safety, LLMs for inspiration. Best of both.
What I Learned
Closures beat classes for secret isolation. Frozen factory functions are genuinely harder to introspect than private class fields. We have 14 tests proving it.
Devnet resilience is not optional. If you're building anything demo-able on Solana devnet, build your RPC client to degrade gracefully from day one.
Deterministic agents are auditable agents. When every decision produces a complete trace, debugging becomes reading a JSON object instead of guessing.
Package boundaries can be security boundaries. ESLint
no-restricted-importsenforces that agent code literally cannot import crypto libraries. The monorepo structure is the security model.
If you want to try it: github.com/Zolldyk/autarch — clone it, run pnpm run demo, and watch three agents trade on devnet in about two minutes.
Drop a comment if you try this out or if you've tackled the agent-key-isolation problem differently. I'd love to hear other approaches!
Top comments (0)