UX: Rate-limit blocked transfers stuck silently on step 4/4 with no user-facing reason #127

Open
opened 2026-05-01 06:10:20 +00:00 by Brouie · 12 comments
Brouie commented 2026-05-01 06:10:20 +00:00 (Migrated from gitlab.com)

Summary

When a bridge transfer hits a destination chain rate limit, the TransferStatus page stays on step 4/4 ACTIVE indefinitely with no user-facing indication of why it is stuck. Verify page shows the transfer as a successful verify (correct on-chain), but the UI gives the user no reason for the apparent freeze.

Repro (today, 2026-05-01)

Transfers stuck >5 min past cancel window with no error surfaced to UI:

  • Terra → MegaETH testa 0.997, xchain 0x8f3cb99bd1ffcf0e8738f6ce769a32fb4b8c0eeb71d71b85ffb4bb6073a78995
  • opBNB → MegaETH testa 0.040295, xchain 0x45ded3ea2eb82ae066827c38305f2d331547f785a4bac7fb4b54d55d0b0fe4e2

Both stayed on step 4/4 "Delivering tokens to recipient" with CANCEL WINDOW ACTIVE / Executing... Operator usually completes within ~30 seconds indefinitely. Per @PlasticDigits Telegram: rate-limit blocked the withdrawal correctly, behavior on-chain is correct, but the frontend never surfaces the rate-limit reason to the user.

BSC → MegaETH testa same session completed cleanly (xchain 0x7e4317bac1b3f6e7a1a579bbff63f0961afb10b22a72c9d9ecc90807edb6df38) — only Terra and opBNB sources hit the limit.

Expected

When a transfer is blocked by a rate limit (or any operator-side condition), the TransferStatus page should surface the reason to the user instead of staying silently stuck on step 4/4. Suggested copy: a banner under step 4 like BLOCKED: rate limit on destination — try again in <window>, or similar.

Severity

P2 medium UX — not on-chain incorrect, but user-facing behavior is opaque and looks like a hung transfer.

cc @PlasticDigits

## Summary When a bridge transfer hits a destination chain rate limit, the TransferStatus page stays on step 4/4 ACTIVE indefinitely with no user-facing indication of why it is stuck. Verify page shows the transfer as a successful verify (correct on-chain), but the UI gives the user no reason for the apparent freeze. ## Repro (today, 2026-05-01) Transfers stuck >5 min past cancel window with no error surfaced to UI: - Terra → MegaETH testa 0.997, xchain `0x8f3cb99bd1ffcf0e8738f6ce769a32fb4b8c0eeb71d71b85ffb4bb6073a78995` - opBNB → MegaETH testa 0.040295, xchain `0x45ded3ea2eb82ae066827c38305f2d331547f785a4bac7fb4b54d55d0b0fe4e2` Both stayed on step 4/4 "Delivering tokens to recipient" with `CANCEL WINDOW ACTIVE / Executing... Operator usually completes within ~30 seconds` indefinitely. Per @PlasticDigits Telegram: rate-limit blocked the withdrawal correctly, behavior on-chain is correct, but the frontend never surfaces the rate-limit reason to the user. BSC → MegaETH testa same session completed cleanly (xchain `0x7e4317bac1b3f6e7a1a579bbff63f0961afb10b22a72c9d9ecc90807edb6df38`) — only Terra and opBNB sources hit the limit. ## Expected When a transfer is blocked by a rate limit (or any operator-side condition), the TransferStatus page should surface the reason to the user instead of staying silently stuck on step 4/4. Suggested copy: a banner under step 4 like `BLOCKED: rate limit on destination — try again in <window>`, or similar. ## Severity P2 medium UX — not on-chain incorrect, but user-facing behavior is opaque and looks like a hung transfer. cc @PlasticDigits
Brouie commented 2026-05-01 06:50:46 +00:00 (Migrated from gitlab.com)

mentioned in issue #129

mentioned in issue #129
PlasticDigits commented 2026-05-01 07:15:52 +00:00 (Migrated from gitlab.com)

mentioned in commit 22878c45e6

mentioned in commit 22878c45e69e4dbc3aafa21e8d22fc0f9298ac65
PlasticDigits commented 2026-05-01 07:16:21 +00:00 (Migrated from gitlab.com)

Implemented (GL-127) — merged to main @ eaa3d0a

Summary: Transfer Status now classifies EVM destination rate-limit stalls (MegaETH / any EVM bridge using TokenRegistry.getWithdrawRateLimitWindow) the same way we already did for Terra, and shows a 1-second-updating “Resets in …” countdown aligned to the RPC snapshot (fetchedAtWallMs).

Code: useEvmExecutionRateLimitStatus → useTokenDetails + computeEvmExecutionRateLimitStatus; useWithdrawRateLimitCountdown; EVM getPendingWithdraw mapping now includes destDecimals for correct payout normalization (matches Bridge._normalizeDecimals). Terra queries share normalizeBridgeAmountToDestDecimals; temporary blocks attach optional fetchedAtWallMs for the same countdown alignment.

Docs: docs/FRONTEND_BRIDGE_INVARIANTS.md INV-UX2; skills/agent-frontend-bridge-chains.md crosslink + invariant bullet.

@brouie — please verify on a rate-limit repro (e.g. Terra/opBNB → MegaETH) when you have a moment. Issue left open per request.

Verification checklist

  • Open /transfer/<xchainHashId> for an approved, non-executed transfer to an EVM dest when the destination 24h withdraw window is exhausted: amber Blocked: destination rate limit + Resets in countdown ticks every second until window end.
  • Same flow for Terra dest: behavior unchanged except countdown uses wall-aligned timer when fetchedAtWallMs is present.
  • Permanent block (payout > max per period): red banner still shows.
  • Unknown + cancel window already expired: soft “may be delayed / Verify” banner still shows.
  • Settings → token withdraw rate limit panel and Transfer form caps still consistent with on-chain data (same useTokenDetails path).

Tests run: packages/frontend npm run test:unit, npm run build; pre-commit hooks (tsc, eslint) on merge.

## Implemented (GL-127) — merged to `main` @ eaa3d0a **Summary:** Transfer Status now classifies **EVM** destination rate-limit stalls (MegaETH / any EVM bridge using `TokenRegistry.getWithdrawRateLimitWindow`) the same way we already did for **Terra**, and shows a **1-second-updating** “Resets in …” countdown aligned to the RPC snapshot (`fetchedAtWallMs`). **Code:** `useEvmExecutionRateLimitStatus` → `useTokenDetails` + `computeEvmExecutionRateLimitStatus`; `useWithdrawRateLimitCountdown`; EVM `getPendingWithdraw` mapping now includes **`destDecimals`** for correct payout normalization (matches `Bridge._normalizeDecimals`). Terra queries share `normalizeBridgeAmountToDestDecimals`; temporary blocks attach optional `fetchedAtWallMs` for the same countdown alignment. **Docs:** `docs/FRONTEND_BRIDGE_INVARIANTS.md` **INV-UX2**; `skills/agent-frontend-bridge-chains.md` crosslink + invariant bullet. @brouie — please verify on a rate-limit repro (e.g. Terra/opBNB → MegaETH) when you have a moment. Issue left **open** per request. ### Verification checklist - [ ] Open `/transfer/<xchainHashId>` for an approved, non-executed transfer to an **EVM** dest when the **destination** 24h withdraw window is exhausted: amber **Blocked: destination rate limit** + **Resets in** countdown ticks every second until window end. - [ ] Same flow for **Terra** dest: behavior unchanged except countdown uses wall-aligned timer when `fetchedAtWallMs` is present. - [ ] **Permanent** block (payout &gt; max per period): red banner still shows. - [ ] **Unknown** + cancel window already expired: soft “may be delayed / Verify” banner still shows. - [ ] Settings → token **withdraw rate limit** panel and Transfer form caps still consistent with on-chain data (same `useTokenDetails` path). **Tests run:** `packages/frontend` `npm run test:unit`, `npm run build`; pre-commit hooks (tsc, eslint) on merge.
Brouie commented 2026-05-01 07:54:29 +00:00 (Migrated from gitlab.com)

mentioned in issue #130

mentioned in issue #130
Brouie commented 2026-05-01 07:58:34 +00:00 (Migrated from gitlab.com)

mentioned in issue #123

mentioned in issue #123
Brouie commented 2026-05-01 15:48:53 +00:00 (Migrated from gitlab.com)

Source-level pre-walkthrough on eaa3d0a. Implementation looks solid end-to-end.

Files in place

  • services/evmExecutionRateLimit.ts — pure classifier, returns unified TerraRateLimitStatus shape (so EVM and Terra paths share the same status type per your commit notes).
  • services/evmExecutionRateLimit.test.ts — 6 tests covering every branch (unknown × 2, permanently-blocked, temporarily-blocked, ok, decimal-norm parity).
  • hooks/useEvmExecutionRateLimitStatus.ts — composes useTokenDetails + classifier, gates on dest.approved && !dest.executed.
  • hooks/useWithdrawRateLimitCountdown.ts — 1Hz setInterval with wall-time anchored countdown; matches SourceChainSelector pattern.
  • utils/bridgeAmountDecimals.ts — normalizeBridgeAmountToDestDecimals mirrors Bridge._normalizeDecimals 1:1 across all 3 branches (equal / down / up).
  • pages/TransferStatusPage.tsx — wires the hook with defensive null gating (only fires when rateLimitTemporarilyBlocked === true).

Cross-module invariant verified

Decimal-normalization parity between utils/bridgeAmountDecimals.ts (TS) and Bridge.sol:823 (Solidity):

Branch Solidity TypeScript
equal return amount return amount
src > dest (downscale) amount / (10 ** (src - dest)) amount / pow10BigInt(srcDec - destDec)
src < dest (upscale) amount * (10 ** (dest - src)) amount * pow10BigInt(destDec - srcDec)

destDecimals propagation end-to-end: ABI fields at withdrawSubmit.ts:53 + tokenRegistry.ts:50 → getDestinationMapping returns { destToken, destDecimals } → lifted into unified mapping → reaches computeEvmExecutionRateLimitStatus via dest.destDecimals for payout normalization. Tested at evmBridgeQueries.test.ts:106,131 and evmExecutionRateLimit.test.ts:22,79.

Tests

  • npx vitest run evmExecutionRateLimit: 6/6 PASS in 9ms.
  • npx vitest run bridgeAmountDecimals: 3/3 PASS in 7ms (all 3 decimal-norm branches).
  • Full suite: npx vitest run → 638/638 across 81 files PASS in 38s. No regression on the existing Terra path.

cc @PlasticDigits

Source-level pre-walkthrough on `eaa3d0a`. Implementation looks solid end-to-end. ### Files in place - `services/evmExecutionRateLimit.ts` — pure classifier, returns unified `TerraRateLimitStatus` shape (so EVM and Terra paths share the same status type per your commit notes). - `services/evmExecutionRateLimit.test.ts` — 6 tests covering every branch (`unknown` × 2, `permanently-blocked`, `temporarily-blocked`, `ok`, decimal-norm parity). - `hooks/useEvmExecutionRateLimitStatus.ts` — composes `useTokenDetails` + classifier, gates on `dest.approved && !dest.executed`. - `hooks/useWithdrawRateLimitCountdown.ts` — 1Hz `setInterval` with wall-time anchored countdown; matches `SourceChainSelector` pattern. - `utils/bridgeAmountDecimals.ts` — `normalizeBridgeAmountToDestDecimals` mirrors `Bridge._normalizeDecimals` 1:1 across all 3 branches (equal / down / up). - `pages/TransferStatusPage.tsx` — wires the hook with defensive null gating (only fires when `rateLimitTemporarilyBlocked === true`). ### Cross-module invariant verified Decimal-normalization parity between `utils/bridgeAmountDecimals.ts` (TS) and `Bridge.sol:823` (Solidity): | Branch | Solidity | TypeScript | |---|---|---| | equal | `return amount` | `return amount` | | src > dest (downscale) | `amount / (10 ** (src - dest))` | `amount / pow10BigInt(srcDec - destDec)` | | src < dest (upscale) | `amount * (10 ** (dest - src))` | `amount * pow10BigInt(destDec - srcDec)` | `destDecimals` propagation end-to-end: ABI fields at `withdrawSubmit.ts:53` + `tokenRegistry.ts:50` → `getDestinationMapping` returns `{ destToken, destDecimals }` → lifted into unified mapping → reaches `computeEvmExecutionRateLimitStatus` via `dest.destDecimals` for payout normalization. Tested at `evmBridgeQueries.test.ts:106,131` and `evmExecutionRateLimit.test.ts:22,79`. ### Tests - `npx vitest run evmExecutionRateLimit`: 6/6 PASS in 9ms. - `npx vitest run bridgeAmountDecimals`: 3/3 PASS in 7ms (all 3 decimal-norm branches). - Full suite: `npx vitest run` → 638/638 across 81 files PASS in 38s. No regression on the existing Terra path. cc @PlasticDigits
Brouie commented 2026-05-01 15:54:32 +00:00 (Migrated from gitlab.com)

mentioned in issue #128

mentioned in issue #128
Brouie commented 2026-05-02 00:58:20 +00:00 (Migrated from gitlab.com)

mentioned in issue #131

mentioned in issue #131
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-02 04:30:08 +00:00
Brouie commented 2026-05-03 22:10:01 +00:00 (Migrated from gitlab.com)

Production deploy gap — sign-off blocked

Verified prod bundle on bridge.cl8y.com today: footer reads v0.1.345 · 27a5e42. Repo main HEAD is currently at 95f8fd5, which is the merge commit containing all four fixes (#127 eaa3d0a, #128 b6c5b5a, #130 Terra || clause drop, #131 67a48e4). Prod is therefore still on the pre-fix bundle and none of the four fixes are live yet.

Confirming repro on prod (proves bundle is pre-fix):

  • EVM wallet chip in bridge header shows ETH text fallback while MetaMask is on MegaETH (chain 4326). This is the clean #131-D pre-fix behavior — getChainLogoPath has no branch for 4326, falls through to getGasSymbol default ETH.

Cannot run mainnet sign-off on the verification checklist for #127 / #128 / #130 / #131 until prod cuts to current HEAD (or whichever release commit contains the four fixes).

Will re-run the combined live walk on bridge.cl8y.com once deploy is confirmed:

  • #128 — Solana → MegaETH × 3 (testa / testb / tdec) for fresh-blockhash retry path
  • #127 — deliberate rate-limit row, observe amber "Resets in" countdown banner
  • #130 — MegaETH → Terra tdec 0.001, expect COMPLETE clean
  • #131 — A (stepper stable across poll), B (Switch button visible on wrong chain), C (no refresh stall after switch), D (mega.png header chip)

Plus the 4 #123 blocked rows pick-up that rides on #128 / #130 fixes.

cc @PlasticDigits

## Production deploy gap — sign-off blocked Verified prod bundle on bridge.cl8y.com today: footer reads `v0.1.345 · 27a5e42`. Repo `main` HEAD is currently at `95f8fd5`, which is the merge commit containing all four fixes (#127 `eaa3d0a`, #128 `b6c5b5a`, #130 Terra `||` clause drop, #131 `67a48e4`). Prod is therefore still on the pre-fix bundle and none of the four fixes are live yet. Confirming repro on prod (proves bundle is pre-fix): - EVM wallet chip in bridge header shows `ETH` text fallback while MetaMask is on MegaETH (chain 4326). This is the clean #131-D pre-fix behavior — `getChainLogoPath` has no branch for 4326, falls through to `getGasSymbol` default `ETH`. Cannot run mainnet sign-off on the verification checklist for #127 / #128 / #130 / #131 until prod cuts to current HEAD (or whichever release commit contains the four fixes). Will re-run the combined live walk on bridge.cl8y.com once deploy is confirmed: - #128 — Solana → MegaETH × 3 (testa / testb / tdec) for fresh-blockhash retry path - #127 — deliberate rate-limit row, observe amber "Resets in" countdown banner - #130 — MegaETH → Terra tdec 0.001, expect COMPLETE clean - #131 — A (stepper stable across poll), B (Switch button visible on wrong chain), C (no refresh stall after switch), D (mega.png header chip) Plus the 4 #123 blocked rows pick-up that rides on #128 / #130 fixes. cc @PlasticDigits
PlasticDigits (Migrated from gitlab.com) reopened this issue 2026-05-04 03:34:51 +00:00
PlasticDigits commented 2026-05-04 03:38:54 +00:00 (Migrated from gitlab.com)

@Brouie bridge.cl8y.com frontend is now deployed at latest commit.

@Brouie bridge.cl8y.com frontend is now deployed at latest commit.
Brouie commented 2026-05-07 05:43:36 +00:00 (Migrated from gitlab.com)

queued in the bundled bridge live-walk along with #128 — see https://gitlab.com/PlasticDigits/cl8y-bridge-monorepo/-/work_items/128#note_3322750554 for timing.

queued in the bundled bridge live-walk along with #128 — see https://gitlab.com/PlasticDigits/cl8y-bridge-monorepo/-/work_items/128#note_3322750554 for timing.
PlasticDigits commented 2026-05-19 08:10:26 +00:00 (Migrated from gitlab.com)

mentioned in issue #132

mentioned in issue #132
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-bridge-monorepo#127
No description provided.