Acta WS Common Conventions

Message envelope

All WS messages use:

{ "type": "MessageType", "data": { ... } }

Unit variants may omit data, for example:

{ "type": "Logout" }

Encodings and units

FieldFormat
Pubkeysbase58
Signaturesbase58 (64 bytes)
order_id64-char hex (optional 0x prefix)
priceu64, gross premium per 1 underlying unit, 1e9 scale
total_premiumu64, net premium amount in quote token atomic units
strikeu64, quote per 1 underlying unit, 1e9 scale
quantityu64, underlying atomic units
size_rulemin_size, max_size, step in underlying atomic units; always tied to underlying_mint
TimestampsUnix seconds
server_time_unix_msUnix milliseconds

price and strike are always 1e9 fixed-point per 1 underlying unit, independent of mint decimals.

Quantity and collateral by position type

quantity on the wire is always in underlying atomic units, regardless of position type. The two position types differ in what the user deposits as collateral:

Covered call — user deposits underlying (e.g. SOL). quantity maps directly to the deposit: quantity = userInput * 10^underlying_decimals.

Cash-secured put — user deposits quote (e.g. USDC). The collateral relationship is: collateral = quantity * strike / 1e9. Convert quote input to underlying quantity before sending:

quantity = round(quoteAmount * 10^underlying_decimals * 1e9 / strike)

And to display the deposit back in quote terms:

quoteAmount = quantity * strike / 1e9 / 10^underlying_decimals

underlying_decimals comes from MarketDescriptorInfo.

Size rule display for CSP

size_rule values (min_size, max_size, step) are in underlying atomic units. To show constraints in quote terms for a CSP UI, apply the same conversion:

min_quote = min_size * strike / 1e9 / 10^underlying_decimals
max_quote = max_size * strike / 1e9 / 10^underlying_decimals
step_quote = step    * strike / 1e9 / 10^underlying_decimals

Example: SOL/USDC CSP, strike $90 (90e9), size_rule = {min: 1e9, max: 10e9, step: 1e8}:

Wire (SOL lamports)Display (USDC)
min_size = 1,000,000,00090 USDC
max_size = 10,000,000,000900 USDC
step = 100,000,0009 USDC

The TS SDK provides helpers for these conversions: quoteAmountToQuantity, quantityToQuoteAmount, sizeRuleInQuoteTerms (exported from @acta-markets/ts-sdk/ws).

Time and clock skew

server_time_unix_ms is present in:

  • Welcome.server_time_unix_ms (optional)
  • Pong.server_time_unix_ms (required)

Snapshot does not include server_time_unix_ms in current protocol.

Client estimate:

offset_ms = server_time_unix_ms - local_time_ms
estimated_server_now_ms = local_time_ms + offset_ms

Use this estimate for expires_at, valid_until, and signature_deadline.

Authentication protocol

Signing algorithm

Ed25519. Signatures are 64 bytes, base58-encoded on the wire.

Challenge format

After the client sends Hello and receives Welcome, the server sends AuthRequest with a challenge field. The challenge is a multiline plaintext string. The format differs by role:

Taker challenge (includes Wallet: line):

Acta RFQ Authentication

Sign this message to authenticate your wallet.

Wallet: {base58_pubkey}
Nonce: {hex_encoded_32_random_bytes}
Issued At: {RFC3339_timestamp}

Maker challenge (no Wallet: line):

Acta RFQ Authentication

Sign this message to authenticate your wallet.

Nonce: {hex_encoded_32_random_bytes}
Issued At: {RFC3339_timestamp}

Makers do not need a Wallet: line. The server identifies the maker from AuthChallenge.pubkey, then looks up the registered signing key from the on-chain maker registry.

What to sign

Sign the raw bytes of the challenge string (challenge.as_bytes() in UTF-8). No additional prefix, domain separator, or hashing before signing.

AuthChallenge (client -> server)

{
  "type": "AuthChallenge",
  "data": {
    "challenge": "Acta RFQ Authentication\n\nSign this message...",
    "signature": "3q7uQqYc3...base58sig",
    "pubkey": "PubkeyBase58"
  }
}

Maker: pubkey is the maker_owner pubkey registered on-chain. Sign the challenge with your auth signing key; the server looks up maker_owner and verifies against the registered auth signing key. (A maker currently registers a single on-chain key that serves as both the auth and quote signing key, so either works today — but sign the auth challenge with the auth key and quote payloads with the quote key, in case the contract later provisions separate keys.)

Taker: pubkey is the taker's wallet pubkey (same as the Wallet: value in the challenge). The signature is verified directly against this key.

Auth constraints

  • Max auth attempts per connection: 3. After 3 failures, connection is closed.
  • Auth deadline after challenge issued: 15s.

Ping / Pong

{ "type": "Ping" }

Ping is a unit variant (no data field). Server responds with:

{
  "type": "Pong",
  "data": {
    "server_time_unix_ms": 1710000000000
  }
}

Clients should send Ping every ~30s to avoid the 90s idle timeout.

Logout

{ "type": "Logout" }

Unit variant, no data field. Server responds with LogoutSuccess and closes the connection.

{ "type": "LogoutSuccess" }

Fee model

The protocol charges a fee per trade, configured per quote mint in basis points (bps). The on-chain config stores two fee rates: protocol_fee_bps_premium and protocol_fee_bps_volume.

  • price in the Quote message is the gross premium per 1 underlying unit (1e9 scale). This is what the maker signs and what goes into the order_id preimage as gross_price.
  • On chain, gross premium is first scaled to quote-token atomic units. The premium-side fee is computed from protocol_fee_bps_premium; the volume-side cap is computed from strike notional and protocol_fee_bps_volume; the charged fee is min(premium_fee, volume_fee).
  • net_price in WebSocket payloads is a display approximation. The authoritative amount is the on-chain net premium after the min(premium-fee, volume-fee) calculation and token-decimal scaling.
  • total_premium in position data is the net premium from on-chain state (in quote token atomic units).
  • Makers always quote gross. The fee does not affect what the maker signs; it is applied by the contract at position open.

Auth messages

AuthSuccess

{
  "type": "AuthSuccess",
  "data": {
    "session_id": "uuid",
    "expires_at": 1710086400,
    "maker_pda": "MakerPdaBase58"
  }
}

expires_at wire contract:

  • Wire type is Option<i64> — number (Unix seconds) when a session expiry is set, null otherwise. The key is always present.
  • Used for session resume by both makers and takers.

maker_pda wire contract:

  • Set to the on-chain maker account PDA (base58) for authenticated makers that are registered on-chain.
  • null (and omitted from the wire on serialization) for non-makers (admins, takers) and for makers not yet registered on-chain.

AuthError

{
  "type": "AuthError",
  "data": {
    "reason": "session_expired",
    "message": "optional detail"
  }
}

reason is a snake_case code. message is optional and may be omitted.

Current reason codes:

  • session_expired, already_authenticated
  • auth_timeout, too_many_auth_attempts
  • invalid_pubkey, invalid_signature, pubkey_mismatch, challenge_mismatch
  • maker_not_registered

Error format

Two error message types exist:

Error — connection-level errors

Sent when no request_id is available (the message had no request_id field, or the error occurred before routing):

{ "type": "Error", "data": { "type": "RfqNotFound" } }

data is a tagged ServerError variant (type + optional nested data).

RequestError — request-correlated errors

Sent when the server can correlate the failure to a specific client request. The request_id echoes the client's request_id from the originating message:

{
  "type": "RequestError",
  "data": {
    "request_id": "uuid",
    "error": { "type": "RfqNotFound" }
  }
}

The inner error has the same ServerError shape as Error.data.

Prefer RequestError handlers over Error handlers wherever you pass request_id in requests. Both events fire error in the TypeScript SDK (see Web client SDK).

Parsing rule (applies to both Error.data and RequestError.data.error)

  1. Branch on type.
  2. If type != "Generic", treat it as a typed variant.
  3. If type == "Generic", branch on data.code.

Generic code example:

{
  "type": "Error",
  "data": {
    "type": "Generic",
    "data": { "code": "parse_error", "message": "invalid payload" }
  }
}

Typed variant examples:

{ "type": "Error", "data": { "type": "RfqNotFound" } }
{
  "type": "Error",
  "data": {
    "type": "Unauthenticated",
    "data": { "action": "submit_quotes" }
  }
}

Common generic.code values

  • parse_error
  • message_too_large
  • too_many_parse_errors
  • hello_required
  • hello_timeout
  • hello_already_sent
  • session_replaced
  • session_expired
  • already_authenticated
  • trading_paused
  • internal_error

This list grows over time; keep a fallback branch for unknown codes.

Correlation semantics

There's no single global correlation id. Correlation is per-request, via the request_id field on messages that have a defined response.

  • Subscribe / Unsubscribe carry a mandatory request_id. The server echoes it in SubscribeAck / UnsubscribeAck, and the subscribed / unsubscribed arrays contain only the diff for this call — not the full subscription list.
  • GetSubscriptions carries request_id; Subscriptions echoes it.
  • Query-style Get* operations require request_id and echo it in their responses.
  • On failure, the server emits RequestError (with request_id) when the request carried one, otherwise Error (no request_id) for connection-level failures.
  • Indicative pricing uses request_id on both IndicativePricesRequest (server → maker) and IndicativePricesResponse (maker → server).
  • Broadcasts (RfqBroadcast, TradeExecuted, StatsUpdate, etc.) don't carry request_id.

SubscribeAck / UnsubscribeAck

{
  "type": "SubscribeAck",
  "data": {
    "request_id": "uuid",
    "subscribed": ["rfqs", "trades"]
  }
}
{
  "type": "UnsubscribeAck",
  "data": {
    "request_id": "uuid",
    "unsubscribed": ["stats"]
  }
}

subscribed / unsubscribed are the channels that were actually added or removed — channels already subscribed (or already absent) are not repeated.

Lifecycle terms

Use the same terms across all external docs:

RFQ lifecycle

  • Terminal RFQ event: RfqClosed. Close RFQ state only on this event.
  • Fill-details event (maker): QuoteFilled. Contains fill details for the winning maker.
  • Order-level confirmation (taker): OrderConfirmed. Confirms selected order with on-chain identifiers (tx_signature, position_pda).

Position lifecycle (post-fill)

  • open — position created, collateral locked, premium paid to taker.
  • funded — maker deposited settlement asset via DepositFundsToPosition.
  • settled — market finalized, assets distributed based on ITM/OTM outcome.
  • liquidated — an unfunded ITM position was closed by a liquidation transaction.

Trade lifecycle and payoff: Protocol flow.

Supported channels

WsChannel values and what each carries. Channels are role-oriented — subscribe only to the ones your role consumes.

ChannelProducer(s)Audience
rfqsRfqBroadcast, IndicativePricesRequest, quote/order lifecycleMakers. Takers do not subscribe — a taker's own RFQ/quote/order events (RfqCreated, QuoteReceived, OrderAccepted, SponsoredTxToSign, Order*, RfqClosed) are delivered session-addressed regardless of subscription.
tradesTradeExecutedPublic trade tape. Participants also receive their own fills session-addressed.
statsStatsUpdateGlobal venue stats.
chain_eventsChainEvent (position opened / settled / liquidated, market finalized)Anyone. This is how a taker tracks position outcomes (settlement / liquidation).
marketsMarketCreated, MarketFinalizedAnyone tracking the tradable market set.
positions— (no public producer)PositionUpdated is delivered owner-direct to the maker's session by PDA lookup, never via this channel. The channel yields nothing for takers.

Takers typically subscribe to chain_events (position outcomes) plus optionally trades / stats / markets. They do not need rfqs or positions.

Timeout hierarchy

market.expiry_ts
  └─ rfq.expires_at                     # Auction window (no new quotes or accepts after this)
       └─ quote_refresh_margin

quote.valid_until                        # Cryptographic expiry (on-chain enforcement)
  └─ effective_expiry                    # = valid_until - settlement_buffer (server-side trading cutoff)

rfq.signature_deadline
  └─ tx_submit_timeout

rfq.expires_at vs quote.valid_until

expires_at is the auction deadline. valid_until is the on-chain order validity.

  • rfq.expires_at controls how long makers can submit quotes and the taker can accept. After this time the server closes the RFQ.
  • quote.valid_until is the cryptographic expiry the maker signs into the order. The on-chain program rejects settlement if valid_until has passed.

valid_until is always larger than expires_at because settlement happens after the auction ends. A taker might accept a quote at second 59 of a 60-second auction. After that, the server builds a sponsored tx, the taker signs it, and the tx confirms on Solana. This can take up to 300 seconds. If valid_until equaled expires_at, the on-chain order would expire before the tx lands.

min:  now + min_signature_expiry_seconds          (default 310s)
max:  rfq.expires_at + settlement_buffer_seconds  (recommended upper bound)

Setting valid_until above the recommended max is not rejected by the server, but provides no benefit: after rfq.expires_at the server will not allow accepts, so extra on-chain validity only increases the maker's exposure window without enabling any additional trades.

Example: RFQ with expires_at = now + 60s, settlement buffer 300s:

valid_until range: [now + 310s, now + 360s]
effective_expiry:  [now + 10s,  now + 60s]

The maker's quote is tradeable for up to 60 seconds (the full RFQ lifetime) and the on-chain order is valid long enough for settlement to land.

Invariants

  • quote.valid_until >= now + min_signature_expiry_seconds (default 310s; server rejects shorter expiries)
  • effective_expiry = quote.valid_until - settlement_buffer (default 300s)
  • rfq.expires_at < market.expiry_ts (recommended client-side validation)
  • signature_deadline <= min(effective_expiry, rfq.expires_at)

signature_deadline appears in QuoteSelected (server -> maker; see Maker API reference) and SponsoredTxToSign (server -> taker; see Taker API reference).

Reconciliation safety

Use entity versions where available:

  • rfq_version for RFQ lifecycle progression
  • order_version for order lifecycle progression

order_version is currently present on order lifecycle push events (OrderSubmitted, OrderConfirmed, OrderFailed) and not on OrderStatusMessage.

On client side:

  • apply update only if new_version > current_version
  • ignore stale updates (new_version < current_version)