Turning algorithmic signals into reliable onchain trades
How I led an onchain trading platform across the seam between a fast off-chain signal engine and a slow, final onchain settlement layer, and why idempotency, ordering, and reconciliation were the whole job.

Short answer: to turn algorithmic trading signals into reliable onchain trades, keep the strategy off-chain and give it one job, emitting intents with a deterministic id. A separate execution service owns the chain, and a settlement contract has the final word. Make that id the primary key everywhere so retries cannot double-trade, submit in strict nonce order with a gas cap, and reconcile local state from onchain events. The trading is the easy part. The discipline at the boundary is the whole job.
I led the engineering on a Web3 trading platform that takes algorithmic trading signals and executes them onchain. Below is how the system was structured and the decisions that made it dependable in production.
What actually makes onchain execution hard?
The hard part is the seam between two worlds. Off-chain, a signal engine changes its mind by the second. Onchain, settlement is final, ordering is set by the mempool, and every write costs gas. Push a signal straight at a contract and you get duplicate trades, stuck nonces, and fills at prices the strategy never intended.
None of those failures come from the strategy math. They come from treating a fast, mutable system and a slow, permanent one as if they were the same thing. Once I accepted that the boundary was the real product, the rest of the design followed.
How should you structure the system?
Split it into three parts, each with one job: a signal engine that only emits intents, an execution service that is the only thing allowed to touch the chain, and a settlement contract that is the final record of what happened. The interfaces between them stay deliberately narrow.
The signal engine never holds a key and never sees gas. It emits an intent, an id plus a size, a limit price, and an expiry, and hands it off. The execution service turns that intent into a transaction. The snippets below are simplified for clarity; production specifics are omitted.
type TradeIntent = {
id: string; // deterministic, so retries dedupe
market: Address;
side: "long" | "short";
size: bigint; // base units
limitPrice: bigint; // 1e18 fixed point
expiry: number; // unix seconds
};
async function execute(intent: TradeIntent) {
if (await store.isSettled(intent.id)) return; // already done
const nonce = await nonces.reserve(intent.market); // serialized per market
const tx = await settlement.submitOrder(
intent.id,
intent.market,
intent.side === "long",
intent.size,
intent.limitPrice,
intent.expiry,
{ nonce, maxFeePerGas: gas.cap() },
);
await store.markSubmitted(intent.id, tx.hash);
}Why model intents instead of transactions?
Because it puts the mutable, iterative work on one side of the boundary and the permanent, careful work on the other. The strategy team could iterate all day on what to trade without ever holding a private key or reasoning about the mempool. The execution service stayed the single component with authority to move funds, so it was the only place I had to review with real care.
An intent is also cheap to reason about. It is a plain value with an id, so it can be logged, retried, deduped, and compared without any chain state in hand.
How do you stop duplicate trades?
Make the intent id the primary key everywhere and enforce it onchain. Retries are the normal case, an RPC call times out, a worker restarts, a webhook fires twice, and each of those can double-submit. The contract rejects any id it has already seen, so even if two workers race the same intent, only one order can land.
mapping(bytes32 => bool) private _filled;
function submitOrder(bytes32 id, /* ... */) external onlyExecutor {
require(!_filled[id], "replay"); // reject a duplicate id
_filled[id] = true;
// match against the book, then settle
emit OrderFilled(id, market, size, fillPrice);
}The guard is cheap and boring, which is exactly the point. The chain becomes the arbiter of "did this happen," so no amount of off-chain confusion can turn one signal into two trades.
How do you handle nonces and gas without freezing the desk?
Serialize submissions per market and cap the fee. On a single account, transactions must go out in strict nonce order, so one stuck transaction blocks everything queued behind it. That is a fast way to freeze a trading desk.
We tracked pending nonces through a small reservation queue, treated the chain as the source of truth on restart, and set a gas cap. If fees spiked, we would rather let an order expire than drain the account chasing a fill the expiry was about to void anyway. Bounding the cost of a bad moment mattered more than winning every fill.
How do you keep off-chain and onchain state in sync?
Treat the chain as the ledger and your database as a cache you repair from it. Off-chain and onchain state drift, a transaction can succeed while your webhook misses the confirmation, and then the dashboard says pending forever while the position is actually open.
A reconciler reads OrderFilled events straight from the chain, matches each one back to its intent id, and repairs local state to match.
// The chain is the ledger; the store is a cache we repair from it.
for (const log of await settlement.queryFilter("OrderFilled")) {
const { id, fillPrice } = log.args;
await store.settle(id, fillPrice); // idempotent upsert
}Once I started treating the store as a cache and the chain as the ledger, a whole category of "our records disagree with reality" bugs simply stopped happening. The chain was never wrong. We just had to keep asking it.
What mistakes should you avoid?
- Letting the strategy side hold a key or care about gas. It couples the fast layer to the slow one and makes every strategy change a funds-safety review.
- Using a random or per-attempt id. If the id is not deterministic, retries stop deduping and the replay guard is useless.
- Firing transactions in parallel on one account. Without strict nonce ordering, one stuck transaction stalls the queue behind it.
- Trusting your own database over the chain. When they disagree, the chain is right, so reconcile toward it.
Key takeaways
- Model intents, not transactions. The side that decides what to trade should never hold a key.
- Make a deterministic id the contract of the whole system, enforced at every layer, so duplicates die without heroics.
- Submit in strict nonce order and cap gas, so a bad market costs one wasted attempt rather than the balance.
- Treat the chain as the source of truth and everything off-chain as a cache you reconcile.
Questions you might have next
- Where do limit prices get enforced, off-chain or onchain? Onchain, at settlement. The intent carries the limit, the contract fills only within it, and the reconciler records the actual fill price.
- What happens when an intent expires before it lands? The expiry is checked at settlement, so a late transaction reverts instead of filling, and the reconciler marks the intent void.
- Does this need an L2? Not fundamentally. The pattern is chain-agnostic; lower fees and faster finality just make the gas cap and reconciliation windows more forgiving.
The off-chain to on-chain seam is one of three that decide whether an onchain product ships. For the full map, see building production Web3 apps.