Smart Contract
UniswapV3SwapperConnectorV3
A.ca7ee55e4fc3251a.UniswapV3SwapperConnectorV3
1import FungibleToken from 0xf233dcee88fe0abe
2import FlowToken from 0x1654653399040a61
3import EVM from 0xe467b9dd11fa00df
4import Burner from 0xf233dcee88fe0abe
5import FlowEVMBridgeUtils from 0x1e4aa0b87d10b141
6import FlowEVMBridgeConfig from 0x1e4aa0b87d10b141
7import DeFiActions from 0xca7ee55e4fc3251a
8
9/// UniswapV3SwapperConnectorV3
10///
11/// DeFiActions Swapper connector for Uniswap V3 routers on Flow EVM.
12/// Based on the official FlowActions UniswapV3SwapConnectors pattern.
13///
14/// Supports single-hop and multi-hop swaps using exactInput with proper
15/// FlowEVMBridge integration for token bridging.
16///
17access(all) contract UniswapV3SwapperConnectorV3 {
18
19 /// Events
20 access(all) event SwapperCreated(
21 routerAddress: String,
22 tokenPath: [String],
23 feePath: [UInt32]
24 )
25 access(all) event SwapExecuted(
26 routerAddress: String,
27 amountIn: UFix64,
28 amountOut: UFix64,
29 tokenPath: [String]
30 )
31 access(all) event QuoteFetched(
32 quoterAddress: String,
33 amountIn: UFix64,
34 amountOut: UFix64
35 )
36
37 /// Storage paths
38 access(all) let AdminStoragePath: StoragePath
39
40 /// Default addresses for FlowSwap V3 on Flow EVM Mainnet
41 access(all) let defaultRouterAddress: EVM.EVMAddress
42 access(all) let defaultQuoterAddress: EVM.EVMAddress
43 access(all) let defaultFactoryAddress: EVM.EVMAddress
44
45 /// WFLOW address on Flow EVM Mainnet
46 access(all) let wflowAddress: EVM.EVMAddress
47
48 /// ABI Helper: Encode a UInt256 as 32 bytes (big-endian)
49 access(all) fun abiUInt256(_ value: UInt256): [UInt8] {
50 var result: [UInt8] = []
51 var remaining = value
52 var bytes: [UInt8] = []
53
54 if remaining == 0 {
55 bytes.append(0)
56 } else {
57 while remaining > 0 {
58 bytes.append(UInt8(remaining % 256))
59 remaining = remaining / 256
60 }
61 }
62
63 // Pad to 32 bytes
64 while bytes.length < 32 {
65 bytes.append(0)
66 }
67
68 // Reverse to get big-endian
69 var i = 31
70 while i >= 0 {
71 result.append(bytes[i])
72 if i == 0 { break }
73 i = i - 1
74 }
75
76 return result
77 }
78
79 /// ABI Helper: Encode an address as 32 bytes
80 access(all) fun abiAddress(_ addr: EVM.EVMAddress): [UInt8] {
81 var result: [UInt8] = []
82 // 12 bytes of zero padding
83 var i = 0
84 while i < 12 {
85 result.append(0)
86 i = i + 1
87 }
88 // 20 bytes of address
89 for byte in addr.bytes {
90 result.append(byte)
91 }
92 return result
93 }
94
95 /// ABI Helper: Encode dynamic bytes with length prefix
96 access(all) fun abiDynamicBytes(_ data: [UInt8]): [UInt8] {
97 var result: [UInt8] = []
98 // Length as uint256
99 result = result.concat(self.abiUInt256(UInt256(data.length)))
100 // Data
101 result = result.concat(data)
102 // Pad to 32-byte boundary
103 let padding = (32 - (data.length % 32)) % 32
104 var i = 0
105 while i < padding {
106 result.append(0)
107 i = i + 1
108 }
109 return result
110 }
111
112 /// ABI Helper: Encode word (32 bytes)
113 access(all) fun abiWord(_ value: UInt256): [UInt8] {
114 return self.abiUInt256(value)
115 }
116
117 /// Encode exactInput tuple: (bytes path, address recipient, uint256 amountIn, uint256 amountOutMin)
118 access(all) fun encodeExactInputTuple(
119 pathBytes: [UInt8],
120 recipient: EVM.EVMAddress,
121 amountIn: UInt256,
122 amountOutMin: UInt256
123 ): [UInt8] {
124 let tupleHeadSize = 32 * 4 // 4 fields: offset, address, uint256, uint256
125
126 var head: [[UInt8]] = []
127 var tail: [[UInt8]] = []
128
129 // 1) bytes path (dynamic) -> offset to tail (after head)
130 head.append(self.abiWord(UInt256(tupleHeadSize)))
131 tail.append(self.abiDynamicBytes(pathBytes))
132
133 // 2) address recipient
134 head.append(self.abiAddress(recipient))
135
136 // 3) uint256 amountIn
137 head.append(self.abiUInt256(amountIn))
138
139 // 4) uint256 amountOutMin
140 head.append(self.abiUInt256(amountOutMin))
141
142 // Concatenate head and tail
143 var result: [UInt8] = []
144 for part in head {
145 result = result.concat(part)
146 }
147 for part in tail {
148 result = result.concat(part)
149 }
150 return result
151 }
152
153 /// UniswapV3Swapper resource implementing DeFiActions.Swapper
154 access(all) resource UniswapV3Swapper: DeFiActions.Swapper {
155 /// Router address for V3 swaps
156 access(all) let routerAddress: EVM.EVMAddress
157 /// Quoter address for price quotes
158 access(all) let quoterAddress: EVM.EVMAddress
159 /// Factory address for pool lookups
160 access(all) let factoryAddress: EVM.EVMAddress
161
162 /// Token path for multi-hop swaps (at least 2 addresses)
163 access(all) let tokenPath: [EVM.EVMAddress]
164 /// Fee path for V3 pools (length = tokenPath.length - 1)
165 access(all) let feePath: [UInt32]
166
167 /// Input vault type (Cadence)
168 access(self) let inVaultType: Type
169 /// Output vault type (Cadence)
170 access(self) let outVaultType: Type
171
172 /// COA capability for EVM interactions
173 access(self) let coaCapability: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>
174
175 /// Track last execution effort (gas used)
176 access(self) var lastGasUsed: UInt64
177
178 init(
179 routerAddress: EVM.EVMAddress,
180 quoterAddress: EVM.EVMAddress,
181 factoryAddress: EVM.EVMAddress,
182 tokenPath: [EVM.EVMAddress],
183 feePath: [UInt32],
184 inVaultType: Type,
185 outVaultType: Type,
186 coaCapability: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>
187 ) {
188 pre {
189 tokenPath.length >= 2: "tokenPath must contain at least two addresses"
190 feePath.length == tokenPath.length - 1: "feePath length must be tokenPath.length - 1"
191 coaCapability.check(): "Invalid COA Capability"
192 }
193 self.routerAddress = routerAddress
194 self.quoterAddress = quoterAddress
195 self.factoryAddress = factoryAddress
196 self.tokenPath = tokenPath
197 self.feePath = feePath
198 self.inVaultType = inVaultType
199 self.outVaultType = outVaultType
200 self.coaCapability = coaCapability
201 self.lastGasUsed = 0
202 }
203
204 /// Build V3 path bytes: token0 + fee0 + token1 + fee1 + token2 ...
205 access(self) fun buildPathBytes(reverse: Bool): [UInt8] {
206 var path: [UInt8] = []
207
208 // Build indices based on direction
209 let length = self.tokenPath.length
210
211 var i = 0
212 while i < length {
213 // Get index based on direction
214 let tokenIdx = reverse ? (length - 1 - i) : i
215 let feeIdx = reverse ? (self.feePath.length - 1 - i) : i
216
217 // Add token address (20 bytes)
218 for byte in self.tokenPath[tokenIdx].bytes {
219 path.append(byte)
220 }
221
222 // Add fee tier (3 bytes, big-endian) if not last token
223 if i < self.feePath.length {
224 let fee = self.feePath[feeIdx]
225 path.append(UInt8((fee >> 16) & 0xFF))
226 path.append(UInt8((fee >> 8) & 0xFF))
227 path.append(UInt8(fee & 0xFF))
228 }
229 i = i + 1
230 }
231 return path
232 }
233
234 /// Execute swap on Uniswap V3 via exactInput
235 /// Uses FlowEVMBridge for token bridging - the bridge handles FLOW↔WFLOW automatically
236 access(all) fun swap(
237 inVault: @{FungibleToken.Vault},
238 quote: DeFiActions.Quote
239 ): @{FungibleToken.Vault} {
240 let originalAmount = inVault.balance
241 let minOut = quote.minAmount
242
243 let coa = self.coaCapability.borrow()
244 ?? panic("Invalid COA Capability")
245
246 // Get input/output token addresses from path
247 let inToken = self.tokenPath[0]
248 let outToken = self.tokenPath[self.tokenPath.length - 1]
249
250 // Round DOWN to nearest 10^10 wei (Cadence-compatible precision)
251 let evmWei = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
252 originalAmount,
253 erc20Address: inToken
254 )
255
256 let precision: UInt256 = 10_000_000_000 // 10^10
257 let cleanWei = (evmWei / precision) * precision
258
259 let cleanAmount = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(
260 cleanWei,
261 erc20Address: inToken
262 )
263
264 let amountIn = cleanAmount > 0.0 ? cleanAmount : originalAmount
265
266 // Withdraw only the clean amount from the vault
267 var vaultToBridge: @{FungibleToken.Vault}? <- nil
268 if amountIn < originalAmount && amountIn > 0.0 {
269 vaultToBridge <-! inVault.withdraw(amount: amountIn)
270 destroy inVault
271 } else {
272 vaultToBridge <-! inVault
273 }
274
275 // Convert amounts to EVM format
276 let evmAmountIn = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
277 amountIn,
278 erc20Address: inToken
279 )
280 let evmAmountOutMin = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
281 minOut,
282 erc20Address: outToken
283 )
284
285 // Calculate bridge fee (2x for deposit + withdraw)
286 let bridgeFee = 2.0 * FlowEVMBridgeUtils.calculateBridgeFee(bytes: 256)
287 let bridgeFeeBalance = EVM.Balance(attoflow: 0)
288 bridgeFeeBalance.setFLOW(flow: bridgeFee)
289
290 // Check COA balance for fees
291 let coaBalance = coa.balance()
292 let requiredAttoflow = bridgeFeeBalance.attoflow
293
294 if coaBalance.attoflow < requiredAttoflow {
295 if self.inVaultType == Type<@FlowToken.Vault>() {
296 let feeDeposit = 0.02
297 var tempVault <- vaultToBridge <- nil
298 let unwrappedVault <- tempVault!
299 let feeFunding <- unwrappedVault.withdraw(amount: feeDeposit)
300 vaultToBridge <-! unwrappedVault
301 coa.deposit(from: <-(feeFunding as! @FlowToken.Vault))
302 } else {
303 panic("COA has insufficient FLOW for bridge fees. Please fund your COA.")
304 }
305 }
306
307 let feeVault <- coa.withdraw(balance: bridgeFeeBalance)
308 let feeVaultRef = &feeVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
309
310 // Bridge input tokens to EVM
311 coa.depositTokens(vault: <-vaultToBridge!, feeProvider: feeVaultRef)
312
313 // Build V3 path bytes
314 let pathBytes = self.buildPathBytes(reverse: false)
315
316 // Approve router to spend input tokens
317 let approveData = EVM.encodeABIWithSignature(
318 "approve(address,uint256)",
319 [self.routerAddress, evmAmountIn]
320 )
321 let approveResult = coa.call(
322 to: inToken,
323 data: approveData,
324 gasLimit: 120_000,
325 value: EVM.Balance(attoflow: 0)
326 )
327 if approveResult.status != EVM.Status.successful {
328 panic("Failed to approve router: ".concat(approveResult.errorMessage))
329 }
330
331 // exactInput selector: 0xb858183f
332 let selector: [UInt8] = [0xb8, 0x58, 0x18, 0x3f]
333
334 let argsBlob = UniswapV3SwapperConnectorV3.encodeExactInputTuple(
335 pathBytes: pathBytes,
336 recipient: coa.address(),
337 amountIn: evmAmountIn,
338 amountOutMin: evmAmountOutMin
339 )
340
341 let head = UniswapV3SwapperConnectorV3.abiWord(UInt256(32))
342 let calldata = selector.concat(head).concat(argsBlob)
343
344 // Execute the swap
345 var swapResult = coa.call(
346 to: self.routerAddress,
347 data: calldata,
348 gasLimit: 2_000_000,
349 value: EVM.Balance(attoflow: 0)
350 )
351
352 var evmAmountOut: UInt256 = 0
353
354 if swapResult.status == EVM.Status.successful {
355 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: swapResult.data)
356 evmAmountOut = decoded.length > 0 ? decoded[0] as! UInt256 : UInt256(0)
357 self.lastGasUsed = swapResult.gasUsed
358 } else {
359 panic("V3 swap FAILED. Error: ".concat(swapResult.errorMessage))
360 }
361
362 // Query output token decimals
363 let decimalsData = EVM.encodeABIWithSignature("decimals()", [])
364 let decimalsResult = coa.call(
365 to: outToken,
366 data: decimalsData,
367 gasLimit: 50_000,
368 value: EVM.Balance(attoflow: 0)
369 )
370
371 var outDecimals: UInt8 = 18
372 if decimalsResult.status == EVM.Status.successful && decimalsResult.data.length > 0 {
373 let decoded = EVM.decodeABI(types: [Type<UInt8>()], data: decimalsResult.data)
374 if decoded.length > 0 {
375 outDecimals = decoded[0] as! UInt8
376 }
377 }
378
379 // Calculate precision for output
380 var outputPrecision: UInt256 = 1
381 if outDecimals > 8 {
382 let exponent = outDecimals - 8
383 var i: UInt8 = 0
384 while i < exponent {
385 outputPrecision = outputPrecision * 10
386 i = i + 1
387 }
388 }
389
390 let cleanAmountOut = (evmAmountOut / outputPrecision) * outputPrecision
391
392 if cleanAmountOut == 0 {
393 panic("Swap returned zero output after precision rounding")
394 }
395
396 // Withdraw output tokens back to Cadence
397 let outVault <- coa.withdrawTokens(
398 type: self.outVaultType,
399 amount: cleanAmountOut,
400 feeProvider: feeVaultRef
401 )
402
403 // Handle leftover fee vault
404 if feeVault.balance > 0.0 {
405 coa.deposit(from: <-feeVault)
406 } else {
407 Burner.burn(<-feeVault)
408 }
409
410 let cadenceAmountOut = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(
411 cleanAmountOut,
412 erc20Address: outToken
413 )
414
415 emit SwapExecuted(
416 routerAddress: self.routerAddress.toString(),
417 amountIn: amountIn,
418 amountOut: cadenceAmountOut,
419 tokenPath: self.getTokenPathStrings()
420 )
421
422 return <- outVault
423 }
424
425 /// Get quote using V3 Quoter
426 access(all) fun getQuote(
427 fromTokenType: Type,
428 toTokenType: Type,
429 amount: UFix64
430 ): DeFiActions.Quote {
431 let coa = self.coaCapability.borrow()
432
433 if coa != nil {
434 let inToken = self.tokenPath[0]
435 let outToken = self.tokenPath[self.tokenPath.length - 1]
436
437 let evmAmount = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
438 amount,
439 erc20Address: inToken
440 )
441
442 let pathBytes = EVM.EVMBytes(value: self.buildPathBytes(reverse: false))
443
444 let quoteData = EVM.encodeABIWithSignature(
445 "quoteExactInput(bytes,uint256)",
446 [pathBytes, evmAmount]
447 )
448
449 let quoteResult = coa!.dryCall(
450 to: self.quoterAddress,
451 data: quoteData,
452 gasLimit: 1_000_000,
453 value: EVM.Balance(attoflow: 0)
454 )
455
456 if quoteResult.status == EVM.Status.successful && quoteResult.data.length > 0 {
457 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: quoteResult.data)
458 if decoded.length > 0 {
459 let evmAmountOut = decoded[0] as! UInt256
460 let expectedOut = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(
461 evmAmountOut,
462 erc20Address: outToken
463 )
464 let minAmount = expectedOut * 0.90
465
466 emit QuoteFetched(
467 quoterAddress: self.quoterAddress.toString(),
468 amountIn: amount,
469 amountOut: expectedOut
470 )
471
472 return DeFiActions.Quote(
473 expectedAmount: expectedOut,
474 minAmount: minAmount,
475 slippageTolerance: 0.10,
476 deadline: nil,
477 data: {
478 "dex": "UniswapV3" as AnyStruct,
479 "tokenPath": self.getTokenPathStrings() as AnyStruct
480 }
481 )
482 }
483 }
484 }
485
486 // Fallback estimate
487 return DeFiActions.Quote(
488 expectedAmount: amount * 0.99,
489 minAmount: amount * 0.89,
490 slippageTolerance: 0.10,
491 deadline: nil,
492 data: {
493 "dex": "UniswapV3" as AnyStruct,
494 "estimated": true as AnyStruct
495 }
496 )
497 }
498
499 /// Get token path as strings
500 access(self) fun getTokenPathStrings(): [String] {
501 var result: [String] = []
502 for token in self.tokenPath {
503 result.append(token.toString())
504 }
505 return result
506 }
507
508 /// Get execution effort from last swap
509 access(all) fun getLastExecutionEffort(): UInt64 {
510 return self.lastGasUsed
511 }
512
513 /// Get swapper info
514 access(all) fun getInfo(): DeFiActions.ComponentInfo {
515 return DeFiActions.ComponentInfo(
516 type: "Swapper",
517 identifier: "UniswapV3",
518 version: "3.0.0"
519 )
520 }
521 }
522
523 /// Create V3 swapper with token path and fee path
524 access(all) fun createSwapper(
525 routerAddress: EVM.EVMAddress,
526 quoterAddress: EVM.EVMAddress,
527 factoryAddress: EVM.EVMAddress,
528 tokenPath: [EVM.EVMAddress],
529 feePath: [UInt32],
530 inVaultType: Type,
531 outVaultType: Type,
532 coaCapability: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>
533 ): @UniswapV3Swapper {
534 var pathStrings: [String] = []
535 for token in tokenPath {
536 pathStrings.append(token.toString())
537 }
538
539 emit SwapperCreated(
540 routerAddress: routerAddress.toString(),
541 tokenPath: pathStrings,
542 feePath: feePath
543 )
544
545 return <- create UniswapV3Swapper(
546 routerAddress: routerAddress,
547 quoterAddress: quoterAddress,
548 factoryAddress: factoryAddress,
549 tokenPath: tokenPath,
550 feePath: feePath,
551 inVaultType: inVaultType,
552 outVaultType: outVaultType,
553 coaCapability: coaCapability
554 )
555 }
556
557 /// Create swapper with FlowSwap V3 defaults
558 access(all) fun createSwapperWithDefaults(
559 tokenPath: [EVM.EVMAddress],
560 feePath: [UInt32],
561 inVaultType: Type,
562 outVaultType: Type,
563 coaCapability: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>
564 ): @UniswapV3Swapper {
565 return <- self.createSwapper(
566 routerAddress: self.defaultRouterAddress,
567 quoterAddress: self.defaultQuoterAddress,
568 factoryAddress: self.defaultFactoryAddress,
569 tokenPath: tokenPath,
570 feePath: feePath,
571 inVaultType: inVaultType,
572 outVaultType: outVaultType,
573 coaCapability: coaCapability
574 )
575 }
576
577 /// Admin resource
578 access(all) resource Admin {
579 // Admin functions for future updates
580 }
581
582 init() {
583 self.AdminStoragePath = /storage/UniswapV3SwapperConnectorV3Admin
584
585 // FlowSwap V3 Mainnet addresses
586 self.defaultRouterAddress = EVM.addressFromString("0xeEDC6Ff75e1b10B903D9013c358e446a73d35341")
587 self.defaultFactoryAddress = EVM.addressFromString("0xca6d7Bb03334bBf135902e1d919a5feccb461632")
588 self.defaultQuoterAddress = EVM.addressFromString("0x370A8DF17742867a44e56223EC20D82092242C85")
589
590 // WFLOW on Flow EVM Mainnet
591 self.wflowAddress = EVM.addressFromString("0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e")
592 }
593}
594