Skip to content
Open
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
48 changes: 40 additions & 8 deletions cadence/contracts/connectors/evm/ERC4626SinkConnectors.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@ access(all) contract ERC4626SinkConnectors {
/// The address of the ERC4626 vault
access(self) let vault: EVM.EVMAddress
/// The COA capability to use for the ERC4626 vault
access(self) let coa: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
/// The token sink to use for the ERC4626 vault
access(self) let coa: Capability<auth(EVM.Call, EVM.Bridge) &EVM.CadenceOwnedAccount>
/// The token sink to use for bridging assets to EVM
access(self) let tokenSink: EVMTokenConnectors.Sink
/// The token source to use for bridging assets back from EVM on failure recovery
access(self) let tokenSource: EVMTokenConnectors.Source
/// The optional UniqueIdentifier of the ERC4626 vault
access(contract) var uniqueID: DeFiActions.UniqueIdentifier?

init(
asset: Type,
vault: EVM.EVMAddress,
coa: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>,
coa: Capability<auth(EVM.Call, EVM.Bridge) &EVM.CadenceOwnedAccount>,
feeSource: {DeFiActions.Sink, DeFiActions.Source},
uniqueID: DeFiActions.UniqueIdentifier?
) {
Expand Down Expand Up @@ -70,6 +72,13 @@ access(all) contract ERC4626SinkConnectors {
feeSource: feeSource,
uniqueID: uniqueID
)
self.tokenSource = EVMTokenConnectors.Source(
min: nil,
withdrawVaultType: asset,
coa: coa,
feeSource: feeSource,
uniqueID: uniqueID
)
self.uniqueID = uniqueID
}

Expand All @@ -80,7 +89,6 @@ access(all) contract ERC4626SinkConnectors {
/// Returns an estimate of how much can be withdrawn from the depositing Vault for this Sink to reach capacity
access(all) fun minimumCapacity(): UFix64 {
// Check the EVMTokenConnectors Sink has capacity to bridge the assets to EVM
// TODO: Update EVMTokenConnector.Sink to return 0.0 if it doesn't have fees to pay for the bridge call
let coa = self.coa.borrow()
if coa == nil {
return 0.0
Expand Down Expand Up @@ -108,7 +116,8 @@ access(all) contract ERC4626SinkConnectors {
// withdraw the appropriate amount from the referenced vault & deposit to the EVMTokenConnectors Sink
var amount = capacity <= from.balance ? capacity : from.balance

// TODO: pass from through and skip the intermediary withdrawal
// Intermediary withdrawal is needed to cap the amount at the ERC4626 vault capacity, since
// tokenSink.depositCapacity only limits by its own capacity and not the ERC4626 vault's
let deposit <- from.withdraw(amount: amount)
self.tokenSink.depositCapacity(from: &deposit as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})

Expand Down Expand Up @@ -139,7 +148,14 @@ access(all) contract ERC4626SinkConnectors {
gasLimit: 500_000
)!
if approveRes.status != EVM.Status.successful {
// TODO: consider more graceful handling of this error
// Approve failed — attempt to bridge tokens back from EVM to Cadence
let recovered <- self.tokenSource.withdrawAvailable(maxAmount: amount)
// withdraws up to `maxAmount: amount`, but recovered.balance may be slightly less than `amount`
// due to UFix64/UInt256 rounding
if recovered.balance > 0.0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if recovered.balance > 0.0 {
if recovered.balance == amount {

Should we require that the recovered amount is exactly as expected? Otherwise this still has the possibility of leaving excess tokens in the EVM state.

Copy link
Contributor Author

@mts1715 mts1715 Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will add

// withdraws up to `maxAmount: amount`, but recovered.balance may be slightly less than `amount`
// due to UFix64/UInt256 rounding

from.deposit(from: <-recovered)
return
}
panic(self._approveErrorMessage(ufixAmount: amount, uintAmount: uintAmount, approveRes: approveRes))
}

Expand All @@ -152,8 +168,24 @@ access(all) contract ERC4626SinkConnectors {
gasLimit: 1_000_000
)!
if depositRes.status != EVM.Status.successful {
// TODO: Consider unwinding the deposit & returning to the from vault
// - would require {Sink, Source} instead of just Sink
// Deposit failed — revoke the approval and attempt to bridge tokens back
let revokeRes = self._call(
dry: false,
to: self.assetEVMAddress,
signature: "approve(address,uint256)",
args: [self.vault, 0 as UInt256],
gasLimit: 500_000
)!
if revokeRes.status != EVM.Status.successful {
panic("Failed to revoke approval after deposit failure. Vault: \(self.vault.toString()), Asset: \(self.assetEVMAddress.toString()). Error code: \(revokeRes.errorCode) Error message: \(revokeRes.errorMessage)")
}
let recovered <- self.tokenSource.withdrawAvailable(maxAmount: amount)
// withdraws up to `maxAmount: amount`, but recovered.balance may be slightly less than `amount`
// due to UFix64/UInt256 rounding
if recovered.balance > 0.0 {
from.deposit(from: <-recovered)
return
}
panic(self._depositErrorMessage(ufixAmount: amount, uintAmount: uintAmount, depositRes: depositRes))
}
}
Expand Down
13 changes: 12 additions & 1 deletion cadence/contracts/connectors/evm/ERC4626SwapConnectors.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ access(all) contract ERC4626SwapConnectors {
/// Performs a swap taking a Vault of type inVault, outputting a resulting outVault. Implementations may choose
/// to swap along a pre-set path or an optimal path of a set of paths or even set of contained Swappers adapted
/// to use multiple Flow swap protocols.
///
/// @param quote: An optional quote for the swap; if nil, a quote will be generated from quoteOut()
/// @param inVault: The vault containing tokens to swap into the ERC4626 vault
///
/// @return A vault containing the resulting ERC4626 shares from the deposit
///
access(all) fun swap(quote: {DeFiActions.Quote}?, inVault: @{FungibleToken.Vault}): @{FungibleToken.Vault} {
if inVault.balance == 0.0 {
// nothing to swap - burn the inVault and return an empty outVault
Expand Down Expand Up @@ -277,7 +283,12 @@ access(all) contract ERC4626SwapConnectors {
/// Performs a swap taking a Vault of type outVault, outputting a resulting inVault. Implementations may choose
/// to swap along a pre-set path or an optimal path of a set of paths or even set of contained Swappers adapted
/// to use multiple Flow swap protocols.
// TODO: Impl detail - accept quote that was just used by swap() but reverse the direction assuming swap() was just called
///
/// @param quote: The quote from a previous swap() call with direction reversed, assuming swap() was just called
/// @param residual: The vault containing residual tokens to swap back to the original input type
///
/// @return A vault containing the original input tokens (not supported - always panics)
///
access(all) fun swapBack(quote: {DeFiActions.Quote}?, residual: @{FungibleToken.Vault}): @{FungibleToken.Vault} {
panic("ERC4626SwapConnectors.Swapper.swapBack() is not supported - ERC4626 Vaults do not support synchronous withdrawals")
}
Expand Down
52 changes: 52 additions & 0 deletions cadence/tests/ERC4626SinkConnectors_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,55 @@ access(all) fun testDepositToERC4626ViaSinkSucceeds() {
let afterShares = getEVMTokenBalance(of: deployerCOAAddress, erc20Address: vaultDeploymentInfo.vault.toString())
Test.assert(afterShares > beforeShares)
}

access(all) fun testDepositToPausedVaultRecoversGracefully() {
// mint and bridge more tokens for this test (test 1 depleted the balance)
let mintCalldata = String.encodeHex(EVM.encodeABIWithSignature("mint(address,uint256)",
[EVM.addressFromString(deployerCOAAddress), uintDepositAmount]
))
evmCall(deployerAccount,
target: vaultDeploymentInfo.underlying.toString(),
calldata: mintCalldata,
gasLimit: 1_000_000,
value: 0,
beFailed: false
)
let bridgeRes = executeTransaction(
"./transactions/bridge/bridge_tokens_from_evm.cdc",
[underlyingIdentifier, uintDepositAmount],
deployerAccount
)
Test.expect(bridgeRes, Test.beSucceeded())

let beforeShares = getEVMTokenBalance(of: deployerCOAAddress, erc20Address: vaultDeploymentInfo.vault.toString())

// pause the ERC4626 vault
evmCall(deployerAccount,
target: vaultDeploymentInfo.vault.toString(),
calldata: String.encodeHex(EVM.encodeABIWithSignature("pause()", [])),
gasLimit: 1_000_000,
value: 0,
beFailed: false
)

// attempt deposit to paused vault — should not panic
let depositRes = _executeTransaction(
"./transactions/erc4626-sink-connectors/deposit_to_paused_vault.cdc",
[ufixDepositAmount, underlyingIdentifier, vaultDeploymentInfo.vault.toString()],
deployerAccount
)
Test.expect(depositRes, Test.beSucceeded())

// verify no shares were gained
let afterShares = getEVMTokenBalance(of: deployerCOAAddress, erc20Address: vaultDeploymentInfo.vault.toString())
Test.assertEqual(beforeShares, afterShares)

// unpause the vault for cleanup
evmCall(deployerAccount,
target: vaultDeploymentInfo.vault.toString(),
calldata: String.encodeHex(EVM.encodeABIWithSignature("unpause()", [])),
gasLimit: 1_000_000,
value: 0,
beFailed: false
)
}
47 changes: 44 additions & 3 deletions cadence/tests/test_helpers.cdc

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import "FungibleToken"
import "FungibleTokenMetadataViews"
import "MetadataViews"
import "FlowToken"
import "EVM"
import "FlowEVMBridgeConfig"
import "FlowEVMBridgeUtils"
import "DeFiActions"
import "FungibleTokenConnectors"
import "ERC4626SinkConnectors"

/// Test transaction that attempts to deposit to a paused ERC4626 vault. When the vault is paused, either:
/// - minimumCapacity() returns 0 (if maxDeposit is guarded by pause) → no-op, balance unchanged
/// - deposit() reverts → recovery bridges tokens back, balance approximately unchanged
///
/// In both cases the transaction succeeds without panic and no shares are gained.
///
transaction(amount: UFix64, assetVaultIdentifier: String, erc4626VaultEVMAddressHex: String) {
let assetVault: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
let sink: {DeFiActions.Sink}
let beforeBalance: UFix64

prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability, UnpublishCapability) &Account) {
let assetVaultType = CompositeType(assetVaultIdentifier)
?? panic("Invalid deposit token identifier: \(assetVaultIdentifier)")
let erc4626VaultEVMAddress = EVM.addressFromString(erc4626VaultEVMAddressHex)

let assetVaultData = MetadataViews.resolveContractViewFromTypeIdentifier(
resourceTypeIdentifier: assetVaultIdentifier,
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
) as? FungibleTokenMetadataViews.FTVaultData
?? panic("Could not resolve FTVaultData for \(assetVaultType.identifier)")
self.assetVault = signer.storage.borrow<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(from: assetVaultData.storagePath)
?? panic("Could not find asset Vault in signer's storage at path \(assetVaultData.storagePath)")

let coaPath = /storage/evm
let coa = signer.capabilities.storage.issue<auth(EVM.Call, EVM.Bridge) &EVM.CadenceOwnedAccount>(coaPath)

let feeVault = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(
/storage/flowTokenVault
)
let feeSource = FungibleTokenConnectors.VaultSinkAndSource(
min: nil,
max: nil,
vault: feeVault,
uniqueID: nil
)

self.sink = ERC4626SinkConnectors.AssetSink(
asset: assetVaultType,
vault: erc4626VaultEVMAddress,
coa: coa,
feeSource: feeSource,
uniqueID: DeFiActions.createUniqueIdentifier()
)

self.beforeBalance = self.assetVault.balance
}

execute {
self.sink.depositCapacity(from: self.assetVault)

// After a paused deposit, balance should be restored (either no-op or recovery).
// Allow small rounding tolerance from the bridge round-trip conversion.
let afterBalance = self.assetVault.balance
let tolerance: UFix64 = 0.00000001
assert(
afterBalance >= self.beforeBalance - tolerance,
message: "Asset balance dropped unexpectedly: before=\(self.beforeBalance) after=\(afterBalance)"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ transaction(amount: UFix64, assetVaultIdentifier: String, erc4626VaultEVMAddress
signer.capabilities.publish(coaCapability, at: /public/evm)
}
// get the signer's COA capability
let coa = signer.capabilities.storage.issue<auth(EVM.Call) &EVM.CadenceOwnedAccount>(coaPath)
let coa = signer.capabilities.storage.issue<auth(EVM.Call, EVM.Bridge) &EVM.CadenceOwnedAccount>(coaPath)

// create the fee source that pays the VM bridge fees
let feeVault = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(
Expand Down