diff --git a/docs/admin.md b/docs/admin.md index 5ec5f578..2561778c 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -1,10 +1,11 @@ # Admin Capabilities -The `deeptrade-core` package is managed by 2 administrative capabilities: `AdminCap` and `LoyaltyAdminCap`. +The `deeptrade-core` package is managed by 3 administrative capabilities: `AdminCap`, `LoyaltyAdminCap`, and `RebatesClaimerCap`. To balance security with operational flexibility, these roles create a tiered system of authority: - **`AdminCap`**: The core capability for all sensitive protocol functions. It is protected by a mandatory on-chain multisig enforcement, with an additional time-lock for the most critical actions. See our [Multisig](./multisig.md) document for more details. - **`LoyaltyAdminCap`**: A delegated capability designed for routine, low-risk operations like managing individual user loyalty levels. +- **`RebatesClaimerCap`**: A delegated capability designed for routine storage-rebate cleanup of empty unsettled-fee bag entries, without requiring a multisig transaction for each claim. ## 1. Direct Operations @@ -19,6 +20,8 @@ For operations that are considered lower-risk or require immediate execution for - `add_loyalty_level` / `remove_loyalty_level`: Manages the available loyalty tiers. - `update_loyalty_admin_cap_owner`: Transfers ownership of the `LoyaltyAdminCap`. - **Fees Management** (`fee_manager.move`): + - `create_rebates_claimer_cap`: Creates and shares a `RebatesClaimerCap` assigned to a designated owner. May be called more than once. + - `update_rebates_claimer_cap_owner`: Transfers ownership of a `RebatesClaimerCap`. - `claim_user_unsettled_fee_storage_rebate_admin`: Claims storage rebate for a user's settled fee. - `claim_protocol_unsettled_fee_storage_rebate_admin`: Claims storage rebate for a settled protocol fee. @@ -26,7 +29,7 @@ For operations that are considered lower-risk or require immediate execution for - **Emergency Response:** Versioning functions do not use a timelock to allow for rapid response in case of a critical bug or an issue with a dependency like the DeepBook protocol. This agility is crucial to protect the protocol and its users, as detailed in our [Versioning Strategy](./versioning.md). - **Operational Flexibility:** The loyalty program is designed to be managed dynamically. We consider these operations to be low-risk, as the primary impact is on user fee discounts rather than a direct risk to locked funds. This flexibility allows us to manage the program efficiently without the delay of a timelock. -- **System Maintenance:** Administrative functions for claiming storage rebates are low-risk cleanup operations. They do not move user or protocol funds but instead recover storage costs from objects that are no longer in use. A timelock for such routine maintenance would add unnecessary overhead. +- **System Maintenance:** Administrative functions for claiming storage rebates are low-risk cleanup operations. They do not move user or protocol funds but instead recover storage costs from objects that are no longer in use. A timelock for such routine maintenance would add unnecessary overhead. Creating and rotating a `RebatesClaimerCap` is likewise low-risk governance of that delegated role; the frequent claims themselves run through the claimer path in section 4. --- @@ -65,3 +68,19 @@ Crucially, the multisig wallet retains ultimate control, as it can transfer owne - **User Loyalty Management** (`loyalty.move`): - `grant_user_level`: Assigns a loyalty tier to a specific user. - `revoke_user_level`: Removes a loyalty tier from a specific user. + +--- + +## 4. Rebates Claimer Operations (`RebatesClaimerCap`) + +The `RebatesClaimerCap` is a specialized capability for cleaning up empty unsettled-fee bag entries and reclaiming their storage rebates. It allows a designated address to perform this routine maintenance without requiring a multisig transaction for each claim. Like `LoyaltyAdminCap`, it is a shared object with an `owner` field; the current owner cannot reassign it. + +Unlike `LoyaltyAdminCap`, it is **not** created in `init`. The admin multisig mints it later via `create_rebates_claimer_cap`, and may mint more than one. The multisig wallet retains ultimate control: it can rotate ownership of any cap via `update_rebates_claimer_cap_owner` using the core `AdminCap`. + +These claimer paths only destroy **empty** bag entries; non-empty entries still abort. They do not move user or protocol fee balances. + +### Operations + +- **Storage Rebate Claims** (`fee_manager.move`): + - `claim_user_unsettled_fee_storage_rebate_claimer`: Claims storage rebate for an empty user unsettled-fee entry. + - `claim_protocol_unsettled_fee_storage_rebate_claimer`: Claims storage rebate for an empty protocol unsettled-fee entry. diff --git a/docs/unsettled-fees.md b/docs/unsettled-fees.md index a095605b..7af5761b 100644 --- a/docs/unsettled-fees.md +++ b/docs/unsettled-fees.md @@ -52,9 +52,13 @@ When an order is completed (e.g., fully filled) or cancelled externally, the fee The permissionless settlement functions (Path B) intentionally leave empty structs and values in the bags. This is a crucial design choice that separates fee settlement from storage rebate claims. -- The user who owns the `FeeManager` has the primary right to reclaim their initial storage deposit by calling one of the `claim_*_storage_rebate` functions. This action destroys the now-empty structs, returning the storage fee to the user. +Three actors can destroy those empty entries and reclaim the storage rebate: -- For the long-term health and economic sustainability of the protocol, a protocol admin may also perform this cleanup. At a large scale, with potentially millions of users, the gas cost of settling countless small protocol fees can exceed the value of the fees themselves. Reclaiming the storage fees from abandoned structs helps subsidize these essential maintenance operations. This ensures that the fee settlement system remains efficient and economically viable for the protocol, which benefits all users by keeping the platform running smoothly. +- **`FeeManager` owner**: The user who owns the `FeeManager` can reclaim their initial storage deposit by calling one of the `claim_*_storage_rebate` functions. This action destroys the now-empty structs, returning the storage fee to the user. +- **Protocol admin (multisig)**: For the long-term health and economic sustainability of the protocol, a protocol admin may also perform this cleanup via the `*_admin` claim paths. At a large scale, with potentially millions of users, the gas cost of settling countless small protocol fees can exceed the value of the fees themselves. Reclaiming the storage fees from abandoned structs helps subsidize these essential maintenance operations. +- **`RebatesClaimerCap` owner**: At scale, rebate cleanup is too frequent for multisig and too operationally awkward to leave only to individual users. A designated operator holding a `RebatesClaimerCap` can call the `*_claimer` paths to clean up empty entries without being either the `FeeManager` owner or the admin multisig. The multisig retains control of minting caps and rotating their owners. See [Admin Capabilities](./admin.md) for details. + +All three paths abort if the bag entry is not empty; they never move unsettled fee balances. ## Order Type Support diff --git a/examples/fee-manager/create-rebates-claimer-cap.ts b/examples/fee-manager/create-rebates-claimer-cap.ts new file mode 100644 index 00000000..35b1475e --- /dev/null +++ b/examples/fee-manager/create-rebates-claimer-cap.ts @@ -0,0 +1,19 @@ +import { Transaction } from "@mysten/sui/transactions"; +import { ADMIN_CAP_OBJECT_ID, DEEPTRADE_CORE_PACKAGE_ID, MULTISIG_CONFIG_OBJECT_ID } from "../constants"; +import { buildAndLogMultisigTransaction } from "../multisig/buildAndLogMultisigTransaction"; + +const OWNER = ""; // Address that will own the rebates claimer cap + +// Usage: yarn ts-node examples/fee-manager/create-rebates-claimer-cap.ts > create-rebates-claimer-cap.log 2>&1 +(async () => { + const tx = new Transaction(); + + tx.moveCall({ + target: `${DEEPTRADE_CORE_PACKAGE_ID}::fee_manager::create_rebates_claimer_cap`, + arguments: [tx.object(MULTISIG_CONFIG_OBJECT_ID), tx.object(ADMIN_CAP_OBJECT_ID), tx.pure.address(OWNER)], + }); + + console.warn(`Building transaction to create rebates claimer cap for owner ${OWNER}`); + + await buildAndLogMultisigTransaction(tx); +})(); diff --git a/examples/fee-manager/update-rebates-claimer-cap-owner.ts b/examples/fee-manager/update-rebates-claimer-cap-owner.ts new file mode 100644 index 00000000..b49b172c --- /dev/null +++ b/examples/fee-manager/update-rebates-claimer-cap-owner.ts @@ -0,0 +1,26 @@ +import { Transaction } from "@mysten/sui/transactions"; +import { ADMIN_CAP_OBJECT_ID, DEEPTRADE_CORE_PACKAGE_ID, MULTISIG_CONFIG_OBJECT_ID } from "../constants"; +import { buildAndLogMultisigTransaction } from "../multisig/buildAndLogMultisigTransaction"; + +// Fill after create_rebates_claimer_cap; take the shared object ID from the RebatesClaimerCapCreated event +const REBATES_CLAIMER_CAP_OBJECT_ID = ""; +const NEW_OWNER = ""; // New owner of the rebates claimer cap + +// Usage: yarn ts-node examples/fee-manager/update-rebates-claimer-cap-owner.ts > update-rebates-claimer-cap-owner.log 2>&1 +(async () => { + const tx = new Transaction(); + + tx.moveCall({ + target: `${DEEPTRADE_CORE_PACKAGE_ID}::fee_manager::update_rebates_claimer_cap_owner`, + arguments: [ + tx.object(REBATES_CLAIMER_CAP_OBJECT_ID), + tx.object(MULTISIG_CONFIG_OBJECT_ID), + tx.object(ADMIN_CAP_OBJECT_ID), + tx.pure.address(NEW_OWNER), + ], + }); + + console.warn(`Building transaction to update rebates claimer cap owner to ${NEW_OWNER}`); + + await buildAndLogMultisigTransaction(tx); +})(); diff --git a/packages/deeptrade-core/sources/fee_manager.move b/packages/deeptrade-core/sources/fee_manager.move index 248fcbfd..f26f48a0 100644 --- a/packages/deeptrade-core/sources/fee_manager.move +++ b/packages/deeptrade-core/sources/fee_manager.move @@ -30,6 +30,7 @@ const EFilledQuantityGreaterThanOrderQuantity: u64 = 7; const EUserUnsettledFeeNotEmpty: u64 = 8; const EProtocolUnsettledFeeNotEmpty: u64 = 9; const EInvalidFeeManagerShareTicket: u64 = 10; +const ESenderIsNotRebatesClaimer: u64 = 11; // === Structs === /// A shared object that manages a user's fee-related operations. Required for trading @@ -78,6 +79,13 @@ public struct FeeSettlementReceipt { /// and must be consumed by the corresponding share function public struct FeeManagerShareTicket { fee_manager_id: ID } +/// A capability to claim storage rebates for empty `FeeManager` bag fields +/// without protocol admin or `FeeManager` owner participation. +public struct RebatesClaimerCap has key { + id: UID, + owner: address, +} + // === Events === public struct UserUnsettledFeeAdded has copy, drop { key: UserUnsettledFeeKey, @@ -106,6 +114,17 @@ public struct FeeManagerCreated has copy, drop { owner: address, } +public struct RebatesClaimerCapCreated has copy, drop { + rebates_claimer_cap_id: ID, + owner: address, +} + +public struct RebatesClaimerCapOwnerUpdated has copy, drop { + rebates_claimer_cap_id: ID, + old_owner: address, + new_owner: address, +} + // === Public-Mutative Functions === /// Creates an unshared `FeeManager`, `FeeManagerOwnerCap`, and `FeeManagerShareTicket`. /// The manager and ticket must be passed to `share_fee_manager` to finalize creation and enforce @@ -151,6 +170,47 @@ public fun share_fee_manager(fee_manager: FeeManager, ticket: FeeManagerShareTic transfer::share_object(fee_manager); } +/// Create and share a `RebatesClaimerCap` assigned to `owner`. +/// A protocol admin operation. Can be called more than once. +public fun create_rebates_claimer_cap( + multisig_config: &MultisigConfig, + _admin_cap: &AdminCap, + owner: address, + ctx: &mut TxContext, +) { + multisig_config.validate_sender_is_admin_multisig(ctx); + + let rebates_claimer_cap = RebatesClaimerCap { id: object::new(ctx), owner }; + + event::emit(RebatesClaimerCapCreated { + rebates_claimer_cap_id: rebates_claimer_cap.id.to_inner(), + owner, + }); + + transfer::share_object(rebates_claimer_cap); +} + +/// Assign a new rebates claimer by updating the owner of the rebates claimer cap. +/// A protocol admin operation. +public fun update_rebates_claimer_cap_owner( + rebates_claimer_cap: &mut RebatesClaimerCap, + multisig_config: &MultisigConfig, + _admin_cap: &AdminCap, + new_owner: address, + ctx: &mut TxContext, +) { + multisig_config.validate_sender_is_admin_multisig(ctx); + + let old_owner = rebates_claimer_cap.owner; + rebates_claimer_cap.owner = new_owner; + + event::emit(RebatesClaimerCapOwnerUpdated { + rebates_claimer_cap_id: rebates_claimer_cap.id.to_inner(), + old_owner, + new_owner, + }); +} + /// Creates a `FeeSettlementReceipt` to begin a batch fee settlement process public fun start_protocol_fee_settlement(): FeeSettlementReceipt { FeeSettlementReceipt { @@ -297,6 +357,30 @@ public fun claim_user_unsettled_fee_storage_rebate_admin( + treasury: &Treasury, + fee_manager: &mut FeeManager, + pool: &Pool, + balance_manager: &BalanceManager, + rebates_claimer_cap: &RebatesClaimerCap, + order_id: u128, + ctx: &mut TxContext, +) { + validate_rebates_claimer_cap(rebates_claimer_cap, ctx); + treasury.verify_version(); + + claim_user_unsettled_fee_rebate_core( + fee_manager, + pool, + balance_manager, + order_id, + ); +} + /// Claims the storage rebate for a settled protocol fee by destroying the empty balance. /// /// Can only be called by the `FeeManager` owner after a fee has been collected via @@ -329,6 +413,22 @@ public fun claim_protocol_unsettled_fee_storage_rebate_admin( claim_protocol_unsettled_fee_rebate_core(fee_manager); } +/// Allows the `RebatesClaimerCap` owner to claim a protocol unsettled fee storage rebate. +/// +/// This is a delegated maintenance function to clean up empty fee balances that have not +/// been claimed. Aborts if the balance is not empty. +public fun claim_protocol_unsettled_fee_storage_rebate_claimer( + treasury: &Treasury, + fee_manager: &mut FeeManager, + rebates_claimer_cap: &RebatesClaimerCap, + ctx: &mut TxContext, +) { + validate_rebates_claimer_cap(rebates_claimer_cap, ctx); + treasury.verify_version(); + + claim_protocol_unsettled_fee_rebate_core(fee_manager); +} + // === Public-Package Functions === /// Stores the potential maker fee for a new limit order in a `UserUnsettledFee` object. /// @@ -551,6 +651,11 @@ fun validate_owner(fee_manager: &FeeManager, ctx: &TxContext) { assert!(ctx.sender() == fee_manager.owner, EInvalidOwner); } +/// Validate that the sender is the owner of the rebates claimer cap +fun validate_rebates_claimer_cap(rebates_claimer_cap: &RebatesClaimerCap, ctx: &TxContext) { + assert!(rebates_claimer_cap.owner == ctx.sender(), ESenderIsNotRebatesClaimer); +} + // === Test Functions === /// Check if an unsettled fee exists for a specific order #[test_only] @@ -621,3 +726,15 @@ public fun finish_protocol_fee_settlement_for_testing( finish_protocol_fee_settlement(receipt); (count, total) } + +/// Get the owner of the `RebatesClaimerCap` for testing purposes. +#[test_only] +public fun owner_for_testing(rebates_claimer_cap: &RebatesClaimerCap): address { + rebates_claimer_cap.owner +} + +/// Share a `RebatesClaimerCap` without admin authorization, for testing. +#[test_only] +public fun share_rebates_claimer_cap_for_testing(owner: address, ctx: &mut TxContext) { + transfer::share_object(RebatesClaimerCap { id: object::new(ctx), owner }); +} diff --git a/packages/deeptrade-core/tests/unsettled-fees/claim_protocol_unsettled_fee_storage_rebate.move b/packages/deeptrade-core/tests/unsettled-fees/claim_protocol_unsettled_fee_storage_rebate.move index 0a0582d9..439705d2 100644 --- a/packages/deeptrade-core/tests/unsettled-fees/claim_protocol_unsettled_fee_storage_rebate.move +++ b/packages/deeptrade-core/tests/unsettled-fees/claim_protocol_unsettled_fee_storage_rebate.move @@ -2,18 +2,23 @@ module deeptrade_core::claim_protocol_unsettled_fee_storage_rebate_tests; use deepbook::balance_manager_tests::USDC; +use deeptrade_core::create_rebates_claimer_cap_tests::create_rebates_claimer_cap_as_admin; use deeptrade_core::fee_manager::{ FeeManager, + RebatesClaimerCap, claim_protocol_unsettled_fee_storage_rebate, claim_protocol_unsettled_fee_storage_rebate_admin, + claim_protocol_unsettled_fee_storage_rebate_claimer, settle_protocol_fee_and_record, start_protocol_fee_settlement, EInvalidOwner, EProtocolUnsettledFeeNotEmpty, + ESenderIsNotRebatesClaimer, }; use deeptrade_core::multisig_config::{MultisigConfig, ESenderIsNotValidMultisig}; use deeptrade_core::settle_user_fees_tests::setup_test_environment; use deeptrade_core::treasury::Treasury; +use deeptrade_core::update_rebates_claimer_cap_owner_tests::update_rebates_claimer_cap_owner_as_admin; use multisig::multisig_test_utils::get_test_multisig_address; use std::unit_test::assert_eq; use sui::balance; @@ -25,6 +30,7 @@ use sui::test_utils::destroy; const ALICE: address = @0xAAAA; const UNRELATED_USER: address = @0xCCCC; const BOB: address = @0xBBBB; +const CLAIMER: address = @0xDDDD; #[test] /// Test that the owner can claim a storage rebate for a settled fee. @@ -201,6 +207,223 @@ fun non_multisig_admin_claim_fails() { end(scenario); } +#[test] +/// Test that a rebates claimer can claim a protocol's storage rebate. +fun claimer_claims_rebate_successfully() { + let (mut scenario, fee_manager_id) = setup_protocol_fee_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + assert_eq!(fee_manager.has_protocol_unsettled_fee(), false); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotRebatesClaimer)] +/// Test that a non-claimer cannot claim a rebate via the claimer function. +fun unauthorized_claimer_claim_fails() { + let (mut scenario, fee_manager_id) = setup_protocol_fee_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(UNRELATED_USER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test] +/// Test that the new cap owner can claim after ownership is transferred. +fun new_claimer_can_claim_after_owner_update() { + let (mut scenario, fee_manager_id) = setup_protocol_fee_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + update_rebates_claimer_cap_owner_as_admin(&mut scenario, BOB); + + scenario.next_tx(BOB); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + assert_eq!(fee_manager.has_protocol_unsettled_fee(), false); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotRebatesClaimer)] +/// Test that the previous cap owner cannot claim after ownership is transferred. +fun old_claimer_cannot_claim_after_owner_update() { + let (mut scenario, fee_manager_id) = setup_protocol_fee_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + update_rebates_claimer_cap_owner_as_admin(&mut scenario, BOB); + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotRebatesClaimer)] +/// Test that the FeeManager owner cannot claim via the claimer function. +fun fee_manager_owner_cannot_claim_via_claimer() { + let (mut scenario, fee_manager_id) = setup_protocol_fee_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(ALICE); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = EProtocolUnsettledFeeNotEmpty)] +/// Test that a claimer cannot claim a rebate for a non-empty (unsettled) fee. +fun claimer_claim_for_unsettled_fee_fails() { + let (mut scenario, _pool_id, _balance_manager_id, fee_manager_id) = setup_test_environment(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(ALICE); + { + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let fee_balance = balance::create_for_testing(1000); + fee_manager.add_to_protocol_unsettled_fees( + fee_balance, + scenario.ctx(), + ); + return_shared(fee_manager); + }; + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test] +/// Test that a claimer claiming a rebate for a coin type with no unsettled fee does nothing. +fun claimer_claim_for_non_existent_fee_is_noop() { + let (mut scenario, _pool_id, _balance_manager_id, fee_manager_id) = setup_test_environment(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + assert_eq!(fee_manager.has_protocol_unsettled_fee(), false); + + claim_protocol_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &rebates_claimer_cap, + scenario.ctx(), + ); + + assert_eq!(fee_manager.has_protocol_unsettled_fee(), false); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + /// Sets up a test scenario where a protocol fee has been settled, leaving an empty /// `Balance` object ready for a storage rebate claim. #[test_only] diff --git a/packages/deeptrade-core/tests/unsettled-fees/claim_user_unsettled_fee_storage_rebate.move b/packages/deeptrade-core/tests/unsettled-fees/claim_user_unsettled_fee_storage_rebate.move index efce1744..fe7d1ec7 100644 --- a/packages/deeptrade-core/tests/unsettled-fees/claim_user_unsettled_fee_storage_rebate.move +++ b/packages/deeptrade-core/tests/unsettled-fees/claim_user_unsettled_fee_storage_rebate.move @@ -3,16 +3,22 @@ module deeptrade_core::claim_user_unsettled_fee_storage_rebate_tests; use deepbook::balance_manager_tests::{USDC, create_acct_and_share_with_funds}; use deepbook::constants; +use deeptrade_core::create_rebates_claimer_cap_tests::create_rebates_claimer_cap_as_admin; use deeptrade_core::fee_manager::{ FeeManager, + RebatesClaimerCap, claim_user_unsettled_fee_storage_rebate, claim_user_unsettled_fee_storage_rebate_admin, + claim_user_unsettled_fee_storage_rebate_claimer, settle_filled_order_fee_and_record, start_protocol_fee_settlement, + ESenderIsNotRebatesClaimer, + EUserUnsettledFeeNotEmpty, }; use deeptrade_core::multisig_config::{MultisigConfig, ESenderIsNotValidMultisig}; use deeptrade_core::settle_user_fees_tests::setup_test_environment; use deeptrade_core::treasury::Treasury; +use deeptrade_core::update_rebates_claimer_cap_owner_tests::update_rebates_claimer_cap_owner_as_admin; use multisig::multisig_test_utils::get_test_multisig_address; use std::unit_test::assert_eq; use sui::balance; @@ -25,6 +31,7 @@ const OWNER: address = @0x1; const ALICE: address = @0xAAAA; const BOB: address = @0xBBBB; const UNRELATED_USER: address = @0xCCCC; +const CLAIMER: address = @0xDDDD; #[test] /// Test that the owner can claim a storage rebate for a settled fee. @@ -294,6 +301,323 @@ fun non_multisig_admin_claim_fails() { end(scenario); } +#[test] +/// Test that a rebates claimer can claim a user's storage rebate. +fun claimer_claims_rebate_successfully() { + let ( + mut scenario, + pool_id, + balance_manager_id, + fee_manager_id, + order_id, + ) = setup_filled_order_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + assert_eq!( + fee_manager.has_user_unsettled_fee(pool_id, balance_manager_id, order_id), + false, + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotRebatesClaimer)] +/// Test that a non-claimer cannot claim a rebate via the claimer function. +fun unauthorized_claimer_claim_fails() { + let ( + mut scenario, + pool_id, + balance_manager_id, + fee_manager_id, + order_id, + ) = setup_filled_order_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(UNRELATED_USER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotRebatesClaimer)] +/// Test that the previous cap owner cannot claim after ownership is transferred. +fun old_claimer_cannot_claim_after_owner_update() { + let ( + mut scenario, + pool_id, + balance_manager_id, + fee_manager_id, + order_id, + ) = setup_filled_order_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + update_rebates_claimer_cap_owner_as_admin(&mut scenario, BOB); + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test] +/// Test that the new cap owner can claim after ownership is transferred. +fun new_claimer_can_claim_after_owner_update() { + let ( + mut scenario, + pool_id, + balance_manager_id, + fee_manager_id, + order_id, + ) = setup_filled_order_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + update_rebates_claimer_cap_owner_as_admin(&mut scenario, BOB); + + scenario.next_tx(BOB); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + assert_eq!( + fee_manager.has_user_unsettled_fee(pool_id, balance_manager_id, order_id), + false, + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotRebatesClaimer)] +/// Test that the FeeManager owner cannot claim via the claimer function. +fun fee_manager_owner_cannot_claim_via_claimer() { + let ( + mut scenario, + pool_id, + balance_manager_id, + fee_manager_id, + order_id, + ) = setup_filled_order_for_rebate(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(ALICE); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = EUserUnsettledFeeNotEmpty)] +/// Test that a claimer cannot claim a rebate for a non-empty (unsettled) fee. +fun claimer_claim_for_unsettled_fee_fails() { + let (mut scenario, pool_id, balance_manager_id, fee_manager_id) = setup_test_environment(); + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(ALICE); + let order_id = { + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let order_info = deepbook::pool_tests::place_limit_order( + ALICE, + pool_id, + balance_manager_id, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 2 * constants::float_scaling(), + 100 * constants::float_scaling(), + true, + true, + constants::max_u64(), + &mut scenario, + ); + let fee_balance = balance::create_for_testing(1000); + fee_manager.add_to_user_unsettled_fees(fee_balance, &order_info, scenario.ctx()); + let order_id = order_info.order_id(); + return_shared(fee_manager); + order_id + }; + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test] +/// Test that a claimer claiming a rebate for an order with no unsettled fee does nothing. +fun claimer_claim_for_non_existent_fee_is_noop() { + let (mut scenario, pool_id, balance_manager_id, fee_manager_id) = setup_test_environment(); + let order_id = 12345; + + create_rebates_claimer_cap_as_admin(&mut scenario, CLAIMER); + + scenario.next_tx(CLAIMER); + { + let treasury = scenario.take_shared(); + let mut fee_manager = scenario.take_shared_by_id(fee_manager_id); + let pool = scenario.take_shared_by_id(pool_id); + let balance_manager = scenario.take_shared_by_id(balance_manager_id); + let rebates_claimer_cap = scenario.take_shared(); + + claim_user_unsettled_fee_storage_rebate_claimer( + &treasury, + &mut fee_manager, + &pool, + &balance_manager, + &rebates_claimer_cap, + order_id, + scenario.ctx(), + ); + + assert_eq!( + fee_manager.has_user_unsettled_fee(pool_id, balance_manager_id, order_id), + false, + ); + + return_shared(treasury); + return_shared(fee_manager); + return_shared(pool); + return_shared(balance_manager); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + #[test_only] public(package) fun setup_filled_order_for_rebate(): (Scenario, ID, ID, ID, u128) { let (mut scenario, pool_id, balance_manager_id, fee_manager_id) = setup_test_environment(); diff --git a/packages/deeptrade-core/tests/unsettled-fees/create_rebates_claimer_cap.move b/packages/deeptrade-core/tests/unsettled-fees/create_rebates_claimer_cap.move new file mode 100644 index 00000000..27706802 --- /dev/null +++ b/packages/deeptrade-core/tests/unsettled-fees/create_rebates_claimer_cap.move @@ -0,0 +1,127 @@ +#[test_only] +module deeptrade_core::create_rebates_claimer_cap_tests; + +use deeptrade_core::admin; +use deeptrade_core::fee_manager::{Self, RebatesClaimerCap}; +use deeptrade_core::initialize_multisig_config_tests::setup; +use deeptrade_core::multisig_config::{ + MultisigConfig, + EMultisigConfigNotInitialized, + ESenderIsNotValidMultisig, +}; +use deeptrade_core::update_multisig_config_tests::setup_with_initialized_config; +use multisig::multisig_test_utils::get_test_multisig_address; +use std::unit_test::assert_eq; +use sui::test_scenario::{Self, Scenario, end, return_shared}; +use sui::test_utils::destroy; + +// === Constants === +const OWNER: address = @0x1; +const ALICE: address = @0xAAAA; +const BOB: address = @0xBBBB; + +#[test] +fun successful_create() { + let mut scenario = setup_with_initialized_config(); + let multisig_address = get_test_multisig_address(); + + scenario.next_tx(multisig_address); + { + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::create_rebates_claimer_cap(&config, &admin_cap, ALICE, scenario.ctx()); + + destroy(admin_cap); + return_shared(config); + }; + + scenario.next_tx(ALICE); + { + let rebates_claimer_cap = scenario.take_shared(); + assert_eq!(rebates_claimer_cap.owner_for_testing(), ALICE); + return_shared(rebates_claimer_cap); + }; + + end(scenario); +} + +#[test] +fun create_more_than_once() { + let mut scenario = setup_with_initialized_config(); + + create_rebates_claimer_cap_as_admin(&mut scenario, ALICE); + scenario.next_tx(ALICE); + let alice_cap_id = test_scenario::most_recent_id_shared().extract(); + + create_rebates_claimer_cap_as_admin(&mut scenario, BOB); + scenario.next_tx(BOB); + let bob_cap_id = test_scenario::most_recent_id_shared().extract(); + + scenario.next_tx(OWNER); + { + let alice_cap = scenario.take_shared_by_id(alice_cap_id); + let bob_cap = scenario.take_shared_by_id(bob_cap_id); + + assert_eq!(alice_cap.owner_for_testing(), ALICE); + assert_eq!(bob_cap.owner_for_testing(), BOB); + + return_shared(alice_cap); + return_shared(bob_cap); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotValidMultisig)] +fun non_multisig_sender_fails() { + let mut scenario = setup_with_initialized_config(); + + scenario.next_tx(OWNER); + { + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::create_rebates_claimer_cap(&config, &admin_cap, ALICE, scenario.ctx()); + + destroy(admin_cap); + return_shared(config); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = EMultisigConfigNotInitialized)] +fun uninitialized_config_fails() { + let mut scenario = setup(); + let multisig_address = get_test_multisig_address(); + + scenario.next_tx(multisig_address); + { + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::create_rebates_claimer_cap(&config, &admin_cap, ALICE, scenario.ctx()); + + destroy(admin_cap); + return_shared(config); + }; + + end(scenario); +} + +/// Creates a `RebatesClaimerCap` as the admin multisig and assigns it to `owner`. +#[test_only] +public(package) fun create_rebates_claimer_cap_as_admin(scenario: &mut Scenario, owner: address) { + let multisig_address = get_test_multisig_address(); + scenario.next_tx(multisig_address); + { + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::create_rebates_claimer_cap(&config, &admin_cap, owner, scenario.ctx()); + + destroy(admin_cap); + return_shared(config); + }; +} diff --git a/packages/deeptrade-core/tests/unsettled-fees/update_rebates_claimer_cap_owner.move b/packages/deeptrade-core/tests/unsettled-fees/update_rebates_claimer_cap_owner.move new file mode 100644 index 00000000..13dbe673 --- /dev/null +++ b/packages/deeptrade-core/tests/unsettled-fees/update_rebates_claimer_cap_owner.move @@ -0,0 +1,199 @@ +#[test_only] +module deeptrade_core::update_rebates_claimer_cap_owner_tests; + +use deeptrade_core::admin; +use deeptrade_core::create_rebates_claimer_cap_tests::create_rebates_claimer_cap_as_admin; +use deeptrade_core::fee_manager::{Self, RebatesClaimerCap}; +use deeptrade_core::initialize_multisig_config_tests::setup; +use deeptrade_core::multisig_config::{ + MultisigConfig, + EMultisigConfigNotInitialized, + ESenderIsNotValidMultisig, +}; +use deeptrade_core::update_multisig_config_tests::setup_with_initialized_config; +use multisig::multisig_test_utils::get_test_multisig_address; +use std::unit_test::assert_eq; +use sui::test_scenario::{Scenario, end, return_shared}; +use sui::test_utils::destroy; + +// === Constants === +const OWNER: address = @0x1; +const ALICE: address = @0xAAAA; +const BOB: address = @0xBBBB; + +#[test] +fun successful_owner_update() { + let mut scenario = setup_with_initialized_config(); + let multisig_address = get_test_multisig_address(); + + create_rebates_claimer_cap_as_admin(&mut scenario, ALICE); + + scenario.next_tx(multisig_address); + { + let mut rebates_claimer_cap = scenario.take_shared(); + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::update_rebates_claimer_cap_owner( + &mut rebates_claimer_cap, + &config, + &admin_cap, + BOB, + scenario.ctx(), + ); + + assert_eq!(rebates_claimer_cap.owner_for_testing(), BOB); + + destroy(admin_cap); + return_shared(rebates_claimer_cap); + return_shared(config); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotValidMultisig)] +fun non_multisig_sender_fails() { + let mut scenario = setup_with_initialized_config(); + + create_rebates_claimer_cap_as_admin(&mut scenario, ALICE); + + scenario.next_tx(OWNER); + { + let mut rebates_claimer_cap = scenario.take_shared(); + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::update_rebates_claimer_cap_owner( + &mut rebates_claimer_cap, + &config, + &admin_cap, + BOB, + scenario.ctx(), + ); + + destroy(admin_cap); + return_shared(rebates_claimer_cap); + return_shared(config); + }; + + end(scenario); +} + +#[test] +fun update_to_same_owner() { + let mut scenario = setup_with_initialized_config(); + let multisig_address = get_test_multisig_address(); + + create_rebates_claimer_cap_as_admin(&mut scenario, ALICE); + + scenario.next_tx(multisig_address); + { + let mut rebates_claimer_cap = scenario.take_shared(); + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::update_rebates_claimer_cap_owner( + &mut rebates_claimer_cap, + &config, + &admin_cap, + ALICE, + scenario.ctx(), + ); + + assert_eq!(rebates_claimer_cap.owner_for_testing(), ALICE); + + destroy(admin_cap); + return_shared(rebates_claimer_cap); + return_shared(config); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = ESenderIsNotValidMultisig)] +fun cap_owner_cannot_update_owner() { + let mut scenario = setup_with_initialized_config(); + + create_rebates_claimer_cap_as_admin(&mut scenario, ALICE); + + scenario.next_tx(ALICE); + { + let mut rebates_claimer_cap = scenario.take_shared(); + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::update_rebates_claimer_cap_owner( + &mut rebates_claimer_cap, + &config, + &admin_cap, + BOB, + scenario.ctx(), + ); + + destroy(admin_cap); + return_shared(rebates_claimer_cap); + return_shared(config); + }; + + end(scenario); +} + +#[test, expected_failure(abort_code = EMultisigConfigNotInitialized)] +fun uninitialized_config_fails() { + let mut scenario = setup(); + let multisig_address = get_test_multisig_address(); + + scenario.next_tx(multisig_address); + { + fee_manager::share_rebates_claimer_cap_for_testing(ALICE, scenario.ctx()); + }; + + scenario.next_tx(multisig_address); + { + let mut rebates_claimer_cap = scenario.take_shared(); + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::update_rebates_claimer_cap_owner( + &mut rebates_claimer_cap, + &config, + &admin_cap, + BOB, + scenario.ctx(), + ); + + destroy(admin_cap); + return_shared(rebates_claimer_cap); + return_shared(config); + }; + + end(scenario); +} + +/// Updates the owner of the shared `RebatesClaimerCap` as the admin multisig. +#[test_only] +public(package) fun update_rebates_claimer_cap_owner_as_admin( + scenario: &mut Scenario, + new_owner: address, +) { + let multisig_address = get_test_multisig_address(); + scenario.next_tx(multisig_address); + { + let mut rebates_claimer_cap = scenario.take_shared(); + let config = scenario.take_shared(); + let admin_cap = admin::get_admin_cap_for_testing(scenario.ctx()); + + fee_manager::update_rebates_claimer_cap_owner( + &mut rebates_claimer_cap, + &config, + &admin_cap, + new_owner, + scenario.ctx(), + ); + + destroy(admin_cap); + return_shared(rebates_claimer_cap); + return_shared(config); + }; +}