Acta Rust Maker SDK

acta-maker-sdk is the Rust client for the maker WebSocket protocol. It handles auth, reconnects, request/response correlation, and lifecycle bookkeeping.

Use ManagedWs for normal bots. It owns the connection, re-authenticates on reconnect, replays subscriptions, and exposes broadcast::Receiver<Arc<ServerMessage>>. WsClient is the raw client for tests, recorded sessions, or custom auth.

For JSON-layer integrations, see Maker quickstart. Messages are in Maker API reference; wire conventions are in WebSocket conventions. Runnable examples: github.com/acta-markets/rust-maker-sdk.

Installation

[dependencies]
acta-maker-sdk = { version = "=0.2.0", features = ["ws-client"] }

Quote-only integrations need ws-client. chain adds Solana instruction builders; chain-rpc adds on-chain reads.

Connection

let quote_config = ManagedWsConfig::new(
    url.clone(),
    hello.clone(),
    pubkey_base58.clone(),
    challenge_signer.clone(),
)
    .with_initial_subscribe(subscribe_data);

let quote_handle = spawn_managed_ws(quote_config);
let mut messages = quote_handle.subscribe_messages();

let data_config = ManagedWsConfig::new(url, hello, pubkey_base58, challenge_signer)
    .with_endpoint(MakerWsEndpoint::Data)
    .with_resync_messages(vec![GetMyQuotes, GetActiveRfqs]);

let data_handle = spawn_managed_ws(data_config);

url accepts http(s):// and ws(s):// interchangeably. The default endpoint appends /maker; with_endpoint(MakerWsEndpoint::Data) appends /maker/data. hello carries the protocol version (use WS_PROTOCOL_VERSION), opt-in features, and client_name / client_version strings.

challenge_signer: Arc<dyn Fn(&str) -> Result<String, String> + Send + Sync> runs on every auth attempt. It receives the challenge string and must return a base58 ed25519 signature. Sign with the registered quote_signing key; pubkey_base58 is the maker_owner.

initial_subscribe is quote-plane only and is sent after every successful auth. resync_messages is replayed after auth; use the data handle for recovery/private reads such as GetActiveRfqs, GetMyQuotes, GetMakerPositions, and GetMmSummary. Do not put GetMmSummary in a timer loop; use it for auth/reconnect recovery, manual refresh, or detected drift. If many workers can reconnect together, jitter expensive data resync calls instead of replaying every heavy snapshot in the same second.

BytesSigner holds an ed25519 keypair in memory and zeroes it on drop. For HSM- or KMS-backed signing, implement SignerLike directly.

Quoting

For each RfqBroadcast, build the order preimage, compute order_id, sign, and send Quote.

use acta_maker_sdk::{
    compute_order_id, sign_order_id_with_signer, AtomicNonceGenerator,
    OrderPreimageArgs, OrderId, Price, Nonce, decode_base58_32, encode_base58,
};

static NONCE_GEN: AtomicNonceGenerator = AtomicNonceGenerator::new();

let mut messages = handle.subscribe_messages();
while let Ok(msg) = messages.recv().await {
    let ServerMessage::RfqBroadcast(rfq) = msg.as_ref() else { continue };

    let valid_until = std::time::SystemTime::now()
        + std::time::Duration::from_secs(350);
    let nonce = NONCE_GEN.next_u64();
    let price: u64 = calculate_premium(&rfq);

    let args = OrderPreimageArgs {
        chain_id: rfq.market.chain_id.value(),
        program_id: decode_base58_32(&rfq.market.program_id)?,
        is_taker_buy: false,
        position_type: rfq.position_type as u8,
        market: decode_base58_32(&rfq.market.market_pda)?,
        strike: rfq.strike.value(),
        quantity: rfq.quantity.value(),
        gross_price: price,
        valid_until: valid_until.duration_since(std::time::UNIX_EPOCH)?.as_secs(),
        maker: signer.pubkey_bytes(),
        taker: decode_base58_32(&rfq.taker)?,
        nonce,
    };

    let order_id = compute_order_id(&args);
    let signature = sign_order_id_with_signer(&order_id, &signer);

    handle.send(ClientMessage::Quote(QuoteMessage {
        rfq_id: rfq.rfq_id,
        strike: rfq.strike,
        price: Price::new(price),
        valid_until,
        nonce: Nonce::new(nonce),
        order_id: OrderId::new(order_id),
        signature: encode_base58(&signature),
    })).await?;
}

Constraints:

  • valid_until ≥ now + 310s. The trailing 300s is reserved for settlement; the effective trading window is valid_until − 300s. The recommended range is now + 320..360s.
  • is_taker_buy is fixed at false. The taker is always the option writer. Setting true produces an order_id the server rejects.
  • nonce must be unique per (rfq_id, strike) per submission. AtomicNonceGenerator is safe to declare as a static.

Quote lifecycle

Lifecycle events arrive on the same broadcast::Receiver, keyed by order_id.

EventMeaning
QuoteAcknowledgedServer accepted the quote. On a replace, includes replaced_order_id.
QuoteRejectedRefused; see reason. Bare retries fail identically.
QuoteBestStatus / QuoteOutbidCurrent book position.
QuoteRefreshRequestedSettlement-buffer cutoff approaching. Resubmit with valid_until ≥ min_valid_until.
QuoteSelectedTaker selected this quote; settlement is in flight.
QuoteFilledPosition opened on-chain. Carries position_pda and tx_signature.
QuoteCancelledTerminal. reason ∈ {requested, risk_check, rfq_accepted}.
QuoteExpiredEmitted only when quote_expired was opted into via Hello.
RfqAvailableAgainSettlement reverted; re-quote with a fresh order_id.
RfqClosedTerminal RFQ event. Discard per-RFQ state.

Use ReplaceQuote for repricing. The prior quote is removed only after the replacement validates, so the swap is single-RTT. CancelQuote followed by Quote creates a gap and adds a round-trip.

handle.send(ClientMessage::ReplaceQuote(ReplaceQuoteMessage {
    old_order_id,
    rfq_id, strike, price, valid_until, nonce, order_id, signature,
})).await?;

The preimage is constructed identically to a fresh Quote with a new nonce and order_id. The server confirms with QuoteAcknowledged { replaced_order_id: Some(old_order_id) }. Retain old_order_id until the replacement is acknowledged, since events keyed to the old id may still be in flight.

Querying state

handle.send_await(msg, timeout) correlates request and response via request_id and returns Arc<ServerMessage>. It applies to messages with a defined response shape:

  • GetMyQuotes, GetMyTrades, GetMakerPositions, GetMmSummary — maker state; prefer the data handle
  • GetActiveRfqs, GetMarketsForMaker, GetMarketDescriptors — recovery and instrument metadata; prefer the data handle
  • GetSubscriptions — quote-plane session view
  • GetTokenCaps, GetMyCaps — risk and per-maker limits

Quote, Subscribe, CancelQuote are fire-and-forget; their effects are observed via subsequent unsolicited server events. Calling send_await on these returns SendAwaitError::NoExpectedResponse.

AddMints, RemoveMints, AddChannels, and RemoveChannels mutate subscriptions incrementally. Each mutation returns SubscriptionUpdated with the current state.

Current query bounds: GetMyQuotes(active_only=false) defaults to 200 historical rows and caps at 1000; GetMyTrades defaults to 50 rows and caps at 200; GetMakerPositions defaults to 100 rows, caps at 500, and sets has_more on truncation (no cursor — narrow filters to page). GetMarketsForMaker still has filters but no result cap. Do not use any of these as polling paths. The WebSocket query bucket is 20 query tokens per second with a burst of 40.

Reconnection

The SDK reconnects by default. After a disconnect: TCP up -> Hello -> Welcome -> server-issued AuthRequest challenge -> challenge signed -> AuthChallenge -> ready. Makers are auto-challenged by the server on connect, so there is no client-driven StartAuth. Backoff is 250 ms initial, 5 s cap, +/-20% jitter.

Outbound calls via handle.send() queue during the reconnection window and flush after re-authentication. try_send returns QueueFull rather than blocking when immediate failure is preferred.

Inbound state recovery is the integrator's responsibility; the server does not replay events missed during the disconnect window. initial_subscribe and resync_messages cover the usual recovery queries. Custom recovery can subscribe to the Authenticated connection event and issue its own queries.

Events in flight during disconnect can be delivered again after recovery, for example a QuoteAcknowledged for an already processed order_id. Process lifecycle events idempotently by order_id.

Indicative pricing

If the account is enrolled in pre-trade pricing, the server emits IndicativePricesRequest; the integrator replies with IndicativePricesResponse correlated by request_id. Indicative quotes are non-binding and operate under a tighter latency budget than auction quotes.

Errors

WsClientError covers transport failures; the reconnection loop retries. ManagedWsError::Closed indicates the handle's connection task is no longer running. SendAwaitError::Timeout signals the send_await deadline elapsed; Disconnected indicates the connection went down before the response arrived.

Protocol-level errors arrive as ServerMessage::Error (session-level) or ServerMessage::RequestError (per-request, correlated by request_id). Variants — AuthError, QuoteError, CapError, RfqError — are exhaustive enums; the full list is in Maker API reference.

Low-level WsClient

WsClient is appropriate when ManagedWs does not fit (integration tests, custom auth flows, replay tooling).

let mut client = WsClient::connect(url).await?;
client.send_hello(hello).await?;
let challenge = client.recv_start_auth().await?;
client.auth_challenge(sign(challenge)).await?;
client.subscribe(subscribe_data).await?;

Convenience methods exist for every ClientMessage variant. WsClient does not reconnect, re-authenticate, or queue outbound messages.

Reference