Skip to content

Commit abe64b3

Browse files
authored
Merge pull request #175 from onflow/claude/fix-flowalp-beta-capability-XU9nk
Remove EPosition auth requirement from FlowALP beta cap transactions
2 parents b9d0051 + 50abec3 commit abe64b3

18 files changed

+112
-516
lines changed

cadence/tests/cap_test.cdc

Lines changed: 2 additions & 251 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,6 @@ import "test_helpers.cdc"
2222
// Published via the FIXED publish_beta_cap.cdc.
2323
// Cap stored at FlowALPv0.PoolCapStoragePath.
2424
//
25-
// ePositionUser — Capability<auth(EPosition) &Pool>
26-
// EPosition-only capability; can perform pool-level position
27-
// ops on any position by ID. No EParticipant.
28-
// Cap stored at FlowALPv0.PoolCapStoragePath.
29-
//
30-
// eParticipantPositionUser — Capability<auth(EParticipant, EPosition) &Pool> over-grant
31-
// Current (unfixed) beta cap — grants EPosition unnecessarily.
32-
// Cap stored at FlowALPv0.PoolCapStoragePath.
33-
//
3425
// eRebalanceUser — Capability<auth(ERebalance) &Pool>
3526
// Narrowly-scoped cap for rebalancer contracts.
3627
// Cap stored at FlowALPv0.PoolCapStoragePath.
@@ -52,7 +43,7 @@ import "test_helpers.cdc"
5243
// =============================================================================
5344

5445

55-
// Position created for PROTOCOL_ACCOUNT in setup — used as target for EPosition tests.
46+
// Position created for PROTOCOL_ACCOUNT in setup — used as target for ERebalance tests.
5647
access(all) var setupPid: UInt64 = 0
5748
access(all) var ePositionAdminPid: UInt64 = 0
5849

@@ -61,8 +52,6 @@ access(all) var snapshot: UInt64 = 0
6152
// Role accounts
6253
access(all) var userWithoutCap = Test.createAccount()
6354
access(all) var eParticipantUser = Test.createAccount()
64-
access(all) var ePositionUser = Test.createAccount()
65-
access(all) var eParticipantPositionUser = Test.createAccount()
6655
access(all) var eRebalanceUser = Test.createAccount()
6756
access(all) var ePositionAdminUser = Test.createAccount()
6857
access(all) var eGovernanceUser = Test.createAccount()
@@ -71,7 +60,7 @@ access(all) var eGovernanceUser = Test.createAccount()
7160
/// Used in negative tests to verify governance methods are inaccessible to them.
7261
access(all)
7362
fun getNonGovernanceUsers(): [Test.TestAccount] {
74-
return [eParticipantUser, ePositionUser, eParticipantPositionUser, eRebalanceUser, ePositionAdminUser]
63+
return [eParticipantUser, eRebalanceUser, ePositionAdminUser]
7564
}
7665

7766
access(all)
@@ -150,28 +139,6 @@ fun setup() {
150139
Test.beSucceeded()
151140
)
152141

153-
// ─────────────────────────────────────────────────────────────────────────
154-
// EPosition user — EPosition-ONLY capability (no EParticipant)
155-
// ─────────────────────────────────────────────────────────────────────────
156-
setupMoetVault(ePositionUser, beFailed: false)
157-
mintMoet(signer: PROTOCOL_ACCOUNT, to: ePositionUser.address, amount: 100.0, beFailed: false)
158-
Test.expect(
159-
_execute2Signers(
160-
"../tests/transactions/flow-alp/setup/grant_eposition_cap.cdc",
161-
[],
162-
PROTOCOL_ACCOUNT,
163-
ePositionUser
164-
),
165-
Test.beSucceeded()
166-
)
167-
168-
// ─────────────────────────────────────────────────────────────────────────
169-
// EParticipantPosition user — EParticipant+EPosition capability (current over-grant)
170-
// ─────────────────────────────────────────────────────────────────────────
171-
setupMoetVault(eParticipantPositionUser, beFailed: false)
172-
mintMoet(signer: PROTOCOL_ACCOUNT, to: eParticipantPositionUser.address, amount: 100.0, beFailed: false)
173-
grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, eParticipantPositionUser)
174-
175142
// ─────────────────────────────────────────────────────────────────────────
176143
// ERebalance user — ERebalance-only capability (rebalancer simulation)
177144
// ─────────────────────────────────────────────────────────────────────────
@@ -277,222 +244,6 @@ fun testEParticipant_CreateAndDeposit() {
277244
Test.assertEqual(6.0, creditBalance)
278245
}
279246

280-
// =============================================================================
281-
// EParticipant+EPosition — over-grant (current beta cap via publish_beta_cap.cdc)
282-
// =============================================================================
283-
//
284-
// Actor: eParticipantPositionUser — Capability<auth(EParticipant, EPosition) &Pool>
285-
// Issued by publish_beta_cap.cdc and stored at FlowALPv0.PoolCapStoragePath.
286-
// This is the CURRENT (unfixed) beta cap. EPosition is NOT needed for normal
287-
// user actions; its presence lets this actor perform pool-level position ops
288-
// on ANY position, including positions owned by other accounts.
289-
//
290-
// Matrix rows: createPosition (EParticipant), depositToPosition (EParticipant),
291-
// withdraw [OVERGRANT], withdrawAndPull [OVERGRANT], depositAndPush [OVERGRANT],
292-
// lockPosition [OVERGRANT], unlockPosition [OVERGRANT], rebalancePosition [OVERGRANT],
293-
// rebalance (Position) [OVERGRANT — same entry point as rebalancePosition]
294-
//
295-
// The [OVERGRANT] rows confirm the security issue: a normal beta user can operate on
296-
// positions they do not own (setupPid is owned by PROTOCOL_ACCOUNT).
297-
298-
/// Over-granted beta cap still allows EParticipant operations (createPosition, depositToPosition).
299-
access(all)
300-
fun testEParticipantPosition_CreateAndDeposit() {
301-
safeReset()
302-
303-
let result = _executeTransaction(
304-
"../tests/transactions/flow-alp/eparticipant/create_and_deposit_via_cap.cdc",
305-
[],
306-
eParticipantPositionUser
307-
)
308-
Test.expect(result, Test.beSucceeded())
309-
310-
// Verify position was created and funded: create_and_deposit_via_cap.cdc deposits
311-
// 5.0 MOET (createPosition) + 1.0 MOET (depositToPosition) = 6.0 MOET credit.
312-
let newPid = getLastPositionId()
313-
let creditBalance = getCreditBalanceForType(
314-
details: getPositionDetails(pid: newPid, beFailed: false),
315-
vaultType: Type<@MOET.Vault>()
316-
)
317-
Test.assertEqual(6.0, creditBalance)
318-
}
319-
320-
/// Over-granted beta cap allows Pool.withdraw on ANY position — including
321-
/// setupPid owned by PROTOCOL_ACCOUNT.
322-
access(all)
323-
fun testEParticipantPosition_WithdrawAnyPosition() {
324-
safeReset()
325-
326-
let balanceBefore = getBalance(address: eParticipantPositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
327-
let result = _executeTransaction(
328-
"../tests/transactions/flow-alp/eposition/withdraw_any.cdc",
329-
[setupPid, 1.0],
330-
eParticipantPositionUser
331-
)
332-
Test.expect(result, Test.beSucceeded())
333-
let balanceAfter = getBalance(address: eParticipantPositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
334-
Test.assertEqual(balanceAfter, balanceBefore + 1.0)
335-
}
336-
337-
/// Over-granted beta cap allows Pool.withdrawAndPull on ANY position — including
338-
/// positions owned by other accounts.
339-
access(all)
340-
fun testEParticipantPosition_WithdrawAndPullAnyPosition() {
341-
safeReset()
342-
343-
let balanceBefore = getBalance(address: eParticipantPositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
344-
let result = _executeTransaction(
345-
"../tests/transactions/flow-alp/eposition/withdraw_and_pull_any.cdc",
346-
[setupPid, 1.0],
347-
eParticipantPositionUser
348-
)
349-
Test.expect(result, Test.beSucceeded())
350-
let balanceAfter = getBalance(address: eParticipantPositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
351-
Test.assertEqual(balanceAfter, balanceBefore + 1.0)
352-
}
353-
354-
/// Over-granted beta cap allows Pool.depositAndPush on ANY position — including
355-
/// positions owned by other accounts.
356-
access(all)
357-
fun testEParticipantPosition_DepositAndPushAnyPosition() {
358-
safeReset()
359-
360-
let creditBefore = getCreditBalanceForType(
361-
details: getPositionDetails(pid: setupPid, beFailed: false),
362-
vaultType: Type<@MOET.Vault>()
363-
)
364-
let result = _executeTransaction(
365-
"../tests/transactions/flow-alp/eposition/deposit_and_push_any.cdc",
366-
[setupPid, 1.0],
367-
eParticipantPositionUser
368-
)
369-
Test.expect(result, Test.beSucceeded())
370-
let creditAfter = getCreditBalanceForType(
371-
details: getPositionDetails(pid: setupPid, beFailed: false),
372-
vaultType: Type<@MOET.Vault>()
373-
)
374-
Test.assertEqual(creditBefore + 1.0, creditAfter)
375-
}
376-
377-
/// Over-granted beta cap allows Pool.lockPosition and Pool.unlockPosition on ANY position —
378-
/// including positions owned by other accounts.
379-
access(all)
380-
fun testEParticipantPosition_LockUnlockAnyPosition() {
381-
safeReset()
382-
383-
let result = _executeTransaction(
384-
"../tests/transactions/flow-alp/eposition/lock_any.cdc",
385-
[setupPid],
386-
eParticipantPositionUser
387-
)
388-
Test.expect(result, Test.beSucceeded())
389-
}
390-
391-
/// Over-granted beta cap allows Pool.rebalancePosition on any position.
392-
access(all)
393-
fun testEParticipantPosition_RebalancePosition() {
394-
safeReset()
395-
396-
let result = _executeTransaction(
397-
"../tests/transactions/flow-alp/eposition/rebalance_position_via_cap.cdc",
398-
[setupPid, true],
399-
eParticipantPositionUser
400-
)
401-
Test.expect(result, Test.beSucceeded())
402-
}
403-
404-
// =============================================================================
405-
// EPosition — narrowly-scoped EPosition-only Pool capability
406-
// =============================================================================
407-
//
408-
// Actor: ePositionUser — Capability<auth(EPosition) &Pool>
409-
// Matrix rows: withdraw, withdrawAndPull, depositAndPush, lockPosition, unlockPosition,
410-
// rebalancePosition
411-
412-
/// EPosition cap allows Pool.withdraw on ANY position by ID — including
413-
/// setupPid owned by PROTOCOL_ACCOUNT.
414-
access(all)
415-
fun testEPosition_WithdrawAnyPosition() {
416-
safeReset()
417-
418-
let balanceBefore = getBalance(address: ePositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
419-
let result = _executeTransaction(
420-
"../tests/transactions/flow-alp/eposition/withdraw_any.cdc",
421-
[setupPid, 1.0],
422-
ePositionUser
423-
)
424-
Test.expect(result, Test.beSucceeded())
425-
let balanceAfter = getBalance(address: ePositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
426-
Test.assertEqual(balanceAfter, balanceBefore + 1.0)
427-
}
428-
429-
/// EPosition cap allows Pool.withdrawAndPull on ANY position — including positions
430-
/// owned by other accounts.
431-
access(all)
432-
fun testEPosition_WithdrawAndPullAnyPosition() {
433-
safeReset()
434-
435-
let balanceBefore = getBalance(address: ePositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
436-
let result = _executeTransaction(
437-
"../tests/transactions/flow-alp/eposition/withdraw_and_pull_any.cdc",
438-
[setupPid, 1.0],
439-
ePositionUser
440-
)
441-
Test.expect(result, Test.beSucceeded())
442-
let balanceAfter = getBalance(address: ePositionUser.address, vaultPublicPath: MOET.VaultPublicPath)!
443-
Test.assertEqual(balanceAfter, balanceBefore + 1.0)
444-
}
445-
446-
/// EPosition cap allows Pool.depositAndPush on ANY position — including positions
447-
/// owned by other accounts.
448-
access(all)
449-
fun testEPosition_DepositAndPushAnyPosition() {
450-
safeReset()
451-
452-
let creditBefore = getCreditBalanceForType(
453-
details: getPositionDetails(pid: setupPid, beFailed: false),
454-
vaultType: Type<@MOET.Vault>()
455-
)
456-
let result = _executeTransaction(
457-
"../tests/transactions/flow-alp/eposition/deposit_and_push_any.cdc",
458-
[setupPid, 1.0],
459-
ePositionUser
460-
)
461-
Test.expect(result, Test.beSucceeded())
462-
let creditAfter = getCreditBalanceForType(
463-
details: getPositionDetails(pid: setupPid, beFailed: false),
464-
vaultType: Type<@MOET.Vault>()
465-
)
466-
Test.assertEqual(creditBefore + 1.0, creditAfter)
467-
}
468-
469-
/// EPosition cap allows Pool.lockPosition and Pool.unlockPosition on ANY position —
470-
/// including positions owned by other accounts.
471-
access(all)
472-
fun testEPosition_LockUnlockAnyPosition() {
473-
safeReset()
474-
475-
let result = _executeTransaction(
476-
"../tests/transactions/flow-alp/eposition/lock_any.cdc",
477-
[setupPid],
478-
ePositionUser
479-
)
480-
Test.expect(result, Test.beSucceeded())
481-
}
482-
483-
/// EPosition cap allows Pool.rebalancePosition.
484-
access(all)
485-
fun testEPosition_RebalancePosition() {
486-
safeReset()
487-
488-
let result = _executeTransaction(
489-
"../tests/transactions/flow-alp/eposition/rebalance_position_via_cap.cdc",
490-
[setupPid, true],
491-
ePositionUser
492-
)
493-
Test.expect(result, Test.beSucceeded())
494-
}
495-
496247
// =============================================================================
497248
// ERebalance — narrowly-scoped rebalancer capability
498249
// =============================================================================

cadence/tests/contracts/AdversarialReentrancyConnectors.cdc

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import "FungibleTokenMetadataViews"
44
import "DeFiActionsUtils"
55
import "DeFiActions"
66
import "FlowALPv0"
7+
import "FlowALPPositionResources"
78
import "FlowALPModels"
89

910
import "MOET"
@@ -107,17 +108,18 @@ access(all) contract AdversarialReentrancyConnectors {
107108
}
108109

109110
access(all) resource LiveData {
110-
/// Optional: Pool capability for recursive withdrawAndPull call
111-
access(all) var recursivePool: Capability<auth(FlowALPModels.EPosition) &FlowALPv0.Pool>?
112-
/// Optional: Position ID for recursive withdrawAndPull call
111+
/// Capability to the attacker's PositionManager for recursive withdrawal
112+
access(all) var positionManagerCap: Capability<auth(FungibleToken.Withdraw, FlowALPModels.EPositionAdmin) &FlowALPPositionResources.PositionManager>?
113+
/// Position ID for recursive withdrawal
113114
access(all) var recursivePositionID: UInt64?
114115

115-
init() { self.recursivePositionID = nil; self.recursivePool = nil }
116-
access(all) fun setRecursivePool(_ pool: Capability<auth(FlowALPModels.EPosition) &FlowALPv0.Pool>) {
117-
self.recursivePool = pool
118-
}
119-
access(all) fun setRecursivePositionID(_ positionID: UInt64) {
120-
self.recursivePositionID = positionID
116+
init() { self.recursivePositionID = nil; self.positionManagerCap = nil }
117+
access(all) fun setRecursivePosition(
118+
managerCap: Capability<auth(FungibleToken.Withdraw, FlowALPModels.EPositionAdmin) &FlowALPPositionResources.PositionManager>,
119+
pid: UInt64
120+
) {
121+
self.positionManagerCap = managerCap
122+
self.recursivePositionID = pid
121123
}
122124
}
123125
access(all) fun createLiveData(): @LiveData {
@@ -203,27 +205,18 @@ access(all) contract AdversarialReentrancyConnectors {
203205
access(FungibleToken.Withdraw) fun withdrawAvailable(maxAmount: UFix64): @{FungibleToken.Vault} {
204206
// If recursive withdrawAndPull is configured, call it first
205207
log("VaultSource.withdrawAvailable called with maxAmount: \(maxAmount)")
206-
log("=====Recursive pool: \(self.liveDataCap.check())")
208+
log("=====Recursive position manager: \(self.liveDataCap.check())")
207209
let liveData = self.liveDataCap.borrow() ?? panic("cant borrow LiveData")
208-
let poolRef = liveData.recursivePool!.borrow() ?? panic("cant borrow Recursive pool is nil")
209-
// Call withdrawAndPull on the position
210-
let recursiveVault <- poolRef.withdrawAndPull(
211-
pid: liveData.recursivePositionID!,
212-
// type: Type<@MOET.Vault>(),
210+
let manager = liveData.positionManagerCap!.borrow() ?? panic("cant borrow PositionManager")
211+
let position = manager.borrowAuthorizedPosition(pid: liveData.recursivePositionID!)
212+
// Attempt reentrant withdrawal via Position (should fail due to position lock)
213+
let recursiveVault <- position.withdraw(
213214
type: Type<@FlowToken.Vault>(),
214-
// type: tokenType,
215-
amount: 900.0,
216-
pullFromTopUpSource: false
215+
amount: 900.0
217216
)
218-
log("Recursive withdrawAndPull returned vault with balance: \(recursiveVault.balance)")
219-
// If we got funds from the recursive call, return them
220-
if recursiveVault.balance > 0.0 {
221-
return <-recursiveVault
222-
}
223-
// Otherwise, destroy the empty vault and continue with normal withdrawal
217+
log("Recursive withdraw succeeded with balance: \(recursiveVault.balance) (should not reach here)")
224218
destroy recursiveVault
225219

226-
227220
// Normal vault withdrawal
228221
let available = self.minimumAvailable()
229222
if !self.withdrawVault.check() || available == 0.0 || maxAmount == 0.0 {

cadence/tests/paid_auto_balance_test.cdc

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,15 @@ access(all) fun test_supervisor_executed() {
316316
/// fixReschedule(uuid:) force-unwrapped borrowRebalancer(uuid)! which panicked on a stale UUID,
317317
/// reverting the whole executeTransaction and blocking recovery for all other rebalancers.
318318
access(all) fun test_supervisor_stale_uuid_does_not_panic() {
319+
// Let the initial cron tick fire first (supervisor set is empty, so it does nothing
320+
// except emit Executed). This avoids a race where the cron fires during the add/delete
321+
// transactions below before the stale state is set up.
322+
Test.moveTime(by: 100.0)
323+
Test.commitBlock()
324+
325+
let initialExecutedEvts = Test.eventsOfType(Type<FlowALPSupervisorv1.Executed>())
326+
Test.assert(initialExecutedEvts.length >= 1, message: "Initial cron tick should have fired")
327+
319328
// Get the UUID of the paid rebalancer created during setup.
320329
let createdEvts = Test.eventsOfType(Type<FlowALPRebalancerv1.CreatedRebalancer>())
321330
Test.assertEqual(1, createdEvts.length)
@@ -328,14 +337,14 @@ access(all) fun test_supervisor_stale_uuid_does_not_panic() {
328337
// stale UUID in the Supervisor's paidRebalancers set, simulating the FLO-27 bug scenario.
329338
deletePaidRebalancer(signer: userAccount, paidRebalancerStoragePath: paidRebalancerStoragePath)
330339

331-
// Advance time to trigger the Supervisor's scheduled tick.
340+
// Advance time to trigger the next Supervisor tick.
332341
Test.moveTime(by: 60.0 * 60.0)
333342
Test.commitBlock()
334343

335344
// The Supervisor must have executed without panicking. If fixReschedule force-unwrapped
336345
// the missing rebalancer the entire transaction would revert and Executed would not be emitted.
337346
let executedEvts = Test.eventsOfType(Type<FlowALPSupervisorv1.Executed>())
338-
Test.assert(executedEvts.length >= 1, message: "Supervisor should have executed at least 1 time")
347+
Test.assert(executedEvts.length >= 2, message: "Supervisor should have executed at least 2 times (initial + stale prune)")
339348

340349
// The stale UUID must have been pruned from the Supervisor's set.
341350
let removedEvts = Test.eventsOfType(Type<FlowALPSupervisorv1.RemovedPaidRebalancer>())

0 commit comments

Comments
 (0)