Smart Contract

UniswapV3SwapConnectors

A.a7825d405ac89518.UniswapV3SwapConnectors

Valid From

142,860,406

Deployed

2w ago
Feb 13, 2026, 05:25:40 PM UTC

Dependents

0 imports
1import FungibleToken from 0xf233dcee88fe0abe
2import FlowToken from 0x1654653399040a61
3import Burner from 0xf233dcee88fe0abe
4import EVM from 0xe467b9dd11fa00df
5import FlowEVMBridgeUtils from 0x1e4aa0b87d10b141
6import FlowEVMBridgeConfig from 0x1e4aa0b87d10b141
7
8import DeFiActions from 0x6d888f175c158410
9import SwapConnectors from 0xe1a479f0cb911df9
10import EVMAbiHelpers from 0xa7825d405ac89518
11import EVMAmountUtils from 0x43c9e8bfec507db4
12
13/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14/// THIS CONTRACT IS IN BETA AND IS NOT FINALIZED - INTERFACES MAY CHANGE AND/OR PENDING CHANGES MAY REQUIRE REDEPLOYMENT
15/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16///
17/// UniswapV3SwapConnectors
18///
19/// DeFiActions Swapper connector implementation for Uniswap V3 routers on Flow EVM.
20/// Supports single-hop and multi-hop swaps using exactInput / exactInputSingle and Quoter for estimates.
21///
22access(all) contract UniswapV3SwapConnectors {
23
24    /// ExactInputSingleParams facilitates the ABI encoding/decoding of the
25    /// Solidity tuple expected in `ISwapRouter.exactInput` function.
26    access(all) struct ExactInputSingleParams {
27        access(all) let path: EVM.EVMBytes
28        access(all) let recipient: EVM.EVMAddress
29        access(all) let amountIn: UInt256
30        access(all) let amountOutMinimum: UInt256
31
32        init(
33            path: EVM.EVMBytes,
34            recipient: EVM.EVMAddress,
35            amountIn: UInt256,
36            amountOutMinimum: UInt256
37        ) {
38            self.path = path
39            self.recipient = recipient
40            self.amountIn = amountIn
41            self.amountOutMinimum = amountOutMinimum
42        }
43    }
44
45    /// Swapper
46    access(all) struct Swapper: DeFiActions.Swapper {
47        access(all) let routerAddress: EVM.EVMAddress
48        access(all) let quoterAddress: EVM.EVMAddress
49        access(self) let factoryAddress: EVM.EVMAddress
50
51        access(all) let tokenPath: [EVM.EVMAddress]
52        access(all) let feePath: [UInt32]
53
54        access(contract) var uniqueID: DeFiActions.UniqueIdentifier?
55
56        access(self) let inVault: Type
57        access(self) let outVault: Type
58
59        access(self) let coaCapability: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>
60
61        init(
62            factoryAddress: EVM.EVMAddress,
63            routerAddress: EVM.EVMAddress,
64            quoterAddress: EVM.EVMAddress,
65            tokenPath: [EVM.EVMAddress],
66            feePath: [UInt32],
67            inVault: Type,
68            outVault: Type,
69            coaCapability: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>,
70            uniqueID: DeFiActions.UniqueIdentifier?
71        ) {
72            pre {
73                tokenPath.length >= 2: "tokenPath must contain at least two addresses"
74                feePath.length == tokenPath.length - 1: "feePath length must be tokenPath.length - 1"
75                FlowEVMBridgeConfig.getTypeAssociated(with: tokenPath[0]) == inVault:
76                    "Provided inVault \(inVault.identifier) is not associated with ERC20 at tokenPath[0]"
77                FlowEVMBridgeConfig.getTypeAssociated(with: tokenPath[tokenPath.length - 1]) == outVault:
78                    "Provided outVault \(outVault.identifier) is not associated with ERC20 at tokenPath[last]"
79                coaCapability.check():
80                    "Provided COA Capability is invalid - need Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>"
81            }
82            self.factoryAddress = factoryAddress
83            self.routerAddress = routerAddress
84            self.quoterAddress = quoterAddress
85            self.tokenPath = tokenPath
86            self.feePath = feePath
87            self.inVault = inVault
88            self.outVault = outVault
89            self.coaCapability = coaCapability
90            self.uniqueID = uniqueID
91        }
92
93        /* --- DeFiActions.Swapper conformance --- */
94
95        access(all) fun getComponentInfo(): DeFiActions.ComponentInfo {
96            return DeFiActions.ComponentInfo(
97                type: self.getType(),
98                id: self.uniqueID?.id,
99                innerComponents: []
100            )
101        }
102
103        access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID }
104        access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id }
105
106        access(all) view fun inType(): Type { return self.inVault }
107        access(all) view fun outType(): Type { return self.outVault }
108
109
110        access(self) view fun outToken(_ reverse: Bool): EVM.EVMAddress {
111            if reverse {
112                return self.tokenPath[0]
113            }
114            return self.tokenPath[self.tokenPath.length - 1]
115        }
116        access(self) view fun inToken(_ reverse: Bool): EVM.EVMAddress {
117            if reverse {
118                return self.tokenPath[self.tokenPath.length - 1]
119            }
120            return self.tokenPath[0]
121        }
122
123        /// Estimate required input for a desired output
124        access(all) fun quoteIn(forDesired: UFix64, reverse: Bool): {DeFiActions.Quote} {
125            // OUT token for this direction
126            let outToken = self.outToken(reverse)
127            let desiredOutEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
128                forDesired,
129                erc20Address: outToken
130            )
131
132            // Derive true Uniswap direction for pool math
133            let zeroForOne = self.isZeroForOne(reverse: reverse)
134
135            // Max INPUT proxy in correct pool terms
136            // TODO: Multi-hop clamp currently uses the first pool (tokenPath[0]/[1]) even in reverse;
137            // consider clamping per-hop or disabling clamp when tokenPath.length > 2.
138            let maxInEVM = self.getMaxInAmount(zeroForOne: zeroForOne)
139
140            // If clamp proxy is 0, don't clamp — it's a truncation/edge case
141            var safeOutEVM = desiredOutEVM
142
143            if maxInEVM > 0 {
144                // Translate max input -> max output using exactInput quote
145                if let maxOutCadence = self.getV3Quote(out: true, amount: maxInEVM, reverse: reverse) {
146                    let maxOutEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
147                        maxOutCadence,
148                        erc20Address: outToken
149                    )
150                    if safeOutEVM > maxOutEVM {
151                        safeOutEVM = maxOutEVM
152                    }
153                }
154                // If maxOutCadence is nil, we also skip clamping (better than forcing 0)
155            }
156
157            let safeOutCadence = self._toCadenceOut(
158                safeOutEVM,
159                erc20Address: outToken
160            )
161
162            // ExactOutput quote: how much IN required for safeOutEVM OUT
163            let amountInCadence = self.getV3Quote(out: false, amount: safeOutEVM, reverse: reverse)
164
165            // Refine outAmount: the ceiled input may produce more output than safeOutCadence
166            // because (a) UFix64 ceiling rounds the input up and (b) the pool's exactOutput/
167            // exactInput math is not perfectly invertible.  Do a follow-up exactInput quote
168            // with the ceiled input so that quoteIn.outAmount matches what a subsequent
169            // quoteOut(forProvided: ceiledInput) would return.  This keeps quote-level dust
170            // bounded at ≤ 1 UFix64 quantum (0.00000001).
171            var refinedOutCadence = safeOutCadence
172            if let inCadence = amountInCadence {
173                let inToken = self.inToken(reverse)
174                let ceiledInEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
175                    inCadence,
176                    erc20Address: inToken
177                )
178                if let forwardOut = self.getV3Quote(out: true, amount: ceiledInEVM, reverse: reverse) {
179                    refinedOutCadence = forwardOut
180                }
181            }
182
183            return SwapConnectors.BasicQuote(
184                inType: reverse ? self.outType() : self.inType(),
185                outType: reverse ? self.inType() : self.outType(),
186                inAmount: amountInCadence ?? 0.0,
187                outAmount: amountInCadence != nil ? refinedOutCadence : 0.0
188            )
189        }
190
191        /// Estimate output for a provided input
192        access(all) fun quoteOut(forProvided: UFix64, reverse: Bool): {DeFiActions.Quote} {
193            // IN token for this direction
194            let inToken = self.inToken(reverse)
195            let providedInEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
196                forProvided,
197                erc20Address: inToken
198            )
199
200            // Max INPUT proxy in correct pool terms
201            // TODO: Multi-hop clamp currently uses the first pool (tokenPath[0]/[1]) even in reverse;
202            // consider clamping per-hop or disabling clamp when tokenPath.length > 2.
203            let maxInEVM = self.maxInAmount(reverse: reverse)
204
205            // If clamp proxy is 0, don't clamp — it's a truncation/edge case
206            var safeInEVM = providedInEVM
207            if maxInEVM > 0 && safeInEVM > maxInEVM {
208                safeInEVM = maxInEVM
209            }
210
211            // Provided IN amount => ceil
212            let safeInCadence = self._toCadenceIn(
213                safeInEVM,
214                erc20Address: inToken
215            )
216
217            // ExactInput quote: how much OUT for safeInEVM IN
218            let amountOutCadence = self.getV3Quote(out: true, amount: safeInEVM, reverse: reverse)
219
220            return SwapConnectors.BasicQuote(
221                inType: reverse ? self.outType() : self.inType(),
222                outType: reverse ? self.inType() : self.outType(),
223                inAmount: amountOutCadence != nil ? safeInCadence : 0.0,
224                outAmount: amountOutCadence ?? 0.0
225            )
226        }
227
228        /// Swap exact input -> min output using Uniswap V3 exactInput/Single
229        access(all) fun swap(quote: {DeFiActions.Quote}?, inVault: @{FungibleToken.Vault}): @{FungibleToken.Vault} {
230            let minOut = quote?.outAmount ?? self.quoteOut(forProvided: inVault.balance, reverse: false).outAmount
231            return <- self._swapExactIn(exactVaultIn: <-inVault, amountOutMin: minOut, reverse: false)
232        }
233
234        /// Swap back (exact input of residual -> min output)
235        access(all) fun swapBack(quote: {DeFiActions.Quote}?, residual: @{FungibleToken.Vault}): @{FungibleToken.Vault} {
236            let minOut = quote?.outAmount ?? self.quoteOut(forProvided: residual.balance, reverse: true).outAmount
237            return <- self._swapExactIn(exactVaultIn: <-residual, amountOutMin: minOut, reverse: true)
238        }
239
240        /* --- Core swap / quote internals --- */
241
242        /// Build Uniswap V3 path bytes:
243        /// token0(20) | fee0(3) | token1(20) | fee1(3) | token2(20) | ...
244        access(self) fun _buildPathBytes(reverse: Bool): EVM.EVMBytes {
245            var out: [UInt8] = []
246
247            // helper to append address bytes
248            fun appendAddr(_ a: EVM.EVMAddress) {
249                let fixed: [UInt8; 20] = a.bytes
250                var i = 0
251                while i < 20 {
252                    out.append(fixed[i])
253                    i = i + 1
254                }
255            }
256
257            // helper to append uint24 fee big-endian
258            fun appendFee(_ f: UInt32) {
259                // validate fee fits uint24
260                pre { f <= 0xFFFFFF: "feePath element exceeds uint24" }
261                out.append(UInt8((f >> 16) & 0xFF))
262                out.append(UInt8((f >> 8) & 0xFF))
263                out.append(UInt8(f & 0xFF))
264            }
265
266            let nHops = self.feePath.length
267            let last = self.tokenPath.length - 1
268
269            // choose first token based on direction
270            let first = reverse ? self.tokenPath[last] : self.tokenPath[0]
271            appendAddr(first)
272
273            var i = 0
274            while i < nHops {
275                let feeIdx = reverse ? (nHops - 1 - i) : i
276                let nextIdx = reverse ? (last - (i + 1)) : (i + 1)
277
278                appendFee(self.feePath[feeIdx])
279                appendAddr(self.tokenPath[nextIdx])
280
281                i = i + 1
282            }
283
284            return EVM.EVMBytes(value: out)
285        }
286
287        access(self) fun getPoolAddress(): EVM.EVMAddress {
288            let res = self._dryCall(
289                self.factoryAddress,
290                "getPool(address,address,uint24)",
291                [ self.tokenPath[0], self.tokenPath[1], UInt256(self.feePath[0]) ],
292                120_000
293            )!
294            assert(res.status == EVM.Status.successful, message: "unable to get pool: token0 \(self.tokenPath[0].toString()), token1 \(self.tokenPath[1].toString()), feePath: self.feePath[0]")
295
296            // ABI return is one 32-byte word; the last 20 bytes are the address
297            let word = res.data as! [UInt8]
298            if word.length < 32 { panic("getPool: invalid ABI word length") }
299
300            let addrSlice = word.slice(from: 12, upTo: 32)   // 20 bytes
301            let addrBytes: [UInt8; 20] = addrSlice.toConstantSized<[UInt8; 20]>()!
302
303            return EVM.EVMAddress(bytes: addrBytes)
304        }
305
306        access(self) fun maxInAmount(reverse: Bool): UInt256 {
307            let zeroForOne = self.isZeroForOne(reverse: reverse)
308            return self.getMaxInAmount(zeroForOne: zeroForOne)
309        }
310
311        /// Simplified max input calculation using default 6% price impact
312        /// Uses current liquidity as proxy for max swappable input amount
313        access(self) fun getMaxInAmount(zeroForOne: Bool): UInt256 {
314            let poolEVMAddress = self.getPoolAddress()
315            
316            // Helper functions
317            fun wordToUInt(_ w: [UInt8]): UInt {
318                var acc: UInt = 0
319                var i = 0
320                while i < 32 { acc = (acc << 8) | UInt(w[i]); i = i + 1 }
321                return acc
322            }
323            fun wordToUIntN(_ w: [UInt8], _ nBits: Int): UInt {
324                let full = wordToUInt(w)
325                if nBits >= 256 { return full }
326                let mask: UInt = (UInt(1) << UInt(nBits)) - UInt(1)
327                return full & mask
328            }
329            fun words(_ data: [UInt8]): [[UInt8]] {
330                let n = data.length / 32
331                var out: [[UInt8]] = []
332                var i = 0
333                while i < n {
334                    out.append(data.slice(from: i*32, upTo: (i+1)*32))
335                    i = i + 1
336                }
337                return out
338            }
339            
340            // Selectors
341            let SEL_SLOT0: [UInt8] = [0x38, 0x50, 0xc7, 0xbd]
342            let SEL_LIQUIDITY: [UInt8] = [0x1a, 0x68, 0x65, 0x02]
343            
344            // Get slot0 (sqrtPriceX96, tick, etc.)
345            let s0Res: EVM.Result? = self._dryCallRaw(
346                to: poolEVMAddress,
347                calldata: EVMAbiHelpers.buildCalldata(selector: SEL_SLOT0, args: []),
348                gasLimit: 1_000_000,
349            )
350            let s0w = words(s0Res!.data)
351            let sqrtPriceX96 = wordToUIntN(s0w[0], 160)
352            
353            // Get current active liquidity
354            let liqRes: EVM.Result? = self._dryCallRaw(
355                to: poolEVMAddress,
356                calldata: EVMAbiHelpers.buildCalldata(selector: SEL_LIQUIDITY, args: []),
357                gasLimit: 300_000,
358            )
359            let L = wordToUIntN(words(liqRes!.data)[0], 128)
360            
361            // Calculate price multiplier based on 6% price impact (600 bps)
362            // Use UInt256 throughout to prevent overflow in multiplication operations
363            let bps: UInt256 = 600
364            let Q96: UInt256 = 0x1000000000000000000000000
365            let sqrtPriceX96_256: UInt256 = UInt256(sqrtPriceX96)
366            let L_256: UInt256 = UInt256(L)
367            
368            var maxAmount: UInt256 = 0
369            if zeroForOne {
370                // Swapping token0 -> token1 (price decreases by maxPriceImpactBps)
371                // Formula: Δx = L * (√P - √P') / (√P * √P')
372                // Approximation: √P' ≈ √P * (1 - priceImpact/2)
373                let sqrtMultiplier: UInt256 = 10000 - (bps / 2)
374                let sqrtPriceNew: UInt256 = (sqrtPriceX96_256 * sqrtMultiplier) / 10000
375                
376                // Uniswap V3 spec: getAmount0Delta
377                // Δx = L * (√P - √P') / (√P * √P')
378                // Since sqrt prices are in Q96 format: (L * ΔsqrtP * Q96) / (sqrtP * sqrtP')
379                // This gives us native token0 units after the two Q96 divisions cancel with one Q96 multiplication
380                let num1: UInt256 = L_256 * bps
381                let num2: UInt256 = num1 * Q96
382                let den: UInt256  = UInt256(20000) * sqrtPriceNew
383                maxAmount = den == 0 ? UInt256(0) : num2 / den
384            } else {
385                // Swapping token1 -> token0 (price increases by maxPriceImpactBps)
386                // Formula: Δy = L * (√P' - √P)
387                // Approximation: √P' ≈ √P * (1 + priceImpact/2)
388                let sqrtMultiplier: UInt256 = 10000 + (bps / 2)
389                let sqrtPriceNew: UInt256 = (sqrtPriceX96_256 * sqrtMultiplier) / 10000
390                let deltaSqrt: UInt256 = sqrtPriceNew - sqrtPriceX96_256
391                
392                // Uniswap V3 spec: getAmount1Delta
393                // Δy = L * (√P' - √P)
394                // Divide by Q96 to convert from Q96 format to native token units
395                maxAmount = (L_256 * deltaSqrt) / Q96
396            }
397            
398            return maxAmount
399        }
400
401        /// Quote using the Uniswap V3 Quoter via dryCall
402        access(self) fun getV3Quote(out: Bool, amount: UInt256, reverse: Bool): UFix64? {
403            // For exactOutput, the path must be reversed (tokenOut -> ... -> tokenIn)
404            let pathReverse = out ? reverse : !reverse
405            let pathBytes = self._buildPathBytes(reverse: pathReverse)
406
407            let callSig = out
408                ? "quoteExactInput(bytes,uint256)"
409                : "quoteExactOutput(bytes,uint256)"
410
411            let args: [AnyStruct] = [pathBytes, amount]
412
413            let res = self._dryCall(self.quoterAddress, callSig, args, 10_000_000)
414            if res == nil || res!.status != EVM.Status.successful { return nil }
415
416            let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: res!.data)
417            if decoded.length == 0 { return nil }
418            let uintAmt = decoded[0] as! UInt256
419
420            let ercAddr = out
421                ? self.outToken(reverse)
422                : self.inToken(reverse)
423
424            // out == true  => quoteExactInput  => result is an OUT amount => floor
425            // out == false => quoteExactOutput => result is an IN amount  => ceil
426            if out {
427                return self._toCadenceOut(uintAmt, erc20Address: ercAddr)
428            } else {
429                return self._toCadenceIn(uintAmt, erc20Address: ercAddr)
430            }
431        }
432
433        /// Executes exact input swap via router
434        access(self) fun _swapExactIn(exactVaultIn: @{FungibleToken.Vault}, amountOutMin: UFix64, reverse: Bool): @{FungibleToken.Vault} {
435            let id = self.uniqueID?.id?.toString() ?? "UNASSIGNED"
436            let idType = self.uniqueID?.getType()?.identifier ?? "UNASSIGNED"
437            let coa = self.borrowCOA()
438                ?? panic("Invalid COA Capability in V3 Swapper \(self.getType().identifier) ID \(idType)#\(id)")
439
440            // Bridge fee
441            let bridgeFeeBalance = EVM.Balance(attoflow: 0)
442            bridgeFeeBalance.setFLOW(flow: 2.0 * FlowEVMBridgeUtils.calculateBridgeFee(bytes: 256))
443            let feeVault <- coa.withdraw(balance: bridgeFeeBalance)
444            let feeVaultRef = &feeVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
445
446            // I/O tokens
447            let inToken = self.inToken(reverse)
448            let outToken = self.outToken(reverse)
449
450            // Bridge input to EVM
451            let evmAmountIn = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(exactVaultIn.balance, erc20Address: inToken)
452            coa.depositTokens(vault: <-exactVaultIn, feeProvider: feeVaultRef)
453
454            // Build path
455            let pathBytes = self._buildPathBytes(reverse: reverse)
456
457            // Approve
458            var res = self._call(
459                to: inToken,
460                signature: "approve(address,uint256)",
461                args: [self.routerAddress, evmAmountIn],
462                gasLimit: 120_000,
463                value: 0
464            )!
465            if res.status != EVM.Status.successful {
466                UniswapV3SwapConnectors._callError("approve(address,uint256)", res, inToken, idType, id, self.getType())
467            }
468
469            // Min out on EVM units
470            let minOutUint = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
471                amountOutMin,
472                erc20Address: outToken
473            )
474
475            let coaRef = self.borrowCOA()!
476            let recipient: EVM.EVMAddress = coaRef.address()
477
478            // optional dev guards
479            let _chkIn  = EVMAbiHelpers.abiUInt256(evmAmountIn)
480            let _chkMin = EVMAbiHelpers.abiUInt256(minOutUint)
481            //panic("path: \(EVMAbiHelpers.toHex(pathBytes.value)), amountIn: \(evmAmountIn.toString()), amountOutMin: \(minOutUint.toString())")
482            assert(_chkIn.length == 32,  message: "amountIn not 32 bytes")
483            assert(_chkMin.length == 32, message: "amountOutMin not 32 bytes")
484
485            let exactInputParams = UniswapV3SwapConnectors.ExactInputSingleParams(
486                path: pathBytes,
487                recipient: recipient,
488                amountIn: evmAmountIn,
489                amountOutMinimum: minOutUint
490            )
491
492            let calldata: [UInt8] = EVM.encodeABIWithSignature(
493                "exactInput((bytes,address,uint256,uint256))",
494                [exactInputParams]
495            )
496
497            // Call the router with raw calldata
498            let swapRes = self._callRaw(
499                to: self.routerAddress,
500                calldata: calldata,
501                gasLimit: 10_000_000,
502                value: 0
503            )!
504            if swapRes.status != EVM.Status.successful {
505                UniswapV3SwapConnectors._callError(
506                    EVMAbiHelpers.toHex(calldata),
507                    swapRes, self.routerAddress, idType, id, self.getType()
508                )
509            }
510            let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: swapRes.data)
511            let amountOut: UInt256 = decoded.length > 0 ? decoded[0] as! UInt256 : UInt256(0)
512
513            let outVaultType = reverse ? self.inType() : self.outType()
514            let outTokenEVMAddress =
515                FlowEVMBridgeConfig.getEVMAddressAssociated(with: outVaultType)
516                ?? panic("out token \(outVaultType.identifier) is not bridged")
517
518            let outUFix = self._toCadenceOut(
519                amountOut,
520                erc20Address: outTokenEVMAddress
521            )
522
523            // Defensive: ensure the router respected amountOutMinimum.
524            // Under normal operation the V3 router reverts when output < min, but guard
525            // against a buggy or malicious router contract.
526            assert(
527                amountOutMin == 0.0 || outUFix >= amountOutMin,
528                message: "UniswapV3SwapConnectors: swap output \(outUFix.toString()) < amountOutMin \(amountOutMin.toString())"
529            )
530
531            /// Quoting exact output then swapping exact input can overshoot by up to 0.00000001 (1 UFix64 quantum)
532            /// when the pool's effective exchange rate is near 1:1.
533            ///
534            /// UFix64 has 8 decimals; EVM tokens typically have 18. One UFix64 step = 10^10 wei.
535            ///
536            /// Example (pool price 1 FLOW = 2 USDC, want 10 USDC out):
537            ///   1. Quoter says need 5,000000002000000000 FLOW wei
538            ///   2. Ceil to UFix64:  5,000000010000000000  (overshoot: 8e9 wei)
539            ///   3. exactInput swaps the ceiled amount; extra 8e9 FLOW wei × 2 = 16e9 USDC wei extra
540            ///   4. Actual output:  10,000000016000000000 USDC wei
541            ///   5. Floor to UFix64: 10.00000001 USDC  (quoted 10.00000000)
542            ///
543            /// The overshoot is always non-negative (ceiled input >= what pool needs).
544            /// It surfaces when the extra output crosses a 10^10 wei quantum boundary.
545            /// Cap at amountOutMin so only the expected amount is bridged; dust stays in the COA.
546            let bridgeUFix = outUFix > amountOutMin && amountOutMin > 0.0 ? amountOutMin : outUFix
547            let dust = outUFix > bridgeUFix ? outUFix - bridgeUFix : 0.0
548            let safeAmountOut = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
549                bridgeUFix,
550                erc20Address: outTokenEVMAddress
551            )
552            // Withdraw output back to Flow; sub-quantum remainder and any overshoot stay in COA
553            let outVault <- coa.withdrawTokens(type: outVaultType, amount: safeAmountOut, feeProvider: feeVaultRef)
554
555            // Handle leftover fee vault
556            self._handleRemainingFeeVault(<-feeVault)
557            return <- outVault
558        }
559
560        /* --- Helpers --- */
561
562        access(self) view fun borrowCOA(): auth(EVM.Owner) &EVM.CadenceOwnedAccount? { return self.coaCapability.borrow() }
563
564        access(self) fun _dryCall(_ to: EVM.EVMAddress, _ signature: String, _ args: [AnyStruct], _ gas: UInt64): EVM.Result? {
565            let calldata = EVM.encodeABIWithSignature(signature, args)
566            let valueBalance = EVM.Balance(attoflow: 0)
567            if let coa = self.borrowCOA() {
568                return coa.dryCall(to: to, data: calldata, gasLimit: gas, value: valueBalance)
569            }
570            return nil
571        }
572
573        access(self) fun _dryCallRaw(to: EVM.EVMAddress, calldata: [UInt8], gasLimit: UInt64): EVM.Result? {
574            let valueBalance = EVM.Balance(attoflow: 0)
575            if let coa = self.borrowCOA() {
576                return coa.dryCall(to: to, data: calldata, gasLimit: gasLimit, value: valueBalance)
577            }
578            return nil
579        }
580
581        access(self) fun _call(to: EVM.EVMAddress, signature: String, args: [AnyStruct], gasLimit: UInt64, value: UInt): EVM.Result? {
582            let calldata = EVM.encodeABIWithSignature(signature, args)
583            let valueBalance = EVM.Balance(attoflow: value)
584            if let coa = self.borrowCOA() {
585                return coa.call(to: to, data: calldata, gasLimit: gasLimit, value: valueBalance)
586            }
587            return nil
588        }
589
590        access(self) fun _callRaw(to: EVM.EVMAddress, calldata: [UInt8], gasLimit: UInt64, value: UInt): EVM.Result? {
591            let valueBalance = EVM.Balance(attoflow: value)
592            if let coa = self.borrowCOA() {
593                return coa.call(to: to, data: calldata, gasLimit: gasLimit, value: valueBalance)
594            }
595            return nil
596        }
597
598        access(self) fun _handleRemainingFeeVault(_ vault: @FlowToken.Vault) {
599            if vault.balance > 0.0 {
600                self.borrowCOA()!.deposit(from: <-vault)
601            } else {
602                Burner.burn(<-vault)
603            }
604        }
605
606        /// OUT amounts: round down to UFix64 precision
607        access(self) fun _toCadenceOut(_ amt: UInt256, erc20Address: EVM.EVMAddress): UFix64 {
608            return EVMAmountUtils.toCadenceOutForToken(amt, erc20Address: erc20Address)
609        }
610
611        /// IN amounts: round up to the next UFix64 such that the ERC20 conversion
612        /// (via ufix64ToUInt256) is >= the original UInt256 amount.
613        access(self) fun _toCadenceIn(_ amt: UInt256, erc20Address: EVM.EVMAddress): UFix64 {
614            return EVMAmountUtils.toCadenceInForToken(amt, erc20Address: erc20Address)
615        }
616        access(self) fun getPoolToken0(_ pool: EVM.EVMAddress): EVM.EVMAddress {
617            // token0() selector = 0x0dfe1681
618            let SEL_TOKEN0: [UInt8] = [0x0d, 0xfe, 0x16, 0x81]
619            let res = self._dryCallRaw(
620                to: pool,
621                calldata: EVMAbiHelpers.buildCalldata(selector: SEL_TOKEN0, args: []),
622                gasLimit: 150_000,
623            )!
624            assert(res.status == EVM.Status.successful, message: "token0() call failed")
625
626            let word = res.data as! [UInt8]
627            let addrSlice = word.slice(from: 12, upTo: 32)
628            let addrBytes: [UInt8; 20] = addrSlice.toConstantSized<[UInt8; 20]>()!
629            return EVM.EVMAddress(bytes: addrBytes)
630        }
631
632        access(self) fun isZeroForOne(reverse: Bool): Bool {
633            let pool = self.getPoolAddress()
634            let token0 = self.getPoolToken0(pool)
635
636            // your actual input token for this swap direction:
637            let inToken = self.inToken(reverse)
638
639            return inToken.equals(token0)
640        }
641    }
642
643    /// Revert helper
644    access(self)
645    fun _callError(
646        _ signature: String,
647        _ res: EVM.Result,
648        _ target: EVM.EVMAddress,
649        _ uniqueIDType: String,
650        _ id: String,
651        _ swapperType: Type
652    ) {
653        panic(
654            "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"
655        )
656    }
657}
658