From af074d62fc89e619cee81de1ba2a0fa181865b9b Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 18 Apr 2026 00:31:48 +0200 Subject: [PATCH 1/2] Add FlowYieldVaults structure --- cadence/contracts/actions/FlowActions.cdc | 42 ++++- .../yield_vaults/FlowYieldVaults.cdc | 105 +++++++++++- .../FlowYieldVaultsEarlyAccess.cdc | 6 +- .../FlowYieldVaultsInterfaces.cdc | 4 +- .../scripts/yield_vaults/get_strategies.cdc | 5 + .../yield_vaults/get_strategy_count.cdc | 5 + .../flow_yield_vaults_early_access_test.cdc | 60 +++---- cadence/tests/flow_yield_vaults_test.cdc | 136 +++++++++++++++ cadence/tests/helpers/actions_helpers.cdc | 8 +- cadence/tests/helpers/all_helpers.cdc | 1 + cadence/tests/helpers/alp_helpers.cdc | 6 +- cadence/tests/helpers/deployment_helpers.cdc | 25 +++ .../yield_vault_early_access_helpers.cdc | 8 +- cadence/tests/helpers/yield_vault_helpers.cdc | 46 +++++ cadence/tests/mocks/MockFlowYieldVaults.cdc | 4 +- cadence/tests/mocks/MockStrategy.cdc | 37 ++++ .../yield_vaults/create_strategy_vault.cdc | 16 ++ .../create_strategy_vault_at_path.cdc | 13 ++ .../yield_vaults/register_mock_strategy.cdc | 16 ++ .../yield_vaults/remove_strategy.cdc | 15 ++ .../yield_vaults/create_position.cdc | 6 +- docs/flow_yield_vaults.md | 158 ++++++++++++++++++ flow.json | 10 +- 23 files changed, 674 insertions(+), 58 deletions(-) create mode 100644 cadence/scripts/yield_vaults/get_strategies.cdc create mode 100644 cadence/scripts/yield_vaults/get_strategy_count.cdc create mode 100644 cadence/tests/flow_yield_vaults_test.cdc create mode 100644 cadence/tests/helpers/yield_vault_helpers.cdc create mode 100644 cadence/tests/mocks/MockStrategy.cdc create mode 100644 cadence/tests/transactions/yield_vaults/create_strategy_vault.cdc create mode 100644 cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc create mode 100644 cadence/tests/transactions/yield_vaults/register_mock_strategy.cdc create mode 100644 cadence/tests/transactions/yield_vaults/remove_strategy.cdc create mode 100644 docs/flow_yield_vaults.md diff --git a/cadence/contracts/actions/FlowActions.cdc b/cadence/contracts/actions/FlowActions.cdc index fee391e..5c55f3b 100644 --- a/cadence/contracts/actions/FlowActions.cdc +++ b/cadence/contracts/actions/FlowActions.cdc @@ -1,4 +1,44 @@ +import "FungibleToken" access(all) contract FlowActions { -} \ No newline at end of file + // Interfaces are not fixed and still under development. + // This is the typical EVM interface translated to cadence. + // Necessary to setup FlowYieldVaults structure. + access(all) struct interface Swapper { + access(all) token0: Type + access(all) token1: Type + access(all) fee: UInt32 + + /// Exact Input: "I have this many tokens, give me whatever they are worth" + access(all) fun quoteExactInput( + zeroForOne: Bool, + amountIn: UFix64 + ): UFix64 + + /// Exact Output: "I want exactly this many tokens, how much do I need to pay?" + access(all) fun quoteExactOutput( + zeroForOne: Bool, + amountOut: UFix64 + ): UFix64 + + access(all) fun swap( + zeroForOne: Bool, + inVault: @{FungibleToken.Vault} + ): @{FungibleToken.Vault} + } + + /// FYV needs this functionality but it doesn't have to be implemented like this! + /// this is dangerous!! if a 3rd party provides a type and we are executing + /// createEmptyVault any code can be run. (reentrancy, etc) + access(all) fun getEmptyVault(_ vaultType: Type): @{FungibleToken.Vault} { + post { + result.getType() == vaultType: + "Invalid Vault returned - expected \(vaultType.identifier) but returned \(result.getType().identifier)" + } + return <- getAccount(vaultType.address!) + .contracts + .borrow<&{FungibleToken}>(name: vaultType.contractName!)! + .createEmptyVault(vaultType: vaultType) + } +} diff --git a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc index 979a29f..f32c0d0 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc @@ -1,4 +1,107 @@ +import "FlowYieldVaultsInterfaces" -access(all) contract FlowYieldVaults { +/// Registry of yield vault strategies on this account, keyed by name. +/// An `Admin` resource (saved at `adminStoragePath` on the contract account) +/// registers and removes strategies conforming to +/// `FlowYieldVaultsInterfaces.Strategy`. Yield vaults are minted from a +/// registered strategy by `name` through `createYieldVault`. +/// +/// This contract is strategy-agnostic: it does not depend on any specific +/// strategy family. Concrete strategies live in their own contracts and are +/// plugged in through `Admin.registerStrategy`. +access(all) contract FlowYieldVaults: FlowYieldVaultsInterfaces { + /// Emitted when a strategy is registered under `name`. + access(all) event StrategyCreated(name: String) + /// Emitted when a strategy is removed from the registry. + access(all) event StrategyRemoved(name: String) + /// Emitted when a yield vault is minted from the named strategy. + access(all) event StrategyVaultCreated(name: String) + + /// Storage path where the `Admin` resource is saved on this account. + access(all) let adminStoragePath: StoragePath + + /// Registered strategies, keyed by name. Names are unique; registering + /// an already-used name panics. + access(self) let strategies: {String: {FlowYieldVaultsInterfaces.Strategy}} + + /// Admin resource; holder may register / remove strategies and mint + /// yield vaults directly (bypassing any external access gate). + access(all) resource Admin { + /// Registers `strategy` under `name`. + /// Panics if a strategy is already registered under that name. + /// + /// **Parameters** + /// - `name`: Unique identifier for the strategy in this registry. + /// - `strategy`: Any value conforming to + /// `FlowYieldVaultsInterfaces.Strategy`. + access(all) fun registerStrategy( + name: String, + strategy: {FlowYieldVaultsInterfaces.Strategy} + ) { + assert( + FlowYieldVaults.strategies[name] == nil, + message: "Strategy already registered: \(name)" + ) + FlowYieldVaults.strategies[name] = strategy + emit StrategyCreated(name: name) + } + + /// Removes the strategy registered under `name`. + /// Panics if no strategy is registered under that name. Does not + /// affect already-minted yield vaults — those captured the strategy + /// parameters at creation time. + /// + /// **Parameters** + /// - `name`: Name of the strategy to remove. + access(all) fun removeStrategy(name: String) { + let strategy = FlowYieldVaults.strategies.remove(key: name) + if strategy == nil { + panic("Strategy not found") + } + emit StrategyRemoved(name: name) + } + + /// Mints a yield vault from a registered strategy. + /// Wrapper around the contract-level `createYieldVault` + /// for callers that hold the admin resource. + /// + /// **Parameters** + /// - `name`: Name of the registered strategy. + /// + /// **Returns** A new `YieldVault` for the caller to save in storage. + access(all) fun createYieldVault(name: String): @{FlowYieldVaultsInterfaces.YieldVault} { + return <- FlowYieldVaults.createYieldVault(name: name) + } + } + + /// Mints a yield vault from a registered strategy. + /// Panics if no strategy is registered under `name`. + /// `access(account)` so that only contracts on this account (e.g. + /// `FlowYieldVaultsEarlyAccess`) can gate or invoke vault creation. + /// + /// **Parameters** + /// - `name`: Name of the registered strategy. + /// + /// **Returns** A new `YieldVault` for the caller to save in storage. + access(account) fun createYieldVault(name: String): @{FlowYieldVaultsInterfaces.YieldVault} { + let strategy = self.strategies[name] ?? panic("Strategy not found") + let vault <- strategy.createYieldVault(name: name) + emit StrategyVaultCreated(name: name) + return <- vault + } + + view access(all) fun strategyCount(): UInt64 { + return UInt64(self.strategies.length) + } + + view access(all) fun strategyNames(): [String] { + return self.strategies.keys + } + + init() { + self.strategies = {} + self.adminStoragePath = StoragePath(identifier: "FlowYieldVaultsAdmin")! + self.account.storage.save(<- create Admin(), to: self.adminStoragePath) + } } diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc index f8a7855..be069d3 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc @@ -32,14 +32,14 @@ access(all) contract FlowYieldVaultsEarlyAccess { /// Panics if allowance is exhausted. /// /// **Parameters** - /// - `strategyID`: Identifies the vault strategy to create. + /// - `name`: Name of the registered strategy to create a vault for. /// /// **Returns** A new `YieldVault` to be saved in the caller's storage. - access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + access(all) fun createYieldVault(name: String): @{FlowYieldVaultsInterfaces.YieldVault} { pre { self.remainingAllowance > 0: "No remaining allowance" } self.remainingAllowance = self.remainingAllowance - 1 let fyv = FlowYieldVaultsEarlyAccess.getFlowYieldVaultsContract() - let vault <- fyv.createYieldVault(strategyID: strategyID) + let vault <- fyv.createYieldVault(name: name) emit PassUsed(passUUID: self.uuid, remainingAllowance: self.remainingAllowance) return <- vault } diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc index 454bcf1..fd6298f 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc @@ -3,10 +3,10 @@ import "FungibleToken" access(all) contract interface FlowYieldVaultsInterfaces { access(all) struct interface Strategy { - access(all) fun createYieldVault(strategyID: UInt64): @{YieldVault} + access(all) fun createYieldVault(name: String): @{YieldVault} } access(all) resource interface YieldVault: FungibleToken.Provider, FungibleToken.Receiver {} - access(account) fun createYieldVault(strategyID: UInt64): @{YieldVault} + access(account) fun createYieldVault(name: String): @{YieldVault} } diff --git a/cadence/scripts/yield_vaults/get_strategies.cdc b/cadence/scripts/yield_vaults/get_strategies.cdc new file mode 100644 index 0000000..efacafb --- /dev/null +++ b/cadence/scripts/yield_vaults/get_strategies.cdc @@ -0,0 +1,5 @@ +import "FlowYieldVaults" + +access(all) fun main(): [String] { + return FlowYieldVaults.strategyNames() +} diff --git a/cadence/scripts/yield_vaults/get_strategy_count.cdc b/cadence/scripts/yield_vaults/get_strategy_count.cdc new file mode 100644 index 0000000..34e61cb --- /dev/null +++ b/cadence/scripts/yield_vaults/get_strategy_count.cdc @@ -0,0 +1,5 @@ +import "FlowYieldVaults" + +access(all) fun main(): UInt64 { + return FlowYieldVaults.strategyCount() +} diff --git a/cadence/tests/flow_yield_vaults_early_access_test.cdc b/cadence/tests/flow_yield_vaults_early_access_test.cdc index 499ead3..758e71a 100644 --- a/cadence/tests/flow_yield_vaults_early_access_test.cdc +++ b/cadence/tests/flow_yield_vaults_early_access_test.cdc @@ -28,7 +28,7 @@ access(all) fun setup() { access(all) fun test_no_pass() { expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + createYieldVault(signer: userA, name: "mock", path: defaultPath), errorMessageSubstring: "No valid early access pass" ) } @@ -37,9 +37,9 @@ access(all) fun test_grant() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) Test.assert(hasEarlyAccess(passUUID)) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: defaultPath), Test.beSucceeded()) expectFailedWithError( - createYieldVault(signer: userB, strategyID: 0, path: defaultPath), + createYieldVault(signer: userB, name: "mock", path: defaultPath), errorMessageSubstring: "No valid early access pass" ) } @@ -51,7 +51,7 @@ access(all) fun test_revoke() { Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) Test.assert(!hasEarlyAccess(passUUIDA)) expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + createYieldVault(signer: userA, name: "mock", path: defaultPath), errorMessageSubstring: "No valid early access pass" ) } @@ -59,7 +59,7 @@ access(all) fun test_revoke() { access(all) fun test_use_position_after_revoke() { let passUUIDA = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUIDA, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: defaultPath), Test.beSucceeded()) Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) Test.expect(deposit(signer: userA, path: defaultPath), Test.beSucceeded()) } @@ -84,23 +84,23 @@ access(all) fun test_revoke_and_re_grant() { let passUUID2 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUID2, provider: admin.address), Test.beSucceeded()) Test.assert(hasEarlyAccess(passUUID2)) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: defaultPath), Test.beSucceeded()) } access(all) fun test_pass_is_reusable() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/b), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/c), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/b), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/c), Test.beSucceeded()) } access(all) fun test_allowance_exhausted() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: /storage/b), + createYieldVault(signer: userA, name: "mock", path: /storage/b), errorMessageSubstring: "No remaining allowance" ) } @@ -110,17 +110,17 @@ access(all) fun test_two_users_independent() { let passUUIDB = grantEarlyAccess(admin: admin, user: userB, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUIDA, provider: admin.address), Test.beSucceeded()) Test.expect(claimPass(user: userB, passUUID: passUUIDB, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userB, strategyID: 0, path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userB, name: "mock", path: defaultPath), Test.beSucceeded()) Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) Test.assert(!hasEarlyAccess(passUUIDA)) Test.assert(hasEarlyAccess(passUUIDB)) expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: /storage/a2), + createYieldVault(signer: userA, name: "mock", path: /storage/a2), errorMessageSubstring: "No valid early access pass" ) - Test.expect(createYieldVault(signer: userB, strategyID: 0, path: /storage/b2), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userB, strategyID: 0, path: /storage/b3), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userB, name: "mock", path: /storage/b2), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userB, name: "mock", path: /storage/b3), Test.beSucceeded()) } access(all) fun test_remainingPositions_reflects_allowance() { @@ -131,21 +131,21 @@ access(all) fun test_remainingPositions_reflects_allowance() { access(all) fun test_remainingPositions_decrements_on_createYieldVault() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) Test.assertEqual(2 as UInt64, remainingAllowance(passUUID)) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/b), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/b), Test.beSucceeded()) Test.assertEqual(1 as UInt64, remainingAllowance(passUUID)) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/c), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/c), Test.beSucceeded()) Test.assertEqual(0 as UInt64, remainingAllowance(passUUID)) } access(all) fun test_remainingPositions_is_zero_after_exhausted() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) Test.assertEqual(0 as UInt64, remainingAllowance(passUUID)) expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: /storage/b), + createYieldVault(signer: userA, name: "mock", path: /storage/b), errorMessageSubstring: "No remaining allowance" ) } @@ -160,7 +160,7 @@ access(all) fun test_remainingPositions_two_users_independent() { let passUUIDA = grantEarlyAccess(admin: admin, user: userA, allowance: 5) let passUUIDB = grantEarlyAccess(admin: admin, user: userB, allowance: 2) Test.expect(claimPass(user: userA, passUUID: passUUIDA, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) Test.assertEqual(4 as UInt64, remainingAllowance(passUUIDA)) Test.assertEqual(2 as UInt64, remainingAllowance(passUUIDB)) } @@ -171,8 +171,8 @@ access(all) fun test_setAllowance() { Test.expect(setAllowance(admin: admin, passUUID: passUUID, newAllowance: 5), Test.beSucceeded()) Test.assertEqual(5 as UInt64, remainingAllowance(passUUID)) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/b), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/b), Test.beSucceeded()) Test.assertEqual(3 as UInt64, remainingAllowance(passUUID)) } @@ -183,7 +183,7 @@ access(all) fun test_setAllowance_to_zero_blocks_createYieldVault() { Test.assert(hasEarlyAccess(passUUID)) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + createYieldVault(signer: userA, name: "mock", path: defaultPath), errorMessageSubstring: "No remaining allowance" ) } @@ -210,7 +210,7 @@ access(all) fun test_revoke_events() { access(all) fun test_used_events() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: /storage/a), Test.beSucceeded()) let events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassUsed @@ -231,7 +231,7 @@ access(all) fun test_claim_by_address() { let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) Test.expect(claimPassByAddress(user: userA, provider: admin.address), Test.beSucceeded()) Test.assert(hasEarlyAccess(passUUID)) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: defaultPath), Test.beSucceeded()) } access(all) fun test_claim_by_address_gets_most_recent() { @@ -239,7 +239,7 @@ access(all) fun test_claim_by_address_gets_most_recent() { let passUUID2 = grantEarlyAccess(admin: admin, user: userA, allowance: 5) Test.expect(claimPassByAddress(user: userA, provider: admin.address), Test.beSucceeded()) Test.assertEqual(5 as UInt64, remainingAllowance(passUUID2)) - Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, name: "mock", path: defaultPath), Test.beSucceeded()) } access(all) fun test_claim_by_address_fails_if_no_pass_issued() { @@ -254,11 +254,11 @@ access(all) fun test_claim_with_custom_path() { let customPath = /storage/myCustomEarlyAccessPath Test.expect(claimPassWithPath(user: userA, passUUID: passUUID, provider: admin.address, path: customPath), Test.beSucceeded()) expectFailedWithError( - createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + createYieldVault(signer: userA, name: "mock", path: defaultPath), errorMessageSubstring: "No valid early access pass" ) Test.expect( - createYieldVaultAtEarlyAccessPath(signer: userA, strategyID: 0, earlyAccessPath: customPath, vaultPath: defaultPath), + createYieldVaultAtEarlyAccessPath(signer: userA, name: "mock", earlyAccessPath: customPath, vaultPath: defaultPath), Test.beSucceeded() ) } diff --git a/cadence/tests/flow_yield_vaults_test.cdc b/cadence/tests/flow_yield_vaults_test.cdc new file mode 100644 index 0000000..25b6250 --- /dev/null +++ b/cadence/tests/flow_yield_vaults_test.cdc @@ -0,0 +1,136 @@ +import Test +import BlockchainHelpers + +import "helpers/deployment_helpers.cdc" +import "helpers/yield_vault_helpers.cdc" +import "FlowYieldVaults" + +access(all) var admin = Test.getAccount(Address(0x0000000000000007)) + +access(all) var snapshot: UInt64 = 0 +access(all) fun beforeEach() { Test.reset(to: snapshot) } + +access(all) fun setup() { + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") + deploy("cadence/tests/mocks/MockStrategy.cdc") + snapshot = getCurrentBlockHeight() +} + +access(all) fun test_strategyCount_starts_at_zero() { + Test.assertEqual(0 as UInt64, strategyCount()) +} + +access(all) fun test_strategyNames_starts_empty() { + Test.assertEqual(0, strategyNames().length) +} + +access(all) fun test_registerStrategy_increments_count() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(1 as UInt64, strategyCount()) +} + +access(all) fun test_registerStrategy_emits_event() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + let events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + Test.assertEqual("a", (events[0] as! FlowYieldVaults.StrategyCreated).name) +} + +access(all) fun test_registerStrategy_multiple_names() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(registerMockStrategy(name: "b", signer: admin), Test.beSucceeded()) + Test.expect(registerMockStrategy(name: "c", signer: admin), Test.beSucceeded()) + Test.assertEqual(3 as UInt64, strategyCount()) + let names = strategyNames() + Test.assertEqual(3, names.length) + Test.assert(names.contains("a")) + Test.assert(names.contains("b")) + Test.assert(names.contains("c")) +} + +access(all) fun test_registerStrategy_duplicate_name_fails() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + expectFailedWithError( + registerMockStrategy(name: "a", signer: admin), + errorMessageSubstring: "Strategy already registered" + ) +} + +access(all) fun test_createStrategyVault_unknown_fails() { + expectFailedWithError( + createStrategyVault(name: "missing", signer: admin), + errorMessageSubstring: "Strategy not found" + ) +} + +access(all) fun test_createStrategyVault_conforms_to_YieldVault_interface() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect( + createStrategyVaultAtPath(name: "a", path: /storage/yieldVaultTypeCheck, signer: admin), + Test.beSucceeded() + ) +} + +access(all) fun test_createStrategyVault_emits_event() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + let events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + Test.assertEqual("a", (events[0] as! FlowYieldVaults.StrategyVaultCreated).name) +} + +access(all) fun test_createStrategyVault_multiple_times_same_name() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(3, Test.eventsOfType(Type()).length) +} + +access(all) fun test_removeStrategy_removes_entry() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(registerMockStrategy(name: "b", signer: admin), Test.beSucceeded()) + Test.expect(removeStrategy(name: "a", signer: admin), Test.beSucceeded()) + let names = strategyNames() + Test.assertEqual(1, names.length) + Test.assert(!names.contains("a")) + Test.assert(names.contains("b")) +} + +access(all) fun test_removeStrategy_emits_event() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(removeStrategy(name: "a", signer: admin), Test.beSucceeded()) + let events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + Test.assertEqual("a", (events[0] as! FlowYieldVaults.StrategyRemoved).name) +} + +access(all) fun test_removeStrategy_unknown_fails() { + expectFailedWithError( + removeStrategy(name: "missing", signer: admin), + errorMessageSubstring: "Strategy not found" + ) +} + +access(all) fun test_removeStrategy_blocks_createYieldVault() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(removeStrategy(name: "a", signer: admin), Test.beSucceeded()) + expectFailedWithError( + createStrategyVault(name: "a", signer: admin), + errorMessageSubstring: "Strategy not found" + ) +} + +access(all) fun test_register_after_remove_succeeds() { + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(removeStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(registerMockStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(1 as UInt64, strategyCount()) + Test.assert(strategyNames().contains("a")) +} + +access(self) fun expectFailedWithError(_ res: Test.TransactionResult, errorMessageSubstring: String) { + Test.expect(res, Test.beFailed()) + Test.assertError(res, errorMessage: errorMessageSubstring) +} diff --git a/cadence/tests/helpers/actions_helpers.cdc b/cadence/tests/helpers/actions_helpers.cdc index 888b3c9..6e786c1 100644 --- a/cadence/tests/helpers/actions_helpers.cdc +++ b/cadence/tests/helpers/actions_helpers.cdc @@ -3,7 +3,7 @@ import Test // PLACEHOLDER: wraps the stub count script used to verify CI script execution. // Replace alongside scripts/actions/count.cdc once real state exists. access(all) fun actionsCount(): Int { - let result = _executeScript("cadence/scripts/actions/count.cdc", []) + let result = executeScript("cadence/scripts/actions/count.cdc", []) Test.expect(result, Test.beSucceeded()) return result.returnValue as! Int } @@ -12,15 +12,15 @@ access(all) fun actionsCount(): Int { // transaction execution. Replace alongside transactions/actions/increment.cdc // once real mutations exist. access(all) fun actionsIncrementCount(signer: Test.TestAccount) { - let result = _executeTransaction("cadence/tests/transactions/actions/increment.cdc", [], signer) + let result = executeTransaction("cadence/tests/transactions/actions/increment.cdc", [], signer) Test.expect(result, Test.beSucceeded()) } -access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { +access(self) fun executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { return Test.executeScript(Test.readFile(path), args) } -access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.TestAccount): Test.TransactionResult { +access(self) fun executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.TestAccount): Test.TransactionResult { let txn = Test.Transaction( code: Test.readFile(path), authorizers: [signer.address], diff --git a/cadence/tests/helpers/all_helpers.cdc b/cadence/tests/helpers/all_helpers.cdc index 734febd..2e0ad61 100644 --- a/cadence/tests/helpers/all_helpers.cdc +++ b/cadence/tests/helpers/all_helpers.cdc @@ -4,3 +4,4 @@ import "deployment_helpers.cdc" import "actions_helpers.cdc" import "alp_helpers.cdc" import "yield_vault_early_access_helpers.cdc" +import "yield_vault_helpers.cdc" diff --git a/cadence/tests/helpers/alp_helpers.cdc b/cadence/tests/helpers/alp_helpers.cdc index dfb0c13..be44ce9 100644 --- a/cadence/tests/helpers/alp_helpers.cdc +++ b/cadence/tests/helpers/alp_helpers.cdc @@ -3,16 +3,16 @@ import Test // PLACEHOLDER: wraps the stub count script used to verify CI script execution. // Replace alongside scripts/alp/count.cdc once real state exists. access(all) fun alpCount(): Int { - let result = _executeScript("cadence/scripts/alp/count.cdc", []) + let result = executeScript("cadence/scripts/alp/count.cdc", []) Test.expect(result, Test.beSucceeded()) return result.returnValue as! Int } -access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { +access(self) fun executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { return Test.executeScript(Test.readFile(path), args) } -access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.TestAccount): Test.TransactionResult { +access(self) fun executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.TestAccount): Test.TransactionResult { let txn = Test.Transaction( code: Test.readFile(path), authorizers: [signer.address], diff --git a/cadence/tests/helpers/deployment_helpers.cdc b/cadence/tests/helpers/deployment_helpers.cdc index 402f218..c7c72ec 100644 --- a/cadence/tests/helpers/deployment_helpers.cdc +++ b/cadence/tests/helpers/deployment_helpers.cdc @@ -1,16 +1,26 @@ import Test +/// Last error returned by `Test.deployContract`; captured so that +/// `Test.expect(err, Test.beNil())` can assert a successful deploy. access(self) var err: Test.Error? = nil +/// Tracks whether `FlowActions` has been deployed in the current test run. access(self) var actionsDeployed = false +/// Tracks whether the `FlowALP*` contracts have been deployed. access(self) var alpDeployed = false +/// Tracks whether the `FlowYieldVaults*` contracts have been deployed. access(self) var yieldVaultsDeployed = false +/// Deploys every production contract in the correct dependency order: +/// `FlowActions` → `FlowALP*` → `FlowYieldVaults*`. +/// Each group can also be deployed individually via the helpers below. access(all) fun deployAllContracts() { deployFlowActions() deployFlowALP() deployFlowYieldVaults() } +/// Deploys `FlowActions`. +/// Panics if called more than once. access(all) fun deployFlowActions() { pre { !actionsDeployed: "FlowActions already deployed" @@ -19,6 +29,9 @@ access(all) fun deployFlowActions() { deploy("cadence/contracts/actions/FlowActions.cdc") } +/// Deploys `FlowALP`. +/// Requires `FlowActions` to be deployed first. +/// Panics if called more than once. access(all) fun deployFlowALP() { pre { actionsDeployed: "FlowActions must be deployed first" @@ -28,8 +41,13 @@ access(all) fun deployFlowALP() { deploy("cadence/contracts/alp/FlowALP.cdc") } +/// Deploys the `FlowYieldVaults` suite +/// (`FlowYieldVaultsInterfaces`, `FlowYieldVaults`, `FlowYieldVaultsEarlyAccess`). +/// Requires `FlowActions` and `FlowALP` to be deployed first. +/// Panics if called more than once. access(all) fun deployFlowYieldVaults() { pre { + actionsDeployed: "FlowActions must be deployed first" alpDeployed: "FlowALP must be deployed first" !yieldVaultsDeployed: "FlowYieldVaults already deployed" } @@ -39,6 +57,13 @@ access(all) fun deployFlowYieldVaults() { deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") } +/// Deploys a single contract from a repo-relative source path and asserts +/// the deploy succeeded. The contract name is taken from the filename +/// (stripping the trailing `.cdc`), which must match the contract +/// declaration inside the file. +/// +/// **Parameters** +/// - `path`: Repo-relative path to the `.cdc` contract source. access(all) fun deploy(_ path: String) { let parts = path.split(separator: "/") let filename = parts[parts.length - 1] diff --git a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc index b24dba9..ac1464e 100644 --- a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc +++ b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc @@ -56,18 +56,18 @@ access(all) fun setAllowance(admin: Test.TestAccount, passUUID: UInt64, newAllow ) } -access(all) fun createYieldVault(signer: Test.TestAccount, strategyID: UInt64, path: StoragePath): Test.TransactionResult { +access(all) fun createYieldVault(signer: Test.TestAccount, name: String, path: StoragePath): Test.TransactionResult { return executeTransaction( "cadence/transactions/yield_vaults/create_position.cdc", - [strategyID, nil, path], + [name, nil, path], signer ) } -access(all) fun createYieldVaultAtEarlyAccessPath(signer: Test.TestAccount, strategyID: UInt64, earlyAccessPath: StoragePath, vaultPath: StoragePath): Test.TransactionResult { +access(all) fun createYieldVaultAtEarlyAccessPath(signer: Test.TestAccount, name: String, earlyAccessPath: StoragePath, vaultPath: StoragePath): Test.TransactionResult { return executeTransaction( "cadence/transactions/yield_vaults/create_position.cdc", - [strategyID, earlyAccessPath, vaultPath], + [name, earlyAccessPath, vaultPath], signer ) } diff --git a/cadence/tests/helpers/yield_vault_helpers.cdc b/cadence/tests/helpers/yield_vault_helpers.cdc new file mode 100644 index 0000000..498e3b1 --- /dev/null +++ b/cadence/tests/helpers/yield_vault_helpers.cdc @@ -0,0 +1,46 @@ +import Test + +access(all) fun strategyCount(): UInt64 { + let result = executeScript("cadence/scripts/yield_vaults/get_strategy_count.cdc", []) + Test.expect(result, Test.beSucceeded()) + return result.returnValue! as! UInt64 +} + +access(all) fun strategyNames(): [String] { + let result = executeScript("cadence/scripts/yield_vaults/get_strategies.cdc", []) + Test.expect(result, Test.beSucceeded()) + return result.returnValue! as! [String] +} + +access(all) fun registerMockStrategy(name: String, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction("cadence/tests/transactions/yield_vaults/register_mock_strategy.cdc", [name], signer) +} + +access(all) fun removeStrategy(name: String, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction("cadence/tests/transactions/yield_vaults/remove_strategy.cdc", [name], signer) +} + +access(all) fun createStrategyVault(name: String, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction("cadence/tests/transactions/yield_vaults/create_strategy_vault.cdc", [name], signer) +} + +access(all) fun createStrategyVaultAtPath(name: String, path: StoragePath, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction( + "cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc", + [name, path], + signer + ) +} + +access(self) fun executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { + return Test.executeScript(Test.readFile(path), args) +} + +access(self) fun executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.TestAccount): Test.TransactionResult { + return Test.executeTransaction(Test.Transaction( + code: Test.readFile(path), + authorizers: [signer.address], + signers: [signer], + arguments: args + )) +} diff --git a/cadence/tests/mocks/MockFlowYieldVaults.cdc b/cadence/tests/mocks/MockFlowYieldVaults.cdc index c25d760..48ea22f 100644 --- a/cadence/tests/mocks/MockFlowYieldVaults.cdc +++ b/cadence/tests/mocks/MockFlowYieldVaults.cdc @@ -27,8 +27,8 @@ access(all) contract MockFlowYieldVaults: FlowYieldVaultsInterfaces { } } - access(account) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { - let _ = strategyID + access(account) fun createYieldVault(name: String): @{FlowYieldVaultsInterfaces.YieldVault} { + let _ = name return <- create YieldVault() } } diff --git a/cadence/tests/mocks/MockStrategy.cdc b/cadence/tests/mocks/MockStrategy.cdc new file mode 100644 index 0000000..c243603 --- /dev/null +++ b/cadence/tests/mocks/MockStrategy.cdc @@ -0,0 +1,37 @@ +import "FungibleToken" +import "FlowYieldVaultsInterfaces" + +access(all) contract MockStrategy { + + access(all) struct Strategy: FlowYieldVaultsInterfaces.Strategy { + access(all) fun createYieldVault(name _: String): @{FlowYieldVaultsInterfaces.YieldVault} { + return <- create Vault() + } + } + + access(all) resource Vault: FlowYieldVaultsInterfaces.YieldVault { + access(all) fun deposit(from: @{FungibleToken.Vault}) { + destroy from + } + + access(FungibleToken.Withdraw) fun withdraw(amount _: UFix64): @{FungibleToken.Vault} { + panic("not implemented") + } + + access(all) view fun isAvailableToWithdraw(amount _: UFix64): Bool { + return false + } + + access(all) view fun getSupportedVaultTypes(): {Type: Bool} { + return {} + } + + access(all) view fun isSupportedVaultType(type _: Type): Bool { + return false + } + } + + access(all) fun createStrategy(): Strategy { + return Strategy() + } +} diff --git a/cadence/tests/transactions/yield_vaults/create_strategy_vault.cdc b/cadence/tests/transactions/yield_vaults/create_strategy_vault.cdc new file mode 100644 index 0000000..1cb584f --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/create_strategy_vault.cdc @@ -0,0 +1,16 @@ +import "FlowYieldVaults" + +transaction(name: String) { + + let admin: &FlowYieldVaults.Admin + + prepare(signer: auth(BorrowValue) &Account) { + self.admin = signer.storage.borrow<&FlowYieldVaults.Admin>(from: FlowYieldVaults.adminStoragePath) + ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") + } + + execute { + let vault <- self.admin.createYieldVault(name: name) + destroy vault + } +} diff --git a/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc b/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc new file mode 100644 index 0000000..e04232b --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc @@ -0,0 +1,13 @@ +import "FlowYieldVaults" +import "FlowYieldVaultsInterfaces" + +transaction(name: String, path: StoragePath) { + prepare(signer: auth(Storage) &Account) { + let admin = signer.storage.borrow<&FlowYieldVaults.Admin>(from: FlowYieldVaults.adminStoragePath) + ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") + let vault <- admin.createYieldVault(name: name) + signer.storage.save(<- vault, to: path) + let ref = signer.storage.borrow<&{FlowYieldVaultsInterfaces.YieldVault}>(from: path) + ?? panic("vault does not conform to FlowYieldVaultsInterfaces.YieldVault at \(path)") + } +} diff --git a/cadence/tests/transactions/yield_vaults/register_mock_strategy.cdc b/cadence/tests/transactions/yield_vaults/register_mock_strategy.cdc new file mode 100644 index 0000000..cb62f67 --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/register_mock_strategy.cdc @@ -0,0 +1,16 @@ +import "FlowYieldVaults" +import "MockStrategy" + +transaction(name: String) { + + let admin: &FlowYieldVaults.Admin + + prepare(signer: auth(BorrowValue) &Account) { + self.admin = signer.storage.borrow<&FlowYieldVaults.Admin>(from: FlowYieldVaults.adminStoragePath) + ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") + } + + execute { + self.admin.registerStrategy(name: name, strategy: MockStrategy.createStrategy()) + } +} diff --git a/cadence/tests/transactions/yield_vaults/remove_strategy.cdc b/cadence/tests/transactions/yield_vaults/remove_strategy.cdc new file mode 100644 index 0000000..a208111 --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/remove_strategy.cdc @@ -0,0 +1,15 @@ +import "FlowYieldVaults" + +transaction(name: String) { + + let admin: &FlowYieldVaults.Admin + + prepare(signer: auth(BorrowValue) &Account) { + self.admin = signer.storage.borrow<&FlowYieldVaults.Admin>(from: FlowYieldVaults.adminStoragePath) + ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") + } + + execute { + self.admin.removeStrategy(name: name) + } +} diff --git a/cadence/transactions/yield_vaults/create_position.cdc b/cadence/transactions/yield_vaults/create_position.cdc index 727f887..4a37327 100644 --- a/cadence/transactions/yield_vaults/create_position.cdc +++ b/cadence/transactions/yield_vaults/create_position.cdc @@ -5,18 +5,18 @@ import "FlowYieldVaultsInterfaces" /// Panics if no valid pass capability is found or the pass allowance is exhausted. /// /// **Parameters** -/// - `strategyID`: Identifies the vault strategy to create a vault for. +/// - `name`: Name of the registered strategy to create a vault for. /// - `earlyAccessPath`: Storage path of the pass capability; defaults to /// `FlowYieldVaultsEarlyAccess.passCapabilityStoragePath` when `nil`. /// - `vaultPath`: Storage path where the new `YieldVault` will be saved. -transaction(strategyID: UInt64, earlyAccessPath: StoragePath?, vaultPath: StoragePath) { +transaction(name: String, earlyAccessPath: StoragePath?, vaultPath: StoragePath) { prepare(signer: auth(Storage) &Account) { let earlyAccessPath = earlyAccessPath ?? FlowYieldVaultsEarlyAccess.passCapabilityStoragePath let capability = signer.storage.copy>( from: earlyAccessPath ) ?? panic("No valid early access pass") let pass = capability.borrow() ?? panic("No valid early access pass") - let vault <- pass.createYieldVault(strategyID: strategyID) + let vault <- pass.createYieldVault(name: name) signer.storage.save(<- vault, to: vaultPath) } } diff --git a/docs/flow_yield_vaults.md b/docs/flow_yield_vaults.md new file mode 100644 index 0000000..dcd16d4 --- /dev/null +++ b/docs/flow_yield_vaults.md @@ -0,0 +1,158 @@ +# Flow Yield Vaults — Core + +## Overview + +### Problem +Users want a single, composable primitive for opening a yield-generating position without having to understand or wire up the underlying strategy (lending, LP, basis trade, …). Each strategy has its own parameters (token types, swappers, health settings); the user should not have to care. + +### Goal +Provide a registry (`FlowYieldVaults`) that admins populate with pre-configured **strategies**, and that mints a **yield vault** for any registered **name** on demand. The caller picks a strategy by name; the vault captures the strategy's parameters internally and exposes a uniform `FungibleToken.Provider` + `FungibleToken.Receiver` interface. + +New strategy families are added by deploying a new strategy contract whose struct conforms to `FlowYieldVaultsInterfaces.Strategy`, and then `Admin.registerStrategy`ing an instance. Users always interact with the same `YieldVault` interface regardless of the strategy backing it. + +### Relation to Early Access +During the launch phase, vault creation is gated by `FlowYieldVaultsEarlyAccess` (see [flow_yield_vaults_early_access.md](./flow_yield_vaults_early_access.md)). `FlowYieldVaults.createYieldVault` is `access(account)` for exactly this reason — only contracts on the same account (initially just early access) can reach it. After the early access period ends, a new implementation without the `access(account)` gate can be deployed under the same `FlowYieldVaultsInterfaces` interface. + +## Nomenclature + +| Term | Definition | +| :--- | :--------- | +| **Strategy** (`{Strategy}`) | A value conforming to `FlowYieldVaultsInterfaces.Strategy`. Encodes the parameters of a yield approach and knows how to mint a matching `YieldVault`. | +| **name** | The `String` identifier a strategy is registered under in `FlowYieldVaults`. Unique per registry. Used by callers to pick a strategy when minting a vault. Emitted in `StrategyCreated`, `StrategyRemoved`, and `StrategyVaultCreated`. | +| **Yield vault** (`YieldVault`) | A Cadence resource conforming to `FlowYieldVaultsInterfaces.YieldVault` (i.e. `FungibleToken.Provider` + `FungibleToken.Receiver`). Holds a user's yield-generating position. Produced by a strategy and held in the user's storage. | +| **Admin** | The holder of the `FlowYieldVaults.Admin` resource at `adminStoragePath` on the contract account. After deployment this is the deploying account. The `Admin` can be moved to transfer admin rights. | + +## How it works + +`FlowYieldVaults` stores every registered strategy by its `name`. The admin registers and removes strategies through an `Admin` resource saved in contract-account storage. Minting a vault from a `name` dispatches to the registered strategy, which produces a resource conforming to the uniform `YieldVault` interface. + +### Strategy registration flow + +```mermaid +sequenceDiagram + actor Admin + participant FYV as FlowYieldVaults + + Admin->>FYV: Admin.registerStrategy(name, strategy) + FYV->>FYV: strategies[name] = strategy + FYV-->>Admin: StrategyCreated(name) +``` + +Registering an already-used `name` panics — names are unique per registry. + +### Vault creation flow + +```mermaid +sequenceDiagram + actor Caller + participant FYV as FlowYieldVaults + participant Strategy as registered strategy + + Caller->>FYV: createYieldVault(name) + FYV->>FYV: lookup strategies[name] + FYV->>Strategy: createYieldVault(name) + Strategy-->>FYV: "@{YieldVault}" + FYV-->>Caller: "@{YieldVault}" + StrategyVaultCreated(name) +``` + +`Caller` is either: +- another contract on the same account (today: `FlowYieldVaultsEarlyAccess.EarlyAccessPass`), or +- a transaction holding `&FlowYieldVaults.Admin` — `Admin.createYieldVault` is a thin wrapper around the `access(account)` entrypoint so admin flows (ops, tests) don't need to go through early access. + +### Strategy removal + +```mermaid +sequenceDiagram + actor Admin + participant FYV as FlowYieldVaults + + Admin->>FYV: Admin.removeStrategy(name) + FYV->>FYV: strategies.remove(name) + FYV-->>Admin: StrategyRemoved(name) +``` + +Removing a strategy deletes the registry entry. Already-minted vaults are unaffected — they captured the strategy parameters at creation time and keep working. Further `createYieldVault(name)` calls panic until a new strategy is registered under that name. + +## `FlowYieldVaults` — registry + +### State + +| Field | Access | Purpose | +| :---- | :----- | :------ | +| `adminStoragePath` | `access(all) let` | Storage path where the contract's `Admin` resource is saved on the contract account. | +| `strategies` | `access(self) let` | Map of `name → {Strategy}`. | + +### Admin operations + +``` +Admin.registerStrategy(name: String, strategy: {FlowYieldVaultsInterfaces.Strategy}) +``` + +Registers `strategy` under `name`. Panics with `"Strategy already registered: "` if the name is already in use. Emits `StrategyCreated(name)`. + +``` +Admin.removeStrategy(name: String) +``` + +Removes the strategy registered under `name`. Panics with `"Strategy not found"` if unknown. Emits `StrategyRemoved(name)`. + +``` +Admin.createYieldVault(name: String) → @{YieldVault} +``` + +Wraps the contract-level `createYieldVault` so that callers holding `&Admin` (ops, tests) can mint vaults. The underlying `access(account)` entrypoint is unreachable from a user transaction, so this wrapper is the only admin-side path to vault creation. + +### Read-only + +``` +strategyCount() → UInt64 // current number of registered strategies +strategyNames() → [String] // all registered names +``` + +Both are `view` and safe to call from any script. + +### Access boundary + +`FlowYieldVaults.createYieldVault` (the contract-level method) is `access(account)`. Only contracts deployed on the same account can call it directly. This is the hook `FlowYieldVaultsEarlyAccess` uses to gate vault creation behind an allowlist during launch. To open vault creation up, deploy a replacement contract on the same account that calls `FlowYieldVaults.createYieldVault` from its own (e.g. `access(all)`) entrypoint. + +## Strategy contract + +A strategy contract only needs to expose a struct conforming to `FlowYieldVaultsInterfaces.Strategy`: + +```cadence +access(all) struct interface Strategy { + access(all) fun createYieldVault(name: String): @{YieldVault} +} +``` + +`createYieldVault` is the strategy's factory for yield vaults. The `name` it receives is the registry name the strategy was registered under — strategies are free to ignore it, log it, or use it as part of event payloads. + +The concrete yield vault resource must conform to: + +```cadence +access(all) resource interface YieldVault: FungibleToken.Provider, FungibleToken.Receiver {} +``` + +— i.e. it implements the standard FT deposit/withdraw surface. That is the only contract a vault has with the outside world; everything strategy-specific lives inside. + +The test suite uses `MockStrategy` as a minimal stand-in. Production strategy families (lending, LP, …) live on separate branches — e.g. `holyfuchs/yield-vaults-lending-strategy`. + +## Failure modes + +| Condition | Outcome | +| :-------- | :------ | +| `Admin.registerStrategy(name, …)` with a name already in use | Panics with `"Strategy already registered: "`. | +| `Admin.removeStrategy(name)` with unknown `name` | Panics with `"Strategy not found"`. | +| `createYieldVault(name)` with unknown `name` | Panics with `"Strategy not found"`. | +| User transaction attempts to call `FlowYieldVaults.createYieldVault` directly | Access denied (`access(account)`). Must go through `Admin.createYieldVault` or `FlowYieldVaultsEarlyAccess.EarlyAccessPass.createYieldVault`. | +| Admin resource is moved out of `adminStoragePath` | Admin-gated transactions can no longer borrow it; registration and admin-side vault creation are blocked. Recoverable by moving the resource back. | + +## Monitoring + +| Contract | Event | Fields | Meaning | +| :------- | :---- | :----- | :------ | +| `FlowYieldVaults` | `StrategyCreated` | `name` | A strategy was registered under `name`. | +| `FlowYieldVaults` | `StrategyRemoved` | `name` | The strategy registered under `name` was removed. | +| `FlowYieldVaults` | `StrategyVaultCreated` | `name` | A yield vault was minted from the strategy registered under `name`. | + +Strategy contracts may emit their own events on top of these (e.g. `LendingStrategyCreated` in a lending implementation). Those are additive; the events above are the core, strategy-agnostic signal. diff --git a/flow.json b/flow.json index 82d3686..1a926dc 100644 --- a/flow.json +++ b/flow.json @@ -45,8 +45,8 @@ "testnet": "0000000000000007" } }, - "MockContract": { - "source": "cadence/tests/mocks/MockContract.cdc", + "MockFlowYieldVaults": { + "source": "cadence/tests/mocks/MockFlowYieldVaults.cdc", "aliases": { "emulator": "0000000000000007", "mainnet": "0000000000000007", @@ -54,8 +54,8 @@ "testnet": "0000000000000007" } }, - "MockFlowYieldVaults": { - "source": "cadence/tests/mocks/MockFlowYieldVaults.cdc", + "MockStrategy": { + "source": "cadence/tests/mocks/MockStrategy.cdc", "aliases": { "emulator": "0000000000000007", "mainnet": "0000000000000007", @@ -195,4 +195,4 @@ } } } -} \ No newline at end of file +} From 8fafe3c9edd09366c9913d7b12b9ffcf9ddd84e7 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 18 Apr 2026 00:31:48 +0200 Subject: [PATCH 2/2] Add FlowYieldVaults lending strategy structure and tests --- .../{FlowActions.cdc => FlowActionsIdea.cdc} | 20 +- cadence/contracts/alp/FlowALP.cdc | 61 ++++- .../contracts/alp/FlowALPHealthWatcher.cdc | 19 ++ .../alp/FlowALPHealthWatcherIdea.cdc | 19 ++ .../contracts/alp/FlowALPInterfaceIdea.cdc | 40 ++++ cadence/contracts/alp/FlowALPTypesIdea.cdc | 26 +++ .../FlowYieldVaultsLendingStrategies.cdc | 213 ++++++++++++++++++ cadence/scripts/actions/count.cdc | 2 +- .../flow_yield_vaults_early_access_test.cdc | 2 +- cadence/tests/helpers/all_helpers.cdc | 1 + cadence/tests/helpers/deployment_helpers.cdc | 12 +- .../yield_vault_lending_strategy_helpers.cdc | 42 ++++ cadence/tests/mocks/MockALP.cdc | 79 +++++++ cadence/tests/mocks/MockSwapper.cdc | 33 +++ cadence/tests/mocks/MockToken.cdc | 62 +++++ .../tests/transactions/actions/increment.cdc | 2 +- .../create_and_save_yield_vault.cdc | 10 + .../create_mock_lending_strategy.cdc | 29 +++ .../create_strategy_vault_at_path.cdc | 2 +- .../yield_vaults/deposit_to_yield_vault.cdc | 11 + .../withdraw_from_yield_vault.cdc | 10 + ..._vaults_lending_strategy_balanced_test.cdc | 35 +++ .../yield_vaults_lending_strategy_test.cdc | 172 ++++++++++++++ docs/flow_yield_vaults_lending_strategy.md | 114 ++++++++++ flow.json | 76 ++++++- 25 files changed, 1076 insertions(+), 16 deletions(-) rename cadence/contracts/actions/{FlowActions.cdc => FlowActionsIdea.cdc} (59%) create mode 100644 cadence/contracts/alp/FlowALPHealthWatcher.cdc create mode 100644 cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc create mode 100644 cadence/contracts/alp/FlowALPInterfaceIdea.cdc create mode 100644 cadence/contracts/alp/FlowALPTypesIdea.cdc create mode 100644 cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc create mode 100644 cadence/tests/helpers/yield_vault_lending_strategy_helpers.cdc create mode 100644 cadence/tests/mocks/MockALP.cdc create mode 100644 cadence/tests/mocks/MockSwapper.cdc create mode 100644 cadence/tests/mocks/MockToken.cdc create mode 100644 cadence/tests/transactions/yield_vaults/create_and_save_yield_vault.cdc create mode 100644 cadence/tests/transactions/yield_vaults/create_mock_lending_strategy.cdc create mode 100644 cadence/tests/transactions/yield_vaults/deposit_to_yield_vault.cdc create mode 100644 cadence/tests/transactions/yield_vaults/withdraw_from_yield_vault.cdc create mode 100644 cadence/tests/yield_vaults/yield_vaults_lending_strategy_balanced_test.cdc create mode 100644 cadence/tests/yield_vaults/yield_vaults_lending_strategy_test.cdc create mode 100644 docs/flow_yield_vaults_lending_strategy.md diff --git a/cadence/contracts/actions/FlowActions.cdc b/cadence/contracts/actions/FlowActionsIdea.cdc similarity index 59% rename from cadence/contracts/actions/FlowActions.cdc rename to cadence/contracts/actions/FlowActionsIdea.cdc index 5c55f3b..ce23563 100644 --- a/cadence/contracts/actions/FlowActions.cdc +++ b/cadence/contracts/actions/FlowActionsIdea.cdc @@ -1,8 +1,17 @@ import "FungibleToken" -access(all) contract FlowActions { +// ----------------------------------------------------------------------------- +// ⚠️ DISCLAIMER — DRAFT / SUBJECT TO CHANGE +// ----------------------------------------------------------------------------- +// The `Swapper` interface and helpers in this contract are placeholders +// needed only to wire up the FlowYieldVaults lending-strategy prototype. +// Signatures, semantics, and the set of methods will change once the real +// actions design lands. `getEmptyVault` in particular is explicitly unsafe — +// see the comment on it. Do not build on this contract outside of this repo. +// ----------------------------------------------------------------------------- + +access(all) contract FlowActionsIdea { - // Interfaces are not fixed and still under development. // This is the typical EVM interface translated to cadence. // Necessary to setup FlowYieldVaults structure. access(all) struct interface Swapper { @@ -11,13 +20,13 @@ access(all) contract FlowActions { access(all) fee: UInt32 /// Exact Input: "I have this many tokens, give me whatever they are worth" - access(all) fun quoteExactInput( + view access(all) fun quoteExactInput( zeroForOne: Bool, amountIn: UFix64 ): UFix64 /// Exact Output: "I want exactly this many tokens, how much do I need to pay?" - access(all) fun quoteExactOutput( + view access(all) fun quoteExactOutput( zeroForOne: Bool, amountOut: UFix64 ): UFix64 @@ -28,8 +37,7 @@ access(all) contract FlowActions { ): @{FungibleToken.Vault} } - /// FYV needs this functionality but it doesn't have to be implemented like this! - /// this is dangerous!! if a 3rd party provides a type and we are executing + /// This is dangerous!! if a 3rd party provides a type and we are executing /// createEmptyVault any code can be run. (reentrancy, etc) access(all) fun getEmptyVault(_ vaultType: Type): @{FungibleToken.Vault} { post { diff --git a/cadence/contracts/alp/FlowALP.cdc b/cadence/contracts/alp/FlowALP.cdc index 6ea3656..8a65611 100644 --- a/cadence/contracts/alp/FlowALP.cdc +++ b/cadence/contracts/alp/FlowALP.cdc @@ -1,4 +1,63 @@ +import "FungibleToken" +import "FlowActionsIdea" +import "FlowALPInterfaceIdea" +import "FlowALPTypesIdea" -access(all) contract FlowALP { +// ----------------------------------------------------------------------------- +// ⚠️ DISCLAIMER — DRAFT / SUBJECT TO CHANGE +// ----------------------------------------------------------------------------- +// This is a placeholder ALP implementation that exists only to unblock the +// FlowYieldVaults lending-strategy prototype. Every method is a stub — the +// real lending-market logic will replace this whole contract. Do not build on +// it outside of this repo. +// ----------------------------------------------------------------------------- +access(all) contract FlowALP: FlowALPInterfaceIdea { + + access(all) resource Position: FlowALPInterfaceIdea.ALPPosition { + access(all) fun deposit(from: @{FungibleToken.Vault}) { + destroy from + } + + access(all) fun withdraw(type: Type, amount: UFix64): @{FungibleToken.Vault} { + let _ = amount + return <- FlowActionsIdea.getEmptyVault(type) + } + + access(all) view fun depositRequiredForMinHealth(type: Type, minHealth: UFix64): UFix64 { + let _t = type + let _h = minHealth + return 0.0 + } + + access(all) view fun withdrawRequiredForMaxHealth(type: Type, maxHealth: UFix64): UFix64 { + let _t = type + let _h = maxHealth + return 0.0 + } + + access(all) view fun withdrawPossibleWithDeposit(type: Type, depositAmount: UFix64, maxHealth: UFix64): UFix64 { + let _t = type + let _d = depositAmount + let _h = maxHealth + return 0.0 + } + + access(all) view fun debtRepaymentForCollateralWithdrawal(debtType: Type, collateralType: Type, collateralAmount: UFix64, targetHealth: UFix64): UFix64 { + let _d = debtType + let _ct = collateralType + let _c = collateralAmount + let _h = targetHealth + return 0.0 + } + + access(all) view fun positionData(type: Type): FlowALPTypesIdea.TokenData { + let _ = type + return FlowALPTypesIdea.TokenData(amount: 0.0, direction: FlowALPTypesIdea.Direction.Collateral) + } + } + + access(account) fun createPosition(): @{FlowALPInterfaceIdea.ALPPosition} { + return <- create Position() + } } diff --git a/cadence/contracts/alp/FlowALPHealthWatcher.cdc b/cadence/contracts/alp/FlowALPHealthWatcher.cdc new file mode 100644 index 0000000..d6c1ee6 --- /dev/null +++ b/cadence/contracts/alp/FlowALPHealthWatcher.cdc @@ -0,0 +1,19 @@ +import "FlowALPHealthWatcherIdea" + +// ----------------------------------------------------------------------------- +// ⚠️ DISCLAIMER — DRAFT / SUBJECT TO CHANGE +// ----------------------------------------------------------------------------- +// This is a placeholder health-watcher implementation that exists only to +// unblock the FlowYieldVaults lending-strategy prototype. The real watcher +// semantics (registration, triggering, callbacks) will replace this whole +// contract. Do not build on it outside of this repo. +// ----------------------------------------------------------------------------- + +access(all) contract FlowALPHealthWatcher { + + access(all) resource Watcher: FlowALPHealthWatcherIdea.Watcher {} + + access(all) fun createWatcher(): @Watcher { + return <- create Watcher() + } +} diff --git a/cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc b/cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc new file mode 100644 index 0000000..ec312ef --- /dev/null +++ b/cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc @@ -0,0 +1,19 @@ +// ----------------------------------------------------------------------------- +// ⚠️ DISCLAIMER — DRAFT / SUBJECT TO CHANGE +// ----------------------------------------------------------------------------- +// This contract is a placeholder shim needed only to wire up the +// FlowYieldVaults lending-strategy prototype. The `Callback` and `Watcher` +// shapes will change once the real ALP health-monitoring design lands. +// Do not build on this contract outside of this repo. +// ----------------------------------------------------------------------------- + +access(all) contract interface FlowALPHealthWatcherIdea { + + access(all) struct interface Callback { + access(all) fun healthWatcherCallback() + } + + access(all) resource interface Watcher {} + + access(contract) fun createWatcher(): @{Watcher} +} diff --git a/cadence/contracts/alp/FlowALPInterfaceIdea.cdc b/cadence/contracts/alp/FlowALPInterfaceIdea.cdc new file mode 100644 index 0000000..d2e30bf --- /dev/null +++ b/cadence/contracts/alp/FlowALPInterfaceIdea.cdc @@ -0,0 +1,40 @@ +import "FungibleToken" +import "FlowALPTypesIdea" + +// ----------------------------------------------------------------------------- +// ⚠️ DISCLAIMER — DRAFT / SUBJECT TO CHANGE +// ----------------------------------------------------------------------------- +// This interface is a placeholder shim so the FlowYieldVaults lending-strategy +// prototype has something to talk to. Method signatures, return types, and the +// set of methods themselves will change once the real ALP design lands. It +// exists today only to let the yield-vaults layer compile and be tested. +// Do not build on this interface outside of this repo. +// ----------------------------------------------------------------------------- + +access(all) contract interface FlowALPInterfaceIdea { + + access(all) resource interface ALPPosition { + access(all) fun deposit(from: @{FungibleToken.Vault}) + access(all) fun withdraw(type: Type, amount: UFix64): @{FungibleToken.Vault} + + /// Amount of `type` to DEPOSIT/REPAY to bring health back up to `minHealth`. + /// Returns 0.0 if health is already at or above `minHealth`. + view access(all) fun depositRequiredForMinHealth(type: Type, minHealth: UFix64): UFix64 + + /// Amount of `type` available to WITHDRAW/BORROW while keeping health at `maxHealth`. + /// Returns 0.0 if health is already at or below `maxHealth`. + view access(all) fun withdrawRequiredForMaxHealth(type: Type, maxHealth: UFix64): UFix64 + + view access(all) fun withdrawPossibleWithDeposit(type: Type, depositAmount: UFix64, maxHealth: UFix64): UFix64 + + + /// Debt repayment required to offset a planned collateral withdrawal + /// while maintaining `targetHealth`. + view access(all) fun debtRepaymentForCollateralWithdrawal(debtType: Type, collateralType: Type, collateralAmount: UFix64, targetHealth: UFix64): UFix64 + + /// Current balance and direction (collateral vs debt) for `type`. + view access(all) fun positionData(type: Type): FlowALPTypesIdea.TokenData + } + + access(account) fun createPosition(): @{ALPPosition} +} diff --git a/cadence/contracts/alp/FlowALPTypesIdea.cdc b/cadence/contracts/alp/FlowALPTypesIdea.cdc new file mode 100644 index 0000000..4b35e8f --- /dev/null +++ b/cadence/contracts/alp/FlowALPTypesIdea.cdc @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------------- +// ⚠️ DISCLAIMER — DRAFT / SUBJECT TO CHANGE +// ----------------------------------------------------------------------------- +// These types are placeholders used only to unblock the FlowYieldVaults +// lending-strategy prototype. The enum, struct, and their field shapes will +// almost certainly change once the real ALP design lands. Do not build on +// them outside of this repo. +// ----------------------------------------------------------------------------- + +access(all) contract FlowALPTypesIdea { + + access(all) enum Direction: UInt8 { + access(all) case Collateral + access(all) case Debt + } + + access(all) struct TokenData { + access(all) let amount: UFix64 + access(all) let direction: Direction + + view init(amount: UFix64, direction: Direction) { + self.amount = amount + self.direction = direction + } + } +} diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc new file mode 100644 index 0000000..238025b --- /dev/null +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc @@ -0,0 +1,213 @@ +import "FungibleToken" +import "FlowActionsIdea" +import "FlowALPInterfaceIdea" +import "FlowALPHealthWatcherIdea" +import "FlowYieldVaultsInterfaces" +import "FlowALPHealthWatcher" + +/// Factory for `LendingStrategy` instances and their `LendingStrategyVault` +/// yield vaults. Strategy structs are stored in the registry of +/// `FlowYieldVaults`; this contract only constructs them. +/// +/// An ALP position and a health watcher are attached to each yield vault at +/// creation time. While the real ALP / health-watcher designs are in flux +/// this contract wires the vault up to the `Mock*` implementations directly. +access(all) contract FlowYieldVaultsLendingStrategies { + + /// Emitted when a new lending strategy is constructed. + access(all) event LendingStrategyCreated() + /// Emitted when a new lending strategy yield vault is constructed. + access(all) event LendingStrategyVaultCreated() + + /// Parameters of a single lending-based yield strategy: + /// deposit collateral, borrow debt, swap debt → yield-bearing token. + access(all) struct LendingStrategy: FlowYieldVaultsInterfaces.Strategy { + access(all) let collateralTokenType: Type + access(all) let debtTokenType: Type + access(all) let yieldTokenType: Type + access(all) let alpContractName: String + access(all) let collateralDebtSwapper: {FlowActionsIdea.Swapper} + access(all) let debtYieldSwapper: {FlowActionsIdea.Swapper} + access(all) let minHealth: UFix64 + access(all) let maxHealth: UFix64 + + init( + collateralDebtSwapper: {FlowActionsIdea.Swapper}, + debtYieldSwapper: {FlowActionsIdea.Swapper}, + yieldTokenType: Type, + debtTokenType: Type, + collateralTokenType: Type, + alpContractName: String, + minHealth: UFix64, + maxHealth: UFix64 + ) { + self.collateralDebtSwapper = collateralDebtSwapper + self.debtYieldSwapper = debtYieldSwapper + self.yieldTokenType = yieldTokenType + self.debtTokenType = debtTokenType + self.collateralTokenType = collateralTokenType + self.alpContractName = alpContractName + self.minHealth = minHealth + self.maxHealth = maxHealth + } + + /// Creates a new yield vault backed by this strategy. + /// The returned vault captures a copy of this strategy's parameters, + /// an ALP position, and a health watcher. + access(all) fun createYieldVault(name _: String): @{FlowYieldVaultsInterfaces.YieldVault} { + let watcher <- FlowALPHealthWatcher.createWatcher() + let vault <- create LendingStrategyVault( + strategy: self, + watcher: <- watcher + ) + emit LendingStrategyVaultCreated() + return <- vault + } + } + + /// Yield vault produced by a `LendingStrategy`. + /// Holds the strategy parameters, an ALP position, a health watcher, + /// and the yield token balance. + access(all) resource LendingStrategyVault: FlowYieldVaultsInterfaces.YieldVault { + access(self) let strategy: LendingStrategy + access(self) let alpPosition: @{FlowALPInterfaceIdea.ALPPosition} + access(self) let watcher: @{FlowALPHealthWatcherIdea.Watcher} + access(self) let yieldTokens: @{FungibleToken.Vault} + + /// Deposits collateral into the strategy. + /// TODO: put into ALP, take max loan, swap debt → yield, store yield. + access(all) fun deposit(from collateral: @{FungibleToken.Vault}) { + self.alpPosition.deposit(from: <- collateral) + let debt <- self.withdrawMaxDebt() + let yield <- self.strategy.debtYieldSwapper.swap( + zeroForOne: true, + inVault: <- debt + ) + self.yieldTokens.deposit(from: <- yield) + } + + /// Withdraws `amount` of yield tokens. + access(FungibleToken.Withdraw) fun withdraw(amount: UFix64): @{FungibleToken.Vault} { + let debtDepositRequired = self.depositRequiredForWithdrawal(amount: amount) + if debtDepositRequired > 0.0 { + let debt <- self.swapYieldToDebt(debtAmount: debtDepositRequired) + self.alpPosition.deposit(from: <- debt) + } + let collateral <- self.withdrawCollateral(amount: amount) + return <- collateral + } + + access(all) fun rebalance() { + + } + + view access(all) fun isAvailableToWithdraw(amount _: UFix64): Bool { + let possibleDebt = self.strategy.debtYieldSwapper.quoteExactInput( + zeroForOne: false, + amountIn: self.yieldTokens.balance + ) + // in case theres is still debt remaining we have to swap some collateral back to debt + let collateralWithdrawPossible = self.alpPosition.withdrawPossibleWithDeposit( + type: self.strategy.collateralTokenType, + depositAmount: self.yieldTokens.balance, + maxHealth: self.strategy.maxHealth + ) + return possibleDebt <= collateralWithdrawPossible + } + + view access(all) fun getSupportedVaultTypes(): {Type: Bool} { + return { + self.strategy.collateralTokenType: true + } + } + + view access(all) fun isSupportedVaultType(type: Type): Bool { + return type == self.strategy.collateralTokenType + } + + access(self) fun withdrawMaxDebt(): @{FungibleToken.Vault} { + let amount = self.alpPosition.withdrawRequiredForMaxHealth( + type: self.strategy.debtTokenType, + maxHealth: self.strategy.maxHealth + ) + return <- self.alpPosition.withdraw( + type: self.strategy.debtTokenType, + amount: amount + ) + } + + access(self) fun withdrawCollateral(amount: UFix64): @{FungibleToken.Vault} { + post { + result.balance == amount: "Withdraw did not result in the expected amount of collateral" + } + return <- self.alpPosition.withdraw( + type: self.strategy.collateralTokenType, + amount: amount + ) + } + + access(self) fun depositRequiredForWithdrawal(amount: UFix64): UFix64 { + return self.alpPosition.debtRepaymentForCollateralWithdrawal( + debtType: self.strategy.debtTokenType, + collateralType: self.strategy.collateralTokenType, + collateralAmount: amount, + targetHealth: self.strategy.minHealth + ) + } + + access(self) fun swapYieldToDebt(debtAmount: UFix64): @{FungibleToken.Vault} { + post { + result.balance == debtAmount: "Swap did not result in the expected amount of debt" + } + let yieldAmountNeeded = self.strategy.debtYieldSwapper.quoteExactOutput(zeroForOne: false, amountOut: debtAmount) + let yield <- self.yieldTokens.withdraw(amount: yieldAmountNeeded) + return <- self.strategy.debtYieldSwapper.swap( + zeroForOne: false, + inVault: <- yield + ) + } + + init( + strategy: LendingStrategy, + watcher: @{FlowALPHealthWatcherIdea.Watcher} + ) { + self.strategy = strategy + self.alpPosition <- FlowYieldVaultsLendingStrategies.createALPPosition(contractName: strategy.alpContractName) + self.watcher <- watcher + self.yieldTokens <- FlowActionsIdea.getEmptyVault(strategy.yieldTokenType) + } + } + + /// Constructs a new `LendingStrategy` and emits `LendingStrategyCreated`. + /// The returned struct is expected to be passed to + /// `FlowYieldVaults.Admin.registerStrategy` by the caller. + access(all) fun createLendingStrategy( + collateralDebtSwapper: {FlowActionsIdea.Swapper}, + debtYieldSwapper: {FlowActionsIdea.Swapper}, + yieldTokenType: Type, + debtTokenType: Type, + collateralTokenType: Type, + alpContractName: String, + minHealth: UFix64, + maxHealth: UFix64 + ): LendingStrategy { + let strategy = LendingStrategy( + collateralDebtSwapper: collateralDebtSwapper, + debtYieldSwapper: debtYieldSwapper, + yieldTokenType: yieldTokenType, + debtTokenType: debtTokenType, + collateralTokenType: collateralTokenType, + alpContractName: alpContractName, + minHealth: minHealth, + maxHealth: maxHealth + ) + emit LendingStrategyCreated() + return strategy + } + + access(contract) fun createALPPosition(contractName: String): @{FlowALPInterfaceIdea.ALPPosition} { + let alp = self.account.contracts.borrow<&{FlowALPInterfaceIdea}>(name: contractName) + ?? panic("FlowALP contract '\(contractName)' not found on this account") + return <- alp.createPosition() + } +} diff --git a/cadence/scripts/actions/count.cdc b/cadence/scripts/actions/count.cdc index dca416b..f6bd10a 100644 --- a/cadence/scripts/actions/count.cdc +++ b/cadence/scripts/actions/count.cdc @@ -1,6 +1,6 @@ // PLACEHOLDER: stub script used to exercise the CI pipeline (script execution // from tests). Replace with a real query once the contract has state to read. -import "FlowActions" +import "FlowActionsIdea" access(all) fun main(): Int { return 0 diff --git a/cadence/tests/flow_yield_vaults_early_access_test.cdc b/cadence/tests/flow_yield_vaults_early_access_test.cdc index 758e71a..82410f2 100644 --- a/cadence/tests/flow_yield_vaults_early_access_test.cdc +++ b/cadence/tests/flow_yield_vaults_early_access_test.cdc @@ -15,7 +15,7 @@ access(all) var snapshot: UInt64 = 0 access(all) fun beforeEach() { Test.reset(to: snapshot) } access(all) fun setup() { - deploy("cadence/contracts/actions/FlowActions.cdc") + deploy("cadence/contracts/actions/FlowActionsIdea.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") deploy("cadence/tests/mocks/MockFlowYieldVaults.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") diff --git a/cadence/tests/helpers/all_helpers.cdc b/cadence/tests/helpers/all_helpers.cdc index 2e0ad61..2bc7545 100644 --- a/cadence/tests/helpers/all_helpers.cdc +++ b/cadence/tests/helpers/all_helpers.cdc @@ -5,3 +5,4 @@ import "actions_helpers.cdc" import "alp_helpers.cdc" import "yield_vault_early_access_helpers.cdc" import "yield_vault_helpers.cdc" +import "yield_vault_lending_strategy_helpers.cdc" diff --git a/cadence/tests/helpers/deployment_helpers.cdc b/cadence/tests/helpers/deployment_helpers.cdc index c7c72ec..0ef7b11 100644 --- a/cadence/tests/helpers/deployment_helpers.cdc +++ b/cadence/tests/helpers/deployment_helpers.cdc @@ -26,10 +26,10 @@ access(all) fun deployFlowActions() { !actionsDeployed: "FlowActions already deployed" } actionsDeployed = true - deploy("cadence/contracts/actions/FlowActions.cdc") + deploy("cadence/contracts/actions/FlowActionsIdea.cdc") } -/// Deploys `FlowALP`. +/// Deploys the `FlowALP` suite (`FlowALP`, `FlowALPHealthWatcher`). /// Requires `FlowActions` to be deployed first. /// Panics if called more than once. access(all) fun deployFlowALP() { @@ -38,11 +38,16 @@ access(all) fun deployFlowALP() { !alpDeployed: "FlowALP already deployed" } alpDeployed = true + deploy("cadence/contracts/alp/FlowALPTypesIdea.cdc") + deploy("cadence/contracts/alp/FlowALPInterfaceIdea.cdc") + deploy("cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc") deploy("cadence/contracts/alp/FlowALP.cdc") + deploy("cadence/contracts/alp/FlowALPHealthWatcher.cdc") } /// Deploys the `FlowYieldVaults` suite -/// (`FlowYieldVaultsInterfaces`, `FlowYieldVaults`, `FlowYieldVaultsEarlyAccess`). +/// (`FlowYieldVaultsInterfaces`, `FlowYieldVaultsLendingStrategies`, +/// `FlowYieldVaults`, `FlowYieldVaultsEarlyAccess`). /// Requires `FlowActions` and `FlowALP` to be deployed first. /// Panics if called more than once. access(all) fun deployFlowYieldVaults() { @@ -53,6 +58,7 @@ access(all) fun deployFlowYieldVaults() { } yieldVaultsDeployed = true deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") } diff --git a/cadence/tests/helpers/yield_vault_lending_strategy_helpers.cdc b/cadence/tests/helpers/yield_vault_lending_strategy_helpers.cdc new file mode 100644 index 0000000..1cf23ff --- /dev/null +++ b/cadence/tests/helpers/yield_vault_lending_strategy_helpers.cdc @@ -0,0 +1,42 @@ +import Test + +access(all) fun createLendingStrategy(name: String, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction( + "cadence/tests/transactions/yield_vaults/create_mock_lending_strategy.cdc", + [name], + signer + ) +} + +access(all) fun createAndSaveYieldVault(name: String, path: StoragePath, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction( + "cadence/tests/transactions/yield_vaults/create_and_save_yield_vault.cdc", + [name, path], + signer + ) +} + +access(all) fun depositToYieldVault(path: StoragePath, amount: UFix64, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction( + "cadence/tests/transactions/yield_vaults/deposit_to_yield_vault.cdc", + [path, amount], + signer + ) +} + +access(all) fun withdrawFromYieldVault(path: StoragePath, amount: UFix64, signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction( + "cadence/tests/transactions/yield_vaults/withdraw_from_yield_vault.cdc", + [path, amount], + signer + ) +} + +access(self) fun executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.TestAccount): Test.TransactionResult { + return Test.executeTransaction(Test.Transaction( + code: Test.readFile(path), + authorizers: [signer.address], + signers: [signer], + arguments: args + )) +} diff --git a/cadence/tests/mocks/MockALP.cdc b/cadence/tests/mocks/MockALP.cdc new file mode 100644 index 0000000..16c72ae --- /dev/null +++ b/cadence/tests/mocks/MockALP.cdc @@ -0,0 +1,79 @@ +import "FungibleToken" +import "FlowActionsIdea" +import "FlowALPInterfaceIdea" +import "FlowALPTypesIdea" + +/// Test-only mock of `FlowALPInterfaceIdea`. Unlike `FlowALP`, this impl +/// actually holds deposited vaults per type and returns them on `withdraw`, +/// so tests can assert on real balance round-trips. +access(all) contract MockALP: FlowALPInterfaceIdea { + + access(all) resource Position: FlowALPInterfaceIdea.ALPPosition { + access(self) let vaults: @{Type: {FungibleToken.Vault}} + + init() { + self.vaults <- {} + } + + access(all) fun deposit(from: @{FungibleToken.Vault}) { + let type = from.getType() + let existing <- self.vaults.remove(key: type) + if existing == nil { + destroy existing + self.vaults[type] <-! from + return + } + let held <- existing! + held.deposit(from: <- from) + self.vaults[type] <-! held + } + + access(all) fun withdraw(type: Type, amount: UFix64): @{FungibleToken.Vault} { + let held <- self.vaults.remove(key: type) + if held == nil { + destroy held + return <- FlowActionsIdea.getEmptyVault(type) + } + let h <- held! + let out <- h.withdraw(amount: amount) + self.vaults[type] <-! h + return <- out + } + + access(all) view fun depositRequiredForMinHealth(type: Type, minHealth: UFix64): UFix64 { + let _t = type + let _h = minHealth + return 0.0 + } + + access(all) view fun withdrawRequiredForMaxHealth(type: Type, maxHealth: UFix64): UFix64 { + let _t = type + let _h = maxHealth + return 0.0 + } + + access(all) view fun withdrawPossibleWithDeposit(type: Type, depositAmount: UFix64, maxHealth: UFix64): UFix64 { + let _t = type + let _d = depositAmount + let _h = maxHealth + return 0.0 + } + + access(all) view fun debtRepaymentForCollateralWithdrawal(debtType: Type, collateralType: Type, collateralAmount: UFix64, targetHealth: UFix64): UFix64 { + let _d = debtType + let _ct = collateralType + let _c = collateralAmount + let _h = targetHealth + return 0.0 + } + + access(all) view fun positionData(type: Type): FlowALPTypesIdea.TokenData { + let _ = type + return FlowALPTypesIdea.TokenData(amount: 0.0, direction: FlowALPTypesIdea.Direction.Collateral) + } + } + + access(account) fun createPosition(): @{FlowALPInterfaceIdea.ALPPosition} { + return <- create Position() + } +} diff --git a/cadence/tests/mocks/MockSwapper.cdc b/cadence/tests/mocks/MockSwapper.cdc new file mode 100644 index 0000000..a268898 --- /dev/null +++ b/cadence/tests/mocks/MockSwapper.cdc @@ -0,0 +1,33 @@ +import "FungibleToken" +import "FlowActionsIdea" + +access(all) contract MockSwapper { + + access(all) struct Swapper: FlowActionsIdea.Swapper { + access(all) let token0: Type + access(all) let token1: Type + access(all) let fee: UInt32 + + init(token0: Type, token1: Type) { + self.token0 = token0 + self.token1 = token1 + self.fee = 0 + } + + access(all) view fun quoteExactInput(zeroForOne _: Bool, amountIn: UFix64): UFix64 { + return amountIn + } + + access(all) view fun quoteExactOutput(zeroForOne _: Bool, amountOut: UFix64): UFix64 { + return amountOut + } + + access(all) fun swap(zeroForOne _: Bool, inVault: @{FungibleToken.Vault}): @{FungibleToken.Vault} { + return <- inVault + } + } + + access(all) fun createSwapper(token0: Type, token1: Type): Swapper { + return Swapper(token0: token0, token1: token1) + } +} diff --git a/cadence/tests/mocks/MockToken.cdc b/cadence/tests/mocks/MockToken.cdc new file mode 100644 index 0000000..92a0f50 --- /dev/null +++ b/cadence/tests/mocks/MockToken.cdc @@ -0,0 +1,62 @@ +import "FungibleToken" + +access(all) contract MockToken: FungibleToken { + + access(all) resource Vault: FungibleToken.Vault { + access(all) var balance: UFix64 + + init(balance: UFix64) { + self.balance = balance + } + + access(contract) fun burnCallback() { + self.balance = 0.0 + } + + access(all) view fun getViews(): [Type] { return [] } + access(all) fun resolveView(_ view: Type): AnyStruct? { + let _ = view + return nil + } + + access(all) view fun isAvailableToWithdraw(amount: UFix64): Bool { + return amount <= self.balance + } + + access(FungibleToken.Withdraw) fun withdraw(amount: UFix64): @{FungibleToken.Vault} { + self.balance = self.balance - amount + return <- create Vault(balance: amount) + } + + access(all) fun deposit(from: @{FungibleToken.Vault}) { + let vault <- from as! @MockToken.Vault + self.balance = self.balance + vault.balance + vault.balance = 0.0 + destroy vault + } + + access(all) fun createEmptyVault(): @{FungibleToken.Vault} { + return <- create Vault(balance: 0.0) + } + } + + access(all) fun createEmptyVault(vaultType: Type): @{FungibleToken.Vault} { + let _ = vaultType + return <- create Vault(balance: 0.0) + } + + /// Mints a `Vault` with the given balance. Test-only — unrestricted minting. + access(all) fun mint(amount: UFix64): @Vault { + return <- create Vault(balance: amount) + } + + access(all) view fun getContractViews(resourceType: Type?): [Type] { + let _ = resourceType + return [] + } + access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? { + let _ = resourceType + let _v = viewType + return nil + } +} diff --git a/cadence/tests/transactions/actions/increment.cdc b/cadence/tests/transactions/actions/increment.cdc index 1561eae..c081e3c 100644 --- a/cadence/tests/transactions/actions/increment.cdc +++ b/cadence/tests/transactions/actions/increment.cdc @@ -1,7 +1,7 @@ // PLACEHOLDER: stub transaction used to exercise the CI pipeline (transaction // execution from tests). Replace with a real mutation once the contract // exposes writable state. -import "FlowActions" +import "FlowActionsIdea" transaction() { prepare(signer: &Account) {} diff --git a/cadence/tests/transactions/yield_vaults/create_and_save_yield_vault.cdc b/cadence/tests/transactions/yield_vaults/create_and_save_yield_vault.cdc new file mode 100644 index 0000000..f257a7a --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/create_and_save_yield_vault.cdc @@ -0,0 +1,10 @@ +import "FlowYieldVaults" + +transaction(name: String, path: StoragePath) { + prepare(signer: auth(BorrowValue, SaveValue) &Account) { + let admin = signer.storage.borrow<&FlowYieldVaults.Admin>(from: FlowYieldVaults.adminStoragePath) + ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") + let vault <- admin.createYieldVault(name: name) + signer.storage.save(<- vault, to: path) + } +} diff --git a/cadence/tests/transactions/yield_vaults/create_mock_lending_strategy.cdc b/cadence/tests/transactions/yield_vaults/create_mock_lending_strategy.cdc new file mode 100644 index 0000000..481162a --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/create_mock_lending_strategy.cdc @@ -0,0 +1,29 @@ +import "FlowYieldVaults" +import "FlowYieldVaultsLendingStrategies" +import "MockSwapper" +import "MockToken" + +transaction(name: String) { + + let admin: &FlowYieldVaults.Admin + + prepare(signer: auth(BorrowValue) &Account) { + self.admin = signer.storage.borrow<&FlowYieldVaults.Admin>(from: FlowYieldVaults.adminStoragePath) + ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") + } + + execute { + let tokenType = Type<@MockToken.Vault>() + let strategy = FlowYieldVaultsLendingStrategies.createLendingStrategy( + collateralDebtSwapper: MockSwapper.createSwapper(token0: tokenType, token1: tokenType), + debtYieldSwapper: MockSwapper.createSwapper(token0: tokenType, token1: tokenType), + yieldTokenType: tokenType, + debtTokenType: tokenType, + collateralTokenType: tokenType, + alpContractName: "MockALP", + minHealth: 1.5, + maxHealth: 2.0 + ) + self.admin.registerStrategy(name: name, strategy: strategy) + } +} diff --git a/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc b/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc index e04232b..e901e49 100644 --- a/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc +++ b/cadence/tests/transactions/yield_vaults/create_strategy_vault_at_path.cdc @@ -7,7 +7,7 @@ transaction(name: String, path: StoragePath) { ?? panic("FlowYieldVaults.Admin not found at \(FlowYieldVaults.adminStoragePath)") let vault <- admin.createYieldVault(name: name) signer.storage.save(<- vault, to: path) - let ref = signer.storage.borrow<&{FlowYieldVaultsInterfaces.YieldVault}>(from: path) + let _ = signer.storage.borrow<&{FlowYieldVaultsInterfaces.YieldVault}>(from: path) ?? panic("vault does not conform to FlowYieldVaultsInterfaces.YieldVault at \(path)") } } diff --git a/cadence/tests/transactions/yield_vaults/deposit_to_yield_vault.cdc b/cadence/tests/transactions/yield_vaults/deposit_to_yield_vault.cdc new file mode 100644 index 0000000..410b71a --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/deposit_to_yield_vault.cdc @@ -0,0 +1,11 @@ +import "FungibleToken" +import "MockToken" + +transaction(path: StoragePath, amount: UFix64) { + prepare(signer: auth(BorrowValue) &Account) { + let vault = signer.storage.borrow<&{FungibleToken.Receiver}>(from: path) + ?? panic("YieldVault not found at \(path)") + let tokens <- MockToken.mint(amount: amount) + vault.deposit(from: <- tokens) + } +} diff --git a/cadence/tests/transactions/yield_vaults/withdraw_from_yield_vault.cdc b/cadence/tests/transactions/yield_vaults/withdraw_from_yield_vault.cdc new file mode 100644 index 0000000..b35885b --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/withdraw_from_yield_vault.cdc @@ -0,0 +1,10 @@ +import "FungibleToken" + +transaction(path: StoragePath, amount: UFix64) { + prepare(signer: auth(BorrowValue) &Account) { + let vault = signer.storage.borrow(from: path) + ?? panic("YieldVault not found at \(path)") + let out <- vault.withdraw(amount: amount) + destroy out + } +} diff --git a/cadence/tests/yield_vaults/yield_vaults_lending_strategy_balanced_test.cdc b/cadence/tests/yield_vaults/yield_vaults_lending_strategy_balanced_test.cdc new file mode 100644 index 0000000..8f93c55 --- /dev/null +++ b/cadence/tests/yield_vaults/yield_vaults_lending_strategy_balanced_test.cdc @@ -0,0 +1,35 @@ +import Test +import BlockchainHelpers + +import "../helpers/deployment_helpers.cdc" +import "../helpers/yield_vault_helpers.cdc" +import "../helpers/yield_vault_lending_strategy_helpers.cdc" +import "FlowYieldVaults" +import "FlowYieldVaultsLendingStrategies" + +access(all) var admin = Test.getAccount(0x0000000000000007) + +access(all) var snapshot: UInt64 = 0 +access(all) fun beforeEach() { Test.reset(to: snapshot) } + +access(all) fun setup() { + // ALP suite (interface/types first, then concrete impls) + deploy("cadence/contracts/actions/FlowActionsIdea.cdc") + deploy("cadence/contracts/alp/FlowALPTypesIdea.cdc") + deploy("cadence/contracts/alp/FlowALPInterfaceIdea.cdc") + deploy("cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc") + deploy("cadence/contracts/alp/FlowALP.cdc") + deploy("cadence/contracts/alp/FlowALPHealthWatcher.cdc") + + // Yield vaults: interface → lending strategy → registry + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") + + // Test-only tokens / swapper / ALP mock + deploy("cadence/tests/mocks/MockToken.cdc") + deploy("cadence/tests/mocks/MockSwapper.cdc") + deploy("cadence/tests/mocks/MockALP.cdc") + + snapshot = getCurrentBlockHeight() +} diff --git a/cadence/tests/yield_vaults/yield_vaults_lending_strategy_test.cdc b/cadence/tests/yield_vaults/yield_vaults_lending_strategy_test.cdc new file mode 100644 index 0000000..819ce6b --- /dev/null +++ b/cadence/tests/yield_vaults/yield_vaults_lending_strategy_test.cdc @@ -0,0 +1,172 @@ +import Test +import BlockchainHelpers + +import "../helpers/deployment_helpers.cdc" +import "../helpers/yield_vault_helpers.cdc" +import "../helpers/yield_vault_lending_strategy_helpers.cdc" +import "FlowYieldVaults" +import "FlowYieldVaultsLendingStrategies" + +access(all) var admin = Test.getAccount(0x0000000000000007) + +access(all) var snapshot: UInt64 = 0 +access(all) fun beforeEach() { Test.reset(to: snapshot) } + +access(all) fun setup() { + // ALP suite (interface/types first, then concrete impls) + deploy("cadence/contracts/actions/FlowActionsIdea.cdc") + deploy("cadence/contracts/alp/FlowALPTypesIdea.cdc") + deploy("cadence/contracts/alp/FlowALPInterfaceIdea.cdc") + deploy("cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc") + deploy("cadence/contracts/alp/FlowALP.cdc") + deploy("cadence/contracts/alp/FlowALPHealthWatcher.cdc") + + // Yield vaults: interface → lending strategy → registry + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") + + // Test-only tokens / swapper / ALP mock + deploy("cadence/tests/mocks/MockToken.cdc") + deploy("cadence/tests/mocks/MockSwapper.cdc") + deploy("cadence/tests/mocks/MockALP.cdc") + + snapshot = getCurrentBlockHeight() +} + +// --- strategy registration --- + +access(all) fun test_createLendingStrategy_registers() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(1 as UInt64, strategyCount()) + Test.assert(strategyNames().contains("a")) +} + +access(all) fun test_createLendingStrategy_multiple_names() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createLendingStrategy(name: "b", signer: admin), Test.beSucceeded()) + Test.expect(createLendingStrategy(name: "c", signer: admin), Test.beSucceeded()) + Test.assertEqual(3 as UInt64, strategyCount()) +} + +access(all) fun test_createLendingStrategy_duplicate_name_fails() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + let res = createLendingStrategy(name: "a", signer: admin) + Test.expect(res, Test.beFailed()) + Test.assertError(res, errorMessage: "Strategy already registered") +} + +access(all) fun test_createLendingStrategy_emits_FlowYieldVaults_event() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + let events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + Test.assertEqual("a", (events[0] as! FlowYieldVaults.StrategyCreated).name) +} + +access(all) fun test_createLendingStrategy_emits_LendingStrategies_event() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(1, Test.eventsOfType(Type()).length) +} + +access(all) fun test_createLendingStrategy_emits_both_events() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createLendingStrategy(name: "b", signer: admin), Test.beSucceeded()) + Test.assertEqual(2, Test.eventsOfType(Type()).length) + Test.assertEqual(2, Test.eventsOfType(Type()).length) +} + +// --- yield vault creation from a lending strategy --- + +access(all) fun test_createStrategyVault() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) +} + +access(all) fun test_createStrategyVault_unknown_name_fails() { + let res = createStrategyVault(name: "missing", signer: admin) + Test.expect(res, Test.beFailed()) + Test.assertError(res, errorMessage: "Strategy not found") +} + +access(all) fun test_createStrategyVault_emits_FlowYieldVaults_event() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + let events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + Test.assertEqual("a", (events[0] as! FlowYieldVaults.StrategyVaultCreated).name) +} + +access(all) fun test_createStrategyVault_emits_LendingStrategies_event() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(1, Test.eventsOfType(Type()).length) +} + +access(all) fun test_createMultipleVaultsForSameStrategy() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.assertEqual(3, Test.eventsOfType(Type()).length) +} + +access(all) fun test_createVaultsForAllStrategies_emits_correct_event_count() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createLendingStrategy(name: "b", signer: admin), Test.beSucceeded()) + Test.expect(createLendingStrategy(name: "c", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "b", signer: admin), Test.beSucceeded()) + Test.expect(createStrategyVault(name: "c", signer: admin), Test.beSucceeded()) + Test.assertEqual(3, Test.eventsOfType(Type()).length) + Test.assertEqual(3, Test.eventsOfType(Type()).length) +} + +// --- deposit / withdraw on a lending-strategy yield vault --- + +access(all) let vaultPath = /storage/testLendingYieldVault + +access(all) fun test_deposit_empty_vault_succeeds() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(depositToYieldVault(path: vaultPath, amount: 0.0, signer: admin), Test.beSucceeded()) +} + +access(all) fun test_deposit_non_empty_vault_succeeds() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(depositToYieldVault(path: vaultPath, amount: 100.0, signer: admin), Test.beSucceeded()) +} + +access(all) fun test_withdraw_zero_succeeds() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(withdrawFromYieldVault(path: vaultPath, amount: 0.0, signer: admin), Test.beSucceeded()) +} + +access(all) fun test_deposit_then_withdraw_zero() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(depositToYieldVault(path: vaultPath, amount: 50.0, signer: admin), Test.beSucceeded()) + Test.expect(withdrawFromYieldVault(path: vaultPath, amount: 0.0, signer: admin), Test.beSucceeded()) +} + +access(all) fun test_deposit_then_withdraw_partial() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(depositToYieldVault(path: vaultPath, amount: 100.0, signer: admin), Test.beSucceeded()) + Test.expect(withdrawFromYieldVault(path: vaultPath, amount: 40.0, signer: admin), Test.beSucceeded()) +} + +access(all) fun test_deposit_then_withdraw_full() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(depositToYieldVault(path: vaultPath, amount: 100.0, signer: admin), Test.beSucceeded()) + Test.expect(withdrawFromYieldVault(path: vaultPath, amount: 100.0, signer: admin), Test.beSucceeded()) +} + +access(all) fun test_withdraw_more_than_deposited_fails() { + Test.expect(createLendingStrategy(name: "a", signer: admin), Test.beSucceeded()) + Test.expect(createAndSaveYieldVault(name: "a", path: vaultPath, signer: admin), Test.beSucceeded()) + Test.expect(depositToYieldVault(path: vaultPath, amount: 100.0, signer: admin), Test.beSucceeded()) + Test.expect(withdrawFromYieldVault(path: vaultPath, amount: 200.0, signer: admin), Test.beFailed()) +} diff --git a/docs/flow_yield_vaults_lending_strategy.md b/docs/flow_yield_vaults_lending_strategy.md new file mode 100644 index 0000000..37f85b9 --- /dev/null +++ b/docs/flow_yield_vaults_lending_strategy.md @@ -0,0 +1,114 @@ +# Flow Yield Vaults — Lending Strategy + +> **Status: prototype.** This strategy depends on `FlowALPInterfaceIdea`, +> `FlowALPTypesIdea`, `FlowALPHealthWatcherIdea`, and `FlowActionsIdea` — +> all tagged as drafts subject to change. Mocks (`MockALP`, +> `MockALPHealthWatcher`, `MockSwapper`, `MockToken`) stand in for the real +> ALP, swap venues, and tokens until those designs land. + +## Overview + +### Goal +Provide the first concrete `{FlowYieldVaultsInterfaces.Strategy}` so the +yield-vaults registry has something real to mint. The strategy earns yield by +posting collateral into an ALP lending market, borrowing debt against it, and +swapping that debt into a yield-bearing token. + +### Shape +A `LendingStrategy` is a struct carrying the parameters of one such loop: + +| Field | Meaning | +| :---- | :------ | +| `collateralTokenType` | Token posted as collateral. | +| `debtTokenType` | Token borrowed against the collateral. | +| `yieldTokenType` | Token the borrowed debt is swapped into to earn yield. | +| `collateralDebtSwapper` | `{FlowActionsIdea.Swapper}` used to move between collateral and debt when rebalancing/unwinding. | +| `debtYieldSwapper` | `{FlowActionsIdea.Swapper}` used to swap debt → yield token. | + +Strategies are constructed through `FlowYieldVaultsLendingStrategies.createLendingStrategy(...)` (emits `LendingStrategyCreated`) and then registered in the `FlowYieldVaults` registry by name via `Admin.registerStrategy`. + +## Per-vault state + +Every yield vault minted from a `LendingStrategy` is a +`LendingStrategyVault` resource, produced by `Strategy.createYieldVault(name)` +and captured under the registry `name`. Each vault holds: + +- a **copy of the `LendingStrategy`** it was minted from — strategy + parameters are frozen at creation time, so later `removeStrategy` / + re-register cycles do not mutate existing vaults; +- an **`@{FlowALPInterfaceIdea.ALPPosition}`** — the lending-market position, + created at vault-creation time via `MockALP.createPosition()` and the only + place ALP state is held; +- an **`@{FlowALPHealthWatcherIdea.Watcher}`** — a watcher handle, created + via `MockALPHealthWatcher.createWatcher()`, wired up once the real + callback/trigger design lands; +- a **`@{FungibleToken.Vault}`** of the yield token — the vault's actual + earnings balance. + +The ALP position and health watcher are intentionally created inside +`createYieldVault`, not on the strategy itself — the strategy is a shared, +immutable value-type; state lives on the vault. + +## How it fits in + +```mermaid +flowchart LR + subgraph yv[Yield Vaults] + FYVI[FlowYieldVaultsInterfaces] + FYV[FlowYieldVaults
registry + Admin] + FYVLS[FlowYieldVaultsLendingStrategies] + end + + subgraph alp[ALP Idea] + ALPI[FlowALPInterfaceIdea] + ALPT[FlowALPTypesIdea] + ALPHW[FlowALPHealthWatcherIdea] + MockALP[MockALP] + MockHW[MockALPHealthWatcher] + end + + subgraph actions[Actions Idea] + FA[FlowActionsIdea
Swapper · getEmptyVault] + end + + FYVLS -. "LendingStrategy conforms to" .-> FYVI + FYVLS -. "LendingStrategyVault conforms to" .-> FYVI + FYVLS -- "registered via Admin.registerStrategy" --> FYV + + FYVLS -- "createPosition" --> MockALP + FYVLS -- "createWatcher" --> MockHW + MockALP -. "Position conforms to" .-> ALPI + MockHW -. "Watcher conforms to" .-> ALPHW + ALPI -. "uses TokenData" .-> ALPT + FYVLS -- "Swapper · getEmptyVault" --> FA + + classDef draft fill:#fff3cd,stroke:#856404; + class ALPI,ALPT,ALPHW,FA draft; +``` + +Yellow nodes are the draft "Idea" contracts — their signatures will be +replaced by the real designs without touching the registry layer. + +## Lifecycle + +1. **Construct** — `FlowYieldVaultsLendingStrategies.createLendingStrategy(...)` returns a `LendingStrategy` struct and emits `LendingStrategyCreated`. +2. **Register** — `FlowYieldVaults.Admin.registerStrategy(name, strategy)` stores it under `name` (emits `StrategyCreated(name)`). +3. **Mint** — `FlowYieldVaults.createYieldVault(name)` (or `Admin.createYieldVault`) dispatches to the stored strategy's `createYieldVault(name)`, which constructs the `LendingStrategyVault`, attaches a fresh ALP position + watcher, and emits `LendingStrategyVaultCreated` + `StrategyVaultCreated(name)`. +4. **Use** — the user saves the vault in their storage and interacts with it through the `FungibleToken.Provider` / `Receiver` surface. Deposit/withdraw logic currently `panic("TODO")` — see Current status. + +## Current status + +| Piece | State | +| :---- | :---- | +| Strategy struct + registration flow | Implemented, tested. | +| Event surface (`LendingStrategyCreated`, `LendingStrategyVaultCreated`) | Implemented. | +| Per-vault ALP position + health watcher wiring | Implemented against `Mock*`. | +| `deposit` / `withdraw` / `isAvailableToWithdraw` / `getSupportedVaultTypes` / `isSupportedVaultType` on the vault | `panic("TODO")` — awaits real ALP semantics. | +| Real ALP, swap venues, health watcher | Not in this repo. Mocks exist only to unblock the yield-vaults layer. | + +## Monitoring + +| Event | Fields | Meaning | +| :---- | :----- | :------ | +| `FlowYieldVaultsLendingStrategies.LendingStrategyCreated` | — | A `LendingStrategy` struct was constructed. Pairs with `FlowYieldVaults.StrategyCreated(name)` when the caller registers it. | +| `FlowYieldVaultsLendingStrategies.LendingStrategyVaultCreated` | — | A `LendingStrategyVault` resource was constructed. Pairs with `FlowYieldVaults.StrategyVaultCreated(name)`. | diff --git a/flow.json b/flow.json index 1a926dc..2f0c91d 100644 --- a/flow.json +++ b/flow.json @@ -9,8 +9,35 @@ "testnet": "0000000000000007" } }, - "FlowActions": { - "source": "cadence/contracts/actions/FlowActions.cdc", + "FlowALPHealthWatcherIdea": { + "source": "cadence/contracts/alp/FlowALPHealthWatcherIdea.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "FlowALPInterfaceIdea": { + "source": "cadence/contracts/alp/FlowALPInterfaceIdea.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "FlowALPTypesIdea": { + "source": "cadence/contracts/alp/FlowALPTypesIdea.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "FlowActionsIdea": { + "source": "cadence/contracts/actions/FlowActionsIdea.cdc", "aliases": { "emulator": "0000000000000007", "mainnet": "0000000000000007", @@ -45,6 +72,33 @@ "testnet": "0000000000000007" } }, + "FlowYieldVaultsLendingStrategies": { + "source": "cadence/contracts/yield_vaults/FlowYieldVaultsLendingStrategies.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "FlowALPHealthWatcher": { + "source": "cadence/contracts/alp/FlowALPHealthWatcher.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "MockALP": { + "source": "cadence/tests/mocks/MockALP.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, "MockFlowYieldVaults": { "source": "cadence/tests/mocks/MockFlowYieldVaults.cdc", "aliases": { @@ -62,6 +116,24 @@ "testing": "0000000000000007", "testnet": "0000000000000007" } + }, + "MockSwapper": { + "source": "cadence/tests/mocks/MockSwapper.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "MockToken": { + "source": "cadence/tests/mocks/MockToken.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } } }, "dependencies": {