Skip to content
Merged
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
23 changes: 21 additions & 2 deletions docs/admin.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -19,14 +20,16 @@ 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.

### Design Motivation

- **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.

---

Expand Down Expand Up @@ -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.
8 changes: 6 additions & 2 deletions docs/unsettled-fees.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions examples/fee-manager/create-rebates-claimer-cap.ts
Original file line number Diff line number Diff line change
@@ -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);
})();
26 changes: 26 additions & 0 deletions examples/fee-manager/update-rebates-claimer-cap-owner.ts
Original file line number Diff line number Diff line change
@@ -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);
})();
117 changes: 117 additions & 0 deletions packages/deeptrade-core/sources/fee_manager.move
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,13 @@ public struct FeeSettlementReceipt<phantom FeeCoinType> {
/// 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<phantom CoinType> has copy, drop {
key: UserUnsettledFeeKey,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<FeeCoinType>(): FeeSettlementReceipt<FeeCoinType> {
FeeSettlementReceipt {
Expand Down Expand Up @@ -297,6 +357,30 @@ public fun claim_user_unsettled_fee_storage_rebate_admin<BaseToken, QuoteToken,
);
}

/// Allows the `RebatesClaimerCap` owner to claim a user's unsettled fee storage rebate.
///
/// This is a delegated maintenance function to clean up empty fee objects that users have
/// not claimed. Aborts if the fee object is not empty.
public fun claim_user_unsettled_fee_storage_rebate_claimer<BaseToken, QuoteToken, FeeCoinType>(
treasury: &Treasury,
fee_manager: &mut FeeManager,
pool: &Pool<BaseToken, QuoteToken>,
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<BaseToken, QuoteToken, FeeCoinType>(
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
Expand Down Expand Up @@ -329,6 +413,22 @@ public fun claim_protocol_unsettled_fee_storage_rebate_admin<FeeCoinType>(
claim_protocol_unsettled_fee_rebate_core<FeeCoinType>(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<FeeCoinType>(
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<FeeCoinType>(fee_manager);
}

// === Public-Package Functions ===
/// Stores the potential maker fee for a new limit order in a `UserUnsettledFee` object.
///
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -621,3 +726,15 @@ public fun finish_protocol_fee_settlement_for_testing<FeeCoinType>(
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 });
}
Loading
Loading