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.
{bid, ask}, an
explicit {"decline":"<reason>"}, or empty output, which declines with
--decline-reason. 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 both sides of the whole package.
When your pricing function or
--price-command supplies the two maxima
itself, the keys are maxFee and maxLockedBalance (the wire names are
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 the taker side’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. Read it from joyride rfq account --output json; hex and base58 are
both accepted.
The quote nonce
The venue keeps a per-account quote nonce high-water mark, and it survives restarts. A maker that seeds its nonce from memory on each boot will collide with that mark and have its first quote refused withRFQ_STATE_CONFLICT.
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 fill. The same fill arrives
as confirmed, then as finalized or reverted. A reverted fill did not
happen, so unwind anything you hedged on it. Key your handler on the fill’s
rfq_id and quote_id and act on finality, rather than counting calls.
Give the runner a fill reader and a disconnect signal and it reconciles for
you: after a reconnect it re-reads recent maker fills and brings its view back
in line. The underlying read is GET /api/query/rfq-fills?role=maker with your
bearer token, newest first, cursored by the last row’s filled_seq_no.
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 refused rather than sent: it would be firm but untakeable.
- Losing quoters are not notified. The first firm quote wins and later quotes expire quietly, 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:price may return a firm {bid, ask}, 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 priced, quoted, declined, dropped, unresolved, and errored
requests — every branch the loop can take, so an operator can scrape them.
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. Read the request back
instead.