From acb8ca464bebd0e41c43ebdba115f6be01cadcc7 Mon Sep 17 00:00:00 2001 From: Evan Witulski Date: Wed, 10 Jun 2026 02:33:44 -0500 Subject: [PATCH 1/2] [SO-163] Permissionless orgs: per-org caps, buckets, fees + org-aware stack - contracts: org.move (Org shared object as its own treasury + transferable OrgCap), create_bucket/gates/cleanup gated by OrgCap, AdminCap keeps protocol fee/pause/treasury + gate overrides, two-level fee split, execute_write split into writer/trader entry points that return the executor side ((Position, Coin) / Coin); cleanup and treasury/org withdraw return instead of transfer - indexer: orgs table, buckets.org_id, 4 new events, org GraphQL queries + orgIds bucket filter; flow-based recipient derivation - token-info: verified_orgs allowlist (JWT-gated) + VerifiedOrgsWatcher in token-info-client (fail-closed boot, keep-last-good refresh) - api-service: /buckets verified-only + org-keyed series, GET /orgs - quoting: bucket_not_verified at WS edge, bulk view, and reservation time - option-scheduler: OrgCap auth (on-chain validated at boot), org-scoped roll reconciliation; deployment-manager creates the platform org and records platformOrgId/platformOrgCapId - sui-tx: split execute_write PTBs with TransferObjects of returns, org.rs builders, gas-station templates updated + shape tests - frontend: split-fn composer PTBs, org/platform admin consoles, permissionless create-org, verified-orgs manager - docs: .claude/ptb-sync.md (frontend<->gas-station template invariant), org-aware redeploy checklist in deployment.md Co-Authored-By: Claude Fable 5 --- .claude/CLAUDE.md | 8 + .claude/ptb-sync.md | 50 ++ contracts/sources/admin.move | 19 +- contracts/sources/bucket.move | 409 +++++++++----- contracts/sources/errors.move | 12 +- contracts/sources/events.move | 114 +++- contracts/sources/org.move | 110 ++++ contracts/sources/treasury.move | 8 +- contracts/tests/admin_tests.move | 42 +- contracts/tests/bucket_tests.move | 531 ++++++++++++++---- contracts/tests/e2e_tests.move | 207 ++++--- contracts/tests/org_tests.move | 198 +++++++ contracts/tests/test_helpers.move | 102 +++- contracts/tests/treasury_tests.move | 15 +- frontend/src/api/client.ts | 27 + frontend/src/api/orgAdmin.ts | 78 +++ frontend/src/api/useAdminCap.ts | 41 ++ frontend/src/api/useVerifiedOrgs.ts | 17 + frontend/src/components/Header.tsx | 8 +- frontend/src/components/OrgManager.tsx | 219 ++++++++ frontend/src/config.ts | 5 + frontend/src/screens/Admin.tsx | 430 ++++++++++++-- frontend/src/state/composer.ts | 2 + frontend/src/tx/admin.ts | 65 ++- frontend/src/tx/composer.ts | 100 ++-- frontend/src/tx/org.ts | 167 ++++++ frontend/tsconfig.tsbuildinfo | 2 +- rust-backend/Cargo.lock | 2 + rust-backend/crates/deployments/src/lib.rs | 32 +- .../crates/indexer-graphql/src/lib.rs | 89 ++- .../crates/protocol-types/src/events.rs | 150 ++++- rust-backend/crates/sui-tx/src/tx/admin.rs | 125 ++++- rust-backend/crates/sui-tx/src/tx/coin_pkg.rs | 10 +- .../crates/sui-tx/src/tx/execute_write.rs | 147 +++-- rust-backend/crates/sui-tx/src/tx/mod.rs | 1 + rust-backend/crates/sui-tx/src/tx/org.rs | 196 +++++++ rust-backend/crates/sui-tx/src/tx/template.rs | 78 ++- .../crates/token-info-client/Cargo.toml | 1 + .../crates/token-info-client/src/lib.rs | 159 ++++++ rust-backend/deployment.md | 30 + .../services/api-service/src/bucket.rs | 2 + .../api-service/src/handlers/buckets.rs | 86 ++- .../src/handlers/call_token_lots.rs | 2 +- .../api-service/src/handlers/events.rs | 15 +- .../services/api-service/src/handlers/mod.rs | 1 + .../services/api-service/src/handlers/orgs.rs | 38 ++ rust-backend/services/api-service/src/main.rs | 17 +- .../services/api-service/src/router.rs | 1 + .../services/api-service/src/state.rs | 11 +- .../src/db/migrations/000005_orgs/down.sql | 3 + .../src/db/migrations/000005_orgs/up.sql | 17 + .../services/indexer/src/db/models.rs | 52 +- rust-backend/services/indexer/src/db/repo.rs | 56 +- .../services/indexer/src/db/schema.rs | 12 + .../services/indexer/src/event_types.rs | 47 +- rust-backend/services/indexer/src/graphql.rs | 53 +- .../services/indexer/src/store/mod.rs | 138 ++++- rust-backend/services/indexer/src/worker.rs | 2 + .../services/option-scheduler/src/config.rs | 13 + .../services/option-scheduler/src/lib.rs | 8 +- .../services/option-scheduler/src/main.rs | 127 ++++- .../services/option-scheduler/src/roller.rs | 6 +- .../services/quoting-service/src/main.rs | 10 + .../quoting-service/src/rfq/bulk_view.rs | 3 + .../services/quoting-service/src/rfq/mod.rs | 48 +- .../services/quoting-service/src/state/mod.rs | 17 +- .../services/quoting-service/src/ws/retail.rs | 27 +- .../migrations/000002_verified_orgs/down.sql | 1 + .../db/migrations/000002_verified_orgs/up.sql | 18 + .../services/token-info/src/db/models.rs | 36 +- .../services/token-info/src/db/repo.rs | 53 +- .../services/token-info/src/db/schema.rs | 12 +- .../services/token-info/src/handlers/mod.rs | 1 + .../services/token-info/src/handlers/orgs.rs | 121 ++++ .../services/token-info/src/router.rs | 12 +- rust-backend/tests/Cargo.toml | 1 + rust-backend/tests/src/lib.rs | 8 + .../tools/deployment-manager/src/deploy.rs | 112 ++++ .../deployment-manager/src/json_store.rs | 8 + .../tools/deployment-manager/src/lib.rs | 14 +- .../tools/deployment-manager/src/main.rs | 28 +- rust-backend/tools/exchange/src/main.rs | 4 +- rust-backend/tools/trader/src/main.rs | 31 +- rust-backend/tools/writer/src/main.rs | 30 +- 84 files changed, 4519 insertions(+), 789 deletions(-) create mode 100644 .claude/ptb-sync.md create mode 100644 contracts/sources/org.move create mode 100644 contracts/tests/org_tests.move create mode 100644 frontend/src/api/orgAdmin.ts create mode 100644 frontend/src/api/useVerifiedOrgs.ts create mode 100644 frontend/src/components/OrgManager.tsx create mode 100644 frontend/src/tx/org.ts create mode 100644 rust-backend/crates/sui-tx/src/tx/org.rs create mode 100644 rust-backend/services/api-service/src/handlers/orgs.rs create mode 100644 rust-backend/services/indexer/src/db/migrations/000005_orgs/down.sql create mode 100644 rust-backend/services/indexer/src/db/migrations/000005_orgs/up.sql create mode 100644 rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/down.sql create mode 100644 rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/up.sql create mode 100644 rust-backend/services/token-info/src/handlers/orgs.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index daced9bd..ec892012 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,6 +4,14 @@ Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-s **Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. +## Project invariants + +- **PTB sync**: any change to a frontend PTB builder for a gas-sponsored flow + (`frontend/src/tx/{composer,dashboard,faucet,deepbook}.ts`) MUST update the + matching gas-station template + shape test in + `rust-backend/crates/sui-tx/src/tx/template.rs` in the same PR — see + [.claude/ptb-sync.md](ptb-sync.md). + ## 1. Think Before Coding **Don't assume. Don't hide confusion. Surface tradeoffs.** diff --git a/.claude/ptb-sync.md b/.claude/ptb-sync.md new file mode 100644 index 00000000..6f198e5a --- /dev/null +++ b/.claude/ptb-sync.md @@ -0,0 +1,50 @@ +# PTB sync invariant: frontend ↔ gas-station templates + +**Rule: any change to a frontend PTB builder for a sponsored flow MUST update +the matching gas-station template AND its shape unit test in the same PR.** + +The gas-station only sponsors PTBs that structurally match one of its +templates (`rust-backend/crates/sui-tx/src/tx/template.rs`). If the frontend +ships a new PTB shape without a matching template update, sponsorship +**silently refuses every transaction of that flow** — no chain error, the +user just can't transact. The two sides are deployed independently, so this +drift is invisible in single-service testing. + +## Builder ↔ template map + +| Frontend builder (`frontend/src/tx/`) | Template (`template.rs` name) | Move targets (required, in order) | Command shape | +|---|---|---|---| +| `composer.ts buildWriteTx` | `write` | `quote::new_quote` → `quote::new_signed_quote` → `bucket::execute_write_writer_flow` (3 type args) | `coinWithBalance` prelude (SplitCoins/MergeCoins) + trailing `TransferObjects` of the returned `(Position, Coin)` | +| `composer.ts buildBuyTx` | `buy` | `quote::new_quote` → `quote::new_signed_quote` → `bucket::execute_write_trader_flow` (3 type args) | `coinWithBalance` prelude + trailing `TransferObjects` of the returned `Coin` | +| `dashboard.ts buildExerciseTx` | `exercise` | `bucket::exercise` (3 type args) | `coinWithBalance` ×2 + `TransferObjects` of the returned underlying | +| `dashboard.ts buildRedeemTx` | `redeem` | `bucket::redeem_position` (3 type args) | `TransferObjects` of the two returned coins | +| `faucet.ts` mint | `faucet_mint:` | `::::mint_to_sender` | dev/staging only | +| `deepbook.ts buildCreateVenueTx` | `deepbook_create_pool` | `pool::create_permissionless_pool` (2 type args) | `coinWithBalance` DEEP-fee prelude | +| `deepbook.ts` BM create | `deepbook_bm_create` | `balance_manager::new` → `register_balance_manager` → `0x2::transfer::public_share_object` | | +| `deepbook.ts` limit/market order | `deepbook_place_limit` / `deepbook_place_market` | optional `balance_manager::deposit`, `generate_proof_as_owner` → `pool::place_*_order` (2 type args) | | +| `deepbook.ts` cancels | `deepbook_cancel_order` / `deepbook_cancel_all` | proof → `pool::cancel_order` / `cancel_all_orders` | | +| `deepbook.ts` withdraw | `deepbook_withdraw` | proof → `pool::withdraw_settled_amounts` → `balance_manager::withdraw_all` ×2 | trailing `TransferObjects` | + +Notes: +- Templates match (a) a **closed set** of allowed Move-call targets, (b) + **type-arg arity** on anchor calls, and (c) the required targets as an + **ordered subsequence**. Non-Move-call commands (`SplitCoins`, + `MergeCoins`, `TransferObjects`, `MakeMoveVec`) are treated as benign — + adding/removing those does NOT need a template change; adding/removing/ + renaming a **Move call** DOES. +- The Rust reference builders in `crates/sui-tx/src/tx/execute_write.rs` + build the same write/buy shapes for `tools/writer` / `tools/trader` — keep + them in sync too (they're the executable documentation of the shape). +- Admin/org PTBs (`tx/admin.ts`, `tx/org.ts`) are NOT sponsored — they're + signed by cap-holders who pay their own gas — so they have no templates. + +## How to verify + +1. `cargo test -p sui-tx` — the template tests in `template.rs` construct + the exact PTB shapes the frontend emits and assert they match (e.g. + `write_flow_with_trailing_transfer_objects_matches`). Add/extend a test + whenever a shape changes. +2. Smoke a sponsored tx on staging after deploying both sides: a write from + the Earn screen with gas-station sponsorship enabled. A template mismatch + shows up as the gas-station refusing sponsorship (check its logs for the + "no template matched" line). diff --git a/contracts/sources/admin.move b/contracts/sources/admin.move index 04e29f93..e41918c0 100644 --- a/contracts/sources/admin.move +++ b/contracts/sources/admin.move @@ -11,8 +11,13 @@ public struct AdminCap has key, store { public struct ProtocolConfig has key { id: UID, + /// Protocol-level skim (basis points of gross premium), routed to the + /// global Treasury on every write. Orgs charge their own fee on top. fee_bps: u64, protocol_id: vector, + /// Emergency brake: blocks new writes across ALL orgs' buckets. + /// Exercises, redeems, burns, and cleanups are never blocked. + paused: bool, } fun init(ctx: &mut TxContext) { @@ -22,19 +27,27 @@ fun init(ctx: &mut TxContext) { id: object::new(ctx), fee_bps: 0, protocol_id, + paused: false, }; transfer::public_transfer(admin_cap, ctx.sender()); transfer::share_object(config); } -public fun set_fee_bps(_: &AdminCap, config: &mut ProtocolConfig, new_bps: u64) { +public fun set_protocol_fee_bps(_: &AdminCap, config: &mut ProtocolConfig, new_bps: u64) { assert!(new_bps <= MAX_FEE_BPS, errors::fee_too_high()); let old_bps = config.fee_bps; config.fee_bps = new_bps; - events::emit_fee_updated(old_bps, new_bps); + events::emit_protocol_fee_updated(old_bps, new_bps); } -public fun fee_bps(config: &ProtocolConfig): u64 { config.fee_bps } +public fun set_pause(_: &AdminCap, config: &mut ProtocolConfig, paused: bool, ctx: &TxContext) { + config.paused = paused; + events::emit_protocol_pause_set(paused, ctx.sender()); +} + +public fun protocol_fee_bps(config: &ProtocolConfig): u64 { config.fee_bps } + +public fun is_paused(config: &ProtocolConfig): bool { config.paused } public fun protocol_id(config: &ProtocolConfig): &vector { &config.protocol_id } diff --git a/contracts/sources/bucket.move b/contracts/sources/bucket.move index 55bb8a70..f95608f2 100644 --- a/contracts/sources/bucket.move +++ b/contracts/sources/bucket.move @@ -9,6 +9,7 @@ use options_protocol::account::{Self, Account}; use options_protocol::admin::{Self, AdminCap, ProtocolConfig}; use options_protocol::errors; use options_protocol::events; +use options_protocol::org::{Self, Org, OrgCap}; use options_protocol::position::{Self, Position}; use options_protocol::quote::{Self, Quote, SignedQuote}; use options_protocol::treasury::{Self, Treasury}; @@ -22,6 +23,9 @@ use options_protocol::treasury::{Self, Treasury}; /// isolation a type-system guarantee rather than a runtime `bucket_id` check. public struct Bucket has key { id: UID, + /// Org that created (and administers) this bucket. Org fees on writes + /// flow to this org; bucket lifecycle is gated by its OrgCap. + org_id: ID, asset_type: TypeName, settlement_type: TypeName, call_type: TypeName, @@ -40,9 +44,9 @@ public struct Bucket has k /// Sole mint/burn authority for the option coin. Held for the bucket's /// whole life; never exposed by reference outside this module. call_treasury: TreasuryCap, - /// Admin-controlled freeze on new writes. Exercises and redeems are - /// unaffected — invalidation only blocks `execute_write`. Toggleable - /// pre-expiry via `invalidate_bucket` / `revalidate_bucket`. + /// Freeze on new writes, toggleable pre-expiry by the bucket's OrgCap + /// or the protocol AdminCap override. Exercises and redeems are + /// unaffected — invalidation only blocks the write entry points. invalidated: bool, } @@ -52,6 +56,10 @@ public struct Bucket has k /// that on a dedicated assert for a cleaner error. const MAX_STRIKE_SCALE: u8 = 38; +/// WriteExecuted.flow discriminator values. +const FLOW_WRITER: u8 = 0; +const FLOW_TRADER: u8 = 1; + /// 10^exp for exp ∈ [0, MAX_STRIKE_SCALE]. Aborts if exp exceeds the cap /// — keeps `pow10` cheap and guarantees the result fits in u128. fun pow10(exp: u8): u128 { @@ -78,20 +86,13 @@ fun apply_strike(amount: u128, strike: u128, strike_scale: u8): u64 { ((numerator + half) / divisor) as u64 } -public enum FlowKind has copy, drop, store { - Writer, - Trader, -} - -public fun writer_flow(): FlowKind { FlowKind::Writer } - -public fun trader_flow(): FlowKind { FlowKind::Trader } - /// Create a single bucket for the (Underlying, Settlement, Call) triple, -/// taking ownership of the option coin's `TreasuryCap`. +/// taking ownership of the option coin's `TreasuryCap`. Permissionless: +/// any OrgCap holder can create buckets; the bucket is stamped with the +/// cap's org so fees and lifecycle authority route to that org. /// -/// One bucket per call (rather than the old `count` loop) because each -/// bucket needs a *distinct* `Call` coin type, and a generic function is +/// One bucket per call (rather than a `count` loop) because each bucket +/// needs a *distinct* `Call` coin type, and a generic function is /// monomorphic in its type arguments per invocation. The options-scheduler /// fans a bucket set out off-chain: it publishes one package containing N /// One-Time-Witness coin modules, then issues N `create_bucket` calls in a @@ -100,7 +101,7 @@ public fun trader_flow(): FlowKind { FlowKind::Trader } /// The cap must be fresh (zero supply) so the supply==outstanding-options /// invariant holds from genesis. public fun create_bucket( - _: &AdminCap, + cap: &OrgCap, call_treasury: TreasuryCap, expiry_ms: u64, strike: u128, @@ -112,11 +113,13 @@ public fun create_bucket( assert!(strike_scale <= MAX_STRIKE_SCALE, errors::strike_scale_too_large()); assert!(coin::total_supply(&call_treasury) == 0, errors::treasury_cap_not_fresh()); + let org_id = org::cap_org_id(cap); let asset_type = type_name::with_defining_ids(); let settlement_type = type_name::with_defining_ids(); let call_type = type_name::with_defining_ids(); let bucket = Bucket { id: object::new(ctx), + org_id, asset_type, settlement_type, call_type, @@ -133,6 +136,7 @@ public fun create_bucket( let bucket_id = object::id(&bucket); events::emit_bucket_created( bucket_id, + org_id, asset_type, settlement_type, call_type, @@ -143,147 +147,231 @@ public fun create_bucket( transfer::share_object(bucket); } -public fun execute_write( +/// Writer flow: the executor is the retail writer supplying underlying; the +/// signer is the trader MM (buyer) whose Account is debited the gross +/// premium. The MM's `Coin` is transferred by the contract to +/// `quote.signer_token_recipient` — the signed quote's routing guarantee — +/// while the writer's `(Position, net premium)` are returned to the PTB. +public fun execute_write_writer_flow( bucket: &mut Bucket, + org: &mut Org, config: &ProtocolConfig, treasury: &mut Treasury, signer_account: &mut Account, underlying_in: Coin, + signed_quote: SignedQuote, + clock: &Clock, + ctx: &mut TxContext, +): (Position, Coin) { + let q = quote::verify_and_consume_quote(signer_account, config, &signed_quote, clock); + writer_flow_with_quote( + bucket, org, config, treasury, signer_account, underlying_in, q, clock, ctx, + ) +} + +/// Trader flow: the executor is the retail trader supplying the gross +/// premium; the signer is the writer MM whose Account is debited the +/// underlying and credited the net premium. The MM's `Position` is +/// transferred by the contract to `quote.signer_token_recipient`; the +/// trader's `Coin` is returned to the PTB. +public fun execute_write_trader_flow( + bucket: &mut Bucket, + org: &mut Org, + config: &ProtocolConfig, + treasury: &mut Treasury, + signer_account: &mut Account, premium_in: Coin, - flow: FlowKind, - position_recipient: address, - call_token_recipient: address, signed_quote: SignedQuote, clock: &Clock, ctx: &mut TxContext, -) { +): Coin { let q = quote::verify_and_consume_quote(signer_account, config, &signed_quote, clock); - execute_write_with_quote( - bucket, - config, - treasury, - signer_account, - underlying_in, - premium_in, - flow, - position_recipient, - call_token_recipient, - q, - clock, - ctx, - ); + trader_flow_with_quote( + bucket, org, config, treasury, signer_account, premium_in, q, clock, ctx, + ) } #[test_only] -public fun execute_write_for_testing( +public fun execute_write_writer_flow_for_testing( bucket: &mut Bucket, + org: &mut Org, config: &ProtocolConfig, treasury: &mut Treasury, signer_account: &mut Account, underlying_in: Coin, + signed_quote: SignedQuote, + clock: &Clock, + ctx: &mut TxContext, +): (Position, Coin) { + let q = quote::verify_skip_sig(signer_account, config, &signed_quote, clock); + writer_flow_with_quote( + bucket, org, config, treasury, signer_account, underlying_in, q, clock, ctx, + ) +} + +#[test_only] +public fun execute_write_trader_flow_for_testing( + bucket: &mut Bucket, + org: &mut Org, + config: &ProtocolConfig, + treasury: &mut Treasury, + signer_account: &mut Account, premium_in: Coin, - flow: FlowKind, - position_recipient: address, - call_token_recipient: address, signed_quote: SignedQuote, clock: &Clock, ctx: &mut TxContext, -) { +): Coin { let q = quote::verify_skip_sig(signer_account, config, &signed_quote, clock); - execute_write_with_quote( - bucket, - config, - treasury, + trader_flow_with_quote( + bucket, org, config, treasury, signer_account, premium_in, q, clock, ctx, + ) +} + +/// Preconditions shared by both write flows. Pause and invalidation gate +/// writes ONLY — exercise/redeem/burn/cleanup never check them. +fun check_write_preconditions( + bucket: &Bucket, + org: &Org, + config: &ProtocolConfig, + q: &Quote, + clock: &Clock, +) { + assert!(!admin::is_paused(config), errors::protocol_paused()); + assert!(quote::bucket_id(q) == object::id(bucket), errors::quote_bucket_mismatch()); + assert!(clock.timestamp_ms() < bucket.expiry_ms, errors::bucket_expired()); + assert!(!bucket.invalidated, errors::bucket_invalidated()); + assert!(object::id(org) == bucket.org_id, errors::bucket_org_mismatch()); + assert!(quote::write_amount(q) > 0, errors::zero_amount()); +} + +/// Both fees are floored independently from gross, so rounding dust (≤ 2 +/// smallest-units) stays in net_premium — never lost, and conservation +/// holds exactly: gross == org_fee + protocol_fee + net. +fun fee_split(gross: u64, org: &Org, config: &ProtocolConfig): (u64, u64) { + let org_fee = (((gross as u128) * (org::fee_bps(org) as u128)) / 10000) as u64; + let protocol_fee = + (((gross as u128) * (admin::protocol_fee_bps(config) as u128)) / 10000) as u64; + (org_fee, protocol_fee) +} + +/// Skim both fees out of the premium balance. Zero fees skip the split so +/// no empty dynamic-field Balance is ever created on the Org/Treasury. +fun skim_fees( + premium: &mut Balance, + org: &mut Org, + treasury: &mut Treasury, + org_fee: u64, + protocol_fee: u64, +) { + if (org_fee > 0) { + org::deposit_balance(org, premium.split(org_fee)); + }; + if (protocol_fee > 0) { + treasury::deposit_balance(treasury, premium.split(protocol_fee)); + }; +} + +fun writer_flow_with_quote( + bucket: &mut Bucket, + org: &mut Org, + config: &ProtocolConfig, + treasury: &mut Treasury, + signer_account: &mut Account, + underlying_in: Coin, + q: Quote, + clock: &Clock, + ctx: &mut TxContext, +): (Position, Coin) { + check_write_preconditions(bucket, org, config, &q, clock); + let bucket_id = object::id(bucket); + + let write_amount = quote::write_amount(&q); + let gross_premium = quote::premium(&q); + let signer_recipient = quote::signer_token_recipient(&q); + assert!(underlying_in.value() == write_amount, errors::amount_mismatch()); + + let (org_fee, protocol_fee) = fee_split(gross_premium, org, config); + let net_premium = gross_premium - org_fee - protocol_fee; + + // Signer (trader MM) pays the gross premium from their Account. + let premium_coin = account::withdraw_internal( signer_account, - underlying_in, - premium_in, - flow, - position_recipient, - call_token_recipient, - q, - clock, + gross_premium, ctx, ); + let mut premium_balance = premium_coin.into_balance(); + skim_fees(&mut premium_balance, org, treasury, org_fee, protocol_fee); + let net_coin = coin::from_balance(premium_balance, ctx); + + bucket.underlying_balance.join(underlying_in.into_balance()); + + let range_start = bucket.total_written; + let range_end = range_start + (write_amount as u128); + bucket.total_written = range_end; + + let position = position::mint(bucket_id, range_start, range_end, ctx); + let position_id = object::id(&position); + + // The signer's side keeps its contract-enforced routing: the quote's + // recipient gets the option coin regardless of what the executor's PTB + // does with the returned values. + let call = coin::mint(&mut bucket.call_treasury, write_amount, ctx); + transfer::public_transfer(call, signer_recipient); + + events::emit_write_executed( + bucket_id, + bucket.org_id, + quote::signer_account_id(&q), + signer_recipient, + ctx.sender(), + position_id, + FLOW_WRITER, + write_amount, + gross_premium, + org_fee, + protocol_fee, + net_premium, + range_start, + range_end, + quote::nonce(&q), + ); + + (position, net_coin) } -#[allow(lint(self_transfer))] -fun execute_write_with_quote( +fun trader_flow_with_quote( bucket: &mut Bucket, + org: &mut Org, config: &ProtocolConfig, treasury: &mut Treasury, signer_account: &mut Account, - underlying_in: Coin, premium_in: Coin, - flow: FlowKind, - position_recipient: address, - call_token_recipient: address, q: Quote, clock: &Clock, ctx: &mut TxContext, -) { +): Coin { + check_write_preconditions(bucket, org, config, &q, clock); let bucket_id = object::id(bucket); - assert!(quote::bucket_id(&q) == bucket_id, errors::quote_bucket_mismatch()); - assert!(clock.timestamp_ms() < bucket.expiry_ms, errors::bucket_expired()); - assert!(!bucket.invalidated, errors::bucket_invalidated()); let write_amount = quote::write_amount(&q); let gross_premium = quote::premium(&q); let signer_recipient = quote::signer_token_recipient(&q); - assert!(write_amount > 0, errors::zero_amount()); - - let fee = (((gross_premium as u128) * (admin::fee_bps(config) as u128)) / 10000) as u64; - let net_premium = gross_premium - fee; - - match (flow) { - FlowKind::Writer => { - // Signer is the trader MM (the buyer of the option). - // Signer-supplied side: premium (Settlement) debited from their Account. - // Executor-supplied side: underlying matching write_amount. - assert!(signer_recipient == call_token_recipient, errors::quote_recipient_mismatch()); - assert!(premium_in.value() == 0, errors::amount_mismatch()); - assert!(underlying_in.value() == write_amount, errors::amount_mismatch()); - - let premium_coin = account::withdraw_internal( - signer_account, - gross_premium, - ctx, - ); - let mut premium_balance = premium_coin.into_balance(); - if (fee > 0) { - let fee_balance = premium_balance.split(fee); - treasury::deposit_balance(treasury, fee_balance); - }; - let net_coin = coin::from_balance(premium_balance, ctx); - transfer::public_transfer(net_coin, ctx.sender()); - - bucket.underlying_balance.join(underlying_in.into_balance()); - premium_in.destroy_zero(); - }, - FlowKind::Trader => { - // Signer is the writer MM (the seller of the option). - // Signer-supplied side: underlying debited from their Account. - // Executor-supplied side: premium matching gross_premium. - assert!(signer_recipient == position_recipient, errors::quote_recipient_mismatch()); - assert!(underlying_in.value() == 0, errors::amount_mismatch()); - assert!(premium_in.value() == gross_premium, errors::amount_mismatch()); - - let underlying_coin = account::withdraw_internal( - signer_account, - write_amount, - ctx, - ); - bucket.underlying_balance.join(underlying_coin.into_balance()); - - let mut premium_balance = premium_in.into_balance(); - if (fee > 0) { - let fee_balance = premium_balance.split(fee); - treasury::deposit_balance(treasury, fee_balance); - }; - account::deposit_balance(signer_account, premium_balance); - - underlying_in.destroy_zero(); - }, - }; + assert!(premium_in.value() == gross_premium, errors::amount_mismatch()); + + let (org_fee, protocol_fee) = fee_split(gross_premium, org, config); + let net_premium = gross_premium - org_fee - protocol_fee; + + // Signer (writer MM) provides the underlying from their Account. + let underlying_coin = account::withdraw_internal( + signer_account, + write_amount, + ctx, + ); + bucket.underlying_balance.join(underlying_coin.into_balance()); + + let mut premium_balance = premium_in.into_balance(); + skim_fees(&mut premium_balance, org, treasury, org_fee, protocol_fee); + account::deposit_balance(signer_account, premium_balance); let range_start = bucket.total_written; let range_end = range_start + (write_amount as u128); @@ -291,28 +379,29 @@ fun execute_write_with_quote( let position = position::mint(bucket_id, range_start, range_end, ctx); let position_id = object::id(&position); - transfer::public_transfer(position, position_recipient); + transfer::public_transfer(position, signer_recipient); - // Mint the option as a fungible coin from the bucket's own treasury. let call = coin::mint(&mut bucket.call_treasury, write_amount, ctx); - transfer::public_transfer(call, call_token_recipient); events::emit_write_executed( bucket_id, + bucket.org_id, quote::signer_account_id(&q), signer_recipient, ctx.sender(), position_id, - position_recipient, - call_token_recipient, + FLOW_TRADER, write_amount, gross_premium, - fee, + org_fee, + protocol_fee, net_premium, range_start, range_end, quote::nonce(&q), ); + + call } public fun exercise( @@ -420,16 +509,21 @@ public fun burn_expired_option( events::emit_expired_option_burned(bucket_id, ctx.sender(), amount); } -#[allow(lint(self_transfer))] +/// Destroy a drained, expired bucket. Gated by the bucket's OrgCap — no +/// AdminCap override, which would hand an arbitrary org's `TreasuryCap` to +/// the protocol admin. Returns the option coin's `TreasuryCap` to the PTB: +/// it can't be dropped (no `drop`), and outstanding option coins may still +/// exist (holders who never exercised or burned). public fun cleanup_bucket( - _: &AdminCap, + cap: &OrgCap, bucket: Bucket, clock: &Clock, - ctx: &mut TxContext, -) { +): TreasuryCap { + org::assert_cap(cap, bucket.org_id); assert!(clock.timestamp_ms() >= bucket.expiry_ms, errors::bucket_not_expired()); let Bucket { id, + org_id: _, asset_type: _, settlement_type: _, call_type: _, @@ -447,43 +541,86 @@ public fun cleanup_bucket( assert!(settlement_balance.value() == 0, errors::bucket_not_drained()); underlying_balance.destroy_zero(); settlement_balance.destroy_zero(); - // The TreasuryCap can't be dropped (no `drop`), and outstanding option - // coins may still exist (holders who never exercised or burned). Hand - // the cap back to the admin rather than forcing supply to zero. - transfer::public_transfer(call_treasury, ctx.sender()); let bucket_id = id.to_inner(); id.delete(); events::emit_bucket_cleaned(bucket_id); + call_treasury } public fun invalidate_bucket( + cap: &OrgCap, + bucket: &mut Bucket, + reason: vector, + clock: &Clock, + ctx: &TxContext, +) { + org::assert_cap(cap, bucket.org_id); + do_invalidate(bucket, reason, clock, ctx.sender(), false); +} + +public fun revalidate_bucket( + cap: &OrgCap, + bucket: &mut Bucket, + reason: vector, + clock: &Clock, + ctx: &TxContext, +) { + org::assert_cap(cap, bucket.org_id); + do_revalidate(bucket, reason, clock, ctx.sender(), false); +} + +/// Protocol-admin override: gate any org's bucket in an emergency. Note +/// `invalidated` is a single flag — an org can re-validate a bucket the +/// admin invalidated (and vice versa); last writer wins. +public fun admin_invalidate_bucket( _: &AdminCap, bucket: &mut Bucket, reason: vector, clock: &Clock, ctx: &TxContext, +) { + do_invalidate(bucket, reason, clock, ctx.sender(), true); +} + +public fun admin_revalidate_bucket( + _: &AdminCap, + bucket: &mut Bucket, + reason: vector, + clock: &Clock, + ctx: &TxContext, +) { + do_revalidate(bucket, reason, clock, ctx.sender(), true); +} + +fun do_invalidate( + bucket: &mut Bucket, + reason: vector, + clock: &Clock, + actor: address, + by_admin: bool, ) { let now = clock.timestamp_ms(); assert!(now < bucket.expiry_ms, errors::bucket_expired()); assert!(!bucket.invalidated, errors::bucket_invalidated()); bucket.invalidated = true; - events::emit_bucket_invalidated(object::id(bucket), now, ctx.sender(), reason); + events::emit_bucket_invalidated(object::id(bucket), now, actor, by_admin, reason); } -public fun revalidate_bucket( - _: &AdminCap, - bucket: &mut Bucket, +fun do_revalidate( + bucket: &mut Bucket, reason: vector, clock: &Clock, - ctx: &TxContext, + actor: address, + by_admin: bool, ) { let now = clock.timestamp_ms(); assert!(now < bucket.expiry_ms, errors::bucket_expired()); assert!(bucket.invalidated, errors::bucket_not_invalidated()); bucket.invalidated = false; - events::emit_bucket_revalidated(object::id(bucket), now, ctx.sender(), reason); + events::emit_bucket_revalidated(object::id(bucket), now, actor, by_admin, reason); } +public fun org_id(bucket: &Bucket): ID { bucket.org_id } public fun expiry_ms(bucket: &Bucket): u64 { bucket.expiry_ms } public fun invalidated(bucket: &Bucket): bool { bucket.invalidated } public fun strike(bucket: &Bucket): u128 { bucket.strike } diff --git a/contracts/sources/errors.move b/contracts/sources/errors.move index bd852870..d214024e 100644 --- a/contracts/sources/errors.move +++ b/contracts/sources/errors.move @@ -6,7 +6,8 @@ public fun quote_signature_invalid(): u64 { 3 } public fun quote_protocol_mismatch(): u64 { 4 } public fun quote_bucket_mismatch(): u64 { 5 } public fun quote_account_mismatch(): u64 { 6 } -public fun quote_recipient_mismatch(): u64 { 7 } +// 7 retired (quote_recipient_mismatch — recipient params removed from +// execute_write; the signer-side routing guarantee is now structural). public fun bucket_expired(): u64 { 8 } public fun bucket_not_expired(): u64 { 9 } public fun bucket_not_drained(): u64 { 10 } @@ -16,15 +17,20 @@ public fun settlement_amount_mismatch(): u64 { 13 } public fun cursor_overflow(): u64 { 14 } public fun not_owner(): u64 { 15 } public fun position_bucket_mismatch(): u64 { 16 } -public fun call_option_bucket_mismatch(): u64 { 17 } +// 17 retired (call_option_bucket_mismatch — dead since the Coin migration). public fun fee_too_high(): u64 { 18 } public fun nonce_still_valid(): u64 { 19 } public fun insufficient_treasury_balance(): u64 { 20 } public fun zero_amount(): u64 { 21 } -public fun count_must_be_positive(): u64 { 22 } +// 22 retired (count_must_be_positive — dead since create_bucket lost `count`). public fun invalid_signing_scheme(): u64 { 23 } public fun invalid_pubkey_length(): u64 { 24 } public fun strike_scale_too_large(): u64 { 25 } public fun bucket_invalidated(): u64 { 26 } public fun bucket_not_invalidated(): u64 { 27 } public fun treasury_cap_not_fresh(): u64 { 28 } +public fun protocol_paused(): u64 { 29 } +public fun org_cap_mismatch(): u64 { 30 } +public fun bucket_org_mismatch(): u64 { 31 } +public fun org_name_invalid(): u64 { 32 } +public fun insufficient_org_balance(): u64 { 33 } diff --git a/contracts/sources/events.move b/contracts/sources/events.move index fd67c8c4..9a5e5865 100644 --- a/contracts/sources/events.move +++ b/contracts/sources/events.move @@ -1,10 +1,13 @@ module options_protocol::events; +use std::string::String; use std::type_name::TypeName; use sui::event; public struct BucketCreated has copy, drop { bucket_id: ID, + /// Org the bucket belongs to (creator's OrgCap.org_id). + org_id: ID, asset_type: TypeName, settlement_type: TypeName, /// Fully-qualified type of the per-bucket option coin (`Coin`). @@ -18,15 +21,24 @@ public struct BucketCreated has copy, drop { public struct WriteExecuted has copy, drop { bucket_id: ID, + org_id: ID, signer_account_id: ID, + /// Where the contract transferred the signer's minted asset (Coin + /// in writer flow, Position in trader flow). The executor's side is + /// returned to the PTB, so its final destination is PTB-decided and not + /// recorded here — `executor` + `flow` are the honest facts. signer_token_recipient: address, executor: address, position_id: ID, - position_recipient: address, - call_token_recipient: address, + /// 0 = writer flow (executor wrote underlying), 1 = trader flow + /// (executor bought with premium). + flow: u8, write_amount: u64, gross_premium: u64, - fee: u64, + /// Fee split: org fee → Org balances, protocol fee → global Treasury. + /// Total fee = org_fee + protocol_fee (derivable, not stored). + org_fee: u64, + protocol_fee: u64, net_premium: u64, range_start: u128, range_end: u128, @@ -64,17 +76,45 @@ public struct BucketCleaned has copy, drop { public struct BucketInvalidated has copy, drop { bucket_id: ID, at_ms: u64, - admin: address, + actor: address, + /// true when gated by the protocol AdminCap override, false when by the + /// bucket's OrgCap. + by_admin: bool, reason: vector, } public struct BucketRevalidated has copy, drop { bucket_id: ID, at_ms: u64, - admin: address, + actor: address, + by_admin: bool, reason: vector, } +public struct OrgCreated has copy, drop { + org_id: ID, + name: String, + fee_bps: u64, + creator: address, +} + +public struct OrgFeeUpdated has copy, drop { + org_id: ID, + old_bps: u64, + new_bps: u64, +} + +public struct OrgWithdraw has copy, drop { + org_id: ID, + asset_type: TypeName, + amount: u64, +} + +public struct ProtocolPauseSet has copy, drop { + paused: bool, + admin: address, +} + public struct AccountCreated has copy, drop { account_id: ID, owner: address, @@ -100,19 +140,21 @@ public struct SigningKeyRotated has copy, drop { new_pubkey: vector, } -public struct FeeUpdated has copy, drop { +public struct ProtocolFeeUpdated has copy, drop { old_bps: u64, new_bps: u64, } +/// No `recipient`: the withdrawn coin is returned to the PTB, so the final +/// destination is PTB-decided. public struct TreasuryWithdrawn has copy, drop { asset_type: TypeName, amount: u64, - recipient: address, } public(package) fun emit_bucket_created( bucket_id: ID, + org_id: ID, asset_type: TypeName, settlement_type: TypeName, call_type: TypeName, @@ -122,6 +164,7 @@ public(package) fun emit_bucket_created( ) { event::emit(BucketCreated { bucket_id, + org_id, asset_type, settlement_type, call_type, @@ -133,15 +176,16 @@ public(package) fun emit_bucket_created( public(package) fun emit_write_executed( bucket_id: ID, + org_id: ID, signer_account_id: ID, signer_token_recipient: address, executor: address, position_id: ID, - position_recipient: address, - call_token_recipient: address, + flow: u8, write_amount: u64, gross_premium: u64, - fee: u64, + org_fee: u64, + protocol_fee: u64, net_premium: u64, range_start: u128, range_end: u128, @@ -149,15 +193,16 @@ public(package) fun emit_write_executed( ) { event::emit(WriteExecuted { bucket_id, + org_id, signer_account_id, signer_token_recipient, executor, position_id, - position_recipient, - call_token_recipient, + flow, write_amount, gross_premium, - fee, + org_fee, + protocol_fee, net_premium, range_start, range_end, @@ -210,19 +255,42 @@ public(package) fun emit_bucket_cleaned(bucket_id: ID) { public(package) fun emit_bucket_invalidated( bucket_id: ID, at_ms: u64, - admin: address, + actor: address, + by_admin: bool, reason: vector, ) { - event::emit(BucketInvalidated { bucket_id, at_ms, admin, reason }); + event::emit(BucketInvalidated { bucket_id, at_ms, actor, by_admin, reason }); } public(package) fun emit_bucket_revalidated( bucket_id: ID, at_ms: u64, - admin: address, + actor: address, + by_admin: bool, reason: vector, ) { - event::emit(BucketRevalidated { bucket_id, at_ms, admin, reason }); + event::emit(BucketRevalidated { bucket_id, at_ms, actor, by_admin, reason }); +} + +public(package) fun emit_org_created( + org_id: ID, + name: String, + fee_bps: u64, + creator: address, +) { + event::emit(OrgCreated { org_id, name, fee_bps, creator }); +} + +public(package) fun emit_org_fee_updated(org_id: ID, old_bps: u64, new_bps: u64) { + event::emit(OrgFeeUpdated { org_id, old_bps, new_bps }); +} + +public(package) fun emit_org_withdraw(org_id: ID, asset_type: TypeName, amount: u64) { + event::emit(OrgWithdraw { org_id, asset_type, amount }); +} + +public(package) fun emit_protocol_pause_set(paused: bool, admin: address) { + event::emit(ProtocolPauseSet { paused, admin }); } public(package) fun emit_account_created( @@ -263,14 +331,10 @@ public(package) fun emit_signing_key_rotated( event::emit(SigningKeyRotated { account_id, new_scheme, new_pubkey }); } -public(package) fun emit_fee_updated(old_bps: u64, new_bps: u64) { - event::emit(FeeUpdated { old_bps, new_bps }); +public(package) fun emit_protocol_fee_updated(old_bps: u64, new_bps: u64) { + event::emit(ProtocolFeeUpdated { old_bps, new_bps }); } -public(package) fun emit_treasury_withdrawn( - asset_type: TypeName, - amount: u64, - recipient: address, -) { - event::emit(TreasuryWithdrawn { asset_type, amount, recipient }); +public(package) fun emit_treasury_withdrawn(asset_type: TypeName, amount: u64) { + event::emit(TreasuryWithdrawn { asset_type, amount }); } diff --git a/contracts/sources/org.move b/contracts/sources/org.move new file mode 100644 index 00000000..940f38f8 --- /dev/null +++ b/contracts/sources/org.move @@ -0,0 +1,110 @@ +module options_protocol::org; + +use std::string::String; +use std::type_name; +use sui::balance::Balance; +use sui::coin::{Self, Coin}; +use sui::dynamic_field as df; + +use options_protocol::errors; +use options_protocol::events; + +/// Same ceiling as the protocol fee. Combined worst case on a write is +/// therefore 20% of gross premium — net_premium can never underflow. +const MAX_ORG_FEE_BPS: u64 = 1000; +const MAX_ORG_NAME_BYTES: u64 = 64; + +/// Shared object owned by no one; all authority flows through the OrgCap. +/// The Org IS its own treasury: per-asset fee balances live as dynamic +/// fields keyed by `BalanceKey` (same pattern as account/treasury). +public struct Org has key { + id: UID, + /// Cosmetic label. Not unique — `org_id` is the identity. + name: String, + /// Org-set fee in basis points, skimmed from gross premium on every + /// write into this org's buckets (alongside the protocol fee). + fee_bps: u64, +} + +/// Sole authority over the org: fee setting, fee withdrawal, and bucket +/// lifecycle (create/invalidate/revalidate/cleanup). `store` is intentional +/// — transferring the cap transfers the org (fee stream + bucket admin) as +/// a sellable unit. A lost cap permanently strands the org's fee balances; +/// the protocol AdminCap can still invalidate/revalidate its buckets. +public struct OrgCap has key, store { + id: UID, + org_id: ID, +} + +public struct BalanceKey has copy, drop, store {} + +/// Permissionless. Shares the Org and returns the cap to the calling PTB. +public fun create_org(name: String, fee_bps: u64, ctx: &mut TxContext): OrgCap { + assert!(fee_bps <= MAX_ORG_FEE_BPS, errors::fee_too_high()); + let name_bytes = name.length(); + assert!(name_bytes > 0 && name_bytes <= MAX_ORG_NAME_BYTES, errors::org_name_invalid()); + + let org = Org { + id: object::new(ctx), + name, + fee_bps, + }; + let org_id = object::id(&org); + let cap = OrgCap { id: object::new(ctx), org_id }; + events::emit_org_created(org_id, org.name, fee_bps, ctx.sender()); + transfer::share_object(org); + cap +} + +public fun set_org_fee_bps(cap: &OrgCap, org: &mut Org, new_bps: u64) { + assert_cap(cap, object::id(org)); + assert!(new_bps <= MAX_ORG_FEE_BPS, errors::fee_too_high()); + let old_bps = org.fee_bps; + org.fee_bps = new_bps; + events::emit_org_fee_updated(object::id(org), old_bps, new_bps); +} + +public fun withdraw( + cap: &OrgCap, + org: &mut Org, + amount: u64, + ctx: &mut TxContext, +): Coin { + assert_cap(cap, object::id(org)); + let key = BalanceKey {}; + assert!(df::exists(&org.id, key), errors::insufficient_org_balance()); + let bal: &mut Balance = df::borrow_mut(&mut org.id, key); + assert!(bal.value() >= amount, errors::insufficient_org_balance()); + let out = coin::from_balance(bal.split(amount), ctx); + events::emit_org_withdraw(object::id(org), type_name::with_defining_ids(), amount); + out +} + +public(package) fun deposit_balance(org: &mut Org, bal_in: Balance) { + let key = BalanceKey {}; + if (df::exists(&org.id, key)) { + let bal: &mut Balance = df::borrow_mut(&mut org.id, key); + bal.join(bal_in); + } else { + df::add(&mut org.id, key, bal_in); + }; +} + +public(package) fun assert_cap(cap: &OrgCap, org_id: ID) { + assert!(cap.org_id == org_id, errors::org_cap_mismatch()); +} + +public fun fee_bps(org: &Org): u64 { org.fee_bps } + +public fun name(org: &Org): &String { &org.name } + +public fun cap_org_id(cap: &OrgCap): ID { cap.org_id } + +public fun balance_of(org: &Org): u64 { + let key = BalanceKey {}; + if (!df::exists(&org.id, key)) { + return 0 + }; + let bal: &Balance = df::borrow(&org.id, key); + bal.value() +} diff --git a/contracts/sources/treasury.move b/contracts/sources/treasury.move index 70cd2a5c..d84e9573 100644 --- a/contracts/sources/treasury.move +++ b/contracts/sources/treasury.move @@ -30,21 +30,21 @@ public(package) fun deposit_balance(treasury: &mut Treasury, bal_in: Balance< }; } +/// Returns the withdrawn coin to the calling PTB; routing is the PTB's job. public fun withdraw( _: &AdminCap, treasury: &mut Treasury, amount: u64, - recipient: address, ctx: &mut TxContext, -) { +): Coin { let key = BalanceKey {}; assert!(df::exists(&treasury.id, key), errors::insufficient_treasury_balance()); let bal: &mut Balance = df::borrow_mut(&mut treasury.id, key); assert!(bal.value() >= amount, errors::insufficient_treasury_balance()); let withdrawn = bal.split(amount); let out: Coin = coin::from_balance(withdrawn, ctx); - transfer::public_transfer(out, recipient); - events::emit_treasury_withdrawn(type_name::with_defining_ids(), amount, recipient); + events::emit_treasury_withdrawn(type_name::with_defining_ids(), amount); + out } public fun balance_of(treasury: &Treasury): u64 { diff --git a/contracts/tests/admin_tests.move b/contracts/tests/admin_tests.move index bc90b830..f7bfb4e5 100644 --- a/contracts/tests/admin_tests.move +++ b/contracts/tests/admin_tests.move @@ -15,8 +15,9 @@ fun test_init_creates_admin_cap_and_config() { let cap = ts::take_from_sender(&scenario); let config = ts::take_shared(&scenario); - assert!(admin::fee_bps(&config) == 0, 0); + assert!(admin::protocol_fee_bps(&config) == 0, 0); assert!(!admin::protocol_id(&config).is_empty(), 0); + assert!(!admin::is_paused(&config), 0); ts::return_to_sender(&scenario, cap); ts::return_shared(config); @@ -25,7 +26,7 @@ fun test_init_creates_admin_cap_and_config() { } #[test] -fun test_set_fee_bps_updates_value() { +fun test_set_protocol_fee_bps_updates_value() { let mut scenario = ts::begin(th::admin_addr()); let clock = th::init_protocol(&mut scenario); @@ -33,11 +34,11 @@ fun test_set_fee_bps_updates_value() { let cap = ts::take_from_sender(&scenario); let mut config = ts::take_shared(&scenario); - admin::set_fee_bps(&cap, &mut config, 50); - assert!(admin::fee_bps(&config) == 50, 0); + admin::set_protocol_fee_bps(&cap, &mut config, 50); + assert!(admin::protocol_fee_bps(&config) == 50, 0); - admin::set_fee_bps(&cap, &mut config, 1000); - assert!(admin::fee_bps(&config) == 1000, 0); + admin::set_protocol_fee_bps(&cap, &mut config, 1000); + assert!(admin::protocol_fee_bps(&config) == 1000, 0); ts::return_to_sender(&scenario, cap); ts::return_shared(config); @@ -47,7 +48,7 @@ fun test_set_fee_bps_updates_value() { #[test] #[expected_failure(abort_code = 18, location = options_protocol::admin)] // fee_too_high -fun test_set_fee_bps_too_high_aborts() { +fun test_set_protocol_fee_bps_too_high_aborts() { let mut scenario = ts::begin(th::admin_addr()); let clock = th::init_protocol(&mut scenario); @@ -55,7 +56,32 @@ fun test_set_fee_bps_too_high_aborts() { let cap = ts::take_from_sender(&scenario); let mut config = ts::take_shared(&scenario); - admin::set_fee_bps(&cap, &mut config, 1001); + admin::set_protocol_fee_bps(&cap, &mut config, 1001); + + ts::return_to_sender(&scenario, cap); + ts::return_shared(config); + clock.destroy_for_testing(); + ts::end(scenario); +} + +#[test] +fun test_set_pause_toggles_and_is_idempotent() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + + ts::next_tx(&mut scenario, th::admin_addr()); + let cap = ts::take_from_sender(&scenario); + let mut config = ts::take_shared(&scenario); + + admin::set_pause(&cap, &mut config, true, scenario.ctx()); + assert!(admin::is_paused(&config), 0); + + // Idempotent re-set is allowed. + admin::set_pause(&cap, &mut config, true, scenario.ctx()); + assert!(admin::is_paused(&config), 0); + + admin::set_pause(&cap, &mut config, false, scenario.ctx()); + assert!(!admin::is_paused(&config), 0); ts::return_to_sender(&scenario, cap); ts::return_shared(config); diff --git a/contracts/tests/bucket_tests.move b/contracts/tests/bucket_tests.move index cd2ef900..15fcda31 100644 --- a/contracts/tests/bucket_tests.move +++ b/contracts/tests/bucket_tests.move @@ -7,9 +7,11 @@ use sui::test_scenario::{Self as ts, Scenario}; use options_protocol::account; use options_protocol::admin; use options_protocol::bucket::{Self, Bucket}; +use options_protocol::org::{Self, Org}; use options_protocol::position::{Self, Position}; use options_protocol::quote; use options_protocol::test_helpers::{Self as th, BTC, USDC, CALL, CALL2, CALL3}; +use options_protocol::treasury; const STRIKE: u128 = 50_000; const STRIKE_INTERVAL: u128 = 1_000; @@ -166,6 +168,7 @@ fun test_writer_flow_happy_path() { ts::next_tx(&mut scenario, th::writer_addr()); let mut b = ts::take_shared>(&scenario); + let mut org = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treasury = th::take_treasury(&scenario); let mut mm_acc = th::take_account(&scenario); @@ -185,21 +188,18 @@ fun test_writer_flow_happy_path() { ); let sq = quote::new_signed_quote(q, vector[]); let underlying = coin::mint_for_testing(write_amount, scenario.ctx()); - let zero_settlement = coin::zero(scenario.ctx()); - bucket::execute_write_for_testing( + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, underlying, - zero_settlement, - bucket::writer_flow(), th::writer_addr(), - th::trader_mm_addr(), sq, &clock, - scenario.ctx(), ); let bucket_id = object::id(&b); @@ -210,6 +210,7 @@ fun test_writer_flow_happy_path() { assert!(account::balance_of(&mm_acc) == 10_000_000 - premium, 0); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -243,16 +244,17 @@ fun test_writer_flow_with_fee_skim() { th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); - // Set fee to 50 bps. + // Set protocol fee to 50 bps (org fee stays 0 from init_protocol). ts::next_tx(&mut scenario, th::admin_addr()); let cap = th::take_admin_cap(&scenario); let mut config = th::take_config(&scenario); - admin::set_fee_bps(&cap, &mut config, 50); + admin::set_protocol_fee_bps(&cap, &mut config, 50); th::return_admin_cap(&scenario, cap); ts::return_shared(config); ts::next_tx(&mut scenario, th::writer_addr()); let mut b = ts::take_shared>(&scenario); + let mut org = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treasury = th::take_treasury(&scenario); let mut mm_acc = th::take_account(&scenario); @@ -274,24 +276,25 @@ fun test_writer_flow_with_fee_skim() { ); let sq = quote::new_signed_quote(q, vector[]); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(write_amount, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, - coin::mint_for_testing(write_amount, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::writer_addr(), - th::trader_mm_addr(), sq, &clock, - scenario.ctx(), ); assert!(account::balance_of(&mm_acc) == 10_000_000 - premium, 0); + assert!(org::balance_of(&org) == 0, 0); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -322,6 +325,7 @@ fun test_trader_flow_happy_path() { ts::next_tx(&mut scenario, th::trader_addr()); let mut b = ts::take_shared>(&scenario); + let mut org = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treasury = th::take_treasury(&scenario); let mut mm_acc = th::take_account(&scenario); @@ -341,19 +345,18 @@ fun test_trader_flow_happy_path() { ); let sq = quote::new_signed_quote(q, vector[]); - bucket::execute_write_for_testing( + let premium_in = coin::mint_for_testing(premium, scenario.ctx()); + th::execute_trader_flow_for_testing( + &mut scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, - coin::zero(scenario.ctx()), - coin::mint_for_testing(premium, scenario.ctx()), - bucket::trader_flow(), - th::writer_mm_addr(), // position NFT recipient = MM - th::trader_addr(), // call token recipient = retail trader + premium_in, + th::trader_addr(), // returned Coin forwarded to the trader sq, &clock, - scenario.ctx(), ); assert!(bucket::total_written(&b) == (write_amount as u128), 0); @@ -363,6 +366,7 @@ fun test_trader_flow_happy_path() { assert!(account::balance_of(&mm_acc) == premium, 0); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -399,6 +403,7 @@ fun test_execute_write_after_expiry_aborts() { ts::next_tx(&mut scenario, th::writer_addr()); let mut b = ts::take_shared>(&scenario); + let mut org = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treasury = th::take_treasury(&scenario); let mut mm_acc = th::take_account(&scenario); @@ -415,22 +420,22 @@ fun test_execute_write_after_expiry_aborts() { ); let sq = quote::new_signed_quote(q, vector[]); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(50, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, - coin::mint_for_testing(50, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::writer_addr(), - th::trader_mm_addr(), sq, &clock, - scenario.ctx(), ); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -449,6 +454,7 @@ fun test_execute_write_bucket_mismatch_aborts() { ts::next_tx(&mut scenario, th::writer_addr()); let mut b = ts::take_shared>(&scenario); + let mut org = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treasury = th::take_treasury(&scenario); let mut mm_acc = th::take_account(&scenario); @@ -465,22 +471,22 @@ fun test_execute_write_bucket_mismatch_aborts() { ); let sq = quote::new_signed_quote(q, vector[]); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(50, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, - coin::mint_for_testing(50, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::writer_addr(), - th::trader_mm_addr(), sq, &clock, - scenario.ctx(), ); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -499,6 +505,7 @@ fun test_writer_flow_amount_mismatch_aborts() { ts::next_tx(&mut scenario, th::writer_addr()); let mut b = ts::take_shared>(&scenario); + let mut org = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treasury = th::take_treasury(&scenario); let mut mm_acc = th::take_account(&scenario); @@ -515,72 +522,22 @@ fun test_writer_flow_amount_mismatch_aborts() { ); let sq = quote::new_signed_quote(q, vector[]); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(49, scenario.ctx()); // off-by-one + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, - coin::mint_for_testing(49, scenario.ctx()), // off-by-one - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::writer_addr(), - th::trader_mm_addr(), sq, &clock, - scenario.ctx(), - ); - - ts::return_shared(b); - ts::return_shared(config); - ts::return_shared(treasury); - ts::return_shared(mm_acc); - clock.destroy_for_testing(); - ts::end(scenario); -} - -#[test] -#[expected_failure(abort_code = 7, location = options_protocol::bucket)] // quote_recipient_mismatch -fun test_writer_flow_recipient_mismatch_aborts() { - let mut scenario = ts::begin(th::admin_addr()); - let clock = th::init_protocol(&mut scenario); - setup_bucket(&mut scenario); - th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); - fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); - - ts::next_tx(&mut scenario, th::writer_addr()); - let mut b = ts::take_shared>(&scenario); - let config = th::take_config(&scenario); - let mut treasury = th::take_treasury(&scenario); - let mut mm_acc = th::take_account(&scenario); - - let q = quote::new_quote( - *admin::protocol_id(&config), - object::id(&mm_acc), - th::trader_mm_addr(), // signer expects this address as call token recipient - object::id(&b), - 50, - 1_000, - EXPIRY_MS, - 1, - ); - let sq = quote::new_signed_quote(q, vector[]); - - bucket::execute_write_for_testing( - &mut b, - &config, - &mut treasury, - &mut mm_acc, - coin::mint_for_testing(50, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), - th::writer_addr(), - th::stranger_addr(), // mismatched call token recipient - sq, - &clock, - scenario.ctx(), ); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -599,6 +556,7 @@ fun write_via_helper( ) { ts::next_tx(scenario, th::writer_addr()); let mut b = ts::take_shared>(scenario); + let mut org = th::take_org(scenario); let config = th::take_config(scenario); let mut treasury = th::take_treasury(scenario); let mut mm_acc = th::take_account(scenario); @@ -615,22 +573,22 @@ fun write_via_helper( ); let sq = quote::new_signed_quote(q, vector[]); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(amount, scenario.ctx()); + th::execute_writer_flow_for_testing( + scenario, &mut b, + &mut org, &config, &mut treasury, &mut mm_acc, - coin::mint_for_testing(amount, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::writer_addr(), - th::trader_mm_addr(), sq, clock, - scenario.ctx(), ); ts::return_shared(b); + ts::return_shared(org); ts::return_shared(config); ts::return_shared(treasury); ts::return_shared(mm_acc); @@ -931,11 +889,12 @@ fun test_cleanup_bucket_when_drained() { clock.set_for_testing(EXPIRY_MS + 1); - ts::next_tx(&mut scenario, th::admin_addr()); - let cap = th::take_admin_cap(&scenario); + ts::next_tx(&mut scenario, th::org_owner_addr()); + let cap = th::take_org_cap(&scenario); let b = ts::take_shared>(&scenario); - bucket::cleanup_bucket(&cap, b, &clock, scenario.ctx()); - th::return_admin_cap(&scenario, cap); + let tcap = bucket::cleanup_bucket(&cap, b, &clock); + std::unit_test::destroy(tcap); + th::return_org_cap(&scenario, cap); clock.destroy_for_testing(); ts::end(scenario); @@ -953,11 +912,12 @@ fun test_cleanup_bucket_with_remaining_balance_aborts() { clock.set_for_testing(EXPIRY_MS + 1); - ts::next_tx(&mut scenario, th::admin_addr()); - let cap = th::take_admin_cap(&scenario); + ts::next_tx(&mut scenario, th::org_owner_addr()); + let cap = th::take_org_cap(&scenario); let b = ts::take_shared>(&scenario); - bucket::cleanup_bucket(&cap, b, &clock, scenario.ctx()); - th::return_admin_cap(&scenario, cap); + let tcap = bucket::cleanup_bucket(&cap, b, &clock); + std::unit_test::destroy(tcap); + th::return_org_cap(&scenario, cap); clock.destroy_for_testing(); ts::end(scenario); @@ -966,20 +926,20 @@ fun test_cleanup_bucket_with_remaining_balance_aborts() { // --- invalidate / revalidate --- fun invalidate(scenario: &mut Scenario, clock: &sui::clock::Clock, reason: vector) { - ts::next_tx(scenario, th::admin_addr()); - let cap = th::take_admin_cap(scenario); + ts::next_tx(scenario, th::org_owner_addr()); + let cap = th::take_org_cap(scenario); let mut b = ts::take_shared>(scenario); bucket::invalidate_bucket(&cap, &mut b, reason, clock, scenario.ctx()); - th::return_admin_cap(scenario, cap); + th::return_org_cap(scenario, cap); ts::return_shared(b); } fun revalidate(scenario: &mut Scenario, clock: &sui::clock::Clock, reason: vector) { - ts::next_tx(scenario, th::admin_addr()); - let cap = th::take_admin_cap(scenario); + ts::next_tx(scenario, th::org_owner_addr()); + let cap = th::take_org_cap(scenario); let mut b = ts::take_shared>(scenario); bucket::revalidate_bucket(&cap, &mut b, reason, clock, scenario.ctx()); - th::return_admin_cap(scenario, cap); + th::return_org_cap(scenario, cap); ts::return_shared(b); } @@ -1161,3 +1121,356 @@ fun test_revalidate_after_expiry_aborts() { clock.destroy_for_testing(); ts::end(scenario); } + +// --- org scoping --- + +/// Create a second org (owned by stranger_addr) and return its shared-object +/// ID. Its cap lands with stranger_addr. +fun setup_second_org(scenario: &mut Scenario, fee_bps: u64): ID { + ts::next_tx(scenario, th::stranger_addr()); + let cap = org::create_org(std::string::utf8(b"other-org"), fee_bps, scenario.ctx()); + let org_id = org::cap_org_id(&cap); + transfer::public_transfer(cap, th::stranger_addr()); + org_id +} + +#[test] +fun test_create_bucket_records_org_id() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + + ts::next_tx(&mut scenario, th::admin_addr()); + let cap = th::take_org_cap(&scenario); + let b = ts::take_shared>(&scenario); + assert!(bucket::org_id(&b) == org::cap_org_id(&cap), 0); + ts::return_shared(b); + th::return_org_cap(&scenario, cap); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 30, location = options_protocol::org)] // org_cap_mismatch +fun test_invalidate_with_wrong_org_cap_aborts() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + setup_second_org(&mut scenario, 0); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let wrong_cap = ts::take_from_sender(&scenario); + let mut b = ts::take_shared>(&scenario); + bucket::invalidate_bucket( + &wrong_cap, &mut b, b"not mine", &clock, scenario.ctx(), + ); + ts::return_to_sender(&scenario, wrong_cap); + ts::return_shared(b); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 30, location = options_protocol::org)] // org_cap_mismatch +fun test_cleanup_with_wrong_org_cap_aborts() { + let mut scenario = ts::begin(th::admin_addr()); + let mut clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + setup_second_org(&mut scenario, 0); + + clock.set_for_testing(EXPIRY_MS + 1); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let wrong_cap = ts::take_from_sender(&scenario); + let b = ts::take_shared>(&scenario); + let tcap = bucket::cleanup_bucket(&wrong_cap, b, &clock); + std::unit_test::destroy(tcap); + ts::return_to_sender(&scenario, wrong_cap); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +#[test] +fun test_admin_override_invalidate_and_revalidate() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + + ts::next_tx(&mut scenario, th::admin_addr()); + let cap = th::take_admin_cap(&scenario); + let mut b = ts::take_shared>(&scenario); + bucket::admin_invalidate_bucket( + &cap, &mut b, b"emergency", &clock, scenario.ctx(), + ); + assert!(bucket::invalidated(&b), 0); + bucket::admin_revalidate_bucket( + &cap, &mut b, b"resolved", &clock, scenario.ctx(), + ); + assert!(!bucket::invalidated(&b), 0); + th::return_admin_cap(&scenario, cap); + ts::return_shared(b); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 31, location = options_protocol::bucket)] // bucket_org_mismatch +fun test_execute_write_with_wrong_org_aborts() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); + fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); + let other_org_id = setup_second_org(&mut scenario, 0); + + ts::next_tx(&mut scenario, th::writer_addr()); + let mut b = ts::take_shared>(&scenario); + let mut wrong_org = th::take_org_by_id(&scenario, other_org_id); + let config = th::take_config(&scenario); + let mut treasury = th::take_treasury(&scenario); + let mut mm_acc = th::take_account(&scenario); + + let q = quote::new_quote( + *admin::protocol_id(&config), + object::id(&mm_acc), + th::trader_mm_addr(), + object::id(&b), + 50, + 1_000, + EXPIRY_MS, + 1, + ); + let sq = quote::new_signed_quote(q, vector[]); + + let underlying_in = coin::mint_for_testing(50, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, + &mut b, + &mut wrong_org, + &config, + &mut treasury, + &mut mm_acc, + underlying_in, + th::writer_addr(), + sq, + &clock, + ); + + ts::return_shared(b); + ts::return_shared(wrong_org); + ts::return_shared(config); + ts::return_shared(treasury); + ts::return_shared(mm_acc); + clock.destroy_for_testing(); + ts::end(scenario); +} + +// --- two-level fee split --- + +#[test] +fun test_fee_split_org_and_protocol() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); + fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); + + // Protocol fee 50 bps; org fee 30 bps. + ts::next_tx(&mut scenario, th::admin_addr()); + let cap = th::take_admin_cap(&scenario); + let mut config = th::take_config(&scenario); + admin::set_protocol_fee_bps(&cap, &mut config, 50); + th::return_admin_cap(&scenario, cap); + ts::return_shared(config); + + ts::next_tx(&mut scenario, th::org_owner_addr()); + let org_cap = th::take_org_cap(&scenario); + let mut org_obj = th::take_org(&scenario); + org::set_org_fee_bps(&org_cap, &mut org_obj, 30); + th::return_org_cap(&scenario, org_cap); + ts::return_shared(org_obj); + + ts::next_tx(&mut scenario, th::writer_addr()); + let mut b = ts::take_shared>(&scenario); + let mut org_obj = th::take_org(&scenario); + let config = th::take_config(&scenario); + let mut treasury = th::take_treasury(&scenario); + let mut mm_acc = th::take_account(&scenario); + + // Odd gross premium: both fees floor independently, dust stays in net. + let write_amount: u64 = 100; + let premium: u64 = 999_999; + let expected_org_fee = 2_999; // floor(999_999 * 30 / 10_000) + let expected_protocol_fee = 4_999; // floor(999_999 * 50 / 10_000) + let expected_net = premium - expected_org_fee - expected_protocol_fee; + + let q = quote::new_quote( + *admin::protocol_id(&config), + object::id(&mm_acc), + th::trader_mm_addr(), + object::id(&b), + write_amount, + premium, + EXPIRY_MS, + 1, + ); + let sq = quote::new_signed_quote(q, vector[]); + + let underlying_in = coin::mint_for_testing(write_amount, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, + &mut b, + &mut org_obj, + &config, + &mut treasury, + &mut mm_acc, + underlying_in, + th::writer_addr(), + sq, + &clock, + ); + + assert!(org::balance_of(&org_obj) == expected_org_fee, 0); + assert!(treasury::balance_of(&treasury) == expected_protocol_fee, 0); + + ts::return_shared(b); + ts::return_shared(org_obj); + ts::return_shared(config); + ts::return_shared(treasury); + ts::return_shared(mm_acc); + + // Writer's net coin carries the rounding dust: exact conservation. + ts::next_tx(&mut scenario, th::writer_addr()); + let net = ts::take_from_sender>(&scenario); + assert!(net.value() == expected_net, 0); + assert!(expected_net + expected_org_fee + expected_protocol_fee == premium, 0); + coin::burn_for_testing(net); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +// --- protocol pause --- + +fun set_pause(scenario: &mut Scenario, paused: bool) { + ts::next_tx(scenario, th::admin_addr()); + let cap = th::take_admin_cap(scenario); + let mut config = th::take_config(scenario); + admin::set_pause(&cap, &mut config, paused, scenario.ctx()); + th::return_admin_cap(scenario, cap); + ts::return_shared(config); +} + +#[test] +#[expected_failure(abort_code = 29, location = options_protocol::bucket)] // protocol_paused +fun test_paused_blocks_writer_flow() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); + fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); + + set_pause(&mut scenario, true); + + write_via_helper(&mut scenario, &clock, 50, 1_000, 1); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +#[test] +fun test_pause_allows_exercise_and_unpause_re_enables_writes() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); + fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); + write_via_helper(&mut scenario, &clock, 60, 1_000, 1); + + set_pause(&mut scenario, true); + + // Exercise still works while paused. + ts::next_tx(&mut scenario, th::trader_mm_addr()); + let call = ts::take_from_sender>(&scenario); + let mut b = ts::take_shared>(&scenario); + let payment = coin::mint_for_testing((((60 as u128) * STRIKE) as u64), scenario.ctx()); + let underlying = bucket::exercise(&mut b, call, payment, &clock, scenario.ctx()); + assert!(underlying.value() == 60, 0); + coin::burn_for_testing(underlying); + ts::return_shared(b); + + set_pause(&mut scenario, false); + + write_via_helper(&mut scenario, &clock, 25, 1_000, 2); + + ts::next_tx(&mut scenario, th::admin_addr()); + let b = ts::take_shared>(&scenario); + assert!(bucket::total_written(&b) == 85, 0); + ts::return_shared(b); + + clock.destroy_for_testing(); + ts::end(scenario); +} + +// --- return-value plumbing (direct, no helper) --- + +#[test] +fun test_writer_flow_returns_position_and_net_coin() { + let mut scenario = ts::begin(th::admin_addr()); + let clock = th::init_protocol(&mut scenario); + setup_bucket(&mut scenario); + th::create_account(&mut scenario, th::trader_mm_addr(), th::pubkey_a()); + fund_account(&mut scenario, th::trader_mm_addr(), 10_000_000); + + ts::next_tx(&mut scenario, th::writer_addr()); + let mut b = ts::take_shared>(&scenario); + let mut org_obj = th::take_org(&scenario); + let config = th::take_config(&scenario); + let mut treasury = th::take_treasury(&scenario); + let mut mm_acc = th::take_account(&scenario); + + let q = quote::new_quote( + *admin::protocol_id(&config), + object::id(&mm_acc), + th::trader_mm_addr(), + object::id(&b), + 42, + 1_000_000, + EXPIRY_MS, + 1, + ); + let sq = quote::new_signed_quote(q, vector[]); + + let (pos, net) = bucket::execute_write_writer_flow_for_testing( + &mut b, + &mut org_obj, + &config, + &mut treasury, + &mut mm_acc, + coin::mint_for_testing(42, scenario.ctx()), + sq, + &clock, + scenario.ctx(), + ); + + // The PTB (this test) decides routing — inspect directly. + assert!(position::range_start(&pos) == 0, 0); + assert!(position::range_end(&pos) == 42, 0); + assert!(position::bucket_id(&pos) == object::id(&b), 0); + assert!(net.value() == 1_000_000, 0); + transfer::public_transfer(pos, th::writer_addr()); + coin::burn_for_testing(net); + + ts::return_shared(b); + ts::return_shared(org_obj); + ts::return_shared(config); + ts::return_shared(treasury); + ts::return_shared(mm_acc); + + clock.destroy_for_testing(); + ts::end(scenario); +} diff --git a/contracts/tests/e2e_tests.move b/contracts/tests/e2e_tests.move index f9e6858f..069b6c6a 100644 --- a/contracts/tests/e2e_tests.move +++ b/contracts/tests/e2e_tests.move @@ -19,6 +19,7 @@ use sui::test_scenario::{Self as ts, Scenario}; use options_protocol::account; use options_protocol::admin; use options_protocol::bucket::{Self, Bucket}; +use options_protocol::org; use options_protocol::position::{Self, Position}; use options_protocol::quote; use options_protocol::treasury; @@ -27,20 +28,39 @@ use options_protocol::test_helpers::{Self as th, BTC, USDC, CALL}; const STRIKE: u128 = 50_000; const STRIKE_SCALE: u8 = 0; const EXPIRY_MS: u64 = 1_000_000; -const FEE_BPS: u64 = 50; // 0.5% +const PROTOCOL_FEE_BPS: u64 = 50; // 0.5% → global Treasury +const ORG_FEE_BPS: u64 = 30; // 0.3% → Org balances -fun bps(x: u64): u64 { - (((x as u128) * (FEE_BPS as u128)) / 10_000) as u64 +fun protocol_fee(x: u64): u64 { + (((x as u128) * (PROTOCOL_FEE_BPS as u128)) / 10_000) as u64 } -/// Set fee to 50 bps and create a single BTC/USDC bucket at STRIKE. +fun org_fee(x: u64): u64 { + (((x as u128) * (ORG_FEE_BPS as u128)) / 10_000) as u64 +} + +fun net_of(x: u64): u64 { + x - protocol_fee(x) - org_fee(x) +} + +/// Set protocol fee to 50 bps, org fee to 30 bps, and create a single +/// BTC/USDC bucket at STRIKE — so every write exercises the real two-level +/// fee split. fun setup_protocol_with_bucket(scenario: &mut Scenario) { ts::next_tx(scenario, th::admin_addr()); let cap = th::take_admin_cap(scenario); let mut config = th::take_config(scenario); - admin::set_fee_bps(&cap, &mut config, FEE_BPS); + admin::set_protocol_fee_bps(&cap, &mut config, PROTOCOL_FEE_BPS); th::return_admin_cap(scenario, cap); ts::return_shared(config); + + ts::next_tx(scenario, th::org_owner_addr()); + let org_cap = th::take_org_cap(scenario); + let mut org_obj = th::take_org(scenario); + org::set_org_fee_bps(&org_cap, &mut org_obj, ORG_FEE_BPS); + th::return_org_cap(scenario, org_cap); + ts::return_shared(org_obj); + th::new_bucket(scenario, EXPIRY_MS, STRIKE, STRIKE_SCALE); } @@ -53,9 +73,9 @@ fun setup_protocol_with_bucket(scenario: &mut Scenario) { // writer_addr — Retail writer #1 (first writer; range [0,100)) // stranger_addr — Retail writer #2 (second writer; range [100,150)) // -// Numbers -// write1: 100 BTC for premium 5_000_000 → fee 25_000, net 4_975_000 -// write2: 50 BTC for premium 3_000_000 → fee 15_000, net 2_985_000 +// Numbers (protocol fee 50 bps + org fee 30 bps) +// write1: 100 BTC for premium 5_000_000 → protocol 25_000, org 15_000, net 4_960_000 +// write2: 50 BTC for premium 3_000_000 → protocol 15_000, org 9_000, net 2_976_000 // exercise 120 by Trader MM: // writer #1 → exercised 100 / unexercised 0 → 0 BTC + (((100 as u128) * STRIKE) as u64) USDC // writer #2 → exercised 20 / unexercised 30 → 30 BTC + (((20 as u128) * STRIKE) as u64) USDC @@ -80,12 +100,12 @@ fun test_e2e_writer_flow_full_lifecycle() { // ---- Write #1: writer_addr writes 100 BTC. Premium 5_000_000, nonce 1. let write1_amount: u64 = 100; let write1_premium: u64 = 5_000_000; - let write1_fee = bps(write1_premium); - let write1_net = write1_premium - write1_fee; + let write1_net = net_of(write1_premium); let bucket_id; { ts::next_tx(&mut scenario, th::writer_addr()); let mut b = ts::take_shared>(&scenario); + let mut org_obj = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treas = th::take_treasury(&scenario); let mut mm = th::take_account(&scenario); @@ -101,19 +121,18 @@ fun test_e2e_writer_flow_full_lifecycle() { EXPIRY_MS, 1, ); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(write1_amount, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org_obj, &config, &mut treas, &mut mm, - coin::mint_for_testing(write1_amount, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::writer_addr(), - th::trader_mm_addr(), quote::new_signed_quote(q, vector[]), &clock, - scenario.ctx(), ); assert!(bucket::total_written(&b) == 100, 0); @@ -121,9 +140,11 @@ fun test_e2e_writer_flow_full_lifecycle() { assert!(bucket::exercise_cursor(&b) == 0, 0); assert!(bucket::call_supply(&b) == 100, 0); assert!(account::balance_of(&mm) == 20_000_000 - write1_premium, 0); - assert!(treasury::balance_of(&treas) == write1_fee, 0); + assert!(treasury::balance_of(&treas) == protocol_fee(write1_premium), 0); + assert!(org::balance_of(&org_obj) == org_fee(write1_premium), 0); ts::return_shared(b); + ts::return_shared(org_obj); ts::return_shared(config); ts::return_shared(treas); ts::return_shared(mm); @@ -143,11 +164,11 @@ fun test_e2e_writer_flow_full_lifecycle() { // ---- Write #2: stranger_addr writes 50 BTC. Premium 3_000_000, nonce 2. let write2_amount: u64 = 50; let write2_premium: u64 = 3_000_000; - let write2_fee = bps(write2_premium); - let write2_net = write2_premium - write2_fee; + let write2_net = net_of(write2_premium); { ts::next_tx(&mut scenario, th::stranger_addr()); let mut b = ts::take_shared>(&scenario); + let mut org_obj = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treas = th::take_treasury(&scenario); let mut mm = th::take_account(&scenario); @@ -162,28 +183,37 @@ fun test_e2e_writer_flow_full_lifecycle() { EXPIRY_MS, 2, ); - bucket::execute_write_for_testing( + let underlying_in = coin::mint_for_testing(write2_amount, scenario.ctx()); + th::execute_writer_flow_for_testing( + &mut scenario, &mut b, + &mut org_obj, &config, &mut treas, &mut mm, - coin::mint_for_testing(write2_amount, scenario.ctx()), - coin::zero(scenario.ctx()), - bucket::writer_flow(), + underlying_in, th::stranger_addr(), - th::trader_mm_addr(), quote::new_signed_quote(q, vector[]), &clock, - scenario.ctx(), ); assert!(bucket::total_written(&b) == 150, 0); assert!(bucket::underlying_balance(&b) == 150, 0); assert!(bucket::call_supply(&b) == 150, 0); assert!(account::balance_of(&mm) == 20_000_000 - write1_premium - write2_premium, 0); - assert!(treasury::balance_of(&treas) == write1_fee + write2_fee, 0); + assert!( + treasury::balance_of(&treas) == + protocol_fee(write1_premium) + protocol_fee(write2_premium), + 0, + ); + assert!( + org::balance_of(&org_obj) == + org_fee(write1_premium) + org_fee(write2_premium), + 0, + ); ts::return_shared(b); + ts::return_shared(org_obj); ts::return_shared(config); ts::return_shared(treas); ts::return_shared(mm); @@ -281,29 +311,40 @@ fun test_e2e_writer_flow_full_lifecycle() { ts::return_shared(b); }; - // ---- Admin reaps the bucket (now drained) and withdraws accumulated fees. - ts::next_tx(&mut scenario, th::admin_addr()); + // ---- Org reaps the bucket (now drained); admin withdraws protocol fees; + // org withdraws its own fees. Both withdrawals return coins. + ts::next_tx(&mut scenario, th::org_owner_addr()); { - let cap = th::take_admin_cap(&scenario); + let org_cap = th::take_org_cap(&scenario); let b = ts::take_shared>(&scenario); - bucket::cleanup_bucket(&cap, b, &clock, scenario.ctx()); + let tcap = bucket::cleanup_bucket(&org_cap, b, &clock); + std::unit_test::destroy(tcap); + + let mut org_obj = th::take_org(&scenario); + let total_org_fee = org_fee(write1_premium) + org_fee(write2_premium); + assert!(org::balance_of(&org_obj) == total_org_fee, 0); + let org_coin = org::withdraw(&org_cap, &mut org_obj, total_org_fee, scenario.ctx()); + assert!(org_coin.value() == total_org_fee, 0); + assert!(org::balance_of(&org_obj) == 0, 0); + coin::burn_for_testing(org_coin); + th::return_org_cap(&scenario, org_cap); + ts::return_shared(org_obj); + }; + ts::next_tx(&mut scenario, th::admin_addr()); + { + let cap = th::take_admin_cap(&scenario); let mut treas = th::take_treasury(&scenario); - let total_fee = write1_fee + write2_fee; + let total_fee = protocol_fee(write1_premium) + protocol_fee(write2_premium); assert!(treasury::balance_of(&treas) == total_fee, 0); - treasury::withdraw(&cap, &mut treas, total_fee, th::admin_addr(), scenario.ctx()); + let fees = treasury::withdraw(&cap, &mut treas, total_fee, scenario.ctx()); + assert!(fees.value() == total_fee, 0); assert!(treasury::balance_of(&treas) == 0, 0); + coin::burn_for_testing(fees); th::return_admin_cap(&scenario, cap); ts::return_shared(treas); }; - ts::next_tx(&mut scenario, th::admin_addr()); - { - let fees = ts::take_from_sender>(&scenario); - assert!(fees.value() == write1_fee + write2_fee, 0); - coin::burn_for_testing(fees); - }; - clock.destroy_for_testing(); ts::end(scenario); } @@ -317,9 +358,9 @@ fun test_e2e_writer_flow_full_lifecycle() { // trader_addr — Retail trader #1 (first buyer; underlying range [0,100)) // stranger_addr — Retail trader #2 (second buyer; underlying range [100,180)) // -// Numbers -// buy1: 100 BTC for premium 6_000_000 → fee 30_000, net 5_970_000 to MM -// buy2: 80 BTC for premium 5_000_000 → fee 25_000, net 4_975_000 to MM +// Numbers (protocol fee 50 bps + org fee 30 bps) +// buy1: 100 BTC for premium 6_000_000 → protocol 30_000, org 18_000, net 5_952_000 to MM +// buy2: 80 BTC for premium 5_000_000 → protocol 25_000, org 15_000, net 4_960_000 to MM // trader#1 exercises full 100 → cursor 100 // trader#2 exercises 30 of 80 → cursor 130 // trader#2 burns leftover 50 call tokens (unexercised) post-expiry @@ -347,12 +388,12 @@ fun test_e2e_trader_flow_full_lifecycle() { // ---- Buy #1: trader_addr buys 100 BTC option. Premium 6_000_000, nonce 1. let buy1_amount: u64 = 100; let buy1_premium: u64 = 6_000_000; - let buy1_fee = bps(buy1_premium); - let buy1_net = buy1_premium - buy1_fee; + let buy1_net = net_of(buy1_premium); let bucket_id; { ts::next_tx(&mut scenario, th::trader_addr()); let mut b = ts::take_shared>(&scenario); + let mut org_obj = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treas = th::take_treasury(&scenario); let mut mm = th::take_account(&scenario); @@ -368,19 +409,18 @@ fun test_e2e_trader_flow_full_lifecycle() { EXPIRY_MS, 1, ); - bucket::execute_write_for_testing( + let premium_in = coin::mint_for_testing(buy1_premium, scenario.ctx()); + th::execute_trader_flow_for_testing( + &mut scenario, &mut b, + &mut org_obj, &config, &mut treas, &mut mm, - coin::zero(scenario.ctx()), - coin::mint_for_testing(buy1_premium, scenario.ctx()), - bucket::trader_flow(), - th::writer_mm_addr(), + premium_in, th::trader_addr(), quote::new_signed_quote(q, vector[]), &clock, - scenario.ctx(), ); assert!(bucket::total_written(&b) == 100, 0); @@ -388,9 +428,11 @@ fun test_e2e_trader_flow_full_lifecycle() { assert!(bucket::call_supply(&b) == 100, 0); assert!(account::balance_of(&mm) == mm_initial_btc - buy1_amount, 0); assert!(account::balance_of(&mm) == buy1_net, 0); - assert!(treasury::balance_of(&treas) == buy1_fee, 0); + assert!(treasury::balance_of(&treas) == protocol_fee(buy1_premium), 0); + assert!(org::balance_of(&org_obj) == org_fee(buy1_premium), 0); ts::return_shared(b); + ts::return_shared(org_obj); ts::return_shared(config); ts::return_shared(treas); ts::return_shared(mm); @@ -399,10 +441,10 @@ fun test_e2e_trader_flow_full_lifecycle() { // ---- Buy #2: stranger_addr buys 80 BTC option. Premium 5_000_000, nonce 2. let buy2_amount: u64 = 80; let buy2_premium: u64 = 5_000_000; - let buy2_fee = bps(buy2_premium); { ts::next_tx(&mut scenario, th::stranger_addr()); let mut b = ts::take_shared>(&scenario); + let mut org_obj = th::take_org(&scenario); let config = th::take_config(&scenario); let mut treas = th::take_treasury(&scenario); let mut mm = th::take_account(&scenario); @@ -417,19 +459,18 @@ fun test_e2e_trader_flow_full_lifecycle() { EXPIRY_MS, 2, ); - bucket::execute_write_for_testing( + let premium_in = coin::mint_for_testing(buy2_premium, scenario.ctx()); + th::execute_trader_flow_for_testing( + &mut scenario, &mut b, + &mut org_obj, &config, &mut treas, &mut mm, - coin::zero(scenario.ctx()), - coin::mint_for_testing(buy2_premium, scenario.ctx()), - bucket::trader_flow(), - th::writer_mm_addr(), + premium_in, th::stranger_addr(), quote::new_signed_quote(q, vector[]), &clock, - scenario.ctx(), ); assert!(bucket::total_written(&b) == 180, 0); @@ -437,12 +478,22 @@ fun test_e2e_trader_flow_full_lifecycle() { assert!(bucket::call_supply(&b) == 180, 0); assert!(account::balance_of(&mm) == mm_initial_btc - buy1_amount - buy2_amount, 0); assert!( - account::balance_of(&mm) == buy1_net + (buy2_premium - buy2_fee), + account::balance_of(&mm) == buy1_net + net_of(buy2_premium), + 0, + ); + assert!( + treasury::balance_of(&treas) == + protocol_fee(buy1_premium) + protocol_fee(buy2_premium), + 0, + ); + assert!( + org::balance_of(&org_obj) == + org_fee(buy1_premium) + org_fee(buy2_premium), 0, ); - assert!(treasury::balance_of(&treas) == buy1_fee + buy2_fee, 0); ts::return_shared(b); + ts::return_shared(org_obj); ts::return_shared(config); ts::return_shared(treas); ts::return_shared(mm); @@ -531,29 +582,39 @@ fun test_e2e_trader_flow_full_lifecycle() { ts::return_shared(b); }; - // ---- Admin cleans up bucket and withdraws total fees. - ts::next_tx(&mut scenario, th::admin_addr()); + // ---- Org cleans up the bucket and withdraws org fees; admin withdraws + // protocol fees. Both withdrawals return coins to the PTB. + ts::next_tx(&mut scenario, th::org_owner_addr()); { - let cap = th::take_admin_cap(&scenario); + let org_cap = th::take_org_cap(&scenario); let b = ts::take_shared>(&scenario); - bucket::cleanup_bucket(&cap, b, &clock, scenario.ctx()); + let tcap = bucket::cleanup_bucket(&org_cap, b, &clock); + std::unit_test::destroy(tcap); + + let mut org_obj = th::take_org(&scenario); + let total_org_fee = org_fee(buy1_premium) + org_fee(buy2_premium); + assert!(org::balance_of(&org_obj) == total_org_fee, 0); + let org_coin = org::withdraw(&org_cap, &mut org_obj, total_org_fee, scenario.ctx()); + assert!(org_coin.value() == total_org_fee, 0); + coin::burn_for_testing(org_coin); + th::return_org_cap(&scenario, org_cap); + ts::return_shared(org_obj); + }; + ts::next_tx(&mut scenario, th::admin_addr()); + { + let cap = th::take_admin_cap(&scenario); let mut treas = th::take_treasury(&scenario); - let total_fee = buy1_fee + buy2_fee; + let total_fee = protocol_fee(buy1_premium) + protocol_fee(buy2_premium); assert!(treasury::balance_of(&treas) == total_fee, 0); - treasury::withdraw(&cap, &mut treas, total_fee, th::admin_addr(), scenario.ctx()); + let fees = treasury::withdraw(&cap, &mut treas, total_fee, scenario.ctx()); + assert!(fees.value() == total_fee, 0); assert!(treasury::balance_of(&treas) == 0, 0); + coin::burn_for_testing(fees); th::return_admin_cap(&scenario, cap); ts::return_shared(treas); }; - ts::next_tx(&mut scenario, th::admin_addr()); - { - let fees = ts::take_from_sender>(&scenario); - assert!(fees.value() == buy1_fee + buy2_fee, 0); - coin::burn_for_testing(fees); - }; - clock.destroy_for_testing(); ts::end(scenario); } diff --git a/contracts/tests/org_tests.move b/contracts/tests/org_tests.move new file mode 100644 index 00000000..3f4a2211 --- /dev/null +++ b/contracts/tests/org_tests.move @@ -0,0 +1,198 @@ +#[test_only] +module options_protocol::org_tests; + +use std::string; +use sui::coin; +use sui::test_scenario::{Self as ts, Scenario}; + +use options_protocol::org::{Self, Org, OrgCap}; +use options_protocol::test_helpers::{Self as th, USDC}; + +fun create_org_as( + scenario: &mut Scenario, + owner: address, + name: vector, + fee_bps: u64, +): ID { + ts::next_tx(scenario, owner); + let cap = org::create_org(string::utf8(name), fee_bps, scenario.ctx()); + let org_id = org::cap_org_id(&cap); + transfer::public_transfer(cap, owner); + org_id +} + +#[test] +fun test_create_org_shares_org_and_returns_cap() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_id = create_org_as(&mut scenario, th::stranger_addr(), b"acme", 250); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let org_obj = ts::take_shared_by_id(&scenario, org_id); + assert!(org::fee_bps(&org_obj) == 250, 0); + assert!(org::name(&org_obj) == string::utf8(b"acme"), 0); + assert!(org::balance_of(&org_obj) == 0, 0); + ts::return_shared(org_obj); + + let cap = ts::take_from_sender(&scenario); + assert!(org::cap_org_id(&cap) == org_id, 0); + ts::return_to_sender(&scenario, cap); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 32, location = options_protocol::org)] // org_name_invalid +fun test_create_org_empty_name_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + create_org_as(&mut scenario, th::stranger_addr(), b"", 0); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 32, location = options_protocol::org)] // org_name_invalid +fun test_create_org_name_too_long_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + // 65 bytes — one over MAX_ORG_NAME_BYTES. + create_org_as( + &mut scenario, + th::stranger_addr(), + b"01234567890123456789012345678901234567890123456789012345678901234", + 0, + ); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 18, location = options_protocol::org)] // fee_too_high +fun test_create_org_fee_above_cap_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + create_org_as(&mut scenario, th::stranger_addr(), b"greedy", 1001); + ts::end(scenario); +} + +#[test] +fun test_set_org_fee_bps_updates_value() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_id = create_org_as(&mut scenario, th::stranger_addr(), b"acme", 0); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let cap = ts::take_from_sender(&scenario); + let mut org_obj = ts::take_shared_by_id(&scenario, org_id); + org::set_org_fee_bps(&cap, &mut org_obj, 1000); + assert!(org::fee_bps(&org_obj) == 1000, 0); + ts::return_to_sender(&scenario, cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 18, location = options_protocol::org)] // fee_too_high +fun test_set_org_fee_bps_above_cap_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_id = create_org_as(&mut scenario, th::stranger_addr(), b"acme", 0); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let cap = ts::take_from_sender(&scenario); + let mut org_obj = ts::take_shared_by_id(&scenario, org_id); + org::set_org_fee_bps(&cap, &mut org_obj, 1001); + ts::return_to_sender(&scenario, cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 30, location = options_protocol::org)] // org_cap_mismatch +fun test_set_org_fee_with_wrong_cap_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_a = create_org_as(&mut scenario, th::stranger_addr(), b"org-a", 0); + let _org_b = create_org_as(&mut scenario, th::writer_addr(), b"org-b", 0); + + // writer's cap (org-b) against org-a. + ts::next_tx(&mut scenario, th::writer_addr()); + let wrong_cap = ts::take_from_sender(&scenario); + let mut org_obj = ts::take_shared_by_id(&scenario, org_a); + org::set_org_fee_bps(&wrong_cap, &mut org_obj, 1); + ts::return_to_sender(&scenario, wrong_cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} + +#[test] +fun test_org_deposit_and_withdraw_round_trip() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_id = create_org_as(&mut scenario, th::stranger_addr(), b"acme", 0); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let mut org_obj = ts::take_shared_by_id(&scenario, org_id); + let coin_in = coin::mint_for_testing(500_000, scenario.ctx()); + org::deposit_balance(&mut org_obj, coin_in.into_balance()); + assert!(org::balance_of(&org_obj) == 500_000, 0); + + let cap = ts::take_from_sender(&scenario); + let out = org::withdraw(&cap, &mut org_obj, 200_000, scenario.ctx()); + assert!(out.value() == 200_000, 0); + assert!(org::balance_of(&org_obj) == 300_000, 0); + coin::burn_for_testing(out); + ts::return_to_sender(&scenario, cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 33, location = options_protocol::org)] // insufficient_org_balance +fun test_org_withdraw_overdraw_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_id = create_org_as(&mut scenario, th::stranger_addr(), b"acme", 0); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let mut org_obj = ts::take_shared_by_id(&scenario, org_id); + let coin_in = coin::mint_for_testing(100, scenario.ctx()); + org::deposit_balance(&mut org_obj, coin_in.into_balance()); + + let cap = ts::take_from_sender(&scenario); + let out = org::withdraw(&cap, &mut org_obj, 101, scenario.ctx()); + coin::burn_for_testing(out); + ts::return_to_sender(&scenario, cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 33, location = options_protocol::org)] // insufficient_org_balance — type never deposited +fun test_org_withdraw_unknown_asset_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_id = create_org_as(&mut scenario, th::stranger_addr(), b"acme", 0); + + ts::next_tx(&mut scenario, th::stranger_addr()); + let mut org_obj = ts::take_shared_by_id(&scenario, org_id); + let cap = ts::take_from_sender(&scenario); + let out = org::withdraw(&cap, &mut org_obj, 1, scenario.ctx()); + coin::burn_for_testing(out); + ts::return_to_sender(&scenario, cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = 30, location = options_protocol::org)] // org_cap_mismatch +fun test_org_withdraw_with_wrong_cap_aborts() { + let mut scenario = ts::begin(th::stranger_addr()); + let org_a = create_org_as(&mut scenario, th::stranger_addr(), b"org-a", 0); + let _org_b = create_org_as(&mut scenario, th::writer_addr(), b"org-b", 0); + + ts::next_tx(&mut scenario, th::writer_addr()); + let wrong_cap = ts::take_from_sender(&scenario); + let mut org_obj = ts::take_shared_by_id(&scenario, org_a); + let out = org::withdraw(&wrong_cap, &mut org_obj, 1, scenario.ctx()); + coin::burn_for_testing(out); + ts::return_to_sender(&scenario, wrong_cap); + ts::return_shared(org_obj); + + ts::end(scenario); +} diff --git a/contracts/tests/test_helpers.move b/contracts/tests/test_helpers.move index 5d40b642..708e0cbe 100644 --- a/contracts/tests/test_helpers.move +++ b/contracts/tests/test_helpers.move @@ -1,13 +1,16 @@ #[test_only] module options_protocol::test_helpers; +use std::string; use sui::clock::{Self, Clock}; -use sui::coin; +use sui::coin::{Self, Coin}; use sui::test_scenario::{Self as ts, Scenario}; use options_protocol::admin::{Self, AdminCap, ProtocolConfig}; use options_protocol::account::{Self, Account}; -use options_protocol::bucket; +use options_protocol::bucket::{Self, Bucket}; +use options_protocol::org::{Self, Org, OrgCap}; +use options_protocol::quote::SignedQuote; use options_protocol::treasury::{Self, Treasury}; public struct USDC has drop {} @@ -22,20 +25,20 @@ public struct CALL2 has drop {} public struct CALL3 has drop {} /// Create and share a bucket for `(U, S, C)` with a fresh test option-coin -/// treasury cap. Runs as the admin. Mirrors what the scheduler does on chain -/// (publish a coin package, then `create_bucket` with the new cap), minus the -/// publish step. +/// treasury cap. Runs as the org owner with the test org's cap. Mirrors what +/// a scheduler does on chain (publish a coin package, then `create_bucket` +/// with the new cap), minus the publish step. public fun new_bucket( scenario: &mut Scenario, expiry_ms: u64, strike: u128, strike_scale: u8, ) { - ts::next_tx(scenario, admin_addr()); - let cap = take_admin_cap(scenario); + ts::next_tx(scenario, org_owner_addr()); + let cap = take_org_cap(scenario); let tcap = coin::create_treasury_cap_for_testing(scenario.ctx()); bucket::create_bucket(&cap, tcap, expiry_ms, strike, strike_scale, scenario.ctx()); - return_admin_cap(scenario, cap); + return_org_cap(scenario, cap); } public fun admin_addr(): address { @0xA1 } @@ -44,6 +47,7 @@ public fun trader_mm_addr(): address { @0xC3 } public fun trader_addr(): address { @0xD4 } public fun writer_mm_addr(): address { @0xE5 } public fun stranger_addr(): address { @0xF6 } +public fun org_owner_addr(): address { @0x07 } public fun pubkey_a(): vector { x"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" @@ -53,7 +57,9 @@ public fun pubkey_b(): vector { x"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" } -/// Initialize protocol: AdminCap to admin_addr, ProtocolConfig shared, Treasury shared. +/// Initialize protocol: AdminCap to admin_addr, ProtocolConfig + Treasury +/// shared, and a fee-0 test org (OrgCap to org_owner_addr). The zero org fee +/// keeps pre-org premium arithmetic in existing tests unchanged. /// Returns a fresh test Clock. public fun init_protocol(scenario: &mut Scenario): Clock { ts::next_tx(scenario, admin_addr()); @@ -64,6 +70,10 @@ public fun init_protocol(scenario: &mut Scenario): Clock { treasury::create_and_share(&admin_cap, scenario.ctx()); ts::return_to_sender(scenario, admin_cap); + ts::next_tx(scenario, org_owner_addr()); + let org_cap = org::create_org(string::utf8(b"test-org"), 0, scenario.ctx()); + transfer::public_transfer(org_cap, org_owner_addr()); + ts::next_tx(scenario, admin_addr()); clock::create_for_testing(scenario.ctx()) } @@ -114,3 +124,77 @@ public fun take_account(scenario: &Scenario): Account { public fun take_account_by_id(scenario: &Scenario, id: ID): Account { ts::take_shared_by_id(scenario, id) } + +public fun take_org(scenario: &Scenario): Org { + ts::take_shared(scenario) +} + +public fun take_org_by_id(scenario: &Scenario, id: ID): Org { + ts::take_shared_by_id(scenario, id) +} + +public fun take_org_cap(scenario: &Scenario): OrgCap { + ts::take_from_address(scenario, org_owner_addr()) +} + +public fun return_org_cap(scenario: &Scenario, cap: OrgCap) { + ts::return_to_address(org_owner_addr(), cap); + let _ = scenario; +} + +/// Writer-flow wrapper preserving the pre-refactor test surface: forwards +/// the returned `(Position, Coin)` to `recipient`, so existing +/// assertions via `take_from_address` keep working. +public fun execute_writer_flow_for_testing( + scenario: &mut Scenario, + bucket: &mut Bucket, + org: &mut Org, + config: &ProtocolConfig, + treasury: &mut Treasury, + signer_account: &mut Account, + underlying_in: Coin, + recipient: address, + signed_quote: SignedQuote, + clock: &Clock, +) { + let (position, net_coin) = bucket::execute_write_writer_flow_for_testing( + bucket, + org, + config, + treasury, + signer_account, + underlying_in, + signed_quote, + clock, + scenario.ctx(), + ); + transfer::public_transfer(position, recipient); + transfer::public_transfer(net_coin, recipient); +} + +/// Trader-flow wrapper: forwards the returned `Coin` to `recipient`. +public fun execute_trader_flow_for_testing( + scenario: &mut Scenario, + bucket: &mut Bucket, + org: &mut Org, + config: &ProtocolConfig, + treasury: &mut Treasury, + signer_account: &mut Account, + premium_in: Coin, + recipient: address, + signed_quote: SignedQuote, + clock: &Clock, +) { + let call = bucket::execute_write_trader_flow_for_testing( + bucket, + org, + config, + treasury, + signer_account, + premium_in, + signed_quote, + clock, + scenario.ctx(), + ); + transfer::public_transfer(call, recipient); +} diff --git a/contracts/tests/treasury_tests.move b/contracts/tests/treasury_tests.move index 16cc1a0b..627f626d 100644 --- a/contracts/tests/treasury_tests.move +++ b/contracts/tests/treasury_tests.move @@ -22,15 +22,12 @@ fun test_treasury_deposit_and_withdraw() { assert!(treasury::balance_of(&treasury) == 500_000, 0); let cap = th::take_admin_cap(&scenario); - treasury::withdraw(&cap, &mut treasury, 200_000, th::stranger_addr(), scenario.ctx()); + let received = treasury::withdraw(&cap, &mut treasury, 200_000, scenario.ctx()); assert!(treasury::balance_of(&treasury) == 300_000, 0); - th::return_admin_cap(&scenario, cap); - ts::return_shared(treasury); - - ts::next_tx(&mut scenario, th::stranger_addr()); - let received = ts::take_from_sender>(&scenario); assert!(received.value() == 200_000, 0); coin::burn_for_testing(received); + th::return_admin_cap(&scenario, cap); + ts::return_shared(treasury); clock.destroy_for_testing(); ts::end(scenario); @@ -48,7 +45,8 @@ fun test_treasury_withdraw_overdraw_aborts() { treasury::deposit_balance(&mut treasury, coin_in.into_balance()); let cap = th::take_admin_cap(&scenario); - treasury::withdraw(&cap, &mut treasury, 101, th::stranger_addr(), scenario.ctx()); + let received = treasury::withdraw(&cap, &mut treasury, 101, scenario.ctx()); + coin::burn_for_testing(received); th::return_admin_cap(&scenario, cap); ts::return_shared(treasury); @@ -65,7 +63,8 @@ fun test_treasury_withdraw_unknown_asset_aborts() { ts::next_tx(&mut scenario, th::admin_addr()); let mut treasury = th::take_treasury(&scenario); let cap = th::take_admin_cap(&scenario); - treasury::withdraw(&cap, &mut treasury, 1, th::stranger_addr(), scenario.ctx()); + let received = treasury::withdraw(&cap, &mut treasury, 1, scenario.ctx()); + coin::burn_for_testing(received); th::return_admin_cap(&scenario, cap); ts::return_shared(treasury); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2254c314..06aa1043 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -61,6 +61,14 @@ export type Bucket = { * Strikes within a series are the user-facing selection axis. */ export type Series = { + /** + * Org that created every bucket in this series. Series are keyed by org + * on the backend — two orgs listing the same asset/expiry stay separate. + * Pass as the shared `Org` object arg when building write/buy PTBs. + */ + org_id: string; + /** Display name from the verified-orgs allowlist; null if unnamed. */ + org_name: string | null; /** Friendly symbol (e.g. `"TBTC"`) or raw Move type if unknown. */ asset_symbol: string; asset_decimals: number | null; @@ -81,6 +89,25 @@ export type BucketsResponse = { series: Series[]; }; +/** One verified org, from `GET /orgs` (the platform allowlist). */ +export type VerifiedOrg = { + org_id: string; + name: string; +}; + +export type OrgsResponse = { + orgs: VerifiedOrg[]; +}; + +export async function fetchOrgs(): Promise { + const res = await fetch(`${API_BASE_URL}/orgs`); + if (!res.ok) { + throw new Error(`GET /orgs failed: ${res.status} ${res.statusText}`); + } + const body: OrgsResponse = await res.json(); + return body.orgs; +} + export async function fetchBuckets(): Promise { const res = await fetch(`${API_BASE_URL}/buckets`); if (!res.ok) { diff --git a/frontend/src/api/orgAdmin.ts b/frontend/src/api/orgAdmin.ts new file mode 100644 index 00000000..be0ea480 --- /dev/null +++ b/frontend/src/api/orgAdmin.ts @@ -0,0 +1,78 @@ +// Verified-orgs allowlist admin client — the authenticated mutate side of +// token-info's `/orgs` routes. +// +// Orgs are created permissionlessly on-chain; this allowlist (platform- +// controlled) decides which orgs' buckets api-service /buckets, the quoting +// service, and this frontend serve. Reads are open; writes carry the admin +// JWT (same auth-service flow as the token catalog — see useAdminAuth). + +import { TOKEN_INFO_URL } from "../config"; +import { AuthExpiredError } from "./tokenAdmin"; + +const base = TOKEN_INFO_URL.replace(/\/$/, ""); + +/** Wire shape of an allowlist row as token-info serializes it. */ +export type VerifiedOrgDto = { + org_id: string; + name: string; + enabled: boolean; +}; + +/** Full allowlist (enabled and disabled), as served by `GET /orgs`. */ +export async function listVerifiedOrgs(): Promise { + const res = await fetch(`${base}/orgs`); + if (!res.ok) throw new Error(`token-info /orgs → ${res.status}`); + return (await res.json()) as VerifiedOrgDto[]; +} + +async function mutate( + token: string, + path: string, + method: "POST" | "PUT" | "DELETE", + body?: unknown, +): Promise { + const res = await fetch(`${base}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body ? { "content-type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 401 || res.status === 403) { + throw new AuthExpiredError(await res.text()); + } + if (!res.ok) { + throw new Error(`${method} ${path} → ${res.status}: ${await res.text()}`); + } + return res; +} + +/** Verify (add or replace) an org on the allowlist. */ +export async function verifyOrg( + token: string, + input: VerifiedOrgDto, +): Promise { + const res = await mutate(token, "/orgs", "POST", input); + return (await res.json()) as VerifiedOrgDto; +} + +/** Flip an org's enabled flag (de-verify keeps history; prefer over DELETE). */ +export async function setOrgEnabled( + token: string, + org: VerifiedOrgDto, + enabled: boolean, +): Promise { + const res = await mutate( + token, + `/orgs/${encodeURIComponent(org.org_id)}`, + "PUT", + { ...org, enabled }, + ); + return (await res.json()) as VerifiedOrgDto; +} + +/** Remove an org from the allowlist entirely. */ +export async function deleteVerifiedOrg(token: string, orgId: string): Promise { + await mutate(token, `/orgs/${encodeURIComponent(orgId)}`, "DELETE"); +} diff --git a/frontend/src/api/useAdminCap.ts b/frontend/src/api/useAdminCap.ts index 1dc2da4e..8aadad38 100644 --- a/frontend/src/api/useAdminCap.ts +++ b/frontend/src/api/useAdminCap.ts @@ -40,3 +40,44 @@ export function useAdminCap(wallet: string | null) { }, }); } + +/** One `OrgCap` held by the connected wallet, with the org it authorizes. */ +export type OwnedOrgCap = { + /** The OrgCap object id (the authorizing PTB argument). */ + capId: string; + /** The shared Org object this cap administers. */ + orgId: string; +}; + +/** + * All `OrgCap`s the connected wallet holds. Orgs are permissionless — any + * wallet may hold several caps (one per org it created or was transferred). + * Each cap's `org_id` field is read from object content so the org admin UI + * can match caps to the buckets/series they govern. + */ +export function useOrgCaps(wallet: string | null) { + const client = useSuiClient(); + return useQuery({ + queryKey: ["org-caps", wallet, PACKAGE_ID], + enabled: wallet !== null && !!PACKAGE_ID, + refetchInterval: 30_000, + queryFn: async () => { + if (!wallet || !PACKAGE_ID) return []; + const structType = `${PACKAGE_ID}::org::OrgCap`; + const page = await client.getOwnedObjects({ + owner: wallet, + filter: { StructType: structType }, + options: { showContent: true }, + }); + const caps: OwnedOrgCap[] = []; + for (const item of page.data) { + const capId = item.data?.objectId; + const content = item.data?.content; + if (!capId || content?.dataType !== "moveObject") continue; + const fields = content.fields as { org_id?: string }; + if (fields.org_id) caps.push({ capId, orgId: fields.org_id }); + } + return caps; + }, + }); +} diff --git a/frontend/src/api/useVerifiedOrgs.ts b/frontend/src/api/useVerifiedOrgs.ts new file mode 100644 index 00000000..1e524a8e --- /dev/null +++ b/frontend/src/api/useVerifiedOrgs.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchOrgs, type VerifiedOrg } from "./client"; + +/** + * The verified-orgs allowlist, from api-service `GET /orgs` (the same set + * `/buckets` is filtered by). Used to label series with their org name and + * to power org filters. Changes only on platform-admin action — a slow poll + * is plenty. + */ +export function useVerifiedOrgs() { + return useQuery({ + queryKey: ["verified-orgs"], + refetchInterval: 60_000, + queryFn: fetchOrgs, + }); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index fe379f19..b8da77a1 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -14,7 +14,7 @@ import { useSponsorHealth, setSponsorEnabled, } from "../state/sponsor"; -import { useAdminCap } from "../api/useAdminCap"; +import { useAdminCap, useOrgCaps } from "../api/useAdminCap"; import { ENV } from "../config"; function shortAddress(addr: string): string { @@ -196,7 +196,11 @@ export function Header() { const account = useCurrentAccount(); const [pickerOpen, setPickerOpen] = useState(false); const adminCap = useAdminCap(account?.address ?? null); - const isAdmin = adminCap.data?.isAdmin ?? false; + const orgCaps = useOrgCaps(account?.address ?? null); + // The /admin console serves both the platform admin (AdminCap) and org + // admins (any OrgCap holder). + const isAdmin = + (adminCap.data?.isAdmin ?? false) || (orgCaps.data?.length ?? 0) > 0; return (
diff --git a/frontend/src/components/OrgManager.tsx b/frontend/src/components/OrgManager.tsx new file mode 100644 index 00000000..7b721c6a --- /dev/null +++ b/frontend/src/components/OrgManager.tsx @@ -0,0 +1,219 @@ +// Verified-orgs allowlist panel (lives on /admin, platform-admin only). +// +// Lists every allowlist row from token-info and lets the platform admin +// verify new orgs, toggle enabled, or remove rows. Mutations require the +// admin JWT (same useAdminAuth challenge flow as the token catalog). +// "Verified" = present AND enabled — this is the set api-service /buckets, +// the quoting service, and this frontend serve. + +import { useState } from "react"; +import { useCurrentAccount } from "@mysten/dapp-kit"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useAdminAuth } from "../api/useAdminAuth"; +import { AuthExpiredError } from "../api/tokenAdmin"; +import { + deleteVerifiedOrg, + listVerifiedOrgs, + setOrgEnabled, + verifyOrg, + type VerifiedOrgDto, +} from "../api/orgAdmin"; + +export function OrgManager({ flash }: { flash: (msg: string) => void }) { + const account = useCurrentAccount(); + const auth = useAdminAuth(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(null); + + const orgs = useQuery({ + queryKey: ["verified-orgs-admin"], + queryFn: listVerifiedOrgs, + }); + + const refetch = () => { + queryClient.invalidateQueries({ queryKey: ["verified-orgs-admin"] }); + queryClient.invalidateQueries({ queryKey: ["verified-orgs"] }); + queryClient.invalidateQueries({ queryKey: ["buckets"] }); + }; + + const runAuthed = async ( + key: string, + fn: (token: string) => Promise, + ok: string, + ) => { + setBusy(key); + try { + const token = await auth.getValidToken(); + await fn(token); + flash(`✓ ${ok}`); + refetch(); + } catch (e) { + if (e instanceof AuthExpiredError) { + auth.signOut(); + flash("session expired · sign in again"); + } else { + flash(`failed · ${e instanceof Error ? e.message : String(e)}`); + } + } finally { + setBusy(null); + } + }; + + return ( +
+
+

Verified orgs

+
+ orgs are permissionless on chain — only allowlisted (verified) orgs' + buckets are served by the API, quoting, and this app. +
+
+ + {!auth.isSignedIn && ( +
+ +
+ )} + + {orgs.isLoading &&
loading orgs…
} + {orgs.error && ( +
failed to load orgs · {orgs.error.message}
+ )} + {orgs.data && orgs.data.length === 0 && ( +
no verified orgs yet.
+ )} + + {orgs.data && orgs.data.length > 0 && ( +
+
+ Name + Org id + Status + Actions +
+ {orgs.data.map((o) => ( +
+ {o.name} + + + {o.org_id.slice(0, 8)}…{o.org_id.slice(-6)} + + + + {o.enabled ? ( + verified + ) : ( + disabled + )} + + + + + +
+ ))} +
+ )} + + + runAuthed( + "verify", + (token) => verifyOrg(token, input).then(() => {}), + `verified ${input.name}`, + ) + } + /> +
+ ); +} + +function VerifyOrgForm({ + disabled, + busy, + onVerify, +}: { + disabled: boolean; + busy: boolean; + onVerify: (input: VerifiedOrgDto) => void; +}) { + const [orgId, setOrgId] = useState(""); + const [name, setName] = useState(""); + const ready = orgId.startsWith("0x") && name.length > 0; + + return ( +
+
+ Verify an org +
+
+
+ + setOrgId(e.target.value.trim())} + /> +
+
+ + setName(e.target.value)} + /> +
+
+ +
+ ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index c1c4ed5e..945a17c3 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -50,6 +50,8 @@ export const CHARTS_URL: string = export let PACKAGE_ID: string | undefined; export let PROTOCOL_CONFIG_ID: string | undefined; export let TREASURY_ID: string | undefined; +/** The platform's own org id ("SuiOptions") — for badging in the UI. */ +export let PLATFORM_ORG_ID: string | undefined; // DeepBook v3 deployment ids (SO-151), served by token-info alongside the // protocol ids. All `undefined` on networks without a DeepBook deployment @@ -119,6 +121,8 @@ type PackageInfoDto = { packageId: string; protocolConfigId: string; treasuryId?: string | null; + /** The platform's own org ("SuiOptions"), created at deploy time. */ + platformOrgId?: string | null; network?: string; testTokens?: { packageId: string; @@ -163,6 +167,7 @@ export async function initConfig(): Promise { PACKAGE_ID = info.packageId; PROTOCOL_CONFIG_ID = info.protocolConfigId; TREASURY_ID = info.treasuryId ?? undefined; + PLATFORM_ORG_ID = info.platformOrgId ?? undefined; const db = info.deepbook; DEEPBOOK_PACKAGE_ID = db?.packageId; diff --git a/frontend/src/screens/Admin.tsx b/frontend/src/screens/Admin.tsx index 6efe2636..c41a6a48 100644 --- a/frontend/src/screens/Admin.tsx +++ b/frontend/src/screens/Admin.tsx @@ -1,11 +1,19 @@ -// Admin console. Visible only to wallets that hold an `AdminCap` -// (`useAdminCap`); the nav link in `Header` is gated by the same check and -// non-admins hitting `/admin` directly are redirected to `/earn`. +// Admin console. Serves two authority levels: // -// Surfaces every admin-gated contract entrypoint: -// - per-bucket invalidate / revalidate / cleanup (bucket.move) -// - set protocol fee (set_fee_bps) (admin.move) -// - withdraw treasury fees / (re)create the treasury (treasury.move) +// - **Platform admin** — wallet holds the protocol `AdminCap` +// (`useAdminCap`): protocol fee, pause, treasury, supported-token +// catalog, the verified-orgs allowlist, and emergency +// invalidate/revalidate overrides on ANY org's bucket. +// - **Org admin** — wallet holds one or more `OrgCap`s (`useOrgCaps`): +// org fee + fee withdrawal, and invalidate / revalidate / cleanup on +// the org's own buckets. +// +// Creating an org is PERMISSIONLESS — the create form shows for any +// connected wallet. The nav link in `Header` is gated on either capability; +// wallets with neither hitting `/admin` directly are redirected to `/earn`, +// but the create-org form remains reachable for them via the redirect-free +// loading state below only when they hold a cap (org creation for fresh +// wallets happens through this same screen once linked from docs). import { useMemo, useState } from "react"; import { Navigate } from "react-router-dom"; @@ -16,17 +24,26 @@ import { useSubmitTransaction } from "../tx/submit"; import { Header } from "../components/Header"; import { Toast } from "../components/Toast"; import { TokenManager } from "../components/TokenManager"; +import { OrgManager } from "../components/OrgManager"; import { useBuckets } from "../api/useBuckets"; -import { useAdminCap } from "../api/useAdminCap"; +import { useAdminCap, useOrgCaps, type OwnedOrgCap } from "../api/useAdminCap"; import type { Bucket, Series } from "../api/client"; import { - buildCleanupBucketTx, buildCreateTreasuryTx, buildInvalidateBucketTx, buildRevalidateBucketTx, buildSetFeeBpsTx, + buildSetPauseTx, buildWithdrawTx, } from "../tx/admin"; +import { + buildCreateOrgTx, + buildOrgCleanupBucketTx, + buildOrgInvalidateBucketTx, + buildOrgRevalidateBucketTx, + buildOrgWithdrawTx, + buildSetOrgFeeTx, +} from "../tx/org"; import { PROTOCOL_CONFIG_ID, TREASURY_ID } from "../config"; function scaleRaw(raw: string, decimals: number | null): string { @@ -35,6 +52,12 @@ function scaleRaw(raw: string, decimals: number | null): string { return v.toLocaleString("en-US", { maximumFractionDigits: 6 }); } +/** Normalize object ids for comparison (0x-prefix + lowercase). */ +function sameId(a: string, b: string): boolean { + const norm = (s: string) => s.replace(/^0x/, "").replace(/^0+/, "").toLowerCase(); + return norm(a) === norm(b); +} + type FlatBucket = { series: Series; bucket: Bucket; @@ -45,6 +68,7 @@ export function Admin() { const account = useCurrentAccount(); const wallet = account?.address ?? null; const adminCap = useAdminCap(wallet); + const orgCaps = useOrgCaps(wallet); const buckets = useBuckets(); const submitTx = useSubmitTransaction(); @@ -63,10 +87,10 @@ export function Admin() { return out; }, [buckets.data, now]); - // Wallet must be connected and admin. While the cap query resolves we - // show a spinner rather than flashing a redirect. + // Wallet must be connected and hold either capability. While the cap + // queries resolve we show a spinner rather than flashing a redirect. if (!wallet) return ; - if (adminCap.isLoading) { + if (adminCap.isLoading || orgCaps.isLoading) { return (
@@ -76,10 +100,16 @@ export function Admin() {
); } - if (!adminCap.data?.isAdmin || !adminCap.data.adminCapId) { + const isPlatformAdmin = (adminCap.data?.isAdmin ?? false) && !!adminCap.data?.adminCapId; + const adminCapId = adminCap.data?.adminCapId ?? null; + const myOrgCaps: OwnedOrgCap[] = orgCaps.data ?? []; + if (!isPlatformAdmin && myOrgCaps.length === 0) { return ; } - const adminCapId = adminCap.data.adminCapId; + + /** The wallet's OrgCap for a series' org, if it holds one. */ + const capForOrg = (orgId: string): OwnedOrgCap | undefined => + myOrgCaps.find((c) => sameId(c.orgId, orgId)); const flash = (msg: string) => { setToast(msg); @@ -95,6 +125,7 @@ export function Admin() { await submitTx(tx); flash(`✓ ${ok}`); buckets.refetch(); + orgCaps.refetch(); } catch (err) { const message = err instanceof Error ? err.message : String(err); flash(`failed · ${message}`); @@ -109,11 +140,18 @@ export function Admin() {
-
privileged · AdminCap holder
-

Admin

-
- cap {adminCapId.slice(0, 6)}…{adminCapId.slice(-4)} +
+ privileged ·{" "} + {isPlatformAdmin + ? "AdminCap holder" + : `org admin (${myOrgCaps.length} cap${myOrgCaps.length === 1 ? "" : "s"})`}
+

Admin

+ {adminCapId && ( +
+ cap {adminCapId.slice(0, 6)}…{adminCapId.slice(-4)} +
+ )}
{/* ── Buckets ─────────────────────────────────────────────── */} @@ -121,8 +159,9 @@ export function Admin() {

Call options & buckets

- every on-chain bucket · invalidate freezes new writes - (exercise/redeem unaffected) + every verified bucket · invalidate freezes new writes + (exercise/redeem unaffected) · org caps gate their own buckets; + the AdminCap can override-gate any bucket
@@ -149,7 +188,7 @@ export function Admin() { {flat.length > 0 && (
- Asset + Asset · org Strike Expiry Written / Exercised @@ -158,10 +197,21 @@ export function Admin() {
{flat.map(({ series, bucket, expired }) => { const key = bucket.bucket_id; + const orgCap = capForOrg(series.org_id); + const gateParams = () => ({ + bucketId: bucket.bucket_id, + underlyingCoinType: series.asset_coin_type, + settlementCoinType: series.settlement_coin_type, + callCoinType: bucket.call_coin_type, + reason, + }); return (
{series.asset_symbol}/{series.settlement_symbol} + {series.org_name && ( + {series.org_name} + )} {bucket.bucket_id.slice(0, 6)}…{bucket.bucket_id.slice(-4)} @@ -186,22 +236,24 @@ export function Admin() { )} - {!expired && !bucket.invalidated && ( + {!expired && !bucket.invalidated && (orgCap || isPlatformAdmin) && ( )} - {!expired && bucket.invalidated && ( + {!expired && bucket.invalidated && (orgCap || isPlatformAdmin) && ( )} - {expired && ( + {expired && orgCap && (
{toast && } @@ -287,6 +365,181 @@ export function Admin() { ); } +// ── Org admin (fee + withdraw) ────────────────────────────────────────── + +function OrgAdminForms({ + cap, + busy, + onRun, +}: { + cap: OwnedOrgCap; + busy: string | null; + onRun: (key: string, build: () => Transaction, ok: string) => void; +}) { + const [bps, setBps] = useState("0"); + const [coinType, setCoinType] = useState(""); + const [amount, setAmount] = useState(""); + const [recipient, setRecipient] = useState(""); + const withdrawReady = coinType && amount && recipient; + + return ( +
+
+

Your org

+
+ org{" "} + + {cap.orgId.slice(0, 8)}…{cap.orgId.slice(-6)} + {" "} + · fee in bps of gross premium (max 1000) · withdraw accrued org fees +
+
+
+ + setBps(e.target.value)} + /> + +
+ + +
+ + setCoinType(e.target.value.trim())} + /> + + + setAmount(e.target.value)} + /> + + + setRecipient(e.target.value.trim())} + /> + +
+ +
+ ); +} + +// ── Create org (permissionless) ───────────────────────────────────────── + +function CreateOrgForm({ + busy, + wallet, + onSubmit, +}: { + busy: string | null; + wallet: string; + onSubmit: (build: () => Transaction) => void; +}) { + const [name, setName] = useState(""); + const [bps, setBps] = useState("0"); + const ready = name.length > 0 && name.length <= 64; + + return ( +
+
+

Create an org

+
+ permissionless — anyone can create an org and roll buckets under it + (via a self-hosted option-scheduler). Buckets only appear in this app + once the platform verifies the org. +
+
+
+ + setName(e.target.value)} + /> + + + setBps(e.target.value)} + /> + +
+ +
+ ); +} + // ── Protocol fee ─────────────────────────────────────────────────────── function SetFeeForm({ @@ -306,7 +559,8 @@ function SetFeeForm({

Protocol fee

- basis points · max 1000 (10%). e.g. 50 = 0.5%. + the protocol-level skim, taken on every write across ALL orgs · + basis points · max 1000 (10%). e.g. 50 = 0.5%. Org fees stack on top.
{configMissing && ( @@ -346,6 +600,68 @@ function SetFeeForm({ ); } +// ── Pause ────────────────────────────────────────────────────────────── + +function PauseForm({ + busy, + onSubmit, + adminCapId, +}: { + busy: string | null; + onSubmit: (build: () => Transaction, ok: string) => void; + adminCapId: string; +}) { + const configMissing = !PROTOCOL_CONFIG_ID; + + return ( +
+
+

Emergency pause

+
+ blocks NEW WRITES across all orgs' buckets — exercises, redeems, and + burns are never blocked. +
+
+
+ + +
+
+ ); +} + // ── Treasury ─────────────────────────────────────────────────────────── function TreasuryForms({ diff --git a/frontend/src/state/composer.ts b/frontend/src/state/composer.ts index 36a43726..83fbd016 100644 --- a/frontend/src/state/composer.ts +++ b/frontend/src/state/composer.ts @@ -437,6 +437,7 @@ export function useComposerState({ view === "trader" ? buildBuyTx({ entry, + orgId: series.org_id, underlyingCoinType: series.asset_coin_type, settlementCoinType: series.settlement_coin_type, callCoinType, @@ -444,6 +445,7 @@ export function useComposerState({ }) : buildWriteTx({ entry, + orgId: series.org_id, underlyingCoinType: series.asset_coin_type, settlementCoinType: series.settlement_coin_type, callCoinType, diff --git a/frontend/src/tx/admin.ts b/frontend/src/tx/admin.ts index c4f24a80..ba04ee35 100644 --- a/frontend/src/tx/admin.ts +++ b/frontend/src/tx/admin.ts @@ -1,20 +1,25 @@ // Programmable Transaction Block builders for the admin page. // +// Two authority levels exist post-orgs: +// - Platform admin (`AdminCap`): protocol fee, pause, treasury, and +// emergency invalidate/revalidate overrides on ANY org's bucket. +// - Org admin (`OrgCap`): gates + cleanup on the org's own buckets — +// see `tx/org.ts`. +// // Shapes mirror the Move signatures in: // contracts/sources/admin.move -// - admin::set_fee_bps(&AdminCap, &mut ProtocolConfig, new_bps) +// - admin::set_protocol_fee_bps(&AdminCap, &mut ProtocolConfig, new_bps) +// - admin::set_pause(&AdminCap, &mut ProtocolConfig, paused, ctx) // contracts/sources/bucket.move -// - bucket::invalidate_bucket(&AdminCap, &mut Bucket, reason, &Clock, ctx) -// - bucket::revalidate_bucket(&AdminCap, &mut Bucket, reason, &Clock, ctx) -// - bucket::cleanup_bucket(&AdminCap, Bucket, &Clock) +// - bucket::admin_invalidate_bucket(&AdminCap, &mut Bucket, reason, &Clock, ctx) +// - bucket::admin_revalidate_bucket(&AdminCap, &mut Bucket, reason, &Clock, ctx) // contracts/sources/treasury.move -// - treasury::withdraw(&AdminCap, &mut Treasury, amount, recipient, ctx) +// - treasury::withdraw(&AdminCap, &mut Treasury, amount, ctx): Coin // - treasury::create_and_share(&AdminCap, ctx) // -// Every admin call passes the caller's owned `AdminCap` object id (see -// `useAdminCap`) as its authorizing argument. Shared objects (Bucket, -// ProtocolConfig, Treasury) are passed by id; dapp-kit's SuiClient -// resolves their shared metadata. +// `treasury::withdraw` RETURNS the coin — the PTB transfers it onward. +// Shared objects (Bucket, ProtocolConfig, Treasury) are passed by id; +// dapp-kit's SuiClient resolves their shared metadata. import { Transaction } from "@mysten/sui/transactions"; import { SUI_CLOCK_OBJECT_ID } from "@mysten/sui/utils"; @@ -45,11 +50,12 @@ export type BucketGateParams = { reason: string; }; +/** Platform-admin override: invalidate ANY org's bucket. */ export function buildInvalidateBucketTx(p: BucketGateParams): Transaction { const pkg = requirePackage(); const tx = new Transaction(); tx.moveCall({ - target: `${pkg}::bucket::invalidate_bucket`, + target: `${pkg}::bucket::admin_invalidate_bucket`, typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], arguments: [ tx.object(p.adminCapId), @@ -61,11 +67,12 @@ export function buildInvalidateBucketTx(p: BucketGateParams): Transaction { return tx; } +/** Platform-admin override: revalidate ANY org's bucket. */ export function buildRevalidateBucketTx(p: BucketGateParams): Transaction { const pkg = requirePackage(); const tx = new Transaction(); tx.moveCall({ - target: `${pkg}::bucket::revalidate_bucket`, + target: `${pkg}::bucket::admin_revalidate_bucket`, typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], arguments: [ tx.object(p.adminCapId), @@ -77,47 +84,42 @@ export function buildRevalidateBucketTx(p: BucketGateParams): Transaction { return tx; } -export type CleanupBucketParams = { +export type SetFeeBpsParams = { adminCapId: string; - bucketId: string; - underlyingCoinType: string; - settlementCoinType: string; - /** The bucket's per-bucket option coin type (`Call` type arg). */ - callCoinType: string; + protocolConfigId: string; + newBps: bigint; }; -export function buildCleanupBucketTx(p: CleanupBucketParams): Transaction { +export function buildSetFeeBpsTx(p: SetFeeBpsParams): Transaction { const pkg = requirePackage(); const tx = new Transaction(); - // `cleanup_bucket` takes the Bucket by value (deletes it). It still - // resolves as a shared-object arg from its id. tx.moveCall({ - target: `${pkg}::bucket::cleanup_bucket`, - typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], + target: `${pkg}::admin::set_protocol_fee_bps`, arguments: [ tx.object(p.adminCapId), - tx.object(p.bucketId), - tx.object(SUI_CLOCK_OBJECT_ID), + tx.object(p.protocolConfigId), + tx.pure.u64(p.newBps), ], }); return tx; } -export type SetFeeBpsParams = { +export type SetPauseParams = { adminCapId: string; protocolConfigId: string; - newBps: bigint; + paused: boolean; }; -export function buildSetFeeBpsTx(p: SetFeeBpsParams): Transaction { +/** Protocol-wide emergency brake: blocks NEW WRITES only across all orgs. */ +export function buildSetPauseTx(p: SetPauseParams): Transaction { const pkg = requirePackage(); const tx = new Transaction(); tx.moveCall({ - target: `${pkg}::admin::set_fee_bps`, + target: `${pkg}::admin::set_pause`, arguments: [ tx.object(p.adminCapId), tx.object(p.protocolConfigId), - tx.pure.u64(p.newBps), + tx.pure.bool(p.paused), ], }); return tx; @@ -136,16 +138,17 @@ export type WithdrawParams = { export function buildWithdrawTx(p: WithdrawParams): Transaction { const pkg = requirePackage(); const tx = new Transaction(); - tx.moveCall({ + // `withdraw` returns the coin; the PTB routes it to the recipient. + const [coin] = tx.moveCall({ target: `${pkg}::treasury::withdraw`, typeArguments: [p.coinType], arguments: [ tx.object(p.adminCapId), tx.object(p.treasuryId), tx.pure.u64(p.amountRaw), - tx.pure.address(p.recipient), ], }); + tx.transferObjects([coin], p.recipient); return tx; } diff --git a/frontend/src/tx/composer.ts b/frontend/src/tx/composer.ts index 66fc118a..487bfb1a 100644 --- a/frontend/src/tx/composer.ts +++ b/frontend/src/tx/composer.ts @@ -1,20 +1,27 @@ -// Programmable Transaction Block builder for the Earn (writer) composer. +// Programmable Transaction Block builders for the Earn (writer) and Buy +// (trader) composers. // -// Shape mirrors the Move signature in `contracts/sources/bucket.move`: -// bucket::execute_write(bucket, config, treasury, signer_account, -// underlying_in, premium_in, flow, position_recipient, -// call_token_recipient, signed_quote, clock, ctx) +// Shapes mirror the split Move signatures in `contracts/sources/bucket.move`: +// bucket::execute_write_writer_flow(bucket, org, config, +// treasury, signer_account, underlying_in, signed_quote, clock, ctx) +// -> (Position, Coin) +// bucket::execute_write_trader_flow(bucket, org, config, +// treasury, signer_account, premium_in, signed_quote, clock, ctx) +// -> Coin +// +// The contract RETURNS the executor's side to the PTB; we transfer it to the +// connected wallet with a trailing `transferObjects`. The signer/MM's side +// is still routed by the contract to the quote's `signer_token_recipient`. // // `Quote` / `SignedQuote` are Move structs, not pure args, so we rebuild // them on chain from the MM's signed RFQ entry via `quote::new_quote` + // `quote::new_signed_quote`. The struct must BCS-encode to the exact bytes // the MM signed, so every field is reconstructed verbatim from the quote. // -// Writer-flow invariants enforced by `execute_write_with_quote` -// (FlowKind::Writer): signer_recipient == call_token_recipient; -// premium_in.value() == 0; underlying_in.value() == write_amount. The writer -// (ctx.sender()) receives the net premium and the Position NFT; the MM/buyer -// (signer_token_recipient) receives the CallOption. +// NOTE: the gas-station only sponsors PTBs that match its templates +// (`rust-backend/crates/sui-tx/src/tx/template.rs`). Any change to the +// shapes built here MUST update those templates in the same change set — +// see `.claude/ptb-sync.md`. import { Transaction, coinWithBalance } from "@mysten/sui/transactions"; import { SUI_CLOCK_OBJECT_ID, fromHex } from "@mysten/sui/utils"; @@ -38,23 +45,26 @@ function strip0x(s: string): string { export type WriteParams = { /** Chosen MM quote (default: the best, `quotes[0]`). */ entry: RfqQuoteEntry; + /** The bucket's Org (shared object; org fee is deposited into it). */ + orgId: string; /** `series.asset_coin_type` — the `Underlying` type arg. */ underlyingCoinType: string; /** `series.settlement_coin_type` — the `Settlement` type arg. */ settlementCoinType: string; /** The bucket's per-bucket option coin type (`Call` type arg). */ callCoinType: string; - /** Connected wallet; receives the Position NFT and net premium. */ + /** Connected wallet; receives the returned Position NFT and net premium. */ writer: string; }; /** - * Build a writer-flow `execute_write` PTB from a signed RFQ quote. + * Build a writer-flow `execute_write_writer_flow` PTB from a signed RFQ + * quote. * * The signer's `Account` (`signer_account_id`) is a shared object * (`account::create_and_share_account` → `transfer::share_object`), so * `tx.object(...)` resolves its shared metadata via dapp-kit's SuiClient, - * the same way the bucket / config / treasury args do elsewhere. + * the same way the bucket / org / config / treasury args do elsewhere. */ export function buildWriteTx(p: WriteParams): Transaction { const pkg = requirePackage(); @@ -88,65 +98,60 @@ export function buildWriteTx(p: WriteParams): Transaction { ], }); - const flow = tx.moveCall({ target: `${pkg}::bucket::writer_flow` }); - - // Writer supplies exactly write_amount of underlying; the premium side is - // a zero Settlement coin (the MM's premium is debited from their Account). + // Writer supplies exactly write_amount of underlying; the MM's premium is + // debited from their Account inside the contract. const underlying = tx.add( coinWithBalance({ balance: BigInt(q.write_amount), type: p.underlyingCoinType, }), ); - const premiumZero = tx.moveCall({ - target: "0x2::coin::zero", - typeArguments: [p.settlementCoinType], - }); - tx.moveCall({ - target: `${pkg}::bucket::execute_write`, + const [position, netPremium] = tx.moveCall({ + target: `${pkg}::bucket::execute_write_writer_flow`, typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], arguments: [ tx.object(q.bucket_id), + tx.object(p.orgId), tx.object(PROTOCOL_CONFIG_ID), tx.object(TREASURY_ID), tx.object(q.signer_account_id), // MM Account (shared, mutable) underlying, - premiumZero, - flow, - tx.pure.address(p.writer), // position_recipient = the writer - tx.pure.address(q.signer_token_recipient), // call_token_recipient = the MM/buyer signedQuote, tx.object(SUI_CLOCK_OBJECT_ID), ], }); + // Returned (Position, net premium) → the writer. The MM's Coin is + // contract-routed to the quote's signer_token_recipient. + tx.transferObjects([position, netPremium], p.writer); + return tx; } export type BuyParams = { /** Chosen MM quote (default: the best, `quotes[0]`). */ entry: RfqQuoteEntry; + /** The bucket's Org (shared object; org fee is deposited into it). */ + orgId: string; /** `series.asset_coin_type` — the `Underlying` type arg. */ underlyingCoinType: string; /** `series.settlement_coin_type` — the `Settlement` type arg. */ settlementCoinType: string; /** The bucket's per-bucket option coin type (`Call` type arg). */ callCoinType: string; - /** Connected wallet; pays the premium and receives the CallOption. */ + /** Connected wallet; pays the premium and receives the returned CallOption. */ trader: string; }; /** - * Build a trader-flow `execute_write` PTB from a signed RFQ quote. + * Build a trader-flow `execute_write_trader_flow` PTB from a signed RFQ + * quote. * - * Mirror of {@link buildWriteTx} for the Buy page. Trader-flow invariants - * enforced by `execute_write_with_quote` (FlowKind::Trader): - * signer_recipient == position_recipient; underlying_in.value() == 0; - * premium_in.value() == gross_premium. The Writer MM (signer) supplies the - * underlying from their Account and receives the Position NFT; the trader - * (ctx.sender()) pays the premium from their wallet and receives the - * CallOption coin. + * Mirror of {@link buildWriteTx} for the Buy page. The Writer MM (signer) + * supplies the underlying from their Account and is contract-routed the + * Position NFT; the trader pays the premium from their wallet and receives + * the returned `Coin` via the trailing transfer. */ export function buildBuyTx(p: BuyParams): Transaction { const pkg = requirePackage(); @@ -180,38 +185,33 @@ export function buildBuyTx(p: BuyParams): Transaction { ], }); - const flow = tx.moveCall({ target: `${pkg}::bucket::trader_flow` }); - - // Trader pays exactly the premium in settlement; the underlying side is a - // zero coin (the MM's underlying is debited from their Account). + // Trader pays exactly the premium in settlement; the MM's underlying is + // debited from their Account inside the contract. const premium = tx.add( coinWithBalance({ balance: BigInt(q.premium), type: p.settlementCoinType, }), ); - const underlyingZero = tx.moveCall({ - target: "0x2::coin::zero", - typeArguments: [p.underlyingCoinType], - }); - tx.moveCall({ - target: `${pkg}::bucket::execute_write`, + const [callCoin] = tx.moveCall({ + target: `${pkg}::bucket::execute_write_trader_flow`, typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], arguments: [ tx.object(q.bucket_id), + tx.object(p.orgId), tx.object(PROTOCOL_CONFIG_ID), tx.object(TREASURY_ID), tx.object(q.signer_account_id), // MM Account (shared, mutable) - underlyingZero, premium, - flow, - tx.pure.address(q.signer_token_recipient), // position_recipient = the MM/writer - tx.pure.address(p.trader), // call_token_recipient = the trader signedQuote, tx.object(SUI_CLOCK_OBJECT_ID), ], }); + // Returned Coin → the trader. The MM's Position is contract-routed + // to the quote's signer_token_recipient. + tx.transferObjects([callCoin], p.trader); + return tx; } diff --git a/frontend/src/tx/org.ts b/frontend/src/tx/org.ts new file mode 100644 index 00000000..f5666b85 --- /dev/null +++ b/frontend/src/tx/org.ts @@ -0,0 +1,167 @@ +// Programmable Transaction Block builders for org operations. +// +// Orgs are PERMISSIONLESS: any wallet can create one. The org's `OrgCap` +// (returned by `create_org`) is the sole authority over the org — fee +// setting, fee withdrawal, and bucket lifecycle (invalidate / revalidate / +// cleanup) on the org's own buckets. +// +// Shapes mirror the Move signatures in: +// contracts/sources/org.move +// - org::create_org(name, fee_bps, ctx): OrgCap +// - org::set_org_fee_bps(&OrgCap, &mut Org, new_bps) +// - org::withdraw(&OrgCap, &mut Org, amount, ctx): Coin +// contracts/sources/bucket.move +// - bucket::invalidate_bucket(&OrgCap, &mut Bucket, reason, &Clock, ctx) +// - bucket::revalidate_bucket(&OrgCap, &mut Bucket, reason, &Clock, ctx) +// - bucket::cleanup_bucket(&OrgCap, Bucket, &Clock): TreasuryCap +// +// `create_org` / `withdraw` / `cleanup_bucket` RETURN values, which the PTB +// must route (the wallet sender keeps them here). + +import { Transaction } from "@mysten/sui/transactions"; +import { SUI_CLOCK_OBJECT_ID } from "@mysten/sui/utils"; + +import { ENV, PACKAGE_ID } from "../config"; + +function requirePackage(): string { + if (!PACKAGE_ID) { + throw new Error( + `No deployment for VITE_ENVIRONMENT="${ENV}" (token-info returned no packageId) — cannot build org PTBs`, + ); + } + return PACKAGE_ID; +} + +function reasonBytes(reason: string): number[] { + return Array.from(new TextEncoder().encode(reason)); +} + +export type CreateOrgParams = { + /** Display name, 1..=64 bytes. Cosmetic — the org id is the identity. */ + name: string; + /** Org fee in basis points of gross premium (0..=1000). */ + feeBps: bigint; + /** Wallet that receives the returned OrgCap (usually the sender). */ + capRecipient: string; +}; + +export function buildCreateOrgTx(p: CreateOrgParams): Transaction { + const pkg = requirePackage(); + const tx = new Transaction(); + const [cap] = tx.moveCall({ + target: `${pkg}::org::create_org`, + arguments: [tx.pure.string(p.name), tx.pure.u64(p.feeBps)], + }); + tx.transferObjects([cap], p.capRecipient); + return tx; +} + +export type SetOrgFeeParams = { + orgCapId: string; + orgId: string; + newBps: bigint; +}; + +export function buildSetOrgFeeTx(p: SetOrgFeeParams): Transaction { + const pkg = requirePackage(); + const tx = new Transaction(); + tx.moveCall({ + target: `${pkg}::org::set_org_fee_bps`, + arguments: [tx.object(p.orgCapId), tx.object(p.orgId), tx.pure.u64(p.newBps)], + }); + return tx; +} + +export type OrgWithdrawParams = { + orgCapId: string; + orgId: string; + /** Move type `T` of the balance to withdraw. */ + coinType: string; + /** Amount in the coin's smallest units (raw u64). */ + amountRaw: bigint; + recipient: string; +}; + +export function buildOrgWithdrawTx(p: OrgWithdrawParams): Transaction { + const pkg = requirePackage(); + const tx = new Transaction(); + const [coin] = tx.moveCall({ + target: `${pkg}::org::withdraw`, + typeArguments: [p.coinType], + arguments: [tx.object(p.orgCapId), tx.object(p.orgId), tx.pure.u64(p.amountRaw)], + }); + tx.transferObjects([coin], p.recipient); + return tx; +} + +export type OrgBucketGateParams = { + orgCapId: string; + bucketId: string; + underlyingCoinType: string; + settlementCoinType: string; + /** The bucket's per-bucket option coin type (`Call` type arg). */ + callCoinType: string; + reason: string; +}; + +export function buildOrgInvalidateBucketTx(p: OrgBucketGateParams): Transaction { + const pkg = requirePackage(); + const tx = new Transaction(); + tx.moveCall({ + target: `${pkg}::bucket::invalidate_bucket`, + typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], + arguments: [ + tx.object(p.orgCapId), + tx.object(p.bucketId), + tx.pure.vector("u8", reasonBytes(p.reason)), + tx.object(SUI_CLOCK_OBJECT_ID), + ], + }); + return tx; +} + +export function buildOrgRevalidateBucketTx(p: OrgBucketGateParams): Transaction { + const pkg = requirePackage(); + const tx = new Transaction(); + tx.moveCall({ + target: `${pkg}::bucket::revalidate_bucket`, + typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], + arguments: [ + tx.object(p.orgCapId), + tx.object(p.bucketId), + tx.pure.vector("u8", reasonBytes(p.reason)), + tx.object(SUI_CLOCK_OBJECT_ID), + ], + }); + return tx; +} + +export type OrgCleanupBucketParams = { + orgCapId: string; + bucketId: string; + underlyingCoinType: string; + settlementCoinType: string; + /** The bucket's per-bucket option coin type (`Call` type arg). */ + callCoinType: string; + /** Wallet that keeps the returned TreasuryCap (usually the sender). */ + capRecipient: string; +}; + +export function buildOrgCleanupBucketTx(p: OrgCleanupBucketParams): Transaction { + const pkg = requirePackage(); + const tx = new Transaction(); + // `cleanup_bucket` takes the Bucket by value (deletes it) and RETURNS the + // option coin's TreasuryCap — outstanding option coins may still exist, so + // the cap can't be dropped on-chain. Route it to the org admin's wallet. + const [treasuryCap] = tx.moveCall({ + target: `${pkg}::bucket::cleanup_bucket`, + typeArguments: [p.underlyingCoinType, p.settlementCoinType, p.callCoinType], + arguments: [ + tx.object(p.orgCapId), + tx.object(p.bucketId), + tx.object(SUI_CLOCK_OBJECT_ID), + ], + }); + tx.transferObjects([treasuryCap], p.capRecipient); + return tx; +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index c0b33945..83438640 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/theme.ts","./src/types.ts","./src/api/authclient.ts","./src/api/client.ts","./src/api/pyth.ts","./src/api/quoting.ts","./src/api/quotingclient.ts","./src/api/sponsor.ts","./src/api/tokenadmin.ts","./src/api/useadminauth.ts","./src/api/useadmincap.ts","./src/api/usebuckets.ts","./src/api/usebulkview.ts","./src/api/usecalltokenlots.ts","./src/api/usecoinbalance.ts","./src/api/useindexerprogress.ts","./src/api/useownedcalloptions.ts","./src/api/useownedpositions.ts","./src/api/usepositions.ts","./src/api/usepythprice.ts","./src/api/usequotingstatus.ts","./src/api/userfq.ts","./src/api/usesupportedtokens.ts","./src/components/actionmodal.tsx","./src/components/amountinput.tsx","./src/components/bucketbar.tsx","./src/components/confirmmodal.tsx","./src/components/header.tsx","./src/components/indexerprogressbar.tsx","./src/components/livebuckets.tsx","./src/components/panels.tsx","./src/components/positioncards.tsx","./src/components/quotefeed.tsx","./src/components/striketiles.tsx","./src/components/tideline.tsx","./src/components/toast.tsx","./src/components/tokenmanager.tsx","./src/screens/activity.tsx","./src/screens/admin.tsx","./src/screens/composer.tsx","./src/screens/dashboard.tsx","./src/screens/debug.tsx","./src/screens/faucet.tsx","./src/state/activity.ts","./src/state/composer.ts","./src/state/dashboard.ts","./src/state/sponsor.ts","./src/tx/admin.ts","./src/tx/composer.ts","./src/tx/dashboard.ts","./src/tx/faucet.ts","./src/tx/submit.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/theme.ts","./src/types.ts","./src/api/authclient.ts","./src/api/charts.ts","./src/api/client.ts","./src/api/deepbook.ts","./src/api/orgadmin.ts","./src/api/pyth.ts","./src/api/quoting.ts","./src/api/quotingclient.ts","./src/api/sponsor.ts","./src/api/tokenadmin.ts","./src/api/useadminauth.ts","./src/api/useadmincap.ts","./src/api/usebuckets.ts","./src/api/usebulkview.ts","./src/api/usecalltokenlots.ts","./src/api/usecoinbalance.ts","./src/api/useindexerprogress.ts","./src/api/useownedcalloptions.ts","./src/api/useownedpositions.ts","./src/api/usepositions.ts","./src/api/usepythprice.ts","./src/api/usequotingstatus.ts","./src/api/userfq.ts","./src/api/usesupportedtokens.ts","./src/api/useverifiedorgs.ts","./src/components/actionmodal.tsx","./src/components/amountinput.tsx","./src/components/bucketbar.tsx","./src/components/chartpanel.tsx","./src/components/confirmmodal.tsx","./src/components/createvenuecard.tsx","./src/components/header.tsx","./src/components/indexerprogressbar.tsx","./src/components/livebuckets.tsx","./src/components/orgmanager.tsx","./src/components/panels.tsx","./src/components/positioncards.tsx","./src/components/quotefeed.tsx","./src/components/striketiles.tsx","./src/components/tideline.tsx","./src/components/toast.tsx","./src/components/tokenlogo.tsx","./src/components/tokenmanager.tsx","./src/components/tradepanel.tsx","./src/screens/activity.tsx","./src/screens/admin.tsx","./src/screens/composer.tsx","./src/screens/dashboard.tsx","./src/screens/debug.tsx","./src/screens/faucet.tsx","./src/state/activity.ts","./src/state/composer.ts","./src/state/dashboard.ts","./src/state/sponsor.ts","./src/tx/admin.ts","./src/tx/composer.ts","./src/tx/dashboard.ts","./src/tx/deepbook.ts","./src/tx/faucet.ts","./src/tx/org.ts","./src/tx/submit.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/rust-backend/Cargo.lock b/rust-backend/Cargo.lock index 7b59c683..5b5eba9f 100644 --- a/rust-backend/Cargo.lock +++ b/rust-backend/Cargo.lock @@ -4279,6 +4279,7 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", + "token-info-client", "tokio", "tokio-tungstenite 0.24.0", "tracing", @@ -10516,6 +10517,7 @@ version = "0.1.0" dependencies = [ "anyhow", "deployments", + "parking_lot 0.12.5", "protocol-types", "reqwest", "serde", diff --git a/rust-backend/crates/deployments/src/lib.rs b/rust-backend/crates/deployments/src/lib.rs index 6fd88261..4cd64a1c 100644 --- a/rust-backend/crates/deployments/src/lib.rs +++ b/rust-backend/crates/deployments/src/lib.rs @@ -176,6 +176,14 @@ pub struct PackageInfo { pub treasury_id: Option, pub publish_digest: String, pub init_digest: Option, + /// The platform's own Org ("SuiOptions"), created at deploy time. Orgs + /// are permissionless; this records the one the platform's + /// option-scheduler rolls buckets under. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_org_id: Option, + /// OrgCap for the platform org (owned by the deployer). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_org_cap_id: Option, pub deployer: String, pub deployed_at: String, pub network: String, @@ -251,8 +259,28 @@ impl NetworkDeployment { SuiAddress::from_str(&self.package_info.deployer).context("parsing deployer") } + /// The platform's own shared Org object. + pub fn platform_org(&self) -> Result { + let id = self + .package_info + .platform_org_id + .as_deref() + .ok_or_else(|| anyhow!("platformOrgId missing from deployments"))?; + ObjectID::from_str(id).context("parsing platform_org_id") + } + + /// The platform org's OrgCap (owned by the deployer). + pub fn platform_org_cap(&self) -> Result { + let id = self + .package_info + .platform_org_cap_id + .as_deref() + .ok_or_else(|| anyhow!("platformOrgCapId missing from deployments"))?; + ObjectID::from_str(id).context("parsing platform_org_cap_id") + } + /// Raw bytes of the AdminCap id — the domain separator the chain - /// compares every `Quote.protocol_id` against (`admin.move:20`). + /// compares every `Quote.protocol_id` against (`admin.move`). pub fn protocol_id_bytes(&self) -> Result> { Ok(self.admin_cap()?.into_bytes().to_vec()) } @@ -347,6 +375,8 @@ mod tests { treasury_id: None, publish_digest: "x".into(), init_digest: None, + platform_org_id: None, + platform_org_cap_id: None, deployer: "0x0".into(), deployed_at: "".into(), network: "testnet".into(), diff --git a/rust-backend/crates/indexer-graphql/src/lib.rs b/rust-backend/crates/indexer-graphql/src/lib.rs index 3f750f6d..5cb7526c 100644 --- a/rust-backend/crates/indexer-graphql/src/lib.rs +++ b/rust-backend/crates/indexer-graphql/src/lib.rs @@ -39,6 +39,8 @@ pub struct IndexerClient { #[derive(Clone, Debug)] pub struct Bucket { pub bucket_id: ObjectId, + /// Org that created (and administers) this bucket. + pub org_id: ObjectId, pub asset_type: AssetType, pub settlement_type: AssetType, /// Fully-qualified type of the per-bucket fungible option coin. @@ -55,6 +57,16 @@ pub struct Bucket { pub deepbook_pool_id: Option, } +/// An org from the indexer's materialized view. The indexer records every +/// org created on-chain; verification status lives in token-info. +#[derive(Clone, Debug)] +pub struct Org { + pub org_id: ObjectId, + pub name: String, + pub fee_bps: u64, + pub creator: SuiAddress, +} + /// An account's registered signing key + per-asset balances. `signing_scheme` /// is `None` only for un-backfilled rows; callers treat that as "unknown /// signer" (reject), matching the pre-JIT behaviour. @@ -127,7 +139,7 @@ impl IndexerClient { /// One bucket by id, or `None` if the indexer doesn't know it. pub async fn bucket(&self, bucket_id: ObjectId) -> Result> { - const Q: &str = "query($id:String!){bucket(id:$id){bucketId assetType settlementType \ + const Q: &str = "query($id:String!){bucket(id:$id){bucketId orgId assetType settlementType \ callType strikeRaw strikeScale expiryMs totalWrittenRaw exerciseCursorRaw cleaned \ invalidated deepbookPoolId}}"; let data: BucketWrap = self @@ -136,20 +148,23 @@ impl IndexerClient { data.bucket.map(Bucket::try_from).transpose() } - /// Buckets matching the filters (all ANDed). `active_only` drops cleaned. + /// Buckets matching the filters (all ANDed). `active_only` drops cleaned; + /// `org_ids` restricts to those orgs' buckets (verified-only surfaces). pub async fn buckets( &self, active_only: bool, + org_ids: Option<&[String]>, asset_type: Option<&AssetType>, settlement_type: Option<&AssetType>, expiry_ms: Option, ) -> Result> { - const Q: &str = "query($a:Boolean,$u:String,$s:String,$e:String){\ - buckets(activeOnly:$a,assetType:$u,settlementType:$s,expiryMs:$e){\ - bucketId assetType settlementType callType strikeRaw strikeScale expiryMs \ + const Q: &str = "query($a:Boolean,$o:[String!],$u:String,$s:String,$e:String){\ + buckets(activeOnly:$a,orgIds:$o,assetType:$u,settlementType:$s,expiryMs:$e){\ + bucketId orgId assetType settlementType callType strikeRaw strikeScale expiryMs \ totalWrittenRaw exerciseCursorRaw cleaned invalidated deepbookPoolId}}"; let vars = json!({ "a": active_only, + "o": org_ids, "u": asset_type.map(|a| a.as_str()), "s": settlement_type.map(|a| a.as_str()), "e": expiry_ms.map(|e| e.to_string()), @@ -158,6 +173,20 @@ impl IndexerClient { data.buckets.into_iter().map(Bucket::try_from).collect() } + /// One org by id, or `None` if the indexer doesn't know it. + pub async fn org(&self, org_id: ObjectId) -> Result> { + const Q: &str = "query($id:String!){org(id:$id){orgId name feeBps creator}}"; + let data: OrgWrap = self.gql(Q, json!({ "id": org_id.to_hex() })).await?; + data.org.map(Org::try_from).transpose() + } + + /// Every org the indexer has seen (verification is a token-info concern). + pub async fn orgs(&self) -> Result> { + const Q: &str = "query{orgs{orgId name feeBps creator}}"; + let data: OrgsWrap = self.gql(Q, json!({})).await?; + data.orgs.into_iter().map(Org::try_from).collect() + } + /// One account (signing key + balances), or `None` if unknown. pub async fn account(&self, account_id: ObjectId) -> Result> { const Q: &str = "query($id:String!){account(id:$id){accountId owner signingScheme \ @@ -239,18 +268,26 @@ impl IndexerClient { Ok(out) } - /// All `WriteExecuted` events whose `call_token_recipient` is `wallet`, in + /// All `WriteExecuted` events whose call-token recipient is `wallet`, in /// ascending order. Backs the api-service call-token "lot" provenance list. + /// + /// The event no longer carries a `call_token_recipient` field (the + /// executor's side is PTB-routed): the coin lands with the executor in + /// trader flow (flow=1) and with the quote's `signer_token_recipient` in + /// writer flow (flow=0), so this is an OR over the two flow shapes. pub async fn write_executed_for_recipient( &self, wallet: SuiAddress, ) -> Result> { - // Match nests under `payload` — the column stores the tagged + // Matches nest under `payload` — the column stores the tagged // `ChainEvent` envelope (`{"type":…,"payload":{…}}`), not the bare // event fields. See `write_executed_for_account_since`. let filter = json!({ "eventType": ["WriteExecuted"], - "payloadContains": { "payload": { "call_token_recipient": wallet.to_hex() } }, + "or": [ + { "payloadContains": { "payload": { "flow": 1, "executor": wallet.to_hex() } } }, + { "payloadContains": { "payload": { "flow": 0, "signer_token_recipient": wallet.to_hex() } } }, + ], }); self.scan_events(filter, 0).await } @@ -361,6 +398,14 @@ struct AccountWrap { account: Option, } #[derive(Deserialize)] +struct OrgWrap { + org: Option, +} +#[derive(Deserialize)] +struct OrgsWrap { + orgs: Vec, +} +#[derive(Deserialize)] struct PositionsWrap { positions: Vec, } @@ -401,10 +446,20 @@ impl EventNodeJson { } } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrgJson { + org_id: String, + name: String, + fee_bps: String, + creator: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BucketJson { bucket_id: String, + org_id: String, asset_type: String, settlement_type: String, call_type: String, @@ -475,11 +530,29 @@ fn parse_u8(v: i32) -> Result { u8::try_from(v).map_err(|_| anyhow!("value {v} out of u8 range")) } +impl TryFrom for Org { + type Error = anyhow::Error; + fn try_from(o: OrgJson) -> Result { + Ok(Org { + org_id: parse_object_id(&o.org_id)?, + name: o.name, + fee_bps: parse_u64(&o.fee_bps)?, + creator: parse_address(&o.creator)?, + }) + } +} + impl TryFrom for Bucket { type Error = anyhow::Error; fn try_from(b: BucketJson) -> Result { Ok(Bucket { bucket_id: parse_object_id(&b.bucket_id)?, + org_id: if b.org_id.is_empty() { + // Pre-org rows (legacy package) carry '' — map to zero. + ObjectId::ZERO + } else { + parse_object_id(&b.org_id)? + }, asset_type: AssetType::new(b.asset_type), settlement_type: AssetType::new(b.settlement_type), call_type: AssetType::new(b.call_type), diff --git a/rust-backend/crates/protocol-types/src/events.rs b/rust-backend/crates/protocol-types/src/events.rs index f8bc298e..8f39e920 100644 --- a/rust-backend/crates/protocol-types/src/events.rs +++ b/rust-backend/crates/protocol-types/src/events.rs @@ -18,6 +18,8 @@ use super::ids::{ObjectId, SuiAddress}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BucketCreated { pub bucket_id: ObjectId, + /// Org the bucket belongs to (creator's OrgCap.org_id). + pub org_id: ObjectId, pub asset_type: AssetType, pub settlement_type: AssetType, /// Fully-qualified type of the per-bucket fungible option coin @@ -42,21 +44,33 @@ impl BucketCreated { } } +/// `flow` values mirroring `bucket.move`'s FLOW_WRITER / FLOW_TRADER. +pub const FLOW_WRITER: u8 = 0; +pub const FLOW_TRADER: u8 = 1; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WriteExecuted { pub bucket_id: ObjectId, + pub org_id: ObjectId, pub signer_account_id: ObjectId, + /// Where the contract transferred the signer's minted asset (Coin + /// in writer flow, Position in trader flow). The executor's side is + /// returned to the PTB, so its final destination is PTB-decided and not + /// recorded on-chain. pub signer_token_recipient: SuiAddress, pub executor: SuiAddress, pub position_id: ObjectId, - pub position_recipient: SuiAddress, - pub call_token_recipient: SuiAddress, + /// 0 = writer flow, 1 = trader flow (see FLOW_WRITER / FLOW_TRADER). + pub flow: u8, #[serde(with = "u64_string")] pub write_amount: u64, #[serde(with = "u64_string")] pub gross_premium: u64, + /// Org fee → Org balances; protocol fee → global Treasury. + #[serde(with = "u64_string")] + pub org_fee: u64, #[serde(with = "u64_string")] - pub fee: u64, + pub protocol_fee: u64, #[serde(with = "u64_string")] pub net_premium: u64, #[serde(with = "u128_string")] @@ -67,6 +81,34 @@ pub struct WriteExecuted { pub nonce: u64, } +impl WriteExecuted { + /// Combined fee taken from the gross premium. + pub fn total_fee(&self) -> u64 { + self.org_fee + self.protocol_fee + } + + /// The Position lands with the executor's PTB in writer flow and with + /// the quote's recipient (the writer MM) in trader flow. Best-effort — + /// in writer flow the PTB could route elsewhere, but conventionally the + /// executor keeps it. + pub fn position_recipient(&self) -> SuiAddress { + if self.flow == FLOW_TRADER { + self.signer_token_recipient + } else { + self.executor + } + } + + /// Mirror of `position_recipient` for the option coin side. + pub fn call_token_recipient(&self) -> SuiAddress { + if self.flow == FLOW_TRADER { + self.executor + } else { + self.signer_token_recipient + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Exercised { pub bucket_id: ObjectId, @@ -112,7 +154,10 @@ pub struct BucketInvalidated { pub bucket_id: ObjectId, #[serde(with = "u64_string")] pub at_ms: u64, - pub admin: SuiAddress, + pub actor: SuiAddress, + /// true when gated by the protocol AdminCap override; false when by the + /// bucket's OrgCap. + pub by_admin: bool, #[serde(with = "crate::coding::bytes_hex")] pub reason: Vec, } @@ -122,11 +167,44 @@ pub struct BucketRevalidated { pub bucket_id: ObjectId, #[serde(with = "u64_string")] pub at_ms: u64, - pub admin: SuiAddress, + pub actor: SuiAddress, + pub by_admin: bool, #[serde(with = "crate::coding::bytes_hex")] pub reason: Vec, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrgCreated { + pub org_id: ObjectId, + pub name: String, + #[serde(with = "u64_string")] + pub fee_bps: u64, + pub creator: SuiAddress, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrgFeeUpdated { + pub org_id: ObjectId, + #[serde(with = "u64_string")] + pub old_bps: u64, + #[serde(with = "u64_string")] + pub new_bps: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrgWithdraw { + pub org_id: ObjectId, + pub asset_type: AssetType, + #[serde(with = "u64_string")] + pub amount: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolPauseSet { + pub paused: bool, + pub admin: SuiAddress, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountCreated { pub account_id: ObjectId, @@ -163,19 +241,20 @@ pub struct SigningKeyRotated { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct FeeUpdated { +pub struct ProtocolFeeUpdated { #[serde(with = "u64_string")] pub old_bps: u64, #[serde(with = "u64_string")] pub new_bps: u64, } +/// No `recipient`: the withdrawn coin is returned to the PTB on-chain, so +/// the final destination is PTB-decided. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TreasuryWithdrawn { pub asset_type: AssetType, #[serde(with = "u64_string")] pub amount: u64, - pub recipient: SuiAddress, } /// A DeepBook v3 pool created for one of OUR buckets' call coins (SO-152). @@ -225,8 +304,12 @@ pub enum ChainEvent { AccountDeposit(AccountDeposit), AccountWithdraw(AccountWithdraw), SigningKeyRotated(SigningKeyRotated), - FeeUpdated(FeeUpdated), + ProtocolFeeUpdated(ProtocolFeeUpdated), + ProtocolPauseSet(ProtocolPauseSet), TreasuryWithdrawn(TreasuryWithdrawn), + OrgCreated(OrgCreated), + OrgFeeUpdated(OrgFeeUpdated), + OrgWithdraw(OrgWithdraw), DeepBookPoolCreated(DeepBookPoolCreated), } @@ -250,6 +333,7 @@ mod tests { fn chain_event_tagged_envelope() { let evt = ChainEvent::BucketCreated(BucketCreated { bucket_id: ObjectId::new([0x01; 32]), + org_id: ObjectId::new([0x0a; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -271,6 +355,7 @@ mod tests { fn strike_as_f64_applies_scale() { let ev = BucketCreated { bucket_id: ObjectId::new([0; 32]), + org_id: ObjectId::new([0; 32]), asset_type: AssetType::new("DEEP"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -287,10 +372,57 @@ mod tests { let env = IndexedEvent { sequence: 42, timestamp_ms: 1, - event: ChainEvent::FeeUpdated(FeeUpdated { old_bps: 0, new_bps: 50 }), + event: ChainEvent::ProtocolFeeUpdated(ProtocolFeeUpdated { old_bps: 0, new_bps: 50 }), }; let j = serde_json::to_string(&env).unwrap(); let back: IndexedEvent = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } + + #[test] + fn org_created_envelope_round_trips() { + let env = IndexedEvent { + sequence: 1, + timestamp_ms: 2, + event: ChainEvent::OrgCreated(OrgCreated { + org_id: ObjectId::new([0x07; 32]), + name: "acme".to_string(), + fee_bps: 30, + creator: SuiAddress::new([0x08; 32]), + }), + }; + let v: serde_json::Value = serde_json::to_value(&env).unwrap(); + assert_eq!(v["event"]["type"], "OrgCreated"); + assert_eq!(v["event"]["payload"]["name"], "acme"); + assert_eq!(v["event"]["payload"]["fee_bps"], "30"); + let back: IndexedEvent = serde_json::from_value(v).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn write_executed_recipient_helpers_follow_flow() { + let mut ev = WriteExecuted { + bucket_id: ObjectId::new([0x11; 32]), + org_id: ObjectId::new([0x12; 32]), + signer_account_id: ObjectId::new([0x22; 32]), + signer_token_recipient: SuiAddress::new([0x33; 32]), + executor: SuiAddress::new([0x44; 32]), + position_id: ObjectId::new([0xaa; 32]), + flow: FLOW_WRITER, + write_amount: 10_000, + gross_premium: 500, + org_fee: 3, + protocol_fee: 5, + net_premium: 492, + range_start: 0, + range_end: 10_000, + nonce: 7, + }; + assert_eq!(ev.total_fee(), 8); + assert_eq!(ev.position_recipient(), ev.executor); + assert_eq!(ev.call_token_recipient(), ev.signer_token_recipient); + ev.flow = FLOW_TRADER; + assert_eq!(ev.position_recipient(), ev.signer_token_recipient); + assert_eq!(ev.call_token_recipient(), ev.executor); + } } diff --git a/rust-backend/crates/sui-tx/src/tx/admin.rs b/rust-backend/crates/sui-tx/src/tx/admin.rs index 4caa395d..77958918 100644 --- a/rust-backend/crates/sui-tx/src/tx/admin.rs +++ b/rust-backend/crates/sui-tx/src/tx/admin.rs @@ -1,9 +1,10 @@ -//! Admin-cap-gated PTBs: `new_call_option`, `set_fee_bps`, `withdraw_treasury`. +//! Admin-cap-gated PTBs: `set_protocol_fee_bps`, `set_pause`, +//! `withdraw_treasury`. //! -//! All three are simple Move calls — no coin manipulation — so they go -//! through the high-level `client.transaction_builder().move_call(...)` -//! builder. The same builder auto-selects a gas coin and computes the -//! reference gas price. +//! The fee/pause setters are simple Move calls and go through the high-level +//! `client.transaction_builder().move_call(...)` builder. `withdraw_treasury` +//! drops to a raw PTB because `treasury::withdraw` now RETURNS the coin — +//! the PTB must transfer it to the recipient itself. use anyhow::{Context, Result}; use shared_crypto::intent::Intent; @@ -86,8 +87,8 @@ async fn execute_move_call( Ok(resp) } -/// Calls `admin::set_fee_bps(&AdminCap, &mut ProtocolConfig, new_bps)`. -pub async fn set_fee_bps( +/// Calls `admin::set_protocol_fee_bps(&AdminCap, &mut ProtocolConfig, new_bps)`. +pub async fn set_protocol_fee_bps( client: &SuiClient, signer: &Signer, package: ObjectID, @@ -101,7 +102,7 @@ pub async fn set_fee_bps( signer, package, "admin", - "set_fee_bps", + "set_protocol_fee_bps", vec![], vec![ SuiJsonValue::from_object_id(admin_cap), @@ -113,33 +114,115 @@ pub async fn set_fee_bps( .await } -/// Calls `treasury::withdraw(&AdminCap, &mut Treasury, amount, recipient, -/// ctx)`. -pub async fn withdraw_treasury( +/// Calls `admin::set_pause(&AdminCap, &mut ProtocolConfig, paused, ctx)` — +/// the protocol-wide emergency brake on new writes. +pub async fn set_pause( client: &SuiClient, signer: &Signer, package: ObjectID, admin_cap: ObjectID, - treasury: ObjectID, - asset_type: &str, - amount: u64, - recipient: SuiAddress, + protocol_config: ObjectID, + paused: bool, gas_budget: u64, ) -> Result { execute_move_call( client, signer, package, - "treasury", - "withdraw", - vec![asset_type], + "admin", + "set_pause", + vec![], vec![ SuiJsonValue::from_object_id(admin_cap), - SuiJsonValue::from_object_id(treasury), - SuiJsonValue::new(serde_json::Value::String(amount.to_string()))?, - SuiJsonValue::new(serde_json::Value::String(recipient.to_string()))?, + SuiJsonValue::from_object_id(protocol_config), + SuiJsonValue::new(serde_json::Value::Bool(paused))?, ], gas_budget, ) .await } + +/// `treasury::withdraw(&AdminCap, &mut Treasury, amount, ctx): Coin` — +/// the coin is RETURNED, so this builds a raw PTB that transfers it to +/// `recipient`. +pub async fn withdraw_treasury( + client: &SuiClient, + signer: &Signer, + package: ObjectID, + admin_cap: ObjectID, + treasury: ObjectID, + asset_type: &str, + amount: u64, + recipient: SuiAddress, + gas_budget: u64, +) -> Result { + use move_core_types::identifier::Identifier; + use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder; + use sui_types::transaction::TransactionData; + use sui_types::TypeTag as MoveTypeTag; + + use crate::tx::{owned_object_arg, shared_object_arg}; + + info!(%package, asset_type, amount, %recipient, "building treasury withdraw PTB"); + let mut pt = ProgrammableTransactionBuilder::new(); + let cap_arg = pt.obj(owned_object_arg(client, admin_cap).await?)?; + let treasury_arg = pt.obj(shared_object_arg(client, treasury, true).await?)?; + let amount_arg = pt.pure(amount)?; + let t_tag = MoveTypeTag::from_str(asset_type) + .with_context(|| format!("parsing asset type {asset_type}"))?; + let coin = pt.programmable_move_call( + package, + Identifier::new("treasury").unwrap(), + Identifier::new("withdraw").unwrap(), + vec![t_tag], + vec![cap_arg, treasury_arg, amount_arg], + ); + pt.transfer_args(recipient, vec![coin]); + let programmable = pt.finish(); + + let gas_coin = client + .coin_read_api() + .get_coins(signer.address, None, None, Some(5)) + .await + .context("listing gas coins")? + .data + .into_iter() + .max_by_key(|c| c.balance) + .ok_or_else(|| anyhow::anyhow!("no SUI coins to pay gas for {}", signer.address))?; + let gas_price = client + .read_api() + .get_reference_gas_price() + .await + .context("fetching reference gas price")?; + let tx_data = TransactionData::new_programmable( + signer.address, + vec![gas_coin.object_ref()], + programmable, + gas_budget, + gas_price, + ); + let sig = Transaction::signature_from_signer( + tx_data.clone(), + Intent::sui_transaction(), + &signer.keypair, + ); + let tx = Transaction::from_data(tx_data, vec![sig]); + let opts = SuiTransactionBlockResponseOptions::new() + .with_effects() + .with_object_changes(); + let resp = client + .quorum_driver_api() + .execute_transaction_block( + tx, + opts, + Some(ExecuteTransactionRequestType::WaitForLocalExecution), + ) + .await + .context("submitting treasury withdraw tx")?; + let effects = resp.effects.as_ref().context("response missing effects")?; + if effects.status().is_err() { + anyhow::bail!("treasury::withdraw reverted: {:?}", effects.status()); + } + debug!(digest = %resp.digest, "treasury withdraw succeeded"); + Ok(resp) +} diff --git a/rust-backend/crates/sui-tx/src/tx/coin_pkg.rs b/rust-backend/crates/sui-tx/src/tx/coin_pkg.rs index 8587a95e..8a2b6c17 100644 --- a/rust-backend/crates/sui-tx/src/tx/coin_pkg.rs +++ b/rust-backend/crates/sui-tx/src/tx/coin_pkg.rs @@ -148,13 +148,13 @@ pub struct CreateBucketSpec { } /// Call `bucket::create_bucket` once per spec in a single PTB, -/// consuming each `TreasuryCap` by value and referencing the shared `AdminCap` +/// consuming each `TreasuryCap` by value and referencing the org's `OrgCap` /// across every command. pub async fn create_buckets( client: &SuiClient, signer: &Signer, package: ObjectID, - admin_cap: ObjectID, + org_cap: ObjectID, specs: &[CreateBucketSpec], gas_budget: u64, ) -> Result { @@ -164,9 +164,9 @@ pub async fn create_buckets( info!(%package, buckets = specs.len(), "building create_buckets PTB"); let mut pt = ProgrammableTransactionBuilder::new(); - // AdminCap is an owned object passed by `&AdminCap`; input it once and + // OrgCap is an owned object passed by `&OrgCap`; input it once and // reuse the Argument across every create_bucket command. - let admin_arg = pt.obj(owned_object_arg(client, admin_cap).await?)?; + let org_cap_arg = pt.obj(owned_object_arg(client, org_cap).await?)?; let bucket_module = Identifier::new("bucket").unwrap(); let create_fn = Identifier::new("create_bucket").unwrap(); @@ -189,7 +189,7 @@ pub async fn create_buckets( bucket_module.clone(), create_fn.clone(), vec![u_tag, s_tag, c_tag], - vec![admin_arg, cap_arg, expiry_arg, strike_arg, scale_arg], + vec![org_cap_arg, cap_arg, expiry_arg, strike_arg, scale_arg], ); } diff --git a/rust-backend/crates/sui-tx/src/tx/execute_write.rs b/rust-backend/crates/sui-tx/src/tx/execute_write.rs index af6a3d52..0e0e24a1 100644 --- a/rust-backend/crates/sui-tx/src/tx/execute_write.rs +++ b/rust-backend/crates/sui-tx/src/tx/execute_write.rs @@ -1,21 +1,31 @@ -//! Programmable transaction for `bucket::execute_write` (writer flow). +//! Programmable transactions for the split `bucket::execute_write_*` entry +//! points (org-aware protocol). //! -//! Single PTB, six commands: +//! Writer flow — single PTB, four commands: //! //! ```text //! 1. test_tokens::::mint(faucet, write_amount) -> Coin -//! 2. coin::zero() -> Coin (empty) -//! 3. quote::new_quote(...) -> Quote -//! 4. quote::new_signed_quote(q, sig) -> SignedQuote -//! 5. bucket::writer_flow() -> FlowKind -//! 6. bucket::execute_write(bucket, config, treasury, mm_account, -//! coin_u, coin_s, flow, position_recipient, call_token_recipient, -//! signed_quote, clock, ctx) +//! 2. quote::new_quote(...) -> Quote +//! 3. quote::new_signed_quote(q, sig) -> SignedQuote +//! 4. bucket::execute_write_writer_flow(bucket, org, config, +//! treasury, mm_account, coin_u, signed_quote, clock, ctx) +//! -> (Position, Coin) +//! 5. TransferObjects([result.0, result.1], executor_recipient) //! ``` //! +//! The contract returns the executor's side to the PTB (the writer's +//! Position + net premium here; the trader's Coin in trader flow) and +//! the trailing `TransferObjects` routes it; the signer/MM's side is still +//! transferred by the contract to the quote's `signer_token_recipient`. +//! //! The faucet mint composes with the rest of the PTB because the test-token //! `mint(&mut Faucet, u64, ctx): Coin` is a non-entry public function — //! its result is addressable as a PTB `Argument`. +//! +//! NOTE: the frontend builds the same PTB shapes in +//! `frontend/src/tx/composer.ts`, and the gas-station validates them against +//! the templates in `template.rs` — keep all three in sync +//! (see `.claude/ptb-sync.md`). use std::str::FromStr; @@ -31,10 +41,10 @@ use sui_sdk::SuiClient; use sui_types::base_types::{ObjectID, SuiAddress}; use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder; use sui_types::transaction::{ - ObjectArg, SharedObjectMutability, Transaction, TransactionData, + Argument, ObjectArg, SharedObjectMutability, Transaction, TransactionData, }; use sui_types::transaction_driver_types::ExecuteTransactionRequestType; -use sui_types::{SUI_CLOCK_OBJECT_ID, SUI_CLOCK_OBJECT_SHARED_VERSION, SUI_FRAMEWORK_PACKAGE_ID}; +use sui_types::{SUI_CLOCK_OBJECT_ID, SUI_CLOCK_OBJECT_SHARED_VERSION}; use tracing::{debug, info}; use crate::sui_client::Signer; @@ -57,6 +67,8 @@ pub struct ExecuteWriteParams<'a> { // Shared protocol objects. pub bucket_id: ObjectID, + /// The bucket's Org (shared, mutated for the org fee deposit). + pub org_id: ObjectID, pub protocol_config_id: ObjectID, pub treasury_id: ObjectID, pub mm_account_id: ObjectID, @@ -72,8 +84,10 @@ pub struct ExecuteWriteParams<'a> { pub nonce: u64, pub signature: Vec, - pub position_recipient: SuiAddress, - pub call_token_recipient: SuiAddress, + /// Where the PTB transfers the RETURNED (Position, net-premium Coin) — + /// conventionally the executor (the writer). The MM's Coin is + /// contract-routed to `signer_token_recipient` and is not configurable. + pub executor_recipient: SuiAddress, pub gas_budget: u64, } @@ -97,6 +111,8 @@ pub struct ExecuteTraderParams<'a> { // Shared protocol objects. pub bucket_id: ObjectID, + /// The bucket's Org (shared, mutated for the org fee deposit). + pub org_id: ObjectID, pub protocol_config_id: ObjectID, pub treasury_id: ObjectID, pub mm_account_id: ObjectID, @@ -112,10 +128,10 @@ pub struct ExecuteTraderParams<'a> { pub nonce: u64, pub signature: Vec, - /// Trader flow: must equal `signer_token_recipient` (the MM gets the Position). - pub position_recipient: SuiAddress, - /// Trader flow: the retail trader receives the CallOption coin. - pub call_token_recipient: SuiAddress, + /// Where the PTB transfers the RETURNED Coin — conventionally the + /// executor (the trader). The MM's Position is contract-routed to + /// `signer_token_recipient` and is not configurable. + pub executor_recipient: SuiAddress, pub gas_budget: u64, } @@ -138,6 +154,7 @@ pub async fn execute_writer_flow( // Shared object args. let bucket = pt.obj(shared_object_arg(client, p.bucket_id, true).await?)?; + let org = pt.obj(shared_object_arg(client, p.org_id, true).await?)?; let config = pt.obj(shared_object_arg(client, p.protocol_config_id, false).await?)?; let treasury = pt.obj(shared_object_arg(client, p.treasury_id, true).await?)?; let mm_account = pt.obj(shared_object_arg(client, p.mm_account_id, true).await?)?; @@ -158,8 +175,6 @@ pub async fn execute_writer_flow( let arg_valid_until_ms = pt.pure(&p.valid_until_ms)?; let arg_nonce = pt.pure(&p.nonce)?; let arg_signature = pt.pure(&p.signature)?; - let arg_position_recipient = pt.pure(&p.position_recipient)?; - let arg_call_token_recipient = pt.pure(&p.call_token_recipient)?; let arg_mint_amount = pt.pure(&p.write_amount)?; // Type tags. @@ -180,16 +195,7 @@ pub async fn execute_writer_flow( vec![faucet, arg_mint_amount], ); - // 2. coin::zero() - let coin_settlement_zero = pt.programmable_move_call( - SUI_FRAMEWORK_PACKAGE_ID, - Identifier::new("coin").unwrap(), - Identifier::new("zero").unwrap(), - vec![s_tag.clone()], - vec![], - ); - - // 3. quote::new_quote(...) + // 2. quote::new_quote(...) let quote_val = pt.programmable_move_call( p.package, Identifier::new("quote").unwrap(), @@ -207,7 +213,7 @@ pub async fn execute_writer_flow( ], ); - // 4. quote::new_signed_quote(quote, signature) + // 3. quote::new_signed_quote(quote, signature) let signed_quote = pt.programmable_move_call( p.package, Identifier::new("quote").unwrap(), @@ -216,35 +222,33 @@ pub async fn execute_writer_flow( vec![quote_val, arg_signature], ); - // 5. bucket::writer_flow() - let flow = pt.programmable_move_call( - p.package, - Identifier::new("bucket").unwrap(), - Identifier::new("writer_flow").unwrap(), - vec![], - vec![], - ); - - // 6. bucket::execute_write(...) - pt.programmable_move_call( + // 4. bucket::execute_write_writer_flow(...) + // -> (Position, Coin) returned to the PTB. + let result = pt.programmable_move_call( p.package, Identifier::new("bucket").unwrap(), - Identifier::new("execute_write").unwrap(), + Identifier::new("execute_write_writer_flow").unwrap(), vec![u_tag, s_tag, c_tag], vec![ bucket, + org, config, treasury, mm_account, coin_underlying, - coin_settlement_zero, - flow, - arg_position_recipient, - arg_call_token_recipient, signed_quote, clock, ], ); + let Argument::Result(idx) = result else { + return Err(anyhow!("unexpected non-Result argument from move call")); + }; + + // 5. Route the returned (Position, net premium) to the executor. + pt.transfer_args( + p.executor_recipient, + vec![Argument::NestedResult(idx, 0), Argument::NestedResult(idx, 1)], + ); submit_execute_write(client, signer, pt, p.gas_budget).await } @@ -253,11 +257,10 @@ pub async fn execute_writer_flow( /// /// Symmetric to [`execute_writer_flow`], but the executor is the *retail /// trader*: they supply the premium (minted from the settlement faucet inside -/// the PTB) and the underlying side is an empty coin (the Writer MM provides -/// the underlying from their Account). Per the `FlowKind::Trader` branch in -/// `bucket::execute_write_with_quote`, the signer (MM) receives the Position -/// NFT, so `position_recipient` must equal the quote's `signer_token_recipient`; -/// the trader receives the `CallOption` coin via `call_token_recipient`. +/// the PTB); the Writer MM provides the underlying from their Account. The +/// contract transfers the Position to the quote's `signer_token_recipient` +/// (the MM) and returns the `Coin` to the PTB, which routes it to +/// `executor_recipient`. pub async fn execute_trader_flow( client: &SuiClient, signer: &Signer, @@ -275,6 +278,7 @@ pub async fn execute_trader_flow( // Shared object args. let bucket = pt.obj(shared_object_arg(client, p.bucket_id, true).await?)?; + let org = pt.obj(shared_object_arg(client, p.org_id, true).await?)?; let config = pt.obj(shared_object_arg(client, p.protocol_config_id, false).await?)?; let treasury = pt.obj(shared_object_arg(client, p.treasury_id, true).await?)?; let mm_account = pt.obj(shared_object_arg(client, p.mm_account_id, true).await?)?; @@ -295,8 +299,6 @@ pub async fn execute_trader_flow( let arg_valid_until_ms = pt.pure(&p.valid_until_ms)?; let arg_nonce = pt.pure(&p.nonce)?; let arg_signature = pt.pure(&p.signature)?; - let arg_position_recipient = pt.pure(&p.position_recipient)?; - let arg_call_token_recipient = pt.pure(&p.call_token_recipient)?; let arg_mint_amount = pt.pure(&p.premium)?; // Type tags. @@ -307,16 +309,7 @@ pub async fn execute_trader_flow( let c_tag = TypeTag::from_str(p.call_type) .with_context(|| format!("parsing call type {}", p.call_type))?; - // 1. coin::zero() — the MM provides the underlying from their Account. - let coin_underlying_zero = pt.programmable_move_call( - SUI_FRAMEWORK_PACKAGE_ID, - Identifier::new("coin").unwrap(), - Identifier::new("zero").unwrap(), - vec![u_tag.clone()], - vec![], - ); - - // 2. test_tokens::::mint(faucet, premium) -> Coin + // 1. test_tokens::::mint(faucet, premium) -> Coin let coin_settlement = pt.programmable_move_call( p.tokens_package, Identifier::new(p.settlement_module) @@ -326,7 +319,7 @@ pub async fn execute_trader_flow( vec![faucet, arg_mint_amount], ); - // 3. quote::new_quote(...) + // 2. quote::new_quote(...) let quote_val = pt.programmable_move_call( p.package, Identifier::new("quote").unwrap(), @@ -344,7 +337,7 @@ pub async fn execute_trader_flow( ], ); - // 4. quote::new_signed_quote(quote, signature) + // 3. quote::new_signed_quote(quote, signature) let signed_quote = pt.programmable_move_call( p.package, Identifier::new("quote").unwrap(), @@ -353,36 +346,28 @@ pub async fn execute_trader_flow( vec![quote_val, arg_signature], ); - // 5. bucket::trader_flow() - let flow = pt.programmable_move_call( + // 4. bucket::execute_write_trader_flow(...) + // -> Coin returned to the PTB. + let call_coin = pt.programmable_move_call( p.package, Identifier::new("bucket").unwrap(), - Identifier::new("trader_flow").unwrap(), - vec![], - vec![], - ); - - // 6. bucket::execute_write(...) - pt.programmable_move_call( - p.package, - Identifier::new("bucket").unwrap(), - Identifier::new("execute_write").unwrap(), + Identifier::new("execute_write_trader_flow").unwrap(), vec![u_tag, s_tag, c_tag], vec![ bucket, + org, config, treasury, mm_account, - coin_underlying_zero, coin_settlement, - flow, - arg_position_recipient, - arg_call_token_recipient, signed_quote, clock, ], ); + // 5. Route the returned Coin to the executor. + pt.transfer_args(p.executor_recipient, vec![call_coin]); + submit_execute_write(client, signer, pt, p.gas_budget).await } diff --git a/rust-backend/crates/sui-tx/src/tx/mod.rs b/rust-backend/crates/sui-tx/src/tx/mod.rs index 601bf031..b011f55a 100644 --- a/rust-backend/crates/sui-tx/src/tx/mod.rs +++ b/rust-backend/crates/sui-tx/src/tx/mod.rs @@ -18,6 +18,7 @@ pub mod admin; pub mod coin_pkg; pub mod deepbook; pub mod execute_write; +pub mod org; pub mod sponsor; pub mod template; pub mod test_tokens; diff --git a/rust-backend/crates/sui-tx/src/tx/org.rs b/rust-backend/crates/sui-tx/src/tx/org.rs new file mode 100644 index 00000000..71bb779f --- /dev/null +++ b/rust-backend/crates/sui-tx/src/tx/org.rs @@ -0,0 +1,196 @@ +//! Org-cap-gated PTBs: `create_org`, `set_org_fee_bps`, `withdraw_org`. +//! +//! Orgs are permissionless: anyone can call `org::create_org`. The cap is +//! RETURNED by the contract, so `create_org` builds a raw PTB that transfers +//! it to the signer; likewise `org::withdraw` returns the coin. + +use anyhow::{anyhow, Context, Result}; +use move_core_types::identifier::Identifier; +use shared_crypto::intent::Intent; +use std::str::FromStr; +use sui_json_rpc_types::{ + ObjectChange, SuiTransactionBlockEffectsAPI, SuiTransactionBlockResponse, + SuiTransactionBlockResponseOptions, +}; +use sui_sdk::SuiClient; +use sui_types::base_types::{ObjectID, SuiAddress}; +use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use sui_types::transaction::{Transaction, TransactionData}; +use sui_types::transaction_driver_types::ExecuteTransactionRequestType; +use sui_types::TypeTag; +use tracing::{debug, info}; + +use crate::sui_client::Signer; +use crate::tx::{owned_object_arg, shared_object_arg}; + +pub struct CreateOrgOutcome { + /// The shared `Org` object. + pub org_id: ObjectID, + /// The `OrgCap` transferred to the signer. + pub org_cap_id: ObjectID, + pub digest: String, +} + +/// `org::create_org(name, fee_bps): OrgCap` — shares the Org and transfers +/// the returned cap to the signer. +pub async fn create_org( + client: &SuiClient, + signer: &Signer, + package: ObjectID, + name: &str, + fee_bps: u64, + gas_budget: u64, +) -> Result { + info!(%package, name, fee_bps, "building create_org PTB"); + let mut pt = ProgrammableTransactionBuilder::new(); + let name_arg = pt.pure(name.to_string())?; + let fee_arg = pt.pure(fee_bps)?; + let cap = pt.programmable_move_call( + package, + Identifier::new("org").unwrap(), + Identifier::new("create_org").unwrap(), + vec![], + vec![name_arg, fee_arg], + ); + pt.transfer_args(signer.address, vec![cap]); + + let resp = submit(client, signer, pt, gas_budget, "create_org").await?; + let digest = resp.digest.to_string(); + let changes = resp + .object_changes + .as_ref() + .ok_or_else(|| anyhow!("create_org response missing object_changes"))?; + let mut org_id = None; + let mut org_cap_id = None; + for change in changes { + if let ObjectChange::Created { + object_id, + object_type, + .. + } = change + { + match (object_type.module.as_str(), object_type.name.as_str()) { + ("org", "Org") => org_id = Some(*object_id), + ("org", "OrgCap") => org_cap_id = Some(*object_id), + _ => {} + } + } + } + Ok(CreateOrgOutcome { + org_id: org_id.ok_or_else(|| anyhow!("Org not found in create_org changes"))?, + org_cap_id: org_cap_id.ok_or_else(|| anyhow!("OrgCap not found in create_org changes"))?, + digest, + }) +} + +/// `org::set_org_fee_bps(&OrgCap, &mut Org, new_bps)`. +pub async fn set_org_fee_bps( + client: &SuiClient, + signer: &Signer, + package: ObjectID, + org_cap: ObjectID, + org: ObjectID, + new_bps: u64, + gas_budget: u64, +) -> Result { + info!(%package, %org, new_bps, "building set_org_fee_bps PTB"); + let mut pt = ProgrammableTransactionBuilder::new(); + let cap_arg = pt.obj(owned_object_arg(client, org_cap).await?)?; + let org_arg = pt.obj(shared_object_arg(client, org, true).await?)?; + let bps_arg = pt.pure(new_bps)?; + pt.programmable_move_call( + package, + Identifier::new("org").unwrap(), + Identifier::new("set_org_fee_bps").unwrap(), + vec![], + vec![cap_arg, org_arg, bps_arg], + ); + submit(client, signer, pt, gas_budget, "set_org_fee_bps").await +} + +/// `org::withdraw(&OrgCap, &mut Org, amount, ctx): Coin` — the coin is +/// returned; the PTB transfers it to `recipient`. +pub async fn withdraw_org( + client: &SuiClient, + signer: &Signer, + package: ObjectID, + org_cap: ObjectID, + org: ObjectID, + asset_type: &str, + amount: u64, + recipient: SuiAddress, + gas_budget: u64, +) -> Result { + info!(%package, %org, asset_type, amount, %recipient, "building org withdraw PTB"); + let mut pt = ProgrammableTransactionBuilder::new(); + let cap_arg = pt.obj(owned_object_arg(client, org_cap).await?)?; + let org_arg = pt.obj(shared_object_arg(client, org, true).await?)?; + let amount_arg = pt.pure(amount)?; + let t_tag = TypeTag::from_str(asset_type) + .with_context(|| format!("parsing asset type {asset_type}"))?; + let coin = pt.programmable_move_call( + package, + Identifier::new("org").unwrap(), + Identifier::new("withdraw").unwrap(), + vec![t_tag], + vec![cap_arg, org_arg, amount_arg], + ); + pt.transfer_args(recipient, vec![coin]); + submit(client, signer, pt, gas_budget, "org withdraw").await +} + +/// Gas-select, sign, submit, and assert success. +async fn submit( + client: &SuiClient, + signer: &Signer, + pt: ProgrammableTransactionBuilder, + gas_budget: u64, + what: &str, +) -> Result { + let programmable = pt.finish(); + let gas_coin = client + .coin_read_api() + .get_coins(signer.address, None, None, Some(5)) + .await + .context("listing gas coins")? + .data + .into_iter() + .max_by_key(|c| c.balance) + .ok_or_else(|| anyhow!("no SUI coins to pay gas for {}", signer.address))?; + let gas_price = client + .read_api() + .get_reference_gas_price() + .await + .context("fetching reference gas price")?; + let tx_data = TransactionData::new_programmable( + signer.address, + vec![gas_coin.object_ref()], + programmable, + gas_budget, + gas_price, + ); + let sig = Transaction::signature_from_signer( + tx_data.clone(), + Intent::sui_transaction(), + &signer.keypair, + ); + let tx = Transaction::from_data(tx_data, vec![sig]); + let opts = SuiTransactionBlockResponseOptions::new() + .with_effects() + .with_object_changes(); + let resp = client + .quorum_driver_api() + .execute_transaction_block( + tx, + opts, + Some(ExecuteTransactionRequestType::WaitForLocalExecution), + ) + .await + .with_context(|| format!("submitting {what} tx"))?; + let effects = resp.effects.as_ref().context("response missing effects")?; + if effects.status().is_err() { + anyhow::bail!("{what} reverted: {:?}", effects.status()); + } + debug!(digest = %resp.digest, what, "tx succeeded"); + Ok(resp) +} diff --git a/rust-backend/crates/sui-tx/src/tx/template.rs b/rust-backend/crates/sui-tx/src/tx/template.rs index 58232d2a..1d731a9e 100644 --- a/rust-backend/crates/sui-tx/src/tx/template.rs +++ b/rust-backend/crates/sui-tx/src/tx/template.rs @@ -135,28 +135,27 @@ pub fn protocol_templates( deepbook: Option, ) -> Vec { let t = |module: &str, function: &str| MoveTarget::new(protocol, module, function); - let coin_zero = MoveTarget::new(framework(), "coin", "zero"); - // write / buy differ only by writer_flow vs trader_flow. - let execute_write_flow = |name: &str, flow: &str| { + // write / buy target the split execute_write entry points. The returned + // executor-side objects (Position + net premium / Coin) are routed + // by a trailing TransferObjects, which `matches` treats as benign. + let execute_write_flow = |name: &str, entry: &str| { let targets = vec![ t("quote", "new_quote"), t("quote", "new_signed_quote"), - t("bucket", flow), - coin_zero.clone(), - t("bucket", "execute_write"), + t("bucket", entry), ]; PtbTemplate { name: name.to_owned(), required: targets.clone(), allowed: targets, - arities: vec![(t("bucket", "execute_write"), 3)], + arities: vec![(t("bucket", entry), 3)], } }; let mut templates = vec![ - execute_write_flow("write", "writer_flow"), - execute_write_flow("buy", "trader_flow"), + execute_write_flow("write", "execute_write_writer_flow"), + execute_write_flow("buy", "execute_write_trader_flow"), PtbTemplate { name: "exercise".to_owned(), required: vec![t("bucket", "exercise")], @@ -316,13 +315,15 @@ mod tests { #[test] fn write_flow_matches() { + // Mirrors frontend buildWriteTx: coinWithBalance prelude, quote + // reconstruction, the split writer entry point, then a trailing + // TransferObjects of the returned (Position, net premium) — modeled + // by `build`'s benign SplitCoins (TransferObjects is equally benign). let pt = build( &[ (target("quote", "new_quote"), 0), (target("quote", "new_signed_quote"), 0), - (target("bucket", "writer_flow"), 0), - (MoveTarget::new(framework(), "coin", "zero"), 1), - (target("bucket", "execute_write"), 3), + (target("bucket", "execute_write_writer_flow"), 3), ], true, ); @@ -335,15 +336,46 @@ mod tests { &[ (target("quote", "new_quote"), 0), (target("quote", "new_signed_quote"), 0), - (target("bucket", "trader_flow"), 0), - (MoveTarget::new(framework(), "coin", "zero"), 1), - (target("bucket", "execute_write"), 3), + (target("bucket", "execute_write_trader_flow"), 3), ], true, ); assert_eq!(match_any(&templates(), &pt), Some("buy")); } + #[test] + fn write_flow_with_trailing_transfer_objects_matches() { + // The real frontend PTB ends with TransferObjects of the returned + // executor-side values — assert the matcher tolerates it explicitly. + let mut b = ProgrammableTransactionBuilder::new(); + for (t, n) in [ + (target("quote", "new_quote"), 0usize), + (target("quote", "new_signed_quote"), 0), + (target("bucket", "execute_write_writer_flow"), 3), + ] { + let type_args: Vec = (0..n).map(|_| TypeTag::U64).collect(); + b.programmable_move_call( + t.package, + Identifier::new(t.module.clone()).unwrap(), + Identifier::new(t.function.clone()).unwrap(), + type_args, + vec![], + ); + } + let recipient = b + .pure(sui_types::base_types::SuiAddress::ZERO) + .unwrap(); + b.command(Command::TransferObjects( + vec![ + sui_types::transaction::Argument::NestedResult(2, 0), + sui_types::transaction::Argument::NestedResult(2, 1), + ], + recipient, + )); + let pt = b.finish(); + assert_eq!(match_any(&templates(), &pt), Some("write")); + } + #[test] fn exercise_and_redeem_match() { let ex = build(&[(target("bucket", "exercise"), 3)], true); @@ -514,10 +546,8 @@ mod tests { &[ (target("quote", "new_quote"), 0), (target("quote", "new_signed_quote"), 0), - (target("bucket", "writer_flow"), 0), - (MoveTarget::new(framework(), "coin", "zero"), 1), (MoveTarget::new(evil, "drain", "all"), 0), - (target("bucket", "execute_write"), 3), + (target("bucket", "execute_write_writer_flow"), 3), ], false, ); @@ -525,15 +555,15 @@ mod tests { } #[test] - fn framework_call_other_than_coin_zero_rejected() { - // Proves we whitelist `0x2::coin::zero`, not all of `0x2`. + fn framework_call_rejected_in_write_flow() { + // Framework Move calls (even coin::zero) are no longer in the write + // template's allowed set — the split entry points need no empty coin. let pt = build( &[ (target("quote", "new_quote"), 0), (target("quote", "new_signed_quote"), 0), - (target("bucket", "writer_flow"), 0), - (MoveTarget::new(framework(), "transfer", "public_transfer"), 1), - (target("bucket", "execute_write"), 3), + (MoveTarget::new(framework(), "coin", "zero"), 1), + (target("bucket", "execute_write_writer_flow"), 3), ], false, ); @@ -548,7 +578,7 @@ mod tests { #[test] fn admin_call_rejected() { - let pt = build(&[(target("admin", "set_fee_bps"), 0)], false); + let pt = build(&[(target("admin", "set_protocol_fee_bps"), 0)], false); assert_eq!(match_any(&templates(), &pt), None); let withdraw = build(&[(target("treasury", "withdraw"), 1)], false); assert_eq!(match_any(&templates(), &withdraw), None); diff --git a/rust-backend/crates/token-info-client/Cargo.toml b/rust-backend/crates/token-info-client/Cargo.toml index f92c9be2..a0ad7464 100644 --- a/rust-backend/crates/token-info-client/Cargo.toml +++ b/rust-backend/crates/token-info-client/Cargo.toml @@ -12,6 +12,7 @@ deployments = { workspace = true } protocol-types = { workspace = true } reqwest = { workspace = true } +parking_lot = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/rust-backend/crates/token-info-client/src/lib.rs b/rust-backend/crates/token-info-client/src/lib.rs index f56877a5..53b582b9 100644 --- a/rust-backend/crates/token-info-client/src/lib.rs +++ b/rust-backend/crates/token-info-client/src/lib.rs @@ -67,6 +67,20 @@ impl SupportedToken { } } +/// One verified-org allowlist entry as served by `GET /orgs`. +/// +/// Orgs are created permissionlessly on-chain; this allowlist (operated by +/// the platform via token-info's JWT-gated mutate routes) decides which +/// orgs' buckets the user-facing surfaces serve. "Verified" = present AND +/// `enabled`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifiedOrg { + pub org_id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, +} + /// An immutable view of token-info's state at fetch time: the protocol /// `package_info` for the configured network plus the full token catalog. #[derive(Debug, Clone)] @@ -95,6 +109,26 @@ impl Snapshot { .ok_or_else(|| anyhow!("treasury_id missing from token-info package_info"))?; ObjectID::from_str_checked(id, "treasury_id") } + /// The platform's own shared Org object ("SuiOptions"). + pub fn platform_org(&self) -> Result { + let id = self + .package_info + .platform_org_id + .as_deref() + .ok_or_else(|| anyhow!("platformOrgId missing from token-info package_info"))?; + ObjectID::from_str_checked(id, "platform_org_id") + } + + /// The platform org's OrgCap (owned by the deployer). + pub fn platform_org_cap(&self) -> Result { + let id = self + .package_info + .platform_org_cap_id + .as_deref() + .ok_or_else(|| anyhow!("platformOrgCapId missing from token-info package_info"))?; + ObjectID::from_str_checked(id, "platform_org_cap_id") + } + pub fn deployer_address(&self) -> Result { use std::str::FromStr; SuiAddress::from_str(&self.package_info.deployer).context("parsing deployer") @@ -253,6 +287,15 @@ impl TokenInfoClient { .with_context(|| format!("token-info at {} unreachable after {max_attempts} attempts", self.base_url)) } + /// Fetch the verified-orgs allowlist (enabled rows only). Unlike the boot + /// snapshot this is runtime-mutable state — consumers poll it via + /// [`VerifiedOrgsWatcher`] rather than caching it once. + pub async fn fetch_verified_orgs(&self) -> Result> { + self.get_json::>("/orgs?enabled=true") + .await + .context("fetching /orgs from token-info") + } + async fn get_json(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url, path); let resp = self.http.get(&url).send().await?; @@ -265,6 +308,87 @@ impl TokenInfoClient { } } +/// A polling view of the verified-orgs allowlist, shared by api-service and +/// quoting-service ("verified-only surfaces"). +/// +/// Semantics: +/// - **Fail-closed at boot**: [`VerifiedOrgsWatcher::start`] errors if the +/// first fetch fails — consistent with the hard-cutover ethos (no surface +/// should come up serving an unknown allowlist). +/// - **Keep-last-good on refresh failure**: once running, a failed refresh +/// logs and keeps the previous set (fail-open-on-stale, never +/// serve-everything). +#[derive(Clone)] +pub struct VerifiedOrgsWatcher { + inner: std::sync::Arc>>, +} + +impl VerifiedOrgsWatcher { + /// Fetch once (fail-closed), then refresh every `interval` in a background + /// task for the life of the process. + pub async fn start(client: TokenInfoClient, interval: Duration) -> Result { + let initial = client + .fetch_verified_orgs() + .await + .context("initial verified-orgs fetch (fail-closed at boot)")?; + info!(orgs = initial.len(), "verified-orgs allowlist loaded"); + let inner = std::sync::Arc::new(parking_lot::RwLock::new(index_orgs(initial))); + let weak = std::sync::Arc::downgrade(&inner); + tokio::spawn(async move { + loop { + tokio::time::sleep(interval).await; + let Some(strong) = weak.upgrade() else { break }; + match client.fetch_verified_orgs().await { + Ok(orgs) => *strong.write() = index_orgs(orgs), + Err(e) => warn!(error = %e, "verified-orgs refresh failed; keeping last good set"), + } + } + }); + Ok(Self { inner }) + } + + /// Build a watcher from a fixed set — for tests and tools that don't poll. + pub fn fixed(orgs: Vec) -> Self { + Self { + inner: std::sync::Arc::new(parking_lot::RwLock::new(index_orgs(orgs))), + } + } + + /// Is this org id on the allowlist (and enabled)? + pub fn is_verified(&self, org_id: &str) -> bool { + self.inner.read().contains_key(&normalize_id(org_id)) + } + + /// Display name for a verified org, if known. + pub fn name_of(&self, org_id: &str) -> Option { + self.inner.read().get(&normalize_id(org_id)).map(|o| o.name.clone()) + } + + /// Snapshot of the current allowlist (enabled rows only). + pub fn all(&self) -> Vec { + self.inner.read().values().cloned().collect() + } + + /// Normalized org ids of the current allowlist. + pub fn ids(&self) -> Vec { + self.inner.read().keys().cloned().collect() + } +} + +fn index_orgs(orgs: Vec) -> std::collections::HashMap { + orgs.into_iter() + .filter(|o| o.enabled) + .map(|o| (normalize_id(&o.org_id), o)) + .collect() +} + +/// Normalize an object id for comparison: strip `0x`, lowercase, left-pad to +/// 64 hex chars — different sources render ids with/without padding. +fn normalize_id(s: &str) -> String { + let stripped = s.strip_prefix("0x").unwrap_or(s).to_ascii_lowercase(); + format!("0x{:0>64}", stripped) +} + /// Normalize a Move type string so address-format differences don't break /// coin-type comparisons (mirrors the api-service catalog normalization): /// strip `0x`, lowercase, left-pad the address to 64 hex chars. @@ -315,6 +439,10 @@ mod tests { treasury_id: Some("0x6".into()), publish_digest: "x".into(), init_digest: None, + platform_org_id: Some( + "0x8a094ab9d022f51ef18271e1226c32405df85b4fada60492383de59324b191c9".into(), + ), + platform_org_cap_id: Some("0x9".into()), deployer: "0x7".into(), deployed_at: "".into(), network: "testnet".into(), @@ -361,6 +489,37 @@ mod tests { ); } + #[test] + fn platform_org_accessors_parse() { + let s = snap(); + assert!(s.platform_org().is_ok()); + assert!(s.platform_org_cap().is_ok()); + } + + #[test] + fn verified_orgs_watcher_normalizes_ids() { + let w = VerifiedOrgsWatcher::fixed(vec![ + VerifiedOrg { + org_id: "0xabc".into(), + name: "acme".into(), + enabled: true, + }, + VerifiedOrg { + org_id: "0xdef".into(), + name: "disabled-org".into(), + enabled: false, + }, + ]); + // Padded and unpadded forms match the same entry. + assert!(w.is_verified("0xabc")); + assert!(w.is_verified(&format!("0x{:0>64}", "abc"))); + assert_eq!(w.name_of("0xabc").as_deref(), Some("acme")); + // Disabled rows are not verified. + assert!(!w.is_verified("0xdef")); + assert!(!w.is_verified("0x123")); + assert_eq!(w.all().len(), 1); + } + #[test] fn supported_token_json_roundtrips() { let t = tok("TUSDC", "0xpkg::tusdc::TUSDC", None); diff --git a/rust-backend/deployment.md b/rust-backend/deployment.md index 951cd153..2f764cba 100644 --- a/rust-backend/deployment.md +++ b/rust-backend/deployment.md @@ -336,6 +336,9 @@ export DB_PASSWORD=$(aws secretsmanager get-secret-value \ 1. Developer runs `cargo run -p deploy -- ...` locally targeting a specific network. The deploy tool writes `deployments.json`. + Post-publish it also creates the **platform org** ("SuiOptions" — name + via `--org-name`, fee via `--org-fee-bps`) and records + `platformOrgId` / `platformOrgCapId` alongside the package ids. 2. Developer commits `deployments.json` + pushes to `staging` (for devnet/testnet redeploys) or `main` (for mainnet). 3. CI builds new images that bake the updated `deployments.json` in. @@ -343,6 +346,33 @@ export DB_PASSWORD=$(aws secretsmanager get-secret-value \ Contract publishes are **not** in CI — too easy to misfire on mainnet. +### Org-aware redeploy checklist (post-orgs protocol) + +A fresh contract publish changes the package id, so the indexer must start +on a **fresh DB** (old events aren't re-ingested; old JSONB payloads predate +the org fields and won't deserialize into the new structs). Then: + +1. **Seed the verified-orgs allowlist**: POST the platform org to + token-info's internal router so the user-facing surfaces serve it — + `curl -X POST http://:9006/orgs -H 'content-type: + application/json' -d '{"org_id":"","name":"SuiOptions", + "enabled":true}'`. api-service and quoting-service **fail closed at + boot** if token-info's `/orgs` is unreachable, so deploy token-info + (with its `verified_orgs` migration) before them. +2. **option-scheduler** now authorizes with the platform **OrgCap** (read + from token-info by default; self-hosted org schedulers set `org_id` / + `org_cap_id` in their config TOML). The old "signer == deployer" + assertion is replaced by an on-chain OrgCap ownership check at boot. +3. **gas-station + frontend must roll in the same window**: the write/buy + PTB shapes changed (split `execute_write_*` entry points + trailing + `TransferObjects`); mismatched templates silently refuse sponsorship. + See `.claude/ptb-sync.md`. +4. Other orgs onboard with zero platform involvement on-chain + (`org::create_org` is permissionless; they self-host a scheduler), but + their buckets only appear in the platform UI/API/quoting after the + platform admin verifies them (Admin → Verified orgs, or the token-info + internal `/orgs` route). + --- ## 8. GitHub Actions diff --git a/rust-backend/services/api-service/src/bucket.rs b/rust-backend/services/api-service/src/bucket.rs index 3e567bdf..765a760d 100644 --- a/rust-backend/services/api-service/src/bucket.rs +++ b/rust-backend/services/api-service/src/bucket.rs @@ -2,6 +2,8 @@ use protocol_types::asset::AssetType; #[derive(Clone, Debug)] pub struct Bucket { + /// Org that created (and administers) this bucket, hex object id. + pub org_id: String, pub asset_type: AssetType, pub settlement_type: AssetType, /// Fully-qualified type of the per-bucket fungible option coin. diff --git a/rust-backend/services/api-service/src/handlers/buckets.rs b/rust-backend/services/api-service/src/handlers/buckets.rs index 91daead6..6f1cdb49 100644 --- a/rust-backend/services/api-service/src/handlers/buckets.rs +++ b/rust-backend/services/api-service/src/handlers/buckets.rs @@ -114,6 +114,11 @@ pub struct BucketDto { #[derive(Serialize)] pub struct SeriesDto { + /// Org that created every bucket in this series (series are keyed by + /// org — two orgs listing the same asset/expiry stay separate). + pub org_id: String, + /// Display name from the verified-orgs allowlist. + pub org_name: Option, /// Friendly symbol from `deployments.json` (`"TBTC"`) — or the raw Move /// type string when the coin type isn't in the catalog. pub asset_symbol: String, @@ -140,9 +145,12 @@ pub struct BucketsResponse { pub async fn list_buckets( State(state): State>, ) -> Result, StatusCode> { + // Verified-only surface: only allowlisted orgs' buckets are served. + // The filter is pushed down to the indexer query. + let org_ids = state.verified_orgs.ids(); let active = state .indexer - .buckets(true, None, None, None) + .buckets(true, Some(&org_ids), None, None, None) .await .map_err(|e| { tracing::warn!(error = %e, "indexer buckets query failed"); @@ -150,8 +158,14 @@ pub async fn list_buckets( })?; let active = active.into_iter().map(into_local_bucket).collect(); let now_ms = Utc::now().timestamp_millis(); + let org_names: BTreeMap = state + .verified_orgs + .all() + .into_iter() + .map(|o| (o.org_id, o.name)) + .collect(); Ok(Json(BucketsResponse { - series: group_into_series(active, &state.catalog, now_ms), + series: group_into_series(active, &state.catalog, &org_names, now_ms), })) } @@ -210,7 +224,11 @@ pub async fn get_bucket( })?; // Cleaned buckets are settled-and-gone — treat them as absent so the // tideline stops polling a stale id rather than rendering dead state. - let bucket = bucket.filter(|b| !b.cleaned).ok_or(StatusCode::NOT_FOUND)?; + // Unverified orgs' buckets are likewise absent (verified-only surface). + let bucket = bucket + .filter(|b| !b.cleaned) + .filter(|b| state.verified_orgs.is_verified(&b.org_id.to_hex())) + .ok_or(StatusCode::NOT_FOUND)?; let now_ms = Utc::now().timestamp_millis(); Ok(Json(detail_dto_from(&bucket, &state.catalog, now_ms))) } @@ -279,6 +297,7 @@ fn into_local_bucket(b: indexer_graphql::Bucket) -> (protocol_types::ids::Object ( b.bucket_id, Bucket { + org_id: b.org_id.to_hex(), asset_type: b.asset_type, settlement_type: b.settlement_type, call_type: b.call_type, @@ -294,17 +313,21 @@ fn into_local_bucket(b: indexer_graphql::Bucket) -> (protocol_types::ids::Object ) } -type SeriesKey = (String, String, u64); +/// Keyed by org FIRST: two orgs can list the same (asset, settlement, +/// expiry) — merging their strike ladders would silently corrupt the UI. +type SeriesKey = (String, String, String, u64); /// Pure helper — split out so it's unit-testable without spinning up axum. fn group_into_series( buckets: Vec<(protocol_types::ids::ObjectId, Bucket)>, catalog: &TokenCatalog, + org_names: &BTreeMap, now_ms: i64, ) -> Vec { let mut grouped: BTreeMap> = BTreeMap::new(); for (id, b) in buckets { let key = ( + b.org_id.clone(), b.asset_type.as_str().to_string(), b.settlement_type.as_str().to_string(), b.expiry_ms, @@ -314,7 +337,7 @@ fn group_into_series( grouped .into_iter() - .map(|((asset_ct, settle_ct, expiry_ms), members)| { + .map(|((org_id, asset_ct, settle_ct, expiry_ms), members)| { let asset_meta = catalog.lookup(&asset_ct); let settle_meta = catalog.lookup(&settle_ct); let asset_decimals = asset_meta.map(|m| m.decimals); @@ -334,6 +357,8 @@ fn group_into_series( }); SeriesDto { + org_name: org_names.get(&org_id).cloned(), + org_id, asset_symbol: asset_meta .map(|m| m.symbol.clone()) .unwrap_or_else(|| asset_ct.clone()), @@ -440,6 +465,7 @@ mod tests { fn mk_bucket(strike: u128, strike_scale: u8, written: u128, cursor: u128) -> Bucket { Bucket { + org_id: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), asset_type: AssetType::new("0xpkg::tbtc::TBTC"), settlement_type: AssetType::new("0xpkg::tusdc::TUSDC"), call_type: AssetType::new("0xpkg::call_0::CALL_0"), @@ -469,7 +495,7 @@ mod tests { ), (ObjectId::new([0xbb; 32]), mk_bucket(900, 0, 0, 0)), ]; - let series = group_into_series(buckets, &cat, NOW_MS); + let series = group_into_series(buckets, &cat, &BTreeMap::new(), NOW_MS); assert_eq!(series.len(), 1); let s = &series[0]; assert_eq!(s.asset_symbol, "TBTC"); @@ -497,7 +523,7 @@ mod tests { // regression can't sneak back in. let cat = fixture_catalog(); let buckets = vec![(ObjectId::new([0xee; 32]), mk_bucket(769, 0, 0, 0))]; - let s = group_into_series(buckets, &cat, NOW_MS); + let s = group_into_series(buckets, &cat, &BTreeMap::new(), NOW_MS); assert_eq!(s[0].buckets[0].strike, Some(76_900.0)); assert_eq!(s[0].buckets[0].strike_raw, "769"); } @@ -515,6 +541,7 @@ mod tests { tok("TUSDC9", "0xpkg::tusdc::TUSDC", 9), ]); let b = Bucket { + org_id: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), asset_type: AssetType::new("0xpkg::deep::DEEP"), settlement_type: AssetType::new("0xpkg::tusdc::TUSDC"), call_type: AssetType::new("0xpkg::call_0::CALL_0"), @@ -527,7 +554,7 @@ mod tests { invalidated: false, deepbook_pool_id: None, }; - let s = group_into_series(vec![(ObjectId::new([0xff; 32]), b)], &cat, NOW_MS); + let s = group_into_series(vec![(ObjectId::new([0xff; 32]), b)], &cat, &BTreeMap::new(), NOW_MS); // 150 * 10^(6-9-0) = 0.15 assert!((s[0].buckets[0].strike.unwrap() - 0.15).abs() < 1e-12); } @@ -543,6 +570,7 @@ mod tests { tok("TUSDC", "0xpkg::tusdc::TUSDC", 6), ]); let b = Bucket { + org_id: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), asset_type: AssetType::new("0xpkg::tdeep::TDEEP"), settlement_type: AssetType::new("0xpkg::tusdc::TUSDC"), call_type: AssetType::new("0xpkg::call_0::CALL_0"), @@ -555,7 +583,7 @@ mod tests { invalidated: false, deepbook_pool_id: None, }; - let s = group_into_series(vec![(ObjectId::new([0xfe; 32]), b)], &cat, NOW_MS); + let s = group_into_series(vec![(ObjectId::new([0xfe; 32]), b)], &cat, &BTreeMap::new(), NOW_MS); // 15_000 * 10^(6 - 6 - 5) = 0.15 USD assert!((s[0].buckets[0].strike.unwrap() - 0.15).abs() < 1e-12); assert_eq!(s[0].buckets[0].strike_scale, 5); @@ -566,7 +594,7 @@ mod tests { fn unknown_coin_type_falls_back_to_raw_string() { let cat = TokenCatalog::default(); let buckets = vec![(ObjectId::new([0xcc; 32]), mk_bucket(1, 0, 0, 0))]; - let series = group_into_series(buckets, &cat, NOW_MS); + let series = group_into_series(buckets, &cat, &BTreeMap::new(), NOW_MS); assert_eq!(series[0].asset_symbol, "0xpkg::tbtc::TBTC"); assert_eq!(series[0].asset_decimals, None); assert_eq!(series[0].buckets[0].strike, None); @@ -581,6 +609,7 @@ mod tests { let cat = fixture_catalog(); let raw = "9b72409a9f38a8784420d17577aa6dbe5aa2ab4224cd04c44d8b515f6c97ba86"; let b = Bucket { + org_id: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), asset_type: AssetType::new(format!("{raw}::tbtc::TBTC")), settlement_type: AssetType::new(format!("{raw}::tusdc::TUSDC")), call_type: AssetType::new(format!("{raw}::call_0::CALL_0")), @@ -593,7 +622,7 @@ mod tests { invalidated: false, deepbook_pool_id: None, }; - let s = group_into_series(vec![(ObjectId::new([0x11; 32]), b)], &cat, NOW_MS); + let s = group_into_series(vec![(ObjectId::new([0x11; 32]), b)], &cat, &BTreeMap::new(), NOW_MS); assert_eq!(s[0].asset_coin_type, format!("0x{raw}::tbtc::TBTC")); assert_eq!(s[0].settlement_coin_type, format!("0x{raw}::tusdc::TUSDC")); } @@ -605,13 +634,14 @@ mod tests { ObjectId::new([0xdd; 32]), mk_bucket(85_000_000_000, 0, 0, 0), )]; - let s = group_into_series(buckets, &cat, NOW_MS); + let s = group_into_series(buckets, &cat, &BTreeMap::new(), NOW_MS); assert_eq!(s[0].buckets[0].fill_pct, Some(0.0)); } fn mk_idx_bucket(id: ObjectId, written: u128, cursor: u128) -> IndexerBucket { IndexerBucket { bucket_id: id, + org_id: ObjectId::new([0xaa; 32]), asset_type: AssetType::new("0xpkg::tbtc::TBTC"), settlement_type: AssetType::new("0xpkg::tusdc::TUSDC"), call_type: AssetType::new("0xpkg::call_0::CALL_0"), @@ -685,7 +715,7 @@ mod tests { let pool_hex = ObjectId::new([0xee; 32]).to_hex(); let mut b = mk_bucket(850, 0, 0, 0); b.deepbook_pool_id = Some(pool_hex.clone()); - let s = group_into_series(vec![(ObjectId::new([0x33; 32]), b)], &cat, NOW_MS); + let s = group_into_series(vec![(ObjectId::new([0x33; 32]), b)], &cat, &BTreeMap::new(), NOW_MS); let dto = &s[0].buckets[0]; assert_eq!(dto.deepbook_pool_id.as_deref(), Some(pool_hex.as_str())); assert!(dto.tradeable); @@ -694,6 +724,7 @@ mod tests { let s = group_into_series( vec![(ObjectId::new([0x34; 32]), mk_bucket(850, 0, 0, 0))], &cat, + &BTreeMap::new(), NOW_MS, ); let dto = &s[0].buckets[0]; @@ -701,6 +732,35 @@ mod tests { assert!(!dto.tradeable); } + #[test] + fn two_orgs_same_pair_and_expiry_stay_separate_series() { + // Two orgs listing the same (asset, settlement, expiry): merging + // their strike ladders would silently corrupt the UI — the series + // key must include the org. + let cat = fixture_catalog(); + let mut other = mk_bucket(900, 0, 0, 0); + other.org_id = format!("0x{}", "bb".repeat(32)); + let names: BTreeMap = [ + (format!("0x{}", "aa".repeat(32)), "SuiOptions".to_string()), + (format!("0x{}", "bb".repeat(32)), "Acme".to_string()), + ] + .into_iter() + .collect(); + let series = group_into_series( + vec![ + (ObjectId::new([0x01; 32]), mk_bucket(850, 0, 0, 0)), + (ObjectId::new([0x02; 32]), other), + ], + &cat, + &names, + NOW_MS, + ); + assert_eq!(series.len(), 2); + assert_eq!(series[0].org_name.as_deref(), Some("SuiOptions")); + assert_eq!(series[1].org_name.as_deref(), Some("Acme")); + assert_ne!(series[0].org_id, series[1].org_id); + } + #[test] fn malformed_bucket_id_is_a_404_guard() { // The handler's 404-on-unknown path keys off `ObjectId::from_hex` diff --git a/rust-backend/services/api-service/src/handlers/call_token_lots.rs b/rust-backend/services/api-service/src/handlers/call_token_lots.rs index ed9a9a73..0ffef8a5 100644 --- a/rust-backend/services/api-service/src/handlers/call_token_lots.rs +++ b/rust-backend/services/api-service/src/handlers/call_token_lots.rs @@ -87,7 +87,7 @@ pub async fn list_call_token_lots( })?; let buckets: BTreeMap = state .indexer - .buckets(false, None, None, None) + .buckets(false, None, None, None, None) .await .map_err(|e| { tracing::warn!(error = %e, "indexer buckets query failed"); diff --git a/rust-backend/services/api-service/src/handlers/events.rs b/rust-backend/services/api-service/src/handlers/events.rs index 574cf0fb..f5fd635e 100644 --- a/rust-backend/services/api-service/src/handlers/events.rs +++ b/rust-backend/services/api-service/src/handlers/events.rs @@ -103,7 +103,7 @@ fn rows_for(event: &ChainEvent, wallet: &SuiAddress) -> Vec { let mut out = Vec::new(); match event { ChainEvent::WriteExecuted(w) => { - if w.position_recipient == *wallet { + if w.position_recipient() == *wallet { // Writer opened a position, received net premium. out.push(Row { event_type: "position_opened", @@ -115,7 +115,7 @@ fn rows_for(event: &ChainEvent, wallet: &SuiAddress) -> Vec { value_asset: ValueAsset::BucketSettlement, }); } - if w.call_token_recipient == *wallet { + if w.call_token_recipient() == *wallet { // Buyer bought the call, paid the gross premium. out.push(Row { event_type: "position_opened", @@ -195,7 +195,7 @@ pub async fn list_events( // Buckets are fetched once and joined locally for symbol/strike/expiry. let buckets: BTreeMap = state .indexer - .buckets(false, None, None, None) + .buckets(false, None, None, None, None) .await .map_err(|e| { tracing::warn!(error = %e, "indexer buckets query failed"); @@ -286,17 +286,20 @@ mod tests { let bucket = ObjectId::new([0xc1; 32]); let writer = SuiAddress::new([0x11; 32]); let buyer = SuiAddress::new([0x22; 32]); + // Writer flow: executor (writer) keeps the returned Position/net + // premium; the signer's recipient (buyer) gets the Coin. let we = ChainEvent::WriteExecuted(WriteExecuted { bucket_id: bucket, + org_id: ObjectId::new([0xee; 32]), signer_account_id: ObjectId::new([0x33; 32]), signer_token_recipient: buyer, executor: writer, position_id: ObjectId::new([0x44; 32]), - position_recipient: writer, - call_token_recipient: buyer, + flow: protocol_types::events::FLOW_WRITER, write_amount: 100, gross_premium: 90, - fee: 5, + org_fee: 2, + protocol_fee: 3, net_premium: 85, range_start: 0, range_end: 100, diff --git a/rust-backend/services/api-service/src/handlers/mod.rs b/rust-backend/services/api-service/src/handlers/mod.rs index 5dd4ae81..4c718f9d 100644 --- a/rust-backend/services/api-service/src/handlers/mod.rs +++ b/rust-backend/services/api-service/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod buckets; +pub mod orgs; pub mod call_token_lots; pub mod dashboard; pub mod events; diff --git a/rust-backend/services/api-service/src/handlers/orgs.rs b/rust-backend/services/api-service/src/handlers/orgs.rs new file mode 100644 index 00000000..c8a640a1 --- /dev/null +++ b/rust-backend/services/api-service/src/handlers/orgs.rs @@ -0,0 +1,38 @@ +//! `GET /orgs` — the verified-orgs allowlist, for the frontend. +//! +//! Served from the in-process [`token_info_client::VerifiedOrgsWatcher`] +//! (polled from token-info) so the frontend has a single API base for all +//! read traffic. Only enabled (verified) orgs appear — this is the same set +//! `/buckets` filters by. + +use std::sync::Arc; + +use axum::{extract::State, Json}; +use serde::Serialize; + +use crate::state::AppState; + +#[derive(Serialize)] +pub struct OrgDto { + pub org_id: String, + pub name: String, +} + +#[derive(Serialize)] +pub struct OrgsResponse { + pub orgs: Vec, +} + +pub async fn list_orgs(State(state): State>) -> Json { + let mut orgs: Vec = state + .verified_orgs + .all() + .into_iter() + .map(|o| OrgDto { + org_id: o.org_id, + name: o.name, + }) + .collect(); + orgs.sort_by(|a, b| a.name.cmp(&b.name)); + Json(OrgsResponse { orgs }) +} diff --git a/rust-backend/services/api-service/src/main.rs b/rust-backend/services/api-service/src/main.rs index b543dfa3..09a825f9 100644 --- a/rust-backend/services/api-service/src/main.rs +++ b/rust-backend/services/api-service/src/main.rs @@ -20,13 +20,26 @@ async fn main() -> Result<()> { // Fetch the supported-token catalog from token-info. Hard cutover: if // token-info is unreachable after the retry window we crash (no // deployments.json fallback). - let snapshot = TokenInfoClient::new(&cfg.token_info_url) + let token_info = TokenInfoClient::new(&cfg.token_info_url); + let snapshot = token_info .fetch_blocking_until_ready(30, Duration::from_secs(2)) .await .with_context(|| format!("fetching catalog from token-info at {}", cfg.token_info_url))?; let catalog = TokenCatalog::from_tokens(snapshot.tokens()); - let state = Arc::new(AppState::new(catalog, cfg.indexer_graphql_url.clone())); + // Verified-orgs allowlist: fail-closed at boot, then polled in the + // background (the list changes via human admin action; ~30s staleness + // is fine). Refresh failures keep the last good set. + let verified_orgs = + token_info_client::VerifiedOrgsWatcher::start(token_info, Duration::from_secs(30)) + .await + .context("loading verified-orgs allowlist from token-info")?; + + let state = Arc::new(AppState::new( + catalog, + cfg.indexer_graphql_url.clone(), + verified_orgs, + )); router::serve(cfg.bind_addr, state, &cfg.allowed_origins).await } diff --git a/rust-backend/services/api-service/src/router.rs b/rust-backend/services/api-service/src/router.rs index d4378c6e..38963a8a 100644 --- a/rust-backend/services/api-service/src/router.rs +++ b/rust-backend/services/api-service/src/router.rs @@ -24,6 +24,7 @@ pub async fn serve( let app = Router::new() .route("/health", get(health)) .route("/buckets", get(handlers::buckets::list_buckets)) + .route("/orgs", get(handlers::orgs::list_orgs)) .route("/buckets/:bucket_id", get(handlers::buckets::get_bucket)) .route("/positions", get(handlers::positions::list_positions)) .route( diff --git a/rust-backend/services/api-service/src/state.rs b/rust-backend/services/api-service/src/state.rs index 917866b5..a1666600 100644 --- a/rust-backend/services/api-service/src/state.rs +++ b/rust-backend/services/api-service/src/state.rs @@ -7,18 +7,27 @@ use crate::catalog::TokenCatalog; pub use indexer_graphql::{Account, Bucket as IndexerBucket, IndexerClient, Position, Progress}; +pub use token_info_client::VerifiedOrgsWatcher; pub struct AppState { pub catalog: TokenCatalog, /// JIT client for the indexer's GraphQL + progress API. pub indexer: IndexerClient, + /// Verified-orgs allowlist (polled from token-info). Every user-facing + /// bucket surface filters to these orgs; the indexer itself records all. + pub verified_orgs: VerifiedOrgsWatcher, } impl AppState { - pub fn new(catalog: TokenCatalog, indexer_graphql_url: String) -> Self { + pub fn new( + catalog: TokenCatalog, + indexer_graphql_url: String, + verified_orgs: VerifiedOrgsWatcher, + ) -> Self { Self { catalog, indexer: IndexerClient::new(indexer_graphql_url), + verified_orgs, } } } diff --git a/rust-backend/services/indexer/src/db/migrations/000005_orgs/down.sql b/rust-backend/services/indexer/src/db/migrations/000005_orgs/down.sql new file mode 100644 index 00000000..f485fae2 --- /dev/null +++ b/rust-backend/services/indexer/src/db/migrations/000005_orgs/down.sql @@ -0,0 +1,3 @@ +DROP INDEX buckets_org_id_idx; +ALTER TABLE buckets DROP COLUMN org_id; +DROP TABLE orgs; diff --git a/rust-backend/services/indexer/src/db/migrations/000005_orgs/up.sql b/rust-backend/services/indexer/src/db/migrations/000005_orgs/up.sql new file mode 100644 index 00000000..9475a5ec --- /dev/null +++ b/rust-backend/services/indexer/src/db/migrations/000005_orgs/up.sql @@ -0,0 +1,17 @@ +-- Permissionless orgs: every bucket now belongs to an org (the OrgCap that +-- created it). The indexer records ALL orgs (complete chain record); the +-- verified-orgs allowlist that gates user-facing surfaces lives in +-- token-info, not here. +CREATE TABLE orgs ( + org_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + fee_bps BIGINT NOT NULL, + creator TEXT NOT NULL, + updated_at_seq BIGINT NOT NULL +); + +-- Fresh deploys start with an empty buckets table; the DEFAULT '' keeps the +-- migration runnable on a pre-org staging DB (those rows are from the old +-- package and are never served once the indexer points at the new one). +ALTER TABLE buckets ADD COLUMN org_id TEXT NOT NULL DEFAULT ''; +CREATE INDEX buckets_org_id_idx ON buckets (org_id); diff --git a/rust-backend/services/indexer/src/db/models.rs b/rust-backend/services/indexer/src/db/models.rs index 7e199560..a072228c 100644 --- a/rust-backend/services/indexer/src/db/models.rs +++ b/rust-backend/services/indexer/src/db/models.rs @@ -14,11 +14,11 @@ use protocol_types::asset::AssetType; use protocol_types::events::ChainEvent; use protocol_types::ids::{ObjectId, SuiAddress}; -use crate::store::{AccountState, BucketState, DeepBookPoolState, PositionState}; +use crate::store::{AccountState, BucketState, DeepBookPoolState, OrgState, PositionState}; use super::schema::{ account_balances, accounts, bucket_deepbook_pools, buckets, event_participants, - indexed_events, indexer_progress, positions, + indexed_events, indexer_progress, orgs, positions, }; // ---------- indexer_progress ---------- @@ -89,8 +89,12 @@ pub fn event_type_tag(ev: &ChainEvent) -> &'static str { ChainEvent::AccountDeposit(_) => "AccountDeposit", ChainEvent::AccountWithdraw(_) => "AccountWithdraw", ChainEvent::SigningKeyRotated(_) => "SigningKeyRotated", - ChainEvent::FeeUpdated(_) => "FeeUpdated", + ChainEvent::ProtocolFeeUpdated(_) => "ProtocolFeeUpdated", + ChainEvent::ProtocolPauseSet(_) => "ProtocolPauseSet", ChainEvent::TreasuryWithdrawn(_) => "TreasuryWithdrawn", + ChainEvent::OrgCreated(_) => "OrgCreated", + ChainEvent::OrgFeeUpdated(_) => "OrgFeeUpdated", + ChainEvent::OrgWithdraw(_) => "OrgWithdraw", ChainEvent::DeepBookPoolCreated(_) => "DeepBookPoolCreated", } } @@ -121,6 +125,36 @@ pub struct AccountBalanceRow { pub updated_at_seq: i64, } +// ---------- orgs ---------- + +#[derive(Queryable, Identifiable, Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = orgs)] +#[diesel(primary_key(org_id))] +pub struct OrgRow { + pub org_id: String, + pub name: String, + pub fee_bps: i64, + pub creator: String, + pub updated_at_seq: i64, +} + +impl OrgRow { + pub fn into_state(self) -> anyhow::Result<(ObjectId, OrgState)> { + let id = ObjectId::from_hex(&self.org_id) + .map_err(|e| anyhow::anyhow!("org_id {}: {e}", self.org_id))?; + let creator = SuiAddress::from_hex(&self.creator) + .map_err(|e| anyhow::anyhow!("org creator {}: {e}", self.creator))?; + Ok(( + id, + OrgState { + name: self.name, + fee_bps: self.fee_bps as u64, + creator, + }, + )) + } +} + // ---------- buckets ---------- #[derive(Queryable, Identifiable, Insertable, AsChangeset, Debug, Clone)] @@ -139,6 +173,9 @@ pub struct BucketRow { pub cleaned: bool, pub invalidated: bool, pub updated_at_seq: i64, + /// Org that created the bucket. Field last to match the `table!` column + /// order (the column was ALTERed in after the original schema). + pub org_id: String, } // ---------- bucket_deepbook_pools ---------- @@ -212,9 +249,18 @@ impl BucketRow { pub fn into_state(self) -> anyhow::Result<(ObjectId, BucketState)> { let id = ObjectId::from_hex(&self.bucket_id) .map_err(|e| anyhow::anyhow!("bucket_id {}: {e}", self.bucket_id))?; + // Pre-org rows (old package) carry the migration default '' — map to + // ObjectId::ZERO so hydration doesn't fail on legacy staging data. + let org_id = if self.org_id.is_empty() { + ObjectId::ZERO + } else { + ObjectId::from_hex(&self.org_id) + .map_err(|e| anyhow::anyhow!("bucket org_id {}: {e}", self.org_id))? + }; Ok(( id, BucketState { + org_id, asset_type: AssetType::new(self.asset_type), settlement_type: AssetType::new(self.settlement_type), call_type: AssetType::new(self.call_type), diff --git a/rust-backend/services/indexer/src/db/repo.rs b/rust-backend/services/indexer/src/db/repo.rs index 8099ffbf..83f7b610 100644 --- a/rust-backend/services/indexer/src/db/repo.rs +++ b/rust-backend/services/indexer/src/db/repo.rs @@ -31,16 +31,16 @@ use tracing::{debug, info, trace}; use protocol_types::events::ChainEvent; use protocol_types::ids::ObjectId; -use crate::store::{AccountState, BucketState, DeepBookPoolState, PositionState}; +use crate::store::{AccountState, BucketState, DeepBookPoolState, OrgState, PositionState}; use super::models::{ account_row_into_state, bigdecimal_to_u128, event_type_tag, AccountBalanceRow, AccountRow, - BucketRow, DeepBookPoolRow, EventParticipantRow, IndexedEventRow, NewIndexedEventRow, + BucketRow, DeepBookPoolRow, EventParticipantRow, IndexedEventRow, NewIndexedEventRow, OrgRow, PositionRow, ProgressRow, }; use super::schema::{ account_balances, accounts, bucket_deepbook_pools, buckets, event_participants, - indexed_events, indexer_progress, positions, + indexed_events, indexer_progress, orgs, positions, }; use super::DbPool; @@ -57,6 +57,7 @@ pub struct CheckpointBatch { pub events: Vec, pub accounts: Vec, pub account_balances: Vec, + pub orgs: Vec, pub buckets: Vec, /// Bucket → DeepBook venue rows (SO-152). Insert-only, first pool wins. pub deepbook_pools: Vec, @@ -75,6 +76,7 @@ impl CheckpointBatch { events: Vec::new(), accounts: Vec::new(), account_balances: Vec::new(), + orgs: Vec::new(), buckets: Vec::new(), deepbook_pools: Vec::new(), position_upserts: Vec::new(), @@ -87,6 +89,7 @@ impl CheckpointBatch { self.events.is_empty() && self.accounts.is_empty() && self.account_balances.is_empty() + && self.orgs.is_empty() && self.buckets.is_empty() && self.deepbook_pools.is_empty() && self.position_upserts.is_empty() @@ -126,6 +129,7 @@ impl EventBuild { /// so the boot path can move it straight in. pub struct HydratedViews { pub accounts: BTreeMap, + pub orgs: BTreeMap, pub buckets: BTreeMap, pub positions: BTreeMap<(ObjectId, u128), PositionState>, pub deepbook_pools: BTreeMap, @@ -207,6 +211,19 @@ impl Repo { .context("upserting account_balances")?; } + for org in &batch.orgs { + diesel::insert_into(orgs::table) + .values(org) + .on_conflict(orgs::org_id) + .do_update() + .set(( + orgs::fee_bps.eq(org.fee_bps), + orgs::updated_at_seq.eq(org.updated_at_seq), + )) + .execute(conn) + .context("upserting orgs")?; + } + for bkt in &batch.buckets { diesel::insert_into(buckets::table) .values(bkt) @@ -353,6 +370,12 @@ impl Repo { } } + let mut org_map: BTreeMap = BTreeMap::new(); + for row in orgs::table.load::(&mut conn).context("loading orgs")? { + let (id, state) = row.into_state()?; + org_map.insert(id, state); + } + let mut bucket_map: BTreeMap = BTreeMap::new(); for row in buckets::table .load::(&mut conn) @@ -382,6 +405,7 @@ impl Repo { debug!( accounts = acct_map.len(), + orgs = org_map.len(), buckets = bucket_map.len(), positions = position_map.len(), deepbook_pools = deepbook_map.len(), @@ -389,6 +413,7 @@ impl Repo { ); Ok(HydratedViews { accounts: acct_map, + orgs: org_map, buckets: bucket_map, positions: position_map, deepbook_pools: deepbook_map, @@ -449,6 +474,26 @@ impl Repo { .context("loading bucket") } + /// JIT point-lookup: one org by id. Backs the GraphQL `org(id)` query. + pub fn org_by_id(&self, org_id: &str) -> Result> { + let mut conn = self.conn()?; + orgs::table + .find(org_id) + .first::(&mut conn) + .optional() + .context("loading org") + } + + /// JIT list of every org the indexer has seen. Backs the GraphQL `orgs` + /// query (api-service joins this against the verified allowlist). + pub fn orgs_query(&self) -> Result> { + let mut conn = self.conn()?; + orgs::table + .order(orgs::org_id.asc()) + .load::(&mut conn) + .context("loading orgs") + } + /// JIT list query over the bucket view. All filters are ANDed; `None` /// means "don't constrain". `active_only` drops cleaned buckets. Backs /// the GraphQL `buckets(...)` query (api-service catalog + option-scheduler @@ -483,6 +528,9 @@ impl Repo { if let Some(ids) = &f.ids { q = q.filter(buckets::bucket_id.eq_any(ids.clone())); } + if let Some(org_ids) = &f.org_ids { + q = q.filter(buckets::org_id.eq_any(org_ids.clone())); + } if let Some(a) = &f.asset_type { q = q.filter(buckets::asset_type.eq(a.clone())); } @@ -591,6 +639,8 @@ pub struct BucketQuery { /// Drop cleaned buckets when true. pub active_only: bool, pub ids: Option>, + /// Restrict to buckets belonging to these orgs (verified-only surfaces). + pub org_ids: Option>, pub asset_type: Option, pub settlement_type: Option, pub expiry_ms: Option, diff --git a/rust-backend/services/indexer/src/db/schema.rs b/rust-backend/services/indexer/src/db/schema.rs index 9d1c4c70..e1a97eae 100644 --- a/rust-backend/services/indexer/src/db/schema.rs +++ b/rust-backend/services/indexer/src/db/schema.rs @@ -41,6 +41,16 @@ diesel::table! { } } +diesel::table! { + orgs (org_id) { + org_id -> Text, + name -> Text, + fee_bps -> Int8, + creator -> Text, + updated_at_seq -> Int8, + } +} + diesel::table! { buckets (bucket_id) { bucket_id -> Text, @@ -55,6 +65,7 @@ diesel::table! { cleaned -> Bool, invalidated -> Bool, updated_at_seq -> Int8, + org_id -> Text, } } @@ -107,6 +118,7 @@ diesel::allow_tables_to_appear_in_same_query!( indexed_events, accounts, account_balances, + orgs, buckets, positions, bucket_deepbook_pools, diff --git a/rust-backend/services/indexer/src/event_types.rs b/rust-backend/services/indexer/src/event_types.rs index 72b1d85c..272866a9 100644 --- a/rust-backend/services/indexer/src/event_types.rs +++ b/rust-backend/services/indexer/src/event_types.rs @@ -19,8 +19,9 @@ use serde::Deserialize; use protocol_types::asset::AssetType; use protocol_types::events::{ AccountCreated, AccountDeposit, AccountWithdraw, BucketCleaned, BucketCreated, - BucketInvalidated, BucketRevalidated, ChainEvent, Exercised, ExpiredOptionBurned, FeeUpdated, - Redeemed, SigningKeyRotated, TreasuryWithdrawn, WriteExecuted, + BucketInvalidated, BucketRevalidated, ChainEvent, Exercised, ExpiredOptionBurned, OrgCreated, + OrgFeeUpdated, OrgWithdraw, ProtocolFeeUpdated, ProtocolPauseSet, Redeemed, SigningKeyRotated, + TreasuryWithdrawn, WriteExecuted, }; use protocol_types::ids::{ObjectId, SuiAddress}; @@ -42,8 +43,12 @@ pub struct EventTypes { pub account_deposit: String, pub account_withdraw: String, pub signing_key_rotated: String, - pub fee_updated: String, + pub protocol_fee_updated: String, + pub protocol_pause_set: String, pub treasury_withdrawn: String, + pub org_created: String, + pub org_fee_updated: String, + pub org_withdraw: String, /// Prefix of DeepBook's generic `pool::PoolCreated` event /// (SO-152). Built from DeepBook's ORIGINAL package id — Sui resolves /// event/struct types to the first publish, not the upgraded package @@ -67,14 +72,18 @@ impl EventTypes { account_deposit: mk("AccountDeposit"), account_withdraw: mk("AccountWithdraw"), signing_key_rotated: mk("SigningKeyRotated"), - fee_updated: mk("FeeUpdated"), + protocol_fee_updated: mk("ProtocolFeeUpdated"), + protocol_pause_set: mk("ProtocolPauseSet"), treasury_withdrawn: mk("TreasuryWithdrawn"), + org_created: mk("OrgCreated"), + org_fee_updated: mk("OrgFeeUpdated"), + org_withdraw: mk("OrgWithdraw"), deepbook_pool_created_prefix: deepbook_original_package_id .map(|pkg| format!("{pkg}::pool::PoolCreated<")), } } - pub fn all_strings(&self) -> [&str; 14] { + pub fn all_strings(&self) -> [&str; 18] { [ &self.bucket_created, &self.write_executed, @@ -88,8 +97,12 @@ impl EventTypes { &self.account_deposit, &self.account_withdraw, &self.signing_key_rotated, - &self.fee_updated, + &self.protocol_fee_updated, + &self.protocol_pause_set, &self.treasury_withdrawn, + &self.org_created, + &self.org_fee_updated, + &self.org_withdraw, ] } } @@ -132,10 +145,18 @@ pub fn dispatch(types: &EventTypes, type_str: &str, contents: &[u8]) -> Result for EventGql { #[derive(SimpleObject)] pub struct BucketGql { pub bucket_id: String, + /// Org that created (and administers) this bucket. + pub org_id: String, pub asset_type: String, pub settlement_type: String, pub call_type: String, @@ -131,6 +135,7 @@ impl BucketGql { fn from_row(b: BucketRow, deepbook_pool_id: Option) -> Self { BucketGql { bucket_id: b.bucket_id, + org_id: b.org_id, asset_type: b.asset_type, settlement_type: b.settlement_type, call_type: b.call_type, @@ -146,6 +151,27 @@ impl BucketGql { } } +/// One org from the materialized view. The indexer records every org created +/// on-chain; verification status lives in token-info, not here. +#[derive(SimpleObject)] +pub struct OrgGql { + pub org_id: String, + pub name: String, + pub fee_bps: String, + pub creator: String, +} + +impl From for OrgGql { + fn from(r: OrgRow) -> Self { + OrgGql { + org_id: r.org_id, + name: r.name, + fee_bps: r.fee_bps.to_string(), + creator: r.creator, + } + } +} + /// One per-asset balance on an account. `balance_raw` is a decimal string. #[derive(SimpleObject)] pub struct AccountBalanceGql { @@ -304,13 +330,35 @@ impl QueryRoot { Ok(row.map(|(b, pool_id)| BucketGql::from_row(b, pool_id))) } + /// JIT: one org by id, or null if unknown. + async fn org(&self, ctx: &Context<'_>, id: String) -> async_graphql::Result> { + let repo = ctx.data_unchecked::().clone(); + let row = tokio::task::spawn_blocking(move || repo.org_by_id(&id)) + .await + .map_err(|e| async_graphql::Error::new(format!("join error: {e}")))? + .map_err(|e| async_graphql::Error::new(format!("db error: {e}")))?; + Ok(row.map(OrgGql::from)) + } + + /// JIT: every org the indexer has seen (verification is a token-info + /// concern; consumers join against the allowlist). + async fn orgs(&self, ctx: &Context<'_>) -> async_graphql::Result> { + let repo = ctx.data_unchecked::().clone(); + let rows = tokio::task::spawn_blocking(move || repo.orgs_query()) + .await + .map_err(|e| async_graphql::Error::new(format!("join error: {e}")))? + .map_err(|e| async_graphql::Error::new(format!("db error: {e}")))?; + Ok(rows.into_iter().map(OrgGql::from).collect()) + } + /// JIT: buckets matching the given filters (all ANDed). `activeOnly` - /// drops cleaned buckets. + /// drops cleaned buckets; `orgIds` restricts to those orgs' buckets. async fn buckets( &self, ctx: &Context<'_>, active_only: Option, ids: Option>, + org_ids: Option>, asset_type: Option, settlement_type: Option, expiry_ms: Option, @@ -325,6 +373,7 @@ impl QueryRoot { let q = BucketQuery { active_only: active_only.unwrap_or(false), ids, + org_ids, asset_type, settlement_type, expiry_ms, diff --git a/rust-backend/services/indexer/src/store/mod.rs b/rust-backend/services/indexer/src/store/mod.rs index 091155ba..f6dcb802 100644 --- a/rust-backend/services/indexer/src/store/mod.rs +++ b/rust-backend/services/indexer/src/store/mod.rs @@ -16,13 +16,13 @@ use tracing::{debug, trace}; use protocol_types::asset::AssetType; use protocol_types::events::{ AccountDeposit, AccountWithdraw, BucketCreated, ChainEvent, DeepBookPoolCreated, Exercised, - IndexedEvent, Redeemed, WriteExecuted, + IndexedEvent, OrgCreated, Redeemed, WriteExecuted, }; use protocol_types::ids::{ObjectId, SuiAddress}; use crate::db::models::{ u128_to_bigdecimal, u64_to_bigdecimal, AccountBalanceRow, AccountRow, BucketRow, - DeepBookPoolRow, EventParticipantRow, PositionRow, + DeepBookPoolRow, EventParticipantRow, OrgRow, PositionRow, }; use crate::db::{CheckpointBatch, EventBuild, HydratedViews}; @@ -40,10 +40,21 @@ pub struct AccountState { pub balances: BTreeMap, } -/// What we keep per Bucket: cursor state + identity. Strike, scale, and +/// What we keep per Org: identity + current fee. The indexer records every +/// org permissionlessly created on-chain; the verified-orgs allowlist that +/// gates user-facing surfaces lives in token-info. +#[derive(Clone, Debug)] +pub struct OrgState { + pub name: String, + pub fee_bps: u64, + pub creator: SuiAddress, +} + +/// What we keep per Bucket: cursor state + identity. Strike, scale, org and /// asset types are immutable across the bucket's life. #[derive(Clone, Debug)] pub struct BucketState { + pub org_id: ObjectId, pub asset_type: AssetType, pub settlement_type: AssetType, pub call_type: AssetType, @@ -99,6 +110,7 @@ struct Inner { /// Next sequence to assign. Starts at 1. next_sequence: u64, accounts: BTreeMap, + orgs: BTreeMap, buckets: BTreeMap, // Positions are keyed off the WriteExecuted's range_start since the // position object id isn't in the event payload (Position objects are @@ -115,6 +127,7 @@ impl Default for Inner { Self { next_sequence: 1, accounts: BTreeMap::new(), + orgs: BTreeMap::new(), buckets: BTreeMap::new(), positions: BTreeMap::new(), deepbook_pools: BTreeMap::new(), @@ -228,6 +241,7 @@ impl Store { "hydrating store from postgres" ); inner.accounts = views.accounts; + inner.orgs = views.orgs; inner.buckets = views.buckets; inner.positions = views.positions; inner.deepbook_pools = views.deepbook_pools; @@ -246,6 +260,19 @@ impl Store { self.inner.read().buckets.get(id).cloned() } + pub fn org(&self, id: &ObjectId) -> Option { + self.inner.read().orgs.get(id).cloned() + } + + pub fn all_orgs(&self) -> Vec<(ObjectId, OrgState)> { + self.inner + .read() + .orgs + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect() + } + /// Resolve a bucket by its call coin type (SO-152: DeepBook pools are /// tied to buckets via `PoolCreated`'s base asset). Linear scan — the /// bucket set is small (a handful per active series). @@ -322,8 +349,10 @@ fn collect_participants( }; match event { ChainEvent::WriteExecuted(w) => { - push(w.position_recipient.to_hex(), "position_recipient"); - push(w.call_token_recipient.to_hex(), "call_token_recipient"); + // Recipient roles are derived from `flow` now that the executor's + // side is PTB-routed: best-effort, but matches convention. + push(w.position_recipient().to_hex(), "position_recipient"); + push(w.call_token_recipient().to_hex(), "call_token_recipient"); push(w.signer_token_recipient.to_hex(), "signer_token_recipient"); push(w.executor.to_hex(), "executor"); if let Some(o) = account_owner_hex(inner, &w.signer_account_id) { @@ -333,8 +362,8 @@ fn collect_participants( ChainEvent::Exercised(e) => push(e.exerciser.to_hex(), "exerciser"), ChainEvent::Redeemed(r) => push(r.redeemer.to_hex(), "redeemer"), ChainEvent::ExpiredOptionBurned(b) => push(b.burner.to_hex(), "burner"), - ChainEvent::BucketInvalidated(i) => push(i.admin.to_hex(), "admin"), - ChainEvent::BucketRevalidated(r) => push(r.admin.to_hex(), "admin"), + ChainEvent::BucketInvalidated(i) => push(i.actor.to_hex(), "actor"), + ChainEvent::BucketRevalidated(r) => push(r.actor.to_hex(), "actor"), ChainEvent::AccountCreated(a) => push(a.owner.to_hex(), "account_owner"), ChainEvent::AccountDeposit(d) => { if let Some(o) = account_owner_hex(inner, &d.account_id) { @@ -351,12 +380,17 @@ fn collect_participants( push(o, "account_owner"); } } - ChainEvent::TreasuryWithdrawn(t) => push(t.recipient.to_hex(), "treasury_recipient"), - // DeepBookPoolCreated carries no addresses (the creator isn't in the - // event payload). + ChainEvent::OrgCreated(o) => push(o.creator.to_hex(), "org_creator"), + ChainEvent::ProtocolPauseSet(p) => push(p.admin.to_hex(), "admin"), + // TreasuryWithdrawn / OrgWithdraw no longer carry a recipient — the + // withdrawn coin is returned to the PTB. DeepBookPoolCreated carries + // no addresses (the creator isn't in the event payload). ChainEvent::BucketCreated(_) | ChainEvent::BucketCleaned(_) - | ChainEvent::FeeUpdated(_) + | ChainEvent::ProtocolFeeUpdated(_) + | ChainEvent::TreasuryWithdrawn(_) + | ChainEvent::OrgFeeUpdated(_) + | ChainEvent::OrgWithdraw(_) | ChainEvent::DeepBookPoolCreated(_) => {} } } @@ -373,6 +407,16 @@ fn stage_event_into_batch( batch: &mut CheckpointBatch, ) { match event { + ChainEvent::OrgCreated(o) => { + if let Some(state) = inner.orgs.get(&o.org_id) { + batch.orgs.push(org_row(o.org_id, state, sequence)); + } + } + ChainEvent::OrgFeeUpdated(o) => { + if let Some(state) = inner.orgs.get(&o.org_id) { + batch.orgs.push(org_row(o.org_id, state, sequence)); + } + } ChainEvent::BucketCreated(b) => { if let Some(state) = inner.buckets.get(&b.bucket_id) { batch.buckets.push(bucket_row(b.bucket_id, state, sequence)); @@ -472,17 +516,30 @@ fn stage_event_into_batch( } } ChainEvent::ExpiredOptionBurned(_) - | ChainEvent::FeeUpdated(_) - | ChainEvent::TreasuryWithdrawn(_) => { + | ChainEvent::ProtocolFeeUpdated(_) + | ChainEvent::ProtocolPauseSet(_) + | ChainEvent::TreasuryWithdrawn(_) + | ChainEvent::OrgWithdraw(_) => { // No materialised-view change. The event itself still lands in // `indexed_events` via the caller. } } } +fn org_row(id: ObjectId, state: &OrgState, sequence: i64) -> OrgRow { + OrgRow { + org_id: id.to_hex(), + name: state.name.clone(), + fee_bps: state.fee_bps as i64, + creator: state.creator.to_hex(), + updated_at_seq: sequence, + } +} + fn bucket_row(id: ObjectId, state: &BucketState, sequence: i64) -> BucketRow { BucketRow { bucket_id: id.to_hex(), + org_id: state.org_id.to_hex(), asset_type: state.asset_type.as_str().to_string(), settlement_type: state.settlement_type.as_str().to_string(), call_type: state.call_type.as_str().to_string(), @@ -508,7 +565,7 @@ fn write_position_row( range_start: u128_to_bigdecimal(w.range_start), range_end: u128_to_bigdecimal(w.range_end), object_id: w.position_id.to_hex(), - recipient: w.position_recipient.to_hex(), + recipient: w.position_recipient().to_hex(), updated_at_seq: sequence, // SO-97 provenance: gross premium the writer received, the // counterparty MM account, and the minting tx for explorer links. @@ -567,6 +624,12 @@ fn balance_row( fn apply_event(inner: &mut Inner, event: &ChainEvent) { match event { + ChainEvent::OrgCreated(o) => apply_org_created(inner, o), + ChainEvent::OrgFeeUpdated(o) => { + if let Some(org) = inner.orgs.get_mut(&o.org_id) { + org.fee_bps = o.new_bps; + } + } ChainEvent::BucketCreated(b) => apply_bucket_created(inner, b), ChainEvent::WriteExecuted(w) => apply_write_executed(inner, w), ChainEvent::Exercised(e) => apply_exercised(inner, e), @@ -633,14 +696,29 @@ fn apply_event(inner: &mut Inner, event: &ChainEvent) { ); } } - ChainEvent::FeeUpdated(_) | ChainEvent::TreasuryWithdrawn(_) => {} + ChainEvent::ProtocolFeeUpdated(_) + | ChainEvent::ProtocolPauseSet(_) + | ChainEvent::TreasuryWithdrawn(_) + | ChainEvent::OrgWithdraw(_) => {} } } +fn apply_org_created(inner: &mut Inner, o: &OrgCreated) { + inner.orgs.insert( + o.org_id, + OrgState { + name: o.name.clone(), + fee_bps: o.fee_bps, + creator: o.creator, + }, + ); +} + fn apply_bucket_created(inner: &mut Inner, b: &BucketCreated) { inner.buckets.insert( b.bucket_id, BucketState { + org_id: b.org_id, asset_type: b.asset_type.clone(), settlement_type: b.settlement_type.clone(), call_type: b.call_type.clone(), @@ -674,13 +752,14 @@ fn apply_write_executed(inner: &mut Inner, w: &WriteExecuted) { // about deposits/withdraws to be authoritative for balances — this // event only mutates the cursor. // - // Positions: the Position object goes to `position_recipient`. + // Positions: recipient derived from `flow` (executor in writer flow, + // quote recipient in trader flow) — see WriteExecuted::position_recipient. inner.positions.insert( (w.bucket_id, w.range_start), PositionState { bucket_id: w.bucket_id, object_id: w.position_id, - recipient: w.position_recipient, + recipient: w.position_recipient(), range_start: w.range_start, range_end: w.range_end, }, @@ -747,6 +826,7 @@ mod tests { fn bucket_evt(id: u8) -> ChainEvent { ChainEvent::BucketCreated(BucketCreated { bucket_id: ObjectId::new([id; 32]), + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -774,6 +854,7 @@ mod tests { store.ingest( ChainEvent::BucketCreated(BucketCreated { bucket_id: id, + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -786,15 +867,17 @@ mod tests { store.ingest( ChainEvent::WriteExecuted(WriteExecuted { bucket_id: id, + org_id: ObjectId::new([0xee; 32]), signer_account_id: ObjectId::ZERO, signer_token_recipient: SuiAddress::ZERO, - executor: SuiAddress::ZERO, + // Writer flow → position lands with the executor. + executor: SuiAddress::new([0x77; 32]), position_id: ObjectId::new([0x88; 32]), - position_recipient: SuiAddress::new([0x77; 32]), - call_token_recipient: SuiAddress::ZERO, + flow: protocol_types::events::FLOW_WRITER, write_amount: 10, gross_premium: 5, - fee: 0, + org_fee: 0, + protocol_fee: 0, net_premium: 5, range_start: 0, range_end: 10, @@ -869,7 +952,8 @@ mod tests { ChainEvent::BucketInvalidated(BucketInvalidated { bucket_id: id, at_ms: 100, - admin: SuiAddress::new([0xa1; 32]), + actor: SuiAddress::new([0xa1; 32]), + by_admin: false, reason: b"bad config".to_vec(), }), 2, @@ -880,7 +964,8 @@ mod tests { ChainEvent::BucketRevalidated(BucketRevalidated { bucket_id: id, at_ms: 200, - admin: SuiAddress::new([0xa1; 32]), + actor: SuiAddress::new([0xa1; 32]), + by_admin: false, reason: b"resolved".to_vec(), }), 3, @@ -940,15 +1025,16 @@ mod tests { let buyer = SuiAddress::new([0x22; 32]); let we = ChainEvent::WriteExecuted(WriteExecuted { bucket_id: ObjectId::new([0x11; 32]), + org_id: ObjectId::new([0xee; 32]), signer_account_id: ObjectId::ZERO, signer_token_recipient: buyer, executor: writer, position_id: ObjectId::new([0x88; 32]), - position_recipient: writer, - call_token_recipient: buyer, + flow: protocol_types::events::FLOW_WRITER, write_amount: 10, gross_premium: 5, - fee: 0, + org_fee: 0, + protocol_fee: 0, net_premium: 5, range_start: 0, range_end: 10, diff --git a/rust-backend/services/indexer/src/worker.rs b/rust-backend/services/indexer/src/worker.rs index 69aee435..14e9cedd 100644 --- a/rust-backend/services/indexer/src/worker.rs +++ b/rust-backend/services/indexer/src/worker.rs @@ -228,6 +228,7 @@ mod tests { let evt = BucketCreated { bucket_id: ObjectId::new([0x99; 32]), + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -267,6 +268,7 @@ mod tests { store.ingest( protocol_types::events::ChainEvent::BucketCreated(BucketCreated { bucket_id: ObjectId::new([0x11; 32]), + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("TBTC"), settlement_type: AssetType::new("0x9::tusdc::TUSDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), diff --git a/rust-backend/services/option-scheduler/src/config.rs b/rust-backend/services/option-scheduler/src/config.rs index db826962..40eeaaa8 100644 --- a/rust-backend/services/option-scheduler/src/config.rs +++ b/rust-backend/services/option-scheduler/src/config.rs @@ -54,6 +54,19 @@ pub struct SchedulerConfig { /// e.g. `http://127.0.0.1:9002/graphql`. pub indexer_graphql_url: String, + /// The org this scheduler rolls buckets under (shared `Org` object id). + /// Optional: when absent, the platform org from token-info's + /// `package-info` is used. Self-hosted org schedulers set both this and + /// `org_cap_id` to their own org. + #[serde(default)] + pub org_id: Option, + + /// The `OrgCap` object id authorizing bucket creation for `org_id`. + /// Must be owned by the configured signer. Optional: defaults to the + /// platform org cap from token-info. + #[serde(default)] + pub org_cap_id: Option, + #[serde(default = "default_tick_secs")] pub tick_secs: u64, diff --git a/rust-backend/services/option-scheduler/src/lib.rs b/rust-backend/services/option-scheduler/src/lib.rs index 988e4474..65416517 100644 --- a/rust-backend/services/option-scheduler/src/lib.rs +++ b/rust-backend/services/option-scheduler/src/lib.rs @@ -37,7 +37,7 @@ pub struct Cli { pub token_info_url: String, /// Per-binary secrets TOML. Holds the Sui signing key. No env-var - /// fallback. Must hold the deployer key — AdminCap is non-transferable. + /// fallback. Must hold the key that owns the configured OrgCap. #[arg( short = 's', long, @@ -62,8 +62,8 @@ cli_spec::define_program! { id = "option-scheduler", cargo_pkg = "option-scheduler", working_dir = ".", - description = "Owns the protocol's bucket-creation lifecycle. Tracks live bucket families per \ - (underlying, settlement) pair via the indexer, and submits new_call_option \ - when the latest family is inside the roll-threshold window. Holds AdminCap.", + description = "Owns an org's bucket-creation lifecycle. Tracks live bucket families per \ + (underlying, settlement) pair via the indexer, and rolls new bucket sets \ + when the latest family is inside the roll-threshold window. Holds the OrgCap.", cli = crate::Cli, } diff --git a/rust-backend/services/option-scheduler/src/main.rs b/rust-backend/services/option-scheduler/src/main.rs index 550aed5f..87b91984 100644 --- a/rust-backend/services/option-scheduler/src/main.rs +++ b/rust-backend/services/option-scheduler/src/main.rs @@ -72,22 +72,31 @@ async fn main() -> Result<()> { })?; let package = snapshot.package()?; - let admin_cap = snapshot.admin_cap()?; let wrap = SuiClientWrapper::connect(&secrets, cli.network).await?; - // AdminCap belongs to the deployer only — exchange enforces the same check - // (tools/exchange/src/main.rs). A scheduler signed by anyone else is - // useless and we'd rather fail loudly at boot than hit a chain revert on - // every tick. - let deployer = snapshot.deployer_address()?; - if wrap.signer.address != deployer { - return Err(anyhow!( - "configured signer {} ≠ deployer {} from token-info — \ - only the deployer holds AdminCap", - wrap.signer.address, - deployer - )); - } + // Resolve the org this scheduler rolls under. Config wins (self-hosted + // org schedulers); otherwise default to the platform org recorded at + // deploy time and served by token-info. + let org_id: sui_types::base_types::ObjectID = match &cfg.org_id { + Some(s) => s.parse().context("parsing config org_id")?, + None => snapshot.platform_org().context( + "no org_id in config and no platformOrgId from token-info — \ + configure the org this scheduler rolls under", + )?, + }; + let org_cap: sui_types::base_types::ObjectID = match &cfg.org_cap_id { + Some(s) => s.parse().context("parsing config org_cap_id")?, + None => snapshot.platform_org_cap().context( + "no org_cap_id in config and no platformOrgCapId from token-info", + )?, + }; + + // Validate the OrgCap at boot: it must exist, be the protocol's + // `org::OrgCap` type, be owned by our signer, and point at the + // configured org. A scheduler holding the wrong cap is useless and we'd + // rather fail loudly here than hit a chain revert on every tick. + validate_org_cap(&wrap, package, org_cap, org_id).await?; + info!(%org_id, %org_cap, "org cap validated; rolling under this org"); if cfg.pairs.is_empty() { return Err(anyhow!( @@ -198,7 +207,9 @@ async fn main() -> Result<()> { let interval = Duration::from_secs(cfg.reconciler_interval_secs.max(1)); tokio::spawn(async move { loop { - if let Err(e) = run_reconciler(&pool, &indexer, &pair_keys, safety_margin).await { + if let Err(e) = + run_reconciler(&pool, &indexer, &pair_keys, org_id, safety_margin).await + { warn!(error = %e, "reconciler tick errored"); } sleep(interval).await; @@ -228,7 +239,7 @@ async fn main() -> Result<()> { &http_client, &wrap, package, - admin_cap, + org_cap, &db_pool, ) .await @@ -259,7 +270,7 @@ async fn tick_once( http_client: &reqwest::Client, wrap: &SuiClientWrapper, package: sui_types::base_types::ObjectID, - admin_cap: sui_types::base_types::ObjectID, + org_cap: sui_types::base_types::ObjectID, db_pool: &db::DbPool, ) -> Result<()> { let now = now_ms(); @@ -417,7 +428,7 @@ async fn tick_once( } // ── Phase 2 step 3: submit + classify ────────────────────── - match roller::submit(wrap, package, admin_cap, &plan, cli.gas_budget).await { + match roller::submit(wrap, package, org_cap, &plan, cli.gas_budget).await { Ok(out) => { info!( digest = %out.digest, @@ -486,9 +497,10 @@ async fn run_reconciler( pool: &db::DbPool, indexer: &IndexerClient, pair_keys: &[PairKey], + org_id: sui_types::base_types::ObjectID, safety_margin: u64, ) -> Result<()> { - confirm_landed_rolls(pool, indexer, pair_keys).await?; + confirm_landed_rolls(pool, indexer, pair_keys, org_id).await?; let rows = db::needs_reconciliation_rows(pool)?; if rows.is_empty() { @@ -529,14 +541,21 @@ async fn confirm_landed_rolls( pool: &db::DbPool, indexer: &IndexerClient, pair_keys: &[PairKey], + org_id: sui_types::base_types::ObjectID, ) -> Result<()> { let rows = db::all_active_rows(pool)?; + // Scope confirmation to OUR org's buckets. Orgs are permissionless: + // another org listing the same pair/expiry would otherwise falsely + // "confirm" our roll and the family would never get created. + let org_filter = [org_id.to_hex_uncompressed()]; for row in rows { if !matches!(row.state.as_str(), "submitted" | "needs_reconciliation") { continue; } let expiry = row.expiry_ms as u64; - let buckets = indexer.buckets(false, None, None, Some(expiry)).await?; + let buckets = indexer + .buckets(false, Some(&org_filter), None, None, Some(expiry)) + .await?; for b in buckets { // A bucket at this expiry could belong to a different configured // pair; confirm only against the pair that actually matches this @@ -563,6 +582,74 @@ async fn confirm_landed_rolls( Ok(()) } +/// Boot-time validation of the configured OrgCap: exists on chain, is this +/// package's `org::OrgCap`, is owned by our signer, and authorizes the +/// configured org. Mirrors the spirit of the old "signer == deployer" +/// assertion — fail loudly at boot instead of reverting on every tick. +async fn validate_org_cap( + wrap: &SuiClientWrapper, + package: sui_types::base_types::ObjectID, + org_cap: sui_types::base_types::ObjectID, + org_id: sui_types::base_types::ObjectID, +) -> Result<()> { + use sui_json_rpc_types::{SuiData, SuiObjectDataOptions}; + + let resp = wrap + .client + .read_api() + .get_object_with_options( + org_cap, + SuiObjectDataOptions::new() + .with_owner() + .with_type() + .with_content(), + ) + .await + .context("fetching OrgCap object")?; + let data = resp + .data + .ok_or_else(|| anyhow!("OrgCap {org_cap} not found on chain"))?; + + let expected_type = format!("{}::org::OrgCap", package.to_hex_uncompressed()); + let actual_type = data + .type_ + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_default(); + if protocol_types::asset::canonicalize_move_type(&actual_type) + != protocol_types::asset::canonicalize_move_type(&expected_type) + { + return Err(anyhow!( + "object {org_cap} is a {actual_type}, expected {expected_type}" + )); + } + + match data.owner { + Some(sui_types::object::Owner::AddressOwner(addr)) if addr == wrap.signer.address => {} + other => { + return Err(anyhow!( + "OrgCap {org_cap} is not owned by signer {} (owner: {other:?})", + wrap.signer.address + )); + } + } + + let cap_org = data + .content + .as_ref() + .and_then(|c| c.try_as_move()) + .and_then(|m| m.fields.field_value("org_id")) + .map(|v| v.to_string()) + .ok_or_else(|| anyhow!("OrgCap {org_cap} content has no org_id field"))?; + let want = org_id.to_hex_uncompressed(); + if cap_org.trim_start_matches("0x") != want.trim_start_matches("0x") { + return Err(anyhow!( + "OrgCap {org_cap} authorizes org {cap_org}, but the scheduler is configured for {want}" + )); + } + Ok(()) +} + fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/rust-backend/services/option-scheduler/src/roller.rs b/rust-backend/services/option-scheduler/src/roller.rs index 37f39306..aa3978a7 100644 --- a/rust-backend/services/option-scheduler/src/roller.rs +++ b/rust-backend/services/option-scheduler/src/roller.rs @@ -98,13 +98,13 @@ pub fn classify_error(err: &anyhow::Error) -> ErrorClass { pub async fn submit( wrap: &SuiClientWrapper, package: ObjectID, - admin_cap: ObjectID, + org_cap: ObjectID, plan: &RollPlan, gas_budget: u64, ) -> Result { debug!( %package, - %admin_cap, + %org_cap, underlying = %plan.underlying_type, settlement = %plan.settlement_type, expiry_ms = plan.expiry_ms, @@ -155,7 +155,7 @@ pub async fn submit( &wrap.client, &wrap.signer, package, - admin_cap, + org_cap, &specs, gas_budget, ) diff --git a/rust-backend/services/quoting-service/src/main.rs b/rust-backend/services/quoting-service/src/main.rs index 85317926..6901e956 100644 --- a/rust-backend/services/quoting-service/src/main.rs +++ b/rust-backend/services/quoting-service/src/main.rs @@ -19,9 +19,19 @@ async fn main() -> Result<()> { .await .with_context(|| format!("loading config from {cfg_path}"))?, ); + // Verified-orgs allowlist: fail-closed at boot, then polled in the + // background. RFQs for unverified orgs' buckets are refused. + let verified_orgs = token_info_client::VerifiedOrgsWatcher::start( + token_info_client::TokenInfoClient::new(&cfg.token_info_url), + Duration::from_secs(30), + ) + .await + .context("loading verified-orgs allowlist from token-info")?; + let app = Arc::new(AppState::with_global_rfq_cap( cfg.max_inflight_rfqs_global, cfg.indexer_graphql_url.clone(), + verified_orgs, )); state::spawn_reservation_evictor(Arc::clone(&app), Duration::from_millis(250)); diff --git a/rust-backend/services/quoting-service/src/rfq/bulk_view.rs b/rust-backend/services/quoting-service/src/rfq/bulk_view.rs index f35c0bd3..296e744b 100644 --- a/rust-backend/services/quoting-service/src/rfq/bulk_view.rs +++ b/rust-backend/services/quoting-service/src/rfq/bulk_view.rs @@ -258,12 +258,14 @@ mod tests { Arc::new(AppState::with_global_rfq_cap( 256, "http://127.0.0.1:1/graphql".into(), + token_info_client::VerifiedOrgsWatcher::fixed(vec![]), )) } fn mk_bucket(tag: u8) -> Bucket { Bucket { bucket_id: ObjectId::new([tag; 32]), + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -274,6 +276,7 @@ mod tests { exercise_cursor: 0, cleaned: false, invalidated: false, + deepbook_pool_id: None, } } diff --git a/rust-backend/services/quoting-service/src/rfq/mod.rs b/rust-backend/services/quoting-service/src/rfq/mod.rs index 4ed5f65d..82dae9c7 100644 --- a/rust-backend/services/quoting-service/src/rfq/mod.rs +++ b/rust-backend/services/quoting-service/src/rfq/mod.rs @@ -51,6 +51,10 @@ pub enum QuoteRejection { /// on chain. Defense-in-depth against a race between the MM signing /// and the BucketInvalidated event landing. BucketInvalidated, + /// The bucket's org is not on the verified allowlist; the service does + /// not broker quotes for it. Defense-in-depth — retail.rs already + /// refuses the RFQ up front. + OrgNotVerified, } impl From for QuoteRejection { @@ -133,6 +137,9 @@ pub fn validate_and_reserve( if bucket.invalidated { return Err(QuoteRejection::BucketInvalidated); } + if !state.verified_orgs.is_verified(&bucket.org_id.to_hex()) { + return Err(QuoteRejection::OrgNotVerified); + } let (asset, amount) = reservation_for(side, bucket, quote); if state.available(account, &asset) < amount { return Err(QuoteRejection::InsufficientAvailableBalance); @@ -214,11 +221,16 @@ pub async fn orchestrate( // The bucket is fetched JIT by the caller (retail.rs) so the broadcast can // include its strike + expiry — MMs price against these instead of - // guessing. Defense-in-depth against direct callers: refuse invalidated. + // guessing. Defense-in-depth against direct callers: refuse invalidated + // buckets and unverified orgs. if bucket.invalidated { debug!(%bucket_id, "rfq for invalidated bucket — returning empty"); return Vec::new(); } + if !state.verified_orgs.is_verified(&bucket.org_id.to_hex()) { + debug!(%bucket_id, "rfq for unverified org's bucket — returning empty"); + return Vec::new(); + } let mm_role = side.counterparty_mm(); let mms = state.mms.all_for_role(mm_role); @@ -335,7 +347,18 @@ mod tests { /// tests pass the bucket + account in directly. The URL is unreachable on /// purpose: any test that actually hit it would be a bug. fn test_state() -> AppState { - AppState::with_global_rfq_cap(256, "http://127.0.0.1:1/graphql".into()) + // The fixture bucket's org ([0xee; 32]) is pre-verified so the + // existing validation tests exercise their own concern, not the + // org gate. See `rejects_quote_for_unverified_org`. + AppState::with_global_rfq_cap( + 256, + "http://127.0.0.1:1/graphql".into(), + token_info_client::VerifiedOrgsWatcher::fixed(vec![token_info_client::VerifiedOrg { + org_id: ObjectId::new([0xee; 32]).to_hex(), + name: "test-org".into(), + enabled: true, + }]), + ) } fn mk_account(mm: ObjectId, signing_pubkey: Vec, balance: u64) -> Account { @@ -351,6 +374,7 @@ mod tests { fn mk_bucket() -> Bucket { Bucket { bucket_id: ObjectId::new([0x99; 32]), + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -361,6 +385,7 @@ mod tests { exercise_cursor: 0, cleaned: false, invalidated: false, + deepbook_pool_id: None, } } @@ -504,6 +529,25 @@ mod tests { assert_eq!(state.reservations.len(), 0); } + #[test] + fn rejects_quote_for_unverified_org() { + // Verified-only surface: a bucket whose org isn't on the allowlist + // must never get a reservation, even if retail.rs let it through. + let sk = SigningKey::generate(&mut OsRng); + let mm = ObjectId::new([0x01; 32]); + let state = test_state(); + let account = mk_account(mm, sk.verifying_key().to_bytes().to_vec(), 10_000); + let mut bucket = mk_bucket(); + bucket.org_id = ObjectId::new([0x66; 32]); // not on the fixed allowlist + let p = signed_quote(&sk, b"P".to_vec(), mm, bucket.bucket_id, 100, 500, 1); + assert_eq!( + validate_and_reserve(&state, Side::Writer, &bucket, &account, 100, &p, mm, b"P", 0) + .unwrap_err(), + QuoteRejection::OrgNotVerified, + ); + assert_eq!(state.reservations.len(), 0); + } + #[test] fn writer_side_sorts_highest_premium_first() { let mk = |premium: u64, rep: f64| RfqQuoteEntry { diff --git a/rust-backend/services/quoting-service/src/state/mod.rs b/rust-backend/services/quoting-service/src/state/mod.rs index d2563685..4a2bb76b 100644 --- a/rust-backend/services/quoting-service/src/state/mod.rs +++ b/rust-backend/services/quoting-service/src/state/mod.rs @@ -26,6 +26,7 @@ use protocol_types::ids::ObjectId; use protocol_types::sides::Side; pub use indexer_graphql::{Account, Bucket, IndexerClient}; +pub use token_info_client::VerifiedOrgsWatcher; pub use mm_registry::{MmConnection, MmRegistry}; pub use reputation::{ReputationStats, ReputationStore}; pub use reservations::{InsertOutcome, Reservation, ReservationTable}; @@ -95,10 +96,17 @@ pub struct AppState { /// [`RfqObservation`] per incoming RFQ; subscribers are best-effort and /// may miss messages under backlog (the broadcast channel drops oldest). pub rfq_observers: broadcast::Sender, + /// Verified-orgs allowlist (polled from token-info). RFQs against + /// buckets of unverified orgs are refused — the verified-only surface. + pub verified_orgs: VerifiedOrgsWatcher, } impl AppState { - pub fn with_global_rfq_cap(cap: usize, indexer_graphql_url: String) -> Self { + pub fn with_global_rfq_cap( + cap: usize, + indexer_graphql_url: String, + verified_orgs: VerifiedOrgsWatcher, + ) -> Self { let (rfq_observers, _) = broadcast::channel(256); Self { reservations: ReservationTable::default(), @@ -111,6 +119,7 @@ impl AppState { bulk_view_refreshing: DashMap::new(), rfq_global_inflight: Arc::new(Semaphore::new(cap)), rfq_observers, + verified_orgs, } } @@ -205,7 +214,11 @@ mod tests { #[test] fn available_subtracts_active_reservations() { - let s = AppState::with_global_rfq_cap(8, "http://127.0.0.1:1/graphql".into()); + let s = AppState::with_global_rfq_cap( + 8, + "http://127.0.0.1:1/graphql".into(), + VerifiedOrgsWatcher::fixed(vec![]), + ); let account = ObjectId::new([0x01; 32]); let acct = account_with(account, 1_000); assert_eq!(s.available(&acct, &AssetType::new("USDC")), 1_000); diff --git a/rust-backend/services/quoting-service/src/ws/retail.rs b/rust-backend/services/quoting-service/src/ws/retail.rs index f26787c3..b9f884b0 100644 --- a/rust-backend/services/quoting-service/src/ws/retail.rs +++ b/rust-backend/services/quoting-service/src/ws/retail.rs @@ -166,6 +166,20 @@ pub async fn handle( continue; } }; + if !state.verified_orgs.is_verified(&bucket.org_id.to_hex()) { + debug!(request_id = %request_id, %payload.bucket_id, "rfq for unverified org's bucket"); + let _ = out_tx + .send(ServiceToRetail::Error { + request_id: Some(request_id), + payload: ErrorPayload { + code: "bucket_not_verified".into(), + message: "bucket belongs to an org that is not on the verified list" + .into(), + }, + }) + .await; + continue; + } if bucket.invalidated { debug!(request_id = %request_id, %payload.bucket_id, "rfq for invalidated bucket"); let _ = out_tx @@ -276,13 +290,18 @@ pub async fn handle( write_amount = payload.write_amount, "retail bulk-view rfq request" ); - // Resolve each bucket JIT, dropping unknown/invalidated ones — - // a quote against those would never execute, so they shouldn't - // show an indicative premium either. + // Resolve each bucket JIT, dropping unknown/invalidated/ + // unverified-org ones — a quote against those would never be + // served, so they shouldn't show an indicative premium either. let mut buckets = Vec::with_capacity(payload.bucket_ids.len()); for id in &payload.bucket_ids { match state.indexer.bucket(*id).await { - Ok(Some(b)) if !b.invalidated => buckets.push(b), + Ok(Some(b)) + if !b.invalidated + && state.verified_orgs.is_verified(&b.org_id.to_hex()) => + { + buckets.push(b) + } Ok(_) => {} Err(e) => { debug!(request_id = %request_id, bucket = %id, error = %e, "bulk-view bucket lookup failed; skipping"); diff --git a/rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/down.sql b/rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/down.sql new file mode 100644 index 00000000..e416ed10 --- /dev/null +++ b/rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/down.sql @@ -0,0 +1 @@ +DROP TABLE verified_orgs; diff --git a/rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/up.sql b/rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/up.sql new file mode 100644 index 00000000..57f95b9d --- /dev/null +++ b/rust-backend/services/token-info/src/db/migrations/000002_verified_orgs/up.sql @@ -0,0 +1,18 @@ +-- Verified-orgs allowlist (org-aware protocol, "verified-only surfaces"). +-- +-- Orgs are created permissionlessly on-chain and the indexer records them +-- all; THIS table is the platform-controlled allowlist that decides which +-- orgs' buckets api-service /buckets, quoting-service RFQs, and the frontend +-- serve. Mutations ride the same admin-JWT (public) / network-isolated +-- (internal) routes as the token catalog. "Verified" = row exists AND +-- enabled; de-verification is a PUT enabled=false so history is preserved. + +CREATE TABLE verified_orgs ( + org_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX verified_orgs_enabled_idx ON verified_orgs (enabled); diff --git a/rust-backend/services/token-info/src/db/models.rs b/rust-backend/services/token-info/src/db/models.rs index 8d346a3b..fbad9423 100644 --- a/rust-backend/services/token-info/src/db/models.rs +++ b/rust-backend/services/token-info/src/db/models.rs @@ -3,9 +3,9 @@ use chrono::{DateTime, Utc}; use diesel::prelude::*; -use token_info_client::SupportedToken; +use token_info_client::{SupportedToken, VerifiedOrg}; -use super::schema::supported_tokens; +use super::schema::{supported_tokens, verified_orgs}; /// One catalog row as read from Postgres. #[derive(Queryable, Identifiable, Debug, Clone)] @@ -52,3 +52,35 @@ pub struct UpsertToken { pub pyth_feed_id: Option, pub enabled: bool, } + +/// One verified-org allowlist row. +#[derive(Queryable, Identifiable, Debug, Clone)] +#[diesel(table_name = verified_orgs)] +#[diesel(primary_key(org_id))] +pub struct OrgRow { + pub org_id: String, + pub name: String, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl OrgRow { + /// Convert to the public wire DTO served by `GET /orgs`. + pub fn into_dto(self) -> VerifiedOrg { + VerifiedOrg { + org_id: self.org_id, + name: self.name, + enabled: self.enabled, + } + } +} + +/// Insert/upsert payload for the org-allowlist mutate API. +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = verified_orgs)] +pub struct UpsertOrg { + pub org_id: String, + pub name: String, + pub enabled: bool, +} diff --git a/rust-backend/services/token-info/src/db/repo.rs b/rust-backend/services/token-info/src/db/repo.rs index af6727c1..299db0ac 100644 --- a/rust-backend/services/token-info/src/db/repo.rs +++ b/rust-backend/services/token-info/src/db/repo.rs @@ -8,8 +8,9 @@ use anyhow::{Context, Result}; use diesel::prelude::*; use diesel::upsert::excluded; -use super::models::{TokenRow, UpsertToken}; +use super::models::{OrgRow, TokenRow, UpsertOrg, UpsertToken}; use super::schema::supported_tokens as st; +use super::schema::verified_orgs as vo; use super::DbPool; #[derive(Clone)] @@ -73,4 +74,54 @@ impl Repo { .execute(&mut conn) .context("deleting token") } + + // ----------------------------------------------------- verified orgs + + /// All allowlist rows, name-sorted. `enabled_only` filters to enabled + /// ("verified" = present AND enabled). + pub fn list_orgs(&self, enabled_only: bool) -> Result> { + let mut conn = self.pool.get().context("checkout conn")?; + let mut q = vo::table.into_boxed(); + if enabled_only { + q = q.filter(vo::enabled.eq(true)); + } + q.order(vo::name.asc()) + .load::(&mut conn) + .context("listing verified orgs") + } + + /// One allowlist row by org id, or `None`. + pub fn get_org(&self, org_id: &str) -> Result> { + let mut conn = self.pool.get().context("checkout conn")?; + vo::table + .find(org_id) + .first::(&mut conn) + .optional() + .context("getting verified org") + } + + /// Insert or update an allowlist row. On conflict on `org_id`, every + /// non-key field is overwritten and `updated_at` is bumped. + pub fn upsert_org(&self, o: UpsertOrg) -> Result { + let mut conn = self.pool.get().context("checkout conn")?; + diesel::insert_into(vo::table) + .values(&o) + .on_conflict(vo::org_id) + .do_update() + .set(( + vo::name.eq(excluded(vo::name)), + vo::enabled.eq(excluded(vo::enabled)), + vo::updated_at.eq(diesel::dsl::now), + )) + .get_result::(&mut conn) + .context("upserting verified org") + } + + /// Delete an allowlist row. Returns the number of rows removed. + pub fn delete_org(&self, org_id: &str) -> Result { + let mut conn = self.pool.get().context("checkout conn")?; + diesel::delete(vo::table.find(org_id)) + .execute(&mut conn) + .context("deleting verified org") + } } diff --git a/rust-backend/services/token-info/src/db/schema.rs b/rust-backend/services/token-info/src/db/schema.rs index f8821115..738acf95 100644 --- a/rust-backend/services/token-info/src/db/schema.rs +++ b/rust-backend/services/token-info/src/db/schema.rs @@ -1,5 +1,5 @@ //! Hand-written equivalent of what `diesel print-schema` would generate. -//! Kept in sync with `migrations/000001_init/up.sql`. +//! Kept in sync with the migrations. diesel::table! { supported_tokens (coin_type) { @@ -14,3 +14,13 @@ diesel::table! { updated_at -> Timestamptz, } } + +diesel::table! { + verified_orgs (org_id) { + org_id -> Text, + name -> Text, + enabled -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} diff --git a/rust-backend/services/token-info/src/handlers/mod.rs b/rust-backend/services/token-info/src/handlers/mod.rs index 5c766355..4308d73d 100644 --- a/rust-backend/services/token-info/src/handlers/mod.rs +++ b/rust-backend/services/token-info/src/handlers/mod.rs @@ -1 +1,2 @@ +pub mod orgs; pub mod tokens; diff --git a/rust-backend/services/token-info/src/handlers/orgs.rs b/rust-backend/services/token-info/src/handlers/orgs.rs new file mode 100644 index 00000000..410b99e9 --- /dev/null +++ b/rust-backend/services/token-info/src/handlers/orgs.rs @@ -0,0 +1,121 @@ +//! Verified-orgs allowlist handlers. +//! +//! Public (read): [`list_orgs`], [`get_org`]. Internal/JWT-gated (mutate): +//! [`create_org`], [`update_org`], [`delete_org`]. Mirrors the token-catalog +//! handlers — this is the platform-controlled allowlist that gates which +//! orgs' buckets the user-facing surfaces (api-service, quoting, frontend) +//! serve. The on-chain org registry itself is permissionless; this table +//! only curates visibility. + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; +use tracing::info; + +use token_info_client::VerifiedOrg; + +use crate::db::models::UpsertOrg; +use crate::state::AppState; + +type ApiError = (StatusCode, String); + +fn internal_err(e: anyhow::Error) -> ApiError { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +// ---------------------------------------------------------------- public + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + /// `?enabled=true` returns only enabled (i.e. actually verified) orgs. + #[serde(default)] + pub enabled: Option, +} + +/// `GET /orgs` — the verified-orgs allowlist. +pub async fn list_orgs( + State(state): State>, + Query(q): Query, +) -> Result>, ApiError> { + let rows = state + .repo + .list_orgs(q.enabled.unwrap_or(false)) + .map_err(internal_err)?; + Ok(Json(rows.into_iter().map(|r| r.into_dto()).collect())) +} + +/// `GET /orgs/:org_id` — one allowlist row, or 404. +pub async fn get_org( + State(state): State>, + Path(org_id): Path, +) -> Result, ApiError> { + match state.repo.get_org(&org_id).map_err(internal_err)? { + Some(row) => Ok(Json(row.into_dto())), + None => Err((StatusCode::NOT_FOUND, format!("no verified org {org_id}"))), + } +} + +// -------------------------------------------------------------- internal + +#[derive(Debug, Deserialize)] +pub struct UpsertOrgReq { + pub org_id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +impl UpsertOrgReq { + fn into_row(self) -> UpsertOrg { + UpsertOrg { + org_id: self.org_id, + name: self.name, + enabled: self.enabled, + } + } +} + +/// `POST /orgs` — verify (add or replace) an org. +pub async fn create_org( + State(state): State>, + Json(req): Json, +) -> Result, ApiError> { + let org_id = req.org_id.clone(); + let row = state.repo.upsert_org(req.into_row()).map_err(internal_err)?; + info!(%org_id, "verified org upserted"); + Ok(Json(row.into_dto())) +} + +/// `PUT /orgs/:org_id` — update an allowlist row (e.g. `enabled=false` to +/// de-verify while preserving history). The path org id wins over the body. +pub async fn update_org( + State(state): State>, + Path(org_id): Path, + Json(mut req): Json, +) -> Result, ApiError> { + req.org_id = org_id.clone(); + let row = state.repo.upsert_org(req.into_row()).map_err(internal_err)?; + info!(%org_id, "verified org updated"); + Ok(Json(row.into_dto())) +} + +/// `DELETE /orgs/:org_id` — remove an org from the allowlist entirely. 404 if +/// absent. Prefer `PUT enabled=false` to keep history. +pub async fn delete_org( + State(state): State>, + Path(org_id): Path, +) -> Result { + let removed = state.repo.delete_org(&org_id).map_err(internal_err)?; + if removed == 0 { + return Err((StatusCode::NOT_FOUND, format!("no verified org {org_id}"))); + } + info!(%org_id, "verified org deleted"); + Ok(StatusCode::NO_CONTENT) +} diff --git a/rust-backend/services/token-info/src/router.rs b/rust-backend/services/token-info/src/router.rs index b8f5e6ae..399d16a6 100644 --- a/rust-backend/services/token-info/src/router.rs +++ b/rust-backend/services/token-info/src/router.rs @@ -20,7 +20,7 @@ use tracing::info; use auth_client::AuthClient; -use crate::handlers::tokens; +use crate::handlers::{orgs, tokens}; use crate::state::AppState; /// Public API: open reads + JWT-gated writes. `auth` points at auth-service's @@ -40,6 +40,11 @@ pub fn public_router( "/tokens/:coin_type", put(tokens::update_token).delete(tokens::delete_token), ) + .route("/orgs", post(orgs::create_org)) + .route( + "/orgs/:org_id", + put(orgs::update_org).delete(orgs::delete_org), + ) .route_layer(axum::middleware::from_fn_with_state( auth, auth_client::require_auth, @@ -49,6 +54,8 @@ pub fn public_router( .route("/health", get(health)) .route("/tokens", get(tokens::list_tokens)) .route("/tokens/:coin_type", get(tokens::get_token)) + .route("/orgs", get(orgs::list_orgs)) + .route("/orgs/:org_id", get(orgs::get_org)) .route("/package-info", get(tokens::package_info)); Ok(reads.merge(writes).with_state(state).layer(cors)) @@ -61,6 +68,9 @@ pub fn internal_router(state: Arc) -> Router { .route("/tokens", post(tokens::create_token)) .route("/tokens/:coin_type", put(tokens::update_token)) .route("/tokens/:coin_type", delete(tokens::delete_token)) + .route("/orgs", post(orgs::create_org)) + .route("/orgs/:org_id", put(orgs::update_org)) + .route("/orgs/:org_id", delete(orgs::delete_org)) .with_state(state) } diff --git a/rust-backend/tests/Cargo.toml b/rust-backend/tests/Cargo.toml index a101397b..3b7d2676 100644 --- a/rust-backend/tests/Cargo.toml +++ b/rust-backend/tests/Cargo.toml @@ -9,6 +9,7 @@ path = "src/lib.rs" [dependencies] protocol-types = { workspace = true } +token-info-client = { workspace = true } indexer = { path = "../services/indexer" } quoting-service = { path = "../services/quoting-service" } diff --git a/rust-backend/tests/src/lib.rs b/rust-backend/tests/src/lib.rs index 02829276..7732f8a2 100644 --- a/rust-backend/tests/src/lib.rs +++ b/rust-backend/tests/src/lib.rs @@ -69,6 +69,7 @@ impl Harness { store.ingest( ChainEvent::BucketCreated(BucketCreated { bucket_id: bucket, + org_id: ObjectId::new([0xee; 32]), asset_type: AssetType::new("BTC"), settlement_type: AssetType::new("USDC"), call_type: AssetType::new("0x9::call_0::CALL_0"), @@ -101,6 +102,12 @@ impl Harness { let app = Arc::new(quoting_service::AppState::with_global_rfq_cap( cfg.max_inflight_rfqs_global, cfg.indexer_graphql_url.clone(), + // Seeded bucket belongs to org [0xee; 32] — pre-verify it. + token_info_client::VerifiedOrgsWatcher::fixed(vec![token_info_client::VerifiedOrg { + org_id: ObjectId::new([0xee; 32]).to_hex(), + name: "test-org".into(), + enabled: true, + }]), )); // Quoting WS server: rebind on ephemeral. @@ -202,6 +209,7 @@ fn account_json(id: &str, a: &indexer::AccountState) -> serde_json::Value { fn bucket_json(id: &str, b: &indexer::BucketState) -> serde_json::Value { serde_json::json!({ "bucketId": id, + "orgId": b.org_id.to_hex(), "assetType": b.asset_type.as_str(), "settlementType": b.settlement_type.as_str(), "callType": b.call_type.as_str(), diff --git a/rust-backend/tools/deployment-manager/src/deploy.rs b/rust-backend/tools/deployment-manager/src/deploy.rs index 7315b679..0f56c6b6 100644 --- a/rust-backend/tools/deployment-manager/src/deploy.rs +++ b/rust-backend/tools/deployment-manager/src/deploy.rs @@ -214,6 +214,118 @@ pub async fn create_and_share_treasury( Ok(InitOutcome { treasury_id, digest }) } +pub struct OrgOutcome { + pub org_id: ObjectID, + pub org_cap_id: ObjectID, + pub digest: String, +} + +/// Call `org::create_org(name, fee_bps)` and transfer the returned `OrgCap` +/// to the deployer. This creates the platform's own org ("SuiOptions") so the +/// option-scheduler can roll buckets under it. `create_org` is permissionless; +/// the deploy step just bootstraps the first one and records its ids. +pub async fn create_platform_org( + client: &SuiClient, + signer: &Signer, + package_id: ObjectID, + name: &str, + fee_bps: u64, + gas_budget: u64, +) -> Result { + use move_core_types::identifier::Identifier; + use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder; + use sui_types::transaction::TransactionData; + + // Give the fullnode a beat to index the freshly published package. + tokio::time::sleep(Duration::from_secs(2)).await; + + let mut pt = ProgrammableTransactionBuilder::new(); + let arg_name = pt.pure(name.to_string())?; + let arg_fee = pt.pure(fee_bps)?; + let cap = pt.programmable_move_call( + package_id, + Identifier::new("org").unwrap(), + Identifier::new("create_org").unwrap(), + vec![], + vec![arg_name, arg_fee], + ); + pt.transfer_arg(signer.address, cap); + let programmable = pt.finish(); + + let gas_coin = client + .coin_read_api() + .get_coins(signer.address, None, None, Some(5)) + .await + .context("listing gas coins")? + .data + .into_iter() + .max_by_key(|c| c.balance) + .ok_or_else(|| anyhow!("no SUI coins to pay gas for {}", signer.address))?; + let gas_price = client + .read_api() + .get_reference_gas_price() + .await + .context("fetching reference gas price")?; + let tx_data = TransactionData::new_programmable( + signer.address, + vec![gas_coin.object_ref()], + programmable, + gas_budget, + gas_price, + ); + let signature = Transaction::signature_from_signer( + tx_data.clone(), + Intent::sui_transaction(), + &signer.keypair, + ); + let tx = Transaction::from_data(tx_data, vec![signature]); + let opts = SuiTransactionBlockResponseOptions::new() + .with_effects() + .with_object_changes(); + + tracing::info!(name, fee_bps, "submitting org::create_org tx"); + let resp = client + .quorum_driver_api() + .execute_transaction_block( + tx, + opts, + Some(ExecuteTransactionRequestType::WaitForLocalExecution), + ) + .await + .context("submitting create_org tx")?; + assert_success(&resp)?; + + let digest = resp.digest.to_string(); + let changes = resp + .object_changes + .as_ref() + .ok_or_else(|| anyhow!("create_org response missing object_changes"))?; + + let mut org_id: Option = None; + let mut org_cap_id: Option = None; + for change in changes { + if let ObjectChange::Created { + object_id, + object_type, + .. + } = change + { + match (object_type.module.as_str(), object_type.name.as_str()) { + ("org", "Org") => org_id = Some(*object_id), + ("org", "OrgCap") => org_cap_id = Some(*object_id), + _ => {} + } + } + } + + Ok(OrgOutcome { + org_id: org_id.ok_or_else(|| anyhow!("Org object not found in create_org response"))?, + org_cap_id: org_cap_id + .ok_or_else(|| anyhow!("OrgCap object not found in create_org response"))?, + digest, + }) +} + /// Symbol → (module name, decimals). Hardcoded because Move modules name /// their OTW after the module in uppercase, so module="tusdc" implies /// type="TUSDC". Decimals match the real-world tokens they shadow. diff --git a/rust-backend/tools/deployment-manager/src/json_store.rs b/rust-backend/tools/deployment-manager/src/json_store.rs index a631a934..431de047 100644 --- a/rust-backend/tools/deployment-manager/src/json_store.rs +++ b/rust-backend/tools/deployment-manager/src/json_store.rs @@ -39,6 +39,14 @@ pub struct PackageInfo { pub publish_digest: String, #[serde(skip_serializing_if = "Option::is_none")] pub init_digest: Option, + /// The platform's own Org ("SuiOptions") created at deploy time. Orgs are + /// permissionless; these just record the one the option-scheduler rolls + /// buckets under. Absent when deployed with `--skip-init`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_org_id: Option, + /// The OrgCap for the platform org (owned by the deployer). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_org_cap_id: Option, pub deployer: String, pub deployed_at: String, // RFC 3339 pub network: String, diff --git a/rust-backend/tools/deployment-manager/src/lib.rs b/rust-backend/tools/deployment-manager/src/lib.rs index faab33de..9bf8909f 100644 --- a/rust-backend/tools/deployment-manager/src/lib.rs +++ b/rust-backend/tools/deployment-manager/src/lib.rs @@ -53,11 +53,21 @@ pub struct Cli { #[arg(long, default_value_t = 500_000_000)] pub gas_budget: u64, - /// Skip the post-publish `treasury::create_and_share` call. Use when - /// re-publishing for testing and you don't need a fresh Treasury. + /// Skip the post-publish init calls (`treasury::create_and_share` and + /// `org::create_org` for the platform org). Use when re-publishing for + /// testing and you don't need a fresh Treasury/Org. #[arg(long)] pub skip_init: bool, + /// Name of the platform org created post-publish (recorded as + /// platformOrgId / platformOrgCapId in deployments.json). + #[arg(long, default_value = "SuiOptions")] + pub org_name: String, + + /// Initial org fee (bps of gross premium) for the platform org. + #[arg(long, default_value_t = 0)] + pub org_fee_bps: u64, + /// Also publish the test-tokens package (TUSDC/TBTC/TWAL/TDEEP) and /// record the faucet IDs in deployments.json. Each run publishes a /// fresh package and overwrites the previous testTokens block. diff --git a/rust-backend/tools/deployment-manager/src/main.rs b/rust-backend/tools/deployment-manager/src/main.rs index 7389b27d..6543e9a1 100644 --- a/rust-backend/tools/deployment-manager/src/main.rs +++ b/rust-backend/tools/deployment-manager/src/main.rs @@ -13,7 +13,7 @@ use clap::Parser; use sui_sdk::SuiClientBuilder; use deployment_manager::deploy::{ - create_and_share_treasury, publish_package, publish_test_tokens, + create_and_share_treasury, create_platform_org, publish_package, publish_test_tokens, }; use deployment_manager::json_store::{ Deployments, NetworkDeployment, PackageInfo, TestTokenRecord, TestTokensRecord, TokenSpec, @@ -83,6 +83,8 @@ async fn main() -> Result<()> { previous_deepbook, cli.gas_budget, cli.skip_init, + &cli.org_name, + cli.org_fee_bps, ) .await .with_context(|| format!("deploying env {env_key} to {network}"))?; @@ -105,6 +107,8 @@ async fn deploy_one( previous_deepbook: Option, gas_budget: u64, skip_init: bool, + org_name: &str, + org_fee_bps: u64, ) -> Result { tracing::info!(network = %network, rpc = network.rpc_url(), "starting deployment"); @@ -143,6 +147,26 @@ async fn deploy_one( (Some(init.treasury_id.to_string()), Some(init.digest)) }; + let (platform_org_id, platform_org_cap_id) = if skip_init { + (None, None) + } else { + let org = create_platform_org( + &client, + &signer, + publish.package_id, + org_name, + org_fee_bps, + gas_budget, + ) + .await + .with_context(|| format!("creating platform org on {network}"))?; + tracing::info!(org = %org.org_id, org_cap = %org.org_cap_id, "platform org created"); + ( + Some(org.org_id.to_string()), + Some(org.org_cap_id.to_string()), + ) + }; + let test_tokens = if let Some(path) = test_tokens_path { let outcome = publish_test_tokens(&client, &signer, path, gas_budget) .await @@ -215,6 +239,8 @@ async fn deploy_one( treasury_id, publish_digest: publish.digest, init_digest, + platform_org_id, + platform_org_cap_id, deployer: signer.address.to_string(), deployed_at: chrono::Utc::now().to_rfc3339(), network: network.as_str().to_owned(), diff --git a/rust-backend/tools/exchange/src/main.rs b/rust-backend/tools/exchange/src/main.rs index 1db59226..a2de117d 100644 --- a/rust-backend/tools/exchange/src/main.rs +++ b/rust-backend/tools/exchange/src/main.rs @@ -23,7 +23,7 @@ use clap::Parser; use token_info_client::{Snapshot, TokenInfoClient}; use sui_tx::sui_client::SuiClientWrapper; -use sui_tx::tx::admin::{set_fee_bps, withdraw_treasury}; +use sui_tx::tx::admin::{set_protocol_fee_bps, withdraw_treasury}; use sui_tx::tx::test_tokens::{mint_and_deposit_into_account, mint_to_sender}; use option_scheduler::roller::{self, RollPlan}; @@ -155,7 +155,7 @@ async fn main() -> Result<()> { ); } Command::SetFee { bps } => { - let resp = set_fee_bps( + let resp = set_protocol_fee_bps( &wrap.client, &wrap.signer, package, diff --git a/rust-backend/tools/trader/src/main.rs b/rust-backend/tools/trader/src/main.rs index 4d0bcd42..ecd33b24 100644 --- a/rust-backend/tools/trader/src/main.rs +++ b/rust-backend/tools/trader/src/main.rs @@ -141,6 +141,7 @@ async fn main() -> Result<()> { // The option coin type is the bucket's third type parameter; read it off // the on-chain Bucket object. let call_type = bucket_call_type(&wrap.client, cli.bucket).await?; + let org_id = bucket_org_id(&wrap.client, cli.bucket).await?; let params = ExecuteTraderParams { package, @@ -151,6 +152,7 @@ async fn main() -> Result<()> { settlement_module: &settlement_module, settlement_faucet_id: settlement_faucet.faucet()?, bucket_id: cli.bucket, + org_id, protocol_config_id: protocol_config, treasury_id: treasury, mm_account_id, @@ -163,10 +165,9 @@ async fn main() -> Result<()> { valid_until_ms: best.quote.valid_until_ms, nonce: best.quote.nonce, signature: best.signature.clone(), - // Trader flow requires position_recipient == signer_token_recipient (the - // MM gets the Position NFT); the trader receives the CallOption. - position_recipient: signer_token_recipient, - call_token_recipient: trader_addr, + // The returned Coin routes to the trader; the MM's Position + // is contract-routed to signer_token_recipient. + executor_recipient: trader_addr, gas_budget: cli.gas_budget, }; @@ -175,6 +176,28 @@ async fn main() -> Result<()> { Ok(()) } + +/// Read the bucket's org id off the on-chain object's `org_id` field. +async fn bucket_org_id(client: &sui_sdk::SuiClient, bucket: ObjectID) -> Result { + use sui_json_rpc_types::SuiData; + let resp = client + .read_api() + .get_object_with_options( + bucket, + sui_json_rpc_types::SuiObjectDataOptions::new().with_content(), + ) + .await?; + let data = resp.data.ok_or_else(|| anyhow!("bucket {bucket} not found on chain"))?; + let org = data + .content + .as_ref() + .and_then(|c| c.try_as_move()) + .and_then(|m| m.fields.field_value("org_id")) + .map(|v| v.to_string()) + .ok_or_else(|| anyhow!("bucket {bucket} content has no org_id field"))?; + ObjectID::from_str(&org).context("parsing bucket org_id") +} + /// Read the bucket's option-coin type (the `Call` in `Bucket`) /// straight off the on-chain object's type parameters. async fn bucket_call_type(client: &sui_sdk::SuiClient, bucket: ObjectID) -> Result { diff --git a/rust-backend/tools/writer/src/main.rs b/rust-backend/tools/writer/src/main.rs index 1442b51b..df710811 100644 --- a/rust-backend/tools/writer/src/main.rs +++ b/rust-backend/tools/writer/src/main.rs @@ -140,6 +140,7 @@ async fn main() -> Result<()> { // The option coin type is the bucket's third type parameter; read it off // the on-chain Bucket object. let call_type = bucket_call_type(&wrap.client, cli.bucket).await?; + let org_id = bucket_org_id(&wrap.client, cli.bucket).await?; let params = ExecuteWriteParams { package, @@ -150,6 +151,7 @@ async fn main() -> Result<()> { underlying_module: &underlying_module, underlying_faucet_id: underlying_faucet.faucet()?, bucket_id: cli.bucket, + org_id, protocol_config_id: protocol_config, treasury_id: treasury, mm_account_id, @@ -162,9 +164,9 @@ async fn main() -> Result<()> { valid_until_ms: best.quote.valid_until_ms, nonce: best.quote.nonce, signature: best.signature.clone(), - position_recipient: writer_addr, - // Writer flow requires signer_token_recipient == call_token_recipient. - call_token_recipient: signer_token_recipient, + // The returned (Position, net premium) route to the writer; the + // MM's Coin is contract-routed to signer_token_recipient. + executor_recipient: writer_addr, gas_budget: cli.gas_budget, }; @@ -173,6 +175,28 @@ async fn main() -> Result<()> { Ok(()) } + +/// Read the bucket's org id off the on-chain object's `org_id` field. +async fn bucket_org_id(client: &sui_sdk::SuiClient, bucket: ObjectID) -> Result { + use sui_json_rpc_types::SuiData; + let resp = client + .read_api() + .get_object_with_options( + bucket, + sui_json_rpc_types::SuiObjectDataOptions::new().with_content(), + ) + .await?; + let data = resp.data.ok_or_else(|| anyhow!("bucket {bucket} not found on chain"))?; + let org = data + .content + .as_ref() + .and_then(|c| c.try_as_move()) + .and_then(|m| m.fields.field_value("org_id")) + .map(|v| v.to_string()) + .ok_or_else(|| anyhow!("bucket {bucket} content has no org_id field"))?; + ObjectID::from_str(&org).context("parsing bucket org_id") +} + /// Read the bucket's option-coin type (the `Call` in `Bucket`) /// straight off the on-chain object's type parameters. async fn bucket_call_type(client: &sui_sdk::SuiClient, bucket: ObjectID) -> Result { From a4241f18b877d9d9adc7a54992b1d5e4f124e155 Mon Sep 17 00:00:00 2001 From: Evan Witulski Date: Wed, 10 Jun 2026 03:00:49 -0500 Subject: [PATCH 2/2] [SO-163] Publish org-aware contracts to testnet dev slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package 0x1399a33d… + platform org "SuiOptions" (platformOrgId/CapId) recorded by the deploy tool; staging/prod slots untouched Co-Authored-By: Claude Fable 5 --- rust-backend/deployments.json | 65 +++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/rust-backend/deployments.json b/rust-backend/deployments.json index d8476997..3ff85a8a 100644 --- a/rust-backend/deployments.json +++ b/rust-backend/deployments.json @@ -1,5 +1,66 @@ { - "dev": null, + "dev": { + "package_info": { + "packageId": "0x1399a33d9024417c71cf8e3c846abc127756806882f009702ebe7d39d79e2db8", + "adminCapId": "0xc6dc63b90d9ff8740fe4acad72f852fc2297a7663ab3d99bf6f29eaf4b48ade0", + "protocolConfigId": "0x7c7cb119bc800390ce074f216c01bfe29ee084518010f334f27cacfd4d415510", + "upgradeCapId": "0x48817b8574f78d41fe688c2cee5d0ae9631a50c650132e52e640bb4733e18c0a", + "treasuryId": "0xe662795fa3218de3a5de6501085d0759198d0637fa086e77a20fe5e41eb83961", + "publishDigest": "5fRurTArnz2hB1XeqDvJeKhaFUAR6cMzxAYNpUN2PX3i", + "initDigest": "EWedGVZCge12CqWmCgLKgy19NvcDmR3vt71SE1UakGV5", + "platformOrgId": "0xb6d43f81eae136cf861298b77ea05ff024eb099402f46aac39443d4195d3f384", + "platformOrgCapId": "0x030526d2055b5990819138a06bf55ff033477d1433d0bbe75413886dbadde8dc", + "deployer": "0xab8d1b5a5311c9400e3eaf5c3b641f10fb48b43cc30d365fa8a98a6ca6bd4865", + "deployedAt": "2026-06-10T07:34:46.026214+00:00", + "network": "testnet", + "testTokens": { + "packageId": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02", + "upgradeCapId": "0x24cfed7a4add96943717aca5fcbd61a4e6fe59321065aa5e358981057610f1cb", + "publishDigest": "AcaACJh14RMukLQHt21ym2pQHUX699xXaTD53cv6PCbE", + "deployedAt": "2026-06-10T07:34:46.026190+00:00", + "tokens": { + "TBTC": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::tbtc::TBTC", + "faucetId": "0x04bd55bf74eb9920d776f1547cf57e11eddf75189777da539248101afe3d82ce", + "decimals": 8 + }, + "TDEEP": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::tdeep::TDEEP", + "faucetId": "0xc167d9c6f83676ea10f8de5ba0f469a4113a8787b81911cb6e59012cee8eb053", + "decimals": 6 + }, + "TUSDC": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::tusdc::TUSDC", + "faucetId": "0x1e3f9fba617567e90935f061c32d19503cfaf09c8a7708b77c172c0c764979b6", + "decimals": 6 + }, + "TWAL": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::twal::TWAL", + "faucetId": "0x0acdef87ac1dff55cbf095891d4fbdc278a9b1272363ef003db39ed2c9448ba3", + "decimals": 9 + } + } + } + }, + "token_info": { + "TBTC": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::tbtc::TBTC", + "decimals": 8 + }, + "TDEEP": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::tdeep::TDEEP", + "decimals": 6 + }, + "TUSDC": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::tusdc::TUSDC", + "decimals": 6 + }, + "TWAL": { + "coinType": "0x56ba805e915acb177e0c20a671634d7c64fb0b045902cf75cb058a10e36d4c02::twal::TWAL", + "decimals": 9 + } + } + }, "prod": { "package_info": { "packageId": "0xb63ebad34344ccc456cfaa4b228c4fa98abd1058d55b0e3dbee7563c7e694d58", @@ -138,4 +199,4 @@ } } } -} +} \ No newline at end of file