[simplex]: Keep posted limit orders alive with heartbeats, renewal and reconciliation - #1275
Conversation
543d09e to
c1ece6f
Compare
c1ece6f to
949e47c
Compare
Wizdave97
left a comment
There was a problem hiding this comment.
Reviewed against #1267 §7 and hyperfx-orderbook@main.
The lifecycle itself is solid: three independent clocks, a running guard so a slow orderbook can't overlap passes, failures confined to their own clock, and boot not blocking on the first reconcile. Halving heartbeatIntervalSecs so one lost request isn't a suspension is the right margin. expireStale before renewExpiring is the right order and the comment says why.
Two checks I ran that came back clean and are worth recording:
myOrdersselects nostatus, and that's correct —push_order_filterwithstatus: Noneemits no status clause, so the page coversACTIVEandSUSPENDEDalike (crates/store/src/orders.rs::solver_order_pages). Reconciliation sees suspended entries and won't repost duplicates for them.underFunds()gatingbacked === falseonvalidatedAtbeing present is right: per the schemabackedis also false before any cycle has read a balance, and an order surfaces at its quoted size meanwhile. Reporting that as under-funded would have been noise on every fresh posting.
One comment below. Note that the two other findings I had here — repost() discarding the cancel result, and the transport-error/UNKNOWN_ORDER conflation that makes it fire — belong to #1271 and #1270 respectively, where those lines were introduced. They both bear directly on the decision doc added in this PR.
ae0f6fa to
ad41f85
Compare
ad41f85 to
a90a359
Compare
523dfd4 to
c50a81c
Compare
Wizdave97
left a comment
There was a problem hiding this comment.
Re-reviewed at 5abbf901 against hyperfx-orderbook@main (now 9121a06). Every one of the 15 comments from the last pass is addressed, and several are fixed better than I suggested — POST_GRACE_MS generalising the reconciliation guard to all three posting paths rather than just the two I named, holdAll taking every hold or none, and 5d06ef80 catching a late posting resurrecting a cancelled row, which I had missed. drawDown/release now throw on a lost CAS, orderAt handles ORDER_EXISTS properly, transport failures are their own kind, and serverInfo.chains is validated. 279 tests pass locally.
The multi-order work has a correctness bug in the pricing, though, and the orderbook has moved under the pinned schema. Three comments below.
Wizdave97
left a comment
There was a problem hiding this comment.
Design direction for multi-order fills, plus a correction to something I got wrong in my first review.
The target shape: when several limit orders serve one incoming order, sort them best price to worst, keeping only those that match the incoming order's rate; price the full order input against each one; and submit a separate bid per limit order rather than one bid for a combined amount.
Reading the gateway on current main makes the case for that stronger than I put it, and shows that a rate filter is not optional — see the comment below.
The correction. In my first review I said dropping the issue's offer >= reqOut match condition (§3 rule 5) was a safe, better-reasoned relaxation, on the grounds that ranking by payout never loses a fill the rule would have kept. That reasoning only holds for selecting a single order. It is wrong about whether an order should participate at all, because of how escrow is released. offer >= reqOut is a genuine participation condition and should come back.
|
Second round is in The overfill donation is fixed by clamping the bid to
A fill now settles its holds in one store transaction. I had argued ordering was enough because a transaction would span two stores; that was wrong, Schema pin refreshed, and Two decisions in there are mine rather than yours, and both are worth arguing with: Tightest first, not best price first. Since every fill settles at One bid with slices, not one bid per order. With the bid clamped to the ask, the total is the ask, so a single bid covers it: each order funds a slice, is drawn down by that slice and receives that fraction of the input, so all of them settle at |
Wizdave97
left a comment
There was a problem hiding this comment.
Simplex should not repost a limit order whose operator expiry has passed — and today all three repost paths can.
expireStale is the only code that reads expiresAt, and it is a separate sweep rather than a guard. repost() is the shared entry point for every posting and never checks it, so whether an expired order gets put back on the book depends on which clock fires first.
7ea64f9 to
aae4eff
Compare
aae4eff to
3ae91c5
Compare
23ca1ee to
eaff412
Compare
a749905 to
c212836
Compare
c212836 to
cb78b49
Compare
Creation netted a new order's payout against what every other live order still promised in that token, and refused it if the sum was over the wallet. That is not how the inventory works: one balance backs every order resting on it, which is what quoting both sides of a book is, and the orderbook says the same — it advertises each entry at `min(quoted, balance)` rather than dividing the balance between them. Whichever order fills first draws the inventory down and the rest are cut to what is left. The check is now the one thing it can honestly say: this order alone is written against money that is not there.
A posting is a signed message to a service that answers a misplaced field with a rejection code and nothing else, so the op simplex builds is decoded the way the orderbook decodes it and pinned field by field: the quote, the take beside it, the TTL, the declaration, the commitment and nonce binding, the recovered signer. A build that declares no source chain is refused, since the encoder itself would take an empty list. The orderbook's vector fixture is gone with it. Its 14 ops were of the shape before this one, so reading them meant carrying decoders for two dead encodings in a test whose subject speaks neither. What the server alone can answer — its own config, its own state — is covered by the posting taken through a running orderbook in CI. The rig answers `version()` for the gateway and the solver account, which signing a bid reads before it will sign one.
Each matching limit order gets its own bid on the incoming order. The bids share the order's nonce key and are told apart by the EntryPoint sequence each signs, which the EntryPoint only runs in order, so the matcher now returns the orders best offer first and the bids take their sequences in that order: the best price signs the first one and is the one that can land first. Hyperbridge keys a bid by that sequence, so a bid's row carries the sequence its op signed rather than its offset from the key's current sequence, which a later round of bids on the same order would repeat once the key has moved. The gas estimate is shared by every bid on the order, but each bid prepends its own funding calls. The estimate is cached without a funding allowance and each bid adds one for its own calls when it is signed; baked in, every bid carried whichever calls were cached when the estimate was taken.
Each bid's nonce key now binds its own calldata, so a solver's bids on one order are each the first sequence of a key no other bid shares. The loop no longer counts sequences: there is no offset to hand on when a bid fails and no number to spend when one goes out, and one bid's fate holds none of the others up. prepareBidUserOp reads the nonce for the bid's own key when it signs it. Hyperbridge files each bid under keccak256 of its calldata, and so does the bid store: a row carries that identifier where it carried a sequence, and a reservation is claimed by it.
Retraction names each bid by the identifier recorded when it was placed, as the pallet PR's retraction now does, so the reservation rig no longer stands in for Hyperbridge's storage. The bid store's schema declares the identifier once.
354634c to
7089552
Compare
Section 7 of #1267. A posting is not something the orderbook keeps for you: it suspends a solver it has not heard from, an entry expires on its own clock, and a crash between two requests leaves one side holding what the other does not.
LimitOrderLifecycleruns the three jobs that keep the two copies together, andbootFillerstarts and stops it with the filler.heartbeat()signsHeartbeat(solver, timestamp)on the same domain asCancelOrderand goes out on half the intervalserverInfoasks for. It stays quiet until something is posted, because the orderbook only knows a solver whose order it has accepted, and it fires immediately on a posting that comes backsurfaced: falsesince that is the orderbook saying the solver is still suspended.renewExpiring()replaces an entry before it expires rather than extending it, which is not possible: the op carries its own deadline and the orderbook remembers every op hash it has taken, so renewal is a fresh op on a new nonce.reconcile()pages through the solver's orders and cancels an entry nothing local owns, reposts an order whose entry has gone, and leaves aresizedorbacked: falseentry alone withUNDER_FUNDEDon the row, since reposting it would only have it cut down again. A row that went toresizingin the last two minutes is skipped, because that is a repost in flight rather than a crash, and posting a second entry for one liability is the thing section 6 went out of its way to avoid.An orderbook that is down at boot does not stop the filler starting. It prices from the local limit orders either way, a pass that throws is logged and its clock carries on, and a tick is skipped while the previous pass is still running.
orderbook.renewMarginSecsandorderbook.reconcileIntervalSecswere already accepted and validated but nothing read them. They now default to 120 and 300 seconds.The last acceptance criterion on #1267 is also covered here.
src/tests/fixtures/orderbook-userops.jsonis a copy of the orderbook's own golden vectors, 14 signedfillOrderUserOps whose descriptions each name the verdict they expect.posted-userop.test.tsreads an op the way the orderbook does and returns the refusal it would earn, all 14 vectors get the verdict they name, and the opprepareLimitOrderUserOpbuilds then goes through the same function and is accepted. Checking our op against a hand-written list of rules would only ever prove we agree with ourselves.Two checks were added on top of that, and each found a bug the rest of the suite could not see, because nothing else here reads a query or talks to a server.
schema.test.tsvalidates every document the client sends against the orderbook's publishedschema.graphql:submitOrderwas asking forcodeon bothOrderRejectedandOrderSubmissionFailed, which return different enums, so a server would have refused the whole mutation and no order would ever have been posted.orderbook.live.test.tstakes one limit order through a real orderbook, and found thatCancelOrderwas signed without thesolverfield the server's struct carries, so every cancel came backSOLVER_MISMATCH: withdrawing left the entry live, and a resize would have put a second entry behind the same liability.It also found that
backed: falsedoes not mean under-funded. It is false before any balance cycle has read the order too, and the order surfaces at its full quoted size until one does, so reconciliation was stampingUNDER_FUNDEDon healthy postings seconds after they went up.PostedOrdernow carriesvalidatedAtand only abacked: falsea cycle actually decided counts.The live test is skipped unless
HYPERFX_ORDERBOOK_URLorHYPERFX_ORDERBOOK_BINsays where to find a server, so it does not run in CI. It needs no chain: the config it writes runs the server the way its ownconfig.dev.tomldoes, with the protocol fee a constant and the validation cycle reading nothing.A review of the whole stack then turned up four defects, fixed in the last commit. An expired limit order was read only by the matcher, so it stopped matching while renewal kept its posting alive and reconciliation put it back, advertising depth the filler would always refuse;
expireStale()now withdraws it and the row moves to a newexpiredstatus. The matcher ranked on the offer rather than onmin(offer, remaining - reserved), so an order quoting a good rate with nothing behind it beat one that could cover the swap, and a pre-filter on the offer made a same-chain partial fill impossible whenever the price rather than the size fell short. And the catch aroundexecuteOrderreleased a reservation directly even after the bid row had taken ownership of it, which gave the same hold back twice when a listener threw.The last commit closes the rest of that review. Same-asset quoting comes back as a local limit order: no book trades a symbol against itself, so one that takes in and pays out the same symbol is stored and priced here and never sent anywhere, and it has to sit at or below par, which is the rule the curves expressed as ask-only and priced under 1. An operator whose
[[pairs]]still carries curve keys is warned at boot that their prices are no longer read, since the config parses cleanly and quotes nothing. A cross-chain order nothing can value in dollars now waits the deepest confirmation its curve allows rather than the shallowest.expiresAtis validated on the way in, and the amount helpers handle a token with more than 18 decimals instead of raisingRangeError.A second read of the stack found one more, fixed in the last commit.
setPostingandsetStatuswrote whatever the caller asked, so a posting already in flight when the operator cancelled the order, or when the expiry sweep took it down, wrote the row back toopenwith a fresh commitment and a live entry: the cancel was undone and the order matched swaps again. Both writes now take the statuses the row must still hold and answer null when it has moved on, andpostwithdraws the entry the orderbook just accepted when its write does not apply.One change outside the feature: a test in
log-store.test.tsstarts twoLogStores in the same tick and assumed the second would lose thewxrace, but two concurrentO_EXCLopens finish in whichever order the pool serves them, so it now waits for the first launch file before starting the second. The stalepairs.setCurveexample in thesrc/index.tsmodule doc, left behind by the curve removal, is replaced with a limit order.Stacked on #1271. Review the last seven commits only, or wait for #1271 to merge and this rebases onto main.
The last commit answers the review here. The grace period that keeps reconciliation from posting over a repost in flight only covered
resizingrows, butcreateposts after its insert andrenewExpiringposts after its cancel, and both leave a live row with no entry to find. The guard is onupdatedAtalone now, for any live row whose entry is missing, andrepostmarks the row for the duration so the renewal path says what it is doing. The decision note is rewritten around postings rather than resizes.The latest round answers the second review. The bid is clamped to the ask, since
targetOutputissolverAmountand the gateway hands everything abovetotalRequiredto the beneficiary and the protocol while debiting the solver the lot;payoutSurplusUsdwas booking that donation as the profit that justified the fill.offer >= requestedOutputis back as a participation condition, because proportional escrow release means every fill settles atT / Iwhatever fraction it covers, so an order below the ask would pay above the rate it signed. A fill settles its holds in one store transaction rather than three writes. The pinned schema is refreshed, token decimals now come from the orderbook's own registry instead of an RPC read per token, and a workflow diffs the pin against the server so it cannot drift silently again.Two decisions in there are mine rather than the reviewer's, and both are worth disagreeing with if the reasoning is wrong. Orders are drawn on tightest first, not best price first: the rate is the swapper's whichever funds the slice, so the choice only decides what stays resting, and a generous order qualifies for every swap a tighter one does plus swaps it cannot serve, at the same cost per unit. And a swap draws on several orders through one bid with slices, not one bid per order: clamping to the ask means the total is the ask, so a single bid covers it, which removes the shared nonce key, the head-of-line blocking and the dependency on #1259 entirely.
abb922e0answers the newest comment: no path reposts an expired limit order. The check now sits inrepost, which every posting goes through, rather than in the sweep, since reconciliation runs on its own clock and a late-settling fill never swept at all. An expired order reaching it is withdrawn and retired the way the sweep does it, through the same function. Reconciliation also counts a repost only when a posting actually landed, since a retired order was being reported as one.Part of #1267.