Skip to content

Commit 2953e7d

Browse files
Remove overshoot trimming during swaps (#148)
* fix overshoot bridging * reset allowance after swap * revert bridging cap * lint * Apply suggestions from code review Co-authored-by: Jordan Schalm <jordan.schalm@gmail.com> Co-authored-by: Alex <12097569+nialexsan@users.noreply.github.com> * fix test --------- Co-authored-by: Jordan Schalm <jordan.schalm@gmail.com>
1 parent 1e7a0aa commit 2953e7d

3 files changed

Lines changed: 125 additions & 58 deletions

File tree

cadence/contracts/connectors/evm/UniswapV3SwapConnectors.cdc

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -660,15 +660,15 @@ access(all) contract UniswapV3SwapConnectors {
660660
let pathBytes = self._buildPathBytes(reverse: reverse, exactOutput: false, numHops: nil)
661661

662662
// Approve
663-
var res = self._call(
663+
let allowanceRes = self._call(
664664
to: inToken,
665665
signature: "approve(address,uint256)",
666666
args: [self.routerAddress, evmAmountIn],
667667
gasLimit: 120_000,
668668
value: 0
669669
)!
670-
if res.status != EVM.Status.successful {
671-
UniswapV3SwapConnectors._callError("approve(address,uint256)", res, inToken, idType, id, self.getType())
670+
if allowanceRes.status != EVM.Status.successful {
671+
UniswapV3SwapConnectors._callError("approve(address,uint256)", allowanceRes, inToken, idType, id, self.getType())
672672
}
673673

674674
// Min out on EVM units
@@ -677,8 +677,7 @@ access(all) contract UniswapV3SwapConnectors {
677677
erc20Address: outToken
678678
)
679679

680-
let coaRef = self.borrowCOA()!
681-
let recipient = coaRef.address()
680+
let recipient = coa.address()
682681

683682
// optional dev guards
684683
let _chkIn = EVMAbiHelpers.abiUInt256(evmAmountIn)
@@ -712,8 +711,21 @@ access(all) contract UniswapV3SwapConnectors {
712711
swapRes, self.routerAddress, idType, id, self.getType()
713712
)
714713
}
714+
// Reset allowance
715+
let resetAllowanceRes = self._call(
716+
to: inToken,
717+
signature: "approve(address,uint256)",
718+
args: [self.routerAddress, 0 as UInt256],
719+
gasLimit: 60_000,
720+
value: 0
721+
)!
722+
723+
if resetAllowanceRes.status != EVM.Status.successful {
724+
UniswapV3SwapConnectors._callError("approve(address,uint256)", resetAllowanceRes, inToken, idType, id, self.getType())
725+
}
715726
let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: swapRes.data)
716-
let amountOut: UInt256 = decoded.length > 0 ? decoded[0] as! UInt256 : 0
727+
assert(decoded.length == 1, message: "invalid swap return data")
728+
let amountOut = decoded[0] as! UInt256
717729

718730
let outVaultType = reverse ? self.inType() : self.outType()
719731
let outTokenEVMAddress =
@@ -733,28 +745,11 @@ access(all) contract UniswapV3SwapConnectors {
733745
message: "UniswapV3SwapConnectors: swap output \(outUFix.toString()) < amountOutMin \(amountOutMin.toString())"
734746
)
735747

736-
/// Quoting exact output then swapping exact input can overshoot by up to 0.00000001 (1 UFix64 quantum)
737-
/// when the pool's effective exchange rate is near 1:1.
738-
///
739-
/// UFix64 has 8 decimals; EVM tokens typically have 18. One UFix64 step = 10^10 wei.
740-
///
741-
/// Example (pool price 1 FLOW = 2 USDC, want 10 USDC out):
742-
/// 1. Quoter says need 5,000000002000000000 FLOW wei
743-
/// 2. Ceil to UFix64: 5,000000010000000000 (overshoot: 8e9 wei)
744-
/// 3. exactInput swaps the ceiled amount; extra 8e9 FLOW wei × 2 = 16e9 USDC wei extra
745-
/// 4. Actual output: 10,000000016000000000 USDC wei
746-
/// 5. Floor to UFix64: 10.00000001 USDC (quoted 10.00000000)
747-
///
748-
/// The overshoot is always non-negative (ceiled input >= what pool needs).
749-
/// It surfaces when the extra output crosses a 10^10 wei quantum boundary.
750-
/// Cap at amountOutMin so only the expected amount is bridged; dust stays in the COA.
751-
let bridgeUFix = outUFix > amountOutMin && amountOutMin > 0.0 ? amountOutMin : outUFix
752-
let dust = outUFix > bridgeUFix ? outUFix - bridgeUFix : 0.0
753748
let safeAmountOut = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
754-
bridgeUFix,
749+
outUFix,
755750
erc20Address: outTokenEVMAddress
756751
)
757-
// Withdraw output back to Flow; sub-quantum remainder and any overshoot stay in COA
752+
// Withdraw output back to Flow
758753
let outVault <- coa.withdrawTokens(type: outVaultType, amount: safeAmountOut, feeProvider: feeVaultRef)
759754

760755
// Handle leftover fee vault
@@ -864,4 +859,4 @@ access(all) contract UniswapV3SwapConnectors {
864859
"Call to \(target.toString()).\(signature) from Swapper \(swapperType.identifier) with UniqueIdentifier \(uniqueIDType) ID \(id) failed:\n\tStatus value: \(res.status.rawValue.toString())\n\tError code: \(res.errorCode.toString())\n\tErrorMessage: \(res.errorMessage)\n"
865860
)
866861
}
867-
}
862+
}

cadence/tests/fork/UniswapV3SwapConnectors_coa_dust_test.cdc

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ access(all) fun setup() {
4949
Test.expect(err, Test.beNil())
5050
Test.commitBlock()
5151

52-
err = Test.deployContract(
53-
name: "DeFiActions",
54-
path: "../../contracts/interfaces/DeFiActions.cdc",
55-
arguments: []
56-
)
57-
Test.expect(err, Test.beNil())
58-
Test.commitBlock()
52+
// err = Test.deployContract(
53+
// name: "DeFiActions",
54+
// path: "../../contracts/interfaces/DeFiActions.cdc",
55+
// arguments: []
56+
// )
57+
// Test.expect(err, Test.beNil())
58+
// Test.commitBlock()
5959

6060
err = Test.deployContract(
6161
name: "SwapConnectors",
@@ -371,14 +371,27 @@ access(all) fun runSwapTests(
371371

372372
/// Asserts swap dust/overshoot properties for each result row.
373373
///
374-
/// Verifies:
375-
/// - vaultBalance == quoteOutAmount (caller gets exactly what was quoted)
376-
/// - coaDustAfter >= coaDustBefore (overshoot/dust accumulates in COA)
374+
/// The swap transaction uses amountOutMin = desiredOut (NOT quoteIn.outAmount).
375+
/// This guarantees outUFix ≈ quoteIn.outAmount > desiredOut = amountOutMin for
376+
/// any amount where the quoter overshoots, forcing the trimming guard to fire.
377+
///
378+
/// With the NEW guard (whole-quantum overshoot passes through):
379+
/// vaultBalance ≈ quoteOutAmount (whole-quantum overshoot passed to caller)
380+
/// COA accumulates only sub-quantum EVM wei (< 1 quantum = 0.00000001)
381+
///
382+
/// With the OLD guard (cap at amountOutMin = desiredOut):
383+
/// vaultBalance = desiredOut < quoteOutAmount → assertion FAILS
384+
/// COA accumulates the entire quote overshoot (up to 87 quanta)
385+
///
386+
/// Example: EVM overshoot = 0.0000001234, desiredOut = amountOutMin
387+
/// Whole quanta passed through to caller: 0.00000012 (12 × 0.00000001)
388+
/// Sub-quantum remainder stays in COA: 0.0000000034
377389
///
378390
access(all) fun assertSwapDust(results: [[UFix64]]) {
379391
var testedCount = 0
380392
var skippedCount = 0
381-
var totalOvershoot = 0.0
393+
var totalQuoteOvershoot = 0.0
394+
var totalVaultOvershoot = 0.0
382395
var totalDustInCOA = 0.0
383396

384397
for row in results {
@@ -400,10 +413,16 @@ access(all) fun assertSwapDust(results: [[UFix64]]) {
400413

401414
testedCount = testedCount + 1
402415

403-
let overshoot = quoteOutAmount >= desiredOut
416+
let quoteOvershoot = quoteOutAmount >= desiredOut
404417
? quoteOutAmount - desiredOut
405418
: 0.0
406-
totalOvershoot = totalOvershoot + overshoot
419+
totalQuoteOvershoot = totalQuoteOvershoot + quoteOvershoot
420+
421+
// Whole-quantum overshoot received by caller (sub-quantum remainder stays in COA)
422+
let vaultOvershoot = vaultBalance > quoteOutAmount
423+
? vaultBalance - quoteOutAmount
424+
: 0.0
425+
totalVaultOvershoot = totalVaultOvershoot + vaultOvershoot
407426

408427
let dustInCOA = coaDustAfter >= coaDustBefore
409428
? coaDustAfter - coaDustBefore
@@ -412,18 +431,23 @@ access(all) fun assertSwapDust(results: [[UFix64]]) {
412431

413432
log("---")
414433
log("[SWAP] Desired: \(desiredOut.toString()), Quote: \(quoteOutAmount.toString()), Returned: \(vaultBalance.toString())")
415-
log(" Overshoot/Dust => quote vs desired: +\(overshoot.toString()), stayed in COA: +\(dustInCOA.toString())")
416-
log(" COA balance => \(coaDustBefore.toString())\(coaDustAfter.toString())")
434+
log(" Quote overshoot vs desired: +\(quoteOvershoot.toString()), whole-quantum overshoot to caller: +\(vaultOvershoot.toString())")
435+
log(" COA balance => \(coaDustBefore.toString())\(coaDustAfter.toString()) (sub-quantum dust: +\(dustInCOA.toString()))")
417436

418-
Test.assertEqual(quoteOutAmount, vaultBalance)
437+
// Caller must receive at least the quoted amount; whole-quantum overshoot passes through.
438+
Test.assert(
439+
vaultBalance >= quoteOutAmount,
440+
message: "Vault balance \(vaultBalance.toString()) < quoteOutAmount \(quoteOutAmount.toString())"
441+
)
419442

420443
Test.assert(coaDustAfter >= coaDustBefore,
421-
message: "COA output-token balance decreased — overshoot/dust should accumulate in COA")
444+
message: "COA output-token balance decreased — sub-quantum remainder should accumulate in COA")
422445
}
423446

424447
Test.assert(testedCount > 0, message: "No test amounts could be swapped")
425448
log("=== PASSED: \(testedCount.toString()) swaps, \(skippedCount.toString()) skipped ===")
426-
log("=== Total overshoot/dust that stayed in COA: \(totalDustInCOA.toString()) ===")
449+
log("=== Total whole-quantum overshoot passed to callers: \(totalVaultOvershoot.toString()) ===")
450+
log("=== Total sub-quantum dust accumulated in COA: \(totalDustInCOA.toString()) ===")
427451
}
428452

429453
// --- Swap tests ---------------------------------------------------------------
@@ -457,14 +481,20 @@ access(all) fun testSwapDustStaysInCOA() {
457481

458482
/// Demonstrates the trimming guard in action on PYUSD → MOET swaps.
459483
///
460-
/// MOET is an 18-decimal ERC20, so `toCadenceOut` floors actual output to the
461-
/// nearest 10^10 wei (1 UFix64 quantum). When the router produces even slightly
462-
/// more output than the quoter predicted — because ceiled input crosses a
463-
/// quantum boundary — the trimming guard caps the bridged amount at
464-
/// `amountOutMin` and the excess stays in the COA.
484+
/// MOET is an 18-decimal ERC20, so `toCadenceOut` floors the actual EVM output
485+
/// to the nearest 10^10 wei (1 UFix64 quantum = 0.00000001). When the router
486+
/// produces more output than the quoter predicted — because ceiled input crosses
487+
/// a quantum boundary — the overshoot is handled as follows:
488+
///
489+
/// Whole-quantum portion (N × 0.00000001): passed through to caller
490+
/// Sub-quantum EVM remainder (< 0.00000001): stays in COA
491+
///
492+
/// Example: EVM overshoot = 0.0000001234
493+
/// Caller receives quoteOutAmount + 0.00000012 (12 whole quanta)
494+
/// COA retains 0.0000000034 (sub-quantum remainder)
465495
///
466496
/// Many fractional amounts are tested to maximise the chance of hitting
467-
/// quantum-boundary crossings that produce observable dust.
497+
/// quantum-boundary crossings that produce observable overshoot.
468498
///
469499
access(all) fun testSwapOvershootStaysInCOA() {
470500
let signer = Test.getAccount(0x47f544294e3b7656)
@@ -475,22 +505,22 @@ access(all) fun testSwapOvershootStaysInCOA() {
475505

476506
let testAmounts = [
477507
0.00987654,
478-
0.01000000,
508+
0.01000000, // +69 quanta quote overshoot (highest observed in quote test)
479509
0.03456789,
480510
0.05432109,
481511
0.10000000,
482-
0.12345678, // dust hit in first run
512+
0.12345678, // +44 quanta quote overshoot — confirmed COA dust accumulation
483513
0.20000000,
484-
0.23456789, // dust hit
514+
0.23456789, // +8 quanta quote overshoot — confirmed COA dust accumulation
485515
0.34567890,
486516
0.45019707,
487517
0.56789012,
488518
0.67890123,
489-
0.78901234, // dust hit
490-
1.00000000, // dust hit (even with 0 quote overshoot)
519+
0.78901234, // confirmed COA dust accumulation
520+
1.00000000, // confirmed COA dust accumulation (swap-level overshoot, 0 quote overshoot)
491521
1.23456789,
492522
1.50000000,
493-
2.34567890, // dust hit
523+
2.34567890, // confirmed COA dust accumulation
494524
3.45678901,
495525
5.00000000
496526
]
@@ -504,5 +534,29 @@ access(all) fun testSwapOvershootStaysInCOA() {
504534
)
505535

506536
assertSwapDust(results: results)
537+
538+
// Spot-check: 0.01000000 (+69 quanta quote overshoot) and 0.12345678 (+44 quanta)
539+
// are confirmed cases where the quoter overshoots the desired output.
540+
// Because the swap uses amountOutMin = desiredOut, the trimming guard fires and
541+
// the full quote overshoot (in whole quanta) must reach the caller's vault.
542+
// With the OLD guard (cap at amountOutMin = desiredOut):
543+
// vaultBalance = desiredOut < quoteOutAmount → FAILS
544+
// With the NEW guard (whole-quantum overshoot passes through):
545+
// vaultBalance ≈ quoteOutAmount, coaDelta < 1 quantum → PASSES
546+
for row in results {
547+
let desiredOut = row[0]
548+
let quoteOutAmount = row[2]
549+
let vaultBalance = row[3]
550+
let coaDustBefore = row[4]
551+
let coaDustAfter = row[5]
552+
// With the new guard, whole-quantum overshoot reaches the caller.
553+
// vaultBalance must be at least quoteOutAmount (= desiredOut + quote_overshoot).
554+
Test.assert(vaultBalance >= quoteOutAmount,
555+
message: "[\(desiredOut.toString())] vaultBalance \(vaultBalance.toString()) < quoteOutAmount \(quoteOutAmount.toString()) — old cap-at-minimum guard still in effect")
556+
// Sub-quantum EVM remainder must be the only thing left in COA.
557+
let coaDelta = coaDustAfter > coaDustBefore ? coaDustAfter - coaDustBefore : 0.0
558+
Test.assert(coaDelta <= 0.00000001,
559+
message: "[\(desiredOut.toString())] COA accumulated \(coaDelta.toString()) MOET — whole-quantum overshoot should have been passed to caller, not left in COA")
560+
}
507561
}
508562

cadence/tests/transactions/uniswap-v3-swap-connectors/uniswap_v3_swap_overshoot_test.cdc

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@ import "FlowEVMBridgeConfig"
77
import "UniswapV3SwapConnectors"
88
import "DeFiActions"
99
import "EVMAmountUtils"
10+
import "SwapConnectors"
1011

1112
/// Tests actual V3 swap execution with tokenIn provisioning from holder.
1213
///
1314
/// Since forked emulator doesn't verify signatures, we can transfer tokens
1415
/// from any address (like a liquidity pool) to provision tokens for testing.
1516
///
17+
/// The swap is executed with amountOutMin = desiredOut (NOT quoteIn.outAmount),
18+
/// by constructing a SwapConnectors.BasicQuote with outAmount = desiredOut.
19+
/// This guarantees outUFix ≈ quoteIn.outAmount > desiredOut = amountOutMin,
20+
/// so the trimming guard is always exercised on amounts where quote overshoot > 0.
21+
///
1622
/// Result: [desiredOut, quoteInAmount, quoteOutAmount, vaultBalance, coaDustBefore, coaDustAfter]
1723
///
1824
transaction(
@@ -111,6 +117,18 @@ transaction(
111117
// --- Run single swap test ---
112118
let quoteIn = swapper.quoteIn(forDesired: desiredOut, reverse: false)
113119

120+
// Build a lower-bound quote: same inAmount as the real quote, but outAmount = desiredOut.
121+
// This sets amountOutMin = desiredOut inside _swapExactIn, guaranteeing
122+
// outUFix ≈ quoteIn.outAmount > desiredOut = amountOutMin on amounts where
123+
// the quoter overshoots (quote overshoot > 0). The trimming guard is
124+
// therefore always exercised for such amounts.
125+
let lowerBoundQuote = SwapConnectors.BasicQuote(
126+
inType: quoteIn.inType,
127+
outType: quoteIn.outType,
128+
inAmount: quoteIn.inAmount,
129+
outAmount: desiredOut
130+
)
131+
114132
// COA output-token ERC20 balance before swap
115133
let outBefore = FlowEVMBridgeUtils.balanceOf(
116134
owner: coaAddr, evmContractAddress: tokenOut
@@ -128,9 +146,9 @@ transaction(
128146
var outAfterCadence = outBeforeCadence
129147

130148
if canSwap {
131-
// Withdraw exact quoteIn.inAmount and swap
149+
// Withdraw exact quoteIn.inAmount and swap using the lower-bound quote.
132150
let inVault <- tokenInRef.withdraw(amount: quoteIn.inAmount)
133-
let outVault <- swapper.swap(quote: quoteIn, inVault: <-inVault)
151+
let outVault <- swapper.swap(quote: lowerBoundQuote, inVault: <-inVault)
134152
vaultBalance = outVault.balance
135153

136154
// COA output-token ERC20 balance after swap

0 commit comments

Comments
 (0)