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.
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.
| 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.
(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:
fold fold-swap-multi swaps …— per-element validation + core swap callsasserts! (> (len swaps) u0)— empty-list guard (post-fold placement, see R01)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-yorswap-y-for-x - Transfers: for each swap element,
amountof the input token leavestx-sender;outof the output token arrives attx-sender(via the core)
(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:
fold fold-swap-x-for-y-same-multi swaps …asserts! (> (len swaps) u0)— post-fold (see R01)asserts! (<= unfavorable max-unfavorable-bins)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
amountof x-token leavestx-sender;y-amount-totalof 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
infields inresults
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.
(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:
fold fold-swap-simple-multi swaps …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-multiorswap-y-for-x-simple-range-multi; those enforce per-legmin-received
- No aggregate output minimum across all 5 legs (see R02)
- No bin-drift protection — no
expected-bin-idor 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)
(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).
Thin wrapper: calls swap-y-for-x-simple-range-multi with max-steps = MAX_STEPS (u319).
(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:
max-steps ∈ [1, 319]—asserts!before foldslice? STEP_INDEX_RANGE u0 max-steps— determines iteration list(>= 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
Symmetric to swap-x-for-y-simple-range-multi, Y→X direction. Returns {in: consumed-y, out: total-x}.
| 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 |
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.
| 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 |
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.
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.
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.
CLEAN. No as-contract calls anywhere in the router. The contract never acts on its own behalf.
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.
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.
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.
| 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. |
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.
- R02 (Medium):
swap-simple-multihas 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. - 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; theexpected-bin-id+max-unfavorable-binsmechanism available in thesame-multifamily is absent, leaving only the output floor as slippage protection in volatile conditions. - R03 (Low):
swap-x-for-y-same-multiandswap-y-for-x-same-multiomit the unconsumed input amount from the response, requiring callers to suminfields from theresultslist to compute actual token consumption — a non-obvious accounting burden.