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.
Overview
Section titled “Overview”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: LPLockand 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.
Voting power from an LP lock
Section titled “Voting power from an LP lock”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 V3 Concentrated Liquidity
Section titled “ICPSwap V3 Concentrated Liquidity”ICPSwap implements the Uniswap V3 concentrated liquidity model on Internet Computer. Key concepts:
sqrtPriceX96
Section titled “sqrtPriceX96”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 token0Token Ordering
Section titled “Token Ordering”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 token0Full-Range Positions
Section titled “Full-Range Positions”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.
Token Ratio Calculation (Slider Logic)
Section titled “Token Ratio Calculation (Slider Logic)”For full-range positions, the required token ratio is determined by the current pool price.
Formula
Section titled “Formula”Given sqrtPriceX96 from pool metadata and user input for one token:
// Convert sqrtPriceX96 to decimal-adjusted pricefunction 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 amountfunction 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); }}Slider Behavior
Section titled “Slider Behavior”When the user adjusts one token amount via the slider:
- Fetch
sqrtPriceX96from pool metadata (pool.metadata()) - Determine which token is token0/token1 (lexicographic canister ID comparison)
- Calculate the counter-token amount using
calculateCounterAmount() - Display both amounts, updating in real-time as the slider moves
- Show a warning if the user’s wallet balance is insufficient for either token
Precision Note
Section titled “Precision Note”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.
Deposit-to-Lock Flow
Section titled “Deposit-to-Lock Flow”Step-by-step (Backend)
Section titled “Step-by-step (Backend)”The LP lock creation follows this sequence:
1. Validation
Section titled “1. Validation”- 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
2. Fetch Pool Metadata
Section titled “2. Fetch Pool Metadata”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.
3. ICRC-2 Approve
Section titled “3. ICRC-2 Approve”For each token, approve the pool canister as spender:
Amount approved = deposit_amount + (ledger_fee * 2)Expiry = now + 5 minutesThe fee buffer (2x) covers the approval fee and potential edge cases.
4. Deposit Tokens to Pool
Section titled “4. Deposit Tokens to Pool”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).
5. Mint Position
Section titled “5. Mint Position”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
amountDesiredof each token to maintain the current price ratio - May use less than desired (unused balance remains in pool)
- Returns: position ID (Nat)
6. Refund Unused Balance
Section titled “6. Refund Unused Balance”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.
7. Create Lock Record
Section titled “7. Create Lock Record”Store a LockRecord (or GovernanceLockRecord) with:
lock_type: LPLocktoken_amount: governance token amount (used for VP calculation)token_canister_id: governance token canister IDunlock_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
8. Failure Classification & Rollback
Section titled “8. Failure Classification & Rollback”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 toLP_SAGA_FUND_EVENTS(queryable viaget_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 sameposition_idexists in two different pools.get_pending_orphan_lps()lists them. admin_complete_orphaned_lp_lockis the honest recovery: it verifies no Active/FrozenLockRecordalready references that position (exhaustive, fail-closed scan), then re-attemptsstore_lockto bind the minted position to a proper lock record — after which the normal reclaim flow can run. Successful recoveries are deduplicated inRECOVERED_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.
Post-Lock Operations
Section titled “Post-Lock Operations”Auto-Completion at Expiry
Section titled “Auto-Completion at Expiry”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.
Fee Monitoring
Section titled “Fee Monitoring”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.
Reclaim (After Expiry)
Section titled “Reclaim (After Expiry)”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:
- Reserve (CAS): the record is CAS-flipped to
Completedbefore thetransferPositioncall, with the expected prior status being eitherCompletedorActive. 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 commitsCompleted(a final state), any later freeze is rejected bylock_storage, so the transfer can never be front-run out of a frozen custody. - Transfer:
transferPosition(locker, user, positionId)releases custody. If the transfer succeeds but the follow-up audit write fails, aReclaimFixupEntryis 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
Section titled “Token Symbols”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.
ONS vs SONS LP Locks
Section titled “ONS vs SONS LP Locks”| Aspect | ONS (OhShii Locker) | SONS (sons_governance) |
|---|---|---|
| Canister | ohshii_locker_backend | Per-campaign sons_governance |
| Storage | lock_storage (inter-canister) | In-memory GovernanceLockRecord |
| Methods | create_lp_lock, reclaim_lp_lock | Same method names |
| VP calculation | lock_storage.calculate_quadratic_voting_power() | sons_governance internal VP function |
| Timer | Auto-completes expired LP locks (VP stops) | Same — auto-completes expired LP locks |
| Pool source | ICPSwap Factory (getPools()), filtered to ICP pairs | Same — ICPSwap Factory |
| Platform fee | None | None |