All writing

Count Every Division Before the Balance Write

· 15 min read

  • exchange-infrastructure
  • accounting
  • solidity
  • architecture

Count Every Division Before the Balance Write

Written on 2026-08-24 from fifteen commit pinned or dated primary sources read line by line, plus one arithmetic harness built here and run against its own mutants, with no engine deployed and nothing measured on live traffic.

Two units of size at forty five basis points cost one unit of fee whole, and two units of fee when the same size arrives as two fills. The rate did not change. The size did not change. An integer division ran twice instead of once, and both times it rounded exactly the way it had been told to.

That gap is the subject. A fee is not a thing you move from one balance to another; it is a thing you compute, and computing it in integers means somebody receives what the division throws away. I have one worked answer to where that computation belongs, and it is my own. The settle itself is ERC-6909 internal balance accounting on warm slots with matching amortized across the batch, no ERC-20 external calls on the hot path.

A fee is derived, and every derivation leaves a remainder

A fee is not transferred, it is derived, and a derived quantity has a remainder that somebody has to receive.

Start with the vocabulary, because three different words are in use for the same idea and they are not interchangeable in code. OpenZeppelin's math library names four rounding directions and takes the direction as an argument to the operation rather than as a property of the value. Floor goes toward negative infinity, ceiling goes toward positive infinity, truncation goes toward zero, and expansion goes away from zero. On a positive number floor and truncation agree; on a negative number ceiling and truncation agree. That is the entire trap, and it is why a codebase that rounds one way on the fee and another way on the rebate can look correct at both call sites.

If the fee rounds up, the payer covers the discarded part and the collector receives it; if it rounds toward zero, the payer keeps it on a charge and the collector keeps it on a rebate.

Applying one rule at both signs is what makes the direction auditable, because a reader has one line to check instead of two branches to compare. Apply different rules at the two signs and each site still look correct on its own, while the collector's balance drifts against the sum of what it charged, quietly, for as long as nobody sums it.

Rounding towards negative infinity, truncating towards zero and rounding towards positive infinity are three different functions, and over every numerator and denominator the harness enumerated, an inexact division puts the outer two exactly one smallest unit apart.

Now the second half, which is about how many times the division runs. Splitting one order into N fills and rounding each fill's fee up costs the payer at least as much as one aggregate rounding and at most N minus one extra smallest units, enumerated over every composition of a total into N ordered parts inside the declared space. The lower bound is the useful half for a venue and the upper bound is the useful half for a trader. Neither is a rounding error in the sense people usually mean, because the count is not noise: it follows from how the order gets filled, and on a public book that is decided by the resting side rather than by the fee code.

The shape is not confined to exchanges. A vault share conversion runs the same shape: OpenZeppelin's implementation reads the current totals and takes the rounding direction as an argument in one expression. the OpenZeppelin vault extension at its pinned commit The difference worth holding on to is that the vault derives its rate from state it reads in the same call that writes the balance, so both halves of the derivation land in one transition. A venue whose fee tier is assessed on a daily clock does not have that property, and the piece returns to it once the cost is on the table.

The case for charging once, at its strongest

Charging once for the whole order is not a shortcut, it is the correct arithmetic for the fee wherever the rate is one market wide constant, and the price Phoenix pays for it is that no consumer can attribute a fee to a fill.

Phoenix v1 by Ellipsis Labs accumulates the matched size across the whole match loop and computes the fee once, after it, rounding up twice on the way out. the Phoenix spot book, fifo.rs at its pinned commit The first rounding is a ceiling on the basis point arithmetic and the second lifts the result to a whole quote lot, and both of them happen once per order rather than once per fill. That is a real advantage: a taker sweeping seven resting orders buys one fee rounding rather than seven, and the fragmentation surcharge from the previous section disappears entirely. The quote leg still carries its own. A third rounding lands on the matched size itself, up on a buy and down on a sell, before the fee is added or subtracted.

It is legal because of a single property, which the file states in its own words. Its rate is a single market level field whose own comment states that there are no maker fees. the Phoenix spot book, fifo.rs at its pinned commit There is no per user tier, no per party lookup, and therefore nothing that could differ between one fill and the next. Aggregating a constant is not an approximation of anything.

The design pays for it twice, and both prices are visible in the same file. Because the fee is known only after matching ends, the buy budget has to be shrunk and the sell budget grown before matching starts. That is a second mechanism the aggregate shape forces into existence: a trader's spending limit has to be adjusted for a charge that does not exist yet, using the maximum the charge could turn out to be.

The second price is the one that matters for anybody downstream. Its per fill event carries the maker, the sequence number, the price and the size, and no fee field at all. the Phoenix spot book, fifo.rs at its pinned commit One summary event at the end of the order carries the total fee in quote lots. The fee it takes is added to a market level counter of unclaimed fees rather than to the payer's own row. So a reconciler outside the engine can tell you what an order paid and can never tell you what a fill paid, and no amount of care on the reconciler's side recovers it, because the information was never emitted.

One detail in that codebase is worth stealing whatever granularity you choose. Its quantity types log a warning whenever a division leaves a remainder, and the silent form has to be spelled out by name.

Six call sites, and the flag is on two of them

I read a fee path by listing its divisions before I read anything else.

dYdX v4 walks a taker order's maker fills one at a time and runs the whole single match routine once for each of them. The quote quantums a fee is charged on come from the maker's resting price, so a taker sweeping several levels pays on several different bases and never on its own limit. Both rates are fetched inside the match, one call for the taker and one for the maker, each keyed on that party's own account. That is what forces the loop: the rate is a state read, so it cannot be hoisted out and computed once.

A dYdX fee tier stores its maker and taker rates as signed integers in parts per million, so a rebate is a negative fee rather than a second code path. Carrying the sign as data is what lets one multiply serve both directions.

The comment directly above the two multiplies says that taker fees and maker fees and rebates are rounded towards positive infinity, and the same flag is passed to both. the dYdX perpetuals clob, process_single_match.go at its pinned commit A comment is not a guarantee, so open the helper it names. The helper behind that flag adds one only when the division left a remainder and the two operands share a sign, which is a true ceiling on a positive fee and on a negative rebate alike. the dYdX perpetuals clob, big_math.go at its pinned commit

Toward positive infinity increases a positive fee and shrinks a negative rebate, so one rule favors the collector on both signs. What is branchless is the call site: the same flag goes in for the fee and for the rebate, and the sign handling lives one level down.

Six named call sites between a dYdX fee tier and the balance write each hand out a remainder, they round in three different directions, and an argument saying which way appears on only the two that produce a charge.

The four functions, as the harness compiles them.

fn floor_div(a: i128, b: i128) -> i128 {
    let q = a / b;
    if a % b != 0 && (a < 0) != (b < 0) { q - 1 } else { q }
}
 
fn trunc_div(a: i128, b: i128) -> i128 {
    a / b
}
 
fn ceil_div(a: i128, b: i128) -> i128 {
    let q = a / b;
    if a % b != 0 && (a < 0) == (b < 0) { q + 1 } else { q }
}
 
fn phoenix_fee(size: u128, bps: u128) -> u128 {
    (size * bps + 10000 - 1) / 10000
}

The same library's other parts per million helper carries a comment saying it rounds towards negative infinity. the dYdX perpetuals clob, big_math.go at its pinned commit Opening it shows one multiply and one divide with no adjustment after them. That divide is the standard library's Euclidean method rather than the language operator, and its own body subtracts one whenever the remainder comes out negative against a positive divisor. the Go standard library arbitrary precision integer source at a release tag The tier thresholds a rate is selected against go through that helper. The tier loop runs that helper twice, once against the total volume share requirement and once against the maker share. A threshold rounded down is a bar lowered, so the trader clears it marginally sooner, and those are the first two call sites.

The third is on the rate itself. The rate this fill will use passes through a plain integer multiply and divide whenever a per market discount is active. the dYdX perpetuals clob, feetiers keeper.go at its pinned commit The language specification for that divide states that integer division is truncated towards zero. The Go programming language specification, arithmetic operators Toward zero is floor on a positive fee and ceiling on a negative rebate, which means this division favours the trader on the fee side and the collector on the rebate side.

The fourth is the only one of the six that branches on the sign. The staking discount runs only when the fee is positive, so a rebate never passes through that division at all. That discount is applied by the same plain multiply and divide, with no direction argument on it. The fifth is the one already met, the trading fee itself. And a sixth. A builder code fee is derived from the same fill quantums by the same round up helper and subtracted from the same balance delta. the dYdX perpetuals clob, builder_code.go at its pinned commit

Below, the six call sites one fill passes, and which transition each runs in.

SIX CALL SITES, THREE DIRECTIONS, ONE FLAG ON TWO OF THEM
 
  fee tier params ..................... GetPerpetualFeeParams (state)
        |
        |  [1] total volume share requirement   lib.BigIntMulPpm    toward negative infinity
        |  [2] maker volume share requirement   lib.BigIntMulPpm    toward negative infinity
        v
  getUserFeeTier  ->  base rate, sint32 ppm, the sign carries the rebate
        |
        |  [3] per market discount     int64 * ppm / MaxChargePpm   toward zero
        |  [4] staking discount        int64 * ppm / MaxChargePpm   toward zero, positive fees only
        v
  GetPerpetualFeePpm  ->  the rate this fill will use
        |
        |  [5] the trading fee         lib.BigMulPpm(.., true)      toward positive infinity
        |  [6] the builder code fee    lib.BigMulPpm(.., true)      toward positive infinity
        v
  persistMatchedOrders  ->  quote balance delta, both sides, one subaccount update
        |
        v
  one fill event carrying MakerFee, TakerFee and both builder fees
 
  Only [5] and [6] are handed an argument saying which way. No site runs in a different state
  transition from the balance write it feeds.

The drift condition from the first section needs two rules at one site, and the fee amount site applies one rule to both signs. What the chain has instead is three directions spread across six call sites, and one site a rebate never reaches. No division on the path runs in a different state transition from the balance write it feeds, and that is the property that makes the whole chain checkable at all. Each side's fee is subtracted from that side's own quote balance delta, inside the same subaccount update that moves the position. Conservation holds at the event: a reader can compute what a fill charged from the fill, and audit the direction by opening the three files those call sites live in.

Every inexact division between the fee parameters and the balance write hands its remainder to somebody, and any one outside that write gives your fee two sources of truth.

What it costs, and when it reverses

Per fill accounting hands the taker a surcharge that scales with the fill count, and the fill count comes from the shape of the resting book rather than from the order.

The maker pays too, and the payment is easy to miss because it looks like the same rule. A rebate rounded toward positive infinity is a rebate rounded down in magnitude, so the maker is paid slightly less than the published rate promises on every fill that does not divide cleanly. On one path the maker is paid nothing. On a liquidation the taker fee is set to zero and the maker rebate is floored at zero, and the comment gives the reason as the fee collector having insufficient funds to pay it.

On my own book the same shape appears on the other side of the trade. A taker pays for every level it consumes, and rebalancing makes the per trade cost a distribution rather than a constant. That variance is the price of a structure that bounds the search rather than the sweep.

Neither matching engine in this piece is one I have run: all of it was read from retained bytes, and the only thing here I built is the arithmetic harness.

The recommendation reverses when the rate is one market wide constant and nobody outside the engine has to attribute a fee to a fill. Under both conditions the aggregate shape wins the arithmetic and the surcharge, and it still owes the budget adjustment that a fee known only after matching forces on it. Hyperliquid's published schedule, as it stood on 2026-08-07, sets the fee tier from a rolling fourteen day volume assessed at the end of each day in UTC. Hyperliquid published fee schedule On the same page, maker rebates are paid out continuously on each trade. The tier is set on one clock and paid on another, and whether those two reconcile is not something the page says. Its node repository at that commit holds thirteen files, among them a Dockerfile, two readme files, a compose file, a pruner and a public key, and no engine source at all. the Hyperliquid node repository tree at the pinned commit The record is not. The same readme documents a flag that streams one record per fill in the published fills format, carrying the deployer fee field.

I have opened a part of a system nobody had touched in months and found something in it that nobody knew was there, myself included. A ledger whose conservation is checkable at each event does not depend on how recently anybody read it, and that is the argument for paying the surcharge rather than the argument against it.

What this piece does not settle

This piece measures nothing: no engine was executed, and no cost, latency or throughput figure for either matching engine appears anywhere in it.

Three conditions stop it applying to your system. If your rate is one market wide constant and nothing downstream attributes a fee to a fill, the aggregate is correct and cheaper. If your fee never passes through an integer division, none of these sites exist. If your engine's source is not published, the arithmetic is closed to you, and what remains admissible is whatever the engine itself emits. What was not examined: both engines under load, any fee level, and any cost. The campaign behind the arithmetic is one enumerated harness over a declared space, five targeted mutants and one control.


Abdel KIARI

I’ve owned the EVM side of a DeFi protocol. I redesigned its architecture from scratch and built the Solidity infrastructure end to end.

Always happy to talk about interesting opportunities

abdel.kiari@gmail.com