Metadata-Version: 2.4
Name: olive-solana-sdk
Version: 3.2.1
Summary: Async Python SDK for market makers quoting Olive Solana options
Project-URL: Documentation, https://olive.ag/docs
Project-URL: Repository, https://github.com/macols77/olive
Project-URL: Issues, https://github.com/macols77/olive/issues
Author: Olive
License-Expression: MIT
License-File: LICENSE
Keywords: market-maker,olive,options,rfq,solana
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: jsonschema<5,>=4.23
Requires-Dist: solders<1,>=0.26
Requires-Dist: websockets<18,>=15
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == 'dev'
Requires-Dist: mypy<2,>=1.15; extra == 'dev'
Requires-Dist: pytest-asyncio<2,>=0.25; extra == 'dev'
Requires-Dist: pytest<10,>=8.3; extra == 'dev'
Requires-Dist: ruff<1,>=0.11; extra == 'dev'
Requires-Dist: twine<7,>=6; extra == 'dev'
Requires-Dist: types-jsonschema<5,>=4.23; extra == 'dev'
Description-Content-Type: text/markdown

# Olive Solana Python SDK

Olive's Long Calls and Long Puts are cash-settled: both pay intrinsic value in
USDC, and holders never supply an exercise payment.

This SDK implements the V13 program (devnet `8TjjPPLKhwgmeSRpK9TdZETVk5xcnJCAhoLb7SHnxSr3`,
`OLIVE_OPTIONS_PROGRAM_ID`) and the native `/maker/v4/ws` protocol (`protocolVersion` `4.0`) and
nothing else. There is no translation or compatibility layer: earlier clients are refused by the
server, and quotes signed for earlier programs cannot be carried forward. Makers moving from 2.x:
see [MIGRATION-3.0.md](https://github.com/macols77/olive/blob/main/packages/solana-sdk/MIGRATION-3.0.md). For pricing, read `rfq.leg` (`request["legs"][0]`) and return
`rfq.quote(premium=...)` or `rfq.quote(amount=...)`.

`olive_solana` is the maker-facing surface. Keeper, admin and backend tooling (governance and lifecycle
builders, account decoders, entry/exit transaction assembly and instruction encoders, the margin mirror,
Pyth helpers) is `olive_solana.operator`.

To write a bot, read [Write a bot](#write-a-bot): four functions and one setting. What can go wrong and who handles it:
[Risks and who handles them](https://olive.ag/docs/developers/maker-risks).

## Install

```bash
# From this repository:
pip install -e "packages/python-sdk"

# After the distribution is published:
pip install "olive-solana-sdk"
```

Python 3.11 or newer is required. The SDK needs no database.

## Write a bot

A maker bot is four functions and one setting. `ManagedMaker` does everything else: it connects, checks and signs your
quotes, reconnects, rebuilds its view from Olive on every connect (no database), and turns every money or operations
situation into an alert.

| You write | What it does |
| --- | --- |
| `on_entry_rfq` | Price a sale: a user buys a call or put from you. Return `rfq.quote(premium=...)` or `rfq.decline(reason)`. |
| `on_exit_rfq` | Price a buyback: a holder sells back early. Return `rfq.quote(amount=...)` or `rfq.decline(reason)`. |
| `on_fill` | Hedge. One function, given to both `on_entry_filled` and `on_exit_filled`. |
| `on_alert` | Page a human. Every money or operations situation arrives here with a stable `code`, and `data` carries the amount and the deadline. |
| the money setting | Who signs deposits and the buyback allowance: none (alerts only), `LocalKeypairFundingSigner` or `FordefiApiFundingSigner` (EXPERIMENTAL). |

Olive checks your margin before it sends you a request, again when you quote and when your quote is selected, and the
program checks it at the fill, so the bot needs no margin code. What can go wrong and who handles it:
[Risks and who handles them](https://olive.ag/docs/developers/maker-risks).

```python
import asyncio

from olive_solana import MakerAlert, ManagedFundingOptions, ManagedMaker, ManagedMakerEventHandlers

# The money setting: who signs deposits and the buyback allowance (see "The money setting" below).
FUNDING_SIGNER = None  # none: alerts only, your desk deposits by hand


def on_entry_rfq(rfq):
    """Price a sale. rfq.leg is the call or put the user buys (kind, strike_e8, size, maturity)."""
    premium = my_premium(rfq.leg, rfq.request)  # USDC base units for rfq.leg.size, or None
    return rfq.quote(premium=premium) if premium else rfq.decline("outside_risk_limits")


def on_exit_rfq(rfq):
    """Price a buyback of rfq.request["fractionBps"] of the position. It is paid from your funding wallet."""
    amount = my_buyback(rfq.leg, rfq.request)
    return rfq.quote(amount=amount) if amount else rfq.decline("outside_risk_limits")


async def on_fill(event) -> None:
    """A sale (trade.filled) or a buyback (exit.filled), final on chain. Olive may deliver an event twice: hedge to
    the target your book implies, not by adding the event's size."""
    await my_hedger.rebalance(event["position"])


def on_alert(alert: MakerAlert) -> None:
    """alert.code is stable (funding_due, true_up_pending, buyback_allowance_low, ...); alert.message is plain English."""
    my_pager.send(alert.severity, alert.code, alert.message, alert.action, alert.data)


async def main() -> None:
    maker = ManagedMaker.from_env(  # reads olive.env and the bot key file (OLIVE_QUOTE_SIGNER_KEYPAIR)
        on_entry_rfq=on_entry_rfq,
        on_exit_rfq=on_exit_rfq,
        events=ManagedMakerEventHandlers(on_entry_filled=on_fill, on_exit_filled=on_fill),
        on_alert=on_alert,
        funding=ManagedFundingOptions(signer=FUNDING_SIGNER),
    )
    await maker.run()


asyncio.run(main())
```

Olive's onboarding test request is answered for you with the 5 USDC minimum premium; pass `on_test_rfq` only to answer
it differently (below).

### The money setting

`funding=ManagedFundingOptions(signer=...)` plans the deposit due before each funding deadline (24 hours before
expiry, when the full margin requirement must be in your margin account) and alerts 36, 30 and 25 hours before it
(`funding_due`), then from the deadline while it is late (`funding_overdue`).

- **None** (`signer=None`). Alerts only: each alert carries the amount and the deadline, and your desk deposits and
  sets the buyback allowance by hand.
- **`LocalKeypairFundingSigner(keypair, rpc)`.** A key in this process signs and sends. For tests and a small float wallet.
- **`FordefiApiFundingSigner(vault_id=..., rpc=...)`.** Your Fordefi vault signs after your Fordefi policy approves;
  the API token comes from `FORDEFI_API_TOKEN` and is never logged.

> **WARNING: `FordefiApiFundingSigner` is EXPERIMENTAL.** It follows Fordefi's public API description and has only been
> run against scripted responses, never against a real Fordefi account. Try it on devnet with a throwaway vault first.
> Approval stays with your Fordefi policy.

With a signer, also let the SDK keep the buyback allowance topped up through it:

```python
from olive_solana import FordefiApiFundingSigner, ManagedExitApproval, rpc_token_account_reader

FUNDING_SIGNER = FordefiApiFundingSigner(vault_id="...", rpc=my_rpc)  # EXPERIMENTAL

maker = ManagedMaker.from_env(
    ...,  # the four functions, as above
    funding=ManagedFundingOptions(signer=FUNDING_SIGNER),        # asks custody for each deposit the pool needs
    exit_approval=ManagedExitApproval(signer=FUNDING_SIGNER),    # keeps the buyback allowance up, also through custody
    funding_wallet_account=rpc_token_account_reader(RPC_URL),    # the top-up reads the allowance and balance on chain
)
```

What the SDK does with a signer:

- **Nothing moves without your approval.** Olive builds an unsigned transaction, the SDK checks it instruction by
  instruction, and your signer takes it to custody. Olive never holds a maker key.
- **Pinned mint.** Prepared transactions are checked against the deployment's USDC mint (`OLIVE_USDC_MINT`); the source
  (`ATA(signer, mint)`) and the margin account are derived, the instruction is found by program id and Anchor discriminator, and
  a durable nonce is required unless the signer sends at once (`requires_durable_nonce = False`).
- **No double counting.** Every request still waiting, including a timed-out one (custody may still approve it), counts as
  money on its way and is subtracted from the next gap; it is watched until it lands, is refused, or `abandon_after_ms`
  (7 days) passes. Each request has a deterministic reference that the Fordefi signer sends as `x-idempotence-id`.
- **Single instance.** Pending requests are held in the process: run one `ManagedMaker` per MakerConfig.
- **Buffer.** `max(gap * buffer_bps / 10_000, min_buffer_usdc)` on top of the gap (200 bps, 1 USDC), or a fixed
  `buffer_usdc`; a request below `min_request_usdc` (1 USDC) waits for the next stage.
- **Buyback allowance.** The top-up restores the allowance to `target_usdc` (default 25,000 USDC), never above it,
  decided from the chain via `funding_wallet_account` (skipped, `exit_approval_failed`, without it), and adds at most
  the 24 h refill budget (below).
- **Defaulted or inactive maker.** No deposit requests, one `funding_overdue` alert (`data.blocked`); overdue alerts repeat
  hourly for 6 hours, then every 6 hours.
- For a separate hot float wallet, add `auto=AutoFunding(float_signer, float_wallet, max_per_day_usdc)` to
  `ManagedFundingOptions`; it is off unless set.

### Alerts

| `code` | When | `data` |
| --- | --- | --- |
| `funding_due` | 36, 30 and 25 hours before a funding deadline | `amount`, `deadline`, `reasons`, `onTheWay`, `request` |
| `funding_overdue` | from the deadline while money is missing (hourly for 6 hours, then every 6 hours) | as `funding_due`; `blocked` when the maker is defaulted or inactive |
| `funding_ok` | an alerted need is covered | `need` |
| `true_up_pending` | an expiry settled short: pay within 4 hours | `amount`, `deadline`, `bucket` |
| `credit_breached` | a price move took you over your credit line; sales that need new credit wait | `amount` (over the line), `creditLimit`, `unfundedExposure` |
| `routing_paused` | Olive stopped sending you entry requests | `reason`, `amount`, `maturity`, `bucket` |
| `not_receiving_rfqs` | Olive says why you are not getting entry requests | `reasons`, `markets` |
| `buyback_allowance_low` | the allowance is low, its refill budget is used or a refill failed, or a buyback was declined | `current`, `wanted` or `needed` and `approved` |
| `handler_failed` | your pricing handler threw or ran past `pricing_timeout_ms`; the RFQ is declined (`pricer_error`, `too_slow`). Also: an event handler failed and is retried | `rfqId`, `kind`, `reason`, `declineReason`; for an event `method`, `reason`, `blocksEntries` |
| `quote_invalid` | your handler returned a quote the SDK will not sign (premium not a whole number of USDC base units, under the 5 USDC minimum, premium plus fee over the user's budget, exit amount not positive); declined `invalid_quote` | `rfqId`, `kind`, `reason`, `declineReason` |
| `unquoted_exit` | an exit was paid that your bot didn't quote: your bot key may be compromised (critical) | `amount`, `rfqId`, `quoteId`, `position`, `transactionSignature`, `predatesProcess` |

Amounts are USDC base units and deadlines unix seconds, as decimal strings; `data` keys are camelCase in both SDKs. The
same alert (same `key`) repeats at most once an hour (`alert_repeat_ms`). The plain log lines stay (stderr, or `logger=`).
`deadline_roll_pending` and `underfunded_past_deadline` are reserved codes, not sent on V13.

Every alert has an `action`: one short sentence saying what to do now (for example `Approve the pending deposit in your custody.`). When a deposit request is already waiting in custody, `funding_due` and `funding_overdue` say so instead of "deposit now", and `data.waitingOn` is `"custody"`. A pricing failure never holds you out of new entries (only an event handler that keeps failing on `bucket.funding_required` or `bucket.true_up_pending` does, and `handler_failed` says so with `data.blocksEntries`). `unquoted_exit` is the stolen-key alarm: revoke your buyback allowance first, then replace your bot key. Set `alarm_on_unquoted_exits=False` only if several processes quote with one bot key.

### Default log lines

The SDK prints one plain line (to stderr) for each of: connected, signed in, synced, disconnected (with the reason: "Olive is restarting", "login refused"), reconnecting, every RFQ received (id, asset, side, call or put, strike, size), every quote sent (with the price) and decline sent, every fill, and every alert, plus the money events above. No keys or signatures appear in them except transaction signatures. Redirect them with `logger=lambda entry: ...` (`entry.level`, `entry.message`, `entry.source`) or turn them off with `quiet=True`. Pricing code gets `pricing_timeout_ms` (default 1500 ms; the auction window is 2000 ms); `0` turns the timeout off.

A plain `def` pricing handler runs on the event loop and cannot be interrupted: one that finishes after the limit is declined `too_slow`. Use `async def` (and `asyncio.to_thread` for CPU-heavy pricing) so the SDK can cancel it.

## Olive's onboarding test request

While a maker is onboarding, Olive sends each chosen asset one test request (an `rfq.request` with `test: true`) the first time
the software subscribes, so the first-quote check completes in seconds. It is never an auction and can never be filled.
`ManagedMaker` answers it itself and never calls `on_entry_rfq` for it: by default a signed quote at the five-USDC minimum
premium, or whatever `on_test_rfq(rfq)` returns (`rfq.decline(reason)` means "connected, not quoting yet" and does not complete
the first quote). It asks for it with the `x-olive-features: test-rfq` header; a plain `MakerWsClient` is sent none unless you
set `accept_test_requests=True`. `load_olive_env()` reads the `olive.env` the onboarding page gives you (a variable already set wins).

## How margin works (reference)

You do not need this to write a bot; it is what Olive and the program do with your money.

- **Legs.** Entry RFQs, quotes and positions carry `legs`: `{kind, maturity, strikeE8, qty}` (`kind`
  0 = call, 1 = put; `qty` the unsigned size the user buys). The wire carries 1 to 4 legs; the program
  accepts exactly one user-long call or put. `ManagedMaker` declines any other structure itself
  (`unsupported_structure`) before your code sees it, and `rfq.leg` (`LaunchLeg`: `kind`, `kind_name`,
  `strike_e8`, `size`, `maturity`) is the leg to price. `assert_launch_structure` / `launch_leg` raise
  `LegError` with the program's error name. Maturities sit on the 08:00 UTC grid (`is_maturity_on_grid`): an
  off-grid leg fails `InvalidMaturity` and `ManagedMaker` declines its RFQ as `invalid_maturity`.
- **Signed quotes.** Entry quotes sign under `OLIVE_ENTRY_QUOTE_V13` (the leg, the unsigned `premium`);
  exits under `OLIVE_EXIT_QUOTE_V13` (the unsigned `amount`, `fractionBps` of the original position and the
  position's `remainingBps` before the exit; no USDC source). You return the quote's
  `premium` and `amount`; the SDK signs them.
- **Margin requirement, not maximum payout.** Each bucket holds a price-driven requirement
  (`risk.bucket_requirement`): every strike slot's time-value add-on plus the worse of the bucket's
  intrinsic payout at a shocked-up and a shocked-down price. That is **netting**: calls and puts in the
  same bucket offset, because only one side of a strangle can pay at expiry. Every fill reprices the whole
  book at the live price (`risk.fill_requirement`).
- **One margin account, one balance.** Buckets hold no tokens. Every maker has one margin account, the USDC associated
  token account of its `MakerConfig` (`pool_vault(maker_config, mint)`), and the maker ledger splits it
  into `pool_free` (the maker's to withdraw) and `pool_committed` (the `collateral` of its buckets). What
  collateral does not cover is **unfunded** and is carried by credit (`credit_limit`, set by governance).
  You fund by depositing (`deposit_pool_instruction`; anyone may deposit) and take free money out only
  with `withdraw_pool_instruction` (unrepaid insurance is repaid first; `amount=0` only repays; refused
  while defaulted, with a Pending bucket or a matured live bucket). Every fresh reprice (entry, refresh,
  handover) moves collateral toward the requirement: credit carries what it may (before the deadline,
  within the limit; an entry keeps at least its bucket's pre-fill unfunded on credit), free margin
  covers the rest, and collateral above the requirement goes back to `pool_free`
  (`risk.apply_requirement`). `maker_balance(capacity)` / `ManagedMaker.balance()` give one maker-level
  view: `pool_free`, `pool_committed`, the requirement, unfunded and credit, `insurance_unrepaid`,
  `defaulted` and `needed_by_next_deadline` (what to deposit before `next_funding_deadline`).
- **Entries.** A fill that adds unfunded exposure needs the funding deadline ahead
  (`FundingDeadlinePassed`) and the maker within its limit afterwards (`CreditCapacityExceeded`).
  `risk.predict_entry_funding` predicts the margin account pull and both refusals; `capacity_entry_funding` and
  `capacity_max_size(capacity, market_config=..., maturity=..., kind=..., strike_e8=..., max_size=...,
  now_seconds=...)` run it on a `maker.capacity` result so you can clip a quote to what will execute
  (`max_size_for_quote` underneath). Both give nothing for a defaulted, inactive or handed-over maker.
- **Exits** remove quantity from the slot and pay the user from the maker's **funding wallet**, with no price and
  no margin account movement: the funding wallet's USDC account (`exit_source_address(funding_authority, usdc_mint)`)
  approves the PDA `exit_delegate_address(program, maker_config)` with an SPL `approve`
  (`approve_exit_delegate_instruction`, signed by the funding wallet, never the bot key;
  `revoke_exit_delegate_instruction` stops every exit), and `accept_exit_quote` pays through it. The buyback allowance
  falls with every exit and is the cap on what a stolen bot key could move: size it to a day or two of exit
  flow, not to the wallet. `read_exit_approval` / `check_exit_approval` tell you where it stands
  (`ExitDelegateNotApproved`, `ExitAllowanceExceeded`). The SDK declines (it does not raise) an exit quote above
  `ledger.exitDelegatedAmount`, above the funding wallet's balance (with `funding_wallet_balance=`), or while
  insurance is unrepaid: it sends `rfq.decline` with the reason `exit_approval_too_low`, `exit_wallet_balance_low` or
  `insurance_unrepaid` and reports `exit_declined` (amounts, balance, what to do). Pass
  `exit_approval=ManagedExitApproval(funding_authority=..., submit=...)` to `ManagedMakerOptions` and the SDK keeps
  it topped up: when what is left falls below half of the target (25,000 USDC by default; `target_usdc`,
  `refill_below_bps`), or an exit needs more than what is left, it restores the buyback allowance to the target (never above
  it) through `signer=` (custody, see "The money setting") or a `submit(instruction, current=..., target=...)` callback
  that signs with the funding wallet, one request at a time and at most once a minute (`exit_approval_set` /
  `exit_approval_failed` statuses). Without the option it reports `exit_approval_low` and the `buyback_allowance_low` alert.
- **The refill budget.** The auto top-up refills the buyback allowance each time it drops below half. On its own that would
  turn the buyback allowance into no limit at all: an attacker who signs exits with a stolen bot key makes the bot
  refill the allowance after every one. So the SDK adds at most `max_refill_usdc_per_24h` to the buyback allowance in any
  rolling 24 hours (**default: twice the target, 50,000 USDC for the default target**; the first buyback allowance at start-up
  counts). When the budget is used it stops refilling and reports `exit_approval_budget_exhausted` (default log at
  error level, with the time the window reopens). Exits above what is left are then declined until the window moves
  on or you approve by hand. The trade-off is explicit: a busy legitimate day can use the budget and decline exits;
  raise `max_refill_usdc_per_24h` if your real flow needs it, knowing that it is also what a stolen key can move per
  day. The budget lives in memory, so a restart opens a fresh window.
- **Exits you did not quote.** Every `exit.filled` is compared with the exit quotes this process signed. A fill against
  a quote it did not sign raises the `unquoted_exit` status and an `EXIT YOU DID NOT QUOTE` line at error level (warn
  level when the quote was issued before this process started, since an earlier run may have signed it). With one bot
  per MakerConfig that means your bot key signed elsewhere: **revoke the buyback allowance first, then rotate
  the key.** If several processes quote with the same signer, set `alarm_on_unquoted_exits=False`.
- **The money log.** Every event that moves or endangers your money is logged by default, whatever handlers you wired,
  unless `quiet=True`: funding needed, settled short (default), true-up paid or partly paid, default cleared, insurance drawn, credit breached,
  margin account deposited and withdrawn (a withdrawal is a WARN: it used the maker-authority key), routing paused and resumed,
  and every buyback allowance status. A line carries amounts in USDC, the transaction signature and the new balance where
  known, for example `FUNDING NEEDED: deposit at least 12,345.67 USDC into your margin account within 3h 20m (deadline
  2026-10-17 08:00 UTC, bucket 7Hk2...4fQa)`. Lines go to stderr; pass `logger=` to send them elsewhere.
- **Nothing due.** Entries are refused while the maker has a Pending bucket (`PendingBucketsOutstanding`; the
  free margin is then held for the true-up and reprices pull nothing from it) or a live bucket past its maturity
  (`EarlierMaturityOutstanding`); `capacity_max_size` returns 0 then. A settlement that leaves a bucket Pending
  puts the maker in default in that call (`bucket.settled.makerDefaulted`, true only on that call). The default clears by itself (`maker.default_cleared`, `on_maker_default_cleared`) once nothing is due, unless insurance was drawn, a bucket was Haircut or one was handed over (`ledger.defaultCanCure`, `default_can_cure(episode)`); there are no incidents; governance reactivates a maker only once none of its
  buckets is live (`LiveBucketsOutstanding`).
- **One bucket per expiry.** A maker has exactly one bucket per market and maturity, with 256 strike
  slots, at `bucket_address(program, maker, market, maturity)` (no index). Its address derives from the
  maker that created it even after a governance handover (`originMaker` vs `makerConfig` on bucket objects;
  `bucket.handed_over`).
- **24h funding deadline and the freeze.** Credit stops carrying a bucket 24 hours before maturity
  (`funding_deadline(maturity)`). At the deadline the keeper reprices every live bucket and free margin covers
  what it can. A maturity still unfunded at or past its deadline **freezes** the maker
  (`is_underfunded_past_deadline`, `ledger.underfundedPastDeadline`, `ledger.maturities[]`): entries on every
  maturity, withdrawals (repay-only too) and handovers to it are refused (`UnderfundedPastDeadline`) until a
  refresh covers it. Nothing defaults at that point; the maker defaults only if the bucket then settles short.
- **Repeated warnings and the routing gate.** Olive sends `bucket.funding_required` at the deadline and
  12 h, 6 h, 3 h, 2 h and 90 min before expiry while the bucket is short, plus one extra notice when the
  deficit has grown since the last one by at least the larger of 5% of the requirement and 100 USDC, one per
  deposit that leaves the bucket short (with what is left), and one when the routing pause begins; each
  carries the current deficit and `secondsUntilDeadline` (negative after the deadline). A
  maker receives no new entry requests while a maturity is short past its deadline
  (`funding_shortfall`, at once: the program refuses its entries anyway), a bucket is Pending with a gap
  (`settlement_shortfall`) or the maker is deactivated on chain (`maker_inactive`).
  `maker.capacity` reports `routing: {paused, reason?, since?}` on both scopes (`reason` and `since`
  absent, not null, when not paused) and `maker.routing_paused` / `maker.routing_resumed`
  announce it. Exits, funding, true-up and settlement
  continue. `ManagedMaker` turns these notices into the `funding_due` / `funding_overdue` and `routing_paused`
  alerts (with the amount and the deadline) without any handler, and with a funding signer it asks your custody
  for the deposit (see "The money setting").
- **4h true-up.** A bucket that settles short enters `pending` with a 4-hour true-up window
  (`bucket.true_up_pending`, `true_up_deadline`; anyone may `fund_bucket`). Afterwards the margin account, then
  insurance (`insurance.drawn`) pay, and holders are paid pro rata (`haircut`); the maker defaults.
- **Headroom.** `maker.capacity` (scope `all`) returns the maker `ledger` (`poolVault`, `poolFree`,
  `poolCommitted`, `creditLimit`, `unfundedExposure`, `availableCredit`, `breached`, `defaulted`,
  `insuranceDrawn`, `insuranceUnrepaid`, `pendingBucketCount`, `exitSource`, `exitDelegatedAmount`,
  `maturities[]` and `underfundedPastDeadline`), the margin constants,
  each market's risk parameters, `minLegNotional` and live price, and the maker's one bucket per expiry
  (`required`, `collateral`, `unfunded`, `payoutPool`, `payoutRemaining`, `live`, deadlines and occupied
  slots `{index, kind, strikeE8, qtyShort}`). Scope `ledger` returns only the ledger and routing.
  `credit.breached` reports unfunded exposure above the limit after a price move; `credit.limit_changed`
  a new limit.

Every V13 instruction has a builder, and each derives its PDAs, vaults and Pyth push account so you pass
only what cannot be derived (a bucket by `bucket=` or `maturity=`, a live price by `price_update=` or the
market's `oracle_feed_id=`): `olive_solana.lifecycle` (initialize, fund, refresh, handover,
record_settlement with an optional `FallbackPrice`, settle, redeem, sweep_position, claim_swept_payout,
close), `olive_solana.governance` (protocol, roles, makers, `set_credit_limit`, markets, insurance),
`olive_solana.pool` (`deposit_pool`, `withdraw_pool`, `maker_balance`) and `olive_solana.transactions`
(entry, exit and transfer, `quote_signature_instruction`, `build_quote_transaction` for a v0 transaction
with an address lookup table, `entry_lookup_table_addresses`). `olive_solana.accounts` decodes the 8
program accounts. Entries and exits are v0-only: `build_quote_transaction` requires the market's lookup
table (section 7 of the interface: the user's USDC source and the position stay static; a one-leg entry is
682 bytes). `decode_program_error` also explains Anchor's 2040 `ConstraintDuplicateMutableAccount` (a self-handover or a
claim into the claims vault) in its `message`. `olive_solana.pyth` normalizes Pyth exponents -18..0 to -8 and checks a settlement update
(`validate_pyth_benchmark_message`). Sign and send with your own custody.

## State without a database

Olive already keeps everything a maker would otherwise persist: it refuses a
reused nonce or quote id per maker, settles each fill exactly once on chain,
and keeps the durable event log (`event.ack`, `event.recover`) server-side. So
the SDK keeps no state across restarts, and a bot rebuilds from Olive:

- `on_sync(snapshot)` (optional: `maker.book` keeps every active position) runs on start and after every reconnect,
  before any lifecycle event or RFQ of that connection reaches maker code;
  events arriving meanwhile are held. The SDK subscribes first, then pages
  `maker.positions` (active) to the end and reads `maker.capacity`.
  `OliveSnapshot` has `slot`, `positions` (a mapping keyed by position
  address), `capacity`, `connection_id` and `sequence`. A sync that fails, or
  an `on_sync` that raises, is retried with backoff (`sync_failed` status);
  nothing is dispatched until one succeeds. RFQs wait for the sync until their
  auction ends (`rfq_skipped` otherwise).
- `snapshot.slot` is a lower bound: every finalized change at or below it is in
  the snapshot (later ones may be too). `snapshot_covers(maker.snapshot, event)`
  is true for a finalized chain event (it has `transactionSignature`) at or
  below that slot: skip it. Anything else must be applied.
- Delivery is at least once. An event is acknowledged after its handler
  returns; a handler that raises is retried and the event stays
  unacknowledged, and Olive redelivers what it did not see acknowledged (after
  a crash, a reconnect or `event.recover`). Write handlers state-driven and
  idempotent: key the book by position or bucket, set state rather than add to
  it, and make external side effects (hedges, transfers) target-based rather
  than incremental. A bounded memory of recently handled event ids
  acknowledges a redelivery within one process without dispatching it again.
- `maker.position(address)` (and `client.position(address)`) reads one of the
  maker's positions, in any status, through the `maker.position` method.
- Quote ids are random; nonces count up from a random 63-bit start per process
  and quote kind. A signed quote is remembered in memory, bounded and
  forgotten once its RFQ is final or expired, so the same RFQ offered again
  after a reconnect gets the exact same signed quote. After a restart,
  in-flight quotes are simply dropped: they expire.
- Every in-process map is bounded (`MANAGED_MAKER_LIMITS`, overridable with
  `ManagedMakerOptions.limits`); the event buffer holds `event_queue_capacity`
  events and leaves the rest unacknowledged for redelivery (`event_dropped`).

## Remote and HSM signing

The bot key file that `from_env` reads (`OLIVE_QUOTE_SIGNER_KEYPAIR`) is for laptop tests or a tightly controlled
key-file deployment. Production remote/HSM integrations pass `ManagedMaker.from_env(signer=...)` a
`CallbackMakerSigner` and never give private-key bytes to the maker process:

```python
from olive_solana import CallbackMakerSigner, SignerContext


async def sign_with_remote_provider(
    digest: bytes,
    context: SignerContext,
) -> bytes:
    if len(digest) != 32:
        raise ValueError("expected a 32-byte Olive digest")
    signature = await remote_provider.sign_ed25519(
        key_id="olive-quote-signer",
        message=digest,
        audit_context={"purpose": context.purpose},
    )
    if len(signature) != 64:
        raise ValueError("expected a 64-byte Ed25519 signature")
    return signature


signer = CallbackMakerSigner(
    public_key="<remote Ed25519 public key in canonical base58>",
    callback=sign_with_remote_provider,
)
```

The callback signs the supplied raw 32-byte `SHA-256(preimage)` digest with
ordinary Ed25519 and returns the raw 64-byte signature. Do not hash again, sign
encoded text/the full preimage, or use Ed25519ph. The remote public key may be
the finalized on-chain `MakerConfig.quoteSigner`.

An empty topic or product collection subscribes to everything authorized by
`maker.hello`. Keep `takeover_existing_session=False` for ordinary starts; enable
it only for an intentional failover.

After a silent network drop the gateway may still hold your session. Since 3.0 the
reconnect names the lost connection (`previousConnectionId` in `auth.respond`) and
the gateway replaces that session at once; a second instance of your maker cannot
name it and is still refused. If a reconnect is refused anyway, a client that had a
session of its own reports `session_release_wait` through `on_status` and retries,
at most about 2 seconds apart, for the lease plus 30 seconds from the moment it lost
the connection; refusals past that, or on a first connect, still raise.
`ManagedMaker` does not retry a sync while disconnected: `sync_failed` is a failure
on a live connection.

## One session per asset

Since 1.3.0 every session owns an explicit set of assets: `assets` (1-32 unique
lowercase topics) is required on `ManagedMakerOptions`, `MakerWsClientOptions` and
`MakerDoctorOptions` (`--assets` / `OLIVE_ASSETS` on the CLI), and `topics` must be a
subset. List every asset to quote them all in one session. There is no all-assets
mode. The gateway fixes the claim for the session's whole life: `maker.hello.assets`
lists it, and the client fails terminally if it differs from the request.

- Run one process per asset if you like; each session syncs and acknowledges its own
  assets' events (maker-wide events go to every session).
- `SESSION_ACTIVE` names the sessions in the way in `conflicting_assets` on the
  `MakerWsRpcFailure` (the terminal error's `__cause__`) and in the
  `session_release_wait` status. `takeover_existing_session=True` replaces every
  overlapping session (close 4001), only for the assets you claim. The restart wait of
  1.2.1 works per asset session.
- Credit, funding and the routing pause stay maker-wide: `maker.capacity` shows
  `ledger.reservedCredit` across all sessions, while its other rows and
  `maker.positions` are filtered to the session's assets.
- Stagger process starts by about one second. `FORBIDDEN` with `session_limit`
  reports `maxSessionsPerMaker`; `asset_not_claimed` means the session does not own
  the topic it used.

## Onboard from a server

If your signing key lives on a server, one command takes a maker from Olive's invitation to a passing readiness
check. Ask Olive's console for a server token (`olv_onb_...`), then:

```bash
export OLIVE_ONBOARDING_TOKEN=olv_onb_...   # or --token; the env var keeps it out of shell history
olive-maker-onboard --keypair ./maker.json --assets btc,sol
```

It proves the key is yours (it signs the plain-text ownership message Olive writes, with `--keypair` as the maker
authority; nothing is a transaction), waits for Olive's approval (Olive registers the maker with a temporary maker
authority and proposes yours; on devnet it also sends 0.05 SOL to your maker authority and funding wallet for fees),
then, in the page's Activate stage, takes the account over (`accept_maker_authority`), deposits into your margin account
(`deposit_pool`, signed by the funding wallet) and approves exits (below): with one key that is **one transaction**, in
that order (the take-over first, so nothing can be deposited while Olive's temporary key still owns the account;
`--two-step` signs the take-over alone first, and separate keys always do). Olive sends devnet test funds when it approves
the maker, so there is no faucet step. **There is no default deposit amount.**
Without `--pool-deposit <usdc>` the command shows a suggestion with its reason (keep the smaller of 25,000 USDC and a tenth of
the wallet for exits, deposit the rest; on a wallet of 100,000 USDC or more you must type the amount, below that Enter
accepts the suggestion), and `--yes` refuses to run without `--pool-deposit`. The prompt names where the money can come back to
(only your maker authority wallet, and not while you are in default, owe a settlement or are short at a funding deadline).
`--pool-deposit` only fills an empty margin account: a rerun of the same command leaves a funded margin account alone (it also reads the margin account
on chain, so a deposit Olive's page has not caught up with yet is seen), and `--add-to-pool` lets it add, after a prompt that
shows the current balance and the new total. It sets the buyback allowance on the funding wallet's USDC account with an SPL
`approve` of the exit delegate (what exits may pay from the funding wallet, not from the margin account; default 25,000 USDC or the wallet's balance when
lower, or `--exit-approval <usdc>`; an amount above the wallet's USDC is warned about), and writes `olive.env` (0600, in `--out`, default the current directory) and the starter maker `olive_maker.py` (never over an existing
file). Start it (`python olive_maker.py`; it reads `olive.env` itself and answers Olive's test request) and the page shows each
asset connected, its first quote, then Ready. The readiness check is optional: `--doctor` runs it and sends its report to Olive.
The command never deposits before the takeover (it prints "Take over your maker account first"): until then Olive's temporary key could withdraw the margin account.
Rerun it at any time: it reads the current state and continues. Before signing anything it checks the message text binds
exactly your values (environment, program, maker config and id, the four roles, assets, not expired), and it checks every
transaction semantically: exactly one `deposit_pool` of the amount from your funding wallet's USDC account into your
maker's margin account, or Olive's `accept_maker_authority` for your maker,
the signing key the only signer and fee payer (the buyback allowance is built by the command itself, not taken from the server). Compute
Budget instructions and a different blockhash are fine; anything else is refused and named. Other flags: `--api-url`,
`--products 0,1`, `--yes` (skip the confirmation prompts, not the checks; it needs `--pool-deposit`), `--two-step`, `--doctor`, `--add-to-pool`, `--rpc-url`,
`--wait-timeout <seconds>`, `--json` (one JSON object per line).

By default one key does everything. Two options change where money and quoting authority sit:

- `--premium-receiver-owner <wallet>` pays premiums to that wallet's USDC account (any address, including an
  off-curve vault or multisig address). Olive creates the account when it registers the maker, the wallet never
  signs, and the message you sign names that wallet. Left out, premiums go to the funding
  authority.
- Separate keys: `--keypair` stays the **maker authority** and signs the registration. Name the others with
  `--quote-signer <pubkey>` (the hot key on the quoting server) and `--funding-authority <pubkey>` (the custody
  wallet that holds the funds); the command checks the message names exactly those (with `--quote-signer-keypair` / `--funding-keypair` it also adds their signatures as extra proof). It cannot sign for keys it
  does not hold, so in separate mode:
  - the margin account deposit is skipped with a pointer to the console's "Sign with another tool" panel, unless you pass
    `--funding-keypair <path>` (it must match the funding wallet; refused otherwise); the buyback allowance, also signed
    by the funding wallet, waits for it;
  - the readiness check runs only with `--quote-signer-keypair <path>` (it must match the bot key); otherwise
    the command prints what to run on the quoting server;
  - `olive.env` names `--quote-signer-keypair` as `OLIVE_QUOTE_SIGNER_KEYPAIR` when given; otherwise it keeps
    Olive's placeholder, which you point at the hot key file. It never names the maker authority key in separate mode;
  - the test funds Olive sends at approval go to the funding wallet, so they arrive without its key.

```bash
olive-maker-onboard --keypair ./authority.json --assets btc,sol \
  --quote-signer <HOT_PUBKEY> --funding-authority <VAULT_PUBKEY> --premium-receiver-owner <VAULT_PUBKEY>
```

Rerunning a separate-key maker needs the same `--quote-signer` / `--funding-authority` flags.

## Operational preflight

The doctor authenticates without subscribing, taking over a live maker, or
submitting quotes. Its convenience CLI intentionally uses a local test keypair:

```bash
olive-maker-doctor --topics btc,sol --products 0,1

olive-maker-doctor --json > maker-doctor.json
```

Connection and deployment values default from `OLIVE_MAKER_WS_URL`,
`OLIVE_MAKER_CONFIG`, `OLIVE_PROGRAM_ID`, `OLIVE_GENESIS_HASH`,
`OLIVE_CHAIN_TAG`, and `OLIVE_QUOTE_SIGNER_KEYPAIR`. `--exit-approval-target <usdc>` (default 25000) is the buyback allowance you
intend to keep, and `--rpc-url` lets the doctor read the funding wallet's USDC.

By default the doctor reads `maker.capacity` with scope `all` and reports
`credit` (limit, unfunded exposure and available credit; fail when breached,
warning with no credit), `pool` (fail while defaulted, warning with what to
deposit before the next funding deadline), `insurance` (fail while insurance
draws are unrepaid), `bucket_margin` (requirement, collateral and unfunded per
open bucket; fail when a bucket is unfunded past its funding deadline),
`routing` (fail while Olive has paused new entry requests) and `true_up` (fail
while a settled bucket owes a true-up). The same checks are available as
`margin_checks(capacity, now_seconds)`. `capacity_state` fails while the maker is frozen
(`underfundedPastDeadline`) and `exit_approval` reports what the funding wallet has approved to the exit
delegate (fail at 0 and below half of the target, warning below the target). `exit_wallet` compares the funding
wallet's USDC with the buyback allowance (fail below the buyback allowance, warning below the target or when not checked). Pass `--exit-only`
(`exit_only=True`) to read only the ledger scope.

Remote/HSM deployments MUST NOT export their private key to use the CLI. Call
`run_maker_doctor(MakerDoctorOptions(..., signer=signer, assets=["btc"]))` programmatically with
the same `CallbackMakerSigner` used by the managed maker, and retain
`report.to_dict()` as the machine-readable preflight record.

## Strike grid

Entry strikes sit on a fixed grid of round levels per market. The step depends
on how far the strike is from the request's reference price (`referencePriceE8`):

| Market | within 5% | 5% to 15% | beyond 15% |
| --- | --- | --- | --- |
| BTC | $1,000 | $2,000 | $5,000 |
| SOL | $1 | $2 | $5 |
| GOLD / PAXG | $25 | $50 | $100 |
| SP500 / SPYX | $5 | $10 | $10 (one tier beyond 5%) |

- Symbols are case-insensitive, and both the app names (GOLD, SP500) and the
  on-chain names (PAXG, SPYX) work.
- **Re-striking.** If the price falls so far that the finest step is wider than
  5% of spot, every step is divided by ten until it fits: SOL at $15 strikes on
  $0.10 / $0.20 / $0.50, BTC at $15,000 on $100 / $200 / $500.
- **Boundary tolerance.** Near a tier boundary the API accepts a strike that is
  a multiple of either neighbouring tier's step, within 1 percentage point
  (`STRIKE_GRID_SLACK_PCT`). With BTC at $98,000, $103,000 is 5.1% out, where
  the step is $2,000, but it is accepted because it is within 1 point of the
  $1,000 tier.
- A market without a configured grid gets one tier with a 1-2-5 step near 0.1%
  of spot.

The API rejects an off-grid entry RFQ (`INVALID_STRIKE`, reason
`strike_off_grid`), so a maker should never receive one. The SDK lets you
check anyway, with exact decimal arithmetic (no binary floats):

```python
from olive_solana import (
    STRIKE_GRID_SLACK_PCT, entry_strike_grid_warning, grid_levels, grid_step,
    nearest_strike, on_grid, strike_grid_for,
)

grid = strike_grid_for("BTC", "112480")          # tuple of StrikeTier(within_pct, step)
on_grid(grid, "112480", "113000", STRIKE_GRID_SLACK_PCT)   # True
grid_step(grid, "112480", "130000")              # Decimal('5000')
nearest_strike(grid, 0, "112480")                # Decimal('113000'), call side
nearest_strike(grid, 1, "112480")                # Decimal('112000'), put side
grid_levels(grid, "112480", "100000", "125000")  # every level in the range

entry_strike_grid_warning(rfq.request)           # None when on grid, else a message
```

Prices can be `Decimal`, `int`, `str`, `Fraction` or (converted through their
decimal text) `float`. `entry_strike_grid_warning` reads `underlyingSymbol`,
`legs[*].strikeE8` and `referencePriceE8` and judges each strike exactly as the API does.
The grid matches the TypeScript SDK (the one the app and the API use); the repository's
`packages/solana-sdk/test/vectors/strike-grid.golden.json` holds the shared test vectors.

Conformance runs can pass the entry requests they received as
`run_maker_conformance_suite(..., observed_entry_requests=[...])`. Any off-grid
request is listed as a `[WARN]` line under `entry_quoting`; it does not fail
the report, because it points at a gateway or fixture problem, not at the
maker.

## Wire types and schema

The complete required/optional DTO catalog is published at
[`maker-websocket.schema.json`](https://olive.ag/docs/schemas/maker-websocket.schema.json)
and included in the wheel at
`olive_solana/schemas/maker-websocket.schema.json`. Python exports typed RFQ,
quote, selected, filled, decline, and error/result shapes from `olive_solana`.
Missing required fields fail before maker callbacks run; unknown additive server
fields are preserved, while outbound quotes remain strict. The normative
semantics and signing preimages are in the
[maker WebSocket specification](https://olive.ag/docs/developers/maker-websocket-spec) (protocol 4.0).

## Reliability contract

- Wire integers remain canonical decimal strings.
- Olive refuses a reused entry nonce or quote id per maker; exit quotes have no wire
  nonce and are replay-safe because each binds the position's `remainingBps`, which the
  exit changes.
- A reconnect resends only the exact signed quote the process already submitted.
- Durable events are acknowledged after their handler returns; a lifecycle event
  carrying one of this maker's quotes must match the quote this process submitted
  or verify against the bot key, otherwise it is neither dispatched nor
  acknowledged (`event_rejected`).
- `quote.selected` and `exit_quote.selected` are not fills. Only finalized
  `trade.filled` and `exit.filled` are canonical execution signals.
- Signatures and private signer data are never included in SDK status events.

Releases must pass the shared fixture/digest suite and all fail-closed maker conformance scenarios.

## Advanced: raw events

Most makers never need this section: the alerts above already cover every money event. It is for desks that want to
react to the raw lifecycle events themselves, or run their own orchestration.

`ManagedMakerEventHandlers` has a handler per event, besides `on_entry_filled` and `on_exit_filled`:
`on_funding_required`, `on_true_up_pending`, `on_credit_breached`, `on_routing_paused`, `on_routing_resumed`,
`on_bucket_funded`, `on_bucket_refreshed`, `on_bucket_settled`, `on_pool_deposited`, `on_pool_withdrawn`,
`on_insurance_drawn`, `on_credit_limit_changed`, `on_maker_status_changed`, `on_maker_default_cleared`,
`on_market_updated`, `on_bucket_handed_over`, `on_settlement`, `on_position_redeemed`, `on_position_expired`,
`on_entry_selected`, `on_exit_selected` (selection is feedback, not a fill), the two `..._expired_unfilled`, and a
catch-all `on_event`. `on_sync(snapshot)` runs on start and after every reconnect, before anything else of that
connection (see "State without a database"); `maker.book` keeps every active position without it.

`bucket.funding_required` and `bucket.true_up_pending` put money at risk: Olive refuses the maker new entries while one
is unacknowledged. The SDK acknowledges them for you when `on_alert` or `funding` is set (the alert is how they are
handled). Without either (a 3.1 bot) they need `on_funding_required` / `on_true_up_pending` or the generic `on_event`;
otherwise the event is never acknowledged. Every money event is also logged with its amounts before any handler runs.

Two off-chain events report the routing gate:

| Event | Handler | Gates entries | Meaning |
| --- | --- | --- | --- |
| `maker.routing_paused` | `on_routing_paused` | no (the pause itself stops new entry requests) | A maturity is short past its funding deadline (`reason` `"funding_shortfall"`), a bucket is Pending with a gap (`"settlement_shortfall"`) or the maker is deactivated on chain (`"maker_inactive"`); an open set, treat an unknown reason as paused, with `bucket`, `marketConfig`, `maturity`, `deficit` and `pausedAt`. Sent again if the reason changes. |
| `maker.routing_resumed` | `on_routing_resumed` | no | None of the buckets qualifies any more: covered or settled Solvent (`resumedAt`). Never sent for a pause whose paused notification you did not receive. |

Their typed payloads are `RoutingPaused` and `RoutingResumed`.

Finalized chain events report hand-overs and parameter changes:

| Event | Handler | Gates entries | Meaning |
| --- | --- | --- | --- |
| `bucket.handed_over` | `on_bucket_handed_over` | no | Governance handed a defaulted maker's Unsettled bucket to a substitute (`previousMaker`, `newMaker`, `priceE8`, `releasedToPrevious`); sent to both makers. |
| `credit.limit_changed` | `on_credit_limit_changed` | no | Governance set the maker's `creditLimit`; refresh cached credit headroom. |
| `maker.status_changed` | `on_maker_status_changed` | no | The maker's on-chain `active` flag changed (`clearedDefault` when a reactivation cleared a default). |
| `market.updated` | `on_market_updated` | no | A market's controls, risk parameters and `minLegNotional` were replaced; refresh cached `maker.capacity`. |

All four carry `transactionSignature`, `slot` and `occurredAtMs`; `bucket.handed_over` also carries the bucket state
fields. Their typed payloads are `BucketHandedOver`, `CreditLimitChanged`, `MakerStatusChanged` and `MarketUpdated`.

To deposit from your own code instead of a `FundingSigner`, build `deposit_pool_instruction` for the alert's `amount`
(or `maker_balance(capacity).needed_by_next_deadline`), sign it with the wallet the USDC comes from, and send it.
`capacity_max_size(capacity, ...)` sizes a quote against your credit and free margin if you want your own limit on
top of Olive's checks. `on_status` reports every SDK status (`funding_warning`, `routing_paused`, `exit_declined`,
`unquoted_exit`, `quote_rejected`, ...).

The low-level `MakerWsClient` remains available for makers requiring custom orchestration: it speaks the wire protocol
and leaves quoting state, acknowledgement and recovery to you.

## Development

```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/ruff check .
.venv/bin/mypy src
.venv/bin/pytest
.venv/bin/python -m build
```

## Release

PyPI releases use trusted publishing from
`.github/workflows/publish-python-sdk.yml`; no long-lived API token is stored in
GitHub. Configure the PyPI publisher for repository `macols77/olive`, workflow
`publish-python-sdk.yml`, and environment `pypi`, and protect the `pypi`
environment with required reviewers.

Pushing a `python-sdk-v<pyproject version>` tag only builds and verifies; it
never uploads. The build job:

- requires the tag's commit to be on `origin/main` (merge first, then tag the
  merged commit);
- when a `coordinated_tag` (`solana-sdk-v*`) is given, requires it to point at
  the same commit, and otherwise warns with the TypeScript tags found on that
  commit;
- requires the SDK's program id to equal the program's `declare_id!` and to be
  live (executable) on devnet;
- requires the tag to equal `python-sdk-v<pyproject version>`;
- runs ruff, mypy and pytest, builds and `twine check`s the wheel and sdist,
  and reruns the tests against the built wheel in a clean environment.

To publish, run the workflow manually (**Run workflow**) with `release_tag` set
to the existing tag, `publish` typed as `PUBLISH`, and `coordinated_tag` set to
the sibling TypeScript tag for a coordinated release. The publish job then waits
for the `pypi` environment's reviewers and uploads both distributions. The same
dispatch retries a failed release.

