diff --git a/CHANGELOG.md b/CHANGELOG.md index 035418c3..5a1d61f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.13.0] - 2026-10-22 + - Replace the unmaintained web3-onboard wallet library with RainbowKit on wagmi 2 and viem. - Connect through MetaMask, WalletConnect (which also reaches Ledger Live), Base (Coinbase Wallet) and Safe, which connects automatically when the explorer is opened as a Safe App. - Move the contract and services layer from ethers v5 to viem, with ABIs and deployment addresses generated by `@wagmi/cli` (`pnpm codegen`) from the `@cartesi` contract packages. - Resolve ENS avatars on the server through viem's universal resolver. - Move `ethers` and the `@cartesi` contract packages to devDependencies (ABI generation only) and remove `@ethersproject/*`, `eth-chains` and `eth-rpc-errors`; the `elliptic` advisory no longer reaches the production dependency tree. +- Wait for Safe App transactions through the Safe provider, so the confirmation banner resolves once the multisig executes the transaction. +- Replace `react-gtm-module` with the Google Tag Manager component from `@next/third-parties`. ## [3.12.8] - 2026-09-23 @@ -491,7 +495,8 @@ Staking Pools - First release -[unreleased]: https://github.com/cartesi/explorer/compare/v3.12.8...HEAD +[unreleased]: https://github.com/cartesi/explorer/compare/v3.13.0...HEAD +[3.13.0]: https://github.com/cartesi/explorer/compare/v3.13.0...v3.12.8 [3.12.8]: https://github.com/cartesi/explorer/compare/v3.12.8...v3.12.7 [3.12.7]: https://github.com/cartesi/explorer/compare/v3.12.7...v3.12.6 [3.12.6]: https://github.com/cartesi/explorer/compare/v3.12.6...v3.12.5 diff --git a/__tests__/components/pools/steps/CommissionModel.test.tsx b/__tests__/components/pools/steps/CommissionModel.test.tsx index 6dd53b7f..4d346add 100644 --- a/__tests__/components/pools/steps/CommissionModel.test.tsx +++ b/__tests__/components/pools/steps/CommissionModel.test.tsx @@ -377,7 +377,7 @@ describe('CommissionModel step component', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Pool 0xE656584736b1EFC14b4b6c785AA9C23BAc8f41AA created! moving to the next step...', + 'Pool 0xE656584736b1EFC14b4b6c785AA9C23BAc8f41AA created! Moving to the next step...', ), ).toBeInTheDocument(); }); diff --git a/__tests__/components/pools/steps/ENS.test.tsx b/__tests__/components/pools/steps/ENS.test.tsx index f50dfdc6..a6caa115 100644 --- a/__tests__/components/pools/steps/ENS.test.tsx +++ b/__tests__/components/pools/steps/ENS.test.tsx @@ -128,7 +128,9 @@ describe('Pool ENS step', () => { expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.getByText('Pool ENS')).toBeInTheDocument(); expect( - screen.getByText('Registering a ENS domain and setting it up.'), + screen.getByText( + 'Registering an ENS domain and setting it up.', + ), ).toBeInTheDocument(); expect( @@ -147,7 +149,9 @@ describe('Pool ENS step', () => { expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.getByText('Pool ENS')).toBeInTheDocument(); expect( - screen.getByText('Registering a ENS domain and setting it up.'), + screen.getByText( + 'Registering an ENS domain and setting it up.', + ), ).toBeInTheDocument(); expect( @@ -360,7 +364,7 @@ describe('Pool ENS step', () => { ).toBeInTheDocument(); expect( - await screen.findByText('Pool ENS updated with success!'), + await screen.findByText('Pool ENS updated successfully!'), ).toBeInTheDocument(); // Checking even though the step-state router is controlled the router is still called diff --git a/__tests__/components/pools/steps/HireNode.test.tsx b/__tests__/components/pools/steps/HireNode.test.tsx index bdc40443..7234930f 100644 --- a/__tests__/components/pools/steps/HireNode.test.tsx +++ b/__tests__/components/pools/steps/HireNode.test.tsx @@ -559,7 +559,7 @@ describe('HireNode Step', () => { expect( await findByText( alert, - 'Node hired! moving to the next step...', + 'Node hired! Moving to the next step...', ), ).toBeInTheDocument(); }); diff --git a/__tests__/services/transaction.test.ts b/__tests__/services/transaction.test.ts index 6f0c0f23..55e2cc51 100644 --- a/__tests__/services/transaction.test.ts +++ b/__tests__/services/transaction.test.ts @@ -164,10 +164,90 @@ describe('Transaction service', () => { expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash, confirmations: confirmations[Network.MAINNET], + timeout: 0, + checkReplacement: true, }); }); }); + describe('Safe App', () => { + const safeWaitForTransactionReceipt = jest.fn< + Promise, + [{ hash: Hash; confirmations: number }] + >(); + const extend = jest.fn(() => ({ + waitForTransactionReceipt: safeWaitForTransactionReceipt, + })); + + it('should wait for the receipt through the Safe provider when connected as a Safe', async () => { + mockUseWallet.mockReturnValue({ + account, + active: true, + activate: jest.fn(), + deactivate: jest.fn(), + chainId: 1, + isGnosisSafe: true, + publicClient: { waitForTransactionReceipt }, + walletClient: { extend }, + } as any); + const hash = buildTransactionHash(); + safeWaitForTransactionReceipt.mockResolvedValue( + buildContractReceipt(), + ); + const { result } = renderHook(() => useTransaction()); + + const promise = new Promise((resolve) => + act(() => resolve(hash)), + ); + + act(() => { + result.current.set(promise); + }); + + await waitFor(() => expect(result.current.state).toBe('confirmed')); + + expect(result.current.receipt).toBeDefined(); + expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(safeWaitForTransactionReceipt).toHaveBeenCalledWith({ + hash, + confirmations: confirmations[Network.MAINNET], + timeout: 0, + checkReplacement: false, + }); + }); + + it('should fall back to the public client when the Safe wallet client is not ready', async () => { + mockUseWallet.mockReturnValue({ + account, + active: true, + activate: jest.fn(), + deactivate: jest.fn(), + chainId: 1, + isGnosisSafe: true, + publicClient: { waitForTransactionReceipt }, + walletClient: undefined, + } as any); + const hash = buildTransactionHash(); + waitForTransactionReceipt.mockResolvedValue(buildContractReceipt()); + const { result } = renderHook(() => useTransaction()); + + const promise = new Promise((resolve) => + act(() => resolve(hash)), + ); + + act(() => { + result.current.set(promise); + }); + + await waitFor(() => expect(result.current.state).toBe('confirmed')); + + expect(extend).not.toHaveBeenCalled(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash, checkReplacement: false }), + ); + }); + }); + describe('Error stages', () => { it('should change its state to errored when transaction is rejected for any reason', async () => { const { result } = renderHook(() => useTransaction()); diff --git a/__tests__/utils/messages.test.tsx b/__tests__/utils/messages.test.tsx index 415dc67d..19ca030d 100644 --- a/__tests__/utils/messages.test.tsx +++ b/__tests__/utils/messages.test.tsx @@ -73,7 +73,7 @@ describe('useMessages hook', () => { const { result } = renderHook(() => useMessages('node.authorize.success'), ); - expect(result.current).toEqual('Node authorized with success!'); + expect(result.current).toEqual('Node authorized successfully!'); }); describe('PoS v2 migration steps', () => { @@ -296,7 +296,7 @@ describe('useMessages hook', () => { useMessages('pool.update.v2.success'), ); expect(result.current).toEqual( - 'Pool updated to use PoS v2 with success!', + 'Pool updated to use PoS v2 successfully!', ); }); diff --git a/package.json b/package.json index fc21efe1..90f0303c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@chakra-ui/react": "^3.37.0", "@emotion/react": "^11.14.0", "@libsql/client": "^0.18.0", + "@next/third-parties": "^16.4.1", "@rainbow-me/rainbowkit": "^2.2.11", "@supercharge/promise-pool": "3.3.0", "@tanstack/react-query": "^5.104.0", @@ -51,7 +52,6 @@ "react-chartjs-2": "^5.3.1", "react-dom": "^19.3.0", "react-ga4": "^3.0.1", - "react-gtm-module": "^2.0.11", "react-hook-form": "^7.88.0", "react-icons": "^5.7.0", "react-is": "^19.3.0", @@ -85,7 +85,6 @@ "@types/node": "^24", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@types/react-gtm-module": "^2.0.4", "@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/parser": "^8.70.0", "@wagmi/cli": "^2.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55afea1d..8a74da3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: '@libsql/client': specifier: ^0.18.0 version: 0.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@next/third-parties': + specifier: ^16.4.1 + version: 16.4.1(next@16.3.5(@babel/core@8.0.5)(@types/node@24.13.4)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) '@rainbow-me/rainbowkit': specifier: ^2.2.11 version: 2.2.11(@tanstack/react-query@5.104.0(react@19.3.0))(@types/react@19.3.0)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)(viem@2.57.2(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.5(@tanstack/query-core@5.104.0)(@tanstack/react-query@5.104.0(react@19.3.0))(@types/react@19.3.0)(bufferutil@4.0.9)(debug@4.4.3(supports-color@4.4.0))(encoding@0.1.13)(immer@11.1.18)(react@19.3.0)(supports-color@4.4.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.57.2(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)) @@ -94,9 +97,6 @@ importers: react-ga4: specifier: ^3.0.1 version: 3.0.1 - react-gtm-module: - specifier: ^2.0.11 - version: 2.0.11 react-hook-form: specifier: ^7.88.0 version: 7.88.0(react@19.3.0) @@ -191,9 +191,6 @@ importers: '@types/react-dom': specifier: ^19.3.0 version: 19.3.0(@types/react@19.3.0) - '@types/react-gtm-module': - specifier: ^2.0.4 - version: 2.0.4 '@typescript-eslint/eslint-plugin': specifier: ^8.70.0 version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(supports-color@4.4.0))(supports-color@4.4.0)(typescript@6.0.3))(eslint@10.10.0(supports-color@4.4.0))(supports-color@4.4.0)(typescript@6.0.3) @@ -2250,6 +2247,12 @@ packages: cpu: [x64] os: [win32] + '@next/third-parties@16.4.1': + resolution: {integrity: sha512-gN09urIDYH1DLhBxd/xfuaG0KCN3ynVlwwGsTk8uszUY2vM59Ja2lFI1uPQ1v2tXT+xMNs6MxXpfC6NTNmm4JA==} + peerDependencies: + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + '@noble/ciphers@1.2.1': resolution: {integrity: sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==} engines: {node: ^14.21.3 || >=16} @@ -3923,9 +3926,6 @@ packages: peerDependencies: '@types/react': ^19.3.0 - '@types/react-gtm-module@2.0.4': - resolution: {integrity: sha512-5wPMWsUE5AI6O0B0K1/zbs0rFHBKu+7NWXQwDXhqvA12ooLD6W1AYiWZqR4UiOd7ixZDV1H5Ys301zEsqyIfNg==} - '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} @@ -9734,9 +9734,6 @@ packages: react-ga4@3.0.1: resolution: {integrity: sha512-GyCc01bSheWXjzGDyHsXMOqk/SP5Cf/JrcJTg4hcpKx4eeSwaJKpJUc+ipF4ffLTZkmabmf3ZGBv4OKHTXNXyA==} - react-gtm-module@2.0.11: - resolution: {integrity: sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==} - react-hook-form@7.88.0: resolution: {integrity: sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==} engines: {node: '>=18.0.0'} @@ -10632,6 +10629,9 @@ packages: text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + third-party-capital@1.0.20: + resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} + thread-stream@0.15.2: resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} @@ -14360,6 +14360,12 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.5': optional: true + '@next/third-parties@16.4.1(next@16.3.5(@babel/core@8.0.5)(@types/node@24.13.4)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + dependencies: + next: 16.3.5(@babel/core@8.0.5)(@types/node@24.13.4)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + third-party-capital: 1.0.20 + '@noble/ciphers@1.2.1': {} '@noble/ciphers@1.3.0': {} @@ -16184,8 +16190,6 @@ snapshots: dependencies: '@types/react': 19.3.0 - '@types/react-gtm-module@2.0.4': {} - '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -24182,8 +24186,6 @@ snapshots: react-ga4@3.0.1: {} - react-gtm-module@2.0.11: {} - react-hook-form@7.88.0(react@19.3.0): dependencies: react: 19.3.0 @@ -25311,6 +25313,8 @@ snapshots: text-table@0.2.0: {} + third-party-capital@1.0.20: {} + thread-stream@0.15.2: dependencies: real-require: 0.1.0 diff --git a/src/components/node/NodeManage.tsx b/src/components/node/NodeManage.tsx index 6569055a..e9c72cdb 100644 --- a/src/components/node/NodeManage.tsx +++ b/src/components/node/NodeManage.tsx @@ -412,7 +412,7 @@ const ManageNode: FC = () => { setWithdrawAlertActive(false)} /> @@ -422,7 +422,7 @@ const ManageNode: FC = () => { setStakeAlertActive(false)} /> diff --git a/src/components/node/steps/HireNode.tsx b/src/components/node/steps/HireNode.tsx index 5a1f0a9e..e80cbcfe 100644 --- a/src/components/node/steps/HireNode.tsx +++ b/src/components/node/steps/HireNode.tsx @@ -123,7 +123,7 @@ const HireNode = ({ diff --git a/src/components/pools/steps/ENS.tsx b/src/components/pools/steps/ENS.tsx index 3e22e708..87065c3a 100644 --- a/src/components/pools/steps/ENS.tsx +++ b/src/components/pools/steps/ENS.tsx @@ -188,7 +188,7 @@ const EthereumNameServer = ({ return ( = ({ @@ -164,7 +164,7 @@ export const Staking: FC = ({ diff --git a/src/providers/TrackerProvider.tsx b/src/providers/TrackerProvider.tsx index f4ecc567..548fa622 100644 --- a/src/providers/TrackerProvider.tsx +++ b/src/providers/TrackerProvider.tsx @@ -1,23 +1,22 @@ 'use client'; import { GA4TrackerProvider } from '../contexts/ga4Tracker'; -import TagManager from 'react-gtm-module'; -import { FC, ReactNode, useEffect } from 'react'; +import { GoogleTagManager } from '@next/third-parties/google'; +import { FC, ReactNode } from 'react'; interface ProvidersProps { children: ReactNode; } -const TrackerProvider: FC = ({ children }) => { - useEffect(() => { - if (process.env.NODE_ENV === 'production') { - TagManager.initialize({ - gtmId: process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, - }); - } - }, []); +const gtmId = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID; - return {children}; -}; +const TrackerProvider: FC = ({ children }) => ( + <> + {process.env.NODE_ENV === 'production' && gtmId && ( + + )} + {children} + +); export default TrackerProvider; diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 402818dd..39ce8622 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -21,11 +21,13 @@ import { propEq, T, } from 'lodash/fp'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BaseError, ContractFunctionRevertedError, Hash, + PublicClient, + publicActions, TransactionReceipt, } from 'viem'; import { useWallet } from '../components/wallet'; @@ -110,10 +112,24 @@ export function extractErrorMessage(error: unknown): string { return String(error); } +type ReceiptClient = Pick; + export function useTransaction( resultResolver?: (receipt: TransactionReceipt) => R, ): Transaction { - const { chainId, publicClient } = useWallet(); + const { chainId, publicClient, walletClient, isGnosisSafe } = useWallet(); + // Inside a Safe App the wallet returns the Safe transaction hash, which only + // the Safe provider (the wallet client's transport) can map to the receipt + // once the multisig executes it. Poll through it in that case. + const receiptClient = useMemo( + () => + isGnosisSafe && walletClient + ? (walletClient.extend( + publicActions, + ) as unknown as ReceiptClient) + : publicClient, + [isGnosisSafe, walletClient, publicClient], + ); const [acknowledged, setAcknowledged] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(); @@ -142,11 +158,16 @@ export function useTransaction( const update = async () => { setError(null); try { - // wait for confirmations + // wait for confirmations, with no deadline (a Safe can take + // hours to execute). The Safe provider answers + // eth_getTransactionByHash with a placeholder, so viem's + // replaced-transaction check is pointless there. const required = confirmations[transaction.chainId] ?? 1; - const receipt = await publicClient.waitForTransactionReceipt({ + const receipt = await receiptClient.waitForTransactionReceipt({ hash: transaction.hash, confirmations: required, + timeout: 0, + checkReplacement: !isGnosisSafe, }); if (receipt.status === 'reverted') { throw new Error('Transaction reverted'); @@ -162,7 +183,7 @@ export function useTransaction( setError(extractErrorMessage(e)); } }; - if (transaction && publicClient) { + if (transaction && receiptClient) { update(); } }, [transaction]); diff --git a/src/stories/Step.stories.tsx b/src/stories/Step.stories.tsx index 13db9ea9..e98ce832 100644 --- a/src/stories/Step.stories.tsx +++ b/src/stories/Step.stories.tsx @@ -132,7 +132,7 @@ export const Optional: Story = { args: { stepNumber: 1, title: 'Pool ENS', - subtitle: 'Registering a ENS domain and setting it up.', + subtitle: 'Registering an ENS domain and setting it up.', status: StepStatus.ACTIVE, optionalText: 'This step could be skipped.', }, diff --git a/src/utils/messages.tsx b/src/utils/messages.tsx index c0fad055..aedb2ee0 100644 --- a/src/utils/messages.tsx +++ b/src/utils/messages.tsx @@ -33,7 +33,7 @@ const messages = { retired: () => 'This node is already retired.', authorize: { authorizing: () => 'Authorizing node to use new PoS...', - success: () => 'Node authorized with success!', + success: () => 'Node authorized successfully!', fail: () => 'Node authorization failed!', pos: { steps: { @@ -102,7 +102,7 @@ const messages = { }, set: { ens: { - success: () => 'Pool ENS updated with success!', + success: () => 'Pool ENS updated successfully!', fail: () => 'Pool ENS update failed!', update: () => 'Updating pool ENS...', }, @@ -113,7 +113,7 @@ const messages = { }, update: { v2: { - success: () => 'Pool updated to use PoS v2 with success!', + success: () => 'Pool updated to use PoS v2 successfully!', fail: () => 'Pool update failed!', update: () => 'Updating pool to use PoS v2...', },