contract address: 0x573584cc4698e82fd85f2b54e64ad4cd901c42b768f7628ec167bf2d24aa2aa7
package only_dep_upgrades: F5kVa3YDSHoBvJvYJFH9y5dANCJScEdyZoxZLLy6qd15
Shared object IDs (
FeeHouse,AccessList,AdminCap,GlobalRecord) and theRecordobject type live in each skill'sreference/constants.mdso SDK callers can import them directly. Seeskills/cdpm-user-sdk/reference/constants.mdfor the canonical list.
CDPM (Cetus DLMM Position Manager) is a Sui Move smart contract that enables proxy liquidity management on Cetus DLMM (Discrete Liquidity Market Maker). The contract allows users to manage their liquidity positions directly or delegate management to the protocol or custom AI agents, with the protocol collecting fees on managed operations.
- Deposit & Create Positions: Deposit liquidity and create new positions
- Add/Remove Liquidity: Manage existing positions
- Collect Fees/Rewards: Collect accumulated fees and rewards
- Balance Management: Deposit to and withdraw from position balance
- Agent Management: Authorize/deauthorize agent addresses
- Automated Liquidity Management: Protocol can add/remove liquidity on behalf of users
- Fee Collection with Protocol Cut: Protocol collects fees, taking a percentage (default: 20%)
- Balance Transfers: Move fees from fee bag to balance for withdrawal
- Access Control: Admin-managed allow list for protocol addresses
- Custom Agent Authorization: Users can authorize custom AI agents
- Agent Operations: Agents can perform liquidity management operations
- Permission Control: Agents have limited permissions (cannot withdraw funds)
Building an LP agent? RandyPen/lp-agent is an open-source reference implementation of an autonomous liquidity-provider agent built against this contract's agent-delegation surface (TypeScript/Bun agent + Python forecasting pipeline). Third-party developers can fork it as a starting point — bring your own strategies, pool profiles, and prediction models, then deploy independently.
Central structure representing a user's liquidity position:
public struct PositionManager has key {
id: UID,
owner: address, // Position owner
agents: VecSet<address>, // Authorized agents
position: Position, // 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)
}Protocol fee management:
public struct FeeHouse has key {
id: UID,
fee_rate: u64, // Protocol fee rate (0-5000 of 10000, capped at MAX_FEE_RATE = 50%)
fee: Bag, // Accumulated protocol fees
}Protocol address allow list:
public struct AccessList has key {
id: UID,
allow: VecSet<address>, // Allowed protocol addresses
}Administrator capability token:
public struct AdminCap has key {
id: UID, // Single admin capability
}Four-tier permission model:
- Owner: Creator of the PositionManager
- Agent: Addresses authorized by owner (limited operations)
- Protocol: Addresses in AccessList (managed by admin)
- Admin: Holder of AdminCap (global management)
- Sui CLI installed
- Move development environment
- Access to Sui network (testnet/mainnet)
-
Build the contract:
sui move build
-
Publish the package:
sui client publish --gas-budget 100000000
-
Initialize the contract:
- The
initfunction automatically creates:AdminCaptransferred to deployerFeeHousewith default 20% fee rateAccessList(empty)GlobalRecordfor position tracking
- The
Two entrypoints exist depending on whether the caller already owns a Cetus
Position object.
// (a) Deposit raw coins and open a fresh Cetus position inside a new PM.
public fun user_deposit_liquidity<CoinTypeA, CoinTypeB>(
record: &mut Record,
pool: &mut Pool<CoinTypeA, CoinTypeB>,
coin_a: &mut Coin<CoinTypeA>,
coin_b: &mut Coin<CoinTypeB>,
bins: vector<u32>,
amounts_a: vector<u64>,
amounts_b: vector<u64>,
config: &GlobalConfig,
versioned: &Versioned,
clk: &Clock,
ctx: &mut TxContext,
);
// (b) Wrap an existing Cetus `Position` the caller already holds into a fresh PM.
public fun user_deposit_position(
record: &mut Record,
position: Position,
ctx: &mut TxContext,
);// Authorize an AI agent (no Clock parameter)
public fun user_insert_agent(
pm: &mut PositionManager,
agent: address,
ctx: &TxContext,
);// Collect accumulated fees
let (coin_a, coin_b) = user_collect_fee<CoinTypeA, CoinTypeB>(
pm: &mut PositionManager,
pool: &mut Pool<CoinTypeA, CoinTypeB>,
config: &GlobalConfig,
versioned: &Versioned,
clk: &Clock,
ctx: &mut TxContext,
);The protocol charges a fee on managed operations (default: 20%):
fee_amount = amount * fee_rate / FEE_DENOMINATOR
Where FEE_DENOMINATOR = 10000 (100%).
The fee rate is capped at 50% (
MAX_FEE_RATE = 5000) viaadmin_set_fee. This bound limits admin authority and cannot be bypassed without redeploying.
- User Operations: No protocol fee
- Protocol Operations: Protocol takes fee (default 20%)
- Agent Operations: No protocol fee (fees go to user's fee bag)
The following behaviors are intentional design choices, not defects. They are documented here so that integrators and auditors do not mistake them for bugs.
The fee_rate in FeeHouse is a service fee for protocol-executed
automation, not a tax on all yield generated by the position.
protocol_collect_fee/protocol_collect_rewardcalltake_feeand deduct the configured rate.user_collect_fee/user_collect_rewardandagent_collect_fee/agent_collect_rewarddo not charge a fee; 100% of the collected yield is credited to the user's fee bag.
Rationale: users who self-manage (or delegate to their own agent) are not consuming protocol automation services, so no fee is charged.
Each PositionManager has an agents: VecSet<address> field that acts as a
mutually exclusive switch between protocol-managed and agent-managed modes:
agents state |
Who may call protocol_* |
Who may call agent_* |
|---|---|---|
| empty (default) | whitelisted protocol addresses | nobody |
| non-empty | nobody | only addresses in agents |
When the owner authorizes at least one agent, the protocol is automatically excluded from managing the position — the user has opted into a custom agent and the protocol must step aside. Conversely, when no agent is configured, the position is considered opted into protocol management.
admin_set_fee updates FeeHouse.fee_rate without a timelock or per-position
checkpoint. The new rate applies to the next protocol_collect_* call,
including any Cetus fees/rewards that accrued before the rate change.
Rationale: rebalancing and fee collection happen frequently, so the amount of yield that could possibly accrue between two rate changes is small. The economic impact of retroactive application is therefore bounded and acceptable in exchange for operational simplicity.
When the requested amount is greater than or equal to the current balance,
the internal withdrawal helpers return the entire available balance rather
than aborting:
if (amount >= balance_amount) {
bag::remove<String, Balance<T>>(&mut pm.balance, coin_type).into_coin(ctx)
} else {
balance::split<T>(bag::borrow_mut(&mut pm.balance, coin_type), amount).into_coin(ctx)
}This is intentional. It lets callers pass a conservative upper bound (for
example u64::MAX for "withdraw everything") and guarantees that dust from
rounding never strands a bag entry. Downstream functions that require an exact
amount (e.g. pool::add_liquidity) will still abort, so no funds can be lost.
The CDPM package is published with the only_dep_upgrades upgrade policy
(see the package only_dep_upgrades: line near the top of this README).
cdpm.move bytecode is locked — no admin can change protocol logic — but
dependency-version upgrades are permitted so the package can track new
versions of Cetus DLMM, Scallop, and Kai SAV without a fresh deploy. The
only admin levers remain admin_set_fee (capped at 50%) and admin_transfer.
Rationale: users get guaranteed semantics on the cdpm side, and the contract can keep working when one of its dependency packages publishes a non-breaking upgrade. If a dependency ships a breaking type-identity change, a fresh cdpm deploy is still required.
user_close_pm validates four drain-preconditions up front so the abort
diagnostic is always a cdpm error code, not an upstream framework abort:
EPositionHasRewards(1007) if any reward type in the underlying CetusPositionInfo.rewards_ownedvector is non-zero. cdpm reads the vector throughpool::position_manager→position::borrow_position_info→position::info_rewardsbefore callingpool::close_position.ELendingNotEmpty(1004) ifpm.lendingstill holds any Scallop / Kai vault.EBalanceNotEmpty(1008) ifpm.balanceis non-empty.EFeeNotEmpty(1009) ifpm.feeis non-empty.
How to close safely: build a PTB that first
- calls
user_collect_reward<CoinTypeA, CoinTypeB, R>once for every reward type on the pool (set ofRs is pool-specific and not known statically inside cdpm), - redeems every Scallop / Kai vault via
scallop_redeem/kai_redeem, - drains
pm.balanceviauser_remove_liquidity_from_balance, - drains
pm.feeviauser_withdraw_fee,
then calls user_close_pm in the same transaction. The user-sdk skill
documents a helper; see skills/cdpm-user-sdk/reference/workflows.md.
Idle balances in pm.balance (assets outside the active bin range) can be
loaned to Scallop to earn interest. The PM stores Scallop's sCoin
protocol::reserve::MarketCoin<T> in a lending: Bag keyed by underlying
type:
public struct ScallopVault<phantom T> has store {
scoin: Balance<MarketCoin<T>>,
principal: u64,
}Type-pinned sCoin. MarketCoin<T> has only the drop ability and no
public constructor, so the only way to obtain a non-zero
Balance<MarketCoin<T>> is through Scallop's own mint flow. cdpm
populates ScallopVault.scoin exclusively via mint::mint<T> called from
inside scallop_supply — no PTB-supplied share token ever reaches storage.
Single-shot API (post-refactor):
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,
clock: &Clock,
ctx: &mut TxContext,
);Each operation is a single MoveCall. cdpm itself invokes mint::mint /
redeem::redeem; the returned share token / underlying flows directly into
PM storage. Both upstream functions call accrue_interest_for_market as
their first step, so the exchange rate used is always fresh by construction
— no EStaleScallopState freshness guard is needed.
All three managed-tier callers (owner / agent / protocol & no-agents) are
accepted via assert_caller_authorized. Yield interest is treated as
protocol income — every scallop_redeem deducts fee_rate (capped at
MAX_FEE_RATE = 50%) from the interest portion
(max(0, redeemed_amount - principal_portion)); principal returns to
pm.balance intact. Departure from D-01 is intentional: lending is a
recurring protocol-managed yield, not self-management.
PTB templates:
Supply:
cdpm::scallop_supply<T>(access, pm, version, market, amount, clock)
Redeem:
cdpm::scallop_redeem<T>(access, pm, fee_house, version, market, scoin_amount, clock)
Operational risks:
- Liquidity risk — Scallop is over-collateralized lending. At ≈100%
utilization,
redeem::redeemaborts. Off-chain scheduler must reserve a non-Scallop buffer for emergency rebalancing. - Version risk — Scallop bumping
Versionaborts cdpm'smint/redeemcall. Resolution path: a cdpm DEP_ONLY upgrade to the new Scallopprotocoldep (cdpm source unchanged). - Whitelist risk — Scallop checks
tx_context::sender(ctx); cdpm is the immediate sender from Scallop's perspective. If Scallop disablesallow_all, supply/redeem fails until cdpm's address is whitelisted. - Asset deactivation — Scallop's
mintaborts on disabled coin types. - Supply cap — Scallop enforces a per-asset
supply_limit; large mints may abort. - Gas vs yield — every redeem call accrues interest first; tiny positions can be net-negative.
- Scallop package re-deploy — if Scallop ever publishes a NEW package
with a new
MarketCoin<T>type identity, cdpm's bytecode references the OLDMarketCoin<T>and cannot accept the new sCoin type. Resolution: a fresh cdpm deploy is required to track the newMarketCoin<T>.
Trust boundary. The security ceiling of cdpm's Scallop integration is
the integrity of the Scallop team and their custody of the
ScallopProtocol package upgrade-cap + AdminCap. A backdoored
protocol::reserve upgrade can drain 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.
No escape hatch for lending. cdpm exposes no owner-only function that
hands raw Coin<MarketCoin<T>> back to the user. The only exit is the
normal scallop_redeem → pm.balance → user_remove_liquidity_from_balance
flow, which preserves the principal-counter accounting that protocol-fee
math depends on. User mitigations are purely off-chain (don't supply if you
don't trust Scallop; bound per-PM exposure via the scheduler).
user_close_pm asserts lending is empty (ELendingNotEmpty = 1004).
Callers must redeem every vault before closing the PM.
A second yield destination alongside Scallop. Targets Kai's
Single-Asset Vault (SAV) layer — kai_sav::vault — with the
production-only kai_leverage_supply_pool strategy module. cdpm calls
the full chain (vault::deposit for supply; vault::withdraw →
klsp::withdraw → vault::redeem_withdraw_ticket for redeem) inline.
public struct KaiVault<phantom T, phantom YT> has store {
yt_balance: Balance<YT>,
principal: u64,
}Stored in the same lending: Bag as ScallopVault. Bag key is YT's
type_name (not T's), so a single underlying T can simultaneously have a
Scallop vault (key = T) and a Kai vault (key = YT) without collision.
Type-pin defense: lp_treasury: TreasuryCap<YT> lives inside Kai's
Vault<T, YT> and only kai_sav::vault itself can mint YT balances.
Combined with kai_sav::vault::new being public(package)
(vault.move:235), no external code can publish a fake Vault<USDC, EvilYT>
and forge Coin<EvilYT> of attacker-chosen value — cdpm does not need an
admin-curated vault registry.
Public surface:
public fun kai_supply<T, YT>(
access: &AccessList,
pm: &mut PositionManager,
vault: &mut kai_sav::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_sav::vault::Vault<T, YT>,
strategy: &mut klsp::Strategy<T, ST>,
supply_pool: &mut SupplyPool<T, ST>,
yt_amount: u64,
clock: &Clock,
ctx: &mut TxContext,
);PTB templates:
Supply:
cdpm::kai_supply<T, YT>(access, pm, vault, amount, clock)
Redeem:
cdpm::kai_redeem<T, ST, YT>(
access, pm, fee_house, vault, strategy, supply_pool, yt_amount, clock)
The full Kai withdrawal chain 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.
Yield fee semantics match Scallop (MAX_FEE_RATE = 50%, charged on
max(0, redeemed - principal_portion)).
Operational caveats:
- Bootstrap (
yt_supply == 0): Kai'svault::depositreturns YT at the bootstrap 1:1 rate. cdpm records the actual returnedyt_balanceas principal accumulation; noEZeroExpectedguard exists. - Strategy losses (
StrategyLossEventinvault::redeem_withdraw_ticket): when the strategy returns less than requested, cdpm consumes the smaller amount. The fee formula yieldsinterest = 0for any redeem where the loss eats into principal — the user is not double-taxed. The user receivesredeemed - 0 = redeemedintopm.balance. - 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,
kai_redeemon that vault aborts at PTB construction or strategy::withdraw execution. Resolution: a new cdpm release that adds a parallelkai_redeem_via_<newmodule>entry.
Trust boundary. Identical shape to Scallop's: integrity of Kunalabs
and their custody of the kai_sav / kai_leverage upgrade-caps. No
admin-curated YT whitelist; the type-system suffices.
No escape hatch for Kai lending either — same as Scallop. Only exit is
kai_redeem → pm.balance → user_remove_liquidity_from_balance.
- Clear boundaries between owner, agent, protocol, and admin roles
- Agent-limited operations (cannot withdraw funds)
- Protocol access controlled by admin-managed allow list
- Fee rate bounds checking (0-50%,
MAX_FEE_RATE = 5000) - Permission validation on all operations
- Safe mathematical operations (u128 for fee calculations)
Comprehensive event emission for all operations:
- Position creation/closing
- Liquidity addition/removal
- Fee/reward collection
- Agent management
- Admin operations
All key operations emit events for off-chain monitoring. Recent improvements include:
FeeCollected: Addedcoin_type_aandcoin_type_bfieldsProtocolFeeCollected: Addedcoin_type_aandcoin_type_bfieldsRewardCollected: Renamedreward_typetocoin_typefor consistencyProtocolRewardCollected: Renamedreward_typetocoin_typefor consistency
See API Documentation for complete event details.
cdpm/
├── sources/
│ └── cdpm.move # Main contract
├── tests/
│ └── cdpm_tests.move # Test suite (to be implemented)
├── Move.toml # Package configuration
└── Move.lock # Dependency lock
- CetusDlmm: Cetus DLMM interface (patched mainnet-v0.9.0 — see
Move.toml) - IntegerMate: Integer utilities (mainnet-v1.3.0)
- MoveSTL: Move standard template library (mainnet-v1.3.0)
- ScallopProtocol / X: Scallop lending protocol (local sibling checkout — see
Move.toml) - kai_sav: Kai Single-Asset Vault (patched local sibling — see
Move.toml)
# Build contract
sui move build
# Run tests (when implemented)
sui move testComprehensive documentation is available:
- DESIGN.md - Technical design and architecture
- API.md - Complete API reference
- RandyPen/lp-agent - Reference implementation of an autonomous LP agent built on this contract, for third-party agent developers to fork
This project is licensed under the terms of the original repository.
- Review the design notes (D-01 through D-11) in DESIGN.md before changing protocol behavior
- Ensure comprehensive testing for all changes
- Maintain backward compatibility where possible
For issues, questions, or security concerns:
- Review documentation
- Check existing issues
- Contact maintainers through appropriate channels
Last Updated: 2026-02-28 Contract Version: As deployed Documentation Version: 1.0