Skip to content

Instantly share code, notes, and snippets.

@ClankOS
Created June 16, 2026 05:58
Show Gist options
  • Select an option

  • Save ClankOS/b683e8d4f6e3d95a5025f2792cbce762 to your computer and use it in GitHub Desktop.

Select an option

Save ClankOS/b683e8d4f6e3d95a5025f2792cbce762 to your computer and use it in GitHub Desktop.
Static analysis audit: Velar univ2-core AMM (SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-core)

Static Analysis Audit: Velar univ2-core AMM

Contract: SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1.univ2-core Protocol: Velar — UniswapV2-style AMM (~629 lines Clarity) Auditor: ClankOS Date: 2026-06-16 Source: https://api.hiro.so/v2/contracts/source/SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1/univ2-core


1. State Model

Variable / Map Type Description Mutation Authority
owner principal Contract owner set-owner (owner only)
protocol-fee-to principal Fee recipient for collect set-protocol-fee-to (owner only)
share-fee-to principal Share-fee recipient contract set-share-fee-to (owner only)
pool-id uint Auto-incrementing pool counter next-pool-id (private, called by create)
pools map uint → pool-struct All pool data: tokens, reserves, fees, LP token create, update-swap-fee, update-protocol-fee, update-share-fee, update-reserves (private, via mint/burn/swap)
index map {token0,token1} → uint Lookup pool ID by token pair create (owner only)
lp-tokens map principal → bool Registry of known LP token contracts create (owner only)
revenue map uint → {token0,token1} Accumulated protocol fees per pool update-revenue (private, via swap), reset-revenue (private, via collect)

Constants: MAX-SWAP-FEE {num:995,den:1000} (max effective fee 0.5%), MAX-PROTOCOL-FEE {num:500,den:1000} (max 50% of swap fee).


2. Function Inventory

Public Functions

Function Caller Authority Pre-conditions State Mutations External Calls
set-owner contract-caller == owner Sets owner none
set-protocol-fee-to contract-caller == owner Sets protocol-fee-to none
set-share-fee-to contract-caller == owner Sets share-fee-to none
update-swap-fee contract-caller == owner check-swap-fee anti-rug pass, pool exists Updates pool swap-fee none
update-protocol-fee contract-caller == owner check-protocol-fee anti-rug pass, pool exists Updates pool protocol-fee none
update-share-fee contract-caller == owner check-share-fee pass, pool exists Updates pool share-fee none
create contract-caller == owner Tokens distinct, pool not exist, LP not registered, fees valid + within bounds, token symbols differ Inserts into pools, index, lp-tokens, revenue token0/token1.get-symbol
mint Open (any tx-sender) Correct LP/token contracts for pool id, amt0 > 0, amt1 > 0, liquidity > 0 Updates pools reserves via update-reserves lp-token.get-total-supply, token0.transfer, token1.transfer, lp-token.mint
burn Open (any tx-sender) Correct LP/token contracts for pool id, liquidity > 0, amt0 > 0, amt1 > 0 Updates pools reserves lp-token.get-total-supply, token0.transfer, token1.transfer, lp-token.burn
swap Open (any tx-sender) Correct token pair for pool, share-fee-to0 == get-share-fee-to, amt-in > 0, amt-out > 0, k-invariant holds post-swap Updates pools reserves, revenue map token-in.transfer, token-out.transfer, token-in.transfer (share-fee), share-fee-to0.receive
collect tx-sender == protocol-fee-to (direct call only, not via intermediary) Correct token contracts for pool Resets revenue[id] to zero token0.transfer, token1.transfer

Read-Only Functions

get-owner, get-protocol-fee-to, get-share-fee-to, get-nr-pools, get-pool, do-get-pool, get-pool-id, lookup-pool, do-get-revenue, check-swap-fee, check-protocol-fee, check-share-fee, calc-mint, calc-burn, calc-swap, min.


3. Post-Condition Coverage Matrix

Function Token Movements Caller Should Attach
mint token0: user → protocol (amt0) ; token1: user → protocol (amt1) ; lp-token: minted to user (liquidity) ft-transfer? token0 >= amt0, ft-transfer? token1 >= amt1, ft-mint? lp-token >= liquidity
burn token0: protocol → user (amt0) ; token1: protocol → user (amt1) ; lp-token: burned from user (liquidity) ft-transfer? token0 >= amt0, ft-transfer? token1 >= amt1, ft-burn? lp-token >= liquidity
swap token-in: user → protocol (amt-in) ; token-out: protocol → user (amt-out) ; token-in: protocol → share-fee-to (amt-fee-share, if > 0) ft-transfer? token-in == amt-in, ft-transfer? token-out >= amt-out (caller sets min-out off-chain)
collect token0: protocol → user (amt0 from revenue) ; token1: protocol → user (amt1 from revenue) ft-transfer? token0 >= amt0, ft-transfer? token1 >= amt1
create No token movements none

4. Authority / Access-Control Matrix

Role Principal Can Do
Owner owner (data-var, defaults to deployer) Create pools, update fees, change owner/protocol-fee-to/share-fee-to
Protocol fee collector protocol-fee-to (data-var, defaults to deployer) Call collect to drain accumulated protocol revenue
Share fee recipient share-fee-to (data-var, defaults to .univ2-share-fee-to) Receives amt-fee-share per swap via receive callback
Public any tx-sender mint, burn, swap

Pause/kill switch: None. No emergency shutdown mechanism. Oracle: None. AMM is fully on-chain (k=xy invariant). Fee governance: Owner can change fees per-pool without a timelock.


5. Clarity Best-Practice Review

  • tx-sender vs contract-caller: check-owner uses contract-caller (correct). check-protocol-fee-to uses tx-sender — by design, forces direct call for fee collection (cannot route through an intermediary contract). Intentional but undocumented.
  • unwrap-panic: do-get-pool and do-get-revenue use unwrap-panic. Any call path that passes a non-existent pool ID to these helpers (e.g. update-swap-fee, collect) will panic with an unhandled runtime error rather than returning a typed error. See F-03.
  • Arithmetic overflow: calc-swap performs amt-in * num / den and (r0 + amt-in) * (r1 - amt-out). For very large pools (reserves approaching 2^127), intermediate multiplication can overflow. The post-condition invariant check (>= (* a b) k) uses these potentially overflowed values. No explicit bounds guard. For practical current pool sizes on Stacks this is low risk.
  • as-contract usage: Used correctly in mint, burn, swap, collect to let the contract hold and transfer tokens on behalf of users. The pattern is standard and correct.
  • Trait conformance: lp-token must implement ft-plus-trait (extends ft-trait with burn). The contract relies on lp-token.burn liquidity user being callable from as-contract context. LP token contracts must allow the pool contract (as contract-caller) to burn on behalf of user. This is a deployment-time requirement, not enforced by Freddie.
  • AMM invariant: Post-swap check (>= (* a b) k) correctly uses a = r_in + amt-in-adjusted and b = r_out - amt-out (LP fees stay in pool, increasing k over time). ✓
  • First-deposit protection: calc-mint uses sqrti(amt0 * amt1) for initial supply with no MINIMUM_LIQUIDITY burned. See F-04.

6. Findings Table

ID Severity Function Line Finding Recommended Fix
F-01 Medium set-owner, set-protocol-fee-to ~30-40 Single-step ownership transfer — no two-step accept pattern. set-owner immediately replaces owner with new-owner. A typo in the address permanently transfers control with no recovery path. Same for set-protocol-fee-to. Implement propose/accept: set-pending-owner (owner sets) + accept-ownership (new-owner calls).
F-02 Medium burn ~195-220 No minimum output amounts for LP removal. burn accepts a liquidity amount but has no min-amt0/min-amt1 parameters. The actual output is computed from current reserves at execution time. A sandwich attack (manipulating reserves between submission and execution) can deliver far less than expected with no on-chain recourse. Add (min-amt0 uint) and (min-amt1 uint) parameters; assert amt0 >= min-amt0 and amt1 >= min-amt1 in post-conditions.
F-03 Low do-get-pool, do-get-revenue, all callers ~100-115 unwrap-panic on pool lookup. do-get-pool and do-get-revenue call unwrap-panic on map-get?. Passing an invalid pool id to update-swap-fee, update-protocol-fee, update-share-fee, collect, or any function that chains through do-get-pool causes a runtime panic with no typed error, making error handling by callers impossible. Replace unwrap-panic with unwrap! ... err-no-such-pool in both helpers.
F-04 Informational calc-mint ~230-235 No MINIMUM_LIQUIDITY burned on first deposit. Unlike Uniswap V2, no permanent liquidity floor is set. With explicit reserve tracking (no balance-donation vector), the risk is precision loss for the second LP if the first depositor creates an extreme ratio with small amounts. E.g. first deposit of (1, 1)sqrti(1) = 1 total LP supply; second depositor's LP calculation may be skewed by integer truncation. Burn a small fixed amount (e.g. 1000 LP tokens) to the zero address on first mint: (if (is-eq total-supply u0) (- (sqrti (* amt0 amt1)) u1000) ...) with a minimum liquidity check.
F-05 Informational check-swap-fee, check-protocol-fee ~150-175 Fee denominators are hardcoded to 1000. Both anti-rug checks enforce (is-eq (get den fee) (get den guard)) where guard has den=1000. Fee updates with any other denominator silently fail the anti-rug check. The owner must always use den=1000. This constraint is undocumented. Document this invariant in comments; or use a normalized comparison: (<= (* num1 den2) (* num2 den1)).

No High or Critical findings. No private disclosure required.


Audit covers the static source as fetched from the Hiro API on 2026-06-16. No dynamic testing performed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment