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.
  • On the OhShii Locker, the pool check, the ratio check, the ten-part add and what the lock records are described in Deposit-to-Lock Flow.

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 the lock’s governance-token amount, 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:

weight(lock) = governance_token_amount * lock_months^2
VP = min( sqrt( sum of weight over the account's eligible locks ), 36,000 )

Weights are summed before the square root, which is applied once per account — so for a single lock this is exactly sqrt(amount) * lock_months, and splitting one amount across several locks yields exactly the same VP as one lock. Any per-lock figure shown for an LP lock is that lock’s share of the account total.

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 OhShii Locker’s 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
  • The pool must be the one the ICPSwap Factory (getPool) names for the two tokens and the fee tier the pool reports. A pool the factory does not name is refused with LP_POOL_NOT_AUTHENTIC; if the factory cannot be read the lock is refused with LP_POOL_UNVERIFIED. Both refusals happen before any token moves. The same check runs again before a stranded refund is paid (see below).

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.

At the price just read, and before any token is pulled, the locker computes what a full-range position would take of each deposit (net of two ledger fees per token). It refuses the lock, with nothing pulled, when:

  • either token’s surplus over what the position would take is larger than the price tolerance in force (the caller’s price_tolerance_bps, or 500 bps by default) as a share of that deposit: LP_LOCK_DEPOSIT_RATIO_OFF (the two amounts are not in the pool’s ratio);
  • the governance token the position would take is below that token’s minimum lock amount: LP_LOCK_BELOW_MIN_AMOUNT;
  • no liquidity can be minted at that price for this deposit: LP_LOCK_PRICE_OUT_OF_RANGE.

The Locker page runs the same ratio check before it sends the request. The price can still move before the parts (each part re-reads it); this check only avoids pulling what cannot be locked, and the lock record never depends on it.

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 every leg on a pool that declares the token as ICRC-2, including ICP. For a leg whose pool declares the token as ICRC-1, the locker transfers the amount to its deposit subaccount on the pool and calls deposit; a leg whose deposit did not land, or whose transfer to that subaccount could not be confirmed, is kept as a parked record (get_parked_lp_legs) that its owner returns with resume_parked_lp_leg (an unconfirmed transfer is first confirmed against the ledger by the authorized caller). A pool that declares an unknown standard is refused before any deposit.

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

The locker mints ONE full-range position (ticks snapped to the pool’s tick spacing, e.g. ±887220 for the 3000 tier):

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 computes the liquidity from the pool price at the moment the mint message runs, takes the two amounts in that price’s ratio, and sends the part of the “desired” amounts it did not use back to the caller’s main account on the token ledger (its own refund).

The liquidity is added in ten parts.

Why ten parts. The price the lock is sized on and the mint that uses it are two different messages: the price is read in one, the liquidity is added in a later one. Anyone who moves the pool price in between makes the deposit go in at the wrong ratio, and when the price returns that party keeps the difference. For a single full-range add the share of the deposit’s value exposed this way is 0.5 · (1 − 1/√r)² for a price moved by a factor r: about 12.5 % for a 4× move, approaching half of the deposit for an extreme move. ICPSwap’s mint takes no price limit, so no check made before it can close that gap.

Adding the liquidity in LP_LOCK_MINT_CHUNKS = 10 parts, re-reading the price before each part and checking after each part the liquidity it actually produced, bounds that exposure: the locker stops at the first part that shows a moved price, so at most one part is exposed. The design bound is about 5 % of the deposit’s value (0.5 / 10). This bound holds when the caller sends the expected price, which the Locker page always does; a direct caller that sends no expected price gets the stop test of each part against the price re-read for it, and no bound on that price. Every part also carries the small loss the chosen tolerance allows inside its band (0.5 · (1 − 1/√(1 + t))²: about 0.03 % at the default 5 %, 1.7 % at the 50 % maximum).

How each part is sized, for both tokens.

  1. Before each part the locker re-reads the pool price (metadata). If the caller sent the price the page showed (expected_sqrt_price_x96), the new price must lie within the tolerance (price_tolerance_bps, default 500 = 5 %, from 1 to 5000). The band is on the price, not on its square root, and symmetric in ratio: the price may rise to 1 + t times the expected price or fall to 1 / (1 + t) of it (+5 % / −4.76 % at the default).
  2. The part may use 1 / (parts left) of what is still unminted of each token (remaining / (10 − k)); the last part may use everything left.
  3. From that share the locker computes the exact amounts the pool will use at the price it just read (the ratio of that price), and passes those as the desired amounts. When nothing moved, the pool’s own refund for the part is zero. The token in excess of that ratio is not passed; it carries over to the later parts.
  4. Part 1 is a mint, which creates the position; parts 2 to 10 are increaseLiquidity on the same position, so the lock keeps one position id.
  5. After each part the locker reads the position (getUserPosition) and compares the liquidity the part added with the liquidity expected at the re-read price. A part that minted less than the band allows (the price moved further than the tolerance between the re-read and the part) stops the loop. When the caller sent no price, the stop uses the default 500 bps.

Before the first part. If the price is outside the tolerance (POOL_PRICE_DRIFT_AT_MINT), cannot be read (LP_LOCK_PRICE_READ_FAILED), or admits no liquidity for this deposit (LP_LOCK_PRICE_OUT_OF_RANGE), nothing is minted, no position exists, both deposits are withdrawn back to the caller, and the error says what happened to each deposit.

After a stop.

  • The parts already added stay in the position and stay locked: the lock is smaller than asked, and it records only what entered the position (step 7). A stopped lock can be below the minimum lock amount; it is kept, not refused.
  • The unminted rest of both tokens goes back to the caller by amount (step 6).
  • create_lp_lock answers the lock id followed by a LP_LOCK_CHUNK_STOP_NOTICE that says how many parts were minted and why it stopped, what is in the position per token, what was returned, and which refund records exist for the lock. The same notice is kept per lock and read with the queries get_lp_lock_stop_notice(lock_id) and get_lp_lock_stop_notices(lock_ids) (up to 100 ids per call), so a lost reply does not lose it. A lock that minted all ten parts answers the bare lock id, and the queries return nothing for it.
  • A part whose increaseLiquidity outcome is unknown (the reply was lost) is not returned: its amounts are journaled for reconciliation and only the rest goes back.

Stranded refunds. When a part ran at a price other than the one it was sized on (the push that stopped the loop, or ordinary trading inside the band), the pool refunded the excess of that part to the locker’s main account. The locker turns each such refund into a record owed to the lock owner:

  • The record is written only from the pool’s own record of that part (getSwapRecordState: this canister as owner, the lock’s position, the part’s minted liquidity). Its amount is what the pool says it sent: the unused desired amount minus the pool fee. It is never derived from a price read, a balance, or an argument. A part the pool’s records do not resolve stays in the journal and has no record.
  • A record is paid only when the pool shows its own completed refund of that part and token to the locker for exactly the recorded amount; one proof backs one record, and the pool is checked against the ICPSwap Factory again first. A refund still in flight, a failed one, or one the pool no longer shows pays nothing. The owner receives the recorded amount minus one ledger fee, sent once (a lost reply is retried with the identical transfer, which the ledger deduplicates); a record is never paid twice (STRANDED_REFUND_ALREADY_PAID).
  • Paid automatically. Right after the lock is stored, create_lp_lock pays every record of that lock whose completed refund the pool already shows. The rest is picked up by a back-stop on the unlock timer: each tick reads at most 2 open records, in turn by id, through the same steps. A record whose refund the pool still cannot prove after 10 consecutive reads, or whose proof does not match it or is below the ledger fee, is not paid automatically: it moves to NEEDS_ADMIN (“needs review”) and is resolved by the authorized caller (admin_resolve_stranded_lp_refund, which can reopen or close the record or settle an uncertain transfer, but moves no funds itself).
  • Claim as a fallback. claim_stranded_lp_refund(id, pow) runs the same steps at any time; it may be called by the owner, and the tokens always go to the recorded owner. Records in review, with an unconfirmed transfer, closed or already paid are refused by name. Like create_lp_lock, the claim passes through Shield, so under load it may require a proof-of-work (pow).
  • Record states: OWED, PAYING, PAID, PAY_UNKNOWN (the transfer could not be confirmed), NEEDS_ADMIN, CLOSED. Queries, newest first and paged (at most 100 per call): get_stranded_lp_refunds(before_id, limit) for every owner and get_stranded_lp_refunds_of(owner, before_id, limit) for one owner.
  • The Locker shows these records, with their state and a Claim now button where a claim is accepted, on the lock’s page (with its stop notice), on the Locker dashboard and on the LP lock form; the OhShii Launcher shows them, with the stop notice, under each Locker LP lock in My Locks.

The locker returns exactly the part of this call’s deposits that was not passed to any part (deposited − Σ desired), by amount, to the caller. A remainder below the pool and ledger fees stays with the locker as dust.

Store a LockRecord 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

On every lock, stopped or not, lp_token0_amount, lp_token1_amount and the governance-token side of them as token_amount are what entered the position, summed part by part, as a lower bound:

  • a part that produced the liquidity expected at its re-read price counts the amounts it was given;
  • a part that ran at a moved price counts what the pool’s own record of that part says it used, capped at what the part was given;
  • a moved part the pool’s record does not resolve counts, if it stayed inside the stop band, a lower bound computed from the liquidity it added, and otherwise nothing;
  • a part whose outcome is unknown counts nothing.

The amounts the caller sent are never read for the record; whatever of them did not enter the position goes back by amount (step 6). Voting power and lock points therefore come from what is locked.

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 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) mint failure after the deposits, the locker withdraws this call’s deposited amounts back to the user by amount.


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 + a guarded recovery path:

  • 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, at most 200 per call.
  • admin_complete_orphaned_lp_lock, invoked by the authorized caller, is the recovery path: 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) (same authorized caller) 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, a guarded 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 guarded reclaim-fixup endpoints) and the user still has the position — a second reclaim is a no-op because the locker no longer owns it.

Why the CAS always gets to decide the race. The reservation is the authoritative arbitration, but it can only arbitrate a status change that actually reaches lock_storage. Alongside it, the locker holds a short-lived per-lock claim in memory for the duration of each call, shared by the unlock timer, the lock creation flow and the status-changing methods, so two flows never interleave on the same lock. A reclaim’s claim is explicitly yieldable: a status change executed by ohshii_governance under an approved proposal — a freeze, for instance — takes the claim over an in-flight reclaim, reaches lock_storage, and the CAS in step 1 decides the race as described. A reclaim can never suppress it by keeping calls in flight. The unlock timer’s own claim is not yieldable, so nothing can interleave with a tranche being paid. The claim is released automatically even if the call traps.

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)GovernanceLockRecord in the campaign canister’s stable memory
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
MintingTen parts on one position, price re-read before each (see Step 5)One mint (see below)
Platform feeNoneNone

A campaign DAO’s LP lock (sons_governance) is a separate implementation that adds the deposit to the pool in one mint call.