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
| 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).
| 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 |
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.
| 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 |
| 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.
tx-sendervscontract-caller:check-ownerusescontract-caller(correct).check-protocol-fee-tousestx-sender— by design, forces direct call for fee collection (cannot route through an intermediary contract). Intentional but undocumented.unwrap-panic:do-get-poolanddo-get-revenueuseunwrap-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-swapperformsamt-in * num / denand(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-contractusage: Used correctly inmint,burn,swap,collectto let the contract hold and transfer tokens on behalf of users. The pattern is standard and correct.- Trait conformance:
lp-tokenmust implementft-plus-trait(extendsft-traitwithburn). The contract relies onlp-token.burn liquidity userbeing callable fromas-contractcontext. LP token contracts must allow the pool contract (ascontract-caller) to burn on behalf ofuser. This is a deployment-time requirement, not enforced by Freddie. - AMM invariant: Post-swap check
(>= (* a b) k)correctly usesa = r_in + amt-in-adjustedandb = r_out - amt-out(LP fees stay in pool, increasing k over time). ✓ - First-deposit protection:
calc-mintusessqrti(amt0 * amt1)for initial supply with no MINIMUM_LIQUIDITY burned. See F-04.
| 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.