Skip to content

Commit 71e8533

Browse files
authored
Merge branch 'main' into nialexsan/erc4626-fixes-0122
2 parents 5f66a66 + b5a3a60 commit 71e8533

17 files changed

Lines changed: 645 additions & 70 deletions

.github/workflows/cadence_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
restore-keys: |
2020
${{ runner.os }}-go-
2121
- name: Install Flow CLI
22-
run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.11.1
22+
run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" -- v2.13.4
2323
- name: Flow CLI Version
2424
run: flow version
2525
- name: Update PATH

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ out/
99
imports
1010
coverage.lcov
1111
coverage.json
12+
lcov.info

cadence/contracts/connectors/SwapConnectors.cdc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,11 @@ access(all) contract SwapConnectors {
251251
/// A DeFiActions connector that deposits the resulting post-conversion currency of a token swap to an inner
252252
/// DeFiActions Sink, sourcing funds from a deposited Vault of a pre-set Type.
253253
///
254+
/// NOTE: If residual tokens remain after depositing to the inner sink, swapBack() is called on the swapper to
255+
/// return them to the original vault. One-way swappers (e.g., ERC4626SwapConnectors.Swapper) that do not
256+
/// support swapBack() can still be used safely if the inner sink always consumes all swapped tokens (i.e.,
257+
/// has sufficient capacity). If residuals occur with a one-way swapper, the transaction will revert.
258+
///
254259
access(all) struct SwapSink : DeFiActions.Sink {
255260
access(self) let swapper: {DeFiActions.Swapper}
256261
access(self) let sink: {DeFiActions.Sink}

cadence/contracts/connectors/evm/ERC4626SinkConnectors.cdc

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "FungibleToken"
33
import "EVM"
44
import "FlowEVMBridgeConfig"
55
import "FlowEVMBridgeUtils"
6+
import "FlowToken"
67
import "DeFiActions"
78
import "EVMTokenConnectors"
89
import "ERC4626Utils"
@@ -46,10 +47,20 @@ access(all) contract ERC4626SinkConnectors {
4647
"Provided asset \(asset.identifier) is not a Vault type"
4748
coa.check():
4849
"Provided COA Capability is invalid - need Capability<&EVM.CadenceOwnedAccount>"
50+
51+
feeSource.getSourceType() == Type<@FlowToken.Vault>():
52+
"Invalid feeSource - given Source must provide FlowToken Vault, but provides \(feeSource.getSourceType().identifier)"
4953
}
5054
self.asset = asset
5155
self.assetEVMAddress = FlowEVMBridgeConfig.getEVMAddressAssociated(with: asset)
5256
?? panic("Provided asset \(asset.identifier) is not associated with ERC20 - ensure the type & ERC20 contracts are associated via the VM bridge")
57+
58+
let actualUnderlyingAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: vault)
59+
assert(
60+
actualUnderlyingAddress?.equals(self.assetEVMAddress) ?? false,
61+
message: "Provided asset \(asset.identifier) does not underly ERC4626 vault \(vault.toString()) - found \(actualUnderlyingAddress?.toString() ?? "nil") but expected \(self.assetEVMAddress.toString())"
62+
)
63+
5364
self.vault = vault
5465
self.coa = coa
5566
self.tokenSink = EVMTokenConnectors.Sink(
@@ -100,16 +111,21 @@ access(all) contract ERC4626SinkConnectors {
100111
// TODO: pass from through and skip the intermediary withdrawal
101112
let deposit <- from.withdraw(amount: amount)
102113
self.tokenSink.depositCapacity(from: &deposit as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
103-
if deposit.balance == amount {
104-
// nothing was deposited to the EVMTokenConnectors Sink
105-
Burner.burn(<-deposit)
114+
115+
let remainder = deposit.balance
116+
117+
if remainder == amount {
118+
// 0 deposited -> return everything, stop
119+
from.deposit(from: <-deposit)
106120
return
107-
} else if deposit.balance > 0.0 {
108-
// update deposit amount & deposit the residual
109-
amount = amount - deposit.balance
121+
}
122+
123+
if remainder > 0.0 {
124+
// partial deposited -> return remainder
125+
amount = amount - remainder
110126
from.deposit(from: <-deposit)
111127
} else {
112-
// nothing left - burn & execute vault's burnCallback()
128+
// fully deposited -> clean up empty vault
113129
Burner.burn(<-deposit)
114130
}
115131

cadence/contracts/connectors/evm/ERC4626SwapConnectors.cdc

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "FungibleToken"
33
import "EVM"
44
import "FlowEVMBridgeConfig"
55
import "FlowEVMBridgeUtils"
6+
import "FlowToken"
67
import "DeFiActions"
78
import "DeFiActionsUtils"
89
import "ERC4626SinkConnectors"
@@ -28,9 +29,10 @@ access(all) contract ERC4626SwapConnectors {
2829
/// for liquidity to flow between Cadnece & EVM. These "swaps" are performed by depositing the input asset into the
2930
/// ERC4626 vault and withdrawing the resulting shares from the ERC4626 vault.
3031
///
31-
/// NOTE: Since ERC4626 vaults typically do not support synchronous withdrawals, this Swapper only supports the
32-
/// default inType -> outType path via swap() and reverts on swapBack() since the withdrawal cannot be returned
33-
/// synchronously.
32+
/// NOTE: Since ERC4626 vaults typically do not support synchronous withdrawals, this is a one-way swapper that
33+
/// only supports the default inType -> outType path via swap() and reverts on swapBack(). When used with
34+
/// SwapConnectors.SwapSink, ensure the inner sink always consumes all swapped shares (has sufficient capacity).
35+
/// If residuals remain, swapBack() will be called and the transaction will revert.
3436
///
3537
access(all) struct Swapper : DeFiActions.Swapper {
3638
/// The asset type serving as the price basis in the ERC4626 vault
@@ -60,10 +62,20 @@ access(all) contract ERC4626SwapConnectors {
6062
"Provided asset \(asset.identifier) is not a Vault type"
6163
coa.check():
6264
"Provided COA Capability is invalid - need Capability<&EVM.CadenceOwnedAccount>"
65+
66+
feeSource.getSourceType() == Type<@FlowToken.Vault>():
67+
"Invalid feeSource - given Source must provide FlowToken Vault, but provides \(feeSource.getSourceType().identifier)"
6368
}
6469
self.asset = asset
6570
self.assetEVMAddress = FlowEVMBridgeConfig.getEVMAddressAssociated(with: asset)
6671
?? panic("Provided asset \(asset.identifier) is not associated with ERC20 - ensure the type & ERC20 contracts are associated via the VM bridge")
72+
73+
let actualUnderlyingAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: vault)
74+
assert(
75+
actualUnderlyingAddress?.equals(self.assetEVMAddress) ?? false,
76+
message: "Provided asset \(asset.identifier) does not underly ERC4626 vault \(vault.toString()) - found \(actualUnderlyingAddress?.toString() ?? "nil") but expected \(self.assetEVMAddress.toString())"
77+
)
78+
6779
self.vault = vault
6880
self.vaultType = FlowEVMBridgeConfig.getTypeAssociated(with: vault)
6981
?? panic("Provided ERC4626 Vault \(vault.toString()) is not associated with a Cadence FungibleToken - ensure the type & ERC4626 contracts are associated via the VM bridge")
@@ -104,18 +116,53 @@ access(all) contract ERC4626SwapConnectors {
104116
outAmount: 0.0
105117
)
106118
}
107-
let uintForDesired = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(forDesired, erc20Address: self.vault)
119+
120+
// Check maximum deposit capacity first
121+
let maxCapacity = self.assetSink.minimumCapacity()
122+
if maxCapacity > 0.0 {
123+
let uintForDesired = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(forDesired, erc20Address: self.vault)
124+
if let uintRequired = ERC4626Utils.previewMint(vault: self.vault, shares: uintForDesired) {
125+
let uintMaxAllowed = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(UFix64.max, erc20Address: self.assetEVMAddress)
108126

109-
if let uintRequired = ERC4626Utils.previewMint(vault: self.vault, shares: uintForDesired) {
110-
let maxAvailableRequired = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(UFix64.max, erc20Address: self.assetEVMAddress)
111-
let safeUIntRequired = uintRequired < maxAvailableRequired ? uintRequired : maxAvailableRequired
112-
let ufixRequired = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(safeUIntRequired, erc20Address: self.assetEVMAddress)
113-
return SwapConnectors.BasicQuote(
114-
inType: self.asset,
115-
outType: self.vaultType,
116-
inAmount: ufixRequired,
117-
outAmount: forDesired
118-
)
127+
if uintRequired > uintMaxAllowed {
128+
return SwapConnectors.BasicQuote(
129+
inType: self.asset,
130+
outType: self.vaultType,
131+
inAmount: UFix64.max,
132+
outAmount: forDesired
133+
)
134+
}
135+
136+
let ufixRequired = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(uintRequired, erc20Address: self.assetEVMAddress)
137+
138+
// Cap input to maxCapacity and recalculate output if needed
139+
if ufixRequired > maxCapacity {
140+
// Required assets exceed capacity - cap at maxCapacity and calculate achievable shares
141+
let uintMaxCapacity = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount(maxCapacity, erc20Address: self.assetEVMAddress)
142+
if let uintActualShares = ERC4626Utils.previewDeposit(vault: self.vault, assets: uintMaxCapacity) {
143+
let ufixActualShares = FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(uintActualShares, erc20Address: self.vault)
144+
return SwapConnectors.BasicQuote(
145+
inType: self.asset,
146+
outType: self.vaultType,
147+
inAmount: maxCapacity,
148+
outAmount: ufixActualShares
149+
)
150+
}
151+
return SwapConnectors.BasicQuote(
152+
inType: self.asset,
153+
outType: self.vaultType,
154+
inAmount: 0.0,
155+
outAmount: 0.0
156+
)
157+
}
158+
159+
return SwapConnectors.BasicQuote(
160+
inType: self.asset,
161+
outType: self.vaultType,
162+
inAmount: ufixRequired,
163+
outAmount: forDesired
164+
)
165+
}
119166
}
120167
return SwapConnectors.BasicQuote(
121168
inType: self.asset,
@@ -172,21 +219,62 @@ access(all) contract ERC4626SwapConnectors {
172219

173220
// assign or get the quote for the swap
174221
let _quote = quote ?? self.quoteOut(forProvided: inVault.balance, reverse: false)
222+
let outAmount = _quote.outAmount
223+
224+
assert(_quote.inType == self.inType(), message: "Quote inType mismatch")
225+
assert(_quote.outType == self.outType(), message: "Quote outType mismatch")
226+
assert(_quote.inAmount > 0.0, message: "Invalid quote: inAmount must be > 0")
227+
assert(outAmount > 0.0, message: "Invalid quote: outAmount must be > 0")
228+
229+
// --- Slippage protection: don't allow spending more than quoted ---
230+
let beforeInBalance = inVault.balance
231+
assert(
232+
beforeInBalance <= _quote.inAmount,
233+
message: "Swap input (\(beforeInBalance)) exceeds quote.inAmount (\(_quote.inAmount)). Provide an updated quote or reduce inVault balance."
234+
)
175235

176-
// get the before available shares
236+
// Track shares available before/after to determine received shares
177237
let beforeAvailable = self.shareSource.minimumAvailable()
178238

179-
// deposit the inVault into the asset sink
239+
// Deposit the inVault into the asset sink (should consume all of it)
180240
self.assetSink.depositCapacity(from: &inVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
241+
242+
let remainder = inVault.balance
243+
let consumedIn = beforeInBalance - remainder
244+
245+
// We expect full consumption in this connector's semantics.
246+
// If this ever becomes "partial fill" in the future, this check + price check below
247+
// ensures it still can't be worse than quoted.
248+
assert(
249+
consumedIn > 0.0,
250+
message: "Asset sink did not consume any input."
251+
)
252+
assert(remainder == 0.0, message: "Asset sink did not consume full input; remainder: \(remainder.toString()). Adjust inVault balance.")
253+
181254
Burner.burn(<-inVault)
182255

183256
// get the after available shares
184257
let afterAvailable = self.shareSource.minimumAvailable()
185258
assert(afterAvailable > beforeAvailable, message: "Expected ERC4626 Vault \(self.vault.toString()) to have more shares after depositing")
186259

187260
// withdraw the available difference in shares
188-
let availableDiff = afterAvailable - beforeAvailable
189-
let sharesVault <- self.shareSource.withdrawAvailable(maxAmount: availableDiff)
261+
let receivedShares = afterAvailable - beforeAvailable
262+
263+
// --- Slippage protection: ensure minimum out ---
264+
assert(
265+
receivedShares >= outAmount,
266+
message: "Slippage: received \(receivedShares) < quote.outAmount (\(outAmount))."
267+
)
268+
269+
let sharesVault <- self.shareSource.withdrawAvailable(maxAmount: receivedShares)
270+
271+
// Extra safety: ensure the vault we’re returning matches the computed delta
272+
// (withdrawAvailable could theoretically return less if liquidity changed)
273+
assert(
274+
sharesVault.balance >= outAmount,
275+
message: "Slippage: withdrawn shares \(sharesVault.balance) < outAmount (\(outAmount))."
276+
)
277+
190278
return <- sharesVault
191279
}
192280
/// Performs a swap taking a Vault of type outVault, outputting a resulting inVault. Implementations may choose

cadence/contracts/connectors/evm/EVMTokenConnectors.cdc

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,9 @@ access(all) contract EVMTokenConnectors {
209209
return DeFiActions.ComponentInfo(
210210
type: self.getType(),
211211
id: self.id(),
212-
innerComponents: []
212+
innerComponents: [
213+
self.feeSource.getComponentInfo()
214+
]
213215
)
214216
}
215217
/// Sets the UniqueIdentifier of this component to the provided UniqueIdentifier, used in extending a stack to
@@ -291,7 +293,13 @@ access(all) contract EVMTokenConnectors {
291293
if feeVault.balance > 0.0 {
292294
self.feeSource.depositCapacity(from: &feeVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
293295
}
296+
// feeSource should not enforce a deposit capacity limit, as it is only a vault backing a sink.
297+
// Assert here to catch unexpected partial deposits.
298+
assert(
299+
feeVault.balance == 0.0,
300+
message: "Fee sink failed to accept full balance; feeVault still contains funds"
301+
)
294302
Burner.burn(<-feeVault)
295303
}
296304
}
297-
}
305+
}

cadence/contracts/connectors/evm/UniswapV3SwapConnectors.cdc

Lines changed: 31 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,6 @@ import "EVMAbiHelpers"
1919
/// Supports single-hop and multi-hop swaps using exactInput / exactInputSingle and Quoter for estimates.
2020
///
2121
access(all) contract UniswapV3SwapConnectors {
22-
// (bytes,address,uint256,uint256)
23-
access(all) fun encodeTuple_bytes_addr_u256_u256(
24-
path: [UInt8],
25-
recipient: EVM.EVMAddress,
26-
amountOne: UInt256,
27-
amountTwo: UInt256
28-
): [UInt8] {
29-
let tupleHeadSize = 32 * 4
30-
31-
var head: [[UInt8]] = []
32-
var tail: [[UInt8]] = []
33-
34-
// 1) bytes path (dynamic) -> pointer to tail, relative to start of this tuple blob
35-
head.append(EVMAbiHelpers.abiWord(UInt256(tupleHeadSize)))
36-
tail.append(EVMAbiHelpers.abiDynamicBytes(path))
37-
38-
head.append(EVMAbiHelpers.abiAddress(recipient))
39-
40-
head.append(EVMAbiHelpers.abiUInt256(amountOne))
41-
42-
head.append(EVMAbiHelpers.abiUInt256(amountTwo))
43-
44-
return EVMAbiHelpers.concat(head).concat(EVMAbiHelpers.concat(tail))
45-
}
4622

4723
/// Convert an ERC20 `UInt256` amount into a Cadence `UFix64` **by rounding down** to the
4824
/// maximum `UFix64` precision (8 decimal places).
@@ -88,6 +64,27 @@ access(all) contract UniswapV3SwapConnectors {
8864
return FlowEVMBridgeUtils.uint256ToUFix64(value: padded, decimals: decimals)
8965
}
9066

67+
/// ExactInputSingleParams facilitates the ABI encoding/decoding of the
68+
/// Solidity tuple expected in `ISwapRouter.exactInput` function.
69+
access(all) struct ExactInputSingleParams {
70+
access(all) let path: EVM.EVMBytes
71+
access(all) let recipient: EVM.EVMAddress
72+
access(all) let amountIn: UInt256
73+
access(all) let amountOutMinimum: UInt256
74+
75+
init(
76+
path: EVM.EVMBytes,
77+
recipient: EVM.EVMAddress,
78+
amountIn: UInt256,
79+
amountOutMinimum: UInt256
80+
) {
81+
self.path = path
82+
self.recipient = recipient
83+
self.amountIn = amountIn
84+
self.amountOutMinimum = amountOutMinimum
85+
}
86+
}
87+
9188
/// Swapper
9289
access(all) struct Swapper: DeFiActions.Swapper {
9390
access(all) let routerAddress: EVM.EVMAddress
@@ -439,7 +436,7 @@ access(all) contract UniswapV3SwapConnectors {
439436

440437
let args: [AnyStruct] = [pathBytes, amount]
441438

442-
let res = self._dryCall(self.quoterAddress, callSig, args, 1_000_000)
439+
let res = self._dryCall(self.quoterAddress, callSig, args, 10_000_000)
443440
if res == nil || res!.status != EVM.Status.successful { return nil }
444441

445442
let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: res!.data)
@@ -501,9 +498,6 @@ access(all) contract UniswapV3SwapConnectors {
501498
erc20Address: outToken
502499
)
503500

504-
// exactInput((bytes,address,uint256,uint256)) selector = 0xb858183f
505-
let selector: [UInt8] = [0xb8, 0x58, 0x18, 0x3f]
506-
507501
let coaRef = self.borrowCOA()!
508502
let recipient: EVM.EVMAddress = coaRef.address()
509503

@@ -514,26 +508,23 @@ access(all) contract UniswapV3SwapConnectors {
514508
assert(_chkIn.length == 32, message: "amountIn not 32 bytes")
515509
assert(_chkMin.length == 32, message: "amountOutMin not 32 bytes")
516510

517-
// 1) Build the tuple blob (you already have this)
518-
let argsBlob: [UInt8] = UniswapV3SwapConnectors.encodeTuple_bytes_addr_u256_u256(
519-
path: pathBytes.value,
511+
let exactInputParams = UniswapV3SwapConnectors.ExactInputSingleParams(
512+
path: pathBytes,
520513
recipient: recipient,
521-
amountOne: evmAmountIn,
522-
amountTwo: minOutUint
514+
amountIn: evmAmountIn,
515+
amountOutMinimum: minOutUint
523516
)
524517

525-
// 2) Head for a single dynamic arg is always 32
526-
let head: [UInt8] = EVMAbiHelpers.abiWord(UInt256(32))
527-
528-
// 3) Final calldata = selector || head || tuple
529-
let calldata: [UInt8] = selector.concat(head).concat(argsBlob)
530-
518+
let calldata: [UInt8] = EVM.encodeABIWithSignature(
519+
"exactInput((bytes,address,uint256,uint256))",
520+
[exactInputParams]
521+
)
531522

532523
// Call the router with raw calldata
533524
let swapRes = self._callRaw(
534525
to: self.routerAddress,
535526
calldata: calldata,
536-
gasLimit: 2_000_000,
527+
gasLimit: 10_000_000,
537528
value: 0
538529
)!
539530
if swapRes.status != EVM.Status.successful {

0 commit comments

Comments
 (0)