-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| }); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Summary
AND-gate in
collect_feesblocks protocol fee withdrawal whenever swap flow is unidirectional, permanently trapping accrued revenueAsset in scope:
contracts/pool.fcSeverity: Major
Root cause:
contracts/pool/msgs/protocolfee.fc:5, compiled intocontracts/pool.fc: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: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_feescall to revert witherror::zero_output(code 81). No override or emergency path exists.Impacts:
protocol_fee_addresscannot withdraw accrued protocol fees from any pool where one swap direction dominates.protocolfee.fcis shared.Steps to reproduce
contracts/router.fc) withprotocol_fee > 0.collected_token1_protocol_feegrows;collected_token0_protocol_feeremains 0.protocol_fee_address, sendop::collect_feesto the pool with sufficient gas (~0.14 TON per pool comment).throw_unless(error::zero_output, (0 > 0) & (N > 0))evaluatesfalseand throws exit code 81.Recommendations
No user-side workaround exists. The
protocol_fee_addresshas no alternative path to claim fees.To fix, replace the AND-gate with an OR-gate and conditionally send
pay_toonly for non-zero buckets:Then guard each
pay_tomessage 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-v2Setup:
git clone https://github.com/ston-fi/dex-core-v2 cd dex-core-v2 yarn installPOC 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:Then run with:
Observed: test passes,
exitCode: 81, fees unchanged. See attached screenshots.Output:
Additional info
contracts/pool/msgs/protocolfee.fc:5(part of in-scopecontracts/pool.fc)contracts/pool/pool.fc:97,104(part of in-scopecontracts/pool.fc)