Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f8e0c64
multi debt
nialexsan Feb 27, 2026
921efff
Merge remote-tracking branch 'origin/main' into nialexsan/multi-debt
nialexsan Feb 27, 2026
cfbcd1a
Apply suggestion from @nialexsan
nialexsan Feb 27, 2026
7b1a144
fix refs
nialexsan Feb 27, 2026
c492f20
fix balance
nialexsan Feb 27, 2026
a7f0828
revert MOET collateral restriction
nialexsan Feb 27, 2026
4c9e452
fix test
nialexsan Feb 28, 2026
a916839
fix testing transactions
nialexsan Feb 28, 2026
aa091eb
Merge branch 'main' into nialexsan/multi-debt
nialexsan Feb 28, 2026
2b1ed97
add tests
nialexsan Feb 28, 2026
7187d1e
fix test
nialexsan Feb 28, 2026
1a6b4ec
method names for multi types
nialexsan Mar 2, 2026
093d823
reserve handler
nialexsan Mar 4, 2026
e3dcb84
Apply suggestion from @jordanschalm
nialexsan Mar 4, 2026
8153647
Fix phantom debt/collateral types after exact zero balances
liobrasil Mar 4, 2026
368dbaa
trim tests
nialexsan Mar 4, 2026
a414472
fix test
nialexsan Mar 4, 2026
385387f
Merge branch 'nialexsan/multi-debt' into lionel/fix-phantom-balance-t…
nialexsan Mar 4, 2026
3641469
Merge pull request #199 from onflow/lionel/fix-phantom-balance-types
nialexsan Mar 4, 2026
ebffab0
move around
nialexsan Mar 5, 2026
5d9b84c
address comments
nialexsan Mar 5, 2026
6e95bab
Merge branch 'main' into nialexsan/multi-debt
nialexsan Mar 5, 2026
af6b9d7
fix test
nialexsan Mar 5, 2026
5058488
skip multi collateral tests
nialexsan Mar 5, 2026
9fbd813
skip test
nialexsan Mar 5, 2026
b65f0ff
fix zero balance check
nialexsan Mar 5, 2026
c0713a7
Merge branch 'main' into nialexsan/multi-debt
liobrasil Mar 5, 2026
416a9d2
clean-up
nialexsan Mar 6, 2026
23603b4
simplify handler
nialexsan Mar 6, 2026
ee601c1
tweak handler
nialexsan Mar 6, 2026
08fd6fd
switch tests to use dummy token
nialexsan Mar 6, 2026
8d48045
revert unnecessary changes
nialexsan Mar 7, 2026
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
66 changes: 66 additions & 0 deletions cadence/contracts/FlowALPModels.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -1903,6 +1903,22 @@ access(all) contract FlowALPModels {
/// Sets the top-up source. See borrowTopUpSource for additional details.
/// If nil, the Pool will not pull underflown value, and liquidation may occur.
access(EImplementation) fun setTopUpSource(_ source: {DeFiActions.Source}?)

/// Returns the current collateral type for this position based on existing Credit balances
/// Returns nil if no Credit balance exists yet (allows any collateral type for first deposit)
access(all) fun getCollateralType(): Type?

/// Returns the current debt type for this position based on existing Debit balances
/// Returns nil if no Debit balance exists yet (allows any debt type for first borrow)
access(all) fun getDebtType(): Type?

/// Validates that the given token type can be used as collateral for this position
/// Panics if position already has a different collateral type
access(EImplementation) fun validateCollateralType(_ type: Type)

/// Validates that the given token type can be used as debt for this position
/// Panics if position already has a different debt type
access(EImplementation) fun validateDebtType(_ type: Type)
}

/// InternalPositionImplv1 is the concrete implementation of InternalPosition.
Expand Down Expand Up @@ -2071,6 +2087,56 @@ access(all) contract FlowALPModels {
access(EImplementation) fun setTopUpSource(_ source: {DeFiActions.Source}?) {
self.topUpSource = source
}

/// Returns the current collateral type for this position based on existing Credit balances
/// Returns nil if no Credit balance exists yet (allows any collateral type for first deposit)
access(all) fun getCollateralType(): Type? {
for type in self.balances.keys {
if self.balances[type]!.direction == BalanceDirection.Credit {
Copy link
Copy Markdown
Contributor

@liobrasil liobrasil Mar 4, 2026

Choose a reason for hiding this comment

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

Follow-up fix: this type discovery now needs to ignore zero balances. Otherwise exact repay/full withdraw can leave a phantom type that still trips validateDebtType/validateCollateralType. Addressed in PR #199

return type
}
}
return nil
}

/// Returns the current debt type for this position based on existing Debit balances
/// Returns nil if no Debit balance exists yet (allows any debt type for first borrow)
access(all) fun getDebtType(): Type? {
for type in self.balances.keys {
if self.balances[type]!.direction == BalanceDirection.Debit {
return type
}
}
return nil
}

/// Validates that the given token type can be used as collateral for this position
/// Panics if position already has a different collateral type
access(EImplementation) fun validateCollateralType(_ type: Type) {
let existingType = self.getCollateralType()
if existingType == nil {
// No collateral yet, allow any type
return
}

if existingType! != type {
panic("Position already has collateral type ".concat(existingType!.identifier).concat(". Cannot deposit ").concat(type.identifier).concat(". Only one collateral type allowed per position."))
}
}

/// Validates that the given token type can be used as debt for this position
/// Panics if position already has a different debt type
access(EImplementation) fun validateDebtType(_ type: Type) {
let existingType = self.getDebtType()
if existingType == nil {
// No debt yet, allow any type
return
}

if existingType! != type {
panic("Position already has debt type ".concat(existingType!.identifier).concat(". Cannot borrow ").concat(type.identifier).concat(". Only one debt type allowed per position."))
}
}
}

/// Factory function to create a new InternalPositionImplv1 resource.
Expand Down
107 changes: 87 additions & 20 deletions cadence/contracts/FlowALPv0.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -576,15 +576,31 @@ access(all) contract FlowALPv0 {
let debtState = self._borrowUpdatedTokenState(type: debtType)

if position.getBalance(debtType) == nil {
// Liquidation is repaying debt - validate single debt type
position.validateDebtType(debtType)

position.setBalance(debtType, FlowALPModels.InternalBalance(direction: FlowALPModels.BalanceDirection.Debit, scaledBalance: 0.0))
}
Comment thread
nialexsan marked this conversation as resolved.
Outdated
position.borrowBalance(debtType)!.recordDeposit(amount: UFix128(repayAmount), tokenState: debtState)

// Withdraw seized collateral from position and send to liquidator
let seizeState = self._borrowUpdatedTokenState(type: seizeType)
if position.getBalance(seizeType) == nil {
// MOET cannot be seized as collateral (it should never exist as collateral)
if seizeType == Type<@MOET.Vault>() {
panic("Cannot seize MOET as collateral. MOET should not exist as collateral in any position.")
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you sure it is the desired behaviour, that MOET cannot be used as collateral?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@dete do we want to allow MOET as a collateral?
It creates a vulnerability for the protocol, since MOET is a fully synthetic token and not directly backed by any stable coin.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, MOET should be allowed as collateral. Of course, you can't borrow MOET against MOET, so we're not creating a "stack of turtles" here. If you try to "withdraw" MOET when you have a MOET balance, you just draw down your collateral.

Note that it's the lending protocol itself that makes MOET safe, so it's not unreasonable for the lending protocol to assume that its safe (so long as MOET isn't backed by MOET, which we will never allow).

Like other assets, there should be deposit interest on MOET collateral (which is always < the borrow interest on the same asset). Note that since all MOET is created by borrowing, and all borrowed MOET pays interest, the interest we pay on MOET deposits are always less than the MOET we earn by charging interest on debts. So, you can think of the interest we pay on MOET deposits as passing directly through the protocol from the original borrower to the current holder.

Critically: We don't allow USDC or PyUSD0 or any other stables to be deposited as collateral. So folks will have to "swap into" MOET if they want to have USD-denominated collateral. (Again, this has been modelled and is HEALTHY for the protocol, not risky.)

Thanks for being cautious, Alex! But this is part of the design and included in our stability analysis. 🙇


// Liquidation is seizing collateral - validate single collateral type
position.validateCollateralType(seizeType)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I noticed the validation is always done before setBalance, does it make sense to simply add the validation to setBalance? That way, we don't need to add it everywhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

by Jordan's suggestion I moved the check to post condition


position.setBalance(seizeType, FlowALPModels.InternalBalance(direction: FlowALPModels.BalanceDirection.Credit, scaledBalance: 0.0))
}
// Additional safety check: MOET should never be seized as collateral
if seizeType == Type<@MOET.Vault>() && position.balances[seizeType]!.direction == BalanceDirection.Credit {
panic("Cannot seize MOET as collateral. MOET should not exist as collateral in any position.")
}

position.borrowBalance(seizeType)!.recordWithdrawal(amount: UFix128(seizeAmount), tokenState: seizeState)
let seizeReserveRef = self.state.borrowReserve(seizeType)!
let seizedCollateral <- seizeReserveRef.withdraw(amount: seizeAmount)
Expand Down Expand Up @@ -1323,7 +1339,15 @@ access(all) contract FlowALPv0 {
}

// If this position doesn't currently have an entry for this token, create one.
if position.getBalance(type) == nil {
if position.balances[type] == nil {
Comment thread
nialexsan marked this conversation as resolved.
Outdated
// MOET cannot be used as collateral (only as debt)
if type == Type<@MOET.Vault>() {
panic("MOET cannot be deposited as collateral. MOET can only be borrowed (debt), not used as collateral.")
}

// Validate single collateral type constraint
position.validateCollateralType(type)

Copy link
Copy Markdown
Contributor

@liobrasil liobrasil Mar 4, 2026

Choose a reason for hiding this comment

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

Follow-up for the collateral-type bypass case is in #197 .
When a deposit starts as repayment, deposit > current debt can flip surplus into Credit and create a second collateral type unless validated before recording. PR #197 adds that pre-check in _depositEffectsOnly and includes a regression test (testCannotCreateSecondCollateralTypeByOverRepayingDebt).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a post check should address this issue

position.setBalance(type, FlowALPModels.InternalBalance(
direction: FlowALPModels.BalanceDirection.Credit,
scaledBalance: 0.0
Expand All @@ -1341,6 +1365,13 @@ access(all) contract FlowALPv0 {
// This only records the portion of the deposit that was accepted, not any queued portions,
// as the queued deposits will be processed later (by this function being called again), and therefore
// will be recorded at that time.

// MOET cannot be deposited as collateral (Credit direction)
// It can only be deposited to repay debt (Debit direction)
if type == Type<@MOET.Vault>() && position.balances[type]!.direction == BalanceDirection.Credit {
panic("MOET cannot be deposited as collateral. MOET can only be borrowed (debt), not used as collateral.")
}

let acceptedAmount = from.balance
position.borrowBalance(type)!.recordDeposit(
amount: UFix128(acceptedAmount),
Expand Down Expand Up @@ -1527,20 +1558,28 @@ access(all) contract FlowALPv0 {

// If this position doesn't currently have an entry for this token, create one.
if position.getBalance(type) == nil {
// When withdrawing a token that doesn't exist in position yet,
// we'll be creating a debit (debt). Validate single debt type.
position.validateDebtType(type)

position.setBalance(type, FlowALPModels.InternalBalance(
direction: FlowALPModels.BalanceDirection.Credit,
scaledBalance: 0.0
))
}

let reserveVault = self.state.borrowReserve(type)!

// Reflect the withdrawal in the position's balance
let wasCredit = position.balances[type]!.direction == BalanceDirection.Credit
let uintAmount = UFix128(amount)
position.borrowBalance(type)!.recordWithdrawal(
amount: uintAmount,
tokenState: tokenState
)

// If we flipped from Credit to Debit, validate debt type constraint
if wasCredit && position.balances[type]!.direction == BalanceDirection.Debit {
position.validateDebtType(type)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

could we validate before the recordWithdrawal? So that instead of returning the error message "Internal error, Position has multiple debt types", which is confusing, we could provide better error message, saying "Position already has debt type X. Cannot borrow Y"

  if currentBalance.direction == Credit && currentBalance.wouldFlipToDebit(amount, tokenState) {
      position.validateDebtType(type)
  }
  position.borrowBalance(type)!.recordWithdrawal(...)

Copy link
Copy Markdown
Contributor

@liobrasil liobrasil Mar 4, 2026

Choose a reason for hiding this comment

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

Follow-up for this validation-order path is in PR #200:
We now validate debt type before recordWithdrawal on credit->debit flip flows, so invalid attempts fail with the intended domain error instead of the internal invariant assert.

}
// Attempt to pull additional collateral from the top-up source (if configured)
// to keep the position above minHealth after the withdrawal.
// Regardless of whether a top-up occurs, the position must be healthy post-withdrawal.
Expand All @@ -1565,18 +1604,30 @@ access(all) contract FlowALPv0 {
// Queue for update if necessary
self._queuePositionForUpdateIfNecessary(pid: pid)

let withdrawn <- reserveVault.withdraw(amount: amount)
// Get tokens either by minting (MOET) or from reserves (other tokens)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Dete suggested an abstraction to help simplify this different behaviour between MOET and other tokens in this thread. I think we should apply that, or a similar idea here to minimize the additional complexity we are adding.

This function was already ~150 LOC before this PR, which is beyond the threshold where a person can reasonably understand its behaviour, in my opinion. This is a serious technical risk for introducing and hiding security vulnerabilities. We should ideally be working on improving code quality over time, but at a minimum we need to stop making the problem worse. If you find yourself adding new functionality inline to a >100LOC function, I think that should be a strong signal that we can afford to spend the time to decompose the function into more manageable components as part of adding that functionality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added abstraction with interfaces for reserve handlers

var withdrawn: @{FungibleToken.Vault}? <- nil
if type == Type<@MOET.Vault>() {
// For MOET, mint new tokens
withdrawn <-! FlowALPv0._borrowMOETMinter().mintTokens(amount: amount)
} else {
// For other tokens, withdraw from reserves
assert(self.reserves[type] != nil, message: "Cannot withdraw \(type.identifier) - no reserves available")
let reserveVault = self.state.borrowReserve(type)!
assert(reserveVault.balance >= amount, message: "Insufficient reserves for \(type.identifier): need \(amount), have \(reserveVault.balance)")
withdrawn <-! reserveVault.withdraw(amount: amount)
}
let unwrappedWithdrawn <- withdrawn!

FlowALPEvents.emitWithdrawn(
pid: pid,
poolUUID: self.uuid,
vaultType: type,
amount: withdrawn.balance,
withdrawnUUID: withdrawn.uuid
amount: unwrappedWithdrawn.balance,
withdrawnUUID: unwrappedWithdrawn.uuid
)

self.unlockPosition(pid)
return <- withdrawn
return <- unwrappedWithdrawn
}

///////////////////////
Expand Down Expand Up @@ -1951,40 +2002,56 @@ access(all) contract FlowALPv0 {
let sinkCapacity = drawDownSink.minimumCapacity()
let sinkAmount = (idealWithdrawal > sinkCapacity) ? sinkCapacity : idealWithdrawal

// TODO(jord): we enforce in setDrawDownSink that the type is MOET -> we should panic here if that does not hold (currently silently fail)
if sinkAmount > 0.0 && sinkType == Type<@MOET.Vault>() {
let tokenState = self._borrowUpdatedTokenState(type: Type<@MOET.Vault>())
if position.getBalance(Type<@MOET.Vault>()) == nil {
position.setBalance(Type<@MOET.Vault>(), FlowALPModels.InternalBalance(
// Support multiple token types: MOET (minted) or other tokens (from reserves)
if sinkAmount > 0.0 {
let tokenState = self._borrowUpdatedTokenState(type: sinkType)
if position.getBalance(sinkType) == nil {
// Rebalancing is borrowing/withdrawing to push to sink - validate single debt type
position.validateDebtType(sinkType)

position.setBalance(sinkType, FlowALPModels.InternalBalance(
direction: FlowALPModels.BalanceDirection.Credit,
scaledBalance: 0.0
))
}
// record the withdrawal and mint the tokens
// Record the withdrawal
let uintSinkAmount = UFix128(sinkAmount)
position.borrowBalance(Type<@MOET.Vault>())!.recordWithdrawal(
position.borrowBalance(sinkType)!.recordWithdrawal(
amount: uintSinkAmount,
tokenState: tokenState
)
let sinkVault <- FlowALPv0._borrowMOETMinter().mintTokens(amount: sinkAmount)

// Get tokens either by minting (MOET) or from reserves (other tokens)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems very similar to the logic on lines 1597-1608 - can we make a shared helper function to avoid duplicating big chunks of code inline?

var sinkVault: @{FungibleToken.Vault}? <- nil
if sinkType == Type<@MOET.Vault>() {
// For MOET, mint new tokens
sinkVault <-! FlowALPv0._borrowMOETMinter().mintTokens(amount: sinkAmount)
} else {
// For other tokens, withdraw from reserves
assert(self.reserves[sinkType] != nil, message: "Cannot withdraw \(sinkAmount) of \(sinkType.identifier) - token not in reserves")
let reserveVault = (&self.reserves[sinkType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)!
assert(reserveVault.balance >= sinkAmount, message: "Insufficient reserves for \(sinkType.identifier): available \(reserveVault.balance), needed \(sinkAmount)")
sinkVault <-! reserveVault.withdraw(amount: sinkAmount)
}
let unwrappedSinkVault <- sinkVault!

FlowALPEvents.emitRebalanced(
pid: pid,
poolUUID: self.uuid,
atHealth: balanceSheet.health,
amount: sinkVault.balance,
amount: unwrappedSinkVault.balance,
fromUnder: false
)

// Push what we can into the sink, and redeposit the rest
drawDownSink.depositCapacity(from: &sinkVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
if sinkVault.balance > 0.0 {
drawDownSink.depositCapacity(from: &unwrappedSinkVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
if unwrappedSinkVault.balance > 0.0 {
self._depositEffectsOnly(
pid: pid,
from: <-sinkVault,
from: <-unwrappedSinkVault,
)
} else {
Burner.burn(<-sinkVault)
Burner.burn(<-unwrappedSinkVault)
}
}
}
Expand Down
Loading
Loading