diff --git a/.env.test b/.env.test index a37d6dce..49786c73 100644 --- a/.env.test +++ b/.env.test @@ -1,10 +1,30 @@ +# Wallet NEXT_PUBLIC_WALLETCONNECT_KEY=test_walletconnect_key NEXT_PUBLIC_WEB3AUTH_NETWORK=testnet NEXT_PUBLIC_WEB3AUTH_CLIENT_ID=test_client_id -NEXT_PUBLIC_CHAIN=manifest -NEXT_PUBLIC_CHAIN_ID=test_chain_id + +# Chains +NEXT_PUBLIC_CHAIN=manifesttestnet +NEXT_PUBLIC_OSMOSIS_CHAIN=osmosistestnet +NEXT_PUBLIC_CHAIN_ID='manifest-ledger-testnet' +NEXT_PUBLIC_OSMOSIS_CHAIN_ID='osmo-test-5' +NEXT_PUBLIC_AXELAR_CHAIN=axelartestnet +NEXT_PUBLIC_AXELAR_CHAIN_ID='axelar-testnet-lisbon-3' + +# Ops NEXT_PUBLIC_CHAIN_TIER=testnet -NEXT_PUBLIC_RPC_URL=https://test.rpc.url -NEXT_PUBLIC_API_URL=https://test.api.url -NEXT_PUBLIC_EXPLORER_URL=https://test.explorer.url -NEXT_PUBLIC_INDEXER_URL=https://test.indexer.url \ No newline at end of file +# Explorer URLs +NEXT_PUBLIC_EXPLORER_URL=https://testnet.manifest.explorers.guru +NEXT_PUBLIC_OSMOSIS_EXPLORER_URL=https://manifest.io/testnet-osmosis +NEXT_PUBLIC_AXELAR_EXPLORER_URL=https://manifest.io/testnet-axelar +# Indexer URL +NEXT_PUBLIC_INDEXER_URL= +# RPC URLs +NEXT_PUBLIC_RPC_URL= +NEXT_PUBLIC_API_URL= +# Osmosis RPC URLs +NEXT_PUBLIC_OSMOSIS_API_URL= +NEXT_PUBLIC_OSMOSIS_RPC_URL= +# Axelar RPC URLs +NEXT_PUBLIC_AXELAR_API_URL= +NEXT_PUBLIC_AXELAR_RPC_URL= diff --git a/.gitignore b/.gitignore index 0add2f24..c5910cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ next-env.d.ts .idea/ -certificates \ No newline at end of file +certificates + +/scripts/*.json \ No newline at end of file diff --git a/README.md b/README.md index b06d1539..d25f2be7 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ NEXT_PUBLIC_RPC_URL= NEXT_PUBLIC_API_URL= NEXT_PUBLIC_EXPLORER_URL= NEXT_PUBLIC_INDEXER_URL= +NEXT_PUBLIC_OSMOSIS_CHAIN_ID= +NEXT_PUBLIC_OSMOSIS_RPC_URL= +NEXT_PUBLIC_OSMOSIS_API_URL= +NEXT_PUBLIC_OSMOSIS_EXPLORER_URL= ``` where @@ -45,6 +49,10 @@ where - `NEXT_PUBLIC_API_URL` is the chain API URL - `NEXT_PUBLIC_EXPLORER_URL` is the block explorer URL - `NEXT_PUBLIC_INDEXER_URL` is the YACI indexer URL +- `NEXT_PUBLIC_OSMOSIS_CHAIN_ID` is the osmosis chain ID +- `NEXT_PUBLIC_OSMOSIS_RPC_URL` is the osmosis RPC URL +- `NEXT_PUBLIC_OSMOSIS_API_URL` is the osmosis API URL +- `NEXT_PUBLIC_OSMOSIS_EXPLORER_URL` is the osmosis block explorer URL ### Development diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..e90d21f2 Binary files /dev/null and b/bun.lockb differ diff --git a/components/bank/components/__tests__/sendBox.test.tsx b/components/bank/components/__tests__/sendBox.test.tsx index 68531724..dc84a4bb 100644 --- a/components/bank/components/__tests__/sendBox.test.tsx +++ b/components/bank/components/__tests__/sendBox.test.tsx @@ -1,7 +1,7 @@ import { test, expect, afterEach, describe, mock, jest } from 'bun:test'; import React from 'react'; import matchers from '@testing-library/jest-dom/matchers'; -import { screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react'; import SendBox from '@/components/bank/components/sendBox'; import { mockBalances } from '@/tests/mock'; import { renderWithChainProvider } from '@/tests/render'; @@ -17,12 +17,25 @@ mock.module('next/router', () => ({ }), })); +// Add this mock before your tests +mock.module('next/image', () => ({ + default: (props: any) => { + // eslint-disable-next-line @next/next/no-img-element + return {props.alt; + }, +})); + const renderWithProps = (props = {}) => { const defaultProps = { address: 'test_address', balances: mockBalances, isBalancesLoading: false, refetchBalances: () => {}, + refetchHistory: () => {}, + osmosisBalances: [], + isOsmosisBalancesLoading: false, + refetchOsmosisBalances: () => {}, + resolveOsmosisRefetch: () => {}, }; return renderWithChainProvider(); }; @@ -30,6 +43,7 @@ const renderWithProps = (props = {}) => { describe('SendBox', () => { afterEach(() => { cleanup(); + mock.restore(); }); test('renders correctly', () => { @@ -40,42 +54,27 @@ describe('SendBox', () => { test('toggles between Send and Cross-Chain Transfer', async () => { renderWithProps(); - expect(screen.getByText('Amount')).toBeInTheDocument(); - - fireEvent.click(screen.getByText('Cross-Chain Transfer')); - await waitFor(() => expect(screen.getByText('Chain')).toBeInTheDocument()); - }); - - test('displays chain selection dropdown when in Cross-Chain Transfer mode', async () => { - renderWithProps(); - fireEvent.click(screen.getByText('Cross-Chain Transfer')); - await waitFor(() => expect(screen.getByText('Chain')).toBeInTheDocument()); - }); - - test('selects a chain in Cross-Chain Transfer mode', async () => { - renderWithProps(); - const crossChainBtn = screen.getByLabelText('cross-chain-transfer-tab'); - fireEvent.click(crossChainBtn); + // Check initial send form + expect(screen.getByPlaceholderText('0.00')).toBeInTheDocument(); + expect(screen.queryByLabelText('to-chain-selector')).not.toBeInTheDocument(); - await waitFor(() => { - const chainSelector = screen.getByLabelText('chain-selector'); - expect(chainSelector).toBeTruthy(); - }); - - const chainSelector = screen.getByLabelText('chain-selector'); - fireEvent.click(chainSelector); + // Switch to cross-chain transfer + fireEvent.click(screen.getByLabelText('cross-chain-transfer-tab')); + // Verify cross-chain elements are present await waitFor(() => { - const osmosisOption = screen.getByText('Osmosis'); - expect(osmosisOption).toBeTruthy(); + expect(screen.getByLabelText('from-chain-selector')).toBeInTheDocument(); + expect(screen.getByLabelText('to-chain-selector')).toBeInTheDocument(); }); + }); - const osmosisOption = screen.getByText('Osmosis'); - fireEvent.click(osmosisOption); + test('displays chain selection dropdowns in Cross-Chain Transfer mode', async () => { + renderWithProps(); + fireEvent.click(screen.getByLabelText('cross-chain-transfer-tab')); await waitFor(() => { - const updatedChainSelector = screen.getByLabelText('chain-selector'); - expect(updatedChainSelector.textContent).toContain('Osmosis'); + expect(screen.getByLabelText('from-chain-selector')).toBeInTheDocument(); + expect(screen.getByLabelText('to-chain-selector')).toBeInTheDocument(); }); }); }); diff --git a/components/bank/components/__tests__/tokenList.test.tsx b/components/bank/components/__tests__/tokenList.test.tsx index 5f2cf6af..d2ae8e75 100644 --- a/components/bank/components/__tests__/tokenList.test.tsx +++ b/components/bank/components/__tests__/tokenList.test.tsx @@ -58,6 +58,7 @@ expect.extend(matchers); describe('TokenList', () => { afterEach(() => { cleanup(); + mock.restore(); }); test('renders correctly', () => { diff --git a/components/bank/components/sendBox.tsx b/components/bank/components/sendBox.tsx index 62950b32..155fe0d8 100644 --- a/components/bank/components/sendBox.tsx +++ b/components/bank/components/sendBox.tsx @@ -1,14 +1,16 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import SendForm from '../forms/sendForm'; import IbcSendForm from '../forms/ibcSendForm'; - +import env from '@/config/env'; import { CombinedBalanceInfo } from '@/utils/types'; +import { ChainContext } from '@cosmos-kit/core'; export interface IbcChain { id: string; name: string; icon: string; prefix: string; + chainID: string; } export default function SendBox({ @@ -21,6 +23,11 @@ export default function SendBox({ isGroup, admin, refetchProposals, + osmosisBalances, + isOsmosisBalancesLoading, + refetchOsmosisBalances, + resolveOsmosisRefetch, + chains, }: { address: string; balances: CombinedBalanceInfo[]; @@ -31,17 +38,55 @@ export default function SendBox({ selectedDenom?: string; isGroup?: boolean; admin?: string; + osmosisBalances: CombinedBalanceInfo[]; + isOsmosisBalancesLoading: boolean; + refetchOsmosisBalances: () => void; + resolveOsmosisRefetch: () => void; + chains: Record; }) { + const ibcChains = useMemo( + () => [ + { + id: env.chain, + name: 'Manifest', + icon: 'logo.svg', + prefix: 'manifest', + chainID: env.chainId, + }, + { + id: env.osmosisChain, + name: 'Osmosis', + icon: 'osmosis.svg', + prefix: 'osmo', + chainID: env.osmosisChainId, + }, + { + id: env.axelarChain, + name: 'Axelar', + icon: 'https://github.com/cosmos/chain-registry/raw/refs/heads/master/axelar/images/axl.svg', + prefix: 'axelar', + chainID: env.axelarChainId, + }, + ], + [] + ); const [activeTab, setActiveTab] = useState<'send' | 'cross-chain'>('send'); - const [selectedChain, setSelectedChain] = useState(''); - const ibcChains: IbcChain[] = [ - { - id: 'osmosis', - name: 'Osmosis', - icon: 'https://osmosis.zone/assets/icons/osmo-logo-icon.svg', - prefix: 'osmo', - }, - ]; + const [selectedFromChain, setSelectedFromChain] = useState(ibcChains[0]); + const [selectedToChain, setSelectedToChain] = useState(ibcChains[1]); + + useEffect(() => { + if (selectedFromChain && selectedToChain && selectedFromChain.id === selectedToChain.id) { + // If chains match, switch the destination chain to the other available chain + const otherChain = ibcChains.find(chain => chain.id !== selectedFromChain.id); + if (otherChain) { + setSelectedToChain(otherChain); + } + } + }, [selectedFromChain, selectedToChain, ibcChains]); + + const getAvailableToChains = useMemo(() => { + return ibcChains.filter(chain => chain.id !== selectedFromChain.id); + }, [ibcChains, selectedFromChain]); return (
@@ -57,17 +102,19 @@ export default function SendBox({ > Send - + {env.chainTier === 'testnet' && ( + + )}
@@ -75,20 +122,30 @@ export default function SendBox({
) : ( <> - {activeTab === 'cross-chain' ? ( + {activeTab === 'cross-chain' && env.chainTier === 'testnet' ? ( ) : ( void; searchTerm?: string; + osmosisBalances?: CombinedBalanceInfo[] | undefined; + isOsmosisBalancesLoading?: boolean; + refetchOsmosisBalances?: () => void; + resolveOsmosisRefetch?: () => void; + chains: Record; } export function TokenList(props: Readonly) { @@ -30,6 +36,11 @@ export function TokenList(props: Readonly) { admin, refetchProposals, searchTerm = '', + osmosisBalances, + isOsmosisBalancesLoading, + refetchOsmosisBalances, + resolveOsmosisRefetch, + chains, } = props; const [selectedDenom, setSelectedDenom] = useState(null); const [isSendModalOpen, setIsSendModalOpen] = useState(false); @@ -100,7 +111,7 @@ export function TokenList(props: Readonly) { className="flex flex-row justify-between gap-4 items-center p-4 bg-[#FFFFFFCC] dark:bg-[#FFFFFF0F] rounded-[16px] cursor-pointer hover:bg-[#FFFFFF66] dark:hover:bg-[#FFFFFF1A] transition-colors" onClick={() => { setSelectedDenom(balance?.denom); - (document?.getElementById(`denom-info-modal`) as HTMLDialogElement)?.showModal(); + setOpenDenomInfoModal(true); }} >
@@ -125,9 +136,7 @@ export function TokenList(props: Readonly) { onClick={e => { e.stopPropagation(); setSelectedDenom(balance?.denom); - ( - document?.getElementById(`denom-info-modal`) as HTMLDialogElement - )?.showModal(); + setOpenDenomInfoModal(true); }} className="btn btn-md bg-base-300 text-primary btn-square group-hover:bg-secondary hover:outline hover:outline-primary hover:outline-1 outline-none" > @@ -228,6 +237,11 @@ export function TokenList(props: Readonly) { isGroup={isGroup} admin={admin} refetchProposals={refetchProposals} + osmosisBalances={osmosisBalances ?? []} + isOsmosisBalancesLoading={isOsmosisBalancesLoading ?? false} + refetchOsmosisBalances={refetchOsmosisBalances ?? (() => {})} + resolveOsmosisRefetch={resolveOsmosisRefetch ?? (() => {})} + chains={chains} />
); diff --git a/components/bank/forms/__tests__/ibcSendForm.test.tsx b/components/bank/forms/__tests__/ibcSendForm.test.tsx index 7941702e..a5b3c462 100644 --- a/components/bank/forms/__tests__/ibcSendForm.test.tsx +++ b/components/bank/forms/__tests__/ibcSendForm.test.tsx @@ -1,6 +1,6 @@ -import { describe, test, afterEach, expect, jest, mock } from 'bun:test'; +import { describe, test, afterEach, expect, jest, mock, beforeAll } from 'bun:test'; import React from 'react'; -import { screen, cleanup, fireEvent } from '@testing-library/react'; +import { screen, cleanup, fireEvent, act } from '@testing-library/react'; import IbcSendForm from '@/components/bank/forms/ibcSendForm'; import matchers from '@testing-library/jest-dom/matchers'; import { mockBalances } from '@/tests/mock'; @@ -17,37 +17,78 @@ mock.module('next/router', () => ({ })); function renderWithProps(props = {}) { + const defaultChains = [ + { + id: 'manifest', + name: 'Manifest', + icon: 'https://osmosis.zone/assets/icons/osmo-logo-icon.svg', + prefix: 'manifest', + chainID: 'manifest-1', + }, + { + id: 'osmosistestnet', + name: 'Osmosis', + icon: 'https://osmosis.zone/assets/icons/osmo-logo-icon.svg', + prefix: 'osmo', + chainID: 'osmo-test-1', + }, + ]; + const defaultProps = { address: 'manifest1address', - destinationChain: 'osmosis', + destinationChain: defaultChains[1], balances: mockBalances, isBalancesLoading: false, refetchBalances: jest.fn(), + refetchHistory: jest.fn(), isIbcTransfer: true, - setIsIbcTransfer: jest.fn(), - ibcChains: [ - { - id: 'osmosis', - name: 'Osmosis', - icon: 'https://osmosis.zone/assets/icons/osmo-logo-icon.svg', - prefix: 'osmo', + ibcChains: defaultChains, + selectedFromChain: defaultChains[0], + setSelectedFromChain: jest.fn(), + selectedToChain: defaultChains[1], + setSelectedToChain: jest.fn(), + osmosisBalances: [], + isOsmosisBalancesLoading: false, + refetchOsmosisBalances: jest.fn(), + resolveOsmosisRefetch: jest.fn(), + availableToChains: defaultChains, + chains: { + manifest: { + address: 'manifest1address', + getOfflineSignerAmino: jest.fn(), + }, + osmosistestnet: { + address: 'osmo1address', + getOfflineSignerAmino: jest.fn(), }, - ], - selectedChain: 'osmosis', - setSelectedChain: jest.fn(), + }, }; - return renderWithChainProvider(); + const rendered = renderWithChainProvider( +
+ +
+ ); + + // Wait for component to be mounted + return { + ...rendered, + findForm: () => rendered.findByTestId('ibc-send-form'), + }; } describe('IbcSendForm Component', () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + mock.restore(); + }); - test('renders form with correct details', () => { - renderWithProps(); - expect(screen.getByText('Amount')).toBeInTheDocument(); - expect(screen.getByText('Send To')).toBeInTheDocument(); - expect(screen.getByText('Chain')).toBeInTheDocument(); + test('renders form with correct details', async () => { + const { findForm } = renderWithProps(); + const form = await findForm(); + expect(form).toBeInTheDocument(); + expect(screen.getByLabelText('from-chain-selector')).toBeInTheDocument(); + expect(screen.getByLabelText('to-chain-selector')).toBeInTheDocument(); }); test('empty balances', async () => { @@ -73,9 +114,26 @@ describe('IbcSendForm Component', () => { test('updates chain selector correctly', () => { renderWithProps(); - const chainSelector = screen.getByLabelText('chain-selector'); - fireEvent.click(chainSelector); - expect(chainSelector).toHaveTextContent('Osmosis'); + + const fromChainSelector = screen.getByLabelText('from-chain-selector'); + fireEvent.click(fromChainSelector); + + // Get all Manifest options and select the enabled one + const manifestOptions = screen.getAllByRole('option', { name: 'Manifest' }); + const enabledManifestOption = manifestOptions.find( + option => !option.className.includes('opacity-50') + ); + fireEvent.click(enabledManifestOption!); + + expect(screen.getByLabelText('from-chain-selector')).toHaveTextContent('Manifest'); + + const toChainSelector = screen.getByLabelText('to-chain-selector'); + fireEvent.click(toChainSelector); + + const osmosisOption = screen.getAllByRole('option', { name: 'Osmosis' }); + fireEvent.click(osmosisOption[0]); + + expect(screen.getByLabelText('to-chain-selector')).toHaveTextContent('Osmosis'); }); test('updates amount input correctly', () => { @@ -106,4 +164,74 @@ describe('IbcSendForm Component', () => { const sendButton = screen.getByRole('button', { name: 'send-btn' }); expect(sendButton).not.toBeDisabled(); }); + + test('handles chain selection correctly', async () => { + renderWithProps(); + + const fromChainSelector = screen.getByLabelText('from-chain-selector'); + fireEvent.click(fromChainSelector); + + const manifestOptions = screen.getAllByRole('option', { name: 'Manifest' }); + const enabledManifestOption = manifestOptions.find( + option => !option.className.includes('opacity-50') + ); + fireEvent.click(enabledManifestOption!); + + expect(screen.getByLabelText('from-chain-selector')).toHaveTextContent('Manifest'); + + const toChainSelector = screen.getByLabelText('to-chain-selector'); + fireEvent.click(toChainSelector); + + const osmosisOption = screen.getAllByRole('option', { name: 'Osmosis' }); + fireEvent.click(osmosisOption[0]); + + expect(screen.getByLabelText('to-chain-selector')).toHaveTextContent('Osmosis'); + }); + + test('prevents selecting same chain for source and destination', async () => { + const { findForm } = renderWithProps(); + await findForm(); // Wait for form to be mounted + + const fromChainSelector = screen.getByLabelText('from-chain-selector'); + await act(async () => { + fireEvent.click(fromChainSelector); + }); + + // Wait for dropdown content to be visible + await act(async () => { + const manifestOptions = await screen.findAllByRole('option', { + name: 'Manifest', + hidden: true, + }); + + const enabledManifestOption = manifestOptions.find( + option => !option.className.includes('opacity-50') + ); + fireEvent.click(enabledManifestOption!); + }); + + expect(screen.getByLabelText('from-chain-selector')).toHaveTextContent('Manifest'); + + const toChainSelector = screen.getByLabelText('to-chain-selector'); + await act(async () => { + fireEvent.click(toChainSelector); + }); + + // Wait for dropdown content to be visible + await act(async () => { + const toChainOptions = await screen.findAllByRole('option', { + hidden: true, + }); + + // Find the Manifest option in the to-chain dropdown + const manifestInToChain = toChainOptions.find(option => { + const link = option.querySelector('a'); + return link && link.textContent?.includes('Manifest'); + }); + + // Check that the Manifest option has the disabled styling and attributes + expect(manifestInToChain?.querySelector('a')).toHaveStyle({ pointerEvents: 'none' }); + expect(manifestInToChain?.querySelector('a')).toHaveClass('opacity-50'); + }); + }); }); diff --git a/components/bank/forms/__tests__/sendForm.test.tsx b/components/bank/forms/__tests__/sendForm.test.tsx index bf77ba4f..27be74fb 100644 --- a/components/bank/forms/__tests__/sendForm.test.tsx +++ b/components/bank/forms/__tests__/sendForm.test.tsx @@ -35,7 +35,10 @@ function renderWithProps(props = {}) { } describe('SendForm Component', () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + mock.restore(); + }); test('renders form with correct details', () => { renderWithProps(); diff --git a/components/bank/forms/ibcSendForm.tsx b/components/bank/forms/ibcSendForm.tsx index de0ca28e..b6e845e7 100644 --- a/components/bank/forms/ibcSendForm.tsx +++ b/components/bank/forms/ibcSendForm.tsx @@ -1,7 +1,22 @@ -import React, { useState, useMemo } from 'react'; -import { useFeeEstimation, useTx } from '@/hooks'; -import { ibc } from '@liftedinit/manifestjs'; -import { getIbcInfo, parseNumberToBigInt, shiftDigits, truncateString } from '@/utils'; +import React, { useState, useMemo, useEffect } from 'react'; +import { + useFeeEstimation, + useOsmosisTokenBalancesResolved, + useOsmosisTokenFactoryDenomsMetadata, + useTokenBalancesOsmosis, + useTx, +} from '@/hooks'; +import { cosmos, ibc } from '@liftedinit/manifestjs'; +import { MsgTransfer } from '@liftedinit/manifestjs/dist/codegen/ibc/applications/transfer/v1/tx'; +import { + getIbcInfo, + parseNumberToBigInt, + shiftDigits, + truncateString, + getIbcDenom, + OSMOSIS_TOKEN_DATA, + denomToAsset, +} from '@/utils'; import { PiCaretDownBold } from 'react-icons/pi'; import { MdContacts } from 'react-icons/md'; import { CombinedBalanceInfo } from '@/utils/types'; @@ -9,14 +24,19 @@ import { DenomDisplay } from '@/components/factory'; import { Formik, Form } from 'formik'; import Yup from '@/utils/yupExtensions'; import { TextInput } from '@/components/react/inputs'; -import { IbcChain } from '@/components'; + import Image from 'next/image'; -import { SearchIcon } from '@/components/icons'; +import { SearchIcon, TransferIcon } from '@/components/icons'; import { TailwindModal } from '@/components/react/modal'; import env from '@/config/env'; -//TODO: use formatTokenDisplayName instead of repeating format +import { useSkipClient } from '@/contexts/skipGoContext'; + +import { IbcChain } from '@/components'; +import { ChainContext } from '@cosmos-kit/core'; + +//TODO: switch to main-net names export default function IbcSendForm({ address, destinationChain, @@ -26,49 +46,111 @@ export default function IbcSendForm({ refetchHistory, isIbcTransfer, ibcChains, - selectedChain, - setSelectedChain, + selectedFromChain, + setSelectedFromChain, + selectedToChain, + setSelectedToChain, selectedDenom, isGroup, + osmosisBalances, + isOsmosisBalancesLoading, + refetchOsmosisBalances, + resolveOsmosisRefetch, + refetchProposals, + admin, + availableToChains, + chains, }: Readonly<{ address: string; - destinationChain: string; + destinationChain: IbcChain; balances: CombinedBalanceInfo[]; isBalancesLoading: boolean; refetchBalances: () => void; refetchHistory: () => void; isIbcTransfer: boolean; ibcChains: IbcChain[]; - selectedChain: string; - setSelectedChain: (selectedChain: string) => void; - selectedDenom?: string; isGroup?: boolean; + selectedFromChain: IbcChain; + setSelectedFromChain: (selectedChain: IbcChain) => void; + selectedToChain: IbcChain; + setSelectedToChain: (selectedChain: IbcChain) => void; + selectedDenom?: string; + osmosisBalances: CombinedBalanceInfo[]; + isOsmosisBalancesLoading: boolean; + refetchOsmosisBalances: () => void; + resolveOsmosisRefetch: () => void; + refetchProposals?: () => void; + admin?: string; + availableToChains: IbcChain[]; + chains: Record; }>) { + const formatTokenDisplayName = (displayName: string) => { + if (displayName.startsWith('factory')) { + return displayName.split('/').pop()?.toUpperCase(); + } + if (displayName.startsWith('u')) { + return displayName.slice(1).toUpperCase(); + } + return truncateString(displayName, 10).toUpperCase(); + }; + const [isSending, setIsSending] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [feeWarning, setFeeWarning] = useState(''); - const { tx } = useTx(env.chain); - const { estimateFee } = useFeeEstimation(env.chain); + const { tx } = useTx(selectedFromChain.name === env.osmosisChain ? env.osmosisChain : env.chain); + const { estimateFee } = useFeeEstimation( + selectedFromChain.name === env.osmosisChain ? env.osmosisChain : env.chain + ); + const { transfer } = ibc.applications.transfer.v1.MessageComposer.withTypeUrl; const [isContactsOpen, setIsContactsOpen] = useState(false); + const [isIconRotated, setIsIconRotated] = useState(false); - // Adjusted filter logic to handle undefined metadata - const filteredBalances = useMemo( - () => - balances?.filter(token => { - const displayName = token.metadata?.display ?? token.denom; - return displayName.toLowerCase().includes(searchTerm.toLowerCase()); - }), - [balances, searchTerm] - ); + const getCosmosSigner = async () => { + const signer = chains[selectedFromChain.id].getOfflineSignerAmino(); + return signer; + }; + + const skipClient = useSkipClient({ + getCosmosSigner: getCosmosSigner, + }); + + const { submitProposal } = cosmos.group.v1.MessageComposer.withTypeUrl; + useEffect(() => { + if (isGroup) { + setSelectedFromChain(ibcChains.find(chain => chain.id === env.chain) ?? ibcChains[0]); + } + }, [isGroup, setSelectedFromChain]); + + // Add this combined balances memo for Osmosis tokens + + // Update the filtered balances logic to use passed props instead of hooks + const filteredBalances = useMemo(() => { + const sourceBalances = selectedFromChain.id === env.osmosisChain ? osmosisBalances : balances; + + return sourceBalances?.filter(token => { + const displayName = token.metadata?.display ?? token.denom; + return displayName.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }, [balances, osmosisBalances, searchTerm, selectedFromChain]); - // Set initialSelectedToken to 'mfx' if available - const initialSelectedToken = - balances?.find(token => token.coreDenom === selectedDenom) || balances?.[0] || null; + // Update initialSelectedToken to consider the chain + const initialSelectedToken = useMemo(() => { + const sourceBalances = selectedFromChain.id === env.osmosisChain ? osmosisBalances : balances; - // Return null or a loading component if balances are not loaded - if (isBalancesLoading || !initialSelectedToken) { - return null; // Or render a loading indicator + return ( + sourceBalances?.find(token => token.coreDenom === selectedDenom) || + sourceBalances?.[0] || + null + ); + }, [balances, osmosisBalances, selectedDenom, selectedFromChain]); + + // Update the loading check + if ( + (selectedFromChain.id === env.osmosisChain ? isOsmosisBalancesLoading : isBalancesLoading) || + !initialSelectedToken + ) { + return null; } const validationSchema = Yup.object().shape({ @@ -84,14 +166,23 @@ export default function IbcSendForm({ const balance = parseFloat(selectedToken.amount) / Math.pow(10, exponent); return value <= balance; }) - .test('leave-for-fees', 'Insufficient balance for fees', function (value) { + .test('leave-for-fees', '', function (value) { const { selectedToken } = this.parent; if (!selectedToken || !value || selectedToken.denom !== 'umfx') return true; const exponent = selectedToken.metadata?.denom_units[1]?.exponent ?? 6; const balance = parseFloat(selectedToken.amount) / Math.pow(10, exponent); - return value <= balance - 0.09; + const MIN_FEE_BUFFER = 0.09; + const hasInsufficientBuffer = value > balance - MIN_FEE_BUFFER; + + if (hasInsufficientBuffer) { + setFeeWarning('Remember to leave tokens for fees!'); + } else { + setFeeWarning(''); + } + + return !hasInsufficientBuffer; }), selectedToken: Yup.object().required('Please select a token'), memo: Yup.string().max(255, 'Memo must be less than 255 characters'), @@ -112,7 +203,7 @@ export default function IbcSendForm({ const exponent = values.selectedToken.metadata?.denom_units[1]?.exponent ?? 6; const amountInBaseUnits = parseNumberToBigInt(values.amount, exponent).toString(); - const { source_port, source_channel } = getIbcInfo(env.chain, destinationChain ?? ''); + const { source_port, source_channel } = getIbcInfo(selectedFromChain.id, selectedToChain.id); const token = { denom: values.selectedToken.coreDenom, @@ -122,28 +213,139 @@ export default function IbcSendForm({ const stamp = Date.now(); const timeoutInNanos = (stamp + 1.2e6) * 1e6; - const msg = transfer({ - sourcePort: source_port, - sourceChannel: source_channel, - sender: address ?? '', - receiver: values.recipient ?? '', - token, - //@ts-ignore - timeoutHeight: undefined, - //@ts-ignore - timeoutTimestamp: timeoutInNanos, + const ibcDenom = getIbcDenom(selectedToChain.id, values.selectedToken.coreDenom); + + const route = await skipClient.route({ + sourceAssetDenom: values.selectedToken.coreDenom, + sourceAssetChainID: selectedFromChain.chainID, + destAssetChainID: selectedToChain.chainID, + destAssetDenom: ibcDenom ?? '', + amountIn: amountInBaseUnits, + }); + + console.log('route', route); + console.log('route.requiredChainAddresses', route.requiredChainAddresses); + const addressList = route.requiredChainAddresses.map((chainID: string) => ({ + address: + Object.values(chains).find(chain => chain.chain.chain_id === chainID)?.address ?? '', + })); + + const userAddresses = route.requiredChainAddresses.map((chainID: string) => { + const chainContext = Object.values(chains).find(chain => chain.chain.chain_id === chainID); + + if (!chainContext?.address) { + throw new Error(`No address found for chain: ${chainID}`); + } + + return { + chainID, + address: chainContext.address, + }; + }); + + console.log(userAddresses); + + const messages = await skipClient.messages({ + sourceAssetDenom: values.selectedToken.coreDenom, + sourceAssetChainID: selectedFromChain.chainID, + destAssetDenom: ibcDenom ?? values.selectedToken.coreDenom, + destAssetChainID: selectedToChain.chainID, + amountIn: amountInBaseUnits, + amountOut: route.estimatedAmountOut ?? '', + addressList: addressList.map((user: { address: any }) => user.address), + operations: route.operations, + estimatedAmountOut: route.estimatedAmountOut ?? '', + slippageTolerancePercent: '1', + affiliates: [], + chainIDsToAffiliates: {}, + postRouteHandler: undefined, + enableGasWarnings: false, }); - const fee = await estimateFee(address, [msg]); + await skipClient.executeRoute({ + route, + userAddresses, - await tx([msg], { - memo: values.memo, - fee, - onSuccess: () => { - refetchBalances(); - refetchHistory(); + // Executes after all of the operations triggered by a user's signature complete. + // For multi-tx routes that require multiple user signatures, this will be called once for each tx in sequence + // @ts-ignore + onTransactionCompleted: async (chainID, txHash, status) => { + console.log(`Route completed with tx hash: ${txHash} & status: ${status.state}`); + }, + // called after the transaction that the user signs gets broadcast on chain + // @ts-ignore + onTransactionBroadcast: async ({ txHash, chainID }) => { + console.log(`Transaction broadcasted with tx hash: ${txHash}`); + }, + // called after the transaction that the user signs is successfully registered for tracking + // @ts-ignore + onTransactionTracked: async ({ txHash, chainID }) => { + console.log(`Transaction tracked with tx hash: ${txHash}`); + }, + // called after the user signs a transaction + // @ts-ignore + onTransactionSigned: async ({ chainID }) => { + console.log(`Transaction signed with chain ID: ${chainID}`); + }, + // validate gas balance on each chain + // @ts-ignore + onValidateGasBalance: async ({ chainID, txIndex, status }) => { + console.log(`Validating gas balance for chain ${chainID}...`); }, }); + + // const transferMsg = transfer({ + // sourcePort: source_port, + // sourceChannel: source_channel, + // sender: admin + // ? admin + // : selectedFromChain.id === env.osmosisChain + // ? (chains?.osmosistestnet?.address ?? '') + // : (address ?? ''), + // receiver: values.recipient ?? '', + // token, + // timeoutHeight: { + // revisionNumber: BigInt(0), + // revisionHeight: BigInt(0), + // }, + // timeoutTimestamp: BigInt(timeoutInNanos), + // }); + + // const msg = isGroup + // ? submitProposal({ + // groupPolicyAddress: admin!, + // messages: [ + // Any.fromPartial({ + // typeUrl: MsgTransfer.typeUrl, + // value: MsgTransfer.encode(transferMsg.value).finish(), + // }), + // ], + // metadata: '', + // proposers: [address], + // title: `IBC Transfer`, + // summary: `This proposal will send ${values.amount} ${values.selectedToken.metadata?.display} to ${values.recipient} via IBC transfer`, + // exec: 0, + // }) + // : transferMsg; + + // const fee = await estimateFee( + // selectedFromChain.id === env.osmosisChain + // ? (chains.osmosistestnet.address ?? '') + // : (address ?? ''), + // [msg] + // ); + + // await tx([msg], { + // memo: values.memo, + // fee, + // onSuccess: () => { + // refetchBalances(); + // refetchHistory(); + // refetchOsmosisBalances(); + // resolveOsmosisRefetch(); + // refetchProposals?.(); + // }, + // }); } catch (error) { console.error('Error during sending:', error); } finally { @@ -171,62 +373,222 @@ export default function IbcSendForm({ {({ isValid, dirty, setFieldValue, values, errors }) => (
-
-
- - Chain - -
-
diff --git a/components/factory/forms/TokenDetailsForm.tsx b/components/factory/forms/TokenDetailsForm.tsx index 85c34ffe..0a802fae 100644 --- a/components/factory/forms/TokenDetailsForm.tsx +++ b/components/factory/forms/TokenDetailsForm.tsx @@ -39,6 +39,7 @@ export default function TokenDetails({ formData.isGroup && formData.groupPolicyAddress ? formData.groupPolicyAddress : address; const fullDenom = `factory/${effectiveAddress}/u${formData.subdenom}`; + // Automatically set denom units React.useEffect(() => { const denomUnits = [ diff --git a/components/factory/modals/updateDenomMetadata.tsx b/components/factory/modals/updateDenomMetadata.tsx index b01d15d9..89e53dec 100644 --- a/components/factory/modals/updateDenomMetadata.tsx +++ b/components/factory/modals/updateDenomMetadata.tsx @@ -97,11 +97,10 @@ export default function UpdateDenomMetadataModal({ sender: admin, metadata: { description: values.description || formData.description, - denomUnits: - [ - { denom: fullDenom, exponent: 0, aliases: [symbol] }, - { denom: symbol, exponent: 6, aliases: [fullDenom] }, - ] || formData.denomUnits, + denomUnits: [ + { denom: fullDenom, exponent: 0, aliases: [symbol] }, + { denom: symbol, exponent: 6, aliases: [fullDenom] }, + ], base: fullDenom, display: symbol, name: values.name || formData.name, @@ -122,11 +121,10 @@ export default function UpdateDenomMetadataModal({ sender: address, metadata: { description: values.description || formData.description, - denomUnits: - [ - { denom: fullDenom, exponent: 0, aliases: [symbol] }, - { denom: symbol, exponent: 6, aliases: [fullDenom] }, - ] || formData.denomUnits, + denomUnits: [ + { denom: fullDenom, exponent: 0, aliases: [symbol] }, + { denom: symbol, exponent: 6, aliases: [fullDenom] }, + ], base: fullDenom, display: symbol, name: values.name || formData.name, diff --git a/components/groups/components/groupControls.tsx b/components/groups/components/groupControls.tsx index cdbc5d91..ca8774ff 100644 --- a/components/groups/components/groupControls.tsx +++ b/components/groups/components/groupControls.tsx @@ -13,7 +13,7 @@ import { useRouter } from 'next/router'; import VoteDetailsModal from '@/components/groups/modals/voteDetailsModal'; import { useGroupsByMember } from '@/hooks/useQueries'; -import { useChain } from '@cosmos-kit/react'; +import { useChain, useChains } from '@cosmos-kit/react'; import { MemberSDKType } from '@liftedinit/manifestjs/dist/codegen/cosmos/group/v1/types'; import { ArrowRightIcon } from '@/components/icons'; import ProfileAvatar from '@/utils/identicon'; @@ -230,6 +230,7 @@ export default function GroupControls({ } const { address } = useChain(env.chain); + const chains = useChains([env.chain, env.osmosisChain, env.axelarChain]); const { groupByMemberData } = useGroupsByMember(address ?? ''); useEffect(() => { @@ -603,6 +604,7 @@ export default function GroupControls({ isGroup={true} admin={policyAddress} refetchProposals={refetchProposals} + chains={chains} /> )}
diff --git a/components/groups/components/myGroups.tsx b/components/groups/components/myGroups.tsx index 4f9a7aa4..1582296c 100644 --- a/components/groups/components/myGroups.tsx +++ b/components/groups/components/myGroups.tsx @@ -11,6 +11,7 @@ import { import ProfileAvatar from '@/utils/identicon'; import { CombinedBalanceInfo, + denomToAsset, ExtendedMetadataSDKType, MFX_TOKEN_DATA, truncateString, @@ -260,6 +261,35 @@ export function YourGroups({ ); const metadata = metadatas.metadatas.find(m => m.base === coreBalance.denom); + if (coreBalance.denom.startsWith('ibc/')) { + const assetInfo = denomToAsset(env.chain, coreBalance.denom); + + let baseDenom = ''; + if (assetInfo?.traces && assetInfo.traces.length > 1) { + baseDenom = assetInfo.traces[1]?.counterparty?.base_denom ?? ''; + } + + return { + denom: baseDenom ?? '', // normalized denom (e.g., 'umfx') + coreDenom: coreBalance.denom, // full IBC trace + amount: coreBalance.amount, + metadata: { + description: assetInfo?.description ?? '', + denom_units: + assetInfo?.denom_units?.map(unit => ({ + ...unit, + aliases: unit.aliases || [], + })) ?? [], + base: assetInfo?.base ?? '', + display: assetInfo?.display ?? '', + name: assetInfo?.name ?? '', + symbol: assetInfo?.symbol ?? '', + uri: assetInfo?.logo_URIs?.svg ?? assetInfo?.logo_URIs?.png ?? '', + uri_hash: assetInfo?.logo_URIs?.svg ?? assetInfo?.logo_URIs?.png ?? '', + }, + }; + } + return { denom: resolvedBalance?.denom || coreBalance.denom, coreDenom: coreBalance.denom, diff --git a/components/react/modal.tsx b/components/react/modal.tsx index 9b416e8a..fe4b61ca 100644 --- a/components/react/modal.tsx +++ b/components/react/modal.tsx @@ -36,6 +36,8 @@ import { Web3AuthClient, Web3AuthWallet } from '@cosmos-kit/web3auth'; import { useDeviceDetect } from '@/hooks'; import { State } from '@cosmos-kit/core'; import { ExpiredError } from '@cosmos-kit/core'; +import env from '@/config/env'; +import { useChains } from '@cosmos-kit/react'; export enum ModalView { WalletList, @@ -92,7 +94,25 @@ export const TailwindModal: React.FC< const [qrState, setQRState] = useState(State.Init); const [qrMessage, setQrMessage] = useState(''); - const current = walletRepo?.current; + const chains = useChains([env.chain, env.osmosisChain, env.axelarChain]); + + const chainStates = useMemo(() => { + return Object.values(chains).map(chain => ({ + connect: chain.connect, + openView: chain.openView, + status: chain.status, + username: chain.username, + address: chain.address, + disconnect: chain.disconnect, + })); + }, [chains]); + + const disconnect = async () => { + await Promise.all(chainStates.map(chain => chain.disconnect())); + }; + + const current = chains?.manifesttestnet?.walletRepo?.current; + const currentWalletData = current?.walletInfo; const walletStatus = current?.walletStatus || WalletStatus.Disconnected; const currentWalletName = current?.walletName; @@ -444,7 +464,7 @@ export const TailwindModal: React.FC< setCurrentView(ModalView.WalletList)} - disconnect={() => current?.disconnect()} + disconnect={() => disconnect()} name={currentWalletData?.prettyName!} logo={currentWalletData?.logo!.toString() ?? ''} username={current?.username} diff --git a/components/wallet.tsx b/components/wallet.tsx index 54db3e10..fee320fd 100644 --- a/components/wallet.tsx +++ b/components/wallet.tsx @@ -2,7 +2,7 @@ import React, { MouseEventHandler, useEffect, useMemo, useState, useRef } from ' import { ArrowDownTrayIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import { ArrowUpIcon, CopyIcon } from './icons'; -import { useChain } from '@cosmos-kit/react'; +import { useChain, useChains } from '@cosmos-kit/react'; import { WalletStatus } from 'cosmos-kit'; import { MdWallet } from 'react-icons/md'; import env from '@/config/env'; @@ -37,7 +37,51 @@ interface WalletSectionProps { } export const WalletSection: React.FC = ({ chainName }) => { - const { connect, openView, status, username, address, wallet } = useChain(chainName); + const chains = useChains([env.chain, env.osmosisChain, env.axelarChain]); + + const chainStates = useMemo(() => { + return Object.values(chains).map(chain => ({ + connect: chain.connect, + openView: chain.openView, + status: chain.status, + username: chain.username, + address: chain.address, + wallet: chain.wallet, + })); + }, [chains]); + + const connect = async () => { + await Promise.all(chainStates.map(chain => chain.connect())); + }; + + const openView = () => { + chainStates[0]?.openView(); + }; + + const status = useMemo(() => { + if (chainStates.some(chain => chain.status === WalletStatus.Connecting)) { + return WalletStatus.Connecting; + } + if (chainStates.some(chain => chain.status === WalletStatus.Error)) { + return WalletStatus.Error; + } + if (chainStates.every(chain => chain.status === WalletStatus.Connected)) { + return WalletStatus.Connected; + } + return WalletStatus.Disconnected; + }, [chainStates]); + + const username = useMemo( + () => chainStates.find(chain => chain.username)?.username || undefined, + [chainStates] + ); + + const wallet = useMemo(() => chainStates.find(chain => chain.wallet)?.wallet, [chainStates]); + + const address = useMemo( + () => chainStates.find(chain => chain.address)?.address || undefined, + [chainStates] + ); const [localStatus, setLocalStatus] = useState(status); const timeoutRef = useRef>(); diff --git a/config/defaults.ts b/config/defaults.ts deleted file mode 100644 index 7bf5d290..00000000 --- a/config/defaults.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { AssetList, Chain } from '@chain-registry/types'; -import env from './env'; - -export const manifestChain: Chain = { - chain_name: env.chain, - status: 'live', - network_type: env.chainTier, - website: '', - pretty_name: 'Manifest Testnet', - chain_id: env.chainId, - bech32_prefix: 'manifest', - daemon_name: 'manifest', - node_home: '$HOME/.manifest', - slip44: 118, - apis: { - rpc: [ - { - address: env.rpcUrl, - }, - ], - rest: [ - { - address: env.apiUrl, - }, - ], - }, - fees: { - fee_tokens: [ - { - denom: 'umfx', - fixed_min_gas_price: 0.02, - low_gas_price: 0.01, - average_gas_price: 0.022, - high_gas_price: 0.034, - }, - ], - }, - staking: { - staking_tokens: [ - { - denom: 'upoa', - }, - ], - }, - codebase: { - git_repo: 'github.com/liftedinit/manifest-ledger', - recommended_version: 'v0.0.1-alpha.4', - compatible_versions: ['v0.0.1-alpha.4'], - binaries: { - 'linux/amd64': - 'https://github.com/liftedinit/manifest-ledger/releases/download/v0.0.1-alpha.4/manifest-ledger_0.0.1-alpha.4_linux_amd64.tar.gz', - }, - versions: [ - { - name: 'v1', - recommended_version: 'v0.0.1-alpha.4', - compatible_versions: ['v0.0.1-alpha.4'], - }, - ], - genesis: { - genesis_url: - 'https://github.com/liftedinit/manifest-ledger/blob/main/network/manifest-1/manifest-1_genesis.json', - }, - }, -}; -export const manifestAssets: AssetList = { - chain_name: env.chain, - assets: [ - { - description: 'Manifest testnet native token', - denom_units: [ - { - denom: 'umfx', - exponent: 0, - }, - { - denom: 'mfx', - exponent: 6, - }, - ], - base: 'umfx', - name: 'Manifest Testnet Token', - display: 'mfx', - symbol: 'MFX', - }, - { - description: 'Proof of Authority token for the Manifest testnet', - denom_units: [ - { - denom: 'upoa', - exponent: 0, - }, - { - denom: 'poa', - exponent: 6, - }, - ], - base: 'upoa', - name: 'Manifest Testnet Token', - display: 'poa', - symbol: 'POA', - }, - ], -}; diff --git a/config/env.ts b/config/env.ts index 543750c5..c3b5d146 100644 --- a/config/env.ts +++ b/config/env.ts @@ -1,14 +1,36 @@ const env = { - chainId: process.env.NEXT_PUBLIC_CHAIN_ID ?? '', - rpcUrl: process.env.NEXT_PUBLIC_RPC_URL ?? '', - explorerUrl: process.env.NEXT_PUBLIC_EXPLORER_URL ?? '', - web3AuthClientId: process.env.NEXT_PUBLIC_WEB3AUTH_CLIENT_ID ?? '', + // Wallet walletConnectKey: process.env.NEXT_PUBLIC_WALLETCONNECT_KEY ?? '', web3AuthNetwork: process.env.NEXT_PUBLIC_WEB3AUTH_NETWORK ?? '', + web3AuthClientId: process.env.NEXT_PUBLIC_WEB3AUTH_CLIENT_ID ?? '', + + // Chains chain: process.env.NEXT_PUBLIC_CHAIN ?? '', + osmosisChain: process.env.NEXT_PUBLIC_OSMOSIS_CHAIN ?? '', + axelarChain: process.env.NEXT_PUBLIC_AXELAR_CHAIN ?? '', + chainId: process.env.NEXT_PUBLIC_CHAIN_ID ?? '', + osmosisChainId: process.env.NEXT_PUBLIC_OSMOSIS_CHAIN_ID ?? '', + axelarChainId: process.env.NEXT_PUBLIC_AXELAR_CHAIN_ID ?? '', + + // Ops chainTier: process.env.NEXT_PUBLIC_CHAIN_TIER ?? '', + + // Explorer URLs + explorerUrl: process.env.NEXT_PUBLIC_EXPLORER_URL ?? '', + osmosisExplorerUrl: process.env.NEXT_PUBLIC_OSMOSIS_EXPLORER_URL ?? '', + axelarExplorerUrl: process.env.NEXT_PUBLIC_AXELAR_EXPLORER_URL ?? '', + // RPC and API URLs + rpcUrl: process.env.NEXT_PUBLIC_RPC_URL ?? '', apiUrl: process.env.NEXT_PUBLIC_API_URL ?? '', indexerUrl: process.env.NEXT_PUBLIC_INDEXER_URL ?? '', + + // Osmosis RPC URLs + osmosisApiUrl: process.env.NEXT_PUBLIC_OSMOSIS_API_URL ?? '', + osmosisRpcUrl: process.env.NEXT_PUBLIC_OSMOSIS_RPC_URL ?? '', + + // Axelar RPC URLs + axelarApiUrl: process.env.NEXT_PUBLIC_AXELAR_API_URL ?? '', + axelarRpcUrl: process.env.NEXT_PUBLIC_AXELAR_RPC_URL ?? '', }; export default env; diff --git a/config/index.ts b/config/index.ts index 34b53e46..c1532d6d 100644 --- a/config/index.ts +++ b/config/index.ts @@ -1,2 +1 @@ -export * from './defaults'; export * from './env'; diff --git a/contexts/skipGoContext.tsx b/contexts/skipGoContext.tsx new file mode 100644 index 00000000..ffadd0dc --- /dev/null +++ b/contexts/skipGoContext.tsx @@ -0,0 +1,47 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { SkipClient, SkipClientOptions } from '@skip-go/client'; +import { OfflineDirectSigner } from '@cosmjs/proto-signing'; +import { OfflineAminoSigner } from '@cosmjs/amino'; + +// Create the context +interface SkipContextType { + createClient: (options: SkipClientOptions) => SkipClient; +} + +const SkipContext = createContext(undefined); + +// Create the provider component +interface SkipProviderProps { + children: React.ReactNode; +} + +export function SkipProvider({ children }: SkipProviderProps) { + const createClient = useMemo(() => { + return (options: SkipClientOptions) => new SkipClient(options); + }, []); + + return {children}; +} + +// Update the hook to accept getCosmosSigner +interface UseSkipClientOptions { + getCosmosSigner: () => Promise< + OfflineAminoSigner | OfflineDirectSigner | (OfflineAminoSigner & OfflineDirectSigner) + >; +} + +export function useSkipClient(options: UseSkipClientOptions) { + const context = useContext(SkipContext); + if (context === undefined) { + throw new Error('useSkipClient must be used within a SkipProvider'); + } + + // Create a new client with the provided options + const skipClient = useMemo(() => { + return context.createClient({ + getCosmosSigner: options.getCosmosSigner, + }); + }, [context.createClient, options.getCosmosSigner]); + + return skipClient; +} diff --git a/hooks/useFeeEstimation.ts b/hooks/useFeeEstimation.ts index 37421bf8..ff204f5f 100644 --- a/hooks/useFeeEstimation.ts +++ b/hooks/useFeeEstimation.ts @@ -2,15 +2,11 @@ import { EncodeObject } from '@cosmjs/proto-signing'; import { GasPrice, calculateFee } from '@cosmjs/stargate'; import { useChain } from '@cosmos-kit/react'; -import { getCoin } from '@/utils'; - export const useFeeEstimation = (chainName: string) => { const { getSigningStargateClient, chain } = useChain(chainName); const gasPrice = chain.fees?.fee_tokens[0].average_gas_price || 0.025; - const coin = getCoin(chainName); - const estimateFee = async ( address: string, messages: EncodeObject[], @@ -29,7 +25,7 @@ export const useFeeEstimation = (chainName: string) => { const fee = calculateFee( Math.round(gasEstimation * (modifier || 1.5)), - GasPrice.fromString(gasPrice + 'umfx') + GasPrice.fromString(`${gasPrice}${chainName === 'manifesttestnet' ? 'umfx' : 'uosmo'}`) ); return fee; diff --git a/hooks/useLcdQueryClient.ts b/hooks/useLcdQueryClient.ts index 13b6074d..510f3ff9 100644 --- a/hooks/useLcdQueryClient.ts +++ b/hooks/useLcdQueryClient.ts @@ -19,3 +19,19 @@ export const useLcdQueryClient = () => { lcdQueryClient: lcdQueryClient.data, }; }; + +export const useOsmosisLcdQueryClient = () => { + const lcdQueryClient = useQuery({ + queryKey: ['lcdQueryClientOsmosis', env.osmosisApiUrl], + queryFn: () => + createLcdQueryClient({ + restEndpoint: env.osmosisApiUrl, + }), + enabled: !!env.osmosisApiUrl, + staleTime: Infinity, + }); + + return { + lcdQueryClient: lcdQueryClient.data, + }; +}; diff --git a/hooks/useQueries.ts b/hooks/useQueries.ts index ca3f4825..86d383f5 100644 --- a/hooks/useQueries.ts +++ b/hooks/useQueries.ts @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react'; import { useQueries, useQuery } from '@tanstack/react-query'; import { QueryGroupsByMemberResponseSDKType } from '@liftedinit/manifestjs/dist/codegen/cosmos/group/v1/query'; -import { useLcdQueryClient } from './useLcdQueryClient'; +import { useLcdQueryClient, useOsmosisLcdQueryClient } from './useLcdQueryClient'; import { usePoaLcdQueryClient } from './usePoaLcdQueryClient'; -import { getLogoUrls } from '@/utils'; +import { getLogoUrls, normalizeIBCDenom } from '@/utils'; import { useManifestLcdQueryClient } from './useManifestLcdQueryClient'; @@ -632,6 +632,32 @@ export const useTokenFactoryDenomsMetadata = () => { }; }; +export const useOsmosisTokenFactoryDenomsMetadata = () => { + const { lcdQueryClient } = useOsmosisLcdQueryClient(); + + const fetchDenoms = async () => { + if (!lcdQueryClient) { + throw new Error('LCD Client not ready'); + } + + return await lcdQueryClient.cosmos.bank.v1beta1.denomsMetadata({}); + }; + + const denomsQuery = useQuery({ + queryKey: ['osmosisAllMetadatas'], + queryFn: fetchDenoms, + enabled: !!lcdQueryClient, + staleTime: Infinity, + }); + + return { + metadatas: denomsQuery.data, + isMetadatasLoading: denomsQuery.isLoading, + isMetadatasError: denomsQuery.isError, + refetchMetadatas: denomsQuery.refetch, + }; +}; + export const useTokenBalances = (address: string) => { const { lcdQueryClient } = useLcdQueryClient(); @@ -660,6 +686,34 @@ export const useTokenBalances = (address: string) => { }; }; +export const useTokenBalancesOsmosis = (address: string) => { + const { lcdQueryClient } = useOsmosisLcdQueryClient(); + + const fetchBalances = async () => { + if (!lcdQueryClient) { + throw new Error('LCD Client not ready'); + } + return await lcdQueryClient.cosmos.bank.v1beta1.allBalances({ + address, + resolveDenom: false, + }); + }; + + const balancesQuery = useQuery({ + queryKey: ['osmosisBalances', address], + queryFn: fetchBalances, + enabled: !!lcdQueryClient && !!address, + staleTime: Infinity, + }); + + return { + balances: balancesQuery.data?.balances, + isBalancesLoading: balancesQuery.isLoading, + isBalancesError: balancesQuery.isError, + refetchBalances: balancesQuery.refetch, + }; +}; + export const useTokenBalancesResolved = (address: string) => { const { lcdQueryClient } = useLcdQueryClient(); @@ -688,6 +742,126 @@ export const useTokenBalancesResolved = (address: string) => { }; }; +export const useOsmosisTokenBalancesResolved = (address: string) => { + const { lcdQueryClient } = useOsmosisLcdQueryClient(); + + const fetchBalances = async () => { + if (!lcdQueryClient) { + throw new Error('LCD Client not ready'); + } + return await lcdQueryClient.cosmos.bank.v1beta1.allBalances({ + address, + resolveDenom: true, + }); + }; + + const balancesQuery = useQuery({ + queryKey: ['osmosisBalances-resolved', address], + queryFn: fetchBalances, + enabled: !!lcdQueryClient && !!address, + staleTime: Infinity, + }); + + return { + balances: balancesQuery.data?.balances, + isBalancesLoading: balancesQuery.isLoading, + isBalancesError: balancesQuery.isError, + refetchBalances: balancesQuery.refetch, + }; +}; + +interface TransactionAmount { + amount: string; + denom: string; +} +export enum HistoryTxType { + SEND, + MINT, + BURN, + PAYOUT, + BURN_HELD_BALANCE, +} + +const _formatMessage = ( + message: any, + address: string +): { + data: { + tx_type: HistoryTxType; + from_address: string; + to_address: string; + amount: { amount: string; denom: string }[]; + }; +}[] => { + switch (message['@type']) { + case `/cosmos.bank.v1beta1.MsgSend`: + return [ + { + data: { + tx_type: HistoryTxType.SEND, + from_address: message.fromAddress, + to_address: message.toAddress, + amount: message.amount.map((amt: TransactionAmount) => ({ + amount: amt.amount, + denom: amt.denom, + })), + }, + }, + ]; + case `/osmosis.tokenfactory.v1beta1.MsgMint`: + return [ + { + data: { + tx_type: HistoryTxType.MINT, + from_address: message.sender, + to_address: message.mintToAddress, + amount: [message.amount], + }, + }, + ]; + case `/osmosis.tokenfactory.v1beta1.MsgBurn`: + return [ + { + data: { + tx_type: HistoryTxType.BURN, + from_address: message.sender, + to_address: message.burnFromAddress, + amount: [message.amount], + }, + }, + ]; + case `/liftedinit.manifest.v1.MsgPayout`: + return message.payoutPairs + .map((pair: { coin: TransactionAmount; address: string }) => { + if (message.authority === address || pair.address === address) { + return { + data: { + tx_type: HistoryTxType.PAYOUT, + from_address: message.authority, + to_address: pair.address, + amount: [{ amount: pair.coin.amount, denom: pair.coin.denom }], + }, + }; + } + return null; + }) + .filter((msg: any) => msg !== null); + case `/lifted.init.manifest.v1.MsgBurnHeldBalance`: + return [ + { + data: { + tx_type: HistoryTxType.BURN_HELD_BALANCE, + from_address: message.authority, + to_address: message.authority, + amount: message.burnCoins, + }, + }, + ]; + default: + return []; + } +}; + export const useGetMessagesFromAddress = ( indexerUrl: string, address: string, diff --git a/hooks/useTx.tsx b/hooks/useTx.tsx index 30d2ea2b..e87598f3 100644 --- a/hooks/useTx.tsx +++ b/hooks/useTx.tsx @@ -46,7 +46,7 @@ export const useTx = (chainName: string) => { const { address, getSigningStargateClient, estimateFee } = useChain(chainName); const { setToastMessage } = useToast(); const [isSigning, setIsSigning] = useState(false); - const explorerUrl = env.explorerUrl; + const explorerUrl = chainName === env.osmosisChain ? env.osmosisExplorerUrl : env.explorerUrl; const tx = async (msgs: Msg[], options: TxOptions) => { if (!address) { @@ -107,6 +107,7 @@ export const useTx = (chainName: string) => { if (isDeliverTxSuccess(res)) { if (options.onSuccess) options.onSuccess(); setIsSigning(false); + if (msgs.filter(msg => msg.typeUrl === '/cosmos.group.v1.MsgSubmitProposal').length > 0) { const submitProposalEvent = res.events.find( event => event.type === 'cosmos.group.v1.EventSubmitProposal' diff --git a/package.json b/package.json index 5d8487e2..fb4b6add 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": false, "description": "An application to interact with the Manifest Chain", "scripts": { - "dev": "next dev -H 0.0.0.0", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", @@ -13,7 +13,9 @@ "update-deps": "bunx npm-check-updates --root --format group -i", "test:coverage": "bun test --coverage", "test:coverage:lcov": "bun run test:coverage --coverage-reporter=lcov --coverage-dir ./coverage", - "coverage:upload": "codecov" + "coverage:upload": "codecov", + "ibc-transfer": "tsx scripts/ibcTransferAll.ts", + "print-tokens": "tsx scripts/printAllTokens.ts" }, "author": "The Lifted Initiative", "license": "MIT", @@ -45,6 +47,7 @@ "@liftedinit/manifestjs": "0.0.1-rc.1", "@react-three/drei": "^9.114.0", "@react-three/fiber": "^8.17.8", + "@skip-go/client": "^0.16.8", "@tanstack/react-query": "^5.55.0", "@tanstack/react-query-devtools": "^5.55.0", "@types/file-saver": "^2.0.7", @@ -52,7 +55,7 @@ "apexcharts": "^3.54.0", "autoprefixer": "^10.4.20", "babel-plugin-glsl": "^1.0.0", - "chain-registry": "1.69.93", + "chain-registry": "^1.69.115", "cosmjs-types": "^0.9.0", "cosmos-kit": "2.23.9", "country-flag-icons": "^1.5.13", @@ -107,7 +110,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "prettier": "^3.3.3", - "typescript": "4.9.3" + "typescript": "5.7.3" }, "files": [ "." diff --git a/pages/_app.tsx b/pages/_app.tsx index 42f5e266..d8b2bcf6 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,7 +9,15 @@ import { createPortal } from 'react-dom'; import { makeWeb3AuthWallets, SignData } from '@cosmos-kit/web3auth'; import { useEffect, useMemo, useState } from 'react'; import SignModal from '@/components/react/authSignerModal'; -import { manifestAssets, manifestChain } from '@/config'; +import { + assets as manifestAssets, + chain as manifestChain, +} from 'chain-registry/testnet/manifesttestnet'; +import { + assets as osmosisAssets, + chain as osmosisChain, +} from 'chain-registry/testnet/osmosistestnet'; +import { assets as axelarAssets, chain as axelarChain } from 'chain-registry/testnet/axelartestnet'; import { SignerOptions, wallets } from 'cosmos-kit'; import { wallets as cosmosExtensionWallets } from '@cosmos-kit/cosmos-extension-metamask'; @@ -31,15 +39,21 @@ import { osmosisProtoRegistry, cosmosAminoConverters, cosmosProtoRegistry, + ibcAminoConverters, + ibcProtoRegistry, } from '@liftedinit/manifestjs'; import MobileNav from '@/components/react/mobileNav'; import { WEB3AUTH_NETWORK_TYPE } from '@web3auth/auth'; +import { SkipProvider } from '@/contexts/skipGoContext'; + type ManifestAppProps = AppProps & { Component: AppProps['Component']; pageProps: AppProps['pageProps']; }; +// TODO: remove asset list injections when chain registry is updated + function ManifestApp({ Component, pageProps }: ManifestAppProps) { const [isDrawerVisible, setIsDrawerVisible] = useState(() => { // Initialize from localStorage if available, otherwise default to true @@ -63,11 +77,13 @@ function ManifestApp({ Component, pageProps }: ManifestAppProps) { ...osmosisProtoRegistry, ...strangeloveVenturesProtoRegistry, ...liftedinitProtoRegistry, + ...ibcProtoRegistry, ]); const mergedAminoTypes = new AminoTypes({ ...cosmosAminoConverters, ...liftedinitAminoConverters, ...osmosisAminoConverters, + ...ibcAminoConverters, ...strangeloveVenturesAminoConverters, }); return { @@ -174,6 +190,10 @@ function ManifestApp({ Component, pageProps }: ManifestAppProps) { rpc: [env.rpcUrl], rest: [env.apiUrl], }, + ['osmosistestnet']: { + rpc: [env.osmosisRpcUrl], + rest: [env.osmosisApiUrl], + }, }, }; @@ -183,8 +203,9 @@ function ManifestApp({ Component, pageProps }: ManifestAppProps) { { - - -
-
- -
+ + + +
+
+ +
-
-
- +
+
+ +
+
+ +
-
- -
-
- - {/* Web3auth signing modal */} - {isBrowser && - createPortal( - web3AuthPrompt?.resolve(false)} - data={web3AuthPrompt?.signData ?? ({} as SignData)} - approve={() => web3AuthPrompt?.resolve(true)} - reject={() => web3AuthPrompt?.resolve(false)} - />, - document.body - )} - - + + {/* Web3auth signing modal */} + {isBrowser && + createPortal( + web3AuthPrompt?.resolve(false)} + data={web3AuthPrompt?.signData ?? ({} as SignData)} + approve={() => web3AuthPrompt?.resolve(true)} + reject={() => web3AuthPrompt?.resolve(false)} + />, + document.body + )} + + + } diff --git a/pages/bank.tsx b/pages/bank.tsx index 0799d933..cbf6d97c 100644 --- a/pages/bank.tsx +++ b/pages/bank.tsx @@ -3,20 +3,24 @@ import { TokenList } from '@/components/bank/components/tokenList'; import { useGetMessagesFromAddress, useIsMobile, + useOsmosisTokenBalancesResolved, + useOsmosisTokenFactoryDenomsMetadata, useTokenBalances, + useTokenBalancesOsmosis, useTokenBalancesResolved, useTokenFactoryDenomsMetadata, } from '@/hooks'; -import { useChain } from '@cosmos-kit/react'; +import { useChain, useChains } from '@cosmos-kit/react'; import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { BankIcon } from '@/components/icons'; import { CombinedBalanceInfo } from '@/utils/types'; -import { MFX_TOKEN_DATA } from '@/utils/constants'; +import { MFX_TOKEN_DATA, OSMOSIS_TOKEN_DATA } from '@/utils/constants'; import env from '@/config/env'; import { SEO } from '@/components'; import { useResponsivePageSize } from '@/hooks/useResponsivePageSize'; import Link from 'next/link'; +import { denomToAsset } from '@/utils'; interface PageSizeConfig { tokenList: number; @@ -25,13 +29,21 @@ interface PageSizeConfig { } export default function Bank() { - const { address, isWalletConnected } = useChain(env.chain); - const { balances, isBalancesLoading, refetchBalances } = useTokenBalances(address ?? ''); + const chains = useChains([env.chain, env.osmosisChain, env.axelarChain]); + + const isWalletConnected = useMemo( + () => Object.values(chains).every(chain => chain.isWalletConnected), + [chains] + ); + + const { balances, isBalancesLoading, refetchBalances } = useTokenBalances( + chains.manifesttestnet.address ?? '' + ); const { balances: resolvedBalances, isBalancesLoading: resolvedLoading, refetchBalances: resolveRefetch, - } = useTokenBalancesResolved(address ?? ''); + } = useTokenBalancesResolved(chains.manifesttestnet.address ?? ''); const { metadatas, isMetadatasLoading } = useTokenFactoryDenomsMetadata(); const [currentPage, setCurrentPage] = useState(1); @@ -81,7 +93,12 @@ export default function Bank() { isError, refetch: refetchHistory, totalCount, - } = useGetMessagesFromAddress(env.indexerUrl, address ?? '', currentPage, historyPageSize); + } = useGetMessagesFromAddress( + env.indexerUrl, + chains.manifesttestnet.address ?? '', + currentPage, + historyPageSize + ); const combinedBalances = useMemo(() => { if (!balances || !resolvedBalances || !metadatas) return []; @@ -109,6 +126,32 @@ export default function Bank() { ); const metadata = metadatas.metadatas.find(m => m.base === coreBalance.denom); + if (coreBalance.denom.startsWith('ibc/')) { + const assetInfo = denomToAsset(env.chain, coreBalance.denom); + + const baseDenom = assetInfo?.traces?.[1]?.counterparty?.base_denom; + + return { + denom: baseDenom ?? '', // normalized denom (e.g., 'umfx') + coreDenom: coreBalance.denom, // full IBC trace + amount: coreBalance.amount, + metadata: { + description: assetInfo?.description ?? '', + denom_units: + assetInfo?.denom_units?.map(unit => ({ + ...unit, + aliases: unit.aliases || [], + })) ?? [], + base: assetInfo?.base ?? '', + display: assetInfo?.display ?? '', + name: assetInfo?.name ?? '', + symbol: assetInfo?.symbol ?? '', + uri: assetInfo?.logo_URIs?.svg ?? assetInfo?.logo_URIs?.png ?? '', + uri_hash: assetInfo?.logo_URIs?.svg ?? assetInfo?.logo_URIs?.png ?? '', + }, + }; + } + return { denom: resolvedBalance?.denom || coreBalance.denom, coreDenom: coreBalance.denom, @@ -121,6 +164,79 @@ export default function Bank() { return mfxCombinedBalance ? [mfxCombinedBalance, ...otherBalances] : otherBalances; }, [balances, resolvedBalances, metadatas]); + const { + balances: osmosisBalances, + isBalancesLoading: isOsmosisBalancesLoading, + refetchBalances: refetchOsmosisBalances, + } = useTokenBalancesOsmosis(chains.osmosistestnet.address ?? ''); + const { + balances: resolvedOsmosisBalances, + isBalancesLoading: resolvedOsmosisLoading, + refetchBalances: resolveOsmosisRefetch, + } = useOsmosisTokenBalancesResolved(chains.osmosistestnet.address ?? ''); + + const { + metadatas: osmosisMetadatas, + isMetadatasLoading: isOsmosisMetadatasLoading, + refetchMetadatas: refetchOsmosisMetadatas, + } = useOsmosisTokenFactoryDenomsMetadata(); + + const combinedOsmosisBalances = useMemo(() => { + if (!osmosisBalances || !resolvedOsmosisBalances || !osmosisMetadatas) { + return []; + } + + const combined = osmosisBalances.map((coreBalance): CombinedBalanceInfo => { + // Handle OSMO token specifically + if (coreBalance.denom === 'uosmo') { + return { + denom: 'uosmo', + coreDenom: coreBalance.denom, + amount: coreBalance.amount, + metadata: OSMOSIS_TOKEN_DATA, + }; + } + + // Handle IBC tokens + if (coreBalance.denom.startsWith('ibc/')) { + const assetInfo = denomToAsset(env.osmosisChain, coreBalance.denom); + + const baseDenom = assetInfo?.traces?.[1]?.counterparty?.base_denom; + + return { + denom: baseDenom ?? '', // normalized denom (e.g., 'umfx') + coreDenom: coreBalance.denom, // full IBC trace + amount: coreBalance.amount, + metadata: { + description: assetInfo?.description ?? '', + denom_units: + assetInfo?.denom_units?.map(unit => ({ + ...unit, + aliases: unit.aliases || [], + })) ?? [], + base: assetInfo?.base ?? '', + display: assetInfo?.display ?? '', + name: assetInfo?.name ?? '', + symbol: assetInfo?.symbol ?? '', + uri: assetInfo?.logo_URIs?.svg ?? assetInfo?.logo_URIs?.png ?? '', + uri_hash: assetInfo?.logo_URIs?.svg ?? assetInfo?.logo_URIs?.png ?? '', + }, + }; + } + + // Handle all other tokens + const metadata = osmosisMetadatas.metadatas?.find(m => m.base === coreBalance.denom); + return { + denom: coreBalance.denom, + coreDenom: coreBalance.denom, + amount: coreBalance.amount, + metadata: metadata || null, + }; + }); + + return combined; + }, [osmosisBalances, resolvedOsmosisBalances, osmosisMetadatas]); + const isLoading = isBalancesLoading || resolvedLoading || isMetadatasLoading; const [searchTerm, setSearchTerm] = useState(''); @@ -193,24 +309,29 @@ export default function Bank() { ) : ( ))} {activeTab === 'history' && - (totalCount === 0 && !txLoading ? ( + (totalPages === 0 ? ( ) : ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/ibcTransferAll.ts b/scripts/ibcTransferAll.ts new file mode 100644 index 00000000..40e1ce68 --- /dev/null +++ b/scripts/ibcTransferAll.ts @@ -0,0 +1,289 @@ +// This script is used to transfer all tokens on manifest to destination chain then print out a list of tokens that can be used in the chain-registry +// you can run this script by providing a mnemonic as an environment variable: `WALLET_MNEMONIC="..." bun run ibc-transfer` + +// ENV's: +// DESTINATION_RPC_URL: the rpc url of the destination chain +// DESTINATION_CHAIN: the name of the destination chain +// DESTINATION_PREFIX: the prefix of the destination chain +// SOURCE_CHANNEL: the channel id of the source chain +// DESTINATION_CHANNEL: the channel id of the destination chain + +// You can provide the above env's in the command in the same fashion as the mnemonic or they will be set to default values +// Axelar example: +// WALLET_MNEMONIC="" DESTINATION_CHAIN="axelar-testnet-lisbon-3" DESTINATION_PREFIX="axelar" SOURCE_CHANNEL="channel-3" DESTINATION_CHANNEL="channel-591" DESTINATION_RPC_URL="https://axelar-testnet-rpc.polkachu.com/" bun run ibc-transfer +// Axlear query only: +// QUERY_ONLY=true WALLET_MNEMONIC="" DESTINATION_CHAIN="axelar-testnet-lisbon-3" DESTINATION_PREFIX="axelar" SOURCE_CHANNEL="channel-3" DESTINATION_CHANNEL="channel-591" DESTINATION_RPC_URL="https://axelar-testnet-rpc.polkachu.com/" QUERY_ONLY="true" bun run ibc-transfer + +import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; +import { SigningStargateClient } from '@cosmjs/stargate'; + +import { MsgTransfer } from '@liftedinit/manifestjs/dist/codegen/ibc/applications/transfer/v1/tx'; + +import * as fs from 'fs'; +import * as path from 'path'; +import { ibc } from '@liftedinit/manifestjs'; + +// Environment Configuration +const env = { + rpcUrl: 'https://nodes.liftedinit.tech/manifest/testnet/rpc', + destinationRpcUrl: process.env.DESTINATION_RPC_URL || 'https://rpc.osmotest5.osmosis.zone', + chain: 'manifest-testnet', + destinationChain: process.env.DESTINATION_CHAIN || 'osmo-test-5', + destinationPrefix: process.env.DESTINATION_PREFIX || 'osmo', + sourceChannel: process.env.SOURCE_CHANNEL || 'channel-0', + destinationChannel: process.env.DESTINATION_CHANNEL || 'channel-10016', +}; + +// Add option for query-only mode at the top with other constants +const QUERY_ONLY = process.env.QUERY_ONLY === 'true'; + +// IBC Configuration +const getIbcInfo = (fromChain: string, toChain: string) => { + // Default configuration + return { + source_port: 'transfer', + source_channel: env.sourceChannel, + }; +}; + +// Configuration +const MANIFEST_RPC = env.rpcUrl; +const DESTINATION_RPC = env.destinationRpcUrl; +const SOURCE_CHAIN = env.chain; +const TARGET_CHAIN = env.destinationChain; + +// Helper function to format token info for asset list +function formatTokenForAssetList(ibcDenom: string, denomTrace: any, originalDenom: string) { + const tokenName = originalDenom.split('/').pop()?.replace('u', '') || ''; + const displayName = tokenName.toUpperCase(); + + return { + description: `${displayName} Token on Manifest Ledger Testnet`, + denom_units: [ + { + denom: ibcDenom, + exponent: 0, + }, + { + denom: tokenName, + exponent: 6, + }, + ], + type_asset: 'ics20', + base: ibcDenom, + name: displayName, + display: tokenName, + symbol: displayName, + traces: [ + { + type: 'ibc', + counterparty: { + chain_name: 'manifesttestnet', + base_denom: originalDenom, + channel_id: env.sourceChannel, + }, + chain: { + channel_id: env.destinationChannel, + path: `${denomTrace.path}/${originalDenom}`, + }, + }, + ], + images: [ + { + image_sync: { + chain_name: 'manifesttestnet', + base_denom: originalDenom, + }, + png: `https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.png`, + svg: `https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.svg`, + }, + ], + logo_URIs: { + png: `https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.png`, + svg: `https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.svg`, + }, + }; +} + +// Update the getDenomTrace function +async function getDenomTrace(hash: string) { + try { + const { createRPCQueryClient } = ibc.ClientFactory; + const client = await createRPCQueryClient({ + rpcEndpoint: DESTINATION_RPC, + }); + + const response = await client.ibc.applications.transfer.v1.denomTrace({ + hash: hash, + }); + + console.log('Denom trace response:', response); + return response.denomTrace; + } catch (error: any) { + console.error('Error fetching denom trace:', { + error: error.message, + hash: hash, + }); + return null; + } +} + +async function main() { + // Get mnemonic from environment or argument + const mnemonic = process.env.WALLET_MNEMONIC; + if (!mnemonic) { + throw new Error('Please provide WALLET_MNEMONIC environment variable'); + } + + // Setup wallets for both chains + const manifestWallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + prefix: 'manifest', + }); + const destinationWallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + prefix: env.destinationPrefix, + }); + + // Get addresses + const [manifestAccount] = await manifestWallet.getAccounts(); + const [destinationAccount] = await destinationWallet.getAccounts(); + + console.log('Manifest address:', manifestAccount.address); + console.log('Destination address:', destinationAccount.address); + + // Create signing clients + const manifestClient = await SigningStargateClient.connectWithSigner( + MANIFEST_RPC, + manifestWallet + ); + const destinationClient = await SigningStargateClient.connectWithSigner( + DESTINATION_RPC, + destinationWallet + ); + + // Query balances on Manifest chain + const balances = await manifestClient.getAllBalances(manifestAccount.address); + console.log('\nManifest chain balances:', balances); + + // Get IBC info + const { source_port, source_channel } = getIbcInfo(SOURCE_CHAIN, TARGET_CHAIN); + + // Filter and create IBC transfer messages for each token + const messages = balances + .filter(token => token.denom.startsWith('factory/')) + .map(token => { + const timeoutInNanos = (Date.now() + 1.2e6) * 1e6; + + return { + typeUrl: MsgTransfer.typeUrl, + value: { + sourcePort: source_port, + sourceChannel: source_channel, + sender: manifestAccount.address, + receiver: destinationAccount.address, + token: { + denom: token.denom, + amount: '1', + }, + timeoutHeight: { + revisionNumber: BigInt(0), + revisionHeight: BigInt(0), + }, + timeoutTimestamp: BigInt(timeoutInNanos), + }, + }; + }); + + // Execute transfers only if not in query-only mode + if (!QUERY_ONLY && messages.length > 0) { + try { + const fee = { + amount: [{ denom: 'umfx', amount: '5500' }], + gas: '5000000', + }; + + console.log('\nExecuting IBC transfers...'); + console.log(`Total tokens to transfer: ${messages.length}`); + + // Process each message individually + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + console.log(`\nProcessing transfer ${i + 1} of ${messages.length}`); + console.log(`Transferring token...`); + + const result = await manifestClient.signAndBroadcast( + manifestAccount.address, + [message], // Send single message instead of batch + fee + ); + + if (result.code !== 0) { + throw new Error(`Transaction failed with code ${result.code}. Logs: ${result.rawLog}`); + } + + console.log('Transfer result:', { + code: result.code, + hash: result.transactionHash, + }); + + // Add a small delay between transfers to prevent rate limiting + if (i + 1 < messages.length) { + console.log('Waiting 5 seconds before next transfer...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + } catch (error) { + console.error('Error during transfer:', error); + process.exit(1); + } + } else if (QUERY_ONLY) { + console.log('\nQuery-only mode - skipping transfers'); + } else { + console.log('No tokens to transfer'); + } + + if (!QUERY_ONLY) { + // Wait a bit for the transfers to complete + console.log('\nWaiting 1 minute for transfers to complete...'); + + await new Promise(resolve => setTimeout(resolve, 60000)); + } + + // Query final balances on Destination + console.log('\nQuerying Destination balances...'); + const destinationBalances = await destinationClient.getAllBalances(destinationAccount.address); + console.log('Destination chain balances:', destinationBalances); + + // Query IBC denom traces for each IBC token and format them + const ibcTokens = destinationBalances.filter(token => token.denom.startsWith('ibc/')); + const formattedTokens = []; + + if (ibcTokens.length > 0) { + console.log('\nProcessing IBC Denom Traces:'); + for (const token of ibcTokens) { + try { + const hash = token.denom.split('/')[1]; + const denomTrace = await getDenomTrace(hash); + console.log(`Processing ${token.denom}:`, denomTrace); + + if (denomTrace) { + // Extract original denom from the denom trace + const originalDenom = denomTrace.baseDenom; + formattedTokens.push(formatTokenForAssetList(token.denom, denomTrace, originalDenom)); + } + } catch (error) { + console.error(`Error processing denom trace for ${token.denom}:`, error); + } + } + + // Save formatted tokens to file + if (formattedTokens.length > 0) { + const outputPath = path.join(__dirname, 'chain-registry-tokens.json'); + fs.writeFileSync(outputPath, JSON.stringify({ tokens: formattedTokens }, null, 2)); + console.log(`\nChain Registry token information saved to ${outputPath}`); + } + } else { + console.log('No IBC tokens found in Destination balances'); + } +} + +main().catch(console.error); diff --git a/scripts/printAllTokens.ts b/scripts/printAllTokens.ts new file mode 100644 index 00000000..53c4aeb3 --- /dev/null +++ b/scripts/printAllTokens.ts @@ -0,0 +1,87 @@ +// This script prints all the tokens in the manifest testnet chain in a format to be used in the chain-registry + +import { cosmos } from '@liftedinit/manifestjs'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Environment Configuration +const env = { + rpcUrl: 'https://nodes.liftedinit.tech/manifest/testnet/rpc', + chain: 'manifest-testnet', +}; + +async function getTokenMetadata(denom: string) { + const { createRPCQueryClient } = cosmos.ClientFactory; + const client = await createRPCQueryClient({ rpcEndpoint: env.rpcUrl }); + try { + // Query token metadata using the bank module + const response = await client.cosmos.bank.v1beta1.denomMetadata({ denom }); + return response.metadata; + } catch (error) { + console.error(`Error fetching metadata for ${denom}:`, error); + return null; + } +} + +function formatTokenInfo(denom: string, metadata: any = null) { + const tokenName = denom.split('/').pop()?.replace('u', '') || ''; + const displayName = tokenName.toUpperCase(); + + return { + description: metadata?.description || `${displayName} Token`, + denom_units: [ + { + denom: denom, + exponent: 0, + }, + { + denom: displayName.toLowerCase(), + exponent: 6, + }, + ], + base: denom, + name: `${displayName} Token`, + display: displayName.toLowerCase(), + symbol: displayName, + logo_URIs: { + png: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.png', + svg: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.svg', + }, + images: [ + { + png: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.png', + svg: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/manifesttestnet/images/mfx.svg', + }, + ], + type_asset: 'factory_token', + }; +} + +async function main() { + // Create client + const { createRPCQueryClient } = cosmos.ClientFactory; + const client = await createRPCQueryClient({ rpcEndpoint: env.rpcUrl }); + + // Query balances for the specified address + const address = 'manifest1hj5fveer5cjtn4wd6wstzugjfdxzl0xp8ws9ct'; + const balances = await client.cosmos.bank.v1beta1.allBalances({ address, resolveDenom: false }); + + // Filter for factory tokens and format them + const factoryTokens = balances.balances.filter(token => token.denom.startsWith('factory/')); + const formattedTokens = []; + + console.log(`Found ${factoryTokens.length} factory tokens`); + + for (const token of factoryTokens) { + const metadata = await getTokenMetadata(token.denom); + const formattedToken = formatTokenInfo(token.denom, metadata); + formattedTokens.push(formattedToken); + } + + // Save to file + const outputPath = path.join(__dirname, 'token_metadata.json'); + fs.writeFileSync(outputPath, JSON.stringify({ tokens: formattedTokens }, null, 2)); + console.log(`\nToken metadata saved to ${outputPath}`); +} + +main().catch(console.error); diff --git a/tests/mock.ts b/tests/mock.ts index a5f9454c..553c437e 100644 --- a/tests/mock.ts +++ b/tests/mock.ts @@ -182,6 +182,42 @@ export const defaultAssetLists = [ }, ]; +export const osmosisAssetList = [ + { + chain_name: 'osmosistestnet', + assets: [ + { + name: 'Osmosis Testnet Token', + display: 'uosmo', + base: 'uosmo', + symbol: 'uosmo', + denom_units: [{ denom: 'uosmo', exponent: 0, aliases: ['uosmo'] }], + }, + ], + }, +]; + +export const osmosisChain: Chain = { + chain_name: 'osmosistestnet', + chain_id: 'osmo-test-5', + status: 'live', + network_type: 'testnet', + pretty_name: 'Osmosis Testnet', + bech32_prefix: 'osmo', + slip44: 118, + fees: { + fee_tokens: [ + { + denom: 'uosmo', + fixed_min_gas_price: 0.001, + low_gas_price: 0.001, + average_gas_price: 0.001, + high_gas_price: 0.001, + }, + ], + }, +}; + export const defaultChain: Chain = { chain_name: 'manifest', chain_id: 'manifest-1', diff --git a/tests/render.tsx b/tests/render.tsx index 20faad3b..344f9730 100644 --- a/tests/render.tsx +++ b/tests/render.tsx @@ -2,12 +2,22 @@ import React from 'react'; import { render } from '@testing-library/react'; import { ChainProvider } from '@cosmos-kit/react'; import { ToastProvider } from '@/contexts'; -import { defaultAssetLists, defaultChain } from '@/tests/mock'; + +import { + assets as manifestAssets, + chain as manifestChain, +} from 'chain-registry/testnet/manifesttestnet'; +import { + assets as osmosisAssets, + chain as osmosisChain, +} from 'chain-registry/testnet/osmosistestnet'; +import { assets as axelarAssets, chain as axelarChain } from 'chain-registry/testnet/axelartestnet'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SkipProvider } from '@/contexts/skipGoContext'; const defaultOptions = { - chains: [defaultChain], - assetLists: defaultAssetLists, + chains: [manifestChain, osmosisChain, axelarChain], + assetLists: [manifestAssets, osmosisAssets, axelarAssets], wallets: [], }; @@ -17,7 +27,9 @@ export const renderWithChainProvider = (ui: React.ReactElement, options = {}) => return render( - {ui} + + {ui} + , options diff --git a/utils/constants.ts b/utils/constants.ts index e9ab2771..64cef0ec 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -14,6 +14,20 @@ export const MFX_TOKEN_DATA: Omit = { + description: 'The native token of the Osmosis Chain', + denom_units: [ + { denom: 'uosmo', exponent: 0, aliases: [] }, + { denom: 'osmo', exponent: 6, aliases: [] }, + ], + base: 'uosmo', + display: 'osmo', + name: 'Osmosis', + symbol: 'OSMO', + uri: '', + uri_hash: '', +}; + export const tokenExponents = [ { exponent: 18, subdenom: 'atto', letter: 'a', description: 'Smallest unit, 10⁻¹⁸' }, { exponent: 15, subdenom: 'femto', letter: 'f', description: '10⁻¹⁵' }, diff --git a/utils/format.ts b/utils/format.ts index 24bc741a..6aa72dd7 100644 --- a/utils/format.ts +++ b/utils/format.ts @@ -1,5 +1,7 @@ import { MetadataSDKType } from '@liftedinit/manifestjs/dist/codegen/cosmos/bank/v1beta1/bank'; import { shiftDigits } from '@/utils/maths'; +import { denomToAsset } from './ibc'; +import env from '@/config/env'; export function formatLargeNumber(num: number): string { if (!Number.isFinite(num)) return 'Invalid number'; @@ -30,9 +32,17 @@ export function formatLargeNumber(num: number): string { } export function formatDenom(denom: string): string { - const cleanDenom = denom.replace(/^factory\/[^/]+\//, ''); + const assetInfo = denomToAsset(env.chain, denom); - if (cleanDenom.startsWith('u')) { + // Fallback to cleaning the denom if no assetInfo + const cleanDenom = denom?.replace(/^factory\/[^/]+\//, ''); + + // Skip cleaning for IBC denoms as they should be resolved via assetInfo + if (cleanDenom.startsWith('ibc/')) { + return assetInfo?.display.toUpperCase() ?? ''; + } + + if (cleanDenom?.startsWith('u')) { return cleanDenom.slice(1).toUpperCase(); } diff --git a/utils/ibc.ts b/utils/ibc.ts index 9a6e9cbd..638a0eba 100644 --- a/utils/ibc.ts +++ b/utils/ibc.ts @@ -1,9 +1,16 @@ -import { asset_lists as assetLists } from '@chain-registry/assets'; -import { Asset, AssetList } from '@chain-registry/types'; -import { assets, ibc } from 'chain-registry'; +import { Asset, AssetList, IBCInfo } from '@chain-registry/types'; + import { Coin } from '@liftedinit/manifestjs/dist/codegen/cosmos/base/v1beta1/coin'; import { shiftDigits } from './maths'; +import { + assets as manifestAssets, + ibc as manifestIbc, +} from 'chain-registry/testnet/manifesttestnet'; +import { assets as osmosisAssets, ibc as osmosisIbc } from 'chain-registry/testnet/osmosistestnet'; +import { assets as axelarAssets, ibc as axelarIbc } from 'chain-registry/testnet/axelartestnet'; + +const assets: AssetList[] = [manifestAssets, osmosisAssets, axelarAssets]; export const truncateDenom = (denom: string) => { return denom.slice(0, 10) + '...' + denom.slice(-6); @@ -13,19 +20,35 @@ const filterAssets = (chainName: string, assetList: AssetList[]): Asset[] => { return ( assetList .find(({ chain_name }) => chain_name === chainName) - ?.assets?.filter(({ type_asset }) => type_asset !== 'ics20') || [] + ?.assets?.filter(({ type_asset }) => type_asset === 'ics20' || !type_asset) || [] ); }; const getAllAssets = (chainName: string) => { const nativeAssets = filterAssets(chainName, assets); - const ibcAssets = filterAssets(chainName, assetLists); + const ibcAssets = filterAssets(chainName, assets); return [...nativeAssets, ...ibcAssets]; }; export const denomToAsset = (chainName: string, denom: string) => { - return getAllAssets(chainName).find(asset => asset.base === denom); + const allAssets = getAllAssets(chainName); + + // Only handle IBC hashes + if (denom.startsWith('ibc/')) { + // Find the asset that has this IBC hash as its base + const asset = allAssets.find(asset => asset.base === denom); + if (asset?.traces?.[0]?.counterparty?.base_denom) { + // Return the original denom from the counterparty chain + return { + ...asset, + base: asset.traces[0].counterparty.base_denom, + }; + } + } + + // Return original asset if not an IBC hash + return allAssets.find(asset => asset.base === denom); }; export const denomToExponent = (chainName: string, denom: string) => { @@ -47,15 +70,17 @@ export const prettyBalance = (chainName: string, balance: Coin) => { export type PrettyBalance = ReturnType; +const ibcData: IBCInfo[] = [...manifestIbc, ...osmosisIbc, ...axelarIbc]; + export const getIbcInfo = (fromChainName: string, toChainName: string) => { let flipped = false; - let ibcInfo = ibc.find( + let ibcInfo = ibcData.find( i => i.chain_1.chain_name === fromChainName && i.chain_2.chain_name === toChainName ); if (!ibcInfo) { - ibcInfo = ibc.find( + ibcInfo = ibcData.find( i => i.chain_1.chain_name === toChainName && i.chain_2.chain_name === fromChainName ); flipped = true; @@ -71,3 +96,25 @@ export const getIbcInfo = (fromChainName: string, toChainName: string) => { return { source_port, source_channel }; }; + +export const getIbcDenom = (chainName: string, denom: string) => { + const allAssets = getAllAssets(chainName); + + // Find the asset that has this denom as its counterparty base_denom + const ibcAsset = allAssets.find(asset => asset.traces?.[0]?.counterparty?.base_denom === denom); + + // Return the IBC hash (base) if found + return ibcAsset?.base; +}; + +export const normalizeIBCDenom = (chainName: string, denom: string) => { + const asset = denomToAsset(chainName, denom); + if (asset) { + return { + denom: asset.base, + }; + } + return { denom }; +}; + +export type ResolvedIBCDenom = ReturnType; diff --git a/utils/yupExtensions.ts b/utils/yupExtensions.ts index e2de151e..c6f3f029 100644 --- a/utils/yupExtensions.ts +++ b/utils/yupExtensions.ts @@ -89,7 +89,7 @@ Yup.addMethod(Yup.string, 'manifestAddress', function (message } const decoded = bech32.decode(value as `${string}1${string}`); - const validPrefixes = ['manifest', 'manifestvaloper', 'manifestvalcons']; + const validPrefixes = ['manifest', 'manifestvaloper', 'manifestvalcons', 'osmo']; if (!validPrefixes.includes(decoded.prefix)) { return createError({ path,