Skip to content

close position from strategies#182

Merged
nialexsan merged 39 commits intomainfrom
nialexsan/dust-debug-flowalp
Mar 10, 2026
Merged

close position from strategies#182
nialexsan merged 39 commits intomainfrom
nialexsan/dust-debug-flowalp

Conversation

@nialexsan
Copy link
Collaborator

@nialexsan nialexsan commented Feb 23, 2026

Closes: #???

Description

implement close position logic in strategies to integrate with FlowALP close position logic


For contributor use:

  • Targeted PR against master branch
  • Linked to Github issue with discussion and accepted design OR link to spec that describes this work.
  • Code follows the standards mentioned here.
  • Updated relevant documentation
  • Re-reviewed Files changed in the Github PR explorer
  • Added appropriate labels

totalDebtAmount = totalDebtAmount + repaymentBuffer

// Step 3: If no debt, pass empty vault array
if totalDebtAmount == 0.0 {
Copy link
Member

Choose a reason for hiding this comment

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

With the repaymentBuffer, this will never be true, so maybe this instead?

Suggested change
if totalDebtAmount == 0.0 {
if totalDebtAmount == repaymentBuffer {

or create a new variable, and then we can check if the original debt amount without buffer is 0

let totalDebtAmountWithBuffer = totalDebtAmount + repaymentBuffer

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the close position logic should always overpay debt slightly

Copy link
Member

Choose a reason for hiding this comment

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

No. Look at this logic, totalDebtAmount should always > 0, and never be 0.0. So if totalDebtAmount == 0.0 will never become true.

            let repaymentBuffer: UFix64 = 0.00000001  // 1e-8
            totalDebtAmount = totalDebtAmount + repaymentBuffer

            // Step 3: If no debt, close with empty sources array
            if totalDebtAmount == 0.0 {

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

it seems to be an artifact from one of the previous versions, there is no repayment buffer any more

?? panic("Could not create external source from AutoBalancer")

// Step 5: Retrieve yield→MOET swapper from contract config
let swapperKey = "yieldToMoetSwapper_".concat(self.id()!.toString())
Copy link
Member

Choose a reason for hiding this comment

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

better to create a swapperKeyByID(self.id()!) function

)
collateralVault.deposit(from: <-swappedCollateral)
} else {
destroy dustVault
Copy link
Member

Choose a reason for hiding this comment

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

Note we will destroy a non-empty collateral vault regardless the amount. This could is techinically possible since the returned vaults can have pending deposits, and overpay vaults.

I think we should do:

if dustVault.balance == 0 { destroy dustVault }
if dustVault.getType == collateralType { collateralVault.deposit(from: <- dustVault) }
// dustVault.getType() != collateralType
// swap and then deposit
...

assert(resultVaults.length > 0, message: "No vaults returned from closePosition")

// First vault should be collateral
var collateralVault <- resultVaults.removeFirst()
Copy link
Member

Choose a reason for hiding this comment

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

Better double check the collateral type?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

put a post check

collateralType: Type,
uniqueID: DeFiActions.UniqueIdentifier
): UniswapV3SwapConnectors.Swapper {
// Reverse the swap path: collateral -> yield (opposite of yield -> collateral)
Copy link
Member

Choose a reason for hiding this comment

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

Does this reverse computation happen a lot? Does it make sense to store the reversed path within the CollateralConfig?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no, it's calculated only once during position creation, storing it separately would be redundant and it will increase risk of misconfiguration

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we'll need to store another path though: moet to collateral

repaymentSource: repaymentSource,
pushToDrawDownSink: pushToDrawDownSink
)
let poolCap = MockFlowALPConsumer.account.storage.load<Capability<auth(FlowALPv0.EParticipant, FlowALPv0.EPosition) &FlowALPv0.Pool>>(
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let poolCap = MockFlowALPConsumer.account.storage.load<Capability<auth(FlowALPv0.EParticipant, FlowALPv0.EPosition) &FlowALPv0.Pool>>(
let poolCap = MockFlowALPConsumer.account.storage.load<Capability<auth(FlowALPv0.EParticipant) &FlowALPv0.Pool>>(

See onflow/FlowALP#175

// Step 1: Get debt amounts from position - returns {Type: UFix64} dictionary
let debtsByType = self.position.getTotalDebt()

// Step 2: Calculate total debt amount across all debt types
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't really make sense to me:

  • getTotalDebt returns a map from token type to the amount of debt denominated in that token type
  • if there are actually multiple debt types, we need to handle them separately, we can't just add up multiple debts of different currencies, without considering what the currencies are
  • further down, we assume the debt is all MOET and only provide a MOET repayment source

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

implemented multy-debt type logic

?? panic("Could not create external source from AutoBalancer")

// Step 5: Withdraw ALL available YT from AutoBalancer to avoid losing funds when Strategy is destroyed
// Use minimumAvailable() to get the actual available amount (UFix64.max might not work as expected)
Copy link
Member

Choose a reason for hiding this comment

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

UFix64.max might not work as expected

Why does it not work as expected?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is a debugging artifact to fully withdraw from the position, I'll remove it

Comment on lines +150 to +154
// Step 2: Calculate total debt amount across all debt types
var totalDebtAmount: UFix64 = 0.0
for debtAmount in debtsByType.values {
totalDebtAmount = totalDebtAmount + debtAmount
}
Copy link
Member

Choose a reason for hiding this comment

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

Same here. This structure and documentation implies that it will handle multiple debt types, but it will only work when there is exactly one debt type, and it is MOET.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

now this is just a check whether there is any debt balance

)
// Extract the first vault (should be collateral)
assert(resultVaults.length > 0, message: "No vaults returned from closePosition")
let collateralVault <- resultVaults.removeFirst()
Copy link
Member

Choose a reason for hiding this comment

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

If we ever have more than one collateral type in the future, we will destroy it here. I'd add an assertion that we got exactly one collateral vault back (if we're certain that is the only allowed initial state), or handle multiple collateral vaults being returned.

Copy link
Collaborator Author

@nialexsan nialexsan Mar 7, 2026

Choose a reason for hiding this comment

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

this strategy will work only with one collateral and one debt type at a time, I added assertions

}

access(all)
fun setup() {
Copy link
Member

Choose a reason for hiding this comment

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

Can we consolidate this setup function with the one used in scenario4?

log(" MOET debt: \(debtAfterFlowDrop) MOET")
log(" Health: \(healthAfterRebalance)")

if healthAfterRebalance >= 1.3 {
Copy link
Member

Choose a reason for hiding this comment

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

Let's assert whichever of these we expect to actually happen!

log(" MOET debt: \(debtBefore) MOET")
log(" Health: \(healthBeforeRebalance)")

if healthBeforeRebalance < 1.0 {
Copy link
Member

Choose a reason for hiding this comment

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

Is observing this health value expected behaviour? Unexpected behaviour? Let's assert what we expect the health to actually be.

}

access(all)
fun test_RebalanceYieldVaultScenario5() {
Copy link
Member

Choose a reason for hiding this comment

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

Same comment here. This test has almost no assertions. Automated tests are useful because they pass when the component behaves as expected, and fail when the component doesn't behave as expected!

Logging as the only strategy for surfacing the behaviour of the component we are testing is counterproductive. It gives the illusion of test coverage, but will not actually perform the purpose of a test suite, by letting us know when the component's behaviour regresses.

// This is significantly better than without rebalanceSource (would be ~94% loss)
// but still substantial due to the extreme 90% price crash.
let expectedMaxBalance = fundingAmount * 0.9 // Allow for up to 10-20% loss
Test.assert((flowBalanceAfter-flowBalanceBefore) <= expectedMaxBalance,
Copy link
Member

Choose a reason for hiding this comment

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

The comment above states:

User ends up with ~65-70 FLOW (30-35% loss)

Can we make the assertion reflect this expectation about the final balance more precisely? "Somewhere between 0-90%" is a really wide range for what should be a deterministic test.

@nialexsan
Copy link
Collaborator Author

nialexsan commented Mar 7, 2026

@jordanschalm I addressed all comments except of FlowYieldVaultsStrategiesV2, they will be addressed in a separate PR. I need to rework the whole strategy to handle different collateral types and a special case for PYUSD0
nvm, the PYUSD0 won't be used for FUSDEV - FlowALP, it simplified the logic

// Otherwise, use MockSwapper.quoteIn to pre-swap YT → debt token and wrap in a VaultSource.
// Pre-swapping with quoteIn guarantees the exact debt amount is delivered to the pool.
var repaymentSources: [{DeFiActions.Source}] = []
var debtCaps: [Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>] = []
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

using this awkward solution to precisely match the debt amounts.
just using swap source directly optimizes for inAmount and has precision issue in this test


// Store yield→MOET swapper in contract config for later access during closePosition
let yieldToMoetSwapperKey = FlowYieldVaultsStrategiesV2.getYieldToMoetSwapperConfigKey(uniqueID)!
FlowYieldVaultsStrategiesV2.config[yieldToMoetSwapperKey] = yieldToMoetSwapper
Copy link
Contributor

@liobrasil liobrasil Mar 9, 2026

Choose a reason for hiding this comment

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

This writes per-vault runtime state into a contract-global dictionary, and nothing removes it on close/burn, so every opened+closed vault leaks a yieldToMoetSwapper_<id> entry.

I opened a follow-up fix in #201

That PR moves the swapper onto FUSDEVStrategy itself and removes the global runtime config/key path.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'll fix that, I missed this one during config partitioning

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

that's right, this contract is already deployed, and this was the original implementation
the top level config with nested maps is a workaround to keep the contract updatable

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I've addressed this issue in #183

@nialexsan nialexsan changed the title rebalance test edge cases close position from strategies Mar 9, 2026
@nialexsan nialexsan requested a review from jordanschalm March 9, 2026 21:18
// Handle any overpayment dust (MOET) returned as the second vault
while resultVaults.length > 0 {
let dustVault <- resultVaults.removeFirst()
if dustVault.balance > 0.0 {
Copy link
Member

Choose a reason for hiding this comment

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

The current logic is to destroy any funds we get back that aren't the collateral type without checking their type or balance, which seems dangerous to me. On line 215 above, we assert that there are either 1 or 2 result vaults, so this while loop is operating on at most one vault in resultVaults.

If it is still possible to have overpayment dust, when closePosition can pull the exact debt repayment it wants, then I would replace the while loop with:

let dustVault <- resultVaults.removeFirst()
assert(resultVaults.length == 0)
destroy <-resultVaults
// We may receive a small amount of MOET in addition to collateral, which represents dust overpayment from repaying the position's debt.
// Because this is a trivial amount of funds, we do not attempt to recover it.
assert(dustVault.getType() == Type<MOET>)
assert(dustVault.balance <= maximumPossibleDustBalance)
destroy <-dustVault

If it isn't possible to have overpayment dust, then we can change line 215 to assert resultVaults.length == 1 and remove this loop.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I implemented dust handling in #183, so the strategy will swap overpayment dust back to collateral

Copy link
Member

Choose a reason for hiding this comment

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

OK, I would still avoid destroying vaults without checking them. The current code handles the first collateral vault (👍) but can destroy subsequent vaults without checking them. I'd just add assertions prior to destroying those vaults, to make sure they are the expected dust (expected type and expected small value).

log(" MOET debt: \(debtBefore) MOET")

rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false)
rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false)
Copy link
Member

Choose a reason for hiding this comment

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

The YV rebalance is doing all the work here, right? We expect rebalancePosition to basically be a no-op? (Otherwise I would expect the FLOW collateral below to change after the rebalance.)

Suggested change
rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false)
// Since we rebalance the yield vault first, which pays down debt, the position rebalance is a no-op
rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false)

@nialexsan nialexsan merged commit f9daf70 into main Mar 10, 2026
7 checks passed
@nialexsan nialexsan deleted the nialexsan/dust-debug-flowalp branch March 10, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants