Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/tangle-cloud/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router';
import { lazy, Suspense, type FC, type ReactNode } from 'react';
import Layout from '../components/Layout';
import ScrollToTop from '../components/ScrollToTop';
import Providers from './providers';
import { PagePath } from '../types';
import { Skeleton } from '@tangle-network/sandbox-ui/primitives';
Expand Down Expand Up @@ -70,6 +71,7 @@ const withLayout = (LayoutCmp: FC<{ children: ReactNode }>, Page: FC) => (
const App: FC = () => {
return (
<Providers>
<ScrollToTop />
<Layout>
<Routes>
<Route
Expand Down
33 changes: 33 additions & 0 deletions apps/tangle-cloud/src/components/ScrollToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

/**
* Reset the window scroll position to (0, 0) on every pathname change.
*
* The default `react-router-dom` `<BrowserRouter>` is path-aware but
* scroll-naive: navigating from a long /blueprints listing to /rewards
* lands the next page already scrolled wherever the previous page was,
* which makes transitions feel broken (the user has to scroll up to
* read the heading of the new page).
*
* Drop this component anywhere inside the router tree — it renders
* `null` and only runs an effect.
*
* NOTE: pathname-only dependency is deliberate. We do NOT scroll on
* search-param or hash changes, because some pages use those for in-page
* tab state and an unsolicited scroll would yank the viewport away.
*/
const ScrollToTop = () => {
const { pathname } = useLocation();

useEffect(() => {
// Use 'instant' (not 'smooth') so the new page is at the top before
// the first paint — smooth-scroll across route transitions looks
// janky because the previous page's content briefly remains visible.
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);

return null;
};

export default ScrollToTop;
2 changes: 1 addition & 1 deletion apps/tangle-cloud/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
Icon: CoinsLineIcon,
},
{
name: 'Payments',
name: 'Private Payments',
href: PagePath.PAYMENTS_POOL,
isInternal: true,
Icon: ShieldKeyholeLineIcon,
Expand Down
13 changes: 12 additions & 1 deletion apps/tangle-cloud/src/pages/rewards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,18 @@ const RewardsPage: FC = () => {
<Skeleton className="h-16 rounded-md" />
</div>
) : rewardHistoryError ? (
<ErrorMessage>Could not load claim history.</ErrorMessage>
// Render an empty-state instead of a red error when the
// indexer is simply absent (e.g. `testnet` env with no
// VITE_ENVIO_TESTNET_ENDPOINT configured). The original
// `<ErrorMessage>` here surfaced an alarming
// "Could not load claim history" banner on every page
// load — that's the UX equivalent of a 500, but the
// actual cause is "no historical data source", which is
// a legitimate empty state.
<EmptyState
title="Claim history unavailable on this network"
description="The indexed history endpoint is not configured for this network. Past reward claims will appear here once the indexer is online."
/>
) : rewardHistory?.length ? (
<RewardClaimsTable
entries={rewardHistory}
Expand Down
119 changes: 119 additions & 0 deletions libs/tangle-shared-ui/src/data/blueprints/fetchBlueprintsOnChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { Address, PublicClient } from 'viem';
import { getContractsByChainId } from '@tangle-network/dapp-config/contracts';
import TANGLE_ABI from '../../abi/tangle';
import type { Blueprint } from '../graphql/useBlueprints';

/**
* Direct-chain fallback for listing blueprints when the Envio GraphQL
* indexer is unavailable (no endpoint configured for the network, or
* the indexer is lagging / down).
*
* Reads `Tangle.blueprintCount()` and then `getBlueprint(id)` /
* `getBlueprintDefinition(id)` for each id in the registry.
*
* O(N) RPC roundtrips — fine for the testnet scale (currently 13
* blueprints on Base Sepolia). At production scale this should be
* replaced with a hosted indexer, not paginated chain reads.
*
* The returned shape matches `Blueprint` (the same type the GraphQL
* path returns) so consumers don't need to branch on the data source.
* Fields the indexer tracks but the chain doesn't expose directly
* (`updatedAt`, `id` string) get sensible defaults: `updatedAt =
* createdAt`, and `id` is the stringified blueprintId.
*/
export const fetchBlueprintsOnChain = async (
publicClient: PublicClient,
chainId: number,
options?: { limit?: number; activeOnly?: boolean },
): Promise<Blueprint[]> => {
const contracts = getContractsByChainId(chainId);
if (!contracts || contracts.tangle === '0x0000000000000000000000000000000000000000') {
return [];
}

const tangleAddress = contracts.tangle as Address;
const { limit = 100, activeOnly = true } = options ?? {};

const total = (await publicClient.readContract({
address: tangleAddress,
abi: TANGLE_ABI,
functionName: 'blueprintCount',
})) as bigint;

if (total === 0n) {
return [];
}

const upper = Math.min(Number(total), limit);
const ids = Array.from({ length: upper }, (_, i) => BigInt(i));

// Batch both reads per id via Promise.all — viem's PublicClient handles
// request batching automatically when the transport supports it.
const rows = await Promise.all(
ids.map(async (id) => {
try {
const [blueprint, definition] = await Promise.all([
publicClient.readContract({
address: tangleAddress,
abi: TANGLE_ABI,
functionName: 'getBlueprint',
args: [id],
}) as Promise<{
owner: Address;
manager: Address;
createdAt: bigint;
operatorCount: number;
active: boolean;
}>,
publicClient.readContract({
address: tangleAddress,
abi: TANGLE_ABI,
functionName: 'getBlueprintDefinition',
args: [id],
}) as Promise<{
metadataUri: string;
metadataHash: `0x${string}`;
}>,
]);

const result: Blueprint = {
id: id.toString(),
blueprintId: id,
owner: blueprint.owner,
manager: blueprint.manager,
metadataUri: definition.metadataUri || null,
metadataHash:
definition.metadataHash &&
definition.metadataHash !==
'0x0000000000000000000000000000000000000000000000000000000000000000'
? definition.metadataHash
: null,
active: blueprint.active,
createdAt: BigInt(blueprint.createdAt),
// `updatedAt` is tracked off-chain by the indexer (event log of
// updateBlueprint calls). Without that, the best we can do is
// surface the original createdAt so downstream sorts behave
// predictably; the value is honest about "not seen any
// updates since creation".
updatedAt: BigInt(blueprint.createdAt),
operatorCount: BigInt(blueprint.operatorCount),
};
return result;
} catch {
// A single failed blueprint shouldn't take down the whole list —
// e.g. a deactivated entry the contract chose to drop from
// getBlueprint. Surface a placeholder so the catalog still
// renders the others.
return null;
}
}),
);

const blueprints: Blueprint[] = [];
for (const row of rows) {
if (row === null) continue;
if (activeOnly && !row.active) continue;
blueprints.push(row);
}
return blueprints;
};
78 changes: 65 additions & 13 deletions libs/tangle-shared-ui/src/data/graphql/useBlueprints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

import { useQuery } from '@tanstack/react-query';
import { Address } from 'viem';
import { useAccount, useChainId } from 'wagmi';
import { useAccount, useChainId, usePublicClient } from 'wagmi';
import {
executeEnvioGraphQL,
EnvioNetwork,
getEnvioNetworkFromChainId,
} from '../../utils/executeEnvioGraphQL';
import { fetchBlueprintsOnChain } from '../blueprints/fetchBlueprintsOnChain';
import useNetworkStore from '../../context/useNetworkStore';
import type { Blueprint as AppBlueprint } from '../../types/blueprint';
import {
Expand Down Expand Up @@ -347,6 +348,7 @@ export const useBlueprints = (options?: {
limit = 100,
} = options ?? {};
const { activeChainId, resolvedNetwork } = useResolvedEnvioNetwork(network);
const publicClient = usePublicClient({ chainId: activeChainId });

return useQuery({
queryKey: [
Expand All @@ -358,13 +360,42 @@ export const useBlueprints = (options?: {
limit,
],
queryFn: async () => {
const blueprints = await fetchBlueprints(
resolvedNetwork,
activeOnly,
// Primary path: hosted Envio indexer.
try {
const blueprints = await fetchBlueprints(
resolvedNetwork,
activeOnly,
limit,
0,
);
// If the indexer is up but lagging (returns 0 while the chain
// has blueprints), fall through to chain-reads so the user
// doesn't see an empty catalog despite there being live data.
if (blueprints.length > 0) {
return blueprints;
}
} catch (err) {
// Indexer endpoint missing or unreachable — log once and
// fall through to the chain-read fallback below. We don't
// re-throw because the on-chain path is the explicit recovery
// strategy, not an error to surface to the user.
console.warn(
`[useBlueprints] Envio indexer fetch failed (${resolvedNetwork}); ` +
'falling back to direct chain reads.',
err,
);
}

// Fallback: read blueprints straight off the Tangle contract.
// Bounded by `limit`, defaults to 100 — fine for the testnet
// scale of a few dozen entries.
if (!publicClient || !activeChainId) {
return [];
}
return fetchBlueprintsOnChain(publicClient, activeChainId, {
limit,
0,
);
return blueprints;
activeOnly,
});
},
enabled,
staleTime: 60_000,
Expand All @@ -384,6 +415,7 @@ export const useBlueprintsWithMetadata = (options?: {
limit = 100,
} = options ?? {};
const { activeChainId, resolvedNetwork } = useResolvedEnvioNetwork(network);
const publicClient = usePublicClient({ chainId: activeChainId });

return useQuery({
queryKey: [
Expand All @@ -395,12 +427,32 @@ export const useBlueprintsWithMetadata = (options?: {
limit,
],
queryFn: async () => {
const blueprints = await fetchBlueprints(
resolvedNetwork,
activeOnly,
limit,
0,
);
// Mirror the indexer → chain-read fallback strategy from
// `useBlueprints`. Without it, the home page's "Registered
// Blueprints" section renders empty whenever the Envio indexer
// isn't configured for the active network (e.g. testnet today),
// even though the chain has live entries.
let blueprints: Blueprint[] = [];
try {
blueprints = await fetchBlueprints(
resolvedNetwork,
activeOnly,
limit,
0,
);
} catch (err) {
console.warn(
`[useBlueprintsWithMetadata] Envio indexer fetch failed (${resolvedNetwork}); ` +
'falling back to direct chain reads.',
err,
);
}
if (blueprints.length === 0 && publicClient && activeChainId) {
blueprints = await fetchBlueprintsOnChain(publicClient, activeChainId, {
limit,
activeOnly,
});
}

return Promise.all(
blueprints.map(async (bp): Promise<BlueprintWithMetadata> => {
Expand Down
Loading