Skip to content

Commit d8eef92

Browse files
authored
feat(explorer): verified worlds (#3707)
1 parent 34ec2ec commit d8eef92

6 files changed

Lines changed: 421 additions & 1148 deletions

File tree

.changeset/unlucky-plants-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@latticexyz/explorer": patch
3+
---
4+
5+
Verified worlds are now shown in the world selection form.

packages/explorer/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"next": "14.2.5",
6969
"node-sql-parser": "^5.3.3",
7070
"nuqs": "^1.19.2",
71+
"pg": "^8.16.0",
7172
"query-string": "^9.1.0",
7273
"react": "^18",
7374
"react-dom": "^18",
@@ -84,6 +85,7 @@
8485
"devDependencies": {
8586
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
8687
"@types/debug": "^4.1.7",
88+
"@types/pg": "^8.15.2",
8789
"@types/react": "18.2.22",
8890
"@types/react-dom": "18.2.7",
8991
"@types/yargs": "^17.0.10",

packages/explorer/src/app/(explorer)/[chainName]/worlds/WorldsForm.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { BadgeCheckIcon } from "lucide-react";
34
import Image from "next/image";
45
import { useParams, useRouter } from "next/navigation";
56
import { Address, isAddress } from "viem";
@@ -14,6 +15,7 @@ import { Command, CommandGroup, CommandItem, CommandList } from "../../../../com
1415
import { Form, FormControl, FormField, FormItem, FormMessage } from "../../../../components/ui/Form";
1516
import { Input } from "../../../../components/ui/Input";
1617
import mudLogo from "../../icon.svg";
18+
import { WorldSelectItem, WorldsQueryResult } from "../../queries/useWorldsQuery";
1719
import { getWorldUrl } from "../../utils/getWorldUrl";
1820

1921
const formSchema = z.object({
@@ -26,7 +28,7 @@ const formSchema = z.object({
2628
});
2729

2830
type Props = {
29-
worlds: Address[];
31+
worlds: WorldsQueryResult["worlds"];
3032
isLoading: boolean;
3133
};
3234

@@ -46,8 +48,8 @@ export function WorldsForm({ worlds, isLoading }: Props) {
4648

4749
const onLuckyWorld = () => {
4850
if (worlds.length > 0) {
49-
const luckyAddress = worlds[Math.floor(Math.random() * worlds.length)];
50-
router.push(getWorldUrl(chainName as string, luckyAddress as Address));
51+
const luckyWorld = worlds[Math.floor(Math.random() * worlds.length)] as WorldSelectItem;
52+
router.push(getWorldUrl(chainName as string, luckyWorld.address));
5153
}
5254
};
5355

@@ -116,8 +118,8 @@ export function WorldsForm({ worlds, isLoading }: Props) {
116118
{worlds?.map((world) => {
117119
return (
118120
<CommandItem
119-
key={world}
120-
value={world}
121+
key={world.address}
122+
value={world.address}
121123
onMouseDown={(event) => {
122124
event.preventDefault();
123125
event.stopPropagation();
@@ -129,9 +131,10 @@ export function WorldsForm({ worlds, isLoading }: Props) {
129131
setOpen(false);
130132
form.handleSubmit(onSubmit)();
131133
}}
132-
className="cursor-pointer font-mono"
134+
className="flex cursor-pointer items-center font-mono"
133135
>
134-
{world}
136+
{world.name || world.address}
137+
{world.verified ? <BadgeCheckIcon className="ml-2 h-4 w-4 text-green-500" /> : null}
135138
</CommandItem>
136139
);
137140
})}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextResponse } from "next/server";
2+
import { Pool } from "pg";
3+
import { Address, getAddress } from "viem";
4+
5+
export type VerifiedWorld = {
6+
address: Address;
7+
name: string;
8+
network: string;
9+
};
10+
11+
export const GET = async (request: Request) => {
12+
if (!process.env.VERIFIED_WORLDS_DATABASE_URL) {
13+
console.log("VERIFIED_WORLDS_DATABASE_URL not set, returning empty result");
14+
return new NextResponse(JSON.stringify([]), { status: 200 });
15+
}
16+
17+
const { searchParams } = new URL(request.url);
18+
const chainId = searchParams.get("chainId");
19+
20+
try {
21+
const client = new Pool({
22+
connectionString: process.env.VERIFIED_WORLDS_DATABASE_URL,
23+
});
24+
const { rows } = await client.query("SELECT * FROM worlds WHERE network = $1", [chainId]);
25+
const result = rows.map((row) => ({
26+
...row,
27+
address: getAddress(row.address),
28+
}));
29+
30+
return new NextResponse(JSON.stringify(result), { status: 200 });
31+
} catch (error) {
32+
console.error("Error fetching verified worlds:", error);
33+
return new NextResponse(JSON.stringify({ error: "Failed to fetch verified worlds" }), { status: 500 });
34+
}
35+
};

packages/explorer/src/app/(explorer)/queries/useWorldsQuery.ts

Lines changed: 94 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,79 @@
11
import { useParams } from "next/navigation";
2-
import { Hex } from "viem";
2+
import { Address, getAddress } from "viem";
3+
import { MUDChain } from "@latticexyz/common/chains";
34
import { UseQueryResult, useQuery } from "@tanstack/react-query";
45
import { supportedChains, validateChainName } from "../../../common";
6+
import { VerifiedWorld } from "../api/verified-worlds/route";
57
import { useIndexerForChainId } from "../hooks/useIndexerForChainId";
68

7-
type WorldsQueryResult = {
8-
worlds: Hex[];
9+
export type WorldSelectItem = {
10+
address: Address;
11+
name: string;
12+
verified: boolean;
13+
};
14+
15+
export type WorldsQueryResult = {
16+
worlds: WorldSelectItem[];
917
};
1018

1119
type ApiResponse = {
12-
items: Array<{ address: { hash: Hex } }>;
20+
items: Array<{ address: { hash: Address } }>;
1321
};
1422

1523
type SqliteResponse = {
1624
result: [string[], ...string[][]];
1725
};
1826

27+
async function getVerifiedWorlds(chain: MUDChain): Promise<VerifiedWorld[]> {
28+
const response = await fetch(`/api/verified-worlds?chainId=${chain.id}`);
29+
const data = await response.json();
30+
return data;
31+
}
32+
33+
async function getLocalWorlds(indexer: ReturnType<typeof useIndexerForChainId>) {
34+
const response = await fetch(indexer.url, {
35+
method: "POST",
36+
headers: {
37+
"Content-Type": "application/json",
38+
},
39+
body: JSON.stringify([
40+
{
41+
query: "SELECT DISTINCT address FROM __mudStoreTables",
42+
},
43+
]),
44+
});
45+
46+
if (!response.ok) {
47+
throw new Error(`HTTP error! Status: ${response.status}`);
48+
}
49+
50+
const data: SqliteResponse = await response.json();
51+
const result = data.result[0];
52+
53+
if (!result || !Array.isArray(result) || result.length < 2) {
54+
return [];
55+
}
56+
57+
const rows = result.slice(1);
58+
return rows.map((row) => getAddress(row[0] as Address));
59+
}
60+
61+
async function getExternalWorlds(chain: MUDChain) {
62+
if (!("blockExplorers" in chain) || !chain.blockExplorers?.default.url) {
63+
return [];
64+
}
65+
66+
const worldsApiUrl = `${chain.blockExplorers.default.url}/api/v2/mud/worlds`;
67+
const response = await fetch(worldsApiUrl);
68+
69+
if (!response.ok) {
70+
throw new Error(`HTTP error! Status: ${response.status}`);
71+
}
72+
73+
const data: ApiResponse = await response.json();
74+
return data.items.map((world) => getAddress(world.address.hash));
75+
}
76+
1977
export function useWorldsQuery(): UseQueryResult<WorldsQueryResult> {
2078
const { chainName } = useParams();
2179
validateChainName(chainName);
@@ -25,49 +83,46 @@ export function useWorldsQuery(): UseQueryResult<WorldsQueryResult> {
2583
return useQuery({
2684
queryKey: ["worlds", chainName],
2785
queryFn: async () => {
28-
if (indexer.type === "sqlite") {
29-
const response = await fetch(indexer.url, {
30-
method: "POST",
31-
headers: {
32-
"Content-Type": "application/json",
33-
},
34-
body: JSON.stringify([
35-
{
36-
query: "SELECT DISTINCT address FROM __mudStoreTables",
37-
},
38-
]),
39-
});
40-
41-
if (!response.ok) {
42-
throw new Error(`HTTP error! Status: ${response.status}`);
43-
}
86+
const worlds: WorldSelectItem[] = [];
87+
const worldsAddresses = new Set<Address>();
4488

45-
const data: SqliteResponse = await response.json();
46-
const result = data.result[0];
89+
let indexerWorlds: Address[] = [];
90+
if (indexer.type === "sqlite") {
91+
indexerWorlds = await getLocalWorlds(indexer);
92+
} else {
93+
indexerWorlds = await getExternalWorlds(chain);
94+
}
4795

48-
if (!result || !Array.isArray(result) || result.length < 2) {
49-
return { worlds: [] };
50-
}
96+
for (const world of indexerWorlds) {
97+
worldsAddresses.add(world);
98+
worlds.push({ address: world, name: "", verified: false });
99+
}
51100

52-
const rows = result.slice(1);
53-
return {
54-
worlds: rows.map((row) => row[0] as Hex),
55-
};
56-
} else if ("blockExplorers" in chain && chain.blockExplorers?.default.url) {
57-
const worldsApiUrl = `${chain.blockExplorers.default.url}/api/v2/mud/worlds`;
58-
const response = await fetch(worldsApiUrl);
101+
let verifiedWorldsMap = new Map<Address, string>();
102+
try {
103+
const verifiedWorlds = await getVerifiedWorlds(chain);
104+
verifiedWorldsMap = new Map(verifiedWorlds.map((world) => [world.address, world.name]));
59105

60-
if (!response.ok) {
61-
throw new Error(`HTTP error! Status: ${response.status}`);
106+
for (const [address, name] of verifiedWorldsMap) {
107+
if (!worldsAddresses.has(address)) {
108+
worlds.push({ address, name, verified: true });
109+
}
62110
}
111+
} catch (error) {
112+
console.error("Failed to fetch verified worlds:", error);
113+
}
63114

64-
const data: ApiResponse = await response.json();
65-
return {
66-
worlds: data.items.map((world) => world.address.hash),
67-
};
115+
for (const world of worlds) {
116+
const name = verifiedWorldsMap.get(world.address);
117+
if (name) {
118+
world.name = name;
119+
world.verified = true;
120+
}
68121
}
69122

70-
return { worlds: [] };
123+
return {
124+
worlds: worlds.sort((a) => (a.verified ? -1 : 1)),
125+
};
71126
},
72127
refetchInterval: 5000,
73128
});

0 commit comments

Comments
 (0)