diff --git a/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md b/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md index 74bf117..87b3822 100644 --- a/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md +++ b/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md @@ -547,7 +547,8 @@ function getPendingRequestIds() returns (uint256[] memory); // Get pending requests in unpacked arrays (pagination) function getPendingRequestsUnpacked(uint256 startIndex, uint256 count) returns (...); -// Get pending requests for a user in unpacked arrays (includes native FLOW balances) +// Get pending requests for a user in unpacked arrays (includes NATIVE_FLOW / WFLOW escrow+refund balances) +// Returns (..., address[] memory balanceTokens, uint256[] memory pendingBalances, uint256[] memory claimableRefundsArray) function getPendingRequestsByUserUnpacked(address user) returns (...); // Get single request by ID diff --git a/FRONTEND_INTEGRATION.md b/FRONTEND_INTEGRATION.md index 97026e7..101c28f 100644 --- a/FRONTEND_INTEGRATION.md +++ b/FRONTEND_INTEGRATION.md @@ -179,12 +179,19 @@ const [ messages, vaultIdentifiers, strategyIdentifiers, - pendingBalance, - claimableRefund, + balanceTokens, + pendingBalances, + claimableRefunds, ] = await contract.getPendingRequestsByUserUnpacked(userAddress); -// pendingBalance = escrowed funds for active pending requests (native FLOW only) -// claimableRefund = funds available to claim via claimRefund() (native FLOW only) -// Use getUserPendingBalance/getClaimableRefund for a specific token + +// `balanceTokens` will contain `[NATIVE_FLOW, WFLOW]` when WFLOW is configured (otherwise `[NATIVE_FLOW]`). +// The balance arrays are aligned by index. +const pendingByToken = Object.fromEntries( + balanceTokens.map((token, i) => [token, pendingBalances[i]]) +); +const claimableByToken = Object.fromEntries( + balanceTokens.map((token, i) => [token, claimableRefunds[i]]) +); ``` #### Get All Pending Requests (Paginated, Admin) diff --git a/cadence/contracts/FlowYieldVaultsEVM.cdc b/cadence/contracts/FlowYieldVaultsEVM.cdc index 5611b57..bfa0458 100644 --- a/cadence/contracts/FlowYieldVaultsEVM.cdc +++ b/cadence/contracts/FlowYieldVaultsEVM.cdc @@ -132,15 +132,17 @@ access(all) contract FlowYieldVaultsEVM { access(all) struct PendingRequestsInfo { access(all) let evmAddress: String access(all) let pendingCount: Int - access(all) let pendingBalance: UFix64 - access(all) let claimableRefund: UFix64 + /// @notice Pending balances per token address (maps token address string -> UFix64 balance) + access(all) let pendingBalances: {String: UFix64} + /// @notice Claimable refunds per token address (maps token address string -> UFix64 balance) + access(all) let claimableRefunds: {String: UFix64} access(all) let requests: [EVMRequest] - init(evmAddress: String, pendingCount: Int, pendingBalance: UFix64, claimableRefund: UFix64, requests: [EVMRequest]) { + init(evmAddress: String, pendingCount: Int, pendingBalances: {String: UFix64}, claimableRefunds: {String: UFix64}, requests: [EVMRequest]) { self.evmAddress = evmAddress self.pendingCount = pendingCount - self.pendingBalance = pendingBalance - self.claimableRefund = claimableRefund + self.pendingBalances = pendingBalances + self.claimableRefunds = claimableRefunds self.requests = requests } } @@ -1682,8 +1684,9 @@ access(all) contract FlowYieldVaultsEVM { Type<[String]>(), // messages Type<[String]>(), // vaultIdentifiers Type<[String]>(), // strategyIdentifiers - Type(), // pendingBalance - Type() // claimableRefund + Type<[EVM.EVMAddress]>(), // balanceTokens + Type<[UInt256]>(), // pendingBalances + Type<[UInt256]>() // claimableRefundsArray ], data: callResult.data ) @@ -1698,12 +1701,32 @@ access(all) contract FlowYieldVaultsEVM { let messages = decoded[7] as! [String] let vaultIdentifiers = decoded[8] as! [String] let strategyIdentifiers = decoded[9] as! [String] - let pendingBalanceRaw = decoded[10] as! UInt256 - let claimableRefundRaw = decoded[11] as! UInt256 + let balanceTokens = decoded[10] as! [EVM.EVMAddress] + let pendingBalancesRaw = decoded[11] as! [UInt256] + let claimableRefundsRaw = decoded[12] as! [UInt256] - // Convert pending balance from wei to UFix64 - let pendingBalance = FlowEVMBridgeUtils.uint256ToUFix64(value: pendingBalanceRaw, decimals: 18) - let claimableRefund = FlowEVMBridgeUtils.uint256ToUFix64(value: claimableRefundRaw, decimals: 18) + assert( + balanceTokens.length == pendingBalancesRaw.length + && balanceTokens.length == claimableRefundsRaw.length, + message: "Balance array length mismatch in ABI decode" + ) + + // Build per-token balance dictionaries + var pendingBalances: {String: UFix64} = {} + var claimableRefundsMap: {String: UFix64} = {} + var j = 0 + while j < balanceTokens.length { + let tokenAddr = balanceTokens[j].toString() + pendingBalances[tokenAddr] = FlowYieldVaultsEVM.ufix64FromUInt256( + pendingBalancesRaw[j], + tokenAddress: balanceTokens[j] + ) + claimableRefundsMap[tokenAddr] = FlowYieldVaultsEVM.ufix64FromUInt256( + claimableRefundsRaw[j], + tokenAddress: balanceTokens[j] + ) + j = j + 1 + } // Build request array var requests: [EVMRequest] = [] @@ -1729,8 +1752,8 @@ access(all) contract FlowYieldVaultsEVM { return PendingRequestsInfo( evmAddress: evmAddressHex, pendingCount: ids.length, - pendingBalance: pendingBalance, - claimableRefund: claimableRefund, + pendingBalances: pendingBalances, + claimableRefunds: claimableRefundsMap, requests: requests ) } diff --git a/deployments/artifacts/FlowYieldVaultsRequests.json b/deployments/artifacts/FlowYieldVaultsRequests.json index 88f282c..9f8244b 100644 --- a/deployments/artifacts/FlowYieldVaultsRequests.json +++ b/deployments/artifacts/FlowYieldVaultsRequests.json @@ -532,14 +532,19 @@ "internalType": "string[]" }, { - "name": "pendingBalance", - "type": "uint256", - "internalType": "uint256" + "name": "balanceTokens", + "type": "address[]", + "internalType": "address[]" }, { - "name": "claimableRefund", - "type": "uint256", - "internalType": "uint256" + "name": "pendingBalances", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "claimableRefundsArray", + "type": "uint256[]", + "internalType": "uint256[]" } ], "stateMutability": "view" diff --git a/solidity/deployments/artifacts/FlowYieldVaultsRequests.json b/solidity/deployments/artifacts/FlowYieldVaultsRequests.json index 88f282c..9f8244b 100644 --- a/solidity/deployments/artifacts/FlowYieldVaultsRequests.json +++ b/solidity/deployments/artifacts/FlowYieldVaultsRequests.json @@ -532,14 +532,19 @@ "internalType": "string[]" }, { - "name": "pendingBalance", - "type": "uint256", - "internalType": "uint256" + "name": "balanceTokens", + "type": "address[]", + "internalType": "address[]" }, { - "name": "claimableRefund", - "type": "uint256", - "internalType": "uint256" + "name": "pendingBalances", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "claimableRefundsArray", + "type": "uint256[]", + "internalType": "uint256[]" } ], "stateMutability": "view" diff --git a/solidity/src/FlowYieldVaultsRequests.sol b/solidity/src/FlowYieldVaultsRequests.sol index 7b4bef1..fd9a8ca 100644 --- a/solidity/src/FlowYieldVaultsRequests.sol +++ b/solidity/src/FlowYieldVaultsRequests.sol @@ -1274,8 +1274,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @return messages Messages /// @return vaultIdentifiers Vault identifiers /// @return strategyIdentifiers Strategy identifiers - /// @return pendingBalance Escrowed balance for active pending requests (native FLOW only) - /// @return claimableRefund Claimable refund amount (native FLOW only) + /// @return balanceTokens Token addresses for balance arrays (NATIVE_FLOW and, when configured, WFLOW) + /// @return pendingBalances Escrowed balances for active pending requests per token + /// @return claimableRefundsArray Claimable refund amounts per token function getPendingRequestsByUserUnpacked( address user ) @@ -1292,8 +1293,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { string[] memory messages, string[] memory vaultIdentifiers, string[] memory strategyIdentifiers, - uint256 pendingBalance, - uint256 claimableRefund + address[] memory balanceTokens, + uint256[] memory pendingBalances, + uint256[] memory claimableRefundsArray ) { // Use the user's pending request IDs directly (O(1) lookup) @@ -1330,9 +1332,23 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { } } - // Get balances for native FLOW - pendingBalance = pendingUserBalances[user][NATIVE_FLOW]; - claimableRefund = claimableRefunds[user][NATIVE_FLOW]; + // Get balances for NATIVE_FLOW and, when configured, WFLOW + uint256 tokenCount = WFLOW != address(0) ? 2 : 1; + balanceTokens = new address[](tokenCount); + pendingBalances = new uint256[](tokenCount); + claimableRefundsArray = new uint256[](tokenCount); + + // Native FLOW balances + balanceTokens[0] = NATIVE_FLOW; + pendingBalances[0] = pendingUserBalances[user][NATIVE_FLOW]; + claimableRefundsArray[0] = claimableRefunds[user][NATIVE_FLOW]; + + // WFLOW balances (if configured) + if (WFLOW != address(0)) { + balanceTokens[1] = WFLOW; + pendingBalances[1] = pendingUserBalances[user][WFLOW]; + claimableRefundsArray[1] = claimableRefunds[user][WFLOW]; + } } // ============================================ diff --git a/solidity/test/FlowYieldVaultsRequests.t.sol b/solidity/test/FlowYieldVaultsRequests.t.sol index a7fff0a..e16a660 100644 --- a/solidity/test/FlowYieldVaultsRequests.t.sol +++ b/solidity/test/FlowYieldVaultsRequests.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; import "../src/FlowYieldVaultsRequests.sol"; contract FlowYieldVaultsRequestsTestHelper is FlowYieldVaultsRequests { @@ -20,11 +21,12 @@ contract FlowYieldVaultsRequestsTestHelper is FlowYieldVaultsRequests { contract FlowYieldVaultsRequestsTest is Test { FlowYieldVaultsRequestsTestHelper public c; + ERC20Mock public wflow; address user = makeAddr("user"); address user2 = makeAddr("user2"); address coa = makeAddr("coa"); address constant NATIVE_FLOW = 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF; - address constant WFLOW = 0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e; + address WFLOW; // Events for testing (from OpenZeppelin Ownable2Step) event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); @@ -44,6 +46,10 @@ contract FlowYieldVaultsRequestsTest is Test { function setUp() public { vm.deal(user, 100 ether); vm.deal(user2, 100 ether); + wflow = new ERC20Mock(); + WFLOW = address(wflow); + wflow.mint(user, 100 ether); + wflow.mint(user2, 100 ether); c = new FlowYieldVaultsRequestsTestHelper(coa, WFLOW); c.testRegisterYieldVaultId(42, user, NATIVE_FLOW); } @@ -988,7 +994,8 @@ contract FlowYieldVaultsRequestsTest is Test { string[] memory messages, string[] memory vaultIdentifiers, string[] memory strategyIdentifiers, - uint256 pendingBalance, + address[] memory balanceTokens, + uint256[] memory pendingBalances, ) = c.getPendingRequestsByUserUnpacked(user); assertEq(ids.length, 2, "User should have 2 pending requests"); @@ -998,7 +1005,70 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(requestTypes[1], uint8(FlowYieldVaultsRequests.RequestType.DEPOSIT_TO_YIELDVAULT)); assertEq(amounts[0], 1 ether); assertEq(amounts[1], 2 ether); - assertEq(pendingBalance, 3 ether, "Pending balance should be sum of amounts"); + assertEq(balanceTokens[0], NATIVE_FLOW, "First balance token should be NATIVE_FLOW"); + assertEq(pendingBalances[0], 3 ether, "Pending balance should be sum of amounts"); + } + + function test_GetPendingRequestsByUserUnpacked_WFLOWBalances() public { + vm.startPrank(user); + wflow.approve(address(c), 2 ether); + + uint256 reqId = c.createYieldVault(WFLOW, 2 ether, VAULT_ID, STRATEGY_ID); + + ( + uint256[] memory ids, + , + , + , + , + , + , + , + , + , + address[] memory balanceTokens, + uint256[] memory pendingBalances, + uint256[] memory claimableRefundsArr + ) = c.getPendingRequestsByUserUnpacked(user); + + assertEq(ids.length, 1, "User should have 1 pending request"); + assertEq(ids[0], reqId, "Pending request should be the WFLOW create request"); + assertEq(balanceTokens.length, 2, "Should return NATIVE_FLOW and WFLOW balances"); + assertEq(balanceTokens[0], NATIVE_FLOW, "First balance token should be NATIVE_FLOW"); + assertEq(balanceTokens[1], WFLOW, "Second balance token should be WFLOW"); + assertEq(pendingBalances[0], 0, "NATIVE_FLOW pending balance should be 0"); + assertEq(pendingBalances[1], 2 ether, "WFLOW pending balance should match request amount"); + assertEq(claimableRefundsArr[0], 0, "NATIVE_FLOW refund balance should be 0"); + assertEq(claimableRefundsArr[1], 0, "WFLOW refund balance should be 0 before cancellation"); + assertEq(c.getUserPendingBalance(user, WFLOW), pendingBalances[1], "Direct WFLOW getter should match unpacked view"); + assertEq(c.getClaimableRefund(user, WFLOW), claimableRefundsArr[1], "Direct WFLOW refund getter should match unpacked view"); + + c.cancelRequest(reqId); + + ( + uint256[] memory idsAfterCancel, + , + , + , + , + , + , + , + , + , + address[] memory balanceTokensAfterCancel, + uint256[] memory pendingBalancesAfterCancel, + uint256[] memory claimableRefundsAfterCancel + ) = c.getPendingRequestsByUserUnpacked(user); + + assertEq(idsAfterCancel.length, 0, "Cancelled WFLOW request should no longer be pending"); + assertEq(balanceTokensAfterCancel.length, 2, "Token balance slots should remain stable"); + assertEq(balanceTokensAfterCancel[1], WFLOW, "Second balance token should remain WFLOW"); + assertEq(pendingBalancesAfterCancel[1], 0, "WFLOW pending balance should be cleared after cancellation"); + assertEq(claimableRefundsAfterCancel[1], 2 ether, "WFLOW refund balance should match cancelled amount"); + assertEq(c.getUserPendingBalance(user, WFLOW), 0, "Direct WFLOW pending getter should be cleared after cancellation"); + assertEq(c.getClaimableRefund(user, WFLOW), 2 ether, "Direct WFLOW refund getter should match cancelled amount"); + vm.stopPrank(); } function test_GetPendingRequestsByUserUnpacked_MultipleUsers() public { @@ -1012,23 +1082,23 @@ contract FlowYieldVaultsRequestsTest is Test { c.createYieldVault{value: 3 ether}(NATIVE_FLOW, 3 ether, VAULT_ID, STRATEGY_ID); // Check user's requests - (uint256[] memory userIds, , , , , , , , , , uint256 userBalance, ) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory userIds, , , , , , , , , , , uint256[] memory userBalances, ) = c.getPendingRequestsByUserUnpacked(user); assertEq(userIds.length, 2, "User should have 2 requests"); - assertEq(userBalance, 4 ether, "User pending balance should be 4 ether"); + assertEq(userBalances[0], 4 ether, "User pending balance should be 4 ether"); // Check user2's requests - (uint256[] memory user2Ids, , , , , , , , , , uint256 user2Balance, ) = c.getPendingRequestsByUserUnpacked(user2); + (uint256[] memory user2Ids, , , , , , , , , , , uint256[] memory user2Balances, ) = c.getPendingRequestsByUserUnpacked(user2); assertEq(user2Ids.length, 1, "User2 should have 1 request"); - assertEq(user2Balance, 2 ether, "User2 pending balance should be 2 ether"); + assertEq(user2Balances[0], 2 ether, "User2 pending balance should be 2 ether"); } function test_GetPendingRequestsByUserUnpacked_EmptyForNewUser() public { address newUser = makeAddr("newUser"); - (uint256[] memory ids, , , , , , , , , , uint256 pendingBalance, ) = c.getPendingRequestsByUserUnpacked(newUser); + (uint256[] memory ids, , , , , , , , , , , uint256[] memory pendingBalances, ) = c.getPendingRequestsByUserUnpacked(newUser); assertEq(ids.length, 0, "New user should have no pending requests"); - assertEq(pendingBalance, 0, "New user should have 0 pending balance"); + assertEq(pendingBalances[0], 0, "New user should have 0 pending balance"); } function test_GetPendingRequestsByUserUnpacked_AfterProcessing() public { @@ -1045,13 +1115,13 @@ contract FlowYieldVaultsRequestsTest is Test { vm.stopPrank(); // User should now have req1 and req3 - (uint256[] memory ids, , , , uint256[] memory amounts, , , , , , uint256 pendingBalance, ) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory ids, , , , uint256[] memory amounts, , , , , , , uint256[] memory pendingBalances, ) = c.getPendingRequestsByUserUnpacked(user); assertEq(ids.length, 2, "User should have 2 remaining requests"); // Note: Order in user array may change due to swap-and-pop optimization assertTrue(ids[0] == req1 || ids[0] == req3, "Should contain req1 or req3"); assertTrue(ids[1] == req1 || ids[1] == req3, "Should contain req1 or req3"); assertTrue(ids[0] != ids[1], "Should be different requests"); - assertEq(pendingBalance, 4 ether, "Pending balance should be 4 ether"); + assertEq(pendingBalances[0], 4 ether, "Pending balance should be 4 ether"); } function test_GetPendingRequestsByUserUnpacked_AfterCancel() public { @@ -1063,13 +1133,13 @@ contract FlowYieldVaultsRequestsTest is Test { c.cancelRequest(req1); vm.stopPrank(); - (uint256[] memory ids, , , , , , , , , , uint256 pendingBalance, uint256 claimableRefund) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory ids, , , , , , , , , , , uint256[] memory pendingBalances, uint256[] memory claimableRefundsArr) = c.getPendingRequestsByUserUnpacked(user); assertEq(ids.length, 1, "User should have 1 remaining request"); assertEq(ids[0], req2, "Remaining request should be req2"); // pendingBalance = 2 ether (escrowed for req2 only) // claimableRefund = 1 ether (from cancelled req1) - assertEq(pendingBalance, 2 ether); - assertEq(claimableRefund, 1 ether); + assertEq(pendingBalances[0], 2 ether); + assertEq(claimableRefundsArr[0], 1 ether); } // ============================================ @@ -1103,16 +1173,16 @@ contract FlowYieldVaultsRequestsTest is Test { vm.stopPrank(); // Verify user1's remaining requests - (uint256[] memory u1Ids, , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory u1Ids, , , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user); assertEq(u1Ids.length, 2, "User1 should have 2 requests"); // Verify user2's requests unchanged - (uint256[] memory u2Ids, , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user2); + (uint256[] memory u2Ids, , , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user2); assertEq(u2Ids.length, 1, "User2 should have 1 request"); assertEq(u2Ids[0], u2r1); // Verify user3's requests unchanged - (uint256[] memory u3Ids, , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user3); + (uint256[] memory u3Ids, , , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user3); assertEq(u3Ids.length, 1, "User3 should have 1 request"); assertEq(u3Ids[0], u3r1); } @@ -1130,9 +1200,9 @@ contract FlowYieldVaultsRequestsTest is Test { c.completeProcessing(req2, true, 101, "Created"); vm.stopPrank(); - (uint256[] memory ids, , , , , , , , , , uint256 pendingBalance, ) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory ids, , , , , , , , , , , uint256[] memory pendingBalances, ) = c.getPendingRequestsByUserUnpacked(user); assertEq(ids.length, 0, "User should have no pending requests"); - assertEq(pendingBalance, 0); + assertEq(pendingBalances[0], 0); } // ============================================ @@ -1294,7 +1364,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Verify all other users still have 3 requests for (uint256 i = 0; i < 5; i++) { - (uint256[] memory ids, , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(users[i]); + (uint256[] memory ids, , , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(users[i]); if (i == 2) { assertEq(ids.length, 2, "User 2 should have 2 requests"); } else { @@ -1318,7 +1388,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(c.getPendingRequestCount(), 0); - (uint256[] memory ids, , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory ids, , , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user); assertEq(ids.length, 0); } @@ -1336,13 +1406,13 @@ contract FlowYieldVaultsRequestsTest is Test { vm.stopPrank(); // req1 should still be removed from pending (it's marked FAILED) - (uint256[] memory ids, , , , , , , , , , uint256 pendingBalance, uint256 claimableRefund) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory ids, , , , , , , , , , , uint256[] memory pendingBalances, uint256[] memory claimableRefundsArr) = c.getPendingRequestsByUserUnpacked(user); assertEq(ids.length, 1, "Should have 1 pending request"); assertEq(ids[0], req2); // Escrowed balance only includes req2 - assertEq(pendingBalance, 2 ether, "Escrowed balance should be req2 amount"); + assertEq(pendingBalances[0], 2 ether, "Escrowed balance should be req2 amount"); // Refunded amount is in claimableRefunds - assertEq(claimableRefund, 1 ether, "Claimable refund should be req1 amount"); + assertEq(claimableRefundsArr[0], 1 ether, "Claimable refund should be req1 amount"); } function test_CancelMiddleRequest_UpdatesIndexCorrectly() public { @@ -1362,7 +1432,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(globalIds[1], req3, "Second should be req3"); // Verify user's array - (uint256[] memory userIds, , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user); + (uint256[] memory userIds, , , , , , , , , , , , ) = c.getPendingRequestsByUserUnpacked(user); assertEq(userIds.length, 2); }