diff --git a/src/modules/filemanager/components/ExpiringNotificationModal/ExpiringNotificationModal.tsx b/src/modules/filemanager/components/ExpiringNotificationModal/ExpiringNotificationModal.tsx index 69153256..2ee61b85 100644 --- a/src/modules/filemanager/components/ExpiringNotificationModal/ExpiringNotificationModal.tsx +++ b/src/modules/filemanager/components/ExpiringNotificationModal/ExpiringNotificationModal.tsx @@ -1,9 +1,10 @@ -import { PostageBatch } from '@ethersphere/bee-js' +import { Bee, PostageBatch } from '@ethersphere/bee-js' import { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib' import { ReactElement, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import AlertIcon from 'remixicon-react/AlertLineIcon' +import { validateStampStillExists } from '../../utils/bee' import { getDaysLeft } from '../../utils/common' import { Button } from '../Button/Button' import { UpgradeDriveModal } from '../UpgradeDriveModal/UpgradeDriveModal' @@ -16,19 +17,23 @@ import '../../styles/global.scss' const EXPIRING_ITEMS_PAGE_SIZE = 3 interface ExpiringNotificationModalProps { + bee: Bee stamps: PostageBatch[] drives: DriveInfo[] files: FileInfo[] onCancelClick: () => void setErrorMessage?: (error: string) => void + setShowError: (show: boolean) => void } export function ExpiringNotificationModal({ + bee, stamps, drives, files, onCancelClick, setErrorMessage, + setShowError, }: ExpiringNotificationModalProps): ReactElement { const [showUpgradeDriveModal, setShowUpgradeDriveModal] = useState(false) const [actualStamp, setActualStamp] = useState(undefined) @@ -75,7 +80,18 @@ export function ExpiringNotificationModal({ files={files} currentPage={currentPage} index={index} - onUpgradeClick={(stamp, drive) => { + onUpgradeClick={async (stamp, drive) => { + const isStampValid = await validateStampStillExists(bee, stamp.batchID) + + if (!isStampValid) { + setErrorMessage?.( + `Drive ${drive.name} has expired. Please clear the browser cache and reload the page.`, + ) + setShowError(true) + + return + } + setActualStamp(stamp) setActualDrive(drive) setShowUpgradeDriveModal(true) diff --git a/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx b/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx index 40d93c89..f448e988 100644 --- a/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx +++ b/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx @@ -10,6 +10,7 @@ import React, { useRef, useState, } from 'react' +import { createPortal } from 'react-dom' import { useSearch } from '../../../../pages/filemanager/SearchContext' import { useView } from '../../../../pages/filemanager/ViewContext' @@ -87,7 +88,9 @@ function ErrorModalBlock({ return null } - return + const modalRoot = document.querySelector('.fm-main') || document.body + + return createPortal(, modalRoot) } const extractFilesFromClipboardEvent = (e: React.ClipboardEvent): File[] => { @@ -431,8 +434,10 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current) } + + setShowError(false) } - }, []) + }, [setShowError]) useEffect(() => { let title = currentDrive?.name || '' diff --git a/src/modules/filemanager/components/NotificationBar/NotificationBar.tsx b/src/modules/filemanager/components/NotificationBar/NotificationBar.tsx index 5caba9dd..5719bc6b 100644 --- a/src/modules/filemanager/components/NotificationBar/NotificationBar.tsx +++ b/src/modules/filemanager/components/NotificationBar/NotificationBar.tsx @@ -30,7 +30,7 @@ export function NotificationBar({ setErrorMessage }: NotificationBarProps): Reac const [stampsToExpire, setStampsToExpire] = useState([]) const [drivesToExpire, setDrivesToExpire] = useState([]) const { beeApi } = useContext(SettingsContext) - const { drives, files, adminDrive } = useContext(FMContext) + const { drives, files, adminDrive, setShowError } = useContext(FMContext) const showExpiration = stampsToExpire.length > 0 @@ -109,8 +109,9 @@ export function NotificationBar({ setErrorMessage }: NotificationBarProps): Reac
setShowExpiringModal(true)}> {stampsToExpire.length} drive{stampsToExpire.length > 1 ? 's' : ''} expiring soon
- {showExpiringModal && ( + {showExpiringModal && beeApi && ( )} diff --git a/src/pages/filemanager/index.tsx b/src/pages/filemanager/index.tsx index fa1254bf..07462b89 100644 --- a/src/pages/filemanager/index.tsx +++ b/src/pages/filemanager/index.tsx @@ -1,3 +1,4 @@ +import { BeeModes } from '@ethersphere/bee-js' import { DriveInfo, FileManagerBase } from '@solarpunkltd/file-manager-lib' import { ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' @@ -44,6 +45,18 @@ function InitializationErrorBlock({ onOk }: { onOk: () => void }) { ) } +function UltraLightNodeErrorBlock() { + return ( +
+
+
+ File Manager is not available with an Ultra-light node. Please upgrade to a Light node to continue. +
+
+
+ ) +} + function ResetModalBlock({ cacheHelpUrl, onConfirm }: { cacheHelpUrl: string; onConfirm: () => void }) { return (
@@ -169,6 +182,7 @@ function FileManagerMainContent(props: { enum PageState { Connecting = 'connecting', // still warming up — show nothing / loader + UltraLightNode = 'ultra-light-node', // ultra-light node — file manager not available NoPrivateKey = 'no-pk', // private key not set Loading = 'loading', // bee ready, pk present, FM init in progress Reset = 'reset', // STATE_INVALID emitted and user has not yet acknowledged @@ -189,7 +203,7 @@ export function FileManagerPage(): ReactElement { const [connectionErrorDismissed, setConnectionErrorDismissed] = useState(false) const [cacheHelpUrl, setCacheHelpUrl] = useState(cacheClearUrls[BrowserPlatform.Chrome]) - const { status, chainState } = useContext(BeeContext) + const { status, chainState, nodeInfo } = useContext(BeeContext) const { fm, initDone, shallReset, adminDrive, initializationError, notifyPkSaved } = useContext(FMContext) useEffect(() => { @@ -228,6 +242,8 @@ export function FileManagerPage(): ReactElement { if (!isBeeReady && !initDone) return PageState.Connecting + if (nodeInfo?.beeMode === BeeModes.ULTRA_LIGHT) return PageState.UltraLightNode + if (!hasPk) return PageState.NoPrivateKey if (!initDone) return PageState.Loading @@ -259,6 +275,7 @@ export function FileManagerPage(): ReactElement { adminDrive, isCreationInProgress, chainState, + nodeInfo?.beeMode, ]) const handlePrivateKeySaved = useCallback(() => { @@ -274,6 +291,10 @@ export function FileManagerPage(): ReactElement { const loading = !fm?.adminStamp || !adminDrive const isFormbricksActive = Boolean(fm && fm.adminStamp && adminDrive && !loading) + if (pageState === PageState.UltraLightNode) { + return + } + if (pageState === PageState.Connecting || pageState === PageState.Loading) { return } diff --git a/src/pages/stamps/PostageStampStandardCreation.tsx b/src/pages/stamps/PostageStampStandardCreation.tsx index fb8f78ed..67ac0d45 100644 --- a/src/pages/stamps/PostageStampStandardCreation.tsx +++ b/src/pages/stamps/PostageStampStandardCreation.tsx @@ -191,7 +191,6 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen Corresponding TTL (Time to live) {amountInput ? getTtl(amountInput) : '-'} - {amountInput ? getTtl(amountInput) : '-'} diff --git a/src/utils/rpc.ts b/src/utils/rpc.ts index eb0984c6..96bf89c3 100644 --- a/src/utils/rpc.ts +++ b/src/utils/rpc.ts @@ -84,7 +84,7 @@ export async function sendNativeTransaction( const feedData = await signer.provider.getFeeData() const gasPrice = externalGasPrice ?? DAI.fromWei(feedData.gasPrice?.toString() || '0') const transaction = await signer.sendTransaction({ - to: to.toHex(), + to: to.toChecksum(), value: BigInt(value.toWeiString()), gasPrice: BigInt(gasPrice.toWeiString()), gasLimit: BigInt(21000), @@ -117,7 +117,7 @@ export async function sendBzzTransaction( const feeData = await signer.provider.getFeeData() const gasPrice = feeData.gasPrice || BigInt(0) const bzz = new Contract(BZZ_TOKEN_ADDRESS, bzzABI, signer) - const transaction = await bzz.transfer(to.toChecksum(), value, { gasPrice }) + const transaction = await bzz.transfer(to.toChecksum(), value.toPLURBigInt(), { gasPrice }) const receipt = await transaction.wait(1) if (!receipt) { diff --git a/tests/unit/rpc.spec.ts b/tests/unit/rpc.spec.ts index 4adba107..2009def5 100644 --- a/tests/unit/rpc.spec.ts +++ b/tests/unit/rpc.spec.ts @@ -1,6 +1,6 @@ -import { BZZ } from '@ethersphere/bee-js' +import { BZZ, DAI } from '@ethersphere/bee-js' -import { sendBzzTransaction } from '../../src/utils/rpc' +import { sendBzzTransaction, sendNativeTransaction } from '../../src/utils/rpc' interface MockProvider { getFeeData: jest.Mock @@ -11,12 +11,14 @@ const mockWait = jest.fn() const mockTransfer = jest.fn() const mockGetFeeData = jest.fn() const mockGetNetwork = jest.fn() +const mockSendTransaction = jest.fn() const mockProvider: MockProvider = { getFeeData: mockGetFeeData, getNetwork: mockGetNetwork, } -const value = BZZ.fromDecimalString('1') +const bzzValue = BZZ.fromDecimalString('1') +const daiValue = DAI.fromDecimalString('1') const privateKey = 'FFFF000000000000000000000000000000000000000000000000000000000000' const jsonRpcProvider = 'http://mock-json-rpc-provider' @@ -39,6 +41,7 @@ jest.mock('ethers', () => { class Wallet { provider: MockProvider + sendTransaction = mockSendTransaction constructor(_privateKey: string, provider: MockProvider) { this.provider = provider @@ -64,8 +67,53 @@ describe('sendBzzTransaction', () => { }) it.each(addresses)('sendBzzTransaction to address: %s', async (address: string) => { - await sendBzzTransaction(privateKey, address, value, jsonRpcProvider) + await sendBzzTransaction(privateKey, address, bzzValue, jsonRpcProvider) const to = mockTransfer.mock.calls[0][0] as string expect(to.startsWith('0x')).toBe(true) }) + + it('passes BZZ value as bigint (not BZZ object)', async () => { + await sendBzzTransaction(privateKey, '0x52908400098527886e0f7030069857d2e4169ee7', bzzValue, jsonRpcProvider) + const transferredValue = mockTransfer.mock.calls[0][1] + expect(typeof transferredValue).toBe('bigint') + expect(transferredValue).toBe(bzzValue.toPLURBigInt()) + }) +}) + +describe('sendNativeTransaction', () => { + const addresses = ['52908400098527886e0f7030069857d2e4169ee7', '0x52908400098527886e0f7030069857d2e4169ee7'] + + beforeEach(() => { + jest.clearAllMocks() + mockWait.mockResolvedValue({ status: 1 }) + mockSendTransaction.mockResolvedValue({ wait: mockWait }) + mockGetFeeData.mockResolvedValue({ gasPrice: BigInt(1) }) + mockGetNetwork.mockResolvedValue({ chainId: BigInt(100) }) + }) + + it.each(addresses)('sendNativeTransaction to address: %s passes 0x-prefixed address', async (address: string) => { + await sendNativeTransaction(privateKey, address, daiValue, jsonRpcProvider) + const tx = mockSendTransaction.mock.calls[0][0] as { to: string } + expect(tx.to.startsWith('0x')).toBe(true) + }) + + it('passes DAI value as bigint', async () => { + await sendNativeTransaction(privateKey, '0x52908400098527886e0f7030069857d2e4169ee7', daiValue, jsonRpcProvider) + const tx = mockSendTransaction.mock.calls[0][0] as { value: bigint } + expect(typeof tx.value).toBe('bigint') + expect(tx.value).toBe(BigInt(daiValue.toWeiString())) + }) + + it('uses externalGasPrice when provided', async () => { + const externalGasPrice = DAI.fromWei('9999') + await sendNativeTransaction( + privateKey, + '0x52908400098527886e0f7030069857d2e4169ee7', + daiValue, + jsonRpcProvider, + externalGasPrice, + ) + const tx = mockSendTransaction.mock.calls[0][0] as { gasPrice: bigint } + expect(tx.gasPrice).toBe(BigInt(externalGasPrice.toWeiString())) + }) })