Skip to content

fix(FLOW-15): Return per-token balances in getPendingRequestsByUserUnpacked#36

Merged
liobrasil merged 14 commits into
mainfrom
fix/FLOW-15-user-pending-balance-per-token
Apr 7, 2026
Merged

fix(FLOW-15): Return per-token balances in getPendingRequestsByUserUnpacked#36
liobrasil merged 14 commits into
mainfrom
fix/FLOW-15-user-pending-balance-per-token

Conversation

@liobrasil

@liobrasil liobrasil commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #30

getPendingRequestsByUserUnpacked now returns per-token pending and refund balances for the full tracked token set instead of only native FLOW. The unpacked balance view keeps previously configured tokens visible so users can still see residual balances or claimable refunds even after a token is later disabled.

Changes

  • Solidity
    • Replace scalar pendingBalance / claimableRefund return values with aligned balanceTokens[], pendingBalances[], and claimableRefundsArray[]
    • Track configured tokens in _trackedTokens so NATIVE_FLOW, WFLOW, and supported ERC20s are surfaced in the unpacked view
    • Keep previously configured tokens visible in balance queries after disablement
  • Cadence
    • Decode the aligned arrays into PendingRequestsInfo.pendingBalances and PendingRequestsInfo.claimableRefunds
    • Harden ABI decoding with array-length validation and token-aware amount conversion when mapping EVM balances into UFix64
  • Docs / artifacts
    • Update integration docs, design notes, and ABI artifacts for the new return shape
  • Tests
    • Add Solidity coverage for WFLOW, tracked ERC20 balances, disabled-token visibility, and never-supported token exclusion
    • Add Cadence query mock coverage for multi-token balance decoding

Test plan

  • All Solidity tests pass
  • All Cadence test suites pass
  • Verify frontend clients consume the aligned balance arrays / Cadence dictionaries

liobrasil and others added 2 commits January 19, 2026 14:18
…packed

Previously, `getPendingRequestsByUserUnpacked` only returned native FLOW
balances, ignoring ERC20/WFLOW balances. This made it impossible for
clients to see pending balances for non-native tokens.

Changes:
- Solidity: Return `balanceTokens[]`, `pendingBalances[]`, and
  `claimableRefundsArray[]` instead of single uint256 values
- Cadence: Update `PendingRequestsInfo` struct to use dictionaries
  `pendingBalances: {String: UFix64}` and `claimableRefunds: {String: UFix64}`
- Update ABI decoding to handle new array types
- Update all tests to use new return format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude

claude Bot commented Feb 12, 2026

Copy link
Copy Markdown

Pull Request Review

Overview

This PR successfully addresses issue #30 by enhancing getPendingRequestsByUserUnpacked to return per-token balance arrays instead of single values. The implementation is clean and maintains backward compatibility through proper ABI versioning.


✅ Strengths

1. Clean API Design

  • The new return format with parallel arrays (balanceTokens, pendingBalances, claimableRefundsArray) is intuitive and follows established patterns
  • Dynamic array sizing based on WFLOW configuration is efficient (tokenCount = WFLOW != address(0) ? 2 : 1)
  • Maintains O(1) complexity for user lookups

2. Cross-VM Consistency

  • Solidity and Cadence implementations stay synchronized (solidity/src/FlowYieldVaultsRequests.sol:1425, cadence/contracts/FlowYieldVaultsEVM.cdc:1730)
  • Proper ABI encoding/decoding with correct type specifications
  • Dictionary-based approach in Cadence ({String: UFix64}) is idiomatic and extensible

3. Test Coverage

  • All 70 Solidity tests updated to use new return format
  • Tests cover key scenarios: multiple users, cancellations, processing, edge cases
  • Proper assertions on array indices and balances

4. Documentation

  • Updated FRONTEND_INTEGRATION.md with clear usage examples
  • Inline comments explain the new return values
  • Helpful note about fallback to individual getters for other ERC20s

⚠️ Issues & Recommendations

Critical: Missing Test Coverage

Issue: The ABI artifacts show new error types (YieldVaultTokenMismatch, YieldVaultTokenNotSet) and a new public mapping (yieldVaultTokens) that aren't part of this PR's stated changes.

// From deployments/artifacts/FlowYieldVaultsRequests.json
{
  "type": "function",
  "name": "yieldVaultTokens",
  "inputs": [{"name": "", "type": "uint64"}],
  "outputs": [{"name": "", "type": "address"}]
}

Recommendation:

  • If these changes are from a previous commit that didn't update artifacts, that's fine
  • If this is new functionality in this PR, it needs dedicated tests and should be mentioned in the PR description
  • Run ./scripts/export-artifacts.sh to ensure artifacts match current contract state

Moderate: Array Index Safety

Location: cadence/contracts/FlowYieldVaultsEVM.cdc:1733

while j < balanceTokens.length {
    let tokenAddr = balanceTokens[j].toString()
    pendingBalances[tokenAddr] = FlowEVMBridgeUtils.uint256ToUFix64(value: pendingBalancesRaw[j], decimals: 18)
    // Assumes pendingBalancesRaw and claimableRefundsRaw have same length
    j = j + 1
}

Issue: No validation that pendingBalancesRaw.length == claimableRefundsRaw.length == balanceTokens.length

Recommendation: Add assertions before the loop:

assert(
    balanceTokens.length == pendingBalancesRaw.length && 
    balanceTokens.length == claimableRefundsRaw.length,
    message: "Balance array length mismatch in ABI decode"
)

Minor: Test Assertion Consistency

Location: solidity/test/FlowYieldVaultsRequests.t.sol:919

Some test destructuring statements have trailing commas in the middle of the parameter list:

(uint256[] memory ids, , , , , , , , , , address[] memory balanceTokens, uint256[] memory pendingBalances, ) = ...

Recommendation: While syntactically valid, consider naming intermediate parameters for readability:

(
    uint256[] memory ids,
    , // requestTypes
    , // statuses  
    , // tokenAddresses
    , // amounts
    ...
) = c.getPendingRequestsByUserUnpacked(user);

Minor: Gas Optimization Opportunity

Location: solidity/src/FlowYieldVaultsRequests.sol:1425-1440

uint256 tokenCount = WFLOW != address(0) ? 2 : 1;
balanceTokens = new address[](tokenCount);
pendingBalances = new uint256[](tokenCount);
claimableRefundsArray = new uint256[](tokenCount);

Observation: Conditional logic executed on every call. Since WFLOW is immutable, this could be optimized with early return branches, though current implementation is already quite efficient.

Verdict: Not worth optimizing - code clarity is more valuable here.


🔒 Security Analysis

No Security Issues Found

  • Read-only view function with no state modifications
  • No reentrancy risks
  • Array bounds properly handled by Solidity's automatic checks
  • User-specific data isolation maintained through existing mappings
  • No new privilege escalation vectors

🎯 Recommendations

Before Merge:

  1. Clarify Artifact Changes - Confirm whether yieldVaultTokens, YieldVaultTokenMismatch, and YieldVaultTokenNotSet are from this PR or a previous commit
  2. Add Array Length Validation - Add assertions in Cadence decoder to prevent index out-of-bounds
  3. Update Test Plan Checkbox - "Verify integration with frontend clients" is unchecked - either test or remove

Optional Improvements:

  1. Consider adding a test that explicitly verifies WFLOW balances when WFLOW is configured
  2. Add a test for the edge case where a user has pending balance in WFLOW but not NATIVE_FLOW (or vice versa)

📊 Final Assessment

Code Quality: ⭐⭐⭐⭐½ (4.5/5)
Test Coverage: ⭐⭐⭐⭐ (4/5)
Security: ⭐⭐⭐⭐⭐ (5/5)
Documentation: ⭐⭐⭐⭐ (4/5)

Verdict:Approve with minor fixes

This is a solid implementation that correctly solves the stated problem. The cross-VM synchronization is well-executed, and the API design is extensible. The identified issues are minor and don't block merge, but addressing the array validation would improve robustness.

Great work maintaining consistency across Solidity and Cadence! The frontend integration guide is particularly helpful.


🤖 Review generated by Claude Code

Comment thread solidity/test/FlowYieldVaultsRequests.t.sol
var j = 0
while j < balanceTokens.length {
let tokenAddr = balanceTokens[j].toString()
pendingBalances[tokenAddr] = FlowEVMBridgeUtils.uint256ToUFix64(value: pendingBalancesRaw[j], decimals: 18)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded decimals: 18 will silently mis-represent any ERC-20 token that does not have 18 decimal places

Both lines here use decimals: 18:

pendingBalances[tokenAddr] = FlowEVMBridgeUtils.uint256ToUFix64(value: pendingBalancesRaw[j], decimals: 18)
claimableRefundsMap[tokenAddr] = FlowEVMBridgeUtils.uint256ToUFix64(value: claimableRefundsRaw[j], decimals: 18)

NATIVE_FLOW and WFLOW both have 18 EVM decimals, so this PR is correct for the current token set. However, the Solidity side already has a generic setTokenConfig admin function that can add any ERC-20 token, and the balance arrays are keyed by token address — if a 6-decimal USDC or similar token were ever added, the Cadence side would silently report values ~10¹² times too small.

The safe fix is to use FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount (which looks up the actual decimals via FlowEVMBridgeConfig) for non-native tokens, matching what ufix64FromUInt256 already does elsewhere in this contract. Alternatively, add a hard assertion that only NATIVE_FLOW/WFLOW (18-decimal) addresses can appear in this loop, so the assumption is at least explicit and checked.

if (WFLOW != address(0)) {
balanceTokens[1] = WFLOW;
pendingBalances[1] = pendingUserBalances[user][WFLOW];
claimableRefundsArray[1] = claimableRefunds[user][WFLOW];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WFLOW balance path (index [1]) has no test coverage — the core feature of this fix is untested

Every updated test assertion in this PR checks pendingBalances[0] / claimableRefundsArr[0] (NATIVE_FLOW). No test exercises:

  • balanceTokens[1] == WFLOW when WFLOW is configured
  • pendingBalances[1] reflecting an active WFLOW CREATE/DEPOSIT request
  • claimableRefundsArr[1] reflecting a cancelled/dropped WFLOW request

A test along these lines is needed:

  1. Approve WFLOW, call createYieldVault(WFLOW, 2 ether, ...) — verify pendingBalances[1] == 2 ether.
  2. Cancel the request — verify pendingBalances[1] == 0 and claimableRefundsArr[1] == 2 ether.

Without this test the regression this PR aims to fix (WFLOW balances not returned) could silently re-appear.

@claude

claude Bot commented Mar 16, 2026

Copy link
Copy Markdown

Review: fix(FLOW-15) — Return per-token balances in getPendingRequestsByUserUnpacked

The core change is sound. The ABI break is intentional, the Cadence decoding is aligned with the new return shape, array-length validation is present, and the tracking logic (_isTrackedToken dedup, constructor seeding) is correct. Tests cover WFLOW, ERC20, disabled-token visibility, and never-supported token exclusion.

One concrete issue

ERC20 decimals() panic surface extended to claimable-refund entries — see inline comment on FlowYieldVaultsEVM.cdc lines 1741–1752.

Before this PR, ufix64FromUInt256 was only invoked inside the request loop (for request.amount), so getPendingRequestsForEVMAddress only panicked when there were pending requests for a non-standard ERC20. The new balance loop now also fires on non-zero claimableRefunds entries, which means a user with no pending requests but a residual refund from a later-broken ERC20 would permanently break the per-user query endpoint. The SchedulerHandler is not affected (it uses getPendingRequestsUnpacked, a different path), so worker correctness is safe — the impact is limited to monitoring scripts and frontends.

Minor observations

  • test_FailedProcessing_RestoresUserArray (line 1544 of the test file) covers the completeProcessing(false) refund path but only for NATIVE_FLOW. There's no multi-token (WFLOW/ERC20) variant of that test. Since cancelRequest and completeProcessing(false) both write to the same claimableRefunds[user][token] mapping, the new WFLOW cancel test gives reasonable coverage, but an explicit ERC20 failure-path test would close the gap.

  • _trackedTokens has no external getter. Off-chain tooling has to call getPendingRequestsByUserUnpacked and read the balanceTokens output to discover what tokens are tracked. A getTrackedTokens() view would be useful but is not blocking.

  • The claimableRefundsMap variable name (Cadence) vs. claimableRefundsArray (Solidity return label) is a cosmetic inconsistency; no functional impact.

@liobrasil
liobrasil requested a review from zhangchiqing March 17, 2026 14:05
,
address[] memory balanceTokens,
uint256[] memory pendingBalances,
uint256[] memory claimableRefundsArr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test setup now always configures WFLOW, so balanceTokens.length will be 2 here. The test only checks index 0 and silently drops claimableRefundsArray (trailing comma). Consider asserting balanceTokens.length == 2, pendingBalances[1] == 0 (no WFLOW pending), and that claimableRefundsArray[0] == 0 to make the return shape fully exercised for the WFLOW-configured case.

uint256[] memory claimableRefundsArr
) = c.getPendingRequestsByUserUnpacked(user);

assertEq(ids.length, 1, "User should have 1 pending request");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no test covering the WFLOW = address(0) path. In that case tokenCount == 1 and callers receive a single-element array. Any client that hardcodes balanceTokens[1] (easy to write after reading the WFLOW example) will out-of-bounds revert on a deployment that has no WFLOW. A companion test that constructs the contract with WFLOW = address(0) and asserts balanceTokens.length == 1 would pin this invariant.


if (!_isTrackedToken[tokenAddress]) {
_isTrackedToken[tokenAddress] = true;
_trackedTokens.push(tokenAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_trackedTokens grows monotonically with no removal path.

Once a token is pushed here it stays forever, even after the owner disables it and every user's pending/refund balance for it reaches zero. In that state every call to getPendingRequestsByUserUnpacked returns a row of all-zeroes for the stale token indefinitely, and there is no admin function to prune it.

The PR description notes "may include disabled tokens if users still have balances/refunds", but the current design keeps the token even after balances are fully claimed. Consider adding an admin function removeTrackedToken(address) that requires pendingUserBalances and claimableRefunds to be zero for that token across all users (or at least a best-effort guard), so the list can be cleaned up over time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't support more then 20 tokens so it seems acceptable not to adda remove API here @claude

@claude claude Bot Mar 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @liobrasil's task in 24s —— View job


That's a reasonable constraint. For future maintainability, consider adding a short inline comment at the _trackedTokens.push site and a guard that makes the limit explicit:

// Max 20 tokens are ever expected; guard prevents unbounded array growth.
require(_trackedTokens.length < 20, "Max tracked token limit reached");
_trackedTokens.push(tokenAddress);

This turns the "won't support" policy into a hard on-chain invariant rather than relying solely on admin discipline — and keeps the concern from resurfacing in future reviews.

I'll withdraw the removal-API concern. Up to you whether you want the guard.

Comment on lines +1704 to +1729
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ABI-decode path has no Cadence-side test coverage.

This block is the most complex change in the PR: it decodes three new return values ([EVM.EVMAddress], [UInt256], [UInt256]), iterates them, and calls ufix64FromUInt256 (which itself dispatches on whether the token is native FLOW or an ERC20). A wrong type in the decoded[N] as! cast causes a runtime panic; a wrong decimal conversion silently produces incorrect balances.

All four Cadence test suites pass trivially because none of them call getPendingRequestsForEVMAddress. The get_pending_requests_for_evm_address.cdc script and the code path below are completely unexercised by the Cadence test suite. At minimum, evm_bridge_lifecycle_test.cdc should assert that the returned PendingRequestsInfo.pendingBalances dictionary contains the expected token keys and converted values after a request is created.

uint256 pendingBalance,
address[] memory balanceTokens,
uint256[] memory pendingBalances,
) = c.getPendingRequestsByUserUnpacked(user);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing comma silently drops the claimableRefundsArray return value, so this test has no assertion on it. With two pending NATIVE_FLOW requests and no cancellation, claimableRefundsArray[0] should be 0; the test should verify that.

Suggested change
) = c.getPendingRequestsByUserUnpacked(user);
uint256[] memory pendingBalances,
uint256[] memory claimableRefundsArray
) = c.getPendingRequestsByUserUnpacked(user);

Then add:

assertEq(claimableRefundsArray[0], 0, "No refund expected for active pending requests");


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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion checks only pendingBalances[0] (NATIVE_FLOW). If _trackedTokens were empty for some reason this would OOB-revert rather than produce a clean failure message. More usefully: the test doesn't assert that balanceTokens.length == 2 (NATIVE_FLOW + WFLOW are both tracked from the constructor), nor that pendingBalances[1] == 0 and all claimableRefundsArray slots are zero. For a test called "EmptyForNewUser" it should confirm the full balance picture is zeroed, not just one slot.

Comment thread cadence/contracts/FlowYieldVaultsEVM.cdc

if (!_isTrackedToken[tokenAddress]) {
_isTrackedToken[tokenAddress] = true;
_trackedTokens.push(tokenAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_trackedTokens grows permanently with no cap

Every distinct address ever passed to _setTokenConfig (directly via setTokenConfig or by the constructor) is appended once and never pruned. Disabling a token (isSupported = false) keeps it in the list.

The Cadence balance-query loop then issues one decimals() EVM call per non-native tracked token per query call. Over the contract's lifetime, an admin adding many tokens (even short-lived test/staging tokens) permanently inflates this cost for every future call.

Consider documenting an explicit governance rule (e.g., "the tracked-token list is expected to remain small — typically ≤ 10 entries") or adding an owner-controlled removeTrackedToken escape hatch so dead tokens can be pruned if needed. At minimum, a getTrackedTokens() view would make the current list inspectable without reading contract storage directly.


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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile index access: pendingBalances[0] without length guard

This works today because the constructor always calls _setTokenConfig(NATIVE_FLOW, ...), ensuring _trackedTokens.length >= 1. But the assertion doesn't verify that precondition — if _trackedTokens were somehow empty (e.g., a future constructor variant, a different test helper), this would revert rather than fail gracefully.

Suggested addition before line 1205:

assertGt(pendingBalances.length, 0, "Balance array should be non-empty (at least NATIVE_FLOW)");

Comment thread solidity/src/FlowYieldVaultsRequests.sol
Comment thread solidity/src/FlowYieldVaultsRequests.sol

if (isSupported && !_isTrackedToken[tokenAddress]) {
_isTrackedToken[tokenAddress] = true;
_trackedTokens.push(tokenAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_trackedTokens is append-only — no removal path exists.

Once a token enters _trackedTokens, it stays forever, even if the owner later wants to drop it entirely (e.g., a misconfigured address, a migrated token contract, or a deprecated asset). setTokenConfig(token, false, ...) disables the token for new deposits but does not remove it from this array, so it keeps showing up as a zero-balance slot in every getPendingRequestsByUserUnpacked response.

In practice the list is expected to stay small (2–5 tokens), but the design gives no escape hatch if a token ever needs to be fully deregistered. Consider adding an owner-only untrackToken(address) that:

  1. clears _isTrackedToken[token]
  2. swaps-and-pops the address out of _trackedTokens

This also lets you enforce a cap on _trackedTokens.length if desired, preventing a theoretical scenario where a large list causes the Cadence dryCall (15 M gas limit) to time out.

Comment thread solidity/src/test/PendingRequestsByUserQueryMock.sol
"name": "pendingBalance",
"type": "uint256",
"internalType": "uint256"
"name": "balanceTokens",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The solidity/deployments/artifacts/FlowYieldVaultsRequests.json artifact is out of sync with deployments/artifacts/FlowYieldVaultsRequests.json. The top-level copy (which is what scripts/export-artifacts.sh actually generates) includes three entries added in this PR that are absent here:

  • DEFAULT_MINIMUM_BALANCE (view function)
  • CannotBlocklistZeroAddress (custom error)
  • InvalidMinimumBalance (custom error)

All three are present in the Solidity source. Any consumer using solidity/deployments/artifacts/ will have an incomplete ABI and won't be able to catch or decode these errors.

The export script only writes to deployments/artifacts/, so this secondary copy needs to be kept in sync manually or the script should also update it.

isNative: isNative
});

if (isSupported && !_isTrackedToken[tokenAddress]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_trackedTokens is append-only: once a token is pushed it is never removed, even after the token is disabled and all user balances/refunds have been claimed. Over the life of the contract, every token that was ever enabled at least once will appear in the array permanently. This means getPendingRequestsByUserUnpacked iterates over — and returns — an ever-growing set of token entries for every user query, regardless of whether the user ever interacted with those tokens.

For the current governance-controlled usage (a handful of tokens) this is low risk, but there is no escape hatch (e.g. pruneTrackedToken(address) callable when both pendingUserBalances and claimableRefunds are zero for all users) if the list grows large. Worth at least a @dev note on the field explaining the one-way growth.

Comment on lines +1718 to +1733
while j < balanceTokens.length {
let tokenAddr = balanceTokens[j].toString()
let rawPending = pendingBalancesRaw[j]
let rawRefund = claimableRefundsRaw[j]
pendingBalances[tokenAddr] = rawPending == 0
? 0.0
: FlowYieldVaultsEVM.ufix64FromUInt256(
rawPending,
tokenAddress: balanceTokens[j]
)
claimableRefundsMap[tokenAddr] = rawRefund == 0
? 0.0
: FlowYieldVaultsEVM.ufix64FromUInt256(
rawRefund,
tokenAddress: balanceTokens[j]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New failure mode: ufix64FromUInt256 panics if a tracked ERC20's decimals() is unreachable

ufix64FromUInt256 on a non-native-FLOW token calls FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount, which issues a cross-VM decimals() call. If that call reverts (e.g. the token contract was self-destructed, or a non-ERC20 address was ever mistakenly registered and given balances), the entire getPendingRequestsForEVMAddress script panics for every user who holds a non-zero balance for that token.

Before this PR the Cadence query decoded a single scalar for native FLOW and made no ERC20 decimals() calls. The new loop iterates over every tracked token and queries decimals for any non-zero amount, so this failure mode is new to this PR.

The zero-shortcut (rawPending == 0 ? 0.0 : …) correctly avoids the call for empty slots, but it does not protect users who have live escrow or claimable refunds in a now-defunct token.

access(all)
fun testGetPendingRequestsForEVMAddressDecodesBalances() {
let tokenBAddress = deployERC20DecimalsOnlyMock(admin, decimals: 6)
let tokenCAddress = "0x00000000000000000000000000000000000000cc"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: tokenC never exercises the non-zero ERC20 decimal-lookup path

tokenCAddress is a bare address with no deployed contract. The mock (PendingRequestsByUserQueryMock) leaves pendingBalances[2] and claimableRefundsArray[2] at their default of 0, so the rawPending == 0 ? 0.0 : ufix64FromUInt256(…) shortcut in FlowYieldVaultsEVM.cdc means decimals() is never actually called for that address.

This means the risky path—"tracked token with a non-zero amount but an unresponsive ERC20 contract"—has no test coverage at all. Consider adding a second variant of the mock that sets a non-zero balance for tokenC with a non-deployed address to confirm the expected behavior (panic with a clear message, or a graceful fallback).

Comment on lines +1517 to +1519
if (isSupported && !_isTrackedToken[tokenAddress]) {
_isTrackedToken[tokenAddress] = true;
_trackedTokens.push(tokenAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_trackedTokens is unbounded and irreversible

_isTrackedToken is set to true once and never cleared, and _trackedTokens is append-only. Every subsequent call to setTokenConfig with a fresh address adds another permanent entry. There is no admin function to prune stale entries.

On the Solidity side this inflates the return array of getPendingRequestsByUserUnpacked linearly with the number of ever-enabled tokens. On the Cadence side, each non-zero entry in that array triggers an additional cross-VM decimals() call during getPendingRequestsForEVMAddress. In a long-running deployment with rotating token lists this can grow without bound.

Consider documenting the intended upper limit, or adding an owner-only removeTrackedToken that is allowed only when all user balances for that token are zero.

@m-Peter m-Peter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 🚀

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}

@zhangchiqing zhangchiqing Apr 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the token address is basically EVM.EVMAddress, can we use it as key instead?

If not, I would suggest to mention in the comments something like the token address string is the hex format of EVM.EVMAddress.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some checks and {EVM.EVMAddress: UFix64} is not viable in Cadence. EVM.EVMAddress is a struct, and Cadence does not allow struct types as dictionary keys.

…20260407

# Conflicts:
#	cadence/tests/test_helpers.cdc
"name": "pendingBalance",
"type": "uint256",
"internalType": "uint256"
"name": "balanceTokens",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This artifact copy is out of sync with the canonical deployments/artifacts/FlowYieldVaultsRequests.json. Three ABI entries present in the canonical file are missing here:

  • DEFAULT_MINIMUM_BALANCE (function)
  • CannotBlocklistZeroAddress (error)
  • InvalidMinimumBalance (error)

scripts/export-artifacts.sh only writes to deployments/artifacts/; solidity/deployments/artifacts/ appears to be maintained manually and was only partially updated in this PR. Either regenerate this file from the Foundry output (or the canonical file) to keep them in sync, or remove the duplicate copy and point all consumers to the canonical path.

@liobrasil
liobrasil merged commit ac06818 into main Apr 7, 2026
5 checks passed
@liobrasil
liobrasil deleted the fix/FLOW-15-user-pending-balance-per-token branch April 7, 2026 11:51
Comment on lines +1741 to +1752
pendingBalances[tokenAddr] = rawPending == 0
? 0.0
: FlowYieldVaultsEVM.ufix64FromUInt256(
rawPending,
tokenAddress: balanceTokens[j]
)
claimableRefundsMap[tokenAddr] = rawRefund == 0
? 0.0
: FlowYieldVaultsEVM.ufix64FromUInt256(
rawRefund,
tokenAddress: balanceTokens[j]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zero-guard correctly skips the decimals() EVM call for zero balances, but any non-zero ERC20 balance/refund entry will call FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount, which panics if the ERC20's decimals() reverts (non-standard token, self-destructed contract, etc.).

Before this PR, ufix64FromUInt256 was only called inside the request loop for request.amount — so the query only panicked if there were pending requests for the broken token. Now it also fires on non-zero claimable refund entries, even when the user has no pending requests. A user who was refunded from a later-broken ERC20 would have getPendingRequestsForEVMAddress permanently stuck.

The SchedulerHandler is unaffected (it uses getPendingRequestsFromEVMgetPendingRequestsUnpacked, not this path), so worker processing is safe. However, monitoring scripts and any frontend caller hitting this endpoint would be unable to query that user's data.

Worth adding a defensive try/catch around the conversion — fall back to 0.0 with a logged event, rather than panicking the whole query:

// pseudo-pattern
let converted: UFix64 = rawPending == 0 ? 0.0 : (
    FlowYieldVaultsEVM._safeUfix64FromUInt256(rawPending, tokenAddress: balanceTokens[j]) ?? 0.0
)

Or at minimum add a comment noting this invariant so future token additions are made with it in mind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FLOW-15: User Pending Balance View Is Native-Only

3 participants