Operator

The operator turns a committed batch of sealed orders into transactions on a chain. It runs the SDK pipeline (openEpoch, matchEpoch) and sends the results to KasumiEpochManager and KasumiSettlement.

It does not accept orders and it does not hold user keys. It needs the batch the relay collected (the envelopes with their sequence numbers) and any cancel secrets the relay received.

There are two ways to run it:

  • Inline. The web relay in apps/web imports this package and, with KASUMI_MODE=live and KASUMI_OPERATOR=inline (the default), calls commitEpoch and settleEpoch itself. It reads the batch and cancel secrets straight from its own store. This is how the mainnet deployment runs. See docs/DEPLOYMENT.md, "Operating the live relay".
  • External. A separate process runs scripts/run.ts and pulls the batch from the relay over HTTP. Set KASUMI_OPERATOR=external on the relay so the two do not both send transactions from the same keys.

Relay key

  1. commitafter the cutoff, before decryptTime

Matcher key

  1. postMatchone hash per market
  2. settleMarketonce per market
  3. amendMarketonly on a failed market

Clock

  1. open
  2. cutoff
  3. drand round
  4. settle deadline
Transactions for one epoch with orders. The relay key sends one; the matcher key sends the rest. amendMarket appears only when a market's settlement would revert.

What is in the package#

File Contents
src/abi.ts ABIs generated from contracts/out. Do not edit; run pnpm sync-abi.
src/chain.ts createChainView (nonces, spendable balance, ERC-1271), readSchedule, readEpochTimes, readEpochStatus, readMarkets
src/fills.ts buildBatch (Merkle tree of a batch), buildFills (calldata for settleMarket), revert decoding, MatchInput JSON
src/operator.ts Operator.commitEpoch, Operator.settleEpoch, matchOneMarket, runRustMatcher
src/source.ts BatchSource interface and HttpBatchSource (reads a relay's operator endpoint, or its public API without a token)
scripts/run.ts long-running operator loop for a deployment (pnpm start)
scripts/sync-abi.ts regenerates src/abi.ts from the forge artifacts
scripts/e2e-anvil.ts end-to-end run on a local chain with real drand encryption

Configuration for a real deployment#

The package is a library plus one runner (scripts/run.ts, see below). Whatever runs it supplies:

Value Used for
RPC URL publicClient and both wallet clients
Relay private key relayWallet. Must be allowed by KasumiEpochManager.setRelay. Sends commit.
Matcher private key matcherWallet. Must be set with setMatcher on both contracts. Sends postMatch, amendMarket, settleMarket.
KasumiEpochManager address epoch times, status, commitment
KasumiSettlement address EIP-712 domain, nonces, markets, settlement
Market ids which markets to match. Parameters and oracle prices are read from the chain.
Path to kasumi-matcher (optional) cross-check of the result hash before anything is sent

The relay key and the matcher key should be different keys held by different processes. Both accounts need gas. Neither can move user funds outside of a valid signed order: the settlement contract checks every constraint again.

ts
import { Operator } from "@kasumi/operator";
import { DrandTimelockScheme } from "@kasumi/sdk";

const operator = new Operator({ publicClient, relayWallet, matcherWallet, epochManager, settlement });

// after the cutoff, before the decryption time
await operator.commitEpoch({ epochId, entries });

// after the decryption time and the drand round
const report = await operator.settleEpoch({
  epochId,
  entries,
  cancellations,                 // Map<commitment lowercase, cancel secret>
  scheme: new DrandTimelockScheme(),
  marketIds,
  rustMatcherPath: "matcher/target/release/kasumi-matcher",
});

Transactions per epoch#

  1. KasumiEpochManager.commit(epochId, root, orderCount) from the relay. Only valid while the epoch is CLOSED, which is from the cutoff until the decryption time. If this transaction does not land in that window the epoch is CANCELLED and nothing can settle. Budget the reveal delay accordingly. Sequences in the batch must be exactly 0..orderCount-1; the contract rejects a fill whose sequence is not below the committed order count.
  2. Wait for the decryption time and for the drand round the orders are locked to.
  3. KasumiEpochManager.postMatch(epochId, marketIds, marketHashes) from the matcher: one hash per market with fills, market ids ascending. An epoch with no crossing orders is published with empty lists and becomes SETTLED in this transaction.
  4. KasumiSettlement.settleMarket(epochId, marketId, clearingPrice, fills) from the matcher, once per published market. The contract recomputes the market hash from the transfers it performs and reverts with ResultHashMismatch unless it equals the published hash. When the last published market settles, the epoch becomes SETTLED. There is no separate finalize transaction.
  5. Only when needed: KasumiEpochManager.amendMarket(epochId, marketId, newHash) from the matcher, to replace the published hash of a market that has not settled, or to withdraw it with a zero hash.

Steps 3 to 5 must finish before the settlement deadline.

Every transaction is simulated first. Nothing is sent if the simulation reverts.

What settleEpoch checks before sending anything#

  • The epoch is DECRYPTABLE (fresh) or MATCHED (resuming). If it is already SETTLED the call returns a report with alreadySettled: true and sends nothing.
  • The Merkle root and order count of the entries it was given equal the commitment stored onchain. If they do not, the local batch is not the committed batch and every proof would fail.
  • Market parameters and oracle prices come from the chain. A market that is disabled, has no oracle price, or has a stale price is not matched at all, and is listed under halted in the report.
  • Orders are validated against chain state: nonce bitmap, min(balance, allowance), ERC-1271 for contract accounts.
  • Stock Token transfer restrictions are read from the issuer's registry (ACCESS_CONTROLLED_REGISTRY() on the token, then isBlocked(address) and paused()). An order whose owner or receiver is blocked is rejected as TRANSFER_BLOCKED. A market whose token is paused, by itself or registry-wide, is halted with TOKEN_PAUSED. If the settlement contract itself is blocked, every Stock Token market is halted with SETTLEMENT_BLOCKED. A token without the registry function (USDG, test tokens) is treated as unrestricted, and a failed probe does not reject an order: the onchain transfer stays the real guard.
  • If rustMatcherPath is set, the Rust matcher runs on the same input, for the published result and for every re-match. A different result hash aborts before the corresponding transaction is sent.

When a market's settlement fails#

A participant can make a matched batch unsettleable after the result is published: cancel the nonce onchain, revoke the allowance, or move the funds. Settlement is all-or-nothing per market, so the settleMarket simulation reverts.

The operator handles this per market, without touching the other markets:

  1. Decrypt and validate the batch again against current chain state. Orders that no longer pass (nonce used, funds missing) are rejected, exactly as they would have been at the start.
  2. Run the auction again for that one market. Markets are independent, so this is the same result a whole-epoch match over the remaining orders would give.
  3. If the market still crosses, amendMarket with the new hash and settle. If it no longer crosses, amendMarket with a zero hash, which withdraws it.
  4. At most 3 rounds per market. If settlement still fails for a reason re-validation cannot see, the market is withdrawn so it cannot hold the epoch open. A party blocked after the match is seen by re-validation and dropped in the first round.

Every amendment is an onchain MarketAmended event and an entry in report.amendments with the old hash, the new hash and the reason. resultHash in the epoch stays the original publication, so an auditor can see both what was first published and what replaced it. A withdrawn market's orders are unfilled and their nonces unspent.

Resuming#

settleEpoch keeps no local state. Before each step it reads the chain: epoch status, publishedMarketHash and marketSettled for every market it serves. Markets that already settled are skipped. Markets that are published but not settled are re-validated and re-matched; if the hash still equals the published one they settle as published, otherwise they are amended first. Calling it again after a crash therefore completes the epoch, and calling it on a settled epoch is a no-op.

One consequence: when resuming, validation sees the chain after the earlier markets settled. A wallet whose balance changed through those settlements can validate differently in a later market than it did originally. The result is still a correct auction over valid orders, but it may differ from the first publication, in which case it shows up as an amendment.

Failure handling#

Situation Behaviour
commit simulation reverts Throws. Nothing is sent.
Local batch differs from the onchain commitment Throws. Nothing is sent.
Rust and TypeScript result hashes differ Throws before the transaction that would publish that result.
settleMarket fails for one market Re-match, amend, retry (see above). Reported with every decoded error in markets[].errors.
A market cannot be settled after 3 rounds Withdrawn with amendMarket(…, 0), status WITHDRAWN. The other markets still settle and the epoch can reach SETTLED.
Withdrawal itself fails Status FAILED, finalStatus: "MATCHED". The epoch reads CANCELLED after its deadline; markets that settled stay final. Call settleEpoch again to retry.
Order rejected by validation Listed in report.rejected with a reason code. It never reaches the auction.
Process dies mid-epoch Run settleEpoch again with the same arguments.

Running the operator#

text
RPC_URL=...            # chain RPC
EPOCH_MANAGER=0x...    # KasumiEpochManager
SETTLEMENT=0x...       # KasumiSettlement
RELAY_PRIVATE_KEY=0x...
MATCHER_PRIVATE_KEY=0x...
RELAY_API_URL=https://<relay>/api/v1
OPERATOR_TOKEN=...     # same value as KASUMI_OPERATOR_TOKEN on the relay
RUST_MATCHER_PATH=matcher/target/release/kasumi-matcher   # optional
MARKET_IDS=0x...,0x...                                    # optional, default: the relay's /config
pnpm --filter @kasumi/operator start

The loop polls once a second. For each epoch that can still be inside its commit or settlement window it reads the status from the chain and does the next step: commit when CLOSED, settle when DECRYPTABLE or MATCHED and the drand round has been published. It stores nothing. It can be stopped and restarted at any time.

It refuses to start on chain id 4663 (Robinhood Chain mainnet) unless KASUMI_ALLOW_MAINNET=1 is set. The contracts are deployed there and unaudited; see the open items in docs/DEPLOYMENT.md.

The loop has been typechecked and its mainnet guard exercised. It has not been run end to end against a deployed relay. The mainnet deployment uses the inline operator instead.

Where the batch comes from#

The runner gets the batch through the BatchSource interface:

ts
interface BatchSource {
  entries(epochId: number): Promise<CommittedEntry[]>;
  cancellations(epochId: number): Promise<Map<string, Hex>>;
}

HttpBatchSource(relayApiUrl, { token }) reads both from the relay's authenticated endpoint GET /api/v1/operator/epochs/:id, sending Authorization: Bearer <token>. The relay only answers when its own KASUMI_OPERATOR_TOKEN is set and matches. The response carries the envelopes with their sequence numbers and the cancel secrets users revealed before the cutoff. Cancel secrets must stay private until decryption, which is why they are not in the public epoch endpoint.

Without a token, HttpBatchSource falls back to the public GET /api/v1/epochs/:id and returns no cancellations. A privately cancelled order would then still be matched. Use that only against a test deployment. Users always keep the onchain invalidateNonce escape hatch.

When the relay runs with KASUMI_OPERATOR=external it does not send commit itself. This runner is then the component that commits, so it must be running and funded before the first epoch closes. When the relay runs inline, do not start this runner with the same keys: two operators on one key race on nonces.

End-to-end test#

text
(cd contracts && forge build)
pnpm --filter @kasumi/operator e2e

Requirements: anvil on PATH, network access to the drand HTTP API, and optionally cargo (the script builds the Rust matcher and enables the cross-check when it can).

The script starts anvil, deploys three mock tokens (two markets), a mock oracle, the epoch manager and the settlement contract, and then runs one full epoch in real time (about one minute):

  • six traders sign and seal ten orders with drand quicknet timelock encryption, exactly as a browser would with the SDK
  • a relay stand-in assigns sequences and signs inclusion receipts
  • one order is cancelled by revealing its cancel secret, one by invalidating its nonce onchain, one comes from a wallet without allowance
  • decryption before the drand round is attempted and must fail
  • the batch is committed, every receipt is verified against the root read back from the chain, and a signed receipt for an omitted order is detected as censorship
  • the first settleEpoch call publishes the result and is then interrupted (a simulated crash); settling at a price other than the published one is shown to revert with ResultHashMismatch
  • the second call resumes from chain state. Just before the second market settles, a matched trader burns his nonce onchain; the operator re-matches that market without him, amends the published hash and settles, and the epoch reaches SETTLED
  • a third call sends no transaction
  • the script then checks token balances, nonces, the stored market hashes, the stored result hash, dust accounting, that a replay of settleMarket reverts, and that an epoch with no commitment cannot be committed after its decryption time

It exits non-zero on the first failed check and always stops anvil.

Unit tests for the pure parts: pnpm --filter @kasumi/operator test.

Mainnet fork rehearsal#

text
pnpm --filter @kasumi/operator rehearse

scripts/fork-rehearsal.ts is the dress rehearsal for a mainnet deployment. It sends nothing to the real chain: it forks Robinhood Chain mainnet at a recent block with anvil (chain id 4663, wall-clock time) and makes only read-only calls to the public RPC. It needs anvil, forge and network access to the RPC and to drand, and takes about three minutes.

What it does, in order:

  • deploys with the real contracts/script/Deploy.s.sol and lists the four Stock Token markets with the real contracts/script/ListMarkets.s.sol, using anvil dev keys. Broadcast records go to a temporary directory, not to contracts/broadcast.
  • if any feed is older than the default 26 hour limit at the fork block (market closed), it says so, raises the ages for the rehearsal only and continues
  • gives three test traders real Stock Tokens and USDG by writing the balance mapping in fork storage (Stock Tokens: the ERC-7201 OpenZeppelin ERC20 slot; USDG: mapping at slot 1)
  • probes the token behaviour settlement relies on and reverts the probes afterwards: exact-amount transferFrom and transfer by the settlement contract, uiMultiplier(), ERC-2612 permit, the registry blocklist (forced through storage) for sender, recipient and msg.sender, the per-token pause and the registry-wide pause (roles forced through storage)
  • runs one epoch: SDK signing, drand timelock sealing, commit, inclusion checks against the root read from the fork, then settleEpoch against the real KasumiChainlinkOracle and the real Chainlink feeds, with the Rust matcher cross-check when the binary is built
  • asserts the epoch is SETTLED, every balance change equals the match result, and the contract keeps only rounding dust
  • prints gas per transaction and the ETH cost at the current mainnet gas price, with the L1 data-fee estimate from NodeInterface.gasEstimateL1Component on the real chain

It exits non-zero on the first failed check and always stops anvil. The public RPC is rate-limited; anvil is started with --compute-units-per-second 40 --retries 20 and --hardfork cancun. Set ROBINHOOD_CHAIN_RPC_URL to use another endpoint.

The results of the 2026-10-02 run and the deployment commands derived from it are in docs/DEPLOYMENT.md under "Mainnet deployment, as performed".

Keeping the ABIs in sync#

src/abi.ts is generated. After changing a contract:

text
(cd contracts && forge build)
pnpm --filter @kasumi/operator sync-abi