Acta TypeScript SDK
TypeScript SDK for Acta options on Solana: WebSocket RFQ clients for takers, makers and vault operators, order signing, checked signing of served transactions, HTTP reads, and Solana instruction builders.
Version 0.4.x speaks WS protocol 1.0.0. Read the changelog before
upgrading.
Install
npm install @acta-markets/ts-sdk
The package ships ESM and CommonJS builds.
| Import | Contents |
|---|---|
@acta-markets/ts-sdk/ws | WebSocket client, wallet auth, quote building, checked taker signing |
@acta-markets/ts-sdk/chain | Account fetchers, PDAs, order preimages, instruction builders, vault planners |
@acta-markets/ts-sdk/http | Venue, token, market and vault reads |
@acta-markets/ts-sdk/ws/keypair | KeypairAuthProvider for Node |
@acta-markets/ts-sdk/types | Shared types |
Browser apps should import /ws. It does not load the chain client or the IDL.
Taker: request a quote
import {
ActaWsClient,
ALL_MINTS,
ALL_POSITIONS,
DEVNET_WS,
WalletAuthProvider,
type WalletLike,
} from "@acta-markets/ts-sdk/ws";
import type { Address } from "@solana/kit";
export function connectTaker(wallet: WalletLike, market: Address) {
const client = new ActaWsClient({
url: DEVNET_WS,
role: "taker",
onDisconnect: "keep_quotes",
products: ["covered_call", "cash_secured_put", "call_spread", "put_spread", "cash_put"],
clientName: "my-app",
clientVersion: "1.0.0",
});
client.on("error", console.error);
client.on("quoteReceived", (quote) => console.log(quote.maker, quote.price));
client.on("authenticated", () => {
client.getMarketDescriptors();
client.getPositions(ALL_POSITIONS);
client.subscribe(["chain_events"], ALL_MINTS);
});
client.on("marketDescriptors", async () => {
await client.createRfq({
market,
terms: { type: "cash_secured_put", strike: 136_000_000_000 },
side: "sell",
quantity: 20_000_000,
timeoutSeconds: 30,
requestId: crypto.randomUUID(),
});
});
client.connectAndAuthenticate(new WalletAuthProvider(wallet));
return client;
}
url is the API origin; the client appends /taker, /maker, /maker/data or /vault.
DEVNET_WS and MAINNET_WS are exported. Register handlers before connecting and wait
for authenticated before trading. createRfq needs the market's descriptor, so wait for
marketDescriptors first.
price and strike are 1e9 fixed-point values per underlying unit. quantity is in
underlying-mint atoms. createRfq, acceptQuote and submitSignedSponsoredTx resolve
when the server accepts the command, not when the transaction lands.
After acceptQuote(rfqId, maker, orderIdHex), the server sends sponsoredTxToSign. Sign
it only with verifyAndSignTakerFill, which rebuilds the expected fill and refuses
anything else. Keep accepted order IDs across reconnects; after a timeout or an unknown
delivery, call getOrderStatus(orderIdHex) before retrying. The
web client guide has the
full signing and recovery flow.
Buying options
A taker buys with side: "buy" in the RFQ. A maker writes the option from its Maker PDA
custody, so only makers subscribed to taker_buy_rfqs with enough custody see the RFQ. A vault
whose delegate holds SIGN_WRITER_QUOTES also quotes buys and writes from its collateral leg.
The best buy quote is the lowest price. If no maker can receive the RFQ, the server closes it
at once with reason no_eligible_makers instead of waiting for the timeout.
await client.createRfq({
market,
terms: { type: "covered_call", strike: 150_000_000_000 },
side: "buy",
quantity: 10_000_000,
timeoutSeconds: 30,
requestId: crypto.randomUUID(),
});
Accept and sign exactly as for a sell: verifyAndSignTakerFill reads the side from the RFQ
and refuses a fill whose side, terms or amounts differ. The buyer pays the gross premium in
the quote mint and becomes the position holder; the maker or vault is the writer. For a
vault quote pass the decoded vault as makerVault: the buyer then checks the served
OpenPositionForVaultWriter with holder byte 1, in which the buyer's wallet is the signer and
the vault's delegate signed only the order id.
A physical buy (covered_call, cash_secured_put) is exercised by depositing the
settlement amount strictly before expiryTs; the program rejects a later deposit:
import { fetchMarket, fetchPosition } from "@acta-markets/ts-sdk";
import { flows } from "@acta-markets/ts-sdk/chain";
const { ixs } = await flows.buildDepositFundsIxs({
programId,
payer: buyer.address,
owner: buyer,
position: (await fetchPosition(rpc, positionPda)).data,
market: (await fetchMarket(rpc, marketPda)).data,
});
Spreads and cash puts take no deposit and settle in cash. Outcomes arrive as the position
state:
settledwithsettlement.typephysical(deposited, or out of the money),cash(holder_payoffis yours), ornot_exercised(in the money without a deposit: all collateral returns to the writer).terminal_pending { slot, outcome }: the chain has settled the position and the server has not sealed that slot yet. Show it, but do not count it.
Maker: quote an RFQ
import {
ActaWsClient,
buildSignedQuoteFromRfq,
generateNonce,
MAINNET_WS,
type AuthProvider,
type RfqBroadcastMessage,
} from "@acta-markets/ts-sdk/ws";
import type { SignerLike } from "@acta-markets/ts-sdk/chain";
import type { Address } from "@solana/kit";
export function runMaker(
auth: AuthProvider,
makerOwner: Address,
makerSigner: SignerLike,
priceFor: (rfq: RfqBroadcastMessage) => number,
) {
const quotes = new ActaWsClient({
url: MAINNET_WS,
role: "maker",
onDisconnect: "cancel_quotes",
products: ["covered_call", "cash_secured_put"],
clientName: "my-maker",
clientVersion: "1.0.0",
});
let timing = { minValidity: 0, buffer: 0, clockOffsetMs: 0 };
quotes.on("welcome", (welcome) => {
timing = {
minValidity: welcome.min_quote_validity_seconds,
buffer: welcome.settlement_buffer_seconds,
clockOffsetMs: welcome.server_time_unix_ms - Date.now(),
};
});
quotes.on("rfqBroadcast", async (rfq) => {
const now = Math.floor((Date.now() + timing.clockOffsetMs) / 1000);
const validUntil = Math.max(
now + timing.minValidity,
rfq.expires_at + Math.max(timing.minValidity, timing.buffer),
);
if (validUntil > rfq.market.expiry_ts) return;
const quote = await buildSignedQuoteFromRfq({
rfq,
terms: rfq.order_options[0],
price: priceFor(rfq),
validUntil,
nonce: generateNonce(),
makerOwner,
makerSigner,
});
quotes.submitQuote(quote);
});
quotes.connectAndAuthenticate(auth);
return quotes;
}
Quote only terms listed in rfq.order_options. buildSignedQuoteFromRfq checks the terms
and integer ranges, not venue timing. A quote must stay valid past the RFQ expiry by the
larger of min_quote_validity_seconds and settlement_buffer_seconds from Welcome. Use a second client with makerEndpoint: "data" for reads
and owner pushes (positionUpdated, makerCapsUpdated, tradeExecuted) so the quote
socket only quotes. The maker API reference
covers the quote lifecycle.
HTTP reads
import { ActaHttpClient } from "@acta-markets/ts-sdk/http";
import { MAINNET_API } from "@acta-markets/ts-sdk/ws";
const http = new ActaHttpClient({ baseUrl: MAINNET_API });
const [venue, tokens, markets, vaults] = await Promise.all([
http.venue(new AbortController().signal),
http.tokens(),
http.markets({ active: true }),
http.vaults(),
]);
tokens() returns a Map keyed by mint. Responses are revalidated with ETags.
Chain
/chain exports account fetchers, PDA helpers, order preimages and signing, the generated
instruction builders, and flows:
flows.buildOpenPositionFlowIxsandbuildOpenSpreadFlowIxsreturn the maker Ed25519 verification and open instructions.flows.buildDepositFundsIxsfunds an Open position; a taker-buy buyer signs it strictly beforeexpiryTs.buildSettlePositionIxsettles physical options and spreads. An unfunded taker-buy returns all collateral to the writer, also when ITM.flows.VaultManagerPlannerbuilds vault capital, trading, spread, swap and governance instructions.trading.openPositionAsWriterandspreads.openSpreadAsWritersell to a maker (holder byte 0);openPositionForWalletHolderandopenSpreadForWalletHolderbuild the open a wallet buys from the vault (holder byte 1).decodeActaEventData(root export) reads event version 2: onePositionOpenedper open (writer, holder,flags, terms, gross and net premium, collateral) and oneVaultCycleOpenedper vault side. Version 1 events from before the upgrade returnnull.
Vault operators connect with role: "vault" and a vaultPda. Sign served vault
transactions only with verifyAndSignSponsoredTx and verifyAndSignSwap from /ws; they
sign only an exact match of the expected intent and throw flows.VaultIntentError
(exported from /chain) otherwise.
Mint-aware builders need rpc or an explicit token program; Token-2022 mints need their
actual token program. Pass a programId per flow when you do not use the default program.
The vault operator guide
covers vault sessions, served swaps and staging.
Migrating to 0.4.2
Requires the vault-writer program upgrade. Opcodes, account layouts and seeds are unchanged.
| 0.4.1 | 0.4.2 |
|---|---|
OpenPositionForVaultAsTaker, OpenSpreadForVaultAsTaker | OpenPositionForVaultWriter, OpenSpreadForVaultWriter, plus holderKind |
OpenPositionForVaultAsMaker, OpenSpreadForVaultAsMaker | OpenPositionForVaultHolder, OpenSpreadForVaultHolder |
VaultFundMakerPosition, fundMakerPosition | FundPositionForVaultHolder, fundHolderPosition |
openPositionAsTaker, openSpreadAsTaker | openPositionAsWriter, openSpreadAsWriter |
openPositionAsMaker, openSpreadAsMaker | openPositionAsHolder, openSpreadAsHolder |
vault writer slots actor, maker, makerCustodyAta | participant, counterparty, premiumSource |
vault holder slots taker, takerUnderlyingAta, takerQuoteAta | writer, writerUnderlyingAta, writerQuoteAta |
OpenPosition slots user, userUnderlyingAta, userQuoteAta | taker, writerUnderlyingAta, takerQuoteAta |
deposit makerPda; maker transfer makerOwnerPremiumAta | holderMakerPda; makerOwnerAta |
OPEN_TAKER, SIGN_MAKER_QUOTES, FUND_MAKER | OPEN_AS_WRITER, SIGN_HOLDER_QUOTES, FUND_HOLDER; new SIGN_WRITER_QUOTES |
events OpenPosition, OpenSpread, OpenTakerBuy, OpenTakerBuySpread | PositionOpened |
events VaultPositionOpened, VaultSpreadOpened | VaultCycleOpened |
VaultMakerPositionFunded; TakerBuyFunded.user | VaultHolderPositionFunded; TakerBuyFunded.holder |
holder mismatch on deposit: MakerNotOwner | NotPositionHolder (1111) |
Migrating to 0.4.0
Position roles are writer and holder; bytes and offsets are unchanged.
| 0.3.x | 0.4.0 |
|---|---|
Position.takerOwner, makerOwner | writer, holder |
POSITION_OFFSET_TAKER_OWNER, _MAKER_OWNER | POSITION_OFFSET_WRITER, _HOLDER |
deposit makerOwner, makerFundingAta | holder, holderFundingAta |
settle and liquidate userDestAta, makerDestAta | writerDestAta, holderDestAta |
events user, makerOwner, taker | holder, writer |
makerPayoff, takerReturn; WS maker_payoff, taker_return | holderPayoff, writerReturn; holder_payoff, writer_return |
fetchPositionsByMaker, ByTaker | fetchPositionsByHolder, ByWriter |
buildDepositPremiumIx, DepositPremium | buildMakerDepositIx, MakerDeposit |
See the CHANGELOG for the full list.
Recovery
The client reconnects and restores subscriptions. With autoReconcile, the default, it
then re-reads your RFQs and positions. A frame it cannot read does not drop the session:
you get unknownMessage or an invalid_frame error, and the same reads run at most once
every 5 seconds, because the skipped frame may have carried a fill or a position change.
List paging
List methods collect every page for you through fetchAllPages. For a "load more" view
use a page method such as getPositionsPageAsync, or pass a page query to an HTTP
history method, and keep the returned cursor as an opaque value.
On the wire, WS lists take limit (at most 200) and a tagged cursor, and return
next_cursor, null on the last page. REST lists take an optional limit (default 50,
at most 200, 100 for vault requests) and an optional cursor: omit it for the first
page, then pass next_cursor as is. See the
paging contract.
Documentation
License
MIT