[SO-163] Permissionless orgs: per-org caps, buckets, fees + org-aware stack#153
Open
ewitulsk wants to merge 2 commits into
Open
[SO-163] Permissionless orgs: per-org caps, buckets, fees + org-aware stack#153ewitulsk wants to merge 2 commits into
ewitulsk wants to merge 2 commits into
Conversation
… 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 globalProtocolConfig/Treasurycollected 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:
Org model: org-owned + protocol skim. Each org has an
OrgCap, its ownfee_bps, its own treasury, its own buckets. A globalAdminCapsurvives with three powers only: the protocol fee skim (→ globalTreasury), a protocol-widepausedflag (blocks new writes only, never exits), and emergencyadmin_invalidate/revalidateoverrides on any org's bucket. Rationale: platform revenue + a kill switch, without the platform ever being able to touch an org's funds.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.Returns: executor side only.
execute_writereturns 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'ssigner_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.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
Quotestruct + BCS layout, theprotocol_idquote 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
org::create_org(name, fee_bps, ctx): OrgCap— permissionless. Shares theOrg, returns the cap to the PTB.OrgCapisstoreon 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.BalanceKey<T>dynamic fields directly on theOrgobject (same pattern asAccount/Treasury) — no separate per-org treasury object to create, share, or pass around.Authority matrix
create_orgcreate_bucketinvalidate_bucket/revalidate_bucketadmin_invalidate_bucket/admin_revalidate_bucketcleanup_bucket(returnsTreasuryCap<Call>)set_org_fee_bps,org::withdraw<T>set_protocol_fee_bps,set_pause,treasury::withdraw<T>execute_write_*,exercise,redeem_position,burn_expired_optionNotes:
invalidatedis 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.pausedblocks only the twoexecute_write_*entry points across all orgs. Exercises, redeems, burns, and cleanups are never blocked — users can always exit.create_bucketalso 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.gross == org_fee + protocol_fee + net), rounding dust (≤2 units) stays with the net-premium receiver.netcan never underflow.Org's balances; protocol fee into the globalTreasury. Zero fees skip the split (no empty dynamic-field balances are ever created).max_total_fee_bpsfield onQuoteis the future fix.Return-don't-transfer
execute_write(one fn,FlowKindparam, two zero-coin params, two recipient params)execute_write_writer_flow → (Position, Coin<Settlement>)andexecute_write_trader_flow → Coin<Call>; takesorg: &mut Org; the MM side is still contract-transferred toquote.signer_token_recipientcleanup_bucketTreasuryCap<Call>to sendertreasury::withdraw<T>recipientparamCoin<T>;recipientparam removedorg::withdraw<T>(new)Coin<T>exercise,redeem_positionSplitting
execute_writealso deletedFlowKind,writer_flow(),trader_flow(), bothcoin::zeroplumbing params, theposition_recipient/call_token_recipientparams, and thequote_recipient_mismatchchecks — 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)BucketCreatedorg_id: IDWriteExecutedorg_id, +flow: u8(0=writer, 1=trader);fee→org_fee+protocol_fee; droppedposition_recipient/call_token_recipient(executor's side is PTB-routed, so final destinations are unknowable on-chain;executor+floware the honest facts, and off-chain consumers derive conventional recipients fromflow)BucketInvalidated/BucketRevalidatedadmin→actor, +by_admin: boolFeeUpdatedProtocolFeeUpdatedTreasuryWithdrawnrecipient(coin is returned; destination is PTB-decided)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
Per-component changes
crates/protocol-typesWriteExecuted::total_fee(), andposition_recipient()/call_token_recipient()helpers that derive the conventional recipients fromflow— these replace the dropped event fields for all consumers.services/indexer000005_orgs(orgstable;buckets.org_id+ index,DEFAULT ''so it's runnable on a legacy DB). 18 event types ingested (was 14).OrgStatein the store, hydration, participant fan-out updated (actor,org_creator). GraphQL:BucketGql.org_id,buckets(orgIds:),OrgGql+orgs/org(id).crates/indexer-graphqlBucket.org_id,buckets(org_ids, …),Orgtype +orgs()/org(). Bug fixed in passing:write_executed_for_recipientfiltered the JSONB payload on the now-deletedcall_token_recipientfield — it would have silently returned[]forever. Now an OR over the two flow shapes (flow:1 + executor/flow:0 + signer_token_recipient).services/token-info000002_verified_orgs./orgsCRUD mirroring the token-catalog pattern: open reads, JWT-gated public writes, no-auth internal writes (network-isolated port, for ops seeding). "Verified" = row exists ANDenabled— de-verify withPUT enabled=falseto keep history.crates/token-info-clientVerifiedOrgDTO,fetch_verified_orgs(),platform_org()/platform_org_cap()snapshot accessors, and the sharedVerifiedOrgsWatcher(normalized-id matching so padded/unpadded forms agree).services/api-serviceAppStategains the watcher (crash if first fetch fails — consistent with the hard-cutover ethos)./bucketspushed-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).SeriesDtogainsorg_id/org_name.get_bucket404s unverified. NewGET /orgs./eventsstays unfiltered by design (complete record).services/quoting-serviceAppState. Rejection at three layers: WS edge (bucket_not_verifiederror frame), bulk-view resolution (silent drop), andvalidate_and_reserve(QuoteRejection::OrgNotVerified, defense-in-depth against races).services/option-schedulerorg_id/org_cap_id(defaults to the platform org from token-info — self-hosted org schedulers set their own). The oldsigner == deployerassertion is replaced by on-chain OrgCap validation at boot (object exists, is{pkg}::org::OrgCap, owned by the signer,org_idfield matches). Correctness fix: roll reconciliation now passesorgIds: [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-txcoin_pkg::create_bucketstakes the OrgCap.execute_write.rsrebuilt for the split entry points: org shared-object input, returns captured asNestedResults and routed with a trailingTransferObjects(these Rust builders are the executable documentation of the PTB shapes). Newtx/org.rs:create_org/set_org_fee_bps/withdraw_orgbuilders.admin.rs:set_protocol_fee_bpsrename, newset_pause,withdraw_treasuryis now a raw PTB (the coin is returned and must be transferred).tx/template.rs)write/buytemplates retargeted toexecute_write_writer_flow/execute_write_trader_flow(3 type args);writer_flow/trader_flow/coin::zeroremoved from the allowed sets; explicit shape test that the trailingTransferObjectsPTB matches.tools/deployment-manager--org-name, default "SuiOptions";--org-fee-bps, default 0) and recordsplatformOrgId/platformOrgCapIdindeployments.json.--skip-initskips it along with the treasury.tools/writer/tools/trader/tools/exchangeorg_idread off the bucket object,executor_recipient).Quotestruct/domain separator are unchanged.Frontend (
frontend/)tx/composer.ts—buildWriteTx/buildBuyTxtarget the split entry points, pass the series'org_idas the sharedOrgarg, andtransferObjectsthe returned values to the connected wallet.tx/org.ts(new) —buildCreateOrgTx(transfers the returnedOrgCapto the sender),buildSetOrgFeeTx,buildOrgWithdrawTx(transfers the returned coin), and OrgCap-gated invalidate/revalidate/cleanup (cleanup transfers the returnedTreasuryCapto the org admin's wallet).tx/admin.ts— gate builders now target theadmin_*override entry points;buildSetFeeBpsTxtargetsset_protocol_fee_bps; newbuildSetPauseTx; treasury withdraw transfers the returned coin. The AdminCap cleanup builder is gone (no admin cleanup on-chain)./adminscreen — now serves two authority levels in one console:OrgManager.tsx, same JWT challenge flow as the token catalog).OrgCapholder, via the newuseOrgCapshook which reads each cap'sorg_idfrom 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).api/client.tsSeriesgainsorg_id/org_name;useVerifiedOrgshook against api-serviceGET /orgs; Header nav gating includes OrgCap holders;config.tsexposesPLATFORM_ORG_ID.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 fromCLAUDE.md: any change to a frontend PTB builder for a sponsored flow must update the matching template + shape test incrates/sui-tx/src/tx/template.rsin the same PR.Breaking changes inventory
Move API (fresh redeploy, no upgrade path):
execute_write→execute_write_writer_flow/execute_write_trader_flow(new params/returns;FlowKinddeleted)create_bucket,invalidate_bucket,revalidate_bucket,cleanup_bucket:&AdminCap→&OrgCap; cleanup returns theTreasuryCapand dropsctxadmin::set_fee_bps→set_protocol_fee_bps; getterfee_bps→protocol_fee_bpstreasury::withdrawreturnsCoin<T>, dropsrecipientEvents/BCS: see the table above. Old
indexed_eventsJSONB rows do not deserialize into the new structs — fresh indexer DB required after republish (don't make the BCS fieldsOption; that changes the byte layout).HTTP/GraphQL:
SeriesDtogainsorg_id/org_name(frontend types updated in this PR);/bucketsnow verified-filtered; newGET /orgsbucket_not_verifiedorgIdfield,orgIdsarg,orgs/orgqueries)/orgsroutes);package-infogainsplatformOrgId/platformOrgCapIdConfig: option-scheduler accepts optional
org_id/org_cap_id; nothing else changed shape.Merge consequences & rollout
The full org-aware redeploy checklist is in
rust-backend/deployment.md(§ deployments.json update cycle). Summary for staging, in order:cargo run -p deployment-manager --bin deploy -- -e staging -n testnet …) — auto-creates the platform org and records its ids.verified_orgsmigration), 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}'/orgsis unreachable).The
devslot indeployments.jsoncommitted 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, orsui-tx::tx::org::create_org), self-host an option-scheduler with theirorg_id/org_cap_id, and ask the platform to verify them (Admin → Verified orgs, or token-info internal/orgs).Testing evidence
Suites (all green):
sui move test— 80/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)cargo test --workspace— all crates/services (new: unverified-org reservation rejection, two-orgs-same-pair series split, watcher id normalization, gas-station trailing-TransferObjectsshape match, recipient-derivation helpers)tsc --noEmit+vite buildcleanLive on testnet (fresh
dev-slot publish, package0x1399a33d…2db8; deployed services untouched; local stack: disposable Postgres + token-info + indexer + quoting + api-service + mm-bot, all torn down after):create_orgreturns the cap; deploy transfers it)0xb6d4…f384shared,name: SuiOptions; cap owned by deployer/orgsCRUD + internal seedingenabled=trueOrgCreatedfrom chain;orgsGraphQL querym7D2wCYs…— strikes $76.5k–$93.5kbuckets(orgIds:)filter: positive returns 5, unknown org returns[]/buckets: org-named, org-keyed series;/orgsseries: SuiOptions TBTC/TUSDC, 5 strikesexecute_write_writer_flow, returned(Position, net premium)routed by the PTB51SdAR8H…, range [0, 10M)Coin<Call>routed by the PTBGjXSWxrw…, range [10M, 15M)WriteExecutedcarriesflow/org_id/fee split; FIFO ranges correctset_org_fee_bps) + protocol 50 bps (set_protocol_fee_bps), then a write9brp4Bhx…: gross 113632841 → org 1136328 + protocol 568164 + net 111928349 — exact floors, exact conservationBCsdoU8b…G23okMMm…PUT enabled=false→ api-service serves 0 series, RFQ returnsbucket_not_verified; re-enable → recovers within one 30s watcher refreshNot tested live (covered by Move tests instead):
redeem_positionandcleanup_bucket(both require expiry — the rolled buckets expire 2026-06-18), the AdminCap gate overrides, andset_pause(asserted in unit tests; the builder is wired in the admin UI).Review guide
Suggested order:
contracts/sources/org.move(new, small) →bucket.move(the heart: split write flows, fee split, gates) →admin.move,treasury.move,events.move,errors.movecontracts/tests/—org_tests.move+ the new bucket tests show intended semanticscrates/protocol-types/src/events.rs— the BCS mirror +flow-derived recipient helpers (everything downstream leans on these)services/indexer(migration →event_types.rs→store/→graphql.rs) andcrates/indexer-graphqlcrates/token-info-client(VerifiedOrgsWatcher) →services/token-info/orgs→ api-servicehandlers/buckets.rs(series keying) → quotingws/retail.rs+rfq/mod.rsservices/option-scheduler/src/main.rs(OrgCap validation + reconciliation fix) +crates/sui-tx(execute_write.rs,template.rs,org.rs)tx/composer.ts↔template.rsside-by-side (the sync invariant), thenscreens/Admin.tsx.claude/ptb-sync.md,rust-backend/deployment.mdchecklistHigh-leverage spots to scrutinize:
bucket.move::check_write_preconditionsordering +fee_splitmathpublic_transferin both*_flow_with_quotebodies (this is the quote guarantee)group_into_series) and its two-org regression testconfirm_landed_rollsorg filter (silent-corruption class bug if reverted)Known limitations / follow-ups
options-protocol-spec.mdstill describes the pre-org MVP; updating it is a docs follow-up.invalidatedis last-writer-wins (see authority matrix).max_total_fee_bpsonQuoteis the future fix if fee front-running ever matters at 10%+10% caps).staging/prodrepublish + DB reset are operational steps after merge, per the checklist.🤖 Generated with Claude Code