diff --git a/.env.test b/.env.test index 78ac09f8..ed2cd47f 100644 --- a/.env.test +++ b/.env.test @@ -33,3 +33,20 @@ NEXT_PUBLIC_AXELAR_RPC_URL= # Minimum voting period NEXT_PUBLIC_MINIMUM_VOTING_PERIOD=3m + + +# Production (US) +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +NEXT_PUBLIC_POSTHOG_ASSETS_HOST=https://us-assets.i.posthog.com + +# Production (EU) +#NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com +#NEXT_PUBLIC_POSTHOG_ASSETS_HOST=https://eu-assets.i.posthog.com + +# Self-hosted +#NEXT_PUBLIC_POSTHOG_HOST=https://posthog.yourcompany.com +NEXT_PUBLIC_POSTHOG_ASSETS_HOST=https://posthog-assets.yourcompany.com + +# Local development/testing +#NEXT_PUBLIC_POSTHOG_HOST=http://localhost:8000 +#NEXT_PUBLIC_POSTHOG_ASSETS_HOST=http://localhost:8001 \ No newline at end of file diff --git a/bun.lock b/bun.lock index 8a3d7875..58cae512 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,8 @@ "octokit": "4.1.2", "parse-duration": "2.1.3", "postcss": "8.5.3", + "posthog-js": "^1.249.5", + "posthog-node": "^4.18.0", "pretty-format": "29.7.0", "qrcode": "1.5.4", "react": "19.1.0", @@ -1639,6 +1641,8 @@ "copyfiles": ["copyfiles@2.4.1", "", { "dependencies": { "glob": "^7.0.5", "minimatch": "^3.0.3", "mkdirp": "^1.0.4", "noms": "0.0.0", "through2": "^2.0.1", "untildify": "^4.0.0", "yargs": "^16.1.0" }, "bin": { "copyfiles": "copyfiles", "copyup": "copyfiles" } }, "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg=="], + "core-js": ["core-js@3.43.0", "", {}, "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cosmiconfig": ["cosmiconfig@6.0.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.1.0", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.7.2" } }, "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg=="], @@ -2555,6 +2559,10 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "posthog-js": ["posthog-js@1.249.5", "", { "dependencies": { "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" }, "peerDependencies": { "@rrweb/types": "2.0.0-alpha.17", "rrweb-snapshot": "2.0.0-alpha.17" }, "optionalPeers": ["@rrweb/types", "rrweb-snapshot"] }, "sha512-ynB2bcSZz91xF36Aun2OgAvJ37WPjp8hrUHplZJTdJKhaX7j63CePR+Ved6NVO4YqwhCOVqepxwGGJXG4ROBeA=="], + + "posthog-node": ["posthog-node@4.18.0", "", { "dependencies": { "axios": "^1.8.2" } }, "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw=="], + "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "preact": ["preact@10.26.4", "", {}, "sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w=="], @@ -3019,6 +3027,8 @@ "wagmi": ["wagmi@2.14.13", "", { "dependencies": { "@wagmi/connectors": "5.7.9", "@wagmi/core": "2.16.5", "use-sync-external-store": "1.4.0" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18", "typescript": ">=5.0.4", "viem": "2.x" }, "optionalPeers": ["typescript"] }, "sha512-CX+NpyTczVIST5DqLtasKZ3VrhImKQZ9XM9aDUVgOM46MRN/CykgGGAJfuIfpQ80LZ91GCY+JuitGknHUz7MNQ=="], + "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], + "web3-eth-abi": ["web3-eth-abi@1.3.6", "", { "dependencies": { "@ethersproject/abi": "5.0.7", "underscore": "1.12.1", "web3-utils": "1.3.6" } }, "sha512-Or5cRnZu6WzgScpmbkvC6bfNxR26hqiKK4i8sMPFeTUABQcb/FU3pBj7huBLYbp9dH+P5W79D2MqwbWwjj9DoQ=="], "web3-utils": ["web3-utils@1.3.6", "", { "dependencies": { "bn.js": "^4.11.9", "eth-lib": "0.2.8", "ethereum-bloom-filters": "^1.0.6", "ethjs-unit": "0.1.6", "number-to-bn": "1.7.0", "randombytes": "^2.1.0", "underscore": "1.12.1", "utf8": "3.0.0" } }, "sha512-hHatFaQpkQgjGVER17gNx8u1qMyaXFZtM0y0XLGH1bzsjMPlkMPLRcYOrZ00rOPfTEuYFOdrpGOqZXVmGrMZRg=="], @@ -3645,6 +3655,8 @@ "parse-asn1/hash-base": ["hash-base@3.0.5", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1" } }, "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg=="], + "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "psl/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], diff --git a/config/env.ts b/config/env.ts index a978b922..c625bfc2 100644 --- a/config/env.ts +++ b/config/env.ts @@ -55,6 +55,10 @@ const env = { * By default, it is set to 30 minutes. */ minimumVotingPeriod: parseDuration(process.env.NEXT_PUBLIC_MINIMUM_VOTING_PERIOD, 1800), + + // PostHog + posthogKey: process.env.NEXT_PUBLIC_POSTHOG_KEY ?? '', + posthogApiHost: process.env.NEXT_PUBLIC_POSTHOG_API_HOST ?? '', }; export default env; diff --git a/hooks/__tests__/usePostHog.test.tsx b/hooks/__tests__/usePostHog.test.tsx new file mode 100644 index 00000000..d7abc666 --- /dev/null +++ b/hooks/__tests__/usePostHog.test.tsx @@ -0,0 +1,524 @@ +import { cleanup, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, jest, test } from 'bun:test'; +import React from 'react'; + +import { clearAllMocks, mockModule, mockRouter } from '@/tests'; +import { renderWithChainProvider } from '@/tests/render'; + +import { useManifestPostHog } from '../usePostHog'; + +// Mock the config/env module +const mockEnv = { + chain: 'manifesttestnet', + chainId: 'manifest-ledger-testnet', +}; + +function TestComponent({ mockReturnValues }: { mockReturnValues?: any[] }) { + const { posthog, trackTransaction, isReady } = useManifestPostHog(); + + return ( +
+
{isReady ? 'ready' : 'not ready'}
+ + +
{posthog ? 'available' : 'not available'}
+
+ ); +} + +describe('useManifestPostHog', () => { + let mockPostHog: any; + let mockWallet: any; + + beforeEach(() => { + // Mock Next.js router + mockRouter(); + + // Mock the env config + mockModule('@/config/env', () => mockEnv); + + // Create mock PostHog + mockPostHog = { + identify: jest.fn(), + capture: jest.fn(), + reset: jest.fn(), + setPersonProperties: jest.fn(), + }; + + // Create mock wallet + mockWallet = { + prettyName: 'Test Wallet', + mode: 'extension', + }; + + // Mock PostHog hook + mockModule('posthog-js/react', () => ({ + usePostHog: jest.fn().mockReturnValue(mockPostHog), + })); + + // Mock cosmos-kit useChain + mockModule('@cosmos-kit/react', () => ({ + useChain: jest.fn().mockReturnValue({ + address: 'manifest1test', + wallet: mockWallet, + isWalletConnected: true, + }), + })); + }); + + afterEach(() => { + cleanup(); + clearAllMocks(); + jest.clearAllMocks(); + }); + + test('identifies user when wallet connects', async () => { + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(wrapper.getByTestId('is-ready')).toHaveTextContent('ready'); + }); + + expect(mockPostHog.identify).toHaveBeenCalledWith('manifest1test', { + wallet_address: 'manifest1test', + wallet_name: 'Test Wallet', + wallet_mode: 'extension', + chain_id: 'manifest-ledger-testnet', + chain_name: 'manifesttestnet', + last_connected: expect.any(String), + }); + + expect(mockPostHog.capture).toHaveBeenCalledWith('wallet_connected', { + wallet_address: 'manifest1test', + wallet_name: 'Test Wallet', + wallet_mode: 'extension', + chain_id: 'manifest-ledger-testnet', + chain_name: 'manifesttestnet', + }); + }); + + test('re-identifies when wallet changes', async () => { + // Mock a sequence where wallet changes from one to another + const mockUseChain = jest.fn(); + const firstWallet = { + prettyName: 'First Wallet', + mode: 'extension', + }; + const secondWallet = { + prettyName: 'Second Wallet', + mode: 'mobile', + }; + + // Sequence: connected with first wallet -> connected with second wallet + mockUseChain + .mockReturnValueOnce({ + address: 'manifest1test', + wallet: firstWallet, + isWalletConnected: true, + }) + .mockReturnValue({ + address: 'manifest1test', + wallet: secondWallet, + isWalletConnected: true, + }); + + mockModule( + '@cosmos-kit/react', + () => ({ + useChain: mockUseChain, + }), + true + ); + + const { rerender } = renderWithChainProvider(); + + // First render - connected with first wallet + await waitFor(() => { + expect(mockPostHog.identify).toHaveBeenCalledWith('manifest1test', { + wallet_address: 'manifest1test', + wallet_name: 'First Wallet', + wallet_mode: 'extension', + chain_id: 'manifest-ledger-testnet', + chain_name: 'manifesttestnet', + last_connected: expect.any(String), + }); + }); + + // Clear mocks to test wallet change re-identification + mockPostHog.identify.mockClear(); + mockPostHog.capture.mockClear(); + + // Second render - wallet changed (should trigger re-identification) + rerender(); + + await waitFor(() => { + expect(mockPostHog.identify).toHaveBeenCalledWith('manifest1test', { + wallet_address: 'manifest1test', + wallet_name: 'Second Wallet', + wallet_mode: 'mobile', + chain_id: 'manifest-ledger-testnet', + chain_name: 'manifesttestnet', + last_connected: expect.any(String), + }); + expect(mockPostHog.capture).toHaveBeenCalledWith( + 'wallet_connected', + expect.objectContaining({ + wallet_name: 'Second Wallet', + wallet_mode: 'mobile', + }) + ); + }); + }); + + test('handles wallet disconnection', async () => { + // Create a controllable test component that can simulate disconnection + let mockChainData = { + address: 'manifest1test' as string | null, + wallet: mockWallet as any, + isWalletConnected: true, + }; + + const ControllableDisconnectionTestComponent = () => { + const [, forceUpdate] = React.useState({}); + React.useEffect(() => { + // Store the force update function globally so we can trigger it + (window as any).forceDisconnectionUpdate = () => forceUpdate({}); + }, []); + + // Mock useChain to return our controllable data + mockModule( + '@cosmos-kit/react', + () => ({ + useChain: jest.fn().mockReturnValue(mockChainData), + }), + true + ); + + const { posthog, isReady } = useManifestPostHog(); + return ( +
+
{isReady ? 'ready' : 'not ready'}
+
{posthog ? 'available' : 'not available'}
+
+ ); + }; + + renderWithChainProvider(); + + // Wait for initial connection + await waitFor(() => { + expect(mockPostHog.identify).toHaveBeenCalled(); + }); + + // Clear mocks to test disconnection behavior + mockPostHog.capture.mockClear(); + mockPostHog.reset.mockClear(); + + // Simulate wallet disconnection + mockChainData = { + address: null, + wallet: null, + isWalletConnected: false, + }; + (window as any).forceDisconnectionUpdate(); + + await waitFor(() => { + expect(mockPostHog.capture).toHaveBeenCalledWith('wallet_disconnected', { + chain_id: 'manifest-ledger-testnet', + chain_name: 'manifesttestnet', + previous_address: 'manifest1test', + }); + expect(mockPostHog.reset).toHaveBeenCalled(); + }); + }); + + test('tracks successful transaction', async () => { + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(wrapper.getByTestId('is-ready')).toHaveTextContent('ready'); + }); + + wrapper.getByTestId('track-success').click(); + + expect(mockPostHog.capture).toHaveBeenCalledWith('transaction_success', { + success: true, + transactionHash: 'test-hash', + chainId: 'manifest-ledger-testnet', + messageTypes: ['/cosmos.bank.v1beta1.MsgSend'], + fee: { amount: '1000', denom: 'umfx' }, + memo: 'test memo', + gasUsed: '100000', + gasWanted: '110000', + height: '12345', + wallet_address: 'manifest1test', + wallet_name: 'Test Wallet', + timestamp: expect.any(String), + $groups: { + chain: 'manifest-ledger-testnet', + wallet_type: 'Test Wallet', + }, + }); + + expect(mockPostHog.setPersonProperties).toHaveBeenCalledWith({ + last_successful_transaction: expect.any(String), + last_transaction_hash: 'test-hash', + }); + }); + + test('tracks failed transaction', async () => { + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(wrapper.getByTestId('is-ready')).toHaveTextContent('ready'); + }); + + wrapper.getByTestId('track-failure').click(); + + expect(mockPostHog.capture).toHaveBeenCalledWith('transaction_failed', { + success: false, + transactionHash: 'test-hash-fail', + chainId: 'manifest-ledger-testnet', + messageTypes: ['/cosmos.bank.v1beta1.MsgSend'], + error: 'Transaction failed', + wallet_address: 'manifest1test', + wallet_name: 'Test Wallet', + timestamp: expect.any(String), + $groups: { + chain: 'manifest-ledger-testnet', + wallet_type: 'Test Wallet', + }, + }); + + expect(mockPostHog.setPersonProperties).toHaveBeenCalledWith({ + last_failed_transaction: expect.any(String), + last_error: 'Transaction failed', + }); + }); + + test('does not track when PostHog is not available', async () => { + // Mock PostHog as null + mockModule( + 'posthog-js/react', + () => ({ + usePostHog: jest.fn().mockReturnValue(null), + }), + true + ); + + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(wrapper.getByTestId('posthog-available')).toHaveTextContent('not available'); + }); + + wrapper.getByTestId('track-success').click(); + + // Should not have called any PostHog methods + expect(mockPostHog.capture).not.toHaveBeenCalled(); + expect(mockPostHog.setPersonProperties).not.toHaveBeenCalled(); + }); + + test('updates person properties when address matches', async () => { + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(wrapper.getByTestId('is-ready')).toHaveTextContent('ready'); + }); + + // Track a transaction - this should work normally + wrapper.getByTestId('track-success').click(); + + // Should capture transaction + expect(mockPostHog.capture).toHaveBeenCalledWith( + 'transaction_success', + expect.objectContaining({ + wallet_address: 'manifest1test', + }) + ); + + // Should update person properties since address matches + expect(mockPostHog.setPersonProperties).toHaveBeenCalledWith({ + last_successful_transaction: expect.any(String), + last_transaction_hash: 'test-hash', + }); + }); + + test('handles wallet without prettyName', async () => { + // Mock wallet without prettyName + mockModule( + '@cosmos-kit/react', + () => ({ + useChain: jest.fn().mockReturnValue({ + address: 'manifest1test', + wallet: { mode: 'extension' }, // No prettyName + isWalletConnected: true, + }), + }), + true + ); + + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(mockPostHog.identify).toHaveBeenCalledWith('manifest1test', { + wallet_address: 'manifest1test', + wallet_name: null, + wallet_mode: 'extension', + chain_id: 'manifest-ledger-testnet', + chain_name: 'manifesttestnet', + last_connected: expect.any(String), + }); + }); + + wrapper.getByTestId('track-success').click(); + + expect(mockPostHog.capture).toHaveBeenLastCalledWith( + 'transaction_success', + expect.objectContaining({ + wallet_name: undefined, + $groups: { + chain: 'manifest-ledger-testnet', + wallet_type: 'unknown', + }, + }) + ); + }); + + test('is not ready when wallet is not connected', async () => { + // Mock wallet as not connected + mockModule( + '@cosmos-kit/react', + () => ({ + useChain: jest.fn().mockReturnValue({ + address: null, + wallet: null, + isWalletConnected: false, + }), + }), + true + ); + + const wrapper = renderWithChainProvider(); + + await waitFor(() => { + expect(wrapper.getByTestId('is-ready')).toHaveTextContent('not ready'); + }); + }); + + test('does not re-identify same address and wallet (comprehensive test)', async () => { + // Create a controllable test component that can simulate state changes + let mockChainData = { + address: null as string | null, + wallet: null as any, + isWalletConnected: false, + }; + + const ControllableTestComponent = () => { + const [, forceUpdate] = React.useState({}); + React.useEffect(() => { + // Store the force update function globally so we can trigger it + (window as any).forceTestUpdate = () => forceUpdate({}); + }, []); + + // Mock useChain to return our controllable data + mockModule( + '@cosmos-kit/react', + () => ({ + useChain: jest.fn().mockReturnValue(mockChainData), + }), + true + ); + + const { posthog, isReady } = useManifestPostHog(); + return ( +
+
{isReady ? 'ready' : 'not ready'}
+
{posthog ? 'available' : 'not available'}
+
+ ); + }; + + renderWithChainProvider(); + + // Initial state - not connected + await waitFor(() => { + expect(mockPostHog.identify).not.toHaveBeenCalled(); + }); + + // Connect wallet - should trigger identification + mockChainData = { + address: 'manifest1test', + wallet: mockWallet, + isWalletConnected: true, + }; + (window as any).forceTestUpdate(); + + await waitFor(() => { + expect(mockPostHog.identify).toHaveBeenCalledTimes(1); + expect(mockPostHog.capture).toHaveBeenCalledWith('wallet_connected', expect.any(Object)); + }); + + // Clear mocks + mockPostHog.identify.mockClear(); + mockPostHog.capture.mockClear(); + + // Trigger multiple state updates with same wallet - should NOT re-identify + (window as any).forceTestUpdate(); + (window as any).forceTestUpdate(); + (window as any).forceTestUpdate(); + + // Should not have called identify again + expect(mockPostHog.identify).toHaveBeenCalledTimes(0); + expect(mockPostHog.capture).not.toHaveBeenCalledWith('wallet_connected', expect.any(Object)); + + // Change wallet - should trigger re-identification + mockChainData = { + address: 'manifest1test', + wallet: { prettyName: 'Different Wallet', mode: 'mobile' }, + isWalletConnected: true, + }; + (window as any).forceTestUpdate(); + + await waitFor(() => { + expect(mockPostHog.identify).toHaveBeenCalledTimes(1); + expect(mockPostHog.identify).toHaveBeenCalledWith( + 'manifest1test', + expect.objectContaining({ + wallet_name: 'Different Wallet', + wallet_mode: 'mobile', + }) + ); + }); + }); +}); diff --git a/hooks/__tests__/useTx.test.tsx b/hooks/__tests__/useTx.test.tsx new file mode 100644 index 00000000..f556bd1b --- /dev/null +++ b/hooks/__tests__/useTx.test.tsx @@ -0,0 +1,554 @@ +import { cleanup, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, jest, test } from 'bun:test'; +import React from 'react'; + +import { clearAllMocks, mockModule, mockRouter } from '@/tests'; +import { renderWithWeb3AuthProvider } from '@/tests/render'; + +import { useTx } from '../useTx'; + +// Mock the config/env module +const mockEnv = { + osmosisChain: 'osmosis-1', + osmosisExplorerUrl: 'https://osmosis.explorer.com', + explorerUrl: 'https://testnet.manifest.explorers.guru', +}; + +interface TestComponentProps { + chainName?: string; + promptId?: string; +} + +function TestComponent({ chainName = 'manifesttestnet', promptId }: TestComponentProps) { + const { tx, isSigning } = useTx(chainName, promptId); + + const handleSimulate = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'test' } }], { + simulate: true, + }); + }; + + const handleTxWithFeeFunction = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'test' } }], { + fee: async () => ({ amount: [{ amount: '1000', denom: 'umfx' }], gas: '200000' }), + returnError: true, + }); + }; + + const handleTxWithNoFee = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'test' } }], { + returnError: true, + }); + }; + + const handleGroupProposal = () => { + tx( + [ + { + typeUrl: '/cosmos.group.v1.MsgSubmitProposal', + value: { groupPolicyAddress: 'test-policy-address' }, + }, + ], + { returnError: true } + ); + }; + + const handleFailedTx = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'fail' } }], { + returnError: true, + }); + }; + + const handleTxWithError = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'error' } }], { + returnError: true, + }); + }; + + const handleSimulationError = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'sim-error' } }], { + simulate: true, + returnError: true, + }); + }; + + const handleTxNoErrorToast = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'fail' } }], { + showToastOnErrors: false, + returnError: true, + }); + }; + + return ( +
+
{isSigning ? 'signing' : 'not signing'}
+ + + + + + + + +
+ ); +} + +describe('useTx', () => { + let mockClient: any; + let mockSetToastMessage: any; + let mockTrackTransaction: any; + let mockWeb3AuthContext: any; + + beforeEach(() => { + // Mock Next.js router + mockRouter(); + + // Mock the env config + mockModule('@/config/env', () => ({ default: mockEnv })); + + // Create mock client + mockClient = { + simulate: jest.fn().mockResolvedValue({ gasInfo: { gasUsed: 100000 } }), + sign: jest + .fn() + .mockResolvedValue({ bodyBytes: new Uint8Array(), authInfoBytes: new Uint8Array() }), + broadcastTx: jest.fn().mockResolvedValue({ + code: 0, + transactionHash: 'test-hash', + gasUsed: 100000, + gasWanted: 110000, + height: 12345, + events: [], + }), + }; + + // Mock toast + mockSetToastMessage = jest.fn(); + + // Mock PostHog tracking + mockTrackTransaction = jest.fn(); + + // Mock Web3Auth context + mockWeb3AuthContext = { + isSigning: false, + setIsSigning: jest.fn(), + setPromptId: jest.fn(), + }; + + // Mock dependencies + mockModule('@cosmos-kit/react', () => ({ + useChain: jest.fn().mockReturnValue({ + address: 'manifest1test', + getSigningStargateClient: jest.fn().mockResolvedValue(mockClient), + estimateFee: jest.fn().mockResolvedValue({ + amount: [{ amount: '1000', denom: 'umfx' }], + gas: '200000', + }), + }), + })); + + mockModule('@/contexts/toastContext', () => ({ + useToast: jest.fn().mockReturnValue({ + setToastMessage: mockSetToastMessage, + }), + })); + + mockModule('@/hooks/usePostHog', () => ({ + useManifestPostHog: jest.fn().mockReturnValue({ + trackTransaction: mockTrackTransaction, + }), + })); + + // Mock cosmjs functions + mockModule('@cosmjs/stargate', () => ({ + isDeliverTxSuccess: jest.fn().mockReturnValue(true), + })); + + mockModule('cosmjs-types/cosmos/tx/v1beta1/tx', () => ({ + TxRaw: { + encode: jest.fn().mockReturnValue({ + finish: jest.fn().mockReturnValue(new Uint8Array()), + }), + }, + })); + }); + + afterEach(() => { + cleanup(); + clearAllMocks(); + jest.clearAllMocks(); + }); + + test('handles wallet not connected', async () => { + // Mock no address + mockModule.force('@cosmos-kit/react', () => ({ + useChain: jest.fn().mockReturnValue({ + address: null, + getSigningStargateClient: jest.fn(), + estimateFee: jest.fn(), + }), + })); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('simulate').click(); + + await waitFor(() => { + expect(mockSetToastMessage).toHaveBeenCalledWith({ + type: 'alert-error', + title: 'Wallet not connected', + description: 'Please connect your wallet.', + bgColor: '#e74c3c', + }); + }); + }); + + test('handles successful simulation', async () => { + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('simulate').click(); + + await waitFor(() => { + expect(mockClient.simulate).toHaveBeenCalledWith( + 'manifest1test', + [{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'test' } }], + '' + ); + }); + }); + + test('handles simulation error with message extraction', async () => { + mockClient.simulate.mockRejectedValueOnce( + new Error('message index: 0: insufficient funds [cosmos.bank.v1beta1.MsgSend]') + ); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('simulation-error').click(); + + await waitFor(() => { + expect(mockSetToastMessage).toHaveBeenCalledWith({ + type: 'alert-error', + title: 'Simulation Failed', + description: 'insufficient funds', + bgColor: '#e74c3c', + }); + }); + }); + + test('handles simulation error with account does not exist', async () => { + mockClient.simulate.mockRejectedValueOnce( + new Error("Account 'manifest1test' does not exist on chain") + ); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('simulation-error').click(); + + await waitFor(() => { + expect(mockSetToastMessage).toHaveBeenCalledWith({ + type: 'alert-error', + title: 'Simulation Failed', + description: "Account 'manifest1test' does not exist on chain", + bgColor: '#e74c3c', + }); + }); + }); + + test('handles fee function', async () => { + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('tx-fee-function').click(); + + await waitFor(() => { + expect(mockClient.sign).toHaveBeenCalledWith( + 'manifest1test', + [{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'test' } }], + { amount: [{ amount: '1000', denom: 'umfx' }], gas: '200000' }, + '' + ); + }); + }); + + test('handles fee estimation failure', async () => { + // Mock estimateFee to return null + mockModule.force('@cosmos-kit/react', () => ({ + useChain: jest.fn().mockReturnValue({ + address: 'manifest1test', + getSigningStargateClient: jest.fn().mockResolvedValue(mockClient), + estimateFee: jest.fn().mockResolvedValue(null), + }), + })); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('tx-no-fee').click(); + + await waitFor(() => { + // Should not proceed to sign since fee estimation failed + expect(mockClient.sign).not.toHaveBeenCalled(); + }); + }); + + test('tracks successful transaction', async () => { + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('tx-fee-function').click(); + + await waitFor(() => { + expect(mockTrackTransaction).toHaveBeenCalledWith({ + success: true, + transactionHash: 'test-hash', + chainId: 'manifesttestnet', + messageTypes: ['/cosmos.bank.v1beta1.MsgSend'], + fee: { + amount: '1000', + denom: 'umfx', + }, + memo: undefined, + gasUsed: '100000', + gasWanted: '110000', + height: '12345', + }); + }); + }); + + test('handles group proposal submission', async () => { + // Mock successful response with group proposal event + mockClient.broadcastTx.mockResolvedValueOnce({ + code: 0, + transactionHash: 'test-hash', + gasUsed: 100000, + gasWanted: 110000, + height: 12345, + events: [ + { + type: 'cosmos.group.v1.EventSubmitProposal', + attributes: [{ key: 'proposal_id', value: '"123"' }], + }, + ], + }); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('group-proposal').click(); + + await waitFor(() => { + // Verify we got both broadcasting and success toasts + expect(mockSetToastMessage).toHaveBeenCalledTimes(2); + + // Check the first call was broadcasting toast + expect(mockSetToastMessage).toHaveBeenNthCalledWith(1, { + type: 'alert-info', + title: 'Broadcasting', + description: 'Transaction is signed and is being broadcasted...', + bgColor: '#3498db', + }); + + // Check the second call was the success toast + expect(mockSetToastMessage).toHaveBeenNthCalledWith(2, { + type: 'alert-success', + title: 'Proposal Submitted', + description: 'Proposal submitted successfully', + link: '/groups?policyAddress=test-policy-address&tab=proposals&proposalId=123', + explorerLink: 'https://testnet.manifest.explorers.guru/transaction/test-hash', + bgColor: '#2ecc71', + }); + }); + }); + + test('handles failed transaction', async () => { + // Mock failed transaction + mockModule.force('@cosmjs/stargate', () => ({ + isDeliverTxSuccess: jest.fn().mockReturnValue(false), + })); + + mockClient.broadcastTx.mockResolvedValueOnce({ + code: 1, + transactionHash: 'test-hash-fail', + gasUsed: 100000, + gasWanted: 110000, + height: 12345, + rawLog: 'Transaction failed due to insufficient funds', + }); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('failed-tx').click(); + + await waitFor(() => { + expect(mockTrackTransaction).toHaveBeenCalledWith({ + success: false, + transactionHash: 'test-hash-fail', + chainId: 'manifesttestnet', + messageTypes: ['/cosmos.bank.v1beta1.MsgSend'], + fee: { + amount: '1000', + denom: 'umfx', + }, + memo: undefined, + error: 'Transaction failed due to insufficient funds', + gasUsed: '100000', + gasWanted: '110000', + height: '12345', + }); + + expect(mockSetToastMessage).toHaveBeenCalledWith({ + type: 'alert-error', + title: 'Transaction Failed', + description: 'Transaction failed due to insufficient funds', + bgColor: '#e74c3c', + }); + }); + }); + + test('handles transaction error with exception', async () => { + mockClient.broadcastTx.mockRejectedValueOnce(new Error('Network error')); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('tx-error').click(); + + await waitFor(() => { + expect(mockSetToastMessage).toHaveBeenCalledWith({ + type: 'alert-error', + title: 'Transaction Failed', + description: 'Network error', + bgColor: '#e74c3c', + }); + }); + }); + + test('suppresses error toast when showToastOnErrors is false', async () => { + // Mock failed transaction + mockModule.force('@cosmjs/stargate', () => ({ + isDeliverTxSuccess: jest.fn().mockReturnValue(false), + })); + + mockClient.broadcastTx.mockResolvedValueOnce({ + code: 1, + transactionHash: 'test-hash-fail', + rawLog: 'Transaction failed', + }); + + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('tx-no-error-toast').click(); + + await waitFor(() => { + // Should track transaction but not show error toast + expect(mockTrackTransaction).toHaveBeenCalled(); + // Should not have called setToastMessage for error (only for broadcasting info) + const errorToastCalls = mockSetToastMessage.mock.calls.filter( + (call: any) => call[0].type === 'alert-error' + ); + expect(errorToastCalls).toHaveLength(0); + }); + }); + + test('uses osmosis explorer URL for osmosis chain', async () => { + const wrapper = renderWithWeb3AuthProvider( + , + mockWeb3AuthContext + ); + + wrapper.getByTestId('tx-fee-function').click(); + + await waitFor(() => { + // Verify we got both broadcasting and success toasts + expect(mockSetToastMessage).toHaveBeenCalledTimes(2); + + // Check the first call was broadcasting toast + expect(mockSetToastMessage).toHaveBeenNthCalledWith(1, { + type: 'alert-info', + title: 'Broadcasting', + description: 'Transaction is signed and is being broadcasted...', + bgColor: '#3498db', + }); + + // Check the second call was the success toast with osmosis explorer URL + expect(mockSetToastMessage).toHaveBeenNthCalledWith(2, { + type: 'alert-success', + title: 'Transaction Successful', + description: 'Transaction completed successfully', + link: 'https://osmosis.explorer.com/transaction/test-hash', + bgColor: '#2ecc71', + }); + }); + }); + + test('sets signing state and prompt ID correctly', async () => { + const wrapper = renderWithWeb3AuthProvider( + , + mockWeb3AuthContext + ); + + wrapper.getByTestId('tx-fee-function').click(); + + await waitFor(() => { + expect(mockWeb3AuthContext.setIsSigning).toHaveBeenCalledWith(true); + expect(mockWeb3AuthContext.setPromptId).toHaveBeenCalledWith('test-prompt'); + }); + + await waitFor(() => { + expect(mockWeb3AuthContext.setIsSigning).toHaveBeenCalledWith(false); + expect(mockWeb3AuthContext.setPromptId).toHaveBeenCalledWith(undefined); + }); + }); + + test('shows broadcasting toast before transaction submission', async () => { + const wrapper = renderWithWeb3AuthProvider(, mockWeb3AuthContext); + + wrapper.getByTestId('tx-fee-function').click(); + + await waitFor(() => { + expect(mockSetToastMessage).toHaveBeenCalledWith({ + type: 'alert-info', + title: 'Broadcasting', + description: 'Transaction is signed and is being broadcasted...', + bgColor: '#3498db', + }); + }); + }); + + test('calls onSuccess callback when provided', async () => { + const onSuccess = jest.fn(); + + function TestComponentWithCallback() { + const { tx } = useTx('manifesttestnet'); + + const handleTx = () => { + tx([{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { toAddress: 'test' } }], { + onSuccess, + }); + }; + + return