Skip to content

Instantly share code, notes, and snippets.

@Zee99y
Last active March 17, 2026 23:48
Show Gist options
  • Select an option

  • Save Zee99y/7143ff27791222d45f3de8486f3f49dc to your computer and use it in GitHub Desktop.

Select an option

Save Zee99y/7143ff27791222d45f3de8486f3f49dc to your computer and use it in GitHub Desktop.
AND-gate in `collect_fees` blocks protocol fee withdrawal whenever swap flow is unidirectional, permanently trapping accrued revenue
// here is the POC test file. but find below the full report.
it('collect_fees blocked after unidirectional swaps', async () => {
// setup pool with protocol fee enabled
let setup = await setupDex({
createPool: {
amount1: toNano(1000000),
amount2: toNano(2000000),
}
});
let data = await (setup.pool as SBCtrPool).getPoolData();
// enable protocol fee, set collector to alice
await setFees({
...setup,
newLPFee: data.lpFee,
newProtocolFee: 10n, // 10 bps, non-zero protocol fee
newProtocolFeeAddress: alice.address,
});
// execute swaps in ONE direction only: token1 -> token2
// this accumulates collected_token2_protocol_fee only.
// collected_token1_protocol_fee stays at 0.
for (let i = 0; i < 5; i++) {
await swap({
router: setup.router,
tokenIn: setup.token1,
tokenOut: setup.token2,
amountIn: toNano(1000),
});
}
// confirm the asymmetry in fee buckets
let poolData = await (setup.pool as SBCtrPool).getPoolData();
expect(poolData.collectedLeftJettonProtocolFees + poolData.collectedRightJettonProtocolFees).toBeGreaterThan(0n);
const oneBucketIsZero =
poolData.collectedLeftJettonProtocolFees === 0n ||
poolData.collectedRightJettonProtocolFees === 0n;
expect(oneBucketIsZero).toBe(true);
// alice (protocol_fee_address) attempts to collect, must fail
await collectFees({
...setup,
sender: alice,
expectBounce: true, // pool throws error::zero_output (81), message bounces
});
// confirm fees are still trapped, unchanged after failed collect
let poolDataAfter = await (setup.pool as SBCtrPool).getPoolData();
expect(poolDataAfter.collectedLeftJettonProtocolFees)
.toEqual(poolData.collectedLeftJettonProtocolFees);
expect(poolDataAfter.collectedRightJettonProtocolFees)
.toEqual(poolData.collectedRightJettonProtocolFees);
});
@Zee99y

Zee99y commented Mar 17, 2026

Copy link
Copy Markdown
Author

Summary

AND-gate in collect_fees blocks protocol fee withdrawal whenever swap flow is unidirectional, permanently trapping accrued revenue

Asset in scope: contracts/pool.fc
Severity: Major

Root cause:

contracts/pool/msgs/protocolfee.fc:5, compiled into contracts/pool.fc:

throw_unless(error::zero_output,
    (storage::collected_token0_protocol_fee > 0) &
    (storage::collected_token1_protocol_fee > 0)
);

This requires both fee buckets to be nonzero before any collection is allowed. The swap handler (contracts/pool/pool.fc, lines 97 and 104) only ever increments one bucket per swap direction:

if amount0 {
    // token0 → token1
    storage::collected_token1_protocol_fee += protocol_fee_out;  // line 97
} else {
    // token1 → token0
    storage::collected_token0_protocol_fee += protocol_fee_out;  // line 104
}

A pool that processes exclusively or predominantly one-directional swaps, a common real-world condition, will permanently have one bucket at zero. The AND-gate then causes every collect_fees call to revert with error::zero_output (code 81). No override or emergency path exists.

Impacts:

  • protocol_fee_address cannot withdraw accrued protocol fees from any pool where one swap direction dominates.
  • No alternative collection mechanism exists in any in-scope contract.
  • Affects all pool types (constant product, stableswap, weighted variants) since protocolfee.fc is shared.
  • At $25.5M TVL with fee revenue as the protocol's primary income stream, sustained fee trapping represents direct, ongoing financial loss to the protocol.

Steps to reproduce

  1. Deploy a pool via the router (contracts/router.fc) with protocol_fee > 0.
  2. Execute N swaps exclusively in the token0→token1 direction. collected_token1_protocol_fee grows; collected_token0_protocol_fee remains 0.
  3. As protocol_fee_address, send op::collect_fees to the pool with sufficient gas (~0.14 TON per pool comment).
  4. Transaction reverts , throw_unless(error::zero_output, (0 > 0) & (N > 0)) evaluates false and throws exit code 81.
  5. Fees remain trapped in pool storage. Repeat step 3 indefinitely — result is always the same.

Recommendations

No user-side workaround exists. The protocol_fee_address has no alternative path to claim fees.

To fix, replace the AND-gate with an OR-gate and conditionally send pay_to only for non-zero buckets:

- throw_unless(error::zero_output,
-     (storage::collected_token0_protocol_fee > 0) &
-     (storage::collected_token1_protocol_fee > 0)
- );
+ throw_unless(error::zero_output,
+     (storage::collected_token0_protocol_fee > 0) |
+     (storage::collected_token1_protocol_fee > 0)
+ );

Then guard each pay_to message send with a nonzero check on its respective bucket before sending.

Proof of concept

environment: Node >= 22, repo cloned from https://github.com/ston-fi/dex-core-v2

Setup:

git clone https://github.com/ston-fi/dex-core-v2
cd dex-core-v2
yarn install

POC test file see above POC test code.. and add to tests/ConstProduct.spec.ts (constant product pool, simplest case):

In file tests/ConstProduct.spec.ts , find this block:

describe('Fees', () => {
    it('should set fees', async () => { ... });

    it('should collect fees', async () => { ... });

    // <- PASTE THE POC TEST HERE

    it('should collect ref fee from vault', async () => { ... });
});

Then run with:

npm run test -- --testNamePattern="collect_fees blocked after unidirectional"

Observed: test passes, exitCode: 81, fees unchanged. See attached screenshots.

Capture test PASS screenshot

Output:

> @ston-fi/dex-core-v2@2.2.0 test
> jest --verbose --testNamePattern=collect_fees blocked after unidirectional

 PASS  tests/ConstProduct.spec.ts (9.652 s)
  Const Product
    wip
      ○ skipped 123
    Pool code upgrade
      ○ skipped should init new pool code
      ○ skipped should cancel new pool code
      ○ skipped should finalize new pool code
      ○ skipped should not finalize new pool code if time not passed
      ○ skipped should set new pool code on pool
      ○ skipped should not set new pool code if empty
    Fees
      ✓ collect_fees blocked after unidirectional swaps (2689 ms)
      ○ skipped should set fees
      ○ skipped should collect fees
      ○ skipped should collect ref fee from vault
    Dex
      ○ skipped should deploy dex
      ○ skipped should swap
      ○ skipped should provide lp
      ○ skipped should provide lp with payload
      ○ skipped should provide lp with payload to a different address
      ○ skipped should handle more complex scenarios
      ○ skipped should provide lp (single side)
      ○ skipped should burn lp and swap
      ○ skipped should provide lp (single side) with protocol fees
      ○ skipped should burn liquidity
      ○ skipped should cross-swap on the same router
      ○ skipped should cross-swap on 2 routers
      ○ skipped should direct add liquidity (all)
      ○ skipped should direct add liquidity (partial)
      ○ skipped should refund swap if tx is expired
      ○ skipped should swap with 0 ref fee
      ○ skipped should swap with max ref fee
    Refund
      ○ skipped should refund partial liquidity
      ○ skipped should refund both lp tokens
      ○ skipped should refund swap
      ○ skipped should refund cross-swap on the same router
      ○ skipped should refund cross-swap on 2 routers (in)
      ○ skipped should refund cross-swap on 2 routers (mid)
      ○ skipped should refund swap if fee more than max
    Bounce
      ○ skipped should bounce set fees if not admin
      ○ skipped should bounce collect fees if no fee

Test Suites: 8 skipped, 1 passed, 1 of 9 total
Tests:       158 skipped, 1 passed, 159 total
Snapshots:   0 total
Time:        14.356 s
Ran all test suites with tests matching "collect_fees blocked after unidirectional".

Additional info

  • Root cause: contracts/pool/msgs/protocolfee.fc:5 (part of in-scope contracts/pool.fc)
  • Fee accumulation: contracts/pool/pool.fc:97,104 (part of in-scope contracts/pool.fc)
  • Bounty impact category: "Theft or permanent freezing of unclaimed yield and rewards" is Major
  • Trail of Bits audit (Jan 2025) did not flag this condition

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