diff --git a/.changeset/four-searching-registries.md b/.changeset/four-searching-registries.md new file mode 100644 index 0000000..3031cbe --- /dev/null +++ b/.changeset/four-searching-registries.md @@ -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. diff --git a/src/apps/cli/src/commands/deploy.ts b/src/apps/cli/src/commands/deploy.ts index 88fe1eb..b741b7e 100644 --- a/src/apps/cli/src/commands/deploy.ts +++ b/src/apps/cli/src/commands/deploy.ts @@ -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"; @@ -216,9 +216,11 @@ async function bootstrapDeploy(rootDir: string, opts: DeployOptions): Promise(val: unknown): T | undefined { if (val && typeof val === "object" && "isSome" in val) { const opt = val as { isSome: boolean; value: T }; @@ -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 { + 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); diff --git a/src/apps/frontend/src/hooks/useRegistrySearch.ts b/src/apps/frontend/src/hooks/useRegistrySearch.ts new file mode 100644 index 0000000..6c7f2e3 --- /dev/null +++ b/src/apps/frontend/src/hooks/useRegistrySearch.ts @@ -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([]); + const [metadataMap, setMetadataMap] = useState>>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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>(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, + }; +} diff --git a/src/apps/frontend/src/pages/HomePage.tsx b/src/apps/frontend/src/pages/HomePage.tsx index 7ea77ff..90fd331 100644 --- a/src/apps/frontend/src/pages/HomePage.tsx +++ b/src/apps/frontend/src/pages/HomePage.tsx @@ -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" /> diff --git a/src/apps/frontend/src/pages/SearchPage.css b/src/apps/frontend/src/pages/SearchPage.css index d2667e6..ae23851 100644 --- a/src/apps/frontend/src/pages/SearchPage.css +++ b/src/apps/frontend/src/pages/SearchPage.css @@ -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); @@ -19,34 +24,6 @@ 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; @@ -54,14 +31,32 @@ } .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; diff --git a/src/apps/frontend/src/pages/SearchPage.tsx b/src/apps/frontend/src/pages/SearchPage.tsx index 71972d9..4808f91 100644 --- a/src/apps/frontend/src/pages/SearchPage.tsx +++ b/src/apps/frontend/src/pages/SearchPage.tsx @@ -1,48 +1,51 @@ -import { useState, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import InfiniteScroll from "../components/InfiniteScroll"; import Layout from "../components/Layout"; import PackageCard from "../components/PackageCard"; +import SearchBox from "../components/SearchBox"; import { SkeletonCard } from "../components/SkeletonCard"; import { useNetwork } from "../context/useNetwork"; -import { useRegistry } from "../hooks/useRegistry"; +import { useRegistrySearch } from "../hooks/useRegistrySearch"; import "./SearchPage.css"; -type SortMode = "name" | "popularity"; - export default function SearchPage() { const [searchParams] = useSearchParams(); - const query = searchParams.get("q") || ""; - const [sort, setSort] = useState("name"); + const query = (searchParams.get("q") || "").trim(); + const [inputValue, setInputValue] = useState(query); + const navigate = useNavigate(); const { networkConfig, connecting, error: networkError } = useNetwork(); - const { packages, loading, error: registryError } = useRegistry(); + const { packages, loading, error: registryError, hasMore, loadMore } = useRegistrySearch(query); const error = networkError || registryError; - const results = useMemo(() => { - if (!query) return []; - const lower = query.toLowerCase(); - const filtered = packages.filter( - (pkg) => - pkg.name.toLowerCase().includes(lower) || - (pkg.description ?? "").toLowerCase().includes(lower) || - (pkg.keywords ?? []).some((kw) => kw.toLowerCase().includes(lower)), - ); + useEffect(() => { + setInputValue(query); + }, [query]); - const sorted = [...filtered]; - if (sort === "name") { - sorted.sort((a, b) => a.name.localeCompare(b.name)); - } else { - sorted.sort((a, b) => (b.weeklyCalls ?? 0) - (a.weeklyCalls ?? 0)); - } - return sorted; - }, [query, sort, packages]); + const handleSearch = (value: string) => { + const trimmed = value.trim(); + if (trimmed) navigate(`/search?q=${encodeURIComponent(trimmed)}`); + }; if (!query) { return ( -
-

Search for contracts

-

Enter a search term to find contracts on cdm.

+
+
+ +
+
+

Search for contracts

+

Enter a package name prefix to find contracts on cdm.

+
); @@ -52,21 +55,18 @@ export default function SearchPage() {
+

- {results.length} contract{results.length !== 1 ? "s" : ""}{" "} - found for “{query}” + Showing {packages.length} package name match + {packages.length !== 1 ? "es" : ""} for “{query}”

-
- {(["name", "popularity"] as SortMode[]).map((mode) => ( - - ))} -
{error ? ( @@ -85,17 +85,19 @@ export default function SearchPage() { ))}
- ) : results.length === 0 ? ( + ) : packages.length === 0 ? (

No contracts found

-

Try a different search term.

+

Try a different package name prefix.

) : ( -
- {results.map((pkg) => ( - - ))} -
+ +
+ {packages.map((pkg) => ( + + ))} +
+
)}
diff --git a/src/contract/src/lib.rs b/src/contract/src/lib.rs index d0b6b1d..0cbc646 100644 --- a/src/contract/src/lib.rs +++ b/src/contract/src/lib.rs @@ -2,8 +2,9 @@ #![no_std] use alloc::string::String; +use core::ops::Bound; use parity_scale_codec::{Decode, Encode}; -use pvm::storage::Mapping; +use pvm::storage::{Mapping, OrderedIndex}; use pvm::{Address, ReturnFlags, caller}; use pvm_contract as pvm; @@ -12,6 +13,8 @@ fn revert(msg: &[u8]) -> ! { } pub type Version = u32; +const MAX_CONTRACT_NAME_LEN: usize = 64; +const MAX_SEARCH_LIMIT: u32 = 100; /// A published contract version in the registry. #[derive(Clone, Encode, Decode)] @@ -31,12 +34,48 @@ pub struct NamedContractInfo { pub version_count: Version, } +#[derive(Default, pvm::SolAbi)] +pub struct ContractNameSearchPage { + pub names: alloc::vec::Vec, + pub next_offset: u32, + pub done: bool, +} + +fn validate_contract_name(contract_name: &String) { + if contract_name.is_empty() { + revert(b"ContractNameEmpty"); + } + if contract_name.as_bytes().len() > MAX_CONTRACT_NAME_LEN { + revert(b"ContractNameTooLong"); + } + if !contract_name.is_ascii() { + revert(b"ContractNameInvalid"); + } +} + +fn prefix_upper_bound(prefix: &String) -> Option { + let mut bytes = prefix.as_bytes().to_vec(); + + while bytes.last() == Some(&0xff) { + bytes.pop(); + } + + if let Some(last) = bytes.last_mut() { + *last = last.saturating_add(1); + Some(String::from_utf8_lossy(&bytes).into_owned()) + } else { + None + } +} + #[pvm::storage] struct Storage { /// Count of registered contract names contract_name_count: u32, /// Maps index to contract name (simulates StorageVec) contract_name_at: Mapping, + /// Sorted index of contract names for prefix search. + contract_name_index: OrderedIndex, /// Stores all published versions of named contracts where the key for /// an individual versioned contract is given by `(contract_name, version)` published_address: Mapping<(String, Version), Address>, @@ -60,6 +99,8 @@ mod contract_registry { /// either the name is available or they are already the owner of the name. #[pvm::method] pub fn publish_latest(contract_name: String, contract_address: Address, metadata_uri: String) { + validate_contract_name(&contract_name); + let caller = caller(); // Get existing info or register new `contract_name` with caller as owner @@ -73,7 +114,12 @@ mod contract_registry { // Append to contract names list let count = Storage::contract_name_count().get().unwrap_or(0); Storage::contract_name_at().insert(&count, &contract_name); - Storage::contract_name_count().set(&(count + 1)); + Storage::contract_name_index().insert(&contract_name, &count); + Storage::contract_name_count().set( + &count + .checked_add(1) + .unwrap_or_else(|| revert(b"ContractCountOverflow")), + ); info } }; @@ -140,6 +186,49 @@ mod contract_registry { Storage::contract_name_at().get(&index).unwrap_or_default() } + /// Search registered contract names by prefix. + #[pvm::method] + pub fn search_contract_names( + prefix: String, + offset: u32, + limit: u32, + ) -> ContractNameSearchPage { + let cap = if limit > MAX_SEARCH_LIMIT { + MAX_SEARCH_LIMIT + } else { + limit + }; + + if cap == 0 || prefix.as_bytes().len() > MAX_CONTRACT_NAME_LEN || !prefix.is_ascii() { + return ContractNameSearchPage { + names: alloc::vec::Vec::new(), + next_offset: offset, + done: true, + }; + } + + let upper = prefix_upper_bound(&prefix); + let to = match upper.as_ref() { + Some(bound) => Bound::Excluded(bound), + None => Bound::Unbounded, + }; + + let hits = Storage::contract_name_index().range( + Bound::Included(&prefix), + to, + offset as u64, + cap as u64, + ); + let returned = hits.len() as u32; + let names = hits.into_iter().map(|(name, _)| name).collect(); + + ContractNameSearchPage { + names, + next_offset: offset.saturating_add(returned), + done: returned < cap, + } + } + /// Get the owner of a contract name. #[pvm::method] pub fn get_owner(contract_name: String) -> Address { diff --git a/src/lib/contracts/src/abi/registry.ts b/src/lib/contracts/src/abi/registry.ts index 6cf708e..ec3b76f 100644 --- a/src/lib/contracts/src/abi/registry.ts +++ b/src/lib/contracts/src/abi/registry.ts @@ -107,6 +107,27 @@ export const CONTRACTS_REGISTRY_ABI: AbiEntry[] = [ outputs: [{ name: "", type: "string" }], stateMutability: "view", }, + { + type: "function", + name: "searchContractNames", + inputs: [ + { name: "prefix", type: "string" }, + { name: "offset", type: "uint32" }, + { name: "limit", type: "uint32" }, + ], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "names", type: "string[]" }, + { name: "next_offset", type: "uint32" }, + { name: "done", type: "bool" }, + ], + }, + ], + stateMutability: "view", + }, { type: "function", name: "getOwner", diff --git a/src/lib/contracts/src/deployer.ts b/src/lib/contracts/src/deployer.ts index 7d6cfbe..c472fab 100644 --- a/src/lib/contracts/src/deployer.ts +++ b/src/lib/contracts/src/deployer.ts @@ -18,17 +18,33 @@ import type { Contract, ContractDef } from "@parity/product-sdk-contracts"; export type DeploySaltVersion = number | bigint; /** - * Compute a deterministic 32-byte CREATE2 salt from a CDM package name and, - * when known, the registry version index that this deployment will publish. + * Compute a deterministic 32-byte CREATE2 salt from a CDM package name, the + * registry version index this deployment will publish, and optionally the + * registry address the deployment is being published into. * * With no version this preserves the original package-only salt, which keeps * existing callers such as the universal ContractRegistry deployment stable. * CDM package deployments should pass the next registry version index so each * publish derives a fresh address even if the deployer and bytecode repeat. + * Passing `registryAddress` additionally scopes version numbers to a specific + * registry generation, so a fresh registry can republish version 0 packages on + * a chain that already has older CDM deployments. */ -export function computeDeploySalt(cdmPackage: string, version?: DeploySaltVersion): SizedHex<32> { +export function computeDeploySalt( + cdmPackage: string, + version?: DeploySaltVersion, + registryAddress?: string, +): SizedHex<32> { const material = - version === undefined ? cdmPackage : JSON.stringify([cdmPackage, version.toString()]); + registryAddress === undefined + ? version === undefined + ? cdmPackage + : JSON.stringify([cdmPackage, version.toString()]) + : JSON.stringify([ + registryAddress.toLowerCase(), + cdmPackage, + version?.toString() ?? "", + ]); const hash = blake2b(new TextEncoder().encode(material), { dkLen: 32 }); return Binary.toHex(hash) as SizedHex<32>; } @@ -195,8 +211,9 @@ export class ContractDeployer { pvmPath: string, cdmPackage?: string, saltVersion?: DeploySaltVersion, + saltScope?: string, ): Promise<{ address: string; txHash: string; blockHash: string }> { - const { tx } = await this.dryRunDeploy(pvmPath, cdmPackage, saltVersion); + const { tx } = await this.dryRunDeploy(pvmPath, cdmPackage, saltVersion, saltScope); let result: Awaited>; try { @@ -239,10 +256,15 @@ export class ContractDeployer { * `ReviveApi.instantiate` round-trip that `deployAndRegisterBatch` used * to perform purely to recover the CREATE2 address. */ - async dryRunDeploy(pvmPath: string, cdmPackage?: string, saltVersion?: DeploySaltVersion) { + async dryRunDeploy( + pvmPath: string, + cdmPackage?: string, + saltVersion?: DeploySaltVersion, + saltScope?: string, + ) { const code = new Uint8Array(readFileSync(pvmPath)); const data = new Uint8Array(0); - const salt = cdmPackage ? computeDeploySalt(cdmPackage, saltVersion) : undefined; + const salt = cdmPackage ? computeDeploySalt(cdmPackage, saltVersion, saltScope) : undefined; const dryRun = await this.api.apis.ReviveApi.instantiate( this.origin, 0n, @@ -355,9 +377,12 @@ export class ContractDeployer { pvmPaths: string[], cdmPackages?: (string | undefined)[], saltVersions?: (DeploySaltVersion | undefined)[], + saltScope?: string, ): Promise { const prepared = await Promise.all( - pvmPaths.map((p, i) => this.dryRunDeploy(p, cdmPackages?.[i], saltVersions?.[i])), + pvmPaths.map((p, i) => + this.dryRunDeploy(p, cdmPackages?.[i], saltVersions?.[i], saltScope), + ), ); const budget = await this.resolveChunkBudget(); const chunks = chunkByWeight( @@ -393,7 +418,11 @@ export class ContractDeployer { chunkIndex: number; totalChunks: number; }) => void, - opts?: { plan?: DeployPlan; saltVersions?: (DeploySaltVersion | undefined)[] }, + opts?: { + plan?: DeployPlan; + saltVersions?: (DeploySaltVersion | undefined)[]; + saltScope?: string; + }, ): Promise<{ addresses: string[]; chunkCount: number }> { if (pvmPaths.length === 0) return { addresses: [], chunkCount: 0 }; @@ -401,7 +430,8 @@ export class ContractDeployer { // addresses for the chunker — unless a precomputed plan was passed in. // 2. Chunk by cumulative declared weight (already done inside the plan). const plan = - opts?.plan ?? (await this.planDeploy(pvmPaths, cdmPackages, opts?.saltVersions)); + opts?.plan ?? + (await this.planDeploy(pvmPaths, cdmPackages, opts?.saltVersions, opts?.saltScope)); const { prepared, chunks } = plan; const addresses: string[] = new Array(pvmPaths.length); @@ -417,7 +447,12 @@ export class ContractDeployer { let chunkResult: { txHash: string; blockHash: string; addrs: string[] }; if (idxs.length === 1) { const i = idxs[0]; - const r = await this.deploy(pvmPaths[i], cdmPackages?.[i], opts?.saltVersions?.[i]); + const r = await this.deploy( + pvmPaths[i], + cdmPackages?.[i], + opts?.saltVersions?.[i], + opts?.saltScope, + ); addresses[i] = r.address; chunkResult = { txHash: r.txHash, blockHash: r.blockHash, addrs: [r.address] }; } else { @@ -528,7 +563,7 @@ export class ContractDeployer { chunkIndex: number; totalChunks: number; }) => void, - opts?: { plan?: DeployPlan; saltVersions?: DeploySaltVersion[] }, + opts?: { plan?: DeployPlan; saltVersions?: DeploySaltVersion[]; saltScope?: string }, ): Promise<{ addresses: string[]; chunkCount: number }> { if (pvmPaths.length === 0) return { addresses: [], chunkCount: 0 }; if (pvmPaths.length !== cdmPackages.length || pvmPaths.length !== metadataUris.length) { @@ -539,7 +574,8 @@ export class ContractDeployer { // 1. Dry-run + chunk (or reuse a caller-supplied plan). const plan = - opts?.plan ?? (await this.planDeploy(pvmPaths, cdmPackages, opts?.saltVersions)); + opts?.plan ?? + (await this.planDeploy(pvmPaths, cdmPackages, opts?.saltVersions, opts?.saltScope)); const { prepared, chunks } = plan; // 2. Build the `publishLatest` BatchableCalls via product-sdk @@ -642,6 +678,24 @@ if (import.meta.vitest) { expect(version1).not.toBe(version0); expect(computeDeploySalt("@cdm/example", 1n)).toBe(version1); }); + + test("scopes salts by registry address when provided", () => { + const v0OldRegistry = computeDeploySalt( + "@cdm/example", + 0, + "0x1111111111111111111111111111111111111111", + ); + const v0NewRegistry = computeDeploySalt( + "@cdm/example", + 0, + "0x2222222222222222222222222222222222222222", + ); + + expect(v0NewRegistry).not.toBe(v0OldRegistry); + expect( + computeDeploySalt("@cdm/example", 0, "0x2222222222222222222222222222222222222222"), + ).toBe(v0NewRegistry); + }); }); describe("chunkByWeight", () => { diff --git a/src/lib/contracts/src/pipeline.ts b/src/lib/contracts/src/pipeline.ts index f07d580..dabc251 100644 --- a/src/lib/contracts/src/pipeline.ts +++ b/src/lib/contracts/src/pipeline.ts @@ -856,9 +856,10 @@ async function precomputeDeployAddress( origin: SS58String, deployable: DeployableContract, version: DeploySaltVersion, + registryAddress: HexString, ): Promise { const code = new Uint8Array(readFileSync(deployable.pvmPath)); - const salt = computeDeploySalt(deployable.cdmPackage, version); + const salt = computeDeploySalt(deployable.cdmPackage, version, registryAddress); const result = await api.apis.ReviveApi.instantiate( origin, 0n, @@ -875,47 +876,6 @@ async function precomputeDeployAddress( return result.result.value.addr as HexString; } -async function precomputeLayerSolidityImports(args: { - rootDir: string; - builtCrates: string[]; - build: BuildPhaseResult; - contractMap: Map; - registryContract: Contract; - assetHubApi: PipelineChainClient["assetHub"]; - origin: SS58String; - nextVersionByCrate?: Map; - predictedAddressByCrate?: Map; -}): Promise { - if (args.builtCrates.length === 0) return; - - const deployables = args.builtCrates.map((crate) => - getDeployableContract(args.build, args.contractMap, crate), - ); - const packages = deployables.map((contract) => contract.cdmPackage); - const versionCounts = await queryRegistryVersionCounts(args.registryContract, packages); - - await Promise.all( - deployables.map(async (deployable) => { - const version = versionCounts.get(deployable.cdmPackage); - if (version === undefined) { - throw new Error( - `Failed to query registry version count for "${deployable.cdmPackage}"`, - ); - } - - const address = await precomputeDeployAddress( - args.assetHubApi, - args.origin, - deployable, - version, - ); - args.nextVersionByCrate?.set(deployable.crate, version); - args.predictedAddressByCrate?.set(deployable.crate, address); - writeSolidityImportForDeployable(args.rootDir, deployable, address, version); - }), - ); -} - // ---------- public API ---------- /** @@ -955,7 +915,7 @@ export async function buildContracts(opts: BuildContractsOptions): Promise opts.onEvent?.(e); - // ---- 1. detect + build ---- + // ---- 1. detect + prepare local Solidity imports ---- const detected = detectBuildOrder(opts.rootDir, opts.contracts); writeLocalSolidityBuildImports(opts.rootDir, detected); const crates = [...detected.layers.flat()]; @@ -1019,49 +979,44 @@ export async function deployContracts(opts: DeployContractsOptions): Promise(), + info: new Map(), + }; const nextVersionByCrate = new Map(); - const predictedAddressByCrate = new Map(); + let { contractMap, cdmPackageMap } = buildContractIndexes(opts.rootDir, detected, build); - const { build, crates: builtCrates } = await runDetectedBuild( - opts.rootDir, - detected, - emit as BuildEmitter, - opts.features, - opts.registryAddress, - async ({ builtCrates: layerBuiltCrates, build }) => { - await precomputeLayerSolidityImports({ - rootDir: opts.rootDir, - builtCrates: layerBuiltCrates, - build, - contractMap: detected.contractMap, - registryContract, - assetHubApi, - origin: opts.origin, - nextVersionByCrate, - predictedAddressByCrate, - }); - }, - ); - const buildContractsSummary = summarizeBuildContracts(detected, build, builtCrates); - writeManifestFromBuildSummary(opts.rootDir, buildContractsSummary); - const { contractMap, cdmPackageMap } = buildContractIndexes(opts.rootDir, detected, build); - - for (const crate of build.failed) { - const info = build.info.get(crate); - status.set(crate, { - status: "error", - error: info?.error ?? "Build failed", - cdmPackage: cdmPackageMap.get(crate), - }); - } - - // ---- 3. per-layer deploy loop ---- - const failedCrates = new Set(build.failed); + // ---- 3. per-layer build + deploy loop ---- + const failedCrates = new Set(); const addresses: Record = {}; for (let layerIndex = 0; layerIndex < detected.layers.length; layerIndex++) { const layer = detected.layers[layerIndex]; - const layerDeployable = layer.filter((c) => { + const builtCrates = await runBuildLayer( + opts.rootDir, + detected, + build, + layer, + emit as BuildEmitter, + opts.features, + opts.registryAddress, + ); + assertUniqueBuiltCdmPackages(build); + ({ contractMap, cdmPackageMap } = buildContractIndexes(opts.rootDir, detected, build)); + + for (const crate of layer) { + if (!build.failed.has(crate)) continue; + const info = build.info.get(crate); + failedCrates.add(crate); + status.set(crate, { + status: "error", + error: info?.error ?? "Build failed", + cdmPackage: cdmPackageMap.get(crate), + }); + } + + const layerDeployable = builtCrates.filter((c) => { if (failedCrates.has(c)) return false; const contract = contractMap.get(c); return !contract?.dependsOnCrates.some((dep) => failedCrates.has(dep)); @@ -1145,14 +1100,13 @@ export async function deployContracts(opts: DeployContractsOptions): Promise ({ crate: deployableCrates[i], gasLimit: { @@ -1257,7 +1216,7 @@ export async function deployContracts(opts: DeployContractsOptions): Promise { // Verify CIDs match precomputation — any drift @@ -1273,7 +1232,16 @@ export async function deployContracts(opts: DeployContractsOptions): Promise = {}; for (let i = 0; i < deployables.length; i++) { @@ -1299,7 +1267,16 @@ export async function deployContracts(opts: DeployContractsOptions): Promise status.get(c)?.status !== "cached"); for (const crate of affected) { + const info = build.info.get(crate); failedCrates.add(crate); + build.failed.add(crate); + build.info.set(crate, { + ...(info ?? { + durationMs: 0, + cdmPackage: cdmPackageMap.get(crate), + }), + error: msg, + }); status.set(crate, { status: "error", error: msg, @@ -1314,6 +1291,9 @@ export async function deployContracts(opts: DeployContractsOptions): Promise { const s = status.get(crate)!; @@ -1375,6 +1355,65 @@ if (import.meta.vitest) { computeBulletinStoreCid: vi.fn(async () => "fakeCid123"), })); + vi.mock("@parity/product-sdk-contracts", () => ({ + createContractFromClient: vi.fn(async () => ({ + getVersionCount: { + query: vi.fn(async () => ({ success: true, value: 0 })), + }, + })), + })); + + vi.mock("./deployer", async () => { + const actual = await vi.importActual("./deployer"); + return { + ...actual, + ContractDeployer: vi.fn().mockImplementation(() => ({ + planDeploy: vi.fn(async (pvmPaths: string[]) => ({ + prepared: pvmPaths.map((_path, index) => ({ + address: `0x${String(index + 1).padStart(40, "0")}`, + gasLimit: { ref_time: 1n, proof_size: 1n }, + extrinsicWeight: { ref_time: 1n, proof_size: 1n }, + storageDeposit: 0n, + })), + budget: { ref_time: 10n, proof_size: 10n }, + chunks: [pvmPaths.map((_path, index) => index)], + })), + deployAndRegisterBatch: vi.fn( + async ( + pvmPaths: string[], + _packages: string[], + _registry: unknown, + _metadataUris: string[], + onChunk: (chunk: { + addresses: string[]; + txHash: string; + blockHash: string; + }) => void, + ) => { + const addresses = pvmPaths.map( + (_path, index) => `0x${String(index + 1).padStart(40, "0")}`, + ); + onChunk({ + addresses, + txHash: "0xdeploy", + blockHash: "0xblock", + }); + return { addresses }; + }, + ), + })), + }; + }); + + vi.mock("./publisher", () => ({ + MetadataPublisher: vi.fn().mockImplementation(() => ({ + publishBatch: vi.fn(async (metadataList: unknown[]) => ({ + cids: metadataList.map(() => "fakeCid123"), + txHash: "0xpublish", + })), + })), + })); + vi.mock("./solidity", () => ({ buildSolidityToolchain: vi.fn(), detectSolidityBuildTargets: vi.fn(() => []), @@ -1605,4 +1644,54 @@ if (import.meta.vitest) { ); }); }); + + describe("deployContracts", () => { + test("deploys each layer before building its dependents", async () => { + (mockDetect as any).mockReturnValue( + makeOrder([["a"], ["b"]], { b: ["a"] }, { a: "@example/a", b: "@example/b" }), + ); + (mockReadCdm as any).mockImplementation( + (_root: string, crate: string) => + ({ a: "@example/a", b: "@example/b" })[crate] ?? null, + ); + + const events: string[] = []; + await deployContracts({ + rootDir: "/fake", + client: { + assetHub: { + apis: { + ReviveApi: { + instantiate: vi.fn(async () => ({ + result: { + success: true, + value: { + addr: "0x0000000000000000000000000000000000000001", + }, + }, + })), + }, + }, + }, + bulletin: {}, + raw: { assetHub: {}, bulletin: {} }, + descriptors: { assetHub: {} }, + } as any, + signer: {} as any, + origin: "fake-origin" as any, + registryAddress: "0xa7ae171c78f06c248a9b2556c793aa1df5c9173a", + onEvent: (event) => { + if (event.type === "build-start") events.push(`build-start:${event.crate}`); + if (event.type === "deploy-register-start") { + events.push(`deploy-start:${event.crates.join(",")}`); + } + if (event.type === "deploy-register-done") { + events.push(`deploy-done:${Object.keys(event.addresses).join(",")}`); + } + }, + }); + + expect(events.indexOf("deploy-done:a")).toBeLessThan(events.indexOf("build-start:b")); + }); + }); } diff --git a/src/lib/env/src/registry.ts b/src/lib/env/src/registry.ts index 8ff81f2..b400a1e 100644 --- a/src/lib/env/src/registry.ts +++ b/src/lib/env/src/registry.ts @@ -1,7 +1,7 @@ export type ProductSdkEnvironment = "paseo" | "previewnet"; -const PASEO_V2_REGISTRY_ADDRESS = "0x5c7b23d386ff622c7f7a4e7a95d5c7a67b10a00d"; -const PREVIEW_NET_REGISTRY_ADDRESS = "0x5c7b23d386ff622c7f7a4e7a95d5c7a67b10a00d"; +const PASEO_V2_REGISTRY_ADDRESS = "0xa7ae171c78f06c248a9b2556c793aa1df5c9173a"; +const PREVIEW_NET_REGISTRY_ADDRESS = "0xa7ae171c78f06c248a9b2556c793aa1df5c9173a"; export function getRegistryAddress(name: string): string | undefined { if (name === "paseo" || name === "paseo-next-v2" || name === "paseo-v2") { diff --git a/src/lib/scripts/deploy-registry.ts b/src/lib/scripts/deploy-registry.ts index 29575f9..08861c3 100644 --- a/src/lib/scripts/deploy-registry.ts +++ b/src/lib/scripts/deploy-registry.ts @@ -19,6 +19,7 @@ import { getChainPreset, ss58Address, } from "@dotdm/env"; +import { CONTRACTS_REGISTRY_PACKAGE } from "@dotdm/utils"; import { getAccount } from "@dotdm/utils/accounts"; import { ContractDeployer, CONTRACTS_REGISTRY_CRATE } from "@dotdm/contracts"; @@ -76,18 +77,6 @@ const deployer = new ContractDeployer( chainClient.assetHub, ); -if (selectedRegistryAddress) { - const info = await chainClient.assetHub.query.Revive.AccountInfoOf.getValue( - selectedRegistryAddress as `0x${string}`, - ); - if (info?.account_type.type === "Contract") { - console.log(`ContractRegistry already deployed at ${selectedRegistryAddress}`); - console.log(`\nCONTRACTS_REGISTRY_ADDR=${selectedRegistryAddress}`); - chainClient.destroy(); - process.exit(0); - } -} - // Map account (required on fresh chains, harmless if already mapped) try { await chainClient.assetHub.tx.Revive.map_account().signAndSubmit(signer); @@ -96,8 +85,22 @@ try { // already mapped } -const CDM_REGISTRY_PACKAGE = "@cdm/registry"; -const expected = await deployer.dryRunDeploy(pvmPath, CDM_REGISTRY_PACKAGE); +const expected = await deployer.dryRunDeploy(pvmPath, CONTRACTS_REGISTRY_PACKAGE); + +if (selectedRegistryAddress) { + const info = await chainClient.assetHub.query.Revive.AccountInfoOf.getValue( + selectedRegistryAddress as `0x${string}`, + ); + if (info?.account_type.type === "Contract") { + if (expected.address.toLowerCase() === selectedRegistryAddress.toLowerCase()) { + console.log(`ContractRegistry already deployed at ${selectedRegistryAddress}`); + console.log(`\nCONTRACTS_REGISTRY_ADDR=${selectedRegistryAddress}`); + chainClient.destroy(); + process.exit(0); + } + } +} + if ( hasExplicitRegistryAddress && selectedRegistryAddress && @@ -112,6 +115,17 @@ if ( chainClient.destroy(); process.exit(1); } + +const expectedInfo = await chainClient.assetHub.query.Revive.AccountInfoOf.getValue( + expected.address as `0x${string}`, +); +if (expectedInfo?.account_type.type === "Contract") { + console.log(`ContractRegistry already deployed at ${expected.address}`); + console.log(`\nCONTRACTS_REGISTRY_ADDR=${expected.address}`); + chainClient.destroy(); + process.exit(0); +} + if ( !hasExplicitRegistryAddress && selectedRegistryAddress && @@ -124,8 +138,8 @@ if ( } // Deploy with CREATE2 for deterministic address -console.log(`Deploying ContractRegistry (CREATE2 salt: "${CDM_REGISTRY_PACKAGE}")...`); -const { address } = await deployer.deploy(pvmPath, CDM_REGISTRY_PACKAGE); +console.log(`Deploying ContractRegistry (CREATE2 salt: "${CONTRACTS_REGISTRY_PACKAGE}")...`); +const { address } = await deployer.deploy(pvmPath, CONTRACTS_REGISTRY_PACKAGE); console.log(`\nCONTRACTS_REGISTRY_ADDR=${address}`); chainClient.destroy(); diff --git a/src/lib/utils/src/constants.ts b/src/lib/utils/src/constants.ts index 13892d7..60c99f4 100644 --- a/src/lib/utils/src/constants.ts +++ b/src/lib/utils/src/constants.ts @@ -9,7 +9,11 @@ export const STORAGE_DEPOSIT_LIMIT = 100_000_000_000_000n; // The contracts registry is the bootstrap - it's deployed first and has no CDM macro export const CONTRACTS_REGISTRY_CRATE = "contract-registry"; -// Universal ContractRegistry address — deterministic via CREATE2 with salt "@cdm/registry". +// CREATE2 salt material for ContractRegistry deployments. Bump the suffix when +// deliberately deploying a new registry generation. +export const CONTRACTS_REGISTRY_PACKAGE = "@cdm/registry.1"; + +// Universal ContractRegistry address — deterministic via CREATE2. // Same address on every chain when deployed by the same key with the same bytecode. export const REGISTRY_ADDRESS = "0xae344f7f0f91d3a2176032af2990abcc7606c7d4"; diff --git a/src/lib/utils/src/index.ts b/src/lib/utils/src/index.ts index 5c5a608..60448a5 100644 --- a/src/lib/utils/src/index.ts +++ b/src/lib/utils/src/index.ts @@ -3,6 +3,7 @@ export { GAS_LIMIT, STORAGE_DEPOSIT_LIMIT, CONTRACTS_REGISTRY_CRATE, + CONTRACTS_REGISTRY_PACKAGE, DEFAULT_NODE_URL, REGISTRY_ADDRESS, } from "./constants"; diff --git a/src/templates/instagram/cdm.json b/src/templates/instagram/cdm.json index 6e4a6df..6bfa108 100644 --- a/src/templates/instagram/cdm.json +++ b/src/templates/instagram/cdm.json @@ -3,7 +3,7 @@ "8e7e0cf0a51d8e5e": { "asset-hub": "wss://paseo-asset-hub-next-rpc.polkadot.io", "bulletin": "https://paseo-bulletin-next-ipfs.polkadot.io/ipfs", - "registry": "0x5c7b23d386ff622c7f7a4e7a95d5c7a67b10a00d" + "registry": "0xa7ae171c78f06c248a9b2556c793aa1df5c9173a" } }, "dependencies": {