From 6f50f63858c2aefd50bd9eb00ba6f8347a65c29e Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:44:56 -0600 Subject: [PATCH 01/40] fix(arbitrum-bridge): recreate withdraw claim actions from tx history Withdraw actions were only ever created from a swap action, so a cleared store, another browser, or a withdrawal made outside the app left the user with no claim card even though the claims hook was already polling the withdrawal. Build the action from the claim itself for pending and available claims, keyed by the withdraw tx so every path lands on one action, and scope withdraw actions to the wallet that made them. Co-Authored-By: Claude Fable 5.1 --- .../arbitrumBridgeWithdrawAction.test.ts | 87 +++++++++++++ .../arbitrumBridgeWithdrawAction.ts | 50 ++++++++ .../useArbitrumWithdrawalActionSubscriber.tsx | 117 ++++++++++++++---- .../slices/actionSlice/selectors.test.ts | 31 ++++- src/state/slices/actionSlice/selectors.ts | 4 + 5 files changed, 264 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts create mode 100644 src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts new file mode 100644 index 00000000000..efe5a828abb --- /dev/null +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -0,0 +1,87 @@ +import { ethAssetId, ethChainId } from '@shapeshiftoss/caip' +import { describe, expect, it } from 'vitest' + +import { + buildArbitrumBridgeWithdrawActionFromClaim, + getArbitrumBridgeWithdrawActionId, +} from './arbitrumBridgeWithdrawAction' + +import { ClaimStatus } from '@/components/ClaimRow/types' +import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' + +const destinationAddress = '0xAbCdEf0000000000000000000000000000000001' +const otherEthAccountId = 'eip155:1:0x0000000000000000000000000000000000000002' +const destinationAccountId = `eip155:1:${destinationAddress.toLowerCase()}` + +const claim = { + tx: { txid: '0xwithdraw', blockTime: 1_700_000_000 }, + accountId: 'eip155:42161:0xarb', + amountCryptoBaseUnit: '1000', + assetId: 'eip155:42161/slip44:60', + destinationAddress, + destinationAssetId: ethAssetId, + destinationChainId: ethChainId, + timeRemainingSeconds: 120, +} as unknown as ClaimDetails + +describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { + it('keys the action by the withdraw tx so every path lands on the same action', () => { + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Pending, [ + destinationAccountId, + ]) + + expect(action?.id).toBe(getArbitrumBridgeWithdrawActionId('0xwithdraw')) + expect(action?.type).toBe(ActionType.ArbitrumBridgeWithdraw) + }) + + it('maps a pending claim to an initiated action dated from the withdraw block', () => { + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Pending, [ + destinationAccountId, + ]) + + expect(action?.status).toBe(ActionStatus.Initiated) + expect(action?.createdAt).toBe(1_700_000_000_000) + expect(action?.arbitrumBridgeMetadata).toMatchObject({ + withdrawTxHash: '0xwithdraw', + amountCryptoBaseUnit: '1000', + assetId: 'eip155:42161/slip44:60', + destinationAssetId: ethAssetId, + accountId: 'eip155:42161:0xarb', + destinationAccountId, + timeRemainingSeconds: 120, + claimDetails: claim, + }) + }) + + it('maps an available claim to a claimable action', () => { + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, [ + destinationAccountId, + ]) + + expect(action?.status).toBe(ActionStatus.ClaimAvailable) + }) + + it('signs from the destination account when the wallet holds it, whatever its casing', () => { + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, [ + otherEthAccountId, + destinationAccountId, + ]) + + expect(action?.arbitrumBridgeMetadata.destinationAccountId).toBe(destinationAccountId) + }) + + it('falls back to any ethereum account since the outbox call is permissionless', () => { + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, [ + otherEthAccountId, + ]) + + expect(action?.arbitrumBridgeMetadata.destinationAccountId).toBe(otherEthAccountId) + }) + + it('builds nothing without an ethereum account to claim from', () => { + expect( + buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, []), + ).toBeUndefined() + }) +}) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts new file mode 100644 index 00000000000..a19c2eecec8 --- /dev/null +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -0,0 +1,50 @@ +import type { AccountId } from '@shapeshiftoss/caip' +import { fromAccountId } from '@shapeshiftoss/caip' + +import { ClaimStatus } from '@/components/ClaimRow/types' +import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' +import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' + +export const getArbitrumBridgeWithdrawActionId = (withdrawTxHash: string): string => + `arbitrum-bridge-withdraw-${withdrawTxHash}` + +// The outbox call is permissionless, so any ethereum account can execute it when the destination is not ours +const getClaimDestinationAccountId = ( + destinationAddress: string, + ethAccountIds: AccountId[], +): AccountId | undefined => + ethAccountIds.find( + accountId => + fromAccountId(accountId).account.toLowerCase() === destinationAddress.toLowerCase(), + ) ?? ethAccountIds[0] + +export const buildArbitrumBridgeWithdrawActionFromClaim = ( + claim: ClaimDetails, + claimStatus: ClaimStatus, + ethAccountIds: AccountId[], +): ArbitrumBridgeWithdrawAction | undefined => { + const destinationAccountId = getClaimDestinationAccountId(claim.destinationAddress, ethAccountIds) + if (!destinationAccountId) return + + const createdAt = claim.tx.blockTime * 1000 + + return { + id: getArbitrumBridgeWithdrawActionId(claim.tx.txid), + type: ActionType.ArbitrumBridgeWithdraw, + status: + claimStatus === ClaimStatus.Available ? ActionStatus.ClaimAvailable : ActionStatus.Initiated, + createdAt, + updatedAt: createdAt, + arbitrumBridgeMetadata: { + withdrawTxHash: claim.tx.txid, + amountCryptoBaseUnit: claim.amountCryptoBaseUnit, + assetId: claim.assetId, + destinationAssetId: claim.destinationAssetId, + accountId: claim.accountId, + destinationAccountId, + timeRemainingSeconds: claim.timeRemainingSeconds, + claimDetails: claim, + }, + } +} diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index d1c2b1f032d..c25edb42fac 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -1,22 +1,28 @@ import { usePrevious } from '@chakra-ui/react' -import { ethChainId } from '@shapeshiftoss/caip' +import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' import { SwapperName } from '@shapeshiftoss/swapper' import { isSome } from '@shapeshiftoss/utils' -import { uuidv4 } from '@walletconnect/utils' -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNotificationToast } from '../useNotificationToast' +import { + buildArbitrumBridgeWithdrawActionFromClaim, + getArbitrumBridgeWithdrawActionId, +} from './arbitrumBridgeWithdrawAction' +import { ClaimStatus } from '@/components/ClaimRow/types' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' import { useArbitrumClaimsByStatus } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' +import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus, ActionType, isArbitrumBridgeWithdrawAction, isSwapAction, } from '@/state/slices/actionSlice/types' +import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { swapSlice } from '@/state/slices/swapSlice/swapSlice' import { useAppDispatch, useAppSelector } from '@/state/store' @@ -24,9 +30,27 @@ export const useArbitrumWithdrawalActionSubscriber = () => { const dispatch = useAppDispatch() const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) const swapsById = useAppSelector(swapSlice.selectors.selectSwapsById) + const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const { claimsByStatus } = useArbitrumClaimsByStatus() const translate = useTranslate() + const ethAccountIds = useMemo( + () => + enabledWalletAccountIds.filter(accountId => fromAccountId(accountId).chainId === ethChainId), + [enabledWalletAccountIds], + ) + + const arbitrumActionsByWithdrawTxHash = useMemo( + () => + Object.values(actionsById) + .filter(isArbitrumBridgeWithdrawAction) + .reduce>((acc, action) => { + acc[action.arbitrumBridgeMetadata.withdrawTxHash] = action + return acc + }, {}), + [actionsById], + ) + const { isDrawerOpen } = useActionCenterContext() const toastOptions = useMemo(() => ({ duration: isDrawerOpen ? 5000 : null }), [isDrawerOpen]) const toast = useNotificationToast(toastOptions) @@ -58,14 +82,8 @@ export const useArbitrumWithdrawalActionSubscriber = () => { ) return - // Check if ArbitrumBridge withdraw action already exists // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 - const existingAction = Object.values(actionsById).find( - action => - action.type === ActionType.ArbitrumBridgeWithdraw && - action.arbitrumBridgeMetadata?.withdrawTxHash === swap.sellTxHash, - ) - if (existingAction) return + if (arbitrumActionsByWithdrawTxHash[swap.sellTxHash]) return // Get real-time ETA from claims hook - use fallback if not available yet // Chicken and egg: we need an ETA to upsert the action, but we need an action to check the ETA @@ -73,7 +91,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { dispatch( actionSlice.actions.upsertAction({ - id: uuidv4(), + id: getArbitrumBridgeWithdrawActionId(swap.sellTxHash), createdAt: Date.now(), updatedAt: Date.now(), type: ActionType.ArbitrumBridgeWithdraw as const, @@ -91,7 +109,69 @@ export const useArbitrumWithdrawalActionSubscriber = () => { }), ) }) - }, [actionsById, swapsById, dispatch, claimsByStatus]) + }, [actionsById, swapsById, dispatch, claimsByStatus, arbitrumActionsByWithdrawTxHash]) + + const notifyClaimAvailable = useCallback( + (actionId: string) => { + if (toast.isActive(actionId)) return + + toast({ + id: actionId, + status: 'success', + title: translate('bridge.bridgeWithdrawalReadyNotification'), + description: translate('bridge.checkActionCenterNotification'), + position: 'bottom-right', + }) + }, + [toast, translate], + ) + + // Recreate withdraw actions from the claims tx history knows about, so a wiped store, another browser, + // or a withdrawal made outside the app still surfaces here. Completed claims have nothing left to do. + useEffect(() => { + if (!ethAccountIds.length) return + + const claims = [ + ...claimsByStatus.Pending.map(claim => ({ claim, claimStatus: ClaimStatus.Pending })), + ...claimsByStatus.Available.map(claim => ({ claim, claimStatus: ClaimStatus.Available })), + ] + + claims.forEach(({ claim, claimStatus }) => { + const existingAction = arbitrumActionsByWithdrawTxHash[claim.tx.txid] + + if (existingAction) { + // Swap-created actions predating the wallet filter may carry an empty account + if (existingAction.arbitrumBridgeMetadata.accountId) return + + dispatch( + actionSlice.actions.upsertAction({ + ...existingAction, + arbitrumBridgeMetadata: { + ...existingAction.arbitrumBridgeMetadata, + accountId: claim.accountId, + }, + }), + ) + return + } + + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, claimStatus, ethAccountIds) + if (!action) return + + dispatch(actionSlice.actions.upsertAction(action)) + + if (action.status === ActionStatus.ClaimAvailable) notifyClaimAvailable(action.id) + }) + // claimsByStatus arrays are recreated on every render, use length for stable references + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + dispatch, + ethAccountIds, + arbitrumActionsByWithdrawTxHash, + notifyClaimAvailable, + claimsByStatus.Pending.length, + claimsByStatus.Available.length, + ]) const pendingArbitrumBridgeActions = useMemo(() => { return Object.values(actionsById) @@ -194,16 +274,8 @@ export const useArbitrumWithdrawalActionSubscriber = () => { }), ) - // Show notification when status changes to ClaimAvailable if (previousStatus !== newStatus && newStatus === ActionStatus.ClaimAvailable) { - if (!toast.isActive(update.action.id)) { - toast({ - status: 'success', - title: translate('bridge.bridgeWithdrawalReadyNotification'), - description: translate('bridge.checkActionCenterNotification'), - position: 'bottom-right', - }) - } + notifyClaimAvailable(update.action.id) } }) } catch (error) { @@ -213,8 +285,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dispatch, - toast, - translate, + notifyClaimAvailable, pendingArbitrumBridgeActions, claimsByStatus.Available.length, claimsByStatus.Complete.length, diff --git a/src/state/slices/actionSlice/selectors.test.ts b/src/state/slices/actionSlice/selectors.test.ts index ef564268cd0..013dc36b30f 100644 --- a/src/state/slices/actionSlice/selectors.test.ts +++ b/src/state/slices/actionSlice/selectors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { selectYieldActionsByTxHash } from './selectors' -import type { ActionState, GenericTransactionAction } from './types' +import { selectWalletActions, selectYieldActionsByTxHash } from './selectors' +import type { ActionState, ArbitrumBridgeWithdrawAction, GenericTransactionAction } from './types' import { ActionStatus, ActionType, GenericTransactionDisplayType } from './types' const mockYieldDepositAction: GenericTransactionAction = { @@ -108,3 +108,30 @@ describe('selectYieldActionsByTxHash', () => { expect(result).toEqual({}) }) }) + +describe('selectWalletActions', () => { + const arbitrumWithdrawAction: ArbitrumBridgeWithdrawAction = { + id: 'arbitrum-bridge-withdraw-0xwithdraw', + type: ActionType.ArbitrumBridgeWithdraw, + status: ActionStatus.ClaimAvailable, + createdAt: 1700000000000, + updatedAt: 1700000000000, + arbitrumBridgeMetadata: { + withdrawTxHash: '0xwithdraw', + amountCryptoBaseUnit: '1000', + assetId: 'eip155:42161/slip44:60', + destinationAssetId: 'eip155:1/slip44:60', + accountId: 'eip155:42161:0xarb', + destinationAccountId: 'eip155:1:0xarb', + }, + } + + it('shows an arbitrum withdraw only to the wallet that made it', () => { + expect( + selectWalletActions.resultFunc([arbitrumWithdrawAction], ['eip155:42161:0xarb'], {}), + ).toEqual([arbitrumWithdrawAction]) + expect( + selectWalletActions.resultFunc([arbitrumWithdrawAction], ['eip155:42161:0xother'], {}), + ).toEqual([]) + }) +}) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 1636f8db5b1..d2e27229900 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -86,6 +86,10 @@ export const selectWalletActions = createDeepEqualOutputSelector( return enabledWalletAccountIds.includes(action.chainflipLendingMetadata.accountId) } + if (isArbitrumBridgeWithdrawAction(action)) { + return enabledWalletAccountIds.includes(action.arbitrumBridgeMetadata.accountId) + } + return action }) }, From d40a4cad63a59b835d7b84f60e4bff6db19de6a5 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:10:38 -0600 Subject: [PATCH 02/40] fix(arbitrum-bridge): claim from the live poll and show the real destination The stored claim details are plain JSON after a reload, so the SDK message that builds the outbox proof has to come from the live claims poll. The receive address row now shows the withdrawal's destination rather than the account paying for the claim, which can differ. Co-Authored-By: Claude Fable 5.1 --- .../components/ArbitrumBridgeClaimModal.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 42f130032e7..bcda96cbb79 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -19,6 +19,7 @@ import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' +import { useArbitrumClaimsByStatus } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import { useArbitrumClaimTx } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx' import { Row } from '@/components/Row/Row' import { useModalRegistration } from '@/context/ModalStackProvider' @@ -48,7 +49,7 @@ export const ArbitrumBridgeClaimModal = ({ }: ArbitrumBridgeClaimModalProps) => { const translate = useTranslate() const dispatch = useAppDispatch() - const claimDetails = action.arbitrumBridgeMetadata.claimDetails + const { withdrawTxHash, destinationAccountId } = action.arbitrumBridgeMetadata const isClaimAvailable = action.status === ActionStatus.ClaimAvailable const isClaimCompleted = action.status === ActionStatus.Claimed @@ -67,7 +68,19 @@ export const ArbitrumBridgeClaimModal = ({ selectMarketDataByAssetIdUserCurrency(state, action.arbitrumBridgeMetadata.destinationAssetId), ) - const destinationAccountId = action.arbitrumBridgeMetadata.destinationAccountId + // The stored claim is plain JSON after a reload, so the SDK message that builds the outbox proof + // has to come from the live poll + const { claimsByStatus } = useArbitrumClaimsByStatus() + const claimDetails = useMemo( + () => claimsByStatus.Available.find(claim => claim.tx.txid === withdrawTxHash), + [claimsByStatus.Available, withdrawTxHash], + ) + + // The claim may be paid for by any of our ethereum accounts, but the funds land at the withdrawal's destination + const destinationAddress = + claimDetails?.destinationAddress ?? + action.arbitrumBridgeMetadata.claimDetails?.destinationAddress ?? + fromAccountId(destinationAccountId).account const destinationFeeAsset = useAppSelector(state => selectFeeAssetByChainId( @@ -209,11 +222,7 @@ export const ArbitrumBridgeClaimModal = ({ {translate('bridge.claimReceiveAddress')} - - {middleEllipsis( - fromAccountId(action.arbitrumBridgeMetadata.destinationAccountId).account, - )} - + {middleEllipsis(destinationAddress)} {translate('common.gasFee')} From f962313fbb910c0978e3a1995e1909e00e6fd299 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:15:21 -0600 Subject: [PATCH 03/40] fix(arbitrum-bridge): keep a claim when its time estimate fails, link the withdraw tx Estimating when a pending withdrawal becomes claimable scans a challenge period of L1 logs, which public RPCs can refuse. That failure took the whole claim with it, so a fresh withdrawal never surfaced. Fall back to the challenge period instead and log the failure. Claimable cards now link the withdraw tx so a claim can be checked against the explorer. Co-Authored-By: Claude Fable 5.1 --- .../ArbitrumBridgeWithdrawActionCard.tsx | 26 +++++++++++----- .../Claim/hooks/useArbitrumClaimsByStatus.tsx | 30 ++++++++++++------- .../useArbitrumWithdrawalActionSubscriber.tsx | 8 +++-- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index d872de77ce5..6daa766f015 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -154,14 +154,24 @@ export const ArbitrumBridgeWithdrawActionCard = ({ )} {action.status === ActionStatus.ClaimAvailable && ( - - {translate('actionCenter.bridge.claimWithdraw')} - - - - + <> + + {translate('actionCenter.bridge.withdrawTx')} + + + + + + + + {translate('actionCenter.bridge.claimWithdraw')} + + + + + )} {action.status === ActionStatus.Claimed && ( <> diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx index c6f4bdc503e..8d6dca7c822 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx @@ -21,6 +21,8 @@ import type { Tx } from '@/state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from '@/state/store' const AVERAGE_BLOCK_TIME_BLOCKS = 1000 +// The rollup's challenge period, what a withdrawal waits when nothing better is known +export const ARBITRUM_WITHDRAW_ETA_SECONDS = 6.4 * 24 * 60 * 60 type ClaimStatusResult = { event: ChildToParentTransactionEvent @@ -76,17 +78,25 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { const event = events[0] const message = messages[0] const status = await message.status(l2Provider) - const block = (await message.getFirstExecutableBlock(l2Provider))?.toNumber() + // The ETA scans a challenge period of L1 logs, which public RPCs can refuse; a claim + // must not disappear because its estimate failed const timeRemainingSeconds = await (async () => { - if (!block) return - const latestBlock = await l1Provider.getBlock('latest') - const historicalBlock = await l1Provider.getBlock( - latestBlock.number - AVERAGE_BLOCK_TIME_BLOCKS, - ) - const averageBlockTimeSeconds = - (latestBlock.timestamp - historicalBlock.timestamp) / AVERAGE_BLOCK_TIME_BLOCKS - const remainingBlocks = block - latestBlock.number - return remainingBlocks * averageBlockTimeSeconds + if (status !== ChildToParentMessageStatus.UNCONFIRMED) return + try { + const block = (await message.getFirstExecutableBlock(l2Provider))?.toNumber() + if (!block) return + const latestBlock = await l1Provider.getBlock('latest') + const historicalBlock = await l1Provider.getBlock( + latestBlock.number - AVERAGE_BLOCK_TIME_BLOCKS, + ) + const averageBlockTimeSeconds = + (latestBlock.timestamp - historicalBlock.timestamp) / AVERAGE_BLOCK_TIME_BLOCKS + const remainingBlocks = block - latestBlock.number + return remainingBlocks * averageBlockTimeSeconds + } catch (err) { + console.error(`[arbitrum] failed to estimate the claim time of ${tx.txid}`, err) + return ARBITRUM_WITHDRAW_ETA_SECONDS + } })() return { event, diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index c25edb42fac..10faff1f4f4 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -13,7 +13,10 @@ import { import { ClaimStatus } from '@/components/ClaimRow/types' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' -import { useArbitrumClaimsByStatus } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import { + ARBITRUM_WITHDRAW_ETA_SECONDS, + useArbitrumClaimsByStatus, +} from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { @@ -103,7 +106,8 @@ export const useArbitrumWithdrawalActionSubscriber = () => { destinationAssetId: swap.buyAsset.assetId, accountId: swap.sellAccountId ?? '', destinationAccountId: swap.buyAccountId ?? '', - timeRemainingSeconds: claimDetails?.timeRemainingSeconds ?? 6.4 * 24 * 60 * 60, + timeRemainingSeconds: + claimDetails?.timeRemainingSeconds ?? ARBITRUM_WITHDRAW_ETA_SECONDS, claimDetails, }, }), From 3bb12425be54648a014bc7a4ba9e4ef90930940b Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:18:54 -0600 Subject: [PATCH 04/40] feat(action-center): anchor in-flight actions on top in the order they started Sorting by last update let polling and recreation shuffle pending cards to the top on every reload. Active actions now stay on top ordered by when they started, settled ones follow by when they settled, and the bridge withdraw card shows its start time. Co-Authored-By: Claude Fable 5.1 --- .../ArbitrumBridgeWithdrawActionCard.tsx | 2 +- .../slices/actionSlice/selectors.test.ts | 47 ++++++++++++++++++- src/state/slices/actionSlice/selectors.ts | 18 ++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 6daa766f015..a9a73372a56 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -52,7 +52,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ ), ) - const formattedDate = useMemo(() => formatSmartDate(action.updatedAt), [action.updatedAt]) + const formattedDate = useMemo(() => formatSmartDate(action.createdAt), [action.createdAt]) const isCollapsable = action.status === ActionStatus.ClaimAvailable || action.status === ActionStatus.Claimed const { isOpen, onToggle } = useDisclosure({ diff --git a/src/state/slices/actionSlice/selectors.test.ts b/src/state/slices/actionSlice/selectors.test.ts index 013dc36b30f..a95957519dd 100644 --- a/src/state/slices/actionSlice/selectors.test.ts +++ b/src/state/slices/actionSlice/selectors.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { selectWalletActions, selectYieldActionsByTxHash } from './selectors' +import { + selectWalletActions, + selectWalletActionsSorted, + selectYieldActionsByTxHash, +} from './selectors' import type { ActionState, ArbitrumBridgeWithdrawAction, GenericTransactionAction } from './types' import { ActionStatus, ActionType, GenericTransactionDisplayType } from './types' @@ -135,3 +139,44 @@ describe('selectWalletActions', () => { ).toEqual([]) }) }) + +describe('selectWalletActionsSorted', () => { + const action = ( + id: string, + status: ActionStatus, + createdAt: number, + updatedAt: number, + ): GenericTransactionAction => ({ + ...mockSendAction, + id, + status, + createdAt, + updatedAt, + }) + + it('anchors in-flight actions on top by start time, then settled ones by last update', () => { + const oldPending = action('old-pending', ActionStatus.Pending, 100, 900) + const newPending = action('new-pending', ActionStatus.Initiated, 300, 300) + const claimable = action('claimable', ActionStatus.ClaimAvailable, 200, 950) + const recentlyDone = action('recently-done', ActionStatus.Complete, 150, 800) + const longDone = action('long-done', ActionStatus.Claimed, 400, 500) + const abandoned = action('abandoned', ActionStatus.Abandoned, 999, 999) + + const sorted = selectWalletActionsSorted.resultFunc([ + recentlyDone, + oldPending, + abandoned, + longDone, + claimable, + newPending, + ]) + + expect(sorted.map(a => a.id)).toEqual([ + 'new-pending', + 'claimable', + 'old-pending', + 'recently-done', + 'long-done', + ]) + }) +}) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index d2e27229900..d5f51f1ea0c 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -95,6 +95,17 @@ export const selectWalletActions = createDeepEqualOutputSelector( }, ) +const ACTIVE_ACTION_STATUSES = new Set([ + ActionStatus.AwaitingApproval, + ActionStatus.AwaitingSwap, + ActionStatus.Pending, + ActionStatus.Initiated, + ActionStatus.ClaimAvailable, + ActionStatus.Open, +]) + +// Anything still in flight stays on top, in the order it was started, since its updates only track +// polling. Settled actions follow, most recently settled first. export const selectWalletActionsSorted = createDeepEqualOutputSelector( selectWalletActions, actions => { @@ -102,7 +113,12 @@ export const selectWalletActionsSorted = createDeepEqualOutputSelector( .filter( action => action.status !== ActionStatus.Idle && action.status !== ActionStatus.Abandoned, ) - .sort((a, b) => b.updatedAt - a.updatedAt) + .sort((a, b) => { + const aIsActive = ACTIVE_ACTION_STATUSES.has(a.status) + const bIsActive = ACTIVE_ACTION_STATUSES.has(b.status) + if (aIsActive !== bIsActive) return aIsActive ? -1 : 1 + return aIsActive ? b.createdAt - a.createdAt : b.updatedAt - a.updatedAt + }) }, ) From ddfbe9407321078bcf64edd0e54840ff27c60418 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:21:52 -0600 Subject: [PATCH 05/40] style(action-center): pad the expanded card body below its divider Co-Authored-By: Claude Fable 5.1 --- .../Layout/Header/ActionCenter/components/ActionCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx index a44781b0a37..476fa89335e 100644 --- a/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx @@ -98,7 +98,7 @@ export const ActionCard = ({ {isCollapsable && ( - + {children} From 000f75dd775097f2b6f854591b51d062c290e484 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:22:01 -0600 Subject: [PATCH 06/40] style(arbitrum-bridge): collapse claimable withdraw cards by default Co-Authored-By: Claude Fable 5.1 --- .../components/ArbitrumBridgeWithdrawActionCard.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index a9a73372a56..e7e81daca5d 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -55,9 +55,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ const formattedDate = useMemo(() => formatSmartDate(action.createdAt), [action.createdAt]) const isCollapsable = action.status === ActionStatus.ClaimAvailable || action.status === ActionStatus.Claimed - const { isOpen, onToggle } = useDisclosure({ - defaultIsOpen: action.status === ActionStatus.ClaimAvailable, - }) + const { isOpen, onToggle } = useDisclosure() const handleClaimClick = useCallback( (e: React.MouseEvent) => { From da59104ecf38288e3c733a2a47b38821798417cb Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:22:31 -0600 Subject: [PATCH 07/40] style(action-center): separate the expanded card body with a border, not a card shadow The collapse body was a transparent Card whose elevation shadow read as a curved rule under the header. Co-Authored-By: Claude Fable 5.1 --- .../Header/ActionCenter/components/ActionCard.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx index 476fa89335e..70a7cacf47d 100644 --- a/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ActionCard.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons' -import { Card, CardBody, Collapse, Flex, HStack, Icon, Stack } from '@chakra-ui/react' +import { Box, Collapse, Flex, HStack, Icon, Stack } from '@chakra-ui/react' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback, useMemo } from 'react' @@ -97,11 +97,9 @@ export const ActionCard = ({ {isCollapsable && ( - - - {children} - - + + {children} + )} From ac5eed443b306eeb8663820789a769b1a53b3880 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:25:33 -0600 Subject: [PATCH 08/40] fix(action-center): date every card from the same timestamp the drawer sorts by Cards showed their last update, so a claim recreated after a cache clear read as minutes old while sorting by its real start. One helper now picks the start time for in-flight actions and the settle time for the rest, used by the sort and every card. Co-Authored-By: Claude Fable 5.1 --- .../components/AppUpdateActionCard.tsx | 5 +++-- .../ArbitrumBridgeWithdrawActionCard.tsx | 8 ++++++-- .../components/ChainflipLendingActionCard.tsx | 10 +++++++--- .../ActionCenter/components/ClaimActionCard.tsx | 6 +++--- .../components/GenericTransactionActionCard.tsx | 5 +++-- .../components/LimitOrderActionCard.tsx | 8 ++++---- .../components/RewardDistributionActionCard.tsx | 10 +++++++--- .../ActionCenter/components/SwapActionCard.tsx | 10 +++++++--- src/state/slices/actionSlice/selectors.ts | 17 +++++------------ src/state/slices/actionSlice/types.ts | 17 +++++++++++++++++ 10 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/AppUpdateActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/AppUpdateActionCard.tsx index ae19c30f29d..2431b73634e 100644 --- a/src/components/Layout/Header/ActionCenter/components/AppUpdateActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/AppUpdateActionCard.tsx @@ -10,6 +10,7 @@ import UpdateIcon from '@/assets/update-icon.svg?url' import { Text } from '@/components/Text/Text' import { formatSmartDate } from '@/lib/utils/time' import type { AppUpdateAction } from '@/state/slices/actionSlice/types' +import { getActionTimestamp } from '@/state/slices/actionSlice/types' dayjs.extend(relativeTime) @@ -23,8 +24,8 @@ export const AppUpdateActionCard = ({ action }: AppUpdateActionCardProps) => { const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }) const formattedDate = useMemo(() => { - return formatSmartDate(action.updatedAt) - }, [action.updatedAt]) + return formatSmartDate(getActionTimestamp(action)) + }, [action]) const handleUpdate = useCallback(() => { window.location.reload() diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index e7e81daca5d..6e33d375208 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -18,7 +18,11 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import { getTxLink } from '@/lib/getTxLink' import { formatSecondsToDuration, formatSmartDate } from '@/lib/utils/time' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' -import { ActionStatus, GenericTransactionDisplayType } from '@/state/slices/actionSlice/types' +import { + ActionStatus, + GenericTransactionDisplayType, + getActionTimestamp, +} from '@/state/slices/actionSlice/types' import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -52,7 +56,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ ), ) - const formattedDate = useMemo(() => formatSmartDate(action.createdAt), [action.createdAt]) + const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const isCollapsable = action.status === ActionStatus.ClaimAvailable || action.status === ActionStatus.Claimed const { isOpen, onToggle } = useDisclosure() diff --git a/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx index 979a28960f8..cc31172efd3 100644 --- a/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ChainflipLendingActionCard.tsx @@ -16,7 +16,11 @@ import { Text } from '@/components/Text/Text' import { middleEllipsis } from '@/lib/utils' import { formatSmartDate } from '@/lib/utils/time' import type { ChainflipLendingAction } from '@/state/slices/actionSlice/types' -import { ActionStatus, ChainflipLendingOperationType } from '@/state/slices/actionSlice/types' +import { + ActionStatus, + ChainflipLendingOperationType, + getActionTimestamp, +} from '@/state/slices/actionSlice/types' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -34,8 +38,8 @@ export const ChainflipLendingActionCard = ({ action }: ChainflipLendingActionCar const asset = useAppSelector(state => selectAssetById(state, chainflipLendingMetadata.assetId)) const formattedDate = useMemo(() => { - return formatSmartDate(action.updatedAt) - }, [action.updatedAt]) + return formatSmartDate(getActionTimestamp(action)) + }, [action]) const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: action.status === ActionStatus.Pending, diff --git a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx index b355149a1fc..75941868ff0 100644 --- a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx @@ -18,7 +18,7 @@ import type { RfoxClaimAction, TcyClaimAction, } from '@/state/slices/actionSlice/types' -import { ActionStatus } from '@/state/slices/actionSlice/types' +import { ActionStatus, getActionTimestamp } from '@/state/slices/actionSlice/types' import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -68,14 +68,14 @@ export const ClaimActionCard = ({ const formattedDate = useMemo(() => { const now = dayjs() - const notificationDate = dayjs(action.updatedAt) + const notificationDate = dayjs(getActionTimestamp(action)) const sevenDaysAgo = now.subtract(7, 'day') if (notificationDate.isAfter(sevenDaysAgo)) { return notificationDate.fromNow() } else { return notificationDate.toDate().toLocaleString() } - }, [action.updatedAt]) + }, [action]) const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: false }) diff --git a/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx index 1d61eab92e5..c37723c0f8d 100644 --- a/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx @@ -21,6 +21,7 @@ import { ActionStatus, ActionType, GenericTransactionDisplayType, + getActionTimestamp, } from '@/state/slices/actionSlice/types' import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/assetsSlice/selectors' import { useAppSelector } from '@/state/store' @@ -67,14 +68,14 @@ export const GenericTransactionActionCard = ({ const formattedDate = useMemo(() => { const now = dayjs() - const notificationDate = dayjs(action.updatedAt) + const notificationDate = dayjs(getActionTimestamp(action)) const sevenDaysAgo = now.subtract(7, 'day') if (notificationDate.isAfter(sevenDaysAgo)) { return notificationDate.fromNow() } else { return notificationDate.toDate().toLocaleString() } - }, [action.updatedAt]) + }, [action]) const txLink = useMemo(() => { if (!feeAsset) return diff --git a/src/components/Layout/Header/ActionCenter/components/LimitOrderActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/LimitOrderActionCard.tsx index 0647cf4427e..9b73e17afa7 100644 --- a/src/components/Layout/Header/ActionCenter/components/LimitOrderActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/LimitOrderActionCard.tsx @@ -16,7 +16,7 @@ import type { TextPropTypes } from '@/components/Text/Text' import { Text } from '@/components/Text/Text' import { formatSmartDate } from '@/lib/utils/time' import type { LimitOrderAction } from '@/state/slices/actionSlice/types' -import { ActionStatus } from '@/state/slices/actionSlice/types' +import { ActionStatus, getActionTimestamp } from '@/state/slices/actionSlice/types' dayjs.extend(relativeTime) @@ -36,11 +36,11 @@ export const LimitOrderActionCard = ({ action, }: NotificationCardProps) => { const { isOpen, onToggle } = useDisclosure({ defaultIsOpen }) - const { updatedAt, status, type } = action + const { status, type } = action const formattedDate = useMemo(() => { - return formatSmartDate(updatedAt) - }, [updatedAt]) + return formatSmartDate(getActionTimestamp(action)) + }, [action]) const limitOrderActionTranslationComponents: TextPropTypes['components'] = useMemo(() => { if (!action) return diff --git a/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx index a32f3a77f09..af6036e3ce5 100644 --- a/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx @@ -14,7 +14,11 @@ import { Text } from '@/components/Text/Text' import { getTxLink } from '@/lib/getTxLink' import { getRewardAssetId, maybeGetStakingAssetId } from '@/pages/RFOX/helpers' import type { RewardDistributionAction } from '@/state/slices/actionSlice/types' -import { ActionStatus, GenericTransactionDisplayType } from '@/state/slices/actionSlice/types' +import { + ActionStatus, + GenericTransactionDisplayType, + getActionTimestamp, +} from '@/state/slices/actionSlice/types' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -35,8 +39,8 @@ export const RewardDistributionActionCard = ({ action }: RewardDistributionActio const rewardAsset = useAppSelector(state => selectAssetById(state, rewardAssetId ?? '')) const formattedDate = useMemo(() => { - return dayjs(action.updatedAt).fromNow() - }, [action.updatedAt]) + return dayjs(getActionTimestamp(action)).fromNow() + }, [action]) const rewardDistributionTranslationComponents: TextPropTypes['components'] = useMemo(() => { if (!rewardAsset) return diff --git a/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx index 003527c28b4..4234593f288 100644 --- a/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx @@ -22,7 +22,11 @@ import { Text } from '@/components/Text/Text' import { useActualBuyAmountCryptoPrecision } from '@/hooks/useActualBuyAmountCryptoPrecision' import { formatSmartDate } from '@/lib/utils/time' import type { SwapAction } from '@/state/slices/actionSlice/types' -import { ActionStatus, GenericTransactionDisplayType } from '@/state/slices/actionSlice/types' +import { + ActionStatus, + GenericTransactionDisplayType, + getActionTimestamp, +} from '@/state/slices/actionSlice/types' import { swapSlice } from '@/state/slices/swapSlice/swapSlice' import { useAppSelector } from '@/state/store' @@ -37,8 +41,8 @@ export const SwapActionCard = ({ action, isCollapsable = false }: SwapActionCard const swapsById = useAppSelector(swapSlice.selectors.selectSwapsById) const formattedDate = useMemo(() => { - return formatSmartDate(action.updatedAt) - }, [action.updatedAt]) + return formatSmartDate(getActionTimestamp(action)) + }, [action]) const swap = useMemo(() => { return swapsById[action.swapMetadata.swapId] diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index d5f51f1ea0c..dbc3f52d565 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -15,6 +15,8 @@ import { ActionStatus, ActionType, GenericTransactionDisplayType, + getActionTimestamp, + isActiveActionStatus, isArbitrumBridgeWithdrawAction, isChainflipLendingAction, isGenericTransactionAction, @@ -95,15 +97,6 @@ export const selectWalletActions = createDeepEqualOutputSelector( }, ) -const ACTIVE_ACTION_STATUSES = new Set([ - ActionStatus.AwaitingApproval, - ActionStatus.AwaitingSwap, - ActionStatus.Pending, - ActionStatus.Initiated, - ActionStatus.ClaimAvailable, - ActionStatus.Open, -]) - // Anything still in flight stays on top, in the order it was started, since its updates only track // polling. Settled actions follow, most recently settled first. export const selectWalletActionsSorted = createDeepEqualOutputSelector( @@ -114,10 +107,10 @@ export const selectWalletActionsSorted = createDeepEqualOutputSelector( action => action.status !== ActionStatus.Idle && action.status !== ActionStatus.Abandoned, ) .sort((a, b) => { - const aIsActive = ACTIVE_ACTION_STATUSES.has(a.status) - const bIsActive = ACTIVE_ACTION_STATUSES.has(b.status) + const aIsActive = isActiveActionStatus(a.status) + const bIsActive = isActiveActionStatus(b.status) if (aIsActive !== bIsActive) return aIsActive ? -1 : 1 - return aIsActive ? b.createdAt - a.createdAt : b.updatedAt - a.updatedAt + return getActionTimestamp(b) - getActionTimestamp(a) }) }, ) diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index a96be384916..e639a5e4bc2 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -295,6 +295,23 @@ export const isPendingSendAction = (action: Action): action is GenericTransactio return Boolean(isSendAction(action) && action.status === ActionStatus.Pending) } +const ACTIVE_ACTION_STATUSES = new Set([ + ActionStatus.AwaitingApproval, + ActionStatus.AwaitingSwap, + ActionStatus.Pending, + ActionStatus.Initiated, + ActionStatus.ClaimAvailable, + ActionStatus.Open, +]) + +export const isActiveActionStatus = (status: ActionStatus): boolean => + ACTIVE_ACTION_STATUSES.has(status) + +// An action in flight is dated from when it started, since its updates only track polling; +// a settled one from when it settled +export const getActionTimestamp = (action: Action): number => + isActiveActionStatus(action.status) ? action.createdAt : action.updatedAt + export const isArbitrumBridgeWithdrawAction = ( action: Action, ): action is ArbitrumBridgeWithdrawAction => { From 383f559affdfc2e8f0a2459407aaaa268aadc05b Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:32:37 -0600 Subject: [PATCH 09/40] style(action-center): collapse reward distribution cards by default Co-Authored-By: Claude Fable 5.1 --- .../ActionCenter/components/RewardDistributionActionCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx index af6036e3ce5..b3679eeb07f 100644 --- a/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx @@ -28,7 +28,7 @@ type RewardDistributionActionCardProps = { export const RewardDistributionActionCard = ({ action }: RewardDistributionActionCardProps) => { const translate = useTranslate() - const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }) + const { isOpen, onToggle } = useDisclosure() const { distribution } = action.rewardDistributionMetadata const rewardAssetId = useMemo(() => { From a0669b12b1f64f3249f3a1297b1086b3383a3457 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:55:28 -0600 Subject: [PATCH 10/40] fix(arbitrum-bridge): never store empty withdraw accounts, drop rebuild toasts, show claim loading Co-Authored-By: Claude Opus 5.5 --- .../components/ArbitrumBridgeClaimModal.tsx | 4 +-- .../useArbitrumWithdrawalActionSubscriber.tsx | 26 +++---------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index bcda96cbb79..2f4a5fe48db 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -227,7 +227,7 @@ export const ArbitrumBridgeClaimModal = ({ {translate('common.gasFee')} - + @@ -250,7 +250,7 @@ export const ArbitrumBridgeClaimModal = ({ claimMutation?.isPending || !hasEnoughDestinationFeeBalance } - isLoading={evmFeesResult?.isFetching || claimMutation?.isPending} + isLoading={!claimDetails || evmFeesResult?.isFetching || claimMutation?.isPending} onClick={onConfirm} > {confirmCopy} diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 10faff1f4f4..7718951501a 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -80,6 +80,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { const swap = swapsById[swapAction.swapMetadata.swapId] if ( !swap?.sellTxHash || + !swap.buyAccountId || swap.swapperName !== SwapperName.ArbitrumBridge || swap.buyAsset.chainId !== ethChainId ) @@ -104,8 +105,8 @@ export const useArbitrumWithdrawalActionSubscriber = () => { amountCryptoBaseUnit: swap.sellAmountCryptoBaseUnit, assetId: swap.sellAsset.assetId, destinationAssetId: swap.buyAsset.assetId, - accountId: swap.sellAccountId ?? '', - destinationAccountId: swap.buyAccountId ?? '', + accountId: swap.sellAccountId, + destinationAccountId: swap.buyAccountId, timeRemainingSeconds: claimDetails?.timeRemainingSeconds ?? ARBITRUM_WITHDRAW_ETA_SECONDS, claimDetails, @@ -141,30 +142,12 @@ export const useArbitrumWithdrawalActionSubscriber = () => { ] claims.forEach(({ claim, claimStatus }) => { - const existingAction = arbitrumActionsByWithdrawTxHash[claim.tx.txid] - - if (existingAction) { - // Swap-created actions predating the wallet filter may carry an empty account - if (existingAction.arbitrumBridgeMetadata.accountId) return - - dispatch( - actionSlice.actions.upsertAction({ - ...existingAction, - arbitrumBridgeMetadata: { - ...existingAction.arbitrumBridgeMetadata, - accountId: claim.accountId, - }, - }), - ) - return - } + if (arbitrumActionsByWithdrawTxHash[claim.tx.txid]) return const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, claimStatus, ethAccountIds) if (!action) return dispatch(actionSlice.actions.upsertAction(action)) - - if (action.status === ActionStatus.ClaimAvailable) notifyClaimAvailable(action.id) }) // claimsByStatus arrays are recreated on every render, use length for stable references // eslint-disable-next-line react-hooks/exhaustive-deps @@ -172,7 +155,6 @@ export const useArbitrumWithdrawalActionSubscriber = () => { dispatch, ethAccountIds, arbitrumActionsByWithdrawTxHash, - notifyClaimAvailable, claimsByStatus.Pending.length, claimsByStatus.Available.length, ]) From 8ced34d55363512737b74caf1d326063f7771f87 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:16:10 -0600 Subject: [PATCH 11/40] fix(arbitrum-bridge): time claims from the withdraw block instead of an l1 log scan Co-Authored-By: Claude Opus 5.5 --- .../ArbitrumBridgeWithdrawActionCard.tsx | 11 ++-- .../Notifications/SwapNotification.tsx | 10 ++- .../Claim/hooks/useArbitrumClaimsByStatus.tsx | 34 ++-------- .../arbitrumBridgeWithdrawAction.test.ts | 5 +- .../arbitrumBridgeWithdrawAction.ts | 3 +- .../useArbitrumWithdrawalActionSubscriber.tsx | 63 ++++++------------- src/state/slices/actionSlice/types.ts | 2 +- 7 files changed, 39 insertions(+), 89 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 6e33d375208..4d9fee6eae5 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -84,13 +84,10 @@ export const ArbitrumBridgeWithdrawActionCard = ({ const amountAndSymbol = `${buyAmountCryptoPrecision} ${buyAsset.symbol}` - // Calculate time remaining for initiated status - const timeRemaining = - action.arbitrumBridgeMetadata.claimDetails?.timeRemainingSeconds ?? - action.arbitrumBridgeMetadata.timeRemainingSeconds - const timeDisplay = - timeRemaining && timeRemaining > 0 ? formatSecondsToDuration(timeRemaining) : null - const timeText = timeDisplay ? `in ${timeDisplay}` : 'Available' + const { claimableAt } = action.arbitrumBridgeMetadata + const secondsUntilClaimable = claimableAt ? (claimableAt - Date.now()) / 1000 : 0 + const timeText = + secondsUntilClaimable > 0 ? `in ${formatSecondsToDuration(secondsUntilClaimable)}` : 'soon' switch (action.status) { case ActionStatus.Initiated: diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index 53d9b77d2a5..3bbf7ab7270 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -88,12 +88,10 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat } if (isArbitrumBridgeWithdraw) { - const timeRemaining = - action.arbitrumBridgeMetadata.claimDetails?.timeRemainingSeconds ?? - action.arbitrumBridgeMetadata.timeRemainingSeconds - const timeDisplay = - timeRemaining && timeRemaining > 0 ? formatSecondsToDuration(timeRemaining) : null - const timeText = timeDisplay ? `in ${timeDisplay}` : 'Available' + const { claimableAt } = action.arbitrumBridgeMetadata + const secondsUntilClaimable = claimableAt ? (claimableAt - Date.now()) / 1000 : 0 + const timeText = + secondsUntilClaimable > 0 ? `in ${formatSecondsToDuration(secondsUntilClaimable)}` : 'soon' const buyAmountCryptoPrecision = bnOrZero( actualBuyAmountCryptoPrecision ?? swap.expectedBuyAmountCryptoPrecision, diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx index 8d6dca7c822..fb054fb962a 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx @@ -20,15 +20,16 @@ import { import type { Tx } from '@/state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from '@/state/store' -const AVERAGE_BLOCK_TIME_BLOCKS = 1000 -// The rollup's challenge period, what a withdrawal waits when nothing better is known -export const ARBITRUM_WITHDRAW_ETA_SECONDS = 6.4 * 24 * 60 * 60 +// Challenge period from the withdrawal; the assertion that includes it can add up to an hour +const ARBITRUM_CHALLENGE_PERIOD_MS = 6.4 * 24 * 60 * 60 * 1000 + +export const getArbitrumClaimableAt = (withdrawTimeMs: number): number => + withdrawTimeMs + ARBITRUM_CHALLENGE_PERIOD_MS type ClaimStatusResult = { event: ChildToParentTransactionEvent message: ChildToParentMessageReader status: ChildToParentMessageStatus - timeRemainingSeconds: number | undefined } export type ClaimDetails = Omit & { @@ -78,34 +79,13 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { const event = events[0] const message = messages[0] const status = await message.status(l2Provider) - // The ETA scans a challenge period of L1 logs, which public RPCs can refuse; a claim - // must not disappear because its estimate failed - const timeRemainingSeconds = await (async () => { - if (status !== ChildToParentMessageStatus.UNCONFIRMED) return - try { - const block = (await message.getFirstExecutableBlock(l2Provider))?.toNumber() - if (!block) return - const latestBlock = await l1Provider.getBlock('latest') - const historicalBlock = await l1Provider.getBlock( - latestBlock.number - AVERAGE_BLOCK_TIME_BLOCKS, - ) - const averageBlockTimeSeconds = - (latestBlock.timestamp - historicalBlock.timestamp) / AVERAGE_BLOCK_TIME_BLOCKS - const remainingBlocks = block - latestBlock.number - return remainingBlocks * averageBlockTimeSeconds - } catch (err) { - console.error(`[arbitrum] failed to estimate the claim time of ${tx.txid}`, err) - return ARBITRUM_WITHDRAW_ETA_SECONDS - } - })() return { event, message, status, - timeRemainingSeconds, } }, - select: ({ event, message, status, timeRemainingSeconds }: ClaimStatusResult) => { + select: ({ event, message, status }: ClaimStatusResult) => { const claimStatus = (() => { switch (status) { case ChildToParentMessageStatus.CONFIRMED: @@ -123,7 +103,6 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { event, message, claimStatus, - timeRemainingSeconds, } }, // Periodically refetch until the status is known to be ChildToParentMessageStatus.EXECUTED @@ -160,7 +139,6 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { assetId: data.tx.transfers[0].assetId, event: data.event, message: data.message, - timeRemainingSeconds: data.timeRemainingSeconds, description: translate('bridge.arbitrum.description'), }) } diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts index efe5a828abb..9b8d1862ad7 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -22,7 +22,6 @@ const claim = { destinationAddress, destinationAssetId: ethAssetId, destinationChainId: ethChainId, - timeRemainingSeconds: 120, } as unknown as ClaimDetails describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { @@ -35,7 +34,7 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { expect(action?.type).toBe(ActionType.ArbitrumBridgeWithdraw) }) - it('maps a pending claim to an initiated action dated from the withdraw block', () => { + it('maps a pending claim to an initiated action dated and timed from the withdraw block', () => { const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Pending, [ destinationAccountId, ]) @@ -49,7 +48,7 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { destinationAssetId: ethAssetId, accountId: 'eip155:42161:0xarb', destinationAccountId, - timeRemainingSeconds: 120, + claimableAt: 1_700_000_000_000 + 6.4 * 24 * 60 * 60 * 1000, claimDetails: claim, }) }) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index a19c2eecec8..05f773f0492 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -3,6 +3,7 @@ import { fromAccountId } from '@shapeshiftoss/caip' import { ClaimStatus } from '@/components/ClaimRow/types' import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import { getArbitrumClaimableAt } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' @@ -43,7 +44,7 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( destinationAssetId: claim.destinationAssetId, accountId: claim.accountId, destinationAccountId, - timeRemainingSeconds: claim.timeRemainingSeconds, + claimableAt: getArbitrumClaimableAt(createdAt), claimDetails: claim, }, } diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 7718951501a..a79f7c6dbb8 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -14,7 +14,7 @@ import { import { ClaimStatus } from '@/components/ClaimRow/types' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' import { - ARBITRUM_WITHDRAW_ETA_SECONDS, + getArbitrumClaimableAt, useArbitrumClaimsByStatus, } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' @@ -89,8 +89,6 @@ export const useArbitrumWithdrawalActionSubscriber = () => { // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 if (arbitrumActionsByWithdrawTxHash[swap.sellTxHash]) return - // Get real-time ETA from claims hook - use fallback if not available yet - // Chicken and egg: we need an ETA to upsert the action, but we need an action to check the ETA const claimDetails = allClaims.find(claim => claim.tx.txid === swap.sellTxHash) dispatch( @@ -107,8 +105,9 @@ export const useArbitrumWithdrawalActionSubscriber = () => { destinationAssetId: swap.buyAsset.assetId, accountId: swap.sellAccountId, destinationAccountId: swap.buyAccountId, - timeRemainingSeconds: - claimDetails?.timeRemainingSeconds ?? ARBITRUM_WITHDRAW_ETA_SECONDS, + claimableAt: getArbitrumClaimableAt( + claimDetails ? claimDetails.tx.blockTime * 1000 : Date.now(), + ), claimDetails, }, }), @@ -190,44 +189,22 @@ export const useArbitrumWithdrawalActionSubscriber = () => { ) const currentMetadata = action.arbitrumBridgeMetadata + const matchedClaim = completedClaim ?? availableClaim ?? pendingClaim // Determine new action state from claim data - const newState = (() => { - if (completedClaim) { - return { - newStatus: ActionStatus.Claimed, - claimDetails: completedClaim, - timeRemainingSeconds: currentMetadata.timeRemainingSeconds, - claimTxHash: currentMetadata.claimTxHash, - } - } - - if (availableClaim) { - return { - newStatus: ActionStatus.ClaimAvailable, - claimDetails: availableClaim, - timeRemainingSeconds: availableClaim.timeRemainingSeconds, - claimTxHash: currentMetadata.claimTxHash, - } - } - - if (pendingClaim) { - return { - newStatus: ActionStatus.Initiated, - claimDetails: pendingClaim, - timeRemainingSeconds: pendingClaim.timeRemainingSeconds, - claimTxHash: currentMetadata.claimTxHash, - } - } - - // No changes - return current state - return { - newStatus: action.status, - claimDetails: currentMetadata.claimDetails, - timeRemainingSeconds: currentMetadata.timeRemainingSeconds, - claimTxHash: currentMetadata.claimTxHash, - } - })() + const newState = { + newStatus: (() => { + if (completedClaim) return ActionStatus.Claimed + if (availableClaim) return ActionStatus.ClaimAvailable + if (pendingClaim) return ActionStatus.Initiated + return action.status + })(), + claimDetails: matchedClaim ?? currentMetadata.claimDetails, + claimableAt: matchedClaim + ? getArbitrumClaimableAt(matchedClaim.tx.blockTime * 1000) + : currentMetadata.claimableAt, + claimTxHash: currentMetadata.claimTxHash, + } // Check if action state changed - use deep comparison for objects // once again, paranoia against this bad boi https://github.com/shapeshift/web/pull/10556 @@ -236,7 +213,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { newState.newStatus !== action.status || JSON.stringify(newState.claimDetails) !== JSON.stringify(currentMetadata.claimDetails) || - newState.timeRemainingSeconds !== currentMetadata.timeRemainingSeconds || + newState.claimableAt !== currentMetadata.claimableAt || newState.claimTxHash !== currentMetadata.claimTxHash return hasChanges ? { action, ...newState } : null @@ -254,7 +231,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { arbitrumBridgeMetadata: { ...update.action.arbitrumBridgeMetadata, claimDetails: update.claimDetails, - timeRemainingSeconds: update.timeRemainingSeconds, + claimableAt: update.claimableAt, claimTxHash: update.claimTxHash ?? update.action.arbitrumBridgeMetadata.claimTxHash, }, }), diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index e639a5e4bc2..109ff7c5beb 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -83,7 +83,7 @@ type ActionArbitrumBridgeWithdrawMetadata = { destinationAssetId: AssetId accountId: AccountId destinationAccountId: AccountId - timeRemainingSeconds?: number + claimableAt?: number claimDetails?: ClaimDetails } From b1cfd0e56197dd489c99864c6572a572c875740d Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:23:49 -0600 Subject: [PATCH 12/40] fix(arbitrum-bridge): fill the claimed card's translation, drop persisted claim details, share the withdraw tx row Co-Authored-By: Claude Opus 5.5 --- .../components/ArbitrumBridgeClaimModal.tsx | 14 ++-- .../ArbitrumBridgeWithdrawActionCard.tsx | 68 +++++++------------ .../components/RfoxInitiatedActionCard.tsx | 14 +--- .../Claim/hooks/useArbitrumClaimsByStatus.tsx | 2 +- .../arbitrumBridgeWithdrawAction.test.ts | 1 - .../arbitrumBridgeWithdrawAction.ts | 3 +- .../useArbitrumWithdrawalActionSubscriber.tsx | 46 ++++--------- src/state/slices/actionSlice/selectors.ts | 3 +- src/state/slices/actionSlice/types.ts | 5 +- 9 files changed, 51 insertions(+), 105 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 2f4a5fe48db..1f4763224f5 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -68,20 +68,12 @@ export const ArbitrumBridgeClaimModal = ({ selectMarketDataByAssetIdUserCurrency(state, action.arbitrumBridgeMetadata.destinationAssetId), ) - // The stored claim is plain JSON after a reload, so the SDK message that builds the outbox proof - // has to come from the live poll const { claimsByStatus } = useArbitrumClaimsByStatus() const claimDetails = useMemo( () => claimsByStatus.Available.find(claim => claim.tx.txid === withdrawTxHash), [claimsByStatus.Available, withdrawTxHash], ) - // The claim may be paid for by any of our ethereum accounts, but the funds land at the withdrawal's destination - const destinationAddress = - claimDetails?.destinationAddress ?? - action.arbitrumBridgeMetadata.claimDetails?.destinationAddress ?? - fromAccountId(destinationAccountId).account - const destinationFeeAsset = useAppSelector(state => selectFeeAssetByChainId( state, @@ -222,7 +214,11 @@ export const ArbitrumBridgeClaimModal = ({ {translate('bridge.claimReceiveAddress')} - {middleEllipsis(destinationAddress)} + + + {middleEllipsis(claimDetails?.destinationAddress ?? '')} + + {translate('common.gasFee')} diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 4d9fee6eae5..bb55f903305 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -142,58 +142,42 @@ export const ArbitrumBridgeWithdrawActionCard = ({ return ( - {action.status === ActionStatus.Initiated && ( + + + {translate( + action.status === ActionStatus.Initiated + ? 'actionCenter.bridge.transactionInitiated' + : 'actionCenter.bridge.withdrawTx', + )} + + + + + + + + {action.status === ActionStatus.ClaimAvailable && ( - {translate('actionCenter.bridge.transactionInitiated')} + {translate('actionCenter.bridge.claimWithdraw')} - - - + )} - {action.status === ActionStatus.ClaimAvailable && ( - <> + {action.status === ActionStatus.Claimed && + action.arbitrumBridgeMetadata.claimTxHash && + claimTxLink && ( - {translate('actionCenter.bridge.withdrawTx')} + {translate('actionCenter.bridge.claimTx')} - - + + - - {translate('actionCenter.bridge.claimWithdraw')} - - - - - - )} - {action.status === ActionStatus.Claimed && ( - <> - - {translate('actionCenter.bridge.withdrawTx')} - - - - - - - {action.arbitrumBridgeMetadata.claimTxHash && claimTxLink && ( - - {translate('actionCenter.bridge.claimTx')} - - - - - - - )} - - )} + )} ) }, [ diff --git a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx index e36b039848d..b8a6fc56786 100644 --- a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx @@ -12,8 +12,9 @@ import { ActionStatusTag } from './ActionStatusTag' import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' import { getTxLink } from '@/lib/getTxLink' import { middleEllipsis } from '@/lib/utils' +import { formatSmartDate } from '@/lib/utils/time' import type { GenericTransactionAction } from '@/state/slices/actionSlice/types' -import { ActionType } from '@/state/slices/actionSlice/types' +import { ActionType, getActionTimestamp } from '@/state/slices/actionSlice/types' import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/assetsSlice/selectors' import { foxEthLpAssetId, foxEthPair } from '@/state/slices/opportunitiesSlice/constants' import { useAppSelector } from '@/state/store' @@ -33,16 +34,7 @@ export const RfoxInitiatedActionCard = ({ action }: RfoxInitiatedActionCardProps selectAssetById(state, action.transactionMetadata.assetId ?? ''), ) - const formattedDate = useMemo(() => { - const now = dayjs() - const notificationDate = dayjs(action.updatedAt) - const sevenDaysAgo = now.subtract(7, 'day') - if (notificationDate.isAfter(sevenDaysAgo)) { - return notificationDate.fromNow() - } else { - return notificationDate.toDate().toLocaleString() - } - }, [action.updatedAt]) + const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const txLink = useMemo(() => { if (!feeAsset) return diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx index fb054fb962a..e7b128b69f2 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx @@ -20,7 +20,7 @@ import { import type { Tx } from '@/state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from '@/state/store' -// Challenge period from the withdrawal; the assertion that includes it can add up to an hour +// Arbitrum's challenge period; the claim can open up to an hour later while an assertion posts const ARBITRUM_CHALLENGE_PERIOD_MS = 6.4 * 24 * 60 * 60 * 1000 export const getArbitrumClaimableAt = (withdrawTimeMs: number): number => diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts index 9b8d1862ad7..a3585cda352 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -49,7 +49,6 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { accountId: 'eip155:42161:0xarb', destinationAccountId, claimableAt: 1_700_000_000_000 + 6.4 * 24 * 60 * 60 * 1000, - claimDetails: claim, }) }) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 05f773f0492..4ef982c3d11 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -10,7 +10,7 @@ import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' export const getArbitrumBridgeWithdrawActionId = (withdrawTxHash: string): string => `arbitrum-bridge-withdraw-${withdrawTxHash}` -// The outbox call is permissionless, so any ethereum account can execute it when the destination is not ours +// The outbox call is permissionless, so any of our ethereum accounts can pay for the claim const getClaimDestinationAccountId = ( destinationAddress: string, ethAccountIds: AccountId[], @@ -45,7 +45,6 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( accountId: claim.accountId, destinationAccountId, claimableAt: getArbitrumClaimableAt(createdAt), - claimDetails: claim, }, } } diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index a79f7c6dbb8..fd42fa68358 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -89,7 +89,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 if (arbitrumActionsByWithdrawTxHash[swap.sellTxHash]) return - const claimDetails = allClaims.find(claim => claim.tx.txid === swap.sellTxHash) + const claim = allClaims.find(claim => claim.tx.txid === swap.sellTxHash) dispatch( actionSlice.actions.upsertAction({ @@ -105,10 +105,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { destinationAssetId: swap.buyAsset.assetId, accountId: swap.sellAccountId, destinationAccountId: swap.buyAccountId, - claimableAt: getArbitrumClaimableAt( - claimDetails ? claimDetails.tx.blockTime * 1000 : Date.now(), - ), - claimDetails, + claimableAt: getArbitrumClaimableAt(claim ? claim.tx.blockTime * 1000 : Date.now()), }, }), ) @@ -130,8 +127,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { [toast, translate], ) - // Recreate withdraw actions from the claims tx history knows about, so a wiped store, another browser, - // or a withdrawal made outside the app still surfaces here. Completed claims have nothing left to do. + // Rebuild missing actions from tx history, e.g. after a wiped store or an outside withdrawal useEffect(() => { if (!ethAccountIds.length) return @@ -188,35 +184,21 @@ export const useArbitrumWithdrawalActionSubscriber = () => { claim => claim.tx.txid === withdrawTxHash, ) - const currentMetadata = action.arbitrumBridgeMetadata const matchedClaim = completedClaim ?? availableClaim ?? pendingClaim + if (!matchedClaim) return null - // Determine new action state from claim data - const newState = { - newStatus: (() => { - if (completedClaim) return ActionStatus.Claimed - if (availableClaim) return ActionStatus.ClaimAvailable - if (pendingClaim) return ActionStatus.Initiated - return action.status - })(), - claimDetails: matchedClaim ?? currentMetadata.claimDetails, - claimableAt: matchedClaim - ? getArbitrumClaimableAt(matchedClaim.tx.blockTime * 1000) - : currentMetadata.claimableAt, - claimTxHash: currentMetadata.claimTxHash, - } - - // Check if action state changed - use deep comparison for objects - // once again, paranoia against this bad boi https://github.com/shapeshift/web/pull/10556 + const newStatus = (() => { + if (completedClaim) return ActionStatus.Claimed + if (availableClaim) return ActionStatus.ClaimAvailable + return ActionStatus.Initiated + })() + const claimableAt = getArbitrumClaimableAt(matchedClaim.tx.blockTime * 1000) + // Only write on a real change, see https://github.com/shapeshift/web/pull/10556 const hasChanges = - newState.newStatus !== action.status || - JSON.stringify(newState.claimDetails) !== - JSON.stringify(currentMetadata.claimDetails) || - newState.claimableAt !== currentMetadata.claimableAt || - newState.claimTxHash !== currentMetadata.claimTxHash + newStatus !== action.status || claimableAt !== action.arbitrumBridgeMetadata.claimableAt - return hasChanges ? { action, ...newState } : null + return hasChanges ? { action, newStatus, claimableAt } : null }) .filter(isSome) .forEach(update => { @@ -230,9 +212,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { status: update.newStatus, arbitrumBridgeMetadata: { ...update.action.arbitrumBridgeMetadata, - claimDetails: update.claimDetails, claimableAt: update.claimableAt, - claimTxHash: update.claimTxHash ?? update.action.arbitrumBridgeMetadata.claimTxHash, }, }), ) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index dbc3f52d565..3bd9f1121d9 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -97,8 +97,7 @@ export const selectWalletActions = createDeepEqualOutputSelector( }, ) -// Anything still in flight stays on top, in the order it was started, since its updates only track -// polling. Settled actions follow, most recently settled first. +// In-flight actions first, newest started first, then settled actions, newest settled first export const selectWalletActionsSorted = createDeepEqualOutputSelector( selectWalletActions, actions => { diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index 109ff7c5beb..0dc0dff41ea 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -1,7 +1,6 @@ import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' import type { Asset, CowSwapQuoteId, OrderId } from '@shapeshiftoss/types' -import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import type { LpConfirmedDepositQuote, LpConfirmedWithdrawalQuote, @@ -84,7 +83,6 @@ type ActionArbitrumBridgeWithdrawMetadata = { accountId: AccountId destinationAccountId: AccountId claimableAt?: number - claimDetails?: ClaimDetails } export enum ChainflipLendingOperationType { @@ -307,8 +305,7 @@ const ACTIVE_ACTION_STATUSES = new Set([ export const isActiveActionStatus = (status: ActionStatus): boolean => ACTIVE_ACTION_STATUSES.has(status) -// An action in flight is dated from when it started, since its updates only track polling; -// a settled one from when it settled +// In-flight actions are dated by when they started, settled ones by when they settled export const getActionTimestamp = (action: Action): number => isActiveActionStatus(action.status) ? action.createdAt : action.updatedAt From 0224560d9a98699eeac4ae90143c83dec093684b Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:14:06 -0600 Subject: [PATCH 13/40] fix(arbitrum-bridge): track pending claims from the confirmed assertion, confirm claims on receipt - read claim status from the latest confirmed assertion instead of the sdk's status scan, which exceeded the rpc eth_getLogs block range and dropped every pending withdraw - fetch each withdraw's receipt and message once, share the confirmed block per poll - extend the claim estimate by the observed confirmation lag, tick the countdown, localize it and say "within the hour" once passed - keep a claim available until its receipt confirms, show it as processing meanwhile, restore the claim button on revert - complete the withdraw swap once its claim lands so it leaves the in-flight group - expand pending withdraws to their withdraw tx, split claim tx data and fee loading/error states in the claim modal Co-Authored-By: Claude Opus 5.5 --- src/assets/translations/de/main.json | 1 - src/assets/translations/en/main.json | 3 +- src/assets/translations/es/main.json | 1 - src/assets/translations/fr/main.json | 1 - src/assets/translations/ja/main.json | 1 - src/assets/translations/pt/main.json | 1 - src/assets/translations/ru/main.json | 1 - src/assets/translations/tr/main.json | 1 - src/assets/translations/uk/main.json | 1 - src/assets/translations/zh/main.json | 1 - .../components/ArbitrumBridgeClaimModal.tsx | 85 ++++++++++--- .../ArbitrumBridgeWithdrawActionCard.tsx | 62 +++++----- .../Notifications/SwapNotification.tsx | 15 +-- .../Claim/hooks/useArbitrumClaimTx.tsx | 5 +- .../Claim/hooks/useArbitrumClaimsByStatus.tsx | 115 +++++++++++++++--- .../arbitrumBridgeWithdrawAction.test.ts | 5 +- .../arbitrumBridgeWithdrawAction.ts | 3 +- .../useArbitrumWithdrawalActionSubscriber.tsx | 25 +++- .../useArbitrumClaimTimeText.ts | 29 +++++ src/lib/utils/time.ts | 4 +- 20 files changed, 270 insertions(+), 90 deletions(-) create mode 100644 src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index 19b49c03d3d..51d21b75efd 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "Ihre Auszahlung von %{amountAndSymbol} wird %{timeText} verfügbar sein.", "claimAvailable": "Ihr Wechsel von %{amountAndSymbol} kann beansprucht werden.", "failed": "Ihr Wechsel von %{sellAmountAndSymbol}.%{sellChainShortName} zu %{buyAmountAndSymbol}.%{buyChainShortName} ist fehlgeschlagen.", - "transactionInitiated": "Transaktion eingeleitet", "claimWithdraw": "Auszahlung beanspruchen", "withdrawTx": "Tx abheben", "claimTx": "Anspruch Tx" diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 42205044202..5a103154641 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -883,6 +883,7 @@ }, "claimReceiveAddress": "Claim Receive Address", "confirmAndClaim": "Confirm & Claim", + "claimTxDataFailed": "Couldn't prepare the claim transaction", "bridgeWithdrawalReadyNotification": "Bridge withdrawal ready to claim", "checkActionCenterNotification": "Check Action Center for details" }, @@ -3295,11 +3296,11 @@ "complete": "Your bridge of %{sellAmountAndSymbol}.%{sellChainShortName} to %{buyAmountAndSymbol}.%{buyChainShortName} is complete.", "initiated": "Your bridge of %{sellAmountAndSymbol} to %{buyAmountAndSymbol} has been initiated.", "pendingWithdraw": "Your withdraw of %{amountAndSymbol} will be available %{timeText}.", + "availableSoon": "within the hour", "claimAvailable": "Your bridge of %{amountAndSymbol} is available to claim.", "withdrawComplete": "Your bridge of %{amountAndSymbol} is complete.", "withdrawProcessing": "Your bridge of %{amountAndSymbol} is being processed.", "failed": "Your bridge of %{sellAmountAndSymbol}.%{sellChainShortName} to %{buyAmountAndSymbol}.%{buyChainShortName} has failed.", - "transactionInitiated": "Transaction Initiated", "claimWithdraw": "Claim Withdraw", "withdrawTx": "Withdraw Tx", "claimTx": "Claim Tx" diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index b8ca78d9979..8cb385639a8 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "Su retiro de %{amountAndSymbol} estará disponible %{timeText}.", "claimAvailable": "Su bridge de %{amountAndSymbol} está disponible para reclamar.", "failed": "Bridge de %{sellAmountAndSymbol}.%{sellChainShortName} a %{buyAmountAndSymbol}.%{buyChainShortName} ha fallado.", - "transactionInitiated": "Transacción Iniciada", "claimWithdraw": "Reclamar Retiro", "withdrawTx": "Retirar Tx", "claimTx": "Reclamar Tx" diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 786f23d06a0..7f8ff9f23f8 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "Votre retrait de %{amountAndSymbol} sera disponible %{timeText}.", "claimAvailable": "Votre bridge de %{amountAndSymbol} est disponible pour être réclamé.", "failed": "Votre bridge de %{sellAmountAndSymbol}.%{sellChainShortName} vers %{buyAmountAndSymbol}.%{buyChainShortName} a échoué.", - "transactionInitiated": "Transaction initiée", "claimWithdraw": "Réclamer le retrait", "withdrawTx": "Tx de retrait", "claimTx": "Tx de réclamation" diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 88f5600dda2..63bcfc27ca0 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "%{amountAndSymbol} の出金は %{timeText} から可能になります。", "claimAvailable": "ブリッジした %{amountAndSymbol} を受け取れます", "failed": "%{sellAmountAndSymbol}.%{sellChainShortName} から %{buyAmountAndSymbol}.%{buyChainShortName} へのブリッジが失敗しました。", - "transactionInitiated": "取引開始", "claimWithdraw": "引き出しを請求", "withdrawTx": "出金トランザクション", "claimTx": "請求トランザクション" diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index 7c03a7c2fed..0684d2a2ed6 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "Seu saque de %{amountAndSymbol} estará disponível em %{timeText}.", "claimAvailable": "Sua ponte de %{amountAndSymbol} está disponível para ser resgatada.", "failed": "Sua ponte de %{sellAmountAndSymbol}.%{sellChainShortName} para %{buyAmountAndSymbol}.%{buyChainShortName} falhou.", - "transactionInitiated": "Transação iniciada", "claimWithdraw": "Resgate seu saque", "withdrawTx": "Saque Tx", "claimTx": "Resgate Tx" diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index 0bfd5776543..2b0c50a0b4d 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "Ваш вывод %{amountAndSymbol} будет доступен %{timeText}.", "claimAvailable": "Ваш мост %{amountAndSymbol} доступен для получения.", "failed": "Ваш мост из %{sellAmountAndSymbol}.%{sellChainShortName} в %{buyAmountAndSymbol}.%{buyChainShortName} потерпел неудачу.", - "transactionInitiated": "Транзакция инициирована", "claimWithdraw": "Заклеймить вывод", "withdrawTx": "Тx вывода", "claimTx": "Tx клейма" diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index ac72a72a48d..fbeda6fca90 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "%{amountAndSymbol} tutarındaki çekim işleminiz %{timeText} tarihinde gerçekleştirilebilecek.", "claimAvailable": "%{amountAndSymbol} köprünüz talep edilebilir.", "failed": "%{sellAmountAndSymbol}.%{sellChainShortName} ile %{buyAmountAndSymbol}.%{buyChainShortName} arasındaki köprünüz başarısız oldu.", - "transactionInitiated": "İşlem Başlatıldı", "claimWithdraw": "Talep Geri Çekme", "withdrawTx": "Tx'i Çek", "claimTx": "Talep Tx" diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index b1528b1392f..66e27d377f4 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "Ваш вивід %{amountAndSymbol} буде доступний %{timeText}.", "claimAvailable": "Ваш міст на суму %{amountAndSymbol} доступний для отримання.", "failed": "Ваш міст %{sellAmountAndSymbol}.%{sellChainShortName} до %{buyAmountAndSymbol}.%{buyChainShortName} не вдався.", - "transactionInitiated": "Транзакція ініційована", "claimWithdraw": "Заклеймити вивід", "withdrawTx": "Транзакція виводу", "claimTx": "Заклеймлені транзакції" diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index 3a41479bb49..89ebee78b28 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -3297,7 +3297,6 @@ "pendingWithdraw": "您提现的 %{amountAndSymbol} 将在 %{timeText} 可用。", "claimAvailable": "您桥接的 %{amountAndSymbol} 已可领取。", "failed": "您从 %{sellAmountAndSymbol}.%{sellChainShortName} 到 %{buyAmountAndSymbol}.%{buyChainShortName} 的跨链交易已失败。", - "transactionInitiated": "交易已发起", "claimWithdraw": "领取提现", "withdrawTx": "提现交易", "claimTx": "领取交易" diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 1f4763224f5..6179f45d460 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -12,10 +12,12 @@ import { } from '@chakra-ui/react' import { fromAccountId } from '@shapeshiftoss/caip' import type { KnownChainIds } from '@shapeshiftoss/types' +import { TxStatus } from '@shapeshiftoss/unchained-client' import { BigAmount, getChainShortName } from '@shapeshiftoss/utils' import { noop } from 'lodash' import { useCallback, useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { zeroAddress } from 'viem' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' @@ -29,12 +31,13 @@ import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus } from '@/state/slices/actionSlice/types' import { + selectArbitrumBridgeWithdrawActionById, selectAssetById, selectFeeAssetByChainId, selectMarketDataByAssetIdUserCurrency, selectPortfolioCryptoBalanceByFilter, } from '@/state/slices/selectors' -import { useAppDispatch, useAppSelector } from '@/state/store' +import { store, useAppDispatch, useAppSelector } from '@/state/store' type ArbitrumBridgeClaimModalProps = { action: ArbitrumBridgeWithdrawAction @@ -114,31 +117,59 @@ export const ArbitrumBridgeClaimModal = ({ amountCryptoPrecision, ]) - const handleClaimSuccess = useCallback( + // Stays claimable until the claim confirms, so a dropped or reverted claim can be retried + const handleClaimBroadcast = useCallback( (claimTxHash: string) => { dispatch( actionSlice.actions.upsertAction({ ...action, - updatedAt: Date.now(), - status: ActionStatus.Claimed, - arbitrumBridgeMetadata: { - ...action.arbitrumBridgeMetadata, - claimTxHash, - }, + arbitrumBridgeMetadata: { ...action.arbitrumBridgeMetadata, claimTxHash }, }), ) }, [dispatch, action], ) + // Runs after the claim receipt resolves, possibly after the modal closed + const handleClaimTxStatus = useCallback( + (txStatus: TxStatus) => { + const latestAction = selectArbitrumBridgeWithdrawActionById(store.getState(), action.id) + if (!latestAction || latestAction.status === ActionStatus.Claimed) return + + switch (txStatus) { + case TxStatus.Confirmed: + dispatch( + actionSlice.actions.upsertAction({ ...latestAction, status: ActionStatus.Claimed }), + ) + return + case TxStatus.Failed: + dispatch( + actionSlice.actions.upsertAction({ + ...latestAction, + arbitrumBridgeMetadata: { + ...latestAction.arbitrumBridgeMetadata, + claimTxHash: undefined, + }, + }), + ) + return + default: + return + } + }, + [dispatch, action.id], + ) + const claimTxResult = useArbitrumClaimTx( claimDetails, destinationAccountId, noop, - noop, - handleClaimSuccess, + handleClaimTxStatus, + handleClaimBroadcast, ) + const executeTransactionDataResult = claimTxResult?.executeTransactionDataResult + const evmFeesResult = claimTxResult?.evmFeesResult const claimMutation = claimTxResult?.claimMutation @@ -162,6 +193,8 @@ export const ArbitrumBridgeClaimModal = ({ }, [claimMutation, onClose]) const confirmCopy = useMemo(() => { + if (executeTransactionDataResult?.isError) return translate('bridge.claimTxDataFailed') + if (claimMutation?.isError) return translate('trade.errors.title') if (evmFeesResult?.isError) return translate('trade.errors.networkFeeEstimateFailed') @@ -173,7 +206,14 @@ export const ArbitrumBridgeClaimModal = ({ }) return translate('bridge.confirmAndClaim') - }, [claimMutation, destinationFeeAsset, evmFeesResult, hasEnoughDestinationFeeBalance, translate]) + }, [ + claimMutation, + destinationFeeAsset, + evmFeesResult?.isError, + executeTransactionDataResult?.isError, + hasEnoughDestinationFeeBalance, + translate, + ]) useEffect(() => { if (!isClaimCompleted) return @@ -216,14 +256,20 @@ export const ArbitrumBridgeClaimModal = ({ {translate('bridge.claimReceiveAddress')} - {middleEllipsis(claimDetails?.destinationAddress ?? '')} + {middleEllipsis(claimDetails?.destinationAddress ?? zeroAddress)} {translate('common.gasFee')} - + @@ -236,7 +282,10 @@ export const ArbitrumBridgeClaimModal = ({ width='full' size='lg' colorScheme={ - !hasEnoughDestinationFeeBalance || claimMutation?.isError || evmFeesResult?.isError + !hasEnoughDestinationFeeBalance || + executeTransactionDataResult?.isError || + claimMutation?.isError || + evmFeesResult?.isError ? 'red' : 'blue' } @@ -246,7 +295,13 @@ export const ArbitrumBridgeClaimModal = ({ claimMutation?.isPending || !hasEnoughDestinationFeeBalance } - isLoading={!claimDetails || evmFeesResult?.isFetching || claimMutation?.isPending} + isLoading={ + // The claim status poll keeps refetching, so a missing claim resolves on its own + !claimDetails || + executeTransactionDataResult?.isFetching || + evmFeesResult?.isFetching || + claimMutation?.isPending + } onClick={onConfirm} > {confirmCopy} diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index bb55f903305..145c6911296 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -14,9 +14,10 @@ import { ActionStatusTag } from './ActionStatusTag' import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { Row } from '@/components/Row/Row' +import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' import { getTxLink } from '@/lib/getTxLink' -import { formatSecondsToDuration, formatSmartDate } from '@/lib/utils/time' +import { formatSmartDate } from '@/lib/utils/time' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus, @@ -56,11 +57,20 @@ export const ArbitrumBridgeWithdrawActionCard = ({ ), ) + const timeText = useArbitrumClaimTimeText(action.arbitrumBridgeMetadata.claimableAt) + const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const isCollapsable = - action.status === ActionStatus.ClaimAvailable || action.status === ActionStatus.Claimed + action.status === ActionStatus.Initiated || + action.status === ActionStatus.ClaimAvailable || + action.status === ActionStatus.Claimed const { isOpen, onToggle } = useDisclosure() + const isClaiming = + action.status === ActionStatus.ClaimAvailable && + Boolean(action.arbitrumBridgeMetadata.claimTxHash) + const displayStatus = isClaiming ? ActionStatus.Pending : action.status + const handleClaimClick = useCallback( (e: React.MouseEvent) => { e.preventDefault() @@ -84,15 +94,11 @@ export const ArbitrumBridgeWithdrawActionCard = ({ const amountAndSymbol = `${buyAmountCryptoPrecision} ${buyAsset.symbol}` - const { claimableAt } = action.arbitrumBridgeMetadata - const secondsUntilClaimable = claimableAt ? (claimableAt - Date.now()) / 1000 : 0 - const timeText = - secondsUntilClaimable > 0 ? `in ${formatSecondsToDuration(secondsUntilClaimable)}` : 'soon' - switch (action.status) { case ActionStatus.Initiated: return translate('actionCenter.bridge.pendingWithdraw', { amountAndSymbol, timeText }) case ActionStatus.ClaimAvailable: + if (isClaiming) return translate('actionCenter.bridge.processing', bridgeArgs) return translate('actionCenter.bridge.claimAvailable', { amountAndSymbol }) case ActionStatus.Claimed: return translate('actionCenter.bridge.withdrawComplete', { amountAndSymbol }) @@ -103,21 +109,22 @@ export const ArbitrumBridgeWithdrawActionCard = ({ action.status, buyAmountCryptoPrecision, buyAsset, + isClaiming, sellAsset, + timeText, translate, - action.arbitrumBridgeMetadata, ]) const icon = useMemo(() => { if (!sellAsset) return null return ( - + ) - }, [sellAsset, action.status]) + }, [sellAsset, displayStatus]) - const footer = useMemo(() => , [action.status]) + const footer = useMemo(() => , [displayStatus]) const details = useMemo(() => { if (!(sellAsset && buyAsset && sellFeeAsset && destinationFeeAsset)) return null @@ -143,20 +150,14 @@ export const ArbitrumBridgeWithdrawActionCard = ({ return ( - - {translate( - action.status === ActionStatus.Initiated - ? 'actionCenter.bridge.transactionInitiated' - : 'actionCenter.bridge.withdrawTx', - )} - + {translate('actionCenter.bridge.withdrawTx')} - {action.status === ActionStatus.ClaimAvailable && ( + {action.status === ActionStatus.ClaimAvailable && !isClaiming && ( {translate('actionCenter.bridge.claimWithdraw')} @@ -166,18 +167,16 @@ export const ArbitrumBridgeWithdrawActionCard = ({ )} - {action.status === ActionStatus.Claimed && - action.arbitrumBridgeMetadata.claimTxHash && - claimTxLink && ( - - {translate('actionCenter.bridge.claimTx')} - - - - - - - )} + {action.arbitrumBridgeMetadata.claimTxHash && claimTxLink && ( + + {translate('actionCenter.bridge.claimTx')} + + + + + + + )} ) }, [ @@ -188,6 +187,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ action.arbitrumBridgeMetadata.withdrawTxHash, action.arbitrumBridgeMetadata.claimTxHash, action.status, + isClaiming, translate, handleClaimClick, ]) diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index 3bbf7ab7270..b0bc2a490d6 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -13,8 +13,8 @@ import { Text } from '@/components/Text' import type { TextPropTypes } from '@/components/Text/Text' import { StandardToast } from '@/components/Toast/StandardToast' import { useActualBuyAmountCryptoPrecision } from '@/hooks/useActualBuyAmountCryptoPrecision' +import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { formatSecondsToDuration } from '@/lib/utils/time' import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { selectArbitrumBridgeWithdrawActionByWithdrawTxHash, @@ -54,6 +54,12 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat const action = swap?.swapperName === SwapperName.ArbitrumBridge ? maybeArbitrumBridgeAction : swapAction + const timeText = useArbitrumClaimTimeText( + maybeArbitrumBridgeAction && isArbitrumBridgeWithdrawAction(maybeArbitrumBridgeAction) + ? maybeArbitrumBridgeAction.arbitrumBridgeMetadata.claimableAt + : undefined, + ) + const swapNotificationComponents = useMemo((): TextPropTypes['components'] | undefined => { if (!swap) return undefined @@ -88,11 +94,6 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat } if (isArbitrumBridgeWithdraw) { - const { claimableAt } = action.arbitrumBridgeMetadata - const secondsUntilClaimable = claimableAt ? (claimableAt - Date.now()) / 1000 : 0 - const timeText = - secondsUntilClaimable > 0 ? `in ${formatSecondsToDuration(secondsUntilClaimable)}` : 'soon' - const buyAmountCryptoPrecision = bnOrZero( actualBuyAmountCryptoPrecision ?? swap.expectedBuyAmountCryptoPrecision, ) @@ -118,7 +119,7 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat } return components - }, [swap, actualBuyAmountCryptoPrecision, action]) + }, [swap, actualBuyAmountCryptoPrecision, action, timeText]) const swapTitleTranslation = useMemo(() => { if (!action || !swap) return 'actionCenter.swap.processing' diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx index 18e734ad878..2d1dbf5c7ea 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx @@ -126,10 +126,8 @@ export const useArbitrumClaimTx = ( const { status } = await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) switch (status) { - case 'success': { - queryClient.setQueryData(['claimStatus', { txid: claim.tx.txid }], () => null) + case 'success': return setClaimTxStatus(TxStatus.Confirmed) - } case 'reverted': default: return setClaimTxStatus(TxStatus.Failed) @@ -150,6 +148,7 @@ export const useArbitrumClaimTx = ( }) return { + executeTransactionDataResult, evmFeesResult, claimMutation, } diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx index e7b128b69f2..15dd9d8ce4e 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx @@ -1,11 +1,17 @@ import type { ChildToParentMessageReader, ChildToParentTransactionEvent } from '@arbitrum/sdk' -import { ChildToParentMessageStatus, ChildTransactionReceipt } from '@arbitrum/sdk' +import { + ChildToParentMessageStatus, + ChildTransactionReceipt, + getArbitrumNetwork, +} from '@arbitrum/sdk' +import { Outbox__factory } from '@arbitrum/sdk/dist/lib/abi/factories/Outbox__factory' +import { BoldRollupUserLogic__factory } from '@arbitrum/sdk/dist/lib/abi-bold/factories/BoldRollupUserLogic__factory' import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' import { arbitrumChainId, ethAssetId, ethChainId, toAccountId } from '@shapeshiftoss/caip' import { getEthersV5Provider } from '@shapeshiftoss/contracts' import { KnownChainIds } from '@shapeshiftoss/types' import type { Query } from '@tanstack/react-query' -import { useQueries } from '@tanstack/react-query' +import { useQueries, useQueryClient } from '@tanstack/react-query' import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -23,19 +29,75 @@ import { useAppSelector } from '@/state/store' // Arbitrum's challenge period; the claim can open up to an hour later while an assertion posts const ARBITRUM_CHALLENGE_PERIOD_MS = 6.4 * 24 * 60 * 60 * 1000 -export const getArbitrumClaimableAt = (withdrawTimeMs: number): number => - withdrawTimeMs + ARBITRUM_CHALLENGE_PERIOD_MS +// Confirmations can run behind the challenge period, so the observed lag extends the estimate +export const getArbitrumClaimableAt = (withdrawTimeMs: number, confirmationLagMs = 0): number => + withdrawTimeMs + Math.max(ARBITRUM_CHALLENGE_PERIOD_MS, confirmationLagMs) + +type ConfirmedChildBlock = { + number: number + confirmationLagMs: number +} + +type EthersV5Provider = ReturnType + +// Every withdraw at or below the latest confirmed assertion's child block is claimable. Reading it +// directly avoids the sdk's status scan, which walks every assertion since the last confirmation +// and exceeds our rpc's eth_getLogs block range for unconfirmed withdraws. +const getConfirmedChildBlock = async ( + l1Provider: EthersV5Provider, + l2Provider: EthersV5Provider, +): Promise => { + const network = await getArbitrumNetwork(l2Provider) + const rollup = BoldRollupUserLogic__factory.connect(network.ethBridge.rollup, l1Provider) + const latestConfirmed = await rollup.latestConfirmed() + const createdAtBlock = (await rollup.getAssertion(latestConfirmed)).createdAtBlock.toNumber() + + const [assertionCreated] = await rollup.queryFilter( + rollup.filters.AssertionCreated(latestConfirmed), + createdAtBlock, + createdAtBlock, + ) + if (!assertionCreated) throw new Error(`AssertionCreated not found for ${latestConfirmed}`) + + const [blockHash] = assertionCreated.args.assertion.afterState.globalState.bytes32Vals + const block = await l2Provider.getBlock(blockHash) + + return { number: block.number, confirmationLagMs: Date.now() - block.timestamp * 1000 } +} + +type ClaimMessage = { + blockNumber: number + event: Extract + message: ChildToParentMessageReader +} + +const getClaimMessage = async ( + txid: string, + l1Provider: EthersV5Provider, + l2Provider: EthersV5Provider, +): Promise => { + const receipt = await l2Provider.getTransactionReceipt(txid) + const l2Receipt = new ChildTransactionReceipt(receipt) + const [event] = l2Receipt.getChildToParentEvents() + const [message] = await l2Receipt.getChildToParentMessages(l1Provider) + if (!event || !message) throw new Error(`No withdraw message found for ${txid}`) + if (!('position' in event)) throw new Error(`Unsupported classic withdraw ${txid}`) + + return { blockNumber: receipt.blockNumber, event, message } +} type ClaimStatusResult = { event: ChildToParentTransactionEvent message: ChildToParentMessageReader status: ChildToParentMessageStatus + confirmationLagMs: number } -export type ClaimDetails = Omit & { +export type ClaimDetails = Omit & { accountId: AccountId amountCryptoBaseUnit: string assetId: string + claimableAt: number description: string destinationAddress: string destinationAssetId: AssetId @@ -48,6 +110,7 @@ type ClaimsByStatus = Record export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { const translate = useTranslate() + const queryClient = useQueryClient() const ethAsset = useAppSelector(state => selectAssetById(state, ethAssetId)) const arbitrumWithdrawTxs = useAppSelector(selectArbitrumWithdrawTxs) @@ -72,20 +135,43 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { return { queryKey: ['claimStatus', { txid: tx.txid }], queryFn: async (): Promise => { - const receipt = await l2Provider.getTransactionReceipt(tx.txid) - const l2Receipt = new ChildTransactionReceipt(receipt) - const events = l2Receipt.getChildToParentEvents() - const messages = await l2Receipt.getChildToParentMessages(l1Provider) - const event = events[0] - const message = messages[0] - const status = await message.status(l2Provider) + // A withdraw's message never changes, so it is fetched once + const { blockNumber, event, message } = await queryClient.fetchQuery({ + queryKey: ['arbitrumClaimMessage', { txid: tx.txid }], + queryFn: () => getClaimMessage(tx.txid, l1Provider, l2Provider), + staleTime: Infinity, + gcTime: Infinity, + }) + + // Shared by every withdraw in the same poll + const confirmedChildBlock = await queryClient.fetchQuery({ + queryKey: ['arbitrumConfirmedChildBlock'], + queryFn: () => getConfirmedChildBlock(l1Provider, l2Provider), + staleTime: 30_000, + }) + + const status = await (async () => { + if (blockNumber > confirmedChildBlock.number) { + return ChildToParentMessageStatus.UNCONFIRMED + } + + const network = await getArbitrumNetwork(l2Provider) + const outbox = Outbox__factory.connect(network.ethBridge.outbox, l1Provider) + const isSpent = await outbox.isSpent(event.position) + + return isSpent + ? ChildToParentMessageStatus.EXECUTED + : ChildToParentMessageStatus.CONFIRMED + })() + return { event, message, status, + confirmationLagMs: confirmedChildBlock.confirmationLagMs, } }, - select: ({ event, message, status }: ClaimStatusResult) => { + select: ({ event, message, status, confirmationLagMs }: ClaimStatusResult) => { const claimStatus = (() => { switch (status) { case ChildToParentMessageStatus.CONFIRMED: @@ -103,6 +189,7 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { event, message, claimStatus, + confirmationLagMs, } }, // Periodically refetch until the status is known to be ChildToParentMessageStatus.EXECUTED @@ -137,6 +224,7 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { destinationChainId: ethChainId, destinationExplorerTxLink: ethAsset.explorerTxLink, assetId: data.tx.transfers[0].assetId, + claimableAt: getArbitrumClaimableAt(data.tx.blockTime * 1000, data.confirmationLagMs), event: data.event, message: data.message, description: translate('bridge.arbitrum.description'), @@ -154,6 +242,5 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { return { claimsByStatus, - isLoading: claimStatuses.some(claimStatus => claimStatus.isLoading), } } diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts index a3585cda352..6ec6c430523 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -22,6 +22,7 @@ const claim = { destinationAddress, destinationAssetId: ethAssetId, destinationChainId: ethChainId, + claimableAt: 1_700_600_000_000, } as unknown as ClaimDetails describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { @@ -34,7 +35,7 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { expect(action?.type).toBe(ActionType.ArbitrumBridgeWithdraw) }) - it('maps a pending claim to an initiated action dated and timed from the withdraw block', () => { + it('maps a pending claim to an initiated action dated from the withdraw block', () => { const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Pending, [ destinationAccountId, ]) @@ -48,7 +49,7 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { destinationAssetId: ethAssetId, accountId: 'eip155:42161:0xarb', destinationAccountId, - claimableAt: 1_700_000_000_000 + 6.4 * 24 * 60 * 60 * 1000, + claimableAt: 1_700_600_000_000, }) }) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 4ef982c3d11..3640c0bf1ef 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -3,7 +3,6 @@ import { fromAccountId } from '@shapeshiftoss/caip' import { ClaimStatus } from '@/components/ClaimRow/types' import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' -import { getArbitrumClaimableAt } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' @@ -44,7 +43,7 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( destinationAssetId: claim.destinationAssetId, accountId: claim.accountId, destinationAccountId, - claimableAt: getArbitrumClaimableAt(createdAt), + claimableAt: claim.claimableAt, }, } } diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index fd42fa68358..a132cb7aea4 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -65,7 +65,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { } }, [isDrawerOpen, toast, previousIsDrawerOpen]) - // Create ArbitrumBridge withdraw actions from completed swap actions + // Create ArbitrumBridge withdraw actions from initiated swap actions, completing the swap once claimed useEffect(() => { const allClaims = [ ...claimsByStatus.Pending, @@ -86,8 +86,18 @@ export const useArbitrumWithdrawalActionSubscriber = () => { ) return + const withdrawAction = arbitrumActionsByWithdrawTxHash[swap.sellTxHash] + + // The swap stays in flight until its withdraw is claimed + if (withdrawAction?.status === ActionStatus.Claimed) { + dispatch( + actionSlice.actions.upsertAction({ ...swapAction, status: ActionStatus.Complete }), + ) + return + } + // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 - if (arbitrumActionsByWithdrawTxHash[swap.sellTxHash]) return + if (withdrawAction) return const claim = allClaims.find(claim => claim.tx.txid === swap.sellTxHash) @@ -105,7 +115,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { destinationAssetId: swap.buyAsset.assetId, accountId: swap.sellAccountId, destinationAccountId: swap.buyAccountId, - claimableAt: getArbitrumClaimableAt(claim ? claim.tx.blockTime * 1000 : Date.now()), + claimableAt: claim?.claimableAt ?? getArbitrumClaimableAt(Date.now()), }, }), ) @@ -167,6 +177,8 @@ export const useArbitrumWithdrawalActionSubscriber = () => { }) }, [actionsById]) + const pendingClaimableAts = claimsByStatus.Pending.map(claim => claim.claimableAt).join() + useEffect(() => { try { pendingArbitrumBridgeActions @@ -192,7 +204,11 @@ export const useArbitrumWithdrawalActionSubscriber = () => { if (availableClaim) return ActionStatus.ClaimAvailable return ActionStatus.Initiated })() - const claimableAt = getArbitrumClaimableAt(matchedClaim.tx.blockTime * 1000) + // Only a pending claim's estimate still matters + const claimableAt = + newStatus === ActionStatus.Initiated + ? matchedClaim.claimableAt + : action.arbitrumBridgeMetadata.claimableAt // Only write on a real change, see https://github.com/shapeshift/web/pull/10556 const hasChanges = @@ -233,5 +249,6 @@ export const useArbitrumWithdrawalActionSubscriber = () => { claimsByStatus.Available.length, claimsByStatus.Complete.length, claimsByStatus.Pending.length, + pendingClaimableAts, ]) } diff --git a/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts b/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts new file mode 100644 index 00000000000..872488eabd1 --- /dev/null +++ b/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' +import { useTranslate } from 'react-polyglot' + +import { formatSecondsToDuration } from '@/lib/utils/time' + +const TICK_INTERVAL_MS = 60_000 + +export const useArbitrumClaimTimeText = (claimableAt: number | undefined): string => { + const translate = useTranslate() + const [now, setNow] = useState(Date.now) + + useEffect(() => { + if (!claimableAt || claimableAt <= Date.now()) return + + const interval = setInterval(() => { + const nextNow = Date.now() + setNow(nextNow) + if (nextNow >= claimableAt) clearInterval(interval) + }, TICK_INTERVAL_MS) + + return () => clearInterval(interval) + }, [claimableAt]) + + const secondsUntilClaimable = claimableAt ? (claimableAt - now) / 1000 : 0 + + return secondsUntilClaimable > 0 + ? formatSecondsToDuration(secondsUntilClaimable, true) + : translate('actionCenter.bridge.availableSoon') +} diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts index 38986540202..0a4538ed765 100644 --- a/src/lib/utils/time.ts +++ b/src/lib/utils/time.ts @@ -9,12 +9,12 @@ import { store } from '@/state/store' dayjs.extend(duration) dayjs.extend(relativeTime) -export const formatSecondsToDuration = (seconds: number) => { +export const formatSecondsToDuration = (seconds: number, withSuffix = false) => { const selectedLocale = preferences.selectors.selectSelectedLocale(store.getState()) const locale = selectedLocale in LanguageTypeEnum ? selectedLocale : 'en' void import(`dayjs/locale/${locale}.js`) - return dayjs.duration(seconds, 'seconds').locale(locale).humanize() + return dayjs.duration(seconds, 'seconds').locale(locale).humanize(withSuffix) } /** From 0c3c4cd169f47252a7b1b0a2af0b7201b50e119a Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:30:43 -0600 Subject: [PATCH 14/40] feat(action-center): claims tab, resolve in-flight claims after reload, stable claim estimates - split claims into their own always-visible tab (ready count badge, empty state), recent feed is plain newest first - claim ready toasts open the claims tab - resolve a broadcast claim from its receipt in the subscriber, clearing it on revert or drop, even after a reload - hold the confirmation lag until the next confirmation, fall back to the last confirmed block on a failed refresh - read the outbox address from the sdk network for both status and claim, write the broadcast onto the latest action - show a claimed withdraw swap as complete, reserve the scrollbar gutter in the drawer - restore non-english locales, tighten comments Co-Authored-By: Claude Opus 5.5 --- src/assets/translations/de/main.json | 1 + src/assets/translations/en/main.json | 3 + src/assets/translations/es/main.json | 1 + src/assets/translations/fr/main.json | 1 + src/assets/translations/ja/main.json | 1 + src/assets/translations/pt/main.json | 1 + src/assets/translations/ru/main.json | 1 + src/assets/translations/tr/main.json | 1 + src/assets/translations/uk/main.json | 1 + src/assets/translations/zh/main.json | 1 + .../Header/ActionCenter/ActionCenter.tsx | 116 ++++++++++++++---- .../ActionCenter/ActionCenterContext.tsx | 36 +++++- .../components/ArbitrumBridgeClaimModal.tsx | 51 ++------ .../components/ClaimsEmptyState.tsx | 19 +++ .../ActionCenter/components/SpeedUpModal.tsx | 3 +- .../components/SwapActionCard.tsx | 9 +- .../Claim/hooks/useArbitrumClaimTx.tsx | 46 ++----- .../Claim/hooks/useArbitrumClaimsByStatus.tsx | 57 ++++++--- .../useArbitrumWithdrawalActionSubscriber.tsx | 85 ++++++++++++- .../useGenericTransactionSubscriber.tsx | 16 ++- .../useArbitrumClaimTimeText.ts | 1 + .../slices/actionSlice/selectors.test.ts | 94 ++++++++++---- src/state/slices/actionSlice/selectors.ts | 24 ++-- src/state/slices/actionSlice/types.ts | 23 ++++ 24 files changed, 419 insertions(+), 173 deletions(-) create mode 100644 src/components/Layout/Header/ActionCenter/components/ClaimsEmptyState.tsx diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index 51d21b75efd..19b49c03d3d 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "Ihre Auszahlung von %{amountAndSymbol} wird %{timeText} verfügbar sein.", "claimAvailable": "Ihr Wechsel von %{amountAndSymbol} kann beansprucht werden.", "failed": "Ihr Wechsel von %{sellAmountAndSymbol}.%{sellChainShortName} zu %{buyAmountAndSymbol}.%{buyChainShortName} ist fehlgeschlagen.", + "transactionInitiated": "Transaktion eingeleitet", "claimWithdraw": "Auszahlung beanspruchen", "withdrawTx": "Tx abheben", "claimTx": "Anspruch Tx" diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 5a103154641..225918e215d 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -3219,6 +3219,9 @@ }, "actionCenter": { "title": "Notifications", + "claims": "Claims", + "recent": "Recent", + "nothingToClaim": "Nothing to claim", "viewTransaction": "View Transaction", "originalTransaction": "Original Transaction", "replacementTransaction": "Replacement Transaction", diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index 8cb385639a8..b8ca78d9979 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "Su retiro de %{amountAndSymbol} estará disponible %{timeText}.", "claimAvailable": "Su bridge de %{amountAndSymbol} está disponible para reclamar.", "failed": "Bridge de %{sellAmountAndSymbol}.%{sellChainShortName} a %{buyAmountAndSymbol}.%{buyChainShortName} ha fallado.", + "transactionInitiated": "Transacción Iniciada", "claimWithdraw": "Reclamar Retiro", "withdrawTx": "Retirar Tx", "claimTx": "Reclamar Tx" diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 7f8ff9f23f8..786f23d06a0 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "Votre retrait de %{amountAndSymbol} sera disponible %{timeText}.", "claimAvailable": "Votre bridge de %{amountAndSymbol} est disponible pour être réclamé.", "failed": "Votre bridge de %{sellAmountAndSymbol}.%{sellChainShortName} vers %{buyAmountAndSymbol}.%{buyChainShortName} a échoué.", + "transactionInitiated": "Transaction initiée", "claimWithdraw": "Réclamer le retrait", "withdrawTx": "Tx de retrait", "claimTx": "Tx de réclamation" diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 63bcfc27ca0..88f5600dda2 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "%{amountAndSymbol} の出金は %{timeText} から可能になります。", "claimAvailable": "ブリッジした %{amountAndSymbol} を受け取れます", "failed": "%{sellAmountAndSymbol}.%{sellChainShortName} から %{buyAmountAndSymbol}.%{buyChainShortName} へのブリッジが失敗しました。", + "transactionInitiated": "取引開始", "claimWithdraw": "引き出しを請求", "withdrawTx": "出金トランザクション", "claimTx": "請求トランザクション" diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index 0684d2a2ed6..7c03a7c2fed 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "Seu saque de %{amountAndSymbol} estará disponível em %{timeText}.", "claimAvailable": "Sua ponte de %{amountAndSymbol} está disponível para ser resgatada.", "failed": "Sua ponte de %{sellAmountAndSymbol}.%{sellChainShortName} para %{buyAmountAndSymbol}.%{buyChainShortName} falhou.", + "transactionInitiated": "Transação iniciada", "claimWithdraw": "Resgate seu saque", "withdrawTx": "Saque Tx", "claimTx": "Resgate Tx" diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index 2b0c50a0b4d..0bfd5776543 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "Ваш вывод %{amountAndSymbol} будет доступен %{timeText}.", "claimAvailable": "Ваш мост %{amountAndSymbol} доступен для получения.", "failed": "Ваш мост из %{sellAmountAndSymbol}.%{sellChainShortName} в %{buyAmountAndSymbol}.%{buyChainShortName} потерпел неудачу.", + "transactionInitiated": "Транзакция инициирована", "claimWithdraw": "Заклеймить вывод", "withdrawTx": "Тx вывода", "claimTx": "Tx клейма" diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index fbeda6fca90..ac72a72a48d 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "%{amountAndSymbol} tutarındaki çekim işleminiz %{timeText} tarihinde gerçekleştirilebilecek.", "claimAvailable": "%{amountAndSymbol} köprünüz talep edilebilir.", "failed": "%{sellAmountAndSymbol}.%{sellChainShortName} ile %{buyAmountAndSymbol}.%{buyChainShortName} arasındaki köprünüz başarısız oldu.", + "transactionInitiated": "İşlem Başlatıldı", "claimWithdraw": "Talep Geri Çekme", "withdrawTx": "Tx'i Çek", "claimTx": "Talep Tx" diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index 66e27d377f4..b1528b1392f 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "Ваш вивід %{amountAndSymbol} буде доступний %{timeText}.", "claimAvailable": "Ваш міст на суму %{amountAndSymbol} доступний для отримання.", "failed": "Ваш міст %{sellAmountAndSymbol}.%{sellChainShortName} до %{buyAmountAndSymbol}.%{buyChainShortName} не вдався.", + "transactionInitiated": "Транзакція ініційована", "claimWithdraw": "Заклеймити вивід", "withdrawTx": "Транзакція виводу", "claimTx": "Заклеймлені транзакції" diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index 89ebee78b28..3a41479bb49 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -3297,6 +3297,7 @@ "pendingWithdraw": "您提现的 %{amountAndSymbol} 将在 %{timeText} 可用。", "claimAvailable": "您桥接的 %{amountAndSymbol} 已可领取。", "failed": "您从 %{sellAmountAndSymbol}.%{sellChainShortName} 到 %{buyAmountAndSymbol}.%{buyChainShortName} 的跨链交易已失败。", + "transactionInitiated": "交易已发起", "claimWithdraw": "领取提现", "withdrawTx": "提现交易", "claimTx": "领取交易" diff --git a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx index 9d2201475ca..e5cc7b13ab9 100644 --- a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx +++ b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx @@ -10,17 +10,24 @@ import { Flex, Icon, IconButton, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Tag, } from '@chakra-ui/react' import { memo, useCallback, useMemo, useState } from 'react' import { TbBellFilled } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { Virtuoso } from 'react-virtuoso' -import { useActionCenterContext } from './ActionCenterContext' +import { ActionCenterTab, useActionCenterContext } from './ActionCenterContext' import { AppUpdateActionCard } from './components/AppUpdateActionCard' import { ArbitrumBridgeClaimModal } from './components/ArbitrumBridgeClaimModal' import { ArbitrumBridgeWithdrawActionCard } from './components/ArbitrumBridgeWithdrawActionCard' import { ChainflipLendingActionCard } from './components/ChainflipLendingActionCard' +import { ClaimsEmptyState } from './components/ClaimsEmptyState' import { EmptyState } from './components/EmptyState' import { GenericTransactionActionCard } from './components/GenericTransactionActionCard' import { LimitOrderActionCard } from './components/LimitOrderActionCard' @@ -40,9 +47,11 @@ import { useIsWalletConnected } from '@/hooks/useIsWalletConnected/useIsWalletCo import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { selectWalletActionsSorted, + selectWalletClaimActions, selectWalletPendingActions, } from '@/state/slices/actionSlice/selectors' import type { + Action, ArbitrumBridgeWithdrawAction, GenericTransactionAction, } from '@/state/slices/actionSlice/types' @@ -50,6 +59,7 @@ import { ActionType, GenericTransactionDisplayType, isArbitrumBridgeWithdrawAction, + isClaimReadyAction, isGenericTransactionAction, } from '@/state/slices/actionSlice/types' import { swapSlice } from '@/state/slices/swapSlice/swapSlice' @@ -61,6 +71,7 @@ const ActionCenterIcon = const virtuosoStyle = { height: '100%', + scrollbarGutter: 'stable', } const INCREASE_VIEWPORT_BY = { @@ -68,9 +79,14 @@ const INCREASE_VIEWPORT_BY = { bottom: 100, } +const computeActionKey = (_index: number, action: Action) => action.id + +const tabs = [ActionCenterTab.Recent, ActionCenterTab.Claims] + export const ActionCenter = memo(() => { 'use no memo' - const { isDrawerOpen, openActionCenter, closeDrawer } = useActionCenterContext() + const { isDrawerOpen, activeTab, setActiveTab, openActionCenter, closeDrawer } = + useActionCenterContext() const { modalContentProps, overlayProps, modalProps } = useModalRegistration({ isOpen: isDrawerOpen, onClose: closeDrawer, @@ -112,6 +128,7 @@ export const ActionCenter = memo(() => { const handleCloseArbitrumClaim = useCallback(() => setArbitrumClaimActionId(undefined), []) const actions = useAppSelector(state => (isConnected ? selectWalletActionsSorted(state) : [])) + const claimActions = useAppSelector(state => (isConnected ? selectWalletClaimActions(state) : [])) const pendingActions = useAppSelector(state => isConnected ? selectWalletPendingActions(state) : [], @@ -119,11 +136,8 @@ export const ActionCenter = memo(() => { const { ordersByActionId } = useLimitOrders() const swapsById = useAppSelector(swapSlice.selectors.selectSwapsById) - const renderActionCard = useMemo(() => { - return (index: number) => { - const action = actions[index] - if (!action) return null - + const renderAction = useCallback( + (action: Action) => { const actionsCards = (() => { switch (action.type) { case ActionType.Swap: { @@ -201,8 +215,31 @@ export const ActionCenter = memo(() => { })() return actionsCards - } - }, [actions, handleOpenArbitrumClaim, handleOpenSpeedUp, ordersByActionId, swapsById]) + }, + [handleOpenArbitrumClaim, handleOpenSpeedUp, ordersByActionId, swapsById], + ) + + const renderActionCard = useCallback( + (_index: number, action: Action) => renderAction(action), + [renderAction], + ) + + const renderActionList = useCallback( + (data: Action[]) => ( + + ), + [renderActionCard], + ) + + const handleTabChange = useCallback((index: number) => setActiveTab(tabs[index]), [setActiveTab]) const actionCenterButton = useMemo(() => { if (pendingActions.length) { @@ -237,23 +274,56 @@ export const ActionCenter = memo(() => { ) }, [openActionCenter, translate, pendingActions]) - const drawerContent = useMemo(() => { - if (!actions.length) { - return - } + const readyClaimCount = useMemo( + () => claimActions.filter(isClaimReadyAction).length, + [claimActions], + ) + const drawerContent = useMemo(() => { return ( - action.id} - className='scroll-container' - /> + + + + {translate('actionCenter.recent')} + + + {translate('actionCenter.claims')} + {readyClaimCount > 0 && ( + + {readyClaimCount} + + )} + + + + + {actions.length ? renderActionList(actions) : } + + + {claimActions.length ? renderActionList(claimActions) : } + + + ) - }, [actions, renderActionCard, closeDrawer]) + }, [ + actions, + activeTab, + claimActions, + closeDrawer, + handleTabChange, + readyClaimCount, + renderActionList, + translate, + ]) return ( <> diff --git a/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx b/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx index f599355000a..5bb22c6b5e2 100644 --- a/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx +++ b/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx @@ -4,9 +4,17 @@ import { useNavigate } from 'react-router' import { breakpoints } from '@/theme/theme' +export enum ActionCenterTab { + Recent = 'recent', + Claims = 'claims', +} + type ActionCenterContextProps = { isDrawerOpen: boolean + activeTab: ActionCenterTab + setActiveTab: (tab: ActionCenterTab) => void openActionCenter: () => void + openActionCenterClaims: () => void closeDrawer: () => void } @@ -14,23 +22,41 @@ const ActionCenterContext = createContext( export const ActionCenterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [activeTab, setActiveTab] = useState(ActionCenterTab.Recent) const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`, { ssr: false }) const navigate = useNavigate() - const openActionCenter = useCallback(() => { - if (!isLargerThanMd) return navigate('/history') + const openActionCenterOnTab = useCallback( + (tab: ActionCenterTab) => { + setActiveTab(tab) + if (!isLargerThanMd) return navigate('/history') + + setIsDrawerOpen(true) + }, + [isLargerThanMd, navigate], + ) - setIsDrawerOpen(true) - }, [isLargerThanMd, navigate]) + const openActionCenter = useCallback( + () => openActionCenterOnTab(ActionCenterTab.Recent), + [openActionCenterOnTab], + ) + + const openActionCenterClaims = useCallback( + () => openActionCenterOnTab(ActionCenterTab.Claims), + [openActionCenterOnTab], + ) const closeDrawer = useCallback(() => setIsDrawerOpen(false), []) const value = useMemo( () => ({ isDrawerOpen, + activeTab, + setActiveTab, openActionCenter, + openActionCenterClaims, closeDrawer, }), - [isDrawerOpen, openActionCenter, closeDrawer], + [isDrawerOpen, activeTab, openActionCenter, openActionCenterClaims, closeDrawer], ) return {children} diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 6179f45d460..9d92816f8ad 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -12,9 +12,7 @@ import { } from '@chakra-ui/react' import { fromAccountId } from '@shapeshiftoss/caip' import type { KnownChainIds } from '@shapeshiftoss/types' -import { TxStatus } from '@shapeshiftoss/unchained-client' import { BigAmount, getChainShortName } from '@shapeshiftoss/utils' -import { noop } from 'lodash' import { useCallback, useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { zeroAddress } from 'viem' @@ -117,56 +115,23 @@ export const ArbitrumBridgeClaimModal = ({ amountCryptoPrecision, ]) - // Stays claimable until the claim confirms, so a dropped or reverted claim can be retried + // Stays claimable until the subscriber sees the claim confirm, revert or drop const handleClaimBroadcast = useCallback( (claimTxHash: string) => { + const latestAction = selectArbitrumBridgeWithdrawActionById(store.getState(), action.id) + if (!latestAction || latestAction.status !== ActionStatus.ClaimAvailable) return + dispatch( actionSlice.actions.upsertAction({ - ...action, - arbitrumBridgeMetadata: { ...action.arbitrumBridgeMetadata, claimTxHash }, + ...latestAction, + arbitrumBridgeMetadata: { ...latestAction.arbitrumBridgeMetadata, claimTxHash }, }), ) }, - [dispatch, action], - ) - - // Runs after the claim receipt resolves, possibly after the modal closed - const handleClaimTxStatus = useCallback( - (txStatus: TxStatus) => { - const latestAction = selectArbitrumBridgeWithdrawActionById(store.getState(), action.id) - if (!latestAction || latestAction.status === ActionStatus.Claimed) return - - switch (txStatus) { - case TxStatus.Confirmed: - dispatch( - actionSlice.actions.upsertAction({ ...latestAction, status: ActionStatus.Claimed }), - ) - return - case TxStatus.Failed: - dispatch( - actionSlice.actions.upsertAction({ - ...latestAction, - arbitrumBridgeMetadata: { - ...latestAction.arbitrumBridgeMetadata, - claimTxHash: undefined, - }, - }), - ) - return - default: - return - } - }, [dispatch, action.id], ) - const claimTxResult = useArbitrumClaimTx( - claimDetails, - destinationAccountId, - noop, - handleClaimTxStatus, - handleClaimBroadcast, - ) + const claimTxResult = useArbitrumClaimTx(claimDetails, destinationAccountId, handleClaimBroadcast) const executeTransactionDataResult = claimTxResult?.executeTransactionDataResult @@ -296,7 +261,7 @@ export const ArbitrumBridgeClaimModal = ({ !hasEnoughDestinationFeeBalance } isLoading={ - // The claim status poll keeps refetching, so a missing claim resolves on its own + // A missing claim resolves on the next status poll !claimDetails || executeTransactionDataResult?.isFetching || evmFeesResult?.isFetching || diff --git a/src/components/Layout/Header/ActionCenter/components/ClaimsEmptyState.tsx b/src/components/Layout/Header/ActionCenter/components/ClaimsEmptyState.tsx new file mode 100644 index 00000000000..a5edb3cef6b --- /dev/null +++ b/src/components/Layout/Header/ActionCenter/components/ClaimsEmptyState.tsx @@ -0,0 +1,19 @@ +import { Flex, Icon, Stack } from '@chakra-ui/react' +import { TbCoins } from 'react-icons/tb' + +import { Text } from '@/components/Text/Text' + +export const ClaimsEmptyState = () => ( + + + + + + +) diff --git a/src/components/Layout/Header/ActionCenter/components/SpeedUpModal.tsx b/src/components/Layout/Header/ActionCenter/components/SpeedUpModal.tsx index 0b9e05fc0e7..1f84e1c1aad 100644 --- a/src/components/Layout/Header/ActionCenter/components/SpeedUpModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/SpeedUpModal.tsx @@ -346,8 +346,7 @@ export const SpeedUpModal = ({ const signedTx = await adapter.signTransaction({ txToSign, wallet }) const replacementTxHash = await adapter.broadcastTransaction({ hex: signedTx }) - // Mark the replaced action first so the new Pending insert gets the newer - // `updatedAt` from the reducer and sorts above it in the action list. + // Mark the replaced action before inserting its replacement const replacedAction = actionsById[txHash] if ( replacedAction && diff --git a/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx index 4234593f288..9cad138eb0f 100644 --- a/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx @@ -1,5 +1,4 @@ import { Box, useDisclosure } from '@chakra-ui/react' -import { ethChainId } from '@shapeshiftoss/caip' import { SwapperName } from '@shapeshiftoss/swapper' import type { KnownChainIds } from '@shapeshiftoss/types' import { getChainShortName } from '@shapeshiftoss/utils' @@ -101,11 +100,7 @@ export const SwapActionCard = ({ action, isCollapsable = false }: SwapActionCard const title = useMemo(() => { const { status } = action if (isArbitrumBridge) { - if (status === ActionStatus.Complete) { - // Complete is not really complete for withdrawals - const isWithdrawal = swap?.buyAsset.chainId === ethChainId - return isWithdrawal ? 'actionCenter.bridge.initiated' : 'actionCenter.bridge.complete' - } + if (status === ActionStatus.Complete) return 'actionCenter.bridge.complete' if (status === ActionStatus.Failed) return 'actionCenter.bridge.failed' if (status === ActionStatus.Initiated) return 'actionCenter.bridge.initiated' @@ -119,7 +114,7 @@ export const SwapActionCard = ({ action, isCollapsable = false }: SwapActionCard if (status === ActionStatus.AwaitingSwap) return 'actionCenter.swap.awaitingSwap' return 'actionCenter.swap.processing' - }, [action, isArbitrumBridge, swap?.isStreaming, swap?.buyAsset.chainId]) + }, [action, isArbitrumBridge, swap?.isStreaming]) const icon = useMemo(() => { return ( diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx index 2d1dbf5c7ea..7ad8ad42d87 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx @@ -1,15 +1,15 @@ import type { AccountId } from '@shapeshiftoss/caip' import { fromAccountId } from '@shapeshiftoss/caip' import { CONTRACT_INTERACTION } from '@shapeshiftoss/chain-adapters' -import { ARB_OUTBOX_ABI, assertGetViemClient, getEthersV5Provider } from '@shapeshiftoss/contracts' +import { ARB_OUTBOX_ABI, getEthersV5Provider } from '@shapeshiftoss/contracts' import { KnownChainIds } from '@shapeshiftoss/types' -import { TxStatus } from '@shapeshiftoss/unchained-client' import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMemo } from 'react' -import type { Address, Hash, Hex } from 'viem' +import type { Address, Hex } from 'viem' import { encodeFunctionData, getAddress } from 'viem' import type { ClaimDetails } from './useArbitrumClaimsByStatus' +import { arbitrumNetwork } from './useArbitrumClaimsByStatus' import { useEvmFees } from '@/hooks/queries/useEvmFees' import { useWallet } from '@/hooks/useWallet/useWallet' @@ -21,14 +21,10 @@ import { import { selectBip44ParamsByAccountId } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const ARBITRUM_OUTBOX = '0x0B9857ae2D4A3DBe74ffE1d7DF045bb7F96E4840' - export const useArbitrumClaimTx = ( claim: ClaimDetails | undefined, destinationAccountId: AccountId | undefined, - setClaimTxHash: (txHash: string) => void, - setClaimTxStatus: (txStatus: TxStatus) => void, - onClaimSuccess?: (claimTxHash: string) => void, + onClaimBroadcast: (claimTxHash: string) => void, ) => { const wallet = useWallet().state.wallet const queryClient = useQueryClient() @@ -75,7 +71,7 @@ export const useArbitrumClaimTx = ( chainId: claim?.destinationChainId, data: executeTransactionDataResult.data, refetchInterval: 15_000, - to: getAddress(ARBITRUM_OUTBOX), + to: getAddress(arbitrumNetwork.ethBridge.outbox), value: '0', }) @@ -95,7 +91,7 @@ export const useArbitrumClaimTx = ( from: fromAccountId(destinationAccountId).account, adapter, data: executeTransactionDataResult.data, - to: ARBITRUM_OUTBOX, + to: arbitrumNetwork.ethBridge.outbox, value: '0', wallet, }) @@ -108,36 +104,8 @@ export const useArbitrumClaimTx = ( return txHash }, - onMutate() { - setClaimTxStatus(TxStatus.Pending) - }, onSuccess(txHash) { - if (!txHash) { - setClaimTxStatus(TxStatus.Failed) - return - } - - setClaimTxHash(txHash) - onClaimSuccess?.(txHash) - - const checkStatus = async () => { - if (!claim) return - const publicClient = assertGetViemClient(claim.destinationChainId) - const { status } = await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) - - switch (status) { - case 'success': - return setClaimTxStatus(TxStatus.Confirmed) - case 'reverted': - default: - return setClaimTxStatus(TxStatus.Failed) - } - } - - checkStatus() - }, - onError() { - setClaimTxStatus(TxStatus.Failed) + if (txHash) onClaimBroadcast(txHash) }, onSettled() { queryClient.invalidateQueries({ diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx index 15dd9d8ce4e..c623d3f9c8d 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx @@ -7,7 +7,13 @@ import { import { Outbox__factory } from '@arbitrum/sdk/dist/lib/abi/factories/Outbox__factory' import { BoldRollupUserLogic__factory } from '@arbitrum/sdk/dist/lib/abi-bold/factories/BoldRollupUserLogic__factory' import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' -import { arbitrumChainId, ethAssetId, ethChainId, toAccountId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + ethAssetId, + ethChainId, + fromChainId, + toAccountId, +} from '@shapeshiftoss/caip' import { getEthersV5Provider } from '@shapeshiftoss/contracts' import { KnownChainIds } from '@shapeshiftoss/types' import type { Query } from '@tanstack/react-query' @@ -29,7 +35,7 @@ import { useAppSelector } from '@/state/store' // Arbitrum's challenge period; the claim can open up to an hour later while an assertion posts const ARBITRUM_CHALLENGE_PERIOD_MS = 6.4 * 24 * 60 * 60 * 1000 -// Confirmations can run behind the challenge period, so the observed lag extends the estimate +// Confirmations running past the challenge period extend the estimate by the observed lag export const getArbitrumClaimableAt = (withdrawTimeMs: number, confirmationLagMs = 0): number => withdrawTimeMs + Math.max(ARBITRUM_CHALLENGE_PERIOD_MS, confirmationLagMs) @@ -40,15 +46,19 @@ type ConfirmedChildBlock = { type EthersV5Provider = ReturnType -// Every withdraw at or below the latest confirmed assertion's child block is claimable. Reading it -// directly avoids the sdk's status scan, which walks every assertion since the last confirmation -// and exceeds our rpc's eth_getLogs block range for unconfirmed withdraws. +export const arbitrumNetwork = getArbitrumNetwork( + Number(fromChainId(arbitrumChainId).chainReference), +) + +const CONFIRMED_CHILD_BLOCK_QUERY_KEY = ['arbitrumConfirmedChildBlock'] + +// Withdraws at or below this block are claimable; the sdk's own status scan exceeds our rpc's log range const getConfirmedChildBlock = async ( l1Provider: EthersV5Provider, l2Provider: EthersV5Provider, + previous: ConfirmedChildBlock | undefined, ): Promise => { - const network = await getArbitrumNetwork(l2Provider) - const rollup = BoldRollupUserLogic__factory.connect(network.ethBridge.rollup, l1Provider) + const rollup = BoldRollupUserLogic__factory.connect(arbitrumNetwork.ethBridge.rollup, l1Provider) const latestConfirmed = await rollup.latestConfirmed() const createdAtBlock = (await rollup.getAssertion(latestConfirmed)).createdAtBlock.toNumber() @@ -61,6 +71,10 @@ const getConfirmedChildBlock = async ( const [blockHash] = assertionCreated.args.assertion.afterState.globalState.bytes32Vals const block = await l2Provider.getBlock(blockHash) + if (!block) throw new Error(`Confirmed child block ${blockHash} not found`) + + // Lag is measured when a confirmation is first seen so the estimate holds until the next one + if (previous?.number === block.number) return previous return { number: block.number, confirmationLagMs: Date.now() - block.timestamp * 1000 } } @@ -130,12 +144,29 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { const l1Provider = getEthersV5Provider(KnownChainIds.EthereumMainnet) const l2Provider = getEthersV5Provider(KnownChainIds.ArbitrumMainnet) + // Shared by every withdraw in the same poll, falling back to the last good block on a failed refresh + const fetchConfirmedChildBlock = async (): Promise => { + const previous = queryClient.getQueryData(CONFIRMED_CHILD_BLOCK_QUERY_KEY) + + try { + return await queryClient.fetchQuery({ + queryKey: CONFIRMED_CHILD_BLOCK_QUERY_KEY, + queryFn: () => getConfirmedChildBlock(l1Provider, l2Provider, previous), + staleTime: 30_000, + gcTime: Infinity, + }) + } catch (error) { + if (previous) return previous + throw error + } + } + const claimStatuses = useQueries({ queries: arbitrumWithdrawTxs.map(tx => { return { queryKey: ['claimStatus', { txid: tx.txid }], queryFn: async (): Promise => { - // A withdraw's message never changes, so it is fetched once + // Fetched once, a withdraw's message never changes const { blockNumber, event, message } = await queryClient.fetchQuery({ queryKey: ['arbitrumClaimMessage', { txid: tx.txid }], queryFn: () => getClaimMessage(tx.txid, l1Provider, l2Provider), @@ -143,20 +174,14 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { gcTime: Infinity, }) - // Shared by every withdraw in the same poll - const confirmedChildBlock = await queryClient.fetchQuery({ - queryKey: ['arbitrumConfirmedChildBlock'], - queryFn: () => getConfirmedChildBlock(l1Provider, l2Provider), - staleTime: 30_000, - }) + const confirmedChildBlock = await fetchConfirmedChildBlock() const status = await (async () => { if (blockNumber > confirmedChildBlock.number) { return ChildToParentMessageStatus.UNCONFIRMED } - const network = await getArbitrumNetwork(l2Provider) - const outbox = Outbox__factory.connect(network.ethBridge.outbox, l1Provider) + const outbox = Outbox__factory.connect(arbitrumNetwork.ethBridge.outbox, l1Provider) const isSpent = await outbox.isSpent(event.position) return isSpent diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index a132cb7aea4..b167190af6e 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -1,9 +1,14 @@ import { usePrevious } from '@chakra-ui/react' import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' +import { assertGetViemClient } from '@shapeshiftoss/contracts' import { SwapperName } from '@shapeshiftoss/swapper' +import { TxStatus } from '@shapeshiftoss/unchained-client' import { isSome } from '@shapeshiftoss/utils' +import { useQueries } from '@tanstack/react-query' import { useCallback, useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import type { Hash } from 'viem' +import { TransactionNotFoundError, TransactionReceiptNotFoundError } from 'viem' import { useNotificationToast } from '../useNotificationToast' import { @@ -29,6 +34,28 @@ import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { swapSlice } from '@/state/slices/swapSlice/swapSlice' import { useAppDispatch, useAppSelector } from '@/state/store' +// A node can briefly miss a fresh broadcast, so only a long-unknown claim counts as dropped +const CLAIM_TX_DROPPED_AFTER_MS = 10 * 60 * 1000 + +const getClaimTxStatus = async (claimTxHash: Hash, broadcastAt: number): Promise => { + const client = assertGetViemClient(ethChainId) + + try { + const { status } = await client.getTransactionReceipt({ hash: claimTxHash }) + return status === 'success' ? TxStatus.Confirmed : TxStatus.Failed + } catch (error) { + if (!(error instanceof TransactionReceiptNotFoundError)) throw error + } + + try { + await client.getTransaction({ hash: claimTxHash }) + return TxStatus.Pending + } catch (error) { + if (!(error instanceof TransactionNotFoundError)) throw error + return Date.now() - broadcastAt > CLAIM_TX_DROPPED_AFTER_MS ? TxStatus.Failed : TxStatus.Pending + } +} + export const useArbitrumWithdrawalActionSubscriber = () => { const dispatch = useAppDispatch() const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) @@ -54,7 +81,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { [actionsById], ) - const { isDrawerOpen } = useActionCenterContext() + const { isDrawerOpen, openActionCenterClaims } = useActionCenterContext() const toastOptions = useMemo(() => ({ duration: isDrawerOpen ? 5000 : null }), [isDrawerOpen]) const toast = useNotificationToast(toastOptions) const previousIsDrawerOpen = usePrevious(isDrawerOpen) @@ -132,9 +159,13 @@ export const useArbitrumWithdrawalActionSubscriber = () => { title: translate('bridge.bridgeWithdrawalReadyNotification'), description: translate('bridge.checkActionCenterNotification'), position: 'bottom-right', + onClick: () => { + toast.close(actionId) + openActionCenterClaims() + }, }) }, - [toast, translate], + [openActionCenterClaims, toast, translate], ) // Rebuild missing actions from tx history, e.g. after a wiped store or an outside withdrawal @@ -251,4 +282,54 @@ export const useArbitrumWithdrawalActionSubscriber = () => { claimsByStatus.Pending.length, pendingClaimableAts, ]) + + // Resolves in-flight claims, including ones broadcast before a reload + const claimingActions = useMemo( + () => + Object.values(actionsById) + .filter(isArbitrumBridgeWithdrawAction) + .filter( + action => + action.status === ActionStatus.ClaimAvailable && + Boolean(action.arbitrumBridgeMetadata.claimTxHash), + ), + [actionsById], + ) + + const claimTxStatuses = useQueries({ + queries: claimingActions.map(action => { + const claimTxHash = action.arbitrumBridgeMetadata.claimTxHash as Hash + + return { + queryKey: ['arbitrumClaimTxStatus', { claimTxHash }], + // updatedAt is the broadcast time, a claiming action isn't written again until it resolves + queryFn: () => getClaimTxStatus(claimTxHash, action.updatedAt), + refetchInterval: 15_000, + } + }), + }) + + const claimTxStatusKey = claimTxStatuses.map(({ data }) => data).join() + + useEffect(() => { + claimingActions.forEach((action, i) => { + switch (claimTxStatuses[i]?.data) { + case TxStatus.Confirmed: + dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.Claimed })) + return + case TxStatus.Failed: + dispatch( + actionSlice.actions.upsertAction({ + ...action, + arbitrumBridgeMetadata: { ...action.arbitrumBridgeMetadata, claimTxHash: undefined }, + }), + ) + return + default: + return + } + }) + // claimTxStatuses is recreated on every render, use its statuses for a stable reference + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, claimingActions, claimTxStatusKey]) } diff --git a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx index 0a0d00c34f8..86857ec101a 100644 --- a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx @@ -74,7 +74,7 @@ const YIELD_POLL_INTERVAL_MS = 5000 export const useGenericTransactionSubscriber = () => { const dispatch = useAppDispatch() - const { isDrawerOpen, openActionCenter } = useActionCenterContext() + const { isDrawerOpen, openActionCenter, openActionCenterClaims } = useActionCenterContext() const toast = useNotificationToast({ duration: isDrawerOpen ? 5000 : null }) const pendingGenericTransactionActions = useAppSelector(selectPendingGenericTransactionActions) @@ -92,7 +92,11 @@ export const useGenericTransactionSubscriber = () => { }, []) const fireToast = useCallback( - (action: (typeof pendingGenericTransactionActions)[number], status: 'success' | 'error') => { + ( + action: (typeof pendingGenericTransactionActions)[number], + status: 'success' | 'error', + isClaim = false, + ) => { if (toast.isActive(action.transactionMetadata.txHash)) return toast({ @@ -102,7 +106,7 @@ export const useGenericTransactionSubscriber = () => { render: ({ onClose, ...props }) => { const handleClick = () => { onClose() - openActionCenter() + isClaim ? openActionCenterClaims() : openActionCenter() } return ( @@ -116,7 +120,7 @@ export const useGenericTransactionSubscriber = () => { }, }) }, - [isDrawerOpen, openActionCenter, toast], + [isDrawerOpen, openActionCenter, openActionCenterClaims, toast], ) useEffect(() => { @@ -178,7 +182,7 @@ export const useGenericTransactionSubscriber = () => { }), ) - fireToast(action, 'success') + fireToast(action, 'success', true) clearPollingInterval(pollingKey) } } @@ -235,7 +239,7 @@ export const useGenericTransactionSubscriber = () => { }), ) - fireToast(action, 'success') + fireToast(action, 'success', true) } else { const typeMessagesMap = displayTypeMessagesMap[action.type] const message = diff --git a/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts b/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts index 872488eabd1..e07a3ff3568 100644 --- a/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts +++ b/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts @@ -10,6 +10,7 @@ export const useArbitrumClaimTimeText = (claimableAt: number | undefined): strin const [now, setNow] = useState(Date.now) useEffect(() => { + setNow(Date.now()) if (!claimableAt || claimableAt <= Date.now()) return const interval = setInterval(() => { diff --git a/src/state/slices/actionSlice/selectors.test.ts b/src/state/slices/actionSlice/selectors.test.ts index a95957519dd..2c0b47cc96c 100644 --- a/src/state/slices/actionSlice/selectors.test.ts +++ b/src/state/slices/actionSlice/selectors.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { selectWalletActions, selectWalletActionsSorted, + selectWalletClaimActions, selectYieldActionsByTxHash, } from './selectors' import type { ActionState, ArbitrumBridgeWithdrawAction, GenericTransactionAction } from './types' @@ -140,26 +141,49 @@ describe('selectWalletActions', () => { }) }) -describe('selectWalletActionsSorted', () => { - const action = ( - id: string, - status: ActionStatus, - createdAt: number, - updatedAt: number, - ): GenericTransactionAction => ({ - ...mockSendAction, - id, - status, - createdAt, - updatedAt, - }) +const action = ( + id: string, + status: ActionStatus, + createdAt: number, + updatedAt: number, +): GenericTransactionAction => ({ + ...mockSendAction, + id, + status, + createdAt, + updatedAt, +}) + +const arbitrumWithdraw = ( + id: string, + status: ActionStatus, + createdAt: number, + claimableAt: number, + claimTxHash?: string, +): ArbitrumBridgeWithdrawAction => ({ + id, + type: ActionType.ArbitrumBridgeWithdraw, + status, + createdAt, + updatedAt: createdAt, + arbitrumBridgeMetadata: { + withdrawTxHash: id, + amountCryptoBaseUnit: '1000', + assetId: 'eip155:42161/slip44:60', + destinationAssetId: 'eip155:1/slip44:60', + accountId: 'eip155:42161:0xarb', + destinationAccountId: 'eip155:1:0xarb', + claimableAt, + claimTxHash, + }, +}) - it('anchors in-flight actions on top by start time, then settled ones by last update', () => { +describe('selectWalletActionsSorted', () => { + it('lists the feed newest first, dating in-flight actions by start and settled ones by last update', () => { const oldPending = action('old-pending', ActionStatus.Pending, 100, 900) - const newPending = action('new-pending', ActionStatus.Initiated, 300, 300) - const claimable = action('claimable', ActionStatus.ClaimAvailable, 200, 950) + const newPending = action('new-pending', ActionStatus.Pending, 300, 300) const recentlyDone = action('recently-done', ActionStatus.Complete, 150, 800) - const longDone = action('long-done', ActionStatus.Claimed, 400, 500) + const longDone = action('long-done', ActionStatus.Complete, 400, 500) const abandoned = action('abandoned', ActionStatus.Abandoned, 999, 999) const sorted = selectWalletActionsSorted.resultFunc([ @@ -167,16 +191,44 @@ describe('selectWalletActionsSorted', () => { oldPending, abandoned, longDone, - claimable, newPending, ]) expect(sorted.map(a => a.id)).toEqual([ - 'new-pending', - 'claimable', - 'old-pending', 'recently-done', 'long-done', + 'new-pending', + 'old-pending', ]) }) + + it('leaves claims to the claims section, but keeps settled claims in the feed', () => { + const claimable = action('claimable', ActionStatus.ClaimAvailable, 200, 950) + const pendingWithdraw = arbitrumWithdraw('pending-withdraw', ActionStatus.Initiated, 300, 900) + const claimed = arbitrumWithdraw('claimed', ActionStatus.Claimed, 100, 100) + + const sorted = selectWalletActionsSorted.resultFunc([claimable, pendingWithdraw, claimed]) + + expect(sorted.map(a => a.id)).toEqual(['claimed']) + }) +}) + +describe('selectWalletClaimActions', () => { + it('lists claims newest first by the same timestamps as the feed', () => { + const oldReady = action('old-ready', ActionStatus.ClaimAvailable, 100, 100) + const newReady = arbitrumWithdraw('new-ready', ActionStatus.ClaimAvailable, 200, 200) + const claiming = arbitrumWithdraw('claiming', ActionStatus.ClaimAvailable, 50, 60, '0xclaim') + const pending = arbitrumWithdraw('pending', ActionStatus.Initiated, 300, 1_000) + const swap = action('swap', ActionStatus.Pending, 500, 500) + + const claims = selectWalletClaimActions.resultFunc([ + claiming, + swap, + oldReady, + pending, + newReady, + ]) + + expect(claims.map(a => a.id)).toEqual(['pending', 'new-ready', 'old-ready', 'claiming']) + }) }) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 3bd9f1121d9..6e2f61e3263 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -16,9 +16,9 @@ import { ActionType, GenericTransactionDisplayType, getActionTimestamp, - isActiveActionStatus, isArbitrumBridgeWithdrawAction, isChainflipLendingAction, + isClaimSectionAction, isGenericTransactionAction, isLimitOrderAction, isPendingSendAction, @@ -97,20 +97,26 @@ export const selectWalletActions = createDeepEqualOutputSelector( }, ) -// In-flight actions first, newest started first, then settled actions, newest settled first +export const selectWalletClaimActions = createDeepEqualOutputSelector( + selectWalletActions, + actions => { + return actions + .filter(isClaimSectionAction) + .sort((a, b) => getActionTimestamp(b) - getActionTimestamp(a)) + }, +) + export const selectWalletActionsSorted = createDeepEqualOutputSelector( selectWalletActions, actions => { return actions .filter( - action => action.status !== ActionStatus.Idle && action.status !== ActionStatus.Abandoned, + action => + action.status !== ActionStatus.Idle && + action.status !== ActionStatus.Abandoned && + !isClaimSectionAction(action), ) - .sort((a, b) => { - const aIsActive = isActiveActionStatus(a.status) - const bIsActive = isActiveActionStatus(b.status) - if (aIsActive !== bIsActive) return aIsActive ? -1 : 1 - return getActionTimestamp(b) - getActionTimestamp(a) - }) + .sort((a, b) => getActionTimestamp(b) - getActionTimestamp(a)) }, ) diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index 0dc0dff41ea..ce2a56adfbd 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -318,3 +318,26 @@ export const isArbitrumBridgeWithdrawAction = ( export const isChainflipLendingAction = (action: Action): action is ChainflipLendingAction => { return Boolean(action.type === ActionType.ChainflipLending && action.chainflipLendingMetadata) } + +const isYieldCooldownClaimAction = (action: Action): action is GenericTransactionAction => + isGenericTransactionAction(action) && + action.type === ActionType.Claim && + action.transactionMetadata.displayType === GenericTransactionDisplayType.Claim + +// Claims live in their own action center section while they are waiting, ready or being claimed +export const isClaimSectionAction = (action: Action): boolean => { + switch (action.status) { + case ActionStatus.ClaimAvailable: + return true + case ActionStatus.Initiated: + return isArbitrumBridgeWithdrawAction(action) || isYieldCooldownClaimAction(action) + case ActionStatus.Pending: + return isRfoxClaimAction(action) || isTcyClaimAction(action) + default: + return false + } +} + +export const isClaimReadyAction = (action: Action): boolean => + action.status === ActionStatus.ClaimAvailable && + !(isArbitrumBridgeWithdrawAction(action) && action.arbitrumBridgeMetadata.claimTxHash) From 386260ebe79d1dee7f4d4fa4f50132c6b4dfaba3 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:44:41 -0600 Subject: [PATCH 15/40] fix(action-center): complete withdraw swaps on their own tx, settle tcy and rfox claims - a successful arbitrum withdraw swap is complete once its withdraw tx confirms, the withdraw action tracks the claim, so nothing jumps to the top of recent at claim time - key tcy claim, pending and claimed actions by the same account id so a claim leaves the claims tab, and keep the subscriber off in-flight and claimed tcy claims - delete claimable rfox claims whose request is gone from their account's fresh requests, i.e. claimed elsewhere Co-Authored-By: Claude Opus 5.5 --- .../components/SwapActionCard.tsx | 10 ++++++--- .../useArbitrumWithdrawalActionSubscriber.tsx | 20 +++++------------- .../useSwapActionSubscriber.tsx | 12 +---------- .../hooks/useRfoxClaimActionSubscriber.tsx | 21 +++++++++++++++++++ .../TCY/components/Claim/ClaimConfirm.tsx | 2 +- .../TCY/components/Claim/ClaimRoutes.tsx | 4 ++-- .../TCY/hooks/useTcyClaimActionSubscriber.tsx | 9 ++++++++ 7 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx index 9cad138eb0f..6d13dc69060 100644 --- a/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/SwapActionCard.tsx @@ -1,4 +1,5 @@ import { Box, useDisclosure } from '@chakra-ui/react' +import { ethChainId } from '@shapeshiftoss/caip' import { SwapperName } from '@shapeshiftoss/swapper' import type { KnownChainIds } from '@shapeshiftoss/types' import { getChainShortName } from '@shapeshiftoss/utils' @@ -100,9 +101,12 @@ export const SwapActionCard = ({ action, isCollapsable = false }: SwapActionCard const title = useMemo(() => { const { status } = action if (isArbitrumBridge) { - if (status === ActionStatus.Complete) return 'actionCenter.bridge.complete' + if (status === ActionStatus.Complete) { + // A withdraw swap only covers the withdraw tx, its withdraw action tracks the claim + const isWithdrawal = swap?.buyAsset.chainId === ethChainId + return isWithdrawal ? 'actionCenter.bridge.initiated' : 'actionCenter.bridge.complete' + } if (status === ActionStatus.Failed) return 'actionCenter.bridge.failed' - if (status === ActionStatus.Initiated) return 'actionCenter.bridge.initiated' return 'actionCenter.bridge.processing' } @@ -114,7 +118,7 @@ export const SwapActionCard = ({ action, isCollapsable = false }: SwapActionCard if (status === ActionStatus.AwaitingSwap) return 'actionCenter.swap.awaitingSwap' return 'actionCenter.swap.processing' - }, [action, isArbitrumBridge, swap?.isStreaming]) + }, [action, isArbitrumBridge, swap?.isStreaming, swap?.buyAsset.chainId]) const icon = useMemo(() => { return ( diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index b167190af6e..3b4a0bce371 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -1,7 +1,7 @@ import { usePrevious } from '@chakra-ui/react' import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' import { assertGetViemClient } from '@shapeshiftoss/contracts' -import { SwapperName } from '@shapeshiftoss/swapper' +import { SwapperName, SwapStatus } from '@shapeshiftoss/swapper' import { TxStatus } from '@shapeshiftoss/unchained-client' import { isSome } from '@shapeshiftoss/utils' import { useQueries } from '@tanstack/react-query' @@ -92,7 +92,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { } }, [isDrawerOpen, toast, previousIsDrawerOpen]) - // Create ArbitrumBridge withdraw actions from initiated swap actions, completing the swap once claimed + // Create ArbitrumBridge withdraw actions from successful withdraw swaps useEffect(() => { const allClaims = [ ...claimsByStatus.Pending, @@ -102,29 +102,19 @@ export const useArbitrumWithdrawalActionSubscriber = () => { Object.values(actionsById) .filter(isSwapAction) - .filter(action => action.status === ActionStatus.Initiated) .forEach(swapAction => { const swap = swapsById[swapAction.swapMetadata.swapId] if ( - !swap?.sellTxHash || + swap?.status !== SwapStatus.Success || + !swap.sellTxHash || !swap.buyAccountId || swap.swapperName !== SwapperName.ArbitrumBridge || swap.buyAsset.chainId !== ethChainId ) return - const withdrawAction = arbitrumActionsByWithdrawTxHash[swap.sellTxHash] - - // The swap stays in flight until its withdraw is claimed - if (withdrawAction?.status === ActionStatus.Claimed) { - dispatch( - actionSlice.actions.upsertAction({ ...swapAction, status: ActionStatus.Complete }), - ) - return - } - // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 - if (withdrawAction) return + if (arbitrumActionsByWithdrawTxHash[swap.sellTxHash]) return const claim = allClaims.find(claim => claim.tx.txid === swap.sellTxHash) diff --git a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx index 6c7d7f2b472..c2f5e195d7e 100644 --- a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx @@ -1,9 +1,8 @@ import { usePrevious } from '@chakra-ui/react' -import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' +import { fromAccountId } from '@shapeshiftoss/caip' import type { Swap } from '@shapeshiftoss/swapper' import { fetchSafeTransactionInfo, - SwapperName, swappers, SwapStatus, TRADE_STATUS_POLL_INTERVAL_MILLISECONDS, @@ -58,15 +57,6 @@ const getActionStatusFromSwap = ( isApprovalRequired?: boolean, isActiveSwap: boolean = true, ): ActionStatus => { - // Special handling for ArbitrumBridge - Success means withdrawal initiated, not complete - if ( - swap.status === SwapStatus.Success && - swap.swapperName === SwapperName.ArbitrumBridge && - swap.buyAsset.chainId === ethChainId - ) { - return ActionStatus.Initiated - } - // If swap is pending/success/failed, use direct mapping if (swap.status !== SwapStatus.Idle) { return swapStatusToActionStatus[swap.status] diff --git a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx index 5939ebe5a5e..e6411536cde 100644 --- a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx +++ b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx @@ -112,4 +112,25 @@ export const useRfoxClaimActionSubscriber = () => { // We definitely don't want to react on assets here // eslint-disable-next-line react-hooks/exhaustive-deps }, [allUnstakingRequests.data, allUnstakingRequests.isSuccess, dispatch, actionIds]) + + // A claimable request missing from its account's fresh requests was claimed elsewhere + useEffect(() => { + if (!allUnstakingRequests.isSuccess) return + + const { byAccountId } = allUnstakingRequests.data + + Object.values(actions) + .filter(isRfoxClaimAction) + .filter(action => action.status === ActionStatus.ClaimAvailable) + .forEach(action => { + const accountRequests = + byAccountId[action.rfoxClaimActionMetadata.request.stakingAssetAccountId] + if (!accountRequests) return + if (accountRequests.some(request => request.id === action.id)) return + + dispatch(actionSlice.actions.deleteAction(action.id)) + }) + // Only react to fresh requests, not to our own deletes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allUnstakingRequests.data, allUnstakingRequests.isSuccess, dispatch]) } diff --git a/src/pages/TCY/components/Claim/ClaimConfirm.tsx b/src/pages/TCY/components/Claim/ClaimConfirm.tsx index c5631e36ee3..4de8a274450 100644 --- a/src/pages/TCY/components/Claim/ClaimConfirm.tsx +++ b/src/pages/TCY/components/Claim/ClaimConfirm.tsx @@ -182,7 +182,7 @@ export const ClaimConfirm = ({ claim, setClaimTxid }: ClaimConfirmProps) => { dispatch( actionSlice.actions.upsertAction({ - id: claim.l1_address, + id: claim.accountId, status: ActionStatus.Pending, type: ActionType.TcyClaim, createdAt: Date.now(), diff --git a/src/pages/TCY/components/Claim/ClaimRoutes.tsx b/src/pages/TCY/components/Claim/ClaimRoutes.tsx index d27cd21273b..e12cd88e5c8 100644 --- a/src/pages/TCY/components/Claim/ClaimRoutes.tsx +++ b/src/pages/TCY/components/Claim/ClaimRoutes.tsx @@ -73,7 +73,7 @@ const ClaimRoutes = ({ const dispatch = useAppDispatch() const pendingTcyClaimActions = useAppSelector(selectPendingTcyClaimActions) const maybePendingAction = useMemo( - () => pendingTcyClaimActions.find(a => a.id === claim?.l1_address), + () => pendingTcyClaimActions.find(a => a.id === claim?.accountId), [pendingTcyClaimActions, claim], ) @@ -95,7 +95,7 @@ const ClaimRoutes = ({ // Dispatch claimed action dispatch( actionSlice.actions.upsertAction({ - id: claim.l1_address, + id: claim.accountId, status: ActionStatus.Claimed, type: ActionType.TcyClaim, createdAt, diff --git a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx index 903398b32c3..a951bd97640 100644 --- a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx +++ b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx @@ -37,6 +37,15 @@ export const useTcyClaimActionSubscriber = () => { allClaims.forEach(claim => { const maybeStoreAction = actions[claim.accountId] + // A claim in flight or already claimed shares this action, leave it to the claim flow + if ( + maybeStoreAction && + isTcyClaimAction(maybeStoreAction) && + (maybeStoreAction.status === ActionStatus.Pending || + maybeStoreAction.status === ActionStatus.Claimed) + ) + return + // If this claim is already available and still available, no-op if ( maybeStoreAction && From c7633c5ee44cffd2d21b64d1d5da17b0b77bd046 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:34:55 -0600 Subject: [PATCH 16/40] fix(action-center): address review, unify claim lifecycles, clear legacy claim actions - keep selectWalletActionsSorted complete for subscribers, the recent tab gets its own selector - group claims by action type and open status so new claims land in the claims tab - arbitrum, rfox and tcy claims share one lifecycle: claimable, pending while the claim tx is in flight, claimed or back to claimable - resolve tcy claims from their thorchain status outside the claim page, keep rfox and tcy creation off in-flight claims - only delete claimed-elsewhere rfox claims whose program was fetched - create withdraw actions when a withdraw swap succeeds, dated by the withdraw block, tx history only recovers missing ones - claims hook returns action statuses, moved out of the removed trade claims tab - time pending withdraws from their covering assertion, skip pre-nitro withdraws, never move a withdraw backwards - clear arbitrum withdraw and tcy claim actions once, completing legacy withdraw swaps in place - split the action center tab into its own context, reuse formatSmartDate Co-Authored-By: Claude Opus 5.5 --- .../Header/ActionCenter/ActionCenter.tsx | 14 +- .../ActionCenter/ActionCenterContext.tsx | 28 +- .../components/ArbitrumBridgeClaimModal.tsx | 17 +- .../ArbitrumBridgeWithdrawActionCard.tsx | 32 +-- .../components/ClaimActionCard.tsx | 16 +- .../GenericTransactionActionCard.tsx | 14 +- .../Notifications/SwapNotification.tsx | 6 +- .../arbitrumBridgeWithdrawAction.test.ts | 60 ++-- .../arbitrumBridgeWithdrawAction.ts | 51 +++- .../useArbitrumWithdrawalActionSubscriber.tsx | 143 ++-------- .../useSwapActionSubscriber.tsx | 20 ++ .../useArbitrumClaims}/useArbitrumClaimTx.tsx | 4 +- .../useArbitrumClaims/useArbitrumClaims.tsx} | 261 ++++++++++++------ .../hooks/useRfoxClaimActionSubscriber.tsx | 34 +-- .../TCY/hooks/useTcyClaimActionSubscriber.tsx | 58 +++- src/state/migrations/clearClaimActions.ts | 32 +++ src/state/migrations/index.ts | 2 + .../slices/actionSlice/selectors.test.ts | 31 ++- src/state/slices/actionSlice/selectors.ts | 11 +- src/state/slices/actionSlice/types.ts | 30 +- 20 files changed, 510 insertions(+), 354 deletions(-) rename src/{components/MultiHopTrade/components/TradeInput/components/Claim/hooks => hooks/useArbitrumClaims}/useArbitrumClaimTx.tsx (96%) rename src/{components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx => hooks/useArbitrumClaims/useArbitrumClaims.tsx} (50%) create mode 100644 src/state/migrations/clearClaimActions.ts diff --git a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx index e5cc7b13ab9..ac665d05bde 100644 --- a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx +++ b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx @@ -22,7 +22,7 @@ import { TbBellFilled } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { Virtuoso } from 'react-virtuoso' -import { ActionCenterTab, useActionCenterContext } from './ActionCenterContext' +import { ActionCenterTab, useActionCenterContext, useActionCenterTab } from './ActionCenterContext' import { AppUpdateActionCard } from './components/AppUpdateActionCard' import { ArbitrumBridgeClaimModal } from './components/ArbitrumBridgeClaimModal' import { ArbitrumBridgeWithdrawActionCard } from './components/ArbitrumBridgeWithdrawActionCard' @@ -46,9 +46,9 @@ import { useModalRegistration } from '@/context/ModalStackProvider' import { useIsWalletConnected } from '@/hooks/useIsWalletConnected/useIsWalletConnected' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { - selectWalletActionsSorted, selectWalletClaimActions, selectWalletPendingActions, + selectWalletRecentActions, } from '@/state/slices/actionSlice/selectors' import type { Action, @@ -56,10 +56,10 @@ import type { GenericTransactionAction, } from '@/state/slices/actionSlice/types' import { + ActionStatus, ActionType, GenericTransactionDisplayType, isArbitrumBridgeWithdrawAction, - isClaimReadyAction, isGenericTransactionAction, } from '@/state/slices/actionSlice/types' import { swapSlice } from '@/state/slices/swapSlice/swapSlice' @@ -85,8 +85,8 @@ const tabs = [ActionCenterTab.Recent, ActionCenterTab.Claims] export const ActionCenter = memo(() => { 'use no memo' - const { isDrawerOpen, activeTab, setActiveTab, openActionCenter, closeDrawer } = - useActionCenterContext() + const { isDrawerOpen, openActionCenter, closeDrawer } = useActionCenterContext() + const { activeTab, setActiveTab } = useActionCenterTab() const { modalContentProps, overlayProps, modalProps } = useModalRegistration({ isOpen: isDrawerOpen, onClose: closeDrawer, @@ -127,7 +127,7 @@ export const ActionCenter = memo(() => { const handleCloseArbitrumClaim = useCallback(() => setArbitrumClaimActionId(undefined), []) - const actions = useAppSelector(state => (isConnected ? selectWalletActionsSorted(state) : [])) + const actions = useAppSelector(state => (isConnected ? selectWalletRecentActions(state) : [])) const claimActions = useAppSelector(state => (isConnected ? selectWalletClaimActions(state) : [])) const pendingActions = useAppSelector(state => @@ -275,7 +275,7 @@ export const ActionCenter = memo(() => { }, [openActionCenter, translate, pendingActions]) const readyClaimCount = useMemo( - () => claimActions.filter(isClaimReadyAction).length, + () => claimActions.filter(action => action.status === ActionStatus.ClaimAvailable).length, [claimActions], ) diff --git a/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx b/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx index 5bb22c6b5e2..66062a4ab2f 100644 --- a/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx +++ b/src/components/Layout/Header/ActionCenter/ActionCenterContext.tsx @@ -11,15 +11,21 @@ export enum ActionCenterTab { type ActionCenterContextProps = { isDrawerOpen: boolean - activeTab: ActionCenterTab - setActiveTab: (tab: ActionCenterTab) => void openActionCenter: () => void openActionCenterClaims: () => void closeDrawer: () => void } +type ActionCenterTabContextProps = { + activeTab: ActionCenterTab + setActiveTab: (tab: ActionCenterTab) => void +} + const ActionCenterContext = createContext(undefined) +// Separate so switching tabs doesn't re-render every action center subscriber +const ActionCenterTabContext = createContext(undefined) + export const ActionCenterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [activeTab, setActiveTab] = useState(ActionCenterTab.Recent) @@ -50,16 +56,20 @@ export const ActionCenterProvider: React.FC<{ children: React.ReactNode }> = ({ const value = useMemo( () => ({ isDrawerOpen, - activeTab, - setActiveTab, openActionCenter, openActionCenterClaims, closeDrawer, }), - [isDrawerOpen, activeTab, openActionCenter, openActionCenterClaims, closeDrawer], + [isDrawerOpen, openActionCenter, openActionCenterClaims, closeDrawer], ) - return {children} + const tabValue = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]) + + return ( + + {children} + + ) } export const useActionCenterContext = () => { @@ -67,3 +77,9 @@ export const useActionCenterContext = () => { if (!ctx) throw new Error('useActionCenterContext must be used within an ActionCenterProvider') return ctx } + +export const useActionCenterTab = () => { + const ctx = useContext(ActionCenterTabContext) + if (!ctx) throw new Error('useActionCenterTab must be used within an ActionCenterProvider') + return ctx +} diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 9d92816f8ad..61cadaf68bd 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -19,10 +19,10 @@ import { zeroAddress } from 'viem' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' -import { useArbitrumClaimsByStatus } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' -import { useArbitrumClaimTx } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx' import { Row } from '@/components/Row/Row' import { useModalRegistration } from '@/context/ModalStackProvider' +import { useArbitrumClaims } from '@/hooks/useArbitrumClaims/useArbitrumClaims' +import { useArbitrumClaimTx } from '@/hooks/useArbitrumClaims/useArbitrumClaimTx' import { bnOrZero } from '@/lib/bignumber/bignumber' import { middleEllipsis } from '@/lib/utils' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' @@ -69,11 +69,11 @@ export const ArbitrumBridgeClaimModal = ({ selectMarketDataByAssetIdUserCurrency(state, action.arbitrumBridgeMetadata.destinationAssetId), ) - const { claimsByStatus } = useArbitrumClaimsByStatus() - const claimDetails = useMemo( - () => claimsByStatus.Available.find(claim => claim.tx.txid === withdrawTxHash), - [claimsByStatus.Available, withdrawTxHash], - ) + const { claimsByTxid } = useArbitrumClaims() + const claimDetails = useMemo(() => { + const claim = claimsByTxid[withdrawTxHash] + return claim?.status === ActionStatus.ClaimAvailable ? claim : undefined + }, [claimsByTxid, withdrawTxHash]) const destinationFeeAsset = useAppSelector(state => selectFeeAssetByChainId( @@ -115,7 +115,7 @@ export const ArbitrumBridgeClaimModal = ({ amountCryptoPrecision, ]) - // Stays claimable until the subscriber sees the claim confirm, revert or drop + // Pending until the subscriber sees the claim confirm, or revert or drop back to claimable const handleClaimBroadcast = useCallback( (claimTxHash: string) => { const latestAction = selectArbitrumBridgeWithdrawActionById(store.getState(), action.id) @@ -124,6 +124,7 @@ export const ArbitrumBridgeClaimModal = ({ dispatch( actionSlice.actions.upsertAction({ ...latestAction, + status: ActionStatus.Pending, arbitrumBridgeMetadata: { ...latestAction.arbitrumBridgeMetadata, claimTxHash }, }), ) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 145c6911296..7b7d1b04875 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -1,9 +1,6 @@ import { Button, Link, Stack, useDisclosure } from '@chakra-ui/react' import { fromAssetId } from '@shapeshiftoss/caip' import { BigAmount } from '@shapeshiftoss/utils' -import dayjs from 'dayjs' -import duration from 'dayjs/plugin/duration' -import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -27,9 +24,6 @@ import { import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -dayjs.extend(relativeTime) -dayjs.extend(duration) - type ArbitrumBridgeWithdrawActionCardProps = { action: ArbitrumBridgeWithdrawAction onClaimClick: (action: ArbitrumBridgeWithdrawAction) => void @@ -63,14 +57,10 @@ export const ArbitrumBridgeWithdrawActionCard = ({ const isCollapsable = action.status === ActionStatus.Initiated || action.status === ActionStatus.ClaimAvailable || + action.status === ActionStatus.Pending || action.status === ActionStatus.Claimed const { isOpen, onToggle } = useDisclosure() - const isClaiming = - action.status === ActionStatus.ClaimAvailable && - Boolean(action.arbitrumBridgeMetadata.claimTxHash) - const displayStatus = isClaiming ? ActionStatus.Pending : action.status - const handleClaimClick = useCallback( (e: React.MouseEvent) => { e.preventDefault() @@ -98,33 +88,24 @@ export const ArbitrumBridgeWithdrawActionCard = ({ case ActionStatus.Initiated: return translate('actionCenter.bridge.pendingWithdraw', { amountAndSymbol, timeText }) case ActionStatus.ClaimAvailable: - if (isClaiming) return translate('actionCenter.bridge.processing', bridgeArgs) return translate('actionCenter.bridge.claimAvailable', { amountAndSymbol }) case ActionStatus.Claimed: return translate('actionCenter.bridge.withdrawComplete', { amountAndSymbol }) default: return translate('actionCenter.bridge.withdrawProcessing', { amountAndSymbol }) } - }, [ - action.status, - buyAmountCryptoPrecision, - buyAsset, - isClaiming, - sellAsset, - timeText, - translate, - ]) + }, [action.status, buyAmountCryptoPrecision, buyAsset, sellAsset, timeText, translate]) const icon = useMemo(() => { if (!sellAsset) return null return ( - + ) - }, [sellAsset, displayStatus]) + }, [sellAsset, action.status]) - const footer = useMemo(() => , [displayStatus]) + const footer = useMemo(() => , [action.status]) const details = useMemo(() => { if (!(sellAsset && buyAsset && sellFeeAsset && destinationFeeAsset)) return null @@ -157,7 +138,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ - {action.status === ActionStatus.ClaimAvailable && !isClaiming && ( + {action.status === ActionStatus.ClaimAvailable && ( {translate('actionCenter.bridge.claimWithdraw')} @@ -187,7 +168,6 @@ export const ArbitrumBridgeWithdrawActionCard = ({ action.arbitrumBridgeMetadata.withdrawTxHash, action.arbitrumBridgeMetadata.claimTxHash, action.status, - isClaiming, translate, handleClaimClick, ]) diff --git a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx index 75941868ff0..66f1a138c01 100644 --- a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx @@ -1,8 +1,6 @@ import { Button, Link, Stack, useDisclosure } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { fromAssetId } from '@shapeshiftoss/caip' -import dayjs from 'dayjs' -import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -13,6 +11,7 @@ import { ActionStatusTag } from './ActionStatusTag' import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' import { getTxLink } from '@/lib/getTxLink' +import { formatSmartDate } from '@/lib/utils/time' import type { GenericTransactionDisplayType, RfoxClaimAction, @@ -22,8 +21,6 @@ import { ActionStatus, getActionTimestamp } from '@/state/slices/actionSlice/typ import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -dayjs.extend(relativeTime) - type ClaimActionCardProps = { action: RfoxClaimAction | TcyClaimAction // The asset to be claimed @@ -66,16 +63,7 @@ export const ClaimActionCard = ({ [onClaimClick, closeDrawer], ) - const formattedDate = useMemo(() => { - const now = dayjs() - const notificationDate = dayjs(getActionTimestamp(action)) - const sevenDaysAgo = now.subtract(7, 'day') - if (notificationDate.isAfter(sevenDaysAgo)) { - return notificationDate.fromNow() - } else { - return notificationDate.toDate().toLocaleString() - } - }, [action]) + const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: false }) diff --git a/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx index c37723c0f8d..98a60d7ac10 100644 --- a/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/GenericTransactionActionCard.tsx @@ -2,7 +2,6 @@ import { Button, ButtonGroup, HStack, Link, Stack, useDisclosure } from '@chakra import { btcChainId } from '@shapeshiftoss/caip' import dayjs from 'dayjs' import duration from 'dayjs/plugin/duration' -import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router' @@ -16,6 +15,7 @@ import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ import { RawText } from '@/components/Text' import { getTxLink } from '@/lib/getTxLink' import { middleEllipsis } from '@/lib/utils' +import { formatSmartDate } from '@/lib/utils/time' import type { GenericTransactionAction } from '@/state/slices/actionSlice/types' import { ActionStatus, @@ -27,7 +27,6 @@ import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/assetsS import { useAppSelector } from '@/state/store' dayjs.extend(duration) -dayjs.extend(relativeTime) type GenericTransactionActionCardProps = { action: GenericTransactionAction @@ -66,16 +65,7 @@ export const GenericTransactionActionCard = ({ [closeDrawer, navigate, action.transactionMetadata.yieldId], ) - const formattedDate = useMemo(() => { - const now = dayjs() - const notificationDate = dayjs(getActionTimestamp(action)) - const sevenDaysAgo = now.subtract(7, 'day') - if (notificationDate.isAfter(sevenDaysAgo)) { - return notificationDate.fromNow() - } else { - return notificationDate.toDate().toLocaleString() - } - }, [action]) + const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const txLink = useMemo(() => { if (!feeAsset) return diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index b0bc2a490d6..0610b04985d 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -50,9 +50,11 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat : undefined, ) - // Use ArbitrumBridge action for ArbitrumBridge swaps, otherwise use swap action + // Bridge swaps show their withdraw action once history creates it, the swap action until then const action = - swap?.swapperName === SwapperName.ArbitrumBridge ? maybeArbitrumBridgeAction : swapAction + swap?.swapperName === SwapperName.ArbitrumBridge + ? maybeArbitrumBridgeAction ?? swapAction + : swapAction const timeText = useArbitrumClaimTimeText( maybeArbitrumBridgeAction && isArbitrumBridgeWithdrawAction(maybeArbitrumBridgeAction) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts index 6ec6c430523..d0b28a54b0c 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -1,13 +1,15 @@ import { ethAssetId, ethChainId } from '@shapeshiftoss/caip' +import type { Swap } from '@shapeshiftoss/swapper' +import { SwapperName } from '@shapeshiftoss/swapper' import { describe, expect, it } from 'vitest' import { buildArbitrumBridgeWithdrawActionFromClaim, + buildArbitrumBridgeWithdrawActionFromSwap, getArbitrumBridgeWithdrawActionId, } from './arbitrumBridgeWithdrawAction' -import { ClaimStatus } from '@/components/ClaimRow/types' -import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import type { ClaimDetails } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' const destinationAddress = '0xAbCdEf0000000000000000000000000000000001' @@ -22,23 +24,22 @@ const claim = { destinationAddress, destinationAssetId: ethAssetId, destinationChainId: ethChainId, + status: ActionStatus.ClaimAvailable, claimableAt: 1_700_600_000_000, } as unknown as ClaimDetails +const pendingClaim = { ...claim, status: ActionStatus.Initiated } as ClaimDetails + describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { it('keys the action by the withdraw tx so every path lands on the same action', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Pending, [ - destinationAccountId, - ]) + const action = buildArbitrumBridgeWithdrawActionFromClaim(pendingClaim, [destinationAccountId]) expect(action?.id).toBe(getArbitrumBridgeWithdrawActionId('0xwithdraw')) expect(action?.type).toBe(ActionType.ArbitrumBridgeWithdraw) }) it('maps a pending claim to an initiated action dated from the withdraw block', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Pending, [ - destinationAccountId, - ]) + const action = buildArbitrumBridgeWithdrawActionFromClaim(pendingClaim, [destinationAccountId]) expect(action?.status).toBe(ActionStatus.Initiated) expect(action?.createdAt).toBe(1_700_000_000_000) @@ -54,15 +55,13 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { }) it('maps an available claim to a claimable action', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, [ - destinationAccountId, - ]) + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, [destinationAccountId]) expect(action?.status).toBe(ActionStatus.ClaimAvailable) }) it('signs from the destination account when the wallet holds it, whatever its casing', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, [ + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, [ otherEthAccountId, destinationAccountId, ]) @@ -71,16 +70,41 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { }) it('falls back to any ethereum account since the outbox call is permissionless', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, [ - otherEthAccountId, - ]) + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, [otherEthAccountId]) expect(action?.arbitrumBridgeMetadata.destinationAccountId).toBe(otherEthAccountId) }) it('builds nothing without an ethereum account to claim from', () => { - expect( - buildArbitrumBridgeWithdrawActionFromClaim(claim, ClaimStatus.Available, []), - ).toBeUndefined() + expect(buildArbitrumBridgeWithdrawActionFromClaim(claim, [])).toBeUndefined() + }) +}) + +describe('buildArbitrumBridgeWithdrawActionFromSwap', () => { + const swap = { + swapperName: SwapperName.ArbitrumBridge, + sellTxHash: '0xwithdraw', + sellAccountId: 'eip155:42161:0xarb', + buyAccountId: destinationAccountId, + sellAmountCryptoBaseUnit: '1000', + sellAsset: { assetId: 'eip155:42161/slip44:60' }, + buyAsset: { assetId: ethAssetId, chainId: ethChainId }, + } as unknown as Swap + + it('keys the action like the history rebuild and dates it from the withdraw', () => { + const action = buildArbitrumBridgeWithdrawActionFromSwap(swap, 1_700_000_000_000) + + expect(action?.id).toBe(getArbitrumBridgeWithdrawActionId('0xwithdraw')) + expect(action?.status).toBe(ActionStatus.Initiated) + expect(action?.createdAt).toBe(1_700_000_000_000) + expect(action?.arbitrumBridgeMetadata.destinationAccountId).toBe(destinationAccountId) + }) + + it('skips deposits and other swappers', () => { + const deposit = { ...swap, buyAsset: { assetId: 'x', chainId: 'eip155:42161' } } as Swap + const otherSwapper = { ...swap, swapperName: SwapperName.Thorchain } as Swap + + expect(buildArbitrumBridgeWithdrawActionFromSwap(deposit, 1)).toBeUndefined() + expect(buildArbitrumBridgeWithdrawActionFromSwap(otherSwapper, 1)).toBeUndefined() }) }) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 3640c0bf1ef..2771a778530 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -1,8 +1,12 @@ import type { AccountId } from '@shapeshiftoss/caip' -import { fromAccountId } from '@shapeshiftoss/caip' +import { arbitrumChainId, ethChainId, fromAccountId } from '@shapeshiftoss/caip' +import { assertGetViemClient } from '@shapeshiftoss/contracts' +import type { Swap } from '@shapeshiftoss/swapper' +import { SwapperName } from '@shapeshiftoss/swapper' +import type { Hash } from 'viem' -import { ClaimStatus } from '@/components/ClaimRow/types' -import type { ClaimDetails } from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import type { ClaimDetails } from '@/hooks/useArbitrumClaims/useArbitrumClaims' +import { getArbitrumClaimableAt } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' @@ -21,7 +25,6 @@ const getClaimDestinationAccountId = ( export const buildArbitrumBridgeWithdrawActionFromClaim = ( claim: ClaimDetails, - claimStatus: ClaimStatus, ethAccountIds: AccountId[], ): ArbitrumBridgeWithdrawAction | undefined => { const destinationAccountId = getClaimDestinationAccountId(claim.destinationAddress, ethAccountIds) @@ -33,7 +36,9 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( id: getArbitrumBridgeWithdrawActionId(claim.tx.txid), type: ActionType.ArbitrumBridgeWithdraw, status: - claimStatus === ClaimStatus.Available ? ActionStatus.ClaimAvailable : ActionStatus.Initiated, + claim.status === ActionStatus.ClaimAvailable + ? ActionStatus.ClaimAvailable + : ActionStatus.Initiated, createdAt, updatedAt: createdAt, arbitrumBridgeMetadata: { @@ -47,3 +52,39 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( }, } } + +export const isArbitrumBridgeWithdrawSwap = (swap: Swap): boolean => + swap.swapperName === SwapperName.ArbitrumBridge && swap.buyAsset.chainId === ethChainId + +export const buildArbitrumBridgeWithdrawActionFromSwap = ( + swap: Swap, + withdrawTimeMs: number, +): ArbitrumBridgeWithdrawAction | undefined => { + if (!isArbitrumBridgeWithdrawSwap(swap)) return + if (!swap.sellTxHash || !swap.buyAccountId) return + + return { + id: getArbitrumBridgeWithdrawActionId(swap.sellTxHash), + type: ActionType.ArbitrumBridgeWithdraw, + status: ActionStatus.Initiated, + createdAt: withdrawTimeMs, + updatedAt: withdrawTimeMs, + arbitrumBridgeMetadata: { + withdrawTxHash: swap.sellTxHash, + amountCryptoBaseUnit: swap.sellAmountCryptoBaseUnit, + assetId: swap.sellAsset.assetId, + destinationAssetId: swap.buyAsset.assetId, + accountId: swap.sellAccountId, + destinationAccountId: swap.buyAccountId, + claimableAt: getArbitrumClaimableAt(withdrawTimeMs), + }, + } +} + +// Dated by the withdraw block, a swap resumed after a reload can succeed long after it +export const getArbitrumWithdrawTimeMs = async (withdrawTxHash: Hash): Promise => { + const client = assertGetViemClient(arbitrumChainId) + const { blockNumber } = await client.getTransactionReceipt({ hash: withdrawTxHash }) + const { timestamp } = await client.getBlock({ blockNumber }) + return Number(timestamp) * 1000 +} diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 3b4a0bce371..865b8352874 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -1,7 +1,6 @@ import { usePrevious } from '@chakra-ui/react' import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' import { assertGetViemClient } from '@shapeshiftoss/contracts' -import { SwapperName, SwapStatus } from '@shapeshiftoss/swapper' import { TxStatus } from '@shapeshiftoss/unchained-client' import { isSome } from '@shapeshiftoss/utils' import { useQueries } from '@tanstack/react-query' @@ -11,29 +10,25 @@ import type { Hash } from 'viem' import { TransactionNotFoundError, TransactionReceiptNotFoundError } from 'viem' import { useNotificationToast } from '../useNotificationToast' -import { - buildArbitrumBridgeWithdrawActionFromClaim, - getArbitrumBridgeWithdrawActionId, -} from './arbitrumBridgeWithdrawAction' +import { buildArbitrumBridgeWithdrawActionFromClaim } from './arbitrumBridgeWithdrawAction' -import { ClaimStatus } from '@/components/ClaimRow/types' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' -import { - getArbitrumClaimableAt, - useArbitrumClaimsByStatus, -} from '@/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import { useArbitrumClaims } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' -import { - ActionStatus, - ActionType, - isArbitrumBridgeWithdrawAction, - isSwapAction, -} from '@/state/slices/actionSlice/types' +import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' -import { swapSlice } from '@/state/slices/swapSlice/swapSlice' import { useAppDispatch, useAppSelector } from '@/state/store' +const WITHDRAW_STATUS_ORDER: Partial> = { + [ActionStatus.Initiated]: 0, + [ActionStatus.ClaimAvailable]: 1, + [ActionStatus.Pending]: 2, + [ActionStatus.Claimed]: 3, +} + +const getWithdrawStatusOrder = (status: ActionStatus): number => WITHDRAW_STATUS_ORDER[status] ?? 0 + // A node can briefly miss a fresh broadcast, so only a long-unknown claim counts as dropped const CLAIM_TX_DROPPED_AFTER_MS = 10 * 60 * 1000 @@ -59,9 +54,8 @@ const getClaimTxStatus = async (claimTxHash: Hash, broadcastAt: number): Promise export const useArbitrumWithdrawalActionSubscriber = () => { const dispatch = useAppDispatch() const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) - const swapsById = useAppSelector(swapSlice.selectors.selectSwapsById) const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) - const { claimsByStatus } = useArbitrumClaimsByStatus() + const { claims, claimsByTxid, claimsKey } = useArbitrumClaims() const translate = useTranslate() const ethAccountIds = useMemo( @@ -92,53 +86,6 @@ export const useArbitrumWithdrawalActionSubscriber = () => { } }, [isDrawerOpen, toast, previousIsDrawerOpen]) - // Create ArbitrumBridge withdraw actions from successful withdraw swaps - useEffect(() => { - const allClaims = [ - ...claimsByStatus.Pending, - ...claimsByStatus.Available, - ...claimsByStatus.Complete, - ] - - Object.values(actionsById) - .filter(isSwapAction) - .forEach(swapAction => { - const swap = swapsById[swapAction.swapMetadata.swapId] - if ( - swap?.status !== SwapStatus.Success || - !swap.sellTxHash || - !swap.buyAccountId || - swap.swapperName !== SwapperName.ArbitrumBridge || - swap.buyAsset.chainId !== ethChainId - ) - return - - // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 - if (arbitrumActionsByWithdrawTxHash[swap.sellTxHash]) return - - const claim = allClaims.find(claim => claim.tx.txid === swap.sellTxHash) - - dispatch( - actionSlice.actions.upsertAction({ - id: getArbitrumBridgeWithdrawActionId(swap.sellTxHash), - createdAt: Date.now(), - updatedAt: Date.now(), - type: ActionType.ArbitrumBridgeWithdraw as const, - status: ActionStatus.Initiated, - arbitrumBridgeMetadata: { - withdrawTxHash: swap.sellTxHash, - amountCryptoBaseUnit: swap.sellAmountCryptoBaseUnit, - assetId: swap.sellAsset.assetId, - destinationAssetId: swap.buyAsset.assetId, - accountId: swap.sellAccountId, - destinationAccountId: swap.buyAccountId, - claimableAt: claim?.claimableAt ?? getArbitrumClaimableAt(Date.now()), - }, - }), - ) - }) - }, [actionsById, swapsById, dispatch, claimsByStatus, arbitrumActionsByWithdrawTxHash]) - const notifyClaimAvailable = useCallback( (actionId: string) => { if (toast.isActive(actionId)) return @@ -158,32 +105,22 @@ export const useArbitrumWithdrawalActionSubscriber = () => { [openActionCenterClaims, toast, translate], ) - // Rebuild missing actions from tx history, e.g. after a wiped store or an outside withdrawal + // Recover missing withdraw actions from tx history, e.g. after a wiped store or an outside withdrawal useEffect(() => { if (!ethAccountIds.length) return - const claims = [ - ...claimsByStatus.Pending.map(claim => ({ claim, claimStatus: ClaimStatus.Pending })), - ...claimsByStatus.Available.map(claim => ({ claim, claimStatus: ClaimStatus.Available })), - ] - - claims.forEach(({ claim, claimStatus }) => { + claims.forEach(claim => { + if (claim.status === ActionStatus.Claimed) return if (arbitrumActionsByWithdrawTxHash[claim.tx.txid]) return - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, claimStatus, ethAccountIds) + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ethAccountIds) if (!action) return dispatch(actionSlice.actions.upsertAction(action)) }) - // claimsByStatus arrays are recreated on every render, use length for stable references + // claims are recreated on every render, claimsKey tracks what matters // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - dispatch, - ethAccountIds, - arbitrumActionsByWithdrawTxHash, - claimsByStatus.Pending.length, - claimsByStatus.Available.length, - ]) + }, [dispatch, ethAccountIds, arbitrumActionsByWithdrawTxHash, claimsKey]) const pendingArbitrumBridgeActions = useMemo(() => { return Object.values(actionsById) @@ -198,37 +135,22 @@ export const useArbitrumWithdrawalActionSubscriber = () => { }) }, [actionsById]) - const pendingClaimableAts = claimsByStatus.Pending.map(claim => claim.claimableAt).join() - useEffect(() => { try { pendingArbitrumBridgeActions .map(action => { - const withdrawTxHash = action.arbitrumBridgeMetadata.withdrawTxHash + const claim = claimsByTxid[action.arbitrumBridgeMetadata.withdrawTxHash] + if (!claim) return null - // Find claims by transaction hash - const availableClaim = claimsByStatus.Available.find( - claim => claim.tx.txid === withdrawTxHash, - ) - const completedClaim = claimsByStatus.Complete.find( - claim => claim.tx.txid === withdrawTxHash, - ) - const pendingClaim = claimsByStatus.Pending.find( - claim => claim.tx.txid === withdrawTxHash, - ) + const newStatus = claim.status - const matchedClaim = completedClaim ?? availableClaim ?? pendingClaim - if (!matchedClaim) return null + // A lagging rpc or a claim in flight reads as an earlier status, a withdraw never moves backwards + if (getWithdrawStatusOrder(newStatus) < getWithdrawStatusOrder(action.status)) return null - const newStatus = (() => { - if (completedClaim) return ActionStatus.Claimed - if (availableClaim) return ActionStatus.ClaimAvailable - return ActionStatus.Initiated - })() // Only a pending claim's estimate still matters const claimableAt = newStatus === ActionStatus.Initiated - ? matchedClaim.claimableAt + ? claim.claimableAt : action.arbitrumBridgeMetadata.claimableAt // Only write on a real change, see https://github.com/shapeshift/web/pull/10556 @@ -261,17 +183,9 @@ export const useArbitrumWithdrawalActionSubscriber = () => { } catch (error) { console.error('Error updating ArbitrumBridge action statuses:', error) } - // claimsByStatus arrays are recreated on every render, use length for stable references + // claims are recreated on every render, claimsKey tracks what matters // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - dispatch, - notifyClaimAvailable, - pendingArbitrumBridgeActions, - claimsByStatus.Available.length, - claimsByStatus.Complete.length, - claimsByStatus.Pending.length, - pendingClaimableAts, - ]) + }, [dispatch, notifyClaimAvailable, pendingArbitrumBridgeActions, claimsKey]) // Resolves in-flight claims, including ones broadcast before a reload const claimingActions = useMemo( @@ -280,7 +194,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { .filter(isArbitrumBridgeWithdrawAction) .filter( action => - action.status === ActionStatus.ClaimAvailable && + action.status === ActionStatus.Pending && Boolean(action.arbitrumBridgeMetadata.claimTxHash), ), [actionsById], @@ -311,6 +225,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { dispatch( actionSlice.actions.upsertAction({ ...action, + status: ActionStatus.ClaimAvailable, arbitrumBridgeMetadata: { ...action.arbitrumBridgeMetadata, claimTxHash: undefined }, }), ) diff --git a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx index c2f5e195d7e..580201b8fef 100644 --- a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx @@ -14,6 +14,7 @@ import { useQueries, useQuery } from '@tanstack/react-query' import { uuidv4 } from '@walletconnect/utils' import { detectIncognito } from 'detectincognitojs' import { useCallback, useEffect, useMemo } from 'react' +import type { Hash } from 'viem' import { preferences } from '../../state/slices/preferencesSlice/preferencesSlice' import { fetchIsSmartContractAddressQuery } from '../useIsSmartContractAddress/useIsSmartContractAddress' @@ -21,6 +22,11 @@ import { MobileFeature, useMobileFeaturesCompatibility } from '../useMobileFeatu import { useModal } from '../useModal/useModal' import { useNotificationToast } from '../useNotificationToast' import { useWallet } from '../useWallet/useWallet' +import { + buildArbitrumBridgeWithdrawActionFromSwap, + getArbitrumWithdrawTimeMs, + isArbitrumBridgeWithdrawSwap, +} from './arbitrumBridgeWithdrawAction' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' import { SwapNotification } from '@/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification' @@ -271,6 +277,20 @@ export const useSwapActionSubscriber = () => { }), ) + // Tx history only recovers withdraws this missed, e.g. after a wiped store + if (isArbitrumBridgeWithdrawSwap(swap)) { + const withdrawTimeMs = await getArbitrumWithdrawTimeMs(swap.sellTxHash as Hash).catch( + () => Date.now(), + ) + const withdrawAction = buildArbitrumBridgeWithdrawActionFromSwap(swap, withdrawTimeMs) + if ( + withdrawAction && + !actionSlice.selectors.selectActionsById(store.getState())[withdrawAction.id] + ) { + dispatch(actionSlice.actions.upsertAction(withdrawAction)) + } + } + const { getAccount } = portfolioApi.endpoints // Balance refetches fire before the history parses - they share rate-limited request diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx similarity index 96% rename from src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx rename to src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx index 7ad8ad42d87..55b15c966c5 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimTx.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx @@ -8,8 +8,8 @@ import { useMemo } from 'react' import type { Address, Hex } from 'viem' import { encodeFunctionData, getAddress } from 'viem' -import type { ClaimDetails } from './useArbitrumClaimsByStatus' -import { arbitrumNetwork } from './useArbitrumClaimsByStatus' +import type { ClaimDetails } from './useArbitrumClaims' +import { arbitrumNetwork } from './useArbitrumClaims' import { useEvmFees } from '@/hooks/queries/useEvmFees' import { useWallet } from '@/hooks/useWallet/useWallet' diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx similarity index 50% rename from src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx rename to src/hooks/useArbitrumClaims/useArbitrumClaims.tsx index c623d3f9c8d..8eea8e57142 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx @@ -6,29 +6,19 @@ import { } from '@arbitrum/sdk' import { Outbox__factory } from '@arbitrum/sdk/dist/lib/abi/factories/Outbox__factory' import { BoldRollupUserLogic__factory } from '@arbitrum/sdk/dist/lib/abi-bold/factories/BoldRollupUserLogic__factory' +import { ARB1_NITRO_GENESIS_L2_BLOCK } from '@arbitrum/sdk/dist/lib/dataEntities/constants' import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' -import { - arbitrumChainId, - ethAssetId, - ethChainId, - fromChainId, - toAccountId, -} from '@shapeshiftoss/caip' +import { arbitrumChainId, ethChainId, fromChainId, toAccountId } from '@shapeshiftoss/caip' import { getEthersV5Provider } from '@shapeshiftoss/contracts' import { KnownChainIds } from '@shapeshiftoss/types' import type { Query } from '@tanstack/react-query' import { useQueries, useQueryClient } from '@tanstack/react-query' import { useMemo } from 'react' -import { useTranslate } from 'react-polyglot' -import { ClaimStatus } from '@/components/ClaimRow/types' import { useWallet } from '@/hooks/useWallet/useWallet' import { assertUnreachable } from '@/lib/utils' -import { - selectArbitrumWithdrawTxs, - selectAssetById, - selectPortfolioLoadingStatus, -} from '@/state/slices/selectors' +import { ActionStatus } from '@/state/slices/actionSlice/types' +import { selectArbitrumWithdrawTxs, selectPortfolioLoadingStatus } from '@/state/slices/selectors' import type { Tx } from '@/state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from '@/state/store' @@ -39,11 +29,28 @@ const ARBITRUM_CHALLENGE_PERIOD_MS = 6.4 * 24 * 60 * 60 * 1000 export const getArbitrumClaimableAt = (withdrawTimeMs: number, confirmationLagMs = 0): number => withdrawTimeMs + Math.max(ARBITRUM_CHALLENGE_PERIOD_MS, confirmationLagMs) +// Assertions post roughly every 300 parent blocks, so the covering one lands well inside this window +const ASSERTION_SCAN_BLOCKS = 1_200 +const LOG_RANGE_BLOCKS = 100 +const ASSERTION_RETRY_MS = 10 * 60 * 1000 +const FALLBACK_BLOCK_TIME_MS = 12_000 + type ConfirmedChildBlock = { number: number confirmationLagMs: number } +type CoveringAssertion = { + blockNumber: number + timestampMs: number +} + +// Final once the assertion is found or the whole window has been scanned without it +type AssertionSearch = { + assertion: CoveringAssertion | undefined + isFinal: boolean +} + type EthersV5Provider = ReturnType export const arbitrumNetwork = getArbitrumNetwork( @@ -79,6 +86,59 @@ const getConfirmedChildBlock = async ( return { number: block.number, confirmationLagMs: Date.now() - block.timestamp * 1000 } } +// The first assertion after the withdraw that covers its child block +const findCoveringAssertion = async ( + parentBlockNumber: number, + childBlockNumber: number, + l1Provider: EthersV5Provider, + l2Provider: EthersV5Provider, +): Promise => { + const rollup = BoldRollupUserLogic__factory.connect(arbitrumNetwork.ethBridge.rollup, l1Provider) + const windowEndBlock = parentBlockNumber + ASSERTION_SCAN_BLOCKS + const latestBlock = await l1Provider.getBlockNumber() + const toBlock = Math.min(windowEndBlock, latestBlock) + + for (let fromBlock = parentBlockNumber; fromBlock <= toBlock; fromBlock += LOG_RANGE_BLOCKS) { + const logs = await rollup.queryFilter( + rollup.filters.AssertionCreated(), + fromBlock, + Math.min(fromBlock + LOG_RANGE_BLOCKS - 1, toBlock), + ) + + for (const log of logs) { + const [blockHash] = log.args.assertion.afterState.globalState.bytes32Vals + const childBlock = await l2Provider.getBlock(blockHash) + if (!childBlock || childBlock.number < childBlockNumber) continue + + const { timestamp } = await l1Provider.getBlock(log.blockNumber) + return { + assertion: { blockNumber: log.blockNumber, timestampMs: timestamp * 1000 }, + isFinal: true, + } + } + } + + // Past the window the lag estimate stands in rather than rescanning + return { assertion: undefined, isFinal: latestBlock >= windowEndBlock } +} + +// The claim opens once the covering assertion's confirm period passes, timed by the observed block rate +const getAssertionClaimableAt = async ( + assertion: CoveringAssertion, + l1Provider: EthersV5Provider, +): Promise => { + const latest = await l1Provider.getBlock('latest') + const elapsedBlocks = latest.number - assertion.blockNumber + const blockTimeMs = + elapsedBlocks > 0 + ? (latest.timestamp * 1000 - assertion.timestampMs) / elapsedBlocks + : FALLBACK_BLOCK_TIME_MS + const claimableAt = assertion.timestampMs + arbitrumNetwork.confirmPeriodBlocks * blockTimeMs + + // Minute precision keeps the stored estimate from rewriting on every poll + return Math.round(claimableAt / 60_000) * 60_000 +} + type ClaimMessage = { blockNumber: number event: Extract @@ -105,31 +165,34 @@ type ClaimStatusResult = { message: ChildToParentMessageReader status: ChildToParentMessageStatus confirmationLagMs: number + assertionClaimableAt: number | undefined } -export type ClaimDetails = Omit & { +// A withdraw's status in action terms: waiting on its challenge period, claimable, or claimed +export type ClaimDetails = Pick & { + status: ActionStatus.Initiated | ActionStatus.ClaimAvailable | ActionStatus.Claimed accountId: AccountId amountCryptoBaseUnit: string assetId: string claimableAt: number - description: string destinationAddress: string destinationAssetId: AssetId destinationChainId: ChainId - destinationExplorerTxLink: string tx: Tx } -type ClaimsByStatus = Record - -export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { - const translate = useTranslate() +export const useArbitrumClaims = (props?: { skip?: boolean }) => { const queryClient = useQueryClient() - const ethAsset = useAppSelector(state => selectAssetById(state, ethAssetId)) const arbitrumWithdrawTxs = useAppSelector(selectArbitrumWithdrawTxs) const portfolioLoadingStatus = useAppSelector(selectPortfolioLoadingStatus) + // Pre-nitro withdraws use the classic outbox, which we can neither track nor claim + const nitroWithdrawTxs = useMemo( + () => arbitrumWithdrawTxs.filter(tx => tx.blockHeight >= ARB1_NITRO_GENESIS_L2_BLOCK), + [arbitrumWithdrawTxs], + ) + const { state: { isLoadingLocalWallet, modal, isConnected }, } = useWallet() @@ -162,7 +225,7 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { } const claimStatuses = useQueries({ - queries: arbitrumWithdrawTxs.map(tx => { + queries: nitroWithdrawTxs.map(tx => { return { queryKey: ['claimStatus', { txid: tx.txid }], queryFn: async (): Promise => { @@ -176,46 +239,59 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { const confirmedChildBlock = await fetchConfirmedChildBlock() - const status = await (async () => { - if (blockNumber > confirmedChildBlock.number) { - return ChildToParentMessageStatus.UNCONFIRMED + if (blockNumber > confirmedChildBlock.number) { + // Kept once final, retried until the covering assertion posts + const { assertion } = await queryClient.fetchQuery({ + queryKey: ['arbitrumClaimAssertion', { txid: tx.txid }], + queryFn: () => + findCoveringAssertion( + event.ethBlockNum.toNumber(), + blockNumber, + l1Provider, + l2Provider, + ), + staleTime: query => (query.state.data?.isFinal ? Infinity : ASSERTION_RETRY_MS), + gcTime: Infinity, + }) + + return { + event, + message, + status: ChildToParentMessageStatus.UNCONFIRMED, + confirmationLagMs: confirmedChildBlock.confirmationLagMs, + assertionClaimableAt: assertion + ? await getAssertionClaimableAt(assertion, l1Provider) + : undefined, } + } - const outbox = Outbox__factory.connect(arbitrumNetwork.ethBridge.outbox, l1Provider) - const isSpent = await outbox.isSpent(event.position) - - return isSpent - ? ChildToParentMessageStatus.EXECUTED - : ChildToParentMessageStatus.CONFIRMED - })() + const outbox = Outbox__factory.connect(arbitrumNetwork.ethBridge.outbox, l1Provider) + const isSpent = await outbox.isSpent(event.position) return { event, message, - status, + status: isSpent + ? ChildToParentMessageStatus.EXECUTED + : ChildToParentMessageStatus.CONFIRMED, confirmationLagMs: confirmedChildBlock.confirmationLagMs, + assertionClaimableAt: undefined, } }, - select: ({ event, message, status, confirmationLagMs }: ClaimStatusResult) => { - const claimStatus = (() => { - switch (status) { + select: (result: ClaimStatusResult) => { + const status = (() => { + switch (result.status) { + case ChildToParentMessageStatus.UNCONFIRMED: + return ActionStatus.Initiated as const case ChildToParentMessageStatus.CONFIRMED: - return ClaimStatus.Available + return ActionStatus.ClaimAvailable as const case ChildToParentMessageStatus.EXECUTED: - return ClaimStatus.Complete - case ChildToParentMessageStatus.UNCONFIRMED: - return ClaimStatus.Pending + return ActionStatus.Claimed as const default: - assertUnreachable(status) + assertUnreachable(result.status) } })() - return { - tx, - event, - message, - claimStatus, - confirmationLagMs, - } + return { ...result, tx, status } }, // Periodically refetch until the status is known to be ChildToParentMessageStatus.EXECUTED refetchInterval: (latestData: Query) => @@ -227,45 +303,56 @@ export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { }), }) - const claimsByStatus = useMemo(() => { - return claimStatuses.reduce( - (prev, { data }) => { - if (!data) return prev - if (!ethAsset) return prev - if (!data.tx.transfers.length) return prev - if (data.tx.data?.parser === 'arbitrumBridge') { - if (!data.tx.data.value) return prev - if (!data.tx.data.destinationAddress) return prev - if (!data.tx.data.destinationAssetId) return prev - prev[data.claimStatus].push({ - tx: data.tx, - accountId: toAccountId({ - chainId: arbitrumChainId, - account: data.tx.pubkey, - }), - amountCryptoBaseUnit: data.tx.data.value, - destinationAddress: data.tx.data.destinationAddress, - destinationAssetId: data.tx.data.destinationAssetId, - destinationChainId: ethChainId, - destinationExplorerTxLink: ethAsset.explorerTxLink, - assetId: data.tx.transfers[0].assetId, - claimableAt: getArbitrumClaimableAt(data.tx.blockTime * 1000, data.confirmationLagMs), - event: data.event, - message: data.message, - description: translate('bridge.arbitrum.description'), - }) - } - return prev - }, - { - [ClaimStatus.Pending]: [], - [ClaimStatus.Available]: [], - [ClaimStatus.Complete]: [], - }, - ) - }, [ethAsset, claimStatuses, translate]) + const claims = useMemo( + () => + claimStatuses.reduce((acc, { data }) => { + if (!data) return acc + if (!data.tx.transfers.length) return acc + if (data.tx.data?.parser !== 'arbitrumBridge') return acc + if (!data.tx.data.value) return acc + if (!data.tx.data.destinationAddress) return acc + if (!data.tx.data.destinationAssetId) return acc + + acc.push({ + status: data.status, + tx: data.tx, + accountId: toAccountId({ + chainId: arbitrumChainId, + account: data.tx.pubkey, + }), + amountCryptoBaseUnit: data.tx.data.value, + destinationAddress: data.tx.data.destinationAddress, + destinationAssetId: data.tx.data.destinationAssetId, + destinationChainId: ethChainId, + assetId: data.tx.transfers[0].assetId, + claimableAt: + data.assertionClaimableAt ?? + getArbitrumClaimableAt(data.tx.blockTime * 1000, data.confirmationLagMs), + event: data.event, + message: data.message, + }) + return acc + }, []), + [claimStatuses], + ) + + const claimsByTxid = useMemo( + () => + claims.reduce>((acc, claim) => { + acc[claim.tx.txid] = claim + return acc + }, {}), + [claims], + ) + + // Changes only when a claim's status or estimate does, for effects keyed on the claims + const claimsKey = claims + .map(claim => `${claim.tx.txid}:${claim.status}:${claim.claimableAt}`) + .join() return { - claimsByStatus, + claims, + claimsByTxid, + claimsKey, } } diff --git a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx index e6411536cde..882142efef6 100644 --- a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx +++ b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx @@ -47,6 +47,19 @@ export const useRfoxClaimActionSubscriber = () => { const tx = txs[serializedTxIndex] if (!tx) return + + // A failed claim leaves the request claimable + if (tx.status === TxStatus.Failed) { + dispatch( + actionSlice.actions.upsertAction({ + ...action, + status: ActionStatus.ClaimAvailable, + rfoxClaimActionMetadata: { request: action.rfoxClaimActionMetadata.request }, + }), + ) + return + } + if (tx.status !== TxStatus.Confirmed) return dispatch( @@ -112,25 +125,4 @@ export const useRfoxClaimActionSubscriber = () => { // We definitely don't want to react on assets here // eslint-disable-next-line react-hooks/exhaustive-deps }, [allUnstakingRequests.data, allUnstakingRequests.isSuccess, dispatch, actionIds]) - - // A claimable request missing from its account's fresh requests was claimed elsewhere - useEffect(() => { - if (!allUnstakingRequests.isSuccess) return - - const { byAccountId } = allUnstakingRequests.data - - Object.values(actions) - .filter(isRfoxClaimAction) - .filter(action => action.status === ActionStatus.ClaimAvailable) - .forEach(action => { - const accountRequests = - byAccountId[action.rfoxClaimActionMetadata.request.stakingAssetAccountId] - if (!accountRequests) return - if (accountRequests.some(request => request.id === action.id)) return - - dispatch(actionSlice.actions.deleteAction(action.id)) - }) - // Only react to fresh requests, not to our own deletes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allUnstakingRequests.data, allUnstakingRequests.isSuccess, dispatch]) } diff --git a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx index a951bd97640..4facbc4494a 100644 --- a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx +++ b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx @@ -1,4 +1,6 @@ -import { useEffect } from 'react' +import { TxStatus } from '@shapeshiftoss/unchained-client' +import { useQueries, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo } from 'react' import { useNavigate } from 'react-router' import { useTCYClaims } from '../queries/useTcyClaims' @@ -6,6 +8,7 @@ import { useTCYClaims } from '../queries/useTcyClaims' import { TcyClaimSaversNotification } from '@/components/Layout/Header/ActionCenter/components/Notifications/TcyClaimSaversNotification' import { useNotificationToast } from '@/hooks/useNotificationToast' import { useWallet } from '@/hooks/useWallet/useWallet' +import { getThorchainTransactionStatus } from '@/lib/utils/thorchain' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { ActionStatus, ActionType, isTcyClaimAction } from '@/state/slices/actionSlice/types' import { preferences } from '@/state/slices/preferencesSlice/preferencesSlice' @@ -15,6 +18,7 @@ const TCY_TOAST_ID = 'tcyClaimAlert' export const useTcyClaimActionSubscriber = () => { const dispatch = useAppDispatch() + const queryClient = useQueryClient() const toast = useNotificationToast() const navigate = useNavigate() const { @@ -99,4 +103,56 @@ export const useTcyClaimActionSubscriber = () => { toast, navigate, ]) + + // Resolves sent claims even after leaving the claim status page, sharing its status query + const pendingClaimActions = useMemo( + () => + Object.values(actions) + .filter(isTcyClaimAction) + .filter( + action => + action.status === ActionStatus.Pending && Boolean(action.tcyClaimActionMetadata.txHash), + ), + [actions], + ) + + const claimTxStatuses = useQueries({ + queries: pendingClaimActions.map(action => { + const txHash = action.tcyClaimActionMetadata.txHash ?? '' + + return { + queryKey: ['getThorchainTransactionStatus', txHash], + queryFn: () => getThorchainTransactionStatus({ txHash, skipOutbound: true }), + refetchInterval: 10_000, + } + }), + }) + + const claimTxStatusKey = claimTxStatuses.map(({ data }) => data).join() + + useEffect(() => { + pendingClaimActions.forEach((action, i) => { + switch (claimTxStatuses[i]?.data) { + case TxStatus.Confirmed: + dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.Claimed })) + queryClient.invalidateQueries({ + queryKey: ['tcy-claims', action.tcyClaimActionMetadata.claim.accountId], + }) + return + case TxStatus.Failed: + dispatch( + actionSlice.actions.upsertAction({ + ...action, + status: ActionStatus.ClaimAvailable, + tcyClaimActionMetadata: { claim: action.tcyClaimActionMetadata.claim }, + }), + ) + return + default: + return + } + }) + // claimTxStatuses is recreated on every render, use its statuses for a stable reference + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, pendingClaimActions, claimTxStatusKey, queryClient]) } diff --git a/src/state/migrations/clearClaimActions.ts b/src/state/migrations/clearClaimActions.ts new file mode 100644 index 00000000000..0b7f4efb5be --- /dev/null +++ b/src/state/migrations/clearClaimActions.ts @@ -0,0 +1,32 @@ +import type { PersistPartial } from 'redux-persist/es/persistReducer' + +import type { Action, ActionState } from '@/state/slices/actionSlice/types' +import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' + +const clearClaimAction = (action: Action): Action | undefined => { + switch (action.type) { + case ActionType.ArbitrumBridgeWithdraw: + case ActionType.TcyClaim: + return + case ActionType.Swap: + return action.status === ActionStatus.Initiated + ? { ...action, status: ActionStatus.Complete } + : action + default: + return action + } +} + +export const clearClaimActions = (state: ActionState): ActionState & PersistPartial => { + const byId = Object.values(state.byId).reduce((acc, action) => { + const migrated = clearClaimAction(action) + if (migrated) acc[migrated.id] = migrated + return acc + }, {}) + + return { + ...state, + byId, + ids: state.ids.filter(id => Boolean(byId[id])), + } as ActionState & PersistPartial +} diff --git a/src/state/migrations/index.ts b/src/state/migrations/index.ts index c4acaabf41b..4e0e5d50b84 100644 --- a/src/state/migrations/index.ts +++ b/src/state/migrations/index.ts @@ -3,6 +3,7 @@ import type { MigrationManifest } from 'redux-persist' import { clearAction } from './clearAction' import { clearAddressBook } from './clearAddressBook' import { clearAssets } from './clearAssets' +import { clearClaimActions } from './clearClaimActions' import { clearLocalWallet } from './clearLocalWallet' import { clearMarketData } from './clearMarketData' import { clearOpportunities } from './clearOpportunities' @@ -430,6 +431,7 @@ export const clearActionMigrations = { 1: clearAction, // Swaps persisted with the pre-swapperMetadata shape were cleared - drop the orphaned actions too 2: clearAction, + 3: clearClaimActions, } as unknown as Omit export const clearSwapsMigrations = { diff --git a/src/state/slices/actionSlice/selectors.test.ts b/src/state/slices/actionSlice/selectors.test.ts index 2c0b47cc96c..203749a9a61 100644 --- a/src/state/slices/actionSlice/selectors.test.ts +++ b/src/state/slices/actionSlice/selectors.test.ts @@ -4,6 +4,7 @@ import { selectWalletActions, selectWalletActionsSorted, selectWalletClaimActions, + selectWalletRecentActions, selectYieldActionsByTxHash, } from './selectors' import type { ActionState, ArbitrumBridgeWithdrawAction, GenericTransactionAction } from './types' @@ -154,6 +155,16 @@ const action = ( updatedAt, }) +const claimAction = ( + id: string, + status: ActionStatus, + createdAt: number, + updatedAt: number, +): GenericTransactionAction => ({ + ...action(id, status, createdAt, updatedAt), + type: ActionType.Claim, +}) + const arbitrumWithdraw = ( id: string, status: ActionStatus, @@ -179,7 +190,7 @@ const arbitrumWithdraw = ( }) describe('selectWalletActionsSorted', () => { - it('lists the feed newest first, dating in-flight actions by start and settled ones by last update', () => { + it('lists actions newest first, dating in-flight actions by start and settled ones by last update', () => { const oldPending = action('old-pending', ActionStatus.Pending, 100, 900) const newPending = action('new-pending', ActionStatus.Pending, 300, 300) const recentlyDone = action('recently-done', ActionStatus.Complete, 150, 800) @@ -202,20 +213,28 @@ describe('selectWalletActionsSorted', () => { ]) }) - it('leaves claims to the claims section, but keeps settled claims in the feed', () => { - const claimable = action('claimable', ActionStatus.ClaimAvailable, 200, 950) + it('keeps claims in the full list subscribers read', () => { + const claimable = claimAction('claimable', ActionStatus.ClaimAvailable, 200, 950) + + expect(selectWalletActionsSorted.resultFunc([claimable]).map(a => a.id)).toEqual(['claimable']) + }) +}) + +describe('selectWalletRecentActions', () => { + it('leaves claims to the claims tab, but keeps settled claims', () => { + const claimable = claimAction('claimable', ActionStatus.ClaimAvailable, 200, 950) const pendingWithdraw = arbitrumWithdraw('pending-withdraw', ActionStatus.Initiated, 300, 900) const claimed = arbitrumWithdraw('claimed', ActionStatus.Claimed, 100, 100) - const sorted = selectWalletActionsSorted.resultFunc([claimable, pendingWithdraw, claimed]) + const recent = selectWalletRecentActions.resultFunc([claimed, pendingWithdraw, claimable]) - expect(sorted.map(a => a.id)).toEqual(['claimed']) + expect(recent.map(a => a.id)).toEqual(['claimed']) }) }) describe('selectWalletClaimActions', () => { it('lists claims newest first by the same timestamps as the feed', () => { - const oldReady = action('old-ready', ActionStatus.ClaimAvailable, 100, 100) + const oldReady = claimAction('old-ready', ActionStatus.ClaimAvailable, 100, 100) const newReady = arbitrumWithdraw('new-ready', ActionStatus.ClaimAvailable, 200, 200) const claiming = arbitrumWithdraw('claiming', ActionStatus.ClaimAvailable, 50, 60, '0xclaim') const pending = arbitrumWithdraw('pending', ActionStatus.Initiated, 300, 1_000) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 6e2f61e3263..bf2fd6c9b28 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -111,15 +111,18 @@ export const selectWalletActionsSorted = createDeepEqualOutputSelector( actions => { return actions .filter( - action => - action.status !== ActionStatus.Idle && - action.status !== ActionStatus.Abandoned && - !isClaimSectionAction(action), + action => action.status !== ActionStatus.Idle && action.status !== ActionStatus.Abandoned, ) .sort((a, b) => getActionTimestamp(b) - getActionTimestamp(a)) }, ) +// Subscribers read the full list above, only the recent tab leaves claims to the claims tab +export const selectWalletRecentActions = createDeepEqualOutputSelector( + selectWalletActionsSorted, + actions => actions.filter(action => !isClaimSectionAction(action)), +) + export const selectWalletPendingActions = createDeepEqualOutputSelector( selectWalletActions, actions => { diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index ce2a56adfbd..e8f1c211124 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -319,25 +319,13 @@ export const isChainflipLendingAction = (action: Action): action is ChainflipLen return Boolean(action.type === ActionType.ChainflipLending && action.chainflipLendingMetadata) } -const isYieldCooldownClaimAction = (action: Action): action is GenericTransactionAction => - isGenericTransactionAction(action) && - action.type === ActionType.Claim && - action.transactionMetadata.displayType === GenericTransactionDisplayType.Claim - -// Claims live in their own action center section while they are waiting, ready or being claimed -export const isClaimSectionAction = (action: Action): boolean => { - switch (action.status) { - case ActionStatus.ClaimAvailable: - return true - case ActionStatus.Initiated: - return isArbitrumBridgeWithdrawAction(action) || isYieldCooldownClaimAction(action) - case ActionStatus.Pending: - return isRfoxClaimAction(action) || isTcyClaimAction(action) - default: - return false - } -} +const CLAIM_ACTION_TYPES = new Set([ + ActionType.Claim, + ActionType.RfoxClaim, + ActionType.TcyClaim, + ActionType.ArbitrumBridgeWithdraw, +]) -export const isClaimReadyAction = (action: Action): boolean => - action.status === ActionStatus.ClaimAvailable && - !(isArbitrumBridgeWithdrawAction(action) && action.arbitrumBridgeMetadata.claimTxHash) +// Claims stay in the claims tab until they settle +export const isClaimSectionAction = (action: Action): boolean => + CLAIM_ACTION_TYPES.has(action.type) && isActiveActionStatus(action.status) From 5b5c78b0eaa0178254c1e1e55fa3293fea0fcc7c Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:27:45 -0600 Subject: [PATCH 17/40] fix(arbitrum-bridge): event-driven claim status, precise countdown, lazy-history safe recovery Co-Authored-By: Claude Opus 5.5 --- .../Header/ActionCenter/ActionCenter.tsx | 11 +- .../components/ArbitrumBridgeClaimModal.tsx | 9 +- .../ArbitrumBridgeWithdrawActionCard.tsx | 7 +- .../Notifications/SwapNotification.tsx | 17 +- .../RewardDistributionActionCard.tsx | 6 +- .../arbitrumBridgeWithdrawAction.test.ts | 3 +- .../arbitrumBridgeWithdrawAction.ts | 18 +- .../useArbitrumWithdrawalActionSubscriber.tsx | 73 ++-- .../useGenericTransactionSubscriber.tsx | 13 +- .../useSwapActionSubscriber.tsx | 27 +- .../useArbitrumClaims/useArbitrumClaimTx.tsx | 6 +- .../useArbitrumClaims/useArbitrumClaims.tsx | 331 ++++++++++-------- .../TCY/hooks/useTcyClaimActionSubscriber.tsx | 33 +- .../slices/actionSlice/selectors.test.ts | 2 +- src/state/slices/actionSlice/selectors.ts | 12 - src/state/slices/actionSlice/types.ts | 15 +- 16 files changed, 284 insertions(+), 299 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx index ac665d05bde..3033b256170 100644 --- a/src/components/Layout/Header/ActionCenter/ActionCenter.tsx +++ b/src/components/Layout/Header/ActionCenter/ActionCenter.tsx @@ -136,8 +136,8 @@ export const ActionCenter = memo(() => { const { ordersByActionId } = useLimitOrders() const swapsById = useAppSelector(swapSlice.selectors.selectSwapsById) - const renderAction = useCallback( - (action: Action) => { + const renderActionCard = useCallback( + (_index: number, action: Action) => { const actionsCards = (() => { switch (action.type) { case ActionType.Swap: { @@ -219,11 +219,6 @@ export const ActionCenter = memo(() => { [handleOpenArbitrumClaim, handleOpenSpeedUp, ordersByActionId, swapsById], ) - const renderActionCard = useCallback( - (_index: number, action: Action) => renderAction(action), - [renderAction], - ) - const renderActionList = useCallback( (data: Action[]) => ( { const drawerContent = useMemo(() => { return ( { + if (!isOpen) return + queryClient.invalidateQueries({ queryKey: ['claimStatus', { txid: withdrawTxHash }] }) + }, [isOpen, withdrawTxHash]) const claimDetails = useMemo(() => { const claim = claimsByTxid[withdrawTxHash] return claim?.status === ActionStatus.ClaimAvailable ? claim : undefined diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 7b7d1b04875..98d261e319e 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -54,11 +54,6 @@ export const ArbitrumBridgeWithdrawActionCard = ({ const timeText = useArbitrumClaimTimeText(action.arbitrumBridgeMetadata.claimableAt) const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) - const isCollapsable = - action.status === ActionStatus.Initiated || - action.status === ActionStatus.ClaimAvailable || - action.status === ActionStatus.Pending || - action.status === ActionStatus.Claimed const { isOpen, onToggle } = useDisclosure() const handleClaimClick = useCallback( @@ -179,7 +174,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ type={action.type} displayType={GenericTransactionDisplayType.Bridge} formattedDate={formattedDate} - isCollapsable={isCollapsable} + isCollapsable isOpen={isOpen} onToggle={onToggle} description={description} diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index 0610b04985d..87f7e0b776f 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -12,12 +12,13 @@ import { Amount } from '@/components/Amount/Amount' import { Text } from '@/components/Text' import type { TextPropTypes } from '@/components/Text/Text' import { StandardToast } from '@/components/Toast/StandardToast' +import { getArbitrumBridgeWithdrawActionId } from '@/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction' import { useActualBuyAmountCryptoPrecision } from '@/hooks/useActualBuyAmountCryptoPrecision' import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { - selectArbitrumBridgeWithdrawActionByWithdrawTxHash, + selectArbitrumBridgeWithdrawActionById, selectSwapActionBySwapId, selectWalletSwapsById, } from '@/state/slices/selectors' @@ -46,20 +47,18 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat const maybeArbitrumBridgeAction = useAppSelector(state => withdrawTxHash - ? selectArbitrumBridgeWithdrawActionByWithdrawTxHash(state, withdrawTxHash) + ? selectArbitrumBridgeWithdrawActionById( + state, + getArbitrumBridgeWithdrawActionId(withdrawTxHash), + ) : undefined, ) // Bridge swaps show their withdraw action once history creates it, the swap action until then - const action = - swap?.swapperName === SwapperName.ArbitrumBridge - ? maybeArbitrumBridgeAction ?? swapAction - : swapAction + const action = maybeArbitrumBridgeAction ?? swapAction const timeText = useArbitrumClaimTimeText( - maybeArbitrumBridgeAction && isArbitrumBridgeWithdrawAction(maybeArbitrumBridgeAction) - ? maybeArbitrumBridgeAction.arbitrumBridgeMetadata.claimableAt - : undefined, + maybeArbitrumBridgeAction?.arbitrumBridgeMetadata.claimableAt, ) const swapNotificationComponents = useMemo((): TextPropTypes['components'] | undefined => { diff --git a/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx index b3679eeb07f..abea87de780 100644 --- a/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RewardDistributionActionCard.tsx @@ -1,6 +1,5 @@ import { Button, ButtonGroup, Link, useDisclosure } from '@chakra-ui/react' import { BigAmount } from '@shapeshiftoss/utils' -import dayjs from 'dayjs' import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -12,6 +11,7 @@ import { Amount } from '@/components/Amount/Amount' import type { TextPropTypes } from '@/components/Text/Text' import { Text } from '@/components/Text/Text' import { getTxLink } from '@/lib/getTxLink' +import { formatSmartDate } from '@/lib/utils/time' import { getRewardAssetId, maybeGetStakingAssetId } from '@/pages/RFOX/helpers' import type { RewardDistributionAction } from '@/state/slices/actionSlice/types' import { @@ -38,9 +38,7 @@ export const RewardDistributionActionCard = ({ action }: RewardDistributionActio }, [distribution.epoch, distribution.stakingContract]) const rewardAsset = useAppSelector(state => selectAssetById(state, rewardAssetId ?? '')) - const formattedDate = useMemo(() => { - return dayjs(getActionTimestamp(action)).fromNow() - }, [action]) + const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const rewardDistributionTranslationComponents: TextPropTypes['components'] = useMemo(() => { if (!rewardAsset) return diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts index d0b28a54b0c..b9816682412 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -17,7 +17,8 @@ const otherEthAccountId = 'eip155:1:0x0000000000000000000000000000000000000002' const destinationAccountId = `eip155:1:${destinationAddress.toLowerCase()}` const claim = { - tx: { txid: '0xwithdraw', blockTime: 1_700_000_000 }, + withdrawTxHash: '0xwithdraw', + withdrawTimeMs: 1_700_000_000_000, accountId: 'eip155:42161:0xarb', amountCryptoBaseUnit: '1000', assetId: 'eip155:42161/slip44:60', diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 2771a778530..9e57ccb35bb 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -1,9 +1,7 @@ import type { AccountId } from '@shapeshiftoss/caip' -import { arbitrumChainId, ethChainId, fromAccountId } from '@shapeshiftoss/caip' -import { assertGetViemClient } from '@shapeshiftoss/contracts' +import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' import type { Swap } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' -import type { Hash } from 'viem' import type { ClaimDetails } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { getArbitrumClaimableAt } from '@/hooks/useArbitrumClaims/useArbitrumClaims' @@ -30,10 +28,10 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( const destinationAccountId = getClaimDestinationAccountId(claim.destinationAddress, ethAccountIds) if (!destinationAccountId) return - const createdAt = claim.tx.blockTime * 1000 + const createdAt = claim.withdrawTimeMs return { - id: getArbitrumBridgeWithdrawActionId(claim.tx.txid), + id: getArbitrumBridgeWithdrawActionId(claim.withdrawTxHash), type: ActionType.ArbitrumBridgeWithdraw, status: claim.status === ActionStatus.ClaimAvailable @@ -42,7 +40,7 @@ export const buildArbitrumBridgeWithdrawActionFromClaim = ( createdAt, updatedAt: createdAt, arbitrumBridgeMetadata: { - withdrawTxHash: claim.tx.txid, + withdrawTxHash: claim.withdrawTxHash, amountCryptoBaseUnit: claim.amountCryptoBaseUnit, assetId: claim.assetId, destinationAssetId: claim.destinationAssetId, @@ -80,11 +78,3 @@ export const buildArbitrumBridgeWithdrawActionFromSwap = ( }, } } - -// Dated by the withdraw block, a swap resumed after a reload can succeed long after it -export const getArbitrumWithdrawTimeMs = async (withdrawTxHash: Hash): Promise => { - const client = assertGetViemClient(arbitrumChainId) - const { blockNumber } = await client.getTransactionReceipt({ hash: withdrawTxHash }) - const { timestamp } = await client.getBlock({ blockNumber }) - return Number(timestamp) * 1000 -} diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 865b8352874..f2257285516 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -10,25 +10,22 @@ import type { Hash } from 'viem' import { TransactionNotFoundError, TransactionReceiptNotFoundError } from 'viem' import { useNotificationToast } from '../useNotificationToast' -import { buildArbitrumBridgeWithdrawActionFromClaim } from './arbitrumBridgeWithdrawAction' +import { + buildArbitrumBridgeWithdrawActionFromClaim, + getArbitrumBridgeWithdrawActionId, +} from './arbitrumBridgeWithdrawAction' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' import { useArbitrumClaims } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' -import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' -import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' +import { + ActionStatus, + isArbitrumBridgeWithdrawAction, + isClaimStatusRegression, +} from '@/state/slices/actionSlice/types' import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { useAppDispatch, useAppSelector } from '@/state/store' -const WITHDRAW_STATUS_ORDER: Partial> = { - [ActionStatus.Initiated]: 0, - [ActionStatus.ClaimAvailable]: 1, - [ActionStatus.Pending]: 2, - [ActionStatus.Claimed]: 3, -} - -const getWithdrawStatusOrder = (status: ActionStatus): number => WITHDRAW_STATUS_ORDER[status] ?? 0 - // A node can briefly miss a fresh broadcast, so only a long-unknown claim counts as dropped const CLAIM_TX_DROPPED_AFTER_MS = 10 * 60 * 1000 @@ -51,11 +48,13 @@ const getClaimTxStatus = async (claimTxHash: Hash, broadcastAt: number): Promise } } +const selectClaimTxStatuses = (results: { data?: TxStatus }[]) => results.map(({ data }) => data) + export const useArbitrumWithdrawalActionSubscriber = () => { const dispatch = useAppDispatch() const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) - const { claims, claimsByTxid, claimsKey } = useArbitrumClaims() + const { claims, claimsByTxid } = useArbitrumClaims() const translate = useTranslate() const ethAccountIds = useMemo( @@ -64,17 +63,6 @@ export const useArbitrumWithdrawalActionSubscriber = () => { [enabledWalletAccountIds], ) - const arbitrumActionsByWithdrawTxHash = useMemo( - () => - Object.values(actionsById) - .filter(isArbitrumBridgeWithdrawAction) - .reduce>((acc, action) => { - acc[action.arbitrumBridgeMetadata.withdrawTxHash] = action - return acc - }, {}), - [actionsById], - ) - const { isDrawerOpen, openActionCenterClaims } = useActionCenterContext() const toastOptions = useMemo(() => ({ duration: isDrawerOpen ? 5000 : null }), [isDrawerOpen]) const toast = useNotificationToast(toastOptions) @@ -111,28 +99,22 @@ export const useArbitrumWithdrawalActionSubscriber = () => { claims.forEach(claim => { if (claim.status === ActionStatus.Claimed) return - if (arbitrumActionsByWithdrawTxHash[claim.tx.txid]) return + if (actionsById[getArbitrumBridgeWithdrawActionId(claim.withdrawTxHash)]) return const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ethAccountIds) if (!action) return dispatch(actionSlice.actions.upsertAction(action)) }) - // claims are recreated on every render, claimsKey tracks what matters - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, ethAccountIds, arbitrumActionsByWithdrawTxHash, claimsKey]) + }, [dispatch, ethAccountIds, actionsById, claims]) const pendingArbitrumBridgeActions = useMemo(() => { - return Object.values(actionsById) - .filter(isArbitrumBridgeWithdrawAction) - .filter(action => { - // Early bailout: if action is already in terminal state, don't process - // i.e see this bad boi https://github.com/shapeshift/web/pull/10556 - if (action.status === ActionStatus.Claimed || action.status === ActionStatus.Failed) { - return false - } - return true - }) + return ( + Object.values(actionsById) + .filter(isArbitrumBridgeWithdrawAction) + // Claimed is final, i.e see this bad boi https://github.com/shapeshift/web/pull/10556 + .filter(action => action.status !== ActionStatus.Claimed) + ) }, [actionsById]) useEffect(() => { @@ -145,7 +127,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { const newStatus = claim.status // A lagging rpc or a claim in flight reads as an earlier status, a withdraw never moves backwards - if (getWithdrawStatusOrder(newStatus) < getWithdrawStatusOrder(action.status)) return null + if (isClaimStatusRegression(action.status, newStatus)) return null // Only a pending claim's estimate still matters const claimableAt = @@ -183,9 +165,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { } catch (error) { console.error('Error updating ArbitrumBridge action statuses:', error) } - // claims are recreated on every render, claimsKey tracks what matters - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, notifyClaimAvailable, pendingArbitrumBridgeActions, claimsKey]) + }, [dispatch, notifyClaimAvailable, pendingArbitrumBridgeActions, claimsByTxid]) // Resolves in-flight claims, including ones broadcast before a reload const claimingActions = useMemo( @@ -211,13 +191,12 @@ export const useArbitrumWithdrawalActionSubscriber = () => { refetchInterval: 15_000, } }), + combine: selectClaimTxStatuses, }) - const claimTxStatusKey = claimTxStatuses.map(({ data }) => data).join() - useEffect(() => { claimingActions.forEach((action, i) => { - switch (claimTxStatuses[i]?.data) { + switch (claimTxStatuses[i]) { case TxStatus.Confirmed: dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.Claimed })) return @@ -234,7 +213,5 @@ export const useArbitrumWithdrawalActionSubscriber = () => { return } }) - // claimTxStatuses is recreated on every render, use its statuses for a stable reference - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, claimingActions, claimTxStatusKey]) + }, [dispatch, claimingActions, claimTxStatuses]) } diff --git a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx index 86857ec101a..65ba5d1f006 100644 --- a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx @@ -27,6 +27,7 @@ import { ActionType, GenericTransactionDisplayType, GenericTransactionQueryId, + isClaimAction, } from '@/state/slices/actionSlice/types' import { selectTxs } from '@/state/slices/selectors' import { serializeTxIndex } from '@/state/slices/txHistorySlice/utils' @@ -92,11 +93,7 @@ export const useGenericTransactionSubscriber = () => { }, []) const fireToast = useCallback( - ( - action: (typeof pendingGenericTransactionActions)[number], - status: 'success' | 'error', - isClaim = false, - ) => { + (action: (typeof pendingGenericTransactionActions)[number], status: 'success' | 'error') => { if (toast.isActive(action.transactionMetadata.txHash)) return toast({ @@ -106,7 +103,7 @@ export const useGenericTransactionSubscriber = () => { render: ({ onClose, ...props }) => { const handleClick = () => { onClose() - isClaim ? openActionCenterClaims() : openActionCenter() + isClaimAction(action) ? openActionCenterClaims() : openActionCenter() } return ( @@ -182,7 +179,7 @@ export const useGenericTransactionSubscriber = () => { }), ) - fireToast(action, 'success', true) + fireToast(action, 'success') clearPollingInterval(pollingKey) } } @@ -239,7 +236,7 @@ export const useGenericTransactionSubscriber = () => { }), ) - fireToast(action, 'success', true) + fireToast({ ...action, type: ActionType.Claim }, 'success') } else { const typeMessagesMap = displayTypeMessagesMap[action.type] const message = diff --git a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx index 580201b8fef..8029aa47061 100644 --- a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx @@ -14,7 +14,6 @@ import { useQueries, useQuery } from '@tanstack/react-query' import { uuidv4 } from '@walletconnect/utils' import { detectIncognito } from 'detectincognitojs' import { useCallback, useEffect, useMemo } from 'react' -import type { Hash } from 'viem' import { preferences } from '../../state/slices/preferencesSlice/preferencesSlice' import { fetchIsSmartContractAddressQuery } from '../useIsSmartContractAddress/useIsSmartContractAddress' @@ -24,7 +23,6 @@ import { useNotificationToast } from '../useNotificationToast' import { useWallet } from '../useWallet/useWallet' import { buildArbitrumBridgeWithdrawActionFromSwap, - getArbitrumWithdrawTimeMs, isArbitrumBridgeWithdrawSwap, } from './arbitrumBridgeWithdrawAction' @@ -34,6 +32,7 @@ import { getConfig } from '@/config' import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { queryClient } from '@/context/QueryClientProvider/queryClient' +import { fetchArbitrumClaimMessage } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { getTxLink } from '@/lib/getTxLink' import { fetchTradeStatus, tradeStatusQueryKey } from '@/lib/tradeExecution' @@ -277,18 +276,20 @@ export const useSwapActionSubscriber = () => { }), ) - // Tx history only recovers withdraws this missed, e.g. after a wiped store + // Tx history only recovers withdraws this missed, e.g. after a wiped store. Not awaited so the + // balance refetches below aren't held up by the withdraw block lookup if (isArbitrumBridgeWithdrawSwap(swap)) { - const withdrawTimeMs = await getArbitrumWithdrawTimeMs(swap.sellTxHash as Hash).catch( - () => Date.now(), - ) - const withdrawAction = buildArbitrumBridgeWithdrawActionFromSwap(swap, withdrawTimeMs) - if ( - withdrawAction && - !actionSlice.selectors.selectActionsById(store.getState())[withdrawAction.id] - ) { - dispatch(actionSlice.actions.upsertAction(withdrawAction)) - } + void fetchArbitrumClaimMessage(swap.sellTxHash) + .then(({ withdrawTimeMs }) => withdrawTimeMs) + .catch(() => Date.now()) + .then(withdrawTimeMs => { + const withdrawAction = buildArbitrumBridgeWithdrawActionFromSwap(swap, withdrawTimeMs) + if (!withdrawAction) return + if (actionSlice.selectors.selectActionsById(store.getState())[withdrawAction.id]) + return + + dispatch(actionSlice.actions.upsertAction(withdrawAction)) + }) } const { getAccount } = portfolioApi.endpoints diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx index 55b15c966c5..2e4d6bb48e7 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx @@ -38,7 +38,7 @@ export const useArbitrumClaimTx = ( const bip44Params = useAppSelector(state => selectBip44ParamsByAccountId(state, accountIdFilter)) const executeTransactionDataResult = useQuery({ - queryKey: ['executeTransactionData', { txid: claim?.tx.txid }], + queryKey: ['executeTransactionData', { txid: claim?.withdrawTxHash }], queryFn: claim ? async () => { const { event, message } = claim @@ -76,7 +76,7 @@ export const useArbitrumClaimTx = ( }) const claimMutation = useMutation({ - mutationKey: ['claim', { txid: claim?.tx.txid }], + mutationKey: ['claim', { txid: claim?.withdrawTxHash }], mutationFn: async () => { if (!claim) return if (!wallet) return @@ -109,7 +109,7 @@ export const useArbitrumClaimTx = ( }, onSettled() { queryClient.invalidateQueries({ - queryKey: ['claimStatus', { txid: claim?.tx.txid }], + queryKey: ['claimStatus', { txid: claim?.withdrawTxHash }], refetchType: 'all', }) }, diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx index 8eea8e57142..54ad630df19 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx @@ -8,46 +8,51 @@ import { Outbox__factory } from '@arbitrum/sdk/dist/lib/abi/factories/Outbox__fa import { BoldRollupUserLogic__factory } from '@arbitrum/sdk/dist/lib/abi-bold/factories/BoldRollupUserLogic__factory' import { ARB1_NITRO_GENESIS_L2_BLOCK } from '@arbitrum/sdk/dist/lib/dataEntities/constants' import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' -import { arbitrumChainId, ethChainId, fromChainId, toAccountId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + ethChainId, + fromAccountId, + fromChainId, + toAccountId, +} from '@shapeshiftoss/caip' import { getEthersV5Provider } from '@shapeshiftoss/contracts' import { KnownChainIds } from '@shapeshiftoss/types' import type { Query } from '@tanstack/react-query' -import { useQueries, useQueryClient } from '@tanstack/react-query' +import { useQueries } from '@tanstack/react-query' +import keyBy from 'lodash/keyBy' import { useMemo } from 'react' +import { queryClient } from '@/context/QueryClientProvider/queryClient' import { useWallet } from '@/hooks/useWallet/useWallet' -import { assertUnreachable } from '@/lib/utils' -import { ActionStatus } from '@/state/slices/actionSlice/types' +import { assertUnreachable, isSome } from '@/lib/utils' +import { actionSlice } from '@/state/slices/actionSlice/actionSlice' +import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' +import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { selectArbitrumWithdrawTxs, selectPortfolioLoadingStatus } from '@/state/slices/selectors' import type { Tx } from '@/state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from '@/state/store' -// Arbitrum's challenge period; the claim can open up to an hour later while an assertion posts +// Estimate from the withdraw until its covering assertion posts, about an hour in const ARBITRUM_CHALLENGE_PERIOD_MS = 6.4 * 24 * 60 * 60 * 1000 -// Confirmations running past the challenge period extend the estimate by the observed lag -export const getArbitrumClaimableAt = (withdrawTimeMs: number, confirmationLagMs = 0): number => - withdrawTimeMs + Math.max(ARBITRUM_CHALLENGE_PERIOD_MS, confirmationLagMs) +export const getArbitrumClaimableAt = (withdrawTimeMs: number): number => + withdrawTimeMs + ARBITRUM_CHALLENGE_PERIOD_MS // Assertions post roughly every 300 parent blocks, so the covering one lands well inside this window const ASSERTION_SCAN_BLOCKS = 1_200 const LOG_RANGE_BLOCKS = 100 const ASSERTION_RETRY_MS = 10 * 60 * 1000 -const FALLBACK_BLOCK_TIME_MS = 12_000 + +const UNCONFIRMED_POLL_MS = 60_000 type ConfirmedChildBlock = { + assertionHash: string number: number - confirmationLagMs: number -} - -type CoveringAssertion = { - blockNumber: number - timestampMs: number } -// Final once the assertion is found or the whole window has been scanned without it +// Final once the covering assertion is found or the whole window has been scanned without it type AssertionSearch = { - assertion: CoveringAssertion | undefined + claimableAt: number | undefined isFinal: boolean } @@ -67,6 +72,10 @@ const getConfirmedChildBlock = async ( ): Promise => { const rollup = BoldRollupUserLogic__factory.connect(arbitrumNetwork.ethBridge.rollup, l1Provider) const latestConfirmed = await rollup.latestConfirmed() + + // Confirmations land about hourly, so most polls stop here + if (previous?.assertionHash === latestConfirmed) return previous + const createdAtBlock = (await rollup.getAssertion(latestConfirmed)).createdAtBlock.toNumber() const [assertionCreated] = await rollup.queryFilter( @@ -80,14 +89,12 @@ const getConfirmedChildBlock = async ( const block = await l2Provider.getBlock(blockHash) if (!block) throw new Error(`Confirmed child block ${blockHash} not found`) - // Lag is measured when a confirmation is first seen so the estimate holds until the next one - if (previous?.number === block.number) return previous - - return { number: block.number, confirmationLagMs: Date.now() - block.timestamp * 1000 } + return { assertionHash: latestConfirmed, number: block.number } } -// The first assertion after the withdraw that covers its child block -const findCoveringAssertion = async ( +// The claim opens a confirm period after the assertion covering the withdraw posts, timed by the +// parent chain's block rate over the last confirm period +const findClaimableAt = async ( parentBlockNumber: number, childBlockNumber: number, l1Provider: EthersV5Provider, @@ -95,8 +102,8 @@ const findCoveringAssertion = async ( ): Promise => { const rollup = BoldRollupUserLogic__factory.connect(arbitrumNetwork.ethBridge.rollup, l1Provider) const windowEndBlock = parentBlockNumber + ASSERTION_SCAN_BLOCKS - const latestBlock = await l1Provider.getBlockNumber() - const toBlock = Math.min(windowEndBlock, latestBlock) + const latest = await l1Provider.getBlock('latest') + const toBlock = Math.min(windowEndBlock, latest.number) for (let fromBlock = parentBlockNumber; fromBlock <= toBlock; fromBlock += LOG_RANGE_BLOCKS) { const logs = await rollup.queryFilter( @@ -110,37 +117,24 @@ const findCoveringAssertion = async ( const childBlock = await l2Provider.getBlock(blockHash) if (!childBlock || childBlock.number < childBlockNumber) continue - const { timestamp } = await l1Provider.getBlock(log.blockNumber) - return { - assertion: { blockNumber: log.blockNumber, timestampMs: timestamp * 1000 }, - isFinal: true, - } + const { confirmPeriodBlocks } = arbitrumNetwork + const [assertionBlock, periodStartBlock] = await Promise.all([ + l1Provider.getBlock(log.blockNumber), + l1Provider.getBlock(latest.number - confirmPeriodBlocks), + ]) + const confirmPeriodMs = (latest.timestamp - periodStartBlock.timestamp) * 1000 + + return { claimableAt: assertionBlock.timestamp * 1000 + confirmPeriodMs, isFinal: true } } } - // Past the window the lag estimate stands in rather than rescanning - return { assertion: undefined, isFinal: latestBlock >= windowEndBlock } -} - -// The claim opens once the covering assertion's confirm period passes, timed by the observed block rate -const getAssertionClaimableAt = async ( - assertion: CoveringAssertion, - l1Provider: EthersV5Provider, -): Promise => { - const latest = await l1Provider.getBlock('latest') - const elapsedBlocks = latest.number - assertion.blockNumber - const blockTimeMs = - elapsedBlocks > 0 - ? (latest.timestamp * 1000 - assertion.timestampMs) / elapsedBlocks - : FALLBACK_BLOCK_TIME_MS - const claimableAt = assertion.timestampMs + arbitrumNetwork.confirmPeriodBlocks * blockTimeMs - - // Minute precision keeps the stored estimate from rewriting on every poll - return Math.round(claimableAt / 60_000) * 60_000 + // Past the window the estimate stands in rather than rescanning + return { claimableAt: undefined, isFinal: latest.number >= windowEndBlock } } type ClaimMessage = { blockNumber: number + withdrawTimeMs: number event: Extract message: ChildToParentMessageReader } @@ -157,41 +151,126 @@ const getClaimMessage = async ( if (!event || !message) throw new Error(`No withdraw message found for ${txid}`) if (!('position' in event)) throw new Error(`Unsupported classic withdraw ${txid}`) - return { blockNumber: receipt.blockNumber, event, message } + const { timestamp } = await l2Provider.getBlock(receipt.blockNumber) + + return { blockNumber: receipt.blockNumber, withdrawTimeMs: timestamp * 1000, event, message } } +// Fetched once and shared with the swap flow, a withdraw's message never changes +export const fetchArbitrumClaimMessage = (txid: string): Promise => + queryClient.fetchQuery({ + queryKey: ['arbitrumClaimMessage', { txid }], + queryFn: () => + getClaimMessage( + txid, + getEthersV5Provider(KnownChainIds.EthereumMainnet), + getEthersV5Provider(KnownChainIds.ArbitrumMainnet), + ), + staleTime: Infinity, + gcTime: Infinity, + }) + type ClaimStatusResult = { event: ChildToParentTransactionEvent message: ChildToParentMessageReader + withdrawTimeMs: number status: ChildToParentMessageStatus - confirmationLagMs: number - assertionClaimableAt: number | undefined + claimableAt: number | undefined } -// A withdraw's status in action terms: waiting on its challenge period, claimable, or claimed -export type ClaimDetails = Pick & { - status: ActionStatus.Initiated | ActionStatus.ClaimAvailable | ActionStatus.Claimed +// What a claim needs to know about its withdraw, from tx history or from the stored withdraw action +type WithdrawSource = { + withdrawTxHash: string accountId: AccountId amountCryptoBaseUnit: string assetId: string - claimableAt: number destinationAddress: string destinationAssetId: AssetId destinationChainId: ChainId - tx: Tx } -export const useArbitrumClaims = (props?: { skip?: boolean }) => { - const queryClient = useQueryClient() +// A withdraw's status in action terms: waiting on its challenge period, claimable, or claimed +export type ClaimDetails = WithdrawSource & + Pick & { + status: ActionStatus.Initiated | ActionStatus.ClaimAvailable | ActionStatus.Claimed + claimableAt: number + } +const toClaimStatus = (status: ChildToParentMessageStatus): ClaimDetails['status'] => { + switch (status) { + case ChildToParentMessageStatus.UNCONFIRMED: + return ActionStatus.Initiated + case ChildToParentMessageStatus.CONFIRMED: + return ActionStatus.ClaimAvailable + case ChildToParentMessageStatus.EXECUTED: + return ActionStatus.Claimed + default: + return assertUnreachable(status) + } +} + +const getHistoryWithdrawSource = (tx: Tx): WithdrawSource | undefined => { + if (tx.data?.parser !== 'arbitrumBridge') return + if (!tx.transfers.length) return + if (!tx.data.value || !tx.data.destinationAddress || !tx.data.destinationAssetId) return + + return { + withdrawTxHash: tx.txid, + accountId: toAccountId({ chainId: arbitrumChainId, account: tx.pubkey }), + amountCryptoBaseUnit: tx.data.value, + assetId: tx.transfers[0].assetId, + destinationAddress: tx.data.destinationAddress, + destinationAssetId: tx.data.destinationAssetId, + destinationChainId: ethChainId, + } +} + +const getActionWithdrawSource = ({ + arbitrumBridgeMetadata: metadata, +}: ArbitrumBridgeWithdrawAction): WithdrawSource => ({ + withdrawTxHash: metadata.withdrawTxHash, + accountId: metadata.accountId, + amountCryptoBaseUnit: metadata.amountCryptoBaseUnit, + assetId: metadata.assetId, + destinationAddress: fromAccountId(metadata.destinationAccountId).account, + destinationAssetId: metadata.destinationAssetId, + destinationChainId: ethChainId, +}) + +// Stable and module level so claims only change when a withdraw's query result does +const combineClaims = (results: { data?: ClaimDetails }[]) => { + const claims = results.map(({ data }) => data).filter(isSome) + return { claims, claimsByTxid: keyBy(claims, claim => claim.withdrawTxHash) } +} + +// Pollers keep the claims fresh, observers like the claim modal read the shared cache without extra timers +export const useArbitrumClaims = (props?: { skip?: boolean; isPolling?: boolean }) => { const arbitrumWithdrawTxs = useAppSelector(selectArbitrumWithdrawTxs) + const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) const portfolioLoadingStatus = useAppSelector(selectPortfolioLoadingStatus) - // Pre-nitro withdraws use the classic outbox, which we can neither track nor claim - const nitroWithdrawTxs = useMemo( - () => arbitrumWithdrawTxs.filter(tx => tx.blockHeight >= ARB1_NITRO_GENESIS_L2_BLOCK), - [arbitrumWithdrawTxs], - ) + // Stored withdraws are tracked even when tx history isn't loaded, history adds the ones we missed + const withdrawSources = useMemo(() => { + const sources = new Map() + + // Pre-nitro withdraws use the classic outbox, which we can neither track nor claim + arbitrumWithdrawTxs + .filter(tx => tx.blockHeight >= ARB1_NITRO_GENESIS_L2_BLOCK) + .forEach(tx => { + const source = getHistoryWithdrawSource(tx) + if (source) sources.set(source.withdrawTxHash, source) + }) + + Object.values(actionsById) + .filter(isArbitrumBridgeWithdrawAction) + .filter(action => action.status !== ActionStatus.Claimed) + .forEach(action => { + const source = getActionWithdrawSource(action) + if (!sources.has(source.withdrawTxHash)) sources.set(source.withdrawTxHash, source) + }) + + return [...sources.values()] + }, [arbitrumWithdrawTxs, actionsById]) const { state: { isLoadingLocalWallet, modal, isConnected }, @@ -224,32 +303,24 @@ export const useArbitrumClaims = (props?: { skip?: boolean }) => { } } - const claimStatuses = useQueries({ - queries: nitroWithdrawTxs.map(tx => { + const { claims, claimsByTxid } = useQueries({ + queries: withdrawSources.map(source => { + const txid = source.withdrawTxHash + return { - queryKey: ['claimStatus', { txid: tx.txid }], + queryKey: ['claimStatus', { txid }], queryFn: async (): Promise => { - // Fetched once, a withdraw's message never changes - const { blockNumber, event, message } = await queryClient.fetchQuery({ - queryKey: ['arbitrumClaimMessage', { txid: tx.txid }], - queryFn: () => getClaimMessage(tx.txid, l1Provider, l2Provider), - staleTime: Infinity, - gcTime: Infinity, - }) + const { blockNumber, withdrawTimeMs, event, message } = + await fetchArbitrumClaimMessage(txid) const confirmedChildBlock = await fetchConfirmedChildBlock() if (blockNumber > confirmedChildBlock.number) { // Kept once final, retried until the covering assertion posts - const { assertion } = await queryClient.fetchQuery({ - queryKey: ['arbitrumClaimAssertion', { txid: tx.txid }], + const { claimableAt } = await queryClient.fetchQuery({ + queryKey: ['arbitrumClaimAssertion', { txid }], queryFn: () => - findCoveringAssertion( - event.ethBlockNum.toNumber(), - blockNumber, - l1Provider, - l2Provider, - ), + findClaimableAt(event.ethBlockNum.toNumber(), blockNumber, l1Provider, l2Provider), staleTime: query => (query.state.data?.isFinal ? Infinity : ASSERTION_RETRY_MS), gcTime: Infinity, }) @@ -257,11 +328,9 @@ export const useArbitrumClaims = (props?: { skip?: boolean }) => { return { event, message, + withdrawTimeMs, status: ChildToParentMessageStatus.UNCONFIRMED, - confirmationLagMs: confirmedChildBlock.confirmationLagMs, - assertionClaimableAt: assertion - ? await getAssertionClaimableAt(assertion, l1Provider) - : undefined, + claimableAt, } } @@ -271,88 +340,44 @@ export const useArbitrumClaims = (props?: { skip?: boolean }) => { return { event, message, + withdrawTimeMs, status: isSpent ? ChildToParentMessageStatus.EXECUTED : ChildToParentMessageStatus.CONFIRMED, - confirmationLagMs: confirmedChildBlock.confirmationLagMs, - assertionClaimableAt: undefined, + claimableAt: undefined, } }, - select: (result: ClaimStatusResult) => { - const status = (() => { - switch (result.status) { - case ChildToParentMessageStatus.UNCONFIRMED: - return ActionStatus.Initiated as const - case ChildToParentMessageStatus.CONFIRMED: - return ActionStatus.ClaimAvailable as const - case ChildToParentMessageStatus.EXECUTED: - return ActionStatus.Claimed as const - default: - assertUnreachable(result.status) - } - })() - return { ...result, tx, status } + select: (result: ClaimStatusResult): ClaimDetails => ({ + ...source, + status: toClaimStatus(result.status), + withdrawTimeMs: result.withdrawTimeMs, + claimableAt: result.claimableAt ?? getArbitrumClaimableAt(result.withdrawTimeMs), + event: result.event, + message: result.message, + }), + // Only a pending withdraw changes on its own. A claimable one changes when claimed, which the claim + // flow tracks, and claims made elsewhere are caught on load or when the claim modal opens + refetchInterval: (latestData: Query) => { + if (props?.isPolling === false) return false + + const data = latestData?.state?.data + if (!data) return UNCONFIRMED_POLL_MS + if (data.status !== ChildToParentMessageStatus.UNCONFIRMED) return false + + // Until its assertion posts only the countdown can improve, after that nothing changes before it opens + if (data.claimableAt === undefined) return ASSERTION_RETRY_MS + return Math.max(data.claimableAt - Date.now(), UNCONFIRMED_POLL_MS) }, - // Periodically refetch until the status is known to be ChildToParentMessageStatus.EXECUTED - refetchInterval: (latestData: Query) => - latestData?.state?.data?.status === ChildToParentMessageStatus.EXECUTED ? false : 60_000, enabled: !skip, staleTime: Infinity, gcTime: Infinity, } }), + combine: combineClaims, }) - const claims = useMemo( - () => - claimStatuses.reduce((acc, { data }) => { - if (!data) return acc - if (!data.tx.transfers.length) return acc - if (data.tx.data?.parser !== 'arbitrumBridge') return acc - if (!data.tx.data.value) return acc - if (!data.tx.data.destinationAddress) return acc - if (!data.tx.data.destinationAssetId) return acc - - acc.push({ - status: data.status, - tx: data.tx, - accountId: toAccountId({ - chainId: arbitrumChainId, - account: data.tx.pubkey, - }), - amountCryptoBaseUnit: data.tx.data.value, - destinationAddress: data.tx.data.destinationAddress, - destinationAssetId: data.tx.data.destinationAssetId, - destinationChainId: ethChainId, - assetId: data.tx.transfers[0].assetId, - claimableAt: - data.assertionClaimableAt ?? - getArbitrumClaimableAt(data.tx.blockTime * 1000, data.confirmationLagMs), - event: data.event, - message: data.message, - }) - return acc - }, []), - [claimStatuses], - ) - - const claimsByTxid = useMemo( - () => - claims.reduce>((acc, claim) => { - acc[claim.tx.txid] = claim - return acc - }, {}), - [claims], - ) - - // Changes only when a claim's status or estimate does, for effects keyed on the claims - const claimsKey = claims - .map(claim => `${claim.tx.txid}:${claim.status}:${claim.claimableAt}`) - .join() - return { claims, claimsByTxid, - claimsKey, } } diff --git a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx index 4facbc4494a..1fd00b723ad 100644 --- a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx +++ b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx @@ -10,12 +10,20 @@ import { useNotificationToast } from '@/hooks/useNotificationToast' import { useWallet } from '@/hooks/useWallet/useWallet' import { getThorchainTransactionStatus } from '@/lib/utils/thorchain' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' -import { ActionStatus, ActionType, isTcyClaimAction } from '@/state/slices/actionSlice/types' +import { selectPendingTcyClaimActions } from '@/state/slices/actionSlice/selectors' +import { + ActionStatus, + ActionType, + isClaimStatusRegression, + isTcyClaimAction, +} from '@/state/slices/actionSlice/types' import { preferences } from '@/state/slices/preferencesSlice/preferencesSlice' import { useAppDispatch, useAppSelector } from '@/state/store' const TCY_TOAST_ID = 'tcyClaimAlert' +const selectClaimTxStatuses = (results: { data?: TxStatus }[]) => results.map(({ data }) => data) + export const useTcyClaimActionSubscriber = () => { const dispatch = useAppDispatch() const queryClient = useQueryClient() @@ -45,8 +53,7 @@ export const useTcyClaimActionSubscriber = () => { if ( maybeStoreAction && isTcyClaimAction(maybeStoreAction) && - (maybeStoreAction.status === ActionStatus.Pending || - maybeStoreAction.status === ActionStatus.Claimed) + isClaimStatusRegression(maybeStoreAction.status, ActionStatus.ClaimAvailable) ) return @@ -105,15 +112,10 @@ export const useTcyClaimActionSubscriber = () => { ]) // Resolves sent claims even after leaving the claim status page, sharing its status query + const pendingTcyClaimActions = useAppSelector(selectPendingTcyClaimActions) const pendingClaimActions = useMemo( - () => - Object.values(actions) - .filter(isTcyClaimAction) - .filter( - action => - action.status === ActionStatus.Pending && Boolean(action.tcyClaimActionMetadata.txHash), - ), - [actions], + () => pendingTcyClaimActions.filter(action => Boolean(action.tcyClaimActionMetadata.txHash)), + [pendingTcyClaimActions], ) const claimTxStatuses = useQueries({ @@ -126,13 +128,12 @@ export const useTcyClaimActionSubscriber = () => { refetchInterval: 10_000, } }), + combine: selectClaimTxStatuses, }) - const claimTxStatusKey = claimTxStatuses.map(({ data }) => data).join() - useEffect(() => { pendingClaimActions.forEach((action, i) => { - switch (claimTxStatuses[i]?.data) { + switch (claimTxStatuses[i]) { case TxStatus.Confirmed: dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.Claimed })) queryClient.invalidateQueries({ @@ -152,7 +153,5 @@ export const useTcyClaimActionSubscriber = () => { return } }) - // claimTxStatuses is recreated on every render, use its statuses for a stable reference - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, pendingClaimActions, claimTxStatusKey, queryClient]) + }, [dispatch, pendingClaimActions, claimTxStatuses, queryClient]) } diff --git a/src/state/slices/actionSlice/selectors.test.ts b/src/state/slices/actionSlice/selectors.test.ts index 203749a9a61..b245b7249b3 100644 --- a/src/state/slices/actionSlice/selectors.test.ts +++ b/src/state/slices/actionSlice/selectors.test.ts @@ -236,7 +236,7 @@ describe('selectWalletClaimActions', () => { it('lists claims newest first by the same timestamps as the feed', () => { const oldReady = claimAction('old-ready', ActionStatus.ClaimAvailable, 100, 100) const newReady = arbitrumWithdraw('new-ready', ActionStatus.ClaimAvailable, 200, 200) - const claiming = arbitrumWithdraw('claiming', ActionStatus.ClaimAvailable, 50, 60, '0xclaim') + const claiming = arbitrumWithdraw('claiming', ActionStatus.Pending, 50, 60, '0xclaim') const pending = arbitrumWithdraw('pending', ActionStatus.Initiated, 300, 1_000) const swap = action('swap', ActionStatus.Pending, 500, 500) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index bf2fd6c9b28..540215f0203 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -296,18 +296,6 @@ export const selectArbitrumBridgeWithdrawActionById = createDeepEqualOutputSelec }, ) -export const selectArbitrumBridgeWithdrawActionByWithdrawTxHash = createDeepEqualOutputSelector( - actionSlice.selectors.selectActionsById, - (_state: any, withdrawTxHash: string) => withdrawTxHash, - (actionsById, withdrawTxHash) => { - return Object.values(actionsById).find( - action => - isArbitrumBridgeWithdrawAction(action) && - action.arbitrumBridgeMetadata?.withdrawTxHash === withdrawTxHash, - ) - }, -) - export const selectPendingArbitrumBridgeWithdrawActions = createDeepEqualOutputSelector( selectWalletActions, actions => { diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index e8f1c211124..38545924611 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -326,6 +326,19 @@ const CLAIM_ACTION_TYPES = new Set([ ActionType.ArbitrumBridgeWithdraw, ]) +export const isClaimAction = (action: Action): boolean => CLAIM_ACTION_TYPES.has(action.type) + // Claims stay in the claims tab until they settle export const isClaimSectionAction = (action: Action): boolean => - CLAIM_ACTION_TYPES.has(action.type) && isActiveActionStatus(action.status) + isClaimAction(action) && isActiveActionStatus(action.status) + +// Every claim moves forward through these, only a failed or dropped claim tx steps back to claimable +const CLAIM_STATUS_ORDER: Partial> = { + [ActionStatus.Initiated]: 0, + [ActionStatus.ClaimAvailable]: 1, + [ActionStatus.Pending]: 2, + [ActionStatus.Claimed]: 3, +} + +export const isClaimStatusRegression = (from: ActionStatus, to: ActionStatus): boolean => + (CLAIM_STATUS_ORDER[to] ?? 0) < (CLAIM_STATUS_ORDER[from] ?? 0) From 10a3fdcbfebc3ffc1afa4e3e4c4a2f0ef8572765 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:22:05 -0600 Subject: [PATCH 18/40] fix(rfox): key unstaking requests by their unstake event, claim at the current index - request ids are the Unstake event's account, contract, cooldown expiry and amount, the index moves as other requests are claimed - the claim confirm resolves the request's current index from a fresh read before building the withdraw - clear stored rfox claim actions with the other claim actions, the subscriber recreates the claimable ones Co-Authored-By: Claude Opus 5.5 --- .../RFOX/components/Claim/ClaimConfirm.tsx | 28 ++++++++++++++++--- .../useGetUnstakingRequestsQuery/utils.ts | 8 ++---- .../hooks/useRfoxClaimActionSubscriber.tsx | 2 +- src/state/migrations/clearClaimActions.ts | 1 + 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/pages/RFOX/components/Claim/ClaimConfirm.tsx b/src/pages/RFOX/components/Claim/ClaimConfirm.tsx index 91769f8692f..7ae954482a6 100644 --- a/src/pages/RFOX/components/Claim/ClaimConfirm.tsx +++ b/src/pages/RFOX/components/Claim/ClaimConfirm.tsx @@ -15,7 +15,7 @@ import { CONTRACT_INTERACTION } from '@shapeshiftoss/chain-adapters' import { RFOX_ABI } from '@shapeshiftoss/contracts' import { isTrezor } from '@shapeshiftoss/hdwallet-trezor' import { BigAmount } from '@shapeshiftoss/utils' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import type { FC } from 'react' import { useCallback, useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -23,6 +23,10 @@ import { useNavigate } from 'react-router-dom' import { encodeFunctionData } from 'viem' import type { UnstakingRequest } from '../../hooks/useGetUnstakingRequestsQuery/utils' +import { + getUnstakingRequestsQueryFn, + getUnstakingRequestsQueryKey, +} from '../../hooks/useGetUnstakingRequestsQuery/utils' import { useRFOXContext } from '../../hooks/useRfoxContext' import type { ClaimRouteProps } from './types' @@ -138,13 +142,29 @@ export const ClaimConfirm: FC< [claimAssetMarketDataUserCurrency?.price, stakingAmountCryptoPrecision], ) + // Claims reorder the contract's requests, so the selected request is claimed at its current index + const { data: claimIndex } = useQuery({ + queryKey: getUnstakingRequestsQueryKey({ + stakingAssetAccountId: selectedUnstakingRequest.stakingAssetAccountId, + stakingAssetId: selectedUnstakingRequest.stakingAssetId, + }), + queryFn: getUnstakingRequestsQueryFn({ + stakingAssetAccountId: selectedUnstakingRequest.stakingAssetAccountId, + stakingAssetId: selectedUnstakingRequest.stakingAssetId, + }), + select: data => + data.unstakingRequests.find(request => request.id === selectedUnstakingRequest.id)?.index, + }) + const callData = useMemo(() => { + if (claimIndex === undefined) return + return encodeFunctionData({ abi: RFOX_ABI, functionName: 'withdraw', - args: [BigInt(selectedUnstakingRequest.index)], + args: [BigInt(claimIndex)], }) - }, [selectedUnstakingRequest.index]) + }, [claimIndex]) const { mutateAsync: handleClaim, @@ -153,7 +173,7 @@ export const ClaimConfirm: FC< isSuccess: isClaimMutationSuccess, } = useMutation({ mutationFn: async () => { - if (!wallet || stakingAssetAccountNumber === undefined) return + if (!wallet || stakingAssetAccountNumber === undefined || !callData) return const adapter = assertGetEvmChainAdapter( fromAssetId(selectedUnstakingRequest.stakingAssetId).chainId, diff --git a/src/pages/RFOX/hooks/useGetUnstakingRequestsQuery/utils.ts b/src/pages/RFOX/hooks/useGetUnstakingRequestsQuery/utils.ts index e051816dac4..07fa2a4b7ea 100644 --- a/src/pages/RFOX/hooks/useGetUnstakingRequestsQuery/utils.ts +++ b/src/pages/RFOX/hooks/useGetUnstakingRequestsQuery/utils.ts @@ -102,12 +102,8 @@ export const getUnstakingRequestsQueryFn = ({ cooldownExpiry: result.cooldownExpiry.toString(), stakingAssetId, index, - // composite ID to ensure uniqueness of unstaking requests, to be used for lookups - // cooldownExpiry should be a unique enough index, since it's based on blockTime, and users are *not* going to be able to make - // two unstaking requests in one block. But for the sake of paranoia, we make it a composite ID with index too - // Which itself *is* unique at any given time, though as users unstake/claim, it may change due to the inner workings of solidity, as - // indexes can reorg - id: `${index}-${result.cooldownExpiry}-${contractAddress}`, + // The request's Unstake event, the index moves as other requests are claimed + id: `${stakingAssetAccountId}-${contractAddress}-${result.cooldownExpiry}-${amountCryptoBaseUnit}`, stakingAssetAccountId, } }) diff --git a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx index 882142efef6..46c1429e721 100644 --- a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx +++ b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx @@ -110,7 +110,7 @@ export const useRfoxClaimActionSubscriber = () => { ) }) - // Ids carry the request's index, which claims reorder, so a missing request was claimed or moved + // A claimable request missing from its account's fresh requests was claimed elsewhere Object.values(actions) .filter(isRfoxClaimAction) .filter(action => action.status === ActionStatus.ClaimAvailable) diff --git a/src/state/migrations/clearClaimActions.ts b/src/state/migrations/clearClaimActions.ts index 0b7f4efb5be..f57beaa4122 100644 --- a/src/state/migrations/clearClaimActions.ts +++ b/src/state/migrations/clearClaimActions.ts @@ -6,6 +6,7 @@ import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' const clearClaimAction = (action: Action): Action | undefined => { switch (action.type) { case ActionType.ArbitrumBridgeWithdraw: + case ActionType.RfoxClaim: case ActionType.TcyClaim: return case ActionType.Swap: From 644bc4993ec0dab8db0d61c2ea835762788a6bd1 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:47:07 -0600 Subject: [PATCH 19/40] fix(arbitrum-bridge): claim to the withdraw's destination, no account fallback Co-Authored-By: Claude Fable 5.1 --- .../arbitrumBridgeWithdrawAction.test.ts | 38 ++++++------------- .../arbitrumBridgeWithdrawAction.ts | 22 +++-------- .../useArbitrumWithdrawalActionSubscriber.tsx | 19 ++-------- 3 files changed, 21 insertions(+), 58 deletions(-) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts index b9816682412..4469eb9bf1e 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.test.ts @@ -13,7 +13,6 @@ import type { ClaimDetails } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' const destinationAddress = '0xAbCdEf0000000000000000000000000000000001' -const otherEthAccountId = 'eip155:1:0x0000000000000000000000000000000000000002' const destinationAccountId = `eip155:1:${destinationAddress.toLowerCase()}` const claim = { @@ -33,18 +32,18 @@ const pendingClaim = { ...claim, status: ActionStatus.Initiated } as ClaimDetail describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { it('keys the action by the withdraw tx so every path lands on the same action', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(pendingClaim, [destinationAccountId]) + const action = buildArbitrumBridgeWithdrawActionFromClaim(pendingClaim) - expect(action?.id).toBe(getArbitrumBridgeWithdrawActionId('0xwithdraw')) - expect(action?.type).toBe(ActionType.ArbitrumBridgeWithdraw) + expect(action.id).toBe(getArbitrumBridgeWithdrawActionId('0xwithdraw')) + expect(action.type).toBe(ActionType.ArbitrumBridgeWithdraw) }) it('maps a pending claim to an initiated action dated from the withdraw block', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(pendingClaim, [destinationAccountId]) + const action = buildArbitrumBridgeWithdrawActionFromClaim(pendingClaim) - expect(action?.status).toBe(ActionStatus.Initiated) - expect(action?.createdAt).toBe(1_700_000_000_000) - expect(action?.arbitrumBridgeMetadata).toMatchObject({ + expect(action.status).toBe(ActionStatus.Initiated) + expect(action.createdAt).toBe(1_700_000_000_000) + expect(action.arbitrumBridgeMetadata).toMatchObject({ withdrawTxHash: '0xwithdraw', amountCryptoBaseUnit: '1000', assetId: 'eip155:42161/slip44:60', @@ -56,28 +55,15 @@ describe('buildArbitrumBridgeWithdrawActionFromClaim', () => { }) it('maps an available claim to a claimable action', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, [destinationAccountId]) - - expect(action?.status).toBe(ActionStatus.ClaimAvailable) - }) + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim) - it('signs from the destination account when the wallet holds it, whatever its casing', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, [ - otherEthAccountId, - destinationAccountId, - ]) - - expect(action?.arbitrumBridgeMetadata.destinationAccountId).toBe(destinationAccountId) + expect(action.status).toBe(ActionStatus.ClaimAvailable) }) - it('falls back to any ethereum account since the outbox call is permissionless', () => { - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, [otherEthAccountId]) - - expect(action?.arbitrumBridgeMetadata.destinationAccountId).toBe(otherEthAccountId) - }) + it("claims to the withdraw's destination, whatever its casing", () => { + const action = buildArbitrumBridgeWithdrawActionFromClaim(claim) - it('builds nothing without an ethereum account to claim from', () => { - expect(buildArbitrumBridgeWithdrawActionFromClaim(claim, [])).toBeUndefined() + expect(action.arbitrumBridgeMetadata.destinationAccountId).toBe(destinationAccountId) }) }) diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 9e57ccb35bb..4b14575106e 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -1,5 +1,4 @@ -import type { AccountId } from '@shapeshiftoss/caip' -import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' +import { ethChainId, toAccountId } from '@shapeshiftoss/caip' import type { Swap } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' @@ -11,22 +10,13 @@ import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' export const getArbitrumBridgeWithdrawActionId = (withdrawTxHash: string): string => `arbitrum-bridge-withdraw-${withdrawTxHash}` -// The outbox call is permissionless, so any of our ethereum accounts can pay for the claim -const getClaimDestinationAccountId = ( - destinationAddress: string, - ethAccountIds: AccountId[], -): AccountId | undefined => - ethAccountIds.find( - accountId => - fromAccountId(accountId).account.toLowerCase() === destinationAddress.toLowerCase(), - ) ?? ethAccountIds[0] - export const buildArbitrumBridgeWithdrawActionFromClaim = ( claim: ClaimDetails, - ethAccountIds: AccountId[], -): ArbitrumBridgeWithdrawAction | undefined => { - const destinationAccountId = getClaimDestinationAccountId(claim.destinationAddress, ethAccountIds) - if (!destinationAccountId) return +): ArbitrumBridgeWithdrawAction => { + const destinationAccountId = toAccountId({ + chainId: claim.destinationChainId, + account: claim.destinationAddress, + }) const createdAt = claim.withdrawTimeMs diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index f2257285516..f2f1ecbe3fa 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -1,5 +1,5 @@ import { usePrevious } from '@chakra-ui/react' -import { ethChainId, fromAccountId } from '@shapeshiftoss/caip' +import { ethChainId } from '@shapeshiftoss/caip' import { assertGetViemClient } from '@shapeshiftoss/contracts' import { TxStatus } from '@shapeshiftoss/unchained-client' import { isSome } from '@shapeshiftoss/utils' @@ -23,7 +23,6 @@ import { isArbitrumBridgeWithdrawAction, isClaimStatusRegression, } from '@/state/slices/actionSlice/types' -import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { useAppDispatch, useAppSelector } from '@/state/store' // A node can briefly miss a fresh broadcast, so only a long-unknown claim counts as dropped @@ -53,16 +52,9 @@ const selectClaimTxStatuses = (results: { data?: TxStatus }[]) => results.map(({ export const useArbitrumWithdrawalActionSubscriber = () => { const dispatch = useAppDispatch() const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) - const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const { claims, claimsByTxid } = useArbitrumClaims() const translate = useTranslate() - const ethAccountIds = useMemo( - () => - enabledWalletAccountIds.filter(accountId => fromAccountId(accountId).chainId === ethChainId), - [enabledWalletAccountIds], - ) - const { isDrawerOpen, openActionCenterClaims } = useActionCenterContext() const toastOptions = useMemo(() => ({ duration: isDrawerOpen ? 5000 : null }), [isDrawerOpen]) const toast = useNotificationToast(toastOptions) @@ -95,18 +87,13 @@ export const useArbitrumWithdrawalActionSubscriber = () => { // Recover missing withdraw actions from tx history, e.g. after a wiped store or an outside withdrawal useEffect(() => { - if (!ethAccountIds.length) return - claims.forEach(claim => { if (claim.status === ActionStatus.Claimed) return if (actionsById[getArbitrumBridgeWithdrawActionId(claim.withdrawTxHash)]) return - const action = buildArbitrumBridgeWithdrawActionFromClaim(claim, ethAccountIds) - if (!action) return - - dispatch(actionSlice.actions.upsertAction(action)) + dispatch(actionSlice.actions.upsertAction(buildArbitrumBridgeWithdrawActionFromClaim(claim))) }) - }, [dispatch, ethAccountIds, actionsById, claims]) + }, [dispatch, actionsById, claims]) const pendingArbitrumBridgeActions = useMemo(() => { return ( From 2e0b2baebba8c4ca9ee368c578a5f2557dd06e1a Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:55:33 -0600 Subject: [PATCH 20/40] fix(tcy): delete claims made elsewhere, tell an empty claim read from a failed one - thornode answers an address without claims with a 400, which now reads as no claims, any other failure reads as unknown - a claimable action whose claim is missing from its account's fresh read is deleted, unknown reads are left alone Co-Authored-By: Claude Fable 5.1 --- src/pages/TCY/components/TCYCta.tsx | 2 +- src/pages/TCY/components/TCYNavIndicator.tsx | 2 +- .../TCY/hooks/useTcyClaimActionSubscriber.tsx | 25 +++++++++++++++- src/pages/TCY/queries/useTcyClaims.tsx | 30 +++++++++++++------ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/pages/TCY/components/TCYCta.tsx b/src/pages/TCY/components/TCYCta.tsx index ac28b649e16..31f9fdc81f8 100644 --- a/src/pages/TCY/components/TCYCta.tsx +++ b/src/pages/TCY/components/TCYCta.tsx @@ -45,7 +45,7 @@ export const TCYCta = () => { const translate = useTranslate() const claimsQuery = useTCYClaims('all') const [isClosed, setIsClosed] = useLocalStorage('TCY_CTA_CLOSED', false) - const hasClaims = useMemo(() => claimsQuery.some(query => query.data.length), [claimsQuery]) + const hasClaims = useMemo(() => claimsQuery.some(query => query.data?.length), [claimsQuery]) const handleClose = useCallback(() => { setIsClosed(true) diff --git a/src/pages/TCY/components/TCYNavIndicator.tsx b/src/pages/TCY/components/TCYNavIndicator.tsx index a1513dfff21..aef2c0eb59b 100644 --- a/src/pages/TCY/components/TCYNavIndicator.tsx +++ b/src/pages/TCY/components/TCYNavIndicator.tsx @@ -12,7 +12,7 @@ const top = { base: -1, '2xl': 'auto' } export const TCYNavIndicator = () => { const claimsQuery = useTCYClaims('all') - const hasClaims = useMemo(() => claimsQuery.some(query => query.data.length), [claimsQuery]) + const hasClaims = useMemo(() => claimsQuery.some(query => query.data?.length), [claimsQuery]) if (!hasClaims) return null diff --git a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx index 1fd00b723ad..8837d24a64a 100644 --- a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx +++ b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx @@ -17,6 +17,7 @@ import { isClaimStatusRegression, isTcyClaimAction, } from '@/state/slices/actionSlice/types' +import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { preferences } from '@/state/slices/preferencesSlice/preferencesSlice' import { useAppDispatch, useAppSelector } from '@/state/store' @@ -34,6 +35,7 @@ export const useTcyClaimActionSubscriber = () => { } = useWallet() const allTcyClaims = useTCYClaims('all') + const walletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const actions = useAppSelector(actionSlice.selectors.selectActionsById) const actionIds = useAppSelector(actionSlice.selectors.selectActionIds) const hasWalletSeenTcyClaimAlert = useAppSelector( @@ -44,7 +46,7 @@ export const useTcyClaimActionSubscriber = () => { if (!allTcyClaims.length) return const now = Date.now() - const allClaims = allTcyClaims.map(queryResult => queryResult.data).flat() + const allClaims = allTcyClaims.flatMap(queryResult => queryResult.data ?? []) allClaims.forEach(claim => { const maybeStoreAction = actions[claim.accountId] @@ -111,6 +113,27 @@ export const useTcyClaimActionSubscriber = () => { navigate, ]) + // A claimable account missing from its fresh read was claimed elsewhere, an unknown read is left alone + useEffect(() => { + allTcyClaims.forEach((queryResult, i) => { + if (!queryResult.data) return + + // Queries are keyed in wallet account order, see useTCYClaims('all') + const accountId = walletAccountIds[i] + const action = actions[accountId] + if (!action || !isTcyClaimAction(action)) return + if (action.status !== ActionStatus.ClaimAvailable) return + + const { asset, l1_address } = action.tcyClaimActionMetadata.claim + if (queryResult.data.some(claim => claim.asset === asset && claim.l1_address === l1_address)) + return + + dispatch(actionSlice.actions.deleteAction(action.id)) + }) + // Only react to fresh reads, not to our own deletes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allTcyClaims, walletAccountIds, dispatch]) + // Resolves sent claims even after leaving the claim status page, sharing its status query const pendingTcyClaimActions = useAppSelector(selectPendingTcyClaimActions) const pendingClaimActions = useMemo( diff --git a/src/pages/TCY/queries/useTcyClaims.tsx b/src/pages/TCY/queries/useTcyClaims.tsx index d1f1c0cbd42..556b850318c 100644 --- a/src/pages/TCY/queries/useTcyClaims.tsx +++ b/src/pages/TCY/queries/useTcyClaims.tsx @@ -4,7 +4,7 @@ import { isMetaMaskNativeMultichain } from '@shapeshiftoss/hdwallet-metamask-mul import { isRune, thorPoolAssetIdToAssetId } from '@shapeshiftoss/swapper' import { isUtxoChainId } from '@shapeshiftoss/utils' import { useSuspenseQueries } from '@tanstack/react-query' -import axios from 'axios' +import axios, { isAxiosError } from 'axios' import { useMemo } from 'react' import type { Claim, TcyClaimer } from '../components/Claim/types' @@ -24,6 +24,12 @@ import { } from '@/state/slices/selectors' import { store, useAppSelector } from '@/state/store' +// THORNode answers an address without claims with a 400 rather than an empty list +const isNoTcyClaimsError = (error: unknown): boolean => + isAxiosError(error) && + error.response?.status === 400 && + JSON.stringify(error.response.data).includes("doesn't have any tcy to claim") + export const useTCYClaims = (accountNumber: number | 'all') => { const { state: { isConnected, wallet, isLocked }, @@ -48,7 +54,7 @@ export const useTCYClaims = (accountNumber: number | 'all') => { return useSuspenseQueries({ queries: accountIds.map(accountId => ({ queryKey: ['tcy-claims', accountId, isConnected, isLocked, isSnapInstalled], - queryFn: async (): Promise => { + queryFn: async (): Promise => { if (!isConnected) return [] const activeAddresses = ( @@ -97,15 +103,20 @@ export const useTCYClaims = (accountNumber: number | 'all') => { try { const tcyClaimers = await Promise.all( - activeAddresses.map(address => - axios.get<{ tcy_claimer: TcyClaimer[] }>( - `${getConfig().VITE_THORCHAIN_NODE_URL}/thorchain/tcy_claimer/${address}`, - ), - ), + activeAddresses.map(async address => { + try { + const { data } = await axios.get<{ tcy_claimer: TcyClaimer[] }>( + `${getConfig().VITE_THORCHAIN_NODE_URL}/thorchain/tcy_claimer/${address}`, + ) + return data.tcy_claimer + } catch (error) { + if (isNoTcyClaimsError(error)) return [] + throw error + } + }), ) return tcyClaimers - .map(response => response.data.tcy_claimer) .flat() .filter(claimer => { const assetId = thorPoolAssetIdToAssetId(claimer.asset) @@ -140,7 +151,8 @@ export const useTCYClaims = (accountNumber: number | 'all') => { } }) } catch { - return [] + // Unknown rather than empty, so a failed read never reads as claimed elsewhere + return undefined } }, staleTime: 60_000, From ba85c4fa7ae4c42ad6825acd983367cd1d1e7b35 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:55:33 -0600 Subject: [PATCH 21/40] fix(arbitrum-bridge): a passed claim estimate reads soon, it lands within minutes Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 225918e215d..5c3e88ab024 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -3299,7 +3299,7 @@ "complete": "Your bridge of %{sellAmountAndSymbol}.%{sellChainShortName} to %{buyAmountAndSymbol}.%{buyChainShortName} is complete.", "initiated": "Your bridge of %{sellAmountAndSymbol} to %{buyAmountAndSymbol} has been initiated.", "pendingWithdraw": "Your withdraw of %{amountAndSymbol} will be available %{timeText}.", - "availableSoon": "within the hour", + "availableSoon": "soon", "claimAvailable": "Your bridge of %{amountAndSymbol} is available to claim.", "withdrawComplete": "Your bridge of %{amountAndSymbol} is complete.", "withdrawProcessing": "Your bridge of %{amountAndSymbol} is being processed.", From 15986013469d78e10913fb00757253a51303cc93 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:23:56 -0600 Subject: [PATCH 22/40] fix(action-center): address review, unknown tcy reads, stale rfox index, claim modal errors - selectArbitrumBridgeWithdrawActionById tolerates a missing action, the swap toast derives ids that often have none - tcy claim reads return null when unknown, a tanstack queryFn can't return undefined and every skipped path is unknown too, results carry their accountId - a claimed tcy action makes way for the account's next claim - the rfox claim confirm always refetches the request index on mount - a claim that can't be built or signed throws, the modal stays open with the error and closes on broadcast - withdraw action ids and hashes are lowercase, matching tx history - narrow the claim event type, drop the unreachable classic withdraw guard, one-line comments Co-Authored-By: Claude Fable 5.1 --- .../components/ArbitrumBridgeClaimModal.tsx | 10 +++---- .../arbitrumBridgeWithdrawAction.ts | 9 ++++-- .../useSwapActionSubscriber.tsx | 5 ++-- .../useArbitrumClaims/useArbitrumClaimTx.tsx | 15 +++++----- .../useArbitrumClaims/useArbitrumClaims.tsx | 8 ++--- .../RFOX/components/Claim/ClaimConfirm.tsx | 1 + .../TCY/hooks/useTcyClaimActionSubscriber.tsx | 26 ++++++++-------- src/pages/TCY/queries/useTcyClaims.tsx | 30 ++++++++++++------- src/state/slices/actionSlice/selectors.ts | 2 +- 9 files changed, 57 insertions(+), 49 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 47ab526cf00..1040fb8b66f 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -135,8 +135,9 @@ export const ArbitrumBridgeClaimModal = ({ arbitrumBridgeMetadata: { ...latestAction.arbitrumBridgeMetadata, claimTxHash }, }), ) + onClose() }, - [dispatch, action.id], + [dispatch, action.id, onClose], ) const claimTxResult = useArbitrumClaimTx(claimDetails, destinationAccountId, handleClaimBroadcast) @@ -159,11 +160,8 @@ export const ArbitrumBridgeClaimModal = ({ ) }, [destinationFeeAsset, destinationFeeAssetBalanceCryptoPrecision, evmFeesResult?.data]) - const onConfirm = useCallback(async () => { - if (!claimMutation) return - await claimMutation.mutateAsync() - onClose() - }, [claimMutation, onClose]) + // A failed broadcast keeps the modal open with the error copy on the button + const onConfirm = useCallback(() => claimMutation?.mutate(), [claimMutation]) const confirmCopy = useMemo(() => { if (executeTransactionDataResult?.isError) return translate('bridge.claimTxDataFailed') diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 4b14575106e..349065e7143 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -7,8 +7,9 @@ import { getArbitrumClaimableAt } from '@/hooks/useArbitrumClaims/useArbitrumCla import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' +// Tx history keys withdraws by their lowercase txid, wallets don't promise a casing export const getArbitrumBridgeWithdrawActionId = (withdrawTxHash: string): string => - `arbitrum-bridge-withdraw-${withdrawTxHash}` + `arbitrum-bridge-withdraw-${withdrawTxHash.toLowerCase()}` export const buildArbitrumBridgeWithdrawActionFromClaim = ( claim: ClaimDetails, @@ -51,14 +52,16 @@ export const buildArbitrumBridgeWithdrawActionFromSwap = ( if (!isArbitrumBridgeWithdrawSwap(swap)) return if (!swap.sellTxHash || !swap.buyAccountId) return + const withdrawTxHash = swap.sellTxHash.toLowerCase() + return { - id: getArbitrumBridgeWithdrawActionId(swap.sellTxHash), + id: getArbitrumBridgeWithdrawActionId(withdrawTxHash), type: ActionType.ArbitrumBridgeWithdraw, status: ActionStatus.Initiated, createdAt: withdrawTimeMs, updatedAt: withdrawTimeMs, arbitrumBridgeMetadata: { - withdrawTxHash: swap.sellTxHash, + withdrawTxHash, amountCryptoBaseUnit: swap.sellAmountCryptoBaseUnit, assetId: swap.sellAsset.assetId, destinationAssetId: swap.buyAsset.assetId, diff --git a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx index 8029aa47061..52017b445d3 100644 --- a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx @@ -276,10 +276,9 @@ export const useSwapActionSubscriber = () => { }), ) - // Tx history only recovers withdraws this missed, e.g. after a wiped store. Not awaited so the - // balance refetches below aren't held up by the withdraw block lookup + // Not awaited, the withdraw block lookup must not hold up the balance refetches below if (isArbitrumBridgeWithdrawSwap(swap)) { - void fetchArbitrumClaimMessage(swap.sellTxHash) + void fetchArbitrumClaimMessage(swap.sellTxHash.toLowerCase()) .then(({ withdrawTimeMs }) => withdrawTimeMs) .catch(() => Date.now()) .then(withdrawTimeMs => { diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx index 2e4d6bb48e7..6bdf6e5231b 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx @@ -45,8 +45,6 @@ export const useArbitrumClaimTx = ( const proof = (await message.getOutboxProof(l2Provider)) as Hex[] - if (!('position' in event)) return - // nitro transaction return encodeFunctionData({ abi: ARB_OUTBOX_ABI, functionName: 'executeTransaction', @@ -78,11 +76,12 @@ export const useArbitrumClaimTx = ( const claimMutation = useMutation({ mutationKey: ['claim', { txid: claim?.withdrawTxHash }], mutationFn: async () => { - if (!claim) return - if (!wallet) return - if (!bip44Params) return - if (!executeTransactionDataResult.data) return - if (!destinationAccountId) return + if (!claim || !destinationAccountId || !executeTransactionDataResult.data) { + throw new Error('Claim not ready') + } + if (!wallet || !bip44Params) { + throw new Error(`Wallet has no account for ${destinationAccountId}`) + } const adapter = assertGetEvmChainAdapter(claim.destinationChainId) @@ -105,7 +104,7 @@ export const useArbitrumClaimTx = ( return txHash }, onSuccess(txHash) { - if (txHash) onClaimBroadcast(txHash) + onClaimBroadcast(txHash) }, onSettled() { queryClient.invalidateQueries({ diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx index 54ad630df19..f6536e42981 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx @@ -92,8 +92,7 @@ const getConfirmedChildBlock = async ( return { assertionHash: latestConfirmed, number: block.number } } -// The claim opens a confirm period after the assertion covering the withdraw posts, timed by the -// parent chain's block rate over the last confirm period +// Claimable one confirm period, at the parent chain's recent block rate, after the covering assertion posts const findClaimableAt = async ( parentBlockNumber: number, childBlockNumber: number, @@ -171,7 +170,7 @@ export const fetchArbitrumClaimMessage = (txid: string): Promise = }) type ClaimStatusResult = { - event: ChildToParentTransactionEvent + event: ClaimMessage['event'] message: ChildToParentMessageReader withdrawTimeMs: number status: ChildToParentMessageStatus @@ -355,8 +354,7 @@ export const useArbitrumClaims = (props?: { skip?: boolean; isPolling?: boolean event: result.event, message: result.message, }), - // Only a pending withdraw changes on its own. A claimable one changes when claimed, which the claim - // flow tracks, and claims made elsewhere are caught on load or when the claim modal opens + // Only a pending withdraw changes on its own, claims made elsewhere are caught on load or in the modal refetchInterval: (latestData: Query) => { if (props?.isPolling === false) return false diff --git a/src/pages/RFOX/components/Claim/ClaimConfirm.tsx b/src/pages/RFOX/components/Claim/ClaimConfirm.tsx index 7ae954482a6..dc9ec6b1293 100644 --- a/src/pages/RFOX/components/Claim/ClaimConfirm.tsx +++ b/src/pages/RFOX/components/Claim/ClaimConfirm.tsx @@ -154,6 +154,7 @@ export const ClaimConfirm: FC< }), select: data => data.unstakingRequests.find(request => request.id === selectedUnstakingRequest.id)?.index, + refetchOnMount: 'always', }) const callData = useMemo(() => { diff --git a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx index 8837d24a64a..44cd38289e1 100644 --- a/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx +++ b/src/pages/TCY/hooks/useTcyClaimActionSubscriber.tsx @@ -17,7 +17,6 @@ import { isClaimStatusRegression, isTcyClaimAction, } from '@/state/slices/actionSlice/types' -import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { preferences } from '@/state/slices/preferencesSlice/preferencesSlice' import { useAppDispatch, useAppSelector } from '@/state/store' @@ -35,7 +34,6 @@ export const useTcyClaimActionSubscriber = () => { } = useWallet() const allTcyClaims = useTCYClaims('all') - const walletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const actions = useAppSelector(actionSlice.selectors.selectActionsById) const actionIds = useAppSelector(actionSlice.selectors.selectActionIds) const hasWalletSeenTcyClaimAlert = useAppSelector( @@ -113,26 +111,30 @@ export const useTcyClaimActionSubscriber = () => { navigate, ]) - // A claimable account missing from its fresh read was claimed elsewhere, an unknown read is left alone + // A claim missing from its account's fresh read was claimed elsewhere, an unknown read is left alone useEffect(() => { - allTcyClaims.forEach((queryResult, i) => { - if (!queryResult.data) return + allTcyClaims.forEach(({ data, accountId }) => { + if (!data) return - // Queries are keyed in wallet account order, see useTCYClaims('all') - const accountId = walletAccountIds[i] const action = actions[accountId] if (!action || !isTcyClaimAction(action)) return - if (action.status !== ActionStatus.ClaimAvailable) return const { asset, l1_address } = action.tcyClaimActionMetadata.claim - if (queryResult.data.some(claim => claim.asset === asset && claim.l1_address === l1_address)) - return + const isStoredClaimPresent = data.some( + claim => claim.asset === asset && claim.l1_address === l1_address, + ) + + // One action per account, so a claimed one makes way for the account's next claim + const isStale = + action.status === ActionStatus.ClaimAvailable + ? !isStoredClaimPresent + : action.status === ActionStatus.Claimed && !isStoredClaimPresent && data.length > 0 - dispatch(actionSlice.actions.deleteAction(action.id)) + if (isStale) dispatch(actionSlice.actions.deleteAction(action.id)) }) // Only react to fresh reads, not to our own deletes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allTcyClaims, walletAccountIds, dispatch]) + }, [allTcyClaims, dispatch]) // Resolves sent claims even after leaving the claim status page, sharing its status query const pendingTcyClaimActions = useAppSelector(selectPendingTcyClaimActions) diff --git a/src/pages/TCY/queries/useTcyClaims.tsx b/src/pages/TCY/queries/useTcyClaims.tsx index 556b850318c..25396f23082 100644 --- a/src/pages/TCY/queries/useTcyClaims.tsx +++ b/src/pages/TCY/queries/useTcyClaims.tsx @@ -3,9 +3,10 @@ import { isMetaMask } from '@shapeshiftoss/hdwallet-core/wallet' import { isMetaMaskNativeMultichain } from '@shapeshiftoss/hdwallet-metamask-multichain' import { isRune, thorPoolAssetIdToAssetId } from '@shapeshiftoss/swapper' import { isUtxoChainId } from '@shapeshiftoss/utils' +import type { UseSuspenseQueryResult } from '@tanstack/react-query' import { useSuspenseQueries } from '@tanstack/react-query' import axios, { isAxiosError } from 'axios' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import type { Claim, TcyClaimer } from '../components/Claim/types' @@ -51,11 +52,18 @@ export const useTCYClaims = (accountNumber: number | 'all') => { [accountNumber, allAccountIds, accountIdsByAccountNumberAndChainId], ) + // null is an unknown read, as opposed to an account without claims + const combine = useCallback( + (results: UseSuspenseQueryResult[]) => + results.map((result, i) => ({ ...result, accountId: accountIds[i] })), + [accountIds], + ) + return useSuspenseQueries({ queries: accountIds.map(accountId => ({ queryKey: ['tcy-claims', accountId, isConnected, isLocked, isSnapInstalled], - queryFn: async (): Promise => { - if (!isConnected) return [] + queryFn: async (): Promise => { + if (!isConnected) return null const activeAddresses = ( await (() => { @@ -78,13 +86,13 @@ export const useTCYClaims = (accountNumber: number | 'all') => { !isSnapInstalled && !isMetaMaskNativeMultichain(wallet) ) - return [] + return null const accountMetadata = selectPortfolioAccountMetadataByAccountId(store.getState(), { accountId, }) - if (!accountMetadata) return [] - if (!wallet) return [] + if (!accountMetadata) return null + if (!wallet) return null // Introspects THORChain savers to get the active address for a given xpub AccountId // Defaults to 0 if none found @@ -97,13 +105,13 @@ export const useTCYClaims = (accountNumber: number | 'all') => { wallet, }) })() - ).filter(isSome) + ) - if (!activeAddresses) return [] + if (!activeAddresses) return null try { const tcyClaimers = await Promise.all( - activeAddresses.map(async address => { + activeAddresses.filter(isSome).map(async address => { try { const { data } = await axios.get<{ tcy_claimer: TcyClaimer[] }>( `${getConfig().VITE_THORCHAIN_NODE_URL}/thorchain/tcy_claimer/${address}`, @@ -151,11 +159,11 @@ export const useTCYClaims = (accountNumber: number | 'all') => { } }) } catch { - // Unknown rather than empty, so a failed read never reads as claimed elsewhere - return undefined + return null } }, staleTime: 60_000, })), + combine, }) } diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 540215f0203..64d7e3b8bc4 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -292,7 +292,7 @@ export const selectArbitrumBridgeWithdrawActionById = createDeepEqualOutputSelec (_state: any, actionId: string) => actionId, (actionsById, actionId) => { const action = actionsById[actionId] - return isArbitrumBridgeWithdrawAction(action) ? action : undefined + return action && isArbitrumBridgeWithdrawAction(action) ? action : undefined }, ) From 8a9ac70f8dd2e62a9a2adf519cdc890c37e1fef6 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:47:10 -0600 Subject: [PATCH 23/40] perf(arbitrum-bridge): drop the unused withdraw ordering, memoize withdraw sources on what they read - selectArbitrumWithdrawTxs sorted by txIds.indexOf for a claims tab that no longer exists, it now returns a deep-equal array from the wallet's arbitrum tx ids - withdraw sources and the subscriber read the wallet's pending withdraw actions instead of every action Co-Authored-By: Claude Fable 5.1 --- .../useArbitrumWithdrawalActionSubscriber.tsx | 31 ++++-------- .../useArbitrumClaims/useArbitrumClaims.tsx | 24 ++++----- src/state/slices/txHistorySlice/selectors.ts | 49 +++++++++---------- 3 files changed, 44 insertions(+), 60 deletions(-) diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index f2f1ecbe3fa..9ebb636a3da 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -18,11 +18,8 @@ import { import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' import { useArbitrumClaims } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' -import { - ActionStatus, - isArbitrumBridgeWithdrawAction, - isClaimStatusRegression, -} from '@/state/slices/actionSlice/types' +import { ActionStatus, isClaimStatusRegression } from '@/state/slices/actionSlice/types' +import { selectPendingArbitrumBridgeWithdrawActions } from '@/state/slices/selectors' import { useAppDispatch, useAppSelector } from '@/state/store' // A node can briefly miss a fresh broadcast, so only a long-unknown claim counts as dropped @@ -95,14 +92,8 @@ export const useArbitrumWithdrawalActionSubscriber = () => { }) }, [dispatch, actionsById, claims]) - const pendingArbitrumBridgeActions = useMemo(() => { - return ( - Object.values(actionsById) - .filter(isArbitrumBridgeWithdrawAction) - // Claimed is final, i.e see this bad boi https://github.com/shapeshift/web/pull/10556 - .filter(action => action.status !== ActionStatus.Claimed) - ) - }, [actionsById]) + // Claimed is final, i.e see this bad boi https://github.com/shapeshift/web/pull/10556 + const pendingArbitrumBridgeActions = useAppSelector(selectPendingArbitrumBridgeWithdrawActions) useEffect(() => { try { @@ -157,14 +148,12 @@ export const useArbitrumWithdrawalActionSubscriber = () => { // Resolves in-flight claims, including ones broadcast before a reload const claimingActions = useMemo( () => - Object.values(actionsById) - .filter(isArbitrumBridgeWithdrawAction) - .filter( - action => - action.status === ActionStatus.Pending && - Boolean(action.arbitrumBridgeMetadata.claimTxHash), - ), - [actionsById], + pendingArbitrumBridgeActions.filter( + action => + action.status === ActionStatus.Pending && + Boolean(action.arbitrumBridgeMetadata.claimTxHash), + ), + [pendingArbitrumBridgeActions], ) const claimTxStatuses = useQueries({ diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx index f6536e42981..d8fc363d127 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx @@ -25,10 +25,13 @@ import { useMemo } from 'react' import { queryClient } from '@/context/QueryClientProvider/queryClient' import { useWallet } from '@/hooks/useWallet/useWallet' import { assertUnreachable, isSome } from '@/lib/utils' -import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' -import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' -import { selectArbitrumWithdrawTxs, selectPortfolioLoadingStatus } from '@/state/slices/selectors' +import { ActionStatus } from '@/state/slices/actionSlice/types' +import { + selectArbitrumWithdrawTxs, + selectPendingArbitrumBridgeWithdrawActions, + selectPortfolioLoadingStatus, +} from '@/state/slices/selectors' import type { Tx } from '@/state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from '@/state/store' @@ -245,7 +248,7 @@ const combineClaims = (results: { data?: ClaimDetails }[]) => { // Pollers keep the claims fresh, observers like the claim modal read the shared cache without extra timers export const useArbitrumClaims = (props?: { skip?: boolean; isPolling?: boolean }) => { const arbitrumWithdrawTxs = useAppSelector(selectArbitrumWithdrawTxs) - const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) + const pendingWithdrawActions = useAppSelector(selectPendingArbitrumBridgeWithdrawActions) const portfolioLoadingStatus = useAppSelector(selectPortfolioLoadingStatus) // Stored withdraws are tracked even when tx history isn't loaded, history adds the ones we missed @@ -260,16 +263,13 @@ export const useArbitrumClaims = (props?: { skip?: boolean; isPolling?: boolean if (source) sources.set(source.withdrawTxHash, source) }) - Object.values(actionsById) - .filter(isArbitrumBridgeWithdrawAction) - .filter(action => action.status !== ActionStatus.Claimed) - .forEach(action => { - const source = getActionWithdrawSource(action) - if (!sources.has(source.withdrawTxHash)) sources.set(source.withdrawTxHash, source) - }) + pendingWithdrawActions.forEach(action => { + const source = getActionWithdrawSource(action) + if (!sources.has(source.withdrawTxHash)) sources.set(source.withdrawTxHash, source) + }) return [...sources.values()] - }, [arbitrumWithdrawTxs, actionsById]) + }, [arbitrumWithdrawTxs, pendingWithdrawActions]) const { state: { isLoadingLocalWallet, modal, isConnected }, diff --git a/src/state/slices/txHistorySlice/selectors.ts b/src/state/slices/txHistorySlice/selectors.ts index d7558e5b31b..35294ac390f 100644 --- a/src/state/slices/txHistorySlice/selectors.ts +++ b/src/state/slices/txHistorySlice/selectors.ts @@ -107,39 +107,34 @@ const selectWalletTxIdsByAccountIdAssetId = createSelector( pickBy(txsByAccountIdAssetId, (_, accountId) => accountIds.includes(accountId)), ) -export const selectArbitrumWithdrawTxs = createSelector( - selectTxIds, - selectTxs, +// A withdraw is indexed under every asset it touched, this only changes when a tx id is added +const selectWalletArbitrumTxIds = createDeepEqualOutputSelector( selectWalletTxIdsByAccountIdAssetId, - (txIds, txs, data): Tx[] => { - const arbitrumData = pickBy( - data, - (_, accountId) => fromAccountId(accountId).chainId === arbitrumChainId, - ) - - const arbitrumTxIds = values(arbitrumData) - .flatMap(data => values(data).flat()) - .filter(isSome) - - const sortedArbitrumTxIds = uniq(arbitrumTxIds).sort( - (a, b) => txIds.indexOf(a) - txIds.indexOf(b), - ) - - return sortedArbitrumTxIds.reduce((prev, txid) => { - const tx = txs[txid] + (data): TxId[] => { + const txIds = new Set() - if ( - tx.data?.parser === 'arbitrumBridge' && - ['outboundTransfer', 'withdrawEth'].includes(tx.data?.method ?? '') - ) { - prev.push(tx) - } + Object.entries(data).forEach(([accountId, byAssetId]) => { + if (fromAccountId(accountId).chainId !== arbitrumChainId) return + values(byAssetId).flat().filter(isSome).forEach(txId => txIds.add(txId)) + }) - return prev - }, []) + return [...txIds] }, ) +export const selectArbitrumWithdrawTxs = createDeepEqualOutputSelector( + selectWalletArbitrumTxIds, + selectTxs, + (txIds, txs): Tx[] => + txIds + .map(txId => txs[txId]) + .filter( + tx => + tx?.data?.parser === 'arbitrumBridge' && + ['outboundTransfer', 'withdrawEth'].includes(tx.data.method ?? ''), + ), +) + export const selectTxIdsByFilter = createCachedSelector( selectTxIds, selectTxs, From 88a035faf8af7c5330f1899330ac8127dcd9d54f Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:49:39 -0600 Subject: [PATCH 24/40] chore(arbitrum-bridge): say why the subscriber never rewrites an unchanged action Co-Authored-By: Claude Fable 5.1 --- .../useArbitrumWithdrawalActionSubscriber.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 9ebb636a3da..9bd44376be1 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -92,7 +92,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { }) }, [dispatch, actionsById, claims]) - // Claimed is final, i.e see this bad boi https://github.com/shapeshift/web/pull/10556 + // Claimed is final, nothing below reads or writes it again const pendingArbitrumBridgeActions = useAppSelector(selectPendingArbitrumBridgeWithdrawActions) useEffect(() => { @@ -113,7 +113,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { ? claim.claimableAt : action.arbitrumBridgeMetadata.claimableAt - // Only write on a real change, see https://github.com/shapeshift/web/pull/10556 + // Every upsert bumps updatedAt and re-fires this effect, an unchanged write loops it (#10556) const hasChanges = newStatus !== action.status || claimableAt !== action.arbitrumBridgeMetadata.claimableAt From 81d19caf336ea9389445b8bc3b4203b015666178 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:52:31 -0600 Subject: [PATCH 25/40] chore(dev): keep window.store the store, the middleware replaced it with the state on every dispatch Co-Authored-By: Claude Fable 5.1 --- src/state/store.ts | 4 +--- src/state/windowMiddleware.ts | 8 -------- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 src/state/windowMiddleware.ts diff --git a/src/state/store.ts b/src/state/store.ts index 01dacf26f89..8cbb2a1da10 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -20,7 +20,6 @@ import { opportunitiesApi } from './slices/opportunitiesSlice/opportunitiesApiSl import { portfolioApi } from './slices/portfolioSlice/portfolioSlice' import { txHistoryApi } from './slices/txHistorySlice/txHistorySlice' import { createSubscriptionMiddleware } from './subscriptionMiddleware' -import { updateWindowStoreMiddleware } from './windowMiddleware' import { getConfig } from '@/config' import { selectAssetById } from '@/state/slices/assetsSlice/selectors' @@ -134,8 +133,7 @@ export const createStore = () => }, }) .concat(apiMiddleware) - .concat(subscriptionMiddleware.middleware) - .concat(getConfig().VITE_REDUX_WINDOW ? [updateWindowStoreMiddleware] : []), + .concat(subscriptionMiddleware.middleware), devTools: { actionSanitizer, stateSanitizer, diff --git a/src/state/windowMiddleware.ts b/src/state/windowMiddleware.ts deleted file mode 100644 index aeab0eb956b..00000000000 --- a/src/state/windowMiddleware.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Middleware } from '@reduxjs/toolkit' - -// developer QoL: update window.store with the latest state -export const updateWindowStoreMiddleware: Middleware = storeAPI => next => action => { - const result = next(action) - if (window) window.store = storeAPI.getState() - return result -} From 711bcde94218ba2d7509fe883b118f44f8f43efa Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:00:10 -0600 Subject: [PATCH 26/40] feat(arbitrum-bridge): toast a confirmed claim, its card moves from claims to recent Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 1 + .../useArbitrumWithdrawalActionSubscriber.tsx | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 5c3e88ab024..7656848bbe5 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -885,6 +885,7 @@ "confirmAndClaim": "Confirm & Claim", "claimTxDataFailed": "Couldn't prepare the claim transaction", "bridgeWithdrawalReadyNotification": "Bridge withdrawal ready to claim", + "bridgeWithdrawalClaimedNotification": "Bridge withdrawal claimed", "checkActionCenterNotification": "Check Action Center for details" }, "trade": { diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 9bd44376be1..668957b8dcd 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -52,7 +52,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { const { claims, claimsByTxid } = useArbitrumClaims() const translate = useTranslate() - const { isDrawerOpen, openActionCenterClaims } = useActionCenterContext() + const { isDrawerOpen, openActionCenter, openActionCenterClaims } = useActionCenterContext() const toastOptions = useMemo(() => ({ duration: isDrawerOpen ? 5000 : null }), [isDrawerOpen]) const toast = useNotificationToast(toastOptions) const previousIsDrawerOpen = usePrevious(isDrawerOpen) @@ -82,6 +82,27 @@ export const useArbitrumWithdrawalActionSubscriber = () => { [openActionCenterClaims, toast, translate], ) + // The claimed card leaves the claims tab for recent, say where it went + const notifyClaimed = useCallback( + (actionId: string) => { + const toastId = `${actionId}-claimed` + if (toast.isActive(toastId)) return + + toast({ + id: toastId, + status: 'success', + title: translate('bridge.bridgeWithdrawalClaimedNotification'), + description: translate('bridge.checkActionCenterNotification'), + position: 'bottom-right', + onClick: () => { + toast.close(toastId) + openActionCenter() + }, + }) + }, + [openActionCenter, toast, translate], + ) + // Recover missing withdraw actions from tx history, e.g. after a wiped store or an outside withdrawal useEffect(() => { claims.forEach(claim => { @@ -175,6 +196,7 @@ export const useArbitrumWithdrawalActionSubscriber = () => { switch (claimTxStatuses[i]) { case TxStatus.Confirmed: dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.Claimed })) + notifyClaimed(action.id) return case TxStatus.Failed: dispatch( @@ -189,5 +211,5 @@ export const useArbitrumWithdrawalActionSubscriber = () => { return } }) - }, [dispatch, claimingActions, claimTxStatuses]) + }, [dispatch, notifyClaimed, claimingActions, claimTxStatuses]) } From 0b76e38d62480b00e5ea93209fc47e78a5da2d63 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:00:28 -0600 Subject: [PATCH 27/40] style(tcy): drop the redundant parens around the active address lookup Co-Authored-By: Claude Fable 5.1 --- src/pages/TCY/queries/useTcyClaims.tsx | 76 ++++++++++++-------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/src/pages/TCY/queries/useTcyClaims.tsx b/src/pages/TCY/queries/useTcyClaims.tsx index 25396f23082..542035964f8 100644 --- a/src/pages/TCY/queries/useTcyClaims.tsx +++ b/src/pages/TCY/queries/useTcyClaims.tsx @@ -65,47 +65,41 @@ export const useTCYClaims = (accountNumber: number | 'all') => { queryFn: async (): Promise => { if (!isConnected) return null - const activeAddresses = ( - await (() => { - const chainId = fromAccountId(accountId).chainId - const assetId = getChainAdapterManager().get(chainId)?.getFeeAssetId() - if (!assetId) return [] - if (!isSupportedThorchainSaversAssetId(assetId)) return [] - if (isRune(assetId)) return [] - - // UTXO-based chains are the odd ones, for all address-based, we can simply use the `account` caip-10 part - if (!isUtxoChainId(fromAccountId(accountId).chainId)) - return [fromAccountId(accountId).account] - - const isMetaMaskMultichainWallet = isMetaMask(wallet) - - // Metamask snap might be uninstalled but UTXO accounts not cleared yet because of reactivity - // Native multichain wallets don't use snaps, so skip this guard for them - if ( - isMetaMaskMultichainWallet && - !isSnapInstalled && - !isMetaMaskNativeMultichain(wallet) - ) - return null - - const accountMetadata = selectPortfolioAccountMetadataByAccountId(store.getState(), { - accountId, - }) - if (!accountMetadata) return null - if (!wallet) return null - - // Introspects THORChain savers to get the active address for a given xpub AccountId - // Defaults to 0 if none found - // We do not duplicate this for LP and Lending, as those users should all be on a 0th account_index, only savers is the exception - // as some users may have historical non-zero account_index active address - return getThorfiUtxoFromAddresses({ - accountId, - assetId, - accountMetadata, - wallet, - }) - })() - ) + const activeAddresses = await (() => { + const chainId = fromAccountId(accountId).chainId + const assetId = getChainAdapterManager().get(chainId)?.getFeeAssetId() + if (!assetId) return [] + if (!isSupportedThorchainSaversAssetId(assetId)) return [] + if (isRune(assetId)) return [] + + // UTXO-based chains are the odd ones, for all address-based, we can simply use the `account` caip-10 part + if (!isUtxoChainId(fromAccountId(accountId).chainId)) + return [fromAccountId(accountId).account] + + const isMetaMaskMultichainWallet = isMetaMask(wallet) + + // Metamask snap might be uninstalled but UTXO accounts not cleared yet because of reactivity + // Native multichain wallets don't use snaps, so skip this guard for them + if (isMetaMaskMultichainWallet && !isSnapInstalled && !isMetaMaskNativeMultichain(wallet)) + return null + + const accountMetadata = selectPortfolioAccountMetadataByAccountId(store.getState(), { + accountId, + }) + if (!accountMetadata) return null + if (!wallet) return null + + // Introspects THORChain savers to get the active address for a given xpub AccountId + // Defaults to 0 if none found + // We do not duplicate this for LP and Lending, as those users should all be on a 0th account_index, only savers is the exception + // as some users may have historical non-zero account_index active address + return getThorfiUtxoFromAddresses({ + accountId, + assetId, + accountMetadata, + wallet, + }) + })() if (!activeAddresses) return null From 1c21dd670a7f35b33e98b5337706e8c3783cef32 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:03:31 -0600 Subject: [PATCH 28/40] feat(arbitrum-bridge): toast the withdraw card itself when a claim opens or confirms - ArbitrumBridgeWithdrawNotification renders the card's icon and wording from the store, like every other action toast - the ready toast opens the claims tab, the claimed toast opens recent where the card went - the card and the toasts share one message key per status, the plain-text toast strings are gone from every locale Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/de/main.json | 4 +- src/assets/translations/en/main.json | 5 +- src/assets/translations/es/main.json | 4 +- src/assets/translations/fr/main.json | 4 +- src/assets/translations/ja/main.json | 4 +- src/assets/translations/pt/main.json | 4 +- src/assets/translations/ru/main.json | 4 +- src/assets/translations/tr/main.json | 4 +- src/assets/translations/uk/main.json | 4 +- src/assets/translations/zh/main.json | 4 +- .../ArbitrumBridgeWithdrawActionCard.tsx | 15 ++-- .../ArbitrumBridgeWithdrawNotification.tsx | 86 +++++++++++++++++++ .../arbitrumBridgeWithdrawAction.ts | 14 +++ .../useArbitrumWithdrawalActionSubscriber.tsx | 60 +++++++------ 14 files changed, 144 insertions(+), 72 deletions(-) create mode 100644 src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index 19b49c03d3d..1995ef4c00c 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Adresse für den Erhalt des Anspruchs", - "confirmAndClaim": "Bestätigen und Beanspruchen", - "bridgeWithdrawalReadyNotification": "Wechsel Abhebung bereit zur Inanspruchnahme", - "checkActionCenterNotification": "Weitere Einzelheiten im Action Center" + "confirmAndClaim": "Bestätigen und Beanspruchen" }, "trade": { "trade": "Handeln", diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 7656848bbe5..1ae016d2d7e 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -883,10 +883,7 @@ }, "claimReceiveAddress": "Claim Receive Address", "confirmAndClaim": "Confirm & Claim", - "claimTxDataFailed": "Couldn't prepare the claim transaction", - "bridgeWithdrawalReadyNotification": "Bridge withdrawal ready to claim", - "bridgeWithdrawalClaimedNotification": "Bridge withdrawal claimed", - "checkActionCenterNotification": "Check Action Center for details" + "claimTxDataFailed": "Couldn't prepare the claim transaction" }, "trade": { "trade": "Trade", diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index b8ca78d9979..820740d5bb2 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Dirección para Reclamar", - "confirmAndClaim": "Confirmar y Reclamar", - "bridgeWithdrawalReadyNotification": "Retiro de Bridge listo para reclamar", - "checkActionCenterNotification": "Consulte el Centro de actividades para más detalles" + "confirmAndClaim": "Confirmar y Reclamar" }, "trade": { "trade": "Intercambiar", diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 786f23d06a0..d69e77b62b3 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Adresse de réception de réclamation", - "confirmAndClaim": "Confirmer et réclamer", - "bridgeWithdrawalReadyNotification": "Retrait du bridge prêt à être réclamé", - "checkActionCenterNotification": "Consultez le centre d'actions pour plus de détails" + "confirmAndClaim": "Confirmer et réclamer" }, "trade": { "trade": "Échanger", diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 88f5600dda2..28bf78a2228 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "請求の受取アドレス", - "confirmAndClaim": "確認と請求", - "bridgeWithdrawalReadyNotification": "ブリッジした資産の受け取り準備ができました", - "checkActionCenterNotification": "詳細についてはアクションセンターを確認してください" + "confirmAndClaim": "確認と請求" }, "trade": { "trade": "取引", diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index 7c03a7c2fed..73be46305c8 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Endereço de recebimento do resgate", - "confirmAndClaim": "Confirmar e Resgate", - "bridgeWithdrawalReadyNotification": "Saque da ponte pronto para resgate", - "checkActionCenterNotification": "Verifique o Centro de Ações para obter detalhes" + "confirmAndClaim": "Confirmar e Resgate" }, "trade": { "trade": "Swap", diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index 0bfd5776543..b33738e5c7c 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Адрес получения клейма", - "confirmAndClaim": "Подтвердить и заклеймить", - "bridgeWithdrawalReadyNotification": "Мост готов к выводу средств", - "checkActionCenterNotification": "Подробности см. в Центре действий." + "confirmAndClaim": "Подтвердить и заклеймить" }, "trade": { "trade": "Торговля", diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index ac72a72a48d..a51fe724b25 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Alıcı Adresi Talep Et", - "confirmAndClaim": "Onayla ve Talep Et", - "bridgeWithdrawalReadyNotification": "Köprü çekimi talep edilmeye hazır", - "checkActionCenterNotification": "Ayrıntılar için Eylem Merkezi'ni kontrol edin" + "confirmAndClaim": "Onayla ve Talep Et" }, "trade": { "trade": "Takas", diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index b1528b1392f..719d30b855c 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "Адреса для отримання клейму", - "confirmAndClaim": "Підтвердити та заявити", - "bridgeWithdrawalReadyNotification": "Виведення коштів з мосту готові до отримання", - "checkActionCenterNotification": "Детальнішу інформацію дивіться в Центрі дій." + "confirmAndClaim": "Підтвердити та заявити" }, "trade": { "trade": "Торгівля", diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index 3a41479bb49..d688b021d5e 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -882,9 +882,7 @@ } }, "claimReceiveAddress": "领取接收地址", - "confirmAndClaim": "确认并认领", - "bridgeWithdrawalReadyNotification": "链桥提现已可领取", - "checkActionCenterNotification": "进入操作中心以查看详情" + "confirmAndClaim": "确认并认领" }, "trade": { "trade": "交易", diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 98d261e319e..36d2abf9aca 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -11,6 +11,7 @@ import { ActionStatusTag } from './ActionStatusTag' import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { Row } from '@/components/Row/Row' +import { getArbitrumBridgeWithdrawMessageKey } from '@/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction' import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' import { getTxLink } from '@/lib/getTxLink' @@ -79,16 +80,10 @@ export const ArbitrumBridgeWithdrawActionCard = ({ const amountAndSymbol = `${buyAmountCryptoPrecision} ${buyAsset.symbol}` - switch (action.status) { - case ActionStatus.Initiated: - return translate('actionCenter.bridge.pendingWithdraw', { amountAndSymbol, timeText }) - case ActionStatus.ClaimAvailable: - return translate('actionCenter.bridge.claimAvailable', { amountAndSymbol }) - case ActionStatus.Claimed: - return translate('actionCenter.bridge.withdrawComplete', { amountAndSymbol }) - default: - return translate('actionCenter.bridge.withdrawProcessing', { amountAndSymbol }) - } + return translate(getArbitrumBridgeWithdrawMessageKey(action.status), { + amountAndSymbol, + timeText, + }) }, [action.status, buyAmountCryptoPrecision, buyAsset, sellAsset, timeText, translate]) const icon = useMemo(() => { diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx new file mode 100644 index 00000000000..815516db38e --- /dev/null +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx @@ -0,0 +1,86 @@ +import { Box } from '@chakra-ui/react' +import type { RenderProps } from '@chakra-ui/react/dist/types/toast/toast.types' +import { BigAmount } from '@shapeshiftoss/utils' +import { useMemo } from 'react' + +import { ActionIcon } from '../ActionIcon' + +import { Amount } from '@/components/Amount/Amount' +import { Text } from '@/components/Text' +import type { TextPropTypes } from '@/components/Text/Text' +import { StandardToast } from '@/components/Toast/StandardToast' +import { getArbitrumBridgeWithdrawMessageKey } from '@/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction' +import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { selectArbitrumBridgeWithdrawActionById, selectAssetById } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +export type ArbitrumBridgeWithdrawNotificationProps = { + handleClick: () => void + actionId: string +} & RenderProps + +export const ArbitrumBridgeWithdrawNotification = ({ + handleClick, + actionId, + onClose, +}: ArbitrumBridgeWithdrawNotificationProps) => { + const action = useAppSelector(state => selectArbitrumBridgeWithdrawActionById(state, actionId)) + const buyAsset = useAppSelector(state => + selectAssetById(state, action?.arbitrumBridgeMetadata.destinationAssetId ?? ''), + ) + const timeText = useArbitrumClaimTimeText(action?.arbitrumBridgeMetadata.claimableAt) + + const icon = useMemo(() => { + if (!action) return undefined + return + }, [action]) + + const translationComponents = useMemo((): TextPropTypes['components'] | undefined => { + if (!action || !buyAsset) return undefined + + const amountCryptoPrecision = bnOrZero( + BigAmount.fromBaseUnit({ + value: action.arbitrumBridgeMetadata.amountCryptoBaseUnit, + precision: buyAsset.precision, + }).toPrecision(), + ) + .decimalPlaces(8) + .toString() + + return { + amountAndSymbol: ( + + ), + timeText: ( + + {timeText} + + ), + } + }, [action, buyAsset, timeText]) + + const title = useMemo(() => { + if (!action || !translationComponents) return undefined + return ( + + ) + }, [action, translationComponents]) + + if (!icon || !title) return null + + return +} diff --git a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts index 349065e7143..49f78e6fdb8 100644 --- a/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts +++ b/src/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction.ts @@ -11,6 +11,20 @@ import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' export const getArbitrumBridgeWithdrawActionId = (withdrawTxHash: string): string => `arbitrum-bridge-withdraw-${withdrawTxHash.toLowerCase()}` +// Shared by the card and its toasts, pendingWithdraw also takes timeText +export const getArbitrumBridgeWithdrawMessageKey = (status: ActionStatus): string => { + switch (status) { + case ActionStatus.Initiated: + return 'actionCenter.bridge.pendingWithdraw' + case ActionStatus.ClaimAvailable: + return 'actionCenter.bridge.claimAvailable' + case ActionStatus.Claimed: + return 'actionCenter.bridge.withdrawComplete' + default: + return 'actionCenter.bridge.withdrawProcessing' + } +} + export const buildArbitrumBridgeWithdrawActionFromClaim = ( claim: ClaimDetails, ): ArbitrumBridgeWithdrawAction => { diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index 668957b8dcd..fb6ac7b858f 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -5,7 +5,6 @@ import { TxStatus } from '@shapeshiftoss/unchained-client' import { isSome } from '@shapeshiftoss/utils' import { useQueries } from '@tanstack/react-query' import { useCallback, useEffect, useMemo } from 'react' -import { useTranslate } from 'react-polyglot' import type { Hash } from 'viem' import { TransactionNotFoundError, TransactionReceiptNotFoundError } from 'viem' @@ -16,6 +15,7 @@ import { } from './arbitrumBridgeWithdrawAction' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' +import { ArbitrumBridgeWithdrawNotification } from '@/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification' import { useArbitrumClaims } from '@/hooks/useArbitrumClaims/useArbitrumClaims' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { ActionStatus, isClaimStatusRegression } from '@/state/slices/actionSlice/types' @@ -50,7 +50,6 @@ export const useArbitrumWithdrawalActionSubscriber = () => { const dispatch = useAppDispatch() const actionsById = useAppSelector(actionSlice.selectors.selectActionsById) const { claims, claimsByTxid } = useArbitrumClaims() - const translate = useTranslate() const { isDrawerOpen, openActionCenter, openActionCenterClaims } = useActionCenterContext() const toastOptions = useMemo(() => ({ duration: isDrawerOpen ? 5000 : null }), [isDrawerOpen]) @@ -63,44 +62,43 @@ export const useArbitrumWithdrawalActionSubscriber = () => { } }, [isDrawerOpen, toast, previousIsDrawerOpen]) - const notifyClaimAvailable = useCallback( - (actionId: string) => { - if (toast.isActive(actionId)) return + // The toast reads the action from the store, so it follows the card's wording and status + const notify = useCallback( + (actionId: string, toastId: string, openTab: () => void) => { + if (toast.isActive(toastId)) return toast({ - id: actionId, + id: toastId, status: 'success', - title: translate('bridge.bridgeWithdrawalReadyNotification'), - description: translate('bridge.checkActionCenterNotification'), - position: 'bottom-right', - onClick: () => { - toast.close(actionId) - openActionCenterClaims() + render: ({ onClose, ...props }) => { + const handleClick = () => { + onClose() + openTab() + } + + return ( + + ) }, }) }, - [openActionCenterClaims, toast, translate], + [toast], ) - // The claimed card leaves the claims tab for recent, say where it went - const notifyClaimed = useCallback( - (actionId: string) => { - const toastId = `${actionId}-claimed` - if (toast.isActive(toastId)) return + const notifyClaimAvailable = useCallback( + (actionId: string) => notify(actionId, actionId, openActionCenterClaims), + [notify, openActionCenterClaims], + ) - toast({ - id: toastId, - status: 'success', - title: translate('bridge.bridgeWithdrawalClaimedNotification'), - description: translate('bridge.checkActionCenterNotification'), - position: 'bottom-right', - onClick: () => { - toast.close(toastId) - openActionCenter() - }, - }) - }, - [openActionCenter, toast, translate], + // The claimed card leaves the claims tab for recent + const notifyClaimed = useCallback( + (actionId: string) => notify(actionId, `${actionId}-claimed`, openActionCenter), + [notify, openActionCenter], ) // Recover missing withdraw actions from tx history, e.g. after a wiped store or an outside withdrawal From 0cbeca97616e119cd94d9427e81545ad2bd23c96 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:35:45 -0600 Subject: [PATCH 29/40] fix(arbitrum-bridge): say when the destination wallet isn't connected, show a claimed withdraw instead of closing, bridge wording for deposit toasts - the claim modal names the destination address to connect instead of a gas balance error for an account the wallet doesn't hold - a withdraw claimed while the modal is open shows an already-claimed state with a close button rather than vanishing - deposit swap toasts use the bridge wording their cards already use Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 5 +- .../components/ArbitrumBridgeClaimModal.tsx | 94 ++++++++++++------- .../Notifications/SwapNotification.tsx | 20 ++-- 3 files changed, 78 insertions(+), 41 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 1ae016d2d7e..db4aac57fa8 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -883,7 +883,10 @@ }, "claimReceiveAddress": "Claim Receive Address", "confirmAndClaim": "Confirm & Claim", - "claimTxDataFailed": "Couldn't prepare the claim transaction" + "claimTxDataFailed": "Couldn't prepare the claim transaction", + "connectDestinationWallet": "Connect the wallet holding %{address} to claim", + "alreadyClaimed": "Already claimed", + "alreadyClaimedBody": "This withdraw has already been claimed." }, "trade": { "trade": "Trade", diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 1040fb8b66f..c48926a73f2 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -20,6 +20,7 @@ import { zeroAddress } from 'viem' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' import { Row } from '@/components/Row/Row' +import { Text } from '@/components/Text' import { useModalRegistration } from '@/context/ModalStackProvider' import { queryClient } from '@/context/QueryClientProvider/queryClient' import { useArbitrumClaims } from '@/hooks/useArbitrumClaims/useArbitrumClaims' @@ -29,6 +30,7 @@ import { middleEllipsis } from '@/lib/utils' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import type { ArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { ActionStatus } from '@/state/slices/actionSlice/types' +import { selectEnabledWalletAccountIds } from '@/state/slices/common-selectors' import { selectArbitrumBridgeWithdrawActionById, selectAssetById, @@ -55,6 +57,10 @@ export const ArbitrumBridgeClaimModal = ({ const isClaimAvailable = action.status === ActionStatus.ClaimAvailable const isClaimCompleted = action.status === ActionStatus.Claimed + // The claim is signed by the destination account, there is no fallback to another one + const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) + const isDestinationAccountConnected = enabledWalletAccountIds.includes(destinationAccountId) + const asset = useAppSelector(state => selectAssetById(state, action.arbitrumBridgeMetadata.assetId), ) @@ -164,6 +170,13 @@ export const ArbitrumBridgeClaimModal = ({ const onConfirm = useCallback(() => claimMutation?.mutate(), [claimMutation]) const confirmCopy = useMemo(() => { + if (isClaimCompleted) return translate('common.close') + + if (!isDestinationAccountConnected) + return translate('bridge.connectDestinationWallet', { + address: middleEllipsis(fromAccountId(destinationAccountId).account), + }) + if (executeTransactionDataResult?.isError) return translate('bridge.claimTxDataFailed') if (claimMutation?.isError) return translate('trade.errors.title') @@ -178,6 +191,9 @@ export const ArbitrumBridgeClaimModal = ({ return translate('bridge.confirmAndClaim') }, [ + isClaimCompleted, + isDestinationAccountConnected, + destinationAccountId, claimMutation, destinationFeeAsset, evmFeesResult?.isError, @@ -186,12 +202,6 @@ export const ArbitrumBridgeClaimModal = ({ translate, ]) - useEffect(() => { - if (!isClaimCompleted) return - - onClose() - }, [isClaimCompleted, onClose]) - const { modalProps, overlayProps, modalContentProps } = useModalRegistration({ isOpen, onClose, @@ -208,7 +218,9 @@ export const ArbitrumBridgeClaimModal = ({ - {translate('common.confirm')} + + {translate(isClaimCompleted ? 'bridge.alreadyClaimed' : 'common.confirm')} + @@ -231,20 +243,29 @@ export const ArbitrumBridgeClaimModal = ({ - - {translate('common.gasFee')} - - - - - - + {isClaimCompleted ? ( + + ) : ( + + {translate('common.gasFee')} + + + + + + + )} @@ -253,27 +274,32 @@ export const ArbitrumBridgeClaimModal = ({ width='full' size='lg' colorScheme={ - !hasEnoughDestinationFeeBalance || - executeTransactionDataResult?.isError || - claimMutation?.isError || - evmFeesResult?.isError + !isClaimCompleted && + (!isDestinationAccountConnected || + !hasEnoughDestinationFeeBalance || + executeTransactionDataResult?.isError || + claimMutation?.isError || + evmFeesResult?.isError) ? 'red' : 'blue' } isDisabled={ - !evmFeesResult?.isSuccess || - evmFeesResult?.isPending || - claimMutation?.isPending || - !hasEnoughDestinationFeeBalance + !isClaimCompleted && + (!isDestinationAccountConnected || + !hasEnoughDestinationFeeBalance || + !evmFeesResult?.isSuccess || + evmFeesResult?.isPending || + claimMutation?.isPending) } isLoading={ // A missing claim resolves on the next status poll - !claimDetails || - executeTransactionDataResult?.isFetching || - evmFeesResult?.isFetching || - claimMutation?.isPending + !isClaimCompleted && + (!claimDetails || + executeTransactionDataResult?.isFetching || + evmFeesResult?.isFetching || + claimMutation?.isPending) } - onClick={onConfirm} + onClick={isClaimCompleted ? onClose : onConfirm} > {confirmCopy} diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index 87f7e0b776f..eb9115ac712 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -127,15 +127,23 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat const { status } = action - if (swap.swapperName === SwapperName.ArbitrumBridge && swap.buyAsset.chainId === ethChainId) { - if (status === ActionStatus.Complete) return 'actionCenter.bridge.initiated' + if (swap.swapperName === SwapperName.ArbitrumBridge) { if (status === ActionStatus.Failed) return 'actionCenter.bridge.failed' - if (status === ActionStatus.Initiated) { - return isArbitrumBridgeWithdrawAction(action) - ? 'actionCenter.bridge.pendingWithdraw' - : 'actionCenter.bridge.initiated' + + // A withdraw is initiated once its tx lands, the claim is what completes it + if (swap.buyAsset.chainId === ethChainId) { + if (status === ActionStatus.Complete) return 'actionCenter.bridge.initiated' + if (status === ActionStatus.Initiated) { + return isArbitrumBridgeWithdrawAction(action) + ? 'actionCenter.bridge.pendingWithdraw' + : 'actionCenter.bridge.initiated' + } + + return 'actionCenter.bridge.processing' } + if (status === ActionStatus.Complete) return 'actionCenter.bridge.complete' + return 'actionCenter.bridge.processing' } From bb40bc2eae9940feaa71d3908600fa464f157ce1 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:38:52 -0600 Subject: [PATCH 30/40] fix(action-center): skip action ids with no action instead of crashing every subscriber Co-Authored-By: Claude Fable 5.1 --- src/state/slices/actionSlice/selectors.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 64d7e3b8bc4..14d210946bf 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -30,6 +30,7 @@ import { isThorchainLpAction, } from './types' +import { isSome } from '@/lib/utils' import { createDeepEqualOutputSelector } from '@/state/selector-utils' import { selectCowSwapQuoteIdParamFromRequiredFilter, @@ -39,7 +40,8 @@ import { export const selectActions = createDeepEqualOutputSelector( actionSlice.selectors.selectActionsById, actionSlice.selectors.selectActionIds, - (actionsById, actionIds) => actionIds.map(id => actionsById[id]), + // An id without an action, e.g. a persisted null, must not take every subscriber down + (actionsById, actionIds) => actionIds.map(id => actionsById[id]).filter(isSome), ) export const selectWalletActions = createDeepEqualOutputSelector( From b73f3460af2fdda51461b196d4e71e6e60c3890d Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:42:11 -0600 Subject: [PATCH 31/40] fix(arbitrum-bridge): any connected ethereum account pays for the claim, the destination stays the withdraw's Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 1 - .../components/ArbitrumBridgeClaimModal.tsx | 30 +++++++++---------- .../useArbitrumClaims/useArbitrumClaimTx.tsx | 14 ++++----- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index db4aac57fa8..dd78a2c0d07 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -884,7 +884,6 @@ "claimReceiveAddress": "Claim Receive Address", "confirmAndClaim": "Confirm & Claim", "claimTxDataFailed": "Couldn't prepare the claim transaction", - "connectDestinationWallet": "Connect the wallet holding %{address} to claim", "alreadyClaimed": "Already claimed", "alreadyClaimedBody": "This withdraw has already been claimed." }, diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index c48926a73f2..da6a0f27c96 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -57,9 +57,16 @@ export const ArbitrumBridgeClaimModal = ({ const isClaimAvailable = action.status === ActionStatus.ClaimAvailable const isClaimCompleted = action.status === ActionStatus.Claimed - // The claim is signed by the destination account, there is no fallback to another one + // The outbox call is permissionless, so the destination account pays when the wallet holds it, + // otherwise its first ethereum account does, the funds land at the destination either way const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) - const isDestinationAccountConnected = enabledWalletAccountIds.includes(destinationAccountId) + const claimAccountId = useMemo(() => { + if (enabledWalletAccountIds.includes(destinationAccountId)) return destinationAccountId + + return enabledWalletAccountIds.find( + accountId => fromAccountId(accountId).chainId === fromAccountId(destinationAccountId).chainId, + ) + }, [enabledWalletAccountIds, destinationAccountId]) const asset = useAppSelector(state => selectAssetById(state, action.arbitrumBridgeMetadata.assetId), @@ -97,10 +104,10 @@ export const ArbitrumBridgeClaimModal = ({ const destinationFeeAssetBalanceFilter = useMemo( () => ({ - accountId: destinationAccountId, + accountId: claimAccountId, assetId: destinationFeeAsset?.assetId, }), - [destinationAccountId, destinationFeeAsset], + [claimAccountId, destinationFeeAsset], ) const destinationFeeAssetBalanceCryptoPrecision = useAppSelector(state => @@ -146,7 +153,7 @@ export const ArbitrumBridgeClaimModal = ({ [dispatch, action.id, onClose], ) - const claimTxResult = useArbitrumClaimTx(claimDetails, destinationAccountId, handleClaimBroadcast) + const claimTxResult = useArbitrumClaimTx(claimDetails, claimAccountId, handleClaimBroadcast) const executeTransactionDataResult = claimTxResult?.executeTransactionDataResult @@ -172,11 +179,6 @@ export const ArbitrumBridgeClaimModal = ({ const confirmCopy = useMemo(() => { if (isClaimCompleted) return translate('common.close') - if (!isDestinationAccountConnected) - return translate('bridge.connectDestinationWallet', { - address: middleEllipsis(fromAccountId(destinationAccountId).account), - }) - if (executeTransactionDataResult?.isError) return translate('bridge.claimTxDataFailed') if (claimMutation?.isError) return translate('trade.errors.title') @@ -192,8 +194,6 @@ export const ArbitrumBridgeClaimModal = ({ return translate('bridge.confirmAndClaim') }, [ isClaimCompleted, - isDestinationAccountConnected, - destinationAccountId, claimMutation, destinationFeeAsset, evmFeesResult?.isError, @@ -275,8 +275,7 @@ export const ArbitrumBridgeClaimModal = ({ size='lg' colorScheme={ !isClaimCompleted && - (!isDestinationAccountConnected || - !hasEnoughDestinationFeeBalance || + (!hasEnoughDestinationFeeBalance || executeTransactionDataResult?.isError || claimMutation?.isError || evmFeesResult?.isError) @@ -285,8 +284,7 @@ export const ArbitrumBridgeClaimModal = ({ } isDisabled={ !isClaimCompleted && - (!isDestinationAccountConnected || - !hasEnoughDestinationFeeBalance || + (!hasEnoughDestinationFeeBalance || !evmFeesResult?.isSuccess || evmFeesResult?.isPending || claimMutation?.isPending) diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx index 6bdf6e5231b..96545869647 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaimTx.tsx @@ -23,7 +23,7 @@ import { useAppSelector } from '@/state/store' export const useArbitrumClaimTx = ( claim: ClaimDetails | undefined, - destinationAccountId: AccountId | undefined, + claimAccountId: AccountId | undefined, onClaimBroadcast: (claimTxHash: string) => void, ) => { const wallet = useWallet().state.wallet @@ -32,8 +32,8 @@ export const useArbitrumClaimTx = ( const l2Provider = getEthersV5Provider(KnownChainIds.ArbitrumMainnet) const accountIdFilter = useMemo(() => { - return { accountId: destinationAccountId } - }, [destinationAccountId]) + return { accountId: claimAccountId } + }, [claimAccountId]) const bip44Params = useAppSelector(state => selectBip44ParamsByAccountId(state, accountIdFilter)) @@ -65,7 +65,7 @@ export const useArbitrumClaimTx = ( }) const evmFeesResult = useEvmFees({ - from: destinationAccountId ? fromAccountId(destinationAccountId).account : undefined, + from: claimAccountId ? fromAccountId(claimAccountId).account : undefined, chainId: claim?.destinationChainId, data: executeTransactionDataResult.data, refetchInterval: 15_000, @@ -76,18 +76,18 @@ export const useArbitrumClaimTx = ( const claimMutation = useMutation({ mutationKey: ['claim', { txid: claim?.withdrawTxHash }], mutationFn: async () => { - if (!claim || !destinationAccountId || !executeTransactionDataResult.data) { + if (!claim || !claimAccountId || !executeTransactionDataResult.data) { throw new Error('Claim not ready') } if (!wallet || !bip44Params) { - throw new Error(`Wallet has no account for ${destinationAccountId}`) + throw new Error(`Wallet has no account for ${claimAccountId}`) } const adapter = assertGetEvmChainAdapter(claim.destinationChainId) const buildCustomTxInput = await createBuildCustomTxInput({ accountNumber: bip44Params.accountNumber, - from: fromAccountId(destinationAccountId).account, + from: fromAccountId(claimAccountId).account, adapter, data: executeTransactionDataResult.data, to: arbitrumNetwork.ethBridge.outbox, From aadd39aa5384a26200f952a312c5f33adec946d4 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:44:41 -0600 Subject: [PATCH 32/40] fix(arbitrum-bridge): the account that withdrew pays for the claim, then the first ethereum account Co-Authored-By: Claude Fable 5.1 --- .../components/ArbitrumBridgeClaimModal.tsx | 17 +++++++++++------ .../useClaimTimeText.ts} | 0 2 files changed, 11 insertions(+), 6 deletions(-) rename src/hooks/{useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts => useClaimTimeText/useClaimTimeText.ts} (100%) diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index da6a0f27c96..7d32b38c33d 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -57,16 +57,21 @@ export const ArbitrumBridgeClaimModal = ({ const isClaimAvailable = action.status === ActionStatus.ClaimAvailable const isClaimCompleted = action.status === ActionStatus.Claimed - // The outbox call is permissionless, so the destination account pays when the wallet holds it, - // otherwise its first ethereum account does, the funds land at the destination either way + // The outbox call is permissionless, the account that withdrew pays for the claim when the wallet + // holds it on ethereum, otherwise its first ethereum account does. Funds land at the destination either way const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const claimAccountId = useMemo(() => { - if (enabledWalletAccountIds.includes(destinationAccountId)) return destinationAccountId + const { chainId } = fromAccountId(destinationAccountId) + const withdrawAddress = fromAccountId(action.arbitrumBridgeMetadata.accountId).account + const ethAccountIds = enabledWalletAccountIds.filter( + accountId => fromAccountId(accountId).chainId === chainId, + ) - return enabledWalletAccountIds.find( - accountId => fromAccountId(accountId).chainId === fromAccountId(destinationAccountId).chainId, + return ( + ethAccountIds.find(accountId => fromAccountId(accountId).account === withdrawAddress) ?? + ethAccountIds[0] ) - }, [enabledWalletAccountIds, destinationAccountId]) + }, [enabledWalletAccountIds, destinationAccountId, action.arbitrumBridgeMetadata.accountId]) const asset = useAppSelector(state => selectAssetById(state, action.arbitrumBridgeMetadata.assetId), diff --git a/src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts b/src/hooks/useClaimTimeText/useClaimTimeText.ts similarity index 100% rename from src/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText.ts rename to src/hooks/useClaimTimeText/useClaimTimeText.ts From e2764d722157d4f299d445ee9b98af8ba954daa8 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:44:41 -0600 Subject: [PATCH 33/40] feat(rfox): track a cooling unstake as a pending claim, like a bridge withdraw - the subscriber creates an initiated claim for every request still cooling down, dated from the unstake, and moves it to claimable when the cooldown ends, rechecking at the next expiry or daily - the rfox claim card renders the countdown with the shared useClaimTimeText, renamed from the bridge-specific hook Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 3 +- .../ArbitrumBridgeWithdrawActionCard.tsx | 4 +- .../ArbitrumBridgeWithdrawNotification.tsx | 4 +- .../Notifications/SwapNotification.tsx | 4 +- .../components/RfoxClaimActionCard.tsx | 12 +++ .../useClaimTimeText/useClaimTimeText.ts | 4 +- .../hooks/useRfoxClaimActionSubscriber.tsx | 81 ++++++++++++++++--- 7 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index dd78a2c0d07..3f1a9598e6c 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -3289,6 +3289,7 @@ "complete": "Your claim of %{amount} %{symbol} is complete.", "failed": "Your claim of %{amount} %{symbol} has failed." }, + "availableSoon": "soon", "yield": { "unstakeAvailableIn": "Your %{symbol} unstake will be available in %{duration}.", "unstakeReady": "Your %{symbol} unstake is ready to claim.", @@ -3299,7 +3300,6 @@ "complete": "Your bridge of %{sellAmountAndSymbol}.%{sellChainShortName} to %{buyAmountAndSymbol}.%{buyChainShortName} is complete.", "initiated": "Your bridge of %{sellAmountAndSymbol} to %{buyAmountAndSymbol} has been initiated.", "pendingWithdraw": "Your withdraw of %{amountAndSymbol} will be available %{timeText}.", - "availableSoon": "soon", "claimAvailable": "Your bridge of %{amountAndSymbol} is available to claim.", "withdrawComplete": "Your bridge of %{amountAndSymbol} is complete.", "withdrawProcessing": "Your bridge of %{amountAndSymbol} is being processed.", @@ -3315,6 +3315,7 @@ "expired": "Your order to buy %{buyAmountAndSymbol} for %{sellAmountAndSymbol} expired." }, "rfox": { + "unstakePending": "Your unstake of %{amount} %{symbol} will be available %{timeText}.", "unstakeReady": "Your unstake of %{amount} %{symbol} is ready to claim.", "unstakeTxPending": "Your unstake of %{amount} %{symbol} is pending.", "unstakeTxComplete": "Your unstake of %{amount} %{symbol} is complete." diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx index 36d2abf9aca..cddefe73b96 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeWithdrawActionCard.tsx @@ -12,7 +12,7 @@ import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { Row } from '@/components/Row/Row' import { getArbitrumBridgeWithdrawMessageKey } from '@/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction' -import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' +import { useClaimTimeText } from '@/hooks/useClaimTimeText/useClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' import { getTxLink } from '@/lib/getTxLink' import { formatSmartDate } from '@/lib/utils/time' @@ -52,7 +52,7 @@ export const ArbitrumBridgeWithdrawActionCard = ({ ), ) - const timeText = useArbitrumClaimTimeText(action.arbitrumBridgeMetadata.claimableAt) + const timeText = useClaimTimeText(action.arbitrumBridgeMetadata.claimableAt) const formattedDate = useMemo(() => formatSmartDate(getActionTimestamp(action)), [action]) const { isOpen, onToggle } = useDisclosure() diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx index 815516db38e..b8b7c3453a8 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/ArbitrumBridgeWithdrawNotification.tsx @@ -10,7 +10,7 @@ import { Text } from '@/components/Text' import type { TextPropTypes } from '@/components/Text/Text' import { StandardToast } from '@/components/Toast/StandardToast' import { getArbitrumBridgeWithdrawMessageKey } from '@/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction' -import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' +import { useClaimTimeText } from '@/hooks/useClaimTimeText/useClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' import { selectArbitrumBridgeWithdrawActionById, selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -29,7 +29,7 @@ export const ArbitrumBridgeWithdrawNotification = ({ const buyAsset = useAppSelector(state => selectAssetById(state, action?.arbitrumBridgeMetadata.destinationAssetId ?? ''), ) - const timeText = useArbitrumClaimTimeText(action?.arbitrumBridgeMetadata.claimableAt) + const timeText = useClaimTimeText(action?.arbitrumBridgeMetadata.claimableAt) const icon = useMemo(() => { if (!action) return undefined diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index eb9115ac712..3bfc3362140 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -14,7 +14,7 @@ import type { TextPropTypes } from '@/components/Text/Text' import { StandardToast } from '@/components/Toast/StandardToast' import { getArbitrumBridgeWithdrawActionId } from '@/hooks/useActionCenterSubscribers/arbitrumBridgeWithdrawAction' import { useActualBuyAmountCryptoPrecision } from '@/hooks/useActualBuyAmountCryptoPrecision' -import { useArbitrumClaimTimeText } from '@/hooks/useArbitrumClaimTimeText/useArbitrumClaimTimeText' +import { useClaimTimeText } from '@/hooks/useClaimTimeText/useClaimTimeText' import { bnOrZero } from '@/lib/bignumber/bignumber' import { ActionStatus, isArbitrumBridgeWithdrawAction } from '@/state/slices/actionSlice/types' import { @@ -57,7 +57,7 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat // Bridge swaps show their withdraw action once history creates it, the swap action until then const action = maybeArbitrumBridgeAction ?? swapAction - const timeText = useArbitrumClaimTimeText( + const timeText = useClaimTimeText( maybeArbitrumBridgeAction?.arbitrumBridgeMetadata.claimableAt, ) diff --git a/src/components/Layout/Header/ActionCenter/components/RfoxClaimActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RfoxClaimActionCard.tsx index 6aa9605c197..7d356213238 100644 --- a/src/components/Layout/Header/ActionCenter/components/RfoxClaimActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RfoxClaimActionCard.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom' import { ClaimActionCard } from './ClaimActionCard' +import { useClaimTimeText } from '@/hooks/useClaimTimeText/useClaimTimeText' import { bn } from '@/lib/bignumber/bignumber' import type { RfoxClaimAction } from '@/state/slices/actionSlice/types' import { ActionStatus, GenericTransactionDisplayType } from '@/state/slices/actionSlice/types' @@ -25,6 +26,9 @@ export const RfoxClaimActionCard = ({ action }: RfoxClaimActionCardProps) => { const stakingAsset = useAppSelector(state => selectAssetById(state, action.rfoxClaimActionMetadata.request.stakingAssetId), ) + const timeText = useClaimTimeText( + Number(action.rfoxClaimActionMetadata.request.cooldownExpiry) * 1000, + ) const handleClaimClick = useCallback(() => { const index = action.rfoxClaimActionMetadata.request.index @@ -46,6 +50,13 @@ export const RfoxClaimActionCard = ({ action }: RfoxClaimActionCardProps) => { ).toFixed(2) switch (action.status) { + case ActionStatus.Initiated: { + return translate('actionCenter.rfox.unstakePending', { + amount: amountCryptoPrecision, + symbol: stakingAsset.symbol, + timeText, + }) + } case ActionStatus.ClaimAvailable: { return translate('actionCenter.rfox.unstakeReady', { amount: amountCryptoPrecision, @@ -71,6 +82,7 @@ export const RfoxClaimActionCard = ({ action }: RfoxClaimActionCardProps) => { action.rfoxClaimActionMetadata.request.amountCryptoPrecision, action.status, stakingAsset, + timeText, translate, ]) diff --git a/src/hooks/useClaimTimeText/useClaimTimeText.ts b/src/hooks/useClaimTimeText/useClaimTimeText.ts index e07a3ff3568..457908dfd68 100644 --- a/src/hooks/useClaimTimeText/useClaimTimeText.ts +++ b/src/hooks/useClaimTimeText/useClaimTimeText.ts @@ -5,7 +5,7 @@ import { formatSecondsToDuration } from '@/lib/utils/time' const TICK_INTERVAL_MS = 60_000 -export const useArbitrumClaimTimeText = (claimableAt: number | undefined): string => { +export const useClaimTimeText = (claimableAt: number | undefined): string => { const translate = useTranslate() const [now, setNow] = useState(Date.now) @@ -26,5 +26,5 @@ export const useArbitrumClaimTimeText = (claimableAt: number | undefined): strin return secondsUntilClaimable > 0 ? formatSecondsToDuration(secondsUntilClaimable, true) - : translate('actionCenter.bridge.availableSoon') + : translate('actionCenter.availableSoon') } diff --git a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx index 46c1429e721..dae631a4d95 100644 --- a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx +++ b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx @@ -1,13 +1,14 @@ import { fromAccountId } from '@shapeshiftoss/caip' +import { RFOX_ABI } from '@shapeshiftoss/contracts' import { TxStatus } from '@shapeshiftoss/unchained-client' import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useReadContracts } from 'wagmi' +import { getRfoxNetworkId, getStakingContract } from '../helpers' import { useGetUnstakingRequestsQuery } from './useGetUnstakingRequestsQuery' -import { - getUnstakingRequestsQueryKey, - isUnstakingRequestClaimable, -} from './useGetUnstakingRequestsQuery/utils' +import { getUnstakingRequestsQueryKey } from './useGetUnstakingRequestsQuery/utils' +import { supportedStakingAssetIds } from './useRfoxContext' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { selectPendingRfoxClaimActions } from '@/state/slices/actionSlice/selectors' @@ -81,6 +82,34 @@ export const useRfoxClaimActionSubscriber = () => { }) }, [txs, assets, pendingRfoxClaimActions, dispatch, queryClient]) + // Dates a request from its unstake, the period only changes by governance + const cooldownPeriods = useReadContracts({ + contracts: supportedStakingAssetIds.map( + stakingAssetId => + ({ + abi: RFOX_ABI, + address: getStakingContract(stakingAssetId), + functionName: 'cooldownPeriod', + chainId: getRfoxNetworkId(stakingAssetId), + }) as const, + ), + query: { staleTime: Infinity }, + }) + + const cooldownPeriodByStakingAssetId = useMemo( + () => + Object.fromEntries( + supportedStakingAssetIds.map((stakingAssetId, i) => [ + stakingAssetId, + cooldownPeriods.data?.[i]?.result, + ]), + ), + [cooldownPeriods.data], + ) + + // A cooldown outlives any timeout, so the next check is at most a day out + const [cooldownTick, setCooldownTick] = useState(0) + useEffect(() => { if (!allUnstakingRequests.isSuccess) return @@ -88,20 +117,29 @@ export const useRfoxClaimActionSubscriber = () => { const { all, byAccountId } = allUnstakingRequests.data all.forEach(request => { - if (!isUnstakingRequestClaimable(request, now)) return - - // Already available, being claimed, or claimed and waiting on a fresh read + const cooldownExpiryMs = Number(request.cooldownExpiry) * 1000 + const isClaimable = now >= cooldownExpiryMs const action = actions[request.id] - if (action && isRfoxClaimAction(action)) return + + // Only a cooled down request moves forward, the claim flow owns it from there + if (action && isRfoxClaimAction(action)) { + if (isClaimable && action.status === ActionStatus.Initiated) { + dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.ClaimAvailable })) + } + return + } if (!assets[request.stakingAssetId]) return + const cooldownPeriod = cooldownPeriodByStakingAssetId[request.stakingAssetId] + if (cooldownPeriod === undefined) return + dispatch( actionSlice.actions.upsertAction({ id: request.id, - status: ActionStatus.ClaimAvailable, + status: isClaimable ? ActionStatus.ClaimAvailable : ActionStatus.Initiated, type: ActionType.RfoxClaim, - createdAt: Number(request.cooldownExpiry) * 1000, + createdAt: cooldownExpiryMs - Number(cooldownPeriod) * 1000, updatedAt: now, rfoxClaimActionMetadata: { request, @@ -122,7 +160,26 @@ export const useRfoxClaimActionSubscriber = () => { dispatch(actionSlice.actions.deleteAction(action.id)) }) + + const nextCooldownExpiryMs = Math.min( + ...all.map(request => Number(request.cooldownExpiry) * 1000).filter(ms => ms > now), + ) + if (!Number.isFinite(nextCooldownExpiryMs)) return + + const timeout = setTimeout( + () => setCooldownTick(tick => tick + 1), + Math.min(nextCooldownExpiryMs - now, 24 * 60 * 60 * 1000), + ) + + return () => clearTimeout(timeout) // We definitely don't want to react on assets here // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allUnstakingRequests.data, allUnstakingRequests.isSuccess, dispatch, actionIds]) + }, [ + allUnstakingRequests.data, + allUnstakingRequests.isSuccess, + cooldownPeriodByStakingAssetId, + cooldownTick, + dispatch, + actionIds, + ]) } From fb4db3cdf7243589c4e846c6fbea215189acfa7e Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:53:02 -0600 Subject: [PATCH 34/40] fix(rfox): the unstake card is the tx record, the claim card carries the countdown - drop the claim withdraw countdown from the unstake card and say the unstake was initiated, the claims tab tracks the claim - an initiated rfox claim shows when it becomes claimable, and a claim card only collapses when it has details Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/de/main.json | 1 - src/assets/translations/en/main.json | 4 +-- src/assets/translations/es/main.json | 1 - src/assets/translations/fr/main.json | 1 - src/assets/translations/ja/main.json | 1 - src/assets/translations/pt/main.json | 1 - src/assets/translations/ru/main.json | 1 - src/assets/translations/tr/main.json | 1 - src/assets/translations/uk/main.json | 1 - src/assets/translations/zh/main.json | 1 - .../components/ClaimActionCard.tsx | 19 ++++++++++-- .../components/RfoxClaimActionCard.tsx | 1 + .../components/RfoxInitiatedActionCard.tsx | 29 +------------------ 13 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index 1995ef4c00c..fb2ef7c05a5 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Staking initiiert", "unstakeInitiated": "Unstake eingeleitet", - "claimWithdraw": "Anspruch einfordern", "staking": "rFOX Staking", "bridge": "Wechsel", "bridgeFunds": "Wechsel Betrag", diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 3f1a9598e6c..aa8fe3c1c5a 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2935,7 +2935,6 @@ "RFOX": { "stakeInitiated": "Stake Initiated", "unstakeInitiated": "Unstake Initiated", - "claimWithdraw": "Claim Withdraw", "staking": "rFOX Staking", "stake": "Stake", "unstake": "Unstake", @@ -2972,7 +2971,7 @@ "changeAddressPending": "Changing RFOX Reward Address to %{newAddress}", "changeAddressSuccess": "Changed RFOX Reward Address to %{newAddress}", "unstakePending": "Requesting withdraw of %{amount} %{symbol}", - "unstakeSuccess": "Once %{cooldownPeriod} has elapsed you will be able to claim your %{amount} %{symbol}", + "unstakeSuccess": "Your unstake of %{amount} %{symbol} has been initiated.", "unstakeSuccessNoCooldown": "Your %{amount} %{symbol} is unstaked and ready to claim.", "currentRewardAddress": "Current Reward Address", "newRewardAddress": "New Reward Address", @@ -3290,6 +3289,7 @@ "failed": "Your claim of %{amount} %{symbol} has failed." }, "availableSoon": "soon", + "claimAvailableOn": "Claim Available", "yield": { "unstakeAvailableIn": "Your %{symbol} unstake will be available in %{duration}.", "unstakeReady": "Your %{symbol} unstake is ready to claim.", diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index 820740d5bb2..7aec5a538af 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Stake Iniciado", "unstakeInitiated": "Unstake Iniciado", - "claimWithdraw": "Reclamar Retiro", "staking": "Staking rFOX", "bridge": "Bridge", "bridgeFunds": "Bridge Fondos", diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index d69e77b62b3..866d93fd01c 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Staking initié", "unstakeInitiated": "Déstaking initié", - "claimWithdraw": "Réclamer le retrait", "staking": "Staking rFOX", "bridge": "Bridger", "bridgeFunds": "Bridger des fonds", diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 28bf78a2228..63cc8e6c9dc 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "ステーク開始", "unstakeInitiated": "ステーク解除開始", - "claimWithdraw": "引き出し申請", "staking": "rFOXステーキング", "bridge": "ブリッジ", "bridgeFunds": "資金のブリッジ", diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index 73be46305c8..c59f0649df1 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Stake iniciado", "unstakeInitiated": "Unstake iniciado", - "claimWithdraw": "Resgate de Saque", "staking": "rFOX Staking", "bridge": "Ponte", "bridgeFunds": "Faça a ponte de seus fundos", diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index b33738e5c7c..3ebb8043382 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Инициированный стейкинг", "unstakeInitiated": "Инициированное снятие со стейкинга", - "claimWithdraw": "Отзыв претензии", "staking": "Стейкинг rFOX ", "bridge": "Мост", "bridgeFunds": "Перевести средства", diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index a51fe724b25..afd50905150 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Stake Başlatıldı", "unstakeInitiated": "Unstake Başlatıldı", - "claimWithdraw": "Talep Geri Çekme", "staking": "rFOX Stake Etme", "bridge": "Köprü", "bridgeFunds": "Köprü Fonları", diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index 719d30b855c..94d97fcbab8 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "Ініційований стейкінг", "unstakeInitiated": "Ініційовано розблокування", - "claimWithdraw": "Підтвердити Вивід", "staking": "Стейкінг rFOX ", "bridge": "Міст", "bridgeFunds": "Перевести кошти", diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index d688b021d5e..d4b5ba65c2d 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -2931,7 +2931,6 @@ "RFOX": { "stakeInitiated": "质押已发起", "unstakeInitiated": "取消质押已发起", - "claimWithdraw": "领取提款", "staking": "rFOX 质押", "bridge": "桥接", "bridgeFunds": "桥接资金", diff --git a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx index 66f1a138c01..3bab621fdd2 100644 --- a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx @@ -10,6 +10,7 @@ import { ActionStatusIcon } from './ActionStatusIcon' import { ActionStatusTag } from './ActionStatusTag' import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' +import { Row } from '@/components/Row/Row' import { getTxLink } from '@/lib/getTxLink' import { formatSmartDate } from '@/lib/utils/time' import type { @@ -28,6 +29,8 @@ type ClaimActionCardProps = { // Mostly a TCY thing - for most opportunities, that will be the same. It basically means "display asset symbol/amount as one asset (claimAssetId) and icon as another (underlyingAssetId)" underlyingAssetId: AssetId txHash: string | undefined + // A fixed timestamp, shown while the claim is still cooling down + claimableAt?: number onClaimClick: () => void message: string displayType: GenericTransactionDisplayType @@ -37,6 +40,7 @@ export const ClaimActionCard = ({ underlyingAssetId, claimAssetId, txHash, + claimableAt, action, onClaimClick, message, @@ -86,6 +90,17 @@ export const ClaimActionCard = ({ const details = useMemo(() => { if (!(claimAsset && claimFeeAsset)) return null + if (action.status === ActionStatus.Initiated) { + if (!claimableAt) return null + + return ( + + {translate('actionCenter.claimAvailableOn')} + {new Date(claimableAt).toLocaleString()} + + ) + } + if (action.status === ActionStatus.ClaimAvailable) return ( @@ -110,14 +125,14 @@ export const ClaimActionCard = ({ {translate('actionCenter.viewTransaction')} ) - }, [txHash, action.status, claimFeeAsset, handleClaimClick, claimAsset, translate]) + }, [txHash, claimableAt, action.status, claimFeeAsset, handleClaimClick, claimAsset, translate]) return ( { claimAssetId={action.rfoxClaimActionMetadata.request.stakingAssetId} underlyingAssetId={action.rfoxClaimActionMetadata.request.stakingAssetId} txHash={action.rfoxClaimActionMetadata.txHash} + claimableAt={Number(action.rfoxClaimActionMetadata.request.cooldownExpiry) * 1000} onClaimClick={handleClaimClick} message={message ?? ''} /> diff --git a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx index b8a6fc56786..c4e83503fa5 100644 --- a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx @@ -1,7 +1,5 @@ -import { Button, Card, CardBody, Link, Stack, Text, useDisclosure } from '@chakra-ui/react' +import { Card, CardBody, Link, Stack, Text, useDisclosure } from '@chakra-ui/react' import { uniV2EthFoxArbitrumAssetId } from '@shapeshiftoss/caip' -import dayjs from 'dayjs' -import relativeTime from 'dayjs/plugin/relativeTime' import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -19,8 +17,6 @@ import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/assetsS import { foxEthLpAssetId, foxEthPair } from '@/state/slices/opportunitiesSlice/constants' import { useAppSelector } from '@/state/store' -dayjs.extend(relativeTime) - type RfoxInitiatedActionCardProps = { action: GenericTransactionAction } @@ -100,29 +96,6 @@ export const RfoxInitiatedActionCard = ({ action }: RfoxInitiatedActionCardProps {middleEllipsis(action.transactionMetadata.txHash)} - - {isUnstake && action.transactionMetadata.cooldownPeriodSeconds && ( - - - {translate('RFOX.claimWithdraw')} - - - - )} From 0a25dc9822798db7f0689b0fcca8407973baad24 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:56:45 -0600 Subject: [PATCH 35/40] style(rfox): stake and unstake cards open to a view transaction button like the other tx cards Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/de/main.json | 2 -- src/assets/translations/en/main.json | 2 -- src/assets/translations/es/main.json | 2 -- src/assets/translations/fr/main.json | 2 -- src/assets/translations/ja/main.json | 2 -- src/assets/translations/pt/main.json | 2 -- src/assets/translations/ru/main.json | 2 -- src/assets/translations/tr/main.json | 2 -- src/assets/translations/uk/main.json | 2 -- src/assets/translations/zh/main.json | 2 -- .../components/RfoxInitiatedActionCard.tsx | 23 ++++--------------- 11 files changed, 5 insertions(+), 38 deletions(-) diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index fb2ef7c05a5..1b48c07e84c 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Staking initiiert", - "unstakeInitiated": "Unstake eingeleitet", "staking": "rFOX Staking", "bridge": "Wechsel", "bridgeFunds": "Wechsel Betrag", diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index aa8fe3c1c5a..3a5f6a7484e 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2933,8 +2933,6 @@ } }, "RFOX": { - "stakeInitiated": "Stake Initiated", - "unstakeInitiated": "Unstake Initiated", "staking": "rFOX Staking", "stake": "Stake", "unstake": "Unstake", diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index 7aec5a538af..685b2189fc3 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Stake Iniciado", - "unstakeInitiated": "Unstake Iniciado", "staking": "Staking rFOX", "bridge": "Bridge", "bridgeFunds": "Bridge Fondos", diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 866d93fd01c..b0e8e178967 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Staking initié", - "unstakeInitiated": "Déstaking initié", "staking": "Staking rFOX", "bridge": "Bridger", "bridgeFunds": "Bridger des fonds", diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 63cc8e6c9dc..9fac12a4a93 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "ステーク開始", - "unstakeInitiated": "ステーク解除開始", "staking": "rFOXステーキング", "bridge": "ブリッジ", "bridgeFunds": "資金のブリッジ", diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index c59f0649df1..213b7f0c6b8 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Stake iniciado", - "unstakeInitiated": "Unstake iniciado", "staking": "rFOX Staking", "bridge": "Ponte", "bridgeFunds": "Faça a ponte de seus fundos", diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index 3ebb8043382..44f1dfd316d 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Инициированный стейкинг", - "unstakeInitiated": "Инициированное снятие со стейкинга", "staking": "Стейкинг rFOX ", "bridge": "Мост", "bridgeFunds": "Перевести средства", diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index afd50905150..154cc21f532 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Stake Başlatıldı", - "unstakeInitiated": "Unstake Başlatıldı", "staking": "rFOX Stake Etme", "bridge": "Köprü", "bridgeFunds": "Köprü Fonları", diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index 94d97fcbab8..840116dad2b 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "Ініційований стейкінг", - "unstakeInitiated": "Ініційовано розблокування", "staking": "Стейкінг rFOX ", "bridge": "Міст", "bridgeFunds": "Перевести кошти", diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index d4b5ba65c2d..e95020db36d 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -2929,8 +2929,6 @@ } }, "RFOX": { - "stakeInitiated": "质押已发起", - "unstakeInitiated": "取消质押已发起", "staking": "rFOX 质押", "bridge": "桥接", "bridgeFunds": "桥接资金", diff --git a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx index c4e83503fa5..9efaf6e3b33 100644 --- a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx @@ -1,4 +1,4 @@ -import { Card, CardBody, Link, Stack, Text, useDisclosure } from '@chakra-ui/react' +import { Button, Link, useDisclosure } from '@chakra-ui/react' import { uniV2EthFoxArbitrumAssetId } from '@shapeshiftoss/caip' import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -12,7 +12,7 @@ import { getTxLink } from '@/lib/getTxLink' import { middleEllipsis } from '@/lib/utils' import { formatSmartDate } from '@/lib/utils/time' import type { GenericTransactionAction } from '@/state/slices/actionSlice/types' -import { ActionType, getActionTimestamp } from '@/state/slices/actionSlice/types' +import { getActionTimestamp } from '@/state/slices/actionSlice/types' import { selectAssetById, selectFeeAssetByChainId } from '@/state/slices/assetsSlice/selectors' import { foxEthLpAssetId, foxEthPair } from '@/state/slices/opportunitiesSlice/constants' import { useAppSelector } from '@/state/store' @@ -44,8 +44,6 @@ export const RfoxInitiatedActionCard = ({ action }: RfoxInitiatedActionCardProps }) }, [action.transactionMetadata.txHash, action.transactionMetadata.chainId, feeAsset]) - const isUnstake = action.type === ActionType.Withdraw - const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: false }) const icon = useMemo(() => { @@ -85,20 +83,9 @@ export const RfoxInitiatedActionCard = ({ action }: RfoxInitiatedActionCardProps footer={footer} onToggle={onToggle} > - - - - - - {translate(isUnstake ? 'RFOX.unstakeInitiated' : 'RFOX.stakeInitiated')} - - - {middleEllipsis(action.transactionMetadata.txHash)} - - - - - + ) } From 845bbffd0a4d5e4076ab4dad37c5e3951d1ee8e8 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:58:25 -0600 Subject: [PATCH 36/40] feat(rfox): link the unstake tx on its claim card, matched from tx history by the cooldown math - the contract sets cooldownExpiry = block.timestamp + cooldownPeriod, so the unstake is the account's unstake of that amount mined at the action's createdAt, nothing new is stored and a rebuilt action finds it again - claim cards share one details layout: source tx row, claimable date, then the claim or view transaction button Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 1 + .../components/ClaimActionCard.tsx | 88 ++++++++++++------- .../components/RfoxClaimActionCard.tsx | 31 ++++++- 3 files changed, 85 insertions(+), 35 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 3a5f6a7484e..450a6a18de9 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -3313,6 +3313,7 @@ "expired": "Your order to buy %{buyAmountAndSymbol} for %{sellAmountAndSymbol} expired." }, "rfox": { + "unstakeTx": "Unstake Tx", "unstakePending": "Your unstake of %{amount} %{symbol} will be available %{timeText}.", "unstakeReady": "Your unstake of %{amount} %{symbol} is ready to claim.", "unstakeTxPending": "Your unstake of %{amount} %{symbol} is pending.", diff --git a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx index 3bab621fdd2..2d162497887 100644 --- a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx @@ -10,6 +10,7 @@ import { ActionStatusIcon } from './ActionStatusIcon' import { ActionStatusTag } from './ActionStatusTag' import { AssetIconWithBadge } from '@/components/AssetIconWithBadge' +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { Row } from '@/components/Row/Row' import { getTxLink } from '@/lib/getTxLink' import { formatSmartDate } from '@/lib/utils/time' @@ -29,6 +30,9 @@ type ClaimActionCardProps = { // Mostly a TCY thing - for most opportunities, that will be the same. It basically means "display asset symbol/amount as one asset (claimAssetId) and icon as another (underlyingAssetId)" underlyingAssetId: AssetId txHash: string | undefined + // The tx that created the claim, e.g. an unstake, linked like a bridge card's withdraw + sourceTxHash?: string + sourceTxLabel?: string // A fixed timestamp, shown while the claim is still cooling down claimableAt?: number onClaimClick: () => void @@ -40,6 +44,8 @@ export const ClaimActionCard = ({ underlyingAssetId, claimAssetId, txHash, + sourceTxHash, + sourceTxLabel, claimableAt, action, onClaimClick, @@ -90,42 +96,58 @@ export const ClaimActionCard = ({ const details = useMemo(() => { if (!(claimAsset && claimFeeAsset)) return null - if (action.status === ActionStatus.Initiated) { - if (!claimableAt) return null - - return ( - + const toTxLink = (txId: string) => + getTxLink({ + txId, + chainId: claimAsset.chainId, + explorerBaseUrl: claimFeeAsset.explorerTxLink, + address: undefined, + maybeSafeTx: undefined, + }) + + const rows = [ + sourceTxHash && sourceTxLabel && ( + + {sourceTxLabel} + + + + + + + ), + action.status === ActionStatus.Initiated && claimableAt && ( + {translate('actionCenter.claimAvailableOn')} {new Date(claimableAt).toLocaleString()} - ) - } - - if (action.status === ActionStatus.ClaimAvailable) - return ( - - - - ) - - if (!txHash) return null - - const txLink = getTxLink({ - txId: txHash, - chainId: claimAsset.chainId, - explorerBaseUrl: claimFeeAsset.explorerTxLink, - address: undefined, - maybeSafeTx: undefined, - }) - - return ( - - ) - }, [txHash, claimableAt, action.status, claimFeeAsset, handleClaimClick, claimAsset, translate]) + ), + action.status === ActionStatus.ClaimAvailable && ( + + ), + action.status !== ActionStatus.ClaimAvailable && txHash && ( + + ), + ].filter(Boolean) + + if (!rows.length) return null + + return {rows} + }, [ + txHash, + sourceTxHash, + sourceTxLabel, + claimableAt, + action.status, + claimFeeAsset, + handleClaimClick, + claimAsset, + translate, + ]) return ( { Number(action.rfoxClaimActionMetadata.request.cooldownExpiry) * 1000, ) + const { request } = action.rfoxClaimActionMetadata + const rfoxTxFilter = useMemo( + () => ({ accountId: request.stakingAssetAccountId, parser: 'rfox' }), + [request.stakingAssetAccountId], + ) + const rfoxTxIds = useAppSelector(state => selectTxIdsByFilter(state, rfoxTxFilter)) + const txs = useAppSelector(selectTxs) + + // The contract sets cooldownExpiry = block.timestamp + cooldownPeriod, so the unstake is the account's + // unstake of this amount mined at the action's createdAt + const unstakeTxHash = useMemo( + () => + rfoxTxIds + .map(txId => txs[txId]) + .find( + tx => + tx.data?.parser === 'rfox' && + tx.data.method === 'unstakeRequest' && + tx.data.assetId === request.stakingAssetId && + tx.data.value === request.amountCryptoBaseUnit && + tx.blockTime * 1000 === action.createdAt, + )?.txid, + [rfoxTxIds, txs, request.stakingAssetId, request.amountCryptoBaseUnit, action.createdAt], + ) + const handleClaimClick = useCallback(() => { const index = action.rfoxClaimActionMetadata.request.index @@ -93,7 +118,9 @@ export const RfoxClaimActionCard = ({ action }: RfoxClaimActionCardProps) => { claimAssetId={action.rfoxClaimActionMetadata.request.stakingAssetId} underlyingAssetId={action.rfoxClaimActionMetadata.request.stakingAssetId} txHash={action.rfoxClaimActionMetadata.txHash} - claimableAt={Number(action.rfoxClaimActionMetadata.request.cooldownExpiry) * 1000} + sourceTxHash={unstakeTxHash} + sourceTxLabel={translate('actionCenter.rfox.unstakeTx')} + claimableAt={Number(request.cooldownExpiry) * 1000} onClaimClick={handleClaimClick} message={message ?? ''} /> From 9ee94f09e790601e99bd75c58607b2854ed3bf49 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:03:02 -0600 Subject: [PATCH 37/40] style(action-center): size the claim and rfox view transaction buttons like the other tx cards Co-Authored-By: Claude Fable 5.1 --- .../Layout/Header/ActionCenter/components/ClaimActionCard.tsx | 2 +- .../Header/ActionCenter/components/RfoxInitiatedActionCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx index 2d162497887..f48e47ca7fa 100644 --- a/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ClaimActionCard.tsx @@ -128,7 +128,7 @@ export const ClaimActionCard = ({ ), action.status !== ActionStatus.ClaimAvailable && txHash && ( - ), diff --git a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx index 9efaf6e3b33..2b1e84326e8 100644 --- a/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx +++ b/src/components/Layout/Header/ActionCenter/components/RfoxInitiatedActionCard.tsx @@ -83,7 +83,7 @@ export const RfoxInitiatedActionCard = ({ action }: RfoxInitiatedActionCardProps footer={footer} onToggle={onToggle} > - From 390ed76e6181a939cacfaed35b1c1fde5da2f9b7 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:22:23 -0600 Subject: [PATCH 38/40] fix(action-center): address review, claimed toast on every path, already-claimed address, rfox unstake dating - the claim modal reads the receive address from the action, the claimed state no longer skeletons forever, and says when the wallet has no ethereum account to pay with - a claim confirmed by the status read toasts like one confirmed by receipt - a null L2 block during the assertion scan retries instead of finalising the flat estimate - swap toasts for a withdraw follow the withdraw card's wording past initiated - rfox claim confirm dates a fresh action from the cooldown math, matching the subscriber - the rfox unstake tx lookup is a cached selector returning the txid, so claim cards don't re-render on unrelated txs - the claim migration skips persisted ids with no action Co-Authored-By: Claude Fable 5.1 --- src/assets/translations/en/main.json | 1 + .../components/ArbitrumBridgeClaimModal.tsx | 16 ++++---- .../Notifications/SwapNotification.tsx | 13 +++---- .../components/RfoxClaimActionCard.tsx | 38 ++++++++----------- .../useArbitrumWithdrawalActionSubscriber.tsx | 7 +++- .../useArbitrumClaims/useArbitrumClaims.tsx | 3 +- .../RFOX/components/Claim/ClaimConfirm.tsx | 10 ++++- src/state/migrations/clearClaimActions.ts | 5 ++- src/state/slices/txHistorySlice/selectors.ts | 34 +++++++++++++++++ 9 files changed, 84 insertions(+), 43 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 450a6a18de9..14f90e203a1 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -884,6 +884,7 @@ "claimReceiveAddress": "Claim Receive Address", "confirmAndClaim": "Confirm & Claim", "claimTxDataFailed": "Couldn't prepare the claim transaction", + "noEthereumAccount": "Enable an Ethereum account to pay for the claim", "alreadyClaimed": "Already claimed", "alreadyClaimedBody": "This withdraw has already been claimed." }, diff --git a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx index 7d32b38c33d..6bbf0c51162 100644 --- a/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx +++ b/src/components/Layout/Header/ActionCenter/components/ArbitrumBridgeClaimModal.tsx @@ -15,7 +15,6 @@ import type { KnownChainIds } from '@shapeshiftoss/types' import { BigAmount, getChainShortName } from '@shapeshiftoss/utils' import { useCallback, useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' -import { zeroAddress } from 'viem' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' @@ -57,8 +56,7 @@ export const ArbitrumBridgeClaimModal = ({ const isClaimAvailable = action.status === ActionStatus.ClaimAvailable const isClaimCompleted = action.status === ActionStatus.Claimed - // The outbox call is permissionless, the account that withdrew pays for the claim when the wallet - // holds it on ethereum, otherwise its first ethereum account does. Funds land at the destination either way + // Permissionless outbox call, paid by the withdrawing account on ethereum, else the wallet's first const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const claimAccountId = useMemo(() => { const { chainId } = fromAccountId(destinationAccountId) @@ -184,6 +182,8 @@ export const ArbitrumBridgeClaimModal = ({ const confirmCopy = useMemo(() => { if (isClaimCompleted) return translate('common.close') + if (!claimAccountId) return translate('bridge.noEthereumAccount') + if (executeTransactionDataResult?.isError) return translate('bridge.claimTxDataFailed') if (claimMutation?.isError) return translate('trade.errors.title') @@ -199,6 +199,7 @@ export const ArbitrumBridgeClaimModal = ({ return translate('bridge.confirmAndClaim') }, [ isClaimCompleted, + claimAccountId, claimMutation, destinationFeeAsset, evmFeesResult?.isError, @@ -242,11 +243,7 @@ export const ArbitrumBridgeClaimModal = ({ {translate('bridge.claimReceiveAddress')} - - - {middleEllipsis(claimDetails?.destinationAddress ?? zeroAddress)} - - + {middleEllipsis(fromAccountId(destinationAccountId).account)} {isClaimCompleted ? ( { ) const { request } = action.rfoxClaimActionMetadata - const rfoxTxFilter = useMemo( - () => ({ accountId: request.stakingAssetAccountId, parser: 'rfox' }), - [request.stakingAssetAccountId], - ) - const rfoxTxIds = useAppSelector(state => selectTxIdsByFilter(state, rfoxTxFilter)) - const txs = useAppSelector(selectTxs) - - // The contract sets cooldownExpiry = block.timestamp + cooldownPeriod, so the unstake is the account's - // unstake of this amount mined at the action's createdAt - const unstakeTxHash = useMemo( - () => - rfoxTxIds - .map(txId => txs[txId]) - .find( - tx => - tx.data?.parser === 'rfox' && - tx.data.method === 'unstakeRequest' && - tx.data.assetId === request.stakingAssetId && - tx.data.value === request.amountCryptoBaseUnit && - tx.blockTime * 1000 === action.createdAt, - )?.txid, - [rfoxTxIds, txs, request.stakingAssetId, request.amountCryptoBaseUnit, action.createdAt], + const unstakeTxFilter = useMemo( + () => ({ + accountId: request.stakingAssetAccountId, + assetId: request.stakingAssetId, + amountCryptoBaseUnit: request.amountCryptoBaseUnit, + unstakedAtMs: action.createdAt, + }), + [ + request.stakingAssetAccountId, + request.stakingAssetId, + request.amountCryptoBaseUnit, + action.createdAt, + ], ) + const unstakeTxHash = useAppSelector(state => selectRfoxUnstakeTxId(state, unstakeTxFilter)) const handleClaimClick = useCallback(() => { const index = action.rfoxClaimActionMetadata.request.index diff --git a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx index fb6ac7b858f..808d9f8fcc8 100644 --- a/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useArbitrumWithdrawalActionSubscriber.tsx @@ -158,11 +158,16 @@ export const useArbitrumWithdrawalActionSubscriber = () => { if (previousStatus !== newStatus && newStatus === ActionStatus.ClaimAvailable) { notifyClaimAvailable(update.action.id) } + + // The status read can see the claim mined before the receipt poll does + if (previousStatus === ActionStatus.Pending && newStatus === ActionStatus.Claimed) { + notifyClaimed(update.action.id) + } }) } catch (error) { console.error('Error updating ArbitrumBridge action statuses:', error) } - }, [dispatch, notifyClaimAvailable, pendingArbitrumBridgeActions, claimsByTxid]) + }, [dispatch, notifyClaimAvailable, notifyClaimed, pendingArbitrumBridgeActions, claimsByTxid]) // Resolves in-flight claims, including ones broadcast before a reload const claimingActions = useMemo( diff --git a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx index d8fc363d127..75f126be11a 100644 --- a/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx +++ b/src/hooks/useArbitrumClaims/useArbitrumClaims.tsx @@ -117,7 +117,8 @@ const findClaimableAt = async ( for (const log of logs) { const [blockHash] = log.args.assertion.afterState.globalState.bytes32Vals const childBlock = await l2Provider.getBlock(blockHash) - if (!childBlock || childBlock.number < childBlockNumber) continue + if (!childBlock) throw new Error(`Assertion child block ${blockHash} not found`) + if (childBlock.number < childBlockNumber) continue const { confirmPeriodBlocks } = arbitrumNetwork const [assertionBlock, periodStartBlock] = await Promise.all([ diff --git a/src/pages/RFOX/components/Claim/ClaimConfirm.tsx b/src/pages/RFOX/components/Claim/ClaimConfirm.tsx index dc9ec6b1293..09c8fb231fe 100644 --- a/src/pages/RFOX/components/Claim/ClaimConfirm.tsx +++ b/src/pages/RFOX/components/Claim/ClaimConfirm.tsx @@ -46,6 +46,7 @@ import { createBuildCustomTxInput, } from '@/lib/utils/evm' import { getStakingContract } from '@/pages/RFOX/helpers' +import { useCooldownPeriodQuery } from '@/pages/RFOX/hooks/useCooldownPeriodQuery' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' import { @@ -97,6 +98,13 @@ export const ClaimConfirm: FC< [actions, selectedUnstakingRequest.id], ) + // Dates a claim the subscriber hasn't created yet the way it would, from the unstake + const cooldownPeriodQuery = useCooldownPeriodQuery(selectedUnstakingRequest.stakingAssetId) + const unstakedAtMs = cooldownPeriodQuery.data + ? Number(selectedUnstakingRequest.cooldownExpiry) * 1000 - + cooldownPeriodQuery.data.cooldownPeriodSeconds * 1000 + : undefined + const handleGoBack = useCallback(() => { return navigate('/fox-ecosystem') }, [navigate]) @@ -213,7 +221,7 @@ export const ClaimConfirm: FC< id: selectedUnstakingRequest.id, status: ActionStatus.Pending, type: ActionType.RfoxClaim, - createdAt: maybeClaimAction?.createdAt ?? Date.now(), + createdAt: maybeClaimAction?.createdAt ?? unstakedAtMs ?? Date.now(), updatedAt: Date.now(), rfoxClaimActionMetadata: { request: selectedUnstakingRequest, diff --git a/src/state/migrations/clearClaimActions.ts b/src/state/migrations/clearClaimActions.ts index f57beaa4122..cea33cfb028 100644 --- a/src/state/migrations/clearClaimActions.ts +++ b/src/state/migrations/clearClaimActions.ts @@ -1,5 +1,6 @@ import type { PersistPartial } from 'redux-persist/es/persistReducer' +import { isSome } from '@/lib/utils' import type { Action, ActionState } from '@/state/slices/actionSlice/types' import { ActionStatus, ActionType } from '@/state/slices/actionSlice/types' @@ -19,7 +20,9 @@ const clearClaimAction = (action: Action): Action | undefined => { } export const clearClaimActions = (state: ActionState): ActionState & PersistPartial => { - const byId = Object.values(state.byId).reduce((acc, action) => { + const byId = Object.values(state.byId) + .filter(isSome) + .reduce((acc, action) => { const migrated = clearClaimAction(action) if (migrated) acc[migrated.id] = migrated return acc diff --git a/src/state/slices/txHistorySlice/selectors.ts b/src/state/slices/txHistorySlice/selectors.ts index 35294ac390f..c54e582944d 100644 --- a/src/state/slices/txHistorySlice/selectors.ts +++ b/src/state/slices/txHistorySlice/selectors.ts @@ -135,6 +135,40 @@ export const selectArbitrumWithdrawTxs = createDeepEqualOutputSelector( ), ) +type RfoxUnstakeFilter = { + accountId: AccountId + assetId: AssetId + amountCryptoBaseUnit: string + unstakedAtMs: number +} + +const selectRfoxUnstakeFilter = (_state: ReduxState, filter: RfoxUnstakeFilter) => filter + +// The contract sets cooldownExpiry = block.timestamp + cooldownPeriod, so a claim's unstake is the account's +// unstake of that amount mined at the claim's createdAt +export const selectRfoxUnstakeTxId = createCachedSelector( + selectTxs, + selectWalletTxIdsByAccountIdAssetId, + selectRfoxUnstakeFilter, + (txs, data, { accountId, assetId, amountCryptoBaseUnit, unstakedAtMs }): TxId | undefined => + values(data[accountId] ?? {}) + .flat() + .filter(isSome) + .find(txId => { + const tx = txs[txId] + return ( + tx?.data?.parser === 'rfox' && + tx.data.method === 'unstakeRequest' && + tx.data.assetId === assetId && + tx.data.value === amountCryptoBaseUnit && + tx.blockTime * 1000 === unstakedAtMs + ) + }), +)( + (_state, { accountId, assetId, amountCryptoBaseUnit, unstakedAtMs }) => + `${accountId}-${assetId}-${amountCryptoBaseUnit}-${unstakedAtMs}`, +) + export const selectTxIdsByFilter = createCachedSelector( selectTxIds, selectTxs, From 6b7886a6674bfea100b262fcfc0e8cabdf325440 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:40:46 -0600 Subject: [PATCH 39/40] fix(action-center): type the pending withdraw selector, narrow the rfox unstake match to evm metadata Co-Authored-By: Claude Fable 5.1 --- .../components/Notifications/SwapNotification.tsx | 7 +++---- src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx | 4 +++- src/state/migrations/clearClaimActions.ts | 8 ++++---- src/state/slices/actionSlice/selectors.ts | 3 ++- src/state/slices/txHistorySlice/selectors.ts | 6 +++++- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx index 9cdf5ad7b66..1c643972c33 100644 --- a/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx +++ b/src/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification.tsx @@ -60,9 +60,7 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat // Bridge swaps show their withdraw action once history creates it, the swap action until then const action = maybeArbitrumBridgeAction ?? swapAction - const timeText = useClaimTimeText( - maybeArbitrumBridgeAction?.arbitrumBridgeMetadata.claimableAt, - ) + const timeText = useClaimTimeText(maybeArbitrumBridgeAction?.arbitrumBridgeMetadata.claimableAt) const swapNotificationComponents = useMemo((): TextPropTypes['components'] | undefined => { if (!swap) return undefined @@ -135,7 +133,8 @@ export const SwapNotification = ({ handleClick, swapId, onClose }: SwapNotificat // A withdraw is initiated once its tx lands, its withdraw action tells the rest if (swap.buyAsset.chainId === ethChainId) { - if (isArbitrumBridgeWithdrawAction(action)) return getArbitrumBridgeWithdrawMessageKey(status) + if (isArbitrumBridgeWithdrawAction(action)) + return getArbitrumBridgeWithdrawMessageKey(status) if (status === ActionStatus.Complete) return 'actionCenter.bridge.initiated' return 'actionCenter.bridge.processing' diff --git a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx index dae631a4d95..d32fd346905 100644 --- a/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx +++ b/src/pages/RFOX/hooks/useRfoxClaimActionSubscriber.tsx @@ -124,7 +124,9 @@ export const useRfoxClaimActionSubscriber = () => { // Only a cooled down request moves forward, the claim flow owns it from there if (action && isRfoxClaimAction(action)) { if (isClaimable && action.status === ActionStatus.Initiated) { - dispatch(actionSlice.actions.upsertAction({ ...action, status: ActionStatus.ClaimAvailable })) + dispatch( + actionSlice.actions.upsertAction({ ...action, status: ActionStatus.ClaimAvailable }), + ) } return } diff --git a/src/state/migrations/clearClaimActions.ts b/src/state/migrations/clearClaimActions.ts index cea33cfb028..34effb7d3d7 100644 --- a/src/state/migrations/clearClaimActions.ts +++ b/src/state/migrations/clearClaimActions.ts @@ -23,10 +23,10 @@ export const clearClaimActions = (state: ActionState): ActionState & PersistPart const byId = Object.values(state.byId) .filter(isSome) .reduce((acc, action) => { - const migrated = clearClaimAction(action) - if (migrated) acc[migrated.id] = migrated - return acc - }, {}) + const migrated = clearClaimAction(action) + if (migrated) acc[migrated.id] = migrated + return acc + }, {}) return { ...state, diff --git a/src/state/slices/actionSlice/selectors.ts b/src/state/slices/actionSlice/selectors.ts index 14d210946bf..7f0e3942807 100644 --- a/src/state/slices/actionSlice/selectors.ts +++ b/src/state/slices/actionSlice/selectors.ts @@ -5,6 +5,7 @@ import { selectEnabledWalletAccountIds } from '../common-selectors' import { swapSlice } from '../swapSlice/swapSlice' import { actionSlice } from './actionSlice' import type { + ArbitrumBridgeWithdrawAction, ChainflipLendingAction, GenericTransactionAction, LimitOrderAction, @@ -302,7 +303,7 @@ export const selectPendingArbitrumBridgeWithdrawActions = createDeepEqualOutputS selectWalletActions, actions => { return actions.filter( - action => + (action): action is ArbitrumBridgeWithdrawAction => isArbitrumBridgeWithdrawAction(action) && action.status !== ActionStatus.Claimed && action.status !== ActionStatus.Failed, diff --git a/src/state/slices/txHistorySlice/selectors.ts b/src/state/slices/txHistorySlice/selectors.ts index c54e582944d..bd3fa21775e 100644 --- a/src/state/slices/txHistorySlice/selectors.ts +++ b/src/state/slices/txHistorySlice/selectors.ts @@ -115,7 +115,10 @@ const selectWalletArbitrumTxIds = createDeepEqualOutputSelector( Object.entries(data).forEach(([accountId, byAssetId]) => { if (fromAccountId(accountId).chainId !== arbitrumChainId) return - values(byAssetId).flat().filter(isSome).forEach(txId => txIds.add(txId)) + values(byAssetId) + .flat() + .filter(isSome) + .forEach(txId => txIds.add(txId)) }) return [...txIds] @@ -158,6 +161,7 @@ export const selectRfoxUnstakeTxId = createCachedSelector( const tx = txs[txId] return ( tx?.data?.parser === 'rfox' && + tx.data.type === 'evm' && tx.data.method === 'unstakeRequest' && tx.data.assetId === assetId && tx.data.value === amountCryptoBaseUnit && From a4d74721ea4deb8f750e60ab96a93f12e45d3dfe Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:48:23 -0600 Subject: [PATCH 40/40] fix(rfox): link the unstake tx by its hash, not its tx history index Co-Authored-By: Claude Fable 5.1 --- src/state/slices/txHistorySlice/selectors.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/state/slices/txHistorySlice/selectors.ts b/src/state/slices/txHistorySlice/selectors.ts index bd3fa21775e..1a219e1337d 100644 --- a/src/state/slices/txHistorySlice/selectors.ts +++ b/src/state/slices/txHistorySlice/selectors.ts @@ -153,21 +153,20 @@ export const selectRfoxUnstakeTxId = createCachedSelector( selectTxs, selectWalletTxIdsByAccountIdAssetId, selectRfoxUnstakeFilter, - (txs, data, { accountId, assetId, amountCryptoBaseUnit, unstakedAtMs }): TxId | undefined => + (txs, data, { accountId, assetId, amountCryptoBaseUnit, unstakedAtMs }): string | undefined => values(data[accountId] ?? {}) .flat() .filter(isSome) - .find(txId => { - const tx = txs[txId] - return ( + .map(txId => txs[txId]) + .find( + tx => tx?.data?.parser === 'rfox' && tx.data.type === 'evm' && tx.data.method === 'unstakeRequest' && tx.data.assetId === assetId && tx.data.value === amountCryptoBaseUnit && - tx.blockTime * 1000 === unstakedAtMs - ) - }), + tx.blockTime * 1000 === unstakedAtMs, + )?.txid, )( (_state, { accountId, assetId, amountCryptoBaseUnit, unstakedAtMs }) => `${accountId}-${assetId}-${amountCryptoBaseUnit}-${unstakedAtMs}`,