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
9 changes: 9 additions & 0 deletions .changeset/four-searching-registries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@dotdm/contracts": patch
"@dotdm/env": patch
"@dotdm/utils": patch
---

Add registry package-name search support and fix redeploying CDM packages against fresh registry deployments.

`@dotdm/contracts` now includes the registry `searchContractNames` ABI, scopes package deployment salts by registry address, and avoids dry-running dependent layers before prior layers have been registered. `@dotdm/utils` exposes the registry package salt constant used by deploy scripts and bootstrap deploys. `@dotdm/env` points presets at the redeployed registry address.
13 changes: 9 additions & 4 deletions src/apps/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type CdmChainClient,
} from "@dotdm/env";
import { getAccount } from "@dotdm/utils/accounts";
import { ALICE_SS58, REGISTRY_ADDRESS } from "@dotdm/utils";
import { ALICE_SS58, CONTRACTS_REGISTRY_PACKAGE, REGISTRY_ADDRESS } from "@dotdm/utils";
import { ContractDeployer, CONTRACTS_REGISTRY_CRATE, resolveFeatures } from "@dotdm/contracts";
import type { HexString } from "polkadot-api";
import { runDeployWithUI, spinner } from "../lib/ui";
Expand Down Expand Up @@ -216,9 +216,11 @@ async function bootstrapDeploy(rootDir: string, opts: DeployOptions): Promise<vo

// Phase 1 preflight: deploy ContractRegistry only if this signer/bytecode
// produces the registry address selected for this network/target.
const CDM_REGISTRY_PACKAGE = "@cdm/registry";
const registryAddress = getRegistryAddress(opts);
const expectedRegistry = await deployer.dryRunDeploy(registryPvmPath, CDM_REGISTRY_PACKAGE);
const expectedRegistry = await deployer.dryRunDeploy(
registryPvmPath,
CONTRACTS_REGISTRY_PACKAGE,
);
if (expectedRegistry.address.toLowerCase() !== registryAddress.toLowerCase()) {
console.error(
`ERROR: ContractRegistry bootstrap would deploy ${expectedRegistry.address}, but the selected target uses ${registryAddress}.`,
Expand All @@ -232,7 +234,10 @@ async function bootstrapDeploy(rootDir: string, opts: DeployOptions): Promise<vo

// Phase 1: Deploy ContractRegistry (CREATE2 for deterministic address)
console.log("Deploying ContractRegistry...");
const { address: registryAddr } = await deployer.deploy(registryPvmPath, CDM_REGISTRY_PACKAGE);
const { address: registryAddr } = await deployer.deploy(
registryPvmPath,
CONTRACTS_REGISTRY_PACKAGE,
);
console.log(` ContractRegistry: ${registryAddr}\n`);
opts.registryAddress = registryAddr;

Expand Down
43 changes: 43 additions & 0 deletions src/apps/frontend/src/data/registry-queries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { Package, AbiEntry } from "./types";
import type { RegistryContract } from "../context/network-context";

export interface ContractNameSearchPage {
names: string[];
nextOffset: number;
done: boolean;
}

export function unwrapOption<T>(val: unknown): T | undefined {
if (val && typeof val === "object" && "isSome" in val) {
const opt = val as { isSome: boolean; value: T };
Expand Down Expand Up @@ -34,6 +40,43 @@ export async function queryContractByName(
};
}

function parseSearchPage(value: unknown): ContractNameSearchPage {
if (Array.isArray(value)) {
return {
names: Array.isArray(value[0]) ? value[0] : [],
nextOffset: Number(value[1] ?? 0),
done: Boolean(value[2]),
};
}

if (value && typeof value === "object") {
const page = value as {
names?: unknown;
next_offset?: unknown;
nextOffset?: unknown;
done?: unknown;
};
return {
names: Array.isArray(page.names) ? (page.names as string[]) : [],
nextOffset: Number(page.next_offset ?? page.nextOffset ?? 0),
done: Boolean(page.done),
};
}

return { names: [], nextOffset: 0, done: true };
}

export async function queryContractNamesByPrefix(
registry: RegistryContract,
prefix: string,
offset: number,
limit: number,
): Promise<ContractNameSearchPage> {
const result = await registry.searchContractNames.query(prefix, offset, limit);
if (!result.success) throw new Error("Failed to search contract names");
return parseSearchPage(result.value);
}

export function metadataCidFromUri(uri: string | undefined): string | undefined {
if (!uri) return undefined;
if (uri.startsWith("ipfs://")) return uri.slice("ipfs://".length);
Expand Down
163 changes: 163 additions & 0 deletions src/apps/frontend/src/hooks/useRegistrySearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNetwork } from "../context/useNetwork";
import { queryBulletinJson } from "../data/bulletin-client";
import type { Package } from "../data/types";
import {
metadataCidFromUri,
parseMetadata,
queryContractByName,
queryContractNamesByPrefix,
} from "../data/registry-queries";
import { withTimeout } from "../data/timeout";

const SEARCH_PAGE_SIZE = 20;

export function useRegistrySearch(query: string) {
const {
registry,
connected,
connecting,
error: networkError,
network,
networkConfig,
} = useNetwork();
const prefix = useMemo(() => query.trim(), [query]);

const [basePackages, setBasePackages] = useState<Package[]>([]);
const [metadataMap, setMetadataMap] = useState<Record<string, Partial<Package>>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);

const generationRef = useRef(0);
const busyRef = useRef(false);
const offsetRef = useRef(0);
const hasMoreRef = useRef(false);
const metadataAttempted = useRef<Set<string>>(new Set());

const metadataKey = useCallback((pkg: Package) => `${network}:${pkg.name}`, [network]);

const loadPage = useCallback(
async (generation: number) => {
if (!registry || !connected || !prefix || busyRef.current || !hasMoreRef.current) {
return;
}

busyRef.current = true;
setLoading(true);
setError(null);

try {
const page = await withTimeout(
queryContractNamesByPrefix(
registry,
prefix,
offsetRef.current,
SEARCH_PAGE_SIZE,
),
`Registry search query timed out on ${networkConfig.label}.`,
);
if (generation !== generationRef.current) return;

const packages = (
await Promise.all(
page.names.map((name) =>
withTimeout(
queryContractByName(registry, name),
`Registry package query timed out for ${name}.`,
),
),
)
).filter((pkg): pkg is Package => pkg !== null);

if (generation !== generationRef.current) return;

offsetRef.current = page.nextOffset;
hasMoreRef.current = !page.done;
setHasMore(!page.done);
setBasePackages((prev) => {
const seen = new Set(prev.map((pkg) => pkg.name));
const next = packages.filter((pkg) => !seen.has(pkg.name));
return [...prev, ...next];
});
} catch (err) {
if (generation !== generationRef.current) return;
setError(err instanceof Error ? err.message : "Failed to search registry");
} finally {
if (generation === generationRef.current) {
busyRef.current = false;
setLoading(false);
}
}
},
[connected, networkConfig.label, prefix, registry],
);

useEffect(() => {
generationRef.current += 1;
const generation = generationRef.current;

setBasePackages([]);
setMetadataMap({});
setError(null);
setLoading(false);
setHasMore(false);
offsetRef.current = 0;
hasMoreRef.current = false;
busyRef.current = false;
metadataAttempted.current.clear();

if (registry && connected && prefix) {
hasMoreRef.current = true;
setHasMore(true);
void loadPage(generation);
}
}, [connected, loadPage, prefix, registry]);

const loadMore = useCallback(() => {
void loadPage(generationRef.current);
}, [loadPage]);

useEffect(() => {
const toFetch = basePackages.filter(
(pkg) =>
metadataCidFromUri(pkg.metadataUri) &&
!metadataAttempted.current.has(metadataKey(pkg)),
);

if (toFetch.length === 0) return;

for (const pkg of toFetch) {
metadataAttempted.current.add(metadataKey(pkg));
}

for (const pkg of toFetch) {
const cid = metadataCidFromUri(pkg.metadataUri)!;
const key = metadataKey(pkg);
queryBulletinJson(networkConfig.productSdkEnvironment, cid)
.then((metadata) => {
setMetadataMap((prev) => ({ ...prev, [key]: parseMetadata(metadata) }));
})
.catch(() => {
setMetadataMap((prev) => ({
...prev,
[key]: { metadataLoaded: true },
}));
});
}
}, [basePackages, metadataKey, networkConfig.productSdkEnvironment]);

const packages = useMemo(
() => basePackages.map((pkg) => ({ ...pkg, ...metadataMap[metadataKey(pkg)] })),
[basePackages, metadataKey, metadataMap],
);

return {
packages,
loading: loading || connecting,
error: networkError ?? error,
hasMore,
loadMore,
network,
};
}
2 changes: 1 addition & 1 deletion src/apps/frontend/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function HomePage() {
value={query}
onChange={setQuery}
onSubmit={handleSearch}
placeholder="Search contracts, authors, descriptions..."
placeholder="Search @org/package names..."
ariaLabel="Search contracts"
/>
</div>
Expand Down
51 changes: 23 additions & 28 deletions src/apps/frontend/src/pages/SearchPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
margin-bottom: 24px;
}

.search-page-box {
max-width: 720px;
margin-bottom: 18px;
}

.search-result-count {
font-size: 1.1rem;
color: var(--color-text-secondary);
Expand All @@ -19,49 +24,39 @@
color: var(--color-text-primary);
}

.search-sort-bar {
display: flex;
gap: 8px;
margin-bottom: 24px;
}

.sort-btn {
padding: 6px 16px;
font-size: 0.85rem;
border: 1px solid var(--color-border-strong);
background: var(--color-surface);
color: var(--color-text-secondary);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
}

.sort-btn:hover {
border-color: var(--accent);
color: var(--accent);
}

.sort-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}

.search-results-list {
display: flex;
flex-direction: column;
gap: 0;
}

.search-results-list .package-card {
height: auto;
min-height: 116px;
padding: 22px 0;
border-bottom: 1px solid var(--color-border);
border-radius: 0;
}

.search-results-list .package-card::before {
inset: 0 -16px;
border-radius: 8px;
}

.search-results-list .package-card:first-child {
border-top: 1px solid var(--color-border);
}

.search-results-list .package-card-description {
flex: initial;
min-height: 0;
margin-bottom: 16px;
}

.search-results-list .package-card-meta {
justify-content: flex-start;
}

.search-empty {
text-align: center;
padding: 60px 20px;
Expand Down
Loading
Loading