On-chain limit orders: checking settlement reliability
A price limit does not promise a fill
After comparing aggregator routes, I wanted to understand what happens when I leave an order waiting. A limit order authorizes a trade at an acceptable price. It does not promise that someone will execute it when a chart touches that price. CoW Protocol makes the distinction concrete: users submit signed trade intents, and solvers compete to settle them using available liquidity. CoW Protocol overview
I checked the contract and API models and tested the inspector against local fixtures. I have not measured live fill rates. Before putting $50 into an order, I want to see the remaining amount and verify any fill so a retry does not double the trade.
The order waits off-chain; execution happens on-chain
In CoW’s normal signed-order flow, the orderbook stores the order, the autopilot builds auctions, and solvers propose settlements. Submitting the signature is not a blockchain transaction. CoW’s auction architecture
The settlement contract validates the signature, expiry, price constraint, and fill accounting before transferring tokens. For ordinary ERC-20 orders, funds are pulled through the Vault Relayer using the owner’s approval. These checks enforce execution conditions; they do not schedule a future execution. Settlement contract reference
The fields I would record before signing are:
| Field | Check |
|---|---|
| Chain ID and verifying contract | Signature belongs to the intended deployment |
sellToken, buyToken, receiver | Correct contracts and destination wallet |
kind, sellAmount, buyAmount | Correct trade direction and integer token amounts |
validTo | Explicit deadline in Unix seconds |
partiallyFillable | Whether several smaller trades are permitted |
appData and displayed fees | Understand attached metadata and the net trade terms |
For a sell order, the ratio between buyAmount and sellAmount defines the minimum exchange rate. Partial fills apply that ratio to the portion executed. CoW’s contract calls a non-partial order “fill-or-kill”; here that means a complete fill in one settlement, not an immediate cancellation if the next auction cannot fill it. Settlement implementation
An illustrative order selling 50 USDC for at least 0.02 WETH has a maximum implied purchase price of 2,500 USDC per WETH. With 6 and 18 decimals respectively, the amounts are 50000000 and 20000000000000000. A 20 USDC partial fill requires at least 0.008 WETH under that ratio. These are arithmetic examples, not current market prices or a complete fee quote.
Why a touched limit can remain open
A candle is evidence of a price somewhere, at some time. My order needs executable liquidity for its size, compatible token behavior, sufficient balance and allowance, and a settlement that reaches the chain before expiry. The services repository describes funding and approval checks; the contract enforces validity at execution. Orderbook services
My working inference is that a brief price touch can disappear before a viable settlement lands. Even if the limit is reachable before costs, the route may not leave enough value to cover execution. I would investigate that before calling an unfilled order a protocol failure. CoW’s competition rules separate contract constraints from the off-chain selection of solutions. Solver competition rules
For a lean wallet, I would start with one $50 USDC/WETH order on Arbitrum, a 30-minute expiry, and no competing orders using the same balance. Those are experiment settings, not recommended protocol defaults. Keep the whole wallet below $1,000 and reserve native ETH for approval and possible cancellation. Start on Sepolia if the signing flow is unfamiliar; testnet success does not measure production liquidity.
Inspect an order without a signer
Save this as inspect-cow-order.mjs and run it with Node.js 22 or later. It takes one snapshot, so a scheduler can append each result to a local log. It uses CoW’s documented order and trade endpoints. API integration reference
import { pathToFileURL } from 'node:url';
const NETWORKS = new Set(['arbitrum_one', 'sepolia']);
export async function inspectOrder(network, uid, request = fetch) {
if (!NETWORKS.has(network)) throw new Error('Unsupported network');
if (!/^0x[0-9a-fA-F]{112}$/.test(uid)) {
throw new Error('Expected a 56-byte order UID');
}
const base = `https://api.cow.fi/${network}/api/v1`;
async function get(path) {
const response = await request(`${base}${path}`, {
signal: AbortSignal.timeout(10000),
});
// A failed read is unknown state, never proof of an unfilled order.
if (!response.ok) throw new Error(`API HTTP ${response.status}`);
return response.json();
}
const order = await get(`/orders/${uid}`);
if (order.uid?.toLowerCase() !== uid.toLowerCase()) {
throw new Error('Order UID mismatch');
}
if (order.kind !== 'sell') throw new Error('This inspector needs a sell order');
const total = BigInt(order.sellAmount);
// Use the pre-fee amount for progress, not total wallet spending.
const filled = BigInt(order.executedSellAmountBeforeFees);
if (total <= 0n || filled < 0n || filled > total) {
throw new Error('Inconsistent sell amounts');
}
const trades = await get(`/trades?orderUid=${uid}`);
if (!Array.isArray(trades)) throw new Error('Expected trade array');
const fills = trades.filter(
(trade) => trade.orderUid?.toLowerCase() === uid.toLowerCase(),
);
return {
observedAt: new Date().toISOString(), network, uid,
apiStatus: order.status,
validTo: order.validTo,
// A local clock crossing expiry still needs a chain-time check.
pastLocalDeadline: Date.now() > order.validTo * 1000,
partiallyFillable: order.partiallyFillable,
filledSellUnits: filled.toString(),
remainingSellUnits: (total - filled).toString(),
executedSellUnits: order.executedSellAmount,
executedBuyUnits: order.executedBuyAmount,
// These hashes are leads for receipt checks, not verified settlements.
settlementCandidates: [...new Set(fills.map((t) => t.txHash).filter(Boolean))],
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const report = await inspectOrder(process.env.NETWORK, process.env.ORDER_UID);
console.log(JSON.stringify(report));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
Set NETWORK=arbitrum_one and ORDER_UID to the identifier returned for your own order, then run node inspect-cow-order.mjs. No key or API credential is needed for these reads. Polling once every 30 seconds is enough for my proposed notebook; it is not a settlement-latency measurement.
The order model exposes both executedSellAmount and executedSellAmountBeforeFees. I keep them separate because fill progress and wallet spending answer different questions. API status is also separate from progress: an expired or cancelled order may already have partial executions. Order response model
Order and trade reads are separate snapshots. A fill between them can produce temporary disagreement. Save both observations and repeat the read before reconciling; keep both snapshots rather than dropping a row to make the totals agree.
What the local checks can prove
I ran the inspector with mocked responses covering zero fill, a partial fill, completion, expiry after a partial fill, duplicate settlement hashes, and API failures. I also checked rejected inputs and inconsistent amounts. These tests check the reporting logic, not solver performance.
| Fixture | Expected observation |
|---|---|
| Open, zero executed | All sell units remain |
| Open, 20 of 50 USDC executed | 30 USDC remains |
| Fulfilled, 50 of 50 executed | Zero remains |
| Expired after 20 USDC executed | 30 USDC remains unexecuted; expiry does not undo the fill |
| HTTP 429 or 500 | Failed inspection, no success report |
For the funded run, I would verify each candidate transaction through an RPC on the same chain: successful receipt, the expected settlement contract’s Trade event with my order UID, and transfers to the intended receiver. Recheck that the receipt’s block hash remains canonical and apply the chain’s finality policy before marking the trade final. CoW’s trade response includes transaction hashes and log locations to help locate that evidence. Trade response model
I would log submission time, first observed fill, every verified fill, final outcome, and read failures. “Submitted to first observed fill” includes polling delay. A few orders cannot establish a reliable fill-rate statistic, and counting every open order as a failed settlement would mix price eligibility with execution failures.
Cancellation has its own race
An API cancellation changes the orderbook’s state. It does not itself call the settlement contract. On-chain invalidateOrder(uid) requires the owner and sets the fill marker to its maximum value, preventing subsequent fills while that invalidation remains canonical. A fill ordered before the invalidation can still execute. Invalidation source
My retry rule is conservative: request cancellation, reconcile existing fills, then confirm on-chain invalidation or wait until chain time is strictly past validTo before signing a replacement for the remaining amount. A local deadline or a cancelled badge alone is insufficient. Removing allowance can stop transfers, but restoring it before expiry can make an otherwise valid signature executable again.
Signing an ordinary EIP-712 order uses no transaction gas. Approvals and on-chain invalidation do. Measure those using their own receipts; a solver’s batch receipt is not my personal gas bill. For the $50 trial, I would stop if estimated wallet transaction costs exceed $1, a self-imposed 2% cap, and inspect the displayed trading fees separately. This is a budget rule, not a claim about current Arbitrum fees.
Risk analysis
- Technical risk. A valid signature can authorize the wrong token, receiver, or deployment. Check typed data and the actual spender. Contract and token bugs remain possible even with correct parameters; use ordinary tokens and a bounded allowance.
- Economic risk. The order may never fill, fill only partly, or lose an attractive opportunity while waiting. Compare net received tokens and actual spending after fees. A limit order is unsuitable when repayment or an exit must happen promptly.
- Operational risk. API downtime, stale indexing, RPC disagreement, and gas spikes can obscure a fill or delay cancellation. Preserve unknown states, verify receipts, and reconcile before replacing an order.
- Permission risk. Open signatures can compete for the same balance. An exact allowance limits aggregate spending through that spender but does not reserve funds for one UID. Keep one trial order open and clean up permission after resolving it.
Practical takeaways for the first funded run
The local exercise made remaining amounts and read failures visible. It did not tell me how often a live solver would fill the order. That needs actual orders, receipts, and a record of whether the limit was executable during the observation window.
Use a limit order when price matters more than timing and a partial position is acceptable. Use a fresh immediate-swap quote when timing matters more, with a deliberate minimum output and a gas budget. Neither choice removes execution risk.
For the first run, I would keep one $50 order, a short explicit expiry, and the UID beside every log entry. The useful result is a reconciled trade or a documented non-fill. A second signature issued because the first screen looked stuck is exactly what this notebook should prevent.