From 76d62547fa559243289ed4332bcf16f2f089d62c Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 1 Apr 2026 21:32:40 -0400 Subject: [PATCH 1/2] Fail closed on zero-quote strategy closes --- .../contracts/FlowYieldVaultsStrategiesV2.cdc | 50 ++--------- ...lowYieldVaultsStrategiesV2_FUSDEV_test.cdc | 85 ++++++++++++++++++ ...wYieldVaultsStrategiesV2_syWFLOWv_test.cdc | 88 +++++++++++++++++++ 3 files changed, 181 insertions(+), 42 deletions(-) diff --git a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc index d2ff54a8..46f5e7db 100644 --- a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc +++ b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc @@ -384,22 +384,14 @@ access(all) contract FlowYieldVaultsStrategiesV2 { // destroy empty vault Burner.burn(<-v) } else { - // Quote first — if dust is too small to route, destroy it + // Quote first. A zero quote for a non-empty vault indicates a broken route or + // unavailable liquidity, not safe-to-burn dust. let quote = debtToCollateralSwapper.quoteOut(forProvided: v.balance, reverse: false) if quote.outAmount > 0.0 { let swapped <- debtToCollateralSwapper.swap(quote: quote, inVault: <-v) collateralVault.deposit(from: <-swapped) } else { - emit DustBurned( - tokenType: v.getType().identifier, - balance: v.balance, - quoteInType: quote.inType.identifier, - quoteOutType: quote.outType.identifier, - quoteInAmount: quote.inAmount, - quoteOutAmount: quote.outAmount, - swapperType: debtToCollateralSwapper.getType().identifier - ) - Burner.burn(<-v) + panic("closePosition: non-empty \(v.getType().identifier) vault returned zero debt→collateral quote") } } } @@ -439,16 +431,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { let extraCollateral <- sharesToCollateral.swap(quote: quote, inVault: <-excessShares) collateralVault.deposit(from: <-extraCollateral) } else { - emit DustBurned( - tokenType: excessShares.getType().identifier, - balance: excessShares.balance, - quoteInType: quote.inType.identifier, - quoteOutType: quote.outType.identifier, - quoteInAmount: quote.inAmount, - quoteOutAmount: quote.outAmount, - swapperType: sharesToCollateral.getType().identifier - ) - Burner.burn(<-excessShares) + panic("closePosition: non-empty excess \(excessShares.getType().identifier) vault returned zero shares→collateral quote") } } else { Burner.burn(<-excessShares) @@ -802,22 +785,14 @@ access(all) contract FlowYieldVaultsStrategiesV2 { // destroy empty vault Burner.burn(<-v) } else { - // FLOW overpayment dust — convert back to collateral if routable + // A zero quote for a non-empty FLOW residual indicates a broken route or + // unavailable liquidity, not safe-to-burn dust. let quote = flowToCollateral.quoteOut(forProvided: v.balance, reverse: false) if quote.outAmount > 0.0 { let swapped <- flowToCollateral.swap(quote: quote, inVault: <-v) collateralVault.deposit(from: <-swapped) } else { - emit DustBurned( - tokenType: v.getType().identifier, - balance: v.balance, - quoteInType: quote.inType.identifier, - quoteOutType: quote.outType.identifier, - quoteInAmount: quote.inAmount, - quoteOutAmount: quote.outAmount, - swapperType: flowToCollateral.getType().identifier - ) - Burner.burn(<-v) + panic("closePosition: non-empty \(v.getType().identifier) vault returned zero FLOW→collateral quote") } } } @@ -839,16 +814,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { let extraCollateral <- sharesToCollateral.swap(quote: quote, inVault: <-excessShares) collateralVault.deposit(from: <-extraCollateral) } else { - emit DustBurned( - tokenType: excessShares.getType().identifier, - balance: excessShares.balance, - quoteInType: quote.inType.identifier, - quoteOutType: quote.outType.identifier, - quoteInAmount: quote.inAmount, - quoteOutAmount: quote.outAmount, - swapperType: sharesToCollateral.getType().identifier - ) - Burner.burn(<-excessShares) + panic("closePosition: non-empty excess \(excessShares.getType().identifier) vault returned zero shares→collateral quote") } } else { Burner.burn(<-excessShares) diff --git a/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc b/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc index 2fce0197..0a7961ef 100644 --- a/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc +++ b/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc @@ -778,3 +778,88 @@ access(all) fun testCloseFUSDEVVaultWithExcessYieldTokens_WFLOW() { log("=== testCloseFUSDEVVaultWithExcessYieldTokens_WFLOW PASSED ===") } + +/// Reconfiguring the close route after vault creation must cause close to fail rather than burn +/// non-empty excess yield on a zero quote. +access(all) fun testCloseFUSDEVVaultWithBrokenCloseRouteFailsInsteadOfBurning() { + log("=== testCloseFUSDEVVaultWithBrokenCloseRouteFailsInsteadOfBurning ===") + + // Open a normal FLOW-collateral FUSDEV vault first, so close would succeed under the + // original configuration. + let createResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [fusdEvStrategyIdentifier, flowVaultIdentifier, 10.0], + [flowUser] + ) + Test.expect(createResult, Test.beSucceeded()) + + let vaultID = _latestVaultID(flowUser) + log("Created vault ID: \(vaultID)") + + // Inject excess FUSDEV into the AutoBalancer so closePosition must process a non-empty + // excess-yield branch. This is the value that would have been silently burned before the + // revert-on-zero-quote change. + let injectResult = _executeTransactionFile( + "transactions/inject_pyusd0_as_fusdev_to_autobalancer.cdc", + [vaultID, fusdEvEVMAddress, 5.0], + [flowUser] + ) + Test.expect(injectResult, Test.beSucceeded()) + + // Break the close route after the vault already exists by repointing FLOW collateral to a + // WBTC-ending path. This preserves the mutable-config scenario we want to probe: the vault + // was opened with a valid route, but close now reconstructs a different one. + let misconfigureResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc", + [ + fusdEvStrategyIdentifier, + flowVaultIdentifier, + fusdEvEVMAddress, + [fusdEvEVMAddress, pyusd0EVMAddress, wbtcEVMAddress], + [100 as UInt32, 3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(misconfigureResult, Test.beSucceeded()) + + // The important assertion: close must fail, not "succeed" by burning the excess FUSDEV or + // any returned non-collateral residuals when the rebuilt route cannot quote a conversion. + let failedClose = _executeTransactionFile( + "../transactions/flow-yield-vaults/close_yield_vault.cdc", + [vaultID], + [flowUser] + ) + Test.expect(failedClose, Test.beFailed()) + + // Restore the original close route and prove the same vault can still be closed afterward. + // That shows the failed close did not destroy value or corrupt the vault state irreversibly. + let restoreResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc", + [ + fusdEvStrategyIdentifier, + flowVaultIdentifier, + fusdEvEVMAddress, + [fusdEvEVMAddress, pyusd0EVMAddress, wflowEVMAddress], + [100 as UInt32, 3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(restoreResult, Test.beSucceeded()) + + let successfulClose = _executeTransactionFile( + "../transactions/flow-yield-vaults/close_yield_vault.cdc", + [vaultID], + [flowUser] + ) + Test.expect(successfulClose, Test.beSucceeded()) + + let vaultBalAfterClose = _executeScript( + "../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", + [flowUser.address, vaultID] + ) + Test.expect(vaultBalAfterClose, Test.beSucceeded()) + // Final proof: the vault disappears only after the valid-route close, not after the broken + // close attempt. + Test.assert(vaultBalAfterClose.returnValue == nil, + message: "Vault \(vaultID) should not exist after restored-route close") +} diff --git a/cadence/tests/FlowYieldVaultsStrategiesV2_syWFLOWv_test.cdc b/cadence/tests/FlowYieldVaultsStrategiesV2_syWFLOWv_test.cdc index 19c19ba7..92032744 100644 --- a/cadence/tests/FlowYieldVaultsStrategiesV2_syWFLOWv_test.cdc +++ b/cadence/tests/FlowYieldVaultsStrategiesV2_syWFLOWv_test.cdc @@ -786,3 +786,91 @@ access(all) fun testCloseSyWFLOWvVaultWithExcessYieldTokens_PYUSD0() { log("=== testCloseSyWFLOWvVaultWithExcessYieldTokens_PYUSD0 PASSED ===") } + +/// Reconfiguring the close route after vault creation must cause close to fail rather than burn +/// non-empty excess yield on a zero quote. +access(all) fun testCloseSyWFLOWvVaultWithBrokenCloseRouteFailsInsteadOfBurning() { + log("=== testCloseSyWFLOWvVaultWithBrokenCloseRouteFailsInsteadOfBurning ===") + + // Open a normal PYUSD0-collateral syWFLOWv vault first, so close would succeed under the + // original configuration. + let createResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [syWFLOWvStrategyIdentifier, pyusd0VaultIdentifier, 2.0], + [pyusd0User] + ) + Test.expect(createResult, Test.beSucceeded()) + + let vaultID = _latestVaultID(pyusd0User) + log("Created vault ID: \(vaultID)") + + // Inject excess syWFLOWv into the AutoBalancer so closePosition must process a non-empty + // excess-yield branch. This is the value that would have been silently burned before the + // revert-on-zero-quote change. + let injectResult = _executeTransactionFile( + "transactions/inject_flow_as_sywflowv_to_autobalancer.cdc", + [vaultID, syWFLOWvEVMAddress, 10.0], + [pyusd0User] + ) + Test.expect(injectResult, Test.beSucceeded()) + + // Break the close route after the vault already exists by repointing PYUSD0 collateral to a + // WBTC-ending debtToCollateral path. This exercises the mutable-config scenario directly. + let misconfigureResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_more_erc4626_config.cdc", + [ + syWFLOWvStrategyIdentifier, + pyusd0VaultIdentifier, + syWFLOWvEVMAddress, + [syWFLOWvEVMAddress, wflowEVMAddress], + [100 as UInt32], + [wflowEVMAddress, wbtcEVMAddress], + [3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(misconfigureResult, Test.beSucceeded()) + + // The important assertion: close must fail, not "succeed" by burning excess syWFLOWv or + // returned FLOW residuals when the rebuilt route cannot quote a conversion. + let failedClose = _executeTransactionFile( + "../transactions/flow-yield-vaults/close_yield_vault.cdc", + [vaultID], + [pyusd0User] + ) + Test.expect(failedClose, Test.beFailed()) + + // Restore the original close route and prove the same vault can still be closed afterward. + // That shows the failed close did not destroy value or corrupt the vault state irreversibly. + let restoreResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_more_erc4626_config.cdc", + [ + syWFLOWvStrategyIdentifier, + pyusd0VaultIdentifier, + syWFLOWvEVMAddress, + [syWFLOWvEVMAddress, wflowEVMAddress], + [100 as UInt32], + [wflowEVMAddress, pyusd0EVMAddress], + [3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(restoreResult, Test.beSucceeded()) + + let successfulClose = _executeTransactionFile( + "../transactions/flow-yield-vaults/close_yield_vault.cdc", + [vaultID], + [pyusd0User] + ) + Test.expect(successfulClose, Test.beSucceeded()) + + let vaultBalAfterClose = _executeScript( + "../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", + [pyusd0User.address, vaultID] + ) + Test.expect(vaultBalAfterClose, Test.beSucceeded()) + // Final proof: the vault disappears only after the valid-route close, not after the broken + // close attempt. + Test.assert(vaultBalAfterClose.returnValue == nil, + message: "Vault \(vaultID) should not exist after restored-route close") +} From 8db5b0e3e17940de40c8c9498fb5287099575ae3 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Wed, 1 Apr 2026 21:36:42 -0400 Subject: [PATCH 2/2] Clarify ambiguous zero-quote comment --- cadence/contracts/FlowYieldVaultsStrategiesV2.cdc | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc index 46f5e7db..3e448a59 100644 --- a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc +++ b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc @@ -384,8 +384,9 @@ access(all) contract FlowYieldVaultsStrategiesV2 { // destroy empty vault Burner.burn(<-v) } else { - // Quote first. A zero quote for a non-empty vault indicates a broken route or - // unavailable liquidity, not safe-to-burn dust. + // Quote first. A zero quote for a non-empty vault is ambiguous: it may reflect an + // unquotable route or a tiny residual, so it is not safe to assume this value can + // be burned without further justification. let quote = debtToCollateralSwapper.quoteOut(forProvided: v.balance, reverse: false) if quote.outAmount > 0.0 { let swapped <- debtToCollateralSwapper.swap(quote: quote, inVault: <-v) @@ -785,8 +786,9 @@ access(all) contract FlowYieldVaultsStrategiesV2 { // destroy empty vault Burner.burn(<-v) } else { - // A zero quote for a non-empty FLOW residual indicates a broken route or - // unavailable liquidity, not safe-to-burn dust. + // Quote first. A zero quote for a non-empty vault is ambiguous: it may reflect an + // unquotable route or a tiny residual, so it is not safe to assume this value can + // be burned without further justification. let quote = flowToCollateral.quoteOut(forProvided: v.balance, reverse: false) if quote.outAmount > 0.0 { let swapped <- flowToCollateral.swap(quote: quote, inVault: <-v)