@covrfun/maker-sdk
v0.1.1
Published
Builders, signing helpers and a gateway client for external market makers on covr's V3 covered-call program.
Maintainers
Readme
@covrfun/maker-sdk
TypeScript building blocks for an external market maker on covr's V3 covered-call program and RFQ gateway. It builds instructions, signs gateway messages and speaks the gateway's WebSocket protocol. It does not price, hold keys, pick an RPC, or submit transactions. Those stay yours.
Every function wraps covr's own reviewed code. covr's tests prove each builder emits the same bytes as covr's builders, and that each signed message passes the gateway's own verifiers. The package ships golden vectors so you can check your own signer the same way.
Status. covr turns gateway access on per desk. Until covr lists your desk on
its markets, your bids are verified exactly like real ones and then refused with
Bid intent verified but not entered: this market does not list this maker yet
(practice mode). They never win and never move prices. Desks can quote JitoSOL
today; tokenized stocks stay covr-only for now.
Install
npm install @covrfun/maker-sdk # Node 24 or laterRuntime dependencies (exact versions): @solana/kit, ethers, ws. The
package is ESM with TypeScript types; use it from an ES module project
("type": "module").
Getting started
You need three things from covr: the gateway URL, your bearer token, and your
quote key registered with covr. Send covr your quote key address (0x…) and your
Solana authority; covr sends the URL and the token. Keep the token
secret. You also need your own Solana RPC URL.
Check your signer. Sign the inputs in
vectors/golden-vectors.jsonwith your own signer (KMS, HSM) and compare byte for byte (see Golden vectors).Dry run (sends nothing). The package ships an example bot. Start in a new folder (the bot is an ES module and imports
ethersand@solana/kititself;npm pkg set type=modulewould change an existing project), copy the bot out ofnode_modulesand run it with your URL, token and RPC. With noMAKER_QUOTE_KEYit signs with a throwaway key and only logs.mkdir covr-maker && cd covr-maker npm init -y && npm pkg set type=module npm install @covrfun/maker-sdk [email protected] @solana/[email protected] cp -r node_modules/@covrfun/maker-sdk/examples/maker ./maker GATEWAY_WS_URL=wss://<from covr> GATEWAY_TOKEN=<from covr> \ RPC_URL=https://<your rpc> SPREAD_BPS=500 \ npx tsx maker/index.tsEach JitoSOL RFQ prints
DRY_RUN would send bid_intent {...}.maker/README.mdlists every setting.Replace the pricing stub.
referencePremiumE18()inmaker/index.tsreturns a constant 1.00 USDC per token. Put your model there.Register and fund (one time, your authority signs). Use the setup builders below. Check every step on chain at finalized commitment before the next.
Go live in practice mode. Add
MAKER_QUOTE_KEYandNONCE_JOURNAL, then setDRY_RUN=0. The bot reads your on-chainmin_nonce, starts at or above it and re-reads it every 5 s;MIN_NONCEcan only raise that floor. Until covr lists your desk, every bid is refused as "verified but not entered".First real order. Once covr lists your desk, the same bot wins orders. A fill shows up only on chain, as your position PDA (
derivePosition). The gateway sends no fill message.After expiry. Run the example keeper for the exercise window (see Expiry keeper).
Expiry keeper
An option you sold expires into an exercise window, expiry to expiry + window
(60 s to 1 hour, set by the market). Inside it only your authority or your
operator can exercise, paying the strike from your vault. After it anyone can
release, which returns the collateral to the writer and frees your exposure.
Nobody can release inside the window.
The package ships an example keeper that does both. Each pass it lists your open
positions with one getProgramAccounts, exercises a JitoSOL position when a fresh
spot price is at least MIN_EXERCISE_EDGE_BPS above the strike and your vault
holds the strike, and releases once the window has closed. It only prints what it
would send unless DRY_RUN=0. Its default price source reads Jupiter's public
Price API: replace it with yours.
cp -r node_modules/@covrfun/maker-sdk/examples/keeper ./keeper
RPC_URL=https://<your rpc> MAKER_ETH_ADDRESS=0x<your quote key address> \
MAKER_AUTHORITY=<your authority> MIN_EXERCISE_EDGE_BPS=100 ONCE=1 \
npx tsx keeper/index.tskeeper/README.md lists every setting and what each pass checks. Flash exercise
(the program pays the strike from the collateral itself) has no builder in the
SDK yet: until then an exercise needs the strike in your vault.
Withdraw
withdraw({authority, maker, destination, amount}) needs one Solana signature,
your authority's (your operator cannot withdraw), and no EIP-712 signature
(signWithdrawal takes a quote back from the gateway; it is unrelated to the
vault). The program reserves nothing in the vault: withdraw the USDC your open
positions need and their exercises fail; withdraw the premium of a quote you
signed and its fill fails. The premium budget and exposure limit only gate new
fills.
examples/keeper/withdraw.ts checks your Maker, the vault and the destination,
refuses to leave less than the strikes your open positions may still draw (unless
ALLOW_SHORTFALL=1), and only prints the instruction unless DRY_RUN=0:
RPC_URL=https://<your rpc> MAKER_ETH_ADDRESS=0x<your quote key address> \
MAKER_AUTHORITY=<your authority> AMOUNT_USDC=250 \
npx tsx keeper/withdraw.tsAPI
Constants
PROGRAM_ID, DEPLOYMENT_ID, GENESIS_HASH, USDC_MINT, USDC_DECIMALS,
JITOSOL_MINT, JITO_STAKE_POOL, TOKEN_PROGRAM, TOKEN_2022_PROGRAM,
CHAIN_ID, MAINNET, EIP712_DOMAIN_BASE, ORDINARY_TERMS_ID,
RESTRICTED_TERMS_ID, RESTRICTED_CANCELLATION_TIMEOUT_SECONDS,
REGISTRATION_SCOPE, assetAlias(), JITOSOL_ALIAS, USDC_ALIAS,
GATEWAY_MAX_FRAME_BYTES, and the auction timing COLLECT_MS, AWARD_MS,
TOTAL_MS, REVIEW_SECONDS.
Every function that touches a program address takes an optional last
Deployment argument (default MAINNET), for a local validator or tests.
Setup (your Solana authority signs and pays rent)
| Order | Builder | Notes |
| --- | --- | --- |
| 1 | signRegistrationGrant(quoteKey, authority) then registerMakerV3({authority, makerEthAddress, signature}) | The grant is checked locally first. |
| 2 | initializeVault({authority, maker}) | Premium budget starts disabled. |
| 3 | initializeExposureV3({authority, makerEthAddress, collateral, limitAtoms}) | Per collateral mint. |
| 4 | initializeMintPolicyV1({authority, maker, collateral, settings}) | Build settings with mintPolicySettingsFor(market, caps). |
| 5 | bootstrapMintUsageV1({authority, makerEthAddress, collateral}) | Fills fail until this runs. |
| 6 | setPremiumBudget({authority, maker, enabled, limit}) | Optional lifetime cap. |
| 7 | deposit({authority, maker, source, amount}) | USDC into the vault. |
| 8 | configureMaker({authority, makerEthAddress, operator, minNonce}) | Replaces both fields. |
Later changes, signed by the same authority:
withdraw({authority, maker, destination, amount})moves vault USDC to your own USDC token account. Nothing in the vault is reserved: exercises pay the strike from it, so keep enough there for the positions you will exercise (see Withdraw).setExposureLimit({authority, makerEthAddress, collateral, limitAtoms})takes the same input asinitializeExposureV3. The cap only gates new fills. Legacy SPL Token collateral only: the program cannot change a Token-2022 cap.updateMintPolicyV1({authority, maker, collateral, expectedGeneration, settings})replaces every setting.pauseMintPolicyV1({signer, maker, collateral, expectedGeneration})setsenabledto false; the policy's watcher may sign it. Both fail unless the policy is still atexpectedGeneration, then move it up by one.
PDAs: deriveMaker, deriveVault, deriveExposure, deriveMintPolicy,
deriveMarket, derivePosition.
decodeMaker(makerAddress, raw, makerEthAddress) decodes your Maker account
(read it at finalized commitment, owner PROGRAM_ID) and checks it is the PDA
of that key. Its minNonce is the floor every fill compares a quote nonce to.
decodeMintPolicy(policyAddress, raw, {authority, maker, collateral}) and
decodeMintUsage(usageAddress, raw, {maker, collateral}) work the same way: the
policy's generation (the expectedGeneration above) and settings (enabled,
watcher, caps), and the usage counters.
Markets
decodeMarketV3(marketAddress, raw)returns typed terms (settlementMode0 ordinary, 1 restricted). Read the account at finalized commitment and check its owner isPROGRAM_ID.marketDomain(marketAddress, raw)/quoteDomain(decoded)give the EIP-712 domain for that one market. The domain commits to every term, so a signature only verifies for the market you decoded.createMarketV3(input)/createRestrictedMarketV3(input)check the program's bounds (expiry 60 s to 90 days fromnowUnix, window 60 to 3,600 s, fee at most 1,000 bps). A market you create is not shown to customers until covr adds it to its catalog and app pins.
Quoting
buildBidIntent(snapshot, {maker, price, validUntil})copies the RFQ fields verbatim.priceis USDC x 1e18 per whole token.defaultIntentValidUntil()matches covr's own runner.signIntent(signer, domain, intent)andsignQuote(signer, domain, quote)return the exact wire objects. They refuse a signature that does not recover tomaker, or a high-s signature the program would reject.buildQuote(snapshot, awardedIntent, {nonce, validUntil}). The gateway admits a quote only ifnow < validUntil <= now + 60(its clock) andvalidUntil <= expiry; leave a few seconds of margin. Write the nonce to durable storage before signing. A timed-out reply can still leave a live quote. The nonce must be at least your on-chainmin_nonce(decodeMaker) and never reused.- Cancelling. covr's gateway offers desks no quote withdrawal. To cancel
signed quotes, raise
min_nonceon chain (configureMaker); every fill below the floor fails. Then sign only nonces at or above it (the example bot re-reads the floor every reconcile tick). A quote is valid for at most 60 s anyway. (signWithdrawalbuilds the body for covr's own internal withdrawal route; it is not served to desks.)
signer is anything with signTypedData(domain, types, value): an ethers
Wallet, or an adapter over your KMS or HSM.
Settlement
- Math, exact like the program (throws where the program refuses):
strikePayment,grossPremium,fee,writerPremium,priceForGross,wireQuantity,atomsFromWire,minimumExactLot,settlementAmounts. exactPriceAtOrBelow(priceE18, atoms, decimals): the greatest price at or below your model whose premium for exactly these atoms is a whole USDC atom (covr's own rounding).0nmeans none is above zero: skip the RFQ. Bid this, not a price derived from a rounded premium: for most sizes no price pays an arbitrary premium exactly.decodePositionV3({position, maker, slot, makerEthAddress, makerAuthority, nonce, collateral}).fillV3(the writer signs fills; use it to simulate or audit),exerciseV3(ordinary),releaseV3,fundRestrictedExerciseV3andcompletePendingDeliveryV3(restricted),encodeCancellationRequestV1.exerciseV3Instructions({market, position, authority, rentLamports, makerCollateral?})returns theinstructionsof one exercise transaction:SetComputeUnitLimitat 400,000 (the 200,000 default is too low and the window is short), fresh writer USDC and collateral accounts that only your signer can create, thenexercise_v3paying into them. Sign withauthority(or your operator) as fee payer. You may add aSetComputeUnitPrice, never a second limit. That fits one packet with room to spare (1,111 of 1,232 bytes at worst); a separate fee payer adds a signature (1,207 bytes), leaving no room for a Jito tip. Both new accounts areWRITER_ACCOUNT_SPACE(165) bytes: ordinary markets only take collateral mints without extensions.rentLamportsis the rent for one of them; your signer pays it twice and the writer keeps it.releaseV3Instructions({market, position, payer, rentLamports})does the same for a release after the window, at 400,000 CU in both modes.makerCollateralreceives the collateral you buy. The program requires a token account for the collateral mint owned by your authority, even when your operator signs, that is not frozen, has no delegate and no close authority other than its owner. Any other account fails every attempt. It defaults to the authority's associated token account for the mint (under the market's collateral program), which must already exist: create it before the exercise window. The helper refuses a wallet, your operator's associated token account, or another account of the exercise here.- Releasing Token-2022 collateral needs its account size. Pass
collateralAccountSpacetoreleaseV3Instructions: the token account size with every account extension the mint requires (175 for the stock mints of restricted markets: TransferHook and Pausable;getTokenSizefrom@solana-program/token-2022computes it), andrentLamportspriced for that size. The helper refuses Token-2022 collateral without it. - Rent: read
getMinimumBalanceForRentExemption(size)from your RPC at finalized commitment for each account size, every time. The SDK hard-codes no rate (mainnet rent has dropped before) and only checks that rent is positive; too little fails the transaction atomically, which simulation shows.
Gateway
const client = await GatewayClient.connect(wsUrl, token, {assets: [JITOSOL_MINT]});
const signed = new Map<string, BidIntent>(); // RFQ id -> the intent you signed and sent
for await (const event of client.events()) {
// Do not await handlers here: a slow RFQ read must never delay an award.
if (event.type === 'rfq') void bid(event).catch(console.error); // getRfq, price, signIntent, sendBidIntent, signed.set
else if (event.type === 'award') void answer(event).catch(console.error);
else if (event.type === 'notice') console.log('gateway notice', event.message);
else if (event.type === 'closed') break; // single-use: connect a new client (with backoff)
}
client.close();
async function answer(award: Award) {
const intent = signed.get(award.id);
// Answer only an award whose intent equals the one you signed, and build the quote
// from YOUR stored intent, never from the copy in the award.
if (!intent || JSON.stringify(intent) !== JSON.stringify(award.intent)) return client.sendAwardDecline(award.id, award.intent.signature);
// Check award.awardUntil, durably record nonce, terms and holds, then:
// await client.sendQuote(intent.signature, await signQuote(signer, domain, buildQuote(snapshot, intent, {nonce, validUntil})));
}sendBidIntent resolves "BID_ACCEPTED", sendQuote resolves "OK",
sendAwardDecline resolves "DECLINED". Gateway refusals reject with
GatewayError (code -32602). "OK" is not a fill: the gateway sends no fill
notice, so watch for your position PDA on chain.
The client never reconnects; you must. A closed event means a socket ended:
the gateway closed it, a heartbeat ping got no pong, it errored (reason says
which), or the client closed itself because more than maxQueuedEvents (1000)
events went unread (socket: 'client', always the last event). Every socket is
pinged every 15 s and ended 30 s after a ping goes unanswered, so a path that dies
silently is reported within about 45 s: up to 15 s until the next ping, then 30 s
for its pong. Close the client and connect a new
one with capped exponential backoff and jitter, as the example bot does. Upgrades
time out after 10 s and getRfq after 5 s (handshakeTimeoutMs, httpTimeoutMs).
One request per RFQ is in flight at a time, and its reply carries the RFQ id. If
a request times out (requestTimeoutMs, 5 s), its outcome is unknown: a quote
may have been admitted. The client then refuses every further request for that
RFQ, and the late reply arrives as a notice. A request still awaiting its reply
when /maker closes, when the socket fails while sending it, or when you call
close() is just as unknown: frames queued behind one write share its result, so
the gateway may have read a frame whose send failed. All of these reject with
OutcomeUnknownError (method, rfqId, and a send error as cause), never as
"not sent": keep a quote's holds until the chain settles it.
Golden vectors
vectors/golden-vectors.json holds a registration grant, an ordinary and a
restricted market domain, a bid intent, a quote, a gateway withdrawal and a
restricted cancellation request. They are signed with a throwaway key:
privateKey = keccak256(utf8("covr-maker-sdk-golden-vectors-v1")).
The file is in the package at @covrfun/maker-sdk/vectors/golden-vectors.json.
Recompute each entry with your own signer stack and compare the bytes: domains,
digests, signatures and the encoded cancellation request.
Connecting to covr's gateway
Pass covr's URL and your token to GatewayClient.connect(url, token). It sends
Authorization: Bearer <token> on every socket upgrade and RFQ read. Desks get
three routes: the /maker command socket, the /rfqs/<asset alias> feed, and
GET /api/rfqs/<id>. There is no market list: each RFQ snapshot names its
market, so read that account from chain (decodeMarketV3).
| Close code | Meaning |
| --- | --- |
| 4401 | No token, or an unknown one. Closed before any RFQ is seen. |
| 4403 | A feed for an asset your token does not allow. |
| 4429 Too many connections | More than 16 sockets for your maker, across all its tokens. |
| 4429 Too many frames | More than 100 frames at once, or 20 a second after that, on one connection. |
- The gateway pings every socket every 30 s and closes one that missed the previous ping or has more than 1 MiB of unread data queued.
- One connection acts as one maker: its token maps to one quote key and one Solana authority.
- Bids are collected for 3 s and ranked by price. The winner must return a signed
quote before the hard deadline (6 s after the RFQ). A decline, a disconnect or
any failed quote answer passes the award to the next bidder at once. A silent
winner holds it to the deadline and the customer gets no quote, so answer every
award: a quote or
award_decline.
Not decided by covr yet
- How maker-created markets reach customers.
Changes
See CHANGELOG.md in the package.
Support
Report problems to covr directly: the bugs link in package.json.
