Skip to content

[SO-163] Permissionless orgs: per-org caps, buckets, fees + org-aware stack#153

Open
ewitulsk wants to merge 2 commits into
stagingfrom
SO-163-permissionless-orgs
Open

[SO-163] Permissionless orgs: per-org caps, buckets, fees + org-aware stack#153
ewitulsk wants to merge 2 commits into
stagingfrom
SO-163-permissionless-orgs

Conversation

@ewitulsk

@ewitulsk ewitulsk commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Permissionless Orgs: per-org admin caps, buckets, fees + org-aware stack

Jira: SO-163

TL;DR

Before this PR, one global AdminCap (held by the deployer, used by the option-scheduler) created every bucket, and one global ProtocolConfig/Treasury collected every fee. This PR makes the protocol multi-tenant: anyone can create an org on-chain — its own admin cap, its own fee, its own treasury, its own buckets — with zero platform involvement. The platform keeps a thin protocol-level layer (fee skim, emergency pause, bucket-gate overrides) and a verified-orgs allowlist that decides which orgs' buckets the user-facing surfaces serve.

Independently of orgs, the contracts also adopt full PTB composability: functions return objects/coins to the calling PTB instead of transferring them, everywhere that doesn't break the signed-quote guarantee.

Everything was exercised end-to-end on testnet (fresh dev-slot publish + full local stack) — see Testing evidence for tx digests.


Design decisions (and why)

These four were decided up front and shape everything below:

  1. Org model: org-owned + protocol skim. Each org has an OrgCap, its own fee_bps, its own treasury, its own buckets. A global AdminCap survives with three powers only: the protocol fee skim (→ global Treasury), a protocol-wide paused flag (blocks new writes only, never exits), and emergency admin_invalidate/revalidate overrides on any org's bucket. Rationale: platform revenue + a kill switch, without the platform ever being able to touch an org's funds.

  2. Bucket creation: self-hosted scheduler only. Each bucket set needs a freshly published one-time-witness coin package, which only the option-scheduler does. Orgs that want buckets run their own scheduler instance configured with their org_id/org_cap_id. No in-browser bucket creation.

  3. Returns: executor side only. execute_write returns the executor's outputs to the PTB (writer flow: (Position, net-premium Coin); trader flow: Coin<Call>). The signer/MM's output is still transferred by the contract to the quote's signer_token_recipient. This is deliberate: the executor builds and signs the PTB — if the contract returned the MM's object to the PTB, a malicious executor could route it anywhere and void the signed quote's guarantee. Returning the executor's own side is safe by construction.

  4. Curation: verified-only surfaces. The indexer ingests every org's events (complete chain record), but api-service /buckets, quoting-service RFQs, and the frontend only serve orgs on a platform-controlled allowlist stored in token-info (mutable via the existing admin-JWT flow, exactly like the token catalog). Unverified orgs are fully functional on-chain; they're just not displayed/brokered by the platform's surfaces.

Deliberately unchanged: the Quote struct + BCS layout, the protocol_id quote domain separator (still the AdminCap id bytes — MM quote-signing code is untouched), account.move (Accounts stay global/org-agnostic), position.move, quote.move.


On-chain design (contracts/)

Object model

ProtocolConfig (shared)            Org (shared) ×N — permissionless
  fee_bps      ← protocol skim       name: String         (cosmetic, ≤64 bytes, not unique)
  protocol_id  ← quote domain sep    fee_bps: u64         (org-set, ≤1000)
  paused       ← write brake         [Balance<T> dynamic fields]  ← the Org IS its own treasury

AdminCap (owned, deployer)         OrgCap (owned, `key + store`) — sole org authority
                                     org_id: ID

Treasury (shared)                  Bucket<U, S, Call> (shared) ×N per org
  [Balance<T> dynamic fields]        org_id: ID           ← NEW: ties bucket → org
  ← protocol fees only               …cursor model unchanged…
  • org::create_org(name, fee_bps, ctx): OrgCappermissionless. Shares the Org, returns the cap to the PTB.
  • OrgCap is store on purpose: transferring the cap transfers the org (fee stream + bucket admin) as a sellable unit. A lost cap permanently strands the org's fee balances and its gate/cleanup authority; the protocol AdminCap can still invalidate/revalidate its buckets as a backstop.
  • Org fee balances live as BalanceKey<T> dynamic fields directly on the Org object (same pattern as Account/Treasury) — no separate per-org treasury object to create, share, or pass around.

Authority matrix

Operation OrgCap (own org) AdminCap Anyone
create_org
create_bucket ❌ (no longer)
invalidate_bucket / revalidate_bucket ✅ via admin_invalidate_bucket / admin_revalidate_bucket
cleanup_bucket (returns TreasuryCap<Call>) ❌ deliberate — would hand an org's coin TreasuryCap to the platform
set_org_fee_bps, org::withdraw<T>
set_protocol_fee_bps, set_pause, treasury::withdraw<T>
execute_write_*, exercise, redeem_position, burn_expired_option ✅ (quote-gated where applicable)

Notes:

  • invalidated is one bool with last-writer-wins between an org and the admin (an org can re-open a bucket the admin froze, and vice versa). Accepted for now; a one-way admin freeze would be a separate field if ever needed.
  • paused blocks only the two execute_write_* entry points across all orgs. Exercises, redeems, burns, and cleanups are never blocked — users can always exit. create_bucket also stays allowed while paused (no funds at risk).

Two-level fees

On every write: org_fee = ⌊gross · org_bps / 10000⌋, protocol_fee = ⌊gross · protocol_bps / 10000⌋, net = gross − org_fee − protocol_fee.

  • Both fees floor independently from gross — no compounding, exact conservation (gross == org_fee + protocol_fee + net), rounding dust (≤2 units) stays with the net-premium receiver.
  • Each side is independently capped at 1000 bps, so worst case combined is 20% and net can never underflow.
  • Org fee deposits into the Org's balances; protocol fee into the global Treasury. Zero fees skip the split (no empty dynamic-field balances are ever created).
  • Known and accepted: fee bps can change between an MM signing a quote and execution (bounded at 10% + 10%). If this ever matters, a max_total_fee_bps field on Quote is the future fix.

Return-don't-transfer

Function Before After
execute_write (one fn, FlowKind param, two zero-coin params, two recipient params) transferred Position + Coin<Call> + net premium internally split into execute_write_writer_flow → (Position, Coin<Settlement>) and execute_write_trader_flow → Coin<Call>; takes org: &mut Org; the MM side is still contract-transferred to quote.signer_token_recipient
cleanup_bucket self-transferred the TreasuryCap<Call> to sender returns it (outstanding option coins may exist, so it can't be destroyed)
treasury::withdraw<T> transferred to a recipient param returns Coin<T>; recipient param removed
org::withdraw<T> (new) returns Coin<T>
exercise, redeem_position already returned unchanged

Splitting execute_write also deleted FlowKind, writer_flow(), trader_flow(), both coin::zero plumbing params, the position_recipient/call_token_recipient params, and the quote_recipient_mismatch checks — the recipient guarantee is now structural (direct transfer to the quote's recipient) instead of an assert.

Event schema changes (BCS — coordinated with protocol-types)

Event Change
BucketCreated + org_id: ID
WriteExecuted + org_id, + flow: u8 (0=writer, 1=trader); feeorg_fee + protocol_fee; dropped position_recipient/call_token_recipient (executor's side is PTB-routed, so final destinations are unknowable on-chain; executor + flow are the honest facts, and off-chain consumers derive conventional recipients from flow)
BucketInvalidated/BucketRevalidated adminactor, + by_admin: bool
FeeUpdated renamed ProtocolFeeUpdated
TreasuryWithdrawn dropped recipient (coin is returned; destination is PTB-decided)
New OrgCreated { org_id, name, fee_bps, creator }, OrgFeeUpdated, OrgWithdraw, ProtocolPauseSet { paused, admin }

Error codes

Added 29 protocol_paused, 30 org_cap_mismatch, 31 bucket_org_mismatch, 32 org_name_invalid, 33 insufficient_org_balance. Retired (gaps preserved, no renumbering): 7 quote_recipient_mismatch, 17, 22 (dead).


Off-chain architecture (rust-backend/)

Verified-orgs data flow

on-chain: org::create_org (permissionless)
   │  OrgCreated event
   ▼
indexer  ── ingests EVERY org (orgs table, buckets.org_id) ── GraphQL: orgs / org(id) / buckets(orgIds:)
   │
token-info ── verified_orgs table (PLATFORM-controlled allowlist)
   │            POST/PUT/DELETE /orgs  (admin JWT, public)  +  internal :9006 (ops seeding)
   │            GET /orgs?enabled=true (open)
   ▼
VerifiedOrgsWatcher (crates/token-info-client) — poll 30s, FAIL-CLOSED at boot, keep-last-good on refresh failure
   ├── api-service:    /buckets + /buckets/:id filtered to verified orgs; series keyed by org; GET /orgs
   └── quoting-service: RFQ → "bucket_not_verified"; bulk-view silently drops; re-checked at reservation time

Per-component changes

Component Change
crates/protocol-types Event mirrors updated in exact Move field order (BCS positional). WriteExecuted::total_fee(), and position_recipient()/call_token_recipient() helpers that derive the conventional recipients from flow — these replace the dropped event fields for all consumers.
services/indexer Migration 000005_orgs (orgs table; buckets.org_id + index, DEFAULT '' so it's runnable on a legacy DB). 18 event types ingested (was 14). OrgState in the store, hydration, participant fan-out updated (actor, org_creator). GraphQL: BucketGql.org_id, buckets(orgIds:), OrgGql + orgs/org(id).
crates/indexer-graphql Bucket.org_id, buckets(org_ids, …), Org type + orgs()/org(). Bug fixed in passing: write_executed_for_recipient filtered the JSONB payload on the now-deleted call_token_recipient field — it would have silently returned [] forever. Now an OR over the two flow shapes (flow:1 + executor / flow:0 + signer_token_recipient).
services/token-info Migration 000002_verified_orgs. /orgs CRUD mirroring the token-catalog pattern: open reads, JWT-gated public writes, no-auth internal writes (network-isolated port, for ops seeding). "Verified" = row exists AND enabled — de-verify with PUT enabled=false to keep history.
crates/token-info-client VerifiedOrg DTO, fetch_verified_orgs(), platform_org()/platform_org_cap() snapshot accessors, and the shared VerifiedOrgsWatcher (normalized-id matching so padded/unpadded forms agree).
services/api-service AppState gains the watcher (crash if first fetch fails — consistent with the hard-cutover ethos). /buckets pushed-down org filter; series key is now (org, asset, settlement, expiry) — two orgs listing the same pair/expiry would otherwise silently merge into one strike ladder (there's a regression test for exactly this). SeriesDto gains org_id/org_name. get_bucket 404s unverified. New GET /orgs. /events stays unfiltered by design (complete record).
services/quoting-service Watcher in AppState. Rejection at three layers: WS edge (bucket_not_verified error frame), bulk-view resolution (silent drop), and validate_and_reserve (QuoteRejection::OrgNotVerified, defense-in-depth against races).
services/option-scheduler Config gains optional org_id/org_cap_id (defaults to the platform org from token-info — self-hosted org schedulers set their own). The old signer == deployer assertion is replaced by on-chain OrgCap validation at boot (object exists, is {pkg}::org::OrgCap, owned by the signer, org_id field matches). Correctness fix: roll reconciliation now passes orgIds: [our org] — previously a different org listing the same pair/expiry would falsely "confirm" our roll and the family would never get created.
crates/sui-tx coin_pkg::create_buckets takes the OrgCap. execute_write.rs rebuilt for the split entry points: org shared-object input, returns captured as NestedResults and routed with a trailing TransferObjects (these Rust builders are the executable documentation of the PTB shapes). New tx/org.rs: create_org/set_org_fee_bps/withdraw_org builders. admin.rs: set_protocol_fee_bps rename, new set_pause, withdraw_treasury is now a raw PTB (the coin is returned and must be transferred).
gas-station (tx/template.rs) write/buy templates retargeted to execute_write_writer_flow/execute_write_trader_flow (3 type args); writer_flow/trader_flow/coin::zero removed from the allowed sets; explicit shape test that the trailing TransferObjects PTB matches.
tools/deployment-manager Post-publish, creates the platform org (--org-name, default "SuiOptions"; --org-fee-bps, default 0) and records platformOrgId/platformOrgCapId in deployments.json. --skip-init skips it along with the treasury.
tools/writer / tools/trader / tools/exchange Adapted to the new builder params (org_id read off the bucket object, executor_recipient).
mm-bot Recompile only — it signs quotes, and the Quote struct/domain separator are unchanged.

Frontend (frontend/)

  • tx/composer.tsbuildWriteTx/buildBuyTx target the split entry points, pass the series' org_id as the shared Org arg, and transferObjects the returned values to the connected wallet. ⚠️ These shapes are matched by the gas-station templates — see the sync invariant below.
  • tx/org.ts (new) — buildCreateOrgTx (transfers the returned OrgCap to the sender), buildSetOrgFeeTx, buildOrgWithdrawTx (transfers the returned coin), and OrgCap-gated invalidate/revalidate/cleanup (cleanup transfers the returned TreasuryCap to the org admin's wallet).
  • tx/admin.ts — gate builders now target the admin_* override entry points; buildSetFeeBpsTx targets set_protocol_fee_bps; new buildSetPauseTx; treasury withdraw transfers the returned coin. The AdminCap cleanup builder is gone (no admin cleanup on-chain).
  • /admin screen — now serves two authority levels in one console:
    • Platform admin (AdminCap): protocol fee, emergency pause/unpause, treasury, supported-token catalog, and a verified-orgs manager (OrgManager.tsx, same JWT challenge flow as the token catalog).
    • Org admin (any OrgCap holder, via the new useOrgCaps hook which reads each cap's org_id from object content): per-org fee + fee withdrawal, and gates/cleanup on the org's own buckets. The shared bucket table picks the right builder per row (own-org cap → org builders; otherwise AdminCap override).
    • Create-org form — permissionless, any connected wallet.
  • api/client.ts Series gains org_id/org_name; useVerifiedOrgs hook against api-service GET /orgs; Header nav gating includes OrgCap holders; config.ts exposes PLATFORM_ORG_ID.

⚠️ The PTB sync invariant (read before touching tx/*.ts)

The gas-station only sponsors PTBs that structurally match its templates. This PR changes the write/buy shapes, so gas-station and frontend must deploy in the same window — a mismatch silently refuses sponsorship (no chain error; users just can't transact).

This invariant is now documented as a standing rule in .claude/ptb-sync.md (builder ↔ template map, what counts as a shape change, how to verify) with a pointer from CLAUDE.md: any change to a frontend PTB builder for a sponsored flow must update the matching template + shape test in crates/sui-tx/src/tx/template.rs in the same PR.


Breaking changes inventory

Move API (fresh redeploy, no upgrade path):

  • execute_writeexecute_write_writer_flow / execute_write_trader_flow (new params/returns; FlowKind deleted)
  • create_bucket, invalidate_bucket, revalidate_bucket, cleanup_bucket: &AdminCap&OrgCap; cleanup returns the TreasuryCap and drops ctx
  • admin::set_fee_bpsset_protocol_fee_bps; getter fee_bpsprotocol_fee_bps
  • treasury::withdraw returns Coin<T>, drops recipient

Events/BCS: see the table above. Old indexed_events JSONB rows do not deserialize into the new structs — fresh indexer DB required after republish (don't make the BCS fields Option; that changes the byte layout).

HTTP/GraphQL:

  • api-service: SeriesDto gains org_id/org_name (frontend types updated in this PR); /buckets now verified-filtered; new GET /orgs
  • quoting WS: new error code bucket_not_verified
  • indexer GraphQL: additive (orgId field, orgIds arg, orgs/org queries)
  • token-info: additive (/orgs routes); package-info gains platformOrgId/platformOrgCapId

Config: option-scheduler accepts optional org_id/org_cap_id; nothing else changed shape.


Merge consequences & rollout

Pushing to staging auto-deploys dev + staging (deploy-lower.yml). The new images expect the new event shapes and the verified-orgs endpoint, so merging without the staging republish will crash-loop the staging indexer against old-package payloads, and api/quoting will fail closed at boot until token-info's /orgs exists.

The full org-aware redeploy checklist is in rust-backend/deployment.md (§ deployments.json update cycle). Summary for staging, in order:

  1. Republish contracts to the staging slot (cargo run -p deployment-manager --bin deploy -- -e staging -n testnet …) — auto-creates the platform org and records its ids.
  2. Fresh indexer DB for the new package.
  3. Deploy token-info first (runs the verified_orgs migration), seed the platform org via the internal router:
    curl -X POST http://<token-info-internal>:9006/orgs -d '{"org_id":"<platformOrgId>","name":"SuiOptions","enabled":true}'
  4. Then api-service + quoting-service (they fail closed if /orgs is unreachable).
  5. Scheduler (now OrgCap-authorized; no config change needed for the platform org), gas-station + frontend together.

The dev slot in deployments.json committed in this PR is already the new package — the dev env will come up correctly on merge once its DB is fresh and the org is seeded.

Onboarding an external org later: they call create_org (any wallet, or sui-tx::tx::org::create_org), self-host an option-scheduler with their org_id/org_cap_id, and ask the platform to verify them (Admin → Verified orgs, or token-info internal /orgs).


Testing evidence

Suites (all green):

  • Move: sui move test80/80 (new: org lifecycle + name/fee validation, cross-org cap aborts, wrong-org write abort, admin overrides, pause semantics incl. exercise-while-paused, exact fee-split math on odd grosses, direct return-value plumbing)
  • Rust: cargo test --workspace — all crates/services (new: unverified-org reservation rejection, two-orgs-same-pair series split, watcher id normalization, gas-station trailing-TransferObjects shape match, recipient-derivation helpers)
  • Frontend: tsc --noEmit + vite build clean

Live on testnet (fresh dev-slot publish, package 0x1399a33d…2db8; deployed services untouched; local stack: disposable Postgres + token-info + indexer + quoting + api-service + mm-bot, all torn down after):

# What Evidence
1 Publish + auto platform-org creation (create_org returns the cap; deploy transfers it) org 0xb6d4…f384 shared, name: SuiOptions; cap owned by deployer
2 token-info /orgs CRUD + internal seeding seeded + read back enabled=true
3 Indexer ingests OrgCreated from chain; orgs GraphQL query org row with name/fee/creator
4 Scheduler boot-validates the OrgCap on-chain (type/owner/org_id), then rolls 5 buckets under it in one PTB m7D2wCYs… — strikes $76.5k–$93.5k
5 buckets(orgIds:) filter: positive returns 5, unknown org returns [] GraphQL
6 api-service /buckets: org-named, org-keyed series; /orgs series: SuiOptions TBTC/TUSDC, 5 strikes
7 Writer flow — real RFQ → mm-bot signed quote → execute_write_writer_flow, returned (Position, net premium) routed by the PTB 51SdAR8H…, range [0, 10M)
8 Trader flow — returned Coin<Call> routed by the PTB GjXSWxrw…, range [10M, 15M)
9 Indexed WriteExecuted carries flow/org_id/fee split; FIFO ranges correct GraphQL payloads
10 Two-level fees live: org 100 bps (set_org_fee_bps) + protocol 50 bps (set_protocol_fee_bps), then a write 9brp4Bhx…: gross 113632841 → org 1136328 + protocol 568164 + net 111928349 — exact floors, exact conservation
11 Exercise (returned-coin path) via raw PTB: burn 0.005 CALL, pay 425 TUSDC, receive 0.005 TBTC BCsdoU8b…
12 Org fee withdrawal (returned coin) — exactly the accrued 1136328 G23okMMm…
13 Verified-only gate live: PUT enabled=false → api-service serves 0 series, RFQ returns bucket_not_verified; re-enable → recovers within one 30s watcher refresh quoting error frame observed by the writer tool
14 Bucket state reconciles with all activity written 17M (10+5+2), cursor 500k, queued 16.5M

Not tested live (covered by Move tests instead): redeem_position and cleanup_bucket (both require expiry — the rolled buckets expire 2026-06-18), the AdminCap gate overrides, and set_pause (asserted in unit tests; the builder is wired in the admin UI).


Review guide

Suggested order:

  1. contracts/sources/org.move (new, small) → bucket.move (the heart: split write flows, fee split, gates) → admin.move, treasury.move, events.move, errors.move
  2. contracts/tests/org_tests.move + the new bucket tests show intended semantics
  3. crates/protocol-types/src/events.rs — the BCS mirror + flow-derived recipient helpers (everything downstream leans on these)
  4. services/indexer (migration → event_types.rsstore/graphql.rs) and crates/indexer-graphql
  5. crates/token-info-client (VerifiedOrgsWatcher) → services/token-info /orgs → api-service handlers/buckets.rs (series keying) → quoting ws/retail.rs + rfq/mod.rs
  6. services/option-scheduler/src/main.rs (OrgCap validation + reconciliation fix) + crates/sui-tx (execute_write.rs, template.rs, org.rs)
  7. Frontend: tx/composer.tstemplate.rs side-by-side (the sync invariant), then screens/Admin.tsx
  8. Docs: .claude/ptb-sync.md, rust-backend/deployment.md checklist

High-leverage spots to scrutinize:

  • bucket.move::check_write_preconditions ordering + fee_split math
  • The signer-side public_transfer in both *_flow_with_quote bodies (this is the quote guarantee)
  • api-service series key change (group_into_series) and its two-org regression test
  • Scheduler confirm_landed_rolls org filter (silent-corruption class bug if reverted)

Known limitations / follow-ups

  • options-protocol-spec.md still describes the pre-org MVP; updating it is a docs follow-up.
  • Org/admin invalidated is last-writer-wins (see authority matrix).
  • Quotes don't commit to a fee level (max_total_fee_bps on Quote is the future fix if fee front-running ever matters at 10%+10% caps).
  • staging/prod republish + DB reset are operational steps after merge, per the checklist.

🤖 Generated with Claude Code

ewitulsk and others added 2 commits June 10, 2026 02:33
… 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<Settlement>) / Coin<Call>); 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 <noreply@anthropic.com>
- package 0x1399a33d… + platform org "SuiOptions" (platformOrgId/CapId)
  recorded by the deploy tool; staging/prod slots untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sui-options Ready Ready Preview, Comment Jun 10, 2026 8:01am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant