From f9eaf9b79365408c1f56385dcdbb5a9b24aaafcc Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 18 Apr 2026 17:20:23 +0200 Subject: [PATCH 01/10] Add FlowYieldVaultsInterfaces --- .../yield_vaults/FlowYieldVaultsInterfaces.cdc | 13 +++++++++++++ cadence/tests/mocks/MockContract.cdc | 2 +- flow.json | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc new file mode 100644 index 0000000..b45e73f --- /dev/null +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc @@ -0,0 +1,13 @@ +import "FungibleToken" +import "FlowActions" + +access(all) contract interface FlowYieldVaultsInterfaces { + + access(all) struct interface Strategy { + access(all) fun createStrategyVault(strategyID: UInt64): @{StrategyVault} + } + + access(all) resource interface StrategyVault: FungibleToken.Provider, FungibleToken.Receiver {} + + access(all) fun createStrategyVault(strategyID: UInt64): @{StrategyVault} +} diff --git a/cadence/tests/mocks/MockContract.cdc b/cadence/tests/mocks/MockContract.cdc index f334839..b6a8bcd 100644 --- a/cadence/tests/mocks/MockContract.cdc +++ b/cadence/tests/mocks/MockContract.cdc @@ -1,4 +1,4 @@ access(all) contract MockContract { -} \ No newline at end of file +} diff --git a/flow.json b/flow.json index d225341..7cb9712 100644 --- a/flow.json +++ b/flow.json @@ -26,6 +26,24 @@ "testing": "0000000000000007", "testnet": "0000000000000007" } + }, + "FlowYieldVaultsInterfaces": { + "source": "cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, + "MockContract": { + "source": "cadence/tests/mocks/MockContract.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } } }, "dependencies": { From 42d8b68a4d4ce4efde8d7f28aee3682577f2e5dc Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 18 Apr 2026 17:25:01 +0200 Subject: [PATCH 02/10] update tests use new cli features --- README.md | 7 +---- cadence/tests/helpers/actions_helpers.cdc | 4 +-- cadence/tests/helpers/alp_helpers.cdc | 2 +- cadence/tests/helpers/deployment_helpers.cdc | 28 ++++++++----------- cadence/tests/helpers/yield_vault_helpers.cdc | 2 +- cadence/tests/random_test.cdc | 7 +---- 6 files changed, 17 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 24ebfad..4996b3e 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,7 @@ import Test import BlockchainHelpers access(all) var snapshot: UInt64 = 0 - -access(all) fun beforeEach() { - if snapshot != getCurrentBlockHeight() { - Test.reset(to: snapshot) - } -} +access(all) fun beforeEach() { Test.reset(to: snapshot) } access(all) fun setup() { // deploy contracts diff --git a/cadence/tests/helpers/actions_helpers.cdc b/cadence/tests/helpers/actions_helpers.cdc index 046a0e7..888b3c9 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("../scripts/actions/count.cdc", []) + let result = _executeScript("cadence/scripts/actions/count.cdc", []) Test.expect(result, Test.beSucceeded()) return result.returnValue as! Int } @@ -12,7 +12,7 @@ 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("transactions/actions/increment.cdc", [], signer) + let result = _executeTransaction("cadence/tests/transactions/actions/increment.cdc", [], signer) Test.expect(result, Test.beSucceeded()) } diff --git a/cadence/tests/helpers/alp_helpers.cdc b/cadence/tests/helpers/alp_helpers.cdc index 06cdeb4..dfb0c13 100644 --- a/cadence/tests/helpers/alp_helpers.cdc +++ b/cadence/tests/helpers/alp_helpers.cdc @@ -3,7 +3,7 @@ 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("../scripts/alp/count.cdc", []) + let result = _executeScript("cadence/scripts/alp/count.cdc", []) Test.expect(result, Test.beSucceeded()) return result.returnValue as! Int } diff --git a/cadence/tests/helpers/deployment_helpers.cdc b/cadence/tests/helpers/deployment_helpers.cdc index f8f9282..3d2b4b1 100644 --- a/cadence/tests/helpers/deployment_helpers.cdc +++ b/cadence/tests/helpers/deployment_helpers.cdc @@ -16,12 +16,7 @@ access(all) fun deployFlowActions() { !actionsDeployed: "FlowActions already deployed" } actionsDeployed = true - err = Test.deployContract( - name: "FlowActions", - path: "../contracts/actions/FlowActions.cdc", - arguments: [] - ) - Test.expect(err, Test.beNil()) + deploy("cadence/contracts/actions/FlowActions.cdc") } access(all) fun deployFlowALP() { @@ -30,12 +25,7 @@ access(all) fun deployFlowALP() { !alpDeployed: "FlowALP already deployed" } alpDeployed = true - err = Test.deployContract( - name: "FlowALP", - path: "../contracts/alp/FlowALP.cdc", - arguments: [] - ) - Test.expect(err, Test.beNil()) + deploy("cadence/contracts/alp/FlowALP.cdc") } access(all) fun deployFlowYieldVaults() { @@ -44,10 +34,14 @@ access(all) fun deployFlowYieldVaults() { !yieldVaultsDeployed: "FlowYieldVaults already deployed" } yieldVaultsDeployed = true - err = Test.deployContract( - name: "FlowYieldVaults", - path: "../contracts/yield_vaults/FlowYieldVaults.cdc", - arguments: [] - ) + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") +} + +access(self) fun deploy(_ path: String) { + let parts = path.split(separator: "/") + let filename = parts[parts.length - 1] + let name = filename.slice(from: 0, upTo: filename.length - 4) // strip ".cdc" + err = Test.deployContract(name: name, path: path, arguments: []) Test.expect(err, Test.beNil()) } diff --git a/cadence/tests/helpers/yield_vault_helpers.cdc b/cadence/tests/helpers/yield_vault_helpers.cdc index de98251..b3b6c3b 100644 --- a/cadence/tests/helpers/yield_vault_helpers.cdc +++ b/cadence/tests/helpers/yield_vault_helpers.cdc @@ -3,7 +3,7 @@ import Test // PLACEHOLDER: wraps the stub count script used to verify CI script execution. // Replace alongside scripts/yield_vaults/count.cdc once real state exists. access(all) fun yieldVaultsCount(): Int { - let result = _executeScript("../scripts/yield_vaults/count.cdc", []) + let result = _executeScript("cadence/scripts/yield_vaults/count.cdc", []) Test.expect(result, Test.beSucceeded()) return result.returnValue as! Int } diff --git a/cadence/tests/random_test.cdc b/cadence/tests/random_test.cdc index 569a39a..47b36c7 100644 --- a/cadence/tests/random_test.cdc +++ b/cadence/tests/random_test.cdc @@ -11,12 +11,7 @@ import "helpers/actions_helpers.cdc" access(all) var signer = Test.createAccount() access(all) var snapShot: UInt64 = 0 - -access(all) fun beforeEach() { - if snapShot != getCurrentBlockHeight() { - Test.reset(to: snapShot) - } -} +access(all) fun beforeEach() { Test.reset(to: snapShot) } access(all) fun setup() { deployAllContracts() From 63dc8889b2630ac59448535f688206867d81d4e2 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 17 Apr 2026 09:43:57 +0200 Subject: [PATCH 03/10] Add FlowYieldVaultsEarlyAccess spec --- docs/FlowYieldVaultsEarlyAccess.md | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/FlowYieldVaultsEarlyAccess.md diff --git a/docs/FlowYieldVaultsEarlyAccess.md b/docs/FlowYieldVaultsEarlyAccess.md new file mode 100644 index 0000000..102b0fe --- /dev/null +++ b/docs/FlowYieldVaultsEarlyAccess.md @@ -0,0 +1,42 @@ +## Spec: Flow Yield Vaults Early Access + +### High-Level Intention +Gate all Vault Position interactions to a manually approved allowlist. Access is strictly granted only if the **Signer is allowed**. This permission is tied to the signer's identity and cannot be shared or transferred, even if the Vault resource itself moves. + +### Primitives & Notation +* **$E$**: The set of addresses with Early Access (the "Allowlist"). +* **$s$**: The transaction signer address. +* **$V$**: A Vault Position resource. +* **$Cap(s, V)$**: A boolean representing if $s$ has a valid Cadence-level right to $V$ (meaning $s$ is the **Owner** OR holds a **Capability**). +* **$op(s, V)$**: Signer $s$ attempting an operation (Deposit/Withdraw) on $V$. + +--- + +### Precise Logic +For any operation $op(s, V)$, the system must assert: + +$$(s \in E) \wedge Cap(s, V)$$ + +If this condition is **false**, the transaction must **panic**. + +--- + +### Case Matrix +**Assumptions:** +* $A \in E$ (Authorized) +* $B \notin E$ (Unauthorized) +* $Cap(A, V_1)$ is true +* $Cap(B, V_2)$ is true + +| Action | Result | Requirement Failed | +| :--- | :--- | :--- | +| $op(A, V_1)$ | **OK** | None | +| $op(A, V_2)$ | **Abort** | $\neg Cap(A, V_2)$ | +| $op(B, V_1)$ | **Abort** | $s \notin E \wedge \neg Cap(B, V_1)$ | +| $op(B, V_2)$ | **Abort** | $s \notin E$ | + +--- + +### Implementation Notes +* **Storage:** $E$ is represented by `map: {Address: Bool}`. +* **Entry Point:** Checked via `fun protectedFunction(signer: &Account)`. From dd7d1acccafa25ab7b580280b8e86c920f02c44c Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 17 Apr 2026 09:52:17 +0200 Subject: [PATCH 04/10] Implement FlowYieldVaultsEarlyAccess allowlist gate with tests --- .../yield_vaults/FlowYieldVaults.cdc | 23 ++- .../FlowYieldVaultsEarlyAccess.cdc | 65 +++++++++ .../scripts/yield_vaults/has_early_access.cdc | 5 + .../flow_yield_vaults_early_access_test.cdc | 132 ++++++++++++++++++ cadence/tests/helpers/deployment_helpers.cdc | 6 + cadence/tests/helpers/yield_vault_helpers.cdc | 58 ++++++-- cadence/tests/random_test.cdc | 6 +- .../claim_position_and_deposit.cdc | 14 ++ .../transactions/yield_vaults/increment.cdc | 10 -- .../transfer_position_to_inbox.cdc | 10 ++ .../yield_vaults/create_position.cdc | 19 +++ cadence/transactions/yield_vaults/deposit.cdc | 10 ++ .../early_access/grant_access.cdc | 11 ++ .../early_access/revoke_access.cdc | 11 ++ flow.json | 9 ++ 15 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc create mode 100644 cadence/scripts/yield_vaults/has_early_access.cdc create mode 100644 cadence/tests/flow_yield_vaults_early_access_test.cdc create mode 100644 cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc delete mode 100644 cadence/tests/transactions/yield_vaults/increment.cdc create mode 100644 cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc create mode 100644 cadence/transactions/yield_vaults/create_position.cdc create mode 100644 cadence/transactions/yield_vaults/deposit.cdc create mode 100644 cadence/transactions/yield_vaults/early_access/grant_access.cdc create mode 100644 cadence/transactions/yield_vaults/early_access/revoke_access.cdc diff --git a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc index 979a29f..cbe3ca8 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc @@ -1,4 +1,25 @@ - access(all) contract FlowYieldVaults { + access(all) event PositionCreated() + + access(all) entitlement Action + + access(all) resource Position { + access(all) fun deposit() { + return + } + + access(all) fun withdraw() { + return + } + } + + // Will be access(all) after early access + access(account) fun createPosition(): @Position { + let position <- create Position() + emit PositionCreated() + return <- position + } + + init() {} } diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc new file mode 100644 index 0000000..93155da --- /dev/null +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc @@ -0,0 +1,65 @@ +import "FlowYieldVaults" + +access(all) contract FlowYieldVaultsEarlyAccess { + + access(all) event AccessGranted(addr: Address) + access(all) event AccessRevoked(addr: Address) + + access(all) var allowlist: {Address: Bool} + access(all) let adminStoragePath: StoragePath + + access(all) resource AdminHandle { + access(all) fun grantAccess(to addr: Address) { + if FlowYieldVaultsEarlyAccess.allowlist[addr] != true { + emit AccessGranted(addr: addr) + } + FlowYieldVaultsEarlyAccess.allowlist[addr] = true + } + + access(all) fun revokeAccess(from addr: Address) { + if FlowYieldVaultsEarlyAccess.allowlist[addr] == true { + emit AccessRevoked(addr: addr) + } + let _ = FlowYieldVaultsEarlyAccess.allowlist.remove(key: addr) + } + } + + access(all) resource EarlyAccessPosition { + access(all) var position: @FlowYieldVaults.Position + + access(all) fun withdraw(signer: &Account) { + pre { + FlowYieldVaultsEarlyAccess.isAllowed(signer: signer): "Signer is not in the allowlist" + } + self.position.withdraw() + } + + access(all) fun deposit(signer: &Account) { + pre { + FlowYieldVaultsEarlyAccess.isAllowed(signer: signer): "Signer is not in the allowlist" + } + self.position.deposit() + } + + init() { + self.position <- FlowYieldVaults.createPosition() + } + } + + access(all) fun createPosition(signer: &Account): @EarlyAccessPosition { + pre { + self.isAllowed(signer: signer): "Signer is not in the allowlist" + } + return <- create EarlyAccessPosition() + } + + view access(self) fun isAllowed(signer: &Account): Bool { + return self.allowlist[signer.address] == true + } + + init() { + self.allowlist = {} + self.adminStoragePath = /storage/FlowYieldVaultsEarlyAccessAdmin + self.account.storage.save(<- create AdminHandle(), to: self.adminStoragePath) + } +} diff --git a/cadence/scripts/yield_vaults/has_early_access.cdc b/cadence/scripts/yield_vaults/has_early_access.cdc new file mode 100644 index 0000000..93e0f16 --- /dev/null +++ b/cadence/scripts/yield_vaults/has_early_access.cdc @@ -0,0 +1,5 @@ +import "FlowYieldVaultsEarlyAccess" + +access(all) fun main(addr: Address): Bool { + return FlowYieldVaultsEarlyAccess.allowlist[addr] == true +} diff --git a/cadence/tests/flow_yield_vaults_early_access_test.cdc b/cadence/tests/flow_yield_vaults_early_access_test.cdc new file mode 100644 index 0000000..7df5464 --- /dev/null +++ b/cadence/tests/flow_yield_vaults_early_access_test.cdc @@ -0,0 +1,132 @@ +import Test +import BlockchainHelpers + +import "helpers/deployment_helpers.cdc" +import "helpers/yield_vault_helpers.cdc" +import "FlowYieldVaultsEarlyAccess" + +access(all) var admin = Test.getAccount(Address(0x0000000000000007)) +access(all) let userA = Test.createAccount() +access(all) let userB = Test.createAccount() + +access(all) let defaultPath = /storage/FlowYieldVaultsEarlyAccessPosition + +access(all) var snapshot: UInt64 = 0 + +access(all) fun beforeEach() { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } +} + +access(all) fun setup() { + deployAllContracts() + snapshot = getCurrentBlockHeight() +} + +access(all) fun test_is_allowed_false_by_default() { + Test.assertEqual(false, hasEarlyAccess(userA.address)) + expectFailedWithError(createPosition(signer: userA, path: defaultPath), "Signer is not in the allowlist") +} + +access(all) fun test_grant() { + grantEarlyAccess(admin: admin, addr: userA.address) + Test.assertEqual(true, hasEarlyAccess(userA.address)) + Test.assertEqual(false, hasEarlyAccess(userB.address)) + Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) + Test.expect(deposit(signer: userA), Test.beSucceeded()) + expectFailedWithError(createPosition(signer: userB, path: defaultPath), "Signer is not in the allowlist") +} + +access(all) fun test_revoke() { + revokeEarlyAccess(admin: admin, addr: userA.address) + Test.assertEqual(false, hasEarlyAccess(userA.address)) + expectFailedWithError(createPosition(signer: userA, path: defaultPath), "Signer is not in the allowlist") + + grantEarlyAccess(admin: admin, addr: userA.address) + revokeEarlyAccess(admin: admin, addr: userA.address) + Test.assertEqual(false, hasEarlyAccess(userA.address)) + expectFailedWithError(createPosition(signer: userA, path: defaultPath), "Signer is not in the allowlist") +} + +access(all) fun test_grant_events() { + grantEarlyAccess(admin: admin, addr: userA.address) + var events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + let ev = events[0] as! FlowYieldVaultsEarlyAccess.AccessGranted + Test.assertEqual(userA.address, ev.addr) + + grantEarlyAccess(admin: admin, addr: userA.address) + events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) +} + +access(all) fun test_revoke_events() { + revokeEarlyAccess(admin: admin, addr: userA.address) + var events = Test.eventsOfType(Type()) + Test.assertEqual(0, events.length) + + grantEarlyAccess(admin: admin, addr: userA.address) + revokeEarlyAccess(admin: admin, addr: userA.address) + events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) + let ev = events[0] as! FlowYieldVaultsEarlyAccess.AccessRevoked + Test.assertEqual(userA.address, ev.addr) + + revokeEarlyAccess(admin: admin, addr: userA.address) + events = Test.eventsOfType(Type()) + Test.assertEqual(1, events.length) +} + +access(all) fun test_grant_idempotent() { + grantEarlyAccess(admin: admin, addr: userA.address) + grantEarlyAccess(admin: admin, addr: userA.address) +} + +access(all) fun test_revoke_idempotent() { + revokeEarlyAccess(admin: admin, addr: userA.address) + + grantEarlyAccess(admin: admin, addr: userA.address) + revokeEarlyAccess(admin: admin, addr: userA.address) + revokeEarlyAccess(admin: admin, addr: userA.address) +} + +access(all) fun test_revoke_and_re_grant() { + grantEarlyAccess(admin: admin, addr: userA.address) + revokeEarlyAccess(admin: admin, addr: userA.address) + grantEarlyAccess(admin: admin, addr: userA.address) + Test.assertEqual(true, hasEarlyAccess(userA.address)) + Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) +} + +access(all) fun test_early_access_is_reusable() { + grantEarlyAccess(admin: admin, addr: userA.address) + Test.expect(createPosition(signer: userA, path: /storage/a), Test.beSucceeded()) + Test.expect(createPosition(signer: userA, path: /storage/b), Test.beSucceeded()) + Test.expect(createPosition(signer: userA, path: /storage/c), Test.beSucceeded()) +} + +// userA (allowed) creates a position and shares a borrow capability with userB (not allowed) via inbox. +// userA retains ownership; userB must not be able to use it. +access(all) fun test_transferred_position_blocked_for_non_allowed_user() { + grantEarlyAccess(admin: admin, addr: userA.address) + Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) + Test.expect(transferPositionToInbox(signer: userA, recipient: userB.address), Test.beSucceeded()) + expectFailedWithError(claimPositionAndDeposit(signer: userB, provider: userA.address), "Signer is not in the allowlist") +} + +// userA (allowed) creates a position and shares a borrow capability with userB (also allowed) via inbox. +// userA retains ownership; userB must be able to use it. +access(all) fun test_transferred_position_allowed_for_allowed_user() { + grantEarlyAccess(admin: admin, addr: userA.address) + grantEarlyAccess(admin: admin, addr: userB.address) + Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) + Test.expect(transferPositionToInbox(signer: userA, recipient: userB.address), Test.beSucceeded()) + // userB claims the capability and tries to deposit — succeeds because userB is in the allowlist + Test.expect(claimPositionAndDeposit(signer: userB, provider: userA.address), Test.beSucceeded()) +} + +access(self) fun expectFailedWithError(_ res: Test.TransactionResult, _ error: String) { + Test.expect(res, Test.beFailed()) + Test.assertError(res, errorMessage: error) +} diff --git a/cadence/tests/helpers/deployment_helpers.cdc b/cadence/tests/helpers/deployment_helpers.cdc index 3d2b4b1..40530af 100644 --- a/cadence/tests/helpers/deployment_helpers.cdc +++ b/cadence/tests/helpers/deployment_helpers.cdc @@ -44,4 +44,10 @@ access(self) fun deploy(_ path: String) { let name = filename.slice(from: 0, upTo: filename.length - 4) // strip ".cdc" err = Test.deployContract(name: name, path: path, arguments: []) Test.expect(err, Test.beNil()) + err = Test.deployContract( + name: "FlowYieldVaultsEarlyAccess", + path: "../contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) } diff --git a/cadence/tests/helpers/yield_vault_helpers.cdc b/cadence/tests/helpers/yield_vault_helpers.cdc index b3b6c3b..2a7bbd2 100644 --- a/cadence/tests/helpers/yield_vault_helpers.cdc +++ b/cadence/tests/helpers/yield_vault_helpers.cdc @@ -1,23 +1,61 @@ import Test -// PLACEHOLDER: wraps the stub count script used to verify CI script execution. -// Replace alongside scripts/yield_vaults/count.cdc once real state exists. -access(all) fun yieldVaultsCount(): Int { - let result = _executeScript("cadence/scripts/yield_vaults/count.cdc", []) +access(all) fun revokeEarlyAccess(admin: Test.TestAccount, addr: Address) { + let result = executeTransaction( + "../transactions/yield_vaults/early_access/revoke_access.cdc", + [addr], + admin + ) Test.expect(result, Test.beSucceeded()) - return result.returnValue as! Int } -access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { +access(all) fun createPosition(signer: Test.TestAccount, path: StoragePath): Test.TransactionResult { + return executeTransaction( + "../transactions/yield_vaults/create_position.cdc", + [path], + signer + ) +} + +access(all) fun deposit(signer: Test.TestAccount): Test.TransactionResult { + return executeTransaction( + "../transactions/yield_vaults/deposit.cdc", + [/storage/FlowYieldVaultsEarlyAccessPosition], + signer + ) +} + +access(all) fun transferPositionToInbox(signer: Test.TestAccount, recipient: Address): Test.TransactionResult { + return executeTransaction( + "transactions/yield_vaults/transfer_position_to_inbox.cdc", + [recipient], + signer + ) +} + +access(all) fun claimPositionAndDeposit(signer: Test.TestAccount, provider: Address): Test.TransactionResult { + return executeTransaction( + "transactions/yield_vaults/claim_position_and_deposit.cdc", + [provider], + signer + ) +} + +access(all) fun hasEarlyAccess(_ addr: Address): Bool { + let result = executeScript("../scripts/yield_vaults/has_early_access.cdc", [addr]) + Test.expect(result, Test.beSucceeded()) + return result.returnValue! as! Bool +} + +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 { - let txn = Test.Transaction( +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 - ) - return Test.executeTransaction(txn) + )) } diff --git a/cadence/tests/random_test.cdc b/cadence/tests/random_test.cdc index 47b36c7..2497b14 100644 --- a/cadence/tests/random_test.cdc +++ b/cadence/tests/random_test.cdc @@ -10,12 +10,12 @@ import "helpers/actions_helpers.cdc" access(all) var signer = Test.createAccount() -access(all) var snapShot: UInt64 = 0 -access(all) fun beforeEach() { Test.reset(to: snapShot) } +access(all) var snapshot: UInt64 = 0 +access(all) fun beforeEach() { Test.reset(to: snapshot) } access(all) fun setup() { deployAllContracts() - snapShot = getCurrentBlockHeight() + snapshot = getCurrentBlockHeight() } access(all) fun test_script() { diff --git a/cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc b/cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc new file mode 100644 index 0000000..3b7af2c --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc @@ -0,0 +1,14 @@ +import "FlowYieldVaultsEarlyAccess" + +// Claim an EarlyAccessPosition capability from inbox and immediately try to use it. +// The deposit call will fail if the signer is not in the allowlist. +transaction(provider: Address) { + prepare(signer: auth(Inbox) &Account) { + let cap = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPosition>( + "EarlyAccessPosition", + provider: provider + ) ?? panic("No EarlyAccessPosition found in inbox") + let pos = cap.borrow() ?? panic("Capability invalid") + pos.deposit(signer: signer) + } +} diff --git a/cadence/tests/transactions/yield_vaults/increment.cdc b/cadence/tests/transactions/yield_vaults/increment.cdc deleted file mode 100644 index 8271ee3..0000000 --- a/cadence/tests/transactions/yield_vaults/increment.cdc +++ /dev/null @@ -1,10 +0,0 @@ -// 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 "FlowYieldVaults" - -transaction() { - prepare(signer: &Account) {} - - execute {} -} diff --git a/cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc b/cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc new file mode 100644 index 0000000..405d978 --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc @@ -0,0 +1,10 @@ +import "FlowYieldVaultsEarlyAccess" + +transaction(recipient: Address) { + prepare(signer: auth(Storage, Capabilities, Inbox) &Account) { + let cap = signer.capabilities.storage.issue<&FlowYieldVaultsEarlyAccess.EarlyAccessPosition>( + /storage/FlowYieldVaultsEarlyAccessPosition + ) + signer.inbox.publish(cap, name: "EarlyAccessPosition", recipient: recipient) + } +} diff --git a/cadence/transactions/yield_vaults/create_position.cdc b/cadence/transactions/yield_vaults/create_position.cdc new file mode 100644 index 0000000..cb5709f --- /dev/null +++ b/cadence/transactions/yield_vaults/create_position.cdc @@ -0,0 +1,19 @@ +import "FlowYieldVaultsEarlyAccess" + +transaction(path: StoragePath) { + // Two fields are intentional: createPosition requires &Account (non-auth) for the allowlist + // check, while saving requires auth(Storage). Both must be captured in prepare since + // auth capabilities cannot be obtained outside of that phase. + let signer: &Account + let signerStorage: auth(Storage) &Account + + prepare(signer: auth(Storage) &Account) { + self.signer = signer + self.signerStorage = signer + } + + execute { + let pm <- FlowYieldVaultsEarlyAccess.createPosition(signer: self.signer) + self.signerStorage.storage.save(<-pm, to: path) + } +} diff --git a/cadence/transactions/yield_vaults/deposit.cdc b/cadence/transactions/yield_vaults/deposit.cdc new file mode 100644 index 0000000..04fcb41 --- /dev/null +++ b/cadence/transactions/yield_vaults/deposit.cdc @@ -0,0 +1,10 @@ +import "FlowYieldVaultsEarlyAccess" + +transaction(path: StoragePath) { + prepare(signer: auth(Storage) &Account) { + let pos = signer.storage.borrow<&FlowYieldVaultsEarlyAccess.EarlyAccessPosition>( + from: path + ) ?? panic("No EarlyAccessPosition in storage") + pos.deposit(signer: signer) + } +} diff --git a/cadence/transactions/yield_vaults/early_access/grant_access.cdc b/cadence/transactions/yield_vaults/early_access/grant_access.cdc new file mode 100644 index 0000000..e3c6fcf --- /dev/null +++ b/cadence/transactions/yield_vaults/early_access/grant_access.cdc @@ -0,0 +1,11 @@ +import "FlowYieldVaultsEarlyAccess" + +transaction(addr: Address) { + prepare(admin: auth(Storage) &Account) { + let handle = admin.storage + .borrow<&FlowYieldVaultsEarlyAccess.AdminHandle>( + from: FlowYieldVaultsEarlyAccess.adminStoragePath + ) ?? panic("Could not borrow AdminHandle") + handle.grantAccess(to: addr) + } +} diff --git a/cadence/transactions/yield_vaults/early_access/revoke_access.cdc b/cadence/transactions/yield_vaults/early_access/revoke_access.cdc new file mode 100644 index 0000000..e3ec8ca --- /dev/null +++ b/cadence/transactions/yield_vaults/early_access/revoke_access.cdc @@ -0,0 +1,11 @@ +import "FlowYieldVaultsEarlyAccess" + +transaction(addr: Address) { + prepare(admin: auth(Storage) &Account) { + let handle = admin.storage + .borrow<&FlowYieldVaultsEarlyAccess.AdminHandle>( + from: FlowYieldVaultsEarlyAccess.adminStoragePath + ) ?? panic("Could not borrow AdminHandle") + handle.revokeAccess(from: addr) + } +} diff --git a/flow.json b/flow.json index 7cb9712..129b047 100644 --- a/flow.json +++ b/flow.json @@ -27,6 +27,15 @@ "testnet": "0000000000000007" } }, + "FlowYieldVaultsEarlyAccess": { + "source": "cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } + }, "FlowYieldVaultsInterfaces": { "source": "cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc", "aliases": { From 7d716e9c047607b9e1d8cb9da975308af80d574d Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 18 Apr 2026 00:41:11 +0200 Subject: [PATCH 05/10] simplify access to use capability --- .../yield_vaults/FlowYieldVaults.cdc | 23 +- .../FlowYieldVaultsEarlyAccess.cdc | 199 ++++++++--- .../FlowYieldVaultsInterfaces.cdc | 7 +- .../early_access/has_early_access.cdc | 5 + .../early_access/remaining_allowance.cdc | 5 + .../scripts/yield_vaults/has_early_access.cdc | 5 - .../flow_yield_vaults_early_access_test.cdc | 329 +++++++++++++----- cadence/tests/helpers/all_helpers.cdc | 2 +- cadence/tests/helpers/deployment_helpers.cdc | 9 +- .../yield_vault_early_access_helpers.cdc | 106 ++++++ cadence/tests/helpers/yield_vault_helpers.cdc | 61 ---- cadence/tests/mocks/MockContract.cdc | 4 - cadence/tests/mocks/MockFlowYieldVaults.cdc | 34 ++ .../claim_position_and_deposit.cdc | 14 - .../set_mock_yield_vaults_implementation.cdc | 10 + .../yield_vaults/mock_deposit.cdc | 13 + .../transfer_position_to_inbox.cdc | 10 - .../yield_vaults/create_position.cdc | 31 +- cadence/transactions/yield_vaults/deposit.cdc | 10 - .../early_access/adjust_allowance.cdc | 16 + .../yield_vaults/early_access/claim_pass.cdc | 26 ++ .../early_access/claim_pass_uuid.cdc | 25 ++ .../early_access/grant_access.cdc | 14 +- .../early_access/revoke_access.cdc | 15 +- docs/FlowYieldVaultsEarlyAccess.md | 42 --- docs/flow_yield_vaults_early_access.md | 148 ++++++++ ...ow_yield_vaults_early_access_structure.svg | 4 + flow.json | 11 +- 28 files changed, 855 insertions(+), 323 deletions(-) create mode 100644 cadence/scripts/yield_vaults/early_access/has_early_access.cdc create mode 100644 cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc delete mode 100644 cadence/scripts/yield_vaults/has_early_access.cdc create mode 100644 cadence/tests/helpers/yield_vault_early_access_helpers.cdc delete mode 100644 cadence/tests/helpers/yield_vault_helpers.cdc delete mode 100644 cadence/tests/mocks/MockContract.cdc create mode 100644 cadence/tests/mocks/MockFlowYieldVaults.cdc delete mode 100644 cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc create mode 100644 cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc create mode 100644 cadence/tests/transactions/yield_vaults/mock_deposit.cdc delete mode 100644 cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc delete mode 100644 cadence/transactions/yield_vaults/deposit.cdc create mode 100644 cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc create mode 100644 cadence/transactions/yield_vaults/early_access/claim_pass.cdc create mode 100644 cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc delete mode 100644 docs/FlowYieldVaultsEarlyAccess.md create mode 100644 docs/flow_yield_vaults_early_access.md create mode 100644 docs/flow_yield_vaults_early_access_structure.svg diff --git a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc index cbe3ca8..979a29f 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc @@ -1,25 +1,4 @@ -access(all) contract FlowYieldVaults { - - access(all) event PositionCreated() - - access(all) entitlement Action - access(all) resource Position { - access(all) fun deposit() { - return - } - - access(all) fun withdraw() { - return - } - } - - // Will be access(all) after early access - access(account) fun createPosition(): @Position { - let position <- create Position() - emit PositionCreated() - return <- position - } +access(all) contract FlowYieldVaults { - init() {} } diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc index 93155da..f8a7855 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc @@ -1,65 +1,186 @@ -import "FlowYieldVaults" +import "FlowYieldVaultsInterfaces" +/// Gates yield vault creation during the early access period. +/// An `Admin` resource issues and manages `EarlyAccessPass` resources. +/// Pass holders call `createYieldVault` to create yield vaults +/// until their allowance is exhausted. access(all) contract FlowYieldVaultsEarlyAccess { - access(all) event AccessGranted(addr: Address) - access(all) event AccessRevoked(addr: Address) + /// Emitted when a new pass is issued to an address. + access(all) event PassIssued(passUUID: UInt64, addr: Address, allowance: UInt64) + /// Emitted when a pass is revoked and destroyed by the admin. + access(all) event PassRevoked(passUUID: UInt64) + /// Emitted when a pass is used to create a yield vault. + access(all) event PassUsed(passUUID: UInt64, remainingAllowance: UInt64) - access(all) var allowlist: {Address: Bool} + /// Contract name on this account implementing `FlowYieldVaultsInterfaces`. + access(all) var flowYieldVaultsName: String + /// Storage path where the `Admin` resource is saved. access(all) let adminStoragePath: StoragePath + /// Storage path where pass capabilities are stored for claiming. + access(all) let passCapabilityStoragePath: StoragePath + /// Tracks the UUID of the last pass issued to each address; + /// used by the address-based claim transaction. + access(all) var mostRecentIssuedPassUUID: {Address: UInt64} - access(all) resource AdminHandle { - access(all) fun grantAccess(to addr: Address) { - if FlowYieldVaultsEarlyAccess.allowlist[addr] != true { - emit AccessGranted(addr: addr) - } - FlowYieldVaultsEarlyAccess.allowlist[addr] = true + /// Held in the holder's storage; gates yield vault creation during early access. + access(all) resource EarlyAccessPass { + /// Number of yield vaults the holder may still create. + access(all) var remainingAllowance: UInt64 + + /// Consumes one unit of allowance and creates a new yield vault. + /// Panics if allowance is exhausted. + /// + /// **Parameters** + /// - `strategyID`: Identifies the vault strategy to create. + /// + /// **Returns** A new `YieldVault` to be saved in the caller's storage. + access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + pre { self.remainingAllowance > 0: "No remaining allowance" } + self.remainingAllowance = self.remainingAllowance - 1 + let fyv = FlowYieldVaultsEarlyAccess.getFlowYieldVaultsContract() + let vault <- fyv.createYieldVault(strategyID: strategyID) + emit PassUsed(passUUID: self.uuid, remainingAllowance: self.remainingAllowance) + return <- vault + } + + access(contract) fun setAllowance(_ newAllowance: UInt64) { + self.remainingAllowance = newAllowance } - access(all) fun revokeAccess(from addr: Address) { - if FlowYieldVaultsEarlyAccess.allowlist[addr] == true { - emit AccessRevoked(addr: addr) - } - let _ = FlowYieldVaultsEarlyAccess.allowlist.remove(key: addr) + init(allowance: UInt64) { + self.remainingAllowance = allowance } } - access(all) resource EarlyAccessPosition { - access(all) var position: @FlowYieldVaults.Position + access(all) resource Admin { + /// Issues a pass to `addr`, publishes the capability to their inbox, + /// and records it as their most recently issued pass + /// (used by the address-based claim transaction). + /// + /// **Parameters** + /// - `addr`: Recipient who will claim the pass from their inbox. + /// - `allowance`: Number of yield vaults the pass holder may create. + /// + /// **Returns** The UUID of the newly created pass. + access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { + let pass <- create EarlyAccessPass(allowance: allowance) + let passUUID = FlowYieldVaultsEarlyAccess.storePass(pass: <- pass) + FlowYieldVaultsEarlyAccess.publishPassCapability(passUUID: passUUID, addr: addr) + FlowYieldVaultsEarlyAccess.mostRecentIssuedPassUUID[addr] = passUUID + emit PassIssued(passUUID: passUUID, addr: addr, allowance: allowance) + return passUUID + } - access(all) fun withdraw(signer: &Account) { - pre { - FlowYieldVaultsEarlyAccess.isAllowed(signer: signer): "Signer is not in the allowlist" - } - self.position.withdraw() + /// Destroys the pass and attempts to retract the inbox capability. + /// Panics if the pass is not found. If already claimed, the capability + /// stays in the recipient's storage but becomes unborrow-able, + /// blocking future vault creation. + /// + /// **Parameters** + /// - `passUUID`: UUID of the target pass. + access(all) fun revokePass(passUUID: UInt64) { + let pass <- FlowYieldVaultsEarlyAccess.loadPass(passUUID: passUUID) + destroy pass + FlowYieldVaultsEarlyAccess.unpublishPassCapability(passUUID: passUUID) + emit PassRevoked(passUUID: passUUID) } - access(all) fun deposit(signer: &Account) { - pre { - FlowYieldVaultsEarlyAccess.isAllowed(signer: signer): "Signer is not in the allowlist" - } - self.position.deposit() + /// Replaces the remaining allowance on an existing pass. + /// Panics if the pass is not found. + /// + /// **Parameters** + /// - `passUUID`: UUID of the target pass. + /// - `newAllowance`: New vault budget; `0` immediately blocks creation. + access(all) fun setAllowance(passUUID: UInt64, newAllowance: UInt64) { + let pass = FlowYieldVaultsEarlyAccess.borrowPass(passUUID: passUUID) + pass.setAllowance(newAllowance) } - init() { - self.position <- FlowYieldVaults.createPosition() + /// Sets the contract name used to resolve the yield vaults + /// implementation. Must be called before any vault is created. + /// + /// **Parameters** + /// - `flowYieldVaultsName`: Name of a contract on this account + /// that conforms to `FlowYieldVaultsInterfaces`. + access(all) fun setFlowYieldVaults(flowYieldVaultsName: String) { + FlowYieldVaultsEarlyAccess.flowYieldVaultsName = flowYieldVaultsName } } - access(all) fun createPosition(signer: &Account): @EarlyAccessPosition { - pre { - self.isAllowed(signer: signer): "Signer is not in the allowlist" - } - return <- create EarlyAccessPosition() + /// Returns whether a pass with the given UUID currently exists in storage. + /// + /// **Parameters** + /// - `passUUID`: UUID of the target pass. + /// + /// **Returns** `true` if the pass exists, `false` otherwise. + view access(all) fun passExists(passUUID: UInt64): Bool { + return self.checkPass(passUUID: passUUID) + } + + /// Returns the remaining allowance of the pass with the given UUID. + /// Panics if the pass is not found. + /// + /// **Parameters** + /// - `passUUID`: UUID of the target pass. + /// + /// **Returns** Number of yield vaults the pass holder may still create. + view access(all) fun remainingAllowance(passUUID: UInt64): UInt64 { + let pass = self.borrowPass(passUUID: passUUID) + return pass.remainingAllowance + } + + /// Returns the inbox key used to publish and claim a pass capability. + /// + /// **Parameters** + /// - `passUUID`: UUID of the target pass. + /// + /// **Returns** The inbox key string for the given pass. + view access(all) fun inboxName(passUUID: UInt64): String { + return "EarlyAccessPass_\(passUUID)" + } + + view access(self) fun getFlowYieldVaultsContract(): &{FlowYieldVaultsInterfaces} { + return self.account.contracts.borrow<&{FlowYieldVaultsInterfaces}>(name: self.flowYieldVaultsName) + ?? panic("FlowYieldVaults contract '\(self.flowYieldVaultsName)' not found on this account") + } + + access(self) fun storePass(pass: @EarlyAccessPass): UInt64 { + let uuid = pass.uuid + self.account.storage.save(<- pass, to: FlowYieldVaultsEarlyAccess.passStoragePath(passUUID: uuid)) + return uuid + } + + access(self) fun loadPass(passUUID: UInt64): @EarlyAccessPass { + return <- (self.account.storage.load<@EarlyAccessPass>(from: self.passStoragePath(passUUID: passUUID)) ?? panic("Pass not found")) + } + + view access(self) fun checkPass(passUUID: UInt64): Bool { + return self.account.storage.check<@EarlyAccessPass>(from: self.passStoragePath(passUUID: passUUID)) + } + + view access(self) fun borrowPass(passUUID: UInt64): &EarlyAccessPass { + return self.account.storage.borrow<&EarlyAccessPass>(from: self.passStoragePath(passUUID: passUUID)) ?? panic("Pass not found") + } + + access(self) fun publishPassCapability(passUUID: UInt64, addr: Address) { + let capability = self.account.capabilities.storage.issue<&EarlyAccessPass>(self.passStoragePath(passUUID: passUUID)) + self.account.inbox.publish(capability, name: self.inboxName(passUUID: passUUID), recipient: addr) + } + + access(self) fun unpublishPassCapability(passUUID: UInt64) { + let _ = self.account.inbox.unpublish<&EarlyAccessPass>(self.inboxName(passUUID: passUUID)) } - view access(self) fun isAllowed(signer: &Account): Bool { - return self.allowlist[signer.address] == true + view access(self) fun passStoragePath(passUUID: UInt64): StoragePath { + return StoragePath(identifier: "FlowYieldVaultsEarlyAccessPass_\(passUUID)")! } init() { - self.allowlist = {} - self.adminStoragePath = /storage/FlowYieldVaultsEarlyAccessAdmin - self.account.storage.save(<- create AdminHandle(), to: self.adminStoragePath) + self.flowYieldVaultsName = "" + self.adminStoragePath = StoragePath(identifier: "FlowYieldVaultsEarlyAccessAdmin")! + self.passCapabilityStoragePath = StoragePath(identifier: "FlowYieldVaultsEarlyAccessPassCapability")! + self.mostRecentIssuedPassUUID = {} + self.account.storage.save(<- create Admin(), to: self.adminStoragePath) } } diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc index b45e73f..454bcf1 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc @@ -1,13 +1,12 @@ import "FungibleToken" -import "FlowActions" access(all) contract interface FlowYieldVaultsInterfaces { access(all) struct interface Strategy { - access(all) fun createStrategyVault(strategyID: UInt64): @{StrategyVault} + access(all) fun createYieldVault(strategyID: UInt64): @{YieldVault} } - access(all) resource interface StrategyVault: FungibleToken.Provider, FungibleToken.Receiver {} + access(all) resource interface YieldVault: FungibleToken.Provider, FungibleToken.Receiver {} - access(all) fun createStrategyVault(strategyID: UInt64): @{StrategyVault} + access(account) fun createYieldVault(strategyID: UInt64): @{YieldVault} } diff --git a/cadence/scripts/yield_vaults/early_access/has_early_access.cdc b/cadence/scripts/yield_vaults/early_access/has_early_access.cdc new file mode 100644 index 0000000..0918f94 --- /dev/null +++ b/cadence/scripts/yield_vaults/early_access/has_early_access.cdc @@ -0,0 +1,5 @@ +import "FlowYieldVaultsEarlyAccess" + +access(all) fun main(passUUID: UInt64): Bool { + return FlowYieldVaultsEarlyAccess.passExists(passUUID: passUUID) +} diff --git a/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc b/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc new file mode 100644 index 0000000..5dd6c25 --- /dev/null +++ b/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc @@ -0,0 +1,5 @@ +import "FlowYieldVaultsEarlyAccess" + +access(all) fun main(passUUID: UInt64): UInt64 { + return FlowYieldVaultsEarlyAccess.remainingAllowance(passUUID: passUUID) +} diff --git a/cadence/scripts/yield_vaults/has_early_access.cdc b/cadence/scripts/yield_vaults/has_early_access.cdc deleted file mode 100644 index 93e0f16..0000000 --- a/cadence/scripts/yield_vaults/has_early_access.cdc +++ /dev/null @@ -1,5 +0,0 @@ -import "FlowYieldVaultsEarlyAccess" - -access(all) fun main(addr: Address): Bool { - return FlowYieldVaultsEarlyAccess.allowlist[addr] == true -} diff --git a/cadence/tests/flow_yield_vaults_early_access_test.cdc b/cadence/tests/flow_yield_vaults_early_access_test.cdc index 7df5464..499ead3 100644 --- a/cadence/tests/flow_yield_vaults_early_access_test.cdc +++ b/cadence/tests/flow_yield_vaults_early_access_test.cdc @@ -2,7 +2,7 @@ import Test import BlockchainHelpers import "helpers/deployment_helpers.cdc" -import "helpers/yield_vault_helpers.cdc" +import "helpers/yield_vault_early_access_helpers.cdc" import "FlowYieldVaultsEarlyAccess" access(all) var admin = Test.getAccount(Address(0x0000000000000007)) @@ -12,121 +12,292 @@ access(all) let userB = Test.createAccount() access(all) let defaultPath = /storage/FlowYieldVaultsEarlyAccessPosition access(all) var snapshot: UInt64 = 0 - -access(all) fun beforeEach() { - if snapshot != getCurrentBlockHeight() { - Test.reset(to: snapshot) - } -} +access(all) fun beforeEach() { Test.reset(to: snapshot) } access(all) fun setup() { - deployAllContracts() + deploy("cadence/contracts/actions/FlowActions.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") + deploy("cadence/tests/mocks/MockFlowYieldVaults.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") + Test.expect( + setYieldVaultsImpl(admin: admin, txPath: "cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc"), + Test.beSucceeded() + ) snapshot = getCurrentBlockHeight() } -access(all) fun test_is_allowed_false_by_default() { - Test.assertEqual(false, hasEarlyAccess(userA.address)) - expectFailedWithError(createPosition(signer: userA, path: defaultPath), "Signer is not in the allowlist") +access(all) fun test_no_pass() { + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + errorMessageSubstring: "No valid early access pass" + ) } access(all) fun test_grant() { - grantEarlyAccess(admin: admin, addr: userA.address) - Test.assertEqual(true, hasEarlyAccess(userA.address)) - Test.assertEqual(false, hasEarlyAccess(userB.address)) - Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) - Test.expect(deposit(signer: userA), Test.beSucceeded()) - expectFailedWithError(createPosition(signer: userB, path: defaultPath), "Signer is not in the allowlist") + 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()) + expectFailedWithError( + createYieldVault(signer: userB, strategyID: 0, path: defaultPath), + errorMessageSubstring: "No valid early access pass" + ) } access(all) fun test_revoke() { - revokeEarlyAccess(admin: admin, addr: userA.address) - Test.assertEqual(false, hasEarlyAccess(userA.address)) - expectFailedWithError(createPosition(signer: userA, path: defaultPath), "Signer is not in the allowlist") + let passUUIDA = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.expect(claimPass(user: userA, passUUID: passUUIDA, provider: admin.address), Test.beSucceeded()) + + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) + Test.assert(!hasEarlyAccess(passUUIDA)) + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + errorMessageSubstring: "No valid early access pass" + ) +} + +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(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) + Test.expect(deposit(signer: userA, path: defaultPath), Test.beSucceeded()) +} + +access(all) fun test_multiple_grants() { + let passUUID1 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + let passUUID2 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.assert(hasEarlyAccess(passUUID1)) + Test.assert(hasEarlyAccess(passUUID2)) +} + +access(all) fun test_multiple_revoke() { + Test.expect(revokeEarlyAccess(admin: admin, passUUID: 0), Test.beFailed()) + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beFailed()) +} + +access(all) fun test_revoke_and_re_grant() { + let passUUID1 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID1), Test.beSucceeded()) + 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()) +} + +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()) +} + +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()) + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, path: /storage/b), + errorMessageSubstring: "No remaining allowance" + ) +} + +access(all) fun test_two_users_independent() { + let passUUIDA = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + 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(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) + Test.assert(!hasEarlyAccess(passUUIDA)) + Test.assert(hasEarlyAccess(passUUIDB)) + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, 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()) +} + +access(all) fun test_remainingPositions_reflects_allowance() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.assertEqual(3 as UInt64, remainingAllowance(passUUID)) +} - grantEarlyAccess(admin: admin, addr: userA.address) - revokeEarlyAccess(admin: admin, addr: userA.address) - Test.assertEqual(false, hasEarlyAccess(userA.address)) - expectFailedWithError(createPosition(signer: userA, path: defaultPath), "Signer is not in the allowlist") +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.assertEqual(2 as UInt64, remainingAllowance(passUUID)) + Test.expect(createYieldVault(signer: userA, strategyID: 0, 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.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.assertEqual(0 as UInt64, remainingAllowance(passUUID)) + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, path: /storage/b), + errorMessageSubstring: "No remaining allowance" + ) +} + +access(all) fun test_remainingPositions_fails_for_nonexistent_pass() { + Test.expectFailure(fun () { + let _ = FlowYieldVaultsEarlyAccess.remainingAllowance(passUUID: 0) + }, errorMessageSubstring: "Pass not found") +} + +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.assertEqual(4 as UInt64, remainingAllowance(passUUIDA)) + Test.assertEqual(2 as UInt64, remainingAllowance(passUUIDB)) +} + +access(all) fun test_setAllowance() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + Test.assertEqual(1 as UInt64, remainingAllowance(passUUID)) + 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.assertEqual(3 as UInt64, remainingAllowance(passUUID)) +} + +access(all) fun test_setAllowance_to_zero_blocks_createYieldVault() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.expect(setAllowance(admin: admin, passUUID: passUUID, newAllowance: 0), Test.beSucceeded()) + Test.assertEqual(0 as UInt64, remainingAllowance(passUUID)) + Test.assert(hasEarlyAccess(passUUID)) + Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, path: defaultPath), + errorMessageSubstring: "No remaining allowance" + ) } access(all) fun test_grant_events() { - grantEarlyAccess(admin: admin, addr: userA.address) - var events = Test.eventsOfType(Type()) + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + let events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) - let ev = events[0] as! FlowYieldVaultsEarlyAccess.AccessGranted + let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassIssued Test.assertEqual(userA.address, ev.addr) - - grantEarlyAccess(admin: admin, addr: userA.address) - events = Test.eventsOfType(Type()) - Test.assertEqual(1, events.length) + Test.assertEqual(3 as UInt64, ev.allowance) + Test.assertEqual(passUUID, ev.passUUID) } access(all) fun test_revoke_events() { - revokeEarlyAccess(admin: admin, addr: userA.address) - var events = Test.eventsOfType(Type()) - Test.assertEqual(0, events.length) - - grantEarlyAccess(admin: admin, addr: userA.address) - revokeEarlyAccess(admin: admin, addr: userA.address) - events = Test.eventsOfType(Type()) + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) + var events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) - let ev = events[0] as! FlowYieldVaultsEarlyAccess.AccessRevoked - Test.assertEqual(userA.address, ev.addr) + let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassRevoked + Test.assertEqual(passUUID, ev.passUUID) +} - revokeEarlyAccess(admin: admin, addr: userA.address) - events = Test.eventsOfType(Type()) +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()) + let events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) + let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassUsed + Test.assertEqual(passUUID, ev.passUUID) + Test.assertEqual(2 as UInt64, ev.remainingAllowance) +} + +access(all) fun test_invalid_pass_uuid() { + Test.assert(!FlowYieldVaultsEarlyAccess.passExists(passUUID: 0)) + Test.expectFailure( + fun () { + let _ = FlowYieldVaultsEarlyAccess.remainingAllowance(passUUID: 0) + }, errorMessageSubstring: "Pass not found" + ) } -access(all) fun test_grant_idempotent() { - grantEarlyAccess(admin: admin, addr: userA.address) - grantEarlyAccess(admin: admin, addr: userA.address) +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()) } -access(all) fun test_revoke_idempotent() { - revokeEarlyAccess(admin: admin, addr: userA.address) +access(all) fun test_claim_by_address_gets_most_recent() { + let _ = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + 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()) +} - grantEarlyAccess(admin: admin, addr: userA.address) - revokeEarlyAccess(admin: admin, addr: userA.address) - revokeEarlyAccess(admin: admin, addr: userA.address) +access(all) fun test_claim_by_address_fails_if_no_pass_issued() { + expectFailedWithError( + claimPassByAddress(user: userA, provider: admin.address), + errorMessageSubstring: "No pass issued to this address" + ) } -access(all) fun test_revoke_and_re_grant() { - grantEarlyAccess(admin: admin, addr: userA.address) - revokeEarlyAccess(admin: admin, addr: userA.address) - grantEarlyAccess(admin: admin, addr: userA.address) - Test.assertEqual(true, hasEarlyAccess(userA.address)) - Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) +access(all) fun test_claim_with_custom_path() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + 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), + errorMessageSubstring: "No valid early access pass" + ) + Test.expect( + createYieldVaultAtEarlyAccessPath(signer: userA, strategyID: 0, earlyAccessPath: customPath, vaultPath: defaultPath), + Test.beSucceeded() + ) +} + +access(all) fun test_double_claim_fails() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) + expectFailedWithError( + claimPass(user: userA, passUUID: passUUID, provider: admin.address), + errorMessageSubstring: "No pass found in inbox" + ) } -access(all) fun test_early_access_is_reusable() { - grantEarlyAccess(admin: admin, addr: userA.address) - Test.expect(createPosition(signer: userA, path: /storage/a), Test.beSucceeded()) - Test.expect(createPosition(signer: userA, path: /storage/b), Test.beSucceeded()) - Test.expect(createPosition(signer: userA, path: /storage/c), Test.beSucceeded()) +access(all) fun test_claim_nonexistent_pass_fails() { + expectFailedWithError( + claimPass(user: userA, passUUID: 99999, provider: admin.address), + errorMessageSubstring: "No pass found in inbox" + ) } -// userA (allowed) creates a position and shares a borrow capability with userB (not allowed) via inbox. -// userA retains ownership; userB must not be able to use it. -access(all) fun test_transferred_position_blocked_for_non_allowed_user() { - grantEarlyAccess(admin: admin, addr: userA.address) - Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) - Test.expect(transferPositionToInbox(signer: userA, recipient: userB.address), Test.beSucceeded()) - expectFailedWithError(claimPositionAndDeposit(signer: userB, provider: userA.address), "Signer is not in the allowlist") +access(all) fun test_claim_after_revoke_fails() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) + expectFailedWithError( + claimPass(user: userA, passUUID: passUUID, provider: admin.address), + errorMessageSubstring: "No pass found in inbox" + ) } -// userA (allowed) creates a position and shares a borrow capability with userB (also allowed) via inbox. -// userA retains ownership; userB must be able to use it. -access(all) fun test_transferred_position_allowed_for_allowed_user() { - grantEarlyAccess(admin: admin, addr: userA.address) - grantEarlyAccess(admin: admin, addr: userB.address) - Test.expect(createPosition(signer: userA, path: defaultPath), Test.beSucceeded()) - Test.expect(transferPositionToInbox(signer: userA, recipient: userB.address), Test.beSucceeded()) - // userB claims the capability and tries to deposit — succeeds because userB is in the allowlist - Test.expect(claimPositionAndDeposit(signer: userB, provider: userA.address), Test.beSucceeded()) +access(all) fun test_claim_by_address_fails_after_revoke() { + let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) + expectFailedWithError( + claimPassByAddress(user: userA, provider: admin.address), + errorMessageSubstring: "No pass found in inbox" + ) } -access(self) fun expectFailedWithError(_ res: Test.TransactionResult, _ error: String) { +access(self) fun expectFailedWithError(_ res: Test.TransactionResult, errorMessageSubstring: String) { Test.expect(res, Test.beFailed()) - Test.assertError(res, errorMessage: error) + Test.assertError(res, errorMessage: errorMessageSubstring) } diff --git a/cadence/tests/helpers/all_helpers.cdc b/cadence/tests/helpers/all_helpers.cdc index fe044ed..734febd 100644 --- a/cadence/tests/helpers/all_helpers.cdc +++ b/cadence/tests/helpers/all_helpers.cdc @@ -3,4 +3,4 @@ import "deployment_helpers.cdc" import "actions_helpers.cdc" import "alp_helpers.cdc" -import "yield_vault_helpers.cdc" +import "yield_vault_early_access_helpers.cdc" diff --git a/cadence/tests/helpers/deployment_helpers.cdc b/cadence/tests/helpers/deployment_helpers.cdc index 40530af..402f218 100644 --- a/cadence/tests/helpers/deployment_helpers.cdc +++ b/cadence/tests/helpers/deployment_helpers.cdc @@ -36,18 +36,13 @@ access(all) fun deployFlowYieldVaults() { yieldVaultsDeployed = true deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") } -access(self) fun deploy(_ path: String) { +access(all) fun deploy(_ path: String) { let parts = path.split(separator: "/") let filename = parts[parts.length - 1] let name = filename.slice(from: 0, upTo: filename.length - 4) // strip ".cdc" err = Test.deployContract(name: name, path: path, arguments: []) Test.expect(err, Test.beNil()) - err = Test.deployContract( - name: "FlowYieldVaultsEarlyAccess", - path: "../contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc", - arguments: [] - ) - Test.expect(err, Test.beNil()) } diff --git a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc new file mode 100644 index 0000000..b24dba9 --- /dev/null +++ b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc @@ -0,0 +1,106 @@ +import Test +import "FlowYieldVaultsEarlyAccess" + +access(all) fun setYieldVaultsImpl(admin: Test.TestAccount, txPath: String): Test.TransactionResult { + return executeTransaction(txPath, [], admin) +} + +access(all) fun grantEarlyAccess(admin: Test.TestAccount, user: Test.TestAccount, allowance: UInt64): UInt64 { + let result = executeTransaction( + "cadence/transactions/yield_vaults/early_access/grant_access.cdc", + [user.address, allowance], + admin + ) + Test.expect(result, Test.beSucceeded()) + let events = Test.eventsOfType(Type()) + return (events[events.length - 1] as! FlowYieldVaultsEarlyAccess.PassIssued).passUUID +} + +access(all) fun claimPass(user: Test.TestAccount, passUUID: UInt64, provider: Address): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc", + [passUUID, provider, nil], + user + ) +} + +access(all) fun claimPassWithPath(user: Test.TestAccount, passUUID: UInt64, provider: Address, path: StoragePath): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc", + [passUUID, provider, path], + user + ) +} + +access(all) fun claimPassByAddress(user: Test.TestAccount, provider: Address): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/early_access/claim_pass.cdc", + [provider, nil], + user + ) +} + +access(all) fun revokeEarlyAccess(admin: Test.TestAccount, passUUID: UInt64): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/early_access/revoke_access.cdc", + [passUUID], + admin + ) +} + +access(all) fun setAllowance(admin: Test.TestAccount, passUUID: UInt64, newAllowance: UInt64): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc", + [passUUID, newAllowance], + admin + ) +} + +access(all) fun createYieldVault(signer: Test.TestAccount, strategyID: UInt64, path: StoragePath): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/create_position.cdc", + [strategyID, nil, path], + signer + ) +} + +access(all) fun createYieldVaultAtEarlyAccessPath(signer: Test.TestAccount, strategyID: UInt64, earlyAccessPath: StoragePath, vaultPath: StoragePath): Test.TransactionResult { + return executeTransaction( + "cadence/transactions/yield_vaults/create_position.cdc", + [strategyID, earlyAccessPath, vaultPath], + signer + ) +} + +access(all) fun deposit(signer: Test.TestAccount, path: StoragePath): Test.TransactionResult { + return executeTransaction( + "cadence/tests/transactions/yield_vaults/mock_deposit.cdc", + [path], + signer + ) +} + +access(all) fun hasEarlyAccess(_ passUUID: UInt64): Bool { + let result = executeScript("cadence/scripts/yield_vaults/early_access/has_early_access.cdc", [passUUID]) + Test.expect(result, Test.beSucceeded()) + return result.returnValue! as! Bool +} + +access(all) fun remainingAllowance(_ passUUID: UInt64): UInt64 { + let result = executeScript("cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc", [passUUID]) + Test.expect(result, Test.beSucceeded()) + return result.returnValue! as! UInt64 +} + +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/helpers/yield_vault_helpers.cdc b/cadence/tests/helpers/yield_vault_helpers.cdc deleted file mode 100644 index 2a7bbd2..0000000 --- a/cadence/tests/helpers/yield_vault_helpers.cdc +++ /dev/null @@ -1,61 +0,0 @@ -import Test - -access(all) fun revokeEarlyAccess(admin: Test.TestAccount, addr: Address) { - let result = executeTransaction( - "../transactions/yield_vaults/early_access/revoke_access.cdc", - [addr], - admin - ) - Test.expect(result, Test.beSucceeded()) -} - -access(all) fun createPosition(signer: Test.TestAccount, path: StoragePath): Test.TransactionResult { - return executeTransaction( - "../transactions/yield_vaults/create_position.cdc", - [path], - signer - ) -} - -access(all) fun deposit(signer: Test.TestAccount): Test.TransactionResult { - return executeTransaction( - "../transactions/yield_vaults/deposit.cdc", - [/storage/FlowYieldVaultsEarlyAccessPosition], - signer - ) -} - -access(all) fun transferPositionToInbox(signer: Test.TestAccount, recipient: Address): Test.TransactionResult { - return executeTransaction( - "transactions/yield_vaults/transfer_position_to_inbox.cdc", - [recipient], - signer - ) -} - -access(all) fun claimPositionAndDeposit(signer: Test.TestAccount, provider: Address): Test.TransactionResult { - return executeTransaction( - "transactions/yield_vaults/claim_position_and_deposit.cdc", - [provider], - signer - ) -} - -access(all) fun hasEarlyAccess(_ addr: Address): Bool { - let result = executeScript("../scripts/yield_vaults/has_early_access.cdc", [addr]) - Test.expect(result, Test.beSucceeded()) - return result.returnValue! as! Bool -} - -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/MockContract.cdc b/cadence/tests/mocks/MockContract.cdc deleted file mode 100644 index b6a8bcd..0000000 --- a/cadence/tests/mocks/MockContract.cdc +++ /dev/null @@ -1,4 +0,0 @@ - -access(all) contract MockContract { - -} diff --git a/cadence/tests/mocks/MockFlowYieldVaults.cdc b/cadence/tests/mocks/MockFlowYieldVaults.cdc new file mode 100644 index 0000000..c25d760 --- /dev/null +++ b/cadence/tests/mocks/MockFlowYieldVaults.cdc @@ -0,0 +1,34 @@ +import "FlowYieldVaultsInterfaces" +import "FungibleToken" + +access(all) contract MockFlowYieldVaults: FlowYieldVaultsInterfaces { + + access(all) resource YieldVault: FlowYieldVaultsInterfaces.YieldVault { + access(all) view fun isAvailableToWithdraw(amount: UFix64): Bool { + let _ = amount + return false + } + + access(FungibleToken.Withdraw) fun withdraw(amount _: UFix64): @{FungibleToken.Vault} { + panic("Not implemented") + } + + access(all) fun deposit(from: @{FungibleToken.Vault}) { + destroy from + } + + access(all) view fun getSupportedVaultTypes(): {Type: Bool} { + return {} + } + + access(all) view fun isSupportedVaultType(type: Type): Bool { + let _ = type + return false + } + } + + access(account) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + let _ = strategyID + return <- create YieldVault() + } +} diff --git a/cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc b/cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc deleted file mode 100644 index 3b7af2c..0000000 --- a/cadence/tests/transactions/yield_vaults/claim_position_and_deposit.cdc +++ /dev/null @@ -1,14 +0,0 @@ -import "FlowYieldVaultsEarlyAccess" - -// Claim an EarlyAccessPosition capability from inbox and immediately try to use it. -// The deposit call will fail if the signer is not in the allowlist. -transaction(provider: Address) { - prepare(signer: auth(Inbox) &Account) { - let cap = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPosition>( - "EarlyAccessPosition", - provider: provider - ) ?? panic("No EarlyAccessPosition found in inbox") - let pos = cap.borrow() ?? panic("Capability invalid") - pos.deposit(signer: signer) - } -} diff --git a/cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc b/cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc new file mode 100644 index 0000000..d15d316 --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc @@ -0,0 +1,10 @@ +import "FlowYieldVaultsEarlyAccess" + +transaction() { + prepare(admin: auth(Storage) &Account) { + let handle = admin.storage.borrow<&FlowYieldVaultsEarlyAccess.Admin>( + from: FlowYieldVaultsEarlyAccess.adminStoragePath + ) ?? panic("Admin not found") + handle.setFlowYieldVaults(flowYieldVaultsName: "MockFlowYieldVaults") + } +} diff --git a/cadence/tests/transactions/yield_vaults/mock_deposit.cdc b/cadence/tests/transactions/yield_vaults/mock_deposit.cdc new file mode 100644 index 0000000..9dbbebc --- /dev/null +++ b/cadence/tests/transactions/yield_vaults/mock_deposit.cdc @@ -0,0 +1,13 @@ +import "FlowYieldVaultsInterfaces" + +/// Deposits into the strategy vault stored at `path`. +/// Panics if no `YieldVault` is found at the given path. +/// +/// **Parameters** +/// - `path`: Storage path of the `YieldVault` to deposit into. +transaction(path: StoragePath) { + prepare(signer: auth(Storage) &Account) { + let _ = signer.storage.borrow<&{FlowYieldVaultsInterfaces.YieldVault}>(from: path) + ?? panic("No YieldVault found at path") + } +} diff --git a/cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc b/cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc deleted file mode 100644 index 405d978..0000000 --- a/cadence/tests/transactions/yield_vaults/transfer_position_to_inbox.cdc +++ /dev/null @@ -1,10 +0,0 @@ -import "FlowYieldVaultsEarlyAccess" - -transaction(recipient: Address) { - prepare(signer: auth(Storage, Capabilities, Inbox) &Account) { - let cap = signer.capabilities.storage.issue<&FlowYieldVaultsEarlyAccess.EarlyAccessPosition>( - /storage/FlowYieldVaultsEarlyAccessPosition - ) - signer.inbox.publish(cap, name: "EarlyAccessPosition", recipient: recipient) - } -} diff --git a/cadence/transactions/yield_vaults/create_position.cdc b/cadence/transactions/yield_vaults/create_position.cdc index cb5709f..d738816 100644 --- a/cadence/transactions/yield_vaults/create_position.cdc +++ b/cadence/transactions/yield_vaults/create_position.cdc @@ -1,19 +1,22 @@ import "FlowYieldVaultsEarlyAccess" +import "FlowYieldVaultsInterfaces" -transaction(path: StoragePath) { - // Two fields are intentional: createPosition requires &Account (non-auth) for the allowlist - // check, while saving requires auth(Storage). Both must be captured in prepare since - // auth capabilities cannot be obtained outside of that phase. - let signer: &Account - let signerStorage: auth(Storage) &Account - +/// Creates a new yield vault using the signer's early access pass. +/// 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. +/// - `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) { prepare(signer: auth(Storage) &Account) { - self.signer = signer - self.signerStorage = signer - } - - execute { - let pm <- FlowYieldVaultsEarlyAccess.createPosition(signer: self.signer) - self.signerStorage.storage.save(<-pm, to: path) + let earlyAccessPath = earlyAccessPath ?? FlowYieldVaultsEarlyAccess.passCapabilityStoragePath + let cap = signer.storage.copy>( + from: earlyAccessPath + ) ?? panic("No valid early access pass") + let pass = cap.borrow() ?? panic("No valid early access pass") + let vault <- pass.createYieldVault(strategyID: strategyID) + signer.storage.save(<- vault, to: vaultPath) } } diff --git a/cadence/transactions/yield_vaults/deposit.cdc b/cadence/transactions/yield_vaults/deposit.cdc deleted file mode 100644 index 04fcb41..0000000 --- a/cadence/transactions/yield_vaults/deposit.cdc +++ /dev/null @@ -1,10 +0,0 @@ -import "FlowYieldVaultsEarlyAccess" - -transaction(path: StoragePath) { - prepare(signer: auth(Storage) &Account) { - let pos = signer.storage.borrow<&FlowYieldVaultsEarlyAccess.EarlyAccessPosition>( - from: path - ) ?? panic("No EarlyAccessPosition in storage") - pos.deposit(signer: signer) - } -} diff --git a/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc b/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc new file mode 100644 index 0000000..093f78d --- /dev/null +++ b/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc @@ -0,0 +1,16 @@ +import "FlowYieldVaultsEarlyAccess" + +/// Replaces the remaining allowance on an existing pass. +/// Setting `newAllowance` to `0` immediately blocks further vault creation. +/// +/// **Parameters** +/// - `passUUID`: UUID of the target pass. +/// - `newAllowance`: New vault budget; may be lower or higher than the current value. +transaction(passUUID: UInt64, newAllowance: UInt64) { + prepare(admin: auth(Storage) &Account) { + let handle = admin.storage + .borrow<&FlowYieldVaultsEarlyAccess.Admin>(from: FlowYieldVaultsEarlyAccess.adminStoragePath) + ?? panic("Could not borrow Admin") + handle.setAllowance(passUUID: passUUID, newAllowance: newAllowance) + } +} diff --git a/cadence/transactions/yield_vaults/early_access/claim_pass.cdc b/cadence/transactions/yield_vaults/early_access/claim_pass.cdc new file mode 100644 index 0000000..8a0365e --- /dev/null +++ b/cadence/transactions/yield_vaults/early_access/claim_pass.cdc @@ -0,0 +1,26 @@ +import "FlowYieldVaultsEarlyAccess" + +/// Claims the most recently issued pass for the signer from the provider's inbox. +/// Use when you want to claim the latest pass without knowing its UUID; the UUID is +/// looked up automatically from `mostRecentIssuedPassUUID`. +/// +/// **Parameters** +/// - `provider`: Address of the account that issued the pass. +/// - `path`: Storage path for the pass capability; defaults to +/// `passCapabilityStoragePath` if `nil`. +transaction(provider: Address, path: StoragePath?) { + prepare(signer: auth(Storage, Inbox) &Account) { + let passUUID = FlowYieldVaultsEarlyAccess.mostRecentIssuedPassUUID[signer.address] + ?? panic("No pass issued to this address") + var storagePath = FlowYieldVaultsEarlyAccess.passCapabilityStoragePath + if let p = path { + storagePath = p + } + let _ = signer.storage.load>(from: storagePath) + let cap = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( + FlowYieldVaultsEarlyAccess.inboxName(passUUID: passUUID), + provider: provider + ) ?? panic("No pass found in inbox") + signer.storage.save(cap, to: storagePath) + } +} diff --git a/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc b/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc new file mode 100644 index 0000000..d0d7c44 --- /dev/null +++ b/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc @@ -0,0 +1,25 @@ +import "FlowYieldVaultsEarlyAccess" + +/// Claims a specific pass by UUID from the provider's inbox. +/// Use when claiming a pass that is not the most recently issued one, +/// e.g. when multiple passes have been issued to the same address. +/// +/// **Parameters** +/// - `passUUID`: UUID of the target pass. +/// - `provider`: Address of the account that issued the pass. +/// - `path`: Storage path for the pass capability; defaults to +/// `passCapabilityStoragePath` if `nil`. +transaction(passUUID: UInt64, provider: Address, path: StoragePath?) { + prepare(signer: auth(Storage, Inbox) &Account) { + var storagePath = FlowYieldVaultsEarlyAccess.passCapabilityStoragePath + if let p = path { + storagePath = p + } + let _ = signer.storage.load>(from: storagePath) + let cap = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( + FlowYieldVaultsEarlyAccess.inboxName(passUUID: passUUID), + provider: provider + ) ?? panic("No pass found in inbox") + signer.storage.save(cap, to: storagePath) + } +} diff --git a/cadence/transactions/yield_vaults/early_access/grant_access.cdc b/cadence/transactions/yield_vaults/early_access/grant_access.cdc index e3c6fcf..00a1dc5 100644 --- a/cadence/transactions/yield_vaults/early_access/grant_access.cdc +++ b/cadence/transactions/yield_vaults/early_access/grant_access.cdc @@ -1,11 +1,15 @@ import "FlowYieldVaultsEarlyAccess" -transaction(addr: Address) { +/// Issues an early access pass to `addr` and publishes the capability to their inbox. +/// +/// **Parameters** +/// - `addr`: Recipient address to issue the pass to. +/// - `allowance`: Number of yield vaults the pass holder may create. +transaction(addr: Address, allowance: UInt64) { prepare(admin: auth(Storage) &Account) { let handle = admin.storage - .borrow<&FlowYieldVaultsEarlyAccess.AdminHandle>( - from: FlowYieldVaultsEarlyAccess.adminStoragePath - ) ?? panic("Could not borrow AdminHandle") - handle.grantAccess(to: addr) + .borrow<&FlowYieldVaultsEarlyAccess.Admin>(from: FlowYieldVaultsEarlyAccess.adminStoragePath) + ?? panic("Could not borrow Admin") + let _ = handle.issuePass(to: addr, allowance: allowance) } } diff --git a/cadence/transactions/yield_vaults/early_access/revoke_access.cdc b/cadence/transactions/yield_vaults/early_access/revoke_access.cdc index e3ec8ca..e54d228 100644 --- a/cadence/transactions/yield_vaults/early_access/revoke_access.cdc +++ b/cadence/transactions/yield_vaults/early_access/revoke_access.cdc @@ -1,11 +1,16 @@ import "FlowYieldVaultsEarlyAccess" -transaction(addr: Address) { +/// Destroys the pass and attempts to retract the inbox capability. +/// If already claimed, the stored capability becomes invalid, blocking +/// future vault creation. +/// +/// **Parameters** +/// - `passUUID`: UUID of the target pass. +transaction(passUUID: UInt64) { prepare(admin: auth(Storage) &Account) { let handle = admin.storage - .borrow<&FlowYieldVaultsEarlyAccess.AdminHandle>( - from: FlowYieldVaultsEarlyAccess.adminStoragePath - ) ?? panic("Could not borrow AdminHandle") - handle.revokeAccess(from: addr) + .borrow<&FlowYieldVaultsEarlyAccess.Admin>(from: FlowYieldVaultsEarlyAccess.adminStoragePath) + ?? panic("Could not borrow Admin") + handle.revokePass(passUUID: passUUID) } } diff --git a/docs/FlowYieldVaultsEarlyAccess.md b/docs/FlowYieldVaultsEarlyAccess.md deleted file mode 100644 index 102b0fe..0000000 --- a/docs/FlowYieldVaultsEarlyAccess.md +++ /dev/null @@ -1,42 +0,0 @@ -## Spec: Flow Yield Vaults Early Access - -### High-Level Intention -Gate all Vault Position interactions to a manually approved allowlist. Access is strictly granted only if the **Signer is allowed**. This permission is tied to the signer's identity and cannot be shared or transferred, even if the Vault resource itself moves. - -### Primitives & Notation -* **$E$**: The set of addresses with Early Access (the "Allowlist"). -* **$s$**: The transaction signer address. -* **$V$**: A Vault Position resource. -* **$Cap(s, V)$**: A boolean representing if $s$ has a valid Cadence-level right to $V$ (meaning $s$ is the **Owner** OR holds a **Capability**). -* **$op(s, V)$**: Signer $s$ attempting an operation (Deposit/Withdraw) on $V$. - ---- - -### Precise Logic -For any operation $op(s, V)$, the system must assert: - -$$(s \in E) \wedge Cap(s, V)$$ - -If this condition is **false**, the transaction must **panic**. - ---- - -### Case Matrix -**Assumptions:** -* $A \in E$ (Authorized) -* $B \notin E$ (Unauthorized) -* $Cap(A, V_1)$ is true -* $Cap(B, V_2)$ is true - -| Action | Result | Requirement Failed | -| :--- | :--- | :--- | -| $op(A, V_1)$ | **OK** | None | -| $op(A, V_2)$ | **Abort** | $\neg Cap(A, V_2)$ | -| $op(B, V_1)$ | **Abort** | $s \notin E \wedge \neg Cap(B, V_1)$ | -| $op(B, V_2)$ | **Abort** | $s \notin E$ | - ---- - -### Implementation Notes -* **Storage:** $E$ is represented by `map: {Address: Bool}`. -* **Entry Point:** Checked via `fun protectedFunction(signer: &Account)`. diff --git a/docs/flow_yield_vaults_early_access.md b/docs/flow_yield_vaults_early_access.md new file mode 100644 index 0000000..68b5431 --- /dev/null +++ b/docs/flow_yield_vaults_early_access.md @@ -0,0 +1,148 @@ +# Flow Yield Vaults — Early Access + +Gates all yield vault creation to a manually approved allowlist. Only accounts that hold a valid `EarlyAccessPass` capability can create vaults, and each pass carries a finite allowance that decrements on use. + +Architecture + +## How it works + +The contract stores every `EarlyAccessPass` resource in the contract account. The admin issues a pass and publishes a capability to the recipient's inbox. The user claims it into their own storage and then calls through it to create yield vaults. + +```mermaid +sequenceDiagram + actor Admin + participant Contract as Contract account + participant Inbox + actor User + + Admin->>Contract: issuePass(addr, allowance) + Contract->>Contract: store EarlyAccessPass_ + Contract->>Inbox: publish cap → user inbox + User->>Inbox: claim("EarlyAccessPass_") + Inbox-->>User: Capability<&EarlyAccessPass> + User->>User: save cap to storage + User->>Contract: createYieldVault(strategyID) + Contract->>Contract: remainingAllowance -= 1 + Contract-->>User: @YieldVault +``` + +## Security proof + +**Theorem.** No account can create a `YieldVault` unless the admin has explicitly issued it an `EarlyAccessPass` with sufficient remaining allowance. + +**Proof.** We establish six claims: + +- **Claim 1** — The underlying `createYieldVault` is unreachable by any user transaction directly. +- **Claim 2** — The only code path to `createYieldVault` passes through an `EarlyAccessPass` resource. +- **Claim 3** — An `EarlyAccessPass` can only be created by the admin and never leaves contract account storage. +- **Claim 4** — Only the intended recipient can obtain a capability to a given pass. +- **Claim 5** — The total number of vaults created through a pass can never exceed its allowance. +- **Claim 6** — Only the contract account can perform admin operations. + +### Claim 1 — The interface boundary (`FlowYieldVaultsInterfaces`) + +```cadence +access(account) fun createYieldVault(strategyID: UInt64): @{YieldVault} +``` + +`createYieldVault` is `access(account)`. It can only be called from a contract deployed on the **same account** — in this case `FlowYieldVaultsEarlyAccess`. A user transaction cannot call the underlying implementation directly. + +### Claim 2 — The only path to `createYieldVault` + +```cadence +access(all) resource EarlyAccessPass { + access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + // ... + let vault <- fyv.createYieldVault(strategyID: strategyID) + // ... + return <- vault + } +} +``` + +`fyv.createYieldVault` is only ever called from inside the `EarlyAccessPass` resource. To reach it, a caller must hold a live capability pointing to an `EarlyAccessPass` that still exists in contract storage. + +### Claim 3 — The `EarlyAccessPass` resource lifecycle + +```cadence +access(all) resource Admin { + access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { + let pass <- create EarlyAccessPass(allowance: allowance) + let passUUID = FlowYieldVaultsEarlyAccess.storePass(pass: <- pass) + // ... + } + + access(all) fun revokePass(passUUID: UInt64) { + let pass <- FlowYieldVaultsEarlyAccess.loadPass(passUUID: passUUID) + destroy pass + // ... + } +} +``` + +The resource is only created in one location, immediately moved into contract account storage and never touches user storage. `loadPass` the only location which moves the resource out of storage calls `destroy` on it immediately after. +A user never has direct access to the resource. + +### Claim 4 — The `EarlyAccessPass` capability + +```cadence +access(all) resource Admin { + access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { + // ... + let capability = self.account.capabilities.storage.issue<&EarlyAccessPass>(passStoragePath) + self.account.inbox.publish(capability, name: inboxName, recipient: addr) + // ... + } +} +``` + +The capability is issued in exactly one location. It is published to the inbox addressed to a specific recipient, and no further action is taken with it. +Only the intended recipient can obtain a capability to the pass. + +### Claim 5 — Only as many vaults can be created as the allowance + +```cadence +access(all) resource EarlyAccessPass { + access(all) var remainingAllowance: UInt64 + access(contract) fun setAllowance(_ newAllowance: UInt64) { + self.remainingAllowance = newAllowance + } + + access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + pre { self.remainingAllowance > 0: "No remaining allowance" } + self.remainingAllowance = self.remainingAllowance - 1 + // ... + } + + init(allowance: UInt64) { + self.remainingAllowance = allowance + } +} + +access(all) resource Admin { + access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { + let pass <- create EarlyAccessPass(allowance: allowance) + } + + access(all) fun setAllowance(passUUID: UInt64, newAllowance: UInt64) { + let pass = FlowYieldVaultsEarlyAccess.borrowPass(passUUID: passUUID) + pass.setAllowance(newAllowance) + } +} +``` + +`EarlyAccessPass.setAllowance` is `access(contract)` — unreachable through an `&EarlyAccessPass` capability reference. `remainingAllowance` is set exactly once at construction via `init`, and can only be written thereafter through the `Admin` resource. The number of vaults created can never exceed the allowance set by the admin. + +### Claim 6 — The `Admin` resource + +```cadence +access(all) contract FlowYieldVaultsEarlyAccess { + init() { + // ... + self.account.storage.save(<- create Admin(), to: self.adminStoragePath) + } +} +``` + +`Admin` is created exactly once in `init` and saved directly to contract account storage. No capability is ever issued for it. +No external account can perform admin operations. diff --git a/docs/flow_yield_vaults_early_access_structure.svg b/docs/flow_yield_vaults_early_access_structure.svg new file mode 100644 index 0000000..45c2458 --- /dev/null +++ b/docs/flow_yield_vaults_early_access_structure.svg @@ -0,0 +1,4 @@ + + +eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2dWXPiSFx1MDAxMsff51M4vC+7XHUwMDExY23dx7xcdTAwMTlsfGHa97W94ZBBYGxcdTAwMTBYyFx1MDAwN96Y775ZuI0kKIFcdTAwMDD56G67Zzraklx1MDAxMCWp/vXLrMxK/e+PpaXlsN/1lv9aWvaeqm6rWVx1MDAwYtzH5T/N9lx1MDAwNy/oNTs+7Fwig997nfugOjjyOlxmu72//v3v6Fx1MDAxM0610375lNfy2p5cdTAwMWb24Lj/wO9LS/9cdTAwMWL8XHJ7mjXz2e1Wze2ca3RQ6Xc2uytXlUpcdTAwMTFO+ufrQa+NXHS8auj6jVZs11x1MDAxM2xnXGJccn/vw++KRb8/NmvhtWkt0lx1MDAwZcWaS41cdFeEqOFcdTAwMTHXXrNxXHUwMDFkmkNcdTAwMTRyXHUwMDE4YpQoxjgnUlxmXHUwMDBmefnOv5ai0/bCoHPrXHUwMDE1O61OYFx1MDAxYfZcdTAwMGbsmT9Rs67c6m0j6Nz7teiYK1InV1fRMfVmq3VcdTAwMTj2XHUwMDA3Z4b7XGL3bHnk/KevjVx1MDAxZtme9in4wsa17/XMfcbDrZ2uW22G5sbg2I0yretu1Vx1MDAwNo/kv1GbXHUwMDAyt+1tmWfi37daw81Nv+aZO718Rf3E1/m1XHUwMDFmX/f6QKOnRX9s+TtqvOeZM2MmXHUwMDEwXHUwMDE1XHUwMDFjUT7cXHUwMDEz9SpB6ejWSsdcdTAwMWb0MIIo1Viq6MqavTXoWeHgrHW31fOi+2+atj7a6+I9L9GxQu8pXHUwMDFjXlesX7avvDW1tvKtvq5L5XJdXHUwMDE2S99qq8vD4/7+037al1x1MDAwZq88hIXTo3vtXHUwMDA1XY/vXHUwMDFmXHUwMDE3jyq6dJr8ltfvd4Og85j1vOLxvHFeqFx1MDAxNlx1MDAxYmeX7YuNnYOHndvjclx1MDAwZefdPj2/7N12xeNjRW/zXG5cdTAwMTKlbrCX7bw//lx1MDAxNfWj+27NfXkuWErBqcJMM1x1MDAxZT25VtO/XHUwMDFk7WStTvU2epR/xFx1MDAxYTwyWtifythokXioL1x1MDAwM1x1MDAwNZNcdTAwMGVSWoHGJVVM6ZFhI+qQr8NcdTAwMDbG3MFUK004dDxcdTAwMTJcdTAwMWQwXHUwMDFjNeKaymecqNeruqp/9nGiY1x1MDAxZidcdTAwMTKHv1x1MDAwZVxiUlx0JClTY9I3XHUwMDAzXHUwMDAywaNbX1x1MDAwN1x1MDAwNCqkRvBcdTAwMTijpzLDgJBnX426nulycPnf/XU3aPVXq1W46u/+99igWe/44WHz+WVAS2wtue1my9x+njjfaqvZMDdiuVxuTfeC5fjdXGKbwNrhXHUwMDAxYadcdTAwMWLtrcJcdTAwMTndpu9cdTAwMDVbWVxi21x0mo2m77aOpl+Ae1x1MDAxZnZcdTAwMGW83sslhMG9XHUwMDE3v1He5lBcdTAwMTdcdTAwMGXhXHUwMDEzNCzK15Xd2lNjg503XHUwMDFlNltcclXW1bXMxFx1MDAxN1x1MDAwMoEslVx1MDAxNlx1MDAxYXpcdTAwMGVCWCaFjKSjtNSKKi45i1BcdTAwMWZcdTAwMTlcdTAwMDPRnY+ETImDXHUwMDExQVhgRlx1MDAxOCcoUtNcdTAwMTf/I113XHUwMDE35z/HMJ6C1saEXHI7tZSjW1/ljlx1MDAwNcOKXCIu5tL7fFx1MDAwNsDJalx1MDAxON7sndSfz7bwcXl/v37axltcdTAwMWZccupcdTAwMDVcZovpoFx1MDAwNtNYcVx1MDAxMdf0/KC2371cZqBcdTAwMTZSOZJohPXAVGRJeWMyWd5YwFx1MDAwMVhrhDgmQnNqXHUwMDEx+1x1MDAxN7Wt6r6bgdqIXGKutSBjglx1MDAxZOyMPbRcdTAwMTFcdTAwMWQz+CRnlM1lx+fZcy3YLrU6j0vnTa9VWzpx71vh54D3XHUwMDE0WI7De8pl5IPwo1x1MDAxM/l82S9cdTAwMWNf3j1tP/U7lVte8S8yI5xS7lxiMMHBXHUwMDBll1xmulx1MDAwM08yXFxcdTAwMTDlMElBv0rCXHUwMDExsec6VLlccuLs95b18vpu7/LsqFx1MDAxMYRIbD8yUq317mJoiCmdqVxcOC6ZsMmfsVT148FcdTAwMDRcZozK70hx/2GzvHbax7u6XW9cdTAwMWVVrqrn+GBvJipcIqQlJXGZzE9Fe2syUJFy5IBcIrREjFx1MDAwYlxyWORJydBpkpHIXHUwMDExXHUwMDEya1xuUOWYXHUwMDBimztL8lx1MDAxNtDPZfXOICA9XHUwMDBiKsGUwUhYZ7xcdTAwMTjRaVqhQiPNsJ5HKXl25jFQXHUwMDAyPF7mfW1kxFwisXVhMrabtVqcXHUwMDFlI3CcgqFROI43PVx1MDAxZlx1MDAxYW72VWl375jKSq/O+/Wn0tPm8W72KWyepOHnhKHLa6pe/1x1MDAwNbW8ujhcZlx0XHUwMDEzYH5KZMUhV6NbXyXONaaKclx1MDAxMfkob05D+Vx1MDAxMLZQ5Sy4OXlcXFdB7bm/K33/w2hob01cdTAwMDZcdTAwMWEyhVx1MDAxY1xmNOSKMK0po0nJTIUhx1x1MDAwZZLGwWRMXHUwMDEzsGS+YPijTfNcYqgwXHUwMDAzXGah+yBcdTAwMDHdaFxme1x1MDAwM1x1MDAxOKaGf+BBM0RcdJtLKW9Kw0OARjX8XGYsnFx1MDAwMqFRXHUwMDE2jjY8XHUwMDFmXHUwMDEyhueVuiiWXHUwMDBlQ1xcKKqz7WPmXHUwMDFlPHcyk5Dgn8Iv/GWVXFxcXFx1MDAxY4WYwKiMqVx1MDAxZIVcdTAwMTKNblx1MDAxZHqGmGlcZlx1MDAwM1x1MDAwMIs+9+Ys5MHlaWPPLVeap1x1MDAxNbbyfNMun595XHUwMDFmxkJ7azKwkFDsYOBcdTAwMTh4XHUwMDE4XFxrXHUwMDFky27IxkKhXHUwMDFkhKRmWlx1MDAxMY1cdTAwMTGOXHUwMDFk8cXCmVx1MDAxNbSWnYWSILBcXIS2ojDWq0aVQpTQklCdd+RzcVx1MDAxNlx1MDAxNlx1MDAwMUiB+zloOFx1MDAwNUSjNFx1MDAxY296PjxcZsq3JffaLZySs/3SQbf9zft2+i0zXHUwMDBm9U+Bw3rdq+pfcZp0fXFcdTAwMWNcboaxokLbwp1g0aaKXFwwQcA/eUfPXHUwMDEwr+27x2ed9oXa3Vxu+/urK1x1MDAxN71v5Vx1MDAwZqOhvTVcdTAwMTloXGKGhENcdTAwMDXSXHUwMDFjxlbFhUQ4qZnpOCRcdTAwMGWX3HiWXHUwMDFja06teT+58/CXlVBpXHUwMDA231BzgTjHzGo6otRZXHUwMDE0TLHkRMWd+M9cdTAwMDLE1WpcdTAwMTW+71PwcFxuiEZ5ONbyfHC4vnuiXHUwMDFmeuGx6DTdh1x1MDAxZN3flHv97DhUyVRfk7AzJl/CXHUwMDEwmLOxXHUwMDFmmzn7myMwJVx1MDAwMYBtLM47KYhEXHUwMDAyK5uGKVx1MDAxNaNbh1x1MDAxYVaKXHUwMDEyqt41vWfzor/6rdDbLyHVXHUwMDBmt721/vPF2d47pPdMXHUwMDA3KaYynlxutVx1MDAxMEjtV5lcdTAwMDWkUjpcdTAwMTLGQIKFkEwhPqq98YRZxlx1MDAxY0Y1XGbjwmR4a5v0+JfybMrbzI5Jc3MxlnaFjbMzmmBRYM8gjeeaYMmzs45R8rt/3Iuj7YNcdTAwMTJtprBpPNEm2ep8XHUwMDAweb/fWCtsXHUwMDFjN/qnurHbelx1MDAwZW/3j1Sm9HbK4cxcdTAwMDRGUMVcdTAwMTRcdTAwMTi9XCKZ3o4x7JaYKSa4JjL2vIauolx1MDAxNiZvTnGwjlx1MDAwNSVcbkfyXHUwMDE4ijdmYb1Dgk016PR6K9duWL3+XHUwMDE5JLw9g6VLXHQ49NTi/8HO8fmgYVx1MDAxMFx1MDAwNDHGdDxTPlx1MDAxZlx1MDAwNTPwNGORlTlcdTAwMTRcXFxyPDjlYVx1MDAxOMDfjf4g6+y7v+f2ektcdTAwMDMr8ru/gt9F3lNs4MnQXHUwMDFlU/hMV5WP/G9cdTAwMGVugqdKw8XtzY2H7aDSPCvf55dWp+aaL1x1MDAxMrMg20NcdTAwMTT8sV9P9TO4vDtcdTAwMGJb0VxugbsrsbRcdTAwMDdJU2eGXHUwMDE1PHHAsXrHXHUwMDEwyunl/u7dw8Fqc+1k7+ristMt9to3XHUwMDFmNmlkb00meE5OrtM5JNfN5HN+yYiVZ+CpQIphQeyhXHUwMDE0nJqOSpBcdTAwMTBcZjEt885Gzy/J7ru/5fdgrP9cdTAwMWPpdlPwlJZuZ7uIfHjZfz6RXFy5leJ9Ta4/i4e+2KhavNv5XHUwMDEy795cdTAwMDGXXHUwMDE4+l6VLJB49/PrfHdhXFxKLJhxfeyB1NRcdTAwMTWkmCApjMfzjmvKN8t7h5XtXHJ/o8N3Wrxee6hcdTAwMWXtkVx1MDAwZuOlvTVcdTAwMTl4ySR2sFBASlx1MDAwMbxTiiSFM1x1MDAxNZdCOZprXHKwlWDnIM3GZTTbXHUwMDE0bVx1MDAwNlx1MDAxOf3iuKxkxyVgj0ErJLZnpKfm6Fx1MDAxMMRcdTAwMTEmUs5nYL5DXHUwMDE23ueC5Vx1MDAxNDbZ8/HeXHUwMDBllVuien55vNFa3WnKsHDS7t89bs5cdTAwMTJ6SUZVklx0t1xuWVx1MDAwMjFUOzr+XHUwMDEz9ZpoJeZssPxtpoPdxVx1MDAwMzFcXFx1MDAwYqFcYlx1MDAxZU+pfVx1MDAxOaBTfUizqpZhOteyk/mY2Hy+7O9cdTAwMWTL6/PiTbe0rzfWu6zS/Vx1MDAxY3VLKNcqp7ol9qvMwFosiWNcblx1MDAxZFx1MDAxMMrAVlx1MDAwMdyKXHUwMDEx6Y3HYThziNSaU/iUJjo2XHUwMDE3/Fx1MDAxNYeZLLyr7Fx1MDAxNMXUTJsygW1mp2W111BhXG7Bw8G5r4BcdTAwMDZzVsmFIPrdX621m1x1MDAxZr/ieVxuqcZcdTAwMDMxI83Oh5eN0/buxfpxrVC/qtxcdTAwMTZcdTAwMWJcdTAwMWRxIFx1MDAxZu8z81x1MDAxMsduWP/lqY+7j+JrtnU2eXqLc1FcdTAwMTKludLW8KlgqbFcdTAwMTcujcWs5ou9zMfF1qW8XHUwMDBibk83dKtXO9jvPd6J4vXdbPxcIiReaGUhftlbk4VflDiEgu/AzWRbfGrU9FxubbFcdTAwMWNcdTAwMDG7XHUwMDBlgE4pJWRcdTAwMGVrs343kdSzM4xzXHRWXCK3XHUwMDFiieliwIhCv8JcdTAwMWHnXnxr1lx1MDAwZTvGsFx1MDAwMVxuYuWrPoMjOIUkY6l3qZeQXHUwMDBm2G63eu7+RbHQKav9WuFZ7jx6nbMsSlx1MDAwNmPYXHUwMDAxXHUwMDEzQ3FcdTAwMTCtVnQkw1x1MDAwMKxcdTAwMWGHgrw1xVKDWWQpzZMhxYB8pVx1MDAxOKQruzmDdVxuViaWmI9Pf5prSlW2YJIpinNcdTAwMTc2IzCcsHh3zSHF4DOkXHUwMDE0TC7HlSWlIG95+1x1MDAwN/c9d3Pvmd2ekcLB8/pTcLB+mT0kgrWjOVx1MDAxMkphcFF0cppcdTAwMDc0Tlx1MDAxZFxyw75GhFx1MDAxOFx1MDAxNUdPdKhx8FYpY1x1MDAxNLhcdTAwMDL2XHUwMDEyXHUwMDEz0pJcdTAwMDMo4SSIIyZcdTAwMDRGQlwilnetvZ+rbE+a2G9cdTAwMTa2dSlcdTAwMDa8U2aNinKU6qBcbsYlVfFwypubuuV7f1x1MDAwNXdcdTAwMWF+6bZ8uFx1MDAxZvhX5bbcXv+JS+1ON82JSZRFcYHPb5pPSWtKNjKhdlwigOhmnVx1MDAwYuOEKDJim2NwYlx1MDAxZFx1MDAwNjqmlDBwfuiY2uFDjqn2Zlx1MDAwNniqpdaWantaOia4qqlcdTAwMDBTk2KVd2XtX2Pi6SZz7j1Jk7ugXHUwMDE0THK73Fx1MDAwNUpPgsCUXG5cdTAwMWVfJ/Xmcrcnuc5cIlx1MDAxZka4iq1cYprBnuh2mqNt/0+shSjeXFw0/Pd//7RcdTAwMWVcclZcdTAwMTZYtJRcIvifgMdcdTAwMWFVPjM/IC5Tolx1MDAxMvaBwjSVetrp0uVkflZgL4xcdTAwMTlCgT9cdTAwMGJGmkKSRidcdTAwMWO7Zb3QXHLCXHUwMDAy9LCm30j2o1x1MDAxZvX4X+xcdTAwMTe+1t1qXHUwMDE1XHUwMDFhZeHjXHUwMDBi92G3ed48+VG3/8fR7U5t8Cw7wVUz/jBBZ09ebc/czcTNXFyKJDK4hVx1MDAwZUdcYo+1M+q8nl+b3srJNk2slU2/16x5mZpcdENcdTAwMWJYICZ+XHLOrKZasWS7NaZm4COIXGKkNFJcdTAwMTOuYXCvV83weu25YyqHK4zvXHUwMDFiXHUwMDFkh73WVefR0lHbnVx1MDAwN2+3+dL03mkzvP6hu1xmQJhilE5cdTAwMDBcdTAwMDJX3IHBWlx1MDAxMaWxlkgki6+CuVx1MDAwNv1cdTAwMWSZODBcdTAwMTBcdTAwMDEsuHFcImAhnUGRP8TBQlx1MDAwNF/P8q5cdTAwMDWzRDNxlmjo+Vx1MDAxZEtVpVwi4WRhJGBcdTAwMDNejeMpLlx1MDAxMVx1MDAxMyw1mIeZ5jC8UMnRO5qA9lmJmZhcdTAwMDB65XNcdTAwMDVA8mVcdTAwMDJTZlx1MDAxNZR514hJtNA0Ma6sXHUwMDEw4VxiSZFiXFwzqTjjU8+XqqjB+cbFtDhcdTAwMTTeZrg15ZxhXHUwMDA04USZNzBEtubLXrgyJOFBXCJcdTAwMDbXqolmizJjct3cuchcdTAwMDYwXHUwMDAzq1x1MDAxZEtcdTAwMDHPmFCMo2U01r1kwiV8NmTYPbAxZNgqsVx1MDAwMTFcdTAwMDQlJsNDcY6SOZRcdTAwMThccoyWSTNcdTAwMDZcZpt38+BBt0D25faMOSCU2J+vXHRcdTAwMDNcdTAwMGIvbmeYXHUwMDFkRFqAi1x1MDAxZlx1MDAwN3gsXHUwMDAzTKUu0zVcdTAwMDOtUvE865yqd8/qXHJHXHUwMDFkc1x1MDAxOFx1MDAwNDZcdTAwMGJz7LP9XHUwMDEybPT4XHUwMDFmmTjqTUPZUybjxkPZyavIZ0aQrjNcXL8+Ld6UXHUwMDBli721p526Lzees0eyXHUwMDExSc5cdTAwMTEk60thzKmDJZjRTCEhibIsLbSlSc+UgPK7hfBaXHUwMDBiz/1cdTAwMTFcdTAwMDQyJlpZK3TT9CVcdTAwMTHgqILJXCLjn3tz0y/o7K+o7c3CUelutbNXpYXKTlx1MDAxZM1cdTAwMTboRmCw5vTeXG57azKQ0LwuSoFhQ4VcIlxibG41gkIs1Fx1MDAxNKFcZibGKbCQga1cdTAwMDestKRFf0W+J8imnZ2A4NYrk8FlrVM4SVx1MDAxZpxoilx1MDAwNMl9XHUwMDEx7sxdeFxmgYNXPqRcdTAwMDfG3jnoPVx1MDAwNTqj5LM1Plx1MDAxZvpNnuGaSj82mX5IT6VcdTAwMWb5ot9sMl78LZNMXHUwMDEypiW3LqCl6VWGwflATDCi3zHL69pv3T/7xf3gqdFbv+3zlc5lMfhcdTAwMTQhKma8hFli9lx1MDAxM0Rov8osUJXMIVx1MDAxNFx1MDAwNmrw+lxihVaNRqT1NKhy6WBFzXIjXHUwMDAxYrVlk32tzJ0gxlx1MDAxOV7lSDhlmNtcdTAwMDNQlKXXb2PaXGatc1x1MDAwNpzfXHUwMDE0qcZcdTAwMWL77v9zUO7hX5+BqlNgNkrVlPbnXHUwMDAz1oOde3nUuGx4YfPu/Hm9jUWwe51cdTAwMTmsXGbcco5Nvlx1MDAxMVx1MDAwNu1ilIw0aJNcdTAwMDJq6thIU+NcdTAwMWbbkslgYFBSIIqkknBcdTAwMTZkMZbBnqbwWcHM6nCO8de8kU3hwcK4xVx1MDAxMmnG4GnappOgiampJvCIMabx1WRvztv90mN77by712eH2+JhXVxcXHUwMDE2S7f6U6w2XCLwn54lpj1Bm5NcdTAwMWI6KVx1MDAwMlxivo+jXHUwMDE1/JGMUVxu/yVzQpRcdTAwMDbZgfNcdTAwMDQ7JCYslsJcdTAwMTm9NdnEXGK54pxcYqqNIWZcdTAwMTGmqdFcblx1MDAwZt5cdTAwMTSWXHUwMDEznKN3XHJcdTAwMDD+ZFx1MDAwMFx1MDAwZXJcYlx1MDAwM1x1MDAxMlx0XHUwMDAzLbfXjJMqPc9bmHBcdTAwMDZ0ztzne2ft6sPN+YbqUruq+Vx1MDAxOeuk0enGxJwxsDZ5ZdPSvIE1XHUwMDEza2RcXFx1MDAxMlx1MDAwMVx1MDAxN1x1MDAwM7pN3Fx1MDAwM4drXCKI4GaqXHUwMDAzXHUwMDExLvnYVcxcdTAwMThXm4z9ea9cdTAwMDGZ2Fx1MDAxMFx1MDAwNeAzjkzGjEpeXHUwMDA0My/6wUBxXHKuncA/UzKGXHUwMDFkOFx1MDAxOVxcXHUwMDFmuFx1MDAxZOC5mGJEYFx1MDAwNYHvokdWzlxi4lx1MDAxMPBVTflcdTAwMWEhqLTk4ua7jub3sYh69lx1MDAxMdfm88DQoLCQtkpcbiq9klx1MDAxZlxmOVxivFx1MDAxZZ33XHUwMDFh0PnH1c+8fmaKo/He62eeXHUwMDEwdze64Xa3u1MolI5cdTAwMWG9g5aXvUZcdTAwMWZB3DHVcZU2tcZA2yMpVtPDaWCBUaZcdTAwMDVG2pT4Y2rR1d2/2YxcdTAwMDY/zWpPpVx1MDAwN9ewmepIeatcdTAwMDMl6TNcdTAwMWRYmVd6KvKOeVV36zf1683HXHUwMDAz2TjW59vtXHUwMDEyPjm63J6xXG5cdTAwMDJGiselMr9fYm9NXHUwMDA2XHUwMDE4kq/g2lx1MDAwN8vmLDtcdTAwMTQ5XHUwMDAxn17ZKyNQMiH2TDFcdTAwMTBcZvHcY2vQg/lCy0o/V2xtXG6B3i+21uJP/on/oMn9vTy43lB3d7SQvaZcdTAwMTBG1EHgYmFKjGJHXG6bYIKIgzXWhFx1MDAxM1wimeCWqYavxJJcdTAwMTlFfL74XFxcdTAwMWZm5qVlliia+Vx1MDAxNE9/w1x1MDAwM1grXHUwMDAyg//5jm946KPN7euLh5ut7m1wtU62XHUwMDBl7sqn7ocllthbk4F9XHUwMDAwN8dcdTAwMTQ45ZxLU1x1MDAwNkiNxKBcdJZThPLFvpet88rmXCI7+8yLMcBrl9aF15P0IZFcdTAwMDDfIH+f8FdLLJnCnPeD35TajK96XHUwMDFl9K6koMHFU2JcdTAwMTD3XHUwMDEw4Fx1MDAxMKCR2JdZgyNcdTAwMTFcdTAwMTNISm1cbnSMy5kz6YDnyMGFpFx1MDAwNNxIS1x1MDAxZFx1MDAwNazNu1jM3K2kXHUwMDEywdA7y7LLq/irOS3aXHUwMDBlXHUwMDAz1+913cBLvNrqTad6osZGyuaLXHUwMDBiO6XQnr3ItOSapeRMx1x1MDAxZcHo8kpcdTAwMTiSwdCZh3qJVuRYNNM3XHUwMDFkXHUwMDEyLr/sNTy/9sJDw8LBjVt2u93DXHUwMDEwvmpoXHUwMDE2LD80vceCdYQ3P+bzXHUwMDAzgZhcdTAwMWXgXHKsib//+Pv/0dOP9yJ9EarlyAccessFlow Yield VaultsResourceStructContractAccountUserResourceInstanceStructInstanceAdminAdminEarlyAccessPasscreateStrategyVaultPass count-1createStrategyVaultYieldVaultPass(count)AdminEarlyAccessYieldVaultYieldVaultLegend \ No newline at end of file diff --git a/flow.json b/flow.json index 129b047..82d3686 100644 --- a/flow.json +++ b/flow.json @@ -53,6 +53,15 @@ "testing": "0000000000000007", "testnet": "0000000000000007" } + }, + "MockFlowYieldVaults": { + "source": "cadence/tests/mocks/MockFlowYieldVaults.cdc", + "aliases": { + "emulator": "0000000000000007", + "mainnet": "0000000000000007", + "testing": "0000000000000007", + "testnet": "0000000000000007" + } } }, "dependencies": { @@ -186,4 +195,4 @@ } } } -} +} \ No newline at end of file From 2b7ac28a3dee47a595c37c59a6914e1be9402833 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sun, 19 Apr 2026 00:06:50 +0200 Subject: [PATCH 06/10] add context to docs --- docs/flow_yield_vaults_early_access.md | 150 ++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 13 deletions(-) diff --git a/docs/flow_yield_vaults_early_access.md b/docs/flow_yield_vaults_early_access.md index 68b5431..a506b90 100644 --- a/docs/flow_yield_vaults_early_access.md +++ b/docs/flow_yield_vaults_early_access.md @@ -1,13 +1,36 @@ # Flow Yield Vaults — Early Access -Gates all yield vault creation to a manually approved allowlist. Only accounts that hold a valid `EarlyAccessPass` capability can create vaults, and each pass carries a finite allowance that decrements on use. +## Overview -Architecture +### Problem +During the launch phase, yield vault creation must be restricted to vetted participants. An open deployment would expose the protocol to malicious actors before the system has been stress-tested in production. Participants are semi-trusted — we can reasonably assume they will not behave maliciously. + +### Goal +Gate yield vault creation behind an allowlist curated by us. Each approved participant receives access to an `EarlyAccessPass` with a finite allowance controlling how many vaults they may create. This limits exposure in the event of a leaked capability: the blast radius is bounded by the allowance on that specific pass and the capital permitted per vault. + +### Lifetime +This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrapper over the core vault interfaces with no state in the underlying contracts. Removing early access requires deploying a new contract that implements `FlowYieldVaultsInterfaces` without the gate and updating callers to use it — no changes to vault logic. + +## Nomenclature + +| Term | Definition | +| :--- | :--------- | +| **Vault** (`YieldVault`) | A Cadence resource representing a yield-generating position. Implements `FungibleToken.Provider` and `FungibleToken.Receiver`. | +| **Pass** (`EarlyAccessPass`) | A Cadence resource stored in contract account storage. Represents a grant of vault-creation rights. Never held by the user directly. | +| **Access to a pass** (`Capability<&EarlyAccessPass>`) | A capability pointing to a pass. The only object the user holds. Grants access to `access(all)` functions on the pass, which includes `createYieldVault`. Becomes dead (unborrow-able) when the underlying pass is destroyed. | +| **passUUID** | The unique identifier of a pass, assigned by the Cadence runtime at creation. Used to reference a pass in all admin operations. Emitted in `PassIssued`. | +| **Allowance** (`remainingAllowance`) | The number of vaults the holder of access to a pass may still create. | +| **strategyID** | An identifier passed to `createYieldVault` that selects which yield strategy the vault should use. Defined by the underlying `FlowYieldVaultsInterfaces` implementation. | +| **Admin** | The contract account. The only account that can issue, revoke, and adjust passes. | ## How it works +Architecture + The contract stores every `EarlyAccessPass` resource in the contract account. The admin issues a pass and publishes a capability to the recipient's inbox. The user claims it into their own storage and then calls through it to create yield vaults. +### Issuance and vault creation flow + ```mermaid sequenceDiagram actor Admin @@ -26,18 +49,45 @@ sequenceDiagram Contract-->>User: @YieldVault ``` +### Pass lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Issued: issuePass + Issued --> Claimed: claimPass + Issued --> Revoked: revokePass + Claimed --> Exhausted: allowance = 0 + Exhausted --> Claimed: setAllowance + Exhausted --> Revoked: revokePass + Claimed --> Revoked: revokePass + Revoked --> [*] +``` + +`Claimed → Exhausted` is triggered by `createYieldVault` when allowance reaches 0, or directly by `setAllowance(0)`. `Exhausted → Claimed` is triggered by `setAllowance(n > 0)`. Revoked is terminal — no transition out. + +## Invariants + +The implementation must maintain the following invariants at all times: + +- **(I)** The total number of `YieldVault`s created through pass P never exceeds the allowance set for P at its most recent `issuePass` or `setAllowance` call. +- **(II)** A `YieldVault` can only be created by an account holding a live capability to a pass P that was issued by the admin and has not been revoked. +- **(III)** An `EarlyAccessPass` resource never exists outside contract account storage. +- **(IV)** The `Admin` resource can only be operated by a transaction signed by the contract account. + ## Security proof **Theorem.** No account can create a `YieldVault` unless the admin has explicitly issued it an `EarlyAccessPass` with sufficient remaining allowance. -**Proof.** We establish six claims: +**Proof.** We establish six claims, each proving one of the invariants stated above: + +- **Claim 1** — The underlying `createYieldVault` is unreachable by any user transaction directly. *(supports II)* +- **Claim 2** — The only code path to `createYieldVault` passes through an `EarlyAccessPass` resource. *(supports II)* +- **Claim 3** — An `EarlyAccessPass` can only be created by the admin and never leaves contract account storage. *(establishes III, supports II)* +- **Claim 4** — Only the intended recipient can obtain access to a given pass. *(supports II)* +- **Claim 5** — The total number of vaults created through a pass can never exceed its allowance. *(establishes I)* +- **Claim 6** — Only the contract account can perform admin operations. *(establishes IV)* -- **Claim 1** — The underlying `createYieldVault` is unreachable by any user transaction directly. -- **Claim 2** — The only code path to `createYieldVault` passes through an `EarlyAccessPass` resource. -- **Claim 3** — An `EarlyAccessPass` can only be created by the admin and never leaves contract account storage. -- **Claim 4** — Only the intended recipient can obtain a capability to a given pass. -- **Claim 5** — The total number of vaults created through a pass can never exceed its allowance. -- **Claim 6** — Only the contract account can perform admin operations. +Claims 1–4 together establish invariant (II): to create a vault, a caller must hold live access to a pass that the admin issued and has not revoked. Claim 5 establishes invariant (I). Claim 6 establishes invariant (IV). The theorem follows. ### Claim 1 — The interface boundary (`FlowYieldVaultsInterfaces`) @@ -80,8 +130,7 @@ access(all) resource Admin { } ``` -The resource is only created in one location, immediately moved into contract account storage and never touches user storage. `loadPass` the only location which moves the resource out of storage calls `destroy` on it immediately after. -A user never has direct access to the resource. +Cadence restricts `create EarlyAccessPass` to the defining contract — no external code can construct one. The resource is immediately moved into contract account storage and never touches user storage. `loadPass`, the only location that moves the resource out of storage, calls `destroy` immediately after. Once destroyed, any access to that pass returns `nil` on `borrow()`. ### Claim 4 — The `EarlyAccessPass` capability @@ -96,8 +145,8 @@ access(all) resource Admin { } ``` -The capability is issued in exactly one location. It is published to the inbox addressed to a specific recipient, and no further action is taken with it. -Only the intended recipient can obtain a capability to the pass. +The capability is issued in exactly one location. It is published to the inbox addressed to a specific recipient, and no further action is taken with it. Cadence's `inbox.claim` requires the claimer to be the transaction signer matching the recipient address — no third party can intercept it. +Only the intended recipient can obtain access to the pass. ### Claim 5 — Only as many vaults can be created as the allowance @@ -122,6 +171,7 @@ access(all) resource EarlyAccessPass { access(all) resource Admin { access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { let pass <- create EarlyAccessPass(allowance: allowance) + // ... } access(all) fun setAllowance(passUUID: UInt64, newAllowance: UInt64) { @@ -146,3 +196,77 @@ access(all) contract FlowYieldVaultsEarlyAccess { `Admin` is created exactly once in `init` and saved directly to contract account storage. No capability is ever issued for it. No external account can perform admin operations. + +## Admin operations + +### Initial setup + +Before any vault can be created, the admin must point the contract at the underlying yield vaults implementation: + +``` +setFlowYieldVaults(flowYieldVaultsName: String) +``` + +This only needs to be called once (or again if the implementation contract is redeployed). + +### Granting access + +``` +issuePass(to addr: Address, allowance: UInt64) → passUUID: UInt64 +``` + +Issues a pass and publishes the capability to `addr`'s inbox. The returned `passUUID` is needed for all subsequent operations on that pass. It is also emitted in the `PassIssued` event — see Monitoring. + +### Revoking access + +``` +revokePass(passUUID: UInt64) +``` + +Destroys the pass resource, immediately invalidating any capability the recipient holds — `borrow()` will return `nil` and all further vault creation attempts will fail. If the pass has not yet been claimed, the inbox entry is also retracted. + +### Adjusting allowance + +``` +setAllowance(passUUID: UInt64, newAllowance: UInt64) +``` + +Replaces the remaining allowance on an existing pass. Can be used to increase, decrease, or set to `0` to temporarily block vault creation without revoking the pass. Setting back to a non-zero value re-enables creation. + +### Re-issuing to the same address + +Revoking and re-issuing creates an independent new pass with a new `passUUID`. The recipient must claim access to the new pass separately. There is no automatic handover between passes. + +## Contract with callers + +### Requirements on the admin + +- **(A1)** `setFlowYieldVaults` must be called before `issuePass` is first called. Calling `issuePass` before this is set will not fail at issuance but will cause `createYieldVault` to panic at vault creation time. +- **(A2)** The contract account must not deploy additional contracts that call `access(account)` functions on the underlying vault implementation. Doing so would bypass the gate and violate invariant (II). +- **(A3)** The contract account's signing key must be kept secure. Compromise of the key grants full admin power. + +### Requirements on the user + +- **(U1)** The user must claim access to the pass from their inbox before attempting vault creation. Unclaimed access cannot be used. +- **(U2)** The user must not share their capability with untrusted parties. Any account that can borrow the capability can consume allowance. + +## Failure modes + +| Condition | Outcome | +| :-------- | :------ | +| User never claims access to the pass | Pass remains in contract storage indefinitely; no vault can be created through it. Admin can revoke to clean up. | +| `setFlowYieldVaults` not called or points to a missing contract | `createYieldVault` panics at vault creation time with "contract not found". Issuance and claiming succeed normally. | +| Pass revoked before claim | Inbox entry is retracted; user cannot claim access. Vault creation is permanently blocked for that pass. | +| Pass revoked after claim | Access to the pass becomes dead (`borrow()` returns `nil`); all further vault creation attempts panic. | +| `setAllowance(0)` called | Vault creation is blocked; capability remains live. Re-enabled by a subsequent `setAllowance` with a non-zero value. | +| Admin loses `passUUID` | `revokePass` and `setAllowance` cannot be called for that pass. The pass remains valid and the user can continue creating vaults until allowance is exhausted. | + +## Monitoring + +All significant state changes emit events. Querying the event history is the primary way to read operational state (e.g. which passes have been issued and their UUIDs). + +| Event | Fields | Meaning | +| :---- | :----- | :------ | +| `PassIssued` | `passUUID`, `addr`, `allowance` | A new pass was issued to `addr` with the given allowance. | +| `PassRevoked` | `passUUID` | The pass was destroyed; any held capability is now dead. | +| `PassUsed` | `passUUID`, `remainingAllowance` | A vault was created; `remainingAllowance` reflects the value after decrement. | From 8c9de31e83a6c99e3f0060784b20f1b4df9e7d7b Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sun, 19 Apr 2026 00:48:32 +0200 Subject: [PATCH 07/10] rename cap to capability --- cadence/transactions/yield_vaults/create_position.cdc | 4 ++-- cadence/transactions/yield_vaults/early_access/claim_pass.cdc | 4 ++-- .../yield_vaults/early_access/claim_pass_uuid.cdc | 4 ++-- docs/flow_yield_vaults_early_access.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cadence/transactions/yield_vaults/create_position.cdc b/cadence/transactions/yield_vaults/create_position.cdc index d738816..727f887 100644 --- a/cadence/transactions/yield_vaults/create_position.cdc +++ b/cadence/transactions/yield_vaults/create_position.cdc @@ -12,10 +12,10 @@ import "FlowYieldVaultsInterfaces" transaction(strategyID: UInt64, earlyAccessPath: StoragePath?, vaultPath: StoragePath) { prepare(signer: auth(Storage) &Account) { let earlyAccessPath = earlyAccessPath ?? FlowYieldVaultsEarlyAccess.passCapabilityStoragePath - let cap = signer.storage.copy>( + let capability = signer.storage.copy>( from: earlyAccessPath ) ?? panic("No valid early access pass") - let pass = cap.borrow() ?? panic("No valid early access pass") + let pass = capability.borrow() ?? panic("No valid early access pass") let vault <- pass.createYieldVault(strategyID: strategyID) signer.storage.save(<- vault, to: vaultPath) } diff --git a/cadence/transactions/yield_vaults/early_access/claim_pass.cdc b/cadence/transactions/yield_vaults/early_access/claim_pass.cdc index 8a0365e..e7a6145 100644 --- a/cadence/transactions/yield_vaults/early_access/claim_pass.cdc +++ b/cadence/transactions/yield_vaults/early_access/claim_pass.cdc @@ -17,10 +17,10 @@ transaction(provider: Address, path: StoragePath?) { storagePath = p } let _ = signer.storage.load>(from: storagePath) - let cap = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( + let capability = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( FlowYieldVaultsEarlyAccess.inboxName(passUUID: passUUID), provider: provider ) ?? panic("No pass found in inbox") - signer.storage.save(cap, to: storagePath) + signer.storage.save(capability, to: storagePath) } } diff --git a/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc b/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc index d0d7c44..2ef307d 100644 --- a/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc +++ b/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc @@ -16,10 +16,10 @@ transaction(passUUID: UInt64, provider: Address, path: StoragePath?) { storagePath = p } let _ = signer.storage.load>(from: storagePath) - let cap = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( + let capability = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( FlowYieldVaultsEarlyAccess.inboxName(passUUID: passUUID), provider: provider ) ?? panic("No pass found in inbox") - signer.storage.save(cap, to: storagePath) + signer.storage.save(capability, to: storagePath) } } diff --git a/docs/flow_yield_vaults_early_access.md b/docs/flow_yield_vaults_early_access.md index a506b90..a5ccc78 100644 --- a/docs/flow_yield_vaults_early_access.md +++ b/docs/flow_yield_vaults_early_access.md @@ -40,10 +40,10 @@ sequenceDiagram Admin->>Contract: issuePass(addr, allowance) Contract->>Contract: store EarlyAccessPass_ - Contract->>Inbox: publish cap → user inbox + Contract->>Inbox: publish capability → user inbox User->>Inbox: claim("EarlyAccessPass_") Inbox-->>User: Capability<&EarlyAccessPass> - User->>User: save cap to storage + User->>User: save capability to storage User->>Contract: createYieldVault(strategyID) Contract->>Contract: remainingAllowance -= 1 Contract-->>User: @YieldVault From 4869dd632cf9f94479b8b97ef01cff641ec82267 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sun, 19 Apr 2026 01:03:38 +0200 Subject: [PATCH 08/10] further docs clarifications --- docs/flow_yield_vaults_early_access.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/flow_yield_vaults_early_access.md b/docs/flow_yield_vaults_early_access.md index a5ccc78..ae2581a 100644 --- a/docs/flow_yield_vaults_early_access.md +++ b/docs/flow_yield_vaults_early_access.md @@ -6,22 +6,22 @@ During the launch phase, yield vault creation must be restricted to vetted participants. An open deployment would expose the protocol to malicious actors before the system has been stress-tested in production. Participants are semi-trusted — we can reasonably assume they will not behave maliciously. ### Goal -Gate yield vault creation behind an allowlist curated by us. Each approved participant receives access to an `EarlyAccessPass` with a finite allowance controlling how many vaults they may create. This limits exposure in the event of a leaked capability: the blast radius is bounded by the allowance on that specific pass and the capital permitted per vault. +Gate yield vault creation behind an allowlist curated by us. Each approved participant receives one `EarlyAccessPass` with an allowance — a fixed number of yield vaults they may create. One pass equals one participant; the allowance is the cap on how many vaults that participant can open. If a participant misbehaves, the admin revokes their pass, immediately blocking any further vault creation. This limits exposure in the event of a leaked capability: the blast radius is bounded by the allowance on that specific pass and the capital permitted per vault. ### Lifetime -This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrapper over the core vault interfaces with no state in the underlying contracts. Removing early access requires deploying a new contract that implements `FlowYieldVaultsInterfaces` without the gate and updating callers to use it — no changes to vault logic. +This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrapper over the core vault interfaces with no state in the underlying contracts. Removing early access requires updating the contract that implements `FlowYieldVaultsInterfaces` without the `access(account)` gate on `fun createYieldVault`. After that, vault creation is open to anyone — no pass and no allowance required. `FlowYieldVaultsEarlyAccess` remains deployed but becomes a dead entrypoint; existing passes are irrelevant since users will interact with the new open contract directly. ## Nomenclature | Term | Definition | | :--- | :--------- | -| **Vault** (`YieldVault`) | A Cadence resource representing a yield-generating position. Implements `FungibleToken.Provider` and `FungibleToken.Receiver`. | +| **Vault** (`YieldVault`) | A Cadence resource representing a yield-generating position. | | **Pass** (`EarlyAccessPass`) | A Cadence resource stored in contract account storage. Represents a grant of vault-creation rights. Never held by the user directly. | | **Access to a pass** (`Capability<&EarlyAccessPass>`) | A capability pointing to a pass. The only object the user holds. Grants access to `access(all)` functions on the pass, which includes `createYieldVault`. Becomes dead (unborrow-able) when the underlying pass is destroyed. | | **passUUID** | The unique identifier of a pass, assigned by the Cadence runtime at creation. Used to reference a pass in all admin operations. Emitted in `PassIssued`. | | **Allowance** (`remainingAllowance`) | The number of vaults the holder of access to a pass may still create. | | **strategyID** | An identifier passed to `createYieldVault` that selects which yield strategy the vault should use. Defined by the underlying `FlowYieldVaultsInterfaces` implementation. | -| **Admin** | The contract account. The only account that can issue, revoke, and adjust passes. | +| **Admin** | The holder of the `Admin` resource. After deployment this is the deploying account. The `Admin` resource can be moved to transfer admin rights. | ## How it works From 66d8b5b64229f51725aaeb95f08b9b73bba69a1f Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Mon, 20 Apr 2026 10:53:49 +0200 Subject: [PATCH 09/10] remove FlowActionInterfaces --- .../yield_vaults/FlowYieldVaults.cdc | 32 +++++++++++++++++++ .../FlowYieldVaultsEarlyAccess.cdc | 24 ++------------ .../FlowYieldVaultsInterfaces.cdc | 12 ------- .../flow_yield_vaults_early_access_test.cdc | 13 ++++---- cadence/tests/helpers/deployment_helpers.cdc | 1 - .../yield_vault_early_access_helpers.cdc | 4 --- cadence/tests/mocks/MockFlowYieldVaults.cdc | 7 ++-- .../set_mock_yield_vaults_implementation.cdc | 10 ------ .../yield_vaults/mock_deposit.cdc | 4 +-- .../yield_vaults/create_position.cdc | 1 - docs/flow_yield_vaults_early_access.md | 32 ++++++------------- flow.json | 20 +----------- 12 files changed, 58 insertions(+), 102 deletions(-) delete mode 100644 cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc delete mode 100644 cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc diff --git a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc index 979a29f..4f0105d 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaults.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaults.cdc @@ -1,4 +1,36 @@ +import "FungibleToken" access(all) contract FlowYieldVaults { + access(all) struct interface Strategy { + access(all) fun createYieldVault(strategyID: UInt64): @YieldVault + } + + access(all) resource YieldVault: FungibleToken.Provider, FungibleToken.Receiver { + + + access(all) view fun isAvailableToWithdraw(amount _: UFix64): Bool { + panic("TODO") + } + + access(FungibleToken.Withdraw) fun withdraw(amount _: UFix64): @{FungibleToken.Vault} { + panic("TODO") + } + + access(all) fun deposit(from _: @{FungibleToken.Vault}) { + panic("TODO") + } + + access(all) view fun getSupportedVaultTypes(): {Type: Bool} { + panic("TODO") + } + + access(all) view fun isSupportedVaultType(type _: Type): Bool { + panic("TODO") + } +} + + access(account) fun createYieldVault(strategyID _: UInt64): @YieldVault { + panic("not implemented") + } } diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc index f8a7855..0d09979 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc @@ -1,4 +1,4 @@ -import "FlowYieldVaultsInterfaces" +import "FlowYieldVaults" /// Gates yield vault creation during the early access period. /// An `Admin` resource issues and manages `EarlyAccessPass` resources. @@ -13,8 +13,6 @@ access(all) contract FlowYieldVaultsEarlyAccess { /// Emitted when a pass is used to create a yield vault. access(all) event PassUsed(passUUID: UInt64, remainingAllowance: UInt64) - /// Contract name on this account implementing `FlowYieldVaultsInterfaces`. - access(all) var flowYieldVaultsName: String /// Storage path where the `Admin` resource is saved. access(all) let adminStoragePath: StoragePath /// Storage path where pass capabilities are stored for claiming. @@ -35,11 +33,10 @@ access(all) contract FlowYieldVaultsEarlyAccess { /// - `strategyID`: Identifies the vault strategy to create. /// /// **Returns** A new `YieldVault` to be saved in the caller's storage. - access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + access(all) fun createYieldVault(strategyID: UInt64): @FlowYieldVaults.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 <- FlowYieldVaults.createYieldVault(strategyID: strategyID) emit PassUsed(passUUID: self.uuid, remainingAllowance: self.remainingAllowance) return <- vault } @@ -97,15 +94,6 @@ access(all) contract FlowYieldVaultsEarlyAccess { pass.setAllowance(newAllowance) } - /// Sets the contract name used to resolve the yield vaults - /// implementation. Must be called before any vault is created. - /// - /// **Parameters** - /// - `flowYieldVaultsName`: Name of a contract on this account - /// that conforms to `FlowYieldVaultsInterfaces`. - access(all) fun setFlowYieldVaults(flowYieldVaultsName: String) { - FlowYieldVaultsEarlyAccess.flowYieldVaultsName = flowYieldVaultsName - } } /// Returns whether a pass with the given UUID currently exists in storage. @@ -140,11 +128,6 @@ access(all) contract FlowYieldVaultsEarlyAccess { return "EarlyAccessPass_\(passUUID)" } - view access(self) fun getFlowYieldVaultsContract(): &{FlowYieldVaultsInterfaces} { - return self.account.contracts.borrow<&{FlowYieldVaultsInterfaces}>(name: self.flowYieldVaultsName) - ?? panic("FlowYieldVaults contract '\(self.flowYieldVaultsName)' not found on this account") - } - access(self) fun storePass(pass: @EarlyAccessPass): UInt64 { let uuid = pass.uuid self.account.storage.save(<- pass, to: FlowYieldVaultsEarlyAccess.passStoragePath(passUUID: uuid)) @@ -177,7 +160,6 @@ access(all) contract FlowYieldVaultsEarlyAccess { } init() { - self.flowYieldVaultsName = "" self.adminStoragePath = StoragePath(identifier: "FlowYieldVaultsEarlyAccessAdmin")! self.passCapabilityStoragePath = StoragePath(identifier: "FlowYieldVaultsEarlyAccessPassCapability")! self.mostRecentIssuedPassUUID = {} diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc deleted file mode 100644 index 454bcf1..0000000 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc +++ /dev/null @@ -1,12 +0,0 @@ -import "FungibleToken" - -access(all) contract interface FlowYieldVaultsInterfaces { - - access(all) struct interface Strategy { - access(all) fun createYieldVault(strategyID: UInt64): @{YieldVault} - } - - access(all) resource interface YieldVault: FungibleToken.Provider, FungibleToken.Receiver {} - - access(account) fun createYieldVault(strategyID: UInt64): @{YieldVault} -} diff --git a/cadence/tests/flow_yield_vaults_early_access_test.cdc b/cadence/tests/flow_yield_vaults_early_access_test.cdc index 499ead3..239a06b 100644 --- a/cadence/tests/flow_yield_vaults_early_access_test.cdc +++ b/cadence/tests/flow_yield_vaults_early_access_test.cdc @@ -16,13 +16,14 @@ access(all) fun beforeEach() { Test.reset(to: snapshot) } access(all) fun setup() { deploy("cadence/contracts/actions/FlowActions.cdc") - deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") - deploy("cadence/tests/mocks/MockFlowYieldVaults.cdc") - deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") - Test.expect( - setYieldVaultsImpl(admin: admin, txPath: "cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc"), - Test.beSucceeded() + Test.expect(Test.deployContract( + name: "FlowYieldVaults", + path: "cadence/tests/mocks/MockFlowYieldVaults.cdc", + arguments: [] + ), + Test.beNil() ) + deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") snapshot = getCurrentBlockHeight() } diff --git a/cadence/tests/helpers/deployment_helpers.cdc b/cadence/tests/helpers/deployment_helpers.cdc index 402f218..4403045 100644 --- a/cadence/tests/helpers/deployment_helpers.cdc +++ b/cadence/tests/helpers/deployment_helpers.cdc @@ -34,7 +34,6 @@ access(all) fun deployFlowYieldVaults() { !yieldVaultsDeployed: "FlowYieldVaults already deployed" } yieldVaultsDeployed = true - deploy("cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaults.cdc") deploy("cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc") } diff --git a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc index b24dba9..96817b3 100644 --- a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc +++ b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc @@ -1,10 +1,6 @@ import Test import "FlowYieldVaultsEarlyAccess" -access(all) fun setYieldVaultsImpl(admin: Test.TestAccount, txPath: String): Test.TransactionResult { - return executeTransaction(txPath, [], admin) -} - access(all) fun grantEarlyAccess(admin: Test.TestAccount, user: Test.TestAccount, allowance: UInt64): UInt64 { let result = executeTransaction( "cadence/transactions/yield_vaults/early_access/grant_access.cdc", diff --git a/cadence/tests/mocks/MockFlowYieldVaults.cdc b/cadence/tests/mocks/MockFlowYieldVaults.cdc index c25d760..a3320c6 100644 --- a/cadence/tests/mocks/MockFlowYieldVaults.cdc +++ b/cadence/tests/mocks/MockFlowYieldVaults.cdc @@ -1,9 +1,8 @@ -import "FlowYieldVaultsInterfaces" import "FungibleToken" -access(all) contract MockFlowYieldVaults: FlowYieldVaultsInterfaces { +access(all) contract FlowYieldVaults { - access(all) resource YieldVault: FlowYieldVaultsInterfaces.YieldVault { + access(all) resource YieldVault: FungibleToken.Provider, FungibleToken.Receiver { access(all) view fun isAvailableToWithdraw(amount: UFix64): Bool { let _ = amount return false @@ -27,7 +26,7 @@ access(all) contract MockFlowYieldVaults: FlowYieldVaultsInterfaces { } } - access(account) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + access(account) fun createYieldVault(strategyID: UInt64): @YieldVault { let _ = strategyID return <- create YieldVault() } diff --git a/cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc b/cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc deleted file mode 100644 index d15d316..0000000 --- a/cadence/tests/transactions/yield_vaults/early_access/set_mock_yield_vaults_implementation.cdc +++ /dev/null @@ -1,10 +0,0 @@ -import "FlowYieldVaultsEarlyAccess" - -transaction() { - prepare(admin: auth(Storage) &Account) { - let handle = admin.storage.borrow<&FlowYieldVaultsEarlyAccess.Admin>( - from: FlowYieldVaultsEarlyAccess.adminStoragePath - ) ?? panic("Admin not found") - handle.setFlowYieldVaults(flowYieldVaultsName: "MockFlowYieldVaults") - } -} diff --git a/cadence/tests/transactions/yield_vaults/mock_deposit.cdc b/cadence/tests/transactions/yield_vaults/mock_deposit.cdc index 9dbbebc..3a72fd5 100644 --- a/cadence/tests/transactions/yield_vaults/mock_deposit.cdc +++ b/cadence/tests/transactions/yield_vaults/mock_deposit.cdc @@ -1,4 +1,4 @@ -import "FlowYieldVaultsInterfaces" +import "FlowYieldVaults" /// Deposits into the strategy vault stored at `path`. /// Panics if no `YieldVault` is found at the given path. @@ -7,7 +7,7 @@ import "FlowYieldVaultsInterfaces" /// - `path`: Storage path of the `YieldVault` to deposit into. transaction(path: StoragePath) { prepare(signer: auth(Storage) &Account) { - let _ = signer.storage.borrow<&{FlowYieldVaultsInterfaces.YieldVault}>(from: path) + let _ = signer.storage.borrow<&FlowYieldVaults.YieldVault>(from: path) ?? panic("No YieldVault found at path") } } diff --git a/cadence/transactions/yield_vaults/create_position.cdc b/cadence/transactions/yield_vaults/create_position.cdc index 727f887..120ad5d 100644 --- a/cadence/transactions/yield_vaults/create_position.cdc +++ b/cadence/transactions/yield_vaults/create_position.cdc @@ -1,5 +1,4 @@ import "FlowYieldVaultsEarlyAccess" -import "FlowYieldVaultsInterfaces" /// Creates a new yield vault using the signer's early access pass. /// Panics if no valid pass capability is found or the pass allowance is exhausted. diff --git a/docs/flow_yield_vaults_early_access.md b/docs/flow_yield_vaults_early_access.md index ae2581a..d696c41 100644 --- a/docs/flow_yield_vaults_early_access.md +++ b/docs/flow_yield_vaults_early_access.md @@ -9,7 +9,7 @@ During the launch phase, yield vault creation must be restricted to vetted parti Gate yield vault creation behind an allowlist curated by us. Each approved participant receives one `EarlyAccessPass` with an allowance — a fixed number of yield vaults they may create. One pass equals one participant; the allowance is the cap on how many vaults that participant can open. If a participant misbehaves, the admin revokes their pass, immediately blocking any further vault creation. This limits exposure in the event of a leaked capability: the blast radius is bounded by the allowance on that specific pass and the capital permitted per vault. ### Lifetime -This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrapper over the core vault interfaces with no state in the underlying contracts. Removing early access requires updating the contract that implements `FlowYieldVaultsInterfaces` without the `access(account)` gate on `fun createYieldVault`. After that, vault creation is open to anyone — no pass and no allowance required. `FlowYieldVaultsEarlyAccess` remains deployed but becomes a dead entrypoint; existing passes are irrelevant since users will interact with the new open contract directly. +This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrapper over `FlowYieldVaults` with no state in the underlying contract. Removing early access requires updating `FlowYieldVaults` to remove the `access(account)` gate on `fun createYieldVault`. After that, vault creation is open to anyone — no pass and no allowance required. `FlowYieldVaultsEarlyAccess` remains deployed but becomes a dead entrypoint; existing passes are irrelevant since users will interact with `FlowYieldVaults` directly. ## Nomenclature @@ -20,7 +20,7 @@ This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrap | **Access to a pass** (`Capability<&EarlyAccessPass>`) | A capability pointing to a pass. The only object the user holds. Grants access to `access(all)` functions on the pass, which includes `createYieldVault`. Becomes dead (unborrow-able) when the underlying pass is destroyed. | | **passUUID** | The unique identifier of a pass, assigned by the Cadence runtime at creation. Used to reference a pass in all admin operations. Emitted in `PassIssued`. | | **Allowance** (`remainingAllowance`) | The number of vaults the holder of access to a pass may still create. | -| **strategyID** | An identifier passed to `createYieldVault` that selects which yield strategy the vault should use. Defined by the underlying `FlowYieldVaultsInterfaces` implementation. | +| **strategyID** | An identifier passed to `createYieldVault` that selects which yield strategy the vault should use. Defined by the underlying `FlowYieldVaults` implementation. | | **Admin** | The holder of the `Admin` resource. After deployment this is the deploying account. The `Admin` resource can be moved to transfer admin rights. | ## How it works @@ -89,10 +89,10 @@ The implementation must maintain the following invariants at all times: Claims 1–4 together establish invariant (II): to create a vault, a caller must hold live access to a pass that the admin issued and has not revoked. Claim 5 establishes invariant (I). Claim 6 establishes invariant (IV). The theorem follows. -### Claim 1 — The interface boundary (`FlowYieldVaultsInterfaces`) +### Claim 1 — The `access(account)` gate ```cadence -access(account) fun createYieldVault(strategyID: UInt64): @{YieldVault} +access(account) fun createYieldVault(strategyID: UInt64): @YieldVault ``` `createYieldVault` is `access(account)`. It can only be called from a contract deployed on the **same account** — in this case `FlowYieldVaultsEarlyAccess`. A user transaction cannot call the underlying implementation directly. @@ -101,16 +101,16 @@ access(account) fun createYieldVault(strategyID: UInt64): @{YieldVault} ```cadence access(all) resource EarlyAccessPass { - access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + access(all) fun createYieldVault(strategyID: UInt64): @FlowYieldVaults.YieldVault { // ... - let vault <- fyv.createYieldVault(strategyID: strategyID) + let vault <- FlowYieldVaults.createYieldVault(strategyID: strategyID) // ... return <- vault } } ``` -`fyv.createYieldVault` is only ever called from inside the `EarlyAccessPass` resource. To reach it, a caller must hold a live capability pointing to an `EarlyAccessPass` that still exists in contract storage. +`FlowYieldVaults.createYieldVault` is only ever called from inside the `EarlyAccessPass` resource. To reach it, a caller must hold a live capability pointing to an `EarlyAccessPass` that still exists in contract storage. ### Claim 3 — The `EarlyAccessPass` resource lifecycle @@ -157,7 +157,7 @@ access(all) resource EarlyAccessPass { self.remainingAllowance = newAllowance } - access(all) fun createYieldVault(strategyID: UInt64): @{FlowYieldVaultsInterfaces.YieldVault} { + access(all) fun createYieldVault(strategyID: UInt64): @FlowYieldVaults.YieldVault { pre { self.remainingAllowance > 0: "No remaining allowance" } self.remainingAllowance = self.remainingAllowance - 1 // ... @@ -199,16 +199,6 @@ No external account can perform admin operations. ## Admin operations -### Initial setup - -Before any vault can be created, the admin must point the contract at the underlying yield vaults implementation: - -``` -setFlowYieldVaults(flowYieldVaultsName: String) -``` - -This only needs to be called once (or again if the implementation contract is redeployed). - ### Granting access ``` @@ -241,9 +231,8 @@ Revoking and re-issuing creates an independent new pass with a new `passUUID`. T ### Requirements on the admin -- **(A1)** `setFlowYieldVaults` must be called before `issuePass` is first called. Calling `issuePass` before this is set will not fail at issuance but will cause `createYieldVault` to panic at vault creation time. -- **(A2)** The contract account must not deploy additional contracts that call `access(account)` functions on the underlying vault implementation. Doing so would bypass the gate and violate invariant (II). -- **(A3)** The contract account's signing key must be kept secure. Compromise of the key grants full admin power. +- **(A1)** The contract account must not deploy additional contracts that call `access(account)` functions on `FlowYieldVaults`. Doing so would bypass the gate and violate invariant (II). +- **(A2)** The contract account's signing key must be kept secure. Compromise of the key grants full admin power. ### Requirements on the user @@ -255,7 +244,6 @@ Revoking and re-issuing creates an independent new pass with a new `passUUID`. T | Condition | Outcome | | :-------- | :------ | | User never claims access to the pass | Pass remains in contract storage indefinitely; no vault can be created through it. Admin can revoke to clean up. | -| `setFlowYieldVaults` not called or points to a missing contract | `createYieldVault` panics at vault creation time with "contract not found". Issuance and claiming succeed normally. | | Pass revoked before claim | Inbox entry is retracted; user cannot claim access. Vault creation is permanently blocked for that pass. | | Pass revoked after claim | Access to the pass becomes dead (`borrow()` returns `nil`); all further vault creation attempts panic. | | `setAllowance(0)` called | Vault creation is blocked; capability remains live. Re-enabled by a subsequent `setAllowance` with a non-zero value. | diff --git a/flow.json b/flow.json index 82d3686..6c24bb3 100644 --- a/flow.json +++ b/flow.json @@ -36,24 +36,6 @@ "testnet": "0000000000000007" } }, - "FlowYieldVaultsInterfaces": { - "source": "cadence/contracts/yield_vaults/FlowYieldVaultsInterfaces.cdc", - "aliases": { - "emulator": "0000000000000007", - "mainnet": "0000000000000007", - "testing": "0000000000000007", - "testnet": "0000000000000007" - } - }, - "MockContract": { - "source": "cadence/tests/mocks/MockContract.cdc", - "aliases": { - "emulator": "0000000000000007", - "mainnet": "0000000000000007", - "testing": "0000000000000007", - "testnet": "0000000000000007" - } - }, "MockFlowYieldVaults": { "source": "cadence/tests/mocks/MockFlowYieldVaults.cdc", "aliases": { @@ -195,4 +177,4 @@ } } } -} \ No newline at end of file +} From 216084b9b0db618fd1d507efbe218aa987d2d951 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Mon, 20 Apr 2026 11:22:32 +0200 Subject: [PATCH 10/10] Early access passes by address instead of UUID --- .../FlowYieldVaultsEarlyAccess.cdc | 142 ++++++------ .../early_access/has_early_access.cdc | 4 +- .../early_access/remaining_allowance.cdc | 4 +- .../flow_yield_vaults_early_access_test.cdc | 214 ++++++++---------- .../yield_vault_early_access_helpers.cdc | 41 ++-- .../early_access/adjust_allowance.cdc | 8 +- .../yield_vaults/early_access/claim_pass.cdc | 9 +- .../early_access/claim_pass_uuid.cdc | 25 -- .../early_access/grant_access.cdc | 4 +- .../early_access/revoke_access.cdc | 12 +- docs/flow_yield_vaults_early_access.md | 96 ++++---- 11 files changed, 265 insertions(+), 294 deletions(-) delete mode 100644 cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc diff --git a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc index 0d09979..771f84e 100644 --- a/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc +++ b/cadence/contracts/yield_vaults/FlowYieldVaultsEarlyAccess.cdc @@ -1,28 +1,28 @@ import "FlowYieldVaults" /// Gates yield vault creation during the early access period. -/// An `Admin` resource issues and manages `EarlyAccessPass` resources. +/// An `Admin` resource issues and manages `EarlyAccessPass` resources, +/// keyed by recipient address: each address has at most one pass. /// Pass holders call `createYieldVault` to create yield vaults /// until their allowance is exhausted. access(all) contract FlowYieldVaultsEarlyAccess { - /// Emitted when a new pass is issued to an address. - access(all) event PassIssued(passUUID: UInt64, addr: Address, allowance: UInt64) + /// Emitted when a pass is issued (or re-issued) to an address. + access(all) event PassIssued(addr: Address, allowance: UInt64) /// Emitted when a pass is revoked and destroyed by the admin. - access(all) event PassRevoked(passUUID: UInt64) + access(all) event PassRevoked(addr: Address) /// Emitted when a pass is used to create a yield vault. - access(all) event PassUsed(passUUID: UInt64, remainingAllowance: UInt64) + access(all) event PassUsed(addr: Address, remainingAllowance: UInt64) /// Storage path where the `Admin` resource is saved. access(all) let adminStoragePath: StoragePath /// Storage path where pass capabilities are stored for claiming. access(all) let passCapabilityStoragePath: StoragePath - /// Tracks the UUID of the last pass issued to each address; - /// used by the address-based claim transaction. - access(all) var mostRecentIssuedPassUUID: {Address: UInt64} /// Held in the holder's storage; gates yield vault creation during early access. access(all) resource EarlyAccessPass { + /// Address this pass was issued to; stamped at creation and never changes. + access(all) let addr: Address /// Number of yield vaults the holder may still create. access(all) var remainingAllowance: UInt64 @@ -37,7 +37,7 @@ access(all) contract FlowYieldVaultsEarlyAccess { pre { self.remainingAllowance > 0: "No remaining allowance" } self.remainingAllowance = self.remainingAllowance - 1 let vault <- FlowYieldVaults.createYieldVault(strategyID: strategyID) - emit PassUsed(passUUID: self.uuid, remainingAllowance: self.remainingAllowance) + emit PassUsed(addr: self.addr, remainingAllowance: self.remainingAllowance) return <- vault } @@ -45,124 +45,130 @@ access(all) contract FlowYieldVaultsEarlyAccess { self.remainingAllowance = newAllowance } - init(allowance: UInt64) { + init(addr: Address, allowance: UInt64) { + self.addr = addr self.remainingAllowance = allowance } } access(all) resource Admin { - /// Issues a pass to `addr`, publishes the capability to their inbox, - /// and records it as their most recently issued pass - /// (used by the address-based claim transaction). + /// Issues a pass to `addr` and publishes the capability to their inbox. + /// If a pass already exists for `addr`, its `remainingAllowance` is + /// replaced with the new `allowance`, and any previously issued + /// capabilities for that pass are invalidated. /// /// **Parameters** /// - `addr`: Recipient who will claim the pass from their inbox. /// - `allowance`: Number of yield vaults the pass holder may create. - /// - /// **Returns** The UUID of the newly created pass. - access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { - let pass <- create EarlyAccessPass(allowance: allowance) - let passUUID = FlowYieldVaultsEarlyAccess.storePass(pass: <- pass) - FlowYieldVaultsEarlyAccess.publishPassCapability(passUUID: passUUID, addr: addr) - FlowYieldVaultsEarlyAccess.mostRecentIssuedPassUUID[addr] = passUUID - emit PassIssued(passUUID: passUUID, addr: addr, allowance: allowance) - return passUUID + access(all) fun issuePass(to addr: Address, allowance: UInt64) { + let path = FlowYieldVaultsEarlyAccess.passStoragePath(addr: addr) + if FlowYieldVaultsEarlyAccess.checkPass(addr: addr) { + let pass = FlowYieldVaultsEarlyAccess.borrowPass(addr: addr) + pass.setAllowance(allowance) + FlowYieldVaultsEarlyAccess.deletePassCapabilities(addr: addr) + } else { + let pass <- create EarlyAccessPass(addr: addr, allowance: allowance) + FlowYieldVaultsEarlyAccess.account.storage.save(<- pass, to: path) + } + FlowYieldVaultsEarlyAccess.publishPassCapability(addr: addr) + emit PassIssued(addr: addr, allowance: allowance) } - /// Destroys the pass and attempts to retract the inbox capability. - /// Panics if the pass is not found. If already claimed, the capability - /// stays in the recipient's storage but becomes unborrow-able, - /// blocking future vault creation. + /// Destroys the pass, deletes its capability controllers, and retracts + /// the inbox entry if still unclaimed. Panics if no pass exists for `addr`. + /// Any previously claimed capability becomes dead (`borrow()` returns `nil`). /// /// **Parameters** - /// - `passUUID`: UUID of the target pass. - access(all) fun revokePass(passUUID: UInt64) { - let pass <- FlowYieldVaultsEarlyAccess.loadPass(passUUID: passUUID) + /// - `addr`: Recipient whose pass should be revoked. + access(all) fun revokePass(addr: Address) { + let pass <- FlowYieldVaultsEarlyAccess.loadPass(addr: addr) destroy pass - FlowYieldVaultsEarlyAccess.unpublishPassCapability(passUUID: passUUID) - emit PassRevoked(passUUID: passUUID) + FlowYieldVaultsEarlyAccess.deletePassCapabilities(addr: addr) + FlowYieldVaultsEarlyAccess.unpublishPassCapability(addr: addr) + emit PassRevoked(addr: addr) } /// Replaces the remaining allowance on an existing pass. - /// Panics if the pass is not found. + /// Panics if no pass exists for `addr`. /// /// **Parameters** - /// - `passUUID`: UUID of the target pass. + /// - `addr`: Recipient whose pass allowance should be updated. /// - `newAllowance`: New vault budget; `0` immediately blocks creation. - access(all) fun setAllowance(passUUID: UInt64, newAllowance: UInt64) { - let pass = FlowYieldVaultsEarlyAccess.borrowPass(passUUID: passUUID) + access(all) fun setAllowance(addr: Address, newAllowance: UInt64) { + let pass = FlowYieldVaultsEarlyAccess.borrowPass(addr: addr) pass.setAllowance(newAllowance) } } - /// Returns whether a pass with the given UUID currently exists in storage. + /// Returns whether a pass is currently held for the given address. /// /// **Parameters** - /// - `passUUID`: UUID of the target pass. + /// - `addr`: Recipient to check. /// - /// **Returns** `true` if the pass exists, `false` otherwise. - view access(all) fun passExists(passUUID: UInt64): Bool { - return self.checkPass(passUUID: passUUID) + /// **Returns** `true` if a pass exists for `addr`, `false` otherwise. + view access(all) fun passExists(addr: Address): Bool { + return self.checkPass(addr: addr) } - /// Returns the remaining allowance of the pass with the given UUID. - /// Panics if the pass is not found. + /// Returns the remaining allowance of the pass issued to `addr`. + /// Panics if no pass exists for `addr`. /// /// **Parameters** - /// - `passUUID`: UUID of the target pass. + /// - `addr`: Recipient whose pass to query. /// /// **Returns** Number of yield vaults the pass holder may still create. - view access(all) fun remainingAllowance(passUUID: UInt64): UInt64 { - let pass = self.borrowPass(passUUID: passUUID) + view access(all) fun remainingAllowance(addr: Address): UInt64 { + let pass = self.borrowPass(addr: addr) return pass.remainingAllowance } - /// Returns the inbox key used to publish and claim a pass capability. + /// Returns the inbox key used to publish and claim a pass capability + /// for the given address. /// /// **Parameters** - /// - `passUUID`: UUID of the target pass. + /// - `addr`: Recipient whose inbox entry name to compute. /// - /// **Returns** The inbox key string for the given pass. - view access(all) fun inboxName(passUUID: UInt64): String { - return "EarlyAccessPass_\(passUUID)" + /// **Returns** The inbox key string for the given address. + view access(all) fun inboxName(addr: Address): String { + return "EarlyAccessPass_\(addr.toString())" } - access(self) fun storePass(pass: @EarlyAccessPass): UInt64 { - let uuid = pass.uuid - self.account.storage.save(<- pass, to: FlowYieldVaultsEarlyAccess.passStoragePath(passUUID: uuid)) - return uuid + access(self) fun loadPass(addr: Address): @EarlyAccessPass { + return <- (self.account.storage.load<@EarlyAccessPass>(from: self.passStoragePath(addr: addr)) ?? panic("Pass not found")) } - access(self) fun loadPass(passUUID: UInt64): @EarlyAccessPass { - return <- (self.account.storage.load<@EarlyAccessPass>(from: self.passStoragePath(passUUID: passUUID)) ?? panic("Pass not found")) + view access(self) fun checkPass(addr: Address): Bool { + return self.account.storage.check<@EarlyAccessPass>(from: self.passStoragePath(addr: addr)) } - view access(self) fun checkPass(passUUID: UInt64): Bool { - return self.account.storage.check<@EarlyAccessPass>(from: self.passStoragePath(passUUID: passUUID)) + view access(self) fun borrowPass(addr: Address): &EarlyAccessPass { + return self.account.storage.borrow<&EarlyAccessPass>(from: self.passStoragePath(addr: addr)) ?? panic("Pass not found") } - view access(self) fun borrowPass(passUUID: UInt64): &EarlyAccessPass { - return self.account.storage.borrow<&EarlyAccessPass>(from: self.passStoragePath(passUUID: passUUID)) ?? panic("Pass not found") + access(self) fun publishPassCapability(addr: Address) { + let capability = self.account.capabilities.storage.issue<&EarlyAccessPass>(self.passStoragePath(addr: addr)) + self.account.inbox.publish(capability, name: self.inboxName(addr: addr), recipient: addr) } - access(self) fun publishPassCapability(passUUID: UInt64, addr: Address) { - let capability = self.account.capabilities.storage.issue<&EarlyAccessPass>(self.passStoragePath(passUUID: passUUID)) - self.account.inbox.publish(capability, name: self.inboxName(passUUID: passUUID), recipient: addr) + access(self) fun unpublishPassCapability(addr: Address) { + let _ = self.account.inbox.unpublish<&EarlyAccessPass>(self.inboxName(addr: addr)) } - access(self) fun unpublishPassCapability(passUUID: UInt64) { - let _ = self.account.inbox.unpublish<&EarlyAccessPass>(self.inboxName(passUUID: passUUID)) + access(self) fun deletePassCapabilities(addr: Address) { + let controllers = self.account.capabilities.storage.getControllers(forPath: self.passStoragePath(addr: addr)) + for controller in controllers { + controller.delete() + } } - view access(self) fun passStoragePath(passUUID: UInt64): StoragePath { - return StoragePath(identifier: "FlowYieldVaultsEarlyAccessPass_\(passUUID)")! + view access(self) fun passStoragePath(addr: Address): StoragePath { + return StoragePath(identifier: "FlowYieldVaultsEarlyAccessPass_\(addr.toString())")! } init() { self.adminStoragePath = StoragePath(identifier: "FlowYieldVaultsEarlyAccessAdmin")! self.passCapabilityStoragePath = StoragePath(identifier: "FlowYieldVaultsEarlyAccessPassCapability")! - self.mostRecentIssuedPassUUID = {} self.account.storage.save(<- create Admin(), to: self.adminStoragePath) } } diff --git a/cadence/scripts/yield_vaults/early_access/has_early_access.cdc b/cadence/scripts/yield_vaults/early_access/has_early_access.cdc index 0918f94..73910d4 100644 --- a/cadence/scripts/yield_vaults/early_access/has_early_access.cdc +++ b/cadence/scripts/yield_vaults/early_access/has_early_access.cdc @@ -1,5 +1,5 @@ import "FlowYieldVaultsEarlyAccess" -access(all) fun main(passUUID: UInt64): Bool { - return FlowYieldVaultsEarlyAccess.passExists(passUUID: passUUID) +access(all) fun main(addr: Address): Bool { + return FlowYieldVaultsEarlyAccess.passExists(addr: addr) } diff --git a/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc b/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc index 5dd6c25..cf29452 100644 --- a/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc +++ b/cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc @@ -1,5 +1,5 @@ import "FlowYieldVaultsEarlyAccess" -access(all) fun main(passUUID: UInt64): UInt64 { - return FlowYieldVaultsEarlyAccess.remainingAllowance(passUUID: passUUID) +access(all) fun main(addr: Address): UInt64 { + return FlowYieldVaultsEarlyAccess.remainingAllowance(addr: addr) } diff --git a/cadence/tests/flow_yield_vaults_early_access_test.cdc b/cadence/tests/flow_yield_vaults_early_access_test.cdc index 239a06b..e1364c2 100644 --- a/cadence/tests/flow_yield_vaults_early_access_test.cdc +++ b/cadence/tests/flow_yield_vaults_early_access_test.cdc @@ -35,9 +35,9 @@ access(all) fun test_no_pass() { } 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) + Test.assert(hasEarlyAccess(userA.address)) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) expectFailedWithError( createYieldVault(signer: userB, strategyID: 0, path: defaultPath), @@ -46,11 +46,11 @@ access(all) fun test_grant() { } access(all) fun test_revoke() { - let passUUIDA = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.expect(claimPass(user: userA, passUUID: passUUIDA, provider: admin.address), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) - Test.assert(!hasEarlyAccess(passUUIDA)) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) + Test.assert(!hasEarlyAccess(userA.address)) expectFailedWithError( createYieldVault(signer: userA, strategyID: 0, path: defaultPath), errorMessageSubstring: "No valid early access pass" @@ -58,47 +58,65 @@ 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: defaultPath), Test.beSucceeded()) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) Test.expect(deposit(signer: userA, path: defaultPath), Test.beSucceeded()) } -access(all) fun test_multiple_grants() { - let passUUID1 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - let passUUID2 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.assert(hasEarlyAccess(passUUID1)) - Test.assert(hasEarlyAccess(passUUID2)) +access(all) fun test_reissue_replaces_allowance() { + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) + Test.assertEqual(1 as UInt64, remainingAllowance(userA.address)) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 5), Test.beSucceeded()) + Test.assertEqual(5 as UInt64, remainingAllowance(userA.address)) + Test.assert(hasEarlyAccess(userA.address)) +} + +access(all) fun test_reissue_invalidates_previously_claimed_capability() { + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) + + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 5), Test.beSucceeded()) + + expectFailedWithError( + createYieldVault(signer: userA, strategyID: 0, path: /storage/b), + errorMessageSubstring: "No valid early access pass" + ) + + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) + Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/b), Test.beSucceeded()) + Test.assertEqual(4 as UInt64, remainingAllowance(userA.address)) } access(all) fun test_multiple_revoke() { - Test.expect(revokeEarlyAccess(admin: admin, passUUID: 0), Test.beFailed()) - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beFailed()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beFailed()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beFailed()) } access(all) fun test_revoke_and_re_grant() { - let passUUID1 = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID1), Test.beSucceeded()) - 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) + Test.assert(hasEarlyAccess(userA.address)) Test.expect(createYieldVault(signer: userA, strategyID: 0, 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, 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()) } 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(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) expectFailedWithError( createYieldVault(signer: userA, strategyID: 0, path: /storage/b), @@ -107,15 +125,15 @@ access(all) fun test_allowance_exhausted() { } access(all) fun test_two_users_independent() { - let passUUIDA = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userB, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) + Test.expect(claimPass(user: userB, 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(revokeEarlyAccess(admin: admin, passUUID: passUUIDA), Test.beSucceeded()) - Test.assert(!hasEarlyAccess(passUUIDA)) - Test.assert(hasEarlyAccess(passUUIDB)) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) + Test.assert(!hasEarlyAccess(userA.address)) + Test.assert(hasEarlyAccess(userB.address)) expectFailedWithError( createYieldVault(signer: userA, strategyID: 0, path: /storage/a2), errorMessageSubstring: "No valid early access pass" @@ -125,26 +143,26 @@ access(all) fun test_two_users_independent() { } access(all) fun test_remainingPositions_reflects_allowance() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.assertEqual(3 as UInt64, remainingAllowance(passUUID)) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.assertEqual(3 as UInt64, remainingAllowance(userA.address)) } 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) - Test.assertEqual(2 as UInt64, remainingAllowance(passUUID)) + Test.assertEqual(2 as UInt64, remainingAllowance(userA.address)) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/b), Test.beSucceeded()) - Test.assertEqual(1 as UInt64, remainingAllowance(passUUID)) + Test.assertEqual(1 as UInt64, remainingAllowance(userA.address)) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/c), Test.beSucceeded()) - Test.assertEqual(0 as UInt64, remainingAllowance(passUUID)) + Test.assertEqual(0 as UInt64, remainingAllowance(userA.address)) } 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(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) - Test.assertEqual(0 as UInt64, remainingAllowance(passUUID)) + Test.assertEqual(0 as UInt64, remainingAllowance(userA.address)) expectFailedWithError( createYieldVault(signer: userA, strategyID: 0, path: /storage/b), errorMessageSubstring: "No remaining allowance" @@ -153,36 +171,36 @@ access(all) fun test_remainingPositions_is_zero_after_exhausted() { access(all) fun test_remainingPositions_fails_for_nonexistent_pass() { Test.expectFailure(fun () { - let _ = FlowYieldVaultsEarlyAccess.remainingAllowance(passUUID: 0) + let _ = FlowYieldVaultsEarlyAccess.remainingAllowance(addr: userA.address) }, errorMessageSubstring: "Pass not found") } 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(grantEarlyAccess(admin: admin, user: userA, allowance: 5), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userB, allowance: 2), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) - Test.assertEqual(4 as UInt64, remainingAllowance(passUUIDA)) - Test.assertEqual(2 as UInt64, remainingAllowance(passUUIDB)) + Test.assertEqual(4 as UInt64, remainingAllowance(userA.address)) + Test.assertEqual(2 as UInt64, remainingAllowance(userB.address)) } access(all) fun test_setAllowance() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) - Test.assertEqual(1 as UInt64, remainingAllowance(passUUID)) - 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(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) + Test.assertEqual(1 as UInt64, remainingAllowance(userA.address)) + Test.expect(setAllowance(admin: admin, addr: userA.address, newAllowance: 5), Test.beSucceeded()) + Test.assertEqual(5 as UInt64, remainingAllowance(userA.address)) + Test.expect(claimPass(user: userA, 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.assertEqual(3 as UInt64, remainingAllowance(passUUID)) + Test.assertEqual(3 as UInt64, remainingAllowance(userA.address)) } access(all) fun test_setAllowance_to_zero_blocks_createYieldVault() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.expect(setAllowance(admin: admin, passUUID: passUUID, newAllowance: 0), Test.beSucceeded()) - Test.assertEqual(0 as UInt64, remainingAllowance(passUUID)) - Test.assert(hasEarlyAccess(passUUID)) - Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(setAllowance(admin: admin, addr: userA.address, newAllowance: 0), Test.beSucceeded()) + Test.assertEqual(0 as UInt64, remainingAllowance(userA.address)) + Test.assert(hasEarlyAccess(userA.address)) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) expectFailedWithError( createYieldVault(signer: userA, strategyID: 0, path: defaultPath), errorMessageSubstring: "No remaining allowance" @@ -190,70 +208,47 @@ access(all) fun test_setAllowance_to_zero_blocks_createYieldVault() { } access(all) fun test_grant_events() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) let events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassIssued Test.assertEqual(userA.address, ev.addr) Test.assertEqual(3 as UInt64, ev.allowance) - Test.assertEqual(passUUID, ev.passUUID) } access(all) fun test_revoke_events() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 3) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) var events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassRevoked - Test.assertEqual(passUUID, ev.passUUID) + Test.assertEqual(userA.address, ev.addr) } 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(grantEarlyAccess(admin: admin, user: userA, allowance: 3), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) Test.expect(createYieldVault(signer: userA, strategyID: 0, path: /storage/a), Test.beSucceeded()) let events = Test.eventsOfType(Type()) Test.assertEqual(1, events.length) let ev = events[0] as! FlowYieldVaultsEarlyAccess.PassUsed - Test.assertEqual(passUUID, ev.passUUID) + Test.assertEqual(userA.address, ev.addr) Test.assertEqual(2 as UInt64, ev.remainingAllowance) } -access(all) fun test_invalid_pass_uuid() { - Test.assert(!FlowYieldVaultsEarlyAccess.passExists(passUUID: 0)) +access(all) fun test_no_pass_for_addr() { + Test.assert(!FlowYieldVaultsEarlyAccess.passExists(addr: userA.address)) Test.expectFailure( fun () { - let _ = FlowYieldVaultsEarlyAccess.remainingAllowance(passUUID: 0) + let _ = FlowYieldVaultsEarlyAccess.remainingAllowance(addr: userA.address) }, errorMessageSubstring: "Pass not found" ) } -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()) -} - -access(all) fun test_claim_by_address_gets_most_recent() { - let _ = grantEarlyAccess(admin: admin, user: userA, allowance: 1) - 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()) -} - -access(all) fun test_claim_by_address_fails_if_no_pass_issued() { - expectFailedWithError( - claimPassByAddress(user: userA, provider: admin.address), - errorMessageSubstring: "No pass issued to this address" - ) -} - access(all) fun test_claim_with_custom_path() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) let customPath = /storage/myCustomEarlyAccessPath - Test.expect(claimPassWithPath(user: userA, passUUID: passUUID, provider: admin.address, path: customPath), Test.beSucceeded()) + Test.expect(claimPassWithPath(user: userA, provider: admin.address, path: customPath), Test.beSucceeded()) expectFailedWithError( createYieldVault(signer: userA, strategyID: 0, path: defaultPath), errorMessageSubstring: "No valid early access pass" @@ -265,35 +260,26 @@ access(all) fun test_claim_with_custom_path() { } access(all) fun test_double_claim_fails() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) - Test.expect(claimPass(user: userA, passUUID: passUUID, provider: admin.address), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) + Test.expect(claimPass(user: userA, provider: admin.address), Test.beSucceeded()) expectFailedWithError( - claimPass(user: userA, passUUID: passUUID, provider: admin.address), + claimPass(user: userA, provider: admin.address), errorMessageSubstring: "No pass found in inbox" ) } -access(all) fun test_claim_nonexistent_pass_fails() { +access(all) fun test_claim_without_grant_fails() { expectFailedWithError( - claimPass(user: userA, passUUID: 99999, provider: admin.address), + claimPass(user: userA, provider: admin.address), errorMessageSubstring: "No pass found in inbox" ) } access(all) fun test_claim_after_revoke_fails() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) - expectFailedWithError( - claimPass(user: userA, passUUID: passUUID, provider: admin.address), - errorMessageSubstring: "No pass found in inbox" - ) -} - -access(all) fun test_claim_by_address_fails_after_revoke() { - let passUUID = grantEarlyAccess(admin: admin, user: userA, allowance: 1) - Test.expect(revokeEarlyAccess(admin: admin, passUUID: passUUID), Test.beSucceeded()) + Test.expect(grantEarlyAccess(admin: admin, user: userA, allowance: 1), Test.beSucceeded()) + Test.expect(revokeEarlyAccess(admin: admin, addr: userA.address), Test.beSucceeded()) expectFailedWithError( - claimPassByAddress(user: userA, provider: admin.address), + claimPass(user: userA, provider: admin.address), errorMessageSubstring: "No pass found in inbox" ) } diff --git a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc index 96817b3..191f24a 100644 --- a/cadence/tests/helpers/yield_vault_early_access_helpers.cdc +++ b/cadence/tests/helpers/yield_vault_early_access_helpers.cdc @@ -1,53 +1,42 @@ import Test import "FlowYieldVaultsEarlyAccess" -access(all) fun grantEarlyAccess(admin: Test.TestAccount, user: Test.TestAccount, allowance: UInt64): UInt64 { - let result = executeTransaction( +access(all) fun grantEarlyAccess(admin: Test.TestAccount, user: Test.TestAccount, allowance: UInt64): Test.TransactionResult { + return executeTransaction( "cadence/transactions/yield_vaults/early_access/grant_access.cdc", [user.address, allowance], admin ) - Test.expect(result, Test.beSucceeded()) - let events = Test.eventsOfType(Type()) - return (events[events.length - 1] as! FlowYieldVaultsEarlyAccess.PassIssued).passUUID -} - -access(all) fun claimPass(user: Test.TestAccount, passUUID: UInt64, provider: Address): Test.TransactionResult { - return executeTransaction( - "cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc", - [passUUID, provider, nil], - user - ) } -access(all) fun claimPassWithPath(user: Test.TestAccount, passUUID: UInt64, provider: Address, path: StoragePath): Test.TransactionResult { +access(all) fun claimPass(user: Test.TestAccount, provider: Address): Test.TransactionResult { return executeTransaction( - "cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc", - [passUUID, provider, path], + "cadence/transactions/yield_vaults/early_access/claim_pass.cdc", + [provider, nil], user ) } -access(all) fun claimPassByAddress(user: Test.TestAccount, provider: Address): Test.TransactionResult { +access(all) fun claimPassWithPath(user: Test.TestAccount, provider: Address, path: StoragePath): Test.TransactionResult { return executeTransaction( "cadence/transactions/yield_vaults/early_access/claim_pass.cdc", - [provider, nil], + [provider, path], user ) } -access(all) fun revokeEarlyAccess(admin: Test.TestAccount, passUUID: UInt64): Test.TransactionResult { +access(all) fun revokeEarlyAccess(admin: Test.TestAccount, addr: Address): Test.TransactionResult { return executeTransaction( "cadence/transactions/yield_vaults/early_access/revoke_access.cdc", - [passUUID], + [addr], admin ) } -access(all) fun setAllowance(admin: Test.TestAccount, passUUID: UInt64, newAllowance: UInt64): Test.TransactionResult { +access(all) fun setAllowance(admin: Test.TestAccount, addr: Address, newAllowance: UInt64): Test.TransactionResult { return executeTransaction( "cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc", - [passUUID, newAllowance], + [addr, newAllowance], admin ) } @@ -76,14 +65,14 @@ access(all) fun deposit(signer: Test.TestAccount, path: StoragePath): Test.Trans ) } -access(all) fun hasEarlyAccess(_ passUUID: UInt64): Bool { - let result = executeScript("cadence/scripts/yield_vaults/early_access/has_early_access.cdc", [passUUID]) +access(all) fun hasEarlyAccess(_ addr: Address): Bool { + let result = executeScript("cadence/scripts/yield_vaults/early_access/has_early_access.cdc", [addr]) Test.expect(result, Test.beSucceeded()) return result.returnValue! as! Bool } -access(all) fun remainingAllowance(_ passUUID: UInt64): UInt64 { - let result = executeScript("cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc", [passUUID]) +access(all) fun remainingAllowance(_ addr: Address): UInt64 { + let result = executeScript("cadence/scripts/yield_vaults/early_access/remaining_allowance.cdc", [addr]) Test.expect(result, Test.beSucceeded()) return result.returnValue! as! UInt64 } diff --git a/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc b/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc index 093f78d..a951aed 100644 --- a/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc +++ b/cadence/transactions/yield_vaults/early_access/adjust_allowance.cdc @@ -1,16 +1,16 @@ import "FlowYieldVaultsEarlyAccess" -/// Replaces the remaining allowance on an existing pass. +/// Replaces the remaining allowance on the pass issued to `addr`. /// Setting `newAllowance` to `0` immediately blocks further vault creation. /// /// **Parameters** -/// - `passUUID`: UUID of the target pass. +/// - `addr`: Recipient whose pass allowance should be updated. /// - `newAllowance`: New vault budget; may be lower or higher than the current value. -transaction(passUUID: UInt64, newAllowance: UInt64) { +transaction(addr: Address, newAllowance: UInt64) { prepare(admin: auth(Storage) &Account) { let handle = admin.storage .borrow<&FlowYieldVaultsEarlyAccess.Admin>(from: FlowYieldVaultsEarlyAccess.adminStoragePath) ?? panic("Could not borrow Admin") - handle.setAllowance(passUUID: passUUID, newAllowance: newAllowance) + handle.setAllowance(addr: addr, newAllowance: newAllowance) } } diff --git a/cadence/transactions/yield_vaults/early_access/claim_pass.cdc b/cadence/transactions/yield_vaults/early_access/claim_pass.cdc index e7a6145..94df302 100644 --- a/cadence/transactions/yield_vaults/early_access/claim_pass.cdc +++ b/cadence/transactions/yield_vaults/early_access/claim_pass.cdc @@ -1,8 +1,7 @@ import "FlowYieldVaultsEarlyAccess" -/// Claims the most recently issued pass for the signer from the provider's inbox. -/// Use when you want to claim the latest pass without knowing its UUID; the UUID is -/// looked up automatically from `mostRecentIssuedPassUUID`. +/// Claims the pass issued to the signer from the provider's inbox and +/// saves the capability into the signer's storage. /// /// **Parameters** /// - `provider`: Address of the account that issued the pass. @@ -10,15 +9,13 @@ import "FlowYieldVaultsEarlyAccess" /// `passCapabilityStoragePath` if `nil`. transaction(provider: Address, path: StoragePath?) { prepare(signer: auth(Storage, Inbox) &Account) { - let passUUID = FlowYieldVaultsEarlyAccess.mostRecentIssuedPassUUID[signer.address] - ?? panic("No pass issued to this address") var storagePath = FlowYieldVaultsEarlyAccess.passCapabilityStoragePath if let p = path { storagePath = p } let _ = signer.storage.load>(from: storagePath) let capability = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( - FlowYieldVaultsEarlyAccess.inboxName(passUUID: passUUID), + FlowYieldVaultsEarlyAccess.inboxName(addr: signer.address), provider: provider ) ?? panic("No pass found in inbox") signer.storage.save(capability, to: storagePath) diff --git a/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc b/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc deleted file mode 100644 index 2ef307d..0000000 --- a/cadence/transactions/yield_vaults/early_access/claim_pass_uuid.cdc +++ /dev/null @@ -1,25 +0,0 @@ -import "FlowYieldVaultsEarlyAccess" - -/// Claims a specific pass by UUID from the provider's inbox. -/// Use when claiming a pass that is not the most recently issued one, -/// e.g. when multiple passes have been issued to the same address. -/// -/// **Parameters** -/// - `passUUID`: UUID of the target pass. -/// - `provider`: Address of the account that issued the pass. -/// - `path`: Storage path for the pass capability; defaults to -/// `passCapabilityStoragePath` if `nil`. -transaction(passUUID: UInt64, provider: Address, path: StoragePath?) { - prepare(signer: auth(Storage, Inbox) &Account) { - var storagePath = FlowYieldVaultsEarlyAccess.passCapabilityStoragePath - if let p = path { - storagePath = p - } - let _ = signer.storage.load>(from: storagePath) - let capability = signer.inbox.claim<&FlowYieldVaultsEarlyAccess.EarlyAccessPass>( - FlowYieldVaultsEarlyAccess.inboxName(passUUID: passUUID), - provider: provider - ) ?? panic("No pass found in inbox") - signer.storage.save(capability, to: storagePath) - } -} diff --git a/cadence/transactions/yield_vaults/early_access/grant_access.cdc b/cadence/transactions/yield_vaults/early_access/grant_access.cdc index 00a1dc5..3f62375 100644 --- a/cadence/transactions/yield_vaults/early_access/grant_access.cdc +++ b/cadence/transactions/yield_vaults/early_access/grant_access.cdc @@ -1,6 +1,8 @@ import "FlowYieldVaultsEarlyAccess" /// Issues an early access pass to `addr` and publishes the capability to their inbox. +/// If a pass already exists for `addr`, its allowance is replaced and any +/// previously issued capabilities are invalidated. /// /// **Parameters** /// - `addr`: Recipient address to issue the pass to. @@ -10,6 +12,6 @@ transaction(addr: Address, allowance: UInt64) { let handle = admin.storage .borrow<&FlowYieldVaultsEarlyAccess.Admin>(from: FlowYieldVaultsEarlyAccess.adminStoragePath) ?? panic("Could not borrow Admin") - let _ = handle.issuePass(to: addr, allowance: allowance) + handle.issuePass(to: addr, allowance: allowance) } } diff --git a/cadence/transactions/yield_vaults/early_access/revoke_access.cdc b/cadence/transactions/yield_vaults/early_access/revoke_access.cdc index e54d228..99f9a9b 100644 --- a/cadence/transactions/yield_vaults/early_access/revoke_access.cdc +++ b/cadence/transactions/yield_vaults/early_access/revoke_access.cdc @@ -1,16 +1,16 @@ import "FlowYieldVaultsEarlyAccess" -/// Destroys the pass and attempts to retract the inbox capability. -/// If already claimed, the stored capability becomes invalid, blocking -/// future vault creation. +/// Destroys the pass for `addr`, deletes its capability controllers +/// (invalidating any held capability), and retracts the inbox entry +/// if still unclaimed. /// /// **Parameters** -/// - `passUUID`: UUID of the target pass. -transaction(passUUID: UInt64) { +/// - `addr`: Recipient whose pass should be revoked. +transaction(addr: Address) { prepare(admin: auth(Storage) &Account) { let handle = admin.storage .borrow<&FlowYieldVaultsEarlyAccess.Admin>(from: FlowYieldVaultsEarlyAccess.adminStoragePath) ?? panic("Could not borrow Admin") - handle.revokePass(passUUID: passUUID) + handle.revokePass(addr: addr) } } diff --git a/docs/flow_yield_vaults_early_access.md b/docs/flow_yield_vaults_early_access.md index d696c41..841c290 100644 --- a/docs/flow_yield_vaults_early_access.md +++ b/docs/flow_yield_vaults_early_access.md @@ -16,9 +16,9 @@ This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrap | Term | Definition | | :--- | :--------- | | **Vault** (`YieldVault`) | A Cadence resource representing a yield-generating position. | -| **Pass** (`EarlyAccessPass`) | A Cadence resource stored in contract account storage. Represents a grant of vault-creation rights. Never held by the user directly. | -| **Access to a pass** (`Capability<&EarlyAccessPass>`) | A capability pointing to a pass. The only object the user holds. Grants access to `access(all)` functions on the pass, which includes `createYieldVault`. Becomes dead (unborrow-able) when the underlying pass is destroyed. | -| **passUUID** | The unique identifier of a pass, assigned by the Cadence runtime at creation. Used to reference a pass in all admin operations. Emitted in `PassIssued`. | +| **Pass** (`EarlyAccessPass`) | A Cadence resource stored in contract account storage. Represents a grant of vault-creation rights. Never held by the user directly. Keyed by recipient address: each address has at most one pass at a time. | +| **Access to a pass** (`Capability<&EarlyAccessPass>`) | A capability pointing to a pass. The only object the user holds. Grants access to `access(all)` functions on the pass, which includes `createYieldVault`. Becomes dead (unborrow-able) when the underlying pass is destroyed or its capability controller is deleted. | +| **addr** | The recipient address a pass is issued to. Used as the primary key for all admin operations and emitted in every event. | | **Allowance** (`remainingAllowance`) | The number of vaults the holder of access to a pass may still create. | | **strategyID** | An identifier passed to `createYieldVault` that selects which yield strategy the vault should use. Defined by the underlying `FlowYieldVaults` implementation. | | **Admin** | The holder of the `Admin` resource. After deployment this is the deploying account. The `Admin` resource can be moved to transfer admin rights. | @@ -27,7 +27,7 @@ This is a **temporary** restriction. `FlowYieldVaultsEarlyAccess` is a thin wrap Architecture -The contract stores every `EarlyAccessPass` resource in the contract account. The admin issues a pass and publishes a capability to the recipient's inbox. The user claims it into their own storage and then calls through it to create yield vaults. +The contract stores every `EarlyAccessPass` resource in the contract account, keyed by recipient address. The admin issues a pass and publishes a capability to the recipient's inbox. The user claims it into their own storage and then calls through it to create yield vaults. ### Issuance and vault creation flow @@ -39,9 +39,9 @@ sequenceDiagram actor User Admin->>Contract: issuePass(addr, allowance) - Contract->>Contract: store EarlyAccessPass_ + Contract->>Contract: store EarlyAccessPass_ Contract->>Inbox: publish capability → user inbox - User->>Inbox: claim("EarlyAccessPass_") + User->>Inbox: claim("EarlyAccessPass_") Inbox-->>User: Capability<&EarlyAccessPass> User->>User: save capability to storage User->>Contract: createYieldVault(strategyID) @@ -56,38 +56,42 @@ stateDiagram-v2 [*] --> Issued: issuePass Issued --> Claimed: claimPass Issued --> Revoked: revokePass + Issued --> Issued: issuePass (re-issue) + Claimed --> Issued: issuePass (re-issue) Claimed --> Exhausted: allowance = 0 Exhausted --> Claimed: setAllowance Exhausted --> Revoked: revokePass + Exhausted --> Issued: issuePass (re-issue) Claimed --> Revoked: revokePass Revoked --> [*] ``` -`Claimed → Exhausted` is triggered by `createYieldVault` when allowance reaches 0, or directly by `setAllowance(0)`. `Exhausted → Claimed` is triggered by `setAllowance(n > 0)`. Revoked is terminal — no transition out. +`Claimed → Exhausted` is triggered by `createYieldVault` when allowance reaches 0, or directly by `setAllowance(0)`. `Exhausted → Claimed` is triggered by `setAllowance(n > 0)`. `issuePass` on an address that already holds a pass replaces its allowance and invalidates any previously issued capability — the recipient must claim the new capability. Revoked is terminal — no transition out. ## Invariants The implementation must maintain the following invariants at all times: -- **(I)** The total number of `YieldVault`s created through pass P never exceeds the allowance set for P at its most recent `issuePass` or `setAllowance` call. +- **(I)** The total number of `YieldVault`s created through the pass issued to address A never exceeds the allowance set for A at its most recent `issuePass` or `setAllowance` call. - **(II)** A `YieldVault` can only be created by an account holding a live capability to a pass P that was issued by the admin and has not been revoked. - **(III)** An `EarlyAccessPass` resource never exists outside contract account storage. -- **(IV)** The `Admin` resource can only be operated by a transaction signed by the contract account. +- **(IV)** At any point in time, each address holds at most one `EarlyAccessPass`. +- **(V)** The `Admin` resource can only be operated by a transaction signed by the contract account. ## Security proof **Theorem.** No account can create a `YieldVault` unless the admin has explicitly issued it an `EarlyAccessPass` with sufficient remaining allowance. -**Proof.** We establish six claims, each proving one of the invariants stated above: +**Proof.** We establish the following claims, each proving one or more of the invariants stated above: - **Claim 1** — The underlying `createYieldVault` is unreachable by any user transaction directly. *(supports II)* - **Claim 2** — The only code path to `createYieldVault` passes through an `EarlyAccessPass` resource. *(supports II)* -- **Claim 3** — An `EarlyAccessPass` can only be created by the admin and never leaves contract account storage. *(establishes III, supports II)* +- **Claim 3** — An `EarlyAccessPass` can only be created by the admin and never leaves contract account storage. *(establishes III, supports II and IV)* - **Claim 4** — Only the intended recipient can obtain access to a given pass. *(supports II)* - **Claim 5** — The total number of vaults created through a pass can never exceed its allowance. *(establishes I)* -- **Claim 6** — Only the contract account can perform admin operations. *(establishes IV)* +- **Claim 6** — Only the contract account can perform admin operations. *(establishes V)* -Claims 1–4 together establish invariant (II): to create a vault, a caller must hold live access to a pass that the admin issued and has not revoked. Claim 5 establishes invariant (I). Claim 6 establishes invariant (IV). The theorem follows. +Claims 1–4 together establish invariant (II): to create a vault, a caller must hold live access to a pass that the admin issued and has not revoked. Claim 3, combined with the address-keyed storage path, establishes invariant (IV). Claim 5 establishes invariant (I). Claim 6 establishes invariant (V). The theorem follows. ### Claim 1 — The `access(account)` gate @@ -116,31 +120,39 @@ access(all) resource EarlyAccessPass { ```cadence access(all) resource Admin { - access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { - let pass <- create EarlyAccessPass(allowance: allowance) - let passUUID = FlowYieldVaultsEarlyAccess.storePass(pass: <- pass) + access(all) fun issuePass(to addr: Address, allowance: UInt64) { + let path = FlowYieldVaultsEarlyAccess.passStoragePath(addr: addr) + if FlowYieldVaultsEarlyAccess.checkPass(addr: addr) { + let pass = FlowYieldVaultsEarlyAccess.borrowPass(addr: addr) + pass.setAllowance(allowance) + FlowYieldVaultsEarlyAccess.deletePassCapabilities(addr: addr) + } else { + let pass <- create EarlyAccessPass(addr: addr, allowance: allowance) + FlowYieldVaultsEarlyAccess.account.storage.save(<- pass, to: path) + } // ... } - access(all) fun revokePass(passUUID: UInt64) { - let pass <- FlowYieldVaultsEarlyAccess.loadPass(passUUID: passUUID) + access(all) fun revokePass(addr: Address) { + let pass <- FlowYieldVaultsEarlyAccess.loadPass(addr: addr) destroy pass // ... } } ``` -Cadence restricts `create EarlyAccessPass` to the defining contract — no external code can construct one. The resource is immediately moved into contract account storage and never touches user storage. `loadPass`, the only location that moves the resource out of storage, calls `destroy` immediately after. Once destroyed, any access to that pass returns `nil` on `borrow()`. +Cadence restricts `create EarlyAccessPass` to the defining contract — no external code can construct one. The resource is immediately moved into a deterministic, address-keyed path in contract account storage (`FlowYieldVaultsEarlyAccessPass_`) and never touches user storage. `loadPass`, the only location that moves the resource out of storage, calls `destroy` immediately after. Once destroyed, any capability pointing at that path returns `nil` on `borrow()`. + +Because the storage path is a deterministic function of `addr`, and `issuePass` reuses the existing resource when one is already present, an address can never hold more than one pass simultaneously — establishing invariant (IV). ### Claim 4 — The `EarlyAccessPass` capability ```cadence access(all) resource Admin { - access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { + access(all) fun issuePass(to addr: Address, allowance: UInt64) { // ... let capability = self.account.capabilities.storage.issue<&EarlyAccessPass>(passStoragePath) self.account.inbox.publish(capability, name: inboxName, recipient: addr) - // ... } } ``` @@ -163,19 +175,20 @@ access(all) resource EarlyAccessPass { // ... } - init(allowance: UInt64) { + init(addr: Address, allowance: UInt64) { + self.addr = addr self.remainingAllowance = allowance } } access(all) resource Admin { - access(all) fun issuePass(to addr: Address, allowance: UInt64): UInt64 { - let pass <- create EarlyAccessPass(allowance: allowance) + access(all) fun issuePass(to addr: Address, allowance: UInt64) { + // either create with allowance, or call setAllowance on existing pass // ... } - access(all) fun setAllowance(passUUID: UInt64, newAllowance: UInt64) { - let pass = FlowYieldVaultsEarlyAccess.borrowPass(passUUID: passUUID) + access(all) fun setAllowance(addr: Address, newAllowance: UInt64) { + let pass = FlowYieldVaultsEarlyAccess.borrowPass(addr: addr) pass.setAllowance(newAllowance) } } @@ -202,30 +215,32 @@ No external account can perform admin operations. ### Granting access ``` -issuePass(to addr: Address, allowance: UInt64) → passUUID: UInt64 +issuePass(to addr: Address, allowance: UInt64) ``` -Issues a pass and publishes the capability to `addr`'s inbox. The returned `passUUID` is needed for all subsequent operations on that pass. It is also emitted in the `PassIssued` event — see Monitoring. +Issues a pass to `addr` and publishes the capability to their inbox. If a pass already exists for `addr`, its `remainingAllowance` is replaced with the new `allowance` and any previously issued capability controllers are deleted — invalidating any capability the recipient has already claimed. A fresh capability is then issued and published to the inbox, which the recipient must claim before they can create vaults again. ### Revoking access ``` -revokePass(passUUID: UInt64) +revokePass(addr: Address) ``` -Destroys the pass resource, immediately invalidating any capability the recipient holds — `borrow()` will return `nil` and all further vault creation attempts will fail. If the pass has not yet been claimed, the inbox entry is also retracted. +Destroys the pass resource and deletes all capability controllers associated with it, immediately invalidating any capability the recipient holds — `borrow()` will return `nil` and all further vault creation attempts will fail. If the pass has not yet been claimed, the inbox entry is also retracted. Panics if no pass exists for `addr`. ### Adjusting allowance ``` -setAllowance(passUUID: UInt64, newAllowance: UInt64) +setAllowance(addr: Address, newAllowance: UInt64) ``` Replaces the remaining allowance on an existing pass. Can be used to increase, decrease, or set to `0` to temporarily block vault creation without revoking the pass. Setting back to a non-zero value re-enables creation. ### Re-issuing to the same address -Revoking and re-issuing creates an independent new pass with a new `passUUID`. The recipient must claim access to the new pass separately. There is no automatic handover between passes. +Calling `issuePass` on an address that already holds a pass reuses the same underlying resource, replaces its allowance, and deletes all previously issued capability controllers — any capability the recipient already claimed becomes dead. A fresh capability is published to the inbox and must be re-claimed before vault creation can resume. + +Revoking and then issuing is equivalent from the user's perspective, but goes through a terminal `Revoked` state and creates a brand-new resource. ## Contract with callers @@ -237,24 +252,25 @@ Revoking and re-issuing creates an independent new pass with a new `passUUID`. T ### Requirements on the user - **(U1)** The user must claim access to the pass from their inbox before attempting vault creation. Unclaimed access cannot be used. -- **(U2)** The user must not share their capability with untrusted parties. Any account that can borrow the capability can consume allowance. +- **(U2)** After a re-issue, the user must re-claim the new capability; the previously claimed one is dead. +- **(U3)** The user must not share their capability with untrusted parties. Any account that can borrow the capability can consume allowance. ## Failure modes | Condition | Outcome | | :-------- | :------ | | User never claims access to the pass | Pass remains in contract storage indefinitely; no vault can be created through it. Admin can revoke to clean up. | -| Pass revoked before claim | Inbox entry is retracted; user cannot claim access. Vault creation is permanently blocked for that pass. | -| Pass revoked after claim | Access to the pass becomes dead (`borrow()` returns `nil`); all further vault creation attempts panic. | +| Pass revoked before claim | Inbox entry is retracted and the controller is deleted; user cannot claim access. Vault creation is permanently blocked for that pass. | +| Pass revoked after claim | Capability controllers are deleted and the resource is destroyed; the held capability is dead (`borrow()` returns `nil`); all further vault creation attempts panic. | | `setAllowance(0)` called | Vault creation is blocked; capability remains live. Re-enabled by a subsequent `setAllowance` with a non-zero value. | -| Admin loses `passUUID` | `revokePass` and `setAllowance` cannot be called for that pass. The pass remains valid and the user can continue creating vaults until allowance is exhausted. | +| `issuePass` called on an address with an existing pass | Allowance is replaced; any previously claimed capability is dead; a fresh capability is published to the inbox and must be re-claimed. | ## Monitoring -All significant state changes emit events. Querying the event history is the primary way to read operational state (e.g. which passes have been issued and their UUIDs). +All significant state changes emit events. Querying the event history is the primary way to read operational state (e.g. which addresses currently hold passes and their allowances). | Event | Fields | Meaning | | :---- | :----- | :------ | -| `PassIssued` | `passUUID`, `addr`, `allowance` | A new pass was issued to `addr` with the given allowance. | -| `PassRevoked` | `passUUID` | The pass was destroyed; any held capability is now dead. | -| `PassUsed` | `passUUID`, `remainingAllowance` | A vault was created; `remainingAllowance` reflects the value after decrement. | +| `PassIssued` | `addr`, `allowance` | A pass was issued (or re-issued) to `addr` with the given allowance. | +| `PassRevoked` | `addr` | The pass for `addr` was destroyed; any held capability is now dead. | +| `PassUsed` | `addr`, `remainingAllowance` | A vault was created through the pass for `addr`; `remainingAllowance` reflects the value after decrement. |