Skip to main content

Build an RFQ Quoter

This guide builds an RFQ quoter on the raw SDK: a process that listens for incoming RFQs, prices them, and submits signed quotes. The whole thing is about forty lines — the SDK handles offer construction, canonical hashing, signing, and wire encoding for you.

If you'd rather not write code at all, the CLI ships the same loop as a single command: overcast quote.

You currently need an Overcast API key before any of this connects — the RFQ socket handshake is authenticated. See Getting an API key.

How a quoter fits the flow​

A user creates an RFQ on the MAKER side (they'll write the option and post collateral). Your quoter registers as TAKER: it buys the option and pays the premium. Concretely, for each incoming RFQ you:

  1. Receive partial OptionDetails — every term except the premium.
  2. Decide what premium you'd pay (or skip).
  3. Submit a quote with the completed details.
  4. If the creator accepts, the backend settles the trade on-chain and tells you the resulting optionId.

Step 1: authorize your signing key​

Do this first. Every quote and offer you send is signed by your protocol signer — an ed25519 key derived from your privateKey — and the backend only accepts operations from a key the chain recognizes as yours. Authorizing the key writes an on-chain record that links your wallet address to that public key:

await app.chain.setAuthorizedKey(app.protocolSigner!.publicKey);

Without this link your quotes are rejected with BAD_SIGNATURE. You only need to do it once per key; revoke it later with app.chain.removeAuthorizedKey.

Using a KMS key instead of a raw private key​

Instead of a local private key, you can sign with a key stored in AWS KMS or GCP Cloud KMS — the private key never leaves the cloud provider.

import { OvercastProtocolSigner } from "@overcast-xyz/core";
import { AwsKmsEd25519Signer } from "@overcast-xyz/kms-signer-aws";

const raw = await AwsKmsEd25519Signer.create(process.env.KMS_KEY_ID!);
const protocolSigner = new OvercastProtocolSigner(raw);
// Owner attribution comes from the factory/app `owner` config, not a wrapper.

Pass protocolSigner in the factory config in Step 3 instead of privateKey. Your wallet keypair is still needed separately for on-chain transactions (like deposit and setAuthorizedKey above) — the KMS key only replaces the protocol signer used for quotes and offers.

Use @overcast-xyz/kms-signer-gcp (GcpKmsEd25519Signer) for GCP Cloud KMS instead. The CLI covers the same setup with no code — see Using a KMS signing key.

Step 2: fund your escrow account​

Premiums are paid from your escrow account, not your wallet. A quoter that connects with an empty escrow account can receive RFQs but its accepted quotes will fail to settle — this is the most common integration mistake.

await app.chain.deposit({
depositor: app.owner,
token: cashSlot, // canonical Bytes32 slot, e.g. app.utils.toBytes32(cashAddress)
amount: 10_000_000_000n,
});

Step 3: connect and register​

import { OvercastApp, completeOptionDetails } from "@overcast-xyz/core";
import { solana } from "@overcast-xyz/solana-client";

const app = await OvercastApp.create({
factory: solana.withConfig({
rpcUrl: process.env.SOLANA_RPC_URL,
backend: process.env.OVERCAST_BACKEND_URL,
apiKey: process.env.OVERCAST_API_KEY,
privateKey: process.env.OVERCAST_KEYPAIR,
}),
});

const rfq = app.rfq; // socket connects here
await rfq.registerRole("TAKER"); // quoters buy the option → TAKER side

Registration is per-connection; the client automatically re-registers your role whenever the socket reconnects.

Step 4: price and quote incoming RFQs​

onNewRfq fires with the RFQ's curated partial details — asset addresses are already resolved to metadata (symbol, decimals, price hints), which is exactly what a pricing model needs:

rfq.onNewRfq = async (incoming) => {
// 1. Decide your premium (your pricing model goes here).
const premium = await priceRfq(incoming);
if (!premium) {
// Optionally tell the creator you're passing, so they don't wait on you.
await rfq.skipRfq({ rfqId: incoming.rfqId, reason: "unsupported pair" });
return;
}

// 2. Fold the premium into the RFQ's terms → full, signable OptionDetails.
const details = completeOptionDetails(incoming.details, premium, app.utils);
if (!details) return; // a leg was missing

// 3. Quote. The SDK builds your settlement offer, content-addresses it,
// signs the operation digest, and sends it — salt and expiry are defaulted for you.
const { quoteId } = await rfq.submitQuote({
rfqId: incoming.rfqId,
details,
creator: app.owner,
side: "TAKER",
});
console.log("quoted", incoming.rfqId, "→", quoteId);
};

A premium is just (premiumAsset is the canonical Bytes32 slot):

const premium = {
premiumAsset: cashSlot, // Bytes32 slot of the asset you pay in
premiumAmount: 12_500_000n, // native units, bigint
};

For a reference pricing model, see the CLI's built-in Black-Scholes pricer (packages/overcast-cli/src/commands/quote.ts): it identifies the cash leg vs the volatile leg, treats cash-settlement as a covered call and cash-collateral as a cash-secured put, derives the strike from the two amounts, and quotes fair value plus a spread.

Step 5: handle the rest of the lifecycle​

rfq.onQuoteAccepted = (accepted) => {
// Your quote won — the option is already minted on-chain.
console.log("filled:", accepted.optionId, "rfq:", accepted.rfqId);
};

rfq.onRfqCancelled = ({ rfqId }) => console.log("cancelled:", rfqId);
rfq.onQuoteCancelled = ({ quoteId }) => console.log("cancelled:", quoteId);
rfq.onRfqExpired = ({ rfqId }) => console.log("expired:", rfqId);
rfq.onError = (err) => console.error(`rfq error [${err.code}]`, err.message);

process.on("SIGINT", () => {
rfq.disconnect();
process.exit(0);
});

Errors carry structured codes (RfqErrorCode): NOT_REGISTERED, WRONG_ROLE, VALIDATION_FAILED, RFQ_NOT_FOUND, RFQ_NOT_OPEN, QUOTE_NOT_FOUND, NOT_OWNER, ALREADY_ACCEPTED, BAD_SIGNATURE, SETTLEMENT_SUBMIT_FAILED, COLLATERAL_SUBMIT_FAILED, ACCEPT_OFFER_FAILED, INTERNAL. Requests that receive no acknowledgment reject after ackTimeoutMs (default 30 s).

The complete quoter​

import { OvercastApp, completeOptionDetails } from "@overcast-xyz/core";
import { solana } from "@overcast-xyz/solana-client";

const app = await OvercastApp.create({
factory: solana.withConfig({
rpcUrl: process.env.SOLANA_RPC_URL,
backend: process.env.OVERCAST_BACKEND_URL,
apiKey: process.env.OVERCAST_API_KEY,
privateKey: process.env.OVERCAST_KEYPAIR,
}),
});

await app.chain.setAuthorizedKey(app.protocolSigner!.publicKey); // once per key

const rfq = app.rfq;
await rfq.registerRole("TAKER");

rfq.onNewRfq = async (incoming) => {
const premium = await priceRfq(incoming); // your model
if (!premium) return;
const details = completeOptionDetails(incoming.details, premium, app.utils);
if (!details) return;
const { quoteId } = await rfq.submitQuote({
rfqId: incoming.rfqId,
details,
creator: app.owner,
side: "TAKER",
});
console.log("quoted", incoming.rfqId, "→", quoteId);
};

rfq.onQuoteAccepted = (a) => console.log("filled:", a.optionId);
rfq.onError = (e) => console.error(`[${e.code}]`, e.message);

console.log("quoter running as", app.owner);

That's the entire integration. Everything else — offer structs, salts, expiries, canonical IDs, ed25519 signatures, bigint wire encoding — is the SDK's job.

After the fill​

Once a quote is accepted you hold a live option position as the taker (optionId comes from acceptQuote / onQuoteAccepted):

const option = await app.api.getOption(optionId);
const amount = await app.chain.exerciseClaims(option, app.owner);

// Exercise: swap settlement for collateral, any amount up to your amount.
await app.chain.exerciseOption(option, amount);

The counterparty (maker) later calls redeemOption to collect their side. Track positions either through app.api.listOptions({ filter: { taker: app.owner } }) or by consuming indexed chain events (MarketOptionCreated, OptionExercised, OptionRedeemed) via the indexer interfaces.

The other side: creating RFQs​

If you're building the requesting side instead (a wallet UI, a structured product), the flow is the mirror image:

await rfq.registerRole("MAKER");

const quotes = [];
rfq.onNewQuote = (q) => quotes.push(q);
rfq.onRfqSkipped = ({ quoter, reason }) =>
console.log(`${quoter} passed`, reason ?? "");

const { rfqId } = await rfq.createRfq({
creator: app.owner,
request: { details }, // partial: no premium fields
side: "MAKER",
});

// ...collect quotes, pick the best one...
const { optionId, txHashes } = await rfq.acceptQuote({
quoteId: best.quoteId,
side: "MAKER",
details: best.details, // the SDK signs your collateral offer
});

acceptQuote settles inline: when the promise resolves, the option is minted and txHashes points at the settlement transaction(s).

FAQ: quote lifetime, cancellation, and last look​

How long does a quote stay live?​

As long as you decide. Every quote carries an expiry (unix seconds) that you pass to submitQuote; omit it and the SDK defaults to DEFAULT_EXPIRY_DURATION (60 s) from now. There is no protocol-enforced minimum or maximum — seconds or longer, the window is yours to set.

The expiry is part of the offer's content-addressed id, so it's covered by your signature and enforced by the chain at match time. Once it passes, the quote is dead and cannot be matched.

Who sees my quote before it's accepted?​

Your quote stays off-chain, routed over the RFQ channel to the requester. Nothing touches the chain unless they actually accept it, at which point it settles on-chain against their matching offer.

Can I pull a quote before it's accepted?​

Yes, and cancellation is enforced on-chain, atomically. Since an unaccepted quote never touched the chain in the first place, cancelling it means bringing it on-chain and invalidating it in a single transaction, not two separate steps. There is no intermediate state where the quote is live on-chain but not yet cancelled, and therefore no gap in which someone holding your signed payload could sneak in a match.

Cancel over the RFQ channel by quoteId (cancelQuote), or, for an offer you posted on-chain yourself, directly with app.chain.cancelOffer(offer) — which refunds the escrowed premium to your deposit vault.

How much latency does a cancellation add?​

None on our side. Outside of normal chain confirmation time, there is nothing in Overcast adding delay between you deciding to cancel and that cancellation taking effect. It isn't a request that depends on a counterparty or an operator honoring it.