Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions packages/predict/sources/predict_manager.move
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,42 @@ public fun balance<T>(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<App>` 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);

Expand Down
57 changes: 57 additions & 0 deletions packages/predict/sources/registry.move
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<phantom App: drop> has copy, drop, store {}

/// Emitted when a Pyth source is created.
public struct PythSourceCreated has copy, drop, store {
Expand Down Expand Up @@ -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<App: drop>(registry: &mut Registry, _admin_cap: &AdminCap) {
registry.id.add(AppKey<App> {}, true);
}

/// Revoke a previously-authorized `App`. Returns `true` if the App was
/// authorized; `false` otherwise. Mirrors
/// `deepbook::registry::deauthorize_app`.
public fun deauthorize_app<App: drop>(
registry: &mut Registry,
_admin_cap: &AdminCap,
): bool {
registry.id.remove(AppKey<App> {})
}

/// 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<App: drop>(registry: &Registry) {
assert!(registry.id.exists_(AppKey<App> {}), 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<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<App: drop>(
registry: &mut Registry,
owner: address,
ctx: &mut TxContext,
): PredictManager {
assert_app_is_authorized<App>(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<ID> {
if (registry.market_oracle_ids.contains(cap_id)) {
Expand Down
124 changes: 124 additions & 0 deletions packages/predict/tests/registry/custodian_tests.move
Original file line number Diff line number Diff line change
@@ -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<TestApp>(&mut registry, &admin_cap);
ts::return_to_sender(&sc, admin_cap);

// App-authorization sanity check should not abort.
registry::assert_app_is_authorized<TestApp>(&registry);

// 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<TestApp>(
&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<TestApp>(&mut registry, &admin_cap);
ts::return_to_sender(&sc, admin_cap);

// Attempting to create_manager_for_custodian<OtherApp> must abort.
ts::next_tx(&mut sc, ALICE);
let manager = registry::create_manager_for_custodian<OtherApp>(
&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<TestApp>(&mut registry, &admin_cap);
let was_present = registry::deauthorize_app<TestApp>(&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<TestApp>(&mut registry, &admin_cap);
ts::return_to_sender(&sc, admin_cap);

ts::next_tx(&mut sc, ALICE);
let manager = registry::create_manager_for_custodian<TestApp>(
&mut registry,
ALICE,
ts::ctx(&mut sc),
);

assert!(manager.owner() == ALICE, 0);

manager.share();
ts::return_shared(registry);
ts::end(sc);
}