diff --git a/apps/explorer/.env.example b/apps/explorer/.env.example index 6d484438..03635ff1 100644 --- a/apps/explorer/.env.example +++ b/apps/explorer/.env.example @@ -4,4 +4,6 @@ NEXT_PUBLIC_METRICS_URL=https://api.shinzo.network/metrics NEXT_PUBLIC_RPC_URL=https://ethereum-rpc.publicnode.com # Optional overrides per path: /ethereum/* vs /shinzohub/* (Shinzo chain id 91273002) NEXT_PUBLIC_ETHEREUM_RPC_URL= -NEXT_PUBLIC_SHINZOHUB_RPC_URL= +NEXT_PUBLIC_SHINZOHUB_RPC_URL=http://rpc.develop.devnet.shinzo.network:8545 +SHINZOHUB_COSMOS_REST_URL=http://rpc.develop.devnet.shinzo.network:1317 +SHINZOHUB_COMET_RPC_URL=http://rpc.develop.devnet.shinzo.network:26657 diff --git a/apps/explorer/app/api/shinzohub/transactions/[hash]/route.ts b/apps/explorer/app/api/shinzohub/transactions/[hash]/route.ts new file mode 100644 index 00000000..8760e2d2 --- /dev/null +++ b/apps/explorer/app/api/shinzohub/transactions/[hash]/route.ts @@ -0,0 +1,37 @@ +import { + findTransactionByEvmHash, + getTransaction, +} from '@shinzo/shinzohub'; +import { getShinzohubQueryContext } from '../_lib/query-context'; +import { serializeTransaction } from '../_lib/serialize'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ hash: string }> }, +) { + try { + const { hash } = await params; + const { client, cosmosRestUrl, cometRpcUrl } = + getShinzohubQueryContext(); + let transaction; + try { + transaction = await getTransaction(client, { hash, cosmosRestUrl }); + } catch (cosmosError) { + const resolved = await findTransactionByEvmHash(client, { + hash, + cometRpcUrl, + }); + if (!resolved) { + throw cosmosError; + } + transaction = await getTransaction(client, { + hash: resolved.cosmosHash, + cosmosRestUrl, + }); + } + return Response.json(serializeTransaction(transaction)); + } catch (error) { + console.error('Failed to load ShinzoHub transaction:', error); + return Response.json({ error: 'Transaction not found' }, { status: 404 }); + } +} diff --git a/apps/explorer/app/api/shinzohub/transactions/_lib/query-context.ts b/apps/explorer/app/api/shinzohub/transactions/_lib/query-context.ts new file mode 100644 index 00000000..37a45e45 --- /dev/null +++ b/apps/explorer/app/api/shinzohub/transactions/_lib/query-context.ts @@ -0,0 +1,19 @@ +import { shinzoHubDevelop } from '@shinzo/shinzohub'; +import { getPublicClient } from '@/shared/viem/client'; + +const developRpcUrls = shinzoHubDevelop.rpcUrls as typeof shinzoHubDevelop.rpcUrls & { + cosmosRest: { http: readonly string[] }; + cometRpc: { http: readonly string[] }; +}; + +export function getShinzohubQueryContext() { + return { + client: getPublicClient('shinzohub'), + cosmosRestUrl: + process.env.SHINZOHUB_COSMOS_REST_URL ?? + developRpcUrls.cosmosRest.http[0], + cometRpcUrl: + process.env.SHINZOHUB_COMET_RPC_URL ?? + developRpcUrls.cometRpc.http[0], + }; +} diff --git a/apps/explorer/app/api/shinzohub/transactions/_lib/serialize.ts b/apps/explorer/app/api/shinzohub/transactions/_lib/serialize.ts new file mode 100644 index 00000000..db7e413a --- /dev/null +++ b/apps/explorer/app/api/shinzohub/transactions/_lib/serialize.ts @@ -0,0 +1,46 @@ +import type { + ShinzoHubTransaction, + ShinzoHubTransactionSummary, +} from '@shinzo/shinzohub'; +import type { + ShinzohubTransaction, + ShinzohubTransactionSummary, +} from '@/shared/shinzohub/types'; + +export function serializeTransactionSummary( + transaction: ShinzoHubTransactionSummary, +): ShinzohubTransactionSummary { + return { + ...transaction, + height: transaction.height.toString(), + gasWanted: transaction.gasWanted.toString(), + gasUsed: transaction.gasUsed.toString(), + actions: [...transaction.actions], + senders: [...transaction.senders], + recipients: [...transaction.recipients], + transfers: transaction.transfers.map((transfer) => ({ ...transfer })), + events: transaction.events.map((event) => ({ + ...event, + attributes: event.attributes.map((attribute) => ({ ...attribute })), + })), + }; +} +export function serializeTransaction( + transaction: ShinzoHubTransaction, +): ShinzohubTransaction { + return { + ...serializeTransactionSummary(transaction), + timestamp: transaction.timestamp, + memo: transaction.memo, + messages: transaction.messages.map((message) => ({ + typeUrl: message.typeUrl, + value: { ...message.value }, + })), + feeCoins: transaction.feeCoins.map((coin) => ({ ...coin })), + feePayer: transaction.feePayer, + feeGranter: transaction.feeGranter, + gasLimit: transaction.gasLimit.toString(), + signatures: [...transaction.signatures], + rawLog: transaction.rawLog, + }; +} diff --git a/apps/explorer/app/api/shinzohub/transactions/route.ts b/apps/explorer/app/api/shinzohub/transactions/route.ts new file mode 100644 index 00000000..0ab28269 --- /dev/null +++ b/apps/explorer/app/api/shinzohub/transactions/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server'; +import { listTransactions } from '@shinzo/shinzohub'; +import type { ShinzohubTransactionFilter } from '@/shared/shinzohub/types'; +import { getShinzohubQueryContext } from './_lib/query-context'; +import { serializeTransactionSummary } from './_lib/serialize'; + +function parsePositiveInteger(rawValue: string | null, fallback: number): number { + const value = rawValue ? Number(rawValue) : fallback; + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function parseKind(rawKind: string | null): ShinzohubTransactionFilter { + return rawKind === 'evm' ? 'evm' : 'all'; +} + +export async function GET(req: NextRequest) { + try { + const page = parsePositiveInteger(req.nextUrl.searchParams.get('page'), 1); + const limit = Math.min( + 100, + parsePositiveInteger(req.nextUrl.searchParams.get('limit'), 10), + ); + const kind = parseKind(req.nextUrl.searchParams.get('kind')); + const block = req.nextUrl.searchParams.get('block'); + const { client, cometRpcUrl } = getShinzohubQueryContext(); + const result = await listTransactions(client, { + page, + limit, + kind, + blockHeight: block || undefined, + cometRpcUrl, + }); + + return Response.json({ + transactions: result.transactions.map(serializeTransactionSummary), + total: Number(result.total), + }); + } catch (err) { + console.error('Failed to load ShinzoHub transactions:', err); + return Response.json({ error: 'Failed to load transactions' }, { status: 500 }); + } +} diff --git a/apps/explorer/app/ethereum/tx/[hash]/page.tsx b/apps/explorer/app/ethereum/tx/[hash]/page.tsx index cb4b1709..f16f75c3 100644 --- a/apps/explorer/app/ethereum/tx/[hash]/page.tsx +++ b/apps/explorer/app/ethereum/tx/[hash]/page.tsx @@ -1 +1 @@ -export { TransactionDetailPage as default } from '@/pages/transaction-details'; +export { EthereumTransactionDetailPage as default } from '@/pages/transaction-details'; diff --git a/apps/explorer/app/ethereum/txs/page.tsx b/apps/explorer/app/ethereum/txs/page.tsx index ac19d63f..a1caec03 100644 --- a/apps/explorer/app/ethereum/txs/page.tsx +++ b/apps/explorer/app/ethereum/txs/page.tsx @@ -1 +1 @@ -export { TransactionsPage as default } from '@/pages/transactions'; +export { EthereumTransactionsPage as default } from '@/pages/transactions'; diff --git a/apps/explorer/app/shinzohub/page.tsx b/apps/explorer/app/shinzohub/page.tsx index 855f4bf8..dc52f838 100644 --- a/apps/explorer/app/shinzohub/page.tsx +++ b/apps/explorer/app/shinzohub/page.tsx @@ -1 +1 @@ -export { HomePage as default } from '@/pages/shinzohub/home'; +export { HomePage as default } from '@/pages/shinzohub-home'; diff --git a/apps/explorer/app/shinzohub/tx/[hash]/page.tsx b/apps/explorer/app/shinzohub/tx/[hash]/page.tsx new file mode 100644 index 00000000..33542f0e --- /dev/null +++ b/apps/explorer/app/shinzohub/tx/[hash]/page.tsx @@ -0,0 +1 @@ +export { ShinzohubTransactionDetailPage as default } from '@/pages/transaction-details'; diff --git a/apps/explorer/app/shinzohub/txs/page.tsx b/apps/explorer/app/shinzohub/txs/page.tsx new file mode 100644 index 00000000..e19dbb16 --- /dev/null +++ b/apps/explorer/app/shinzohub/txs/page.tsx @@ -0,0 +1 @@ +export { ShinzohubTransactionsPage as default } from '@/pages/transactions'; diff --git a/apps/explorer/lib/pages/home/transactions-home.tsx b/apps/explorer/lib/pages/home/transactions-home.tsx index 9497e47a..0ec0048f 100644 --- a/apps/explorer/lib/pages/home/transactions-home.tsx +++ b/apps/explorer/lib/pages/home/transactions-home.tsx @@ -57,7 +57,7 @@ export const TransactionsHome = () => { <> {(value) => ( - + diff --git a/apps/explorer/lib/pages/home/use-blocks-and-transactions-count.ts b/apps/explorer/lib/pages/home/use-blocks-and-transactions-count.ts index 147ab324..6cf6c04c 100644 --- a/apps/explorer/lib/pages/home/use-blocks-and-transactions-count.ts +++ b/apps/explorer/lib/pages/home/use-blocks-and-transactions-count.ts @@ -52,7 +52,7 @@ export const useBlocksAndTransactionsCount = () => { if (!response.ok) { throw new Error(`Failed to fetch metrics: ${response.statusText}`); } - const data = await response.json(); + const data: MetricsResponse = await response.json(); return data; }, }); diff --git a/apps/explorer/lib/pages/shinzohub/home/hook/use-home-blocks.ts b/apps/explorer/lib/pages/shinzohub-home/hook/use-home-blocks.ts similarity index 98% rename from apps/explorer/lib/pages/shinzohub/home/hook/use-home-blocks.ts rename to apps/explorer/lib/pages/shinzohub-home/hook/use-home-blocks.ts index d70ef8a7..bf0e3f6b 100644 --- a/apps/explorer/lib/pages/shinzohub/home/hook/use-home-blocks.ts +++ b/apps/explorer/lib/pages/shinzohub-home/hook/use-home-blocks.ts @@ -67,7 +67,7 @@ export function useHomeBlocks({ count = 5 }: UseHomeBlocksOptions = {}) { return () => { unwatch() } - }, [count]) + }, [count, publicClient]) return { blocks, isLoading, error } } diff --git a/apps/explorer/lib/pages/shinzohub-home/hook/use-home-transactions.ts b/apps/explorer/lib/pages/shinzohub-home/hook/use-home-transactions.ts new file mode 100644 index 00000000..b62f0c95 --- /dev/null +++ b/apps/explorer/lib/pages/shinzohub-home/hook/use-home-transactions.ts @@ -0,0 +1,35 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { + fetchShinzohubTransactions, + shinzohubTransactionsQueryKey, +} from '@/pages/transactions/hooks/shinzohub/use-shinzohub-transactions'; + +type UseHomeTransactionsOptions = { + count?: number; + refetchIntervalMs?: number; +}; + +export function useHomeTransactions( + { count = 5, refetchIntervalMs = 10_000 }: UseHomeTransactionsOptions = {}, +) { + const limit = Math.max(1, count); + + return useQuery({ + queryKey: shinzohubTransactionsQueryKey({ page: 1, limit, kind: 'all' }), + queryFn: () => fetchShinzohubTransactions({ page: 1, limit, kind: 'all' }), + staleTime: refetchIntervalMs, + refetchInterval: refetchIntervalMs, + refetchIntervalInBackground: true, + select: (data) => ({ + total: data.total, + transactions: data.transactions.map((transaction) => ({ + hash: transaction.cosmosHash, + from: transaction.senders[0] ?? null, + to: transaction.recipients[0] ?? null, + value: transaction.transfers[0]?.amount ?? null, + })), + }), + }); +} diff --git a/apps/explorer/lib/pages/shinzohub/home/index.ts b/apps/explorer/lib/pages/shinzohub-home/index.ts similarity index 100% rename from apps/explorer/lib/pages/shinzohub/home/index.ts rename to apps/explorer/lib/pages/shinzohub-home/index.ts diff --git a/apps/explorer/lib/pages/shinzohub/home/ui/blocks-home.tsx b/apps/explorer/lib/pages/shinzohub-home/ui/blocks-home.tsx similarity index 100% rename from apps/explorer/lib/pages/shinzohub/home/ui/blocks-home.tsx rename to apps/explorer/lib/pages/shinzohub-home/ui/blocks-home.tsx diff --git a/apps/explorer/lib/pages/shinzohub/home/ui/home-page.tsx b/apps/explorer/lib/pages/shinzohub-home/ui/home-page.tsx similarity index 100% rename from apps/explorer/lib/pages/shinzohub/home/ui/home-page.tsx rename to apps/explorer/lib/pages/shinzohub-home/ui/home-page.tsx diff --git a/apps/explorer/lib/pages/shinzohub/home/ui/stats-home.tsx b/apps/explorer/lib/pages/shinzohub-home/ui/stats-home.tsx similarity index 92% rename from apps/explorer/lib/pages/shinzohub/home/ui/stats-home.tsx rename to apps/explorer/lib/pages/shinzohub-home/ui/stats-home.tsx index 0782b809..0e8800e8 100644 --- a/apps/explorer/lib/pages/shinzohub/home/ui/stats-home.tsx +++ b/apps/explorer/lib/pages/shinzohub-home/ui/stats-home.tsx @@ -8,15 +8,15 @@ import { useHomeTransactions } from "../hook/use-home-transactions"; export const HomeStats = () => { const {blocks, isLoading: blocksLoading} = useHomeBlocks({ count: 5 }); const {data: transactions, isLoading: transactionsLoading} = useHomeTransactions(); - + return (
} isLoading={blocksLoading}> } isLoading={transactionsLoading}> - +
); - }; \ No newline at end of file + }; diff --git a/apps/explorer/lib/pages/shinzohub/home/ui/transactions-home.tsx b/apps/explorer/lib/pages/shinzohub-home/ui/transactions-home.tsx similarity index 82% rename from apps/explorer/lib/pages/shinzohub/home/ui/transactions-home.tsx rename to apps/explorer/lib/pages/shinzohub-home/ui/transactions-home.tsx index 88184503..0b9242ce 100644 --- a/apps/explorer/lib/pages/shinzohub/home/ui/transactions-home.tsx +++ b/apps/explorer/lib/pages/shinzohub-home/ui/transactions-home.tsx @@ -6,6 +6,7 @@ import { TableLayout, TableNullableCell } from '@shinzo/ui/table'; import ShinzoTxnIcon from '@/shared/ui/icons/shinzo-txn.svg'; import { Typography } from '@/shared/ui/typography'; import { formatHash } from '@/shared/utils/format-hash'; +import { formatShinzoCoin } from '@/shared/utils/format-token'; import { cn } from '@/shared/utils/utils'; import { HALF_CONTAINER_CLASS } from './blocks-home'; import { useHomeTransactions } from '../hook/use-home-transactions'; @@ -14,8 +15,27 @@ import { CopyButton } from '@/shared/ui/button'; import { getPageLink } from '@/shared/utils/links'; import { useChainPathSegment } from '@/widgets/chain-path-segment'; +function TransactionValue({ value }: { value: string }) { + const formatted = formatShinzoCoin(value); + const separator = formatted.lastIndexOf(' '); + + if (separator === -1) { + return formatted; + } + + return ( + + {formatted.slice(0, separator)} + + {formatted.slice(separator + 1)} + + + ); +} + export const TransactionsHome = () => { - const { data: transactions, isLoading } = useHomeTransactions({ count: 5 }); + const { data, isLoading } = useHomeTransactions({ count: 5 }); + const transactions = data?.transactions; const chain = useChainPathSegment(); const dataIds = useMemo( @@ -27,11 +47,6 @@ export const TransactionsHome = () => { duration: 1000, }); - const formatValue = (value: string) => { - const eth = Number(value) / 1e18; - return eth.toFixed(2); - }; - return (
@@ -58,7 +73,7 @@ export const TransactionsHome = () => { <> {(value) => ( - + @@ -95,8 +110,8 @@ export const TransactionsHome = () => { {(value) => ( -
- {formatValue(value)}ETH +
+
)} diff --git a/apps/explorer/lib/pages/shinzohub/home/hook/use-home-transactions.ts b/apps/explorer/lib/pages/shinzohub/home/hook/use-home-transactions.ts deleted file mode 100644 index 5365d175..00000000 --- a/apps/explorer/lib/pages/shinzohub/home/hook/use-home-transactions.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { useShinzohubTransactionIndex } from '../../hook/use-shinzohub-transaction-index'; - -export type TransactionSummary = { - hash: `0x${string}`; - from: `0x${string}`; - to: `0x${string}` | null | undefined; - value: string; -}; - -type UseHomeTransactionsOptions = { - count?: number; - refetchIntervalMs?: number; -}; - -export function useHomeTransactions( - { count, refetchIntervalMs = 10_000 }: UseHomeTransactionsOptions = {}, -) { - const indexQuery = useShinzohubTransactionIndex({ refetchIntervalMs }); - - const transactions = useMemo(() => { - const indexed = indexQuery.data?.transactions ?? []; - if (!indexed.length) return []; - const sorted = [...indexed] - .sort((a, b) => { - const blockDelta = BigInt(b.blockNumber) - BigInt(a.blockNumber); - if (blockDelta !== BigInt(0)) { - return blockDelta > BigInt(0) ? 1 : -1; - } - return b.transactionIndex - a.transactionIndex; - }) - const requiredTransactions = count === undefined ? sorted : sorted.slice(0, count) - return requiredTransactions.map((tx) => ({ - hash: tx.hash, - from: tx.from, - to: tx.to ?? null, - value: tx.value, - gasPrice: tx.gasPrice, - })); - }, [indexQuery.data?.transactions, count]); - - return { - ...indexQuery, - data: transactions, - }; -} diff --git a/apps/explorer/lib/pages/shinzohub/hook/use-shinzohub-transaction-index.ts b/apps/explorer/lib/pages/shinzohub/hook/use-shinzohub-transaction-index.ts deleted file mode 100644 index 4b0de139..00000000 --- a/apps/explorer/lib/pages/shinzohub/hook/use-shinzohub-transaction-index.ts +++ /dev/null @@ -1,163 +0,0 @@ -'use client'; - -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { getPublicClient } from '@/shared/viem/client'; -import type { Transaction } from 'viem'; - -const TX_INDEX_QUERY_KEY = ['shinzohub', 'transaction-index'] as const; -const TX_INDEX_STORAGE_KEY = 'shinzohub_tx_index_v1'; -const TX_INDEX_REFETCH_INTERVAL_MS = 60_000; -const BLOCK_BATCH_SIZE = 20; - -export type IndexedTransaction = { - hash: `0x${string}`; - from: `0x${string}`; - to: `0x${string}` | null; - value: string; - gasPrice: string; - blockNumber: string; - timestamp: string; - transactionIndex: number; -}; - -export type TransactionIndexState = { - lastScannedBlock: string; - transactions: IndexedTransaction[]; -}; - -function isFullTransaction( - entry: Transaction | `0x${string}`, -): entry is Transaction { - return typeof entry === 'object' && entry !== null && 'hash' in entry; -} - -function safeReadStorage(): TransactionIndexState { - if (typeof window === 'undefined') { - return { lastScannedBlock: '-1', transactions: [] }; - } - try { - const raw = window.localStorage.getItem(TX_INDEX_STORAGE_KEY); - if (!raw) return { lastScannedBlock: '-1', transactions: [] }; - const parsed = JSON.parse(raw) as Partial | null; - if (!parsed || typeof parsed.lastScannedBlock !== 'string' || !Array.isArray(parsed.transactions)) { - return { lastScannedBlock: '-1', transactions: [] }; - } - return { - lastScannedBlock: parsed.lastScannedBlock, - transactions: parsed.transactions as IndexedTransaction[], - }; - } catch { - return { lastScannedBlock: '-1', transactions: [] }; - } -} - -function safeWriteStorage(state: TransactionIndexState): void { - if (typeof window === 'undefined') return; - window.localStorage.setItem(TX_INDEX_STORAGE_KEY, JSON.stringify(state)); -} - -type SyncOptions = { - onCheckpoint?: (state: TransactionIndexState) => void; -}; - -export async function syncShinzohubTransactionIndex( - options: SyncOptions = {}, -): Promise { - const { onCheckpoint } = options; - const publicClient = getPublicClient('shinzohub'); - const latestBlock = await publicClient.getBlockNumber({ cacheTime: 0 }); - - const cached = safeReadStorage(); - let nextBlockToScan = BigInt(cached.lastScannedBlock) + BigInt(1); - if (nextBlockToScan < BigInt(0)) { - nextBlockToScan = BigInt(0); - } - - // Nothing new to scan; keep cache aligned with current tip. - if (nextBlockToScan > latestBlock) { - const unchanged = { - ...cached, - lastScannedBlock: latestBlock.toString(), - }; - safeWriteStorage(unchanged); - onCheckpoint?.(unchanged); - return unchanged; - } - - const allTransactions = [...cached.transactions]; - const seenHashes = new Set(allTransactions.map((tx) => tx.hash)); - - let start = nextBlockToScan; - while (start <= latestBlock) { - const end = start + BigInt(BLOCK_BATCH_SIZE - 1); - const batchEnd = end < latestBlock ? end : latestBlock; - - const blockNumbers: bigint[] = []; - for (let current = start; current <= batchEnd; current += BigInt(1)) { - blockNumbers.push(current); - } - - const blocks = await Promise.all( - blockNumbers.map((blockNumber) => - publicClient.getBlock({ - blockNumber, - includeTransactions: true, - }), - ), - ); - - blocks.forEach((block) => { - const txObjects = block.transactions.filter(isFullTransaction); - txObjects.forEach((tx, idx) => { - if (!tx.hash || seenHashes.has(tx.hash)) return; - allTransactions.push({ - hash: tx.hash, - from: tx.from, - to: tx.to ?? null, - value: (tx.value ?? BigInt(0)).toString(), - gasPrice: (tx.gasPrice ?? BigInt(0)).toString(), - blockNumber: (block.number ?? BigInt(0)).toString(), - timestamp: (block.timestamp ?? BigInt(0)).toString(), - transactionIndex: Number(tx.transactionIndex ?? idx), - }); - seenHashes.add(tx.hash); - }); - }); - - const checkpoint: TransactionIndexState = { - lastScannedBlock: batchEnd.toString(), - transactions: allTransactions, - }; - safeWriteStorage(checkpoint); - onCheckpoint?.(checkpoint); - start = batchEnd + BigInt(1); - } - - const finalState: TransactionIndexState = { - lastScannedBlock: latestBlock.toString(), - transactions: allTransactions, - }; - safeWriteStorage(finalState); - onCheckpoint?.(finalState); - return finalState; -} - -export function useShinzohubTransactionIndex( - { refetchIntervalMs = TX_INDEX_REFETCH_INTERVAL_MS }: { refetchIntervalMs?: number } = {}, -) { - const queryClient = useQueryClient(); - - return useQuery({ - queryKey: TX_INDEX_QUERY_KEY, - queryFn: () => - syncShinzohubTransactionIndex({ - onCheckpoint: (checkpoint) => { - queryClient.setQueryData(TX_INDEX_QUERY_KEY, checkpoint); - }, - }), - staleTime: refetchIntervalMs, - refetchInterval: refetchIntervalMs, - refetchIntervalInBackground: true, - }); -} - diff --git a/apps/explorer/lib/pages/shinzohub/index.ts b/apps/explorer/lib/pages/shinzohub/index.ts deleted file mode 100644 index 47b3eb71..00000000 --- a/apps/explorer/lib/pages/shinzohub/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { HomePage as default } from './home'; \ No newline at end of file diff --git a/apps/explorer/lib/pages/transaction-details/use-attestations.ts b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-attestations.ts similarity index 92% rename from apps/explorer/lib/pages/transaction-details/use-attestations.ts rename to apps/explorer/lib/pages/transaction-details/hook/ethereum/use-attestations.ts index 1f57dda1..62b9ff4f 100644 --- a/apps/explorer/lib/pages/transaction-details/use-attestations.ts +++ b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-attestations.ts @@ -11,7 +11,7 @@ const AttestationsQuery = graphql(` export const useAttestations = (docId: string | undefined) => { return useQuery({ - queryKey: ['attestations', docId], + queryKey: ['ethereum', 'attestations', docId], enabled: !!docId, staleTime: 60 * 1000, queryFn: async () => { diff --git a/apps/explorer/lib/pages/transaction-details/use-decoded-log.ts b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-decoded-log.ts similarity index 95% rename from apps/explorer/lib/pages/transaction-details/use-decoded-log.ts rename to apps/explorer/lib/pages/transaction-details/hook/ethereum/use-decoded-log.ts index 76fdb0d4..3fa8a5a1 100644 --- a/apps/explorer/lib/pages/transaction-details/use-decoded-log.ts +++ b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-decoded-log.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { decodeEventLog, getAddress, type AbiEvent } from 'viem'; -import { KNOWN_EVENTS } from './known-events'; +import { KNOWN_EVENTS } from '../../known-events'; export interface DecodedArg { name: string; @@ -124,11 +124,13 @@ export const useDecodedLog = ( const topic0 = topics?.[0]; return useQuery({ - queryKey: ['decoded-log', topics, data], + queryKey: ['ethereum', 'decoded-log', topics, data], queryFn: async (): Promise => { if (!topic0 || !topics) return null; - const rawData: Hex = `0x${data ?? ''}`; + const rawData: Hex = data?.startsWith('0x') + ? data as Hex + : `0x${data ?? ''}`; const candidates = KNOWN_EVENTS[topic0]; if (candidates) { diff --git a/apps/explorer/lib/pages/transaction-details/use-transaction-logs.ts b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-ethereum-transaction-logs.ts similarity index 78% rename from apps/explorer/lib/pages/transaction-details/use-transaction-logs.ts rename to apps/explorer/lib/pages/transaction-details/hook/ethereum/use-ethereum-transaction-logs.ts index a3ba5ff5..35096a2e 100644 --- a/apps/explorer/lib/pages/transaction-details/use-transaction-logs.ts +++ b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-ethereum-transaction-logs.ts @@ -2,6 +2,7 @@ import { execute, graphql } from '@/shared/graphql'; import { useQuery } from '@tanstack/react-query'; +import { Hex } from 'viem'; const TransactionLogsQuery = graphql(` query TransactionLogs($hash: String) { @@ -19,12 +20,12 @@ const TransactionLogsQuery = graphql(` } `); -interface UseTransactionLogsOptions { - hash: string; +interface UseEthereumTransactionLogsOptions { + hash: Hex; enabled?: boolean; } -export const useTransactionLogs = ({ hash, enabled = true }: UseTransactionLogsOptions) => { +export const useEthereumTransactionLogs = ({ hash, enabled = true }: UseEthereumTransactionLogsOptions) => { return useQuery({ queryKey: ['transaction-logs', hash], enabled: !!hash && enabled, @@ -34,4 +35,4 @@ export const useTransactionLogs = ({ hash, enabled = true }: UseTransactionLogsO return res.Logs ?? []; }, }); -}; +}; \ No newline at end of file diff --git a/apps/explorer/lib/pages/transaction-details/use-transaction.ts b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-ethereum-transaction.ts similarity index 84% rename from apps/explorer/lib/pages/transaction-details/use-transaction.ts rename to apps/explorer/lib/pages/transaction-details/hook/ethereum/use-ethereum-transaction.ts index 6106cfeb..f17b01cb 100644 --- a/apps/explorer/lib/pages/transaction-details/use-transaction.ts +++ b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-ethereum-transaction.ts @@ -1,5 +1,6 @@ import { execute, graphql } from '@/shared/graphql'; import { useQuery } from '@tanstack/react-query'; +import { Hex } from 'viem'; const TransactionQuery = graphql(` query Transaction($hash: String) { @@ -34,11 +35,11 @@ const TransactionQuery = graphql(` } `) -interface UseTransactionOptions { - hash: string; +interface UseEthereumTransactionOptions { + hash: Hex; } -export const useTransaction = (options: UseTransactionOptions) => { +export const useEthereumTransaction = (options: UseEthereumTransactionOptions) => { const { hash } = options; return useQuery({ @@ -49,5 +50,4 @@ export const useTransaction = (options: UseTransactionOptions) => { }, enabled: !!hash, }); -}; - +}; \ No newline at end of file diff --git a/apps/explorer/lib/pages/transaction-details/use-token-metadata.ts b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-token-metadata.ts similarity index 97% rename from apps/explorer/lib/pages/transaction-details/use-token-metadata.ts rename to apps/explorer/lib/pages/transaction-details/hook/ethereum/use-token-metadata.ts index 25e62d4f..090dde95 100644 --- a/apps/explorer/lib/pages/transaction-details/use-token-metadata.ts +++ b/apps/explorer/lib/pages/transaction-details/hook/ethereum/use-token-metadata.ts @@ -29,7 +29,7 @@ export const useTokenMetadata = (address: string | undefined) => { const chain = useChainPathSegment(); return useQuery({ - queryKey: ['token-metadata', chain, address], + queryKey: ['ethereum', 'token-metadata', chain, address], queryFn: async (): Promise => { if (!address) return null; diff --git a/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-evm-transaction.ts b/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-evm-transaction.ts new file mode 100644 index 00000000..07a7d200 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-evm-transaction.ts @@ -0,0 +1,16 @@ +import { getPublicClient } from '@/shared/viem/client'; +import { useQuery } from '@tanstack/react-query'; +import type { Hex, Transaction } from 'viem'; + +export const useShinzohubEvmTransaction = (hash?: Hex | null) => { + return useQuery({ + queryKey: ['shinzohub', 'evm-transaction', hash], + queryFn: () => { + if (!hash) { + throw new Error('EVM transaction hash is required'); + } + return getPublicClient('shinzohub').getTransaction({ hash }); + }, + enabled: !!hash, + }); +}; diff --git a/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-transaction-details.ts b/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-transaction-details.ts new file mode 100644 index 00000000..91ba43f4 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-transaction-details.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ShinzohubTransaction } from '@/shared/shinzohub/types'; + +const fetchShinzohubTransactionDetails = async (hash: string): Promise => { + const response = await fetch(`/api/shinzohub/transactions/${encodeURIComponent(hash)}`); + if (!response.ok) { + throw new Error('Failed to fetch ShinzoHub transaction'); + } + return response.json() as Promise; +}; + +export const useShinzohubTransactionDetails = (hash: string) => { + return useQuery({ + queryKey: ['shinzohub', 'transaction-details', hash], + queryFn: () => fetchShinzohubTransactionDetails(hash), + enabled: !!hash, + }); +}; diff --git a/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-transaction-receipt.ts b/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-transaction-receipt.ts new file mode 100644 index 00000000..236e7a45 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/hook/shinzohub/use-shinzohub-transaction-receipt.ts @@ -0,0 +1,23 @@ +import { getPublicClient } from "@/shared/viem/client"; +import { useQuery } from "@tanstack/react-query"; +import type { Hex, TransactionReceipt } from "viem"; + +const fetchShinzohubTransactionReceipt = async (hash: Hex): Promise => { + const publicClient = getPublicClient('shinzohub'); + const receipt = await publicClient.getTransactionReceipt({ hash }); + + return receipt; +}; + +export const useShinzohubTransactionReceipt = (hash?: Hex | null) => { + return useQuery({ + queryKey: ['shinzohub', 'transaction-receipt', hash], + queryFn: () => { + if (!hash) { + throw new Error('EVM transaction hash is required'); + } + return fetchShinzohubTransactionReceipt(hash); + }, + enabled: !!hash, + }); +}; diff --git a/apps/explorer/lib/pages/transaction-details/index.ts b/apps/explorer/lib/pages/transaction-details/index.ts index afbed1c0..9b4e52f3 100644 --- a/apps/explorer/lib/pages/transaction-details/index.ts +++ b/apps/explorer/lib/pages/transaction-details/index.ts @@ -1 +1,2 @@ -export { TransactionDetailPage } from './page'; +export { EthereumTransactionDetailPage } from './ui/ethereum/ethereum-page'; +export { ShinzohubTransactionDetailPage } from './ui/shinzohub/shinzohub-page'; \ No newline at end of file diff --git a/apps/explorer/lib/pages/transaction-details/transaction-logs.tsx b/apps/explorer/lib/pages/transaction-details/transaction-logs.tsx deleted file mode 100644 index 0c0c6622..00000000 --- a/apps/explorer/lib/pages/transaction-details/transaction-logs.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client'; - -import { Container } from '@/widgets/layout'; -import { Typography } from '@/shared/ui/typography'; -import { Badge } from '@/shared/ui/badge'; -import { CopyButton } from '@/shared/ui/button'; -import { Skeleton } from '@shinzo/ui/skeleton'; -import { DataList, DataItem } from '@/widgets/data-list'; -import { useTransactionLogs } from './use-transaction-logs'; -import { useDecodedLog } from './use-decoded-log'; -import { useTokenMetadata, getTokenIconUrl, type TokenMetadata } from './use-token-metadata'; -import { isTokenEvent } from './known-events'; -import { formatUnits, getAddress } from 'viem'; -import Link from 'next/link'; -import { getPageLink } from '@/shared/utils/links'; -import { useChainPathSegment } from '@/widgets/chain-path-segment'; - -interface LogEntryProps { - logIndex: number | null | undefined; - address: string | null | undefined; - topics: (string | null)[] | null | undefined; - data: string | null | undefined; -} - -const TokenInfo = ({ token }: { token: TokenMetadata }) => { - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {token.symbol} { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} - /> - - {token.symbol} - - - ({token.name}) - -
- ); -}; - -const LogEntry = ({ logIndex, address, topics, data }: LogEntryProps) => { - const nonNullTopics = (topics?.filter(Boolean) as string[]) ?? []; - const { data: decoded } = useDecodedLog(nonNullTopics, data); - const chain = useChainPathSegment(); - - const isToken = isTokenEvent(nonNullTopics); - const { data: token } = useTokenMetadata(isToken ? (address ?? undefined) : undefined); - - let checksumAddress: string | undefined; - try { - checksumAddress = address ? getAddress(address) : undefined; - } catch { - checksumAddress = address ?? undefined; - } - - return ( - - {/* Header: log index + token info / contract address + event name */} - - {decoded && ( - - {decoded.eventName} - - )} - {checksumAddress && ( - <> - - - {checksumAddress} - - - - - )} - - - {decoded ? ( - decoded.args.map((arg, i) => ( - {formatUnits(BigInt(arg.value), token.decimals)} ) - : arg.value - } - /> - )) - ) : ( - // Raw fallback: topics + data as hex - <> - {nonNullTopics.map((topic, i) => ( - - ))} - - - )} - - ); -}; - -export interface TransactionLogsProps { - txHash: string; -} - -export const TransactionLogs = ({ txHash }: TransactionLogsProps) => { - const { data: logs, isLoading } = useTransactionLogs({ hash: txHash }); - - if (isLoading) { - return ( - -
-
-
-
- ); - } - - if (!logs || logs.length === 0) { - return ( - - No logs for this transaction. - - ); - } - - return ( -
- {logs.map((log, i) => log && ( - - ))} -
- ); -}; diff --git a/apps/explorer/lib/pages/transaction-details/attestations-tooltip.tsx b/apps/explorer/lib/pages/transaction-details/ui/ethereum/attestations-tooltip.tsx similarity index 92% rename from apps/explorer/lib/pages/transaction-details/attestations-tooltip.tsx rename to apps/explorer/lib/pages/transaction-details/ui/ethereum/attestations-tooltip.tsx index 348acf81..083dae66 100644 --- a/apps/explorer/lib/pages/transaction-details/attestations-tooltip.tsx +++ b/apps/explorer/lib/pages/transaction-details/ui/ethereum/attestations-tooltip.tsx @@ -1,7 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip'; import { Badge } from '@/shared/ui/badge'; import { Typography } from '@/shared/ui/typography'; -import { useAttestations } from '@/pages/transaction-details/use-attestations'; +import { useAttestations } from '@/pages/transaction-details/hook/ethereum/use-attestations'; export interface AttestationsTooltipProps { docId: string | undefined; diff --git a/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-page.tsx b/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-page.tsx new file mode 100644 index 00000000..6149c867 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-page.tsx @@ -0,0 +1,26 @@ +import { PageLayout } from '@/widgets/layout' +import { Typography } from '@/shared/ui/typography'; +import { formatHash } from '@/shared/utils/format-hash'; +import { CopyButton } from '@/shared/ui/button'; +import { EthereumTxTabs } from './ethereum-transaction-tabs'; +import { Hex } from 'viem'; + +export const EthereumTransactionDetailPage = async ({ params }: { params: Promise<{ hash: Hex }> }) => { + const { hash } = await params; + + return ( + + + {formatHash(hash)} + + +
+ )} + title='Transaction' + > + + + ); +}; \ No newline at end of file diff --git a/apps/explorer/lib/pages/transaction-details/transaction-card.tsx b/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-transaction-card.tsx similarity index 83% rename from apps/explorer/lib/pages/transaction-details/transaction-card.tsx rename to apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-transaction-card.tsx index 52fc15c0..df6ab51b 100644 --- a/apps/explorer/lib/pages/transaction-details/transaction-card.tsx +++ b/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-transaction-card.tsx @@ -4,14 +4,18 @@ import type { ReactNode } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { CheckCircle2, XCircle } from 'lucide-react'; import { Badge } from '@/shared/ui/badge'; -import { useTransaction } from './use-transaction'; +import { useEthereumTransaction } from '../../hook/ethereum/use-ethereum-transaction'; import { DataItem, DataList } from '@/widgets/data-list'; -import { AttestationsTooltip } from '@/pages/transaction-details/attestations-tooltip'; +import { AttestationsTooltip } from '@/pages/transaction-details/ui/ethereum/attestations-tooltip'; import { getPageLink } from "@/shared/utils/links"; import { useChainPathSegment } from "@/widgets/chain-path-segment"; +import { formatGwei, Hex } from 'viem'; +import { formatTokenValue } from '@/shared/utils/format-token'; +import { getToken } from '@/shared/utils/tokens'; +import { formatGasUsed } from '@/shared/utils/format-gas'; -export interface TransactionCardProps { - txHash: string; +export type EthereumTransactionCardProps = { + txHash: Hex; } const TransactionStatus = ({ status, children }: { status: boolean | undefined, children: ReactNode }) => { @@ -40,8 +44,8 @@ const TransactionStatus = ({ status, children }: { status: boolean | undefined, ); }; -export const TransactionCard = ({ txHash }: TransactionCardProps) => { - const { data: tx, isLoading } = useTransaction({ hash: txHash }); +export const EthereumTransactionCard = ({ txHash }: EthereumTransactionCardProps) => { + const { data: tx, isLoading } = useEthereumTransaction({ hash: txHash }); const chain = useChainPathSegment(); if (!tx || !tx.hash) { @@ -50,16 +54,6 @@ export const TransactionCard = ({ txHash }: TransactionCardProps) => { ); } - const formatValue = (value: string) => { - const eth = Number(value) / 1e18 - return eth.toFixed(6) - } - - const formatGasPrice = (gasPrice: string) => { - const gwei = Number(gasPrice) / 1e9 - return gwei.toFixed(2) - } - const transactionFee = (Number(tx.gasUsed) * Number(tx.gasPrice)) / 1e18; return ( @@ -131,7 +125,7 @@ export const TransactionCard = ({ txHash }: TransactionCardProps) => { value={tx.value} loading={isLoading} > - {tx.value && `${formatValue(tx.value)} ETH`} + {tx.value && `${formatTokenValue(tx.value, getToken('eth').decimals)} ${getToken('eth').symbol}`} { value={tx.gasPrice} loading={isLoading} > - {tx.gasPrice && `${formatGasPrice(tx.gasPrice)} Gwei`} + {tx.gasPrice && `${formatGwei(BigInt(tx.gasPrice))} Gwei`} { value={tx.gasUsed} loading={isLoading} > - {tx.gasUsed && tx.gas && ( - <> - {tx.gasUsed} ({((Number(tx.gasUsed) / Number(tx.gas)) * 100).toFixed(2)}%) - - )} + {(tx.gasUsed && tx.gas) ? formatGasUsed(tx.gasUsed.toString(), tx.gas.toString()) : `0.00M (0.00%)`} !!log)} isLoading={isLoading} />; +} diff --git a/apps/explorer/lib/pages/transaction-details/transaction-tabs.tsx b/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-transaction-tabs.tsx similarity index 53% rename from apps/explorer/lib/pages/transaction-details/transaction-tabs.tsx rename to apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-transaction-tabs.tsx index f6d7bb7b..3e1222f5 100644 --- a/apps/explorer/lib/pages/transaction-details/transaction-tabs.tsx +++ b/apps/explorer/lib/pages/transaction-details/ui/ethereum/ethereum-transaction-tabs.tsx @@ -2,20 +2,21 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shinzo/ui/tabs'; import { Container } from '@/widgets/layout'; -import { TransactionCard } from './transaction-card'; -import { TransactionLogs } from './transaction-logs'; -import { useTransaction } from './use-transaction'; -import { useTransactionLogs } from './use-transaction-logs'; +import { EthereumTransactionCard } from './ethereum-transaction-card'; +import { EthereumTransactionLogs } from './ethereum-transaction-logs'; +import { useEthereumTransaction } from '../../hook/ethereum/use-ethereum-transaction'; +import { useEthereumTransactionLogs } from '../../hook/ethereum/use-ethereum-transaction-logs'; +import { Hex } from 'viem'; -export interface TxTabsProps { - hash: string; +export interface EthereumTxTabsProps { + hash: Hex; } -export const TxTabs = ({ hash }: TxTabsProps) => { - const { data: tx } = useTransaction({ hash }); +export const EthereumTxTabs = ({ hash }: EthereumTxTabsProps) => { + const { data: tx } = useEthereumTransaction({ hash }); // preload logs when page is loaded - useTransactionLogs({ hash, enabled: !!tx }); + useEthereumTransactionLogs({ hash, enabled: !!tx }); return ( @@ -35,13 +36,13 @@ export const TxTabs = ({ hash }: TxTabsProps) => {
- + - +
); -}; +}; \ No newline at end of file diff --git a/apps/explorer/lib/pages/transaction-details/page.tsx b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-page.tsx similarity index 73% rename from apps/explorer/lib/pages/transaction-details/page.tsx rename to apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-page.tsx index 6187de5c..b75c42a0 100644 --- a/apps/explorer/lib/pages/transaction-details/page.tsx +++ b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-page.tsx @@ -2,9 +2,9 @@ import { PageLayout } from '@/widgets/layout' import { Typography } from '@/shared/ui/typography'; import { formatHash } from '@/shared/utils/format-hash'; import { CopyButton } from '@/shared/ui/button'; -import { TxTabs } from './transaction-tabs'; +import { ShinzohubTxTabs } from './shinzohub-transaction-tabs'; -export const TransactionDetailPage = async ({ params }: { params: Promise<{ hash: string }> }) => { +export const ShinzohubTransactionDetailPage = async ({ params }: { params: Promise<{ hash: string }> }) => { const { hash } = await params; return ( @@ -19,7 +19,7 @@ export const TransactionDetailPage = async ({ params }: { params: Promise<{ hash )} title='Transaction' > - + ); }; diff --git a/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-card.tsx b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-card.tsx new file mode 100644 index 00000000..46e0e17a --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-card.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { CheckCircle2, ChevronDown, ChevronUp, XCircle } from 'lucide-react'; +import { formatGwei } from 'viem'; +import { Badge } from '@/shared/ui/badge'; +import { Button } from '@/shared/ui/button'; +import type { ShinzohubTransaction } from '@/shared/shinzohub/types'; +import { + formatShinzoBaseAmount, + formatShinzoCoin, +} from '@/shared/utils/format-token'; +import { getPageLink } from '@/shared/utils/links'; +import { DataItem, DataList } from '@/widgets/data-list'; +import { useShinzohubEvmTransaction } from '../../hook/shinzohub/use-shinzohub-evm-transaction'; +import { useShinzohubTransactionReceipt } from '../../hook/shinzohub/use-shinzohub-transaction-receipt'; + +function TransactionStatus({ success }: { success?: boolean }) { + if (success === undefined) return null; + return success ? ( + + + Success + + ) : ( + + + Failed + + ); +} + +function TransactionInput({ + input, + loading, +}: { + input?: string; + loading: boolean; +}) { + const [expanded, setExpanded] = useState(false); + + if (loading || !input) { + return ; + } + + const byteLength = Math.max(0, (input.length - 2) / 2); + + return ( + +
+ + {expanded && ( + + {input} + + )} +
+
+ ); +} + +export function ShinzohubTransactionCard({ + transaction, + isLoading, +}: { + transaction?: ShinzohubTransaction; + isLoading: boolean; +}) { + const { data: evmTransaction, isLoading: isEvmLoading } = + useShinzohubEvmTransaction(transaction?.evmHash); + const { data: receipt, isLoading: isReceiptLoading } = + useShinzohubTransactionReceipt(transaction?.evmHash); + const loading = + isLoading || + (!!transaction?.evmHash && (isEvmLoading || isReceiptLoading)); + + if (!loading && !transaction) { + return

Transaction not found.

; + } + + const transactionFee = + receipt?.gasUsed != null && receipt.effectiveGasPrice != null + ? receipt.gasUsed * receipt.effectiveGasPrice + : null; + const transfer = transaction?.transfers[0]; + const from = transaction?.evmHash + ? evmTransaction?.from + : transfer?.sender ?? transaction?.senders[0]; + const to = transaction?.evmHash + ? evmTransaction?.to + : transfer?.recipient ?? transaction?.recipients[0]; + const amount = transaction?.evmHash + ? evmTransaction?.value != null + ? formatShinzoBaseAmount(evmTransaction.value) + : undefined + : transfer?.amount + ? formatShinzoCoin(transfer.amount) + : undefined; + const fee = transaction?.evmHash + ? transactionFee != null + ? formatShinzoBaseAmount(transactionFee) + : undefined + : transaction?.fee + ? formatShinzoCoin(transaction.fee) + : transaction?.fee; + + return ( + + + + + + {transaction?.kind && {transaction.kind === 'evm' ? 'EVM' : 'Cosmos'}} + + + + + + + + {transaction?.timestamp && ( + <> + {formatDistanceToNow(new Date(transaction.timestamp), { addSuffix: true })} + {' '}({new Date(transaction.timestamp).toUTCString()}) + + )} + + + {transaction?.evmHash && ( + + )} + + + + + {transaction?.evmHash && ( + <> + + {receipt?.effectiveGasPrice != null && `${formatGwei(receipt.effectiveGasPrice)} Gwei`} + + + + + )} + + {transaction?.evmHash && ( + + )} + + ); +} diff --git a/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-events.tsx b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-events.tsx new file mode 100644 index 00000000..88a7109d --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-events.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { ShinzohubEvent } from '@/shared/shinzohub/types'; +import { Typography } from '@/shared/ui/typography'; +import { Container } from '@/widgets/layout'; + +export function ShinzohubTransactionEvents({ + events, +}: { + events?: readonly ShinzohubEvent[]; +}) { + if (!events?.length) { + return ( + + No events for this transaction. + + ); + } + + return ( +
+ {events.map((event, index) => ( +
+

{event.type}

+
+ {event.attributes.map((attribute, attributeIndex) => ( +
+
{attribute.key}
+
{attribute.value}
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-logs.tsx b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-logs.tsx new file mode 100644 index 00000000..1fc7dc97 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-logs.tsx @@ -0,0 +1,70 @@ +'use client'; + +import type { + ShinzohubEvent, + ShinzohubTransaction, +} from '@/shared/shinzohub/types'; +import { + TransactionLogs, + type TransactionLog, +} from '../transaction-logs'; + +interface TxLogPayload { + address?: string; + topics?: string[]; + data?: string; + logIndex?: number; +} + +function base64ToHex(value: string): `0x${string}` { + if (value.startsWith('0x')) { + return value as `0x${string}`; + } + + try { + const decoded = atob(value); + const hex = Array.from( + decoded, + (character) => character.charCodeAt(0).toString(16).padStart(2, '0'), + ).join(''); + return `0x${hex}`; + } catch { + return '0x'; + } +} + +function eventToLog(event: ShinzohubEvent): TransactionLog | null { + const value = event.attributes.find( + (attribute) => attribute.key === 'txLog', + )?.value; + if (!value) { + return null; + } + + try { + const payload = JSON.parse(value) as TxLogPayload; + return { + address: payload.address ?? null, + topics: payload.topics ?? [], + data: base64ToHex(payload.data ?? ''), + logIndex: payload.logIndex ?? null, + }; + } catch { + return null; + } +} + +export function ShinzohubTransactionLogs({ + transaction, + isLoading, +}: { + transaction?: ShinzohubTransaction; + isLoading: boolean; +}) { + const logs = transaction?.events + .filter((event) => event.type === 'tx_log') + .map(eventToLog) + .filter((log): log is TransactionLog => !!log); + + return ; +} diff --git a/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-tabs.tsx b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-tabs.tsx new file mode 100644 index 00000000..bafea2b1 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/ui/shinzohub/shinzohub-transaction-tabs.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shinzo/ui/tabs'; +import { Container } from '@/widgets/layout'; +import { useShinzohubTransactionDetails } from '../../hook/shinzohub/use-shinzohub-transaction-details'; +import { ShinzohubTransactionCard } from './shinzohub-transaction-card'; +import { ShinzohubTransactionEvents } from './shinzohub-transaction-events'; +import { ShinzohubTransactionLogs } from './shinzohub-transaction-logs'; + +export function ShinzohubTxTabs({ hash }: { hash: string }) { + const { data: transaction, isLoading, error } = + useShinzohubTransactionDetails(hash); + const isEvm = transaction?.kind === 'evm'; + + return ( + + + + Overview + {transaction?.kind === 'evm' ? ( + Logs + ) : transaction?.kind === 'cosmos' ? ( + Events + ) : null} + + + +
+ + {error ? ( +

+ Transaction not found. +

+ ) : ( + + )} +
+ + {isEvm ? ( + + + + ) : transaction?.kind === 'cosmos' ? ( + + + + ) : null} +
+
+ ); +} diff --git a/apps/explorer/lib/pages/transaction-details/ui/transaction-logs.tsx b/apps/explorer/lib/pages/transaction-details/ui/transaction-logs.tsx new file mode 100644 index 00000000..a4b72439 --- /dev/null +++ b/apps/explorer/lib/pages/transaction-details/ui/transaction-logs.tsx @@ -0,0 +1,156 @@ +'use client'; + +import Link from 'next/link'; +import { Skeleton } from '@shinzo/ui/skeleton'; +import { formatUnits, getAddress } from 'viem'; +import { Badge } from '@/shared/ui/badge'; +import { CopyButton } from '@/shared/ui/button'; +import { Typography } from '@/shared/ui/typography'; +import { getPageLink } from '@/shared/utils/links'; +import { DataItem, DataList } from '@/widgets/data-list'; +import { Container } from '@/widgets/layout'; +import { useChainPathSegment } from '@/widgets/chain-path-segment'; +import { isTokenEvent } from '../known-events'; +import { useDecodedLog } from '../hook/ethereum/use-decoded-log'; +import { + getTokenIconUrl, + type TokenMetadata, + useTokenMetadata, +} from '../hook/ethereum/use-token-metadata'; + +export interface TransactionLog { + logIndex?: number | null; + address?: string | null; + topics?: readonly (string | null)[] | null; + data?: string | null; +} + +function TokenInfo({ token }: { token: TokenMetadata }) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {token.symbol} { + event.currentTarget.style.display = 'none'; + }} + /> + + {token.symbol} + + + ({token.name}) + +
+ ); +} + +function TransactionLogEntry({ + logIndex, + address, + topics, + data, +}: TransactionLog) { + const normalizedTopics = topics?.filter((topic): topic is string => !!topic) ?? []; + const { data: decoded } = useDecodedLog(normalizedTopics, data); + const chain = useChainPathSegment(); + const { data: token } = useTokenMetadata( + isTokenEvent(normalizedTopics) ? (address ?? undefined) : undefined, + ); + + let checksumAddress: string | undefined; + try { + checksumAddress = address ? getAddress(address) : undefined; + } catch { + checksumAddress = address ?? undefined; + } + + return ( + + + {decoded && ( + + {decoded.eventName} + + )} + {checksumAddress && ( + + + {checksumAddress} + + + + )} + + + {decoded ? ( + decoded.args.map((argument, index) => ( + + {formatUnits(BigInt(argument.value), token.decimals)}{' '} + + + ) : ( + argument.value + ) + } + /> + )) + ) : ( + <> + {normalizedTopics.map((topic, index) => ( + + ))} + + + )} + + ); +} + +export function TransactionLogs({ + logs, + isLoading, +}: { + logs?: readonly TransactionLog[]; + isLoading: boolean; +}) { + if (isLoading) { + return ( + +
+
+
+
+ ); + } + + if (!logs?.length) { + return ( + + No logs for this transaction. + + ); + } + + return ( +
+ {logs.map((log, index) => ( + + ))} +
+ ); +} diff --git a/apps/explorer/lib/pages/transactions/use-transactions.ts b/apps/explorer/lib/pages/transactions/hooks/ethereum/use-ethereum-transactions.ts similarity index 64% rename from apps/explorer/lib/pages/transactions/use-transactions.ts rename to apps/explorer/lib/pages/transactions/hooks/ethereum/use-ethereum-transactions.ts index 7de1890f..63e09531 100644 --- a/apps/explorer/lib/pages/transactions/use-transactions.ts +++ b/apps/explorer/lib/pages/transactions/hooks/ethereum/use-ethereum-transactions.ts @@ -39,22 +39,28 @@ const TransactionsQuery = graphql(` } `) -interface UseTransactionsOptions { +interface UseEthereumTransactionsOptions { offset?: number; limit?: number; blockNumber?: number; } -export const useTransactions = (options: Partial) => { +export type EthereumTransaction = Omit & { + timestamp: string | undefined; +}; +export const useEthereumTransactions = (options: Partial) => { const { offset, limit, blockNumber } = options; return useQuery({ - queryKey: ['transactions', offset, limit, blockNumber], + queryKey: ['ethereum', 'transactions', offset, limit, blockNumber], queryFn: async () => { const res = await execute(TransactionsQuery, { offset, limit, blockNumber }); - return { - transactions: res.Transaction?.filter(Boolean) as Transaction[], - }; + const filteredTxns = res.Transaction?.filter(Boolean) as Transaction[]; + const mappedTxns = filteredTxns.map((txn) => ({ + ...txn, + timestamp: txn.block?.timestamp ? txn.block.timestamp : undefined, + })); + return mappedTxns as EthereumTransaction[]; }, }); }; diff --git a/apps/explorer/lib/pages/transactions/use-transactions-count.ts b/apps/explorer/lib/pages/transactions/hooks/ethereum/use-transactions-count.ts similarity index 90% rename from apps/explorer/lib/pages/transactions/use-transactions-count.ts rename to apps/explorer/lib/pages/transactions/hooks/ethereum/use-transactions-count.ts index 1c168d15..ac7fc214 100644 --- a/apps/explorer/lib/pages/transactions/use-transactions-count.ts +++ b/apps/explorer/lib/pages/transactions/hooks/ethereum/use-transactions-count.ts @@ -1,6 +1,6 @@ import { METRICS_API_URL } from '@/shared/utils/consts'; import { useQuery } from '@tanstack/react-query'; -import { MetricsResponse } from '../home/use-blocks-and-transactions-count'; +import { MetricsResponse } from '../../../home/use-blocks-and-transactions-count'; interface TransactionsCountResponse { totalTransactions: number; diff --git a/apps/explorer/lib/pages/transactions/hooks/shinzohub/fetch-shinzohub-transactions.ts b/apps/explorer/lib/pages/transactions/hooks/shinzohub/fetch-shinzohub-transactions.ts new file mode 100644 index 00000000..ee94fbbc --- /dev/null +++ b/apps/explorer/lib/pages/transactions/hooks/shinzohub/fetch-shinzohub-transactions.ts @@ -0,0 +1,8 @@ +'use client'; + +import type { + ShinzohubTransactionSummary, + ShinzohubTransactionsResponse, +} from '@/shared/shinzohub/types'; + +export type { ShinzohubTransactionSummary, ShinzohubTransactionsResponse }; diff --git a/apps/explorer/lib/pages/transactions/hooks/shinzohub/use-shinzohub-transactions.ts b/apps/explorer/lib/pages/transactions/hooks/shinzohub/use-shinzohub-transactions.ts new file mode 100644 index 00000000..81138eb6 --- /dev/null +++ b/apps/explorer/lib/pages/transactions/hooks/shinzohub/use-shinzohub-transactions.ts @@ -0,0 +1,75 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { DEFAULT_LIMIT, type PageParams } from '@shinzo/ui/pagination'; +import { + type ShinzohubTransactionsResponse, + type ShinzohubTransactionFilter, +} from '@/shared/shinzohub/types'; + +type UseShinzohubTransactionsOptions = { + pageParams: PageParams; + kind?: ShinzohubTransactionFilter; + block?: number; + enabled?: boolean; + refetchIntervalMs?: number; +}; + +export function shinzohubTransactionsQueryKey(params: { + page: number; + limit: number; + kind: ShinzohubTransactionFilter; + block?: number; +}) { + return ['shinzohub', 'transactions', params.page, params.limit, params.kind, params.block] as const; +} + +export async function fetchShinzohubTransactions(params: { + page: number; + limit: number; + kind?: ShinzohubTransactionFilter; + block?: number; +}): Promise { + const searchParams = new URLSearchParams({ + page: String(params.page), + limit: String(params.limit), + kind: params.kind ?? 'all', + }); + if (params.block) { + searchParams.set('block', String(params.block)); + } + const response = await fetch(`/api/shinzohub/transactions?${searchParams.toString()}`); + + if (!response.ok) { + throw new Error('Failed to fetch Shinzohub transactions'); + } + + return response.json() as Promise; +} + +export function useShinzohubTransactions( + { + pageParams, + kind = 'all', + block, + enabled = true, + refetchIntervalMs = 30_000, + }: UseShinzohubTransactionsOptions = { + pageParams: { page: 1, offset: 0, limit: DEFAULT_LIMIT }, + }, +) { + const { page, limit } = pageParams; + + return useQuery({ + queryKey: shinzohubTransactionsQueryKey({ page, limit, kind, block }), + queryFn: () => fetchShinzohubTransactions({ page, limit, kind, block }), + enabled, + staleTime: refetchIntervalMs, + refetchInterval: refetchIntervalMs, + refetchIntervalInBackground: true, + select: (data) => ({ + transactions: data.transactions, + totalTransactionsCount: data.total, + }), + }); +} diff --git a/apps/explorer/lib/pages/transactions/index.ts b/apps/explorer/lib/pages/transactions/index.ts index 51e7b548..2dcf6b31 100644 --- a/apps/explorer/lib/pages/transactions/index.ts +++ b/apps/explorer/lib/pages/transactions/index.ts @@ -1,2 +1,5 @@ -export { TransactionsPage } from './page-server'; -export { useTransactions } from './use-transactions'; +export { EthereumTransactionsPage } from './ui/ethereum/ethereum-page-server'; +export { ShinzohubTransactionsPage } from './ui/shinzohub/shinzohub-page-server'; +export { useEthereumTransactions } from './hooks/ethereum/use-ethereum-transactions'; +export { useShinzohubTransactions } from './hooks/shinzohub/use-shinzohub-transactions'; +export type { ShinzohubTransactionSummary } from '@/shared/shinzohub/types'; diff --git a/apps/explorer/lib/pages/transactions/page-server.tsx b/apps/explorer/lib/pages/transactions/ui/ethereum/ethereum-page-server.tsx similarity index 63% rename from apps/explorer/lib/pages/transactions/page-server.tsx rename to apps/explorer/lib/pages/transactions/ui/ethereum/ethereum-page-server.tsx index 322313e5..e037628a 100644 --- a/apps/explorer/lib/pages/transactions/page-server.tsx +++ b/apps/explorer/lib/pages/transactions/ui/ethereum/ethereum-page-server.tsx @@ -1,17 +1,16 @@ 'use server'; - -import { TransactionsPageClient } from './page'; import { PageParamsOptions, getServerPage } from '@shinzo/ui/pagination'; +import { EthereumTransactionsPageClient } from './ethereum-page'; export interface TransactionsPageProps { searchParams: Promise<{ block?: string } & PageParamsOptions> } -export const TransactionsPage = async ({ searchParams }: TransactionsPageProps) => { +export const EthereumTransactionsPage = async ({ searchParams }: TransactionsPageProps) => { const search = await searchParams; const blockFilter = Number(search.block); const block = Number.isNaN(blockFilter) || blockFilter <= 0 ? undefined : blockFilter; const pageParams = getServerPage(search); - return + return }; diff --git a/apps/explorer/lib/pages/transactions/page.tsx b/apps/explorer/lib/pages/transactions/ui/ethereum/ethereum-page.tsx similarity index 70% rename from apps/explorer/lib/pages/transactions/page.tsx rename to apps/explorer/lib/pages/transactions/ui/ethereum/ethereum-page.tsx index e18a56e6..ed406758 100644 --- a/apps/explorer/lib/pages/transactions/page.tsx +++ b/apps/explorer/lib/pages/transactions/ui/ethereum/ethereum-page.tsx @@ -4,17 +4,17 @@ import { DEFAULT_LIMIT, PageParams, Pagination } from '@shinzo/ui/pagination'; import { Tabs, TabsList, TabsTrigger } from '@shinzo/ui/tabs'; import { Container, PageLayout } from '@/widgets/layout' import { TransactionsList } from './transactions-list'; -import { useTransactions } from './use-transactions'; -import { useTransactionsCount } from './use-transactions-count'; +import { useEthereumTransactions } from '../../hooks/ethereum/use-ethereum-transactions'; +import { useTransactionsCount } from '../../hooks/ethereum/use-transactions-count'; -export interface TransactionPageProps { +export type EthereumTransactionPageProps = { block?: number; pageParams: PageParams; } -export const TransactionsPageClient = ({ block, pageParams }: TransactionPageProps) => { +export const EthereumTransactionsPageClient = ({ block, pageParams }: EthereumTransactionPageProps) => { const { page, offset, limit } = pageParams; - const { data: transactions, isLoading } = useTransactions({ + const { data: transactions, isLoading } = useEthereumTransactions({ limit, offset, blockNumber: block, @@ -43,9 +43,7 @@ export const TransactionsPageClient = ({ block, pageParams }: TransactionPagePro => txn !== null) ?? [] - } + transactions={transactions?.filter((txn): txn is NonNullable => txn !== null) ?? []} isLoading={isLoading} /> diff --git a/apps/explorer/lib/pages/transactions/transactions-list.tsx b/apps/explorer/lib/pages/transactions/ui/ethereum/transactions-list.tsx similarity index 72% rename from apps/explorer/lib/pages/transactions/transactions-list.tsx rename to apps/explorer/lib/pages/transactions/ui/ethereum/transactions-list.tsx index e5ca3b58..26879b67 100644 --- a/apps/explorer/lib/pages/transactions/transactions-list.tsx +++ b/apps/explorer/lib/pages/transactions/ui/ethereum/transactions-list.tsx @@ -9,23 +9,20 @@ import { TableLayout, TableNullableCell, } from '@shinzo/ui/table'; -import { Transaction } from '@/shared/graphql'; import { CopyButton } from '@/shared/ui/button'; import { getPageLink } from "@/shared/utils/links"; import { useChainPathSegment } from "@/widgets/chain-path-segment"; +import { EthereumTransaction } from '../../hooks/ethereum/use-ethereum-transactions'; +import { formatTokenValue } from '@/shared/utils/format-token'; +import { getToken } from '@/shared/utils/tokens'; +import { formatGwei } from 'viem'; -export const TransactionsList = ({ transactions, isLoading }: { transactions: Transaction[] | undefined, isLoading: boolean }) => { +export type TransactionsListProps = { + transactions: EthereumTransaction[] | undefined; + isLoading: boolean; +} +export const TransactionsList = ({ transactions, isLoading }: TransactionsListProps) => { const chain = useChainPathSegment(); - - const formatValue = (value: string) => { - const eth = Number(value) / 1e18; - return eth.toFixed(6); - }; - - const formatGasPrice = (gasPrice: string) => { - const gwei = Number(gasPrice) / 1e9; - return gwei.toFixed(2); - }; return ( {(value) => ( - + {formatHash(value, 12, 8)} @@ -49,7 +46,7 @@ export const TransactionsList = ({ transactions, isLoading }: { transactions: Tr {(value) => ( - + {value} @@ -57,7 +54,7 @@ export const TransactionsList = ({ transactions, isLoading }: { transactions: Tr )} - + {(value) => ( formatDistanceToNow(new Date(Number(value) * 1000), { addSuffix: true }) )} @@ -65,7 +62,7 @@ export const TransactionsList = ({ transactions, isLoading }: { transactions: Tr {(value) => ( - +
{formatHash(value ?? '', 8, 6)} @@ -78,7 +75,7 @@ export const TransactionsList = ({ transactions, isLoading }: { transactions: Tr {(value) => ( - +
{formatHash(value ?? '', 8, 6)} @@ -90,11 +87,11 @@ export const TransactionsList = ({ transactions, isLoading }: { transactions: Tr - {(value) => `${formatValue(value)} ETH`} + {(value) => `${formatTokenValue(value, getToken(chain)?.decimals)} ${getToken(chain)?.symbol}`} - {(value) => `${formatGasPrice(value)} Gwei`} + {(value) => `${formatGwei(BigInt(value ?? '0'))} Gwei`} )} diff --git a/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-page-server.tsx b/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-page-server.tsx new file mode 100644 index 00000000..18a25bde --- /dev/null +++ b/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-page-server.tsx @@ -0,0 +1,25 @@ +'use server'; +import { PageParamsOptions, getServerPage } from '@shinzo/ui/pagination'; +import { ShinzohubTransactionsPageClient } from './shinzohub-page'; +import type { ShinzohubTransactionFilter } from '@/shared/shinzohub/types'; + +export interface TransactionsPageProps { + searchParams: Promise<{ block?: string; kind?: string } & PageParamsOptions> +} + +export const ShinzohubTransactionsPage = async ({ searchParams }: TransactionsPageProps) => { + const search = await searchParams; + const blockFilter = Number(search.block); + const block = Number.isNaN(blockFilter) || blockFilter <= 0 ? undefined : blockFilter; + const kind: ShinzohubTransactionFilter = + search.kind === 'evm' ? 'evm' : 'all'; + const pageParams = getServerPage(search); + + return ( + + ); +}; diff --git a/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-page.tsx b/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-page.tsx new file mode 100644 index 00000000..72de86c6 --- /dev/null +++ b/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-page.tsx @@ -0,0 +1,74 @@ +"use client" + +import { useEffect } from 'react'; +import { DEFAULT_LIMIT, PageParams, Pagination } from '@shinzo/ui/pagination'; +import { Tabs, TabsList, TabsTrigger } from '@shinzo/ui/tabs'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { Container, PageLayout } from '@/widgets/layout' +import { useShinzohubTransactions } from '../../hooks/shinzohub/use-shinzohub-transactions'; +import type { ShinzohubTransactionFilter } from '@/shared/shinzohub/types'; +import { ShinzohubTransactionsList } from './shinzohub-transactions-list'; + +export type ShinzohubTransactionPageProps = { + block?: number; + kind: ShinzohubTransactionFilter; + pageParams: PageParams; +} + +export const ShinzohubTransactionsPageClient = ({ block, kind, pageParams }: ShinzohubTransactionPageProps) => { + const { page } = pageParams; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { data, isLoading } = useShinzohubTransactions({ pageParams, kind, block }); + const transactions = data?.transactions; + const totalTransactionsCount = data?.totalTransactionsCount; + + useEffect(() => { + if (searchParams.get('kind') !== 'cosmos') { + return; + } + const params = new URLSearchParams(searchParams.toString()); + params.set('kind', 'all'); + router.replace(`${pathname}?${params.toString()}`); + }, [pathname, router, searchParams]); + + const setKind = (value: string) => { + const nextKind = value as ShinzohubTransactionFilter; + const params = new URLSearchParams(searchParams.toString()); + params.set('kind', nextKind); + params.set('page', '1'); + router.push(`${pathname}?${params.toString()}`); + }; + + return ( + + + + + + All + + + EVM + + + + + + + + + + ); +}; diff --git a/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-transactions-list.tsx b/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-transactions-list.tsx new file mode 100644 index 00000000..74cf948a --- /dev/null +++ b/apps/explorer/lib/pages/transactions/ui/shinzohub/shinzohub-transactions-list.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Link from 'next/link'; +import { DEFAULT_LIMIT } from '@shinzo/ui/pagination'; +import { TableLayout, TableNullableCell } from '@shinzo/ui/table'; +import { Badge } from '@/shared/ui/badge'; +import { CopyButton } from '@/shared/ui/button'; +import { Typography } from '@/shared/ui/typography'; +import type { ShinzohubTransactionSummary } from '@/shared/shinzohub/types'; +import { formatHash } from '@/shared/utils/format-hash'; +import { formatShinzoCoin } from '@/shared/utils/format-token'; +import { getPageLink } from '@/shared/utils/links'; + +export function ShinzohubTransactionsList({ + transactions, + isLoading, +}: { + transactions: ShinzohubTransactionSummary[]; + isLoading: boolean; +}) { + return ( + { + const sender = transaction.senders[0] ?? null; + const recipient = transaction.recipients[0] ?? null; + const amount = transaction.transfers[0]?.amount ?? null; + + return ( + <> + + {(hash) => ( + + + {formatHash(hash, 12, 8)} + + + )} + + + + {() => ( + + {transaction.kind === 'evm' ? 'EVM' : 'Cosmos'} + + )} + + + + {(height) => ( + + {height} + + )} + + + + {(value) => ( +
+ {formatHash(value, 8, 6)} + +
+ )} +
+ + + {(value) => ( +
+ {formatHash(value, 8, 6)} + +
+ )} +
+ + + {(value) => formatShinzoCoin(value)} + + + {(value) => formatShinzoCoin(value)} + + + ); + }} + /> + ); +} diff --git a/apps/explorer/lib/shared/config/shinzohub-chain.ts b/apps/explorer/lib/shared/config/shinzohub-chain.ts index e41236c3..d159284d 100644 --- a/apps/explorer/lib/shared/config/shinzohub-chain.ts +++ b/apps/explorer/lib/shared/config/shinzohub-chain.ts @@ -1,12 +1,13 @@ import { defineChain, type Chain } from 'viem'; import { getRpcUrlForChainPathSegment } from '@/shared/utils/consts'; +import { SHINZO_TOKEN } from '../utils/tokens'; export function createShinzoHubChain(): Chain { const url = getRpcUrlForChainPathSegment('shinzohub'); return defineChain({ id: 91273002, name: 'Shinzo', - nativeCurrency: { name: 'Shinzo', symbol: 'SHNZ', decimals: 18 }, + nativeCurrency: SHINZO_TOKEN, rpcUrls: { default: { http: [url] }, public: { http: [url] }, diff --git a/apps/explorer/lib/shared/shinzohub/types.ts b/apps/explorer/lib/shared/shinzohub/types.ts new file mode 100644 index 00000000..e9773a55 --- /dev/null +++ b/apps/explorer/lib/shared/shinzohub/types.ts @@ -0,0 +1,45 @@ +import type { + ListBlocksResult, + ListTransactionsResult, + ShinzoHubBlock, + ShinzoHubEvent, + ShinzoHubEventAttribute, + ShinzoHubMessage, + ShinzoHubTransaction, + ShinzoHubTransactionFilter, + ShinzoHubTransactionKind, + ShinzoHubTransactionSummary, + ShinzoHubTransfer, +} from '@shinzo/shinzohub'; + +type JsonSerialized = + T extends bigint + ? string + : T extends readonly (infer Item)[] + ? JsonSerialized[] + : T extends object + ? { -readonly [Key in keyof T]: JsonSerialized } + : T; + +export type ShinzohubTransactionKind = ShinzoHubTransactionKind; +export type ShinzohubTransactionFilter = ShinzoHubTransactionFilter; +export type ShinzohubEventAttribute = JsonSerialized; +export type ShinzohubEvent = JsonSerialized; +export type ShinzohubTransfer = JsonSerialized; +export type ShinzohubTransactionSummary = + JsonSerialized; +export type ShinzohubMessage = JsonSerialized; +export type ShinzohubTransaction = JsonSerialized; + +export type ShinzohubTransactionsResponse = Omit< + JsonSerialized, + 'total' +> & { + total: number; +}; + +export type ShinzohubBlock = JsonSerialized; + +export type ShinzohubBlocksResponse = JsonSerialized & { + total: number; +}; diff --git a/apps/explorer/lib/shared/utils/format-gasprice.ts b/apps/explorer/lib/shared/utils/format-gasprice.ts new file mode 100644 index 00000000..b4d7ab83 --- /dev/null +++ b/apps/explorer/lib/shared/utils/format-gasprice.ts @@ -0,0 +1,5 @@ +import { formatUnits } from "viem"; + +export const formatGasPrice = (gasPrice: string) => { + return Number(formatUnits(BigInt(gasPrice), 9)).toFixed(2); + }; \ No newline at end of file diff --git a/apps/explorer/lib/shared/utils/format-token.ts b/apps/explorer/lib/shared/utils/format-token.ts new file mode 100644 index 00000000..e6507882 --- /dev/null +++ b/apps/explorer/lib/shared/utils/format-token.ts @@ -0,0 +1,44 @@ +import { formatUnits } from 'viem'; +import { SHINZO_TOKEN } from './tokens'; + +export const formatTokenValue = (value: string, decimals: number) => { + return Number(formatUnits(BigInt(value), decimals)).toFixed(6); +}; + +const MAX_SHINZO_FRACTION_DIGITS = 12; +const SHINZO_COIN_PATTERN = /^(-?\d+)([a-zA-Z][a-zA-Z0-9/:._-]*)$/; + +function formatInteger(value: bigint): string { + const zero = BigInt(0); + const sign = value < zero ? '-' : ''; + const digits = (value < zero ? -value : value).toString(); + return `${sign}${digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`; +} + +/** Formats a raw Shinzo amount without hiding very small values. */ +export function formatShinzoBaseAmount(value: string | bigint): string { + const amount = BigInt(value); + const shn = formatUnits(amount, SHINZO_TOKEN.decimals); + const fractionDigits = shn.split('.')[1]?.length ?? 0; + + if (fractionDigits > MAX_SHINZO_FRACTION_DIGITS) { + return `${formatInteger(amount)} ${SHINZO_TOKEN.denom}`; + } + + return `${shn} ${SHINZO_TOKEN.symbol}`; +} + +/** Formats Cosmos coin strings while preserving unknown denominations. */ +export function formatShinzoCoin(value: string): string { + return value + .split(',') + .map((coin) => { + const trimmed = coin.trim(); + const match = trimmed.match(SHINZO_COIN_PATTERN); + if (!match || match[2] !== SHINZO_TOKEN.denom) { + return trimmed; + } + return formatShinzoBaseAmount(match[1]); + }) + .join(', '); +} diff --git a/apps/explorer/lib/shared/utils/tokens.ts b/apps/explorer/lib/shared/utils/tokens.ts new file mode 100644 index 00000000..09c098c5 --- /dev/null +++ b/apps/explorer/lib/shared/utils/tokens.ts @@ -0,0 +1,21 @@ +export const SHINZO_TOKEN = { + name: 'Shinzo', + symbol: 'SHN', + denom: 'ushinzo', + decimals: 18, +}; + +export const ETH_TOKEN = { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, +}; + +const tokenMap = { + 'shinzohub': SHINZO_TOKEN, + 'ethereum': ETH_TOKEN, +}; + +export const getToken = (name: string) => { + return tokenMap[name as keyof typeof tokenMap]; +}; diff --git a/apps/explorer/lib/widgets/data-list/item.tsx b/apps/explorer/lib/widgets/data-list/item.tsx index 968e93dc..7447b760 100644 --- a/apps/explorer/lib/widgets/data-list/item.tsx +++ b/apps/explorer/lib/widgets/data-list/item.tsx @@ -69,7 +69,7 @@ export const DataItem = ({
{loading ? ( -
+
) : ( diff --git a/apps/explorer/package.json b/apps/explorer/package.json index e695aaf0..189184bf 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@shinzo/ui": "workspace:*", + "@shinzo/shinzohub": "workspace:*", "@tanstack/react-query": "^5.90.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/explorer/tsconfig.json b/apps/explorer/tsconfig.json index f84c7561..8b2ef852 100644 --- a/apps/explorer/tsconfig.json +++ b/apps/explorer/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -23,6 +23,7 @@ } }, "include": [ + "cloudflare-env.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx", diff --git a/apps/explorer/wrangler.jsonc b/apps/explorer/wrangler.jsonc index 9c5476e5..fa72748f 100644 --- a/apps/explorer/wrangler.jsonc +++ b/apps/explorer/wrangler.jsonc @@ -1,13 +1,18 @@ { - "$schema": "./node_modules/wrangler/config-schema.json", - "main": ".open-next/worker.js", - "name": "shinzo-explorer", - "compatibility_date": "2026-01-04", - "compatibility_flags": [ - "nodejs_compat" - ], - "assets": { - "directory": ".open-next/assets", - "binding": "ASSETS" - } + "$schema": "./node_modules/wrangler/config-schema.json", + "main": ".open-next/worker.js", + "name": "shinzo-explorer", + "compatibility_date": "2026-01-04", + "compatibility_flags": [ + "nodejs_compat" + ], + "assets": { + "directory": ".open-next/assets", + "binding": "ASSETS" + }, + "vars": { + "NEXT_PUBLIC_SHINZOHUB_RPC_URL": "http://rpc.develop.devnet.shinzo.network:8545", + "SHINZOHUB_COSMOS_REST_URL": "http://rpc.develop.devnet.shinzo.network:1317", + "SHINZOHUB_COMET_RPC_URL": "http://rpc.develop.devnet.shinzo.network:26657" + } } diff --git a/packages/shinzohub/README.md b/packages/shinzohub/README.md index 09eb7ead..862598e1 100644 --- a/packages/shinzohub/README.md +++ b/packages/shinzohub/README.md @@ -2,12 +2,11 @@ Viem-first TypeScript client actions for ShinzoHub. -This package intentionally keeps the public API small: +See the complete [API reference](./api.md). -- query registered views -- create a view from a viewbundle -- convert Shinzo and EVM address formats -- reuse ShinzoHub Viem chain definitions +Each query function maps to exactly one ShinzoHub REST or Comet RPC request. +Applications can compose primitives when they need enrichment or fallback +behavior. ## Usage @@ -32,6 +31,16 @@ const views = await publicClient.listViews({ const view = await publicClient.getView({ address: views.views[0].contractAddress, }); + +const transactions = await publicClient.listTransactions({ + kind: "all", // "all" | "evm" + limit: 20, +}); + +const blocks = await publicClient.listBlocks({ + minHeight: 100, + maxHeight: 119, +}); ``` Create a view with a wallet client: @@ -85,7 +94,7 @@ ShinzoHub protocol surface area that should be considered in future passes. - [x] Export the testnet Viem chain definition as `shinzoHubTestnet`. - [x] Export the mainnet Viem chain definition as `shinzoHubMainnet`. - [x] Export known chain mappings as `shinzoHubChains`. -- [x] Keep package subpaths limited to `@shinzo/shinzohub`, `@shinzo/shinzohub/views`, `@shinzo/shinzohub/addresses`, and `@shinzo/shinzohub/chains`. +- [x] Keep package subpaths limited to the root, views, transactions, blocks, addresses, and chains APIs. - [x] Keep `./internal`, URL builders, calldata builders, event selectors, and payload normalizers out of public package exports. ### ViewRegistry Precompile @@ -109,6 +118,18 @@ ShinzoHub protocol surface area that should be considered in future passes. - [x] Cover `include_data` and `include_metadata` in `getView`. - [x] Cover `GET /shinzonetwork/view/v1/view_count` with `countViews`. +### Transactions And Blocks + +- [x] List all or EVM transactions with `listTransactions`. +- [x] Fetch decoded transaction details by Cosmos hash with `getTransaction`. +- [x] Resolve a Cosmos transaction summary from an EVM hash with `findTransactionByEvmHash`. +- [x] Preserve decoded Cosmos messages and events for generic clients. +- [x] List consensus blocks with `listBlocks`. +- [x] Fetch the latest block with `getLatestBlock`. +- [x] Fetch the latest block height with `getLatestBlockHeight`. +- [x] Fetch one block timestamp with `getBlockTimestamp`. +- [x] Fetch a block by height or hash with `getBlock`. + ### Deployed View Contracts - [ ] Cover `name()`. diff --git a/packages/shinzohub/api.md b/packages/shinzohub/api.md new file mode 100644 index 00000000..22d117b8 --- /dev/null +++ b/packages/shinzohub/api.md @@ -0,0 +1,615 @@ +# API reference + +`@shinzo/shinzohub` provides Viem-compatible functions for ShinzoHub views, +transactions, blocks, addresses, and chain configuration. + +Install it with its Viem peer dependencies: + +```bash +pnpm add @shinzo/shinzohub viem abitype +``` + +Pass an existing Viem client as the first argument. Read methods work with a +public client; `createView` requires a wallet-capable client. The supplied +`shinzoHubLocal` and `shinzoHubDevelop` chains include EVM, Cosmos REST, and +Comet RPC endpoints. Other chains may require explicit endpoint overrides. + +For smaller bundles, prefer standalone functions from focused package +subpaths: + +```ts +import { listTransactions } from "@shinzo/shinzohub/transactions"; +import { listBlocks } from "@shinzo/shinzohub/blocks"; + +const transactions = await listTransactions(publicClient, { kind: "all" }); +const blocks = await listBlocks(publicClient); +``` + +Client extension is more convenient, but imports the combined action bundle. +Use it when your bundler's tree shaking is known to remove unused code: + +```ts +import { createPublicClient, http } from "viem"; +import { shinzoHubActions, shinzoHubDevelop } from "@shinzo/shinzohub"; + +const client = createPublicClient({ + chain: shinzoHubDevelop, + transport: http(), +}).extend(shinzoHubActions); + +const transactions = await client.listTransactions({ kind: "all" }); +``` + +Responses contain native `bigint` values. Convert them before JSON +serialization, for example with `String(value)`. + +Every query function performs exactly one REST or Comet RPC request. Compose +multiple functions in application code when you need fallback or enrichment. + +## Clients + +### `shinzoHubActions` + +Creates a ShinzoHub action bundle for `client.extend(...)`. + +- Parameters + - `client`: existing Viem public or wallet client. +- Added methods + - `countViews` + - `createView` + - `getView` + - `listViews` + - `listTransactions` + - `getShinzoHubTransaction`: Cosmos-hash details via `getTransaction`. + - `findShinzoHubTransactionByEvmHash`: EVM-hash lookup via + `findTransactionByEvmHash`. + - `listBlocks` + - `getLatestShinzoHubBlock`: extended-client name for `getLatestBlock`. + - `getLatestShinzoHubBlockHeight`: extended-client name for + `getLatestBlockHeight`. + - `getShinzoHubBlock`: extended-client name for `getBlock`. + - `getShinzoHubBlockTimestamp`: extended-client name for + `getBlockTimestamp`. + +Example result: + +```ts +const client = publicClient.extend(shinzoHubActions); +await client.listBlocks({ minHeight: 100, maxHeight: 109 }); +``` + +### `createShinzoHubClient` + +Convenience wrapper around `client.extend(shinzoHubActions)`. + +- Parameters + - `client`: existing Viem public or wallet client. + +Example result: + +```ts +const client = createShinzoHubClient(publicClient); +await client.countViews(); +``` + +## Views + +Import from `@shinzo/shinzohub/views` or the package root. + +### `listViews` + +Lists registered views with pagination, content inclusion, and metadata +filters. + +- Parameters + - `client`: Viem client whose chain contains `rpcUrls.cosmosRest`, unless + `cosmosRestUrl` is supplied. + - `parameters` optional + - `limit`: page size as `number | bigint | string`. + - `offset`: numeric pagination offset as `number | bigint | string`. + - `pageKey`: opaque `pagination.nextKey` from the previous response. + - `countTotal`: request the total count. + - `reverse`: request reverse registration order. + - `includeData`: include raw viewbundle data. + - `sinceBlock`: minimum registration height. + - `includeMetadata`: include parsed viewbundle metadata. + - `name`: exact view name. + - `creator`: exact creator address. + - `metadataRootType`: exact metadata root type. + - `metadataLensHash`: required lens hash. + - `metadataQueryContains`: text required in the metadata query. + - `metadataSdlContains`: text required in the metadata SDL. + - `metadataLensArgsContains`: text required in serialized lens arguments. + - `cosmosRestUrl`: Cosmos REST endpoint override. + +Example response: + +```ts +{ + views: [{ + name: "Erc20Transfers", + creator: "shinzo1...", + contractAddress: "0x018a06d78e0802db5bc055b4527d7b481c3e9932", + data: null, + height: 1715911n, + metadata: { + query: "Ethereum__Mainnet__Log { address topics data }", + sdl: "type Erc20Transfer { ... }", + rootType: "Erc20Transfer", + lenses: [{ id: 1, args: "{}", hash: "..." }], + parseError: "", + }, + }], + pagination: { + nextKey: null, + total: 42n, // null unless requested and returned + }, +} +``` + +### `getView` + +- Parameters + - `client`: Viem client with a Cosmos REST endpoint. + - `parameters` + - `address` required: EVM hex, bare 20-byte hex, or Shinzo bech32 view + address. + - `includeData`: include raw viewbundle data. + - `includeMetadata`: include parsed viewbundle metadata. + - `cosmosRestUrl`: Cosmos REST endpoint override. + +Example response: + +```ts +{ + name: "Erc20Transfers", + creator: "shinzo1...", + contractAddress: "0x018a06d78e0802db5bc055b4527d7b481c3e9932", + data: null, + height: 1715911n, + metadata: null, +} +``` + +### `countViews` + +- Parameters + - `client`: Viem client with a Cosmos REST endpoint. + - `parameters` optional + - `cosmosRestUrl`: Cosmos REST endpoint override. + +Example response: + +```ts +42n +``` + +### `createView` + +Registers raw viewbundle bytes through the ViewRegistry precompile. + +- Parameters + - `client`: wallet-capable Viem client. + - `parameters` + - `bundle` required: `0x` hex, `Uint8Array`, or `readonly number[]`. + - `pricing`: optional EVM hex or Shinzo bech32 pricing-contract address. + When supplied, registration uses `registerWithPricing`. + - `account`: optional Viem `Account | Address`; otherwise the client's + account is used. + +Example response: + +```ts +"0xd19c997a42bd50d5a0dfb85415fbac011786185f77c2150c38b24300da78a293" +``` + +### `getCreatedViewAddress` + +Decodes the created View contract address from a confirmed ViewRegistry +transaction receipt. + +- Parameters + - `receipt`: Viem `TransactionReceipt` returned after `createView`. + +Example response: + +```ts +"0x018a06d78e0802db5bc055b4527d7b481c3e9932" +``` + +Throws when the receipt has no decodable `ViewCreated` event from the +ViewRegistry. + +## Transactions + +Import from `@shinzo/shinzohub/transactions` or the package root. + +`cosmosHash` is the canonical chain transaction hash. EVM transactions also +have an `evmHash`. A transaction with `kind: "cosmos"` is a native, +non-EVM transaction. + +### `listTransactions` + +Runs one paginated Comet `tx_search` request. + +- Parameters + - `client`: Viem client whose chain contains `rpcUrls.cometRpc`, unless + `cometRpcUrl` is supplied. + - `parameters` optional + - `page`: positive integer; defaults to `1`. + - `limit`: positive integer; defaults to `20`, maximum `100`. + - `order`: `"asc" | "desc"`; defaults to `"desc"`. + - `kind`: `"all" | "evm"`; defaults to `"all"`. Native Cosmos + transactions remain present in `"all"` results. + - `blockHeight`: positive `number | bigint | string` to restrict results + to one block. + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +{ + transactions: [{ + cosmosHash: "0xd281802a158a199c2a43719d77059ec3b28812d9718ad825533e79bba294650b", + evmHash: "0xd19c997a42bd50d5a0dfb85415fbac011786185f77c2150c38b24300da78a293", + kind: "evm", + height: 1715908n, + index: 0, + success: true, + code: 0, + codespace: "", + gasWanted: 1654365n, + gasUsed: 1654365n, + actions: ["/cosmos.evm.vm.v1.MsgEthereumTx"], + senders: ["shinzo1...", "0x1aa1..."], + recipients: ["0x0000000000000000000000000000000000000210"], + transfers: [], + fee: "", + events: [{ + type: "ethereum_tx", + attributes: [{ + key: "ethereumTxHash", + value: "0xd19c...", + index: true, + }], + }], + }], + total: 178n, +} +``` + +### `getTransaction` + +Returns decoded Cosmos REST details by canonical Cosmos hash. + +- Parameters + - `client`: Viem client with a Cosmos REST endpoint. + - `parameters` + - `hash` required: 32-byte Cosmos transaction hash. + - `cosmosRestUrl`: Cosmos REST endpoint override. + +Example response: + +```ts +{ + cosmosHash: "0xa1df678d2f7afbcbca36a66c9fdeb1ed28b30087e660c742babe48412cbc2795", + evmHash: null, + kind: "cosmos", + height: 1715911n, + index: 0, + timestamp: "2026-06-04T21:27:39.275864228Z", + success: true, + code: 0, + codespace: "", + gasWanted: 1302259n, + gasUsed: 1184936n, + actions: [ + "/ibc.core.client.v1.MsgUpdateClient", + "/ibc.core.channel.v1.MsgAcknowledgement", + ], + senders: ["shinzo1..."], + recipients: ["shinzo1..."], + transfers: [{ + sender: "shinzo1...", + recipient: "shinzo1...", + amount: "32557ushinzo", + }], + fee: "32557ushinzo", + events: [{ type: "message", attributes: [] }], + memo: "", + messages: [{ + typeUrl: "/ibc.core.client.v1.MsgUpdateClient", + value: { client_id: "07-tendermint-0" }, + }], + feeCoins: [{ denom: "ushinzo", amount: "32557" }], + feePayer: "", + feeGranter: "", + gasLimit: 1302259n, + signatures: ["..."], + rawLog: "", +} +``` + +### `findTransactionByEvmHash` + +Runs one Comet `tx_search` request and returns the matching normalized +transaction summary, or `null`. + +- Parameters + - `client`: Viem client with a Comet RPC endpoint. + - `parameters` + - `hash` required: 32-byte EVM transaction hash. + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +{ + cosmosHash: "0xd281802a158a199c2a43719d77059ec3b28812d9718ad825533e79bba294650b", + evmHash: "0xd19c997a42bd50d5a0dfb85415fbac011786185f77c2150c38b24300da78a293", + kind: "evm", + height: 1715908n, + index: 0, + success: true, + code: 0, + codespace: "", + gasWanted: 1654365n, + gasUsed: 1654365n, + actions: ["/cosmos.evm.vm.v1.MsgEthereumTx"], + senders: ["shinzo1...", "0x1aa1..."], + recipients: ["0x0000000000000000000000000000000000000210"], + transfers: [], + fee: "", + events: [], +} +``` + +To load full details from an EVM hash, call this method and then pass its +`cosmosHash` to `getTransaction`. + +## Blocks + +Import from `@shinzo/shinzohub/blocks` or the package root. + +### `listBlocks` + +Runs one Comet `blockchain` request for an optional inclusive height range. + +- Parameters + - `client`: Viem client whose chain contains `rpcUrls.cometRpc`, unless + `cometRpcUrl` is supplied. + - `parameters` optional + - `minHeight`: positive inclusive lower height. + - `maxHeight`: positive inclusive upper height. + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +{ + blocks: [{ + hash: "0xd51bf7b22d1e494a6cec356026840dd0b8b18728fff874e3f815b868a900d6e1", + parentHash: "0x07463b432a44296988375e74775c12bc087c581e6f6bd4f2586c7c02957a19e7", + height: 1715911n, + timestamp: "2026-06-04T21:27:39.275864228Z", + chainId: "91273002", + proposerAddress: "A852CB745D33C37C4837546B2F4D243AE4901EB2", + transactionCount: 1, + size: 804n, + lastCommitHash: "0x...", + dataHash: "0x...", + validatorsHash: "0x...", + nextValidatorsHash: "0x...", + consensusHash: "0x...", + appHash: "0x...", + lastResultsHash: "0x...", + evidenceHash: "0x...", + }], + latestHeight: 2003690n, +} +``` + +### `getLatestBlock` + +- Parameters + - `client`: Viem client with a Comet RPC endpoint. + - `parameters` optional + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +{ + hash: "0x...", + parentHash: "0x...", + height: 2003690n, + timestamp: "2026-06-09T13:00:00Z", + chainId: "91273002", + proposerAddress: "E89750B9...", + transactionCount: 0, + size: null, + lastCommitHash: "0x...", + dataHash: null, + validatorsHash: "0x...", + nextValidatorsHash: "0x...", + consensusHash: "0x...", + appHash: "0x...", + lastResultsHash: "0x...", + evidenceHash: "0x...", +} +``` + +### `getLatestBlockHeight` + +Runs one Comet `status` request. + +- Parameters + - `client`: Viem client with a Comet RPC endpoint. + - `parameters` optional + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +2003690n +``` + +### `getBlockTimestamp` + +Runs one Comet `header` request. + +- Parameters + - `client`: Viem client with a Comet RPC endpoint. + - `parameters` + - `height` required: positive `number | bigint | string`. + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +"2026-06-04T21:27:39.275864228Z" +``` + +### `getBlock` + +Fetches one block by height or hash. + +- Parameters + - `client`: Viem client with a Comet RPC endpoint. + - `parameters` + - `height`: positive `number | bigint | string`. + - `hash`: 32-byte block hash. + - Supply exactly one of `height` or `hash`. + - `cometRpcUrl`: Comet RPC endpoint override. + +Example response: + +```ts +{ + hash: "0xd51bf7b22d1e494a6cec356026840dd0b8b18728fff874e3f815b868a900d6e1", + parentHash: "0x07463b432a44296988375e74775c12bc087c581e6f6bd4f2586c7c02957a19e7", + height: 1715911n, + timestamp: "2026-06-04T21:27:39.275864228Z", + chainId: "91273002", + proposerAddress: "A852CB745D33C37C4837546B2F4D243AE4901EB2", + transactionCount: 1, + size: null, + lastCommitHash: "0x...", + dataHash: "0x...", + validatorsHash: "0x...", + nextValidatorsHash: "0x...", + consensusHash: "0x...", + appHash: "0x...", + lastResultsHash: "0x...", + evidenceHash: "0x...", +} +``` + +`size` is available in `listBlocks` results and is `null` for direct block +lookups. + +## Addresses + +Import from `@shinzo/shinzohub/addresses` or the package root. All functions +validate that addresses contain exactly 20 bytes. + +### `normalizeHexAddress` + +- Parameters + - `value`: `0x`-prefixed or bare EVM hex address. + +Example response: + +```ts +"0x018a06d78e0802db5bc055b4527d7b481c3e9932" +``` + +### `hexToShinzoAddress` + +- Parameters + - `value`: `0x`-prefixed or bare EVM hex address. + - `options` optional + - `prefix`: bech32 prefix; defaults to `"shinzo"`. + +Example response: + +```ts +"shinzo1..." +``` + +### `shinzoAddressToHex` + +- Parameters + - `value`: Shinzo bech32 address. + - `options` optional + - `prefix`: expected bech32 prefix; defaults to `"shinzo"`. + +Example response: + +```ts +"0x018a06d78e0802db5bc055b4527d7b481c3e9932" +``` + +### `normalizeShinzoAddress` + +Accepts hex or bech32 and returns canonical bech32. + +- Parameters + - `value`: Shinzo bech32, `0x`-prefixed hex, or bare EVM hex address. + - `options` optional + - `prefix`: expected/output bech32 prefix; defaults to `"shinzo"`. + +Example response: + +```ts +"shinzo1..." +``` + +## Chains + +Import from `@shinzo/shinzohub/chains` or the package root. + +### Chain exports + +- `shinzoHubLocal`: chain ID `91273002`; local EVM, Cosmos REST, and Comet RPC + endpoints. +- `shinzoHubDevelop`: chain ID `91273002`; shared develop EVM, Cosmos REST, + and Comet RPC endpoints. +- `shinzoHubDevnet`: chain ID `91273002`; public devnet EVM endpoint only. +- `shinzoHubTestnet`: chain ID `91273001`; endpoint placeholders are empty. +- `shinzoHubMainnet`: chain ID `91273000`; endpoint placeholders are empty. +- `shinzoHubChains`: the definitions above keyed by `local`, `develop`, + `devnet`, `testnet`, and `mainnet`. + +Example: + +```ts +const publicClient = createPublicClient({ + chain: shinzoHubChains.develop, + transport: http(), +}); +``` + +## ViewRegistry + +Import from `@shinzo/shinzohub/views` or the package root. + +### `viewRegistryAddress` + +ViewRegistry precompile address: + +```ts +"0x0000000000000000000000000000000000000210" +``` + +### `viewRegistryAbi` + +Viem-compatible ABI containing: + +- `register(bytes)` +- `registerWithPricing(bytes,address)` +- `getView(address)` +- `ViewCreated(address,address,string)` + +Use it for custom Viem contract calls, simulations, filters, or event +decoding when the high-level view functions are insufficient. diff --git a/packages/shinzohub/package.json b/packages/shinzohub/package.json index 7ee66782..79fbf147 100644 --- a/packages/shinzohub/package.json +++ b/packages/shinzohub/package.json @@ -4,54 +4,20 @@ "description": "Viem-first TypeScript client actions for ShinzoHub.", "type": "module", "sideEffects": false, - "types": "./dist/index.d.ts", "exports": { - ".": { - "shinzo-source": "./src/index.ts", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./addresses": { - "shinzo-source": "./src/addresses/index.ts", - "types": "./dist/addresses/index.d.ts", - "default": "./dist/addresses/index.js" - }, - "./chains": { - "shinzo-source": "./src/chains/index.ts", - "types": "./dist/chains/index.d.ts", - "default": "./dist/chains/index.js" - }, - "./views": { - "shinzo-source": "./src/views/index.ts", - "types": "./dist/views/index.d.ts", - "default": "./dist/views/index.js" - }, + ".": "./src/index.ts", + "./addresses": "./src/addresses/index.ts", + "./chains": "./src/chains/index.ts", + "./blocks": "./src/blocks/index.ts", + "./transactions": "./src/transactions/index.ts", + "./views": "./src/views/index.ts", "./package.json": "./package.json" }, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./addresses": { - "types": "./dist/addresses/index.d.ts", - "default": "./dist/addresses/index.js" - }, - "./chains": { - "types": "./dist/chains/index.d.ts", - "default": "./dist/chains/index.js" - }, - "./views": { - "types": "./dist/views/index.d.ts", - "default": "./dist/views/index.js" - }, - "./package.json": "./package.json" - } - }, "files": [ + "src", "dist", "README.md", + "api.md", "llm.md" ], "scripts": { diff --git a/packages/shinzohub/src/addresses/index.test.ts b/packages/shinzohub/src/addresses/index.test.ts index 1b65032f..cfbcd8bb 100644 --- a/packages/shinzohub/src/addresses/index.test.ts +++ b/packages/shinzohub/src/addresses/index.test.ts @@ -4,7 +4,7 @@ import { normalizeHexAddress, normalizeShinzoAddress, shinzoAddressToHex, -} from "./index.js"; +} from "./index"; describe("Shinzo address utilities", () => { it("converts a 20-byte EVM address to Shinzo bech32", () => { diff --git a/packages/shinzohub/src/addresses/index.ts b/packages/shinzohub/src/addresses/index.ts index abc6758a..822a53b1 100644 --- a/packages/shinzohub/src/addresses/index.ts +++ b/packages/shinzohub/src/addresses/index.ts @@ -1,6 +1,6 @@ import type { Address } from "viem"; -import { decodeBech32, encodeBech32 } from "../internal/bech32.js"; -import { hexToBytes, isHexLike, normalizeHex } from "../internal/hex.js"; +import { decodeBech32, encodeBech32 } from "../internal/bech32"; +import { hexToBytes, isHexLike, normalizeHex } from "../internal/hex"; type Brand = TValue & { readonly __brand: TName }; diff --git a/packages/shinzohub/src/blocks/get-block-timestamp.ts b/packages/shinzohub/src/blocks/get-block-timestamp.ts new file mode 100644 index 00000000..c4430526 --- /dev/null +++ b/packages/shinzohub/src/blocks/get-block-timestamp.ts @@ -0,0 +1,31 @@ +import { getFetch, requestCometRpc } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import { positiveHeight } from "./internal"; +import type { GetBlockTimestampParameters } from "./types"; + +interface HeaderWire { + header?: { + time?: string; + }; +} + +/** Reads a block's consensus timestamp by height. */ +export async function getBlockTimestamp( + client: ShinzoHubQueryClient, + parameters: GetBlockTimestampParameters, +): Promise { + const height = positiveHeight(parameters.height, "height"); + const response = await requestCometRpc( + getFetch(), + getRpcEndpoint(client, "cometRpc", parameters.cometRpcUrl), + "header", + { height: height.toString() }, + ); + if (!response.header?.time) { + throw new Error("ShinzoHub header response did not include a timestamp."); + } + return response.header.time; +} diff --git a/packages/shinzohub/src/blocks/get-block.ts b/packages/shinzohub/src/blocks/get-block.ts new file mode 100644 index 00000000..5701281b --- /dev/null +++ b/packages/shinzohub/src/blocks/get-block.ts @@ -0,0 +1,37 @@ +import type { ShinzoHubQueryClient } from "../internal/endpoints"; +import { normalizeHex, stripHexPrefix } from "../internal/hex"; +import { getBlockWire } from "./internal"; +import type { + GetBlockParameters, + ShinzoHubBlock, +} from "./types"; + +/** Loads one consensus block by height or block hash. */ +export async function getBlock( + client: ShinzoHubQueryClient, + parameters: GetBlockParameters, +): Promise { + if ((parameters.height === undefined) === (parameters.hash === undefined)) { + throw new Error("Provide exactly one of height or hash."); + } + if (parameters.height !== undefined) { + const height = BigInt(parameters.height); + if (height < 1n) { + throw new Error("height must be greater than zero."); + } + return getBlockWire( + client, + "block", + { height: height.toString() }, + parameters.cometRpcUrl, + ); + } + + const hash = normalizeHex(parameters.hash ?? "", "hash", 32); + return getBlockWire( + client, + "block_by_hash", + { hash: stripHexPrefix(hash).toUpperCase() }, + parameters.cometRpcUrl, + ); +} diff --git a/packages/shinzohub/src/blocks/get-latest-block-height.ts b/packages/shinzohub/src/blocks/get-latest-block-height.ts new file mode 100644 index 00000000..81db8cb0 --- /dev/null +++ b/packages/shinzohub/src/blocks/get-latest-block-height.ts @@ -0,0 +1,25 @@ +import { getFetch, requestCometRpc } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import type { GetLatestBlockHeightParameters } from "./types"; + +interface StatusWire { + sync_info?: { + latest_block_height?: string; + }; +} + +/** Reads the latest consensus block height. */ +export async function getLatestBlockHeight( + client: ShinzoHubQueryClient, + parameters: GetLatestBlockHeightParameters = {}, +): Promise { + const response = await requestCometRpc( + getFetch(), + getRpcEndpoint(client, "cometRpc", parameters.cometRpcUrl), + "status", + ); + return BigInt(response.sync_info?.latest_block_height ?? 0); +} diff --git a/packages/shinzohub/src/blocks/get-latest-block.ts b/packages/shinzohub/src/blocks/get-latest-block.ts new file mode 100644 index 00000000..828f7180 --- /dev/null +++ b/packages/shinzohub/src/blocks/get-latest-block.ts @@ -0,0 +1,14 @@ +import type { ShinzoHubQueryClient } from "../internal/endpoints"; +import { getBlockWire } from "./internal"; +import type { + GetBlockParameters, + ShinzoHubBlock, +} from "./types"; + +/** Loads the latest consensus block. */ +export async function getLatestBlock( + client: ShinzoHubQueryClient, + parameters: Pick = {}, +): Promise { + return getBlockWire(client, "block", {}, parameters.cometRpcUrl); +} diff --git a/packages/shinzohub/src/blocks/index.test.ts b/packages/shinzohub/src/blocks/index.test.ts new file mode 100644 index 00000000..939a7120 --- /dev/null +++ b/packages/shinzohub/src/blocks/index.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + blockFixture, + hashFixture, + mockShinzoHubApi, + restoreShinzoHubApiMock, + shinzoHubTestClient, +} from "../internal/test-utils"; +import { + getBlock, + getBlockTimestamp, + getLatestBlock, + getLatestBlockHeight, + listBlocks, +} from "./index"; + +const blockHash = hashFixture("44"); +const latestBlockHash = hashFixture("66"); +const parentHash = hashFixture("33"); + +afterEach(restoreShinzoHubApiMock); + +describe("block queries", () => { + it("supports block feeds, detail pages, and timestamp enrichment", async () => { + const api = mockShinzoHubApi({ + latestBlockHeight: 25, + blocks: [ + blockFixture({ + hash: blockHash, + parentHash, + height: 20, + transactionCount: 3, + }), + blockFixture({ + hash: latestBlockHash, + parentHash: blockHash, + height: 25, + transactionCount: 2, + }), + ], + }); + + const listed = await listBlocks(shinzoHubTestClient, { + minHeight: 16, + maxHeight: 20, + }); + const latest = await getLatestBlock(shinzoHubTestClient); + const selected = await getBlock(shinzoHubTestClient, { height: 20 }); + const latestHeight = await getLatestBlockHeight(shinzoHubTestClient); + const timestamp = await getBlockTimestamp(shinzoHubTestClient, { + height: 20, + }); + + expect(listed).toMatchObject({ + latestHeight: 25n, + blocks: [ + { + hash: blockHash, + parentHash, + height: 20n, + transactionCount: 3, + size: 806n, + }, + ], + }); + expect(latest).toMatchObject({ + height: 25n, + transactionCount: 2, + size: null, + }); + expect(selected.height).toBe(20n); + expect(latestHeight).toBe(25n); + expect(timestamp).toBe("2026-06-09T12:00:00Z"); + api.expectRequestCount(5); + }); +}); diff --git a/packages/shinzohub/src/blocks/index.ts b/packages/shinzohub/src/blocks/index.ts new file mode 100644 index 00000000..ec87207b --- /dev/null +++ b/packages/shinzohub/src/blocks/index.ts @@ -0,0 +1,13 @@ +export { getBlock } from "./get-block"; +export { getBlockTimestamp } from "./get-block-timestamp"; +export { getLatestBlock } from "./get-latest-block"; +export { getLatestBlockHeight } from "./get-latest-block-height"; +export { listBlocks } from "./list-blocks"; +export type { + GetBlockParameters, + GetBlockTimestampParameters, + GetLatestBlockHeightParameters, + ListBlocksParameters, + ListBlocksResult, + ShinzoHubBlock, +} from "./types"; diff --git a/packages/shinzohub/src/blocks/internal.ts b/packages/shinzohub/src/blocks/internal.ts new file mode 100644 index 00000000..e8f7e471 --- /dev/null +++ b/packages/shinzohub/src/blocks/internal.ts @@ -0,0 +1,123 @@ +import type { Hex } from "viem"; +import { getFetch, requestCometRpc } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import { normalizeHex } from "../internal/hex"; +import type { ShinzoHubBlock } from "./types"; + +/** Comet block header fields used by block normalization. */ +export interface HeaderWire { + chain_id?: string; + height?: string; + time?: string; + last_block_id?: { hash?: string }; + last_commit_hash?: string; + data_hash?: string; + validators_hash?: string; + next_validators_hash?: string; + consensus_hash?: string; + app_hash?: string; + last_results_hash?: string; + evidence_hash?: string; + proposer_address?: string; +} + +/** Comet block metadata response before normalization. */ +export interface BlockMetaWire { + block_id?: { hash?: string }; + block_size?: string; + header?: HeaderWire; + num_txs?: string; +} + +/** Comet blockchain result before normalization. */ +export interface BlockchainWire { + last_height?: string; + block_metas?: BlockMetaWire[]; +} + +interface BlockWire { + block_id?: { hash?: string }; + block?: { + header?: HeaderWire; + data?: { txs?: string[] | null }; + }; +} + +/** Fetches and normalizes a block from either Comet block lookup method. */ +export async function getBlockWire( + client: ShinzoHubQueryClient, + method: "block" | "block_by_hash", + params: Record, + override?: string, +): Promise { + const response = await requestCometRpc( + getFetch(), + getRpcEndpoint(client, "cometRpc", override), + method, + params, + ); + return toBlock( + response.block_id?.hash, + response.block?.header, + response.block?.data?.txs?.length ?? 0, + null, + ); +} + +/** Normalizes block metadata returned by the Comet blockchain method. */ +export function toBlockMeta(meta: BlockMetaWire): ShinzoHubBlock { + return toBlock( + meta.block_id?.hash, + meta.header, + Number(meta.num_txs ?? 0), + BigInt(meta.block_size ?? 0), + ); +} + +/** Validates a block height and normalizes it to bigint. */ +export function positiveHeight( + value: number | bigint | string, + name: string, +): bigint { + const result = BigInt(value); + if (result < 1n) { + throw new Error(`${name} must be greater than zero.`); + } + return result; +} + +function toBlock( + hash: string | undefined, + header: HeaderWire | undefined, + transactionCount: number, + size: bigint | null, +): ShinzoHubBlock { + if (!hash || !header?.height || !header.time) { + throw new Error("ShinzoHub block response is missing required fields."); + } + return { + hash: normalizeHex(hash, "block hash", 32), + parentHash: optionalHash(header.last_block_id?.hash), + height: BigInt(header.height), + timestamp: header.time, + chainId: header.chain_id ?? "", + proposerAddress: header.proposer_address ?? "", + transactionCount, + size, + lastCommitHash: optionalHash(header.last_commit_hash), + dataHash: optionalHash(header.data_hash), + validatorsHash: optionalHash(header.validators_hash), + nextValidatorsHash: optionalHash(header.next_validators_hash), + consensusHash: optionalHash(header.consensus_hash), + appHash: optionalHash(header.app_hash), + lastResultsHash: optionalHash(header.last_results_hash), + evidenceHash: optionalHash(header.evidence_hash), + }; +} + +function optionalHash(value: string | undefined): Hex | null { + return value ? normalizeHex(value, "block header hash", 32) : null; +} diff --git a/packages/shinzohub/src/blocks/list-blocks.ts b/packages/shinzohub/src/blocks/list-blocks.ts new file mode 100644 index 00000000..ce425669 --- /dev/null +++ b/packages/shinzohub/src/blocks/list-blocks.ts @@ -0,0 +1,54 @@ +import { getFetch, requestCometRpc } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import { + positiveHeight, + toBlockMeta, + type BlockchainWire, +} from "./internal"; +import type { + ListBlocksParameters, + ListBlocksResult, +} from "./types"; + +/** Lists consensus blocks within an optional inclusive height range. */ +export async function listBlocks( + client: ShinzoHubQueryClient, + parameters: ListBlocksParameters = {}, +): Promise { + const minHeight = + parameters.minHeight === undefined + ? undefined + : positiveHeight(parameters.minHeight, "minHeight"); + const maxHeight = + parameters.maxHeight === undefined + ? undefined + : positiveHeight(parameters.maxHeight, "maxHeight"); + if ( + minHeight !== undefined && + maxHeight !== undefined && + minHeight > maxHeight + ) { + throw new Error("minHeight must be less than or equal to maxHeight."); + } + const params: Record = {}; + if (minHeight !== undefined) { + params.minHeight = minHeight.toString(); + } + if (maxHeight !== undefined) { + params.maxHeight = maxHeight.toString(); + } + const response = await requestCometRpc( + getFetch(), + getRpcEndpoint(client, "cometRpc", parameters.cometRpcUrl), + "blockchain", + params, + ); + + return { + blocks: (response.block_metas ?? []).map(toBlockMeta), + latestHeight: BigInt(response.last_height ?? 0), + }; +} diff --git a/packages/shinzohub/src/blocks/types.ts b/packages/shinzohub/src/blocks/types.ts new file mode 100644 index 00000000..7d1814f9 --- /dev/null +++ b/packages/shinzohub/src/blocks/types.ts @@ -0,0 +1,52 @@ +import type { Hex } from "viem"; + +/** A normalized ShinzoHub consensus block. */ +export interface ShinzoHubBlock { + hash: Hex; + parentHash: Hex | null; + height: bigint; + timestamp: string; + chainId: string; + proposerAddress: string; + transactionCount: number; + size: bigint | null; + lastCommitHash: Hex | null; + dataHash: Hex | null; + validatorsHash: Hex | null; + nextValidatorsHash: Hex | null; + consensusHash: Hex | null; + appHash: Hex | null; + lastResultsHash: Hex | null; + evidenceHash: Hex | null; +} + +/** Inclusive height range options for listing blocks. */ +export interface ListBlocksParameters { + minHeight?: number | bigint | string; + maxHeight?: number | bigint | string; + cometRpcUrl?: string; +} + +/** Blocks in the requested range plus the chain's latest height. */ +export interface ListBlocksResult { + blocks: readonly ShinzoHubBlock[]; + latestHeight: bigint; +} + +/** Options for loading a block by height or hash. */ +export interface GetBlockParameters { + height?: number | bigint | string; + hash?: string; + cometRpcUrl?: string; +} + +/** Options for loading a block timestamp by height. */ +export interface GetBlockTimestampParameters { + height: number | bigint | string; + cometRpcUrl?: string; +} + +/** Options for reading the chain's latest block height. */ +export interface GetLatestBlockHeightParameters { + cometRpcUrl?: string; +} diff --git a/packages/shinzohub/src/chains/index.test.ts b/packages/shinzohub/src/chains/index.test.ts index ce65c686..d82c3462 100644 --- a/packages/shinzohub/src/chains/index.test.ts +++ b/packages/shinzohub/src/chains/index.test.ts @@ -3,7 +3,7 @@ import { shinzoHubChains, shinzoHubDevelop, shinzoHubLocal, -} from "./index.js"; +} from "./index"; describe("Viem chain definitions", () => { it("defines standard chain metadata for develop environment", () => { diff --git a/packages/shinzohub/src/index.ts b/packages/shinzohub/src/index.ts index cae6fc98..b5a0f168 100644 --- a/packages/shinzohub/src/index.ts +++ b/packages/shinzohub/src/index.ts @@ -5,15 +5,27 @@ import { getCreatedViewAddress, getView, listViews, -} from "./views/index.js"; +} from "./views/index"; +import { + findTransactionByEvmHash, + getTransaction, + listTransactions, +} from "./transactions/index"; +import { + getBlock, + getBlockTimestamp, + getLatestBlock, + getLatestBlockHeight, + listBlocks, +} from "./blocks/index"; export { hexToShinzoAddress, normalizeHexAddress, normalizeShinzoAddress, shinzoAddressToHex, -} from "./addresses/index.js"; -export type { HexAddress, ShinzoAddress } from "./addresses/index.js"; +} from "./addresses/index"; +export type { HexAddress, ShinzoAddress } from "./addresses/index"; export { shinzoHubChains, shinzoHubDevelop, @@ -21,7 +33,7 @@ export { shinzoHubLocal, shinzoHubMainnet, shinzoHubTestnet, -} from "./chains/index.js"; +} from "./chains/index"; export { countViews, createView, @@ -30,38 +42,52 @@ export { listViews, viewRegistryAbi, viewRegistryAddress, -} from "./views/index.js"; +} from "./views/index"; export type { CreateViewParameters, ListViewsParameters, ListViewsResult, ShinzoHubView, ViewMetadata, -} from "./views/index.js"; +} from "./views/index"; +export { + findTransactionByEvmHash, + getTransaction, + listTransactions, +} from "./transactions/index"; +export type { + FindTransactionByEvmHashParameters, + GetTransactionParameters, + ListTransactionsParameters, + ListTransactionsResult, + ShinzoHubCoin, + ShinzoHubEvent, + ShinzoHubEventAttribute, + ShinzoHubMessage, + ShinzoHubTransaction, + ShinzoHubTransactionFilter, + ShinzoHubTransactionKind, + ShinzoHubTransactionOrder, + ShinzoHubTransactionSummary, + ShinzoHubTransfer, +} from "./transactions/index"; +export { + getBlock, + getBlockTimestamp, + getLatestBlock, + getLatestBlockHeight, + listBlocks, +} from "./blocks/index"; +export type { + GetBlockParameters, + GetBlockTimestampParameters, + GetLatestBlockHeightParameters, + ListBlocksParameters, + ListBlocksResult, + ShinzoHubBlock, +} from "./blocks/index"; -/** - * Creates a Viem action bundle for ShinzoHub view workflows. - * - * Use this with `client.extend(shinzoHubActions)` when you want the methods to - * appear directly on an existing Viem public or wallet client. - * - * @param client - Existing Viem client. Public clients can call read/query - * methods such as `listViews`; wallet clients can also call `createView`. - * @returns ShinzoHub actions bound to the provided Viem client. - * - * @example - * ```ts - * import { createPublicClient, http } from "viem"; - * import { shinzoHubActions, shinzoHubDevelop } from "@shinzo/shinzohub"; - * - * const client = createPublicClient({ - * chain: shinzoHubDevelop, - * transport: http(), - * }).extend(shinzoHubActions); - * - * const views = await client.listViews({ limit: 10, includeMetadata: true }); - * ``` - */ +/** Creates ShinzoHub actions bound to an existing Viem client. */ export function shinzoHubActions(client: Client) { return { /** Counts registered ShinzoHub views. */ @@ -72,35 +98,37 @@ export function shinzoHubActions(client: Client) { getView: (parameters: Parameters[1]) => getView(client, parameters), /** Lists registered ShinzoHub views. */ listViews: (parameters?: Parameters[1]) => listViews(client, parameters), + /** Lists native Cosmos and EVM transactions. */ + listTransactions: (parameters?: Parameters[1]) => + listTransactions(client, parameters), + /** Fetches decoded transaction details by Cosmos hash. */ + getShinzoHubTransaction: (parameters: Parameters[1]) => + getTransaction(client, parameters), + /** Finds a transaction summary by EVM hash. */ + findShinzoHubTransactionByEvmHash: ( + parameters: Parameters[1] + ) => findTransactionByEvmHash(client, parameters), + /** Lists consensus blocks. */ + listBlocks: (parameters?: Parameters[1]) => + listBlocks(client, parameters), + /** Fetches the latest ShinzoHub consensus block. */ + getLatestShinzoHubBlock: (parameters?: Parameters[1]) => + getLatestBlock(client, parameters), + /** Fetches the latest ShinzoHub consensus height. */ + getLatestShinzoHubBlockHeight: ( + parameters?: Parameters[1] + ) => getLatestBlockHeight(client, parameters), + /** Fetches one ShinzoHub consensus block by height or hash. */ + getShinzoHubBlock: (parameters: Parameters[1]) => + getBlock(client, parameters), + /** Fetches a block timestamp by height. */ + getShinzoHubBlockTimestamp: ( + parameters: Parameters[1] + ) => getBlockTimestamp(client, parameters), }; } -/** - * Decorates an existing Viem client with ShinzoHub methods. - * - * This is a small convenience wrapper around `client.extend(shinzoHubActions)`. - * It does not create transports, accounts, or chain configuration for you, so - * the result stays predictable for Viem users. - * - * @param client - Existing Viem public or wallet client to extend. - * @returns The same Viem client shape with ShinzoHub methods attached. - * - * @example - * ```ts - * import { createWalletClient, http } from "viem"; - * import { createShinzoHubClient, shinzoHubDevelop } from "@shinzo/shinzohub"; - * - * const walletClient = createShinzoHubClient( - * createWalletClient({ - * account: "0x1234567890AbcdEF1234567890aBcdef12345678", - * chain: shinzoHubDevelop, - * transport: http(), - * }), - * ); - * - * const hash = await walletClient.createView({ bundle: "0x68656c6c6f" }); - * ``` - */ +/** Extends a Viem client with the complete ShinzoHub action bundle. */ export function createShinzoHubClient(client: TClient) { return client.extend(shinzoHubActions); } diff --git a/packages/shinzohub/src/internal/bech32.ts b/packages/shinzohub/src/internal/bech32.ts index 78777ca6..fd676336 100644 --- a/packages/shinzohub/src/internal/bech32.ts +++ b/packages/shinzohub/src/internal/bech32.ts @@ -1,6 +1,7 @@ const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] as const; +/** Decoded bech32 prefix and payload bytes. */ export interface Bech32Decoded { prefix: string; data: Uint8Array; diff --git a/packages/shinzohub/src/internal/comet.ts b/packages/shinzohub/src/internal/comet.ts new file mode 100644 index 00000000..83595ad1 --- /dev/null +++ b/packages/shinzohub/src/internal/comet.ts @@ -0,0 +1,70 @@ +import { normalizeBaseUrl, type FetchLike } from "./fetch"; + +interface CometRpcErrorWire { + code: number; + message: string; + data?: string; +} + +interface CometRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: T; + error?: CometRpcErrorWire; +} + +/** Error reported by a Comet JSON-RPC method. */ +export class ShinzoHubRpcError extends Error { + readonly code: number; + readonly data?: string; + + constructor(method: string, error: CometRpcErrorWire) { + super(`ShinzoHub ${method} RPC failed: ${error.message}`); + this.name = "ShinzoHubRpcError"; + this.code = error.code; + this.data = error.data; + } +} + +/** Resolves the runtime fetch implementation used by query methods. */ +export function getFetch(): FetchLike { + const fetchFn = globalThis.fetch?.bind(globalThis); + if (!fetchFn) { + throw new Error("No fetch implementation is available."); + } + return fetchFn; +} + +/** Executes a Comet JSON-RPC call and unwraps its result or typed error. */ +export async function requestCometRpc( + fetchFn: FetchLike, + baseUrl: string, + method: string, + params: Record = {}, +): Promise { + const response = await fetchFn(normalizeBaseUrl(baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params, + }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `ShinzoHub ${method} RPC request failed with ${response.status} ${response.statusText}.`, + ); + } + + const payload = JSON.parse(text) as CometRpcResponse; + if (payload.error) { + throw new ShinzoHubRpcError(method, payload.error); + } + if (payload.result === undefined) { + throw new Error(`ShinzoHub ${method} RPC response did not include a result.`); + } + return payload.result; +} diff --git a/packages/shinzohub/src/internal/endpoints.ts b/packages/shinzohub/src/internal/endpoints.ts new file mode 100644 index 00000000..ffab2f86 --- /dev/null +++ b/packages/shinzohub/src/internal/endpoints.ts @@ -0,0 +1,28 @@ +type RpcEndpointName = "cosmosRest" | "cometRpc"; + +/** Minimal Viem client shape required by ShinzoHub query methods. */ +export interface ShinzoHubQueryClient { + chain?: { + rpcUrls?: Record; + } | null; +} + +/** Resolves a Cosmos REST or Comet RPC endpoint from an override or chain. */ +export function getRpcEndpoint( + client: ShinzoHubQueryClient, + name: RpcEndpointName, + override?: string, +): string { + const url = + override ?? + client.chain?.rpcUrls?.[name]?.http?.[0]; + + if (!url) { + const label = name === "cosmosRest" ? "Cosmos REST" : "Comet RPC"; + throw new Error( + `${label} URL not found. Ensure the client's chain configuration includes ${name} endpoints, or pass an explicit URL.`, + ); + } + + return url; +} diff --git a/packages/shinzohub/src/internal/fetch.ts b/packages/shinzohub/src/internal/fetch.ts index 4d967746..eef9e995 100644 --- a/packages/shinzohub/src/internal/fetch.ts +++ b/packages/shinzohub/src/internal/fetch.ts @@ -1,5 +1,6 @@ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +/** HTTP error retaining the upstream response details for diagnostics. */ export class ShinzoHubHttpError extends Error { readonly status: number; readonly statusText: string; diff --git a/packages/shinzohub/src/internal/test-utils.ts b/packages/shinzohub/src/internal/test-utils.ts new file mode 100644 index 00000000..ba68ace3 --- /dev/null +++ b/packages/shinzohub/src/internal/test-utils.ts @@ -0,0 +1,431 @@ +import { createPublicClient, http, type Hex } from "viem"; +import { expect, vi } from "vitest"; +import { shinzoHubDevelop } from "../chains/index"; + +type TransactionKind = "cosmos" | "evm"; + +interface RpcErrorFixture { + code: number; + message: string; + data?: string; +} + +interface EventAttributeFixture { + key: string; + value: string; + index: boolean; +} + +interface EventFixture { + type: string; + attributes: EventAttributeFixture[]; +} + +/** Complete transaction fixture shared by Comet and Cosmos REST mocks. */ +export interface TransactionFixture { + hash: Hex; + height: string; + index: number; + timestamp: string; + events: EventFixture[]; + wire: { + hash: string; + height: string; + index: number; + tx_result: { + code: number; + codespace: string; + gas_wanted: string; + gas_used: string; + events: EventFixture[]; + }; + }; +} + +/** Cosmos REST detail response associated with a transaction fixture. */ +export interface TransactionDetailsFixture { + hash: Hex; + response: Record; +} + +/** Consensus block fixture shared by block RPC mock methods. */ +export interface BlockFixture { + hash: Hex; + parentHash: Hex; + height: string; + timestamp: string; + transactionCount: number; + size: string; + header: Record; +} + +interface MockShinzoHubApiOptions { + transactions?: readonly TransactionFixture[]; + transactionDetails?: readonly TransactionDetailsFixture[]; + blocks?: readonly BlockFixture[]; + latestBlockHeight?: number | bigint | string; + rpcErrors?: Partial>; +} + +interface RpcRequest { + id?: number; + method: string; + params: Record; +} + +const originalFetch = globalThis.fetch; + +/** Viem client configured with the package's develop-chain endpoints. */ +export const shinzoHubTestClient = createPublicClient({ + chain: shinzoHubDevelop, + transport: http(), +}); + +/** Builds a deterministic 32-byte hash from one repeated byte. */ +export function hashFixture(byte: string): Hex { + if (!/^[0-9a-f]{2}$/i.test(byte)) { + throw new Error("Hash fixture byte must be two hexadecimal characters."); + } + return `0x${byte.repeat(32)}`; +} + +/** Builds native or EVM transaction data for query tests. */ +export function transactionFixture({ + hash, + height, + kind = "cosmos", + evmHash = `0x${"aa".repeat(32)}` as Hex, + code = 0, + timestamp = "2026-06-09T12:00:00Z", + transferAmount = "42ushinzo", +}: { + hash: Hex; + height: number | bigint | string; + kind?: TransactionKind; + evmHash?: Hex; + code?: number; + timestamp?: string; + transferAmount?: string | null; +}): TransactionFixture { + const events: EventFixture[] = [ + { + type: "transfer", + attributes: [ + { key: "sender", value: "shinzo1sender", index: true }, + { key: "recipient", value: "shinzo1feecollector", index: true }, + { key: "amount", value: "7ushinzo", index: true }, + ], + }, + { + type: "message", + attributes: [ + { key: "action", value: "/cosmos.bank.v1beta1.MsgSend", index: true }, + { key: "sender", value: "shinzo1sender", index: true }, + { key: "msg_index", value: "0", index: true }, + ], + }, + ]; + + if (transferAmount) { + events.push({ + type: "transfer", + attributes: [ + { key: "sender", value: "shinzo1sender", index: true }, + { key: "recipient", value: "shinzo1recipient", index: true }, + { key: "amount", value: transferAmount, index: true }, + { key: "msg_index", value: "0", index: true }, + ], + }); + } + + events.push({ + type: "tx", + attributes: [{ key: "fee", value: "7ushinzo", index: true }], + }); + + if (kind === "evm") { + events.push({ + type: "ethereum_tx", + attributes: [ + { key: "ethereumTxHash", value: evmHash, index: true }, + { key: "txIndex", value: "2", index: true }, + ], + }); + } + + const normalizedHeight = String(height); + return { + hash, + height: normalizedHeight, + index: 0, + timestamp, + events, + wire: { + hash: hash.slice(2).toUpperCase(), + height: normalizedHeight, + index: 0, + tx_result: { + code, + codespace: code ? "sdk" : "", + gas_wanted: "100", + gas_used: "80", + events, + }, + }, + }; +} + +/** Builds decoded Cosmos REST details for a transaction fixture. */ +export function transactionDetailsFixture( + transaction: TransactionFixture, + { + memo = "", + messages = [ + { + "@type": "/cosmos.bank.v1beta1.MsgSend", + from_address: "shinzo1sender", + }, + ], + feeCoins = [{ denom: "ushinzo", amount: "7" }], + }: { + memo?: string; + messages?: readonly Record[]; + feeCoins?: readonly { denom: string; amount: string }[]; + } = {}, +): TransactionDetailsFixture { + return { + hash: transaction.hash, + response: { + tx: { + body: { messages, memo }, + auth_info: { + fee: { + amount: feeCoins, + gas_limit: transaction.wire.tx_result.gas_wanted, + }, + }, + signatures: ["signature"], + }, + tx_response: { + height: transaction.height, + txhash: transaction.hash.slice(2).toUpperCase(), + code: transaction.wire.tx_result.code, + codespace: transaction.wire.tx_result.codespace, + gas_wanted: transaction.wire.tx_result.gas_wanted, + gas_used: transaction.wire.tx_result.gas_used, + timestamp: transaction.timestamp, + events: transaction.events, + }, + }, + }; +} + +/** Builds block metadata and full-block responses from one fixture. */ +export function blockFixture({ + hash, + parentHash, + height, + transactionCount = 0, + size = 806, + timestamp = "2026-06-09T12:00:00Z", +}: { + hash: Hex; + parentHash: Hex; + height: number | bigint | string; + transactionCount?: number; + size?: number | bigint | string; + timestamp?: string; +}): BlockFixture { + const normalizedHeight = String(height); + return { + hash, + parentHash, + height: normalizedHeight, + timestamp, + transactionCount, + size: String(size), + header: { + chain_id: "91273002", + height: normalizedHeight, + time: timestamp, + last_block_id: { hash: parentHash.slice(2).toUpperCase() }, + data_hash: "55".repeat(32), + proposer_address: "ABCDEF", + }, + }; +} + +/** Installs a fetch mock that models the ShinzoHub query endpoints. */ +export function mockShinzoHubApi({ + transactions = [], + transactionDetails = [], + blocks = [], + latestBlockHeight, + rpcErrors = {}, +}: MockShinzoHubApiOptions = {}) { + let requestCount = 0; + const resolvedLatestBlockHeight = String( + latestBlockHeight ?? + blocks.reduce( + (latest, block) => + BigInt(block.height) > latest ? BigInt(block.height) : latest, + 0n, + ), + ); + const detailsByHash = new Map( + transactionDetails.map(({ hash, response }) => [ + hash.slice(2).toUpperCase(), + response, + ]), + ); + + globalThis.fetch = vi.fn(async (input, init) => { + requestCount += 1; + const url = String(input); + if (url.includes("/cosmos/tx/v1beta1/txs/")) { + const hash = url.split("/").at(-1) ?? ""; + const details = detailsByHash.get(hash); + return details + ? Response.json(details) + : Response.json({ message: "not found" }, { status: 404 }); + } + + const request = JSON.parse(String(init?.body)) as RpcRequest; + return Response.json(handleRpc(request)); + }) as typeof fetch; + + function handleRpc(request: RpcRequest) { + const error = rpcErrors[request.method]; + if (error) { + return { jsonrpc: "2.0", id: request.id ?? 1, error }; + } + + return { + jsonrpc: "2.0", + id: request.id ?? 1, + result: rpcResult(request), + }; + } + + function rpcResult(request: RpcRequest): Record { + switch (request.method) { + case "tx_search": + return searchTransactions(request.params); + case "header": { + const transaction = transactions.find( + ({ height }) => height === request.params.height, + ); + return { + header: { + time: transaction?.timestamp ?? "2026-06-09T12:00:00Z", + }, + }; + } + case "status": + return { + sync_info: { + latest_block_height: resolvedLatestBlockHeight, + }, + }; + case "blockchain": { + const min = BigInt(request.params.minHeight); + const max = BigInt(request.params.maxHeight); + return { + last_height: resolvedLatestBlockHeight, + block_metas: blocks + .filter(({ height }) => { + const value = BigInt(height); + return value >= min && value <= max; + }) + .map((block) => ({ + block_id: { hash: block.hash.slice(2).toUpperCase() }, + block_size: block.size, + header: block.header, + num_txs: String(block.transactionCount), + })), + }; + } + case "block": + case "block_by_hash": { + const block = findBlock(request); + if (!block) { + throw new Error(`No block fixture matched ${request.method}.`); + } + return { + block_id: { hash: block.hash.slice(2).toUpperCase() }, + block: { + header: block.header, + data: { + txs: Array.from( + { length: block.transactionCount }, + (_, index) => `tx-${index}`, + ), + }, + }, + }; + } + default: + throw new Error(`No mock RPC result configured for ${request.method}.`); + } + } + + function searchTransactions(params: Record) { + const query = params.query; + let matches = [...transactions]; + const exactEvmHash = query.match( + /ethereum_tx\.ethereumTxHash = '([^']+)'/, + )?.[1]; + const requestedHeight = query.match(/tx\.height = (\d+)/)?.[1]; + + if (exactEvmHash) { + matches = matches.filter( + (transaction) => evmHashOf(transaction) === exactEvmHash, + ); + } else if (query.includes("ethereum_tx.ethereumTxHash EXISTS")) { + matches = matches.filter((transaction) => evmHashOf(transaction)); + } + if (requestedHeight) { + matches = matches.filter(({ height }) => height === requestedHeight); + } + if (params.order_by === "asc") { + matches.reverse(); + } + + const page = Number(params.page); + const limit = Number(params.per_page); + const start = (page - 1) * limit; + return { + txs: matches.slice(start, start + limit).map(({ wire }) => wire), + total_count: String(matches.length), + }; + } + + function findBlock(request: RpcRequest): BlockFixture | undefined { + if (request.method === "block_by_hash") { + return blocks.find( + ({ hash }) => + hash.slice(2).toUpperCase() === request.params.hash.toUpperCase(), + ); + } + const height = request.params.height ?? resolvedLatestBlockHeight; + return blocks.find((block) => block.height === height); + } + + return { + expectRequestCount(expected: number) { + expect(requestCount).toBe(expected); + }, + }; +} + +/** Restores fetch and clears mocks after a ShinzoHub query test. */ +export function restoreShinzoHubApiMock(): void { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +} + +function evmHashOf(transaction: TransactionFixture): string | undefined { + return transaction.events + .find(({ type }) => type === "ethereum_tx") + ?.attributes.find(({ key }) => key === "ethereumTxHash")?.value; +} diff --git a/packages/shinzohub/src/public-api.test.ts b/packages/shinzohub/src/public-api.test.ts index 37fd31f9..73e59929 100644 --- a/packages/shinzohub/src/public-api.test.ts +++ b/packages/shinzohub/src/public-api.test.ts @@ -1,15 +1,23 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; -import * as shinzohub from "./index.js"; +import * as shinzohub from "./index"; const expectedRootExports = [ "countViews", "createShinzoHubClient", "createView", + "findTransactionByEvmHash", + "getBlock", + "getBlockTimestamp", "getCreatedViewAddress", + "getLatestBlock", + "getLatestBlockHeight", + "getTransaction", "getView", "hexToShinzoAddress", "listViews", + "listBlocks", + "listTransactions", "normalizeHexAddress", "normalizeShinzoAddress", "shinzoAddressToHex", @@ -25,11 +33,11 @@ const expectedRootExports = [ ] as const; describe("public API", () => { - it("keeps the root export surface intentionally small", () => { + it("exports the supported SDK capabilities from the package root", () => { expect(Object.keys(shinzohub).sort()).toEqual([...expectedRootExports].sort()); }); - it("does not expose internal or legacy package subpaths", async () => { + it("limits subpath imports to supported feature modules", async () => { const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ) as { @@ -39,8 +47,10 @@ describe("public API", () => { expect(Object.keys(packageJson.exports).sort()).toEqual([ ".", "./addresses", + "./blocks", "./chains", "./package.json", + "./transactions", "./views", ]); }); diff --git a/packages/shinzohub/src/transactions/find-transaction-by-evm-hash.ts b/packages/shinzohub/src/transactions/find-transaction-by-evm-hash.ts new file mode 100644 index 00000000..ef06b40c --- /dev/null +++ b/packages/shinzohub/src/transactions/find-transaction-by-evm-hash.ts @@ -0,0 +1,34 @@ +import { getFetch } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import { normalizeHex } from "../internal/hex"; +import { + searchTransactions, + toTransactionSummary, +} from "./internal"; +import type { + FindTransactionByEvmHashParameters, + ShinzoHubTransactionSummary, +} from "./types"; + +/** Finds the canonical transaction summary associated with an EVM hash. */ +export async function findTransactionByEvmHash( + client: ShinzoHubQueryClient, + parameters: FindTransactionByEvmHashParameters, +): Promise { + const hash = normalizeHex(parameters.hash, "hash", 32); + const response = await searchTransactions( + getFetch(), + getRpcEndpoint(client, "cometRpc", parameters.cometRpcUrl), + { + query: `ethereum_tx.ethereumTxHash = '${hash}'`, + page: 1, + limit: 1, + order: "desc", + }, + ); + const transaction = response.txs?.[0]; + return transaction ? toTransactionSummary(transaction) : null; +} diff --git a/packages/shinzohub/src/transactions/get-transaction.ts b/packages/shinzohub/src/transactions/get-transaction.ts new file mode 100644 index 00000000..55d31258 --- /dev/null +++ b/packages/shinzohub/src/transactions/get-transaction.ts @@ -0,0 +1,117 @@ +import { getFetch } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import { buildUrl, requestJson } from "../internal/fetch"; +import { normalizeHex, stripHexPrefix } from "../internal/hex"; +import { + eventSummary, + toEvents, + type CometEventWire, +} from "./internal"; +import type { + GetTransactionParameters, + ShinzoHubCoin, + ShinzoHubTransaction, +} from "./types"; + +interface RestMessageWire extends Record { + "@type"?: string; +} + +interface RestTransactionWire { + tx?: { + body?: { + messages?: RestMessageWire[]; + memo?: string; + }; + auth_info?: { + fee?: { + amount?: ShinzoHubCoin[]; + gas_limit?: string; + payer?: string; + granter?: string; + }; + }; + signatures?: string[]; + }; + tx_response?: { + height?: string; + txhash?: string; + codespace?: string; + code?: number; + raw_log?: string; + gas_wanted?: string; + gas_used?: string; + timestamp?: string; + events?: CometEventWire[]; + }; +} + +/** Loads decoded transaction details by canonical Cosmos hash. */ +export async function getTransaction( + client: ShinzoHubQueryClient, + parameters: GetTransactionParameters, +): Promise { + const hash = normalizeHex(parameters.hash, "hash", 32); + const fetchFn = getFetch(); + const cosmosRestUrl = getRpcEndpoint( + client, + "cosmosRest", + parameters.cosmosRestUrl, + ); + + const response = await requestJson( + fetchFn, + buildUrl( + cosmosRestUrl, + `/cosmos/tx/v1beta1/txs/${stripHexPrefix(hash).toUpperCase()}`, + ), + ); + if (!response.tx_response) { + throw new Error( + "ShinzoHub transaction response did not include tx_response.", + ); + } + + const events = toEvents(response.tx_response.events); + const common = eventSummary(events); + const messages = (response.tx?.body?.messages ?? []).map((message) => { + const { "@type": typeUrl = "", ...value } = message; + return { typeUrl, value }; + }); + const fee = response.tx?.auth_info?.fee; + + return { + cosmosHash: normalizeHex( + response.tx_response.txhash ?? hash, + "txhash", + 32, + ), + evmHash: common.evmHash, + kind: common.evmHash ? "evm" : "cosmos", + height: BigInt(response.tx_response.height ?? 0), + index: common.transactionIndex, + timestamp: response.tx_response.timestamp ?? null, + success: (response.tx_response.code ?? 0) === 0, + code: response.tx_response.code ?? 0, + codespace: response.tx_response.codespace ?? "", + gasWanted: BigInt(response.tx_response.gas_wanted ?? 0), + gasUsed: BigInt(response.tx_response.gas_used ?? 0), + actions: common.actions, + senders: common.senders, + recipients: common.recipients, + transfers: common.transfers, + fee: common.fee, + events, + memo: response.tx?.body?.memo ?? "", + messages, + feeCoins: fee?.amount ?? [], + feePayer: fee?.payer ?? "", + feeGranter: fee?.granter ?? "", + gasLimit: BigInt(fee?.gas_limit ?? 0), + signatures: response.tx?.signatures ?? [], + rawLog: response.tx_response.raw_log ?? "", + }; +} diff --git a/packages/shinzohub/src/transactions/index.test.ts b/packages/shinzohub/src/transactions/index.test.ts new file mode 100644 index 00000000..6408d4e1 --- /dev/null +++ b/packages/shinzohub/src/transactions/index.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + hashFixture, + mockShinzoHubApi, + restoreShinzoHubApiMock, + shinzoHubTestClient, + transactionDetailsFixture, + transactionFixture, +} from "../internal/test-utils"; +import { + findTransactionByEvmHash, + getTransaction, + listTransactions, +} from "./index"; + +const cosmosHashA = hashFixture("11"); +const cosmosHashB = hashFixture("22"); +const evmHash = hashFixture("aa"); + +afterEach(restoreShinzoHubApiMock); + +describe("transaction queries", () => { + it("supports combined feeds, EVM feeds, and block-scoped activity", async () => { + const api = mockShinzoHubApi({ + transactions: [ + transactionFixture({ hash: cosmosHashA, height: 30 }), + transactionFixture({ + hash: cosmosHashB, + height: 29, + kind: "evm", + evmHash, + }), + ], + }); + + const all = await listTransactions(shinzoHubTestClient, { + kind: "all", + limit: 1, + }); + const evm = await listTransactions(shinzoHubTestClient, { kind: "evm" }); + const byBlock = await listTransactions(shinzoHubTestClient, { + blockHeight: 30, + }); + + expect(all).toMatchObject({ + total: 2n, + transactions: [{ cosmosHash: cosmosHashA }], + }); + expect(evm).toMatchObject({ + total: 1n, + transactions: [{ cosmosHash: cosmosHashB, evmHash, kind: "evm" }], + }); + expect(byBlock).toMatchObject({ + total: 1n, + transactions: [{ cosmosHash: cosmosHashA, height: 30n }], + }); + api.expectRequestCount(3); + }); + + it("presents native and EVM activity in one normalized model", async () => { + const api = mockShinzoHubApi({ + transactions: [ + transactionFixture({ hash: cosmosHashA, height: 30 }), + transactionFixture({ + hash: cosmosHashB, + height: 29, + kind: "evm", + evmHash, + code: 9, + }), + ], + }); + + const result = await listTransactions(shinzoHubTestClient, { limit: 2 }); + + expect(result.transactions[0]).toMatchObject({ + cosmosHash: cosmosHashA, + kind: "cosmos", + success: true, + actions: ["/cosmos.bank.v1beta1.MsgSend"], + senders: ["shinzo1sender"], + recipients: ["shinzo1recipient"], + transfers: [ + { + sender: "shinzo1sender", + recipient: "shinzo1recipient", + amount: "42ushinzo", + }, + ], + fee: "7ushinzo", + }); + expect(result.transactions[1]).toMatchObject({ + evmHash, + kind: "evm", + success: false, + code: 9, + }); + api.expectRequestCount(1); + }); + + it("does not present fee collection as user transfer activity", async () => { + const api = mockShinzoHubApi({ + transactions: [ + transactionFixture({ + hash: cosmosHashA, + height: 30, + transferAmount: null, + }), + ], + }); + + const result = await listTransactions(shinzoHubTestClient); + + expect(result.transactions[0]).toMatchObject({ + senders: ["shinzo1sender"], + recipients: [], + transfers: [], + fee: "7ushinzo", + }); + api.expectRequestCount(1); + }); + + it("resolves legacy EVM links to canonical Cosmos transaction details", async () => { + const transaction = transactionFixture({ + hash: cosmosHashA, + height: 30, + kind: "evm", + evmHash, + }); + const api = mockShinzoHubApi({ + transactions: [transaction], + transactionDetails: [ + transactionDetailsFixture(transaction, { memo: "hello" }), + ], + }); + + const resolved = await findTransactionByEvmHash(shinzoHubTestClient, { + hash: evmHash, + }); + const details = await getTransaction(shinzoHubTestClient, { + hash: resolved?.cosmosHash ?? "", + }); + + expect(resolved).toMatchObject({ cosmosHash: cosmosHashA, evmHash }); + expect(details).toMatchObject({ + cosmosHash: cosmosHashA, + evmHash, + timestamp: "2026-06-09T12:00:00Z", + memo: "hello", + messages: [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend" }], + feeCoins: [{ denom: "ushinzo", amount: "7" }], + }); + api.expectRequestCount(2); + }); + + it("preserves actionable node errors when transaction search fails", async () => { + const api = mockShinzoHubApi({ + rpcErrors: { + tx_search: { + code: -32603, + message: "Internal error", + data: "transaction indexing is disabled", + }, + }, + }); + + await expect(listTransactions(shinzoHubTestClient)).rejects.toThrow( + "ShinzoHub tx_search RPC failed: Internal error", + ); + api.expectRequestCount(1); + }); +}); diff --git a/packages/shinzohub/src/transactions/index.ts b/packages/shinzohub/src/transactions/index.ts new file mode 100644 index 00000000..2c247630 --- /dev/null +++ b/packages/shinzohub/src/transactions/index.ts @@ -0,0 +1,19 @@ +export { findTransactionByEvmHash } from "./find-transaction-by-evm-hash"; +export { getTransaction } from "./get-transaction"; +export { listTransactions } from "./list-transactions"; +export type { + FindTransactionByEvmHashParameters, + GetTransactionParameters, + ListTransactionsParameters, + ListTransactionsResult, + ShinzoHubCoin, + ShinzoHubEvent, + ShinzoHubEventAttribute, + ShinzoHubMessage, + ShinzoHubTransaction, + ShinzoHubTransactionFilter, + ShinzoHubTransactionKind, + ShinzoHubTransactionOrder, + ShinzoHubTransactionSummary, + ShinzoHubTransfer, +} from "./types"; diff --git a/packages/shinzohub/src/transactions/internal.ts b/packages/shinzohub/src/transactions/internal.ts new file mode 100644 index 00000000..64c9513d --- /dev/null +++ b/packages/shinzohub/src/transactions/internal.ts @@ -0,0 +1,183 @@ +import type { Hex } from "viem"; +import { requestCometRpc } from "../internal/comet"; +import { normalizeHex } from "../internal/hex"; +import type { + ShinzoHubEvent, + ShinzoHubTransactionOrder, + ShinzoHubTransactionSummary, + ShinzoHubTransfer, +} from "./types"; + +interface CometAttributeWire { + key?: string; + value?: string; + index?: boolean; +} + +/** Comet RPC event response before normalization. */ +export interface CometEventWire { + type?: string; + attributes?: CometAttributeWire[]; +} + +/** Comet RPC transaction response before normalization. */ +export interface CometTxWire { + hash?: string; + height?: string; + index?: number; + tx_result?: { + code?: number; + codespace?: string; + gas_wanted?: string; + gas_used?: string; + events?: CometEventWire[]; + }; +} + +/** Comet RPC transaction-search result before normalization. */ +export interface TxSearchWire { + txs?: CometTxWire[]; + total_count?: string; +} + +/** Executes an indexed Comet transaction search. */ +export async function searchTransactions( + fetchFn: typeof globalThis.fetch, + cometRpcUrl: string, + parameters: { + query: string; + page: number; + limit: number; + order: ShinzoHubTransactionOrder; + }, +): Promise { + return requestCometRpc( + fetchFn, + cometRpcUrl, + "tx_search", + { + query: parameters.query, + page: String(parameters.page), + per_page: String(parameters.limit), + order_by: parameters.order, + }, + ); +} + +/** Normalizes a Comet transaction and its events for consumers. */ +export function toTransactionSummary( + tx: CometTxWire, +): ShinzoHubTransactionSummary { + if (!tx.hash) { + throw new Error("ShinzoHub transaction response is missing hash."); + } + const events = toEvents(tx.tx_result?.events); + const summary = eventSummary(events); + return { + cosmosHash: normalizeHex(tx.hash, "transaction hash", 32), + evmHash: summary.evmHash, + kind: summary.evmHash ? "evm" : "cosmos", + height: BigInt(tx.height ?? 0), + index: tx.index ?? summary.transactionIndex, + success: (tx.tx_result?.code ?? 0) === 0, + code: tx.tx_result?.code ?? 0, + codespace: tx.tx_result?.codespace ?? "", + gasWanted: BigInt(tx.tx_result?.gas_wanted ?? 0), + gasUsed: BigInt(tx.tx_result?.gas_used ?? 0), + actions: summary.actions, + senders: summary.senders, + recipients: summary.recipients, + transfers: summary.transfers, + fee: summary.fee, + events, + }; +} + +/** Normalizes optional Comet events into stable event objects. */ +export function toEvents( + wire: CometEventWire[] | undefined, +): ShinzoHubEvent[] { + return (wire ?? []).map((event) => ({ + type: event.type ?? "", + attributes: (event.attributes ?? []).map((attribute) => ({ + key: attribute.key ?? "", + value: attribute.value ?? "", + index: attribute.index ?? false, + })), + })); +} + +/** Derives common explorer fields from normalized transaction events. */ +export function eventSummary(events: readonly ShinzoHubEvent[]) { + const actions: string[] = []; + const senders: string[] = []; + const recipients: string[] = []; + const transfers: ShinzoHubTransfer[] = []; + let evmHash: Hex | null = null; + let fee: string | null = null; + let transactionIndex = 0; + + for (const event of events) { + const attributes = new Map( + event.attributes.map((attribute) => [attribute.key, attribute.value]), + ); + if (event.type === "message") { + addUnique(actions, attributes.get("action")); + addUnique(senders, attributes.get("sender")); + } + if (event.type === "transfer" && attributes.has("msg_index")) { + const sender = attributes.get("sender") ?? null; + const recipient = attributes.get("recipient") ?? null; + const amount = attributes.get("amount") ?? null; + transfers.push({ sender, recipient, amount }); + addUnique(senders, sender); + addUnique(recipients, recipient); + } + if (event.type === "tx") { + fee ??= attributes.get("fee") ?? null; + } + if (event.type === "ethereum_tx") { + const value = attributes.get("ethereumTxHash"); + if (value) { + evmHash = normalizeHex(value, "ethereumTxHash", 32); + } + addUnique(recipients, attributes.get("recipient")); + const parsedIndex = Number(attributes.get("txIndex")); + if (Number.isInteger(parsedIndex)) { + transactionIndex = parsedIndex; + } + } + } + + return { + actions, + senders, + recipients, + transfers, + evmHash, + fee, + transactionIndex, + }; +} + +/** Validates positive integer pagination values and applies a default. */ +export function positiveInteger( + value: number | undefined, + fallback: number, + name: string, +): number { + const result = value ?? fallback; + if (!Number.isInteger(result) || result < 1) { + throw new Error(`${name} must be a positive integer.`); + } + return result; +} + +function addUnique( + values: string[], + value: string | null | undefined, +): void { + if (value && !values.includes(value)) { + values.push(value); + } +} diff --git a/packages/shinzohub/src/transactions/list-transactions.ts b/packages/shinzohub/src/transactions/list-transactions.ts new file mode 100644 index 00000000..95832cdc --- /dev/null +++ b/packages/shinzohub/src/transactions/list-transactions.ts @@ -0,0 +1,66 @@ +import { getFetch } from "../internal/comet"; +import { + getRpcEndpoint, + type ShinzoHubQueryClient, +} from "../internal/endpoints"; +import { + positiveInteger, + searchTransactions, + toTransactionSummary, +} from "./internal"; +import type { + ListTransactionsParameters, + ListTransactionsResult, +} from "./types"; + +const MAX_LIMIT = 100; + +/** Lists indexed native and EVM transactions with optional filters. */ +export async function listTransactions( + client: ShinzoHubQueryClient, + parameters: ListTransactionsParameters = {}, +): Promise { + const page = positiveInteger(parameters.page, 1, "page"); + const limit = Math.min( + MAX_LIMIT, + positiveInteger(parameters.limit, 20, "limit"), + ); + const order = parameters.order ?? "desc"; + const kind = parameters.kind ?? "all"; + const baseQuery = transactionQuery(parameters.blockHeight); + const query = + kind === "evm" + ? `${baseQuery} AND ethereum_tx.ethereumTxHash EXISTS` + : baseQuery; + const cometRpcUrl = getRpcEndpoint( + client, + "cometRpc", + parameters.cometRpcUrl, + ); + const fetchFn = getFetch(); + + const response = await searchTransactions(fetchFn, cometRpcUrl, { + query, + page, + limit, + order, + }); + + return { + transactions: (response.txs ?? []).map(toTransactionSummary), + total: BigInt(response.total_count ?? 0), + }; +} + +function transactionQuery( + blockHeight: ListTransactionsParameters["blockHeight"], +): string { + if (blockHeight === undefined) { + return "tx.height > 0"; + } + const height = BigInt(blockHeight); + if (height < 1n) { + throw new Error("blockHeight must be greater than zero."); + } + return `tx.height = ${height}`; +} diff --git a/packages/shinzohub/src/transactions/types.ts b/packages/shinzohub/src/transactions/types.ts new file mode 100644 index 00000000..6e67d1f4 --- /dev/null +++ b/packages/shinzohub/src/transactions/types.ts @@ -0,0 +1,98 @@ +import type { Hex } from "viem"; + +export type ShinzoHubTransactionKind = "cosmos" | "evm"; +export type ShinzoHubTransactionFilter = "all" | "evm"; +export type ShinzoHubTransactionOrder = "asc" | "desc"; + +/** One indexed attribute attached to a transaction event. */ +export interface ShinzoHubEventAttribute { + key: string; + value: string; + index: boolean; +} + +/** A normalized event emitted while processing a transaction. */ +export interface ShinzoHubEvent { + type: string; + attributes: readonly ShinzoHubEventAttribute[]; +} + +/** A Cosmos SDK coin amount and denomination. */ +export interface ShinzoHubCoin { + denom: string; + amount: string; +} + +/** A value transfer inferred from transaction events. */ +export interface ShinzoHubTransfer { + sender: string | null; + recipient: string | null; + amount: string | null; +} + +/** Explorer-friendly transaction data available from Comet RPC search. */ +export interface ShinzoHubTransactionSummary { + cosmosHash: Hex; + evmHash: Hex | null; + kind: ShinzoHubTransactionKind; + height: bigint; + index: number; + success: boolean; + code: number; + codespace: string; + gasWanted: bigint; + gasUsed: bigint; + actions: readonly string[]; + senders: readonly string[]; + recipients: readonly string[]; + transfers: readonly ShinzoHubTransfer[]; + fee: string | null; + events: readonly ShinzoHubEvent[]; +} + +/** A decoded Cosmos SDK transaction message. */ +export interface ShinzoHubMessage { + typeUrl: string; + value: Readonly>; +} + +/** Full decoded transaction details returned by Cosmos REST. */ +export interface ShinzoHubTransaction extends ShinzoHubTransactionSummary { + timestamp: string | null; + memo: string; + messages: readonly ShinzoHubMessage[]; + feeCoins: readonly ShinzoHubCoin[]; + feePayer: string; + feeGranter: string; + gasLimit: bigint; + signatures: readonly string[]; + rawLog: string; +} + +/** Search and pagination options for listing indexed transactions. */ +export interface ListTransactionsParameters { + page?: number; + limit?: number; + order?: ShinzoHubTransactionOrder; + kind?: ShinzoHubTransactionFilter; + blockHeight?: number | bigint | string; + cometRpcUrl?: string; +} + +/** A page of normalized transactions and its indexed total. */ +export interface ListTransactionsResult { + transactions: readonly ShinzoHubTransactionSummary[]; + total: bigint; +} + +/** Options for loading a transaction by its canonical Cosmos hash. */ +export interface GetTransactionParameters { + hash: string; + cosmosRestUrl?: string; +} + +/** Options for resolving an EVM hash to its canonical transaction. */ +export interface FindTransactionByEvmHashParameters { + hash: string; + cometRpcUrl?: string; +} diff --git a/packages/shinzohub/src/views/index.test.ts b/packages/shinzohub/src/views/index.test.ts index 70ccb24a..6956567e 100644 --- a/packages/shinzohub/src/views/index.test.ts +++ b/packages/shinzohub/src/views/index.test.ts @@ -8,8 +8,8 @@ import { type Hex, type TransactionReceipt, } from "viem"; -import { shinzoHubDevelop } from "../chains/index.js"; -import { createShinzoHubClient } from "../index.js"; +import { shinzoHubDevelop } from "../chains/index"; +import { createShinzoHubClient } from "../index"; import { countViews, createView, @@ -18,7 +18,7 @@ import { listViews, viewRegistryAbi, viewRegistryAddress, -} from "./index.js"; +} from "./index"; const viewAddress = "0x018a06D78E0802dB5bC055B4527d7B481c3e9932" as const satisfies Hex; const creatorAddress = "0x1234567890AbcdEF1234567890aBcdef12345678" as const satisfies Hex; @@ -45,7 +45,7 @@ function parseRequest(init?: RequestInit): JsonRpcRequest { } describe("createView", () => { - it("sends register(bytes) when no pricing contract is provided", async () => { + it("registers a view with default pricing", async () => { const requests: JsonRpcRequest[] = []; const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const request = parseRequest(init); @@ -71,7 +71,7 @@ describe("createView", () => { expect(transaction.data.startsWith("0x82fbdc9c")).toBe(true); }); - it("sends registerWithPricing(bytes,address) when pricing is provided", async () => { + it("registers a view with a custom pricing contract", async () => { const requests: JsonRpcRequest[] = []; const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const request = parseRequest(init); @@ -105,7 +105,7 @@ describe("createView", () => { }); describe("getCreatedViewAddress", () => { - it("decodes the ViewCreated address from a transaction receipt", () => { + it("reads the new view address from a confirmed registration", () => { const topics = encodeEventTopics({ abi: viewRegistryAbi, eventName: "ViewCreated", @@ -127,7 +127,7 @@ describe("getCreatedViewAddress", () => { expect(getCreatedViewAddress(receipt)).toBe(viewAddress); }); - it("throws when the receipt has no ViewCreated log", () => { + it("fails clearly when a receipt did not create a view", () => { const receipt = { logs: [ { @@ -156,7 +156,7 @@ describe("Cosmos REST view actions", () => { transport: http(), }); - it("lists views with flat pagination parameters", async () => { + it("lists registered views with pagination options", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { expect(String(input)).toBe( "https://rest.example/shinzonetwork/view/v1/views?pagination.limit=1&include_metadata=true", @@ -205,7 +205,7 @@ describe("Cosmos REST view actions", () => { }); }); - it("fetches one view and counts views", async () => { + it("loads view details and the registry total", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { if (String(input).endsWith("/view_count")) { return Response.json({ count: "42" }); @@ -234,7 +234,7 @@ describe("Cosmos REST view actions", () => { await expect(countViews(viemClient)).resolves.toBe(42n); }); - it("supports an explicit Cosmos REST endpoint", async () => { + it("queries a custom Cosmos REST deployment", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { expect(String(input)).toBe("https://override.example/shinzonetwork/view/v1/views"); return Response.json({ views: [], pagination: {} }); diff --git a/packages/shinzohub/src/views/index.ts b/packages/shinzohub/src/views/index.ts index 005ccabc..c3f00490 100644 --- a/packages/shinzohub/src/views/index.ts +++ b/packages/shinzohub/src/views/index.ts @@ -1,9 +1,10 @@ import type { Account, Address, Client, Hex, TransactionReceipt } from "viem"; import { decodeEventLog, encodeFunctionData } from "viem"; import { sendTransaction } from "viem/actions"; -import { normalizeHexAddress, shinzoAddressToHex } from "../addresses/index.js"; -import { buildUrl, requestJson } from "../internal/fetch.js"; -import { bytesLikeToHex, normalizeHex } from "../internal/hex.js"; +import { normalizeHexAddress, shinzoAddressToHex } from "../addresses/index"; +import { buildUrl, requestJson } from "../internal/fetch"; +import { getRpcEndpoint } from "../internal/endpoints"; +import { bytesLikeToHex, normalizeHex } from "../internal/hex"; /** * ShinzoHub ViewRegistry precompile address. @@ -417,7 +418,7 @@ export async function listViews( const response = await requestJson( fetchFn, - buildListViewsUrl(getCosmosRestUrl(client, parameters), parameters), + buildListViewsUrl(getRpcEndpoint(client, "cosmosRest", parameters.cosmosRestUrl), parameters), ); return { @@ -456,7 +457,7 @@ export async function getView(client: Client, parameters: GetViewParameters): Pr const response = await requestJson( fetchFn, - buildGetViewUrl(getCosmosRestUrl(client, parameters), parameters), + buildGetViewUrl(getRpcEndpoint(client, "cosmosRest", parameters.cosmosRestUrl), parameters), ); if (!response.view) { @@ -490,7 +491,10 @@ export async function countViews(client: Client, parameters?: CountViewsParamete const response = await requestJson( fetchFn, - buildUrl(getCosmosRestUrl(client, parameters), "/shinzonetwork/view/v1/view_count"), + buildUrl( + getRpcEndpoint(client, "cosmosRest", parameters?.cosmosRestUrl), + "/shinzonetwork/view/v1/view_count", + ), ); return BigInt(response.count ?? 0); @@ -595,20 +599,6 @@ function toMetadata(wire: ViewMetadataWire): ViewMetadata { }; } -function getCosmosRestUrl(client: Client, parameters?: CountViewsParameters): string { - const url = - parameters?.cosmosRestUrl ?? - (client.chain?.rpcUrls as { cosmosRest?: { http?: readonly string[] } } | undefined)?.cosmosRest?.http?.[0]; - - if (!url) { - throw new Error( - "Cosmos REST URL not found. Ensure the client's chain configuration includes cosmosRest endpoints, or pass cosmosRestUrl explicitly.", - ); - } - - return url; -} - function normalizeAnyAddress(value: string): Hex { const trimmed = value.trim(); if (/^0x/i.test(trimmed) || /^[0-9a-fA-F]{40}$/.test(trimmed)) { diff --git a/packages/shinzohub/tsconfig.build.json b/packages/shinzohub/tsconfig.build.json index 184d3f25..622f24ae 100644 --- a/packages/shinzohub/tsconfig.build.json +++ b/packages/shinzohub/tsconfig.build.json @@ -8,5 +8,5 @@ "rootDir": "src", "sourceMap": true }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/internal/test-utils.ts"] } diff --git a/packages/shinzohub/tsconfig.json b/packages/shinzohub/tsconfig.json index f3d81a39..477e3069 100644 --- a/packages/shinzohub/tsconfig.json +++ b/packages/shinzohub/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0870e2c9..b38afc52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@shinzo/shinzohub': + specifier: workspace:* + version: link:../../packages/shinzohub '@shinzo/ui': specifier: workspace:* version: link:../../packages/ui