From 594c375fb60a8d54c13d8fa782e960d121e994da Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Thu, 20 Feb 2025 10:11:18 -0500 Subject: [PATCH] feat(explorer): cluster testing --- apps/explorer-e2e/playwright.config.ts | 5 +- apps/explorer-e2e/src/fixtures/cluster.ts | 90 +++++++++++++++ apps/explorer-e2e/src/fixtures/constants.ts | 9 -- apps/explorer-e2e/src/fixtures/walletd.ts | 82 ++++++++++++++ apps/explorer-e2e/src/specs/address.spec.ts | 103 +++++++++++++----- .../app/address/[id]/opengraph-image.tsx | 4 +- apps/explorer/app/address/[id]/page.tsx | 8 +- .../app/block/[id]/opengraph-image.tsx | 6 +- apps/explorer/app/block/[id]/page.tsx | 8 +- .../app/contract/[id]/opengraph-image.tsx | 8 +- apps/explorer/app/contract/[id]/page.tsx | 18 +-- apps/explorer/app/fallback.ts | 11 +- .../app/host/[id]/opengraph-image.tsx | 6 +- apps/explorer/app/host/[id]/page.tsx | 4 +- apps/explorer/app/layout.tsx | 9 +- apps/explorer/app/opengraph-image.tsx | 6 +- apps/explorer/app/page.tsx | 22 ++-- apps/explorer/app/tx/[id]/opengraph-image.tsx | 10 +- apps/explorer/app/tx/[id]/page.tsx | 10 +- apps/explorer/components/Contract/index.tsx | 5 +- apps/explorer/components/EntityHeading.tsx | 2 +- apps/explorer/components/Home/index.tsx | 5 +- apps/explorer/components/Host/HostPricing.tsx | 5 +- .../explorer/components/Host/HostSettings.tsx | 5 +- apps/explorer/components/Layout/Search.tsx | 6 +- apps/explorer/config/explored.ts | 7 +- apps/explorer/config/index.ts | 2 +- apps/explorer/config/testnet-zen.ts | 2 +- apps/explorer/hooks/useExplored.ts | 11 ++ apps/explorer/hooks/useExploredAddress.ts | 9 ++ apps/explorer/lib/blocks.ts | 10 +- apps/explorer/lib/explored.ts | 45 ++++++++ apps/explorer/lib/hosts.ts | 6 +- apps/hostd-e2e/playwright.config.ts | 2 +- apps/walletd-e2e/playwright.config.ts | 2 +- libs/clusterd/src/index.ts | 5 +- 36 files changed, 425 insertions(+), 123 deletions(-) create mode 100644 apps/explorer-e2e/src/fixtures/cluster.ts create mode 100644 apps/explorer-e2e/src/fixtures/walletd.ts create mode 100644 apps/explorer/hooks/useExplored.ts create mode 100644 apps/explorer/hooks/useExploredAddress.ts create mode 100644 apps/explorer/lib/explored.ts diff --git a/apps/explorer-e2e/playwright.config.ts b/apps/explorer-e2e/playwright.config.ts index fa62e24c7..3abebc1f9 100644 --- a/apps/explorer-e2e/playwright.config.ts +++ b/apps/explorer-e2e/playwright.config.ts @@ -1,6 +1,5 @@ import { defineConfig, devices } from '@playwright/test' import { nxE2EPreset } from '@nx/playwright/preset' - import { workspaceRoot } from '@nx/devkit' // For CI, you may want to set BASE_URL to the deployed application. @@ -25,8 +24,8 @@ export default defineConfig({ trace: 'on-first-retry', video: 'on-first-retry', }, - // Timeout per test. - timeout: 60_000, + // Timeout per test. The cluster takes up to 30 seconds to start and form contracts. + timeout: 180_000, expect: { // Raise the timeout because it is running against next dev mode // which requires compilation the first to a page is visited. diff --git a/apps/explorer-e2e/src/fixtures/cluster.ts b/apps/explorer-e2e/src/fixtures/cluster.ts new file mode 100644 index 000000000..4b9067847 --- /dev/null +++ b/apps/explorer-e2e/src/fixtures/cluster.ts @@ -0,0 +1,90 @@ +import { Explored } from '@siafoundation/explored-js' +import { Hostd } from '@siafoundation/hostd-js' +import { Bus } from '@siafoundation/renterd-js' +import { Walletd } from '@siafoundation/walletd-js' +import { clusterd, setupCluster } from '@siafoundation/clusterd' +import { BrowserContext } from 'playwright' + +export type Cluster = Awaited> + +export async function startCluster({ + renterdCount = 1, + walletdCount = 1, + hostdCount = 3, + context, +}: { + renterdCount?: number + walletdCount?: number + hostdCount?: number + context: BrowserContext +}) { + await setupCluster({ + exploredCount: 1, + renterdCount, + walletdCount, + hostdCount, + }) + const explored = clusterd.nodes.find((n) => n.type === 'explored') + const renterds = clusterd.nodes.filter((n) => n.type === 'renterd') + const hostds = clusterd.nodes.filter((n) => n.type === 'hostd') + const walletds = clusterd.nodes.filter((n) => n.type === 'walletd') + if ( + explored === undefined || + renterds.length !== renterdCount || + hostds.length !== hostdCount || + walletds.length !== walletdCount + ) { + throw new Error('Failed to start cluster') + } + const daemons = { + explored: { + node: explored, + api: Explored({ + api: `${explored.apiAddress}/api`, + password: explored.password, + }), + }, + renterds: renterds.map((r) => ({ + node: r, + api: Bus({ + api: `${r.apiAddress}/api`, + password: r.password, + }), + })), + hostds: hostds.map((h) => ({ + node: h, + api: Hostd({ + api: `${h.apiAddress}/api`, + password: h.password, + }), + })), + walletds: walletds.map((w) => ({ + node: w, + api: Walletd({ + api: `${w.apiAddress}/api`, + password: w.password, + }), + })), + } + console.log(` + clusterd: http://localhost:${clusterd.managementPort} + explored: ${daemons.explored.node.apiAddress} + renterds: ${daemons.renterds.map((r) => r.node.apiAddress)} + hostds: ${daemons.hostds.map((h) => h.node.apiAddress)} + walletds: ${daemons.walletds.map((w) => w.node.apiAddress)} + `) + // Set the explorerd address cookie so that the explorer app overrides the + // normal zen address with the testnet cluster address. + await context.addCookies([ + { + // This should match `exploredCustomApiCookieName` in apps/explorer/config/explored.ts + name: 'exploredAddress', + value: daemons.explored.node.apiAddress, + url: 'http://localhost:3005', + }, + ]) + return { + clusterd, + daemons, + } +} diff --git a/apps/explorer-e2e/src/fixtures/constants.ts b/apps/explorer-e2e/src/fixtures/constants.ts index 2f4551e03..fe553d382 100644 --- a/apps/explorer-e2e/src/fixtures/constants.ts +++ b/apps/explorer-e2e/src/fixtures/constants.ts @@ -56,15 +56,6 @@ export const TEST_TX_1 = { }, } -export const TEST_ADDRESS_1 = { - id: '68bf48e81536f2221f3809aa9d1c89c1c869a17c6f186a088e49fd2605e4bfaaa24f26e4c42c', - display: { - title: 'Address 68bf48e81536f22...', - transactionNumber: '500 events', - transactionID: '95a2b31155be...', - }, -} - export const TEST_CONTRACT_1 = { id: '25c94822bf7bd86a92d28a148d9d30151949f3599bf93af0df7b4f1e1b3c990d', renewedFromTitle: 'Contract 494d147a8028217...', diff --git a/apps/explorer-e2e/src/fixtures/walletd.ts b/apps/explorer-e2e/src/fixtures/walletd.ts new file mode 100644 index 000000000..154734f7e --- /dev/null +++ b/apps/explorer-e2e/src/fixtures/walletd.ts @@ -0,0 +1,82 @@ +import { Walletd } from '@siafoundation/walletd-js' +import { blake2bHex } from 'blakejs' +import { WalletAddressMetadata } from '@siafoundation/walletd-types' +import { to } from '@siafoundation/request' +import { mine } from '@siafoundation/clusterd' +import { humanSiacoin } from '@siafoundation/units' +import { Cluster } from './cluster' + +export async function addWalletToWalletd(walletd: ReturnType) { + // For some reason when this code runs on GitHub Actions it throws an ESM + // import error for the SDK. Running locally it works fine. + // Error: require() of ES Module sdk/index.esm.js from /fixtures/walletd.ts not supported. + // This dynamic import is a workaround. + const { initSDK, getSDK } = await import('@siafoundation/sdk') + await initSDK() + const sdk = getSDK() + const { phrase: mnemonic } = sdk.wallet.generateSeedPhrase() + const mnemonicHash = blake2bHex(mnemonic) + const [wallet, walletError] = await to( + walletd.walletAdd({ + data: { + name: 'test', + description: 'test', + metadata: { + type: 'seed', + mnemonicHash, + }, + }, + }) + ) + if (!wallet || walletError) { + throw new Error(`Failed to add wallet: ${walletError}`) + } + const kp = sdk.wallet.keyPairFromSeedPhrase(mnemonic, 0) + const suh = sdk.wallet.standardUnlockHash(kp.publicKey) + const uc = sdk.wallet.standardUnlockConditions(kp.publicKey) + const metadata: WalletAddressMetadata = { + index: 0, + } + const [, addressError] = await to( + walletd.walletAddressAdd({ + params: { + id: wallet.id, + }, + data: { + address: suh.address, + description: '', + spendPolicy: { + type: 'uc', + policy: uc.unlockConditions, + }, + metadata, + }, + }) + ) + if (addressError) { + throw new Error(`Failed to add address: ${addressError}`) + } + return { wallet, address: suh.address } +} + +// renterd cluster nodes have siacoins we can use to fund other wallets. +export async function sendSiacoinFromRenterd( + renterd: Cluster['daemons']['renterds'][number], + address: string, + amount: string +) { + console.log(`Sending ${humanSiacoin(amount)} from renterd to:`, address) + try { + // Send some funds to the wallet. + await renterd.api.walletSend({ + data: { + address, + amount, + subtractMinerFee: false, + }, + }) + await mine(1) + } catch (e) { + console.log('error sending siacoin', e) + } +} diff --git a/apps/explorer-e2e/src/specs/address.spec.ts b/apps/explorer-e2e/src/specs/address.spec.ts index 8bb676a4d..6842a7400 100644 --- a/apps/explorer-e2e/src/specs/address.spec.ts +++ b/apps/explorer-e2e/src/specs/address.spec.ts @@ -1,53 +1,102 @@ import { test, expect } from '@playwright/test' import { ExplorerApp } from '../fixtures/ExplorerApp' -import { TEST_ADDRESS_1 } from '../fixtures/constants' -import { keys } from '../utils' +import { startCluster } from '../fixtures/cluster' +import { + mine, + renterdWaitForContracts, + teardownCluster, +} from '@siafoundation/clusterd' +import { addWalletToWalletd, sendSiacoinFromRenterd } from '../fixtures/walletd' +import { Cluster } from '../fixtures/cluster' +import { toHastings } from '@siafoundation/units' let explorerApp: ExplorerApp +let cluster: Cluster -test.beforeEach(async ({ page }) => { +test.beforeEach(async ({ page, context }) => { + // Start the cluster which includes an explored node, a renterd node, a + // walletd node, and 3 hostd nodes. + cluster = await startCluster({ context }) + // The renterd node will automatically form contracts with the hostd nodes. + // Wait for the contracts to form so that all related activity is on the + // blockchain and visible to explored. + await renterdWaitForContracts({ + renterdNode: cluster.daemons.renterds[0].node, + hostdCount: cluster.daemons.hostds.length, + }) explorerApp = new ExplorerApp(page) }) +test.afterEach(async () => { + await teardownCluster() +}) + test('address can be searched by id', async ({ page }) => { + const wallet = await cluster.daemons.renterds[0].api.wallet() await explorerApp.goTo('/') - await explorerApp.navigateBySearchBar(TEST_ADDRESS_1.id) - - await expect(page.getByText(TEST_ADDRESS_1.display.title)).toBeVisible() + await explorerApp.navigateBySearchBar(wallet.data.address) + await expect( + page + .getByTestId('entity-heading') + .getByText(`Address ${wallet.data.address.slice(0, 5)}`) + ).toBeVisible() }) test('address can be directly navigated to by id', async ({ page }) => { - await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id) - - await expect(page.getByText(TEST_ADDRESS_1.display.title)).toBeVisible() + const wallet = await cluster.daemons.renterds[0].api.wallet() + await explorerApp.goTo('/address/' + wallet.data.address) + await expect( + page + .getByTestId('entity-heading') + .getByText(`Address ${wallet.data.address.slice(0, 5)}`) + ).toBeVisible() }) test('address displays the intended data', async ({ page }) => { - const displayKeys = keys(TEST_ADDRESS_1.display) - - await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id) - - for (const key of displayKeys) { - const currentProperty = TEST_ADDRESS_1.display[key] - await expect(page.getByText(currentProperty)).toBeVisible() - } -}) - -test('address can navigate to the unspent outputs list', async ({ page }) => { - await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id) + const walletd = cluster.daemons.walletds[0] + const { wallet, address } = await addWalletToWalletd(walletd.api) + await sendSiacoinFromRenterd( + cluster.daemons.renterds[0], + address, + toHastings(1_000_000).toString() + ) + await mine(10) + const events = await walletd.api.walletEvents({ + params: { + id: wallet.id, + limit: 1_000, + offset: 0, + }, + }) + const outputs = await walletd.api.walletOutputsSiacoin({ + params: { + id: wallet.id, + }, + }) + await explorerApp.goTo('/address/' + address) + await expect(page.getByText(`Address ${address.slice(0, 5)}`)).toBeVisible() + await expect(page.getByText(events.data[0].id.slice(0, 5))).toBeVisible() + await expect(page.getByText(`${events.data.length} events`)).toBeVisible() await page.getByRole('tab').getByText('Unspent outputs').click() - await expect(page.getByText('Siacoin output').first()).toBeVisible() + await expect( + page.getByText(outputs.data.outputs[0].id.slice(0, 5)) + ).toBeVisible() }) test('address can navigate through to a transaction', async ({ page }) => { - await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id) + const wallet = await cluster.daemons.renterds[0].api.wallet() + const events = await cluster.daemons.renterds[0].api.walletEvents({ + params: { + limit: 1, + offset: 0, + }, + }) + await explorerApp.goTo('/address/' + wallet.data.address) await page - .locator( - 'a[data-testid="entity-link"][href*="b24a7d623b82206cd3363db0c0d41446f106a59227d86ef081d601315dbd8cca"]' - ) + .locator(`a[data-testid="entity-link"][href*="${events.data[0].id}"]`) .click() - await expect(page.getByText('Transaction b24a7d623b82206...')).toBeVisible() + await expect(page.getByText(events.data[0].id.slice(0, 5))).toBeVisible() }) diff --git a/apps/explorer/app/address/[id]/opengraph-image.tsx b/apps/explorer/app/address/[id]/opengraph-image.tsx index 61218c9b6..44d10406a 100644 --- a/apps/explorer/app/address/[id]/opengraph-image.tsx +++ b/apps/explorer/app/address/[id]/opengraph-image.tsx @@ -2,7 +2,7 @@ import { humanSiacoin, humanSiafund } from '@siafoundation/units' import { getOGImage } from '../../../components/OGImageEntity' import { truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export const revalidate = 0 @@ -18,7 +18,7 @@ export default async function Image({ params }) { const address = params?.id as string const [[balance, balanceError]] = await Promise.all([ - to(explored.addressBalance({ params: { address } })), + to(getExplored().addressBalance({ params: { address } })), ]) if (balanceError) throw balanceError diff --git a/apps/explorer/app/address/[id]/page.tsx b/apps/explorer/app/address/[id]/page.tsx index d6a5ebcf2..0a93f8a60 100644 --- a/apps/explorer/app/address/[id]/page.tsx +++ b/apps/explorer/app/address/[id]/page.tsx @@ -5,7 +5,7 @@ import { buildMetadata } from '../../../lib/utils' import { notFound } from 'next/navigation' import { truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export function generateMetadata({ params }): Metadata { const id = decodeURIComponent((params?.id as string) || '') @@ -29,9 +29,9 @@ export default async function Page({ params }) { [events, eventsError], [unspentOutputs, unspentOutputsError], ] = await Promise.all([ - to(explored.addressBalance({ params: { address } })), - to(explored.addressEvents({ params: { address, limit: 500 } })), - to(explored.addressSiacoinUTXOs({ params: { address } })), + to(getExplored().addressBalance({ params: { address } })), + to(getExplored().addressEvents({ params: { address, limit: 500 } })), + to(getExplored().addressSiacoinUTXOs({ params: { address } })), ]) if (balanceError) throw balanceError diff --git a/apps/explorer/app/block/[id]/opengraph-image.tsx b/apps/explorer/app/block/[id]/opengraph-image.tsx index 016c3f8be..097e40a37 100644 --- a/apps/explorer/app/block/[id]/opengraph-image.tsx +++ b/apps/explorer/app/block/[id]/opengraph-image.tsx @@ -2,7 +2,7 @@ import { humanDate } from '@siafoundation/units' import { getOGImage } from '../../../components/OGImageEntity' import { stripPrefix, truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export const revalidate = 0 @@ -31,7 +31,7 @@ export default async function Image({ params }) { // Grab chainIndex at height. const [chainIndex, chainIndexError] = await to( - explored.consensusTipByHeight({ params: { height: Number(id) } }) + getExplored().consensusTipByHeight({ params: { height: Number(id) } }) ) if (!chainIndex || chainIndexError) { @@ -40,7 +40,7 @@ export default async function Image({ params }) { // Grab block for id at ChainIndex in request above. const [block, blockError] = await to( - explored.blockByID({ params: { id: stripPrefix(chainIndex.id) } }) + getExplored().blockByID({ params: { id: stripPrefix(chainIndex.id) } }) ) if (!block || blockError) { diff --git a/apps/explorer/app/block/[id]/page.tsx b/apps/explorer/app/block/[id]/page.tsx index bcb6b3436..0ad126eca 100644 --- a/apps/explorer/app/block/[id]/page.tsx +++ b/apps/explorer/app/block/[id]/page.tsx @@ -5,7 +5,7 @@ import { Metadata } from 'next' import { buildMetadata } from '../../../lib/utils' import { notFound } from 'next/navigation' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export function generateMetadata({ params }): Metadata { const id = decodeURIComponent((params?.id as string) || '') @@ -39,7 +39,7 @@ export default async function Page({ params }) { if (!isNaN(Number(params?.id))) { // If it is, we need the block ID at that height. const [tipAtHeightInfo, tipAtHeightInfoError] = await to( - explored.consensusTipByHeight({ params: { height: params?.id } }) + getExplored().consensusTipByHeight({ params: { height: params?.id } }) ) if (tipAtHeightInfoError) throw tipAtHeightInfoError if (!tipAtHeightInfo) throw notFound() @@ -54,8 +54,8 @@ export default async function Page({ params }) { // currentTip for next block navigation handling. const [[block, blockError], [currentTipInfo, currentTipInfoError]] = await Promise.all([ - to(explored.blockByID({ params: { id } })), - to(explored.consensusTip()), + to(getExplored().blockByID({ params: { id } })), + to(getExplored().consensusTip()), ]) if (blockError) throw blockError diff --git a/apps/explorer/app/contract/[id]/opengraph-image.tsx b/apps/explorer/app/contract/[id]/opengraph-image.tsx index 6f4fd87d2..18414049e 100644 --- a/apps/explorer/app/contract/[id]/opengraph-image.tsx +++ b/apps/explorer/app/contract/[id]/opengraph-image.tsx @@ -4,7 +4,7 @@ import { truncate } from '@siafoundation/design-system' import { siacoinToFiat } from '../../../lib/currency' import { CurrencyOption, currencyOptions } from '@siafoundation/react-core' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' import { blockHeightToHumanDate } from '../../../lib/time' import { determineContractStatus } from '../../../lib/contracts' import BigNumber from 'bignumber.js' @@ -31,9 +31,9 @@ export default async function Image({ params }) { [currentTip, currentTipError], [rate, rateError], ] = await Promise.all([ - to(explored.contractByID({ params: { id } })), - to(explored.consensusTip()), - to(explored.exchangeRate({ params: { currency: 'usd' } })), + to(getExplored().contractByID({ params: { id } })), + to(getExplored().consensusTip()), + to(getExplored().exchangeRate({ params: { currency: 'usd' } })), ]) if ( diff --git a/apps/explorer/app/contract/[id]/page.tsx b/apps/explorer/app/contract/[id]/page.tsx index 90ca1af1a..798be2de9 100644 --- a/apps/explorer/app/contract/[id]/page.tsx +++ b/apps/explorer/app/contract/[id]/page.tsx @@ -5,7 +5,7 @@ import { buildMetadata } from '../../../lib/utils' import { notFound } from 'next/navigation' import { stripPrefix, truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' import { ChainIndex, ExplorerFileContract } from '@siafoundation/explored-types' export function generateMetadata({ params }): Metadata { @@ -31,9 +31,11 @@ export default async function Page({ params }) { [previousRevisions, previousRevisionsError], [currentTip, currentTipError], ] = await Promise.all([ - to(explored.contractByID({ params: { id } })), - to(explored.contractRevisions({ params: { id } })), - to(explored.consensusTip()), + to(getExplored().contractByID({ params: { id } })), + to( + getExplored().contractRevisions({ params: { id } }) + ), + to(getExplored().consensusTip()), ]) if (contractError) throw contractError @@ -58,14 +60,14 @@ export default async function Page({ params }) { [renewalTransaction, renewalTransactionError], ] = await Promise.all([ to( - explored.transactionByID({ + getExplored().transactionByID({ params: { id: formationTxnID, }, }) ), to( - explored.transactionByID({ + getExplored().transactionByID({ params: { id: finalRevisionTxnID, }, @@ -88,7 +90,7 @@ export default async function Page({ params }) { const [formationTxnChainIndices, formationTxnChainIndicesError] = await to< ChainIndex[] >( - explored.transactionChainIndices({ + getExplored().transactionChainIndices({ params: { id: formationTransaction.id }, }) ) @@ -99,7 +101,7 @@ export default async function Page({ params }) { // Use the first chainIndex from the above call to get our parent block. const [parentBlock, parentBlockError] = await to( - explored.blockByID({ params: { id: formationTxnChainIndices[0].id } }) + getExplored().blockByID({ params: { id: formationTxnChainIndices[0].id } }) ) if (parentBlockError) throw parentBlockError diff --git a/apps/explorer/app/fallback.ts b/apps/explorer/app/fallback.ts index 1fe59cf18..aed787ec2 100644 --- a/apps/explorer/app/fallback.ts +++ b/apps/explorer/app/fallback.ts @@ -1,13 +1,14 @@ -import { explored } from '../config/explored' +import { getExplored } from '../lib/explored' import { unstable_serialize } from 'swr' import { CurrencyID, exchangeRateRoute } from '@siafoundation/explored-types' import { exploredApi } from '../config' +import path from 'path' // Builds fallback data for the exchange rate. Passing this to the SWR // config's fallback prop allows the exchange rate hooks with a matching // key to server-render with an initial exchange rate value. export async function buildFallbackDataExchangeRate(currency: CurrencyID) { - const rate = await explored.exchangeRate({ + const rate = await getExplored().exchangeRate({ params: { currency, }, @@ -17,7 +18,11 @@ export async function buildFallbackDataExchangeRate(currency: CurrencyID) { // ['method', `${api}${route}${params}${JSON.stringify(args.payload)}`] [unstable_serialize([ 'get', - `${exploredApi}${exchangeRateRoute.replace(':currency', currency)}`, + path.join( + exploredApi, + 'api', + exchangeRateRoute.replace(':currency', currency) + ), ])]: rate.data, } } diff --git a/apps/explorer/app/host/[id]/opengraph-image.tsx b/apps/explorer/app/host/[id]/opengraph-image.tsx index a9a4b8b6b..6db89b42b 100644 --- a/apps/explorer/app/host/[id]/opengraph-image.tsx +++ b/apps/explorer/app/host/[id]/opengraph-image.tsx @@ -7,7 +7,7 @@ import { import { truncate } from '@siafoundation/design-system' import { CurrencyOption, currencyOptions } from '@siafoundation/react-core' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export const revalidate = 0 @@ -25,14 +25,14 @@ export default async function Image({ params }) { const id = params?.id as string const [[host, hostError], [rate, rateError]] = await Promise.all([ to( - explored.hostByPubkey({ + getExplored().hostByPubkey({ params: { id, }, }) ), to( - explored.exchangeRate({ + getExplored().exchangeRate({ params: { currency: 'usd', }, diff --git a/apps/explorer/app/host/[id]/page.tsx b/apps/explorer/app/host/[id]/page.tsx index b0c991f1c..ce394f089 100644 --- a/apps/explorer/app/host/[id]/page.tsx +++ b/apps/explorer/app/host/[id]/page.tsx @@ -5,7 +5,7 @@ import { Host } from '../../../components/Host' import { notFound } from 'next/navigation' import { truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export function generateMetadata({ params }): Metadata { const id = decodeURIComponent((params?.id as string) || '') @@ -24,7 +24,7 @@ export const revalidate = 0 export default async function Page({ params }) { const id = params?.id as string const [[host, hostError]] = await Promise.all([ - to(explored.hostByPubkey({ params: { id } })), + to(getExplored().hostByPubkey({ params: { id } })), ]) if (hostError) { diff --git a/apps/explorer/app/layout.tsx b/apps/explorer/app/layout.tsx index 71533b187..4b886e407 100644 --- a/apps/explorer/app/layout.tsx +++ b/apps/explorer/app/layout.tsx @@ -8,6 +8,7 @@ import { cookies } from 'next/headers' import { CurrencyID } from '@siafoundation/explored-types' import { buildFallbackDataDefaultCurrencyId } from '@siafoundation/react-core' import { buildFallbackDataExchangeRate } from './fallback' +import { buildFallbackDataExploredAddress } from '../lib/explored' export const metadata = { title: 'Explorer', @@ -19,7 +20,7 @@ export const metadata = { ), } -function getUserCurrencyPreference() { +function getUserCurrencyPreferenceCookie() { const cookieStore = cookies() const currency = cookieStore.get('currency')?.value as CurrencyID return currency || 'usd' @@ -30,7 +31,7 @@ export default async function RootLayout({ }: { children: React.ReactNode }) { - const currency = getUserCurrencyPreference() + const currency = getUserCurrencyPreferenceCookie() return ( @@ -42,6 +43,10 @@ export default async function RootLayout({ // Pass the currency's initial exchange rate value to the exchange rate // hooks so that they initialize and server-render with the value. ...(await buildFallbackDataExchangeRate(currency)), + // Pass any custom explored address to the client-side. The cookie is + // only allowed in development mode and is used to point the explorer + // to a local cluster. + ...buildFallbackDataExploredAddress(), }} > {children} diff --git a/apps/explorer/app/opengraph-image.tsx b/apps/explorer/app/opengraph-image.tsx index 65fe60e68..9c187d70e 100644 --- a/apps/explorer/app/opengraph-image.tsx +++ b/apps/explorer/app/opengraph-image.tsx @@ -3,7 +3,7 @@ import { network } from '../config' import { humanBytes } from '@siafoundation/units' import { PreviewValue } from '../components/OGImage/Preview' import { to } from '@siafoundation/request' -import { explored } from '../config/explored' +import { getExplored } from '../lib/explored' export const revalidate = 0 @@ -17,8 +17,8 @@ export const contentType = 'image/png' export default async function Image() { const [[tip], [hostMetrics]] = await Promise.all([ - to(explored.consensusTip()), - to(explored.hostMetrics()), + to(getExplored().consensusTip()), + to(getExplored().hostMetrics()), ]) const values: PreviewValue[] = [] diff --git a/apps/explorer/app/page.tsx b/apps/explorer/app/page.tsx index 8f1516b85..8b5fd5a98 100644 --- a/apps/explorer/app/page.tsx +++ b/apps/explorer/app/page.tsx @@ -4,8 +4,8 @@ import { Home } from '../components/Home' import { buildMetadata } from '../lib/utils' import { getLatestBlocks } from '../lib/blocks' import { to } from '@siafoundation/request' -import { explored } from '../config/explored' import { getTopHosts } from '../lib/hosts' +import { getExplored, getExploredAddress } from '../lib/explored' import { unstable_cache } from 'next/cache' export function generateMetadata(): Metadata { @@ -24,16 +24,24 @@ export function generateMetadata(): Metadata { export const revalidate = 0 // Cache our top hosts fetch and ranking. Revalidate every 5 minutes. -const getCachedTopHosts = unstable_cache(getTopHosts, ['top-hosts'], { - tags: ['top-hosts'], - revalidate: 300, -}) +const getCachedTopHosts = unstable_cache( + (exploredAddress: string) => getTopHosts(exploredAddress), + [], + { + tags: ['top-hosts'], + revalidate: 300, + } +) export default async function HomePage() { const [[hostMetrics, hostMetricsError], [blockMetrics, blockMetricsError]] = - await Promise.all([to(explored.hostMetrics()), to(explored.blockMetrics())]) + await Promise.all([ + to(getExplored().hostMetrics()), + to(getExplored().blockMetrics()), + ]) - const selectedTopHosts = await getCachedTopHosts() + const exploredAddress = getExploredAddress() + const selectedTopHosts = await getCachedTopHosts(exploredAddress) const [latestBlocks, latestBlocksError] = await getLatestBlocks() const latestHeight = latestBlocks ? latestBlocks[0].height : 0 diff --git a/apps/explorer/app/tx/[id]/opengraph-image.tsx b/apps/explorer/app/tx/[id]/opengraph-image.tsx index 1b0f40f0a..ba3a409bc 100644 --- a/apps/explorer/app/tx/[id]/opengraph-image.tsx +++ b/apps/explorer/app/tx/[id]/opengraph-image.tsx @@ -2,7 +2,7 @@ import { humanDate } from '@siafoundation/units' import { getOGImage } from '../../../components/OGImageEntity' import { stripPrefix, truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export const revalidate = 0 @@ -23,20 +23,20 @@ export default async function Image({ params }) { [currentTip, currentTipError], ] = await Promise.all([ to( - explored.transactionByID({ + getExplored().transactionByID({ params: { id, }, }) ), to( - explored.transactionChainIndices({ + getExplored().transactionChainIndices({ params: { id, }, }) ), - to(explored.consensusTip()), + to(getExplored().consensusTip()), ]) if ( @@ -60,7 +60,7 @@ export default async function Image({ params }) { // Get the related block const [relatedBlock, relatedBlockError] = await to( - explored.blockByID({ params: { id: transactionChainIndices[0].id } }) + getExplored().blockByID({ params: { id: transactionChainIndices[0].id } }) ) if (!relatedBlock || relatedBlockError) { diff --git a/apps/explorer/app/tx/[id]/page.tsx b/apps/explorer/app/tx/[id]/page.tsx index 3ebe53634..c69c45f57 100644 --- a/apps/explorer/app/tx/[id]/page.tsx +++ b/apps/explorer/app/tx/[id]/page.tsx @@ -5,7 +5,7 @@ import { buildMetadata } from '../../../lib/utils' import { notFound } from 'next/navigation' import { stripPrefix, truncate } from '@siafoundation/design-system' import { to } from '@siafoundation/request' -import { explored } from '../../../config/explored' +import { getExplored } from '../../../lib/explored' export function generateMetadata({ params }): Metadata { const id = decodeURIComponent((params?.id as string) || '') @@ -30,9 +30,9 @@ export default async function Page({ params }) { [transactionChainIndices, transactionChainIndicesError], [currentTip, currentTipError], ] = await Promise.all([ - to(explored.transactionByID({ params: { id } })), - to(explored.transactionChainIndices({ params: { id } })), - to(explored.consensusTip()), + to(getExplored().transactionByID({ params: { id } })), + to(getExplored().transactionChainIndices({ params: { id } })), + to(getExplored().consensusTip()), ]) if (transactionError) throw transactionError @@ -42,7 +42,7 @@ export default async function Page({ params }) { // Use the first chainIndex from the above call to get our parent block. const [parentBlock, parentBlockError] = await to( - explored.blockByID({ params: { id: transactionChainIndices[0].id } }) + getExplored().blockByID({ params: { id: transactionChainIndices[0].id } }) ) if (parentBlockError) throw parentBlockError diff --git a/apps/explorer/components/Contract/index.tsx b/apps/explorer/components/Contract/index.tsx index e57e95451..c0dce1837 100644 --- a/apps/explorer/components/Contract/index.tsx +++ b/apps/explorer/components/Contract/index.tsx @@ -15,10 +15,10 @@ import { } from '@siafoundation/explored-types' import { blockHeightToHumanDate } from '../../lib/time' import { useActiveCurrencySiascanExchangeRate } from '@siafoundation/react-core' -import { exploredApi } from '../../config' import { siacoinToFiat } from '../../lib/currency' import LoadingCurrency from '../LoadingCurrency' import LoadingTimestamp from '../LoadingTimestamp' +import { useExploredAddress } from '../../hooks/useExploredAddress' type Props = { previousRevisions: ExplorerFileContract[] | undefined @@ -37,8 +37,9 @@ export function Contract({ renewedToID, formationTxnChainIndex, }: Props) { + const api = useExploredAddress() const exchange = useActiveCurrencySiascanExchangeRate({ - api: exploredApi, + api, config: { swr: { keepPreviousData: true, diff --git a/apps/explorer/components/EntityHeading.tsx b/apps/explorer/components/EntityHeading.tsx index 262c521ad..159a0025c 100644 --- a/apps/explorer/components/EntityHeading.tsx +++ b/apps/explorer/components/EntityHeading.tsx @@ -21,7 +21,7 @@ type Props = { export function EntityHeading({ label, type, value, href }: Props) { return (
- + {upperFirst(label)}{' '} {type === 'block' && Number(value).toLocaleString()} diff --git a/apps/explorer/components/Home/index.tsx b/apps/explorer/components/Home/index.tsx index d14de11f7..4633a7e2c 100644 --- a/apps/explorer/components/Home/index.tsx +++ b/apps/explorer/components/Home/index.tsx @@ -27,7 +27,7 @@ import { import { Information20 } from '@siafoundation/react-icons' import { useActiveCurrencySiascanExchangeRate } from '@siafoundation/react-core' import LoadingCurrency from '../LoadingCurrency' -import { exploredApi } from '../../config' +import { useExploredAddress } from '../../hooks/useExploredAddress' export function Home({ metrics, @@ -42,8 +42,9 @@ export function Home({ hosts: ExplorerHost[] totalHosts?: number }) { + const api = useExploredAddress() const exchange = useActiveCurrencySiascanExchangeRate({ - api: exploredApi, + api, config: { swr: { keepPreviousData: true, diff --git a/apps/explorer/components/Host/HostPricing.tsx b/apps/explorer/components/Host/HostPricing.tsx index 92b9b1b60..9c7fc16ad 100644 --- a/apps/explorer/components/Host/HostPricing.tsx +++ b/apps/explorer/components/Host/HostPricing.tsx @@ -15,15 +15,16 @@ import { } from '@siafoundation/units' import { useActiveCurrencySiascanExchangeRate } from '@siafoundation/react-core' import LoadingCurrency from '../LoadingCurrency' -import { exploredApi } from '../../config' +import { useExploredAddress } from '../../hooks/useExploredAddress' type Props = { host: ExplorerHost } export function HostPricing({ host }: Props) { + const api = useExploredAddress() const exchange = useActiveCurrencySiascanExchangeRate({ - api: exploredApi, + api, config: { swr: { keepPreviousData: true, diff --git a/apps/explorer/components/Host/HostSettings.tsx b/apps/explorer/components/Host/HostSettings.tsx index deb6979c9..f6ca21873 100644 --- a/apps/explorer/components/Host/HostSettings.tsx +++ b/apps/explorer/components/Host/HostSettings.tsx @@ -17,16 +17,17 @@ import { import { ExplorerHost } from '@siafoundation/explored-types' import { useActiveCurrencySiascanExchangeRate } from '@siafoundation/react-core' import LoadingCurrency from '../LoadingCurrency' -import { exploredApi } from '../../config' import { siacoinToFiat } from '../../lib/currency' +import { useExploredAddress } from '../../hooks/useExploredAddress' type Props = { host: ExplorerHost } export function HostSettings({ host }: Props) { + const api = useExploredAddress() const exchange = useActiveCurrencySiascanExchangeRate({ - api: exploredApi, + api, config: { swr: { keepPreviousData: true, diff --git a/apps/explorer/components/Layout/Search.tsx b/apps/explorer/components/Layout/Search.tsx index 865372c9e..ba5b51f00 100644 --- a/apps/explorer/components/Layout/Search.tsx +++ b/apps/explorer/components/Layout/Search.tsx @@ -12,8 +12,8 @@ import React, { useCallback } from 'react' import { useRouter } from 'next/navigation' import { useForm } from 'react-hook-form' import { routes } from '../../config/routes' -import { explored } from '../../config/explored' import { to } from '@siafoundation/request' +import { useExplored } from '../../hooks/useExplored' const defaultValues = { query: '', @@ -37,6 +37,8 @@ export function Search() { defaultValues, }) + const explored = useExplored() + const onSubmit = useCallback( async (values: { query: string }) => { // Catch possible block number, avoid request, go there. @@ -99,7 +101,7 @@ export function Search() { form.reset() }, - [form, router] + [form, router, explored] ) return ( diff --git a/apps/explorer/config/explored.ts b/apps/explorer/config/explored.ts index 278096c09..708ea78e1 100644 --- a/apps/explorer/config/explored.ts +++ b/apps/explorer/config/explored.ts @@ -1,4 +1,3 @@ -import { Explored } from '@siafoundation/explored-js' -import { exploredApi as api } from '.' - -export const explored = Explored({ api }) +// Allow passing a custom explored address via a cookie for testing purposes. +export const exploredCustomApiCookieName = 'exploredAddress' +export const exploredCustomApiSwrKey = 'exploredAddress' diff --git a/apps/explorer/config/index.ts b/apps/explorer/config/index.ts index f24a0501b..64858fd7b 100644 --- a/apps/explorer/config/index.ts +++ b/apps/explorer/config/index.ts @@ -9,4 +9,4 @@ export const isMainnet = true // APIs export const faucetApi = 'https://api.siascan.com/zen/faucet' -export const exploredApi = 'https://api.beta.siascan.com/api' +export const exploredApi = 'https://api.beta.siascan.com' diff --git a/apps/explorer/config/testnet-zen.ts b/apps/explorer/config/testnet-zen.ts index 97a34f868..8c3e5111b 100644 --- a/apps/explorer/config/testnet-zen.ts +++ b/apps/explorer/config/testnet-zen.ts @@ -9,4 +9,4 @@ export const isMainnet = false // APIs export const faucetApi = 'https://api.siascan.com/zen/faucet' -export const exploredApi = 'https://api.beta.siascan.com/zen/api' +export const exploredApi = 'https://api.beta.siascan.com/zen' diff --git a/apps/explorer/hooks/useExplored.ts b/apps/explorer/hooks/useExplored.ts new file mode 100644 index 000000000..0e17e65f1 --- /dev/null +++ b/apps/explorer/hooks/useExplored.ts @@ -0,0 +1,11 @@ +'use client' + +import { Explored } from '@siafoundation/explored-js' +import { useMemo } from 'react' +import { useExploredAddress } from './useExploredAddress' + +export function useExplored(): ReturnType { + const api = useExploredAddress() + const explored = useMemo(() => Explored({ api }), [api]) + return explored +} diff --git a/apps/explorer/hooks/useExploredAddress.ts b/apps/explorer/hooks/useExploredAddress.ts new file mode 100644 index 000000000..41d89f57a --- /dev/null +++ b/apps/explorer/hooks/useExploredAddress.ts @@ -0,0 +1,9 @@ +'use client' + +import useSWR from 'swr' +import { exploredCustomApiSwrKey } from '../config/explored' + +export function useExploredAddress(): string { + const response = useSWR(exploredCustomApiSwrKey) + return `${response.data}/api` +} diff --git a/apps/explorer/lib/blocks.ts b/apps/explorer/lib/blocks.ts index b29f0b9aa..6e129aa8a 100644 --- a/apps/explorer/lib/blocks.ts +++ b/apps/explorer/lib/blocks.ts @@ -1,11 +1,11 @@ import { to } from '@siafoundation/request' import { ExplorerBlock } from '@siafoundation/explored-types' -import { explored } from '../config/explored' +import { getExplored } from '../lib/explored' export async function getBlockByHeight(height: number) { // Grab the tip at this height. const [tip, tipError] = await to( - explored.consensusTipByHeight({ params: { height } }) + getExplored().consensusTipByHeight({ params: { height } }) ) if (tipError) throw tipError @@ -13,7 +13,7 @@ export async function getBlockByHeight(height: number) { // Grab the block with the ID at this tip height. const [block, blockError] = await to( - explored.blockByID({ params: { id: tip.id } }) + getExplored().blockByID({ params: { id: tip.id } }) ) if (blockError) throw blockError @@ -26,7 +26,7 @@ export async function getLatestBlocks( n = 6 ): Promise<[ExplorerBlock[], undefined] | [undefined, Error]> { // Grab the latest tip. - const [latestTip, latestTipError] = await to(explored.consensusTip()) + const [latestTip, latestTipError] = await to(getExplored().consensusTip()) if (latestTipError) throw latestTipError if (!latestTip) return [undefined, Error('No tip found')] @@ -36,7 +36,7 @@ export async function getLatestBlocks( // Fetch the latest n blocks. for (let i = 1; i <= n; i++) { const [block, blockError] = await to( - explored.blockByID({ params: { id: parentBlockID } }) + getExplored().blockByID({ params: { id: parentBlockID } }) ) if (blockError) throw blockError if (!block) continue diff --git a/apps/explorer/lib/explored.ts b/apps/explorer/lib/explored.ts new file mode 100644 index 000000000..0804b319a --- /dev/null +++ b/apps/explorer/lib/explored.ts @@ -0,0 +1,45 @@ +import { Explored } from '@siafoundation/explored-js' +import { exploredApi } from '../config' +import { cookies } from 'next/headers' +import { + exploredCustomApiCookieName, + exploredCustomApiSwrKey, +} from '../config/explored' + +// Allow passing a custom explored address via a cookie for testing purposes. +function getExploredAddressCookie() { + const cookieStore = cookies() + const customExploredAddress = cookieStore.get( + exploredCustomApiCookieName + )?.value + return customExploredAddress +} + +export function buildFallbackDataExploredAddress() { + if (process.env.NODE_ENV === 'development') { + return { + [exploredCustomApiSwrKey]: getExploredAddressCookie() || exploredApi, + } + } + return { + [exploredCustomApiSwrKey]: exploredApi, + } +} + +export function getExploredAddress() { + if (process.env.NODE_ENV === 'development') { + return getExploredAddressCookie() || exploredApi + } + return exploredApi +} + +export function getExplored(explicitAddress?: string) { + if (explicitAddress) { + return Explored({ api: `${explicitAddress}/api` }) + } + if (process.env.NODE_ENV === 'development') { + const exploredAddress = getExploredAddress() + return Explored({ api: `${exploredAddress}/api` }) + } + return Explored({ api: `${exploredApi}/api` }) +} diff --git a/apps/explorer/lib/hosts.ts b/apps/explorer/lib/hosts.ts index f297e389e..b00b1e4db 100644 --- a/apps/explorer/lib/hosts.ts +++ b/apps/explorer/lib/hosts.ts @@ -1,6 +1,6 @@ import { ExplorerHost } from '@siafoundation/explored-types' import { to } from '@siafoundation/request' -import { explored } from '../config/explored' +import { getExplored } from './explored' const weights = { age: 0.3, @@ -78,9 +78,9 @@ function rankHosts(hosts: ExplorerHost[] | undefined) { .sort((a, b) => b.score - a.score) // Sort descending } -export async function getTopHosts() { +export async function getTopHosts(exploredAddress: string) { const [hosts, hostsError] = await to( - explored.hostsList({ + getExplored(exploredAddress).hostsList({ params: { sortBy: 'date_created', dir: 'asc', limit: 500 }, data: { online: true, acceptContracts: true }, }) diff --git a/apps/hostd-e2e/playwright.config.ts b/apps/hostd-e2e/playwright.config.ts index 93c7a981b..ea7bd9f30 100644 --- a/apps/hostd-e2e/playwright.config.ts +++ b/apps/hostd-e2e/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ trace: 'on-first-retry', video: 'on-first-retry', }, - // Timeout per test. + // Timeout per test. The cluster takes up to 30 seconds to start and form contracts. timeout: 180_000, expect: { // Raise the timeout because it is running against next dev mode diff --git a/apps/walletd-e2e/playwright.config.ts b/apps/walletd-e2e/playwright.config.ts index 81839ca59..00d76d91f 100644 --- a/apps/walletd-e2e/playwright.config.ts +++ b/apps/walletd-e2e/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ trace: 'on-first-retry', video: 'on-first-retry', }, - // Timeout per test. + // Timeout per test. The cluster takes up to 30 seconds to start and form contracts. timeout: 180_000, expect: { // Raise the timeout because it is running against next dev mode diff --git a/libs/clusterd/src/index.ts b/libs/clusterd/src/index.ts index 85c81d8e1..1e50ae8ee 100644 --- a/libs/clusterd/src/index.ts +++ b/libs/clusterd/src/index.ts @@ -100,8 +100,9 @@ export async function setupCluster({ Maybe<{ type: string; apiAddress: string; password: string }[]> >(`http://localhost:${clusterd.managementPort}/nodes`) const runningCount = nodes.data?.length - const totalCount = renterdCount + hostdCount + walletdCount - if (nodes.data?.length === renterdCount + hostdCount + walletdCount) { + const totalCount = + renterdCount + hostdCount + walletdCount + exploredCount + if (nodes.data?.length === totalCount) { clusterd.nodes = nodes.data?.map((n) => { if ('apiAddress' in n) { return {