Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cadence/contracts/connectors/evm/ERC4626SwapConnectors.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ access(all) contract ERC4626SwapConnectors {
}
let uintForDesired = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(forDesired, erc20Address: self.vault)
if let uintRequired = ERC4626Utils.previewMint(vault: self.vault, shares: uintForDesired) {
let uintMaxAllowed = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(UFix64.max, erc20Address: self.vault)

if uintRequired > uintMaxAllowed {
return SwapConnectors.BasicQuote(
inType: self.asset,
outType: self.vaultType,
inAmount: UFix64.max,
outAmount: forDesired
)
}
let ufixRequired = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(uintRequired, erc20Address: self.assetEVMAddress)
return SwapConnectors.BasicQuote(
inType: self.asset,
Expand Down
164 changes: 150 additions & 14 deletions cadence/contracts/connectors/evm/UniswapV3SwapConnectors.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,49 @@ access(all) contract UniswapV3SwapConnectors {
return EVMAbiHelpers.concat(head).concat(EVMAbiHelpers.concat(tail))
}

/// Convert an ERC20 `UInt256` amount into a Cadence `UFix64` **by rounding down** to the
/// maximum `UFix64` precision (8 decimal places).
///
/// - For `decimals <= 8`, the value is exactly representable, so this is a direct conversion.
/// - For `decimals > 8`, this floors the ERC20 amount to the nearest multiple of
/// `quantum = 10^(decimals - 8)` so the result round-trips safely:
/// `ufix64ToUInt256(result) <= amt`.
access(all) fun toCadenceOutWithDecimals(_ amt: UInt256, decimals: UInt8): UFix64 {
if decimals <= 8 {
return FlowEVMBridgeUtils.uint256ToUFix64(value: amt, decimals: decimals)
}

let quantumExp: UInt8 = decimals - 8
let quantum: UInt256 = FlowEVMBridgeUtils.pow(base: 10, exponent: quantumExp)
let remainder: UInt256 = amt % quantum
let floored: UInt256 = amt - remainder

return FlowEVMBridgeUtils.uint256ToUFix64(value: floored, decimals: decimals)
}

/// Convert an ERC20 `UInt256` amount into a Cadence `UFix64` **by rounding up** to the
/// smallest representable value at `UFix64` precision (8 decimal places).
///
/// - For `decimals <= 8`, the value is exactly representable, so this is a direct conversion.
/// - For `decimals > 8`, this ceils the ERC20 amount to the next multiple of
/// `quantum = 10^(decimals - 8)` (unless already exact), ensuring:
/// `ufix64ToUInt256(result) >= amt`, and the increase is `< quantum`.
access(all) fun toCadenceInWithDecimals(_ amt: UInt256, decimals: UInt8): UFix64 {
if decimals <= 8 {
return FlowEVMBridgeUtils.uint256ToUFix64(value: amt, decimals: decimals)
}

let quantumExp: UInt8 = decimals - 8
let quantum: UInt256 = FlowEVMBridgeUtils.pow(base: 10, exponent: quantumExp)

let remainder: UInt256 = amt % quantum
var padded: UInt256 = amt
if remainder != 0 {
padded = amt + (quantum - remainder)
}

return FlowEVMBridgeUtils.uint256ToUFix64(value: padded, decimals: decimals)
}

/// Swapper
access(all) struct Swapper: DeFiActions.Swapper {
Expand Down Expand Up @@ -118,19 +161,20 @@ access(all) contract UniswapV3SwapConnectors {
erc20Address: tokenEVMAddress
)

let maxAmount = self.getMaxAmount(zeroForOne: reverse)
let maxAmountOut = self.maxOutAmount(reverse: reverse)

var safeAmount = desired
if safeAmount > maxAmount {
safeAmount = maxAmount
var safeAmountOut = desired
if safeAmountOut > maxAmountOut {
safeAmountOut = maxAmountOut
}

let safeAmountDesired = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(
safeAmount,
// Desired OUT amount => floor
let safeAmountDesired = self._toCadenceOut(
safeAmountOut,
erc20Address: tokenEVMAddress
)
//panic("desired: \(desired), maxAmount: \(maxAmount), safeAmount: \(safeAmount)")
let amountIn = self.getV3Quote(out: false, amount: safeAmount, reverse: reverse)

let amountIn = self.getV3Quote(out: false, amount: safeAmountOut, reverse: reverse)
return SwapConnectors.BasicQuote(
inType: reverse ? self.outType() : self.inType(),
outType: reverse ? self.inType() : self.outType(),
Expand All @@ -147,14 +191,15 @@ access(all) contract UniswapV3SwapConnectors {
erc20Address: tokenEVMAddress
)

let maxAmount = self.getMaxAmount(zeroForOne: reverse)
let maxAmount = self.maxInAmount(reverse: reverse)

var safeAmount = provided
if safeAmount > maxAmount {
safeAmount = maxAmount
}

let safeAmountProvided = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(
// Provided IN amount => ceil
let safeAmountProvided = self._toCadenceIn(
safeAmount,
erc20Address: tokenEVMAddress
)
Expand Down Expand Up @@ -251,6 +296,62 @@ access(all) contract UniswapV3SwapConnectors {

return EVM.EVMAddress(bytes: addrBytes)
}
access(self) fun getPoolTokens(_ pool: EVM.EVMAddress): [EVM.EVMAddress] {
let SEL_TOKEN0: [UInt8] = [0x0d, 0xfe, 0x16, 0x81] // token0()
let SEL_TOKEN1: [UInt8] = [0xd2, 0x12, 0x20, 0xa7] // token1()

let t0Res = self._callRaw(
to: pool,
calldata: EVMAbiHelpers.buildCalldata(selector: SEL_TOKEN0, args: []),
gasLimit: 200_000,
value: 0
)!
let t1Res = self._callRaw(
to: pool,
calldata: EVMAbiHelpers.buildCalldata(selector: SEL_TOKEN1, args: []),
gasLimit: 200_000,
value: 0
)!

let t0Bytes = (t0Res.data.slice(from: 12, upTo: 32))
let t1Bytes = (t1Res.data.slice(from: 12, upTo: 32))

return [
EVM.EVMAddress(bytes: self.to20(t0Bytes)),
EVM.EVMAddress(bytes: self.to20(t1Bytes))
]
}

access(self) fun maxInAmount(reverse: Bool): UInt256 {
let pool = self.getPoolAddress()
let tokens = self.getPoolTokens(pool)
let token0 = tokens[0]
let token1 = tokens[1]

let input = reverse ? self.tokenPath[self.tokenPath.length - 1]
: self.tokenPath[0]

let zeroForOne = (input.toString() == token0.toString()) // input == token0 ? 0→1 : 1→0
return self.getMaxAmount(zeroForOne: zeroForOne)
}

access(self) fun maxOutAmount(reverse: Bool): UInt256 {
let maxIn = self.maxInAmount(reverse: reverse)

// Max out at that max-in, using quoteExactInput
let maxOutUFix: UFix64 = self.getV3Quote(out: true, amount: maxIn, reverse: reverse)
?? 0.0

// OUT token address
let outToken = reverse
? self.tokenPath[0] // reverse: path[last] -> ... -> path[0], so out is path[0]
: self.tokenPath[self.tokenPath.length - 1]

return FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
maxOutUFix,
erc20Address: outToken
)
}

/// Simplified getMaxAmount using default 6% price impact
/// Uses current liquidity as proxy for max swappable amount
Expand Down Expand Up @@ -304,7 +405,7 @@ access(all) contract UniswapV3SwapConnectors {
)
let L = wordToUIntN(words(liqRes!.data)[0], 128)

// Calculate price multiplier based on 6% price impact (600 bps)
// Calculate price multiplier based on 4% price impact (600 bps)
// Use UInt256 throughout to prevent overflow in multiplication operations
let bps: UInt256 = 600
let Q96: UInt256 = 0x1000000000000000000000000
Expand All @@ -325,7 +426,10 @@ access(all) contract UniswapV3SwapConnectors {
// Since sqrt prices are in Q96 format: (L * ΔsqrtP * Q96) / (sqrtP * sqrtP')
// This gives us native token0 units after the two Q96 divisions cancel with one Q96 multiplication
let numerator: UInt256 = L_256 * deltaSqrt
maxAmount = (numerator * Q96) / sqrtPriceX96_256 / sqrtPriceNew
let num1: UInt256 = L_256 * bps
let num2: UInt256 = num1 * Q96
let den: UInt256 = UInt256(20000) * sqrtPriceNew
maxAmount = den == 0 ? UInt256(0) : num2 / den
} else {
// Swapping token1 -> token0 (price increases by maxPriceImpactBps)
// Formula: Δy = L * (√P' - √P)
Expand Down Expand Up @@ -366,7 +470,13 @@ access(all) contract UniswapV3SwapConnectors {
? (out ? self.tokenPath[0] : self.tokenPath[self.tokenPath.length - 1])
: (out ? self.tokenPath[self.tokenPath.length - 1] : self.tokenPath[0])

return FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(uintAmt, erc20Address: ercAddr)
// out == true => quoteExactInput => result is an OUT amount => floor
// out == false => quoteExactOutput => result is an IN amount => ceil
if out {
return self._toCadenceOut(uintAmt, erc20Address: ercAddr)
} else {
return self._toCadenceIn(uintAmt, erc20Address: ercAddr)
}
}

/// Executes exact input swap via router
Expand Down Expand Up @@ -456,8 +566,21 @@ access(all) contract UniswapV3SwapConnectors {
let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: swapRes.data)
let amountOut: UInt256 = decoded.length > 0 ? decoded[0] as! UInt256 : UInt256(0)

let outTokenEVMAddress =
FlowEVMBridgeConfig.getEVMAddressAssociated(with: self.outType())
?? panic("out token \(self.outType().identifier) is not bridged")

let outUFix = self._toCadenceOut(
amountOut,
erc20Address: outTokenEVMAddress
)

let safeAmountOut = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(
outUFix,
erc20Address: outTokenEVMAddress
)
// Withdraw output back to Flow
let outVault <- coa.withdrawTokens(type: self.outType(), amount: amountOut, feeProvider: feeVaultRef)
let outVault <- coa.withdrawTokens(type: self.outType(), amount: safeAmountOut, feeProvider: feeVaultRef)

// Handle leftover fee vault
self._handleRemainingFeeVault(<-feeVault)
Expand Down Expand Up @@ -501,6 +624,19 @@ access(all) contract UniswapV3SwapConnectors {
Burner.burn(<-vault)
}
}

/// OUT amounts: round down to UFix64 precision
access(self) fun _toCadenceOut(_ amt: UInt256, erc20Address: EVM.EVMAddress): UFix64 {
let decimals = FlowEVMBridgeUtils.getTokenDecimals(evmContractAddress: erc20Address)
return UniswapV3SwapConnectors.toCadenceOutWithDecimals(amt, decimals: decimals)
}

/// IN amounts: round up to the next UFix64 such that the ERC20 conversion
/// (via ufix64ToUInt256) is >= the original UInt256 amount.
access(self) fun _toCadenceIn(_ amt: UInt256, erc20Address: EVM.EVMAddress): UFix64 {
let decimals = FlowEVMBridgeUtils.getTokenDecimals(evmContractAddress: erc20Address)
return UniswapV3SwapConnectors.toCadenceInWithDecimals(amt, decimals: decimals)
}
}

/// Revert helper
Expand Down
67 changes: 67 additions & 0 deletions cadence/tests/UniswapV3SwapConnectors_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import BlockchainHelpers
import "test_helpers.cdc"

import "FlowToken"
import "FlowEVMBridgeUtils"
import "UniswapV3SwapConnectors"

access(all) let serviceAccount = Test.serviceAccount()

Expand Down Expand Up @@ -66,3 +68,68 @@ access(all)
fun testSetupSucceeds() {
log("UniswapV3SwapConnectors deployment success")
}

/* Rounding tests */

access(all) fun roundTrip(_ x: UFix64, decimals: UInt8): UInt256 {
return FlowEVMBridgeUtils.ufix64ToUInt256(value: x, decimals: decimals)
}

access(all) fun quantum(decimals: UInt8): UInt256 {
if decimals <= 8 { return UInt256(1) }
return FlowEVMBridgeUtils.pow(base: 10, exponent: decimals - 8)
}

access(all) fun test_decimals_le_8_exact_roundtrip_in_and_out() {
// decimals 6: every unit is representable
let decimals: UInt8 = 6
let amt: UInt256 = UInt256(123_456_789) // 123.456789 with 6 decimals

let uIn = UniswapV3SwapConnectors.toCadenceInWithDecimals(amt, decimals: decimals)
let uOut = UniswapV3SwapConnectors.toCadenceOutWithDecimals(amt, decimals: decimals)

assert(roundTrip(uIn, decimals: decimals) == amt, message: "in: round-trip should equal original when decimals<=8")
assert(roundTrip(uOut, decimals: decimals) == amt, message: "out: round-trip should equal original when decimals<=8")
}

access(all) fun test_decimals_gt_8_out_is_floor_to_quantum() {
// decimals 18 => quantum = 10^(18-8) = 10^10
let decimals: UInt8 = 18
let q = quantum(decimals: decimals)

// choose an amt that's not divisible by q
let amt: UInt256 = UInt256(1000) * q + UInt256(123) // remainder 123

let uOut = UniswapV3SwapConnectors.toCadenceOutWithDecimals(amt, decimals: decimals)
let back = roundTrip(uOut, decimals: decimals)

assert(back <= amt, message: "out: round-trip must be <= original (floor)")
assert(amt - back < q, message: "out: should only drop by < quantum")
assert(back == amt - (amt % q), message: "out: must floor to multiple of quantum")
}

access(all) fun test_decimals_gt_8_in_is_ceil_to_quantum_minimal() {
let decimals: UInt8 = 18
let q = quantum(decimals: decimals)

// not divisible by q
let amt: UInt256 = UInt256(1000) * q + UInt256(123)

let uIn = UniswapV3SwapConnectors.toCadenceInWithDecimals(amt, decimals: decimals)
let back = roundTrip(uIn, decimals: decimals)

assert(back >= amt, message: "in: round-trip must be >= original (ceil)")
assert(back - amt < q, message: "in: should only increase by < quantum")
assert(back == amt + (q - (amt % q)), message: "in: must ceil to next multiple of quantum")
}

access(all) fun test_decimals_gt_8_in_exact_if_already_multiple_of_quantum() {
let decimals: UInt8 = 18
let q = quantum(decimals: decimals)

let amt: UInt256 = UInt256(1000) * q // exact multiple
let uIn = UniswapV3SwapConnectors.toCadenceInWithDecimals(amt, decimals: decimals)
let back = roundTrip(uIn, decimals: decimals)

assert(back == amt, message: "in: if already quantum-multiple, must not change")
}