From e63afacb6aa86bba8e27d81ed526c86cf799a7ed Mon Sep 17 00:00:00 2001 From: Illia Malachyn Date: Thu, 26 Mar 2026 16:23:12 +0200 Subject: [PATCH 1/4] Add tryGetPositionDetails for non-panicking position lookups getPositionDetails panics when called with a closed/non-existent position ID. This is problematic for batch queries (get_positions_by_ids.cdc) - one stale ID causes the entire batch to fail. Refactor _borrowPosition to delegate to a new _tryBorrowPosition that returns an optional reference instead of panicking. Add tryGetPositionDetails that guards on existence before delegating to getPositionDetails. Update get_positions_by_ids.cdc to use tryGetPositionDetails so closed positions are silently skipped instead of failing the batch. --- cadence/contracts/FlowALPv0.cdc | 27 +++++++- .../scripts/flow-alp/get_positions_by_ids.cdc | 6 +- .../flow-alp/try_get_position_details.cdc | 11 ++++ cadence/tests/get_positions_by_ids_test.cdc | 16 +++++ cadence/tests/test_helpers.cdc | 10 +++ .../tests/try_get_position_details_test.cdc | 66 +++++++++++++++++++ 6 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 cadence/scripts/flow-alp/try_get_position_details.cdc create mode 100644 cadence/tests/try_get_position_details_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index c53b2c44..b30ae007 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1934,6 +1934,24 @@ access(all) contract FlowALPv0 { ) } + /// Returns the details of a given position, or nil if the position does not exist. + /// This is the non-panicking variant of getPositionDetails. + /// + /// Safe to guard-then-delegate because Cadence script execution is atomic — + /// a position cannot be removed between the existence check and getPositionDetails. + access(all) fun tryGetPositionDetails(pid: UInt64): PositionDetails? { + if self.debugLogging { + log(" [CONTRACT] tryGetPositionDetails(pid: \(pid))") + } + + if self._tryBorrowPosition(pid: pid) == nil { + return nil + } + + return self.getPositionDetails(pid: pid) + } + + /// Any external party can perform a manual liquidation on a position under the following circumstances: /// - the position has health < 1 /// - the liquidation price offered is better than what is available on a DEX @@ -4079,9 +4097,14 @@ access(all) contract FlowALPv0 { } } - /// Returns an authorized reference to the requested InternalPosition or `nil` if the position does not exist - access(self) view fun _borrowPosition(pid: UInt64): auth(EImplementation) &InternalPosition { + /// Returns an authorized reference to the requested InternalPosition, or nil if it does not exist. + access(self) view fun _tryBorrowPosition(pid: UInt64): (auth(EImplementation) &InternalPosition)? { return &self.positions[pid] as auth(EImplementation) &InternalPosition? + } + + /// Returns an authorized reference to the requested InternalPosition or panics if the position does not exist. + access(self) view fun _borrowPosition(pid: UInt64): auth(EImplementation) &InternalPosition { + return self._tryBorrowPosition(pid: pid) ?? panic("Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool") } diff --git a/cadence/scripts/flow-alp/get_positions_by_ids.cdc b/cadence/scripts/flow-alp/get_positions_by_ids.cdc index ce4ca48f..5a1651c5 100644 --- a/cadence/scripts/flow-alp/get_positions_by_ids.cdc +++ b/cadence/scripts/flow-alp/get_positions_by_ids.cdc @@ -1,4 +1,6 @@ // Returns the details of multiple positions by their IDs. +// Positions that no longer exist (e.g., closed between fetching IDs and executing this script) +// are silently skipped. import "FlowALPv0" access(all) fun main(positionIDs: [UInt64]): [FlowALPv0.PositionDetails] { @@ -9,7 +11,9 @@ access(all) fun main(positionIDs: [UInt64]): [FlowALPv0.PositionDetails] { let details: [FlowALPv0.PositionDetails] = [] for id in positionIDs { - details.append(pool.getPositionDetails(pid: id)) + if let detail = pool.tryGetPositionDetails(pid: id) { + details.append(detail) + } } return details } diff --git a/cadence/scripts/flow-alp/try_get_position_details.cdc b/cadence/scripts/flow-alp/try_get_position_details.cdc new file mode 100644 index 00000000..f8b96807 --- /dev/null +++ b/cadence/scripts/flow-alp/try_get_position_details.cdc @@ -0,0 +1,11 @@ +// Returns the details of a position by its ID, or nil if the position does not exist. +import "FlowALPv0" + +access(all) fun main(positionID: UInt64): FlowALPv0.PositionDetails? { + let protocolAddress = Type<@FlowALPv0.Pool>().address! + let account = getAccount(protocolAddress) + let pool = account.capabilities.borrow<&FlowALPv0.Pool>(FlowALPv0.PoolPublicPath) + ?? panic("Could not find Pool at path \(FlowALPv0.PoolPublicPath)") + + return pool.tryGetPositionDetails(pid: positionID) +} diff --git a/cadence/tests/get_positions_by_ids_test.cdc b/cadence/tests/get_positions_by_ids_test.cdc index 4648f3bc..cb2b8ef5 100644 --- a/cadence/tests/get_positions_by_ids_test.cdc +++ b/cadence/tests/get_positions_by_ids_test.cdc @@ -65,4 +65,20 @@ fun test_getPositionsByIDs() { let singleDetails = getPositionsByIDs(positionIDs: [UInt64(0)]) Test.assertEqual(1, singleDetails.length) Test.assertEqual(details0.health, singleDetails[0].health) + + // --- Closed positions are silently skipped --- + // Close position 1, then request both IDs — should only return position 0 + closePosition(user: user, positionID: 1) + let afterClose = getPositionsByIDs(positionIDs: [UInt64(0), UInt64(1)]) + Test.assertEqual(1, afterClose.length) + Test.assertEqual(details0.health, afterClose[0].health) + + // --- All IDs closed/invalid returns empty array --- + closePosition(user: user, positionID: 0) + let allClosed = getPositionsByIDs(positionIDs: [UInt64(0), UInt64(1)]) + Test.assertEqual(0, allClosed.length) + + // --- Non-existent IDs are skipped --- + let nonExistent = getPositionsByIDs(positionIDs: [UInt64(999), UInt64(1000)]) + Test.assertEqual(0, nonExistent.length) } diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 6afe0cfa..3a2ac830 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -810,6 +810,16 @@ fun getPositionsByIDs(positionIDs: [UInt64]): [FlowALPv0.PositionDetails] { return res.returnValue as! [FlowALPv0.PositionDetails] } +access(all) +fun tryGetPositionDetails(pid: UInt64): FlowALPv0.PositionDetails? { + let res = _executeScript( + "../scripts/flow-alp/try_get_position_details.cdc", + [pid] + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue as! FlowALPv0.PositionDetails? +} + access(all) fun closePosition(user: Test.TestAccount, positionID: UInt64) { let res = _executeTransaction( diff --git a/cadence/tests/try_get_position_details_test.cdc b/cadence/tests/try_get_position_details_test.cdc new file mode 100644 index 00000000..4ba896fe --- /dev/null +++ b/cadence/tests/try_get_position_details_test.cdc @@ -0,0 +1,66 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPv0" +import "test_helpers.cdc" + +// ----------------------------------------------------------------------------- +// tryGetPositionDetails Test +// +// Verifies that Pool.tryGetPositionDetails() returns position details for +// existing positions and nil for non-existent or closed positions. +// ----------------------------------------------------------------------------- + +access(all) +fun setup() { + deployContracts() +} + +// ============================================================================= +// Test: tryGetPositionDetails returns details for open positions and nil otherwise +// ============================================================================= +access(all) +fun test_tryGetPositionDetails() { + // --- Setup --- + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + + // --- Non-existent position returns nil --- + let nonExistent = tryGetPositionDetails(pid: 0) + Test.assertEqual(nil, nonExistent) + + // --- Open a position --- + createPosition(signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + // --- Existing position returns details --- + let details = tryGetPositionDetails(pid: 0) + Test.assert(details != nil, message: "Expected non-nil details for open position") + + // --- Result matches getPositionDetails --- + let expected = getPositionDetails(pid: 0, beFailed: false) + Test.assertEqual(expected.health, details!.health) + Test.assertEqual(expected.balances.length, details!.balances.length) + + // --- Still nil for non-existent ID --- + let stillNil = tryGetPositionDetails(pid: 999) + Test.assertEqual(nil, stillNil) + + // --- Close the position, should return nil --- + closePosition(user: user, positionID: 0) + let afterClose = tryGetPositionDetails(pid: 0) + Test.assertEqual(nil, afterClose) +} From 1623f0e9494c4e25194e4decd178ed6419760c3d Mon Sep 17 00:00:00 2001 From: Illia Malachyn Date: Thu, 26 Mar 2026 16:50:33 +0200 Subject: [PATCH 2/4] remove useless comment --- cadence/contracts/FlowALPv0.cdc | 3 --- 1 file changed, 3 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index b30ae007..3ef686cc 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1936,9 +1936,6 @@ access(all) contract FlowALPv0 { /// Returns the details of a given position, or nil if the position does not exist. /// This is the non-panicking variant of getPositionDetails. - /// - /// Safe to guard-then-delegate because Cadence script execution is atomic — - /// a position cannot be removed between the existence check and getPositionDetails. access(all) fun tryGetPositionDetails(pid: UInt64): PositionDetails? { if self.debugLogging { log(" [CONTRACT] tryGetPositionDetails(pid: \(pid))") From 57cefd36b32cf196370bf5f2d2285c5d0510f83a Mon Sep 17 00:00:00 2001 From: Illia Malachyn Date: Fri, 27 Mar 2026 15:55:10 +0200 Subject: [PATCH 3/4] add id field to PositionDetails struct --- cadence/contracts/FlowALPv0.cdc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 3ef686cc..4e427e20 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1927,6 +1927,7 @@ access(all) contract FlowALPv0 { ) return PositionDetails( + id: pid, balances: balances, poolDefaultToken: self.defaultToken, defaultTokenAvailableBalance: defaultTokenAvailable, @@ -4782,6 +4783,9 @@ access(all) contract FlowALPv0 { /// This structure is NOT used internally. access(all) struct PositionDetails { + /// The unique identifier of the position + access(all) let id: UInt64 + /// Balance details about each Vault Type deposited to the related Position access(all) let balances: [PositionBalance] @@ -4795,11 +4799,13 @@ access(all) contract FlowALPv0 { access(all) let health: UFix128 init( + id: UInt64, balances: [PositionBalance], poolDefaultToken: Type, defaultTokenAvailableBalance: UFix64, health: UFix128 ) { + self.id = id self.balances = balances self.poolDefaultToken = poolDefaultToken self.defaultTokenAvailableBalance = defaultTokenAvailableBalance From a2f3fd6006801efd57b9903acc9a9e6dfe48f8ad Mon Sep 17 00:00:00 2001 From: Illia Malachyn Date: Tue, 31 Mar 2026 13:02:37 +0300 Subject: [PATCH 4/4] update FlowActions submodule --- FlowActions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlowActions b/FlowActions index b9c308a8..2953e7d6 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit b9c308a801df618e78d48c02ea4bc06b5f5dcbc9 +Subproject commit 2953e7d69d6c1573bcff207bda1ff5aa4d7f905d