Skip to content
Merged
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
76 changes: 46 additions & 30 deletions cadence/contracts/FlowALPv0.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -582,12 +582,12 @@ access(all) contract FlowALPv0 {
/// Sets the InternalPosition's drawDownSink. If `nil`, the Pool will not be able to push overflown value when
/// the position exceeds its maximum health.
///
/// NOTE: If a non-nil value is provided, the Sink MUST accept MOET deposits or the operation will revert.
/// TODO(jord): precondition assumes Pool's default token is MOET, however Pool has option to specify default token in constructor.
access(EImplementation) fun setDrawDownSink(_ sink: {DeFiActions.Sink}?) {
/// The Sink MUST accept a token type that is supported by the pool (i.e. present in the pool's globalLedger).
/// Validated against the caller-supplied `supportedTypes` set (pass `globalLedger.keys` from Pool).
access(EImplementation) fun setDrawDownSink(_ sink: {DeFiActions.Sink}?, supportedTypes: {Type: Bool}) {
pre {
sink == nil || sink!.getSinkType() == Type<@MOET.Vault>():
"Invalid Sink provided - Sink must accept MOET"
sink == nil || supportedTypes[sink!.getSinkType()] == true:
"Invalid Sink provided - Sink must accept a pool-supported token type"
}
self.drawDownSink = sink
}
Expand Down Expand Up @@ -2690,7 +2690,9 @@ access(all) contract FlowALPv0 {
// assign issuance & repayment connectors within the InternalPosition
let iPos = self._borrowPosition(pid: id)
let fundsType = funds.getType()
iPos.setDrawDownSink(issuanceSink)
var supportedTypes: {Type: Bool} = {}
for t in self.globalLedger.keys { supportedTypes[t] = true }
iPos.setDrawDownSink(issuanceSink, supportedTypes: supportedTypes)
if repaymentSource != nil {
iPos.setTopUpSource(repaymentSource)
}
Expand Down Expand Up @@ -3776,40 +3778,52 @@ 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.balances[Type<@MOET.Vault>()] == nil {
position.balances[Type<@MOET.Vault>()] = InternalBalance(
if sinkAmount > 0.0 {
let tokenState = self._borrowUpdatedTokenState(type: sinkType)
if position.balances[sinkType] == nil {
position.balances[sinkType] = InternalBalance(
direction: BalanceDirection.Credit,
scaledBalance: 0.0
)
}
// record the withdrawal and mint the tokens
// Record the withdrawal against sinkType, then issue it.
// For MOET: mint new tokens. For other tokens: withdraw from pool reserves.
let uintSinkAmount = UFix128(sinkAmount)
position.balances[Type<@MOET.Vault>()]!.recordWithdrawal(
position.balances[sinkType]!.recordWithdrawal(
amount: uintSinkAmount,
tokenState: tokenState
)
let sinkVault <- FlowALPv0._borrowMOETMinter().mintTokens(amount: sinkAmount)

emit Rebalanced(
pid: pid,
poolUUID: self.uuid,
atHealth: balanceSheet.health,
amount: sinkVault.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 {
self._depositEffectsOnly(
if sinkType == Type<@MOET.Vault>() {
let sinkVault <- FlowALPv0._borrowMOETMinter().mintTokens(amount: sinkAmount)
emit Rebalanced(
pid: pid,
from: <-sinkVault,
poolUUID: self.uuid,
atHealth: balanceSheet.health,
amount: sinkVault.balance,
Copy link
Member

Choose a reason for hiding this comment

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

Should this be the actual amount withdrawn to the sink? Given the conditional on line 3806, we might for example withdraw 100X from our position, then attempt to push that 100X to the sink, but the sink only accepts 50X, so we re-deposit the remaining 50X (net withdrawal of 50X).

I think the event should reflect the net withdrawal in this case.

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 create an issue for this to track it, and address it with the other event related tasks

fromUnder: false
)
drawDownSink.depositCapacity(from: &sinkVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
if sinkVault.balance > 0.0 {
self._depositEffectsOnly(pid: pid, from: <-sinkVault)
} else {
Burner.burn(<-sinkVault)
}
} else {
Burner.burn(<-sinkVault)
let reserveRef = (&self.reserves[sinkType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)!
let sinkVault <- reserveRef.withdraw(amount: sinkAmount)
emit Rebalanced(
pid: pid,
poolUUID: self.uuid,
atHealth: balanceSheet.health,
amount: sinkVault.balance,
Copy link
Member

Choose a reason for hiding this comment

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

Same thought here

fromUnder: false
)
drawDownSink.depositCapacity(from: &sinkVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
if sinkVault.balance > 0.0 {
self._depositEffectsOnly(pid: pid, from: <-sinkVault)
} else {
Burner.burn(<-sinkVault)
}
}
}
}
Expand Down Expand Up @@ -4435,7 +4449,9 @@ access(all) contract FlowALPv0 {
let pool = self.pool.borrow()!
pool.lockPosition(self.id)
let pos = pool.borrowPosition(pid: self.id)
pos.setDrawDownSink(sink)
var supportedTypes: {Type: Bool} = {}
for t in pool.getSupportedTokens() { supportedTypes[t] = true }
pos.setDrawDownSink(sink, supportedTypes: supportedTypes)
pool.unlockPosition(self.id)
}

Expand Down
163 changes: 163 additions & 0 deletions cadence/tests/rebalance_drawdown_non_default_token_test.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import Test
import BlockchainHelpers
import "FlowALPv0"

import "test_helpers.cdc"

/// Tests the drawDown rebalancing path where sinkType != defaultToken.
///
/// Setup:
/// - Pool defaultToken = MOET
/// - MockYieldToken is the collateral token (Credit)
/// - drawDownSink accepts FLOW (not MOET) → creates FLOW Debit on position
///
/// When the position becomes overcollateralised (MockYieldToken price rises), the
/// rebalancer borrows FLOW from pool reserves — not mint MOET — and pushes it to the
/// user's FLOW vault. The position's FLOW debit grows (more FLOW borrowed) while the
/// pool's FLOW reserves shrink.

access(all) let MOCK_YIELD_TOKEN_IDENTIFIER = "A.0000000000000007.MockYieldToken.Vault"

access(all)
fun setup() {
deployContracts()
}

access(all)
fun testDrawDownWithNonDefaultTokenSink() {
let YT_PRICE: UFix64 = 1.0
let FLOW_PRICE: UFix64 = 1.0

setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: FLOW_PRICE)
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: FLOW_PRICE)
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOCK_YIELD_TOKEN_IDENTIFIER, price: YT_PRICE)

// Pool: MOET as defaultToken; FLOW and MockYieldToken both supported as collateral
createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false)
addSupportedTokenZeroRateCurve(
signer: PROTOCOL_ACCOUNT,
tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER,
collateralFactor: 0.8,
borrowFactor: 1.0,
depositRate: 1_000_000.0,
depositCapacityCap: 1_000_000.0
)
addSupportedTokenZeroRateCurve(
signer: PROTOCOL_ACCOUNT,
tokenTypeIdentifier: MOCK_YIELD_TOKEN_IDENTIFIER,
collateralFactor: 0.8,
borrowFactor: 1.0,
depositRate: 1_000_000.0,
depositCapacityCap: 1_000_000.0
)

// Protocol deposits a large FLOW reserve position so the pool has FLOW to lend.
// pushToDrawDownSink=false: protocol does not draw down (its own sink is MOET).
let RESERVE_AMOUNT: UFix64 = 10_000.0
transferFlowTokens(to: PROTOCOL_ACCOUNT, amount: RESERVE_AMOUNT)
setupMoetVault(PROTOCOL_ACCOUNT, beFailed: false)
createPosition(signer: PROTOCOL_ACCOUNT, amount: RESERVE_AMOUNT, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false)

let flowReservesAtStart = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
log("FLOW in pool reserves after protocol deposit: \(flowReservesAtStart)")

// User: MockYieldToken collateral, FLOW drawDownSink.
// pushToDrawDownSink=true — pool immediately draws FLOW from reserves and pushes
// to the user's FLOW vault, establishing an initial FLOW Debit on the position.
let user = Test.createAccount()
let COLLATERAL: UFix64 = 1_000.0
transferFlowTokens(to: user, amount: 100.0)
setupMockYieldTokenVault(user, beFailed: false)
mintMockYieldToken(signer: PROTOCOL_ACCOUNT, to: user.address, amount: COLLATERAL, beFailed: false)
grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user)

let flowBeforeOpen = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)!

let openRes = _executeTransaction(
"./transactions/flow-alp/position/create_position_yt_collateral_flow_sink.cdc",
[COLLATERAL, true], // pushToDrawDownSink=true: pool borrows FLOW immediately
user
)
Test.expect(openRes, Test.beSucceeded())

// pid=0: protocol reserve position; pid=1: user's YT-collateral position
let userPid: UInt64 = 1

let flowAfterOpen = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)!
let healthAfterOpen = getPositionHealth(pid: userPid, beFailed: false)
let flowReservesAfterOpen = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)

log("User FLOW before open: \(flowBeforeOpen)")
log("User FLOW after open (initial drawDown fired): \(flowAfterOpen)")
log("Health after open (should ≈ TARGET_HEALTH): \(healthAfterOpen)")
log("FLOW reserves after open: \(flowReservesAfterOpen)")

// Initial drawDown fired: user received FLOW, health is at targetHealth, reserves decreased
Test.assert(flowAfterOpen > flowBeforeOpen,
message: "Expected initial drawDown to push FLOW to user, got \(flowAfterOpen) (was \(flowBeforeOpen))")
Test.assert(equalAmounts128(a: healthAfterOpen, b: INT_TARGET_HEALTH, tolerance: 0.00000001),
message: "Expected health ≈ TARGET_HEALTH (\(INT_TARGET_HEALTH)) after open, got \(healthAfterOpen)")
Test.assert(flowReservesAfterOpen < flowReservesAtStart,
message: "Expected FLOW reserves to decrease after initial drawDown")

let detailsBefore = getPositionDetails(pid: userPid, beFailed: false)
let flowDebitBefore = getDebitBalanceForType(
details: detailsBefore,
vaultType: CompositeType(FLOW_TOKEN_IDENTIFIER)!
)
log("FLOW debit after open: \(flowDebitBefore)")

// MockYieldToken price doubles → position becomes overcollateralised (health > MAX_HEALTH)
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOCK_YIELD_TOKEN_IDENTIFIER, price: YT_PRICE * 2.0)

let healthAfterPriceChange = getPositionHealth(pid: userPid, beFailed: false)
log("Health after YT price doubles: \(healthAfterPriceChange)")
Test.assert(healthAfterPriceChange >= INT_MAX_HEALTH,
message: "Expected health >= MAX_HEALTH (\(INT_MAX_HEALTH)) after price doubling, got \(healthAfterPriceChange)")

// Rebalance — drawDown path fires with sinkType=FLOW, defaultToken=MOET
rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: userPid, force: true, beFailed: false)

let healthAfterRebalance = getPositionHealth(pid: userPid, beFailed: false)
let flowAfterRebalance = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)!
let flowReservesAfterRebalance = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER)
let flowDebitAfter = getDebitBalanceForType(
details: getPositionDetails(pid: userPid, beFailed: false),
vaultType: CompositeType(FLOW_TOKEN_IDENTIFIER)!
)

log("Health after rebalance (should ≈ TARGET_HEALTH): \(healthAfterRebalance)")
log("User FLOW after rebalance: \(flowAfterRebalance)")
log("FLOW reserves after rebalance: \(flowReservesAfterRebalance)")
log("FLOW debit after rebalance: \(flowDebitAfter)")

// Health pulled back to targetHealth
Test.assert(healthAfterRebalance < healthAfterPriceChange,
message: "Expected health to decrease after drawDown rebalance")
Test.assert(equalAmounts128(a: healthAfterRebalance, b: INT_TARGET_HEALTH, tolerance: 0.00000001),
message: "Expected health restored to TARGET_HEALTH (\(INT_TARGET_HEALTH)), got \(healthAfterRebalance)")

// User received more FLOW (pool pushed sinkType=FLOW from reserves)
Test.assert(flowAfterRebalance > flowAfterOpen,
message: "Expected user FLOW to increase after rebalance drawDown, got \(flowAfterRebalance) (was \(flowAfterOpen))")

// Pool FLOW reserves decreased by the drawn amount
Test.assert(flowReservesAfterRebalance < flowReservesAfterOpen,
message: "Expected FLOW reserves to decrease after drawDown, got \(flowReservesAfterRebalance) (was \(flowReservesAfterOpen))")

// Position's FLOW debit grew — pool borrowed more FLOW on behalf of the position
Test.assert(flowDebitAfter > flowDebitBefore,
message: "Expected FLOW debit to increase after drawDown, got \(flowDebitAfter) (was \(flowDebitBefore))")

// Drawn amount should match debit increase and reserve decrease (within rounding tolerance)
let drawnAmount = flowAfterRebalance - flowAfterOpen
let debitIncrease = flowDebitAfter - flowDebitBefore
let reserveDecrease = flowReservesAfterOpen - flowReservesAfterRebalance
log("FLOW drawn to user in rebalance: \(drawnAmount)")
log("FLOW debit increase: \(debitIncrease)")
log("FLOW reserve decrease: \(reserveDecrease)")
Test.assert(equalAmounts(a: drawnAmount, b: debitIncrease, tolerance: 0.01),
message: "Expected drawn amount (\(drawnAmount)) ≈ debit increase (\(debitIncrease))")
Test.assert(equalAmounts(a: drawnAmount, b: reserveDecrease, tolerance: 0.01),
message: "Expected drawn amount (\(drawnAmount)) ≈ reserve decrease (\(reserveDecrease))")
}
10 changes: 10 additions & 0 deletions cadence/tests/test_helpers.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,16 @@ fun getPositionBalance(pid: UInt64, vaultID: String): FlowALPv0.PositionBalance
panic("expected to find balance for \(vaultID) in position\(pid)")
}

access(all)
fun equalAmounts(a: UFix64, b: UFix64, tolerance: UFix64): Bool {
return a >= b ? a - b <= tolerance : b - a <= tolerance
}

access(all)
fun equalAmounts128(a: UFix128, b: UFix128, tolerance: UFix128): Bool {
return a >= b ? a - b <= tolerance : b - a <= tolerance
}

access(all)
fun poolExists(address: Address): Bool {
let res = _executeScript("../scripts/flow-alp/pool_exists.cdc", [address])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import "FungibleToken"
import "FlowToken"

import "DeFiActions"
import "FungibleTokenConnectors"
import "MockYieldToken"
import "FlowALPv0"

/// Opens a Position with MockYieldToken collateral and a FLOW drawDownSink.
///
/// Demonstrates sinkType (FLOW) != defaultToken (MOET): when the position becomes
/// overcollateralised the pool borrows FLOW from reserves — not MOET — and
/// pushes it to the signer's FLOW vault.
///
transaction(amount: UFix64, pushToDrawDownSink: Bool) {

let collateral: @{FungibleToken.Vault}
let sink: {DeFiActions.Sink}
let source: {DeFiActions.Source}
let positionManager: auth(FlowALPv0.EPositionAdmin) &FlowALPv0.PositionManager
let poolCap: Capability<auth(FlowALPv0.EParticipant, FlowALPv0.EPosition) &FlowALPv0.Pool>
let signerAccount: auth(Storage) &Account

prepare(signer: auth(BorrowValue, Storage, Capabilities) &Account) {
self.signerAccount = signer

// Withdraw MockYieldToken as collateral
let ytVault = signer.storage.borrow<auth(FungibleToken.Withdraw) &MockYieldToken.Vault>(
from: MockYieldToken.VaultStoragePath
) ?? panic("No MockYieldToken.Vault in storage")
self.collateral <- ytVault.withdraw(amount: amount)

// Sink: borrowed FLOW is pushed into the signer's FLOW vault
let flowDepositCap = signer.capabilities.get<&{FungibleToken.Vault}>(/public/flowTokenReceiver)
let flowWithdrawCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(/storage/flowTokenVault)

self.sink = FungibleTokenConnectors.VaultSink(
max: nil,
depositVault: flowDepositCap,
uniqueID: nil
)
// Source: repayment of FLOW debt drawn from the signer's FLOW vault
self.source = FungibleTokenConnectors.VaultSource(
min: nil,
withdrawVault: flowWithdrawCap,
uniqueID: nil
)

if signer.storage.borrow<&FlowALPv0.PositionManager>(from: FlowALPv0.PositionStoragePath) == nil {
let manager <- FlowALPv0.createPositionManager()
signer.storage.save(<-manager, to: FlowALPv0.PositionStoragePath)
let readCap = signer.capabilities.storage.issue<&FlowALPv0.PositionManager>(FlowALPv0.PositionStoragePath)
signer.capabilities.publish(readCap, at: FlowALPv0.PositionPublicPath)
}

self.positionManager = signer.storage.borrow<auth(FlowALPv0.EPositionAdmin) &FlowALPv0.PositionManager>(
from: FlowALPv0.PositionStoragePath
) ?? panic("PositionManager not found")

self.poolCap = signer.storage.load<Capability<auth(FlowALPv0.EParticipant, FlowALPv0.EPosition) &FlowALPv0.Pool>>(
from: FlowALPv0.PoolCapStoragePath
) ?? panic("No Pool capability at PoolCapStoragePath")
}

execute {
let pool = self.poolCap.borrow() ?? panic("Could not borrow Pool capability")
let position <- pool.createPosition(
funds: <-self.collateral,
issuanceSink: self.sink,
repaymentSource: self.source,
pushToDrawDownSink: pushToDrawDownSink
)
self.positionManager.addPosition(position: <-position)
self.signerAccount.storage.save(self.poolCap, to: FlowALPv0.PoolCapStoragePath)
}
}
Loading