Skip to content

ICPSwap LP Lock

This document explains the mechanics of LP (Liquidity Position) locking in OhShii, including how ICPSwap V3 liquidity provision works, the token ratio calculation, and the full deposit-to-lock flow.


An LP Lock allows a user to pair a governance token with ICP, provide full-range liquidity on ICPSwap V3, and lock the resulting LP position for a defined period. The locked LP position counts toward voting power (based on the governance token amount and lock duration), and the user reclaims the full position after the lock expires.

Key properties:

  • LP locks are stored as regular lock records with lock_type: LPLock and optional LP-specific fields.
  • Voting power is calculated from token_amount (the governance token portion) using the same quadratic formula as OneTime locks.
  • No platform fee — 100% of the user’s tokens go into the LP position.
  • The locked position continues earning trading fees on ICPSwap while locked.
  • Auto-completion: when the lock period expires, the timer automatically marks the lock as Completed (VP stops). The user then reclaims the position manually to regain ownership on ICPSwap.
  • Pool discovery uses the ICPSwap Factory (getPools()), filtered to pools pairing the governance token with ICP.

An LP lock grants voting power the same way any other lock does — there is no special-casing that zeroes it out. The voting power is computed from the lock’s token_amount, which for an LP lock is set to the governance-token leg of the liquidity position (net_governance_amount, i.e. token0_amount or token1_amount depending on which side is the governance token), with token_canister_id set to the governance token. The paired token (ICP/other side) contributes nothing to voting power.

The same quadratic formula and bounds apply as for a OneTime lock:

VP = min( sqrt(governance_token_amount) * lock_months, 36,000 )

where sqrt is taken on the decimals-normalized (human-readable) amount, the lock must be Active and last at least the minimum lock duration (45 days by default, a DAO-votable parameter), and a single account’s total VP is capped at 36,000. ONS LP locks are read from lock_storage; SONS LP locks are stored and scored inside that campaign’s own sons_governance canister — but both use this identical calculation.


ICPSwap implements the Uniswap V3 concentrated liquidity model on Internet Computer. Key concepts:

The pool stores its current price as sqrtPriceX96: a fixed-point representation of the square root of the price, scaled by 2^96.

sqrtPriceX96 = floor(sqrt(P) * 2^96)
where P = price of token1 in terms of token0

ICPSwap sorts tokens lexicographically by canister ID text representation. The token with the “lower” canister ID is token0; the other is token1. This determines the meaning of sqrtPriceX96:

price = (sqrtPriceX96 / 2^96)^2
= token1_per_token0 (in raw units)

To get the human-readable price (adjusted for decimals):

const sqrtPrice = Number(sqrtPriceX96) / (2 ** 96);
const rawPrice = sqrtPrice * sqrtPrice;
const adjustedPrice = rawPrice * (10 ** token0Decimals) / (10 ** token1Decimals);
// adjustedPrice = how many token1 you get for 1 token0

LP locks use full-range positions, the V3 equivalent of Uniswap V2 liquidity. The theoretical tick bounds are ±887272, but ICPSwap requires every tick to be a multiple of the pool’s tickSpacing (which depends on the fee tier: 500 → 10, 3000 → 60, 10000 → 200). The code therefore snaps the full-range bounds down to the nearest spacing multiple — e.g. for the common 3000 (0.3%) tier the position is minted at tickLower = -887220, tickUpper = +887220. A tick that is not a multiple of the pool’s spacing is rejected by ICPSwap with a generic "illegal args" error. Full-range positions:

  • Are never out of range — always earn fees regardless of price movement.
  • Require both tokens in proportion to the current pool price.
  • Need no active management or rebalancing.
  • Have lower capital efficiency than concentrated positions, but this is acceptable for governance-locked LP.

For full-range positions, the required token ratio is determined by the current pool price.

Given sqrtPriceX96 from pool metadata and user input for one token:

// Convert sqrtPriceX96 to decimal-adjusted price
function priceFromSqrtPriceX96(sqrtPriceX96, token0Decimals, token1Decimals) {
const sqrtPrice = Number(sqrtPriceX96) / (2 ** 96);
const rawPrice = sqrtPrice * sqrtPrice;
return rawPrice * (10 ** token0Decimals) / (10 ** token1Decimals);
}
// Calculate the required counter-token amount
function calculateCounterAmount(inputAmount, isInputToken0, sqrtPriceX96, t0Dec, t1Dec) {
const price = priceFromSqrtPriceX96(sqrtPriceX96, t0Dec, t1Dec);
if (isInputToken0) {
// User provides token0 amount → calculate required token1
return Math.round(inputAmount * price);
} else {
// User provides token1 amount → calculate required token0
return Math.round(inputAmount / price);
}
}

When the user adjusts one token amount via the slider:

  1. Fetch sqrtPriceX96 from pool metadata (pool.metadata())
  2. Determine which token is token0/token1 (lexicographic canister ID comparison)
  3. Calculate the counter-token amount using calculateCounterAmount()
  4. Display both amounts, updating in real-time as the slider moves
  5. Show a warning if the user’s wallet balance is insufficient for either token

The Number type has 53-bit precision. For extremely large token amounts (>2^53 smallest units), consider using BigInt arithmetic. In practice, most governance token amounts fit within safe Number range.


The LP lock creation follows this sequence:

ICRC-2 approve

depositFrom

mint

store_lock

User wallet

Token Ledgers

ICPSwap Pool (internal balance)

LP Position (owned by locker/governance canister)

Locker/Gov

Lock Storage

  • Caller is not anonymous
  • Both token amounts > 0
  • Unlock time is in the future (and within 2-year maximum for locker)
  • Governance token is one of the two tokens
  • Pool exists and metadata confirms token pair

Query pool.metadata() to get:

  • token0, token1 (canonical ordering)
  • sqrtPriceX96 (current price)
  • fee (fee tier, e.g. 3000 = 0.3%)

Verify that the provided token canister IDs match the pool’s token0/token1.

For each token, approve the pool canister as spender:

Amount approved = deposit_amount + (ledger_fee * 2)
Expiry = now + 5 minutes

The fee buffer (2x) covers the approval fee and potential edge cases.

Call pool.depositFrom(token, amount, fee) for each token. This uses the ICRC-2 approval to transfer tokens from the user (via the locker/governance canister) into the pool’s internal balance tracking.

The locker uses the approve + depositFrom path for both legs, including ICP — ICP supports ICRC-2, so there is no ICRC-1 subaccount hop on the locker’s LP path. (The transfer-to-operator-subaccount-then-deposit variant belongs to the launcher’s pool_manager LGE finalize flow, not to LP-lock creation.)

The fee argument must equal the pool’s cached token fee (pool.getCachedTokenFee()), not the token ledger’s live icrc1_fee. ICPSwap validates the fee on every depositFrom / withdraw / mint against its internally-cached value and rejects a mismatch with "Wrong fee cache" — a fund-safe but flow-breaking error. The saga reads getCachedTokenFee() and passes it verbatim (fail-closed if the query fails).

MintArgs {
token0: token0_canister_id.to_text(),
token1: token1_canister_id.to_text(),
fee: pool_fee_tier, // from metadata (e.g. 3000)
// Full range, snapped DOWN to the pool's tickSpacing (3000 -> 60 => ±887220).
tickLower: Int::from(-887220), // floor(-887272 / spacing) * spacing
tickUpper: Int::from(887220), // floor(+887272 / spacing) * spacing
amount0Desired: amount0.to_string(),
amount1Desired: amount1.to_string(),
}

ICPSwap’s mint algorithm:

  • Calculates liquidity: L = min(amount0 * sqrt(P), amount1 / sqrt(P))
  • Uses up to amountDesired of each token to maintain the current price ratio
  • May use less than desired (unused balance remains in pool)
  • Returns: position ID (Nat)

After mint, query pool.getUserUnusedBalance(canister_principal):

if balance0 > fee0 * 2 {
pool.withdraw(token0, balance0, fee0) // refunds to caller
}
if balance1 > fee1 * 2 {
pool.withdraw(token1, balance1, fee1) // refunds to caller
}

This handles minor precision differences between desired and actually consumed amounts.

Store a LockRecord (or GovernanceLockRecord) with:

  • lock_type: LPLock
  • token_amount: governance token amount (used for VP calculation)
  • token_canister_id: governance token canister ID
  • unlock_timestamps: single unlock time
  • LP-specific fields: lp_pool_canister_id, lp_position_id, lp_token0_canister_id, lp_token1_canister_id, lp_token0_amount, lp_token1_amount

Every money leg of the saga (both icrc2_transfer_from pulls, both depositFrom legs, the mint, and each rollback withdraw/refund) is classified as DEFINITE or AMBIGUOUS:

  • DEFINITE — the call was rejected before it applied (e.g. the ledger returned InsufficientFunds/BadFee, the pool returned a validation error). Nothing moved, so the saga rolls back the earlier legs and returns an error safely.
  • AMBIGUOUS — the call may or may not have applied (a bounded-wait SYS_UNKNOWN: the response was lost). A blind rollback here is unsafe (it would double-move funds if the leg actually landed). Instead the outcome is journaled to LP_SAGA_FUND_EVENTS (queryable via get_lp_saga_fund_events) for operator reconciliation rather than rolled back.

To make a lost-response mint recoverable, a write-ahead intent (LpMintIntentRecord) is persisted before the mint await. If the mint applied but its response was lost, the intent lets recovery find the minted-but-unrecorded position instead of leaving a text-log-only strand.

On a clean (DEFINITE) failure after the deposits, the saga queries getUserUnusedBalance() and withdraws the deposited-but-unminted tokens back to the user.


Orphan Recovery (minted position, no lock record)

Section titled “Orphan Recovery (minted position, no lock record)”

The sharp failure case is: the mint succeeded (the position exists, owned by the locker/governance canister) but store_lock then failed — a minted orphan with no LockRecord. This is handled with a durable journal + admin recovery:

  • The orphan is recorded in the pending-orphan journal, keyed by the composite (pool, position_id) (ORPHAN_PENDING_LP_V2) — the composite key prevents a collision when the same position_id exists in two different pools. get_pending_orphan_lps() lists them.
  • admin_complete_orphaned_lp_lock is the honest recovery: it verifies no Active/Frozen LockRecord already references that position (exhaustive, fail-closed scan), then re-attempts store_lock to bind the minted position to a proper lock record — after which the normal reclaim flow can run. Successful recoveries are deduplicated in RECOVERED_ORPHAN_POSITIONS_V2 (also composite-keyed).
  • admin_dismiss_orphan_pending(pool, position_id) clears a journal entry that has been resolved by other means.

Because the minted liquidity sits inside the position (not in the pool’s unused balance), a plain withdraw cannot extract it — re-registering the lock via admin_complete_orphaned_lp_lock (or, equivalently, an admin store_lock) is the intended path, after which the user reclaims normally.


When the lock period expires, the canister timer automatically marks the lock as Completed:

  • VP stops immediately — the lock no longer contributes voting power once marked Completed.
  • The LP position remains owned by the locker/governance canister until the user reclaims it.
  • The user does not need to take any action for VP to stop; it is automatic.

While locked, the LP position earns trading fees. Users can query pending fees:

pool.refreshIncome(positionId) → { tokensOwed0, tokensOwed1 }

This is a query call (no auth needed) executed directly from the frontend to ICPSwap.

Note: Trading fees accrue in the position while locked. The user cannot collect fees until they reclaim the position and regain ownership on ICPSwap.

After the lock expires, the user reclaims the full LP position:

pool.transferPosition(locker_canister, user_principal, positionId)

This transfers ownership of the ICPSwap position from the locker/governance canister back to the user. reclaim_lp_lock is eligible when the lock is Completed (the timer auto-completed it) or Active with its unlock time already elapsed — the same lp_lock_unlock_elapsed predicate the timer uses — so a user does not have to wait for the next timer tick after expiry.

Reclaim uses a reserve-then-transfer protocol to make the irreversible position transfer safe:

  1. Reserve (CAS): the record is CAS-flipped to Completed before the transferPosition call, with the expected prior status being either Completed or Active. If a freeze/cancel committed first, the CAS mismatches and reclaim aborts without moving the position (fail-closed — nothing moved, no journal needed). Once the CAS commits Completed (a final state), any later freeze is rejected by lock_storage, so the transfer can never be front-run out of a frozen custody.
  2. Transfer: transferPosition(locker, user, positionId) releases custody. If the transfer succeeds but the follow-up audit write fails, a ReclaimFixupEntry is journaled (operable via the admin reclaim-fixup endpoints) and the user still has the position — a second reclaim is a no-op because the locker no longer owns it.

Governance proposals can still mark a lock Completed early to enable an early reclaim.

Once the user owns the position on ICPSwap, they can:

  • Collect accrued fees via pool.claim({ positionId }) + pool.withdraw()
  • Decrease or remove liquidity via pool.decreaseLiquidity() + pool.claim() + pool.withdraw()

Token symbols (e.g. “ONS”, “ICP”) are not stored on-chain in lock records. They are resolved at display time from ledger metadata via icrc1_metadata() or from the frontend’s tokenDetails cache. This avoids redundant on-chain storage and ensures symbols always reflect current ledger state.


AspectONS (OhShii Locker)SONS (sons_governance)
Canisterohshii_locker_backendPer-campaign sons_governance
Storagelock_storage (inter-canister)In-memory GovernanceLockRecord
Methodscreate_lp_lock, reclaim_lp_lockSame method names
VP calculationlock_storage.calculate_quadratic_voting_power()sons_governance internal VP function
TimerAuto-completes expired LP locks (VP stops)Same — auto-completes expired LP locks
Pool sourceICPSwap Factory (getPools()), filtered to ICP pairsSame — ICPSwap Factory
Platform feeNoneNone