rfq_quoter role on a provisioned account. See
Becoming an RFQ Quoter.
The runner
Everything below is driven by one component,MakerQuoteRunner. You supply a
pricing function; it owns subscribing to the request channel, catching up on
open requests, pacing its polls inside the account’s rate budget, de-duplicating
requests it has already answered, dropping requests whose window has closed,
signing, persisting the quote nonce before each send, and reconciling fills
after a reconnect.
Note what is absent. Nothing in the runner pulls a delivered quote back.
Two ways to run it
The runner ships inside thejoyride-cli package, and
joyride rfq maker watch is that runner with your pricing function as a
subprocess. This is the path that is installable today, and it is a real
production path rather than a demo: a pricing program in TypeScript, Python, or
anything else that reads and writes JSON plugs straight in.
The library itself — for embedding the runner in your own long-lived TypeScript
process — is distributed to onboarded quoters rather than published to a public
registry. Ask for it at
support@joyride.exchange when you are ready
to run in-process.
Pricing as a subprocess
--price-command runs your program once per open request, with the request as
JSON on stdin and your answer as JSON on stdout.
pricePackage returns a value it declines every request it receives, and each decline is an answer sent to a trader, so run it against live requests only once the model is in:
{"decline":"<reason>"}, or empty output, which declines with
--decline-reason. A firm price is {bid, ask} on a two_way request (either
key alone is also a firm price there), {ask} alone on a buy request, and
{bid} alone on a sell request; the request’s side field says which. A
price on a side the request did not ask for is declined low_confidence by
the runner and never sent, since the venue would refuse it. A program that throws, exits non-zero, or overruns
--price-timeout is treated as a decline too. A pricing bug must never wedge
the loop, and the taker is waiting inside a five-second window.
Decline reasons a maker may send are unlisted_instrument, near_expiry,
size_limit, stale_data, low_confidence, and queue_overflow. A record
can also carry timeout or leg_not_tradable, but those are the venue’s to
emit; the gateway refuses them from a maker.
The signed package quote
One quote answers one request with the side or sides of the whole package the request asked for. A side you leave out is signed as a reserved sentinel by the SDK and omitted from the wire; you never write the sentinel yourself.
When your pricing function or
--price-command supplies the two maxima
itself, the keys are maxFee and maxLockedBalance; the runner sends them
as max_maker_fee and max_maker_locked_balance. A supplied value that does
not parse is declined, never replaced with a derived one.
Both totals are signed, and a sign is not decoration: a negative ask means
the taker is paid to buy the package, which is cash leaving your vault. The
most the package can take out of your vault is therefore
max(bid, -ask, 0), not the larger magnitude of the two.
The fee is on top of that, and it is owed whichever side the taker takes. So
the most a quote can cost you is max(bid, -ask, 0) + max_fee, and that total
is what an operator ceiling bounds — on the CLI’s joyride rfq maker watch, on
the MCP maker tools, and on joyride rfq accept’s --max-premium. A package whose
premium comes towards you is
not automatically under a ceiling: its premium contributes nothing to the cost
and its fee is the whole of it. The fee is also charged and
capped per leg, so it grows with the number of legs while the premium does not
have to; see Fees and Limits.
Totals are whole-package, never per contract, and are canonical six-decimal
strings. Do not compute them in floating point.
The two maxima are the maker’s own guards inside the signed bytes. The runner
derives them from the venue’s stated requirement on the request plus the
headroom you configure, or you can return them yourself. When the request
carries no computed funding view, the runner declines with stale_data; it
never reads a missing maximum as zero.
Every signed quote also carries your quoting subaccount address, copied in
verbatim. It is not derived, because a wrong address signs bytes the venue
refuses. The in-process SDK’s makerSubaccount value must be the 32-byte
address encoded as base58. joyride rfq account --output json reports the
same 32 bytes as 64 hexadecimal characters, so decode that hex and base58-encode
the bytes before constructing MakerQuoteRunner. Do not pass the hex string to
the SDK. The joyride rfq maker CLI commands are a separate convenience layer:
their --maker-subaccount flag and JOYRIDE_RFQ_MAKER_SUBACCOUNT variable
accept either encoding and normalize hex to base58 before calling the SDK.
The quote nonce
The venue keeps a per-account quote nonce high-water mark, and it survives restarts. A counter that restarts from a low value on each boot collides with that mark and has its first quote refused withRFQ_STATE_CONFLICT; a
wall-clock seed does not, which is what the runner falls back to when it has
no provider.
The nonce is persisted before a quote is sent, never after. The CLI writes it
to ~/.joyride/rfq-maker-nonce.json with owner-only permissions, through a
temp file and a rename, and a damaged file is a loud configuration error naming
the file rather than a silent restart of the series.
In-process, the same contract is an RfqNonceProvider:
Reconciliation
The maker channel deliversfill and finality pushes over your own
connection. A disconnect can drop them, and a fill that happened while you were
away still happened.
onFill fires once per finality stage, not once per economic trade. A fill
arrives as confirmed, then as finalized or reverted. finalized is
terminal success. reverted means that transaction attempt left the canonical
chain, but it is not terminal for the accepted RFQ: the RFQ returns to
submitting, and the relayer may land the same trade again under a new
tx_signature.
Key hedge state on rfq_id and the quote identifier, with the transaction
signature as an attempt identifier. A live push carries quote_id, a base58
tx_signature and maker_side; a reconciled REST row carries the same quote
hash as quote_hash, a hex tx_signature and the taker’s numeric side, so
normalize both to one key before comparing. On reverted, mark the attempt reverted but do not
unwind the hedge yet. A later confirmed fill for the same RFQ is a re-land,
not a second trade, so retain the hedge rather than placing another one. Unwind
only after the RFQ emits state: "failed"; on finalized, retain the hedge and
clear the pending-finality state. MakerQuoteRunner.onFill forwards fill and
finality objects, not state events, so also observe
client.rfq.maker.onEvent for the terminal failed state.
Give the runner a fill reader and a disconnect signal and it reconciles the
common case: one second after each disconnect it reads the newest 100 maker
fills, one page, and forwards any it had not seen. It does not page further,
and it does not reconcile on startup or when the socket comes back, so a desk
that can miss more than 100 fills, or that restarts, should read
GET /api/query/rfq-fills?role=maker itself with its bearer token, newest
first, cursored by the last row’s filled_seq_no.
A reverted row alone does not prove that the RFQ ultimately failed; check for a
later active attempt for the same RFQ and keep an unresolved reversion out of
automatic hedge-unwind logic.
For a strategy fill, legs is the authoritative package. Each leg includes
instrument_registry_id, the canonical symbol, and its signed six-decimal
quantity. The scalar instrument_registry_id, symbol, and size fields
are compatibility fields for one-leg fills and are null for a multi-leg
package.
Two traps are worth naming. The side on a fill row is the taker’s side, so
a desk that renders it as its own direction prints the opposite of what
happened. And the server clamps the page limit, so a page shorter than you
asked for does not prove you have reached the end; page until a page comes back
empty.
There is no cancel and no replace
A delivered quote is firm until itsexpires_at. There is no method that pulls
it back, amends it, or replaces it, and none is planned. The TTL you set is the
only control over how long you are committed.
This is the difference that costs money if it is ported over rather than
designed for. A quoting loop built for a venue with cancel-and-replace treats a
stale price as recoverable: it sends wide, then tightens or withdraws as the
market moves. Here, the price you sent is live against you until it expires, so
the exposure you accept is the full TTL multiplied by everything the underlying
can do in it — and on an adverse move you will be taken on every quote you left
standing.
Practical consequences:
- Choose a TTL you are willing to be held to on your worst tick, and treat lengthening it as a risk decision, not a convenience.
- The runner requires the TTL explicitly and has no default, for this reason.
- The runner clamps a TTL down to the request’s own ceiling
(
max_quote_expires_at), never up; the venue rejects a later expiry rather than clamping it, and caps any quote’s life at one hour. Shortening firmness is always safe. - A quote that would reach the venue with under two seconds of life left is
not sent; the runner declines the request with
near_expiryinstead, since a quote that short would be firm but untakeable. - Losing quoters are not notified. The first firm quote wins and a later
respond is refused with
RFQ_STATE_CONFLICT, so keep a local expiry timer per quote to release reserved risk.
Embedding the runner
For an in-process desk, the runner is configured once and started:session
must come from a client composed with the same nonces provider and a signer,
or the constructor refuses to start a nonce series held in memory. fills is
a MakerFillReader, an object exposing getRfqFills; the client’s read
surface satisfies it structurally, which is how the CLI passes it.
connection is anything exposing onDisconnected(handler).
price may return a firm price ({ask}, {bid}, or {bid, ask} as the
request’s side allows), a {decline}, or null to decline with the
configured reason, and may be synchronous or asynchronous. stop() detaches
cleanly, whenIdle() waits for in-flight pricing to drain, and stats()
exposes counters for every branch the loop can take, so an operator can
scrape them: priced, quoted, declined, dropped (window already closed),
filtered (outside your assets), unresolved, errors, and fatal, which
holds the configuration error that stopped the loop, or null.
One counter deserves attention. Unresolved means a quote left your process
with an unknown outcome, and it is never re-sent. A blind retry either gets
refused or puts a second firm commitment on one request. A maker cannot read a
request or quote back, so treat the quote as live until its expires_at and
check GET /api/query/rfq-fills?role=maker for a fill.