CDPM (Cetus DLMM Position Manager) is a proxy contract that sits between users and the Cetus DLMM protocol, providing additional functionality for liquidity management delegation, fee collection.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │────│ CDPM │────│ Cetus DLMM │
│ (Owner) │ │ Proxy │ │ Protocol │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ AI Agent │────│ Protocol │ │ Dependencies│
│ (Delegated)│ │ (Managed) │ │ (IntegerMate,│
└─────────────┘ └─────────────┘ └─────────────┘
│ MoveSTL, etc.)
│
┌─────────────┐
│ Admin │
│ (Global Mgmt)│
└─────────────┘
- Proxy Pattern: CDPM acts as a proxy, managing user positions in Cetus DLMM
- Permission Separation: Clear boundaries between different actor types
- Fee Extraction: Protocol earns fees on managed operations
- Event-Driven: Comprehensive event emission for off-chain monitoring
The central structure representing a user's liquidity management context.
public struct PositionManager has key {
id: UID, // Unique identifier
owner: address, // Position owner (creator)
agents: VecSet<address>, // Authorized agent addresses
position: Option<Position>, // Underlying Cetus DLMM position
balance: Bag, // Token balances (String -> Balance<T>)
fee: Bag, // Accumulated fees (String -> Balance<T>)
lending: Bag, // Scallop sCoin holdings (String -> ScallopVault<T>)
}
public struct ScallopVault<phantom T> has store {
scoin: Balance<MarketCoin<T>>, // Scallop sCoin, type-pinned to MarketCoin<T>
principal: u64, // Underlying principal supplied (net of redemptions)
}Key Characteristics:
- Owner: Immutable after creation, controls all owner functions
- Agents: Dynamic set, can be added/removed by owner
- Position: Optional, exists when user has active liquidity
- Balance: Generic token balances for deposit/withdrawal
- Fee: Accumulated fees from agent/protocol operations
- Lending: Scallop sCoin
MarketCoin<T>(or Kai SAV YT) wrapped per underlying type along with cumulative principal; populated byscallop_supply(Kai:kai_supply), drained exclusively byscallop_redeem(Kai:kai_redeem). The wrapper type is pinned by Move's type system, blocking fake-wrapper attacks. There is no escape hatch for lending: the only way to exit a lending position is to redeem through the upstream protocol's normal path, which lands the underlying inpm.balance; the user then withdraws viauser_remove_liquidity_from_balance.
Global protocol fee management structure.
public struct FeeHouse has key {
id: UID,
fee_rate: u64, // Protocol fee rate (0-10000), capped at MAX_FEE_RATE = 5000 (50%)
fee: Bag, // Accumulated protocol fees (String -> Balance<T>)
}Fee Rate Calculation:
effective_fee_rate = fee_rate / FEE_DENOMINATOR
fee_amount = amount * effective_fee_rate
Where FEE_DENOMINATOR = 10000 (constant).
Protocol address allow list for managed operations.
public struct AccessList has key {
id: UID,
allow: VecSet<address>, // Addresses allowed to perform protocol operations
}Management:
- Admin-controlled via
admin_insert_access_listandadmin_remove_access_list - Used to gate protocol functions
Administrator capability token (singleton).
public struct AdminCap has key {
id: UID, // Single instance, transferable
}Privileges:
- Set protocol fee rate
- Collect accumulated protocol fees
- Manage AccessList
- Transfer admin capability
Two-level record keeping for position tracking.
public struct GlobalRecord has key {
id: UID,
record: Table<address, ID>, // User address -> Record ID
}public struct Record has key {
id: UID,
record: Table<ID, bool>, // PositionManager ID -> exists flag
}Purpose: Track all PositionManagers for each user, enabling efficient lookup and management.
Identifier: pm.owner == ctx.sender()
Capabilities:
- Full control over PositionManager
- Add/remove liquidity
- Collect fees/rewards
- Manage agents
- Close position
- Deposit/withdraw from balance
- Withdraw from fee bag
Functions: All user_* functions
Identifier: vec_set::contains<address>(&pm.agents, &ctx.sender())
Capabilities:
- Add/remove liquidity (using balance)
- Collect fees/rewards (to fee bag)
- Move fees from
feebag back intobalanceviaagent_transfer_fee_to_balance(intentional auto-compound surface — see §Permission Boundaries below) - Cannot transfer funds out of the PositionManager
- Cannot modify PositionManager configuration (owner / agents list / position open or close)
Functions: All agent_* functions
Identifier: vec_set::contains<address>(&access.allow, &ctx.sender())
Additional Check: vec_set::is_empty<address>(&pm.agents) (no active agents)
Capabilities:
- Add/remove liquidity (using balance, with protocol fee)
- Collect fees/rewards (with protocol fee deduction)
- Transfer fees from fee bag to balance
Functions: All protocol_* functions
Identifier: Holds AdminCap
Capabilities:
- Set protocol fee rate (0-50%; capped at
MAX_FEE_RATE = 5000/FEE_DENOMINATOR = 10000) - Collect accumulated protocol fees
- Manage AccessList (add/remove protocol addresses)
- Transfer admin capability
- Force-return raw stored assets to
pm.owner(emergency escape hatch — see §Upgrade Considerations)
Functions: All admin_* functions
| Operation | Owner | Agent | Protocol | Admin |
|---|---|---|---|---|
| Create Position | ✓ | ✗ | ✗ | ✗ |
| Add/Remove Liquidity | ✓ | ✓ | ✓* | ✗ |
| Collect Fees/Rewards | ✓ | ✓† | ✓* | ✗ |
| Withdraw Funds | ✓ | ✗ | ✗ | ✓§ |
| Manage Agents | ✓ | ✗ | ✗ | ✗ |
| Set Fee Rate | ✗ | ✗ | ✗ | ✓ |
| Collect Protocol Fees | ✗ | ✗ | ✗ | ✓ |
*With protocol fee deduction
†To fee bag only
‡Without fee collection
§Emergency escape hatch only — returned to pm.owner, never the admin
These are intentional design choices. Front-ends and SDKs MUST surface them:
-
Agents may auto-compound fees.
agent_transfer_fee_to_balance<T>lets an authorized agent migrate accumulatedfeebag entries intobalance, where they can be redeployed as liquidity byagent_add_liquidity. Owners who want fees to settle into a withdraw-only bucket should NOT keep an agent authorized while fees accrue. Funds never leave the PositionManager — only the bucket changes — but the agent does effectively control reinvestment of realized fees. -
user_close_pmrequires zero pending pool rewards (and empty bags). Caller responsibility: invokeuser_collect_reward<CoinTypeA, CoinTypeB, R>for every reward type the underlying pool emits, in the same PTB or earlier transactions, before callinguser_close_pm. SDKs should query the pool's reward types and emit the matching collect calls automatically. cdpm reads the CetusPositionInfo.rewards_ownedvector viapool::position_manager→position::borrow_position_info→position::info_rewardsand aborts withEPositionHasRewards(1007) if any entry is non-zero — so the abort surfaces as a cdpm error code rather than Cetus'sEPositionRewardNotZero. The same up-front check applies topm.lending(ELendingNotEmpty, 1004),pm.balance(EBalanceNotEmpty, 1008), andpm.fee(EFeeNotEmpty, 1009) — owners must drain all three bags before close. -
withdraw_from_balance/withdraw_from_feeshort-circuitamount == 0tocoin::zero<T>(ctx)(no Bag access). Single-sidedprotocol_/agent_add_liquiditytherefore works without seeding the unused side. Callers requestingamount > 0of a type the PM has never held still abort withENoSuchBalance(1010); SDKs should query the bag before requesting a positive withdraw.
fun take_fee<T>(
balance_in: &mut Balance<T>,
fee_house: &mut FeeHouse,
) {
let amount_in = balance::value<T>(balance_in);
let fee_amount = (((amount_in as u128) * (fee_house.fee_rate as u128) / FEE_DENOMINATOR) as u64);
let fee = balance::split<T>(balance_in, fee_amount);
// Add fee to protocol fee bag
}Safety Features:
- Uses
u128for intermediate calculations to prevent overflow - Division by constant
FEE_DENOMINATOR(no zero division risk) - Final cast to
u64after division
User collects 100 USDC fees
→ User receives: 100 USDC
→ Protocol receives: 0 USDC
Protocol collects 100 USDC fees (20% fee rate)
→ User receives: 80 USDC (to fee bag)
→ Protocol receives: 20 USDC (to protocol fee bag)
Agent collects 100 USDC fees
→ User receives: 100 USDC (to fee bag)
→ Protocol receives: 0 USDC
- Default Fee Rate: 2000/10000 = 20%
- Maximum Fee Rate:
MAX_FEE_RATE = 5000/10000 = 50% (enforced byadmin_set_fee) - Minimum Fee Rate: 0/10000 = 0%
PositionManagerCreated: New PositionManager createdPositionManagerClosed: PositionManager closed
LiquidityAdded: Liquidity added to positionLiquidityRemoved: Liquidity removed from position
FeeCollected: Fees collected by userRewardCollected: Rewards collected by userProtocolFeeCollected: Fees collected by protocol (with fee)ProtocolRewardCollected: Rewards collected by protocol (with fee)UserFeeWithdrawn: User withdraws fees from fee bagAdminFeeCollected: Admin collects protocol fees
AgentAdded: Agent authorized by ownerAgentRemoved: Agent authorization revoked
BalanceDeposited: User deposits to balanceBalanceWithdrawn: User withdraws from balanceFeeTransferredToBalance: Fees transferred from fee bag to balance
FeeRateUpdated: Protocol fee rate changedAccessGranted: Address added to AccessListAccessRevoked: Address removed from AccessListAdminTransferred: AdminCap transferred
1. Creation
user_deposit_liquidity() → PositionManagerCreated (creates a fresh Cetus position from coins)
user_deposit_position() → PositionManagerCreated (wraps an existing Position into a new PM)
State: position = some, balance = empty, fee = empty, lending = empty, agents = empty
2. Normal Operations
- Add/remove liquidity (position or balance side)
- Collect fees/rewards
- Manage agents
- Deposit/withdraw funds
- Supply / redeem idle balance to Scallop (scallop_supply / scallop_redeem)
- Supply / redeem idle balance to Kai SAV (kai_supply / kai_redeem)
3. Closure
user_close_pm() → PositionManagerClosed
Preconditions (cdpm-named asserts up front):
- lending bag empty (ELendingNotEmpty, 1004)
- balance bag empty (EBalanceNotEmpty, 1008)
- fee bag empty (EFeeNotEmpty, 1009)
- Cetus PositionInfo.rewards_owned all zero (EPositionHasRewards, 1007)
State: resources destroyed, position closed
User/Agent/Protocol collects fees:
1. Call collect function
2. Cetus DLMM returns fee balances
3. If protocol: take_fee() extracts protocol cut
4. Remaining fees added to user's fee bag
5. Event emitted with amounts and coin types
-
CetusDlmm: Patched mainnet-v0.9.0 (local path; one missing
use sui::vec_map;import added inside a#[test_only]helper atpartner.move:193to unblocksui move test. Production bytecode is identical to upstreammainnet-v0.9.0. SeeMove.tomlfor the patched path.)pool::module for liquidity operationsposition::Positionstructure +position::borrow_position_info/position::info_rewardsfor the in-contract reward-residual check used byuser_close_pmversioned::Versionedfor upgrade compatibility
-
IntegerMate: Mainnet-v1.3.0
- Integer utilities
-
MoveSTL: Mainnet-v1.3.0
- Standard template library
sui::vec_set: Address set managementsui::bag: Generic container for balances/feessui::balance: Token balance managementsui::coin: Coin operationssui::table: Key-value storagesui::clock: Timestamp accesssui::event: Event emission
const ENotOwner: u64 = 1001; // caller is not pm.owner
const ENotAllow: u64 = 1002; // caller not in agents / access list (or invariant broken)
const EInvalidFeeRate: u64 = 1003; // admin_set_fee given rate > MAX_FEE_RATE (50%)
const ELendingNotEmpty: u64 = 1004; // user_close_pm called with non-empty lending Bag
const ENoSuchVault: u64 = 1005; // pull_from_*_lending called for an absent vault entry
const ENoSuchBalance: u64 = 1006; // withdraw_from_balance / withdraw_from_fee for an absent type
const EPositionHasRewards: u64 = 1007; // user_close_pm called with unclaimed Cetus pool rewards
const EBalanceNotEmpty: u64 = 1008; // user_close_pm called with non-empty balance Bag
const EFeeNotEmpty: u64 = 1009; // user_close_pm called with non-empty fee Baguser_close_pm validates all four drain-preconditions up front (lending /
balance / fee bags empty, and zero pool-reward residuals) so the abort
diagnostic is always a cdpm error code, not the framework's generic
EBagNotEmpty or Cetus's EPositionRewardNotZero. Callers must:
- Collect every reward type on the underlying Cetus pool via
user_collect_reward<CoinTypeA, CoinTypeB, R>for eachRthe pool emits. - Redeem every lending position (Scallop / Kai) via the respective
*_redeemsopm.lendingis empty. - Withdraw any residual
pm.balanceviauser_remove_liquidity_from_balance. - Withdraw any residual
pm.feeviauser_withdraw_fee.
Then user_close_pm succeeds and the underlying Cetus position is closed
inside the same call.
The current 1001–1009 range mixes permission, state, and parameter
faults. A future redeploy could renumber as:
- 1000-1999: Permission errors
- 2000-2999: Parameter validation errors
- 3000-3999: State-related errors
- 4000-4999: External dependency errors
Not pursued in the current DEP_ONLY package — any renumbering would require a fresh deploy.
- Fee Rate Bound:
0 <= fee_rate <= MAX_FEE_RATE(5000 / 10000 = 50%); enforced byadmin_set_fee - Balance Non-Negative: All token balances are non-negative
- Permission Hierarchy: Strict separation between permission tiers
- Agent Restriction: Agents cannot withdraw user funds
- Protocol Check: Protocol operations require no active agents
The current deployment uses the irreversible UpgradePolicy::DEP_ONLY (192)
and the publisher retains the restricted UpgradeCap (see publish.md). This
means:
- Bytecode locked — the published
cdpm.moveimplementation cannot be changed. - Dependency upgrades allowed — a follow-up
sui client upgradewith the same source but a newercetusdlmm/protocol/kai_sav/kai_leverageversion is accepted. Use case: Scallop bumps itsVersion, or Cetus DLMM ships a non-breaking SDK update — neither requires touching cdpm source. - Position migration: still supported via
user_get_position/user_get_and_return_positionfor the rare case where an upstream protocol publishes a breaking new package and dep-only upgrade is insufficient (a fresh cdpm deploy is then needed). - Admin emergency return (asset evacuation) — the
admin_force_return_*/admin_force_close_pmescape hatch lets theAdminCapholder force raw stored assets (balance, fee, rawPosition, raw sCoin / YT) out of any PM and back topm.ownerwhen a dependency ships a breaking upgrade. It can only "un-stick" funds, never steal them (recipient is hard-coded topm.owner). There is no pause function.
- Batch Operations: Single transactions for multiple operations
- Event Efficiency: Events contain only necessary data
- Storage Minimization: Clean up unused resources
- User Isolation: Each PositionManager is independent
- Parallel Processing: Multiple users can operate concurrently
- Resource Limits: Sui network limits apply
- Unit Tests: Individual function testing
- Integration Tests: Cetus DLMM interaction testing
- Permission Tests: Boundary testing for all permission levels
- Edge Case Tests: Fee boundaries, empty states, error conditions
- Sui testnet/mainnet simulation
- Mock Cetus DLMM dependencies
- Comprehensive event validation
The PM proxies idle balances into the Scallop lending market with direct
calls to Scallop's public API. cdpm itself invokes protocol::mint::mint
and protocol::redeem::redeem, threading Scallop's Version shared object
through as a parameter. The asset returned from each call flows directly
into PM storage; no PTB-supplied Coin<*> ever enters cdpm.
public fun scallop_supply<T>(
access: &AccessList,
pm: &mut PositionManager,
version: &ScallopVersion,
market: &mut Market,
amount: u64,
clock: &Clock,
ctx: &mut TxContext,
);
public fun scallop_redeem<T>(
access: &AccessList,
pm: &mut PositionManager,
fee_house: &mut FeeHouse,
version: &ScallopVersion,
market: &mut Market,
scoin_amount: u64, // u64::MAX redeems the entire vault
clock: &Clock,
ctx: &mut TxContext,
);Each function takes the asset amount to operate on, withdraws it from /
deposits it to PM internally, and emits ScallopSupplied /
ScallopRedeemed.
use protocol::market::Market;
use protocol::reserve::MarketCoin;
use protocol::version::Version as ScallopVersion;
use protocol::mint;
use protocol::redeem;MarketCoin<T> is imported because ScallopVault.scoin: Balance<MarketCoin<T>> uses it as a type pin.
scallop_supply and scallop_redeem each call assert_caller_authorized
at entry. The three managed-tier callers (owner / agent / (protocol & no
agents)) are accepted. The owner-only exit path remains
scallop_redeem → pm.balance → user_remove_liquidity_from_balance; the
admin emergency path is admin_force_return_scallop<T> (redeems to underlying
Coin<T> to pm.owner, same interest-only fee as scallop_redeem — see
§Upgrade Considerations).
protocol::mint::mint and protocol::redeem::redeem both call
accrue_interest_for_market as their first step (Scallop's
accrue_interest.move). Under direct-call cdpm, the exchange rate
Scallop uses for cdpm's mint/redeem is the live post-accrual rate
by definition — no PTB-level race window exists, no freshness guard
needed.
Every scallop_redeem call deducts fee_rate on the interest portion.
Principal is amortized linearly when partial-redeeming:
principal_portion = ScallopVault.principal × redeem_scoin_amount / total_scoin
interest = max(0, redeemed_underlying - principal_portion)
fee_amount = interest × FeeHouse.fee_rate / FEE_DENOMINATOR
When redeemed_underlying ≤ principal_portion (socialized loss), fee_amount = 0. The user receives redeemed_underlying - fee_amount into pm.balance.
fee_amount = floor(interest × fee_rate / 10_000) rounds down. A redeem
where interest × fee_rate < 10_000 produces fee_amount = 0. At
fee_rate = 2000 (default 20%) this means slices with interest < 5
underlying units. Sui's reference gas per move-call exceeds the evaded
value by 4–5 orders of magnitude, so the break-even is unfavorable to
the attacker. Recorded as accepted residual risk; no structural defense
warranted.
Supply
PTB[0] cdpm::scallop_supply<T>(access, pm, version, market, amount, clock)
Redeem
PTB[0] cdpm::scallop_redeem<T>(access, pm, fee_house, version, market, scoin_amount, clock)
Each lending operation is now a single MoveCall. No external mint::mint /
redeem::redeem step appears in the caller's PTB — cdpm handles it inline.
user_close_pmaborts withELendingNotEmpty(1004) ifpm.lendingstill holds any vault; withEBalanceNotEmpty(1008) ifpm.balanceis non-empty; withEFeeNotEmpty(1009) ifpm.feeis non-empty; and withEPositionHasRewards(1007) if the underlying Cetus position has any unclaimed reward type. Callers must redeem all lending, drain balance + fee bags, and collect all pool rewards first.- Re-entry of underlying must go through
Coin<T>→pm.balanceviauser_add_liquidity_to_balance. Nouser_inject_market_coinexists. - The sCoin type is fixed to
MarketCoin<T>for any given underlying.ScallopVault<phantom T> { scoin: Balance<MarketCoin<T>> }. Migration to a different Scallop sCoin type identity (Scallop publishes a fresh package with newMarketCoin<T>definition) requires either a dep-only upgrade to the new Scallop or a cdpm redeploy.
The security ceiling of cdpm's Scallop integration is the integrity of
the Scallop team and their custody of the ScallopProtocol package
upgrade-cap + app::AdminCap. A backdoored protocol::reserve upgrade
can drain the underlying Balance<T> out of every existing Reserve
shared object. cdpm inherits exactly the same Scallop-trust assumption
every other Scallop consumer takes — no more, no less.
The cdpm DEP_ONLY policy adds a new trust assumption: the holder of cdpm's
UpgradeCap can upgrade cdpm's dependency set. The cap should be transferred
to a multisig (see publish.md) so dep upgrades require collective action.
User mitigations:
- Don't trust Scallop? Don't supply. Choose an agent / protocol bot
whose off-chain scheduler does NOT call
scallop_supply. Scallop integration is opt-in per-strategy. - Bound per-PM Scallop exposure via the off-chain scheduler.
The owner-only exit for lending remains the normal scallop_redeem →
pm.balance → user_remove_liquidity_from_balance flow, which preserves the
principal-counter accounting that protocol-fee math depends on. The admin
emergency path is admin_force_return_scallop<T> — it redeems the vault to
the underlying Coin<T> via redeem::redeem (charging the same interest-only
protocol fee as scallop_redeem) and returns it to pm.owner, for the
"upgrade incompatible" scenario. The Cetus Position
likewise has an admin emergency exit: admin_force_return_position returns
the raw Position object to pm.owner (see §Upgrade Considerations).
public struct ScallopSupplied { pm_id, coin_type, deposit_amount,
market_coin_minted };
public struct ScallopRedeemed { pm_id, coin_type, market_coin_redeemed,
redeemed_amount, principal_portion, interest,
fee_amount };The PM proxies idle balances into Kai's Single-Asset Vault (SAV), a
multi-strategy yield optimizer maintained by Kunalabs. cdpm calls Kai
directly with the same direct-integration shape used for Scallop. The
production Kai SAV stack uses kai_leverage_supply_pool as the single
strategy module on every vault (Vault<SUI,YSUI>, Vault<USDC,YUSDC>,
etc.), and that module's withdraw<T, ST, YT> function is generic — so
cdpm covers the full set of supported vaults with one generic redeem
function.
- Storage:
lending: Bag(shared with Scallop) keyed by YT'stype_name(Bag entry value isKaiVault<phantom T, phantom YT>). Same T can hold a Scallop vault (key=T) and a Kai vault (key=YT) simultaneously. - API:
kai_supply<T, YT>+kai_redeem<T, ST, YT>. The entirevault::withdraw → klsp::withdraw → vault::redeem_withdraw_ticketchain runs insidekai_redeem. - Fee model: identical to Scallop.
MAX_FEE_RATE = 5000cap shared.
public fun kai_supply<T, YT>(
access: &AccessList,
pm: &mut PositionManager,
vault: &mut kai_vault::Vault<T, YT>,
amount: u64,
clock: &Clock,
ctx: &mut TxContext,
);
public fun kai_redeem<T, ST, YT>(
access: &AccessList,
pm: &mut PositionManager,
fee_house: &mut FeeHouse,
vault: &mut kai_vault::Vault<T, YT>,
strategy: &mut klsp::Strategy<T, ST>,
supply_pool: &mut SupplyPool<T, ST>,
yt_amount: u64,
clock: &Clock,
ctx: &mut TxContext,
);use kai_sav::vault as kai_vault;
use kai_sav::kai_leverage_supply_pool as klsp;
use kai_leverage::supply_pool::SupplyPool;cdpm hardcodes kai_leverage_supply_pool::withdraw as the strategy
withdrawal call. This matches the production deployment shape (verified
against on-chain PTBs as of 2026-06): every Kai SAV under management uses
Strategy<T, ST> from kai_leverage_supply_pool, pulling from a
SupplyPool<T, ST>. Earlier per-vault strategy modules
(scallop_sui, scallop_whusdce, scallop_*_proper) are not supported
through cdpm's Kai path. Users who want direct Scallop exposure use
scallop_supply / scallop_redeem instead — that's the entire point of
having both integrations.
Coin<YT> cannot be forged externally:
lp_treasury: TreasuryCap<YT>is held inside Kai'sVault<T, YT>— onlykai_sav::vaultmints/burns YT balances.kai_sav::vault::new<T, YT>ispublic(package)(vault.move:235) — no external code can publish aVault<T, EvilYT>shared object.
Strategy<T, ST> / SupplyPool<T, ST> likewise can only be created by
their owning packages. cdpm does not need an admin-curated registry; the
type system suffices.
Supply (1 step):
PTB[0] cdpm::kai_supply<T, YT>(access, pm, vault, amount, clock)
Redeem (1 step):
PTB[0] cdpm::kai_redeem<T, ST, YT>(
access, pm, fee_house, vault, strategy, supply_pool,
yt_amount, clock)
The full Kai withdrawal chain — vault::withdraw → klsp::withdraw →
vault::redeem_withdraw_ticket — runs inside kai_redeem atomically.
vault.withdraw_ticket_issued flips to true at the first step and
back to false at the third, all within one Move function; no
intermediate state ever escapes the call.
- Bootstrap (
yt_supply == 0): Kai'svault::depositreturns YT at the bootstrap rate whentotal_available_balance == 0. cdpm'skai_supplyfaithfully records the actual returnedyt_balance.value()as principal accumulation; noEZeroExpectedguard exists anymore. - Strategy losses (
StrategyLossEventinvault::redeem_withdraw_ticket): when strategyoutis less than the requestedto_withdraw, cdpm'skai_redeemconsumes the smaller amount. The fee formulainterest = max(0, redeemed - principal_portion)yieldsinterest = 0for any redeem where the strategy loss eats into principal, so the user is not double-taxed on a principal shortfall. The user receivesredeemed - fee = redeemed - 0 = redeemedintopm.balance— i.e., the loss flows through to the user but no fee is charged. Behavioral note: pre-refactor cdpm reverted on strategy loss; post-refactor it absorbs. This is a deliberate trade-off — the redeem still completes (releasingpm.lendingcapacity) instead of stranding the position. - Admin pause / TVL cap / rate limiter: caller PTB aborts at the
live
vault::*/klsp::*call. cdpm unaffected;pm.lendingintact, awaiting unpause. - New Kai strategy module: if Kai future-deploys a vault using a
different strategy module (not
kai_leverage_supply_pool),kai_redeemon that vault aborts (Strategy<T, ST>type mismatch caught at PTB construction orklsp::withdrawexecution). Resolution: a new cdpm release that adds a parallelkai_redeem_via_<newmodule>entry. The existing positions in old-strategy vaults continue to redeem normally.
Identical to Scallop's: integrity of Kunalabs and their custody of the
kai_sav / kai_leverage upgrade-caps. cdpm intentionally does not
maintain a YT whitelist — external fake-Vault is structurally impossible,
and a Kunalabs-issued malicious upgrade would just backdoor the
whitelisted vault. The mitigation surface remains agent selection.
public struct KaiSupplied { pm_id, coin_type, yt_type, deposit_amount,
yt_minted };
public struct KaiRedeemed { pm_id, coin_type, yt_type, yt_burned,
redeemed_amount, principal_portion, interest,
fee_amount };cdpm and Kai both depend on Scallop's protocol package. The patched Kai
source (kai-contracts/kai/sav/core-prover-patched/Move.toml) points its
protocol dep at ../../../../sui-lending-protocol/contracts/protocol —
the same path cdpm uses. sui move build resolves a single protocol
package; the on-chain deployment has a single Scallop Version shared
object that both cdpm and Kai reference.
spool remains at its _vendor/Scallop/spool-v2 location because the
standalone Scallop tree does not include a spool sub-package. Kai's
strategy modules (scallop_sui, scallop_whusdce) module-level use spool::* so the dep must resolve for build, but cdpm's runtime call
path (which only uses kai_leverage_supply_pool) never touches spool
code.
- Enhanced Error System: Categorized error codes
- Input Validation: Additional parameter validation
- Testing Suite: Comprehensive test coverage
- Monitoring Tools: Off-chain monitoring and analytics
- Multi-Sig Support: Enhanced admin security
- Position Migration: Tools for migrating to new contract versions
- Advanced Agent Controls: Granular agent permissions
- Fee Tiering: Different fee rates for different operations
- Cross-Pool Management: Multi-pool position management
Last Updated: 2026-02-28
Design Document Version: 1.0
Contract Version: As analyzed in sources/cdpm.move