Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save sonic-mast/c3a352aa6b9b46dd42e36f163d9d9b2d to your computer and use it in GitHub Desktop.

Select an option

Save sonic-mast/c3a352aa6b9b46dd42e36f163d9d9b2d to your computer and use it in GitHub Desktop.
Bitflow dlmm-swap-router-v-1-1 Static Analysis Audit — Bounty mpwizl08f7b54c2ff179

Bitflow dlmm-swap-router-v-1-1 — Static Analysis Audit

Contract: SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-swap-router-v-1-1
Source verified: https://api.hiro.so/v2/contracts/source/SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD/dlmm-swap-router-v-1-1
Bounty: mpwizl08f7b54c2ff179
Auditor: Sonic Mast (bc1qd0z0a8z8am9j84fk3lk5g2hutpxcreypnf2p47)
Date: 2026-06-07
Methodology: Full static analysis of on-chain Clarity source. No fuzzing or live simulation.


1. State Model

The router is fully stateless: no data-var, no data-map, no mutable owner variable. All values live on the call stack. Token balances and pool reserves are managed entirely within the hard-coded downstream contract SP1PFR4V08H1RAZXREBGFFQ59WB739XM8VVGTFSEA.dlmm-core-v-1-1.

Compile-time constants

Name Value Purpose
ERR_NO_RESULT_DATA (err u2001) fold accumulator unwrap failed
ERR_BIN_SLIPPAGE (err u2002) unfavorable-bin weight budget exceeded
ERR_MINIMUM_RECEIVED (err u2003) output below per-swap minimum
ERR_MINIMUM_X_AMOUNT (err u2004) aggregate X output below floor
ERR_MINIMUM_Y_AMOUNT (err u2005) aggregate Y output below floor
ERR_NO_ACTIVE_BIN_DATA (err u2006) get-active-bin-id returned none
ERR_EMPTY_SWAPS_LIST (err u2007) caller passed empty list
ERR_RESULTS_LIST_OVERFLOW (err u2008) results list append failed
ERR_INVALID_BIN_ID (err u2009) expected-bin-id outside [−500, 500]
ERR_INVALID_MAX_STEPS (err u2010) max-steps outside [1, 319]
ERR_INVALID_STEP_INDEX_RANGE (err u2011) slice? on STEP_INDEX_RANGE failed
MIN_BIN_ID -500 (int) lower bound for expected-bin-id validation
MAX_BIN_ID 500 (int) upper bound for expected-bin-id validation
MIN_STEPS u1 lower bound for max-steps
MAX_STEPS u319 upper bound for max-steps
STEP_INDEX_RANGE list(0..319, int) fold iteration counter for simple-range functions

Which functions mutate each store: None. The router emits no state transitions. All pool-reserve mutations are inside dlmm-core-v-1-1.


2. Function Inventory

2.1 Public functions

swap-multi

(define-public (swap-multi
    (swaps (list 319 {pool-trait: <dlmm-pool-trait>,
                      x-token-trait: <sip-010-trait>,
                      y-token-trait: <sip-010-trait>,
                      expected-bin-id: int,
                      amount: uint,
                      min-received: uint,
                      x-for-y: bool}))
    (max-unfavorable-bins uint)
) → (response {results: (list 319 {in: uint, out: uint}), unfavorable: uint} uint)
  • Caller authority required: none — permissionless
  • Execution order:
    1. fold fold-swap-multi swaps … — per-element validation + core swap calls
    2. asserts! (> (len swaps) u0) — empty-list guard (post-fold placement, see R01)
    3. asserts! (<= unfavorable max-unfavorable-bins) — bin-drift budget
  • Per-element asserts inside fold:
    • expected-bin-id ∈ [−500, 500]
    • (>= out min-received) per swap
  • State mutations: none (router); pool reserves mutated inside dlmm-core-v-1-1
  • External calls: for each swap element → pool-trait.get-active-bin-id + dlmm-core-v-1-1.swap-x-for-y or swap-y-for-x
  • Transfers: for each swap element, amount of the input token leaves tx-sender; out of the output token arrives at tx-sender (via the core)

swap-x-for-y-same-multi

(define-public (swap-x-for-y-same-multi
    (swaps (list 319 {pool-trait: <dlmm-pool-trait>,
                      expected-bin-id: int,
                      min-received: uint}))
    (x-token-trait <sip-010-trait>)
    (y-token-trait <sip-010-trait>)
    (amount uint)
    (min-y-amount-total uint)
    (max-unfavorable-bins uint)
) → (response {results: (list 319 {in: uint, out: uint}), y-amount: uint, unfavorable: uint} uint)
  • Caller authority required: none
  • Execution order:
    1. fold fold-swap-x-for-y-same-multi swaps …
    2. asserts! (> (len swaps) u0)post-fold (see R01)
    3. asserts! (<= unfavorable max-unfavorable-bins)
    4. asserts! (>= y-amount-total min-y-amount-total) — aggregate Y floor
  • Per-element asserts inside fold:
    • expected-bin-id ∈ [−500, 500]
    • (>= out min-received) per pool
    • Short-circuits (no external call) when x-amount-for-swap == 0
  • State mutations: none (router)
  • External calls: per active pool → pool-trait.get-active-bin-id + dlmm-core-v-1-1.swap-x-for-y
  • Transfers: up to amount of x-token leaves tx-sender; y-amount-total of y-token arrives
  • Note (R03): remaining unconsumed X is NOT included in the response; caller cannot determine actual X spent from the return tuple alone without summing in fields in results

swap-y-for-x-same-multi

Mirror of swap-x-for-y-same-multi in Y→X direction. Parameter min-x-amount-total is the aggregate X floor. Response includes x-amount.


swap-simple-multi

(define-public (swap-simple-multi
    (swaps (list 5 {pool-trait: <dlmm-pool-trait>,
                    x-token-trait: <sip-010-trait>,
                    y-token-trait: <sip-010-trait>,
                    amount: uint,
                    min-received: uint,
                    x-for-y: bool,
                    max-steps: uint}))
) → (response {results: (list 5 {in: uint, out: uint})} uint)
  • Caller authority required: none
  • Execution order:
    1. fold fold-swap-simple-multi swaps …
    2. asserts! (> (len swaps) u0)post-fold (see R01)
  • Per-element asserts inside fold:
    • max-steps ∈ [1, 319]
    • Delegates to swap-x-for-y-simple-range-multi or swap-y-for-x-simple-range-multi; those enforce per-leg min-received
  • No aggregate output minimum across all 5 legs (see R02)
  • No bin-drift protection — no expected-bin-id or unfavorable budget (see R04)
  • External calls: per leg, per step: pool-trait.get-active-bin-id + dlmm-core-v-1-1.swap-x-for-y/y-for-x (only while input remains; steps short-circuit when exhausted — see R02 note)

swap-x-for-y-simple-multi

(define-public (swap-x-for-y-simple-multi
    (pool-trait <dlmm-pool-trait>)
    (x-token-trait <sip-010-trait>) (y-token-trait <sip-010-trait>)
    (x-amount uint) (min-dy uint)
) → (response {in: uint, out: uint} uint)

Thin wrapper: calls swap-x-for-y-simple-range-multi with max-steps = MAX_STEPS (u319).


swap-y-for-x-simple-multi

Thin wrapper: calls swap-y-for-x-simple-range-multi with max-steps = MAX_STEPS (u319).


swap-x-for-y-simple-range-multi

(define-public (swap-x-for-y-simple-range-multi
    (pool-trait <dlmm-pool-trait>)
    (x-token-trait <sip-010-trait>) (y-token-trait <sip-010-trait>)
    (x-amount uint) (min-dy uint) (max-steps uint)
) → (response {in: uint, out: uint} uint)
  • Caller authority required: none
  • Pre-conditions:
    1. max-steps ∈ [1, 319]asserts! before fold
    2. slice? STEP_INDEX_RANGE u0 max-steps — determines iteration list
    3. (>= y-amount min-dy) — aggregate output floor for this pool, enforced after fold
  • Behavior: iterates over STEP_INDEX_RANGE[0..max-steps]; each step fetches the live active bin and calls the core; short-circuits (no external call) when remaining X reaches 0
  • Returns: {in: consumed-x, out: total-y} — aggregate for this single pool

swap-y-for-x-simple-range-multi

Symmetric to swap-x-for-y-simple-range-multi, Y→X direction. Returns {in: consumed-y, out: total-x}.


2.2 Private functions

Function Purpose Key notes
fold-swap-multi Per-swap body for swap-multi Validates bin range; calls core; accumulates unfavorable weight = abs-int(bin-id-delta)
fold-swap-x-for-y-same-multi Per-pool body for same-multi X→Y Short-circuits at x-amount-for-swap == 0; accumulates Y and unfavorable weight
fold-swap-y-for-x-same-multi Per-pool body for same-multi Y→X Symmetric
fold-swap-simple-multi Per-leg body for swap-simple-multi Validates max-steps per leg; delegates to range-multi functions
fold-swap-x-for-y-simple-multi Per-step body for simple-range X→Y bin-id parameter is unused iteration counter; live bin fetched each step (see R07)
fold-swap-y-for-x-simple-multi Per-step body for simple-range Y→X Symmetric
abs-int Helper: signed int → uint absolute value Used for unfavorable-bin weight; safe within ±1000 delta range

3. Post-Condition Coverage Matrix

All token transfers are executed by dlmm-core-v-1-1 acting on behalf of tx-sender. The router itself never holds tokens; it only passes trait references. Callers must attach post-conditions for every token that can move.

Function Token(s) IN (debited from tx-sender) Token(s) OUT (credited to tx-sender) Post-conditions a safe caller MUST attach
swap-multi Per swap: up to amount of x-token or y-token (direction per x-for-y) Per swap: at least min-received of the opposite token Separate ft-transfer? / STX post-condition for EACH distinct token across all legs; ≤ amount sent, ≥ min-received received per leg
swap-x-for-y-same-multi Up to amount of x-token (may be partially consumed) At least min-y-amount-total of y-token x-token send ≤ amount; y-token receive ≥ min-y-amount-total
swap-y-for-x-same-multi Up to amount of y-token At least min-x-amount-total of x-token Symmetric
swap-simple-multi Per leg: up to amount of that leg's input token Per leg: at least min-received of that leg's output token Critical: each leg may use a different token pair; callers must attach post-conditions for ALL tokens across ALL legs. A 3-leg call touching STX, sBTC, and aeUSDC needs three independent token post-conditions.
swap-x-for-y-simple-multi / swap-x-for-y-simple-range-multi Up to x-amount of x-token At least min-dy of y-token x-token send ≤ x-amount; y-token receive ≥ min-dy
swap-y-for-x-simple-multi / swap-y-for-x-simple-range-multi Up to y-amount of y-token At least min-dx of x-token Symmetric

Post-condition mode: Callers MUST use post-condition-mode: deny. In allow mode, any unexpected token movement succeeds silently. For swap-simple-multi with heterogeneous legs, omitting a token from the deny-mode post-condition set allows that token's full balance to move unguarded.


4. Authority / Access-Control Matrix

Control point Implementation Assessment
Owner / admin variable None Contract is fully immutable; no upgrade path
Pause / kill switch None No emergency stop; cannot be paused
Who can call public functions Any tx-sender Fully permissionless; no allowlist, no role gate
Who can call private functions Only this contract (Clarity enforces) Correct
Oracle dependency Live pool-trait.get-active-bin-id per step Fetched in real-time from the caller-supplied pool trait; no pinned price oracle
Core contract trust Hard-coded SP1PFR4V08H1RAZXREBGFFQ59WB739XM8VVGTFSEA.dlmm-core-v-1-1 Total trust: all swap execution, token transfer authority, and pool-state validation is inside this single immutable principal. Any vulnerability in dlmm-core-v-1-1 propagates through every router function.
Caller-supplied trait validation Router does NOT verify that pool-trait or token-traits correspond to authorized Bitflow pools Validation is delegated entirely to dlmm-core-v-1-1. Callers supplying a malicious trait that conforms to the trait interface may cause unexpected behavior if the core does not fully validate all trait-provided pools and tokens.
as-contract escalation None Router never elevates to its own principal context; all calls execute as tx-sender
Privileged principals None No contract-owner, no multisig, no DAO control plane

5. Clarity Best-Practice Review

5.1 tx-sender where contract-caller was intended

CLEAN. The router performs no principal-based access checks. Token transfers are executed by dlmm-core-v-1-1 which uses tx-sender correctly — the original signer is the token source in all cases. No as-contract wrapping introduces a context switch that would confuse tx-sender vs. contract-caller.

5.2 unwrap-panic / unwrap-err-panic in user-facing paths

CLEAN. The contract uses unwrap! and try! throughout. All error paths propagate via the response type rather than panicking. No unwrap-panic or unwrap-err-panic found anywhere in the source.

5.3 Arithmetic overflow risk in * / +

LOW RISK — one note. Clarity unsigned integer arithmetic reverts on overflow/underflow (no silent wrapping). The subtraction (- x-amount-for-swap (get in swap-result)) relies on dlmm-core-v-1-1 never returning in > x-amount-for-swap. If the core returns a correct in value, the subtraction cannot underflow. The router has no independent guard. The abs-int helper: (to-uint (if (>= value 0) value (- value))) is safe in practice because bin-id-delta is bounded to ±1000 (validated ±500 range for each bin id in the pair); the theoretical edge case of negating MIN_INT does not apply here.

5.4 as-contract usage / principal escalation

CLEAN. No as-contract calls anywhere in the router. The contract never acts on its own behalf.

5.5 Trait conformance gaps

CLEAN. Trait imports reference versioned contracts (dlmm-pool-trait-v-1-1, sip-010-trait-ft-standard-v-1-1). All external calls use only methods declared in those trait interfaces. No calls to non-trait methods on passed trait instances.

5.6 Empty-list guard placement (see R01)

STYLE ISSUE. In swap-multi, swap-x-for-y-same-multi, swap-y-for-x-same-multi, and swap-simple-multi, the check (asserts! (> (len swaps) u0) ERR_EMPTY_SWAPS_LIST) is placed AFTER the (let ((swap-result (try! (fold ...))))) binding. An empty list causes fold to return the initial accumulator value immediately (O(1), no external calls), then try! succeeds, and the asserts! fires correctly. Functionally correct, but the guard reads as a post-fold check rather than a pre-condition, and reviewers unfamiliar with Clarity evaluation order may misread it as dead code.

5.7 Unused fold parameter (see R07)

INFORMATIONAL. fold-swap-x-for-y-simple-multi and fold-swap-y-for-x-simple-multi receive a parameter named bin-id (type int, sourced from STEP_INDEX_RANGE). This parameter is never referenced inside the function body. The active bin is fetched live each iteration via get-active-bin-id. The name implies bin-based selection, but it is purely an iteration counter. Clarity does not support _ discard patterns, so the naming confusion is a documentation issue.


6. Findings Table

ID Severity Function Line reference Finding Recommended fix
R01 Low swap-multi, swap-x-for-y-same-multi, swap-y-for-x-same-multi, swap-simple-multi Post-fold asserts! in each public function Empty-list guard (asserts! (> (len swaps) u0) ERR_EMPTY_SWAPS_LIST) executes after the fold rather than before it. Functionally correct (fold on empty list is O(1)), but misleading to readers and inverts the conventional guard-before-effect pattern. Move the asserts! to before the let/fold binding to make it unambiguously a pre-condition.
R02 Medium swap-simple-multi, fold-swap-simple-multi fold-swap-simple-multi and the wrapping public function No aggregate output minimum across all 5 legs. Per-leg min-received is enforced inside each swap-x/y-for-y/x-simple-range-multi call, but there is no parameter at the swap-simple-multi level for a final-token floor. A caller routing A→B→C across two legs has no way to specify "I want at least X of C from the entire multi-leg path" without computing it indirectly. Gas note: steps short-circuit when input is exhausted (the (if (> amount-for-swap u0) ...) guards inside the fold), so actual inter-contract calls scale with consumed bins, not max-steps. Add an optional min-total-output uint parameter covering the output of the final leg, or document that callers must verify aggregate slippage off-chain before construction.
R03 Low swap-x-for-y-same-multi, swap-y-for-x-same-multi Return tuple in both functions Unconsumed input not surfaced in response. When all pools in the list are exhausted before amount is fully consumed, the remaining X (or Y) balance stays in the caller's wallet correctly — it was never transferred. However, the return type {results, y-amount, unfavorable} does not include the residual x-amount-for-swap. Callers that track inventory from the return value must sum in fields in results to derive actual consumption. Add x-amount-spent: (- amount x-amount-for-swap) (or y-amount-spent) to the response tuple to surface actual consumption directly.
R04 Medium swap-simple-multi, swap-x-for-y-simple-multi, swap-y-for-x-simple-multi, swap-x-for-y-simple-range-multi, swap-y-for-x-simple-range-multi All "simple" family functions No bin-drift protection. The simple-range family has no expected-bin-id per pool and no max-unfavorable-bins budget. Output minima (min-dy, min-dx, per-leg min-received) are the only slippage guards. In a high-volatility window, the active bin can shift significantly between the off-chain quote and on-chain execution without the router reverting — protection fires only if the aggregate output falls below the specified floor. By contrast, swap-x-for-y-same-multi with expected-bin-id + max-unfavorable-bins provides bin-level drift detection. Document the distinction clearly in interface docs. For latency-sensitive or large orders, callers should prefer the same-multi family.
R05 Low fold-swap-multi, fold-swap-x-for-y-same-multi, fold-swap-y-for-x-same-multi Unfavorable accumulator in each fold max-unfavorable-bins parameter name is misleading. The unfavorable accumulator tracks the sum of absolute bin-ID deltas for all unfavorable pool entries, not a count of unfavorable pools. A single pool with a 10-bin drift contributes 10 to the budget; ten pools each with a 1-bin drift also contribute 10 total. A caller who interprets the parameter as "allow at most N unfavorable pools" will set too loose a threshold on high-drift single pools, or too tight a threshold on many small-drift pools. Rename the parameter to max-unfavorable-weight and add a docstring explaining the accumulation semantics.
R06 Low swap-x-for-y-same-multi, swap-y-for-x-same-multi Results list capacity u319 in fold-swap-x-for-y-same-multi The same-multi functions accept up to 319 pool entries (matching the global MAX_STEPS constant), each triggering one get-active-bin-id and one core swap call when input is non-zero. At 319 pools with non-zero input, this is 638 inter-contract calls in a single transaction — likely to hit Stacks block execution limits for large pool counts. The current on-chain cap is the list type constraint, not an explicit execution-budget guard. Document the practical safe pool-count ceiling based on Stacks execution costs, and consider adding an explicit max-pools parameter or a compile-time reduced list size.
R07 Informational fold-swap-x-for-y-simple-multi, fold-swap-y-for-x-simple-multi First parameter bin-id int in both private folds The bin-id fold parameter (sourced from STEP_INDEX_RANGE, values 0..319) is never used inside the function body. The live active bin is fetched via get-active-bin-id each iteration. The name implies bin-based indexing but is purely an iteration counter used by the fold mechanism. Rename to _step or add a comment noting the parameter is a fold-counter only; consider using a uint type to match its actual semantics.

Responsible Disclosure

No high or critical severity findings were identified in this audit. No private disclosure to the Bitflow team was required. All findings above (low / medium / informational) are suitable for direct public submission per the bounty terms.


Summary — Top 3 Findings

  1. R02 (Medium): swap-simple-multi has no aggregate output minimum across its up to 5 heterogeneous legs; per-leg minimums do not protect against compounded multi-leg slippage on the final output token.
  2. R04 (Medium): The entire simple-range function family (swap-simple-multi, swap-x-for-y/y-for-x-simple-multi, range variants) lacks bin-drift detection; the expected-bin-id + max-unfavorable-bins mechanism available in the same-multi family is absent, leaving only the output floor as slippage protection in volatile conditions.
  3. R03 (Low): swap-x-for-y-same-multi and swap-y-for-x-same-multi omit the unconsumed input amount from the response, requiring callers to sum in fields from the results list to compute actual token consumption — a non-obvious accounting burden.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment