Skip to main content

Crate acta_maker_sdk

Crate acta_maker_sdk 

Source
Expand description

§acta-maker-sdk

Rust SDK for quoting Acta options: managed WebSocket connections, RFQ-bound order signing, typed protocol messages, and Solana funding instructions.

APIUse it for
MakerQuoteClientQuotes, replacements and cancellations on /maker
MakerDataClientPrivate reads and execution lookups on /maker/data
ManagedWsHandleCorrelated requests and explicit connection epochs
WsClientRaw transport with application-managed authentication and recovery

§Install

Requires Rust 1.89 or later. Default features are empty.

[dependencies]
acta-maker-sdk = { version = "0.4.0", features = ["ws-client"] }
FeatureWhat it enables
ws-clientTyped maker clients, ManagedWsHandle, and raw WsClient; requires Tokio
chainSolana instruction builders (DepositPremium, WithdrawPremium, FundPosition)
chain-rpcSolana RPC queries (extends chain)
test-helpersTest utilities (ManagedWsHandle::test_handle, message injection)

§Onboarding

Acta’s admin registers the maker owner and any delegated auth or quote-signing keys on-chain through governance. Request onboarding through the integration documentation before connecting. Registration is permissioned; maker_not_registered ends the connection attempt without retries.

§Connect and subscribe

use acta_maker_sdk::*;
use acta_maker_sdk::ws::{managed::*, maker::MakerQuoteClient, types::*};
use std::sync::Arc;
let signer = Arc::new(BytesSigner::from_secret([1u8; 32]));

let config = ManagedWsConfig::new(
    "wss://devnet-api.acta.markets/maker",
    HelloData {
        protocol_version: WS_PROTOCOL_VERSION.to_string(),
        features: vec![],
        client_name: Some("my-bot".to_string()),
        client_version: Some("0.1.0".to_string()),
    },
    signer,
)
.with_cancel_on_disconnect(true)
.low_latency()
.with_initial_subscribe(SubscribeData {
    request_id: uuid::Uuid::new_v4(),
    channels: vec![WsChannel::Rfqs],
    underlying_mints: None,
    quote_mints: None,
});

let client = MakerQuoteClient::spawn(config)?;
let mut messages = client.subscribe_messages();
let connection_epoch = client.wait_until_ready().await?;

RFQ broadcasts require the Rfqs server subscription. with_initial_subscribe includes it in SDK readiness; subscribe_messages() only attaches a local receiver. Apply recovery messages to your strategy before starting to quote, even after wait_until_ready() returns.

§Separate quote and data connections

ManagedWsConfig defaults to the maker quote endpoint (/maker). Keep latency-sensitive commands there: Quote, BatchQuotes, ReplaceQuote, CancelQuote, CancelAllQuotes, and subscriptions. For private maker reads and recovery queries, open a separate data-plane connection:

use acta_maker_sdk::*;
use acta_maker_sdk::ws::{managed::*, maker::MakerDataClient, types::*};
use std::sync::Arc;
let data_config = ManagedWsConfig::new("wss://devnet-api.acta.markets", hello, signer)
    .with_endpoint(MakerWsEndpoint::Data);
let data = MakerDataClient::spawn(data_config)?;
data.wait_until_ready().await?;
let summary = data
    .request(
        GetMmSummaryMessage {
            request_id: Uuid::new_v4(),
        },
        std::time::Duration::from_secs(2),
    )
    .await?;

Use the data endpoint (/maker/data) for GetActiveRfqs, GetMyQuotes, GetOrderStatus, GetMakerPositions, GetMarketsForMaker, GetMyTrades, and GetMmSummary. GetMyQuotes.scope is required: Live returns the complete current owner set without pagination; History pages persisted records with limit and the cursor/cursor_id pair (created_at plus order_id). Positions and trades remain paginated by their existing keys. A Live response always has has_more: false. MakerDataClient::request() moves the typed payload out of the received message, so reading it is infallible; into_payload() takes it by value.

§Readiness and reconnect

Both endpoints advance Connected → Authenticated → Reconciled → Ready after receiving MmSummary, ActiveRfqs and GetMyQuotes { scope: Live }. Quote connections restore subscriptions first. The first subscriber can read a bounded since-spawn backlog; subscribe early to avoid overflowing it.

Use connect_timeout and max_reconnect_attempts to bound connection failures; 0 retries forever. reconnect_jitter_ratio spreads reconnect attempts.

After an authenticated disconnect, the managed client first resumes with the server-issued session ID. Only session_expired falls back to a full challenge authentication on the same socket; other resume errors remain failures for that attempt. session_replaced, protocol-version mismatch, rejected credentials, and an exhausted reconnect limit are terminal states, visible through ManagedWsState::Closed and ManagedWsEvent::Terminated. The one exception is invalid_signature: it retries with the normal backoff and only goes terminal on the third rejection with no successful authentication in between, so a one-off signer or server glitch does not stop an unattended maker.

Dropping the last ManagedWsHandle also stops the connection task. Call close().await when you need to wait until shutdown has completed.

Typed clients reject commands before readiness with NotReady. Use client.raw() for protocol operations not exposed by the typed API.

§Delivery and cancellations

Once a session is Ready, the session task reads and correlates responses while one writer owns the socket sink. Quotes, batches, replacements and cancellations share a FIFO lane. Ping, authentication and close use the control lane. The backend also applies quote and cancellation requests in their connection order. Concurrent producers are ordered by actor receipt. A full writer lane reports QueueFull. write_timeout limits each socket write; it does not bound the time a cancellation spends behind earlier quotes.

send() confirms a socket write, not a server ACK. send_await() waits for a correlated response. try_send() returns a SendTicket; wait() reports its socket-write result.

To await cancellation on the server, use client.raw().send_await(ClientMessage::CancelQuote(request), timeout) and match CancelQuoteAck or RequestError. The ACK contains the request ID, RFQ ID and actual cancelled order IDs. CancelAllQuotesAck contains the actual aggregate. QuoteCancelled is a lifecycle event and never completes an awaited cancellation. Losing the connection after a write but before an ACK leaves the outcome unknown; the SDK does not replay trading commands. An empty QuoteCancelled.order_ids removes nothing. Lifecycle RFQ/order versions and cancellation order IDs are required fields; missing values are rejected during decoding rather than replaced with zero or an empty set.

CancelRfq has no correlated success receipt. Use send() and process RfqClosed as lifecycle state; it can also result from expiry or a fill. send_await(CancelRfq) returns NoCorrelationKey without writing the command. A rejected cancellation arrives as RequestError with its request_id.

§Connection epochs and application recovery

Trading commands, including those sent through the raw managed handle, require Ready. All commands submitted from Ready, including reads, are bound to that connection epoch and cannot be sent by a later connection. Typed data requests check readiness and capture that epoch together before enqueueing. The low-level WsClient remains application-managed.

Receive and apply recovery responses and lifecycle messages before restarting your strategy. Ready confirms that the SDK received the required responses; it does not confirm that the strategy applied them, and the reads are not an atomic snapshot. GetMyQuotes { scope: Live } contains every Core-held unfinished quote for the owner: selectable Active { rank }, non-selectable Retained (including locked non-winners and frozen refresh quotes), AwaitingSignature, and Executing. History is a separate source and must not overwrite this live state.

Keep submitted OrderId intent until an exact cancellation/replacement ID, a confirmed lifecycle fact, or a positive execution lookup resolves it. GetOrderStatus is available through MakerDataClient::request() and the ready quote handle’s raw correlated request path; it returns Pending, Confirmed { position_pda }, or Unknown. Unknown, a timeout, and an empty Live result do not prove that a maker is flat. When application recovery is tracked separately from SDK readiness, use ManagedWsHandle::send_in_epoch(message, connection_epoch) with the epoch of the recovery snapshot the application applied. A stale or non-ready epoch is rejected with NotReady before enqueueing. The queued command keeps that epoch and cannot be sent by a later connection; disconnect/write errors still apply.

§Slow consumers

Each inbound message carries a connection epoch and a connection-local sequence. A lagging receiver reports Gap. GapPolicy::EndpointDefault reconnects quote sessions and only surfaces gaps on data sessions. With reconnect enabled, once the session actor processes the signal, it stops sending and reconnects through recovery. A frame already handed to the writer may have reached the server. Explicit GapPolicy::Surface reports the gap without reconnecting. Lag is detected when the receiver next calls recv().

§Cancel-on-disconnect (COD)

Managed quote connections request cancel_on_disconnect by default. The server must include it in Welcome.enabled_features, otherwise the SDK terminates with FeatureUnsupported. To disable COD, set with_cancel_on_disconnect(false) and omit cancel_on_disconnect from HelloData.features. The setter does not remove manually supplied features. Data connections do not request cancellation for the quote session.

COD removes the session’s Active quotes and retained non-selectable quotes after the server detects the connection loss and Core processes it. CancelAll removes the same set, optionally filtered by market, and its ACK lists the removed IDs. Those quotes cannot return through rollback. Selected/in-flight orders remain governed by their lifecycle, including when their maker cancels other strikes. Single-RFQ CancelQuote rejects a locked RFQ as a whole. With cancel_on_disconnect enabled, the same cleanup applies when a Gap causes a reconnect.

For an application-requested stop, close() uses the control lane; an ongoing socket write can still delay it. A slowly draining quote queue does not by itself force a disconnect. COD therefore supplies a disconnect fallback, not a bounded cancellation time.

§Subscriptions

Dynamic subscriptions survive resume and fresh authentication, including removals and empty mint filters. Only one subscription change may await acknowledgement at a time; another returns SubscriptionPending. Trading and cancellation remain available. The acknowledgement timeout starts when the subscription frame is written.

initial_subscribe seeds the desired target; its request_id is not sent on the wire. Recovery generates fresh request IDs. A new handle starts with empty desired channels/scopes when no initial target is supplied. It does not adopt server-persisted mint filters: recovery sends both mint lists explicitly and clears a persisted scope when its desired list is empty. Supply the intended filters even when resuming an existing server session.

§Latency and limits

§Client profile

ManagedWsConfig::low_latency() sets ping every 2 s with a 2 s pong deadline, a 1 s write timeout, 1024 queued commands, a 4096-slot inbound ring and GapPolicy::Reconnect. The defaults stay conservative for tooling that polls occasionally. The heartbeat settings detect a dead socket in about 4 s with low_latency() and up to 40 s with defaults, subject to task scheduling.

§Server limits

Default limits are 32 KiB per client message, 50 quotes per batch, 50 quotes/s with a burst of 100 per wallet, and 30 messages/s with a burst of 60 per connection. Critical pushes (RfqBroadcast, ACKs, fills) retry for 250 ms twice; if delivery still fails, the server closes the connection for recovery.

§SDK loopback measurements

Measured by examples/loopback_latency.rs on one machine, release build, low_latency() profile, 20 000 samples per leg (Apple silicon, Tokio multi-thread). These are local measurements, not a cancellation-latency guarantee; network and server time come on top.

Legp50p90p99
try_send → frame on the peer’s socket (one frame in flight)20 µs27 µs60 µs
peer send → message parsed by the session task (paced 1/ms)150 µs281 µs424 µs
session task → subscriber recv()19 µs39 µs70 µs

The outbound leg is the command channel, lane routing, serialization and the socket write. The inbound leg includes the peer’s own wake-up after pacing, so it overstates the SDK’s share. Under a burst the same legs turn into queueing time, which is what QueueFull and Gap are for.

§Raw WebSocket access

use acta_maker_sdk::*;
use acta_maker_sdk::ws::types::*;
let mut client = WsClient::connect("wss://devnet-api.acta.markets/maker").await?;

client.send_hello(HelloData {
    protocol_version: WS_PROTOCOL_VERSION.to_string(),
    features: vec!["quote_expired".to_string()],
    client_name: Some("maker-bot".to_string()),
    client_version: Some("0.1.0".to_string()),
}).await?;

while let Some(msg) = client.next().await {
    match msg? {
        ServerMessage::AuthRequest(data) => {
            client.auth_challenge(AuthChallengeData {
                challenge: data.challenge.clone(),
                signature: signer.sign_message_base58(data.challenge.as_bytes()),
                pubkey: signer.pubkey_base58(),
            }).await?;
        }
        ServerMessage::AuthSuccess(data) => {
            println!("authenticated: {}", data.session_id);
        }
        other => println!("server: {other:?}"),
    }
}

WsClient enables TCP_NODELAY and applies bounded frame, message, and write buffers. For a message that will be sent more than once, build a PreparedClientMessage and call send_prepared() to reuse its serialized bytes.

§Quoting an RFQ

A quote signs the 32-byte order ID: SHA-256 of a fixed 182-byte preimage. RfqBinding builds the preimage and QuoteMessage from the same strike, price, expiry and nonce, avoiding order_id preimage mismatch errors.

use acta_maker_sdk::*;
use acta_maker_sdk::ws::types::RfqBroadcastMessage;
use std::time::Duration;
let quote = RfqBinding::from_broadcast(rfq)?
    .quote()
    .price(Price::new(2_000_000_000))
    .valid_until(QuoteExpiry::after(Duration::from_secs(350)).ok_or("clock before epoch")?)
    .nonce(Nonce::new(nonce))
    .sign(&signer)?;

RfqBinding::from_broadcast decodes the RFQ’s program, market and taker keys once; reuse one binding for every quote and replacement on that RFQ. sign_with_async_signer covers keys held in an HSM or a remote service. Either way, quote.into_replacement(old_order_id) turns the signed quote into a ReplaceQuoteMessage.

By default the builder quotes the RFQ’s requested strike. offered_strikes() lists every strike the RFQ will accept — its requested one plus its order_options — and .strike(...) selects among them. A strike outside that set is refused locally instead of after a round trip.

valid_until is a QuoteExpiry of whole Unix seconds, because that is what the preimage signs and what the wire carries. Converting from a SystemTime truncates; a sub-second fraction cannot survive to be rounded into a different second than the one that was signed.

Price and Strike are 1e9 fixed-point per one unit of the underlying, independent of mint decimals (PRICE_SCALE is that factor). Quantity is in the underlying mint’s atomic units. Price::new(2_000_000_000) quotes a gross premium of 2 quote tokens per underlying unit; the SDK cannot detect a price supplied in the wrong units.

§Signing the preimage yourself

preimage_args(maker_pubkey) returns the exact OrderPreimageArgs the server will reconstruct, for inspection or for signing out of band:

use acta_maker_sdk::*;
use acta_maker_sdk::ws::types::RfqBroadcastMessage;
let binding = RfqBinding::from_broadcast(rfq)?;
let builder = binding
    .quote()
    .price(Price::new(2_000_000_000))
    .valid_until(QuoteExpiry::from_unix_seconds(1_725_000_000))
    .nonce(Nonce::new(42));

let args = builder.preimage_args(signer.pubkey_bytes())?;
let order_id = compute_order_id(&args);

BytesSigner wraps an Ed25519 keypair with Zeroize on drop. SignerLike is the synchronous interface for local keys; use AsyncSignerLike when signing may wait for an external process or device. The SDK verifies every returned signature against the requested message and pubkey_bytes. ManagedWsConfig::auth_timeout also covers signing, so timed-out signer futures must be cancellation-safe.

§Delegated signing keys

A maker registration can delegate signing: an auth key that opens sessions and a quote key that signs orders, both distinct from the maker owner. The pubkey you authenticate as is always the owner — the server looks the registration up by it and verifies the challenge against the registered auth signing key — and the order preimage is always reconstructed with the owner’s key, so both sides must be told the owner when the keys differ:

use acta_maker_sdk::*;
use acta_maker_sdk::ws::types::RfqBroadcastMessage;
let quote = RfqBinding::from_broadcast(rfq)?
    .quote()
    .price(Price::new(2_000_000_000))
    .valid_until(QuoteExpiry::from_unix_seconds(1_725_000_000))
    .nonce(Nonce::new(42))
    .maker_owner(owner)
    .sign(&quote_signer)?;

On the connection side, both ManagedWsConfig::new and new_async default to the signer’s own key; override it with with_auth_pubkey(owner). Without maker_owner every quote from a delegated key is rejected as order_id preimage mismatch; without the owner auth pubkey the session fails as maker_not_registered.

§Funding positions with native SOL

With the chain-rpc feature, FundPositionArgs can wrap native SOL when the position settles in wSOL. Wrapping is opt-in:

use acta_maker_sdk::chain::{FundPositionArgs, NativeSolFunding};
let fund = FundPositionArgs {
    maker_owner,
    position_pda,
    create_atas: true,
    native_sol: NativeSolFunding::with_default_reserve(),
};

The SDK validates the position, market, and existing wSOL account. When the maker is also the fee payer, the pre-sign check includes the wrap amount, rent for token accounts created by funding, the assembled transaction’s RPC fee, and the configured reserve. With an external fee payer, the maker budget includes only the wrap and reserve. This is a snapshot, not an on-chain lock: another wallet transaction can still spend SOL after the check. RPC and account-validation errors fail closed; they are not treated as a zero token balance. Use NativeSolFunding::Disabled when wrapping is handled by the caller.

§Settlement

Settlement is a permissionless on-chain crank, normally run by Acta’s keeper once the market is finalized. Proceeds are paid directly into the maker owner’s associated token account for the payout mint — the keeper’s transaction creates that account if it is missing — so a settled position requires no maker action and no SDK call. build_withdraw_premium_ixs moves earned premium out of the premium vault; it is not part of settlement.

§Protocol types

The current WS contract requires Welcome.server_time_unix_ms, AuthSuccess.expires_at, RfqBroadcast.sent_at_unix_ms, and instruction_index on all six known chain events. Missing or null values are rejected. The two *_unix_ms fields remain milliseconds; credential expiry remains Unix seconds. Credential expiry does not itself define the lifetime of an authenticated socket. Chain events are identified by (signature, instruction_index), never by signature alone.

The SDK provides enums for known string values and tagged state objects:

  • PositionType: "covered_call" | "cash_secured_put"
  • MakerQuoteState: active { rank, rfq_version }, retained { rfq_version }, awaiting_signature { rfq_version }, executing { rfq_version }, or historical { status }; QuoteRank is "best" | "outbid" and HistoricalQuoteStatus is "submitted" | "filled" | "cancelled" | "replaced" | "expired" | "lost".
  • OrderExecutionState: pending, confirmed { position_pda }, or unknown.
  • QuoteCancelReason: "requested" | "risk_check" | "rfq_accepted" | "maker_disconnected"
  • RfqAvailableAgainReason: "signature_timeout" | "tx_failed" | "tx_build_failed"
  • RfqCloseReason: "expired" | "taker_cancelled" | "filled" | "market_expired" | "ladder_timeout"
  • QuoteFinalStatus: "expired" | "outbid" | "cancelled" | "filled"
  • PositionUpdateType: "created" | "funded" | "liquidated" | "settled"

Known order and position statuses use SDK enums. Open-ended fields, such as diagnostic reason text, remain strings.

§Examples

The programs in the examples/ directory cover raw authentication, discovery, quoting, indicative pricing, and the managed quote client.

cargo run --example managed_quote --features ws-client

§Documentation

See the Rust API reference and the maker integration guide. README examples are compiled as no_run doctests without opening live connections.

§License

MIT

Re-exports§

pub use error::ActaSdkError;
pub use quote::QuoteBuildError;
pub use quote::QuoteBuilder;
pub use quote::RfqBinding;
pub use types::PRICE_SCALE;
pub use types::QuoteExpiry;
pub use types::ids::Balance;
pub use types::ids::ChainId;
pub use types::ids::Decimals;
pub use types::ids::DurationSeconds;
pub use types::ids::MarketId;
pub use types::ids::Nonce;
pub use types::ids::OrderId;
pub use types::ids::OrderVersion;
pub use types::ids::PositionType;
pub use types::ids::PositionTypeParseError;
pub use types::ids::Price;
pub use types::ids::Quantity;
pub use types::ids::QuoteCount;
pub use types::ids::RfqVersion;
pub use types::ids::Slot;
pub use types::ids::Strike;
pub use types::ids::TimeoutSeconds;
pub use types::ids::TradeCount;
pub use types::ids::UserId;
pub use types::ids::Volume;
pub use types::invite::REFERRAL_CODE_MAX_LEN;
pub use types::invite::REFERRAL_CODE_MIN_LEN;
pub use types::invite::RESERVED_CODES;
pub use types::invite::ReferralCode;
pub use types::invite::ReferralCodeFormatError;
pub use types::invite::TakerStatus;
pub use types::invite::is_reserved;
pub use ws::client::PreparedClientMessage;
pub use ws::client::WsClient;
pub use ws::client::WsTransportConfig;
pub use ws::error::WsClientError;
pub use ws::error::WsResult;
pub use ws::error::WsTransportConfigError;
pub use ws::maker::MakerClientError;
pub use ws::maker::MakerDataClient;
pub use ws::maker::MakerDataRequest;
pub use ws::maker::MakerDataRequestError;
pub use ws::maker::MakerQuoteClient;
pub use ws::maker::MakerResponse;
pub use ws::managed::GapPolicy;
pub use ws::managed::MakerWsEndpoint;
pub use ws::managed::ManagedInbound;
pub use ws::managed::ManagedMessageReceiver;
pub use ws::managed::ManagedReceiveError;
pub use ws::managed::ManagedWsConfig;
pub use ws::managed::ManagedWsConfigError;
pub use ws::managed::ManagedWsError;
pub use ws::managed::ManagedWsEvent;
pub use ws::managed::ManagedWsHandle;
pub use ws::managed::ManagedWsState;
pub use ws::managed::ManagedWsTerminationReason;
pub use ws::managed::OutboundMessageError;
pub use ws::managed::SendAwaitError;
pub use ws::managed::SendTicket;
pub use ws::managed::WaitUntilReadyError;
pub use ws::managed::normalize_maker_data_ws_url;
pub use ws::managed::normalize_maker_ws_url;
pub use ws::managed::normalize_maker_ws_url_for_endpoint;
pub use ws::managed::spawn_managed_ws;
pub use ws::types::ClientMessage;
pub use ws::types::ServerMessage;
pub use nonce::AtomicNonceGenerator;
pub use nonce::NonceError;
pub use nonce::NonceGenerator;
pub use orders::BytesSigner;
pub use orders::ORDER_DOMAIN_TAG;
pub use orders::ORDER_ID_LEN;
pub use orders::ORDER_PREIMAGE_LEN;
pub use orders::OrderError;
pub use orders::OrderPreimageArgs;
pub use orders::SignerLike;
pub use orders::build_order_preimage;
pub use orders::compute_order_id;
pub use orders::hash_order_preimage;
pub use orders::order_id_hex;
pub use orders::order_preimage_hex;
pub use orders::sign_order_id_base58;
pub use orders::sign_order_id_bytes;
pub use orders::sign_order_id_from_base58_keypair;
pub use orders::sign_order_id_with_async_signer;
pub use orders::sign_order_id_with_signer;
pub use orders::sign_order_id_with_signer_base58;
pub use orders::verify_order_id_signature_base58;
pub use orders::verify_order_id_signature_bytes;
pub use signing::AsyncSignerLike;
pub use signing::SigningError;
pub use signing::SigningFuture;
pub use wire::WireError;
pub use wire::decode_base58_32;
pub use wire::decode_base58_64;
pub use wire::decode_hex_32;
pub use wire::encode_base58;
pub use wire::encode_hex;

Modules§

chain
error
nonce
orders
quote
Correct-by-construction quote assembly.
signing
types
Domain values shared by WebSocket and chain APIs.
wire
ws

Constants§

WS_PROTOCOL_VERSION