acta-maker-sdk 0.5.3

Rust SDK for Acta options market makers — WebSocket client, order signing, message types
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
# acta-maker-sdk

Rust SDK for the Acta options RFQ venue on Solana. It is for two kinds of users:
**makers**, who receive RFQs over WebSocket and answer with signed quotes, and
**vault operators**, who quote, sign server-built transactions and crank the
capital cycle for an Acta vault. It covers authentication, managed WebSocket
sessions with reconnect and recovery, order signing, Solana instruction builders
and a read-only HTTP client. All features are opt-in; defaults are empty.

## Quickstart (maker)

### 1. Get registered

There is no self-service registration. Request access through the
[integration documentation](https://beta.acta.markets/docs); Acta registers your
maker on chain with the admin-gated, timelocked `RegisterMaker` instruction.
Until then every connection ends with `maker_not_registered`.

You need two keys (they may be the same key):

- **Owner**: the registered maker identity. It holds funds and signs on-chain
  maker transactions (`DepositPremium`, `WithdrawPremium`, `DepositFundsToPosition`).
- **Quote-signing key**: registered together with the owner. It signs the
  authentication challenge and every quote; both name the owner as the maker.

Before quoting, deposit a premium balance with `DepositPremium`: the premium of
a fill is debited from it.

### 2. Install

Requires Rust 1.89 or later.

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

### 3. Run the examples

From a checkout of this repository, point the examples at the quote-signing key
as a standard 64-byte Solana keypair JSON file:

```sh
export ACTA_KEYPAIR_PATH=/absolute/path/to/quote-signing-key.json
export ACTA_MAKER_OWNER=<owner base58>   # required, also when the signing key is the owner
cargo run --locked --features ws-client --example hello_auth           # authenticate, then exit
cargo run --locked --features ws-client,http --example managed_quote   # recover state, quote RFQs
```

`ACTA_WS_URL` defaults to `wss://devnet-api.acta.markets` (`DEVNET_WS`; the
other origins are `MAINNET_WS`, `DEVNET_API` and `MAINNET_API`). The quoting
examples read `GET /api/v1/venue` on the same host and apply it with `.venue(&venue)`. `managed_quote` and
`quote` submit real signed quotes at a fixed demonstration price of
`1_000_000_000` (one quote token per underlying token), so use a test maker on
devnet. They do not price, hedge or fund.

### 4. The quote loop

This is the core of `examples/managed_quote.rs`: connect, authenticate, subscribe
to RFQs, sign a quote for each `RfqBroadcast`, and watch for fills and rejections.

```rust,no_run
use acta_maker_sdk::*;
use acta_maker_sdk::ws::{maker::MakerQuoteClient, managed::*, types::*};
use std::{sync::Arc, time::Duration};
# async fn run(keypair: [u8; 64], owner: String, min_validity: Duration) -> Result<(), Box<dyn std::error::Error>> {
// min_validity: `min_quote_validity_seconds` from `GET /api/v1/venue`.
static NONCES: AtomicNonceGenerator = AtomicNonceGenerator::new();

let signer = Arc::new(BytesSigner::from_keypair(&keypair)?); // quote-signing key
let maker_owner = decode_base58_32(&owner)?;

let config = ManagedWsConfig::new(
    DEVNET_WS,
    HelloData {
        protocol_version: WS_PROTOCOL_VERSION.to_string(),
        features: vec![],
        client_name: Some("my-bot".to_string()),
        client_version: None,
    },
    signer.clone(),
    owner, // maker owner; the signer may be its quote-signing key
)
.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 epoch = client.wait_until_ready().await?;

while let Ok(msg) = messages.recv().await {
    match msg.message() {
        ServerMessage::RfqBroadcast(rfq) => {
            let min_valid_until = QuoteExpiry::after(min_validity).ok_or("clock")?;
            let valid_until =
                QuoteExpiry::after(min_validity + Duration::from_secs(60)).ok_or("clock")?;
            let quote = match RfqBinding::from_broadcast(rfq)?
                .quote()
                .maker_owner(maker_owner)
                .price(Price::new(1_000_000_000))
                .valid_until(valid_until)
                .min_valid_until(min_valid_until)
                .nonce(Nonce::new(NONCES.next_u64()?))
                .sign(signer.as_ref())
            {
                Ok(quote) => quote,
                Err(QuoteBuildError::ValidUntilAfterMarketExpiry { .. }) => continue,
                Err(error) => return Err(error.into()),
            };
            client.quote_in_epoch(quote, epoch).await?;
        }
        ServerMessage::QuoteAcknowledged(ack) => println!("accepted {:?}", ack.order_id),
        ServerMessage::QuoteOutbid(outbid) => println!("outbid on {}", outbid.rfq_id),
        ServerMessage::QuoteRejected(rejected) => println!("rejected {:?}", rejected.reason),
        ServerMessage::QuoteFilled(fill) => {
            println!("filled on {}, position {}", fill.market_pda, fill.position_pda)
        }
        ServerMessage::RfqClosed(closed) => println!("closed {:?}", closed.reason),
        _ => {}
    }
}
# Ok(())
# }
```

The sketch quotes only in its first connection epoch; after a reconnect
`quote_in_epoch` returns `NotReady`. `managed_quote` shows the full pattern: wait
for the new epoch's recovery reads, then quote in it (see
[Readiness and reconnect](#readiness-and-reconnect)). A socket write is not an
acceptance; never replay old signed quotes after a reconnect.

### 5. After a fill

`QuoteFilled` means the position is open on chain. Optionally fund the
settlement leg with `DepositFundsToPosition` (`fund_position`, [Chain helpers](#chain-helpers)); a position
that expires in the money without it is closed by a permissionless liquidator
instead of settling with you. Settlement is run by Acta's keeper and pays the
owner's ATA directly; no SDK call is needed.

## Choose your path

- **Maker, managed session** (`ws-client`): `MakerQuoteClient` on `/maker` for
  quotes, cancellations and subscriptions, `MakerDataClient` on `/maker/data` for
  private reads; both reconnect and re-read state. Read `managed_quote`, then
  [Maker sessions]#maker-sessions and [Quotes and signing]#quotes-and-signing.
- **Maker, own event loop** (`ws-client`): `WsClient` is a plain socket; you own
  auth, reconnect and recovery. Read `hello_auth`, `quote`, `discovery`.
- **Vault operator** (`vault`): `VaultOperatorClient` on `/vault` quotes for the
  vault, verifies and signs served opens and swaps, streams projection cursors;
  `VaultManager` builds the capital-cycle crank.
  Read `vault_operator`, `vault_capital` and [Vault operators]#vault-operators.
- **Chain helpers** (`chain`, `chain-rpc`): premium deposit/withdrawal and
  position funding, account decoders. See [Chain helpers]#chain-helpers.
- **HTTP** (`http`): `HttpClient` for venue limits, tokens, markets, vaults and vault exposure.
  See [HTTP reference data]#http-reference-data.

## Features and examples

| Feature | Enables |
|---|---|
| `ws-client` | `MakerQuoteClient`, `MakerDataClient`, `ManagedWsHandle`, raw `WsClient` (Tokio) |
| `vault` | `VaultOperatorClient`, checked vault transaction signing, projection feed; includes `ws-client` and `chain` |
| `chain` | Instruction builders (maker premium and funding, `VaultManager`), derived addresses, account decoders, crank decision, Pyth price accounts, served-transaction verification; no WebSocket stack |
| `chain-rpc` | `ChainClient` over Solana RPC; extends `chain` |
| `http` | Read-only `HttpClient` (reqwest, rustls with native roots) |
| `test-helpers` | `ManagedWsHandle::test_handle`, message injection |

| Example | Feature | What it does |
|---|---|---|
| `hello_auth` | `ws-client` | Authenticates, prints success, closes |
| `managed_quote` | `ws-client`, `http` | Recovers maker state, submits epoch-bound quotes |
| `quote` | `ws-client`, `http` | Raw `/maker` handshake, RFQ subscription, signed quote |
| `discovery` | `ws-client` | Live quotes, positions, eligible markets and summary on `/maker/data` |
| `indicative` | `ws-client` | Answers unsigned indicative-price requests |
| `loopback_latency` | `ws-client` | Managed client against a local mock server; no keys or chain |
| `vault_operator` | `vault`, `http` | `/vault` session: quotes, verified fill and swap signing, projection cursor |
| `vault_capital` | `chain-rpc` | Capital-cycle crank: deposit window, equity, deposits, withdrawals, finalize |

Maker examples read `ACTA_KEYPAIR_PATH`, `ACTA_MAKER_OWNER` (required) and `ACTA_WS_URL`;
`loopback_latency` reads none. `vault_operator` reads the same three (it
validates `ACTA_MAKER_OWNER` if set) and signs with the operator key in
`ACTA_KEYPAIR_PATH` for `ACTA_VAULT_PDA`. `vault_capital` reads
`ACTA_KEYPAIR_PATH`, `ACTA_VAULT_PDA` and `ACTA_RPC_URL` (default: devnet), signs
with the vault's governance or delegate key and sends one capital step per pass.

```sh
ACTA_VAULT_PDA=<vault> cargo run --locked --features vault,http --example vault_operator
ACTA_VAULT_PDA=<vault> ACTA_RPC_URL=<rpc> cargo run --locked --features chain-rpc --example vault_capital
```

## Maker sessions

### Endpoints

`/maker` (the default) carries quotes, batches, replacements, cancellations and
subscriptions. RFQs need a server-side `Rfqs` subscription: `with_initial_subscribe`
makes it part of readiness; `subscribe_messages()` only attaches a local receiver.

A second connection on `/maker/data` serves private reads: `GetActiveRfqs`,
`GetMyQuotes`, `GetOrderStatus`, `GetMakerPositions`, `GetMarketsForMaker`,
`GetMyTrades`, `GetMmSummary`. `request()` returns a validated typed payload.

```rust,no_run
use acta_maker_sdk::*;
use acta_maker_sdk::ws::{managed::*, maker::MakerDataClient, types::*};
use std::sync::Arc;
# async fn run(hello: HelloData) -> Result<(), Box<dyn std::error::Error>> {
# let signer = Arc::new(BytesSigner::from_secret([1u8; 32]));
# let owner = signer.pubkey_base58();
let config = ManagedWsConfig::new("wss://devnet-api.acta.markets", hello, signer, owner)
    .with_endpoint(MakerWsEndpoint::Data);
let data = MakerDataClient::spawn(config)?;
data.wait_until_ready().await?;
let summary = data
    .request(GetMmSummaryMessage { request_id: uuid::Uuid::new_v4() }, std::time::Duration::from_secs(2))
    .await?;
# let _ = summary;
# Ok(())
# }
```

`GetMyQuotes.scope` is required. `Live` is the complete current set, unpaged.
`History` is persisted records paged by `limit` and `cursor`/`cursor_id`
(`created_at`/`order_id`); positions and trades have their own pagination keys.

### Authentication

Managed clients validate the canonical Acta challenge before calling any signer,
also on reconnect. `ManagedWsConfig::new(url, hello, signer, owner)` and
`new_async` take the maker owner explicitly. The signer must be the owner's current
registered quote-signing key, which may equal the owner key. An unregistered owner fails
with `maker_not_registered`; a registered owner with a different signer fails with
`invalid_signature`. Raw `WsClient` callers must call
`ws::auth::validate_maker_auth_challenge` before signing.

### Readiness and reconnect

Both endpoints go `Connected → Authenticated → Reconciled → Ready`. Every
authentication re-reads state for its endpoint: `/maker` restores subscriptions,
then reads `GetActiveRfqs` and `GetMyQuotes { scope: Live }`; `/maker/data` reads
`GetMmSummary`. The first local subscriber gets a
bounded backlog since spawn, so subscribe early.

`Ready` means the SDK received the reads, not that your strategy applied them,
and they are not one atomic snapshot. Apply recovery and lifecycle messages
before trading. Live quotes are `Active { rank }`, non-selectable `Retained`,
`AwaitingSignature` and `Executing`; historical records must not overwrite them.

Reconnect first tries `ResumeAuth`; only `session_expired` falls back to a full
challenge. Session replacement, version mismatch, rejected credentials and an
exhausted retry limit terminate the client (`Closed` / `Terminated`);
`invalid_signature` terminates after three consecutive rejections.
`max_reconnect_attempts = 0` retries forever.

Subscriptions, including removals and empty mint scopes, survive resume and fresh
auth; recovery re-sends both mint lists with fresh request IDs. Only one
subscription change may await an ACK at a time (`SubscriptionPending`).

Each inbound message carries an epoch and a per-connection sequence. A lagging
receiver gets `Gap` on its next `recv()`. `EndpointDefault` reconnects quote
sessions and surfaces gaps on data sessions; `Surface` never reconnects;
`Reconnect` stops sending and recovers. A gap on the quote endpoint therefore
reconnects, and cancel-on-disconnect removes the session's active and retained
non-winning quotes (selected and executing orders keep their lifecycle): keep receivers
draining (the inbound ring holds 4096 messages by default).

### Sending and cancellation

Typed clients require `Ready`; so do raw trading commands. Every command is bound
to the epoch it was submitted in and is never replayed on a later connection.
`MakerQuoteClient::quote_in_epoch`, `replace_in_epoch` and `batch_quotes_in_epoch`
(and their `try_` forms) take the epoch whose recovery your strategy applied and
return `NotReady` once the session has moved on.
`send()` confirms a socket write, not a server ACK; `try_send()` returns a
`SendTicket` (dropping it before dequeue cancels the send); `send_await()` waits
for the correlated response. Quotes, batches, replacements and cancellations
share one FIFO lane, which the backend preserves; control frames go first. A full
lane returns `QueueFull`.

Cancel with `raw().send_await(ClientMessage::CancelQuote(request), timeout)`:
`CancelQuoteAck` lists the order IDs actually cancelled, `CancelAllQuotesAck` the
aggregate. `QuoteCancelled` is a lifecycle event, not a receipt. A disconnect
between write and ACK leaves the outcome unknown.

Keep each submitted `OrderId` until a cancellation, lifecycle fact or positive
lookup resolves it. `GetOrderStatus` returns `Pending`, `Confirmed { position_pda }`
or `Unknown`; neither `Unknown`, a timeout nor an empty `Live` result proves there
is no exposure.

### Cancel-on-disconnect

Quote connections require `cancel_on_disconnect` in `Welcome` by default and
terminate with `FeatureUnsupported` otherwise; opt out with
`with_cancel_on_disconnect(false)`. On disconnect the server removes Active and
retained quotes (the same set as `CancelAll`); selected and in-flight orders keep
their lifecycle. A gap-triggered reconnect also triggers it. COD has no bounded
cancellation time.

### Low-latency profile

`low_latency()` sets 2 s ping/pong deadlines, 1 s write timeout, a 1024 command
buffer and gap policy `Reconnect`. Dead-socket detection
drops from about 40 s to about 4 s. Raw `WsClient` sets TCP_NODELAY; reuse
serialized bytes with `PreparedClientMessage` and `send_prepared()`.

## Quotes and signing

`RfqBinding::from_broadcast` checks the market PDA against the mints, expiry and
option type under `ACTA_PROGRAM_ID` before anything is signed; for another
deployment use `from_broadcast_for_program` with a locally configured program ID,
never one from the RFQ. `QuoteBuilder` derives the signed preimage and the
`QuoteMessage` from the same inputs. Your strategy still owns its mint allowlist,
pricing and decimals.

`GetActiveRfqs` is discovery data, not a signing binding: it carries the RFQ
fields but identifies the market only by address. Quote a validated broadcast, or verify the market and derive its
PDA yourself before using `compute_order_id` and the low-level signing functions.

- Reuse one `RfqBinding` per RFQ. `sign_with_async_signer` takes HSM or remote
  keys; `into_replacement(old_order_id)` makes a replacement without re-signing.
- The default strike is the requested one; `offered_strikes()` adds
  `order_options`, and `.strike(...)` rejects anything else.
- `Price` and `Strike` are 1e9 fixed-point per underlying unit, independent of
  mint decimals (`PRICE_SCALE`): `Price::new(2_000_000_000)` is 2 quote tokens per
  underlying. `Quantity` is underlying atomic units. Wrong scaling is not caught
  locally.
- `QuoteExpiry` is whole Unix seconds. `valid_until` must lie between
  `.min_valid_until(now + min_quote_validity_seconds)` (from `GET /api/v1/venue`)
  and the market expiry; otherwise signing fails with `ValidUntilTooSoon` or
  `ValidUntilAfterMarketExpiry` instead of a server `quote_expiry_too_short` /
  `market_expired` rejection. With the `http` feature, `.venue(&venue)` sets that
  floor from `HttpClient::venue()` at the current clock and also refuses a
  `valid_until` whose `valid_until − settlement_buffer_seconds` is not after now
  (`ValidUntilWithinSettlementBuffer`).
- Human units: `Price::as_decimal()` / `Strike::as_decimal()` (display `f64`),
  `Quantity::to_units(underlying_decimals)`, and
  `total_premium_quote_atoms(price, quantity, underlying_decimals, quote_decimals)`,
  the premium in quote atoms rounded exactly as the contract does. Decimals come
  from `GetMarketDescriptors` on `/maker/data`; `MarketDecimals` keeps them by
  `market_pda` (`observe(&message)`, `get(market_pda)`), which `RfqBroadcast.market`
  and `QuoteFilled` both name.
- `.maker_owner(owner)` is required, also when the owner signs; a missing owner,
  floor, price, expiry or nonce is `QuoteBuildError::Missing`.

A quote signs the 32-byte SHA-256 order ID of a fixed 182-byte preimage.
`preimage_args()` returns the exact `OrderPreimageArgs` the server
reconstructs:

```rust,no_run
use acta_maker_sdk::*;
use acta_maker_sdk::ws::types::RfqBroadcastMessage;
# fn run(rfq: &RfqBroadcastMessage, owner: [u8; 32]) -> Result<(), Box<dyn std::error::Error>> {
let binding = RfqBinding::from_broadcast(rfq)?;
let builder = binding
    .quote()
    .maker_owner(owner)
    .price(Price::new(2_000_000_000))
    .valid_until(QuoteExpiry::from_unix_seconds(1_725_000_300))
    .min_valid_until(QuoteExpiry::from_unix_seconds(1_725_000_000))
    .nonce(Nonce::new(42));
let order_id = compute_order_id(&builder.preimage_args()?);
# let _ = order_id;
# Ok(()) }
```

`BytesSigner` zeroizes its key on drop. `SignerLike` is for local keys,
`AsyncSignerLike` for external processes or devices; every returned signature is
verified. `ManagedWsConfig::auth_timeout` covers signing, so signer futures must
be safe to cancel.

Server limits (configurable; defaults): 32 KiB per message, 50 quotes per batch;
per connection a quote bucket of 50/s (burst 100; a batch costs one per quote) for
`Quote`, `ReplaceQuote`, `BatchQuotes`, `CancelQuote` and `CancelAllQuotes`, an RFQ
bucket of 10/s (burst 20), a query bucket of 20/s (burst 40), and a general bucket of
30 messages/s (burst 60). Quote-plane messages skip the general bucket while the quote
bucket is configured (an oversized batch does not): over the quote bucket they are
rejected with `QuoteRejectReason::RateLimited` and the session stays up; prefer
`BatchQuotes`. Exhausting the general bucket closes the connection with
`4003 rate_limited`.
If a session's outbound queue is full when an RFQ, acknowledgement or
fill is pushed, the server drops it and closes the connection to force recovery.

## Errors and close codes

Request failures arrive as one `#[non_exhaustive]` `ServerError`, in
`RequestError { request_id, error }` or a bare `Error`; codes without a typed
variant are `ServerError::Generic { code, message }` with a typed `WsErrorCode`
(`Unknown(String)` for codes newer than the SDK), so match on `code`, not
`message`. `RfqRequest`, `AcceptQuote` and `SubmitSignedSponsoredTx` carry a
`request_id` and get exactly one reply: `CommandAccepted { request_id }` (the
server took the command; never chain success) or `RequestError`. On `/vault`,
`rfq_request`, `accept_quote` and `submit_signed_sponsored_tx` resolve on that
reply; `RfqCreated` still arrives as a push. Submission errors carry
`delivery`: `NotForwarded` means this attempt never handed the transaction off,
`Unknown` (or absent) means it may execute. Treat a command as not executed only
when every attempt for it returned `NotForwarded`. Vault swaps resolve by
`swap_status(execution_id)` and the `SwapSettled` push. Quote outcomes are `QuoteRejected { reason }`: besides validation
reasons, `RateLimited` (quote bucket exhausted, retry later), `TradingPaused`
(venue paused, not a fault of the quote) and `InternalError`; unknown reasons
decode as `Unknown`.

Server-initiated closes carry a code and reason, decoded by `CloseCode::from_code`:
`4000 idle_timeout`, `4001 auth_timeout`, `4002 slow_consumer`, `4003 rate_limited`,
`4004 session_replaced`, `4005 hello_timeout`. After `WsClient::next()` returns
`None`, `server_close()` gives the frame as `ServerClose { code, reason }`;
`close_code()` is `None` for codes outside `CloseCode`. `auth_timeout`,
`rate_limited`, `session_replaced` and `hello_timeout` closes are preceded by an
`AuthError` or `Error` with the same code. `auth_timeout` and `session_replaced`
end the session on that frame: the managed client leaves `Ready`, stops writing
and reads up to one second more for the server's close frame. `rate_limited` and
`hello_timeout` errors do not end the session by themselves, so the client keeps
reading until the close arrives. Either way it logs the frame and puts it on
`ManagedWsEvent::Disconnected { close }`. It reconnects after all of them except
session replacement, which terminates it. Some server closes carry no code: a
blacklisted or unregistered maker, an expired session, too many parse errors or
auth attempts, and server shutdown; then `close` is `None` and the preceding
`Error` / `AuthError` usually names the cause. Every connection that opened ends
with exactly one `Disconnected`, before `Terminated` when the client stops (also
on `close()` and on a session panic). Code 4900 is the TypeScript client's own
pong timeout and never comes from the server.

## Vault operators

`/vault` carries the vault's permitted maker and taker trading, swaps,
subscriptions and projections on one connection. `examples/vault_operator.rs`
shows cursor handling and verified fill and swap signing.

- **Authentication:** `StartVaultAuth { operator, vault_pda }` → signed challenge
  bound to operator and vault → `VaultAuthSuccess { operator_kind, permissions }`.
  Every reconnect uses a fresh challenge; there is no `ResumeAuth`.
- **Grant:** `auth()` holds the current connection's grant and emits `None` on
  disconnect or `vault_session_invalidated`. `can_maker()` / `can_taker()` need
  `Ready` plus `SIGN_MAKER_QUOTES` / `OPEN_TAKER`. Governance quotes only while
  the same key holds an active primary-delegate grant with `SIGN_MAKER_QUOTES`.
- **Readiness:** each auth reads `GetMyActiveRfqs` and `GetPositions`; with maker
  permission also `GetMmSummary`, `GetActiveRfqs`, `GetMyQuotes { scope: Live }`.
- **Subscriptions:** only `WsChannel::Vaults`, no mint filters, on by default.
  RFQs arrive without a subscription to grants with `SIGN_MAKER_QUOTES` while the
  vault is tradable (`Normal` safety, caps prefilter).
- **Discovering vaults:** `VaultCatalogChanged { vault_pda }` (a vault was created,
  re-delegated or closed) is not sent on `/vault`. Add `WsChannel::Vaults` to your
  existing `/maker` quote session, or hold a taker session; a second `/maker`
  quote session for the same owner replaces the first (`session_replaced`), and
  `/maker/data` cannot subscribe. Maker and taker `vaults` subscribers also get
  `VaultProjectionAdvanced` for every vault. `VaultCatalogChanged` is best-effort
  (not replayed; on a full queue it is dropped and the connection closed), so re-list
  vaults over HTTP after every (re)connect as well as on each event.
  `VaultFleet::discover` (features `vault` + `http`) does exactly this for one
  operator key: a `/taker` session on `vaults` re-lists
  `GET /api/v1/vaults?delegate=` (unexpired grants only) on each subscription and
  catalog change and publishes `FleetListing` on a watch channel; a feed that
  fails to subscribe or goes silent publishes `Failed` and reconnects.
- **Quoting:** `sign_quote` puts the vault in `maker_owner`; send with
  `quote_in_epoch` / `replace_in_epoch` / `batch_quotes_in_epoch` in the epoch whose
  recovery you applied (`NotReady` once the session moved on). A writer vault with
  `OPEN_TAKER` can take a holder vault's quote; the order binds both vaults, the
  writer operator signs and pays, the holder delegate signs the quote.
- **Checked signing:** `verify_and_sign_sponsored_tx` and `verify_and_sign_swap`
  rebuild the expected instructions from what the operator agreed to
  (`VaultOpenIntent::{Sale, BetweenVaults, Fill}` with `VaultTradeTerms`, or
  `VaultSwapIntent` with request, quote, `PairPriceFeeds` (the pair's Pyth feed
  ids and your shards; the served `main/second_price_update` must be one of
  those shard accounts), `router` and the
  operator's own oracle-based `min_output_floor`) and sign only if the served
  transaction matches: fee payer and signers, compute budget within
  `ComputeBudgetBounds`, every instruction's program, accounts and data, ATA
  creates, the maker's Ed25519 quote signature, and router accounts confined to
  the swap's route tail. A served minimum output below `min_output_floor` is
  rejected even within slippage. `router` is the one the program build pins:
  `JUPITER_V6_PROGRAM_ID`, or `DEVNET_SWAP_ROUTER_ID` on devnet. Deadlines are
  checked, the blockhash is not. v0 lookup tables come from
  `ChainClient::address_lookup_tables`. `verify_vault_open` / `verify_vault_swap`
  do the same for callers outside the operator client.
- **Taker RFQs:** with `OPEN_TAKER`, `CancelRfq` has no receipt: `send()` it and
  wait for `RfqClosed`. `sell_rfq` (features `vault` + `chain-rpc`) drives a
  vault sale end to end: RFQ, your `SaleDesk` picks a quote, accept, checked
  signing against the counterparty read from chain (`Counterparty::load`), and
  submit. Every send goes through your `SendGate` in one connection epoch;
  `SaleError::in_flight()` names the signed sale a failure may have left in flight.
- **Delivery:** `VaultRequestError::delivery()` is `NotForwarded` for local
  refusals before the write (gate, wrong epoch, full queue) and server rejections
  that say so, `Unknown` when the command may be in flight.
- **Operations:** rebalance needs `REBALANCE`; recovery swaps are governance-only.
  With `FUND_MAKER`, `VaultManager::fund_maker_position` moves the settlement leg
  (call: quote, put: underlying) from the vault into a position it holds.
  `VaultManager` also builds the capital-cycle crank (`examples/vault_capital.rs`).
  `ChainClient::unfunded_positions` and `pending_capital_requests` (feature
  `chain-rpc`) read what the crank must fund or process from the program's
  accounts.

`swap_submit_in_epoch` sends only in the given epoch and through the gate.
A swap submit ACK confirms durable enqueue, not execution; after a lost ACK,
retry the same signed payload while the template is valid.
`VaultProjectionAdvanced` is a versioned cursor, not an execution result: ignore
duplicate or older versions and reload the HTTP vault detail on every newer
version or gap. `VaultProjection::follows(previous)` checks adjacency only.

## Chain helpers

With `chain-rpc`, `ChainClient::new(rpc_url, program_id, commitment)` sends the
owner's maker transactions (`deposit_premium`, `withdraw_premium`,
`fund_position`) or returns them via the `build_*_ixs` methods. `chain` alone has
the offline builders. `FundPositionArgs` can wrap native SOL for wSOL positions:

```rust,no_run
# #[cfg(feature = "chain-rpc")]
# {
use acta_maker_sdk::chain::{FundPositionArgs, NativeSolFunding};
# fn args(maker_owner: solana_sdk::pubkey::Pubkey, position_pda: solana_sdk::pubkey::Pubkey) {
let fund = FundPositionArgs {
    maker_owner,
    position_pda,
    create_atas: true,
    native_sol: NativeSolFunding::with_default_reserve(),
};
# let _ = fund;
# }
# }
```

The pre-sign budget covers wrap amount, rent and fee (only wrap and reserve with
another fee payer). It does not lock SOL against concurrent spending, and RPC
errors are never read as a zero balance. Use `NativeSolFunding::Disabled` to
wrap externally.

### Key rotation

`chain::ix::build_update_quote_signing_ixs(&program_id, &UpdateQuoteSigningIxArgs
{ maker_owner, new_quote_signing })` builds `UpdateMakerQuoteSigning`; the owner
signs it, and the new key needs no SOL. The venue accepts only the key registered
on chain, so once the transaction lands restart sessions with the new signer; the
owner passed to `ManagedWsConfig::new` and `.maker_owner` does not change. Rotate
at once if a quote-signing key may be compromised.

## HTTP reference data

`HttpClient` reads `venue()` (program ID, minimum quote validity, settlement
buffer, refresh lead, pre-expiry cutoff, RFQ and signature timeouts, per-RFQ and
per-taker limits),
`tokens()` (by mint; underlyings add size rule, strike tick and quote mints),
`markets(&MarketFilter)` (by underlying, quote, expiry, side and `active`;
`Some(false)` adds closed and finalized markets the server has seen since it
started, not full history) and `vaults()` / `vault(pda)` / `vault_positions(pda)`.
Read limits from here instead of hardcoding them. Venue, token and market
responses are revalidated with `If-None-Match`: once an ETag is cached, unchanged
polls get 304 and changed ones 200; refetch
when `subscribe_reference_changes()` yields new ETags (on `/maker/data` this
requires the backend release that delivers `ReferenceChanged` there).
Vault reads are not cached.

## Wire notes

Known string values and tagged states (`PositionType`, `MakerQuoteState`,
`OrderExecutionState`, `QuoteCancelReason`, `RfqCloseReason`, ...) are SDK
enums; see the API reference. Free-form reason text stays a string.
`Welcome.server_time_unix_ms`, `AuthSuccess.expires_at`,
`RfqBroadcast.sent_at_unix_ms` and `instruction_index` on chain events are
required. `*_unix_ms` is milliseconds; credential expiry is Unix seconds and does
not bound the socket. Chain events are keyed by `(signature, instruction_index)`.
`GlobalStats.usd` amounts are exact decimal strings; legacy numeric volume fields
are raw units, not USD.

## Latency budget

`examples/loopback_latency.rs`, release build, `low_latency()`, 20 000 outbound and
5 000 inbound samples, measured on one Apple silicon machine. These are SDK overhead measurements, not a guarantee; network and server time add on top.

| Leg | p50 | p90 | p99 |
|-----|-----|-----|-----|
| `try_send` → frame on the peer's socket | 20 µs | 27 µs | 60 µs |
| peer send → parsed by the session task (paced 1/ms) | 150 µs | 281 µs | 424 µs |
| session task → subscriber `recv()` | 19 µs | 39 µs | 70 µs |

## Upgrading and development

Breaking changes per release are in `CHANGELOG.md`, which ships with the crate.
In a repository checkout, `bash scripts/test.sh` runs the wire-compatibility
check (needs `../rust-backend` or `ACTA_BACKEND_DIR`), feature combinations,
tests, examples, Clippy, rustdoc and packaging.

## Documentation

- [API reference]https://docs.rs/acta-maker-sdk
- [Acta integration documentation]https://beta.acta.markets/docs

## License

MIT