diff --git a/packages/predict/sources/predict_manager.move b/packages/predict/sources/predict_manager.move index 10f8baf93..03681b5b5 100644 --- a/packages/predict/sources/predict_manager.move +++ b/packages/predict/sources/predict_manager.move @@ -69,12 +69,42 @@ public fun balance(self: &PredictManager): u64 { // === Public-Package Functions === -/// Create a new PredictManager and share it. +/// Create a new PredictManager whose owner is the transaction sender. public(package) fun new(registry_uid: &mut UID, ctx: &mut TxContext): PredictManager { - let id = derived_object::claim(registry_uid, PredictManagerKey(ctx.sender(), 0)); - let owner = ctx.sender(); + new_internal(registry_uid, ctx.sender(), ctx) +} + +/// Create a new PredictManager whose `owner` is an arbitrary address (not +/// necessarily the transaction sender). Used by external custodian +/// protocols that need to escrow Predict positions inside a manager they +/// control — e.g. a margin-loan vault whose loan object derives its own +/// on-chain address and uses it as the owner here. Mirrors +/// `balance_manager::new_with_custom_owner`. +/// +/// Authorization is the caller's responsibility — the public entry point +/// `registry::create_manager_for_custodian` gates this behind the +/// App-witness pattern. +public(package) fun new_with_custom_owner( + registry_uid: &mut UID, + owner: address, + ctx: &mut TxContext, +): PredictManager { + new_internal(registry_uid, owner, ctx) +} - let mut balance_manager = balance_manager::new(ctx); +fun new_internal( + registry_uid: &mut UID, + owner: address, + ctx: &mut TxContext, +): PredictManager { + let id = derived_object::claim(registry_uid, PredictManagerKey(owner, 0)); + + // The inner BalanceManager's `owner` matches PredictManager's owner. + // When `owner` is a contract-derived address, the BalanceManager is + // effectively locked to that contract — only callers that hold a + // mutable reference to the matching PredictManager can drive deposits + // or withdrawals through this module's gated APIs. + let mut balance_manager = balance_manager::new_with_custom_owner(owner, ctx); let deposit_cap = balance_manager.mint_deposit_cap(ctx); let withdraw_cap = balance_manager.mint_withdraw_cap(ctx); diff --git a/packages/predict/sources/registry.move b/packages/predict/sources/registry.move index 7b5e2ce23..7f2f54987 100644 --- a/packages/predict/sources/registry.move +++ b/packages/predict/sources/registry.move @@ -29,6 +29,7 @@ use sui::{ use fun df::exists_ as UID.exists_; use fun df::add as UID.add; +use fun df::remove as UID.remove; const EPredictAlreadyCreated: u64 = 0; const EInvalidTickSize: u64 = 1; @@ -37,6 +38,17 @@ const EFeedIdOverflow: u64 = 3; const EFeedIdMismatch: u64 = 4; const EPythSourceAlreadyCreated: u64 = 5; const EInvalidExpiry: u64 = 6; +const EAppNotAuthorized: u64 = 7; + +// === App Auth === + +/// Authorization key stored in the Registry's dynamic fields. The `App` +/// type parameter is a witness defined in the calling protocol; admin +/// authorizes specific App types via `authorize_app` before they can use +/// gated entry points like `create_manager_for_custodian`. +/// +/// Mirrors the deepbook::registry::AppKey pattern. +public struct AppKey has copy, drop, store {} /// Emitted when a Pyth source is created. public struct PythSourceCreated has copy, drop, store { @@ -369,6 +381,51 @@ entry fun create_and_share_manager(registry: &mut Registry, ctx: &mut TxContext) create_manager(registry, ctx).share(); } +/// Authorize a protocol (identified by the `App` witness type) to call +/// gated composability entry points such as `create_manager_for_custodian`. +/// Mirrors the `deepbook::registry::authorize_app` pattern. +public fun authorize_app(registry: &mut Registry, _admin_cap: &AdminCap) { + registry.id.add(AppKey {}, true); +} + +/// Revoke a previously-authorized `App`. Returns `true` if the App was +/// authorized; `false` otherwise. Mirrors +/// `deepbook::registry::deauthorize_app`. +public fun deauthorize_app( + registry: &mut Registry, + _admin_cap: &AdminCap, +): bool { + registry.id.remove(AppKey {}) +} + +/// Assert that `App` is currently authorized. Called by gated entry +/// points before performing privileged work. Mirrors +/// `deepbook::registry::assert_app_is_authorized`. +public fun assert_app_is_authorized(registry: &Registry) { + assert!(registry.id.exists_(AppKey {}), EAppNotAuthorized); +} + +/// Create a new PredictManager owned by `owner` (not the transaction +/// sender). Used by external custodian protocols — e.g. a margin-loan +/// vault whose loan object derives its own on-chain address and uses it +/// as the owner here. The `App` type parameter is a witness from the +/// caller's package; the admin must have previously `authorize_app` +/// on this registry. +/// +/// Once minted, the manager can only be driven through this module's +/// existing public APIs (`predict_manager::deposit` / `::withdraw`) by a +/// transaction whose `ctx.sender()` matches `owner`. For contract-derived +/// owner addresses, that means only code holding the matching object +/// reference can drive it — the classic locked-custodian pattern. +public fun create_manager_for_custodian( + registry: &mut Registry, + owner: address, + ctx: &mut TxContext, +): PredictManager { + assert_app_is_authorized(registry); + predict_manager::new_with_custom_owner(&mut registry.id, owner, ctx) +} + /// Get market_oracle IDs created by a given MarketOracleCap. public fun market_oracle_ids(registry: &Registry, cap_id: ID): vector { if (registry.market_oracle_ids.contains(cap_id)) { diff --git a/packages/predict/tests/registry/custodian_tests.move b/packages/predict/tests/registry/custodian_tests.move new file mode 100644 index 000000000..be1bdcb67 --- /dev/null +++ b/packages/predict/tests/registry/custodian_tests.move @@ -0,0 +1,124 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +module deepbook_predict::custodian_tests; + +use deepbook_predict::predict_manager::PredictManager; +use deepbook_predict::registry::{Self, Registry, AdminCap}; +use sui::test_scenario as ts; + +/// A test-only App-witness. In real usage a calling protocol would +/// define this in its own module. +public struct TestApp() has drop; + +/// A second witness used to test that auth is per-type. +public struct OtherApp() has drop; + +const ADMIN: address = @0xAD; +const ALICE: address = @0xA11CE; +const CUSTODIAN: address = @0xCAFE; + +#[test] +fun authorize_then_create_for_custodian_succeeds() { + let mut sc = ts::begin(ADMIN); + + // Init the registry. Admin keeps the AdminCap. + let _registry_id = registry::init_for_testing(ts::ctx(&mut sc)); + ts::next_tx(&mut sc, ADMIN); + + // Admin authorizes TestApp. + let admin_cap: AdminCap = ts::take_from_sender(&sc); + let mut registry: Registry = ts::take_shared(&sc); + registry::authorize_app(&mut registry, &admin_cap); + ts::return_to_sender(&sc, admin_cap); + + // App-authorization sanity check should not abort. + registry::assert_app_is_authorized(®istry); + + // ALICE (any sender) can now create a manager whose owner is CUSTODIAN. + ts::next_tx(&mut sc, ALICE); + let manager = registry::create_manager_for_custodian( + &mut registry, + CUSTODIAN, + ts::ctx(&mut sc), + ); + + // Owner is the custodian, not the sender (Alice). + assert!(manager.owner() == CUSTODIAN, 0); + assert!(manager.owner() != ALICE, 1); + + manager.share(); + ts::return_shared(registry); + ts::end(sc); +} + +// EAppNotAuthorized = 7 in registry.move (module-private const; we use the +// numeric form here because Move constants aren't cross-module visible). +#[test, expected_failure(abort_code = 7, location = deepbook_predict::registry)] +fun unauthorized_app_aborts() { + let mut sc = ts::begin(ADMIN); + let _registry_id = registry::init_for_testing(ts::ctx(&mut sc)); + ts::next_tx(&mut sc, ADMIN); + let mut registry: Registry = ts::take_shared(&sc); + + // Authorize TestApp only. + let admin_cap: AdminCap = ts::take_from_sender(&sc); + registry::authorize_app(&mut registry, &admin_cap); + ts::return_to_sender(&sc, admin_cap); + + // Attempting to create_manager_for_custodian must abort. + ts::next_tx(&mut sc, ALICE); + let manager = registry::create_manager_for_custodian( + &mut registry, + CUSTODIAN, + ts::ctx(&mut sc), + ); + + manager.share(); + ts::return_shared(registry); + ts::end(sc); +} + +#[test] +fun deauthorize_removes_access() { + let mut sc = ts::begin(ADMIN); + let _registry_id = registry::init_for_testing(ts::ctx(&mut sc)); + ts::next_tx(&mut sc, ADMIN); + + let admin_cap: AdminCap = ts::take_from_sender(&sc); + let mut registry: Registry = ts::take_shared(&sc); + registry::authorize_app(&mut registry, &admin_cap); + let was_present = registry::deauthorize_app(&mut registry, &admin_cap); + assert!(was_present, 0); + ts::return_to_sender(&sc, admin_cap); + ts::return_shared(registry); + ts::end(sc); +} + +#[test] +fun create_for_custodian_with_owner_eq_sender_still_works() { + // The custom-owner constructor is general — if a caller wants owner == + // sender, that's allowed too. Just exercises the equivalence path. + let mut sc = ts::begin(ADMIN); + let _registry_id = registry::init_for_testing(ts::ctx(&mut sc)); + ts::next_tx(&mut sc, ADMIN); + + let admin_cap: AdminCap = ts::take_from_sender(&sc); + let mut registry: Registry = ts::take_shared(&sc); + registry::authorize_app(&mut registry, &admin_cap); + ts::return_to_sender(&sc, admin_cap); + + ts::next_tx(&mut sc, ALICE); + let manager = registry::create_manager_for_custodian( + &mut registry, + ALICE, + ts::ctx(&mut sc), + ); + + assert!(manager.owner() == ALICE, 0); + + manager.share(); + ts::return_shared(registry); + ts::end(sc); +}