From 3cd84f139520cf94858d43295b4d07e7fca4a930 Mon Sep 17 00:00:00 2001 From: Jackie Xu Date: Mon, 17 Aug 2026 15:03:34 +0200 Subject: [PATCH 1/4] feat(world,cli): add native lifecycle migrations --- .changeset/calm-worlds-migrate.md | 6 + .github/workflows/fork-release.yml | 81 ++ docs/pages/config/reference.mdx | 12 + .../internal/init-module-implementation.mdx | 52 + docs/pages/world/reference/misc.mdx | 2 +- docs/pages/world/reference/world-external.mdx | 141 ++ docs/pages/world/upgrades.mdx | 75 ++ package.json | 2 + packages/cli/package.json | 2 +- packages/cli/src/commands/set-version.ts | 4 +- packages/cli/src/deploy/common.test.ts | 8 + packages/cli/src/deploy/common.ts | 2 +- packages/cli/src/deploy/deploy.ts | 193 ++- .../deploy/ensureFunctionMigrations.test.ts | 486 +++++++ .../src/deploy/ensureFunctionMigrations.ts | 1152 +++++++++++++++++ .../cli/src/deploy/ensureFunctions.test.ts | 42 + packages/cli/src/deploy/ensureFunctions.ts | 108 +- packages/cli/src/deploy/ensureModules.test.ts | 49 + packages/cli/src/deploy/ensureModules.ts | 118 +- .../src/deploy/ensureNamespaceOwner.test.ts | 18 + .../cli/src/deploy/ensureNamespaceOwner.ts | 27 +- packages/cli/src/deploy/ensureSystems.test.ts | 186 +++ packages/cli/src/deploy/ensureSystems.ts | 478 +++++-- packages/cli/src/deploy/ensureTables.test.ts | 28 + packages/cli/src/deploy/ensureTables.ts | 75 +- .../src/deploy/functionMigrationPlan.test.ts | 350 +++++ .../cli/src/deploy/functionMigrationPlan.ts | 414 ++++++ packages/cli/src/deploy/functionPlan.test.ts | 112 ++ packages/cli/src/deploy/functionPlan.ts | 109 ++ .../cli/src/deploy/getFunctionRoutes.test.ts | 33 + packages/cli/src/deploy/getFunctionRoutes.ts | 94 ++ packages/cli/src/deploy/systemAccess.ts | 24 + packages/cli/src/runDeploy.ts | 4 +- packages/cli/src/version.ts | 14 + packages/cli/tsup.config.ts | 2 +- packages/store-sync/src/world/getFunctions.ts | 2 +- packages/world/src/IWorldErrors.sol | 54 + packages/world/src/IWorldEvents.sol | 35 + .../systems/WorldRegistrationSystemLib.sol | 223 ++++ .../interfaces/IWorldRegistrationSystem.sol | 24 + .../world/src/modules/init/InitModule.sol | 2 +- .../src/modules/init/functionSignatures.sol | 6 +- .../WorldRegistrationSystem.sol | 351 ++++- packages/world/src/version.sol | 2 +- packages/world/test/InitSystems.t.sol | 2 +- packages/world/test/SystemMigration.t.sol | 1035 +++++++++++++++ packages/world/test/World.t.sol | 6 +- packages/world/ts/config/v2/defaults.ts | 4 + packages/world/ts/config/v2/input.ts | 40 + packages/world/ts/config/v2/output.ts | 26 +- packages/world/ts/config/v2/world.test.ts | 49 + .../world/ts/protocol-snapshots/2.1.0.snap | 115 ++ packages/world/ts/protocolVersions.ts | 2 + scripts/package-fork-release.mjs | 411 ++++++ scripts/package-fork-release.test.mjs | 110 ++ 55 files changed, 6730 insertions(+), 272 deletions(-) create mode 100644 .changeset/calm-worlds-migrate.md create mode 100644 .github/workflows/fork-release.yml create mode 100644 packages/cli/src/deploy/common.test.ts create mode 100644 packages/cli/src/deploy/ensureFunctionMigrations.test.ts create mode 100644 packages/cli/src/deploy/ensureFunctionMigrations.ts create mode 100644 packages/cli/src/deploy/ensureFunctions.test.ts create mode 100644 packages/cli/src/deploy/ensureModules.test.ts create mode 100644 packages/cli/src/deploy/ensureNamespaceOwner.test.ts create mode 100644 packages/cli/src/deploy/ensureSystems.test.ts create mode 100644 packages/cli/src/deploy/ensureTables.test.ts create mode 100644 packages/cli/src/deploy/functionMigrationPlan.test.ts create mode 100644 packages/cli/src/deploy/functionMigrationPlan.ts create mode 100644 packages/cli/src/deploy/functionPlan.test.ts create mode 100644 packages/cli/src/deploy/functionPlan.ts create mode 100644 packages/cli/src/deploy/getFunctionRoutes.test.ts create mode 100644 packages/cli/src/deploy/getFunctionRoutes.ts create mode 100644 packages/cli/src/deploy/systemAccess.ts create mode 100644 packages/cli/src/version.ts create mode 100644 packages/world/test/SystemMigration.t.sol create mode 100644 packages/world/ts/protocol-snapshots/2.1.0.snap create mode 100644 scripts/package-fork-release.mjs create mode 100644 scripts/package-fork-release.test.mjs diff --git a/.changeset/calm-worlds-migrate.md b/.changeset/calm-worlds-migrate.md new file mode 100644 index 0000000000..06d6efa8db --- /dev/null +++ b/.changeset/calm-worlds-migrate.md @@ -0,0 +1,6 @@ +--- +"@latticexyz/cli": patch +"@latticexyz/world": patch +--- + +Add fail-closed, compare-and-swap lifecycle support for World function routes and Systems. Deployments can now declare exact route replacements and selector removals, permanently retire stale Systems, and reconcile approved System ID renames atomically while rejecting unknown routing state before writes. diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000..a439f364d0 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,81 @@ +name: Fork package release + +on: + push: + tags: + - "v*-floki.*" + +concurrency: + group: fork-release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_OPTIONS: "--max-old-space-size=4096" + +jobs: + release: + name: Build and release World + CLI + if: github.repository == 'Floki-Inu/mud' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - name: Checkout tagged source + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Derive package version from tag + id: release + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + if [[ "v${version}" != "${GITHUB_REF_NAME}" ]]; then + echo "Release tag must begin with v." >&2 + exit 1 + fi + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Test release packager + run: pnpm release:test-fork + + - name: Build World, CLI, and their workspace dependencies + shell: bash + run: pnpm exec turbo run build --filter=@latticexyz/cli... --force + + - name: Test forked World and CLI packages + shell: bash + run: | + pnpm --filter @latticexyz/world test + pnpm --filter @latticexyz/cli test + + - name: Package and validate release assets + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: >- + pnpm release:pack-fork -- + --version "${RELEASE_VERSION}" + --tag "${GITHUB_REF_NAME}" + --output release-assets + --repository "${GITHUB_REPOSITORY}" + + - name: Create GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + "release-assets/latticexyz-world-${RELEASE_VERSION}.tgz" \ + "release-assets/latticexyz-cli-${RELEASE_VERSION}.tgz" \ + "release-assets/SHA256SUMS" \ + --verify-tag \ + --prerelease \ + --generate-notes \ + --title "MUD ${RELEASE_VERSION}" diff --git a/docs/pages/config/reference.mdx b/docs/pages/config/reference.mdx index a656854d70..515b4fc42e 100644 --- a/docs/pages/config/reference.mdx +++ b/docs/pages/config/reference.mdx @@ -164,6 +164,18 @@ The following options are available in both single- and multiple-namespace modes Script name to execute after the deployment is complete. Defaults to `"PostDeploy"`. JSON filename, relative to project root, to write per-chain world deployment addresses. Defaults to `"worlds.json"`. Whether or not to deploy the world with an upgradeable proxy, allowing for the core implementation to be upgraded. Defaults to `false`, but [we recommend `true`](/guides/best-practices/deployment-settings). + + Explicit compare-and-swap replacements for World function routes. Each entry contains `worldSelector`, `fromSystemId`, `fromSystemFunctionSelector`, `toSystemId`, and `toSystemFunctionSelector`. The current route must match the complete declared source tuple or already match the complete destination tuple; every other state aborts before deployment writes. Multiple entries may use the same World selector to approve alternative legacy source tuples, but they must share one final destination tuple. + + + Explicit compare-and-swap removals for obsolete World functions. Each entry contains `worldSelector`, `expectedSystemId`, and `expectedSystemFunctionSelector`. A selector that is still present in the generated World ABI cannot be removed. + + + System IDs to retire after all of their selector routes have been replaced or removed. Each entry contains `systemId`. Retirement clears the active System, reverse registry, hooks, and automatic namespace access while retaining a permanent resource-ID tombstone. A retired ID cannot be registered again. The four core init Systems (AccessManagement, BalanceTransfer, BatchCall, and Registration) cannot be retired; replace their implementations at the stable IDs instead. + + + Guarded, one-time consent to replace a legacy core Registration System when a deployment needs the native compare-and-swap API, including a pending System registration/upgrade or selector lifecycle change. Set `expectedSystem` to the exact live Registration System implementation address only after reviewing that implementation. The deployer refuses the replacement if the live address differs. Omit this option for new Worlds and for older Worlds whose Registration System already exposes the complete native lifecycle API. + Deploy the World using a custom implementation. This world must implement the same interface as `World.sol` so that it can initialize core modules, etc. If you want to extend the world with new functions or override existing registered functions, we recommend using [root systems](/world/systems#root-systems). diff --git a/docs/pages/world/reference/internal/init-module-implementation.mdx b/docs/pages/world/reference/internal/init-module-implementation.mdx index b97ecf945b..aa6c655f57 100644 --- a/docs/pages/world/reference/internal/init-module-implementation.mdx +++ b/docs/pages/world/reference/internal/init-module-implementation.mdx @@ -395,6 +395,32 @@ function registerSystem(ResourceId systemId, System system, bool publicAccess) p | `system` | `System` | The system being registered | | `publicAccess` | `bool` | Flag indicating if access control check is bypassed | +#### replaceSystem + +Registers or replaces a System only when its current implementation and public-access flag match the expected state. + +```solidity +function replaceSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess +) public virtual onlyDelegatecall; +``` + +#### retireSystem + +Permanently retires an active non-core System while retaining its resource ID as a tombstone. The four core init Systems must be replaced at their stable IDs and cannot be retired. + +```solidity +function retireSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess +) public virtual onlyDelegatecall; +``` + #### registerFunctionSelector [Usage Sample](/world/function-selectors) @@ -453,6 +479,32 @@ function registerRootFunctionSelector( | ----------------------- | -------- | ---------------------------------- | | `worldFunctionSelector` | `bytes4` | The selector of the World function | +#### replaceFunctionRoute + +Replaces a World function selector's complete route after checking the exact expected route. + +```solidity +function replaceFunctionRoute( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature +) public virtual onlyDelegatecall; +``` + +#### unregisterFunctionSelector + +Unregisters a World function selector after checking the exact expected route. + +```solidity +function unregisterFunctionSelector( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector +) public virtual onlyDelegatecall; +``` + #### registerDelegation Registers a delegation for the caller diff --git a/docs/pages/world/reference/misc.mdx b/docs/pages/world/reference/misc.mdx index be7eea5f25..35f54109fb 100644 --- a/docs/pages/world/reference/misc.mdx +++ b/docs/pages/world/reference/misc.mdx @@ -104,5 +104,5 @@ Contains a constant representing the version of the World protocol. _Identifier for the current World protocol version._ ```solidity -bytes32 constant WORLD_VERSION = "2.0.2"; +bytes32 constant WORLD_VERSION = "2.1.0"; ``` diff --git a/docs/pages/world/reference/world-external.mdx b/docs/pages/world/reference/world-external.mdx index 54a9338ad6..9d8609e1e0 100644 --- a/docs/pages/world/reference/world-external.mdx +++ b/docs/pages/world/reference/world-external.mdx @@ -263,6 +263,44 @@ error World_SystemAlreadyExists(address system); | -------- | --------- | -------------------------- | | `system` | `address` | The address of the system. | +#### World_SystemAlreadyRetired + +Raised when trying to register a System at a permanently retired System ID. + +```solidity +error World_SystemAlreadyRetired(ResourceId systemId, string systemIdString); +``` + +#### World_SystemCannotBeRetired + +Raised when trying to permanently retire a protected core System. + +```solidity +error World_SystemCannotBeRetired(ResourceId systemId, string systemIdString); +``` + +#### World_SystemStateMismatch + +Raised when the current System implementation or public-access flag does not match the expected state. + +```solidity +error World_SystemStateMismatch( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + address actualSystem, + bool actualPublicAccess +); +``` + +#### World_SystemRegistryMismatch + +Raised when a System's reverse-registry entry does not match its System ID. + +```solidity +error World_SystemRegistryMismatch(address system, ResourceId expectedSystemId, ResourceId actualSystemId); +``` + #### World_FunctionSelectorAlreadyExists Raised when trying to register a function selector that already exists. @@ -291,6 +329,20 @@ error World_FunctionSelectorNotFound(bytes4 functionSelector); | ------------------ | -------- | ---------------------------------- | | `functionSelector` | `bytes4` | The function selector in question. | +#### World_FunctionSelectorMismatch + +Raised when a World function's complete route does not match the expected route. + +```solidity +error World_FunctionSelectorMismatch( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId actualSystemId, + bytes4 actualSystemFunctionSelector +); +``` + #### World_DelegationNotFound Raised when the specified delegation is not found. @@ -398,6 +450,53 @@ event HelloWorld(bytes32 indexed worldVersion); | -------------- | --------- | ---------------------------------- | | `worldVersion` | `bytes32` | The protocol version of the World. | +#### WorldFunctionRouteReplaced + +Emitted when a World function selector's complete route is replaced. + +```solidity +event WorldFunctionRouteReplaced( + bytes4 indexed worldFunctionSelector, + ResourceId indexed oldSystemId, + ResourceId indexed newSystemId, + bytes4 oldSystemFunctionSelector, + bytes4 newSystemFunctionSelector +); +``` + +#### WorldFunctionSelectorUnregistered + +Emitted when a World function selector is unregistered. + +```solidity +event WorldFunctionSelectorUnregistered( + bytes4 indexed worldFunctionSelector, + ResourceId indexed systemId, + bytes4 systemFunctionSelector +); +``` + +#### WorldSystemReplaced + +Emitted when a System is registered or replaced through the compare-and-swap primitive. + +```solidity +event WorldSystemReplaced( + ResourceId indexed systemId, + address indexed oldSystem, + address indexed newSystem, + bool publicAccess +); +``` + +#### WorldSystemRetired + +Emitted when a System is permanently retired. + +```solidity +event WorldSystemRetired(ResourceId indexed systemId, address indexed system); +``` + ## IRegistrationSystem [Git Source](https://github.com/latticexyz/mud/blob/main/packages/world/src/codegen/interfaces/IRegistrationSystem.sol) @@ -553,6 +652,26 @@ function unregisterSystemHook(ResourceId systemId, ISystemHook hookAddress) exte function registerSystem(ResourceId systemId, System system, bool publicAccess) external; ``` +#### replaceSystem + +```solidity +function replaceSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess +) external; +``` + +#### retireSystem + +Permanently retires an active non-core System. Core init System IDs remain stable and can only be replaced. + +```solidity +function retireSystem(ResourceId systemId, address expectedSystem, bool expectedPublicAccess) external; +``` + #### registerFunctionSelector ```solidity @@ -574,6 +693,28 @@ function registerRootFunctionSelector( ) external returns (bytes4 worldFunctionSelector); ``` +#### replaceFunctionRoute + +```solidity +function replaceFunctionRoute( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature +) external; +``` + +#### unregisterFunctionSelector + +```solidity +function unregisterFunctionSelector( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector +) external; +``` + #### registerDelegation ```solidity diff --git a/docs/pages/world/upgrades.mdx b/docs/pages/world/upgrades.mdx index 103f027fab..9f515e660e 100644 --- a/docs/pages/world/upgrades.mdx +++ b/docs/pages/world/upgrades.mdx @@ -7,6 +7,81 @@ The [`System`s](./systems) can be upgraded without changing the underlying `Worl However, you can also upgrade the `World` contract itself if the `World` was deployed [behind a proxy](/config#upgradeableWorldImplementation). This allows you to upgrade to a future version of MUD, but adds some gas overhead for all calls (due to one more level of indirection). +## Migrating System IDs and World selectors + +A System resource ID is part of the deployed World's ABI. Changing a System's configured name does not, by itself, update function routes that were registered under the previous ID. Declare the exact old and new routes when a rename is intentional: + +```typescript filename="mud.config.ts" copy showLineNumbers +import { resourceToHex } from "@latticexyz/common"; +import { defineWorld } from "@latticexyz/world"; +import { toFunctionSelector } from "viem"; + +const oldSystemId = resourceToHex({ type: "system", namespace: "app", name: "OldCounter" }); +const counterSystemId = resourceToHex({ type: "system", namespace: "app", name: "counter" }); +const legacyRegistrationSystem = "0x1234567890123456789012345678901234567890"; + +export default defineWorld({ + namespace: "app", + systems: { + CounterSystem: { name: "counter", openAccess: true }, + }, + deploy: { + // Required only for the first CAS-backed deploy of a legacy World whose + // Registration System does not expose the native lifecycle API. + registrationSystemMigration: { + expectedSystem: legacyRegistrationSystem, + }, + functionRouteMigrations: [ + { + worldSelector: toFunctionSelector("app__increment()"), + fromSystemId: oldSystemId, + fromSystemFunctionSelector: toFunctionSelector("increment()"), + toSystemId: counterSystemId, + toSystemFunctionSelector: toFunctionSelector("increment()"), + }, + ], + systemRetirements: [{ systemId: oldSystemId }], + }, +}); +``` + +The deployer reads the complete live selector table before writing. An exact source route is replaced, an exact destination route is an idempotent no-op, and unknown state aborts. Before retiring a System, every remaining selector that references it must have an explicit route replacement or removal. A same-bytecode rename is ordered atomically as retire the old ID, compare-and-swap the new ID from an unused state to the same implementation, then replace selector routes. + +When at least one declared retirement is still active, preflight enumerates the World's full selector history from authoritative RPC logs at a pinned block instead of trusting an indexer snapshot. This can make the first retirement deploy slower on Worlds with long histories. Once every declared retirement is absent or tombstoned, later idempotent deploys may use the configured indexer again. + +Selector route replacements and removals require the root namespace owner. System retirement requires the owner of that System's namespace. When an older World does not yet expose the native lifecycle methods, any pending System registration/upgrade or selector lifecycle change first requires replacing its core Registration System. That replacement requires an explicit `registrationSystemMigration.expectedSystem` guard matching the exact live implementation. Inspect the live `Systems` record before adding this one-time consent. A custom Registration System that already exposes the complete native lifecycle API is preserved. Once the native core is installed, later deployments ignore the guard and do not need this bootstrap authority. + + + One atomic lifecycle batch requires its submitting account to own the root namespace and every affected source and + target namespace. If those authorities are split, coordinate or transfer ownership first, or execute the deployment + through governance that holds all required permissions. The deployer will not weaken or bypass namespace + authorization. + + + + A legacy core only exposes `registerSystem`, so the first bootstrap cannot atomically compare-and-swap the + Registration System implementation. The deployer verifies `expectedSystem` during preflight, authoritatively re-reads + it at the latest block immediately before submission, batches the replacement and selector changes atomically, and + verifies the result afterward. Another transaction could still replace the core between that final read and inclusion. + Run this one-time bootstrap with an exclusive deploy key or controlled change window. Native `replaceSystem` and + `retireSystem` compare-and-swap the full pinned System tuple (implementation and public access) for all later + lifecycle writes; a future one-shot bootstrap module could remove this residual race. + + + + The bootstrap also requires the four native lifecycle function selectors to be unused or already routed exactly to + the Registration System. It fails closed instead of overwriting an unrelated route. A legacy World with one of these + selector collisions needs a reviewed manual migration; a future selector-independent bootstrap module should make + that recovery path native. + + + + Retirement is permanent. The old resource ID remains as a tombstone and cannot be reused. The four core init Systems + (AccessManagement, BalanceTransfer, BatchCall, and Registration) are protected and cannot be retired; replace their + implementations at the stable IDs instead. Treat System IDs as stable identifiers and check migration manifests into + source control whenever a rename or function removal is intentional. + + ## Making an upgradeable `World` To make a `World` upgradeable, edit the [`mud.config.ts`](/config) file and set `deploy.upgradeableWorldImplementation` to `true`. diff --git a/package.json b/package.json index af2cd1d3f4..4b8cb5fbec 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "prettier": "prettier --write '**/*.{ts,tsx,css,md,mdx,sol}'", "prettier:check": "prettier --check '**/*.{ts,tsx,css,md,mdx,sol}'", "release:check": "changeset status --verbose --since=origin/main", + "release:pack-fork": "node scripts/package-fork-release.mjs", "release:publish": "pnpm install && pnpm build && changeset publish", + "release:test-fork": "node --test scripts/package-fork-release.test.mjs", "release:version": "changeset version && pnpm install --lockfile-only && pnpm run changelog:generate", "test": "with-anvil turbo run test --concurrency=100%", "test:ci": "with-anvil turbo run test:ci --concurrency=100%", diff --git a/packages/cli/package.json b/packages/cli/package.json index ef90f88616..8ff5284a69 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "clean:test-tables": "shx rm -rf src/**/codegen", "dev": "tsup --watch", "lint": "eslint . --ext .ts", - "test": "tsc --noEmit && forge test", + "test": "tsc --noEmit && vitest --run --passWithNoTests && forge test", "test:ci": "pnpm run test" }, "dependencies": { diff --git a/packages/cli/src/commands/set-version.ts b/packages/cli/src/commands/set-version.ts index 71776ec822..b6e85df3f6 100644 --- a/packages/cli/src/commands/set-version.ts +++ b/packages/cli/src/commands/set-version.ts @@ -4,9 +4,9 @@ import path from "path"; import type { CommandModule } from "yargs"; import { MUDError } from "@latticexyz/common/errors"; import { logError } from "../utils/errors"; -import localPackageJson from "../../package.json" with { type: "json" }; import { globSync } from "glob"; import { mudPackages } from "../mudPackages"; +import { cliPackageInfo } from "../version"; type Options = { backup?: boolean; @@ -88,7 +88,7 @@ async function resolveVersion(options: Options) { let npmResult: any; try { console.log(chalk.blue(`Fetching available versions`)); - npmResult = await (await fetch(`https://registry.npmjs.org/${localPackageJson.name}`)).json(); + npmResult = await (await fetch(`https://registry.npmjs.org/${cliPackageInfo.name}`)).json(); } catch (e) { throw new MUDError(`Could not fetch available MUD versions`); } diff --git a/packages/cli/src/deploy/common.test.ts b/packages/cli/src/deploy/common.test.ts new file mode 100644 index 0000000000..169ab8afa4 --- /dev/null +++ b/packages/cli/src/deploy/common.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { supportedWorldVersions } from "./common"; + +describe("supportedWorldVersions", () => { + it("supports the native selector and System lifecycle protocol", () => { + expect(supportedWorldVersions).toContain("2.1.0"); + }); +}); diff --git a/packages/cli/src/deploy/common.ts b/packages/cli/src/deploy/common.ts index fb09812f8e..1fb2726721 100644 --- a/packages/cli/src/deploy/common.ts +++ b/packages/cli/src/deploy/common.ts @@ -10,7 +10,7 @@ export const worldAbi = IBaseWorldAbi; // Ideally, this should be an append-only list. Before adding more versions here, be sure to add backwards-compatible support for old Store/World versions. export const supportedStoreVersions = ["2.0.0", "2.0.1", "2.0.2"]; -export const supportedWorldVersions = ["2.0.0", "2.0.1", "2.0.2"]; +export const supportedWorldVersions = ["2.0.0", "2.0.1", "2.0.2", "2.1.0"]; // TODO: extend this to include factory+deployer address? so we can reuse the deployer for a world? export type WorldDeploy = { diff --git a/packages/cli/src/deploy/deploy.ts b/packages/cli/src/deploy/deploy.ts index 5ec1f617a0..ca3a06c950 100644 --- a/packages/cli/src/deploy/deploy.ts +++ b/packages/cli/src/deploy/deploy.ts @@ -1,6 +1,6 @@ import { Address, Hex, stringToHex } from "viem"; import { deployWorld } from "./deployWorld"; -import { ensureTables } from "./ensureTables"; +import { ensureTables, getTablePlan } from "./ensureTables"; import { CommonDeployOptions, Library, @@ -10,9 +10,9 @@ import { supportedStoreVersions, supportedWorldVersions, } from "./common"; -import { ensureSystems } from "./ensureSystems"; +import { assertPostLifecycleSystemStates, ensureSystems, verifySystems } from "./ensureSystems"; import { getWorldDeploy } from "./getWorldDeploy"; -import { ensureFunctions } from "./ensureFunctions"; +import { ensureFunctions, verifyFunctions } from "./ensureFunctions"; import { ensureModules } from "./ensureModules"; import { ensureNamespaceOwner } from "./ensureNamespaceOwner"; import { debug } from "./debug"; @@ -26,6 +26,20 @@ import { deployCustomWorld } from "./deployCustomWorld"; import { uniqueBy } from "@latticexyz/common/utils"; import { getLibraryMap } from "./getLibraryMap"; import { ensureContractsDeployed, ensureDeployer, waitForTransactions } from "@latticexyz/common/internal"; +import { getBlockNumber } from "viem/actions"; +import { + assertFunctionMigrationOwnership, + ensureFunctionMigrationContract, + ensureFunctionMigrations, + getFunctionMigrationSnapshot, + getRegistrationBootstrapPlan, + getSystemStates, + getSystemMigrationPlan, + hasExactNativeRegistrationFunction, + replaceSystemFunctionSignature, + validateFunctionMigrationConfig, + verifyFunctionMigrations, +} from "./ensureFunctionMigrations"; type DeployOptions = { config: World; @@ -80,23 +94,31 @@ export async function deploy({ }[]; } > { - const deployerAddress = initialDeployerAddress ?? (await ensureDeployer(client)); + // Reject malformed lifecycle declarations before deploying a new World or + // performing any RPC-dependent deployment planning. + validateFunctionMigrationConfig({ config, systems }); - const worldDeploy = existingWorldAddress - ? await getWorldDeploy(client, existingWorldAddress, worldDeployBlock) - : config.deploy.customWorld + let deployerAddress = initialDeployerAddress; + let worldDeploy: WorldDeploy; + if (existingWorldAddress) { + worldDeploy = await getWorldDeploy(client, existingWorldAddress, worldDeployBlock); + } else { + const newWorldDeployerAddress = deployerAddress ?? (await ensureDeployer(client)); + deployerAddress = newWorldDeployerAddress; + worldDeploy = config.deploy.customWorld ? await deployCustomWorld({ client, - deployerAddress, + deployerAddress: newWorldDeployerAddress, artifacts, customWorld: config.deploy.customWorld, }) : await deployWorld( client, - deployerAddress, + newWorldDeployerAddress, salt ?? `0x${randomBytes(32).toString("hex")}`, config.deploy.upgradeableWorldImplementation, ); + } const commonDeployOptions = { client, @@ -112,31 +134,131 @@ export async function deploy({ throw new Error(`Unsupported World version: ${worldDeploy.worldVersion}`); } + const functions = systems.flatMap((system) => system.worldFunctions); + const migrationSnapshot = await getFunctionMigrationSnapshot({ + ...commonDeployOptions, + config, + functions, + systems, + }); + const tablePlan = await getTablePlan({ ...commonDeployOptions, tables }); + + // For existing Worlds, do not deploy even the deterministic deployer until selector conflicts have failed closed. + const resolvedDeployerAddress = deployerAddress ?? (await ensureDeployer(client)); + const libraryMap = getLibraryMap(libraries); + const systemMigrationPlan = getSystemMigrationPlan( + migrationSnapshot.plan, + migrationSnapshot.systemStates, + systems, + resolvedDeployerAddress, + libraryMap, + migrationSnapshot.resourceAccess, + ); + const bootstrapPlan = await getRegistrationBootstrapPlan({ + client, + worldDeploy, + deployerAddress: resolvedDeployerAddress, + migrationPlan: migrationSnapshot.plan, + requiresSystemReconciliation: systemMigrationPlan.requiresSystemReconciliation, + routes: migrationSnapshot.routes, + registrationSystemMigration: config.deploy.registrationSystemMigration, + }); + await assertFunctionMigrationOwnership({ + client, + worldDeploy, + migrationPlan: migrationSnapshot.plan, + bootstrapPlan, + systemRenames: systemMigrationPlan.renames, + targetRegistrations: systemMigrationPlan.targetRegistrations, + reconciliationSystemIds: systemMigrationPlan.reconciliationSystemIds, + configuredResourceIds: [...tables.map((table) => table.tableId), ...systems.map((system) => system.systemId)], + }); + await ensureFunctionMigrationContract({ + client, + deployerAddress: resolvedDeployerAddress, + bootstrapPlan, + }); + const deployedContracts = await ensureContractsDeployed({ ...commonDeployOptions, - deployerAddress, + deployerAddress: resolvedDeployerAddress, contracts: [ ...libraries.map((library) => ({ - bytecode: library.prepareDeploy(deployerAddress, libraryMap).bytecode, + bytecode: library.prepareDeploy(resolvedDeployerAddress, libraryMap).bytecode, deployedBytecodeSize: library.deployedBytecodeSize, debugLabel: `${library.path}:${library.name} library`, })), ...systems.map((system) => ({ - bytecode: system.prepareDeploy(deployerAddress, libraryMap).bytecode, + bytecode: system.prepareDeploy(resolvedDeployerAddress, libraryMap).bytecode, deployedBytecodeSize: system.deployedBytecodeSize, debugLabel: `${resourceToLabel(system)} system`, })), ...modules.map((mod) => ({ - bytecode: mod.prepareDeploy(deployerAddress, libraryMap).bytecode, + bytecode: mod.prepareDeploy(resolvedDeployerAddress, libraryMap).bytecode, deployedBytecodeSize: mod.deployedBytecodeSize, debugLabel: `${mod.name} module`, })), ], }); + const lifecycleTargetSystemIds = [ + ...systemMigrationPlan.renames.map((rename) => rename.targetSystemId), + ...systemMigrationPlan.targetRegistrations.map((target) => target.systemId), + ]; + const lifecycleNamespaceTxs = await ensureNamespaceOwner({ + ...commonDeployOptions, + resourceIds: lifecycleTargetSystemIds, + }); + await waitForTransactions({ + client, + hashes: lifecycleNamespaceTxs, + debugLabel: "selector migration namespace registrations", + }); + + // Apply lifecycle CAS operations before unrelated table/System writes. Missing or + // upgraded selector targets are registered inside this same atomic World batch. + const migrationTxs = await ensureFunctionMigrations({ + ...commonDeployOptions, + migrationPlan: migrationSnapshot.plan, + bootstrapPlan, + systemRenames: systemMigrationPlan.renames, + targetRegistrations: systemMigrationPlan.targetRegistrations, + }); + await waitForTransactions({ + client, + hashes: migrationTxs, + debugLabel: "selector and system migrations", + }); + const postMigrationBlockNumber = await getBlockNumber(client); + const postMigrationSystemStates = await getSystemStates({ + client, + worldDeploy: { ...worldDeploy, stateBlock: postMigrationBlockNumber }, + systemIds: [...systems.map((system) => system.systemId), ...systems.flatMap((system) => system.allowedSystemIds)], + }); + assertPostLifecycleSystemStates({ + systemIds: [...systems.map((system) => system.systemId), ...systems.flatMap((system) => system.allowedSystemIds)], + originalStates: migrationSnapshot.systemStates, + currentStates: postMigrationSystemStates, + lifecycleTargets: [ + ...systemMigrationPlan.targetRegistrations.map((target) => ({ + systemId: target.systemId, + address: target.system, + publicAccess: target.publicAccess, + })), + ...systemMigrationPlan.renames.map((rename) => ({ + systemId: rename.targetSystemId, + address: rename.targetSystem, + publicAccess: rename.targetPublicAccess, + })), + ], + }); + const namespaceTxs = await ensureNamespaceOwner({ ...commonDeployOptions, + worldDeploy: { ...worldDeploy, stateBlock: postMigrationBlockNumber }, + indexerUrl: lifecycleNamespaceTxs.length > 0 || migrationTxs.length > 0 ? undefined : indexerUrl, + chainId: lifecycleNamespaceTxs.length > 0 || migrationTxs.length > 0 ? undefined : chainId, resourceIds: [...tables.map(({ tableId }) => tableId), ...systems.map(({ systemId }) => systemId)], }); // Wait for namespaces to be available, otherwise referencing them below may fail. @@ -144,14 +266,25 @@ export async function deploy({ await waitForTransactions({ client, hashes: namespaceTxs, debugLabel: "namespace registrations" }); const tableTxs = await ensureTables({ - ...commonDeployOptions, - tables, + client, + worldDeploy, + plan: tablePlan, }); const systemTxs = await ensureSystems({ ...commonDeployOptions, - deployerAddress, + worldDeploy: { ...worldDeploy, stateBlock: postMigrationBlockNumber }, + // A just-mined lifecycle batch may not be reflected by the indexer yet. + // Read the authoritative post-migration System state from RPC instead. + indexerUrl: migrationTxs.length > 0 ? undefined : indexerUrl, + chainId: migrationTxs.length > 0 ? undefined : chainId, + deployerAddress: resolvedDeployerAddress, libraryMap, systems, + systemStates: migrationSnapshot.systemStates, + accessSystemStates: postMigrationSystemStates, + useNativeSystemReplacement: + bootstrapPlan != null || + hasExactNativeRegistrationFunction(migrationSnapshot.routes, replaceSystemFunctionSignature), }); // Wait for tables and systems to be available, otherwise referencing their resource IDs below may fail. // This is only here because OPStack chains don't let us estimate gas with pending block tag. @@ -163,11 +296,11 @@ export async function deploy({ const functionTxs = await ensureFunctions({ ...commonDeployOptions, - functions: systems.flatMap((system) => system.worldFunctions), + plan: migrationSnapshot.plan.functionPlan, }); const moduleTxs = await ensureModules({ ...commonDeployOptions, - deployerAddress, + deployerAddress: resolvedDeployerAddress, libraryMap, modules, }); @@ -198,7 +331,7 @@ export async function deploy({ const tagTxs = await ensureResourceTags({ ...commonDeployOptions, - deployerAddress, + deployerAddress: resolvedDeployerAddress, libraryMap, tags: [...namespaceTags, ...tableTags, ...systemTags], valueToHex: stringToHex, @@ -210,6 +343,28 @@ export async function deploy({ debugLabel: "remaining transactions", }); + const latestBlockNumber = await getBlockNumber(client); + await verifySystems({ + client, + worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, + deployerAddress: resolvedDeployerAddress, + libraryMap, + systems, + }); + await verifyFunctions({ + client, + worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, + functions, + }); + await verifyFunctionMigrations({ + client, + worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, + migrationPlan: migrationSnapshot.plan, + systemRenames: systemMigrationPlan.renames, + targetRegistrations: systemMigrationPlan.targetRegistrations, + bootstrapPlan, + }); + debug("deploy complete"); return { ...worldDeploy, diff --git a/packages/cli/src/deploy/ensureFunctionMigrations.test.ts b/packages/cli/src/deploy/ensureFunctionMigrations.test.ts new file mode 100644 index 0000000000..d5fc42cf71 --- /dev/null +++ b/packages/cli/src/deploy/ensureFunctionMigrations.test.ts @@ -0,0 +1,486 @@ +import { decodeFunctionData, toFunctionSelector, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; +import { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config"; +import { resourceToHex } from "@latticexyz/common"; +import { + encodeFunctionMigrationCalls, + encodeLifecycleBatchSystemCall, + assertLegacyRegistrationBootstrapCurrent, + batchCallSystemId, + nativeRegistrationFunctionSignatures, + nativeRegistrationSystemAbi, + planRegistrationSystemBootstrap, + protectedCoreSystemIds, + requiresRegistrationBootstrap, + getConfiguredNamespaceIds, + getSystemMigrationPlan, + type RegistrationBootstrapPlan, + validateFunctionMigrationConfig, +} from "./ensureFunctionMigrations"; +import type { FunctionMigrationPlan, PlannedSystemRename } from "./functionMigrationPlan"; +import type { World } from "@latticexyz/world"; +import { worldAbi, type System } from "./common"; + +const registrationAddress = `0x${"99".repeat(20)}` as Address; +const oldRegistrationAddress = `0x${"98".repeat(20)}` as Address; +const sourceSystemId = `0x7379${"00".repeat(14)}${"11".repeat(16)}` as Hex; +const targetSystemId = `0x7379${"00".repeat(14)}${"22".repeat(16)}` as Hex; +const otherRetirementId = `0x7379${"00".repeat(14)}${"33".repeat(16)}` as Hex; +const implementation = `0x${"44".repeat(20)}` as Address; +const worldAddress = `0x${"aa".repeat(20)}` as Address; +const registrationSystemId = worldSystemsConfig.systems.RegistrationSystem.systemId; + +function nativeRegistrationRoutes() { + return nativeRegistrationFunctionSignatures.map((signature) => { + const selector = toFunctionSelector(signature); + return { selector, systemId: registrationSystemId, systemFunctionSelector: selector }; + }); +} + +function migrationPlan(): FunctionMigrationPlan { + return { + functionPlan: { toAdd: [], toSkip: [] }, + migrationsToApply: [ + { + worldSelector: "0x12345678", + fromSystemId: sourceSystemId, + fromSystemFunctionSelector: "0x87654321", + toSystemId: targetSystemId, + toSystemFunctionSelector: "0x87654321", + toSystemFunctionSignature: "run()", + }, + ], + migrationsAlreadyApplied: [], + migrationsNotApplicable: [], + removalsToApply: [ + { + worldSelector: "0xaaaaaaaa", + expectedSystemId: sourceSystemId, + expectedSystemFunctionSelector: "0xbbbbbbbb", + }, + ], + removalsAlreadyApplied: [], + retirementsToApply: [ + { systemId: sourceSystemId, expectedSystem: implementation, expectedPublicAccess: true }, + { + systemId: otherRetirementId, + expectedSystem: `0x${"55".repeat(20)}`, + expectedPublicAccess: false, + }, + ], + retirementsAlreadyApplied: [], + retirementsNotFound: [], + }; +} + +function bootstrapPlan(): RegistrationBootstrapPlan { + return { + desiredSystem: { + address: registrationAddress, + bytecode: "0x1234", + deployedBytecodeSize: 2, + debugLabel: "core registration system", + }, + expectedSystem: registrationAddress, + expectedPublicAccess: true, + upgrade: { currentSystem: oldRegistrationAddress, publicAccess: true }, + selectorsToRegister: [nativeRegistrationFunctionSignatures[0]], + selectorsToVerify: [nativeRegistrationFunctionSignatures[0]], + }; +} + +describe("encodeFunctionMigrationCalls", () => { + it("bootstraps first, then atomically frees/registers rename addresses before replacing routes", () => { + const rename: PlannedSystemRename = { + systemId: sourceSystemId, + expectedSystem: implementation, + expectedPublicAccess: true, + targetSystemId, + targetSystem: implementation, + targetPublicAccess: false, + }; + const calls = encodeFunctionMigrationCalls({ + migrationPlan: migrationPlan(), + bootstrapPlan: bootstrapPlan(), + systemRenames: [rename], + targetRegistrations: [], + }); + + const decoded = calls.map(({ callData }) => + decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: callData }), + ); + expect(decoded.map(({ functionName }) => functionName)).toEqual([ + "registerSystem", + "registerRootFunctionSelector", + "retireSystem", + "replaceSystem", + "replaceFunctionRoute", + "unregisterFunctionSelector", + "retireSystem", + ]); + expect(decoded[2]).toMatchObject({ + functionName: "retireSystem", + args: [sourceSystemId, implementation, true], + }); + expect(decoded[3]).toMatchObject({ + functionName: "replaceSystem", + args: [targetSystemId, "0x0000000000000000000000000000000000000000", false, implementation, false], + }); + expect(decoded[4]).toMatchObject({ + functionName: "replaceFunctionRoute", + args: ["0x12345678", sourceSystemId, "0x87654321", targetSystemId, "run()"], + }); + expect(decoded.at(-1)).toMatchObject({ + functionName: "retireSystem", + args: [otherRetirementId, `0x${"55".repeat(20)}`, false], + }); + }); + + it("targets the immutable kernel call path for lifecycle batches", () => { + const calls = encodeFunctionMigrationCalls({ + migrationPlan: { ...migrationPlan(), retirementsToApply: [] }, + bootstrapPlan: { ...bootstrapPlan(), upgrade: undefined, selectorsToRegister: [] }, + systemRenames: [], + targetRegistrations: [], + }); + const batch = encodeLifecycleBatchSystemCall(calls); + + expect(batch.systemId).toBe(batchCallSystemId); + expect(decodeFunctionData({ abi: worldAbi, data: batch.callData })).toMatchObject({ + functionName: "batchCall", + args: [calls], + }); + }); + + it("does not encode an implementation upgrade when the bootstrap is already current", () => { + const bootstrap = { ...bootstrapPlan(), upgrade: undefined, selectorsToRegister: [] }; + const calls = encodeFunctionMigrationCalls({ + migrationPlan: { ...migrationPlan(), retirementsToApply: [] }, + bootstrapPlan: bootstrap, + systemRenames: [], + targetRegistrations: [], + }); + + expect( + calls.map( + ({ callData }) => decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: callData }).functionName, + ), + ).toEqual(["replaceFunctionRoute", "unregisterFunctionSelector"]); + }); + + it("encodes migration target upgrades with the snapshot address as a CAS guard", () => { + const currentTarget = `0x${"66".repeat(20)}` as Address; + const desiredTarget = `0x${"77".repeat(20)}` as Address; + const calls = encodeFunctionMigrationCalls({ + migrationPlan: { ...migrationPlan(), retirementsToApply: [] }, + bootstrapPlan: { ...bootstrapPlan(), upgrade: undefined, selectorsToRegister: [] }, + systemRenames: [], + targetRegistrations: [ + { + systemId: targetSystemId, + expectedSystem: currentTarget, + expectedPublicAccess: false, + system: desiredTarget, + publicAccess: true, + }, + ], + }); + const replacement = decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: calls[0].callData }); + + expect(replacement).toMatchObject({ + functionName: "replaceSystem", + args: [targetSystemId, currentTarget, false, desiredTarget, true], + }); + }); +}); + +describe("assertLegacyRegistrationBootstrapCurrent", () => { + it("rejects a RegistrationSystem change after preflight", () => { + expect(() => + assertLegacyRegistrationBootstrapCurrent({ + bootstrapPlan: bootstrapPlan(), + registration: { system: implementation, publicAccess: true }, + }), + ).toThrowError("RegistrationSystem changed after selector migration preflight"); + + expect(() => + assertLegacyRegistrationBootstrapCurrent({ + bootstrapPlan: bootstrapPlan(), + registration: { system: oldRegistrationAddress, publicAccess: true }, + }), + ).not.toThrow(); + }); +}); + +describe("getConfiguredNamespaceIds", () => { + it("includes configured table namespaces in the pre-bootstrap ownership inventory", () => { + const tableId = resourceToHex({ type: "table", namespace: "foreign", name: "Counter" }); + const namespaceId = resourceToHex({ type: "namespace", namespace: "foreign", name: "" }); + + expect(getConfiguredNamespaceIds([tableId])).toEqual([namespaceId]); + }); +}); + +describe("getSystemMigrationPlan", () => { + it("reconciles an exact System tuple when its default namespace grant is missing", () => { + const noLifecycleWrites: FunctionMigrationPlan = { + ...migrationPlan(), + migrationsToApply: [], + removalsToApply: [], + retirementsToApply: [], + }; + const system = { + systemId: targetSystemId, + allowAll: true, + prepareDeploy: () => ({ address: implementation, bytecode: "0x" }), + deployedBytecodeSize: 0, + abi: [], + label: "target", + namespaceLabel: "", + namespace: "", + name: "target", + allowedAddresses: [], + allowedSystemIds: [], + worldFunctions: [], + metadata: { abi: [], worldAbi: [] }, + } satisfies System; + const state = { systemId: targetSystemId, exists: true, address: implementation, publicAccess: true }; + const libraryMap = { getAddress: () => implementation }; + + expect(getSystemMigrationPlan(noLifecycleWrites, [state], [system], worldAddress, libraryMap, [])).toMatchObject({ + requiresSystemReconciliation: true, + reconciliationSystemIds: [targetSystemId], + }); + + const rootNamespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); + expect( + getSystemMigrationPlan(noLifecycleWrites, [state], [system], worldAddress, libraryMap, [ + { resourceId: rootNamespaceId, address: implementation }, + ]), + ).toMatchObject({ requiresSystemReconciliation: false, reconciliationSystemIds: [] }); + }); + + it("repairs a migration target's missing namespace grant inside the lifecycle batch", () => { + const pendingMigration: FunctionMigrationPlan = { + ...migrationPlan(), + removalsToApply: [], + retirementsToApply: [], + }; + const system = { + systemId: targetSystemId, + allowAll: true, + prepareDeploy: () => ({ address: implementation, bytecode: "0x" }), + deployedBytecodeSize: 0, + abi: [], + label: "target", + namespaceLabel: "", + namespace: "", + name: "target", + allowedAddresses: [], + allowedSystemIds: [], + worldFunctions: [], + metadata: { abi: [], worldAbi: [] }, + } satisfies System; + const state = { systemId: targetSystemId, exists: true, address: implementation, publicAccess: true }; + const libraryMap = { getAddress: () => implementation }; + + expect(getSystemMigrationPlan(pendingMigration, [state], [system], worldAddress, libraryMap, [])).toMatchObject({ + targetRegistrations: [ + { + systemId: targetSystemId, + expectedSystem: implementation, + expectedPublicAccess: true, + system: implementation, + publicAccess: true, + }, + ], + }); + + const rootNamespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); + expect( + getSystemMigrationPlan(pendingMigration, [state], [system], worldAddress, libraryMap, [ + { resourceId: rootNamespaceId, address: implementation }, + ]).targetRegistrations, + ).toEqual([]); + }); +}); + +describe("planRegistrationSystemBootstrap", () => { + it("requires bootstrap planning for ordinary pending System reconciliation", () => { + const noLifecycleWrites = { + ...migrationPlan(), + migrationsToApply: [], + removalsToApply: [], + retirementsToApply: [], + }; + + expect(requiresRegistrationBootstrap(noLifecycleWrites, false)).toBe(false); + expect(requiresRegistrationBootstrap(noLifecycleWrites, true)).toBe(true); + expect(() => + planRegistrationSystemBootstrap({ + worldAddress, + desiredSystem: bootstrapPlan().desiredSystem, + registration: { system: oldRegistrationAddress, publicAccess: true }, + routes: [], + registrationSystemMigration: undefined, + }), + ).toThrowError("replacing core Systems is opt-in"); + }); + + it("preserves a custom RegistrationSystem that exposes the complete native API", () => { + const plan = planRegistrationSystemBootstrap({ + worldAddress, + desiredSystem: bootstrapPlan().desiredSystem, + registration: { system: oldRegistrationAddress, publicAccess: false }, + routes: nativeRegistrationRoutes(), + registrationSystemMigration: undefined, + }); + + expect(plan.upgrade).toBeUndefined(); + expect(plan.expectedSystem).toBe(oldRegistrationAddress); + expect(plan.expectedPublicAccess).toBe(false); + expect(plan.selectorsToRegister).toEqual([]); + expect(plan.selectorsToVerify).toEqual(nativeRegistrationFunctionSignatures); + }); + + it("requires explicit consent before replacing a legacy/custom core", () => { + const input = { + worldAddress, + desiredSystem: bootstrapPlan().desiredSystem, + registration: { system: oldRegistrationAddress, publicAccess: true }, + routes: [], + } as const; + + expect(() => planRegistrationSystemBootstrap({ ...input, registrationSystemMigration: undefined })).toThrowError( + "replacing core Systems is opt-in", + ); + expect(() => + planRegistrationSystemBootstrap({ + ...input, + registrationSystemMigration: { expectedSystem: implementation }, + }), + ).toThrowError("RegistrationSystem migration guard mismatch"); + + const plan = planRegistrationSystemBootstrap({ + ...input, + registrationSystemMigration: { expectedSystem: oldRegistrationAddress }, + }); + expect(plan.upgrade).toEqual({ currentSystem: oldRegistrationAddress, publicAccess: true }); + expect(plan.selectorsToRegister).toEqual(nativeRegistrationFunctionSignatures); + }); + + it("does not require consent for the current fork implementation", () => { + const plan = planRegistrationSystemBootstrap({ + worldAddress, + desiredSystem: bootstrapPlan().desiredSystem, + registration: { system: registrationAddress, publicAccess: true }, + routes: [], + registrationSystemMigration: undefined, + }); + + expect(plan.upgrade).toBeUndefined(); + expect(plan.selectorsToRegister).toEqual(nativeRegistrationFunctionSignatures); + }); +}); + +describe("validateFunctionMigrationConfig", () => { + function validate( + deploy: Partial, + systems: readonly Pick[] = [], + ): void { + validateFunctionMigrationConfig({ + config: { + deploy: { + functionRouteMigrations: [], + functionSelectorRemovals: [], + systemRetirements: [], + ...deploy, + }, + } as World, + systems, + }); + } + + it("rejects malformed selectors before planning", () => { + expect(() => + validate({ + functionRouteMigrations: [ + { + worldSelector: "0x1234" as Hex, + fromSystemId: sourceSystemId, + fromSystemFunctionSelector: "0x87654321", + toSystemId: targetSystemId, + toSystemFunctionSelector: "0x87654321", + }, + ], + }), + ).toThrowError("worldSelector must be exactly 4 bytes"); + }); + + it("rejects non-System resource IDs and zero IDs", () => { + expect(() => validate({ systemRetirements: [{ systemId: `0x${"00".repeat(32)}` }] })).toThrowError( + "must be nonzero", + ); + + expect(() => validate({ systemRetirements: [{ systemId: `0x7462${"00".repeat(30)}` }] })).toThrowError( + "must be a System resource ID", + ); + }); + + it.each(protectedCoreSystemIds)("rejects retirement of core System %s", (systemId) => { + expect(() => validate({ systemRetirements: [{ systemId }] })).toThrowError("core System"); + }); + + it("rejects case-insensitive duplicate selector entries", () => { + expect(() => + validate({ + functionSelectorRemovals: [ + { + worldSelector: "0xaabbccdd", + expectedSystemId: sourceSystemId, + expectedSystemFunctionSelector: "0x12345678", + }, + { + worldSelector: "0xAABBCCDD", + expectedSystemId: sourceSystemId, + expectedSystemFunctionSelector: "0x12345678", + }, + ], + }), + ).toThrowError("duplicate selector removal"); + }); + + it("rejects malformed or zero RegistrationSystem migration guards", () => { + expect(() => validate({ registrationSystemMigration: { expectedSystem: "0x1234" as Address } })).toThrowError( + "expectedSystem must be exactly 20 bytes", + ); + expect(() => + validate({ + registrationSystemMigration: { + expectedSystem: "0x0000000000000000000000000000000000000000", + }, + }), + ).toThrowError("expectedSystem must be nonzero"); + }); + + it("rejects access-list references to a System being retired", () => { + const grantee = { + systemId: targetSystemId, + allowedSystemIds: [sourceSystemId], + }; + + expect(() => validate({ systemRetirements: [{ systemId: sourceSystemId }] }, [grantee])).toThrowError( + "is also declared in systemRetirements", + ); + }); + + it("allows access to a distinct configured migration target while retiring its legacy source", () => { + const grantee = { + systemId: otherRetirementId, + allowedSystemIds: [targetSystemId], + }; + const target = { systemId: targetSystemId, allowedSystemIds: [] }; + + expect(() => validate({ systemRetirements: [{ systemId: sourceSystemId }] }, [grantee, target])).not.toThrow(); + }); +}); diff --git a/packages/cli/src/deploy/ensureFunctionMigrations.ts b/packages/cli/src/deploy/ensureFunctionMigrations.ts new file mode 100644 index 0000000000..441318d6ed --- /dev/null +++ b/packages/cli/src/deploy/ensureFunctionMigrations.ts @@ -0,0 +1,1152 @@ +import type { Address, Hex } from "viem"; +import { encodeFunctionData, getAddress, toFunctionSelector, zeroAddress } from "viem"; +import { getBlockNumber } from "viem/actions"; +import { hexToResource, resourceToHex, writeContract } from "@latticexyz/common"; +import { ensureContractsDeployed } from "@latticexyz/common/internal"; +import storeConfig from "@latticexyz/store/mud.config"; +import worldConfig, { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config"; +import type { World } from "@latticexyz/world"; +import type { CommonDeployOptions, System, WorldFunction } from "./common"; +import { worldAbi } from "./common"; +import { debug } from "./debug"; +import { + type DesiredSystem, + type FunctionMigrationPlan, + type PlannedSystemRename, + planFunctionMigrations, + planSystemRenames, + type SystemState, +} from "./functionMigrationPlan"; +import type { FunctionRoute } from "./functionPlan"; +import { getAllFunctionRoutes, getFunctionRoutes } from "./getFunctionRoutes"; +import { getResourceAccess } from "./getResourceAccess"; +import { hasSystemNamespaceGrant, type SystemAccess } from "./systemAccess"; +import { getRecord } from "./getRecord"; +import { getWorldContracts } from "./getWorldContracts"; +import type { LibraryMap } from "./getLibraryMap"; + +export const registrationSystemId = worldSystemsConfig.systems.RegistrationSystem.systemId; +export const batchCallSystemId = worldSystemsConfig.systems.BatchCallSystem.systemId; +export const accessManagementSystemId = worldSystemsConfig.systems.AccessManagementSystem.systemId; +export const balanceTransferSystemId = worldSystemsConfig.systems.BalanceTransferSystem.systemId; +export const protectedCoreSystemIds = [ + accessManagementSystemId, + balanceTransferSystemId, + batchCallSystemId, + registrationSystemId, +] as const; + +export const nativeRegistrationFunctionSignatures = [ + "replaceFunctionRoute(bytes4,bytes32,bytes4,bytes32,string)", + "unregisterFunctionSelector(bytes4,bytes32,bytes4)", + "retireSystem(bytes32,address,bool)", + "replaceSystem(bytes32,address,bool,address,bool)", +] as const; + +export const replaceSystemFunctionSignature = nativeRegistrationFunctionSignatures[3]; + +export function hasExactNativeRegistrationFunction( + routes: readonly FunctionRoute[], + signature: (typeof nativeRegistrationFunctionSignatures)[number], +): boolean { + const selector = toFunctionSelector(signature); + return routes.some( + (route) => + sameHex(route.selector, selector) && + sameHex(route.systemId, registrationSystemId) && + sameHex(route.systemFunctionSelector, selector), + ); +} + +export const nativeRegistrationSystemAbi = [ + { + type: "function", + name: "registerNamespace", + inputs: [{ name: "namespaceId", type: "bytes32" }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "registerTable", + inputs: [ + { name: "tableId", type: "bytes32" }, + { name: "fieldLayout", type: "bytes32" }, + { name: "keySchema", type: "bytes32" }, + { name: "valueSchema", type: "bytes32" }, + { name: "keyNames", type: "string[]" }, + { name: "fieldNames", type: "string[]" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "registerSystem", + inputs: [ + { name: "systemId", type: "bytes32" }, + { name: "system", type: "address" }, + { name: "publicAccess", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "replaceSystem", + inputs: [ + { name: "systemId", type: "bytes32" }, + { name: "expectedSystem", type: "address" }, + { name: "expectedPublicAccess", type: "bool" }, + { name: "system", type: "address" }, + { name: "publicAccess", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "registerFunctionSelector", + inputs: [ + { name: "systemId", type: "bytes32" }, + { name: "systemFunctionSignature", type: "string" }, + ], + outputs: [{ name: "worldFunctionSelector", type: "bytes4" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "registerRootFunctionSelector", + inputs: [ + { name: "systemId", type: "bytes32" }, + { name: "worldFunctionSignature", type: "string" }, + { name: "systemFunctionSignature", type: "string" }, + ], + outputs: [{ name: "worldFunctionSelector", type: "bytes4" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "replaceFunctionRoute", + inputs: [ + { name: "worldFunctionSelector", type: "bytes4" }, + { name: "expectedFromSystemId", type: "bytes32" }, + { name: "expectedSystemFunctionSelector", type: "bytes4" }, + { name: "newSystemId", type: "bytes32" }, + { name: "newSystemFunctionSignature", type: "string" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "unregisterFunctionSelector", + inputs: [ + { name: "worldFunctionSelector", type: "bytes4" }, + { name: "expectedSystemId", type: "bytes32" }, + { name: "expectedSystemFunctionSelector", type: "bytes4" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "retireSystem", + inputs: [ + { name: "systemId", type: "bytes32" }, + { name: "expectedSystem", type: "address" }, + { name: "expectedPublicAccess", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const; + +export type FunctionMigrationSnapshot = { + readonly routes: readonly FunctionRoute[]; + readonly systemStates: readonly SystemState[]; + readonly resourceAccess: readonly SystemAccess[]; + readonly plan: FunctionMigrationPlan; +}; + +export type RegistrationBootstrapPlan = { + readonly desiredSystem: ReturnType["RegistrationSystem"]; + readonly expectedSystem: Address; + readonly expectedPublicAccess: boolean; + readonly upgrade?: { + readonly currentSystem: Address; + readonly publicAccess: boolean; + }; + readonly selectorsToRegister: readonly (typeof nativeRegistrationFunctionSignatures)[number][]; + readonly selectorsToVerify: readonly (typeof nativeRegistrationFunctionSignatures)[number][]; +}; + +export type DirectSystemCall = { + readonly systemId: Hex; + readonly callData: Hex; +}; + +export type PlannedMigrationTargetRegistration = { + readonly systemId: Hex; + readonly expectedSystem: Address; + readonly expectedPublicAccess: boolean; + readonly system: Address; + readonly publicAccess: boolean; +}; + +export type SystemMigrationPlan = { + readonly renames: readonly PlannedSystemRename[]; + readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; + readonly requiresSystemReconciliation: boolean; + readonly reconciliationSystemIds: readonly Hex[]; +}; + +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +function sameHex(a: Hex, b: Hex): boolean { + return normalizeHex(a) === normalizeHex(b); +} + +function configError(message: string): never { + throw new Error(`Invalid selector lifecycle config: ${message}`); +} + +function assertBytes(value: Hex, bytes: number, label: string): void { + const pattern = new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`); + if (!pattern.test(value)) configError(`${label} must be exactly ${bytes} bytes, received ${String(value)}.`); +} + +function assertSystemId(value: Hex, label: string): void { + assertBytes(value, 32, label); + if (/^0x0{64}$/i.test(value)) configError(`${label} must be nonzero.`); + let type: string; + try { + type = hexToResource(value).type; + } catch { + configError(`${label} is not a valid MUD resource ID.`); + } + if (type !== "system") configError(`${label} must be a System resource ID, received type ${type}.`); +} + +function addUniqueConfigValue(values: Set, value: Hex, label: string): void { + const normalized = normalizeHex(value); + if (values.has(normalized)) configError(`duplicate ${label} ${value}.`); + values.add(normalized); +} + +/** Validate lifecycle config shapes locally, before any RPC-dependent planning. */ +export function validateFunctionMigrationConfig({ + config, + systems, +}: { + readonly config: World; + readonly systems: readonly Pick[]; +}): void { + const migrationSelectors = new Map< + string, + { + readonly toSystemId: Hex; + readonly toSystemFunctionSelector: Hex; + readonly sourceTuples: Set; + } + >(); + for (const [index, migration] of config.deploy.functionRouteMigrations.entries()) { + const label = `functionRouteMigrations[${index}]`; + assertBytes(migration.worldSelector, 4, `${label}.worldSelector`); + assertBytes(migration.fromSystemFunctionSelector, 4, `${label}.fromSystemFunctionSelector`); + assertBytes(migration.toSystemFunctionSelector, 4, `${label}.toSystemFunctionSelector`); + assertSystemId(migration.fromSystemId, `${label}.fromSystemId`); + assertSystemId(migration.toSystemId, `${label}.toSystemId`); + if ( + sameHex(migration.fromSystemId, migration.toSystemId) && + sameHex(migration.fromSystemFunctionSelector, migration.toSystemFunctionSelector) + ) { + configError(`${label} must use different source and destination route tuples.`); + } + const selector = normalizeHex(migration.worldSelector); + const group = migrationSelectors.get(selector); + if (group == null) { + migrationSelectors.set(selector, { + toSystemId: migration.toSystemId, + toSystemFunctionSelector: migration.toSystemFunctionSelector, + sourceTuples: new Set([ + `${normalizeHex(migration.fromSystemId)}/${normalizeHex(migration.fromSystemFunctionSelector)}`, + ]), + }); + } else { + if ( + !sameHex(group.toSystemId, migration.toSystemId) || + !sameHex(group.toSystemFunctionSelector, migration.toSystemFunctionSelector) + ) { + configError(`${label} must share the same final destination tuple as its selector alternatives.`); + } + const source = `${normalizeHex(migration.fromSystemId)}/${normalizeHex(migration.fromSystemFunctionSelector)}`; + if (group.sourceTuples.has(source)) { + configError( + `duplicate migration source ${migration.fromSystemId}/${migration.fromSystemFunctionSelector} for selector ${migration.worldSelector}.`, + ); + } + group.sourceTuples.add(source); + } + } + + const removalSelectors = new Set(); + for (const [index, removal] of config.deploy.functionSelectorRemovals.entries()) { + const label = `functionSelectorRemovals[${index}]`; + assertBytes(removal.worldSelector, 4, `${label}.worldSelector`); + assertBytes(removal.expectedSystemFunctionSelector, 4, `${label}.expectedSystemFunctionSelector`); + assertSystemId(removal.expectedSystemId, `${label}.expectedSystemId`); + addUniqueConfigValue(removalSelectors, removal.worldSelector, "selector removal"); + if (migrationSelectors.has(normalizeHex(removal.worldSelector))) { + configError(`selector ${removal.worldSelector} cannot be both migrated and removed.`); + } + } + + const retirementIds = new Set(); + for (const [index, retirement] of config.deploy.systemRetirements.entries()) { + const label = `systemRetirements[${index}].systemId`; + assertSystemId(retirement.systemId, label); + addUniqueConfigValue(retirementIds, retirement.systemId, "System retirement"); + if (protectedCoreSystemIds.some((systemId) => sameHex(retirement.systemId, systemId))) { + configError(`core System ${retirement.systemId} cannot be retired.`); + } + } + + const desiredSystemIds = new Set(); + for (const [index, system] of systems.entries()) { + assertSystemId(system.systemId, `systems[${index}].systemId`); + addUniqueConfigValue(desiredSystemIds, system.systemId, "configured System ID"); + for (const [allowedIndex, allowedSystemId] of system.allowedSystemIds.entries()) { + const label = `systems[${index}].allowedSystemIds[${allowedIndex}]`; + assertSystemId(allowedSystemId, label); + if (retirementIds.has(normalizeHex(allowedSystemId))) { + configError( + `${label} references ${allowedSystemId}, which is also declared in systemRetirements. Point the access list at the distinct configured migration target ID instead.`, + ); + } + } + } + + const registrationMigration = config.deploy.registrationSystemMigration; + if (registrationMigration != null) { + assertBytes(registrationMigration.expectedSystem, 20, "registrationSystemMigration.expectedSystem"); + if (sameHex(registrationMigration.expectedSystem, zeroAddress)) { + configError("registrationSystemMigration.expectedSystem must be nonzero."); + } + } +} + +function hasNativeWrites(plan: FunctionMigrationPlan): boolean { + return plan.migrationsToApply.length > 0 || plan.removalsToApply.length > 0 || plan.retirementsToApply.length > 0; +} + +export function requiresRegistrationBootstrap( + migrationPlan: FunctionMigrationPlan, + requiresSystemReconciliation: boolean, +): boolean { + return hasNativeWrites(migrationPlan) || requiresSystemReconciliation; +} + +export async function getSystemStates({ + client, + worldDeploy, + systemIds, +}: Pick & { + readonly systemIds: readonly Hex[]; +}): Promise { + const uniqueSystemIds = [...new Map(systemIds.map((systemId) => [normalizeHex(systemId), systemId])).values()]; + + return Promise.all( + uniqueSystemIds.map(async (systemId): Promise => { + const [resource, system] = await Promise.all([ + getRecord({ + client, + worldDeploy, + table: storeConfig.namespaces.store.tables.ResourceIds, + key: { resourceId: systemId }, + }), + getRecord({ + client, + worldDeploy, + table: worldConfig.namespaces.world.tables.Systems, + key: { systemId }, + }), + ]); + + return { + systemId, + exists: resource.exists, + address: system.system, + publicAccess: system.publicAccess, + }; + }), + ); +} + +/** Read and classify all configured selector lifecycle changes without writing. */ +export async function getFunctionMigrationSnapshot({ + config, + functions, + systems, + ...options +}: CommonDeployOptions & { + readonly config: World; + readonly functions: readonly WorldFunction[]; + readonly systems: readonly System[]; +}): Promise { + validateFunctionMigrationConfig({ config, systems }); + + const explicitSelectors = [ + ...functions.map((func) => func.selector), + ...config.deploy.functionRouteMigrations.map((migration) => migration.worldSelector), + ...config.deploy.functionSelectorRemovals.map((removal) => removal.worldSelector), + ...nativeRegistrationFunctionSignatures.map((signature) => toFunctionSelector(signature)), + ]; + + const [systemStates, resourceAccess] = await Promise.all([ + getSystemStates({ + client: options.client, + worldDeploy: options.worldDeploy, + systemIds: [ + ...config.deploy.systemRetirements.map((retirement) => retirement.systemId), + ...config.deploy.functionRouteMigrations.map((migration) => migration.fromSystemId), + ...systems.map((system) => system.systemId), + ...systems.flatMap((system) => system.allowedSystemIds), + ], + }), + // Default System namespace grants affect whether an exact registration is usable. + // Read them authoritatively because this snapshot gates the core bootstrap plan. + getResourceAccess({ client: options.client, worldDeploy: options.worldDeploy }), + ]); + const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); + const hasActiveRetirement = config.deploy.systemRetirements.some(({ systemId }) => { + const state = statesById.get(normalizeHex(systemId)); + return state?.exists === true && !sameHex(state.address, zeroAddress); + }); + const routes = await getAllFunctionRoutes({ + ...options, + additionalSelectors: explicitSelectors, + // A lagging indexer can omit an otherwise-unplanned route and make a destructive + // retirement look safe. Active retirements therefore inventory RPC logs directly. + authoritative: hasActiveRetirement, + }); + + for (const state of systemStates) { + if (!state.exists && (!sameHex(state.address, zeroAddress) || state.publicAccess)) { + throw new Error( + `World System tables are inconsistent for ${state.systemId}: a nonzero System tuple exists without a ResourceId.`, + ); + } + } + for (const system of systems) { + const state = statesById.get(normalizeHex(system.systemId)); + if (state?.exists && sameHex(state.address, zeroAddress)) { + throw new Error( + [ + `Configured System ${system.systemId} is permanently retired on this World.`, + "Its ResourceId is a tombstone and registerSystem cannot resurrect it.", + "Choose a new System resource ID and declare explicit selector migrations from the retired ID.", + ].join("\n"), + ); + } + } + + const plan = planFunctionMigrations({ + functions, + routes, + migrations: config.deploy.functionRouteMigrations, + removals: config.deploy.functionSelectorRemovals, + retirements: config.deploy.systemRetirements, + systemStates, + desiredSystems: systems, + }); + + return { routes, systemStates, resourceAccess, plan }; +} + +/** Classify the live core without RPC so replacement consent can be unit tested. */ +export function planRegistrationSystemBootstrap({ + worldAddress, + desiredSystem, + registration, + routes, + registrationSystemMigration, +}: { + readonly worldAddress: Address; + readonly desiredSystem: ReturnType["RegistrationSystem"]; + readonly registration: { readonly system: Address; readonly publicAccess: boolean }; + readonly routes: readonly FunctionRoute[]; + readonly registrationSystemMigration: World["deploy"]["registrationSystemMigration"]; +}): RegistrationBootstrapPlan { + if (sameHex(registration.system, zeroAddress)) { + throw new Error("The World has no active core RegistrationSystem, so selector migrations cannot be bootstrapped."); + } + + const routeBySelector = new Map(routes.map((route) => [normalizeHex(route.selector), route])); + const exactNativeSelectors = new Set(); + const missingNativeSelectors: (typeof nativeRegistrationFunctionSignatures)[number][] = []; + for (const signature of nativeRegistrationFunctionSignatures) { + const selector = toFunctionSelector(signature); + const route = routeBySelector.get(normalizeHex(selector)); + if (route == null) { + missingNativeSelectors.push(signature); + continue; + } + if (sameHex(route.systemId, registrationSystemId) && sameHex(route.systemFunctionSelector, selector)) { + exactNativeSelectors.add(signature); + continue; + } + + // TODO: Replace this manual-recovery boundary with a selector-independent, + // one-shot bootstrap module that can install the native lifecycle API. + throw new Error( + [ + `Native registration selector ${signature} (${selector}) is already routed unexpectedly.`, + `Expected: ${registrationSystemId}/${selector}`, + `Current: ${route.systemId}/${route.systemFunctionSelector}`, + ].join("\n"), + ); + } + + const isForkRegistrationSystem = getAddress(registration.system) === getAddress(desiredSystem.address); + const hasAnyNativeSelector = exactNativeSelectors.size > 0; + const hasCompleteNativeApi = exactNativeSelectors.size === nativeRegistrationFunctionSignatures.length; + + let upgrade: RegistrationBootstrapPlan["upgrade"]; + let selectorsToRegister: RegistrationBootstrapPlan["selectorsToRegister"]; + if (isForkRegistrationSystem) { + selectorsToRegister = missingNativeSelectors; + } else if (hasCompleteNativeApi) { + // Respect a custom/already-native RegistrationSystem. Its exact public routes + // are our capability marker; do not replace it merely due to address drift. + selectorsToRegister = []; + } else { + if (registrationSystemMigration == null) { + throw new Error( + [ + `World ${worldAddress} requires a legacy RegistrationSystem bootstrap, but replacing core Systems is opt-in.`, + `Live RegistrationSystem: ${registration.system}`, + ...(hasAnyNativeSelector + ? [ + "The live implementation exposes only part of the native lifecycle API:", + ...missingNativeSelectors.map((signature) => `- missing ${signature}`), + ] + : []), + "Set deploy.registrationSystemMigration.expectedSystem to this exact address after reviewing the core replacement.", + ].join("\n"), + ); + } + if (getAddress(registration.system) !== getAddress(registrationSystemMigration.expectedSystem)) { + throw new Error( + [ + "RegistrationSystem migration guard mismatch.", + `Configured expectedSystem: ${registrationSystemMigration.expectedSystem}`, + `Live RegistrationSystem: ${registration.system}`, + "Refusing to replace an unexpected core System.", + ].join("\n"), + ); + } + upgrade = { currentSystem: registration.system, publicAccess: registration.publicAccess }; + selectorsToRegister = missingNativeSelectors; + } + + return { + desiredSystem, + expectedSystem: upgrade == null ? registration.system : desiredSystem.address, + expectedPublicAccess: registration.publicAccess, + upgrade, + selectorsToRegister, + selectorsToVerify: nativeRegistrationFunctionSignatures, + }; +} + +/** + * Plan the legacy RegistrationSystem bootstrap after the deterministic deployer is known. + * Native methods are invoked through World.call, while their public World selectors are + * registered for future operators as part of the same atomic batch. + */ +export async function getRegistrationBootstrapPlan({ + client, + worldDeploy, + deployerAddress, + migrationPlan, + requiresSystemReconciliation, + routes, + registrationSystemMigration, +}: Pick & { + readonly deployerAddress: Hex; + readonly migrationPlan: FunctionMigrationPlan; + readonly requiresSystemReconciliation: boolean; + readonly routes: readonly FunctionRoute[]; + readonly registrationSystemMigration: World["deploy"]["registrationSystemMigration"]; +}): Promise { + if (!requiresRegistrationBootstrap(migrationPlan, requiresSystemReconciliation)) return undefined; + + const desiredSystem = getWorldContracts(deployerAddress).RegistrationSystem; + const [registration, batchCallSystem] = await Promise.all([ + getRecord({ + client, + worldDeploy, + table: worldConfig.namespaces.world.tables.Systems, + key: { systemId: registrationSystemId }, + }), + getRecord({ + client, + worldDeploy, + table: worldConfig.namespaces.world.tables.Systems, + key: { systemId: batchCallSystemId }, + }), + ]); + const bootstrapPlan = planRegistrationSystemBootstrap({ + worldAddress: worldDeploy.address, + desiredSystem, + registration, + routes, + registrationSystemMigration, + }); + const requiresBatchCall = + hasNativeWrites(migrationPlan) || bootstrapPlan.upgrade != null || bootstrapPlan.selectorsToRegister.length > 0; + if (requiresBatchCall && sameHex(batchCallSystem.system, zeroAddress)) { + throw new Error( + `The core BatchCallSystem ${batchCallSystemId} is inactive, so the selector lifecycle transaction cannot be submitted safely.`, + ); + } + return bootstrapPlan; +} + +/** Assert every authority needed by the planned atomic transaction before contract deployment. */ +export async function assertFunctionMigrationOwnership({ + client, + worldDeploy, + migrationPlan, + bootstrapPlan, + systemRenames, + targetRegistrations, + reconciliationSystemIds, + configuredResourceIds, +}: Pick & { + readonly migrationPlan: FunctionMigrationPlan; + readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; + readonly systemRenames: readonly PlannedSystemRename[]; + readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; + readonly reconciliationSystemIds: readonly Hex[]; + readonly configuredResourceIds: readonly Hex[]; +}): Promise { + const namespaces = new Map(); + const addNamespace = (namespaceId: Hex, allowMissing: boolean): void => { + const key = normalizeHex(namespaceId); + const existing = namespaces.get(key); + namespaces.set(key, { namespaceId, allowMissing: (existing?.allowMissing ?? true) && allowMissing }); + }; + const needsRoot = + migrationPlan.migrationsToApply.length > 0 || + migrationPlan.removalsToApply.length > 0 || + bootstrapPlan?.upgrade != null || + (bootstrapPlan?.selectorsToRegister.length ?? 0) > 0; + + if (needsRoot) { + const rootNamespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); + addNamespace(rootNamespaceId, false); + } + for (const retirement of migrationPlan.retirementsToApply) { + const namespace = hexToResource(retirement.systemId).namespace; + const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); + addNamespace(namespaceId, false); + } + for (const rename of systemRenames) { + const namespace = hexToResource(rename.targetSystemId).namespace; + const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); + addNamespace(namespaceId, true); + } + for (const target of targetRegistrations) { + const namespace = hexToResource(target.systemId).namespace; + const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); + addNamespace(namespaceId, true); + } + for (const systemId of reconciliationSystemIds) { + const namespace = hexToResource(systemId).namespace; + const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); + addNamespace(namespaceId, true); + } + for (const namespaceId of getConfiguredNamespaceIds(configuredResourceIds)) { + addNamespace(namespaceId, true); + } + + const unauthorized = ( + await Promise.all( + [...namespaces.values()].map(async ({ namespaceId, allowMissing }) => { + const [resource, namespace] = await Promise.all([ + getRecord({ + client, + worldDeploy, + table: storeConfig.namespaces.store.tables.ResourceIds, + key: { resourceId: namespaceId }, + }), + getRecord({ + client, + worldDeploy, + table: worldConfig.namespaces.world.tables.NamespaceOwner, + key: { namespaceId }, + }), + ]); + if (!resource.exists && allowMissing) return undefined; + if (!resource.exists) return { namespaceId, owner: namespace.owner, missing: true }; + return getAddress(namespace.owner) === getAddress(client.account.address) + ? undefined + : { namespaceId, owner: namespace.owner, missing: false }; + }), + ) + ).filter((entry): entry is { namespaceId: Hex; owner: Address; missing: boolean } => entry != null); + + if (unauthorized.length > 0) { + throw new Error( + [ + "The deployment signer does not own every namespace required by the selector migration:", + ...unauthorized.map(({ namespaceId, owner, missing }) => + missing ? `- ${namespaceId} does not exist` : `- ${namespaceId} is owned by ${owner}`, + ), + ].join("\n"), + ); + } +} + +/** Resolve every configured table/System resource to the namespace that must be owned before bootstrap writes. */ +export function getConfiguredNamespaceIds(resourceIds: readonly Hex[]): readonly Hex[] { + return [ + ...new Map( + resourceIds.map((resourceId) => { + const namespace = hexToResource(resourceId).namespace; + const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); + return [normalizeHex(namespaceId), namespaceId] as const; + }), + ).values(), + ]; +} + +export async function ensureFunctionMigrationContract({ + client, + deployerAddress, + bootstrapPlan, +}: Pick & { + readonly deployerAddress: Hex; + readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; +}): Promise { + if (bootstrapPlan?.upgrade == null) return; + await ensureContractsDeployed({ + client, + deployerAddress, + contracts: [bootstrapPlan.desiredSystem], + }); +} + +/** Purely encode the ordered direct System calls executed inside World.batchCall. */ +export function encodeFunctionMigrationCalls({ + migrationPlan, + bootstrapPlan, + systemRenames, + targetRegistrations, +}: { + readonly migrationPlan: FunctionMigrationPlan; + readonly bootstrapPlan: RegistrationBootstrapPlan; + readonly systemRenames: readonly PlannedSystemRename[]; + readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; +}): readonly DirectSystemCall[] { + const calls: DirectSystemCall[] = []; + + if (bootstrapPlan.upgrade != null) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "registerSystem", + args: [registrationSystemId, bootstrapPlan.desiredSystem.address, bootstrapPlan.upgrade.publicAccess], + }), + }); + } + + for (const signature of bootstrapPlan.selectorsToRegister) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "registerRootFunctionSelector", + args: [registrationSystemId, signature, signature], + }), + }); + } + + // A same-implementation rename must release the SystemRegistry entry before + // registering that address at the unused destination ID. Selector rows can + // safely continue to reference the source tombstone until the later calls. + for (const rename of systemRenames) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "retireSystem", + args: [rename.systemId, rename.expectedSystem, rename.expectedPublicAccess], + }), + }); + } + for (const target of targetRegistrations) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "replaceSystem", + args: [target.systemId, target.expectedSystem, target.expectedPublicAccess, target.system, target.publicAccess], + }), + }); + } + for (const rename of systemRenames) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "replaceSystem", + args: [rename.targetSystemId, zeroAddress, false, rename.targetSystem, rename.targetPublicAccess], + }), + }); + } + + for (const migration of migrationPlan.migrationsToApply) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "replaceFunctionRoute", + args: [ + migration.worldSelector, + migration.fromSystemId, + migration.fromSystemFunctionSelector, + migration.toSystemId, + migration.toSystemFunctionSignature, + ], + }), + }); + } + + for (const removal of migrationPlan.removalsToApply) { + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "unregisterFunctionSelector", + args: [removal.worldSelector, removal.expectedSystemId, removal.expectedSystemFunctionSelector], + }), + }); + } + + const renamedSourceIds = new Set(systemRenames.map((rename) => normalizeHex(rename.systemId))); + for (const retirement of migrationPlan.retirementsToApply) { + if (renamedSourceIds.has(normalizeHex(retirement.systemId))) continue; + calls.push({ + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "retireSystem", + args: [retirement.systemId, retirement.expectedSystem, retirement.expectedPublicAccess], + }), + }); + } + + return calls; +} + +/** Bound the legacy registerSystem TOCTOU window immediately before submission. */ +export function assertLegacyRegistrationBootstrapCurrent({ + bootstrapPlan, + registration, +}: { + readonly bootstrapPlan: RegistrationBootstrapPlan; + readonly registration: { readonly system: Address; readonly publicAccess: boolean }; +}): void { + if (bootstrapPlan.upgrade == null) return; + if ( + !sameHex(registration.system, bootstrapPlan.upgrade.currentSystem) || + registration.publicAccess !== bootstrapPlan.upgrade.publicAccess + ) { + throw new Error( + [ + "RegistrationSystem changed after selector migration preflight.", + `Expected: ${bootstrapPlan.upgrade.currentSystem} (publicAccess=${String(bootstrapPlan.upgrade.publicAccess)})`, + `Current: ${registration.system} (publicAccess=${String(registration.publicAccess)})`, + "Refusing to submit the legacy bootstrap batch; re-run deployment against the new state.", + ].join("\n"), + ); + } +} + +/** Encode the kernel-directed batch call without relying on the mutable public alias. */ +export function encodeLifecycleBatchSystemCall(calls: readonly DirectSystemCall[]): DirectSystemCall { + return { + systemId: batchCallSystemId, + callData: encodeFunctionData({ + abi: worldAbi, + functionName: "batchCall", + args: [calls], + }), + }; +} + +export async function ensureFunctionMigrations({ + client, + worldDeploy, + migrationPlan, + bootstrapPlan, + systemRenames, + targetRegistrations, +}: Pick & { + readonly migrationPlan: FunctionMigrationPlan; + readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; + readonly systemRenames: readonly PlannedSystemRename[]; + readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; +}): Promise { + if (bootstrapPlan == null) return []; + + const calls = encodeFunctionMigrationCalls({ + migrationPlan, + bootstrapPlan, + systemRenames, + targetRegistrations, + }); + if (calls.length === 0) return []; + + debug( + "applying selector lifecycle batch:", + `${migrationPlan.migrationsToApply.length} route replacements,`, + `${migrationPlan.removalsToApply.length} removals,`, + `${migrationPlan.retirementsToApply.length} system retirements`, + ); + + if (bootstrapPlan.upgrade != null) { + const latestBlockNumber = await getBlockNumber(client); + const registration = await getRecord({ + client, + worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, + table: worldConfig.namespaces.world.tables.Systems, + key: { systemId: registrationSystemId }, + }); + assertLegacyRegistrationBootstrapCurrent({ bootstrapPlan, registration }); + } + + const batchCall = encodeLifecycleBatchSystemCall(calls); + return [ + await writeContract(client, { + chain: client.chain ?? null, + address: worldDeploy.address, + abi: worldAbi, + functionName: "call", + args: [batchCall.systemId, batchCall.callData], + }), + ]; +} + +export async function verifyFunctionMigrations({ + client, + worldDeploy, + migrationPlan, + systemRenames, + targetRegistrations, + bootstrapPlan, +}: Pick & { + readonly migrationPlan: FunctionMigrationPlan; + readonly systemRenames: readonly PlannedSystemRename[]; + readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; + readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; +}): Promise { + const selectors = [ + ...migrationPlan.migrationsToApply.map((migration) => migration.worldSelector), + ...migrationPlan.migrationsAlreadyApplied.map((migration) => migration.worldSelector), + ...migrationPlan.removalsToApply.map((removal) => removal.worldSelector), + ...migrationPlan.removalsAlreadyApplied.map((removal) => removal.worldSelector), + ]; + const routes = await getFunctionRoutes({ client, worldDeploy, selectors }); + const routesBySelector = new Map(routes.map((route) => [normalizeHex(route.selector), route])); + + for (const migration of [...migrationPlan.migrationsToApply, ...migrationPlan.migrationsAlreadyApplied]) { + const route = routesBySelector.get(normalizeHex(migration.worldSelector)); + if ( + route == null || + !sameHex(route.systemId, migration.toSystemId) || + !sameHex(route.systemFunctionSelector, migration.toSystemFunctionSelector) + ) { + throw new Error(`Selector migration verification failed for ${migration.worldSelector}.`); + } + } + for (const removal of [...migrationPlan.removalsToApply, ...migrationPlan.removalsAlreadyApplied]) { + if (routesBySelector.has(normalizeHex(removal.worldSelector))) { + throw new Error(`Selector removal verification failed for ${removal.worldSelector}.`); + } + } + + const retired = await getSystemStates({ + client, + worldDeploy, + systemIds: [ + ...migrationPlan.retirementsToApply.map((retirement) => retirement.systemId), + ...migrationPlan.retirementsAlreadyApplied.map((retirement) => retirement.systemId), + ...migrationPlan.retirementsNotFound.map((retirement) => retirement.systemId), + ], + }); + for (const state of retired) { + if (state.exists && !sameHex(state.address, zeroAddress)) { + throw new Error(`System retirement verification failed for ${state.systemId}.`); + } + } + + const renameTargets = await getSystemStates({ + client, + worldDeploy, + systemIds: systemRenames.map((rename) => rename.targetSystemId), + }); + for (const rename of systemRenames) { + const target = renameTargets.find((state) => sameHex(state.systemId, rename.targetSystemId)); + if ( + target == null || + !target.exists || + !sameHex(target.address, rename.targetSystem) || + target.publicAccess !== rename.targetPublicAccess + ) { + throw new Error(`System rename verification failed for target ${rename.targetSystemId}.`); + } + } + + const registeredTargets = await getSystemStates({ + client, + worldDeploy, + systemIds: targetRegistrations.map((target) => target.systemId), + }); + for (const expected of targetRegistrations) { + const actual = registeredTargets.find((state) => sameHex(state.systemId, expected.systemId)); + if ( + actual == null || + !actual.exists || + !sameHex(actual.address, expected.system) || + actual.publicAccess !== expected.publicAccess + ) { + throw new Error(`Migration target System verification failed for ${expected.systemId}.`); + } + } + + if (bootstrapPlan != null) { + const [registration] = await getSystemStates({ + client, + worldDeploy, + systemIds: [registrationSystemId], + }); + if ( + registration == null || + !registration.exists || + !sameHex(registration.address, bootstrapPlan.expectedSystem) || + registration.publicAccess !== bootstrapPlan.expectedPublicAccess + ) { + throw new Error("RegistrationSystem bootstrap verification failed."); + } + + const nativeRoutes = await getFunctionRoutes({ + client, + worldDeploy, + selectors: bootstrapPlan.selectorsToVerify.map((signature) => toFunctionSelector(signature)), + }); + const nativeRoutesBySelector = new Map(nativeRoutes.map((route) => [normalizeHex(route.selector), route])); + for (const signature of bootstrapPlan.selectorsToVerify) { + const selector = toFunctionSelector(signature); + const route = nativeRoutesBySelector.get(normalizeHex(selector)); + if ( + route == null || + !sameHex(route.systemId, registrationSystemId) || + !sameHex(route.systemFunctionSelector, selector) + ) { + throw new Error(`Native RegistrationSystem selector verification failed for ${signature}.`); + } + } + } +} + +export function getSystemMigrationPlan( + migrationPlan: FunctionMigrationPlan, + systemStates: readonly SystemState[], + systems: readonly System[], + deployerAddress: Hex, + libraryMap: LibraryMap, + resourceAccess: readonly SystemAccess[], +): SystemMigrationPlan { + const desiredSystems: DesiredSystem[] = systems.map((system) => ({ + systemId: system.systemId, + address: system.prepareDeploy(deployerAddress, libraryMap).address, + publicAccess: system.allowAll, + })); + const renames = planSystemRenames(migrationPlan, desiredSystems, systemStates); + const renameTargetIds = new Set(renames.map((rename) => normalizeHex(rename.targetSystemId))); + const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); + const desiredById = new Map(desiredSystems.map((system) => [normalizeHex(system.systemId), system])); + const targetRegistrations: PlannedMigrationTargetRegistration[] = []; + const reconciliationSystemIds = desiredSystems.flatMap((desired) => { + const state = statesById.get(normalizeHex(desired.systemId)); + const hasDefaultNamespaceAccess = hasSystemNamespaceGrant({ + systemId: desired.systemId, + systemAddress: desired.address, + worldAccess: resourceAccess, + }); + const needsReconciliation = + state == null || + !state.exists || + !sameHex(state.address, desired.address) || + state.publicAccess !== desired.publicAccess || + !hasDefaultNamespaceAccess; + return needsReconciliation ? [desired.systemId] : []; + }); + const requiresSystemReconciliation = reconciliationSystemIds.length > 0; + + const pendingTargetIds = new Map( + migrationPlan.migrationsToApply.map((migration) => [normalizeHex(migration.toSystemId), migration.toSystemId]), + ); + for (const targetSystemId of pendingTargetIds.values()) { + if (renameTargetIds.has(normalizeHex(targetSystemId))) continue; + const desired = desiredById.get(normalizeHex(targetSystemId)); + const state = statesById.get(normalizeHex(targetSystemId)); + if (desired == null || state == null) { + throw new Error(`Missing configured deployment or preflight state for migration target ${targetSystemId}.`); + } + if (state.exists && sameHex(state.address, zeroAddress)) { + throw new Error(`Migration target ${targetSystemId} is a permanent retirement tombstone.`); + } + const hasDefaultNamespaceAccess = hasSystemNamespaceGrant({ + systemId: targetSystemId, + systemAddress: desired.address, + worldAccess: resourceAccess, + }); + if ( + !sameHex(state.address, desired.address) || + state.publicAccess !== desired.publicAccess || + !hasDefaultNamespaceAccess + ) { + const occupyingSource = systemStates.find( + (candidate) => + candidate.exists && + !sameHex(candidate.systemId, targetSystemId) && + sameHex(candidate.address, desired.address), + ); + if (occupyingSource != null) { + throw new Error( + [ + `Migration target implementation ${desired.address} is still registered at ${occupyingSource.systemId}.`, + "Declare that source System in systemRetirements so it can be atomically renamed.", + ].join("\n"), + ); + } + targetRegistrations.push({ + systemId: desired.systemId, + expectedSystem: state.address, + expectedPublicAccess: state.publicAccess, + system: desired.address, + publicAccess: desired.publicAccess, + }); + } + } + + return { renames, targetRegistrations, requiresSystemReconciliation, reconciliationSystemIds }; +} diff --git a/packages/cli/src/deploy/ensureFunctions.test.ts b/packages/cli/src/deploy/ensureFunctions.test.ts new file mode 100644 index 0000000000..9e17bf0019 --- /dev/null +++ b/packages/cli/src/deploy/ensureFunctions.test.ts @@ -0,0 +1,42 @@ +import { decodeFunctionData, toFunctionSelector } from "viem"; +import { describe, expect, it } from "vitest"; +import { resourceToHex } from "@latticexyz/common"; +import type { WorldFunction } from "./common"; +import { encodeFunctionRegistrationCall } from "./ensureFunctions"; +import { nativeRegistrationSystemAbi, registrationSystemId } from "./ensureFunctionMigrations"; + +function worldFunction(namespace: string): WorldFunction { + const systemFunctionSignature = "run()"; + const signature = namespace === "" ? systemFunctionSignature : `${namespace}__${systemFunctionSignature}`; + return { + signature, + selector: toFunctionSelector(signature), + systemId: resourceToHex({ type: "system", namespace, name: "Runner" }), + systemFunctionSignature, + systemFunctionSelector: toFunctionSelector(systemFunctionSignature), + }; +} + +describe("encodeFunctionRegistrationCall", () => { + it("encodes namespaced function registration for direct RegistrationSystem dispatch", () => { + const func = worldFunction("app"); + const call = encodeFunctionRegistrationCall(func); + + expect(call.systemId).toBe(registrationSystemId); + expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ + functionName: "registerFunctionSelector", + args: [func.systemId, func.systemFunctionSignature], + }); + }); + + it("encodes root function registration for direct RegistrationSystem dispatch", () => { + const func = worldFunction(""); + const call = encodeFunctionRegistrationCall(func); + + expect(call.systemId).toBe(registrationSystemId); + expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ + functionName: "registerRootFunctionSelector", + args: [func.systemId, func.systemFunctionSignature, func.systemFunctionSignature], + }); + }); +}); diff --git a/packages/cli/src/deploy/ensureFunctions.ts b/packages/cli/src/deploy/ensureFunctions.ts index 74bc4db0c7..3da3d0df42 100644 --- a/packages/cli/src/deploy/ensureFunctions.ts +++ b/packages/cli/src/deploy/ensureFunctions.ts @@ -1,69 +1,78 @@ -import { Hex } from "viem"; +import { encodeFunctionData, type Hex } from "viem"; import { hexToResource, writeContract } from "@latticexyz/common"; -import { getFunctions } from "@latticexyz/store-sync/world"; import { CommonDeployOptions, WorldFunction, worldAbi } from "./common"; import { debug } from "./debug"; import pRetry from "p-retry"; +import { assertFunctionPlanApplied, FunctionRegistrationPlan, planFunctionRegistrations } from "./functionPlan"; +import { getFunctionRoutes } from "./getFunctionRoutes"; +import { nativeRegistrationSystemAbi, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; -export async function ensureFunctions({ +export function encodeFunctionRegistrationCall(func: WorldFunction): DirectSystemCall { + const { namespace } = hexToResource(func.systemId); + const callData = + namespace === "" + ? encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "registerRootFunctionSelector", + args: [ + func.systemId, + // use system function signature as world signature + func.systemFunctionSignature, + func.systemFunctionSignature, + ], + }) + : encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "registerFunctionSelector", + args: [func.systemId, func.systemFunctionSignature], + }); + return { systemId: registrationSystemId, callData }; +} + +export async function getFunctionPlan({ client, worldDeploy, functions, - indexerUrl, - chainId, -}: CommonDeployOptions & { +}: Pick & { readonly functions: readonly WorldFunction[]; -}): Promise { - const worldFunctions = await getFunctions({ +}): Promise { + const registeredRoutes = await getFunctionRoutes({ client, - worldAddress: worldDeploy.address, - fromBlock: worldDeploy.deployBlock, - toBlock: worldDeploy.stateBlock, - indexerUrl, - chainId, + worldDeploy, + selectors: functions.map((func) => func.selector), }); - const worldSelectorToFunction = Object.fromEntries(worldFunctions.map((func) => [func.selector, func])); + return planFunctionRegistrations(functions, registeredRoutes); +} - const toSkip = functions.filter((func) => worldSelectorToFunction[func.selector]); - const toAdd = functions.filter((func) => !toSkip.includes(func)); +export async function verifyFunctions({ + client, + worldDeploy, + functions, +}: Pick & { + readonly functions: readonly WorldFunction[]; +}): Promise { + const plan = await getFunctionPlan({ client, worldDeploy, functions }); + assertFunctionPlanApplied(plan); +} - if (toSkip.length) { - debug("functions already registered:", toSkip.map((func) => func.signature).join(", ")); - const wrongSystem = toSkip.filter((func) => func.systemId !== worldSelectorToFunction[func.selector]?.systemId); - if (wrongSystem.length) { - console.warn( - "found", - wrongSystem.length, - "functions already registered but pointing at a different system ID:", - wrongSystem.map((func) => func.signature).join(", "), - ); - } +export async function ensureFunctions({ + client, + worldDeploy, + plan, +}: CommonDeployOptions & { + readonly plan: FunctionRegistrationPlan; +}): Promise { + if (plan.toSkip.length) { + debug("functions already registered:", plan.toSkip.map((func) => func.signature).join(", ")); } - if (!toAdd.length) return []; + if (!plan.toAdd.length) return []; - debug("registering functions:", toAdd.map((func) => func.signature).join(", ")); + debug("registering functions:", plan.toAdd.map((func) => func.signature).join(", ")); return Promise.all( - toAdd.map((func) => { - const { namespace } = hexToResource(func.systemId); - - // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) - const params = - namespace === "" - ? ({ - functionName: "registerRootFunctionSelector", - args: [ - func.systemId, - // use system function signature as world signature - func.systemFunctionSignature, - func.systemFunctionSignature, - ], - } as const) - : ({ - functionName: "registerFunctionSelector", - args: [func.systemId, func.systemFunctionSignature], - } as const); + plan.toAdd.map((func) => { + const call = encodeFunctionRegistrationCall(func); return pRetry( () => @@ -71,7 +80,8 @@ export async function ensureFunctions({ chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - ...params, + functionName: "call", + args: [call.systemId, call.callData], }), { retries: 3, diff --git a/packages/cli/src/deploy/ensureModules.test.ts b/packages/cli/src/deploy/ensureModules.test.ts new file mode 100644 index 0000000000..553862851a --- /dev/null +++ b/packages/cli/src/deploy/ensureModules.test.ts @@ -0,0 +1,49 @@ +import { decodeFunctionData, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; +import { worldAbi } from "./common"; +import { encodeModuleInstallationCall, registrationSystemAbi } from "./ensureModules"; +import { batchCallSystemId, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; + +const moduleAddress = `0x${"11".repeat(20)}` as Address; +const installData = "0x1234" as Hex; + +describe("encodeModuleInstallationCall", () => { + it("dispatches ordinary installs directly to RegistrationSystem", () => { + const call = encodeModuleInstallationCall({ moduleAddress, installData, installStrategy: "default" }); + + expect(call.functionName).toBe("call"); + expect(call.args[0]).toBe(registrationSystemId); + expect(decodeFunctionData({ abi: registrationSystemAbi, data: call.args[1] })).toMatchObject({ + functionName: "installModule", + args: [moduleAddress, installData], + }); + }); + + it("dispatches delegated install batches directly to BatchCallSystem", () => { + const call = encodeModuleInstallationCall({ moduleAddress, installData, installStrategy: "delegation" }); + + expect(call.functionName).toBe("call"); + expect(call.args[0]).toBe(batchCallSystemId); + const batch = decodeFunctionData({ abi: worldAbi, data: call.args[1] }); + expect(batch.functionName).toBe("batchCall"); + if (batch.functionName !== "batchCall") throw new Error("Expected batchCall encoding"); + const [systemCalls] = batch.args as readonly [readonly DirectSystemCall[]]; + expect(systemCalls.map(({ systemId }) => systemId)).toEqual([ + registrationSystemId, + registrationSystemId, + registrationSystemId, + ]); + expect( + systemCalls.map( + ({ callData }) => decodeFunctionData({ abi: registrationSystemAbi, data: callData }).functionName, + ), + ).toEqual(["registerDelegation", "installModule", "unregisterDelegation"]); + }); + + it("uses the immutable World kernel for root module installs", () => { + expect(encodeModuleInstallationCall({ moduleAddress, installData, installStrategy: "root" })).toEqual({ + functionName: "installRootModule", + args: [moduleAddress, installData], + }); + }); +}); diff --git a/packages/cli/src/deploy/ensureModules.ts b/packages/cli/src/deploy/ensureModules.ts index 1dd10f4231..69f770ad92 100644 --- a/packages/cli/src/deploy/ensureModules.ts +++ b/packages/cli/src/deploy/ensureModules.ts @@ -1,4 +1,4 @@ -import { Client, Transport, Chain, Account, Hex, BaseError } from "viem"; +import { Client, Transport, Chain, Account, Hex, BaseError, Address, encodeFunctionData } from "viem"; import { resourceToHex, writeContract } from "@latticexyz/common"; import { Module, WorldDeploy, worldAbi } from "./common"; import { debug } from "./debug"; @@ -7,7 +7,70 @@ import pRetry from "p-retry"; import { LibraryMap } from "./getLibraryMap"; import { ensureContractsDeployed } from "@latticexyz/common/internal"; import { encodeSystemCalls } from "@latticexyz/world/internal"; -import { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config"; +import { batchCallSystemId, registrationSystemId } from "./ensureFunctionMigrations"; + +export function encodeModuleInstallationCall({ + moduleAddress, + installData, + installStrategy, +}: { + readonly moduleAddress: Address; + readonly installData: Hex; + readonly installStrategy: Module["installStrategy"]; +}): + | { readonly functionName: "installRootModule"; readonly args: readonly [Address, Hex] } + | { readonly functionName: "call"; readonly args: readonly [Hex, Hex] } { + if (installStrategy === "root") { + // installRootModule is implemented by the immutable World kernel itself. + return { functionName: "installRootModule", args: [moduleAddress, installData] }; + } + + if (installStrategy === "delegation") { + const [calls] = encodeSystemCalls([ + { + abi: registrationSystemAbi, + systemId: registrationSystemId, + functionName: "registerDelegation", + args: [moduleAddress, unlimitedDelegationControlId, "0x"], + }, + { + abi: registrationSystemAbi, + systemId: registrationSystemId, + functionName: "installModule", + args: [moduleAddress, installData], + }, + { + abi: registrationSystemAbi, + systemId: registrationSystemId, + functionName: "unregisterDelegation", + args: [moduleAddress], + }, + ]); + return { + functionName: "call", + args: [ + batchCallSystemId, + encodeFunctionData({ + abi: worldAbi, + functionName: "batchCall", + args: [calls], + }), + ], + }; + } + + return { + functionName: "call", + args: [ + registrationSystemId, + encodeFunctionData({ + abi: registrationSystemAbi, + functionName: "installModule", + args: [moduleAddress, installData], + }), + ], + }; +} export async function ensureModules({ client, @@ -42,48 +105,11 @@ export async function ensureModules({ async () => { try { const moduleAddress = mod.prepareDeploy(deployerAddress, libraryMap).address; - - // TODO: fix strong types for world ABI etc - // TODO: add return types to get better type safety - const params = (() => { - if (mod.installStrategy === "root") { - return { - functionName: "installRootModule", - args: [moduleAddress, mod.installData], - } as const; - } - - if (mod.installStrategy === "delegation") { - return { - functionName: "batchCall", - args: encodeSystemCalls([ - { - abi: registrationSystemAbi, - systemId: registrationSystemId, - functionName: "registerDelegation", - args: [moduleAddress, unlimitedDelegationControlId, "0x"], - }, - { - abi: registrationSystemAbi, - systemId: registrationSystemId, - functionName: "installModule", - args: [moduleAddress, mod.installData], - }, - { - abi: registrationSystemAbi, - systemId: registrationSystemId, - functionName: "unregisterDelegation", - args: [moduleAddress], - }, - ]), - } as const; - } - - return { - functionName: "installModule", - args: [moduleAddress, mod.installData], - } as const; - })(); + const params = encodeModuleInstallationCall({ + moduleAddress, + installData: mod.installData, + installStrategy: mod.installStrategy, + }); return await writeContract(client, { chain: client.chain ?? null, @@ -118,11 +144,9 @@ export async function ensureModules({ // TODO: export from world const unlimitedDelegationControlId = resourceToHex({ type: "system", namespace: "", name: "unlimited" }); -const registrationSystemId = worldSystemsConfig.systems.RegistrationSystem.systemId; - // world/src/modules/init/RegistrationSystem.sol // TODO: import from world once we fix strongly typed JSON imports -const registrationSystemAbi = [ +export const registrationSystemAbi = [ { type: "function", name: "installModule", diff --git a/packages/cli/src/deploy/ensureNamespaceOwner.test.ts b/packages/cli/src/deploy/ensureNamespaceOwner.test.ts new file mode 100644 index 0000000000..eef8b37ca4 --- /dev/null +++ b/packages/cli/src/deploy/ensureNamespaceOwner.test.ts @@ -0,0 +1,18 @@ +import { decodeFunctionData } from "viem"; +import { describe, expect, it } from "vitest"; +import { resourceToHex } from "@latticexyz/common"; +import { encodeNamespaceRegistrationCall } from "./ensureNamespaceOwner"; +import { nativeRegistrationSystemAbi, registrationSystemId } from "./ensureFunctionMigrations"; + +describe("encodeNamespaceRegistrationCall", () => { + it("encodes namespace registration for direct RegistrationSystem dispatch", () => { + const namespaceId = resourceToHex({ type: "namespace", namespace: "app", name: "" }); + const call = encodeNamespaceRegistrationCall(namespaceId); + + expect(call.systemId).toBe(registrationSystemId); + expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ + functionName: "registerNamespace", + args: [namespaceId], + }); + }); +}); diff --git a/packages/cli/src/deploy/ensureNamespaceOwner.ts b/packages/cli/src/deploy/ensureNamespaceOwner.ts index 4250b113e0..271174ac3d 100644 --- a/packages/cli/src/deploy/ensureNamespaceOwner.ts +++ b/packages/cli/src/deploy/ensureNamespaceOwner.ts @@ -1,10 +1,22 @@ -import { Hex, getAddress } from "viem"; +import { encodeFunctionData, type Hex, getAddress } from "viem"; import { CommonDeployOptions, worldAbi } from "./common"; import { hexToResource, resourceToHex, writeContract } from "@latticexyz/common"; import { getResourceIds } from "./getResourceIds"; import { getTableValue } from "./getTableValue"; import { debug } from "./debug"; import worldConfig from "@latticexyz/world/mud.config"; +import { nativeRegistrationSystemAbi, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; + +export function encodeNamespaceRegistrationCall(namespaceId: Hex): DirectSystemCall { + return { + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "registerNamespace", + args: [namespaceId], + }), + }; +} export async function ensureNamespaceOwner({ client, @@ -57,15 +69,16 @@ export async function ensureNamespaceOwner({ debug("registering namespaces:", Array.from(missingNamespaces).join(", ")); } const registrationTxs = Promise.all( - missingNamespaces.map((namespace) => - writeContract(client, { + missingNamespaces.map((namespace) => { + const call = encodeNamespaceRegistrationCall(resourceToHex({ namespace, type: "namespace", name: "" })); + return writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "registerNamespace", - args: [resourceToHex({ namespace, type: "namespace", name: "" })], - }), - ), + functionName: "call", + args: [call.systemId, call.callData], + }); + }), ); return registrationTxs; diff --git a/packages/cli/src/deploy/ensureSystems.test.ts b/packages/cli/src/deploy/ensureSystems.test.ts new file mode 100644 index 0000000000..f696a440fb --- /dev/null +++ b/packages/cli/src/deploy/ensureSystems.test.ts @@ -0,0 +1,186 @@ +import { decodeFunctionData, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; +import { resourceToHex } from "@latticexyz/common"; +import { accessManagementSystemId, nativeRegistrationSystemAbi } from "./ensureFunctionMigrations"; +import { + accessManagementSystemAbi, + assertNativeSystemReplacementAvailable, + assertPostLifecycleSystemStates, + encodeAccessManagementCall, + encodeSystemRegistrationCallData, + getSystemAccessDiff, + resolveAllowedSystemAddress, +} from "./ensureSystems"; +import { hasSystemNamespaceGrant } from "./systemAccess"; + +const systemId = `0x7379${"00".repeat(14)}${"11".repeat(16)}` as Hex; +const desiredSystem = `0x${"22".repeat(20)}` as Address; + +describe("encodeSystemRegistrationCallData", () => { + it("uses zero as the compare-and-swap expectation for an unused System ID", () => { + const call = decodeFunctionData({ + abi: nativeRegistrationSystemAbi, + data: encodeSystemRegistrationCallData({ + systemId, + expectedSystem: "0x0000000000000000000000000000000000000000", + expectedPublicAccess: false, + system: desiredSystem, + publicAccess: true, + }), + }); + + expect(call).toMatchObject({ + functionName: "replaceSystem", + args: [systemId, "0x0000000000000000000000000000000000000000", false, desiredSystem, true], + }); + }); + + it("uses the pinned implementation as the expectation for an upgrade", () => { + const currentSystem = `0x${"33".repeat(20)}` as Address; + const call = decodeFunctionData({ + abi: nativeRegistrationSystemAbi, + data: encodeSystemRegistrationCallData({ + systemId, + expectedSystem: currentSystem, + expectedPublicAccess: true, + system: desiredSystem, + publicAccess: false, + }), + }); + + expect(call).toMatchObject({ + functionName: "replaceSystem", + args: [systemId, currentSystem, true, desiredSystem, false], + }); + }); +}); + +describe("encodeAccessManagementCall", () => { + it.each(["grantAccess", "revokeAccess"] as const)( + "dispatches %s directly to the immutable AccessManagementSystem ID", + (functionName) => { + const grantee = `0x${"66".repeat(20)}` as Address; + const call = encodeAccessManagementCall({ functionName, resourceId: systemId, grantee }); + + expect(call.systemId).toBe(accessManagementSystemId); + expect(decodeFunctionData({ abi: accessManagementSystemAbi, data: call.callData })).toMatchObject({ + functionName, + args: [systemId, grantee], + }); + }, + ); +}); + +describe("getSystemAccessDiff", () => { + it("removes an authoritative extra grant omitted from the desired config", () => { + const desired = `0x${"66".repeat(20)}` as Address; + const stale = `0x${"77".repeat(20)}` as Address; + + expect( + getSystemAccessDiff({ + currentAccess: [ + { resourceId: systemId, address: desired }, + { resourceId: systemId, address: stale }, + ], + desiredAccess: [{ resourceId: systemId, address: desired }], + }), + ).toEqual({ + accessToAdd: [], + accessToRemove: [{ resourceId: systemId, address: stale }], + }); + }); +}); + +describe("hasSystemNamespaceGrant", () => { + it("requires the desired implementation's automatic namespace grant", () => { + const namespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); + + expect( + hasSystemNamespaceGrant({ + systemId, + systemAddress: desiredSystem, + worldAccess: [{ resourceId: namespaceId, address: desiredSystem }], + }), + ).toBe(true); + expect( + hasSystemNamespaceGrant({ + systemId, + systemAddress: desiredSystem, + worldAccess: [{ resourceId: namespaceId, address: `0x${"88".repeat(20)}` }], + }), + ).toBe(false); + }); +}); + +describe("resolveAllowedSystemAddress", () => { + it("never resolves a tombstoned System to address(0)", () => { + expect(() => + resolveAllowedSystemAddress({ + granteeSystemId: systemId, + allowedSystemId: `0x7379${"00".repeat(14)}${"44".repeat(16)}`, + worldSystemAddress: "0x0000000000000000000000000000000000000000", + desiredSystemAddress: undefined, + }), + ).toThrowError("inactive/tombstoned System"); + }); + + it("uses a configured replacement address instead of the stale active implementation", () => { + expect( + resolveAllowedSystemAddress({ + granteeSystemId: systemId, + allowedSystemId: `0x7379${"00".repeat(14)}${"44".repeat(16)}`, + worldSystemAddress: `0x${"55".repeat(20)}`, + desiredSystemAddress: desiredSystem, + }), + ).toBe(desiredSystem); + }); +}); + +describe("assertNativeSystemReplacementAvailable", () => { + it("fails closed when a legacy World drifts after an exact preflight", () => { + expect(() => + assertNativeSystemReplacementAvailable({ pendingSystems: 1, useNativeSystemReplacement: false }), + ).toThrowError("Configured System state changed after legacy bootstrap preflight"); + expect(() => + assertNativeSystemReplacementAvailable({ pendingSystems: 0, useNativeSystemReplacement: false }), + ).not.toThrow(); + }); +}); + +describe("assertPostLifecycleSystemStates", () => { + const originalSystem = `0x${"33".repeat(20)}` as Address; + const concurrentSystem = `0x${"44".repeat(20)}` as Address; + const replacementSystem = `0x${"55".repeat(20)}` as Address; + const originalState = { systemId, exists: true, address: originalSystem, publicAccess: true }; + + it("rejects late drift instead of adopting it as a new CAS expectation", () => { + expect(() => + assertPostLifecycleSystemStates({ + systemIds: [systemId], + originalStates: [originalState], + currentStates: [{ ...originalState, address: concurrentSystem }], + lifecycleTargets: [], + }), + ).toThrowError("changed unexpectedly after deployment preflight"); + }); + + it("accepts only the exact projected result for a lifecycle target", () => { + const lifecycleTargets = [{ systemId, address: replacementSystem, publicAccess: false }]; + expect(() => + assertPostLifecycleSystemStates({ + systemIds: [systemId], + originalStates: [originalState], + currentStates: [{ ...originalState, address: replacementSystem, publicAccess: false }], + lifecycleTargets, + }), + ).not.toThrow(); + expect(() => + assertPostLifecycleSystemStates({ + systemIds: [systemId], + originalStates: [originalState], + currentStates: [{ ...originalState, address: concurrentSystem, publicAccess: false }], + lifecycleTargets, + }), + ).toThrowError("changed unexpectedly after deployment preflight"); + }); +}); diff --git a/packages/cli/src/deploy/ensureSystems.ts b/packages/cli/src/deploy/ensureSystems.ts index 1cabbf2423..8ab8d09212 100644 --- a/packages/cli/src/deploy/ensureSystems.ts +++ b/packages/cli/src/deploy/ensureSystems.ts @@ -1,90 +1,340 @@ -import { Hex, getAddress, Address } from "viem"; -import { writeContract, resourceToLabel } from "@latticexyz/common"; +import { type Address, type Hex, encodeFunctionData, getAddress, zeroAddress } from "viem"; +import { resourceToLabel, writeContract } from "@latticexyz/common"; import { CommonDeployOptions, System, worldAbi } from "./common"; import { debug } from "./debug"; -import { getSystems } from "./getSystems"; import { getResourceAccess } from "./getResourceAccess"; import pRetry from "p-retry"; import { LibraryMap } from "./getLibraryMap"; import { ensureContractsDeployed } from "@latticexyz/common/internal"; +import { + accessManagementSystemId, + getSystemStates, + nativeRegistrationSystemAbi, + registrationSystemId, + type DirectSystemCall, +} from "./ensureFunctionMigrations"; +import worldConfig from "@latticexyz/world/mud.config"; +import { getRecord } from "./getRecord"; +import type { SystemState } from "./functionMigrationPlan"; +import { hasSystemNamespaceGrant, type SystemAccess } from "./systemAccess"; // TODO: move each system registration+access to batch call to be atomic +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +export function encodeSystemRegistrationCallData({ + systemId, + expectedSystem, + expectedPublicAccess, + system, + publicAccess, +}: { + readonly systemId: Hex; + readonly expectedSystem: Address; + readonly expectedPublicAccess: boolean; + readonly system: Address; + readonly publicAccess: boolean; +}): Hex { + return encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "replaceSystem", + args: [systemId, expectedSystem, expectedPublicAccess, system, publicAccess], + }); +} + +export const accessManagementSystemAbi = [ + { + type: "function", + name: "grantAccess", + inputs: [ + { name: "resourceId", type: "bytes32" }, + { name: "grantee", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "revokeAccess", + inputs: [ + { name: "resourceId", type: "bytes32" }, + { name: "grantee", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const; + +export function encodeAccessManagementCall({ + functionName, + resourceId, + grantee, +}: { + readonly functionName: "grantAccess" | "revokeAccess"; + readonly resourceId: Hex; + readonly grantee: Address; +}): DirectSystemCall { + return { + systemId: accessManagementSystemId, + callData: encodeFunctionData({ + abi: accessManagementSystemAbi, + functionName, + args: [resourceId, grantee], + }), + }; +} + +export function getSystemAccessDiff({ + currentAccess, + desiredAccess, +}: { + readonly currentAccess: readonly SystemAccess[]; + readonly desiredAccess: readonly SystemAccess[]; +}): { readonly accessToAdd: readonly SystemAccess[]; readonly accessToRemove: readonly SystemAccess[] } { + const key = (access: SystemAccess): string => `${normalizeHex(access.resourceId)}/${normalizeHex(access.address)}`; + const currentByKey = new Map(currentAccess.map((access) => [key(access), access])); + const desiredByKey = new Map(desiredAccess.map((access) => [key(access), access])); + return { + accessToAdd: [...desiredByKey].filter(([accessKey]) => !currentByKey.has(accessKey)).map(([, access]) => access), + accessToRemove: [...currentByKey].filter(([accessKey]) => !desiredByKey.has(accessKey)).map(([, access]) => access), + }; +} + +export function resolveAllowedSystemAddress({ + granteeSystemId, + allowedSystemId, + worldSystemAddress, + desiredSystemAddress, +}: { + readonly granteeSystemId: Hex; + readonly allowedSystemId: Hex; + readonly worldSystemAddress: Address | undefined; + readonly desiredSystemAddress: Address | undefined; +}): Address { + if (worldSystemAddress != null && getAddress(worldSystemAddress) === zeroAddress) { + throw new Error( + `Cannot grant ${granteeSystemId} access to inactive/tombstoned System ${allowedSystemId} (address(0)).`, + ); + } + const address = desiredSystemAddress ?? worldSystemAddress; + if (address == null || getAddress(address) === zeroAddress) { + throw new Error(`Cannot grant ${granteeSystemId} access to inactive or unregistered System ${allowedSystemId}.`); + } + return address; +} + +export function getDesiredSystemAccess({ + systems, + systemStates, + desiredSystemAddresses, +}: { + readonly systems: readonly Pick[]; + readonly systemStates: readonly SystemState[]; + readonly desiredSystemAddresses: ReadonlyMap; +}): readonly SystemAccess[] { + const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); + return [ + ...systems.flatMap((system) => + system.allowedAddresses.map((address) => ({ resourceId: system.systemId, address })), + ), + ...systems.flatMap((system) => + system.allowedSystemIds.map((allowedSystemId) => { + const key = normalizeHex(allowedSystemId); + const state = statesById.get(key); + return { + resourceId: system.systemId, + address: resolveAllowedSystemAddress({ + granteeSystemId: system.systemId, + allowedSystemId, + worldSystemAddress: state?.exists === true ? state.address : undefined, + desiredSystemAddress: desiredSystemAddresses.get(key), + }), + }; + }), + ), + ]; +} + +export function assertNativeSystemReplacementAvailable({ + pendingSystems, + useNativeSystemReplacement, +}: { + readonly pendingSystems: number; + readonly useNativeSystemReplacement: boolean; +}): void { + if (pendingSystems > 0 && !useNativeSystemReplacement) { + throw new Error( + [ + "Configured System state changed after legacy bootstrap preflight.", + "Native replaceSystem is not available, so the deployer will not overwrite the late state with registerSystem.", + "Re-run deployment against the latest World state and explicitly approve registrationSystemMigration if prompted.", + ].join("\n"), + ); + } +} + +export function assertPostLifecycleSystemStates({ + systemIds, + originalStates, + currentStates, + lifecycleTargets, +}: { + readonly systemIds: readonly Hex[]; + readonly originalStates: readonly SystemState[]; + readonly currentStates: readonly SystemState[]; + readonly lifecycleTargets: readonly { + readonly systemId: Hex; + readonly address: Address; + readonly publicAccess: boolean; + }[]; +}): void { + const originalsById = new Map(originalStates.map((state) => [normalizeHex(state.systemId), state])); + const currentById = new Map(currentStates.map((state) => [normalizeHex(state.systemId), state])); + const lifecycleTargetsById = new Map(lifecycleTargets.map((target) => [normalizeHex(target.systemId), target])); + + for (const systemId of systemIds) { + const key = normalizeHex(systemId); + const original = originalsById.get(key); + const current = currentById.get(key); + const lifecycleTarget = lifecycleTargetsById.get(key); + if (original == null || current == null) { + throw new Error(`Missing direct System state while checking post-lifecycle drift for ${systemId}.`); + } + + const expectedExists = lifecycleTarget == null ? original.exists : true; + const expectedAddress = lifecycleTarget?.address ?? original.address; + const expectedPublicAccess = lifecycleTarget?.publicAccess ?? original.publicAccess; + if ( + current.exists !== expectedExists || + getAddress(current.address) !== getAddress(expectedAddress) || + current.publicAccess !== expectedPublicAccess + ) { + throw new Error( + [ + `System ${systemId} changed unexpectedly after deployment preflight.`, + `Expected: exists=${String(expectedExists)}, address=${expectedAddress}, publicAccess=${String(expectedPublicAccess)}`, + `Current: exists=${String(current.exists)}, address=${current.address}, publicAccess=${String(current.publicAccess)}`, + "Refusing to authorize the late state as a new compare-and-swap expectation; re-run deployment.", + ].join("\n"), + ); + } + } +} + export async function ensureSystems({ client, deployerAddress, libraryMap, worldDeploy, systems, - indexerUrl, - chainId, + useNativeSystemReplacement, + systemStates, + accessSystemStates, }: CommonDeployOptions & { readonly deployerAddress: Hex; readonly libraryMap: LibraryMap; readonly systems: readonly System[]; + readonly useNativeSystemReplacement: boolean; + readonly systemStates: readonly SystemState[]; + readonly accessSystemStates: readonly SystemState[]; }): Promise { - const [worldSystems, worldAccess] = await Promise.all([ - getSystems({ client, worldDeploy, indexerUrl, chainId }), - getResourceAccess({ client, worldDeploy, indexerUrl, chainId }), - ]); + // Access reconciliation is destructive: an incomplete indexer snapshot could + // preserve a stale grant. Inventory authoritative RPC logs at the pinned block. + const worldAccess = await getResourceAccess({ client, worldDeploy }); // Register or replace systems - const existingSystems = systems.filter((system) => - worldSystems.some( - (worldSystem) => - worldSystem.systemId === system.systemId && - getAddress(worldSystem.address) === getAddress(system.prepareDeploy(deployerAddress, libraryMap).address), - ), + const systemStatesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); + const desiredSystemAddresses = new Map( + systems.map((system) => [normalizeHex(system.systemId), system.prepareDeploy(deployerAddress, libraryMap).address]), ); + const existingSystems = systems.filter((system) => { + const state = systemStatesById.get(normalizeHex(system.systemId)); + return ( + state?.exists === true && + getAddress(state.address) === getAddress(desiredSystemAddresses.get(normalizeHex(system.systemId))!) && + state.publicAccess === system.allowAll && + hasSystemNamespaceGrant({ + systemId: system.systemId, + systemAddress: desiredSystemAddresses.get(normalizeHex(system.systemId))!, + worldAccess, + }) + ); + }); if (existingSystems.length) { debug("existing systems:", existingSystems.map(resourceToLabel).join(", ")); } - const existingSystemIds = existingSystems.map((system) => system.systemId); + const existingSystemIds = new Set(existingSystems.map((system) => normalizeHex(system.systemId))); - const missingSystems = systems.filter((system) => !existingSystemIds.includes(system.systemId)); - if (!missingSystems.length) return []; + const missingSystems = systems.filter((system) => !existingSystemIds.has(normalizeHex(system.systemId))); - const systemsToUpgrade = missingSystems.filter((system) => - worldSystems.some( - (worldSystem) => - worldSystem.systemId === system.systemId && - getAddress(worldSystem.address) !== getAddress(system.prepareDeploy(deployerAddress, libraryMap).address), - ), + const systemsToUpgrade = missingSystems.filter( + (system) => systemStatesById.get(normalizeHex(system.systemId))?.exists === true, ); if (systemsToUpgrade.length) { debug("upgrading systems:", systemsToUpgrade.map(resourceToLabel).join(", ")); } const systemsToAdd = missingSystems.filter( - (system) => !worldSystems.some((worldSystem) => worldSystem.systemId === system.systemId), + (system) => systemStatesById.get(normalizeHex(system.systemId))?.exists !== true, ); if (systemsToAdd.length) { debug("registering new systems:", systemsToAdd.map(resourceToLabel).join(", ")); } + assertNativeSystemReplacementAvailable({ + pendingSystems: missingSystems.length, + useNativeSystemReplacement, + }); - await ensureContractsDeployed({ - client, - deployerAddress, - contracts: missingSystems.map((system) => ({ - bytecode: system.prepareDeploy(deployerAddress, libraryMap).bytecode, - deployedBytecodeSize: system.deployedBytecodeSize, - debugLabel: `${resourceToLabel(system)} system`, - })), + // Resolve access targets before any deployment writes. A historical System resource + // with address(0) is a permanent tombstone and must never become an access grant. + const systemIds = systems.map((system) => system.systemId); + const currentAccess = worldAccess.filter(({ resourceId }) => + systemIds.some((systemId) => normalizeHex(systemId) === normalizeHex(resourceId)), + ); + const desiredAccess = getDesiredSystemAccess({ + systems, + systemStates: accessSystemStates, + desiredSystemAddresses, }); + if (missingSystems.length > 0) { + await ensureContractsDeployed({ + client, + deployerAddress, + contracts: missingSystems.map((system) => ({ + bytecode: system.prepareDeploy(deployerAddress, libraryMap).bytecode, + deployedBytecodeSize: system.deployedBytecodeSize, + debugLabel: `${resourceToLabel(system)} system`, + })), + }); + } + const registerTxs = await Promise.all( missingSystems.map((system) => pRetry( - () => - writeContract(client, { + () => { + const desiredAddress = desiredSystemAddresses.get(normalizeHex(system.systemId))!; + const currentState = systemStatesById.get(normalizeHex(system.systemId)); + const callData = encodeSystemRegistrationCallData({ + systemId: system.systemId, + expectedSystem: currentState?.address ?? zeroAddress, + expectedPublicAccess: currentState?.publicAccess ?? false, + system: desiredAddress, + publicAccess: system.allowAll, + }); + return writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) - functionName: "registerSystem", - args: [system.systemId, system.prepareDeploy(deployerAddress, libraryMap).address, system.allowAll], - }), + // Invoke the core implementation through the immutable kernel path; + // never depend on a mutable public RegistrationSystem alias. + functionName: "call", + args: [registrationSystemId, callData], + }); + }, { retries: 3, onFailedAttempt: () => debug(`failed to register system ${resourceToLabel(system)}, retrying...`), @@ -95,39 +345,7 @@ export async function ensureSystems({ // Adjust system access - const systemIds = systems.map((system) => system.systemId); - const currentAccess = worldAccess.filter(({ resourceId }) => systemIds.includes(resourceId)); - const desiredAccess = [ - ...systems.flatMap((system) => - system.allowedAddresses.map((address) => ({ resourceId: system.systemId, address })), - ), - ...systems.flatMap((system) => - system.allowedSystemIds - .map((systemId) => ({ - resourceId: system.systemId, - address: - worldSystems.find((s) => s.systemId === systemId)?.address ?? - systems.find((s) => s.systemId === systemId)?.prepareDeploy(deployerAddress, libraryMap).address, - })) - .filter((access): access is typeof access & { address: Address } => access.address != null), - ), - ]; - - const accessToAdd = desiredAccess.filter( - (access) => - !currentAccess.some( - ({ resourceId, address }) => - resourceId === access.resourceId && getAddress(address) === getAddress(access.address), - ), - ); - - const accessToRemove = currentAccess.filter( - (access) => - !desiredAccess.some( - ({ resourceId, address }) => - resourceId === access.resourceId && getAddress(address) === getAddress(access.address), - ), - ); + const { accessToAdd, accessToRemove } = getSystemAccessDiff({ currentAccess, desiredAccess }); if (accessToRemove.length) { debug("revoking", accessToRemove.length, "access grants"); @@ -139,14 +357,20 @@ export async function ensureSystems({ const accessTxs = await Promise.all([ ...accessToRemove.map((access) => pRetry( - () => - writeContract(client, { + () => { + const call = encodeAccessManagementCall({ + functionName: "revokeAccess", + resourceId: access.resourceId, + grantee: access.address, + }); + return writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "revokeAccess", - args: [access.resourceId, access.address], - }), + functionName: "call", + args: [call.systemId, call.callData], + }); + }, { retries: 3, onFailedAttempt: () => debug("failed to revoke access, retrying..."), @@ -155,14 +379,20 @@ export async function ensureSystems({ ), ...accessToAdd.map((access) => pRetry( - () => - writeContract(client, { + () => { + const call = encodeAccessManagementCall({ + functionName: "grantAccess", + resourceId: access.resourceId, + grantee: access.address, + }); + return writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "grantAccess", - args: [access.resourceId, access.address], - }), + functionName: "call", + args: [call.systemId, call.callData], + }); + }, { retries: 3, onFailedAttempt: () => debug("failed to grant access, retrying..."), @@ -173,3 +403,87 @@ export async function ensureSystems({ return [...registerTxs, ...accessTxs]; } + +/** Verify configured System address and public access directly at one authoritative block. */ +export async function verifySystems({ + client, + worldDeploy, + deployerAddress, + libraryMap, + systems, +}: Pick & { + readonly deployerAddress: Hex; + readonly libraryMap: LibraryMap; + readonly systems: readonly System[]; +}): Promise { + const desiredSystemAddresses = new Map( + systems.map((system) => [normalizeHex(system.systemId), system.prepareDeploy(deployerAddress, libraryMap).address]), + ); + const [results, worldAccess, allowedSystemStates] = await Promise.all([ + Promise.all( + systems.map(async (system) => { + const actual = await getRecord({ + client, + worldDeploy, + table: worldConfig.namespaces.world.tables.Systems, + key: { systemId: system.systemId }, + }); + const expectedAddress = desiredSystemAddresses.get(normalizeHex(system.systemId))!; + return getAddress(actual.system) === getAddress(expectedAddress) && actual.publicAccess === system.allowAll + ? undefined + : { system, actual, expectedAddress }; + }), + ), + // Final verification must not trust an indexer that can omit stale grants. + getResourceAccess({ client, worldDeploy }), + getSystemStates({ + client, + worldDeploy, + systemIds: systems.flatMap((system) => system.allowedSystemIds), + }), + ]); + const mismatches = results.flatMap((mismatch) => (mismatch == null ? [] : [mismatch])); + const configuredSystemIds = new Set(systems.map((system) => normalizeHex(system.systemId))); + const currentAccess = worldAccess.filter(({ resourceId }) => configuredSystemIds.has(normalizeHex(resourceId))); + const desiredAccess = getDesiredSystemAccess({ + systems, + systemStates: allowedSystemStates, + desiredSystemAddresses, + }); + const { accessToAdd, accessToRemove } = getSystemAccessDiff({ currentAccess, desiredAccess }); + const missingNamespaceGrants = systems.filter( + (system) => + !hasSystemNamespaceGrant({ + systemId: system.systemId, + systemAddress: desiredSystemAddresses.get(normalizeHex(system.systemId))!, + worldAccess, + }), + ); + + if ( + mismatches.length > 0 || + missingNamespaceGrants.length > 0 || + accessToAdd.length > 0 || + accessToRemove.length > 0 + ) { + throw new Error( + [ + "Configured System verification failed:", + ...mismatches.map( + ({ system, actual, expectedAddress }) => + `- ${system.systemId}: expected ${expectedAddress} (publicAccess=${String(system.allowAll)}), current ${actual.system} (publicAccess=${String(actual.publicAccess)})`, + ), + ...missingNamespaceGrants.map( + (system) => + `- missing default namespace access for ${desiredSystemAddresses.get(normalizeHex(system.systemId))!} (${system.systemId})`, + ), + ...accessToAdd.map( + ({ resourceId, address }) => `- missing access grant: resource ${resourceId}, grantee ${address}`, + ), + ...accessToRemove.map( + ({ resourceId, address }) => `- unexpected access grant: resource ${resourceId}, grantee ${address}`, + ), + ].join("\n"), + ); + } +} diff --git a/packages/cli/src/deploy/ensureTables.test.ts b/packages/cli/src/deploy/ensureTables.test.ts new file mode 100644 index 0000000000..438990cfa2 --- /dev/null +++ b/packages/cli/src/deploy/ensureTables.test.ts @@ -0,0 +1,28 @@ +import { decodeFunctionData, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; +import { resourceToHex } from "@latticexyz/common"; +import { nativeRegistrationSystemAbi, registrationSystemId } from "./ensureFunctionMigrations"; +import { encodeTableRegistrationCall } from "./ensureTables"; + +describe("encodeTableRegistrationCall", () => { + it("encodes table registration for direct RegistrationSystem dispatch", () => { + const tableId = resourceToHex({ type: "table", namespace: "app", name: "Counter" }); + const fieldLayout = `0x${"11".repeat(32)}` as Hex; + const keySchema = `0x${"22".repeat(32)}` as Hex; + const valueSchema = `0x${"33".repeat(32)}` as Hex; + const call = encodeTableRegistrationCall({ + tableId, + fieldLayout, + keySchema, + valueSchema, + keyNames: ["id"], + fieldNames: ["value"], + }); + + expect(call.systemId).toBe(registrationSystemId); + expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ + functionName: "registerTable", + args: [tableId, fieldLayout, keySchema, valueSchema, ["id"], ["value"]], + }); + }); +}); diff --git a/packages/cli/src/deploy/ensureTables.ts b/packages/cli/src/deploy/ensureTables.ts index 14178cb5d3..683e39a4ee 100644 --- a/packages/cli/src/deploy/ensureTables.ts +++ b/packages/cli/src/deploy/ensureTables.ts @@ -1,4 +1,4 @@ -import { Hex } from "viem"; +import { encodeFunctionData, type Hex } from "viem"; import { resourceToLabel, writeContract } from "@latticexyz/common"; import { CommonDeployOptions, WorldDeploy, worldAbi } from "./common"; import { @@ -14,17 +14,46 @@ import { getTables } from "./getTables"; import pRetry from "p-retry"; import { Table } from "@latticexyz/config"; import { isDefined } from "@latticexyz/common/utils"; +import { nativeRegistrationSystemAbi, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; -export async function ensureTables({ +export function encodeTableRegistrationCall({ + tableId, + fieldLayout, + keySchema, + valueSchema, + keyNames, + fieldNames, +}: { + readonly tableId: Hex; + readonly fieldLayout: Hex; + readonly keySchema: Hex; + readonly valueSchema: Hex; + readonly keyNames: readonly string[]; + readonly fieldNames: readonly string[]; +}): DirectSystemCall { + return { + systemId: registrationSystemId, + callData: encodeFunctionData({ + abi: nativeRegistrationSystemAbi, + functionName: "registerTable", + args: [tableId, fieldLayout, keySchema, valueSchema, [...keyNames], [...fieldNames]], + }), + }; +} + +export type TableRegistrationPlan = { + readonly missingTables: readonly Table[]; +}; + +/** Read and validate immutable table schemas before any core bootstrap/deployment writes. */ +export async function getTablePlan({ client, worldDeploy, tables, - indexerUrl, - chainId, }: CommonDeployOptions & { readonly worldDeploy: WorldDeploy; readonly tables: readonly Table[]; -}): Promise { +}): Promise { const configTables = new Map( tables.map((table) => { const keySchema = getSchemaTypes(getKeySchema(table)); @@ -44,7 +73,10 @@ export async function ensureTables({ }), ); - const worldTables = await getTables({ client, worldDeploy, indexerUrl, chainId }); + // This check gates a potentially irreversible core bootstrap. Enumerate from + // authoritative RPC logs instead of trusting an indexer that may omit a live + // immutable table and defer its schema conflict until after bootstrap. + const worldTables = await getTables({ client, worldDeploy }); const existingTables = worldTables.filter(({ tableId }) => configTables.has(tableId)); if (existingTables.length) { debug("existing tables:", existingTables.map(resourceToLabel).join(", ")); @@ -75,28 +107,39 @@ export async function ensureTables({ const existingTableIds = new Set(existingTables.map(({ tableId }) => tableId)); const missingTables = tables.filter((table) => !existingTableIds.has(table.tableId)); + return { missingTables }; +} + +export async function ensureTables({ + client, + worldDeploy, + plan, +}: Pick & { + readonly plan: TableRegistrationPlan; +}): Promise { + const { missingTables } = plan; if (missingTables.length) { debug("registering tables:", missingTables.map(resourceToLabel).join(", ")); return await Promise.all( missingTables.map((table) => { const keySchema = getSchemaTypes(getKeySchema(table)); const valueSchema = getSchemaTypes(getValueSchema(table)); + const call = encodeTableRegistrationCall({ + tableId: table.tableId, + fieldLayout: valueSchemaToFieldLayoutHex(valueSchema), + keySchema: keySchemaToHex(keySchema), + valueSchema: valueSchemaToHex(valueSchema), + keyNames: Object.keys(keySchema), + fieldNames: Object.keys(valueSchema), + }); return pRetry( () => writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) - functionName: "registerTable", - args: [ - table.tableId, - valueSchemaToFieldLayoutHex(valueSchema), - keySchemaToHex(keySchema), - valueSchemaToHex(valueSchema), - Object.keys(keySchema), - Object.keys(valueSchema), - ], + functionName: "call", + args: [call.systemId, call.callData], }), { retries: 3, diff --git a/packages/cli/src/deploy/functionMigrationPlan.test.ts b/packages/cli/src/deploy/functionMigrationPlan.test.ts new file mode 100644 index 0000000000..33f81bd75b --- /dev/null +++ b/packages/cli/src/deploy/functionMigrationPlan.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; +import { toFunctionSelector, type Address, type Hex } from "viem"; +import type { WorldFunction } from "./common"; +import { planFunctionMigrations, planSystemRenames, type FunctionRouteMigration } from "./functionMigrationPlan"; + +const sourceSystemId = `0x7379${"00".repeat(14)}${"11".repeat(16)}` as Hex; +const targetSystemId = `0x7379${"00".repeat(14)}${"22".repeat(16)}` as Hex; +const otherSystemId = `0x7379${"00".repeat(14)}${"33".repeat(16)}` as Hex; +const sourceAddress = `0x${"11".repeat(20)}` as Address; +const runSelector = toFunctionSelector("run()"); + +function worldFunction(overrides: Partial = {}): WorldFunction { + return { + signature: "app__run()", + selector: "0x12345678", + systemId: targetSystemId, + systemFunctionSignature: "run()", + systemFunctionSelector: runSelector, + ...overrides, + }; +} + +function migration(overrides: Partial = {}): FunctionRouteMigration { + return { + worldSelector: "0x12345678", + fromSystemId: sourceSystemId, + fromSystemFunctionSelector: runSelector, + toSystemId: targetSystemId, + toSystemFunctionSelector: runSelector, + ...overrides, + }; +} + +function plan( + overrides: Partial[0]> = {}, +): ReturnType { + const func = worldFunction(); + return planFunctionMigrations({ + functions: [func], + routes: [ + { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + migrations: [migration()], + removals: [], + retirements: [], + systemStates: [], + desiredSystems: [{ systemId: targetSystemId }], + ...overrides, + }); +} + +describe("planFunctionMigrations", () => { + it("plans an exact source tuple and projects the destination for function registration", () => { + const result = plan(); + + expect(result.migrationsToApply).toEqual([{ ...migration(), toSystemFunctionSignature: "run()" }]); + expect(result.migrationsAlreadyApplied).toEqual([]); + expect(result.functionPlan.toAdd).toEqual([]); + expect(result.functionPlan.toSkip).toEqual([worldFunction()]); + }); + + it("treats the exact destination tuple as an idempotent no-op", () => { + const func = worldFunction(); + const result = plan({ + routes: [ + { + selector: func.selector, + systemId: targetSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + }); + + expect(result.migrationsToApply).toEqual([]); + expect(result.migrationsAlreadyApplied).toEqual([migration()]); + }); + + it("treats a missing route as fresh-world not-applicable only when its sources never existed", () => { + const result = plan({ routes: [], systemStates: [] }); + + expect(result.migrationsNotApplicable).toEqual([migration()]); + expect(result.functionPlan.toAdd).toEqual([worldFunction()]); + }); + + it("rejects a missing route when a declared source System exists or is retired", () => { + expect(() => + plan({ + routes: [], + systemStates: [ + { + systemId: sourceSystemId, + exists: true, + address: "0x0000000000000000000000000000000000000000", + publicAccess: false, + }, + ], + }), + ).toThrowError("Selector migration preflight failed"); + }); + + it("accepts chained alternative source tuples with one canonical destination", () => { + const priorCanonicalId = `0x7379${"00".repeat(14)}${"44".repeat(16)}` as Hex; + const alternatives = [migration(), migration({ fromSystemId: priorCanonicalId })]; + const func = worldFunction(); + const result = plan({ + migrations: alternatives, + routes: [ + { + selector: func.selector, + systemId: priorCanonicalId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + }); + + expect(result.migrationsToApply).toEqual([{ ...alternatives[1], toSystemFunctionSignature: "run()" }]); + }); + + it("accepts alternative source selectors from the same legacy System ID", () => { + const alternate = migration({ fromSystemFunctionSelector: "0xaaaaaaaa" }); + const result = plan({ + migrations: [migration(), alternate], + routes: [ + { + selector: alternate.worldSelector, + systemId: alternate.fromSystemId, + systemFunctionSelector: alternate.fromSystemFunctionSelector, + }, + ], + }); + + expect(result.migrationsToApply).toEqual([{ ...alternate, toSystemFunctionSignature: "run()" }]); + }); + + it("rejects alternative sources with a non-canonical final destination", () => { + expect(() => + plan({ + migrations: [migration(), migration({ fromSystemId: otherSystemId, toSystemId: sourceSystemId })], + }), + ).toThrowError("does not match an exact configured destination route"); + }); + + it("rejects every route other than the exact source or destination", () => { + const func = worldFunction(); + + expect(() => + plan({ + routes: [ + { + selector: func.selector, + systemId: otherSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + }), + ).toThrowError(`Selector migration preflight failed for ${func.selector}`); + }); + + it("requires migrations to match an exact configured destination", () => { + expect(() => plan({ migrations: [migration({ toSystemFunctionSelector: "0xaaaaaaaa" })] })).toThrowError( + "does not match an exact configured destination route", + ); + }); + + it("plans exact removals and treats an absent selector as already applied", () => { + const removal = { + worldSelector: "0xaaaaaaaa" as Hex, + expectedSystemId: sourceSystemId, + expectedSystemFunctionSelector: "0xbbbbbbbb" as Hex, + }; + const base = { + functions: [] as WorldFunction[], + migrations: [], + removals: [removal], + retirements: [], + systemStates: [], + desiredSystems: [], + }; + + const pending = planFunctionMigrations({ + ...base, + routes: [ + { + selector: removal.worldSelector, + systemId: removal.expectedSystemId, + systemFunctionSelector: removal.expectedSystemFunctionSelector, + }, + ], + }); + expect(pending.removalsToApply).toEqual([removal]); + + const applied = planFunctionMigrations({ ...base, routes: [] }); + expect(applied.removalsAlreadyApplied).toEqual([removal]); + }); + + it("rejects a removal that is still desired", () => { + const func = worldFunction(); + expect(() => + plan({ + migrations: [], + removals: [ + { + worldSelector: func.selector, + expectedSystemId: sourceSystemId, + expectedSystemFunctionSelector: func.systemFunctionSelector, + }, + ], + }), + ).toThrowError("is still present in the configured World ABI"); + }); + + it("skips missing and tombstoned retirements", () => { + const tombstonedId = `0x7379${"00".repeat(14)}${"44".repeat(16)}` as Hex; + const result = plan({ + retirements: [{ systemId: otherSystemId }, { systemId: tombstonedId }], + systemStates: [ + { + systemId: tombstonedId, + exists: true, + address: "0x0000000000000000000000000000000000000000", + publicAccess: false, + }, + ], + }); + + expect(result.retirementsNotFound).toEqual([{ systemId: otherSystemId }]); + expect(result.retirementsAlreadyApplied).toEqual([{ systemId: tombstonedId }]); + }); + + it("refuses to retire a system while any unplanned selector still references it", () => { + const func = worldFunction(); + expect(() => + plan({ + routes: [ + { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + { + selector: "0xaaaaaaaa", + systemId: sourceSystemId, + systemFunctionSelector: "0xbbbbbbbb", + }, + ], + retirements: [{ systemId: sourceSystemId }], + systemStates: [{ systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }], + }), + ).toThrowError("live selector routes would still reference it"); + }); + + it("allows retirement after all live routes are migrated or removed", () => { + const result = plan({ + retirements: [{ systemId: sourceSystemId }], + systemStates: [{ systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }], + }); + + expect(result.retirementsToApply).toEqual([ + { systemId: sourceSystemId, expectedSystem: sourceAddress, expectedPublicAccess: true }, + ]); + }); + + it("plans a same-implementation system rename when the target ID is unused", () => { + const result = plan({ + retirements: [{ systemId: sourceSystemId }], + systemStates: [ + { systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }, + { + systemId: targetSystemId, + exists: false, + address: "0x0000000000000000000000000000000000000000", + publicAccess: false, + }, + ], + }); + + expect( + planSystemRenames( + result, + [{ systemId: targetSystemId, address: sourceAddress, publicAccess: true }], + [ + { systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }, + { + systemId: targetSystemId, + exists: false, + address: "0x0000000000000000000000000000000000000000", + publicAccess: false, + }, + ], + ), + ).toEqual([ + { + systemId: sourceSystemId, + expectedSystem: sourceAddress, + expectedPublicAccess: true, + targetSystemId, + targetSystem: sourceAddress, + targetPublicAccess: true, + }, + ]); + }); + + it("rejects a same-implementation rename into an active or tombstoned target ID", () => { + const result = plan({ + retirements: [{ systemId: sourceSystemId }], + systemStates: [{ systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }], + }); + + expect(() => + planSystemRenames( + result, + [{ systemId: targetSystemId, address: sourceAddress, publicAccess: true }], + [ + { systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }, + { + systemId: targetSystemId, + exists: true, + address: "0x0000000000000000000000000000000000000000", + publicAccess: false, + }, + ], + ), + ).toThrowError("target resource ID is not unused"); + }); + + it("keeps the ordinary no-config selector guard fail-closed", () => { + const func = worldFunction(); + expect(() => + planFunctionMigrations({ + functions: [func], + routes: [ + { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + migrations: [], + removals: [], + retirements: [], + systemStates: [], + desiredSystems: [{ systemId: targetSystemId }], + }), + ).toThrowError("is already registered with a different route"); + }); +}); diff --git a/packages/cli/src/deploy/functionMigrationPlan.ts b/packages/cli/src/deploy/functionMigrationPlan.ts new file mode 100644 index 0000000000..1db5bd693f --- /dev/null +++ b/packages/cli/src/deploy/functionMigrationPlan.ts @@ -0,0 +1,414 @@ +import { toFunctionSelector, type Address, type Hex } from "viem"; +import type { WorldFunction } from "./common"; +import { type FunctionRegistrationPlan, type FunctionRoute, planFunctionRegistrations } from "./functionPlan"; + +export type FunctionRouteMigration = { + readonly worldSelector: Hex; + readonly fromSystemId: Hex; + readonly fromSystemFunctionSelector: Hex; + readonly toSystemId: Hex; + readonly toSystemFunctionSelector: Hex; +}; + +export type PlannedFunctionRouteMigration = FunctionRouteMigration & { + /** Canonical generated signature whose selector must equal toSystemFunctionSelector. */ + readonly toSystemFunctionSignature: string; +}; + +export type FunctionSelectorRemoval = { + readonly worldSelector: Hex; + readonly expectedSystemId: Hex; + readonly expectedSystemFunctionSelector: Hex; +}; + +export type SystemRetirement = { + readonly systemId: Hex; +}; + +export type SystemState = { + readonly systemId: Hex; + readonly exists: boolean; + readonly address: Address; + readonly publicAccess: boolean; +}; + +export type DesiredSystem = { + readonly systemId: Hex; + readonly address: Address; + readonly publicAccess: boolean; +}; + +export type PlannedSystemRename = PlannedSystemRetirement & { + readonly targetSystemId: Hex; + readonly targetSystem: Address; + readonly targetPublicAccess: boolean; +}; + +export type PlannedSystemRetirement = { + readonly systemId: Hex; + readonly expectedSystem: Address; + readonly expectedPublicAccess: boolean; +}; + +export type FunctionMigrationPlan = { + /** Function registrations to perform after applying the selector migrations. */ + readonly functionPlan: FunctionRegistrationPlan; + readonly migrationsToApply: readonly PlannedFunctionRouteMigration[]; + readonly migrationsAlreadyApplied: readonly FunctionRouteMigration[]; + readonly migrationsNotApplicable: readonly FunctionRouteMigration[]; + readonly removalsToApply: readonly FunctionSelectorRemoval[]; + readonly removalsAlreadyApplied: readonly FunctionSelectorRemoval[]; + readonly retirementsToApply: readonly PlannedSystemRetirement[]; + readonly retirementsAlreadyApplied: readonly SystemRetirement[]; + readonly retirementsNotFound: readonly SystemRetirement[]; +}; + +const zeroAddress = "0x0000000000000000000000000000000000000000"; + +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +function sameHex(a: Hex, b: Hex): boolean { + return normalizeHex(a) === normalizeHex(b); +} + +function sameRoute( + route: Pick, + systemId: Hex, + systemFunctionSelector: Hex, +): boolean { + return sameHex(route.systemId, systemId) && sameHex(route.systemFunctionSelector, systemFunctionSelector); +} + +function formatRoute(route: Pick | undefined): string { + return route == null + ? "" + : `systemId=${route.systemId}, systemFunctionSelector=${route.systemFunctionSelector}`; +} + +function setUnique(map: Map, key: Hex, value: T, description: string): void { + const normalizedKey = normalizeHex(key); + if (map.has(normalizedKey)) { + throw new Error(`Duplicate ${description} for ${key}.`); + } + map.set(normalizedKey, value); +} + +/** + * Build the complete selector lifecycle plan from an exact onchain snapshot. + * + * The configured source tuples are compare-and-swap guards. A migration or removal + * may only be pending or already applied; every other state fails before deployment. + */ +export function planFunctionMigrations({ + functions, + routes, + migrations, + removals, + retirements, + systemStates, + desiredSystems, +}: { + readonly functions: readonly WorldFunction[]; + readonly routes: readonly FunctionRoute[]; + readonly migrations: readonly FunctionRouteMigration[]; + readonly removals: readonly FunctionSelectorRemoval[]; + readonly retirements: readonly SystemRetirement[]; + readonly systemStates: readonly SystemState[]; + readonly desiredSystems: readonly Pick[]; +}): FunctionMigrationPlan { + const routesBySelector = new Map(); + for (const route of routes) { + setUnique(routesBySelector, route.selector, route, "live selector route"); + } + + const desiredFunctionsBySelector = new Map(); + for (const func of functions) { + const key = normalizeHex(func.selector); + const existing = desiredFunctionsBySelector.get(key); + if (existing == null) { + desiredFunctionsBySelector.set(key, func); + } else if ( + existing.signature !== func.signature || + existing.systemFunctionSignature !== func.systemFunctionSignature || + !sameHex(existing.systemId, func.systemId) || + !sameHex(existing.systemFunctionSelector, func.systemFunctionSelector) + ) { + throw new Error(`Configured functions collide on world selector ${func.selector}.`); + } + } + + const desiredSystemsById = new Map>(); + for (const system of desiredSystems) { + setUnique(desiredSystemsById, system.systemId, system, "configured system ID"); + } + + const systemStatesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); + const migrationsBySelector = new Map(); + for (const migration of migrations) { + if ( + sameHex(migration.fromSystemId, migration.toSystemId) && + sameHex(migration.fromSystemFunctionSelector, migration.toSystemFunctionSelector) + ) { + throw new Error(`Selector migration ${migration.worldSelector} must replace the current route tuple.`); + } + + const desired = desiredFunctionsBySelector.get(normalizeHex(migration.worldSelector)); + if ( + desired == null || + !sameHex(desired.systemId, migration.toSystemId) || + !sameHex(desired.systemFunctionSelector, migration.toSystemFunctionSelector) || + !sameHex(toFunctionSelector(desired.systemFunctionSignature), migration.toSystemFunctionSelector) + ) { + throw new Error( + [ + `Selector migration ${migration.worldSelector} does not match an exact configured destination route.`, + `Migration destination: systemId=${migration.toSystemId}, systemFunctionSelector=${migration.toSystemFunctionSelector}`, + desired == null + ? "Configured destination: " + : `Configured destination: systemId=${desired.systemId}, systemFunctionSelector=${desired.systemFunctionSelector}`, + ].join("\n"), + ); + } + + if (!desiredSystemsById.has(normalizeHex(migration.toSystemId))) { + throw new Error( + `Selector migration ${migration.worldSelector} targets system ${migration.toSystemId}, which is not configured for deployment.`, + ); + } + + const key = normalizeHex(migration.worldSelector); + const alternatives = migrationsBySelector.get(key) ?? []; + const first = alternatives[0]; + if ( + first != null && + (!sameHex(first.toSystemId, migration.toSystemId) || + !sameHex(first.toSystemFunctionSelector, migration.toSystemFunctionSelector)) + ) { + throw new Error( + `Alternative migrations for ${migration.worldSelector} must share the same final destination tuple.`, + ); + } + if ( + alternatives.some( + (alternative) => + sameHex(alternative.fromSystemId, migration.fromSystemId) && + sameHex(alternative.fromSystemFunctionSelector, migration.fromSystemFunctionSelector), + ) + ) { + throw new Error( + `Duplicate migration source ${migration.fromSystemId}/${migration.fromSystemFunctionSelector} for selector ${migration.worldSelector}.`, + ); + } + migrationsBySelector.set(key, [...alternatives, migration]); + } + + const removalsBySelector = new Map(); + for (const removal of removals) { + setUnique(removalsBySelector, removal.worldSelector, removal, "selector removal"); + if (migrationsBySelector.has(normalizeHex(removal.worldSelector))) { + throw new Error(`Selector ${removal.worldSelector} cannot be both migrated and removed.`); + } + if (desiredFunctionsBySelector.has(normalizeHex(removal.worldSelector))) { + throw new Error(`Selector removal ${removal.worldSelector} is still present in the configured World ABI.`); + } + } + + const migrationsToApply: PlannedFunctionRouteMigration[] = []; + const migrationsAlreadyApplied: FunctionRouteMigration[] = []; + const migrationsNotApplicable: FunctionRouteMigration[] = []; + const effectiveRoutesBySelector = new Map(routesBySelector); + + for (const alternatives of migrationsBySelector.values()) { + const migration = alternatives[0]; + const key = normalizeHex(migration.worldSelector); + const current = routesBySelector.get(key); + if (current != null && sameRoute(current, migration.toSystemId, migration.toSystemFunctionSelector)) { + // TODO: An exact destination route does not currently audit the offchain FunctionSignatures table. + // Future work can derive missing metadata from authoritative logs and schedule a one-shot repair + // without requiring root authority on every idempotent deployment. + migrationsAlreadyApplied.push(migration); + continue; + } + const matchingSource = alternatives.find( + (alternative) => + current != null && sameRoute(current, alternative.fromSystemId, alternative.fromSystemFunctionSelector), + ); + if (matchingSource != null) { + const desired = desiredFunctionsBySelector.get(key); + if (desired == null) throw new Error(`Missing configured destination for ${matchingSource.worldSelector}.`); + migrationsToApply.push({ + ...matchingSource, + toSystemFunctionSignature: desired.systemFunctionSignature, + }); + effectiveRoutesBySelector.set(key, { + selector: migration.worldSelector, + systemId: migration.toSystemId, + systemFunctionSelector: migration.toSystemFunctionSelector, + }); + continue; + } + + if (current == null) { + const existingSources = alternatives.filter((alternative) => { + const state = systemStatesById.get(normalizeHex(alternative.fromSystemId)); + return state?.exists || (state != null && !sameHex(state.address, zeroAddress)); + }); + if (existingSources.length === 0) { + migrationsNotApplicable.push(migration); + continue; + } + } + + throw new Error( + [ + `Selector migration preflight failed for ${migration.worldSelector}.`, + "Accepted sources:", + ...alternatives.map( + (alternative) => + `- systemId=${alternative.fromSystemId}, systemFunctionSelector=${alternative.fromSystemFunctionSelector}`, + ), + `Expected destination: systemId=${migration.toSystemId}, systemFunctionSelector=${migration.toSystemFunctionSelector}`, + `Current: ${formatRoute(current)}`, + ].join("\n"), + ); + } + + const removalsToApply: FunctionSelectorRemoval[] = []; + const removalsAlreadyApplied: FunctionSelectorRemoval[] = []; + for (const removal of removals) { + const key = normalizeHex(removal.worldSelector); + const current = routesBySelector.get(key); + if (current == null) { + removalsAlreadyApplied.push(removal); + continue; + } + if (sameRoute(current, removal.expectedSystemId, removal.expectedSystemFunctionSelector)) { + removalsToApply.push(removal); + effectiveRoutesBySelector.delete(key); + continue; + } + + throw new Error( + [ + `Selector removal preflight failed for ${removal.worldSelector}.`, + `Expected: systemId=${removal.expectedSystemId}, systemFunctionSelector=${removal.expectedSystemFunctionSelector}`, + `Current: ${formatRoute(current)}`, + ].join("\n"), + ); + } + + // Running the ordinary planner against the projected post-migration routes preserves + // the fail-closed behavior when no lifecycle config is present. + const functionPlan = planFunctionRegistrations(functions, [...effectiveRoutesBySelector.values()]); + + const retirementIds = new Set(); + const retirementsToApply: PlannedSystemRetirement[] = []; + const retirementsAlreadyApplied: SystemRetirement[] = []; + const retirementsNotFound: SystemRetirement[] = []; + + for (const retirement of retirements) { + const key = normalizeHex(retirement.systemId); + if (retirementIds.has(key)) { + throw new Error(`Duplicate system retirement for ${retirement.systemId}.`); + } + retirementIds.add(key); + + if (desiredSystemsById.has(key)) { + throw new Error(`System ${retirement.systemId} cannot be both configured and retired.`); + } + + const state = systemStatesById.get(key); + if (state == null || !state.exists) { + retirementsNotFound.push(retirement); + continue; + } + if (sameHex(state.address, zeroAddress)) { + retirementsAlreadyApplied.push(retirement); + continue; + } + retirementsToApply.push({ + systemId: retirement.systemId, + expectedSystem: state.address, + expectedPublicAccess: state.publicAccess, + }); + } + + for (const retirement of retirementsToApply) { + const remainingRoutes = [...effectiveRoutesBySelector.values()].filter((route) => + sameHex(route.systemId, retirement.systemId), + ); + if (remainingRoutes.length > 0) { + throw new Error( + [ + `Cannot retire system ${retirement.systemId}: live selector routes would still reference it.`, + ...remainingRoutes.map((route) => `- ${route.selector} -> ${route.systemId}/${route.systemFunctionSelector}`), + "Declare an exact selector migration or removal for every remaining route.", + ].join("\n"), + ); + } + } + + return { + functionPlan, + migrationsToApply, + migrationsAlreadyApplied, + migrationsNotApplicable, + removalsToApply, + removalsAlreadyApplied, + retirementsToApply, + retirementsAlreadyApplied, + retirementsNotFound, + }; +} + +/** + * Plan same-implementation renames that must be kept out of ordinary ensureSystems. + * The atomic batch retires the old ID before registering the implementation at the + * unused target ID, and only then replaces/removes the old selector routes. + */ +export function planSystemRenames( + plan: Pick, + desiredSystems: readonly DesiredSystem[], + systemStates: readonly SystemState[], +): readonly PlannedSystemRename[] { + const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); + const renames: PlannedSystemRename[] = []; + + for (const retirement of plan.retirementsToApply) { + const reusedAddresses = desiredSystems.filter( + (system) => !sameHex(system.systemId, retirement.systemId) && sameHex(system.address, retirement.expectedSystem), + ); + if (reusedAddresses.length === 0) continue; + if (reusedAddresses.length > 1) { + throw new Error( + `System ${retirement.systemId} implementation ${retirement.expectedSystem} has multiple configured rename targets.`, + ); + } + + const target = reusedAddresses[0]; + const targetState = statesById.get(normalizeHex(target.systemId)); + if (targetState == null) { + throw new Error(`Missing preflight state for system rename target ${target.systemId}.`); + } + if (targetState.exists || !sameHex(targetState.address, zeroAddress) || targetState.publicAccess) { + throw new Error( + [ + `Cannot rename system ${retirement.systemId} to ${target.systemId}: the target resource ID is not unused.`, + `Target exists=${String(targetState.exists)}, address=${targetState.address}, publicAccess=${String(targetState.publicAccess)}.`, + "Retired system IDs are permanent tombstones and cannot be reused.", + ].join("\n"), + ); + } + + renames.push({ + ...retirement, + targetSystemId: target.systemId, + targetSystem: target.address, + targetPublicAccess: target.publicAccess, + }); + } + + return renames; +} diff --git a/packages/cli/src/deploy/functionPlan.test.ts b/packages/cli/src/deploy/functionPlan.test.ts new file mode 100644 index 0000000000..b71aaa0bd6 --- /dev/null +++ b/packages/cli/src/deploy/functionPlan.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { Hex } from "viem"; +import { WorldFunction } from "./common"; +import { assertFunctionPlanApplied, planFunctionRegistrations } from "./functionPlan"; + +const sourceSystemId = `0x${"11".repeat(32)}` as Hex; +const targetSystemId = `0x${"22".repeat(32)}` as Hex; + +function worldFunction(overrides: Partial = {}): WorldFunction { + return { + signature: "app__run()", + selector: "0x12345678", + systemId: targetSystemId, + systemFunctionSignature: "run()", + systemFunctionSelector: "0x87654321", + ...overrides, + }; +} + +describe("planFunctionRegistrations", () => { + it("adds missing routes", () => { + const func = worldFunction(); + + expect(planFunctionRegistrations([func], [])).toEqual({ toAdd: [func], toSkip: [] }); + }); + + it("skips routes only when the complete tuple matches", () => { + const func = worldFunction(); + + expect( + planFunctionRegistrations( + [func], + [ + { + selector: func.selector, + systemId: func.systemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + ), + ).toEqual({ toAdd: [], toSkip: [func] }); + }); + + it("rejects a selector registered to another system", () => { + const func = worldFunction(); + + expect(() => + planFunctionRegistrations( + [func], + [ + { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + ), + ).toThrowError( + [ + `World function ${func.signature} (${func.selector}) is already registered with a different route.`, + `Configured: systemId=${targetSystemId}, systemFunctionSelector=${func.systemFunctionSelector}`, + `Registered: systemId=${sourceSystemId}, systemFunctionSelector=${func.systemFunctionSelector}`, + ].join("\n"), + ); + }); + + it("rejects a selector registered to another system function", () => { + const func = worldFunction(); + + expect(() => + planFunctionRegistrations( + [func], + [ + { + selector: func.selector, + systemId: func.systemId, + systemFunctionSelector: "0xaaaaaaaa", + }, + ], + ), + ).toThrowError(`Registered: systemId=${targetSystemId}, systemFunctionSelector=0xaaaaaaaa`); + }); + + it("rejects conflicting configured functions", () => { + const first = worldFunction(); + const second = worldFunction({ + signature: "app__other()", + systemFunctionSignature: "other()", + systemFunctionSelector: "0xaaaaaaaa", + }); + + expect(() => planFunctionRegistrations([first, second], [])).toThrowError( + `Configured functions collide on world selector ${first.selector}.`, + ); + }); + + it("deduplicates exact configured functions", () => { + const func = worldFunction(); + + expect(planFunctionRegistrations([func, func], [])).toEqual({ toAdd: [func], toSkip: [] }); + }); +}); + +describe("assertFunctionPlanApplied", () => { + it("rejects routes that remain missing", () => { + const func = worldFunction(); + + expect(() => assertFunctionPlanApplied({ toAdd: [func], toSkip: [] })).toThrowError( + `Function route verification failed after deployment`, + ); + }); +}); diff --git a/packages/cli/src/deploy/functionPlan.ts b/packages/cli/src/deploy/functionPlan.ts new file mode 100644 index 0000000000..32512fc73f --- /dev/null +++ b/packages/cli/src/deploy/functionPlan.ts @@ -0,0 +1,109 @@ +import { Hex } from "viem"; +import { WorldFunction } from "./common"; + +export type FunctionRoute = { + readonly selector: Hex; + readonly systemId: Hex; + readonly systemFunctionSelector: Hex; +}; + +export type FunctionRegistrationPlan = { + readonly toAdd: readonly WorldFunction[]; + readonly toSkip: readonly WorldFunction[]; +}; + +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +function routesMatch( + expected: Pick, + actual: Pick, +): boolean { + return ( + normalizeHex(expected.systemId) === normalizeHex(actual.systemId) && + normalizeHex(expected.systemFunctionSelector) === normalizeHex(actual.systemFunctionSelector) + ); +} + +function formatRoute(route: Pick): string { + return `systemId=${route.systemId}, systemFunctionSelector=${route.systemFunctionSelector}`; +} + +function uniqueConfiguredFunctions(functions: readonly WorldFunction[]): readonly WorldFunction[] { + const bySelector = new Map(); + + for (const func of functions) { + const selector = normalizeHex(func.selector); + const existing = bySelector.get(selector); + if (existing == null) { + bySelector.set(selector, func); + continue; + } + + const isExactDuplicate = + existing.signature === func.signature && + existing.systemFunctionSignature === func.systemFunctionSignature && + routesMatch(existing, func); + if (isExactDuplicate) continue; + + throw new Error( + [ + `Configured functions collide on world selector ${func.selector}.`, + `First: ${existing.signature} (${formatRoute(existing)})`, + `Second: ${func.signature} (${formatRoute(func)})`, + "Each world selector must have exactly one configured route.", + ].join("\n"), + ); + } + + return [...bySelector.values()]; +} + +/** + * Build a write plan from configured functions and a read-only snapshot of registered routes. + * Existing selectors are immutable here: a non-exact route must be handled by an explicit migration. + */ +export function planFunctionRegistrations( + functions: readonly WorldFunction[], + registeredRoutes: readonly FunctionRoute[], +): FunctionRegistrationPlan { + const routesBySelector = new Map(registeredRoutes.map((route) => [normalizeHex(route.selector), route])); + const toAdd: WorldFunction[] = []; + const toSkip: WorldFunction[] = []; + + for (const func of uniqueConfiguredFunctions(functions)) { + const registered = routesBySelector.get(normalizeHex(func.selector)); + if (registered == null) { + toAdd.push(func); + continue; + } + + if (routesMatch(func, registered)) { + toSkip.push(func); + continue; + } + + throw new Error( + [ + `World function ${func.signature} (${func.selector}) is already registered with a different route.`, + `Configured: ${formatRoute(func)}`, + `Registered: ${formatRoute(registered)}`, + "Refusing to overwrite the selector. Apply an explicit selector migration before deploying this config.", + ].join("\n"), + ); + } + + return { toAdd, toSkip }; +} + +export function assertFunctionPlanApplied(plan: FunctionRegistrationPlan): void { + if (plan.toAdd.length === 0) return; + + throw new Error( + [ + "Function route verification failed after deployment. The following configured routes are still missing:", + ...plan.toAdd.map((func) => `- ${func.signature} (${func.selector}): ${formatRoute(func)}`), + ].join("\n"), + ); +} diff --git a/packages/cli/src/deploy/getFunctionRoutes.test.ts b/packages/cli/src/deploy/getFunctionRoutes.test.ts new file mode 100644 index 0000000000..e9e71fc94b --- /dev/null +++ b/packages/cli/src/deploy/getFunctionRoutes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { CommonDeployOptions } from "./common"; +import { getFunctionDiscoveryOptions } from "./getFunctionRoutes"; + +const common = { + client: {} as CommonDeployOptions["client"], + worldDeploy: { + address: "0x1111111111111111111111111111111111111111", + worldVersion: "2.1.0", + storeVersion: "2.0.2", + deployBlock: 10n, + stateBlock: 20n, + }, + indexerUrl: "https://indexer.invalid", + chainId: 31337, +} satisfies CommonDeployOptions; + +describe("getFunctionDiscoveryOptions", () => { + it("omits indexer options for an authoritative retirement inventory", () => { + const options = getFunctionDiscoveryOptions({ ...common, authoritative: true }); + + expect(options).not.toHaveProperty("indexerUrl"); + expect(options).not.toHaveProperty("chainId"); + expect(options).toMatchObject({ fromBlock: 10n, toBlock: 20n }); + }); + + it("retains indexer options for non-destructive discovery", () => { + expect(getFunctionDiscoveryOptions({ ...common, authoritative: false })).toMatchObject({ + indexerUrl: common.indexerUrl, + chainId: common.chainId, + }); + }); +}); diff --git a/packages/cli/src/deploy/getFunctionRoutes.ts b/packages/cli/src/deploy/getFunctionRoutes.ts new file mode 100644 index 0000000000..79dc8ab210 --- /dev/null +++ b/packages/cli/src/deploy/getFunctionRoutes.ts @@ -0,0 +1,94 @@ +import type { Hex } from "viem"; +import { zeroHash } from "viem"; +import { getFunctions } from "@latticexyz/store-sync/world"; +import worldConfig from "@latticexyz/world/mud.config"; +import type { CommonDeployOptions } from "./common"; +import type { FunctionRoute } from "./functionPlan"; +import { getRecord } from "./getRecord"; + +const emptyFunctionSelector = "0x00000000"; + +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +export function isEmptyFunctionRoute(route: Pick): boolean { + return ( + normalizeHex(route.systemId) === zeroHash && normalizeHex(route.systemFunctionSelector) === emptyFunctionSelector + ); +} + +/** Read exact route tuples from the World at its introspection block. */ +export async function getFunctionRoutes({ + client, + worldDeploy, + selectors, +}: Pick & { + readonly selectors: readonly Hex[]; +}): Promise { + const uniqueSelectors = [...new Map(selectors.map((selector) => [normalizeHex(selector), selector])).values()]; + const functionSelectorsTable = worldConfig.namespaces.world.tables.FunctionSelectors; + + const routes = await Promise.all( + uniqueSelectors.map(async (selector): Promise => { + const record = await getRecord({ + client, + worldDeploy, + table: functionSelectorsTable, + key: { worldFunctionSelector: selector }, + }); + + return { + selector, + systemId: record.systemId, + systemFunctionSelector: record.systemFunctionSelector, + }; + }), + ); + + return routes.filter((route) => !isEmptyFunctionRoute(route)); +} + +/** + * Enumerate every selector row from Store events/indexer, then re-read every tuple + * directly at the deployment snapshot block. Explicit selectors are always included + * so stale or incomplete indexes cannot make a declared migration look unregistered. + */ +export function getFunctionDiscoveryOptions({ + client, + worldDeploy, + indexerUrl, + chainId, + authoritative, +}: CommonDeployOptions & { readonly authoritative: boolean }) { + const rpcOptions = { + client, + worldAddress: worldDeploy.address, + fromBlock: worldDeploy.deployBlock, + toBlock: worldDeploy.stateBlock, + }; + return authoritative ? rpcOptions : { ...rpcOptions, indexerUrl, chainId }; +} + +export async function getAllFunctionRoutes({ + client, + worldDeploy, + indexerUrl, + chainId, + additionalSelectors = [], + authoritative = false, +}: CommonDeployOptions & { + readonly additionalSelectors?: readonly Hex[]; + /** Force exhaustive RPC log enumeration at the pinned state block. */ + readonly authoritative?: boolean; +}): Promise { + const discovered = await getFunctions( + getFunctionDiscoveryOptions({ client, worldDeploy, indexerUrl, chainId, authoritative }), + ); + + return getFunctionRoutes({ + client, + worldDeploy, + selectors: [...discovered.map((func) => func.selector), ...additionalSelectors], + }); +} diff --git a/packages/cli/src/deploy/systemAccess.ts b/packages/cli/src/deploy/systemAccess.ts new file mode 100644 index 0000000000..c3185f24d9 --- /dev/null +++ b/packages/cli/src/deploy/systemAccess.ts @@ -0,0 +1,24 @@ +import { getAddress, type Address, type Hex } from "viem"; +import { hexToResource, resourceToHex } from "@latticexyz/common"; + +export type SystemAccess = { readonly resourceId: Hex; readonly address: Address }; + +export function getSystemNamespaceId(systemId: Hex): Hex { + return resourceToHex({ type: "namespace", namespace: hexToResource(systemId).namespace, name: "" }); +} + +export function hasSystemNamespaceGrant({ + systemId, + systemAddress, + worldAccess, +}: { + readonly systemId: Hex; + readonly systemAddress: Address; + readonly worldAccess: readonly SystemAccess[]; +}): boolean { + const namespaceId = getSystemNamespaceId(systemId); + return worldAccess.some( + ({ resourceId, address }) => + resourceId.toLowerCase() === namespaceId.toLowerCase() && getAddress(address) === getAddress(systemAddress), + ); +} diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index 1ee7df7d18..26c373f99a 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -1,6 +1,5 @@ import path from "node:path"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import packageJson from "../package.json"; import { InferredOptionTypes, Options } from "yargs"; import { deploy } from "./deploy/deploy"; import { createWalletClient, http, Hex, isHex, stringToHex } from "viem"; @@ -20,6 +19,7 @@ import { configToModules } from "./deploy/configToModules"; import { findContractArtifacts } from "@latticexyz/world/node"; import { enableAutomine } from "./utils/enableAutomine"; import { defaultChains } from "./defaultChains"; +import { cliPackageInfo } from "./version"; export const deployOptions = { configPath: { type: "string", desc: "Path to the MUD config file" }, @@ -72,7 +72,7 @@ export async function runDeploy(opts: DeployOptions): Promise { const config = (await loadConfig(configPath)) as WorldConfig; const rootDir = path.dirname(configPath); - console.log(chalk.green(`\nUsing ${packageJson.name}@${packageJson.version}`)); + console.log(chalk.green(`\nUsing ${cliPackageInfo.name}@${cliPackageInfo.version}`)); if (opts.printConfig) { console.log(chalk.green("\nResolved config:\n"), JSON.stringify(config, null, 2)); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000000..4db98f6a3d --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; + +export type CliPackageInfo = { + readonly name: string; + readonly version: string; +}; + +/** + * Read package metadata at runtime so staged release manifests and the CLI's + * reported version cannot drift apart after the bundle is built. + */ +export const cliPackageInfo = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as CliPackageInfo; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index c307577d6c..72fdbeb400 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -20,7 +20,7 @@ const mudPackages: MudPackages = Object.fromEntries( export default defineConfig((opts) => ({ ...baseConfig(opts), - entry: ["src/index.ts", "src/mud.ts"], + entry: ["src/index.ts", "src/mud.ts", "src/version.ts"], env: { MUD_PACKAGES: JSON.stringify(mudPackages), }, diff --git a/packages/store-sync/src/world/getFunctions.ts b/packages/store-sync/src/world/getFunctions.ts index 4ae3fd8858..b6bddc9af5 100644 --- a/packages/store-sync/src/world/getFunctions.ts +++ b/packages/store-sync/src/world/getFunctions.ts @@ -19,7 +19,7 @@ export async function getFunctions({ readonly indexerUrl?: string; readonly chainId?: number; }): Promise { - // This assumes we only use `FunctionSelectors._set(...)`, which is true as of this writing. + // getRecords folds Set/Splice/Delete logs into the current live selector rows. debug("looking up function selectors for", worldAddress); const { records: selectors } = await getRecords({ diff --git a/packages/world/src/IWorldErrors.sol b/packages/world/src/IWorldErrors.sol index e702f86695..4438b8c52b 100644 --- a/packages/world/src/IWorldErrors.sol +++ b/packages/world/src/IWorldErrors.sol @@ -55,6 +55,44 @@ interface IWorldErrors { */ error World_SystemAlreadyExists(address system); + /** + * @notice Raised when trying to register a system at a permanently retired system ID. + * @param systemId The retired system ID. + * @param systemIdString The string representation of the retired system ID. + */ + error World_SystemAlreadyRetired(ResourceId systemId, string systemIdString); + + /** + * @notice Raised when trying to permanently retire a protected core system. + * @param systemId The protected system ID. + * @param systemIdString The string representation of the protected system ID. + */ + error World_SystemCannotBeRetired(ResourceId systemId, string systemIdString); + + /** + * @notice Raised when the current system state does not match the expected system state. + * @param systemId The system ID being checked. + * @param expectedSystem The expected system address. + * @param expectedPublicAccess The expected public access flag. + * @param actualSystem The current system address. + * @param actualPublicAccess The current public access flag. + */ + error World_SystemStateMismatch( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + address actualSystem, + bool actualPublicAccess + ); + + /** + * @notice Raised when a system's reverse registry entry does not match its system ID. + * @param system The system address being checked. + * @param expectedSystemId The system ID expected in the reverse registry. + * @param actualSystemId The current system ID in the reverse registry. + */ + error World_SystemRegistryMismatch(address system, ResourceId expectedSystemId, ResourceId actualSystemId); + /** * @notice Raised when trying to register a function selector that already exists. * @param functionSelector The function selector in question. @@ -67,6 +105,22 @@ interface IWorldErrors { */ error World_FunctionSelectorNotFound(bytes4 functionSelector); + /** + * @notice Raised when a function selector route does not match the expected route. + * @param worldFunctionSelector The World function selector being checked. + * @param expectedSystemId The expected system ID. + * @param expectedSystemFunctionSelector The expected system function selector. + * @param actualSystemId The current system ID. + * @param actualSystemFunctionSelector The current system function selector. + */ + error World_FunctionSelectorMismatch( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId actualSystemId, + bytes4 actualSystemFunctionSelector + ); + /** * @notice Raised when the specified delegation is not found. * @param delegator The address of the delegator. diff --git a/packages/world/src/IWorldEvents.sol b/packages/world/src/IWorldEvents.sol index 03fa598f11..04c9299efe 100644 --- a/packages/world/src/IWorldEvents.sol +++ b/packages/world/src/IWorldEvents.sol @@ -17,4 +17,39 @@ interface IWorldEvents { * @param worldVersion The protocol version of the World. */ event HelloWorld(bytes32 indexed worldVersion); + + /** + * @notice Emitted when a World function selector's complete route is replaced. + */ + event WorldFunctionRouteReplaced( + bytes4 indexed worldFunctionSelector, + ResourceId indexed oldSystemId, + ResourceId indexed newSystemId, + bytes4 oldSystemFunctionSelector, + bytes4 newSystemFunctionSelector + ); + + /** + * @notice Emitted when a World function selector is unregistered. + */ + event WorldFunctionSelectorUnregistered( + bytes4 indexed worldFunctionSelector, + ResourceId indexed systemId, + bytes4 systemFunctionSelector + ); + + /** + * @notice Emitted when a system is registered or replaced through the compare-and-swap registration primitive. + */ + event WorldSystemReplaced( + ResourceId indexed systemId, + address indexed oldSystem, + address indexed newSystem, + bool publicAccess + ); + + /** + * @notice Emitted when a system is permanently retired. + */ + event WorldSystemRetired(ResourceId indexed systemId, address indexed system); } diff --git a/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol b/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol index 7ce9beabc5..0806f9d274 100644 --- a/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol +++ b/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol @@ -69,6 +69,33 @@ library WorldRegistrationSystemLib { return CallWrapper(self.toResourceId(), address(0)).registerSystem(systemId, system, publicAccess); } + function replaceSystem( + WorldRegistrationSystemType self, + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess + ) internal { + return + CallWrapper(self.toResourceId(), address(0)).replaceSystem( + systemId, + expectedSystem, + expectedPublicAccess, + newSystem, + publicAccess + ); + } + + function retireSystem( + WorldRegistrationSystemType self, + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess + ) internal { + return CallWrapper(self.toResourceId(), address(0)).retireSystem(systemId, expectedSystem, expectedPublicAccess); + } + function registerFunctionSelector( WorldRegistrationSystemType self, ResourceId systemId, @@ -91,6 +118,38 @@ library WorldRegistrationSystemLib { ); } + function replaceFunctionRoute( + WorldRegistrationSystemType self, + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature + ) internal { + return + CallWrapper(self.toResourceId(), address(0)).replaceFunctionRoute( + worldFunctionSelector, + expectedSystemId, + expectedSystemFunctionSelector, + newSystemId, + newSystemFunctionSignature + ); + } + + function unregisterFunctionSelector( + WorldRegistrationSystemType self, + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector + ) internal { + return + CallWrapper(self.toResourceId(), address(0)).unregisterFunctionSelector( + worldFunctionSelector, + expectedSystemId, + expectedSystemFunctionSelector + ); + } + function registerDelegation( WorldRegistrationSystemType self, address delegatee, @@ -177,6 +236,44 @@ library WorldRegistrationSystemLib { : _world().callFrom(self.from, self.systemId, systemCall); } + function replaceSystem( + CallWrapper memory self, + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess + ) internal { + // if the contract calling this function is a root system, it should use `callAsRoot` + if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); + + bytes memory systemCall = abi.encodeCall( + _replaceSystem_ResourceId_address_bool_System_bool.replaceSystem, + (systemId, expectedSystem, expectedPublicAccess, newSystem, publicAccess) + ); + self.from == address(0) + ? _world().call(self.systemId, systemCall) + : _world().callFrom(self.from, self.systemId, systemCall); + } + + function retireSystem( + CallWrapper memory self, + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess + ) internal { + // if the contract calling this function is a root system, it should use `callAsRoot` + if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); + + bytes memory systemCall = abi.encodeCall( + _retireSystem_ResourceId_address_bool.retireSystem, + (systemId, expectedSystem, expectedPublicAccess) + ); + self.from == address(0) + ? _world().call(self.systemId, systemCall) + : _world().callFrom(self.from, self.systemId, systemCall); + } + function registerFunctionSelector( CallWrapper memory self, ResourceId systemId, @@ -222,6 +319,44 @@ library WorldRegistrationSystemLib { } } + function replaceFunctionRoute( + CallWrapper memory self, + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature + ) internal { + // if the contract calling this function is a root system, it should use `callAsRoot` + if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); + + bytes memory systemCall = abi.encodeCall( + _replaceFunctionRoute_bytes4_ResourceId_bytes4_ResourceId_string.replaceFunctionRoute, + (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector, newSystemId, newSystemFunctionSignature) + ); + self.from == address(0) + ? _world().call(self.systemId, systemCall) + : _world().callFrom(self.from, self.systemId, systemCall); + } + + function unregisterFunctionSelector( + CallWrapper memory self, + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector + ) internal { + // if the contract calling this function is a root system, it should use `callAsRoot` + if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); + + bytes memory systemCall = abi.encodeCall( + _unregisterFunctionSelector_bytes4_ResourceId_bytes4.unregisterFunctionSelector, + (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector) + ); + self.from == address(0) + ? _world().call(self.systemId, systemCall) + : _world().callFrom(self.from, self.systemId, systemCall); + } + function registerDelegation( CallWrapper memory self, address delegatee, @@ -315,6 +450,34 @@ library WorldRegistrationSystemLib { SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); } + function replaceSystem( + RootCallWrapper memory self, + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess + ) internal { + bytes memory systemCall = abi.encodeCall( + _replaceSystem_ResourceId_address_bool_System_bool.replaceSystem, + (systemId, expectedSystem, expectedPublicAccess, newSystem, publicAccess) + ); + SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); + } + + function retireSystem( + RootCallWrapper memory self, + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess + ) internal { + bytes memory systemCall = abi.encodeCall( + _retireSystem_ResourceId_address_bool.retireSystem, + (systemId, expectedSystem, expectedPublicAccess) + ); + SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); + } + function registerFunctionSelector( RootCallWrapper memory self, ResourceId systemId, @@ -350,6 +513,34 @@ library WorldRegistrationSystemLib { } } + function replaceFunctionRoute( + RootCallWrapper memory self, + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature + ) internal { + bytes memory systemCall = abi.encodeCall( + _replaceFunctionRoute_bytes4_ResourceId_bytes4_ResourceId_string.replaceFunctionRoute, + (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector, newSystemId, newSystemFunctionSignature) + ); + SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); + } + + function unregisterFunctionSelector( + RootCallWrapper memory self, + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector + ) internal { + bytes memory systemCall = abi.encodeCall( + _unregisterFunctionSelector_bytes4_ResourceId_bytes4.unregisterFunctionSelector, + (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector) + ); + SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); + } + function registerDelegation( RootCallWrapper memory self, address delegatee, @@ -446,6 +637,20 @@ interface _registerSystem_ResourceId_System_bool { function registerSystem(ResourceId systemId, System system, bool publicAccess) external; } +interface _replaceSystem_ResourceId_address_bool_System_bool { + function replaceSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess + ) external; +} + +interface _retireSystem_ResourceId_address_bool { + function retireSystem(ResourceId systemId, address expectedSystem, bool expectedPublicAccess) external; +} + interface _registerFunctionSelector_ResourceId_string { function registerFunctionSelector(ResourceId systemId, string memory systemFunctionSignature) external; } @@ -458,6 +663,24 @@ interface _registerRootFunctionSelector_ResourceId_string_string { ) external; } +interface _replaceFunctionRoute_bytes4_ResourceId_bytes4_ResourceId_string { + function replaceFunctionRoute( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature + ) external; +} + +interface _unregisterFunctionSelector_bytes4_ResourceId_bytes4 { + function unregisterFunctionSelector( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector + ) external; +} + interface _registerDelegation_address_ResourceId_bytes { function registerDelegation(address delegatee, ResourceId delegationControlId, bytes memory initCallData) external; } diff --git a/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol b/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol index 276f3ea674..6b0dcd3868 100644 --- a/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol +++ b/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol @@ -21,6 +21,16 @@ interface IWorldRegistrationSystem { function registerSystem(ResourceId systemId, System system, bool publicAccess) external; + function replaceSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess + ) external; + + function retireSystem(ResourceId systemId, address expectedSystem, bool expectedPublicAccess) external; + function registerFunctionSelector( ResourceId systemId, string memory systemFunctionSignature @@ -32,6 +42,20 @@ interface IWorldRegistrationSystem { string memory systemFunctionSignature ) external returns (bytes4 worldFunctionSelector); + function replaceFunctionRoute( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature + ) external; + + function unregisterFunctionSelector( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector + ) external; + function registerDelegation(address delegatee, ResourceId delegationControlId, bytes memory initCallData) external; function unregisterDelegation(address delegatee) external; diff --git a/packages/world/src/modules/init/InitModule.sol b/packages/world/src/modules/init/InitModule.sol index 417b2f91b3..35ccd819b3 100644 --- a/packages/world/src/modules/init/InitModule.sol +++ b/packages/world/src/modules/init/InitModule.sol @@ -143,7 +143,7 @@ contract InitModule is Module { _registerRootFunctionSelector(BATCH_CALL_SYSTEM_ID, functionSignaturesBatchCall[i]); } - string[14] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); + string[18] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); for (uint256 i = 0; i < functionSignaturesRegistration.length; i++) { _registerRootFunctionSelector(REGISTRATION_SYSTEM_ID, functionSignaturesRegistration[i]); } diff --git a/packages/world/src/modules/init/functionSignatures.sol b/packages/world/src/modules/init/functionSignatures.sol index e9a7fe13bd..f3a07390f0 100644 --- a/packages/world/src/modules/init/functionSignatures.sol +++ b/packages/world/src/modules/init/functionSignatures.sol @@ -39,7 +39,7 @@ function getFunctionSignaturesBatchCall() pure returns (string[2] memory) { /** * @dev Function signatures for registration system */ -function getFunctionSignaturesRegistration() pure returns (string[14] memory) { +function getFunctionSignaturesRegistration() pure returns (string[18] memory) { return [ // --- ModuleInstallationSystem --- "installModule(address,bytes)", @@ -52,8 +52,12 @@ function getFunctionSignaturesRegistration() pure returns (string[14] memory) { "registerSystemHook(bytes32,address,uint8)", "unregisterSystemHook(bytes32,address)", "registerSystem(bytes32,address,bool)", + "replaceSystem(bytes32,address,bool,address,bool)", + "retireSystem(bytes32,address,bool)", "registerFunctionSelector(bytes32,string)", "registerRootFunctionSelector(bytes32,string,string)", + "replaceFunctionRoute(bytes4,bytes32,bytes4,bytes32,string)", + "unregisterFunctionSelector(bytes4,bytes32,bytes4)", "registerDelegation(address,bytes32,bytes)", "unregisterDelegation(address)", "registerNamespaceDelegation(bytes32,bytes32,bytes)", diff --git a/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol b/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol index ab89a6964d..8b460bb9db 100644 --- a/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol +++ b/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol @@ -20,6 +20,7 @@ import { UserDelegationControl } from "../../../codegen/tables/UserDelegationCon import { NamespaceDelegationControl } from "../../../codegen/tables/NamespaceDelegationControl.sol"; import { ISystemHook } from "../../../ISystemHook.sol"; import { IWorldErrors } from "../../../IWorldErrors.sol"; +import { IWorldEvents } from "../../../IWorldEvents.sol"; import { IDelegationControl } from "../../../IDelegationControl.sol"; import { SystemHooks } from "../../../codegen/tables/SystemHooks.sol"; @@ -31,6 +32,7 @@ import { requireNamespace } from "../../../requireNamespace.sol"; import { requireValidNamespace } from "../../../requireValidNamespace.sol"; import { LimitedCallContext } from "../LimitedCallContext.sol"; +import { ACCESS_MANAGEMENT_SYSTEM_ID, BALANCE_TRANSFER_SYSTEM_ID, BATCH_CALL_SYSTEM_ID, REGISTRATION_SYSTEM_ID } from "../constants.sol"; import { createDelegation } from "./createDelegation.sol"; /** @@ -87,8 +89,8 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo // Require the provided address to implement the ISystemHook interface requireInterface(address(hookAddress), type(ISystemHook).interfaceId); - // Require the system to exist - AccessControl._requireExistence(systemId); + // Require the system to be active (retired system IDs remain registered as tombstones) + _requireActiveSystem(systemId); // Require the system's namespace to exist AccessControl._requireExistence(systemId.getNamespaceId()); @@ -128,55 +130,149 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo * @param publicAccess Flag indicating if access control check is bypassed */ function registerSystem(ResourceId systemId, System system, bool publicAccess) public virtual onlyDelegatecall { - // Require the provided system ID to have type RESOURCE_SYSTEM - if (systemId.getType() != RESOURCE_SYSTEM) { - revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); + ResourceId namespaceId = _validateSystemRegistration(systemId, system); + + // Check if a system already exists at this system ID + address existingSystem = Systems._getSystem(systemId); + + // A resource ID without an active system is a permanent retirement tombstone + if (existingSystem == address(0) && ResourceIds._getExists(systemId)) { + revert World_SystemAlreadyRetired(systemId, systemId.toString()); } - // Require the system's namespace to exist - ResourceId namespaceId = systemId.getNamespaceId(); - AccessControl._requireExistence(namespaceId); + _requireSystemAddressAvailable(systemId, system); + _setSystemRegistration(systemId, namespaceId, existingSystem, system, publicAccess); + } - // Require the caller to own the namespace - AccessControl._requireOwner(namespaceId, _msgSender()); + /** + * @notice Registers or replaces a system only if its current state matches the expected state. + * @dev This compare-and-swap primitive prevents a deployment planned against stale state from replacing a + * concurrently upgraded system. An expected zero address is only valid for a never-used system ID; retired IDs + * remain permanent tombstones. Repeating an already-applied replacement is a no-op when all desired state matches. + * If the expected and new implementation are the same, `publicAccess` and the system's default namespace access + * are safely reconciled. + * @param systemId The unique identifier for the system + * @param expectedSystem The implementation expected to be registered, or zero for a never-used ID + * @param expectedPublicAccess The public access flag expected in the current registration + * @param newSystem The new implementation to register + * @param publicAccess Flag indicating if access control checks are bypassed when calling the system + */ + function replaceSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess, + System newSystem, + bool publicAccess + ) public virtual onlyDelegatecall { + ResourceId namespaceId = _validateSystemRegistration(systemId, newSystem); + (address actualSystem, bool actualPublicAccess) = Systems._get(systemId); + + if (actualSystem != address(0)) { + // Fail closed if the forward and reverse registries disagree about the implementation being replaced. + ResourceId registrySystemId = SystemRegistry._get(actualSystem); + if (ResourceId.unwrap(registrySystemId) != ResourceId.unwrap(systemId)) { + revert World_SystemRegistryMismatch(actualSystem, systemId, registrySystemId); + } + } - // Require the provided address to implement the WorldContextConsumer interface - requireInterface(address(system), type(IWorldContextConsumer).interfaceId); + // An exact retry of a completed replacement is a no-op even though its original expectation is now stale. + if ( + actualSystem == address(newSystem) && + actualPublicAccess == publicAccess && + ResourceAccess._get(namespaceId, address(newSystem)) + ) return; + + if (actualSystem != expectedSystem || actualPublicAccess != expectedPublicAccess) { + revert World_SystemStateMismatch( + systemId, + expectedSystem, + expectedPublicAccess, + actualSystem, + actualPublicAccess + ); + } - // Require the name to not be the namespace's root name - if (systemId.getName() == ROOT_NAME) revert World_InvalidResourceId(systemId, systemId.toString()); + if (actualSystem == address(0) && ResourceIds._getExists(systemId)) { + // A registered resource without an active implementation is a permanent retirement tombstone. + revert World_SystemAlreadyRetired(systemId, systemId.toString()); + } - // Require this system to not be registered at a different system ID yet - ResourceId existingSystemId = SystemRegistry._get(address(system)); + _requireSystemAddressAvailable(systemId, newSystem); + + _setSystemRegistration(systemId, namespaceId, actualSystem, newSystem, publicAccess); + + emit IWorldEvents.WorldSystemReplaced(systemId, actualSystem, address(newSystem), publicAccess); + } + + /** + * @notice Permanently retires an active system. + * @dev The resource ID remains registered as a tombstone and can never be reused. + * Core init Systems cannot be retired; replace their implementations at the stable IDs instead. + * Repeated calls for an already retired system are no-ops. + * @param systemId The ID of the system to retire + * @param expectedSystem The system address expected to be registered at the ID + * @param expectedPublicAccess The public access flag expected in the current registration + */ + function retireSystem( + ResourceId systemId, + address expectedSystem, + bool expectedPublicAccess + ) public virtual onlyDelegatecall { + // Require the provided system ID to have type RESOURCE_SYSTEM + if (systemId.getType() != RESOURCE_SYSTEM) { + revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); + } + + // Core init System IDs are permanent protocol entrypoints. They may be + // compare-and-swap replaced, but a tombstone would make them unrecoverable. + bytes32 unwrappedSystemId = ResourceId.unwrap(systemId); if ( - ResourceId.unwrap(existingSystemId) != 0 && ResourceId.unwrap(existingSystemId) != ResourceId.unwrap(systemId) + unwrappedSystemId == ResourceId.unwrap(ACCESS_MANAGEMENT_SYSTEM_ID) || + unwrappedSystemId == ResourceId.unwrap(BALANCE_TRANSFER_SYSTEM_ID) || + unwrappedSystemId == ResourceId.unwrap(BATCH_CALL_SYSTEM_ID) || + unwrappedSystemId == ResourceId.unwrap(REGISTRATION_SYSTEM_ID) ) { - revert World_SystemAlreadyExists(address(system)); + revert World_SystemCannotBeRetired(systemId, systemId.toString()); } - // Check if a system already exists at this system ID - address existingSystem = Systems._getSystem(systemId); + ResourceId namespaceId = systemId.getNamespaceId(); - // If there is an existing system with this system ID, remove it - if (existingSystem != address(0)) { - // Remove the existing system from the system registry - SystemRegistry._deleteRecord(existingSystem); + // Require the system's namespace to exist and the caller to own it + AccessControl._requireExistence(namespaceId); + AccessControl._requireOwner(systemId, _msgSender()); - // Remove the existing system's access to its namespace - ResourceAccess._deleteRecord(namespaceId, existingSystem); - } else { - // Otherwise, this is a new system, so register its resource ID - ResourceIds._setExists(systemId, true); + (address actualSystem, bool actualPublicAccess) = Systems._get(systemId); + + // A registered system resource without an active address is already retired + if (actualSystem == address(0)) { + if (ResourceIds._getExists(systemId)) return; + revert World_ResourceNotFound(systemId, systemId.toString()); } - // Systems = mapping from system ID to system address and public access flag - Systems._set(systemId, address(system), publicAccess); + // Compare-and-swap guard against retiring a replacement system + if (actualSystem != expectedSystem || actualPublicAccess != expectedPublicAccess) { + revert World_SystemStateMismatch( + systemId, + expectedSystem, + expectedPublicAccess, + actualSystem, + actualPublicAccess + ); + } - // SystemRegistry = mapping from system address to system ID - SystemRegistry._set(address(system), systemId); + // Verify the reverse registry before deleting it, so inconsistent state fails closed + ResourceId registrySystemId = SystemRegistry._get(actualSystem); + if (ResourceId.unwrap(registrySystemId) != ResourceId.unwrap(systemId)) { + revert World_SystemRegistryMismatch(actualSystem, systemId, registrySystemId); + } + + // Remove all state that makes the system active, but keep ResourceIds as a permanent tombstone + SystemHooks._deleteRecord(systemId); + SystemRegistry._deleteRecord(actualSystem); + ResourceAccess._deleteRecord(namespaceId, actualSystem); + Systems._deleteRecord(systemId); - // Grant the system access to its namespace - ResourceAccess._set(namespaceId, address(system), true); + emit IWorldEvents.WorldSystemRetired(systemId, actualSystem); } /** @@ -195,8 +291,8 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); } - // Require the resource to exist - AccessControl._requireExistence(systemId); + // Require the system to be active (retired system IDs remain registered as tombstones) + _requireActiveSystem(systemId); // Require the caller to own the namespace AccessControl._requireOwner(systemId, _msgSender()); @@ -236,6 +332,13 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo // Require the caller to own the root namespace AccessControl._requireOwner(ROOT_NAMESPACE_ID, _msgSender()); + // Root aliases must still resolve to an active system. In particular, a + // retired resource ID is a tombstone and must not become callable again. + if (systemId.getType() != RESOURCE_SYSTEM) { + revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); + } + _requireActiveSystem(systemId); + // Compute the function selector from the provided signature worldFunctionSelector = bytes4(keccak256(bytes(worldFunctionSignature))); bytes4 systemFunctionSelector = bytes4(keccak256(bytes(systemFunctionSignature))); @@ -253,6 +356,117 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo FunctionSignatures._set(worldFunctionSelector, worldFunctionSignature); } + /** + * @notice Replaces the complete route for an existing World function selector. + * @dev This is a root-owner recovery primitive. Both sides of the transition are explicit so a migration can + * safely change the destination System ID, the destination function selector, or both. Destination signature + * metadata is written from the supplied signature, including on an idempotent route retry. + * @param worldFunctionSelector The World function selector whose route is replaced + * @param expectedSystemId The system ID expected in the current route + * @param expectedSystemFunctionSelector The system function selector expected in the current route + * @param newSystemId The active destination system ID + * @param newSystemFunctionSignature The destination system function signature; its selector is derived onchain + */ + function replaceFunctionRoute( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector, + ResourceId newSystemId, + string memory newSystemFunctionSignature + ) public virtual onlyDelegatecall { + // Raw selector routing is global, so only the root namespace owner may change it + AccessControl._requireOwner(ROOT_NAMESPACE_ID, _msgSender()); + + // Require the destination to be an active system + if (newSystemId.getType() != RESOURCE_SYSTEM) { + revert World_InvalidResourceType(RESOURCE_SYSTEM, newSystemId, newSystemId.toString()); + } + _requireActiveSystem(newSystemId); + + bytes4 newSystemFunctionSelector = bytes4(keccak256(bytes(newSystemFunctionSignature))); + + (ResourceId actualSystemId, bytes4 actualSystemFunctionSelector) = FunctionSelectors._get(worldFunctionSelector); + + // Idempotent retry after a successful replacement. Reconcile the offchain signature metadata as well. + if ( + ResourceId.unwrap(actualSystemId) == ResourceId.unwrap(newSystemId) && + actualSystemFunctionSelector == newSystemFunctionSelector + ) { + FunctionSignatures._set(newSystemFunctionSelector, newSystemFunctionSignature); + return; + } + + // Compare-and-swap guard against overwriting an unexpected route + if ( + ResourceId.unwrap(actualSystemId) != ResourceId.unwrap(expectedSystemId) || + actualSystemFunctionSelector != expectedSystemFunctionSelector + ) { + revert World_FunctionSelectorMismatch( + worldFunctionSelector, + expectedSystemId, + expectedSystemFunctionSelector, + actualSystemId, + actualSystemFunctionSelector + ); + } + + FunctionSelectors._set(worldFunctionSelector, newSystemId, newSystemFunctionSelector); + FunctionSignatures._set(newSystemFunctionSelector, newSystemFunctionSignature); + + emit IWorldEvents.WorldFunctionRouteReplaced( + worldFunctionSelector, + expectedSystemId, + newSystemId, + expectedSystemFunctionSelector, + newSystemFunctionSelector + ); + } + + /** + * @notice Unregisters a World function selector if it still matches the expected route. + * @dev This is a root-owner recovery primitive. FunctionSignatures metadata is intentionally + * retained because signatures are globally keyed and can be shared by other routes. + * Repeated calls after a successful unregister are no-ops. + * @param worldFunctionSelector The World function selector to unregister + * @param expectedSystemId The system ID expected in the current route + * @param expectedSystemFunctionSelector The system function selector expected in the current route + */ + function unregisterFunctionSelector( + bytes4 worldFunctionSelector, + ResourceId expectedSystemId, + bytes4 expectedSystemFunctionSelector + ) public virtual onlyDelegatecall { + // Raw selector routing is global, so only the root namespace owner may change it + AccessControl._requireOwner(ROOT_NAMESPACE_ID, _msgSender()); + + (ResourceId actualSystemId, bytes4 actualSystemFunctionSelector) = FunctionSelectors._get(worldFunctionSelector); + + // Idempotent retry after a successful unregister + if (ResourceId.unwrap(actualSystemId) == 0 && actualSystemFunctionSelector == bytes4(0)) return; + + // Compare-and-swap guard against deleting an unexpected route + if ( + ResourceId.unwrap(actualSystemId) != ResourceId.unwrap(expectedSystemId) || + actualSystemFunctionSelector != expectedSystemFunctionSelector + ) { + revert World_FunctionSelectorMismatch( + worldFunctionSelector, + expectedSystemId, + expectedSystemFunctionSelector, + actualSystemId, + actualSystemFunctionSelector + ); + } + + FunctionSelectors._deleteRecord(worldFunctionSelector); + + emit IWorldEvents.WorldFunctionSelectorUnregistered( + worldFunctionSelector, + expectedSystemId, + expectedSystemFunctionSelector + ); + } + /** * @notice Registers a delegation for the caller * @dev Creates a new delegation from the caller to the specified delegatee @@ -337,4 +551,67 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo // Delete the delegation control NamespaceDelegationControl.deleteRecord(namespaceId); } + + /** + * @dev Validate the invariant and authorization checks shared by system registration primitives. + */ + function _validateSystemRegistration( + ResourceId systemId, + System system + ) internal view returns (ResourceId namespaceId) { + if (systemId.getType() != RESOURCE_SYSTEM) { + revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); + } + + namespaceId = systemId.getNamespaceId(); + AccessControl._requireExistence(namespaceId); + AccessControl._requireOwner(namespaceId, _msgSender()); + + requireInterface(address(system), type(IWorldContextConsumer).interfaceId); + + if (systemId.getName() == ROOT_NAME) revert World_InvalidResourceId(systemId, systemId.toString()); + } + + /** + * @dev Require an implementation address to be unused or already associated with the target system ID. + */ + function _requireSystemAddressAvailable(ResourceId systemId, System system) internal view { + ResourceId existingSystemId = SystemRegistry._get(address(system)); + if ( + ResourceId.unwrap(existingSystemId) != 0 && ResourceId.unwrap(existingSystemId) != ResourceId.unwrap(systemId) + ) { + revert World_SystemAlreadyExists(address(system)); + } + } + + /** + * @dev Apply the forward registry, reverse registry, resource ID, and default namespace access atomically. + */ + function _setSystemRegistration( + ResourceId systemId, + ResourceId namespaceId, + address existingSystem, + System newSystem, + bool publicAccess + ) internal { + if (existingSystem == address(0)) { + ResourceIds._setExists(systemId, true); + } else if (existingSystem != address(newSystem)) { + SystemRegistry._deleteRecord(existingSystem); + ResourceAccess._deleteRecord(namespaceId, existingSystem); + } + + Systems._set(systemId, address(newSystem), publicAccess); + SystemRegistry._set(address(newSystem), systemId); + ResourceAccess._set(namespaceId, address(newSystem), true); + } + + /** + * @dev Require a system ID to currently resolve to an implementation address. + */ + function _requireActiveSystem(ResourceId systemId) internal view { + if (Systems._getSystem(systemId) == address(0)) { + revert World_ResourceNotFound(systemId, systemId.toString()); + } + } } diff --git a/packages/world/src/version.sol b/packages/world/src/version.sol index c09c418bc9..5a9d3463a5 100644 --- a/packages/world/src/version.sol +++ b/packages/world/src/version.sol @@ -8,4 +8,4 @@ pragma solidity >=0.8.24; */ /// @dev Identifier for the current World protocol version. -bytes32 constant WORLD_VERSION = "2.0.2"; +bytes32 constant WORLD_VERSION = "2.1.0"; diff --git a/packages/world/test/InitSystems.t.sol b/packages/world/test/InitSystems.t.sol index 99c9714911..4834c7508e 100644 --- a/packages/world/test/InitSystems.t.sol +++ b/packages/world/test/InitSystems.t.sol @@ -63,7 +63,7 @@ contract LimitedCallContextTest is Test { } function testRegistrationSystem() public { - string[14] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); + string[18] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); for (uint256 i; i < functionSignaturesRegistration.length; i++) { callSystem(REGISTRATION_SYSTEM_ID, functionSignaturesRegistration[i]); diff --git a/packages/world/test/SystemMigration.t.sol b/packages/world/test/SystemMigration.t.sol new file mode 100644 index 0000000000..6c91082c53 --- /dev/null +++ b/packages/world/test/SystemMigration.t.sol @@ -0,0 +1,1035 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { ResourceIds } from "@latticexyz/store/src/codegen/tables/ResourceIds.sol"; +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; +import { IStoreEvents } from "@latticexyz/store/src/IStoreEvents.sol"; + +import { System } from "../src/System.sol"; +import { SystemHook } from "../src/SystemHook.sol"; +import { IWorldContextConsumer } from "../src/WorldContext.sol"; +import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "../src/WorldResourceId.sol"; +import { RESOURCE_SYSTEM } from "../src/worldResourceTypes.sol"; +import { BEFORE_CALL_SYSTEM } from "../src/systemHookTypes.sol"; +import { ROOT_NAME } from "../src/constants.sol"; +import { AccessControl } from "../src/AccessControl.sol"; +import { requireInterface } from "../src/requireInterface.sol"; + +import { IWorldErrors } from "../src/IWorldErrors.sol"; +import { IWorldEvents } from "../src/IWorldEvents.sol"; +import { IBaseWorld } from "../src/codegen/interfaces/IBaseWorld.sol"; +import { IWorldRegistrationSystem } from "../src/codegen/interfaces/IWorldRegistrationSystem.sol"; + +import { FunctionSelectors } from "../src/codegen/tables/FunctionSelectors.sol"; +import { FunctionSignatures } from "../src/codegen/tables/FunctionSignatures.sol"; +import { ResourceAccess } from "../src/codegen/tables/ResourceAccess.sol"; +import { SystemHooks } from "../src/codegen/tables/SystemHooks.sol"; +import { SystemRegistry } from "../src/codegen/tables/SystemRegistry.sol"; +import { Systems } from "../src/codegen/tables/Systems.sol"; + +import { ACCESS_MANAGEMENT_SYSTEM_ID, BALANCE_TRANSFER_SYSTEM_ID, BATCH_CALL_SYSTEM_ID, REGISTRATION_SYSTEM_ID } from "../src/modules/init/constants.sol"; +import { RegistrationSystem } from "../src/modules/init/RegistrationSystem.sol"; +import { LimitedCallContext } from "../src/modules/init/LimitedCallContext.sol"; +import { SystemCallData } from "../src/modules/init/types.sol"; + +import { createWorld } from "./createWorld.sol"; + +contract MigrationTestSystem is System { + address private immutable implementationAddress = address(this); + + function implementation() public view returns (address) { + return implementationAddress; + } + + function alternateImplementation() public view returns (address) { + return implementationAddress; + } +} + +/** + * @dev Minimal pre-2.1 RegistrationSystem fixture. It intentionally exposes only + * the legacy, unconditional registerSystem primitive needed to bootstrap the new + * implementation. The implementation matches the 2.0.2 registration semantics. + */ +contract LegacyMigrationRegistrationSystem is System, IWorldErrors, LimitedCallContext { + using WorldResourceIdInstance for ResourceId; + + function registerSystem(ResourceId systemId, System system, bool publicAccess) public onlyDelegatecall { + if (systemId.getType() != RESOURCE_SYSTEM) { + revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); + } + + ResourceId namespaceId = systemId.getNamespaceId(); + AccessControl._requireExistence(namespaceId); + AccessControl._requireOwner(namespaceId, _msgSender()); + requireInterface(address(system), type(IWorldContextConsumer).interfaceId); + if (systemId.getName() == ROOT_NAME) revert World_InvalidResourceId(systemId, systemId.toString()); + + ResourceId existingSystemId = SystemRegistry._get(address(system)); + if ( + ResourceId.unwrap(existingSystemId) != 0 && ResourceId.unwrap(existingSystemId) != ResourceId.unwrap(systemId) + ) { + revert World_SystemAlreadyExists(address(system)); + } + + address existingSystem = Systems._getSystem(systemId); + if (existingSystem != address(0)) { + SystemRegistry._deleteRecord(existingSystem); + ResourceAccess._deleteRecord(namespaceId, existingSystem); + } else { + ResourceIds._setExists(systemId, true); + } + + Systems._set(systemId, address(system), publicAccess); + SystemRegistry._set(address(system), systemId); + ResourceAccess._set(namespaceId, address(system), true); + } +} + +contract RevertingMigrationHook is SystemHook { + function onBeforeCallSystem(address, ResourceId, bytes memory) public pure { + revert("retired system hook executed"); + } + + function onAfterCallSystem(address, ResourceId, bytes memory) public pure {} +} + +contract SystemMigrationTest is Test { + using WorldResourceIdInstance for ResourceId; + + struct LegacyMigrationFixture { + ResourceId sourceSystemId; + ResourceId targetSystemId; + MigrationTestSystem sourceSystem; + MigrationTestSystem targetSystem; + LegacyMigrationRegistrationSystem legacyRegistrationSystem; + RegistrationSystem newRegistrationSystem; + bytes4 worldFunctionSelector; + bytes4 systemFunctionSelector; + string[4] nativeSignatures; + } + + IBaseWorld internal world; + + function setUp() public { + world = createWorld(); + StoreSwitch.setStoreAddress(address(world)); + } + + function testReplaceFunctionRoute() public { + ( + ResourceId fromSystemId, + ResourceId toSystemId, + , + MigrationTestSystem toSystem, + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + string memory newSystemFunctionSignature = "alternateImplementation()"; + bytes4 newSystemFunctionSelector = MigrationTestSystem.alternateImplementation.selector; + + vm.expectEmit(true, true, true, true); + emit IWorldEvents.WorldFunctionRouteReplaced( + worldFunctionSelector, + fromSystemId, + toSystemId, + systemFunctionSelector, + newSystemFunctionSelector + ); + world.replaceFunctionRoute( + worldFunctionSelector, + fromSystemId, + systemFunctionSelector, + toSystemId, + newSystemFunctionSignature + ); + + (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); + assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(toSystemId)); + assertEq(registeredSystemSelector, newSystemFunctionSelector); + + (bool success, bytes memory returnData) = address(world).call(abi.encodeWithSelector(worldFunctionSelector)); + assertTrue(success); + assertEq(abi.decode(returnData, (address)), address(toSystem)); + } + + function testReplaceFunctionRouteRetryReconcilesSignatureMetadata() public { + ( + ResourceId fromSystemId, + ResourceId toSystemId, + , + , + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + string memory newSystemFunctionSignature = "alternateImplementation()"; + bytes4 newSystemFunctionSelector = MigrationTestSystem.alternateImplementation.selector; + + world.replaceFunctionRoute( + worldFunctionSelector, + fromSystemId, + systemFunctionSelector, + toSystemId, + newSystemFunctionSignature + ); + + // A retry leaves the route unchanged but repairs missing offchain signature metadata. + FunctionSignatures.deleteRecord(newSystemFunctionSelector); + _expectFunctionSignatureSet(newSystemFunctionSelector, newSystemFunctionSignature); + world.replaceFunctionRoute( + worldFunctionSelector, + fromSystemId, + systemFunctionSelector, + toSystemId, + newSystemFunctionSignature + ); + (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); + assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(toSystemId)); + assertEq(registeredSystemSelector, newSystemFunctionSelector); + } + + function _expectFunctionSignatureSet(bytes4 selector, string memory signature) internal { + bytes32[] memory signatureKey = new bytes32[](1); + signatureKey[0] = bytes32(selector); + vm.expectEmit(true, false, false, true, address(world)); + emit IStoreEvents.Store_SetRecord( + FunctionSignatures._tableId, + signatureKey, + new bytes(0), + FunctionSignatures.encodeLengths(signature), + FunctionSignatures.encodeDynamic(signature) + ); + } + + function testReplaceFunctionRouteRequiresRootOwner() public { + ( + ResourceId fromSystemId, + ResourceId toSystemId, + , + , + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + address unauthorized = makeAddr("unauthorized"); + + vm.prank(unauthorized); + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_AccessDenied.selector, + WorldResourceIdLib.encodeNamespace("").toString(), + unauthorized + ) + ); + world.replaceFunctionRoute( + worldFunctionSelector, + fromSystemId, + systemFunctionSelector, + toSystemId, + "implementation()" + ); + } + + function testReplaceFunctionRouteRejectsUnexpectedRoute() public { + ( + ResourceId fromSystemId, + ResourceId toSystemId, + , + , + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + ResourceId unexpectedSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "source", + name: "unexpected" + }); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_FunctionSelectorMismatch.selector, + worldFunctionSelector, + unexpectedSystemId, + systemFunctionSelector, + fromSystemId, + systemFunctionSelector + ) + ); + world.replaceFunctionRoute( + worldFunctionSelector, + unexpectedSystemId, + systemFunctionSelector, + toSystemId, + "implementation()" + ); + + bytes4 unexpectedSystemSelector = bytes4(keccak256("unexpected()")); + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_FunctionSelectorMismatch.selector, + worldFunctionSelector, + fromSystemId, + unexpectedSystemSelector, + fromSystemId, + systemFunctionSelector + ) + ); + world.replaceFunctionRoute( + worldFunctionSelector, + fromSystemId, + unexpectedSystemSelector, + toSystemId, + "implementation()" + ); + } + + function testReplaceFunctionRouteBatchRollbackOnCasMismatch() public { + ( + ResourceId fromSystemId, + ResourceId toSystemId, + , + , + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + + SystemCallData[] memory calls = new SystemCallData[](2); + calls[0] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.replaceFunctionRoute, + (worldFunctionSelector, fromSystemId, systemFunctionSelector, toSystemId, "implementation()") + ) + }); + calls[1] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.unregisterFunctionSelector, + (worldFunctionSelector, fromSystemId, systemFunctionSelector) + ) + }); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_FunctionSelectorMismatch.selector, + worldFunctionSelector, + fromSystemId, + systemFunctionSelector, + toSystemId, + systemFunctionSelector + ) + ); + world.batchCall(calls); + + (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); + assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(fromSystemId)); + assertEq(registeredSystemSelector, systemFunctionSelector); + } + + function testUnregisterFunctionSelector() public { + ( + ResourceId fromSystemId, + , + , + , + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + + vm.expectEmit(true, true, true, true); + emit IWorldEvents.WorldFunctionSelectorUnregistered(worldFunctionSelector, fromSystemId, systemFunctionSelector); + world.unregisterFunctionSelector(worldFunctionSelector, fromSystemId, systemFunctionSelector); + + (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); + assertEq(ResourceId.unwrap(registeredSystemId), bytes32(0)); + assertEq(registeredSystemSelector, bytes4(0)); + + (bool success, bytes memory returnData) = address(world).call(abi.encodeWithSelector(worldFunctionSelector)); + assertFalse(success); + assertEq( + returnData, + abi.encodeWithSelector(IWorldErrors.World_FunctionSelectorNotFound.selector, worldFunctionSelector) + ); + + // A retry after the record was removed is an idempotent no-op. + world.unregisterFunctionSelector(worldFunctionSelector, fromSystemId, systemFunctionSelector); + } + + function testUnregisterFunctionSelectorRequiresRootOwnerAndMatchingRoute() public { + ( + ResourceId fromSystemId, + ResourceId toSystemId, + , + , + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + address unauthorized = makeAddr("unauthorized"); + + vm.prank(unauthorized); + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_AccessDenied.selector, + WorldResourceIdLib.encodeNamespace("").toString(), + unauthorized + ) + ); + world.unregisterFunctionSelector(worldFunctionSelector, fromSystemId, systemFunctionSelector); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_FunctionSelectorMismatch.selector, + worldFunctionSelector, + toSystemId, + systemFunctionSelector, + fromSystemId, + systemFunctionSelector + ) + ); + world.unregisterFunctionSelector(worldFunctionSelector, toSystemId, systemFunctionSelector); + } + + function testUnregisterFunctionSelectorOnlyTreatsExactZeroRouteAsAbsent() public { + bytes4 worldFunctionSelector = bytes4(keccak256("partiallyDeleted()")); + bytes4 actualSystemFunctionSelector = bytes4(keccak256("stale()")); + ResourceId expectedSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "source", + name: "system" + }); + FunctionSelectors.set(worldFunctionSelector, ResourceId.wrap(bytes32(0)), actualSystemFunctionSelector); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_FunctionSelectorMismatch.selector, + worldFunctionSelector, + expectedSystemId, + actualSystemFunctionSelector, + ResourceId.wrap(bytes32(0)), + actualSystemFunctionSelector + ) + ); + world.unregisterFunctionSelector(worldFunctionSelector, expectedSystemId, actualSystemFunctionSelector); + } + + function testReplaceSystemRegistersNeverUsedId() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "new" }); + ResourceId namespaceId = systemId.getNamespaceId(); + world.registerNamespace(namespaceId); + MigrationTestSystem system = new MigrationTestSystem(); + + vm.expectEmit(true, true, true, true); + emit IWorldEvents.WorldSystemReplaced(systemId, address(0), address(system), true); + world.replaceSystem(systemId, address(0), false, system, true); + + (address registeredSystem, bool publicAccess) = Systems.get(systemId); + assertEq(registeredSystem, address(system)); + assertTrue(publicAccess); + assertTrue(ResourceIds.getExists(systemId)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(system))), ResourceId.unwrap(systemId)); + assertTrue(ResourceAccess.get(namespaceId, address(system))); + } + + function testReplaceSystemUpgradesOnlyExpectedImplementation() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "upgrade" }); + ResourceId namespaceId = systemId.getNamespaceId(); + world.registerNamespace(namespaceId); + MigrationTestSystem oldSystem = new MigrationTestSystem(); + MigrationTestSystem newSystem = new MigrationTestSystem(); + world.registerSystem(systemId, oldSystem, true); + + vm.expectEmit(true, true, true, true); + emit IWorldEvents.WorldSystemReplaced(systemId, address(oldSystem), address(newSystem), false); + world.replaceSystem(systemId, address(oldSystem), true, newSystem, false); + + (address registeredSystem, bool publicAccess) = Systems.get(systemId); + assertEq(registeredSystem, address(newSystem)); + assertFalse(publicAccess); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(oldSystem))), bytes32(0)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(newSystem))), ResourceId.unwrap(systemId)); + assertFalse(ResourceAccess.get(namespaceId, address(oldSystem))); + assertTrue(ResourceAccess.get(namespaceId, address(newSystem))); + + // Retrying the original transition is an exact no-op after the desired state is reached. + world.replaceSystem(systemId, address(oldSystem), true, newSystem, false); + } + + function testReplaceSystemRejectsStateMismatchWithoutWrites() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "mismatch" }); + ResourceId namespaceId = systemId.getNamespaceId(); + world.registerNamespace(namespaceId); + MigrationTestSystem currentSystem = new MigrationTestSystem(); + MigrationTestSystem newSystem = new MigrationTestSystem(); + world.registerSystem(systemId, currentSystem, true); + address unexpectedSystem = makeAddr("unexpected system"); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_SystemStateMismatch.selector, + systemId, + unexpectedSystem, + true, + address(currentSystem), + true + ) + ); + world.replaceSystem(systemId, unexpectedSystem, true, newSystem, false); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_SystemStateMismatch.selector, + systemId, + address(currentSystem), + false, + address(currentSystem), + true + ) + ); + world.replaceSystem(systemId, address(currentSystem), false, newSystem, false); + + (address registeredSystem, bool publicAccess) = Systems.get(systemId); + assertEq(registeredSystem, address(currentSystem)); + assertTrue(publicAccess); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(currentSystem))), ResourceId.unwrap(systemId)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(newSystem))), bytes32(0)); + assertTrue(ResourceAccess.get(namespaceId, address(currentSystem))); + assertFalse(ResourceAccess.get(namespaceId, address(newSystem))); + } + + function testReplaceSystemRequiresNamespaceOwner() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "owned" }); + ResourceId namespaceId = systemId.getNamespaceId(); + world.registerNamespace(namespaceId); + MigrationTestSystem currentSystem = new MigrationTestSystem(); + MigrationTestSystem newSystem = new MigrationTestSystem(); + world.registerSystem(systemId, currentSystem, true); + address unauthorized = makeAddr("unauthorized replacement"); + + vm.prank(unauthorized); + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, namespaceId.toString(), unauthorized) + ); + world.replaceSystem(systemId, address(currentSystem), true, newSystem, false); + + assertEq(Systems.getSystem(systemId), address(currentSystem)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(newSystem))), bytes32(0)); + } + + function testReplaceSystemReconcilesAccessForExpectedImplementation() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "access" }); + ResourceId namespaceId = systemId.getNamespaceId(); + world.registerNamespace(namespaceId); + MigrationTestSystem system = new MigrationTestSystem(); + world.registerSystem(systemId, system, true); + + ResourceAccess.set(namespaceId, address(system), false); + + vm.expectEmit(true, true, true, true); + emit IWorldEvents.WorldSystemReplaced(systemId, address(system), address(system), false); + world.replaceSystem(systemId, address(system), true, system, false); + + (address registeredSystem, bool publicAccess) = Systems.get(systemId); + assertEq(registeredSystem, address(system)); + assertFalse(publicAccess); + assertTrue(ResourceAccess.get(namespaceId, address(system))); + } + + function testReplaceSystemRejectsRetiredId() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "retired" }); + world.registerNamespace(systemId.getNamespaceId()); + MigrationTestSystem system = new MigrationTestSystem(); + world.registerSystem(systemId, system, true); + world.retireSystem(systemId, address(system), true); + MigrationTestSystem replacement = new MigrationTestSystem(); + + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_SystemAlreadyRetired.selector, systemId, systemId.toString()) + ); + world.replaceSystem(systemId, address(0), false, replacement, true); + + assertEq(Systems.getSystem(systemId), address(0)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(replacement))), bytes32(0)); + assertTrue(ResourceIds.getExists(systemId)); + } + + function testReplaceSystemRejectsInconsistentReverseRegistry() public { + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "corrupt" }); + world.registerNamespace(systemId.getNamespaceId()); + MigrationTestSystem currentSystem = new MigrationTestSystem(); + MigrationTestSystem replacement = new MigrationTestSystem(); + world.registerSystem(systemId, currentSystem, true); + ResourceId inconsistentSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "cas", + name: "other" + }); + SystemRegistry.set(address(currentSystem), inconsistentSystemId); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_SystemRegistryMismatch.selector, + address(currentSystem), + systemId, + inconsistentSystemId + ) + ); + world.replaceSystem(systemId, address(currentSystem), true, replacement, false); + + assertEq(Systems.getSystem(systemId), address(currentSystem)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(currentSystem))), ResourceId.unwrap(inconsistentSystemId)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(replacement))), bytes32(0)); + } + + function testReplaceSystemRejectsImplementationRegisteredElsewhere() public { + ResourceId firstSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "first" }); + ResourceId secondSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "cas", + name: "second" + }); + world.registerNamespace(firstSystemId.getNamespaceId()); + MigrationTestSystem firstSystem = new MigrationTestSystem(); + MigrationTestSystem secondSystem = new MigrationTestSystem(); + world.registerSystem(firstSystemId, firstSystem, true); + world.registerSystem(secondSystemId, secondSystem, false); + + vm.expectRevert(abi.encodeWithSelector(IWorldErrors.World_SystemAlreadyExists.selector, address(secondSystem))); + world.replaceSystem(firstSystemId, address(firstSystem), true, secondSystem, false); + + assertEq(Systems.getSystem(firstSystemId), address(firstSystem)); + assertEq(Systems.getSystem(secondSystemId), address(secondSystem)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(firstSystem))), ResourceId.unwrap(firstSystemId)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(secondSystem))), ResourceId.unwrap(secondSystemId)); + } + + function testRetireSystemClearsActiveStateAndLeavesTombstone() public { + bytes14 namespace = "retirement"; + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: namespace, name: "system" }); + world.registerNamespace(systemId.getNamespaceId()); + MigrationTestSystem system = new MigrationTestSystem(); + world.registerSystem(systemId, system, true); + + RevertingMigrationHook hook = new RevertingMigrationHook(); + world.registerSystemHook(systemId, hook, BEFORE_CALL_SYSTEM); + + vm.expectEmit(true, true, true, true); + emit IWorldEvents.WorldSystemRetired(systemId, address(system)); + world.retireSystem(systemId, address(system), true); + + (address registeredSystem, bool publicAccess) = Systems.get(systemId); + assertEq(registeredSystem, address(0)); + assertFalse(publicAccess); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(system))), bytes32(0)); + assertFalse(ResourceAccess.get(systemId.getNamespaceId(), address(system))); + assertEq(SystemHooks.get(systemId).length, 0); + assertTrue(ResourceIds.getExists(systemId)); + + // The hook must be deleted before any subsequent call attempts to resolve the retired system. + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, systemId, systemId.toString()) + ); + world.call(systemId, abi.encodeCall(MigrationTestSystem.implementation, ())); + + // Repeated retirement is an idempotent no-op. + world.retireSystem(systemId, address(system), true); + + MigrationTestSystem replacementSystem = new MigrationTestSystem(); + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_SystemAlreadyRetired.selector, systemId, systemId.toString()) + ); + world.registerSystem(systemId, replacementSystem, true); + } + + function testRetireSystemRequiresNamespaceOwnerAndExpectedState() public { + bytes14 namespace = "retirement"; + ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: namespace, name: "system" }); + world.registerNamespace(systemId.getNamespaceId()); + MigrationTestSystem system = new MigrationTestSystem(); + world.registerSystem(systemId, system, true); + address unauthorized = makeAddr("unauthorized"); + + vm.prank(unauthorized); + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, systemId.toString(), unauthorized) + ); + world.retireSystem(systemId, address(system), true); + + address unexpectedSystem = makeAddr("unexpected system"); + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_SystemStateMismatch.selector, + systemId, + unexpectedSystem, + true, + address(system), + true + ) + ); + world.retireSystem(systemId, unexpectedSystem, true); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_SystemStateMismatch.selector, + systemId, + address(system), + false, + address(system), + true + ) + ); + world.retireSystem(systemId, address(system), false); + + assertEq(Systems.getSystem(systemId), address(system)); + } + + function testRetireSystemRejectsInconsistentReverseRegistry() public { + ResourceId systemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "retirement", + name: "system" + }); + world.registerNamespace(systemId.getNamespaceId()); + MigrationTestSystem system = new MigrationTestSystem(); + world.registerSystem(systemId, system, true); + + ResourceId inconsistentSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "retirement", + name: "other" + }); + SystemRegistry.set(address(system), inconsistentSystemId); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_SystemRegistryMismatch.selector, + address(system), + systemId, + inconsistentSystemId + ) + ); + world.retireSystem(systemId, address(system), true); + + assertEq(Systems.getSystem(systemId), address(system)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(system))), ResourceId.unwrap(inconsistentSystemId)); + } + + function testRetireSystemRejectsNeverRegisteredSystem() public { + ResourceId systemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "retirement", + name: "missing" + }); + world.registerNamespace(systemId.getNamespaceId()); + + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, systemId, systemId.toString()) + ); + world.retireSystem(systemId, makeAddr("missing system"), false); + } + + function testRetireSystemRejectsCoreSystems() public { + ResourceId[4] memory coreSystemIds = [ + ACCESS_MANAGEMENT_SYSTEM_ID, + BALANCE_TRANSFER_SYSTEM_ID, + BATCH_CALL_SYSTEM_ID, + REGISTRATION_SYSTEM_ID + ]; + + for (uint256 i; i < coreSystemIds.length; i++) { + ResourceId coreSystemId = coreSystemIds[i]; + (address coreSystem, bool corePublicAccess) = Systems.get(coreSystemId); + + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_SystemCannotBeRetired.selector, coreSystemId, coreSystemId.toString()) + ); + world.retireSystem(coreSystemId, coreSystem, corePublicAccess); + + assertEq(Systems.getSystem(coreSystemId), coreSystem); + assertEq(ResourceId.unwrap(SystemRegistry.get(coreSystem)), ResourceId.unwrap(coreSystemId)); + assertTrue(ResourceIds.getExists(coreSystemId)); + } + } + + function testRetiredSystemCannotReceiveHooksFunctionsOrMovedSelectors() public { + ( + ResourceId fromSystemId, + ResourceId retiredSystemId, + , + MigrationTestSystem retiredSystem, + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) = _registerSystemPairAndSelector(); + world.retireSystem(retiredSystemId, address(retiredSystem), true); + + RevertingMigrationHook hook = new RevertingMigrationHook(); + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) + ); + world.registerSystemHook(retiredSystemId, hook, BEFORE_CALL_SYSTEM); + + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) + ); + world.registerFunctionSelector(retiredSystemId, "implementation()"); + + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) + ); + world.registerRootFunctionSelector(retiredSystemId, "retiredImplementation()", "implementation()"); + + vm.expectRevert( + abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) + ); + world.replaceFunctionRoute( + worldFunctionSelector, + fromSystemId, + systemFunctionSelector, + retiredSystemId, + "implementation()" + ); + + (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); + assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(fromSystemId)); + assertEq(registeredSystemSelector, systemFunctionSelector); + } + + function testLegacyWorldBootstrapAtomicallyMigratesSelectorAndRetiresSource() public { + LegacyMigrationFixture memory fixture = _createLegacyMigrationFixture(); + + (bool success, bytes memory returnData) = address(world).call( + abi.encodeWithSelector(fixture.worldFunctionSelector) + ); + assertTrue(success); + assertEq(abi.decode(returnData, (address)), address(fixture.sourceSystem)); + + // A stale plan failing after the core replacement and target registration + // must roll back the entire batch, including the bootstrap itself. + bytes4 unexpectedSystemSelector = bytes4(keccak256("unexpected()")); + SystemCallData[] memory staleCalls = _legacyBootstrapCalls(fixture, unexpectedSystemSelector); + + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_FunctionSelectorMismatch.selector, + fixture.worldFunctionSelector, + fixture.sourceSystemId, + unexpectedSystemSelector, + fixture.sourceSystemId, + fixture.systemFunctionSelector + ) + ); + world.batchCall(staleCalls); + _assertLegacyMigrationRollback(fixture); + + SystemCallData[] memory migrationCalls = _legacyBootstrapCalls(fixture, fixture.systemFunctionSelector); + world.batchCall(migrationCalls); + _assertLegacyMigrationApplied(fixture); + + // Native lifecycle retries remain harmless after the CLI replans against + // the already-migrated selector and the source tombstone. + SystemCallData[] memory retryCalls = new SystemCallData[](2); + retryCalls[0] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.replaceFunctionRoute, + ( + fixture.worldFunctionSelector, + fixture.sourceSystemId, + fixture.systemFunctionSelector, + fixture.targetSystemId, + "implementation()" + ) + ) + }); + retryCalls[1] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.retireSystem, + (fixture.sourceSystemId, address(fixture.sourceSystem), true) + ) + }); + world.batchCall(retryCalls); + } + + function _createLegacyMigrationFixture() internal returns (LegacyMigrationFixture memory fixture) { + fixture.sourceSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "", + name: "LegacySource" + }); + fixture.targetSystemId = WorldResourceIdLib.encode({ + typeId: RESOURCE_SYSTEM, + namespace: "", + name: "DesiredTarget" + }); + fixture.sourceSystem = new MigrationTestSystem(); + fixture.targetSystem = new MigrationTestSystem(); + world.registerSystem(fixture.sourceSystemId, fixture.sourceSystem, true); + + fixture.worldFunctionSelector = world.registerRootFunctionSelector( + fixture.sourceSystemId, + "migratedImplementation()", + "implementation()" + ); + fixture.systemFunctionSelector = MigrationTestSystem.implementation.selector; + + // Recreate the relevant 2.0.2 state: a legacy core implementation, none of + // the native lifecycle routes, and a selector still routed to its old System. + fixture.nativeSignatures[0] = "replaceFunctionRoute(bytes4,bytes32,bytes4,bytes32,string)"; + fixture.nativeSignatures[1] = "unregisterFunctionSelector(bytes4,bytes32,bytes4)"; + fixture.nativeSignatures[2] = "retireSystem(bytes32,address,bool)"; + fixture.nativeSignatures[3] = "replaceSystem(bytes32,address,bool,address,bool)"; + for (uint256 i; i < fixture.nativeSignatures.length; i++) { + if (i == 1) continue; + bytes4 selector = bytes4(keccak256(bytes(fixture.nativeSignatures[i]))); + world.unregisterFunctionSelector(selector, REGISTRATION_SYSTEM_ID, selector); + } + + // Remove unregisterFunctionSelector last because this call uses that route. + bytes4 unregisterSelector = bytes4(keccak256(bytes(fixture.nativeSignatures[1]))); + world.unregisterFunctionSelector(unregisterSelector, REGISTRATION_SYSTEM_ID, unregisterSelector); + + fixture.legacyRegistrationSystem = new LegacyMigrationRegistrationSystem(); + world.registerSystem(REGISTRATION_SYSTEM_ID, fixture.legacyRegistrationSystem, true); + fixture.newRegistrationSystem = new RegistrationSystem(); + } + + function _assertLegacyMigrationRollback(LegacyMigrationFixture memory fixture) internal { + assertEq(Systems.getSystem(REGISTRATION_SYSTEM_ID), address(fixture.legacyRegistrationSystem)); + assertEq(Systems.getSystem(fixture.sourceSystemId), address(fixture.sourceSystem)); + assertEq(Systems.getSystem(fixture.targetSystemId), address(0)); + assertFalse(ResourceIds.getExists(fixture.targetSystemId)); + for (uint256 i; i < fixture.nativeSignatures.length; i++) { + (ResourceId nativeSystemId, bytes4 nativeSystemSelector) = FunctionSelectors.get( + bytes4(keccak256(bytes(fixture.nativeSignatures[i]))) + ); + assertEq(ResourceId.unwrap(nativeSystemId), bytes32(0)); + assertEq(nativeSystemSelector, bytes4(0)); + } + } + + function _assertLegacyMigrationApplied(LegacyMigrationFixture memory fixture) internal { + (address registeredCore, bool corePublicAccess) = Systems.get(REGISTRATION_SYSTEM_ID); + assertEq(registeredCore, address(fixture.newRegistrationSystem)); + assertTrue(corePublicAccess); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(fixture.legacyRegistrationSystem))), bytes32(0)); + + for (uint256 i; i < fixture.nativeSignatures.length; i++) { + bytes4 selector = bytes4(keccak256(bytes(fixture.nativeSignatures[i]))); + (ResourceId nativeSystemId, bytes4 nativeSystemSelector) = FunctionSelectors.get(selector); + assertEq(ResourceId.unwrap(nativeSystemId), ResourceId.unwrap(REGISTRATION_SYSTEM_ID)); + assertEq(nativeSystemSelector, selector); + } + + (ResourceId migratedSystemId, bytes4 migratedSystemSelector) = FunctionSelectors.get(fixture.worldFunctionSelector); + assertEq(ResourceId.unwrap(migratedSystemId), ResourceId.unwrap(fixture.targetSystemId)); + assertEq(migratedSystemSelector, fixture.systemFunctionSelector); + + (address registeredTarget, bool targetPublicAccess) = Systems.get(fixture.targetSystemId); + assertEq(registeredTarget, address(fixture.targetSystem)); + assertTrue(targetPublicAccess); + assertEq( + ResourceId.unwrap(SystemRegistry.get(address(fixture.targetSystem))), + ResourceId.unwrap(fixture.targetSystemId) + ); + + (address registeredSource, bool sourcePublicAccess) = Systems.get(fixture.sourceSystemId); + assertEq(registeredSource, address(0)); + assertFalse(sourcePublicAccess); + assertTrue(ResourceIds.getExists(fixture.sourceSystemId)); + assertEq(ResourceId.unwrap(SystemRegistry.get(address(fixture.sourceSystem))), bytes32(0)); + assertFalse(ResourceAccess.get(fixture.sourceSystemId.getNamespaceId(), address(fixture.sourceSystem))); + + (bool success, bytes memory returnData) = address(world).call( + abi.encodeWithSelector(fixture.worldFunctionSelector) + ); + assertTrue(success); + assertEq(abi.decode(returnData, (address)), address(fixture.targetSystem)); + } + + function _legacyBootstrapCalls( + LegacyMigrationFixture memory fixture, + bytes4 expectedSystemFunctionSelector + ) internal pure returns (SystemCallData[] memory calls) { + calls = new SystemCallData[](8); + calls[0] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + LegacyMigrationRegistrationSystem.registerSystem, + (REGISTRATION_SYSTEM_ID, System(address(fixture.newRegistrationSystem)), true) + ) + }); + + for (uint256 i; i < fixture.nativeSignatures.length; i++) { + calls[i + 1] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.registerRootFunctionSelector, + (REGISTRATION_SYSTEM_ID, fixture.nativeSignatures[i], fixture.nativeSignatures[i]) + ) + }); + } + + calls[5] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.replaceSystem, + (fixture.targetSystemId, address(0), false, System(address(fixture.targetSystem)), true) + ) + }); + calls[6] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.replaceFunctionRoute, + ( + fixture.worldFunctionSelector, + fixture.sourceSystemId, + expectedSystemFunctionSelector, + fixture.targetSystemId, + "implementation()" + ) + ) + }); + calls[7] = SystemCallData({ + systemId: REGISTRATION_SYSTEM_ID, + callData: abi.encodeCall( + IWorldRegistrationSystem.retireSystem, + (fixture.sourceSystemId, address(fixture.sourceSystem), true) + ) + }); + + // Keep this parameter explicit: it documents that the first direct call is + // executed by the legacy implementation before the in-batch replacement. + assert(address(fixture.legacyRegistrationSystem) != address(fixture.newRegistrationSystem)); + } + + function _registerSystemPairAndSelector() + internal + returns ( + ResourceId fromSystemId, + ResourceId toSystemId, + MigrationTestSystem fromSystem, + MigrationTestSystem toSystem, + bytes4 worldFunctionSelector, + bytes4 systemFunctionSelector + ) + { + fromSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "source", name: "system" }); + toSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "target", name: "system" }); + world.registerNamespace(fromSystemId.getNamespaceId()); + world.registerNamespace(toSystemId.getNamespaceId()); + + fromSystem = new MigrationTestSystem(); + toSystem = new MigrationTestSystem(); + world.registerSystem(fromSystemId, fromSystem, true); + world.registerSystem(toSystemId, toSystem, true); + + string memory worldFunctionSignature = "migrationImplementation()"; + worldFunctionSelector = world.registerRootFunctionSelector( + fromSystemId, + worldFunctionSignature, + "implementation()" + ); + systemFunctionSelector = MigrationTestSystem.implementation.selector; + } +} diff --git a/packages/world/test/World.t.sol b/packages/world/test/World.t.sol index 5522abe3cd..adc737de7b 100644 --- a/packages/world/test/World.t.sol +++ b/packages/world/test/World.t.sol @@ -223,7 +223,7 @@ contract WorldTest is Test, GasReporter { // Should have registered the core system function selectors RegistrationSystem registrationSystem = RegistrationSystem(Systems.getSystem(REGISTRATION_SYSTEM_ID)); - bytes4[22] memory funcSelectors = [ + bytes4[26] memory funcSelectors = [ // --- AccessManagementSystem --- AccessManagementSystem.grantAccess.selector, AccessManagementSystem.revokeAccess.selector, @@ -246,8 +246,12 @@ contract WorldTest is Test, GasReporter { registrationSystem.registerSystemHook.selector, registrationSystem.unregisterSystemHook.selector, registrationSystem.registerSystem.selector, + registrationSystem.replaceSystem.selector, + registrationSystem.retireSystem.selector, registrationSystem.registerFunctionSelector.selector, registrationSystem.registerRootFunctionSelector.selector, + registrationSystem.replaceFunctionRoute.selector, + registrationSystem.unregisterFunctionSelector.selector, registrationSystem.registerDelegation.selector, registrationSystem.unregisterDelegation.selector, registrationSystem.registerNamespaceDelegation.selector, diff --git a/packages/world/ts/config/v2/defaults.ts b/packages/world/ts/config/v2/defaults.ts index c377fb642f..3d8054980d 100644 --- a/packages/world/ts/config/v2/defaults.ts +++ b/packages/world/ts/config/v2/defaults.ts @@ -39,6 +39,10 @@ export const DEPLOY_DEFAULTS = { deploysDirectory: "./deploys", worldsFile: "./worlds.json", upgradeableWorldImplementation: false, + functionRouteMigrations: [], + functionSelectorRemovals: [], + systemRetirements: [], + registrationSystemMigration: undefined, } as const satisfies DeployInput; export type DEPLOY_DEFAULTS = typeof DEPLOY_DEFAULTS; diff --git a/packages/world/ts/config/v2/input.ts b/packages/world/ts/config/v2/input.ts index fc2c0f7702..9ecd656788 100644 --- a/packages/world/ts/config/v2/input.ts +++ b/packages/world/ts/config/v2/input.ts @@ -1,6 +1,7 @@ import { StoreInput, NamespaceInput as StoreNamespaceInput } from "@latticexyz/store/internal"; import { DynamicResolution, ValueWithType } from "./dynamicResolution"; import { Codegen, SystemDeploy } from "./output"; +import type { Address, Hex } from "viem"; export type SystemDeployInput = Partial; @@ -83,6 +84,37 @@ export type ModuleInput = ModuleInputArtifactPath & { readonly args?: readonly (ValueWithType | DynamicResolution)[]; }; +/** + * An explicit, compare-and-swap migration for an existing World function selector. + * The migration is only valid when the complete current route matches the declared + * source tuple, or when it already matches the destination tuple. + */ +export type FunctionRouteMigrationInput = { + readonly worldSelector: Hex; + readonly fromSystemId: Hex; + readonly fromSystemFunctionSelector: Hex; + readonly toSystemId: Hex; + readonly toSystemFunctionSelector: Hex; +}; + +/** An explicit, compare-and-swap removal of an obsolete World function selector. */ +export type FunctionSelectorRemovalInput = { + readonly worldSelector: Hex; + readonly expectedSystemId: Hex; + readonly expectedSystemFunctionSelector: Hex; +}; + +/** An explicit declaration that a legacy System should become permanently inactive. */ +export type SystemRetirementInput = { + readonly systemId: Hex; +}; + +/** Explicit consent to replace a legacy/custom core RegistrationSystem during bootstrap. */ +export type RegistrationSystemMigrationInput = { + /** The exact live RegistrationSystem implementation that may be replaced. */ + readonly expectedSystem: Address; +}; + export type DeployInput = { /** * Script to execute after the deployment is complete (Default "PostDeploy"). @@ -95,6 +127,14 @@ export type DeployInput = { readonly worldsFile?: string; /** Deploy the World as an upgradeable proxy */ readonly upgradeableWorldImplementation?: boolean; + /** Explicit selector routes to replace during an existing World deployment. */ + readonly functionRouteMigrations?: readonly FunctionRouteMigrationInput[]; + /** Explicit selector routes to remove during an existing World deployment. */ + readonly functionSelectorRemovals?: readonly FunctionSelectorRemovalInput[]; + /** Legacy Systems to retire after their selector routes have been migrated or removed. */ + readonly systemRetirements?: readonly SystemRetirementInput[]; + /** Guarded opt-in for bootstrapping native lifecycle APIs onto a legacy World. */ + readonly registrationSystemMigration?: RegistrationSystemMigrationInput; /** * Deploy the World using a custom implementation. This world must implement the same interface as `World.sol` so that it can initialize core modules, etc. * If you want to extend the world with new functions or override existing registered functions, we recommend using [root systems](https://mud.dev/world/systems#root-systems). diff --git a/packages/world/ts/config/v2/output.ts b/packages/world/ts/config/v2/output.ts index f8bf3e6fd0..79bf436bbd 100644 --- a/packages/world/ts/config/v2/output.ts +++ b/packages/world/ts/config/v2/output.ts @@ -1,7 +1,7 @@ import { Store } from "@latticexyz/store"; import { Namespace as StoreNamespace } from "@latticexyz/store/internal"; import { DynamicResolution, ValueWithType } from "./dynamicResolution"; -import { Hex } from "viem"; +import { Address, Hex } from "viem"; export type Module = { /** Should this module be installed as a root module? */ @@ -97,6 +97,30 @@ export type Deploy = { readonly worldsFile: string; /** Deploy the World as an upgradeable proxy */ readonly upgradeableWorldImplementation: boolean; + /** Explicit selector routes to replace during an existing World deployment. */ + readonly functionRouteMigrations: readonly { + readonly worldSelector: Hex; + readonly fromSystemId: Hex; + readonly fromSystemFunctionSelector: Hex; + readonly toSystemId: Hex; + readonly toSystemFunctionSelector: Hex; + }[]; + /** Explicit selector routes to remove during an existing World deployment. */ + readonly functionSelectorRemovals: readonly { + readonly worldSelector: Hex; + readonly expectedSystemId: Hex; + readonly expectedSystemFunctionSelector: Hex; + }[]; + /** Legacy Systems to retire after their selector routes have been migrated or removed. */ + readonly systemRetirements: readonly { + readonly systemId: Hex; + }[]; + /** Guarded opt-in for replacing a legacy/custom core RegistrationSystem. */ + readonly registrationSystemMigration: + | { + readonly expectedSystem: Address; + } + | undefined; /** * Deploy the World using a custom implementation. This world must implement the same interface as `World.sol` so that it can initialize core modules, etc. * If you want to extend the world with new functions or override existing registered functions, we recommend using [root systems](https://mud.dev/world/systems#root-systems). diff --git a/packages/world/ts/config/v2/world.test.ts b/packages/world/ts/config/v2/world.test.ts index 02a67466e7..c0612d6c32 100644 --- a/packages/world/ts/config/v2/world.test.ts +++ b/packages/world/ts/config/v2/world.test.ts @@ -90,6 +90,39 @@ describe("defineWorld", () => { attest>(); }); + it("should preserve explicit selector migrations and System retirements", () => { + const migration = { + worldSelector: "0x12345678", + fromSystemId: `0x${"11".repeat(32)}`, + fromSystemFunctionSelector: "0x10203040", + toSystemId: `0x${"22".repeat(32)}`, + toSystemFunctionSelector: "0x90abcdef", + } as const; + const removal = { + worldSelector: "0x87654321", + expectedSystemId: migration.fromSystemId, + expectedSystemFunctionSelector: migration.fromSystemFunctionSelector, + } as const; + const retirement = { systemId: migration.fromSystemId } as const; + const registrationSystemMigration = { + expectedSystem: "0x1111111111111111111111111111111111111111", + } as const; + + const config = defineWorld({ + deploy: { + functionRouteMigrations: [migration], + functionSelectorRemovals: [removal], + systemRetirements: [retirement], + registrationSystemMigration, + }, + }); + + attest(config.deploy.functionRouteMigrations).equals([migration]); + attest(config.deploy.functionSelectorRemovals).equals([removal]); + attest(config.deploy.systemRetirements).equals([retirement]); + attest(config.deploy.registrationSystemMigration).equals(registrationSystemMigration); + }); + it("should only allow for single namespace or multiple namespaces, not both", () => { attest(() => defineWorld({ @@ -200,6 +233,10 @@ describe("defineWorld", () => { deploysDirectory: "./deploys", worldsFile: "./worlds.json", upgradeableWorldImplementation: false, + functionRouteMigrations: [], + functionSelectorRemovals: [], + systemRetirements: [], + registrationSystemMigration: undefined as never, }, modules: [], }).type.toString.snap(`{ @@ -300,6 +337,10 @@ describe("defineWorld", () => { readonly deploysDirectory: "./deploys" readonly worldsFile: "./worlds.json" readonly upgradeableWorldImplementation: false + readonly functionRouteMigrations: readonly [] + readonly functionSelectorRemovals: readonly [] + readonly systemRetirements: readonly [] + readonly registrationSystemMigration: undefined } }`); }); @@ -387,6 +428,10 @@ describe("defineWorld", () => { deploysDirectory: "./deploys", worldsFile: "./worlds.json", upgradeableWorldImplementation: false, + functionRouteMigrations: [], + functionSelectorRemovals: [], + systemRetirements: [], + registrationSystemMigration: undefined as never, }, modules: [], }).type.toString.snap(`{ @@ -487,6 +532,10 @@ describe("defineWorld", () => { readonly deploysDirectory: "./deploys" readonly worldsFile: "./worlds.json" readonly upgradeableWorldImplementation: false + readonly functionRouteMigrations: readonly [] + readonly functionSelectorRemovals: readonly [] + readonly systemRetirements: readonly [] + readonly registrationSystemMigration: undefined } }`); }); diff --git a/packages/world/ts/protocol-snapshots/2.1.0.snap b/packages/world/ts/protocol-snapshots/2.1.0.snap new file mode 100644 index 0000000000..886bddea12 --- /dev/null +++ b/packages/world/ts/protocol-snapshots/2.1.0.snap @@ -0,0 +1,115 @@ +[ + "error EncodedLengths_InvalidLength(uint256 length)", + "error FieldLayout_Empty()", + "error FieldLayout_InvalidStaticDataLength(uint256 staticDataLength, uint256 computedStaticDataLength)", + "error FieldLayout_StaticLengthDoesNotFitInAWord(uint256 index)", + "error FieldLayout_StaticLengthIsNotZero(uint256 index)", + "error FieldLayout_StaticLengthIsZero(uint256 index)", + "error FieldLayout_TooManyDynamicFields(uint256 numFields, uint256 maxFields)", + "error FieldLayout_TooManyFields(uint256 numFields, uint256 maxFields)", + "error Module_AlreadyInstalled()", + "error Module_MissingDependency(address dependency)", + "error Module_NonRootInstallNotSupported()", + "error Module_RootInstallNotSupported()", + "error Schema_InvalidLength(uint256 length)", + "error Schema_StaticTypeAfterDynamicType()", + "error Slice_OutOfBounds(bytes data, uint256 start, uint256 end)", + "error Store_IndexOutOfBounds(uint256 length, uint256 accessedIndex)", + "error Store_InvalidBounds(uint256 start, uint256 end)", + "error Store_InvalidFieldNamesLength(uint256 expected, uint256 received)", + "error Store_InvalidKeyNamesLength(uint256 expected, uint256 received)", + "error Store_InvalidResourceType(bytes2 expected, bytes32 resourceId, string resourceIdString)", + "error Store_InvalidSplice(uint40 startWithinField, uint40 deleteCount, uint40 fieldLength)", + "error Store_InvalidStaticDataLength(uint256 expected, uint256 received)", + "error Store_InvalidValueSchemaDynamicLength(uint256 expected, uint256 received)", + "error Store_InvalidValueSchemaLength(uint256 expected, uint256 received)", + "error Store_InvalidValueSchemaStaticLength(uint256 expected, uint256 received)", + "error Store_TableAlreadyExists(bytes32 tableId, string tableIdString)", + "error Store_TableNotFound(bytes32 tableId, string tableIdString)", + "error World_AccessDenied(string resource, address caller)", + "error World_AlreadyInitialized()", + "error World_CallbackNotAllowed(bytes4 functionSelector)", + "error World_DelegationNotFound(address delegator, address delegatee)", + "error World_FunctionSelectorAlreadyExists(bytes4 functionSelector)", + "error World_FunctionSelectorMismatch(bytes4 worldFunctionSelector, bytes32 expectedSystemId, bytes4 expectedSystemFunctionSelector, bytes32 actualSystemId, bytes4 actualSystemFunctionSelector)", + "error World_FunctionSelectorNotFound(bytes4 functionSelector)", + "error World_InsufficientBalance(uint256 balance, uint256 amount)", + "error World_InterfaceNotSupported(address contractAddress, bytes4 interfaceId)", + "error World_InvalidNamespace(bytes14 namespace)", + "error World_InvalidResourceId(bytes32 resourceId, string resourceIdString)", + "error World_InvalidResourceType(bytes2 expected, bytes32 resourceId, string resourceIdString)", + "error World_ResourceAlreadyExists(bytes32 resourceId, string resourceIdString)", + "error World_ResourceNotFound(bytes32 resourceId, string resourceIdString)", + "error World_SystemAlreadyExists(address system)", + "error World_SystemAlreadyRetired(bytes32 systemId, string systemIdString)", + "error World_SystemCannotBeRetired(bytes32 systemId, string systemIdString)", + "error World_SystemRegistryMismatch(address system, bytes32 expectedSystemId, bytes32 actualSystemId)", + "error World_SystemStateMismatch(bytes32 systemId, address expectedSystem, bool expectedPublicAccess, address actualSystem, bool actualPublicAccess)", + "error World_UnlimitedDelegationNotAllowed()", + "event HelloStore(bytes32 indexed storeVersion)", + "event HelloWorld(bytes32 indexed worldVersion)", + "event Store_DeleteRecord(bytes32 indexed tableId, bytes32[] keyTuple)", + "event Store_SetRecord(bytes32 indexed tableId, bytes32[] keyTuple, bytes staticData, bytes32 encodedLengths, bytes dynamicData)", + "event Store_SpliceDynamicData(bytes32 indexed tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint48 start, uint40 deleteCount, bytes32 encodedLengths, bytes data)", + "event Store_SpliceStaticData(bytes32 indexed tableId, bytes32[] keyTuple, uint48 start, bytes data)", + "event WorldFunctionRouteReplaced(bytes4 indexed worldFunctionSelector, bytes32 indexed oldSystemId, bytes32 indexed newSystemId, bytes4 oldSystemFunctionSelector, bytes4 newSystemFunctionSelector)", + "event WorldFunctionSelectorUnregistered(bytes4 indexed worldFunctionSelector, bytes32 indexed systemId, bytes4 systemFunctionSelector)", + "event WorldSystemReplaced(bytes32 indexed systemId, address indexed oldSystem, address indexed newSystem, bool publicAccess)", + "event WorldSystemRetired(bytes32 indexed systemId, address indexed system)", + "function batchCall((bytes32 systemId, bytes callData)[] systemCalls) returns (bytes[] returnDatas)", + "function batchCallFrom((address from, bytes32 systemId, bytes callData)[] systemCalls) returns (bytes[] returnDatas)", + "function call(bytes32 systemId, bytes callData) payable returns (bytes)", + "function callFrom(address delegator, bytes32 systemId, bytes callData) payable returns (bytes)", + "function creator() view returns (address)", + "function deleteRecord(bytes32 tableId, bytes32[] keyTuple)", + "function getDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex) view returns (bytes)", + "function getDynamicFieldLength(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex) view returns (uint256)", + "function getDynamicFieldSlice(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint256 start, uint256 end) view returns (bytes data)", + "function getField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes32 fieldLayout) view returns (bytes data)", + "function getField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex) view returns (bytes data)", + "function getFieldLayout(bytes32 tableId) view returns (bytes32 fieldLayout)", + "function getFieldLength(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes32 fieldLayout) view returns (uint256)", + "function getFieldLength(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex) view returns (uint256)", + "function getKeySchema(bytes32 tableId) view returns (bytes32 keySchema)", + "function getRecord(bytes32 tableId, bytes32[] keyTuple, bytes32 fieldLayout) view returns (bytes staticData, bytes32 encodedLengths, bytes dynamicData)", + "function getRecord(bytes32 tableId, bytes32[] keyTuple) view returns (bytes staticData, bytes32 encodedLengths, bytes dynamicData)", + "function getStaticField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes32 fieldLayout) view returns (bytes32)", + "function getValueSchema(bytes32 tableId) view returns (bytes32 valueSchema)", + "function grantAccess(bytes32 resourceId, address grantee)", + "function initialize(address initModule)", + "function installModule(address module, bytes encodedArgs)", + "function installRootModule(address module, bytes encodedArgs)", + "function popFromDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint256 byteLengthToPop)", + "function pushToDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, bytes dataToPush)", + "function registerDelegation(address delegatee, bytes32 delegationControlId, bytes initCallData)", + "function registerFunctionSelector(bytes32 systemId, string systemFunctionSignature) returns (bytes4 worldFunctionSelector)", + "function registerNamespace(bytes32 namespaceId)", + "function registerNamespaceDelegation(bytes32 namespaceId, bytes32 delegationControlId, bytes initCallData)", + "function registerRootFunctionSelector(bytes32 systemId, string worldFunctionSignature, string systemFunctionSignature) returns (bytes4 worldFunctionSelector)", + "function registerStoreHook(bytes32 tableId, address hookAddress, uint8 enabledHooksBitmap)", + "function registerSystem(bytes32 systemId, address system, bool publicAccess)", + "function registerSystemHook(bytes32 systemId, address hookAddress, uint8 enabledHooksBitmap)", + "function registerTable(bytes32 tableId, bytes32 fieldLayout, bytes32 keySchema, bytes32 valueSchema, string[] keyNames, string[] fieldNames)", + "function renounceOwnership(bytes32 namespaceId)", + "function replaceFunctionRoute(bytes4 worldFunctionSelector, bytes32 expectedSystemId, bytes4 expectedSystemFunctionSelector, bytes32 newSystemId, string newSystemFunctionSignature)", + "function replaceSystem(bytes32 systemId, address expectedSystem, bool expectedPublicAccess, address newSystem, bool publicAccess)", + "function retireSystem(bytes32 systemId, address expectedSystem, bool expectedPublicAccess)", + "function revokeAccess(bytes32 resourceId, address grantee)", + "function setDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, bytes data)", + "function setField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes data, bytes32 fieldLayout)", + "function setField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes data)", + "function setRecord(bytes32 tableId, bytes32[] keyTuple, bytes staticData, bytes32 encodedLengths, bytes dynamicData)", + "function setStaticField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes data, bytes32 fieldLayout)", + "function spliceDynamicData(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint40 startWithinField, uint40 deleteCount, bytes data)", + "function spliceStaticData(bytes32 tableId, bytes32[] keyTuple, uint48 start, bytes data)", + "function storeVersion() view returns (bytes32 version)", + "function transferBalanceToAddress(bytes32 fromNamespaceId, address toAddress, uint256 amount)", + "function transferBalanceToNamespace(bytes32 fromNamespaceId, bytes32 toNamespaceId, uint256 amount)", + "function transferOwnership(bytes32 namespaceId, address newOwner)", + "function unregisterDelegation(address delegatee)", + "function unregisterFunctionSelector(bytes4 worldFunctionSelector, bytes32 expectedSystemId, bytes4 expectedSystemFunctionSelector)", + "function unregisterNamespaceDelegation(bytes32 namespaceId)", + "function unregisterStoreHook(bytes32 tableId, address hookAddress)", + "function unregisterSystemHook(bytes32 systemId, address hookAddress)", + "function worldVersion() view returns (bytes32)", +] \ No newline at end of file diff --git a/packages/world/ts/protocolVersions.ts b/packages/world/ts/protocolVersions.ts index b91dcbbbc2..ca59b7681b 100644 --- a/packages/world/ts/protocolVersions.ts +++ b/packages/world/ts/protocolVersions.ts @@ -1,5 +1,7 @@ // History of protocol versions and a short description of what changed in each. export const protocolVersions = { + "2.1.0": + "Added compare-and-swap World function selector and system migrations, plus permanent guarded system retirement.", "2.0.2": "Patched `StoreCore.registerTable` to prevent registering both an offchain and onchain table with the same name.", "2.0.1": "Patched `StoreRead.getDynamicFieldLength` to use the correct method to read the dynamic field length.", diff --git a/scripts/package-fork-release.mjs b/scripts/package-fork-release.mjs new file mode 100644 index 0000000000..f64a55e8c9 --- /dev/null +++ b/scripts/package-fork-release.mjs @@ -0,0 +1,411 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + constants as fsConstants, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const baseVersion = "2.2.23"; +export const defaultRepository = "Floki-Inu/mud"; + +const dependencySections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; +const packageDefinitions = [ + { + name: "@latticexyz/world", + sourceDirectory: "packages/world", + requiredFiles: [ + "dist/index.js", + "dist/internal.js", + "dist/mud.config.js", + "dist/node.js", + "out/IBaseWorld.sol/IBaseWorld.abi.json", + "out/World.sol/World.json", + "src/World.sol", + "test/MudTest.t.sol", + ], + }, + { + name: "@latticexyz/cli", + sourceDirectory: "packages/cli", + requiredFiles: ["bin/mud.js", "dist/index.js", "dist/mud.js", "dist/version.js"], + }, +]; +const lifecycleFunctions = [ + { name: "replaceFunctionRoute", inputs: ["bytes4", "bytes32", "bytes4", "bytes32", "string"] }, + { name: "unregisterFunctionSelector", inputs: ["bytes4", "bytes32", "bytes4"] }, + { name: "retireSystem", inputs: ["bytes32", "address", "bool"] }, + { name: "replaceSystem", inputs: ["bytes32", "address", "bool", "address", "bool"] }, +]; + +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const safeTagPattern = /^[0-9A-Za-z][0-9A-Za-z._-]*$/; +const repositoryPattern = /^[0-9A-Za-z_.-]+\/[0-9A-Za-z_.-]+$/; + +function compareStrings(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function usage() { + return `Usage: + pnpm release:pack-fork -- --version --tag --output + +Options: + --version Fork package version, for example 2.2.24-floki.1 + --tag GitHub Release tag; must equal v + --output New or existing output directory (target assets must not exist) + --repository GitHub owner/repository used in the CLI's World tarball URL + (default: $GITHUB_REPOSITORY or ${defaultRepository}) + --help Show this help +`; +} + +export function parseArguments(argv, environment = process.env) { + const values = {}; + const allowed = new Set(["version", "tag", "output", "repository"]); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--") continue; + if (argument === "--help" || argument === "-h") return { help: true }; + if (!argument.startsWith("--")) throw new Error(`Unexpected argument: ${argument}`); + + const separatorIndex = argument.indexOf("="); + const key = argument.slice(2, separatorIndex === -1 ? undefined : separatorIndex); + if (!allowed.has(key)) throw new Error(`Unknown option: --${key}`); + if (values[key] != null) throw new Error(`Option --${key} was provided more than once.`); + + const value = separatorIndex === -1 ? argv[++index] : argument.slice(separatorIndex + 1); + if (value == null || value === "" || value.startsWith("--")) { + throw new Error(`Option --${key} requires a value.`); + } + values[key] = value; + } + + for (const required of ["version", "tag", "output"]) { + if (values[required] == null) throw new Error(`Missing required option: --${required}`); + } + + const repository = values.repository ?? environment.GITHUB_REPOSITORY ?? defaultRepository; + if (!semverPattern.test(values.version)) throw new Error(`Invalid semantic version: ${values.version}`); + if (!values.version.includes("-floki.")) { + throw new Error(`Fork version must include the "-floki." prerelease identifier: ${values.version}`); + } + if (!safeTagPattern.test(values.tag)) throw new Error(`Unsafe GitHub Release tag: ${values.tag}`); + if (values.tag !== `v${values.version}`) { + throw new Error(`GitHub Release tag must be exactly v${values.version}; received ${values.tag}.`); + } + if (!repositoryPattern.test(repository)) throw new Error(`Invalid GitHub repository: ${repository}`); + + return { + help: false, + version: values.version, + tag: values.tag, + output: resolve(values.output), + repository, + }; +} + +export function packageTarballName(packageName, version) { + return `${packageName.replace(/^@/, "").replaceAll("/", "-")}-${version}.tgz`; +} + +export function worldReleaseAssetUrl({ repository, tag, version }) { + const filename = packageTarballName("@latticexyz/world", version); + return `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${filename}`; +} + +export function stageManifest({ manifest, packageName, version, worldAssetUrl }) { + if (manifest.name !== packageName) { + throw new Error(`Expected raw package ${packageName}, received ${String(manifest.name)}.`); + } + if (manifest.version !== baseVersion) { + throw new Error( + `Expected ${packageName} raw package version ${baseVersion}, received ${String(manifest.version)}. ` + + "Update the fork packaging baseVersion deliberately when rebasing.", + ); + } + + const staged = structuredClone(manifest); + staged.version = version; + for (const section of dependencySections) { + if (staged[section] == null) continue; + for (const dependencyName of Object.keys(staged[section])) { + if (!dependencyName.startsWith("@latticexyz/")) continue; + staged[section][dependencyName] = + packageName === "@latticexyz/cli" && dependencyName === "@latticexyz/world" ? worldAssetUrl : baseVersion; + } + staged[section] = Object.fromEntries( + Object.entries(staged[section]).sort(([left], [right]) => compareStrings(left, right)), + ); + } + return staged; +} + +function run(command, arguments_, options = {}) { + const result = spawnSync(command, arguments_, { + cwd: options.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error != null) throw result.error; + if (result.status !== 0) { + const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(`${command} ${arguments_.join(" ")} failed${details ? `:\n${details}` : "."}`); + } + return result.stdout.trim(); +} + +function parsePackResult(output, packageName) { + let result; + try { + result = JSON.parse(output); + } catch { + throw new Error(`Could not parse pnpm pack output for ${packageName}.`); + } + const pack = Array.isArray(result) ? result[0] : result; + if (pack?.filename == null) throw new Error(`pnpm pack did not return a filename for ${packageName}.`); + return resolve(pack.filename); +} + +function packDirectory(directory, destination, packageName) { + mkdirSync(destination, { recursive: true }); + return parsePackResult( + run("corepack", ["pnpm", "--dir", directory, "pack", "--pack-destination", destination, "--json"]), + packageName, + ); +} + +function extractArchive(archive, destination) { + mkdirSync(destination, { recursive: true }); + const entries = run("tar", ["-tzf", archive]).split("\n").filter(Boolean); + if (entries.length === 0 || entries.some((entry) => entry !== "package" && !entry.startsWith("package/"))) { + throw new Error(`Archive ${basename(archive)} contains an unexpected path.`); + } + if (entries.some((entry) => entry.split("/").includes(".."))) { + throw new Error(`Archive ${basename(archive)} contains a parent-directory path.`); + } + run("tar", ["-xzf", archive, "-C", destination]); + return join(destination, "package"); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function assertFile(path, label) { + if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Missing ${label}: ${path}`); +} + +function validateLifecycleAbi(packageRoot) { + const abi = readJson(join(packageRoot, "out/IBaseWorld.sol/IBaseWorld.abi.json")); + for (const expected of lifecycleFunctions) { + const abiFunction = abi.find((item) => item.type === "function" && item.name === expected.name); + const inputs = abiFunction?.inputs?.map((input) => input.type); + if (abiFunction?.stateMutability !== "nonpayable" || JSON.stringify(inputs) !== JSON.stringify(expected.inputs)) { + throw new Error( + `World ABI is missing ${expected.name}(${expected.inputs.join(",")}) with nonpayable mutability.`, + ); + } + } +} + +function readCliRuntimePackageInfo(packageRoot) { + const moduleUrl = pathToFileURL(join(packageRoot, "dist/version.js")).href; + const output = run(process.execPath, [ + "--input-type=module", + "--eval", + `const { cliPackageInfo } = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(JSON.stringify(cliPackageInfo));`, + ]); + try { + return JSON.parse(output); + } catch { + throw new Error("The packaged CLI runtime did not return valid package metadata."); + } +} + +export function validateStagedManifest({ manifest, packageName, version, worldAssetUrl }) { + if (manifest.name !== packageName) throw new Error(`Unexpected package name: ${String(manifest.name)}`); + if (manifest.version !== version) { + throw new Error(`${packageName} has version ${String(manifest.version)} instead of ${version}.`); + } + + for (const section of dependencySections) { + for (const [dependencyName, dependencyVersion] of Object.entries(manifest[section] ?? {})) { + if (String(dependencyVersion).startsWith("workspace:")) { + throw new Error(`${packageName} ${section}.${dependencyName} still uses a workspace dependency.`); + } + if (!dependencyName.startsWith("@latticexyz/")) continue; + const expected = + packageName === "@latticexyz/cli" && dependencyName === "@latticexyz/world" ? worldAssetUrl : baseVersion; + if (dependencyVersion !== expected) { + throw new Error( + `${packageName} ${section}.${dependencyName} must be exactly ${expected}, received ${String( + dependencyVersion, + )}.`, + ); + } + } + } + + if (packageName === "@latticexyz/cli" && manifest.dependencies?.["@latticexyz/world"] !== worldAssetUrl) { + throw new Error("The CLI package must depend on the tag-specific World release asset URL."); + } +} + +function validateArchive({ archive, definition, version, worldAssetUrl, verificationRoot }) { + const packageRoot = extractArchive(archive, join(verificationRoot, definition.name.replaceAll("/", "-"))); + const manifest = readJson(join(packageRoot, "package.json")); + validateStagedManifest({ manifest, packageName: definition.name, version, worldAssetUrl }); + + for (const relativePath of definition.requiredFiles) { + assertFile(join(packageRoot, relativePath), `${definition.name} package content ${relativePath}`); + } + + if (definition.name === "@latticexyz/world") { + validateLifecycleAbi(packageRoot); + } else if (definition.name === "@latticexyz/cli") { + if (manifest.bin?.mud !== "./bin/mud.js") throw new Error("The CLI manifest does not expose ./bin/mud.js."); + const executable = join(packageRoot, "bin/mud.js"); + if ((statSync(executable).mode & 0o111) === 0) throw new Error("The packaged mud executable has no execute bit."); + if (!readFileSync(executable, "utf8").startsWith("#!/usr/bin/env node\n")) { + throw new Error("The packaged mud executable is missing its Node.js shebang."); + } + const runtimePackageInfo = readCliRuntimePackageInfo(packageRoot); + if (runtimePackageInfo.name !== manifest.name || runtimePackageInfo.version !== manifest.version) { + throw new Error( + `The packaged CLI runtime reports ${String(runtimePackageInfo.name)}@${String( + runtimePackageInfo.version, + )}, but its manifest is ${manifest.name}@${manifest.version}.`, + ); + } + } +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function copyReleaseAssets({ archives, output }) { + mkdirSync(output, { recursive: true }); + const checksumPath = join(output, "SHA256SUMS"); + const targets = [...archives.map((archive) => join(output, basename(archive))), checksumPath]; + const existing = targets.filter(existsSync); + if (existing.length > 0) { + throw new Error(`Refusing to overwrite existing release asset(s): ${existing.join(", ")}`); + } + + for (const archive of archives) { + copyFileSync(archive, join(output, basename(archive)), fsConstants.COPYFILE_EXCL); + } + const checksums = archives + .map((archive) => ({ filename: basename(archive), digest: sha256(archive) })) + .sort((left, right) => compareStrings(left.filename, right.filename)); + writeFileSync(checksumPath, `${checksums.map(({ digest, filename }) => `${digest} ${filename}`).join("\n")}\n`, { + flag: "wx", + }); + for (const { digest, filename } of checksums) { + if (sha256(join(output, filename)) !== digest) throw new Error(`Copied release asset failed SHA-256: ${filename}`); + } + return checksums; +} + +function ensureBuiltPackages(repositoryRoot) { + for (const definition of packageDefinitions) { + const packageRoot = join(repositoryRoot, definition.sourceDirectory); + const sourceManifest = readJson(join(packageRoot, "package.json")); + if (sourceManifest.version !== baseVersion) { + throw new Error( + `${definition.name} source version must be ${baseVersion}; received ${String(sourceManifest.version)}.`, + ); + } + for (const relativePath of definition.requiredFiles) { + assertFile(join(packageRoot, relativePath), `built ${definition.name} file ${relativePath}`); + } + } +} + +export function packageForkRelease(options) { + const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + ensureBuiltPackages(repositoryRoot); + + const temporaryRoot = mkdtempSync(join(tmpdir(), "mud-fork-release-")); + try { + const rawRoot = join(temporaryRoot, "raw"); + const stagedRoot = join(temporaryRoot, "staged"); + const repackedRoot = join(temporaryRoot, "repacked"); + const verificationRoot = join(temporaryRoot, "verified"); + const worldAssetUrl = worldReleaseAssetUrl(options); + const archives = []; + + for (const definition of packageDefinitions) { + const rawArchive = packDirectory(join(repositoryRoot, definition.sourceDirectory), rawRoot, definition.name); + const packageRoot = extractArchive(rawArchive, join(stagedRoot, definition.name.replaceAll("/", "-"))); + const manifestPath = join(packageRoot, "package.json"); + const manifest = stageManifest({ + manifest: readJson(manifestPath), + packageName: definition.name, + version: options.version, + worldAssetUrl, + }); + writeJson(manifestPath, manifest); + + const archive = packDirectory(packageRoot, repackedRoot, definition.name); + const expectedFilename = packageTarballName(definition.name, options.version); + if (basename(archive) !== expectedFilename) { + throw new Error(`Expected ${definition.name} asset ${expectedFilename}, received ${basename(archive)}.`); + } + validateArchive({ + archive, + definition, + version: options.version, + worldAssetUrl, + verificationRoot, + }); + archives.push(archive); + } + + const checksums = copyReleaseAssets({ archives, output: options.output }); + return { archives: archives.map((archive) => join(options.output, basename(archive))), checksums, worldAssetUrl }; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage()); + return; + } + const result = packageForkRelease(options); + process.stdout.write(`World dependency URL: ${result.worldAssetUrl}\n`); + for (const { digest, filename } of result.checksums) process.stdout.write(`${digest} ${filename}\n`); + process.stdout.write(`Wrote ${result.archives.length} packages and SHA256SUMS to ${options.output}\n`); +} + +const invokedAsScript = process.argv[1] != null && pathToFileURL(resolve(process.argv[1])).href === import.meta.url; +if (invokedAsScript) { + try { + main(); + } catch (error) { + process.stderr.write(`Fork release packaging failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/package-fork-release.test.mjs b/scripts/package-fork-release.test.mjs new file mode 100644 index 0000000000..11440e74eb --- /dev/null +++ b/scripts/package-fork-release.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + baseVersion, + packageTarballName, + parseArguments, + stageManifest, + validateStagedManifest, + worldReleaseAssetUrl, +} from "./package-fork-release.mjs"; + +const version = "2.2.24-floki.1"; +const tag = `v${version}`; +const repository = "Floki-Inu/mud"; +const worldAssetUrl = + "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/Floki-Inu/mud/releases/download/v2.2.24-floki.1/latticexyz-world-2.2.24-floki.1.tgz"; + +describe("fork release packaging", () => { + it("parses and validates the required release arguments", () => { + assert.deepEqual( + parseArguments(["--", "--version", version, "--tag", tag, "--output", "release"], { + GITHUB_REPOSITORY: repository, + }), + { + help: false, + version, + tag, + output: new URL("../release", import.meta.url).pathname, + repository, + }, + ); + assert.throws(() => parseArguments(["--version", "2.2.24", "--tag", "v2.2.24", "--output", "release"]), /-floki\./); + assert.throws( + () => parseArguments(["--version", version, "--tag", "refs/tags/unsafe", "--output", "release"]), + /Unsafe GitHub Release tag/, + ); + assert.throws( + () => parseArguments(["--version", version, "--tag", "v2.2.24-floki.2", "--output", "release"]), + /must be exactly v2\.2\.24-floki\.1/, + ); + }); + + it("uses stable package filenames and a tag-specific World URL", () => { + assert.equal(packageTarballName("@latticexyz/world", version), `latticexyz-world-${version}.tgz`); + assert.equal(worldReleaseAssetUrl({ repository, tag, version }), worldAssetUrl); + }); + + it("stages only World as a fork dependency and pins every other internal dependency", () => { + const staged = stageManifest({ + manifest: { + name: "@latticexyz/cli", + version: baseVersion, + dependencies: { + "@latticexyz/common": "workspace:*", + "@latticexyz/world": "workspace:*", + viem: "2.35.1", + }, + devDependencies: { + "@latticexyz/abi-ts": "workspace:^", + }, + }, + packageName: "@latticexyz/cli", + version, + worldAssetUrl, + }); + + assert.equal(staged.version, version); + assert.equal(staged.dependencies["@latticexyz/world"], worldAssetUrl); + assert.equal(staged.dependencies["@latticexyz/common"], baseVersion); + assert.equal(staged.devDependencies["@latticexyz/abi-ts"], baseVersion); + assert.equal(staged.dependencies.viem, "2.35.1"); + assert.deepEqual(Object.keys(staged.dependencies), ["@latticexyz/common", "@latticexyz/world", "viem"]); + assert.doesNotThrow(() => + validateStagedManifest({ + manifest: staged, + packageName: "@latticexyz/cli", + version, + worldAssetUrl, + }), + ); + }); + + it("fails closed on the wrong base version or unresolved internal dependency", () => { + assert.throws( + () => + stageManifest({ + manifest: { name: "@latticexyz/world", version: "2.2.22" }, + packageName: "@latticexyz/world", + version, + worldAssetUrl, + }), + /Expected @latticexyz\/world raw package version 2\.2\.23/, + ); + assert.throws( + () => + validateStagedManifest({ + manifest: { + name: "@latticexyz/world", + version, + dependencies: { "@latticexyz/store": "workspace:*" }, + }, + packageName: "@latticexyz/world", + version, + worldAssetUrl, + }), + /still uses a workspace dependency/, + ); + }); +}); From 50966e6a2a2d664467e7d876fb6d45ed29e96a27 Mon Sep 17 00:00:00 2001 From: Jackie Xu Date: Mon, 17 Aug 2026 15:56:50 +0200 Subject: [PATCH 2/4] fix(cli): reconcile stale selector system IDs --- .changeset/calm-worlds-migrate.md | 3 +- .github/workflows/fork-release.yml | 64 +- docs/pages/config/reference.mdx | 12 - .../internal/init-module-implementation.mdx | 52 - docs/pages/world/reference/misc.mdx | 2 +- docs/pages/world/reference/world-external.mdx | 141 -- docs/pages/world/upgrades.mdx | 75 -- packages/cli/src/deploy/common.test.ts | 8 - packages/cli/src/deploy/common.ts | 2 +- packages/cli/src/deploy/deploy.ts | 193 +-- .../deploy/ensureFunctionMigrations.test.ts | 486 ------- .../src/deploy/ensureFunctionMigrations.ts | 1152 ----------------- .../cli/src/deploy/ensureFunctions.test.ts | 104 +- packages/cli/src/deploy/ensureFunctions.ts | 350 ++++- packages/cli/src/deploy/ensureModules.test.ts | 49 - packages/cli/src/deploy/ensureModules.ts | 118 +- .../src/deploy/ensureNamespaceOwner.test.ts | 18 - .../cli/src/deploy/ensureNamespaceOwner.ts | 27 +- packages/cli/src/deploy/ensureSystems.test.ts | 186 --- packages/cli/src/deploy/ensureSystems.ts | 478 ++----- packages/cli/src/deploy/ensureTables.test.ts | 28 - packages/cli/src/deploy/ensureTables.ts | 75 +- .../src/deploy/functionMigrationPlan.test.ts | 350 ----- .../cli/src/deploy/functionMigrationPlan.ts | 414 ------ packages/cli/src/deploy/functionPlan.test.ts | 94 +- packages/cli/src/deploy/functionPlan.ts | 53 +- .../cli/src/deploy/getFunctionRoutes.test.ts | 33 - packages/cli/src/deploy/getFunctionRoutes.ts | 45 - packages/cli/src/deploy/systemAccess.ts | 24 - packages/store-sync/src/world/getFunctions.ts | 2 +- packages/world/src/IWorldErrors.sol | 54 - packages/world/src/IWorldEvents.sol | 35 - .../systems/WorldRegistrationSystemLib.sol | 223 ---- .../interfaces/IWorldRegistrationSystem.sol | 24 - .../world/src/modules/init/InitModule.sol | 2 +- .../src/modules/init/functionSignatures.sol | 6 +- .../WorldRegistrationSystem.sol | 351 +---- packages/world/src/version.sol | 2 +- .../test/FunctionSelectorSystemIdRepair.t.sol | 93 ++ packages/world/test/InitSystems.t.sol | 2 +- packages/world/test/SystemMigration.t.sol | 1035 --------------- packages/world/test/World.t.sol | 6 +- packages/world/ts/config/v2/defaults.ts | 4 - packages/world/ts/config/v2/input.ts | 40 - packages/world/ts/config/v2/output.ts | 26 +- packages/world/ts/config/v2/world.test.ts | 49 - .../world/ts/protocol-snapshots/2.1.0.snap | 115 -- packages/world/ts/protocolVersions.ts | 2 - scripts/package-fork-release.mjs | 95 +- scripts/package-fork-release.test.mjs | 88 +- 50 files changed, 937 insertions(+), 5953 deletions(-) delete mode 100644 packages/cli/src/deploy/common.test.ts delete mode 100644 packages/cli/src/deploy/ensureFunctionMigrations.test.ts delete mode 100644 packages/cli/src/deploy/ensureFunctionMigrations.ts delete mode 100644 packages/cli/src/deploy/ensureModules.test.ts delete mode 100644 packages/cli/src/deploy/ensureNamespaceOwner.test.ts delete mode 100644 packages/cli/src/deploy/ensureSystems.test.ts delete mode 100644 packages/cli/src/deploy/ensureTables.test.ts delete mode 100644 packages/cli/src/deploy/functionMigrationPlan.test.ts delete mode 100644 packages/cli/src/deploy/functionMigrationPlan.ts delete mode 100644 packages/cli/src/deploy/getFunctionRoutes.test.ts delete mode 100644 packages/cli/src/deploy/systemAccess.ts create mode 100644 packages/world/test/FunctionSelectorSystemIdRepair.t.sol delete mode 100644 packages/world/test/SystemMigration.t.sol delete mode 100644 packages/world/ts/protocol-snapshots/2.1.0.snap diff --git a/.changeset/calm-worlds-migrate.md b/.changeset/calm-worlds-migrate.md index 06d6efa8db..3c0411dd6e 100644 --- a/.changeset/calm-worlds-migrate.md +++ b/.changeset/calm-worlds-migrate.md @@ -1,6 +1,5 @@ --- "@latticexyz/cli": patch -"@latticexyz/world": patch --- -Add fail-closed, compare-and-swap lifecycle support for World function routes and Systems. Deployments can now declare exact route replacements and selector removals, permanently retire stale Systems, and reconcile approved System ID renames atomically while rejecting unknown routing state before writes. +Add selector System-ID reconciliation for existing Worlds. Deployments now update only the `FunctionSelectors.systemId` field when the configured and registered System function selectors match exactly, and reject ambiguous routing conflicts before writing. The deployer must have Store access to the `FunctionSelectors` table; otherwise deployment stops with one-time grant instructions. diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index a439f364d0..16e7c33ae0 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -13,13 +13,15 @@ env: NODE_OPTIONS: "--max-old-space-size=4096" jobs: - release: - name: Build and release World + CLI + build: + name: Build and validate CLI release if: github.repository == 'Floki-Inu/mud' runs-on: ubuntu-latest timeout-minutes: 30 permissions: - contents: write + contents: read + outputs: + version: ${{ steps.release.outputs.version }} steps: - name: Checkout tagged source uses: actions/checkout@v4 @@ -44,15 +46,12 @@ jobs: - name: Test release packager run: pnpm release:test-fork - - name: Build World, CLI, and their workspace dependencies + - name: Build CLI and its workspace dependencies shell: bash run: pnpm exec turbo run build --filter=@latticexyz/cli... --force - - name: Test forked World and CLI packages - shell: bash - run: | - pnpm --filter @latticexyz/world test - pnpm --filter @latticexyz/cli test + - name: Test forked CLI package + run: pnpm --filter @latticexyz/cli test - name: Package and validate release assets shell: bash @@ -63,16 +62,57 @@ jobs: --version "${RELEASE_VERSION}" --tag "${GITHUB_REF_NAME}" --output release-assets - --repository "${GITHUB_REPOSITORY}" + + - name: Install and smoke test packaged CLI + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + consumer_dir="${RUNNER_TEMP}/mud-fork-consumer" + mkdir -p "${consumer_dir}" + cd "${consumer_dir}" + npm init --yes + pnpm add --save-exact "${GITHUB_WORKSPACE}/release-assets/latticexyz-cli-${RELEASE_VERSION}.tgz" + pnpm exec mud --help + + - name: Upload validated release assets + uses: actions/upload-artifact@v4 + with: + name: fork-release-${{ steps.release.outputs.version }} + path: | + release-assets/latticexyz-cli-${{ steps.release.outputs.version }}.tgz + release-assets/SHA256SUMS + if-no-files-found: error + retention-days: 1 + + release: + name: Publish CLI release + needs: build + if: github.repository == 'Floki-Inu/mud' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: write + steps: + - name: Download validated release assets + uses: actions/download-artifact@v4 + with: + name: fork-release-${{ needs.build.outputs.version }} + path: release-assets + + - name: Verify downloaded release assets + working-directory: release-assets + run: sha256sum --check SHA256SUMS - name: Create GitHub Release shell: bash env: + GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_VERSION: ${{ needs.build.outputs.version }} run: | gh release create "${GITHUB_REF_NAME}" \ - "release-assets/latticexyz-world-${RELEASE_VERSION}.tgz" \ "release-assets/latticexyz-cli-${RELEASE_VERSION}.tgz" \ "release-assets/SHA256SUMS" \ --verify-tag \ diff --git a/docs/pages/config/reference.mdx b/docs/pages/config/reference.mdx index 515b4fc42e..a656854d70 100644 --- a/docs/pages/config/reference.mdx +++ b/docs/pages/config/reference.mdx @@ -164,18 +164,6 @@ The following options are available in both single- and multiple-namespace modes Script name to execute after the deployment is complete. Defaults to `"PostDeploy"`. JSON filename, relative to project root, to write per-chain world deployment addresses. Defaults to `"worlds.json"`. Whether or not to deploy the world with an upgradeable proxy, allowing for the core implementation to be upgraded. Defaults to `false`, but [we recommend `true`](/guides/best-practices/deployment-settings). - - Explicit compare-and-swap replacements for World function routes. Each entry contains `worldSelector`, `fromSystemId`, `fromSystemFunctionSelector`, `toSystemId`, and `toSystemFunctionSelector`. The current route must match the complete declared source tuple or already match the complete destination tuple; every other state aborts before deployment writes. Multiple entries may use the same World selector to approve alternative legacy source tuples, but they must share one final destination tuple. - - - Explicit compare-and-swap removals for obsolete World functions. Each entry contains `worldSelector`, `expectedSystemId`, and `expectedSystemFunctionSelector`. A selector that is still present in the generated World ABI cannot be removed. - - - System IDs to retire after all of their selector routes have been replaced or removed. Each entry contains `systemId`. Retirement clears the active System, reverse registry, hooks, and automatic namespace access while retaining a permanent resource-ID tombstone. A retired ID cannot be registered again. The four core init Systems (AccessManagement, BalanceTransfer, BatchCall, and Registration) cannot be retired; replace their implementations at the stable IDs instead. - - - Guarded, one-time consent to replace a legacy core Registration System when a deployment needs the native compare-and-swap API, including a pending System registration/upgrade or selector lifecycle change. Set `expectedSystem` to the exact live Registration System implementation address only after reviewing that implementation. The deployer refuses the replacement if the live address differs. Omit this option for new Worlds and for older Worlds whose Registration System already exposes the complete native lifecycle API. - Deploy the World using a custom implementation. This world must implement the same interface as `World.sol` so that it can initialize core modules, etc. If you want to extend the world with new functions or override existing registered functions, we recommend using [root systems](/world/systems#root-systems). diff --git a/docs/pages/world/reference/internal/init-module-implementation.mdx b/docs/pages/world/reference/internal/init-module-implementation.mdx index aa6c655f57..b97ecf945b 100644 --- a/docs/pages/world/reference/internal/init-module-implementation.mdx +++ b/docs/pages/world/reference/internal/init-module-implementation.mdx @@ -395,32 +395,6 @@ function registerSystem(ResourceId systemId, System system, bool publicAccess) p | `system` | `System` | The system being registered | | `publicAccess` | `bool` | Flag indicating if access control check is bypassed | -#### replaceSystem - -Registers or replaces a System only when its current implementation and public-access flag match the expected state. - -```solidity -function replaceSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess -) public virtual onlyDelegatecall; -``` - -#### retireSystem - -Permanently retires an active non-core System while retaining its resource ID as a tombstone. The four core init Systems must be replaced at their stable IDs and cannot be retired. - -```solidity -function retireSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess -) public virtual onlyDelegatecall; -``` - #### registerFunctionSelector [Usage Sample](/world/function-selectors) @@ -479,32 +453,6 @@ function registerRootFunctionSelector( | ----------------------- | -------- | ---------------------------------- | | `worldFunctionSelector` | `bytes4` | The selector of the World function | -#### replaceFunctionRoute - -Replaces a World function selector's complete route after checking the exact expected route. - -```solidity -function replaceFunctionRoute( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature -) public virtual onlyDelegatecall; -``` - -#### unregisterFunctionSelector - -Unregisters a World function selector after checking the exact expected route. - -```solidity -function unregisterFunctionSelector( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector -) public virtual onlyDelegatecall; -``` - #### registerDelegation Registers a delegation for the caller diff --git a/docs/pages/world/reference/misc.mdx b/docs/pages/world/reference/misc.mdx index 35f54109fb..be7eea5f25 100644 --- a/docs/pages/world/reference/misc.mdx +++ b/docs/pages/world/reference/misc.mdx @@ -104,5 +104,5 @@ Contains a constant representing the version of the World protocol. _Identifier for the current World protocol version._ ```solidity -bytes32 constant WORLD_VERSION = "2.1.0"; +bytes32 constant WORLD_VERSION = "2.0.2"; ``` diff --git a/docs/pages/world/reference/world-external.mdx b/docs/pages/world/reference/world-external.mdx index 9d8609e1e0..54a9338ad6 100644 --- a/docs/pages/world/reference/world-external.mdx +++ b/docs/pages/world/reference/world-external.mdx @@ -263,44 +263,6 @@ error World_SystemAlreadyExists(address system); | -------- | --------- | -------------------------- | | `system` | `address` | The address of the system. | -#### World_SystemAlreadyRetired - -Raised when trying to register a System at a permanently retired System ID. - -```solidity -error World_SystemAlreadyRetired(ResourceId systemId, string systemIdString); -``` - -#### World_SystemCannotBeRetired - -Raised when trying to permanently retire a protected core System. - -```solidity -error World_SystemCannotBeRetired(ResourceId systemId, string systemIdString); -``` - -#### World_SystemStateMismatch - -Raised when the current System implementation or public-access flag does not match the expected state. - -```solidity -error World_SystemStateMismatch( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - address actualSystem, - bool actualPublicAccess -); -``` - -#### World_SystemRegistryMismatch - -Raised when a System's reverse-registry entry does not match its System ID. - -```solidity -error World_SystemRegistryMismatch(address system, ResourceId expectedSystemId, ResourceId actualSystemId); -``` - #### World_FunctionSelectorAlreadyExists Raised when trying to register a function selector that already exists. @@ -329,20 +291,6 @@ error World_FunctionSelectorNotFound(bytes4 functionSelector); | ------------------ | -------- | ---------------------------------- | | `functionSelector` | `bytes4` | The function selector in question. | -#### World_FunctionSelectorMismatch - -Raised when a World function's complete route does not match the expected route. - -```solidity -error World_FunctionSelectorMismatch( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId actualSystemId, - bytes4 actualSystemFunctionSelector -); -``` - #### World_DelegationNotFound Raised when the specified delegation is not found. @@ -450,53 +398,6 @@ event HelloWorld(bytes32 indexed worldVersion); | -------------- | --------- | ---------------------------------- | | `worldVersion` | `bytes32` | The protocol version of the World. | -#### WorldFunctionRouteReplaced - -Emitted when a World function selector's complete route is replaced. - -```solidity -event WorldFunctionRouteReplaced( - bytes4 indexed worldFunctionSelector, - ResourceId indexed oldSystemId, - ResourceId indexed newSystemId, - bytes4 oldSystemFunctionSelector, - bytes4 newSystemFunctionSelector -); -``` - -#### WorldFunctionSelectorUnregistered - -Emitted when a World function selector is unregistered. - -```solidity -event WorldFunctionSelectorUnregistered( - bytes4 indexed worldFunctionSelector, - ResourceId indexed systemId, - bytes4 systemFunctionSelector -); -``` - -#### WorldSystemReplaced - -Emitted when a System is registered or replaced through the compare-and-swap primitive. - -```solidity -event WorldSystemReplaced( - ResourceId indexed systemId, - address indexed oldSystem, - address indexed newSystem, - bool publicAccess -); -``` - -#### WorldSystemRetired - -Emitted when a System is permanently retired. - -```solidity -event WorldSystemRetired(ResourceId indexed systemId, address indexed system); -``` - ## IRegistrationSystem [Git Source](https://github.com/latticexyz/mud/blob/main/packages/world/src/codegen/interfaces/IRegistrationSystem.sol) @@ -652,26 +553,6 @@ function unregisterSystemHook(ResourceId systemId, ISystemHook hookAddress) exte function registerSystem(ResourceId systemId, System system, bool publicAccess) external; ``` -#### replaceSystem - -```solidity -function replaceSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess -) external; -``` - -#### retireSystem - -Permanently retires an active non-core System. Core init System IDs remain stable and can only be replaced. - -```solidity -function retireSystem(ResourceId systemId, address expectedSystem, bool expectedPublicAccess) external; -``` - #### registerFunctionSelector ```solidity @@ -693,28 +574,6 @@ function registerRootFunctionSelector( ) external returns (bytes4 worldFunctionSelector); ``` -#### replaceFunctionRoute - -```solidity -function replaceFunctionRoute( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature -) external; -``` - -#### unregisterFunctionSelector - -```solidity -function unregisterFunctionSelector( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector -) external; -``` - #### registerDelegation ```solidity diff --git a/docs/pages/world/upgrades.mdx b/docs/pages/world/upgrades.mdx index 9f515e660e..103f027fab 100644 --- a/docs/pages/world/upgrades.mdx +++ b/docs/pages/world/upgrades.mdx @@ -7,81 +7,6 @@ The [`System`s](./systems) can be upgraded without changing the underlying `Worl However, you can also upgrade the `World` contract itself if the `World` was deployed [behind a proxy](/config#upgradeableWorldImplementation). This allows you to upgrade to a future version of MUD, but adds some gas overhead for all calls (due to one more level of indirection). -## Migrating System IDs and World selectors - -A System resource ID is part of the deployed World's ABI. Changing a System's configured name does not, by itself, update function routes that were registered under the previous ID. Declare the exact old and new routes when a rename is intentional: - -```typescript filename="mud.config.ts" copy showLineNumbers -import { resourceToHex } from "@latticexyz/common"; -import { defineWorld } from "@latticexyz/world"; -import { toFunctionSelector } from "viem"; - -const oldSystemId = resourceToHex({ type: "system", namespace: "app", name: "OldCounter" }); -const counterSystemId = resourceToHex({ type: "system", namespace: "app", name: "counter" }); -const legacyRegistrationSystem = "0x1234567890123456789012345678901234567890"; - -export default defineWorld({ - namespace: "app", - systems: { - CounterSystem: { name: "counter", openAccess: true }, - }, - deploy: { - // Required only for the first CAS-backed deploy of a legacy World whose - // Registration System does not expose the native lifecycle API. - registrationSystemMigration: { - expectedSystem: legacyRegistrationSystem, - }, - functionRouteMigrations: [ - { - worldSelector: toFunctionSelector("app__increment()"), - fromSystemId: oldSystemId, - fromSystemFunctionSelector: toFunctionSelector("increment()"), - toSystemId: counterSystemId, - toSystemFunctionSelector: toFunctionSelector("increment()"), - }, - ], - systemRetirements: [{ systemId: oldSystemId }], - }, -}); -``` - -The deployer reads the complete live selector table before writing. An exact source route is replaced, an exact destination route is an idempotent no-op, and unknown state aborts. Before retiring a System, every remaining selector that references it must have an explicit route replacement or removal. A same-bytecode rename is ordered atomically as retire the old ID, compare-and-swap the new ID from an unused state to the same implementation, then replace selector routes. - -When at least one declared retirement is still active, preflight enumerates the World's full selector history from authoritative RPC logs at a pinned block instead of trusting an indexer snapshot. This can make the first retirement deploy slower on Worlds with long histories. Once every declared retirement is absent or tombstoned, later idempotent deploys may use the configured indexer again. - -Selector route replacements and removals require the root namespace owner. System retirement requires the owner of that System's namespace. When an older World does not yet expose the native lifecycle methods, any pending System registration/upgrade or selector lifecycle change first requires replacing its core Registration System. That replacement requires an explicit `registrationSystemMigration.expectedSystem` guard matching the exact live implementation. Inspect the live `Systems` record before adding this one-time consent. A custom Registration System that already exposes the complete native lifecycle API is preserved. Once the native core is installed, later deployments ignore the guard and do not need this bootstrap authority. - - - One atomic lifecycle batch requires its submitting account to own the root namespace and every affected source and - target namespace. If those authorities are split, coordinate or transfer ownership first, or execute the deployment - through governance that holds all required permissions. The deployer will not weaken or bypass namespace - authorization. - - - - A legacy core only exposes `registerSystem`, so the first bootstrap cannot atomically compare-and-swap the - Registration System implementation. The deployer verifies `expectedSystem` during preflight, authoritatively re-reads - it at the latest block immediately before submission, batches the replacement and selector changes atomically, and - verifies the result afterward. Another transaction could still replace the core between that final read and inclusion. - Run this one-time bootstrap with an exclusive deploy key or controlled change window. Native `replaceSystem` and - `retireSystem` compare-and-swap the full pinned System tuple (implementation and public access) for all later - lifecycle writes; a future one-shot bootstrap module could remove this residual race. - - - - The bootstrap also requires the four native lifecycle function selectors to be unused or already routed exactly to - the Registration System. It fails closed instead of overwriting an unrelated route. A legacy World with one of these - selector collisions needs a reviewed manual migration; a future selector-independent bootstrap module should make - that recovery path native. - - - - Retirement is permanent. The old resource ID remains as a tombstone and cannot be reused. The four core init Systems - (AccessManagement, BalanceTransfer, BatchCall, and Registration) are protected and cannot be retired; replace their - implementations at the stable IDs instead. Treat System IDs as stable identifiers and check migration manifests into - source control whenever a rename or function removal is intentional. - - ## Making an upgradeable `World` To make a `World` upgradeable, edit the [`mud.config.ts`](/config) file and set `deploy.upgradeableWorldImplementation` to `true`. diff --git a/packages/cli/src/deploy/common.test.ts b/packages/cli/src/deploy/common.test.ts deleted file mode 100644 index 169ab8afa4..0000000000 --- a/packages/cli/src/deploy/common.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { supportedWorldVersions } from "./common"; - -describe("supportedWorldVersions", () => { - it("supports the native selector and System lifecycle protocol", () => { - expect(supportedWorldVersions).toContain("2.1.0"); - }); -}); diff --git a/packages/cli/src/deploy/common.ts b/packages/cli/src/deploy/common.ts index 1fb2726721..fb09812f8e 100644 --- a/packages/cli/src/deploy/common.ts +++ b/packages/cli/src/deploy/common.ts @@ -10,7 +10,7 @@ export const worldAbi = IBaseWorldAbi; // Ideally, this should be an append-only list. Before adding more versions here, be sure to add backwards-compatible support for old Store/World versions. export const supportedStoreVersions = ["2.0.0", "2.0.1", "2.0.2"]; -export const supportedWorldVersions = ["2.0.0", "2.0.1", "2.0.2", "2.1.0"]; +export const supportedWorldVersions = ["2.0.0", "2.0.1", "2.0.2"]; // TODO: extend this to include factory+deployer address? so we can reuse the deployer for a world? export type WorldDeploy = { diff --git a/packages/cli/src/deploy/deploy.ts b/packages/cli/src/deploy/deploy.ts index ca3a06c950..5ec1f617a0 100644 --- a/packages/cli/src/deploy/deploy.ts +++ b/packages/cli/src/deploy/deploy.ts @@ -1,6 +1,6 @@ import { Address, Hex, stringToHex } from "viem"; import { deployWorld } from "./deployWorld"; -import { ensureTables, getTablePlan } from "./ensureTables"; +import { ensureTables } from "./ensureTables"; import { CommonDeployOptions, Library, @@ -10,9 +10,9 @@ import { supportedStoreVersions, supportedWorldVersions, } from "./common"; -import { assertPostLifecycleSystemStates, ensureSystems, verifySystems } from "./ensureSystems"; +import { ensureSystems } from "./ensureSystems"; import { getWorldDeploy } from "./getWorldDeploy"; -import { ensureFunctions, verifyFunctions } from "./ensureFunctions"; +import { ensureFunctions } from "./ensureFunctions"; import { ensureModules } from "./ensureModules"; import { ensureNamespaceOwner } from "./ensureNamespaceOwner"; import { debug } from "./debug"; @@ -26,20 +26,6 @@ import { deployCustomWorld } from "./deployCustomWorld"; import { uniqueBy } from "@latticexyz/common/utils"; import { getLibraryMap } from "./getLibraryMap"; import { ensureContractsDeployed, ensureDeployer, waitForTransactions } from "@latticexyz/common/internal"; -import { getBlockNumber } from "viem/actions"; -import { - assertFunctionMigrationOwnership, - ensureFunctionMigrationContract, - ensureFunctionMigrations, - getFunctionMigrationSnapshot, - getRegistrationBootstrapPlan, - getSystemStates, - getSystemMigrationPlan, - hasExactNativeRegistrationFunction, - replaceSystemFunctionSignature, - validateFunctionMigrationConfig, - verifyFunctionMigrations, -} from "./ensureFunctionMigrations"; type DeployOptions = { config: World; @@ -94,31 +80,23 @@ export async function deploy({ }[]; } > { - // Reject malformed lifecycle declarations before deploying a new World or - // performing any RPC-dependent deployment planning. - validateFunctionMigrationConfig({ config, systems }); + const deployerAddress = initialDeployerAddress ?? (await ensureDeployer(client)); - let deployerAddress = initialDeployerAddress; - let worldDeploy: WorldDeploy; - if (existingWorldAddress) { - worldDeploy = await getWorldDeploy(client, existingWorldAddress, worldDeployBlock); - } else { - const newWorldDeployerAddress = deployerAddress ?? (await ensureDeployer(client)); - deployerAddress = newWorldDeployerAddress; - worldDeploy = config.deploy.customWorld + const worldDeploy = existingWorldAddress + ? await getWorldDeploy(client, existingWorldAddress, worldDeployBlock) + : config.deploy.customWorld ? await deployCustomWorld({ client, - deployerAddress: newWorldDeployerAddress, + deployerAddress, artifacts, customWorld: config.deploy.customWorld, }) : await deployWorld( client, - newWorldDeployerAddress, + deployerAddress, salt ?? `0x${randomBytes(32).toString("hex")}`, config.deploy.upgradeableWorldImplementation, ); - } const commonDeployOptions = { client, @@ -134,131 +112,31 @@ export async function deploy({ throw new Error(`Unsupported World version: ${worldDeploy.worldVersion}`); } - const functions = systems.flatMap((system) => system.worldFunctions); - const migrationSnapshot = await getFunctionMigrationSnapshot({ - ...commonDeployOptions, - config, - functions, - systems, - }); - const tablePlan = await getTablePlan({ ...commonDeployOptions, tables }); - - // For existing Worlds, do not deploy even the deterministic deployer until selector conflicts have failed closed. - const resolvedDeployerAddress = deployerAddress ?? (await ensureDeployer(client)); - const libraryMap = getLibraryMap(libraries); - const systemMigrationPlan = getSystemMigrationPlan( - migrationSnapshot.plan, - migrationSnapshot.systemStates, - systems, - resolvedDeployerAddress, - libraryMap, - migrationSnapshot.resourceAccess, - ); - const bootstrapPlan = await getRegistrationBootstrapPlan({ - client, - worldDeploy, - deployerAddress: resolvedDeployerAddress, - migrationPlan: migrationSnapshot.plan, - requiresSystemReconciliation: systemMigrationPlan.requiresSystemReconciliation, - routes: migrationSnapshot.routes, - registrationSystemMigration: config.deploy.registrationSystemMigration, - }); - await assertFunctionMigrationOwnership({ - client, - worldDeploy, - migrationPlan: migrationSnapshot.plan, - bootstrapPlan, - systemRenames: systemMigrationPlan.renames, - targetRegistrations: systemMigrationPlan.targetRegistrations, - reconciliationSystemIds: systemMigrationPlan.reconciliationSystemIds, - configuredResourceIds: [...tables.map((table) => table.tableId), ...systems.map((system) => system.systemId)], - }); - await ensureFunctionMigrationContract({ - client, - deployerAddress: resolvedDeployerAddress, - bootstrapPlan, - }); - const deployedContracts = await ensureContractsDeployed({ ...commonDeployOptions, - deployerAddress: resolvedDeployerAddress, + deployerAddress, contracts: [ ...libraries.map((library) => ({ - bytecode: library.prepareDeploy(resolvedDeployerAddress, libraryMap).bytecode, + bytecode: library.prepareDeploy(deployerAddress, libraryMap).bytecode, deployedBytecodeSize: library.deployedBytecodeSize, debugLabel: `${library.path}:${library.name} library`, })), ...systems.map((system) => ({ - bytecode: system.prepareDeploy(resolvedDeployerAddress, libraryMap).bytecode, + bytecode: system.prepareDeploy(deployerAddress, libraryMap).bytecode, deployedBytecodeSize: system.deployedBytecodeSize, debugLabel: `${resourceToLabel(system)} system`, })), ...modules.map((mod) => ({ - bytecode: mod.prepareDeploy(resolvedDeployerAddress, libraryMap).bytecode, + bytecode: mod.prepareDeploy(deployerAddress, libraryMap).bytecode, deployedBytecodeSize: mod.deployedBytecodeSize, debugLabel: `${mod.name} module`, })), ], }); - const lifecycleTargetSystemIds = [ - ...systemMigrationPlan.renames.map((rename) => rename.targetSystemId), - ...systemMigrationPlan.targetRegistrations.map((target) => target.systemId), - ]; - const lifecycleNamespaceTxs = await ensureNamespaceOwner({ - ...commonDeployOptions, - resourceIds: lifecycleTargetSystemIds, - }); - await waitForTransactions({ - client, - hashes: lifecycleNamespaceTxs, - debugLabel: "selector migration namespace registrations", - }); - - // Apply lifecycle CAS operations before unrelated table/System writes. Missing or - // upgraded selector targets are registered inside this same atomic World batch. - const migrationTxs = await ensureFunctionMigrations({ - ...commonDeployOptions, - migrationPlan: migrationSnapshot.plan, - bootstrapPlan, - systemRenames: systemMigrationPlan.renames, - targetRegistrations: systemMigrationPlan.targetRegistrations, - }); - await waitForTransactions({ - client, - hashes: migrationTxs, - debugLabel: "selector and system migrations", - }); - const postMigrationBlockNumber = await getBlockNumber(client); - const postMigrationSystemStates = await getSystemStates({ - client, - worldDeploy: { ...worldDeploy, stateBlock: postMigrationBlockNumber }, - systemIds: [...systems.map((system) => system.systemId), ...systems.flatMap((system) => system.allowedSystemIds)], - }); - assertPostLifecycleSystemStates({ - systemIds: [...systems.map((system) => system.systemId), ...systems.flatMap((system) => system.allowedSystemIds)], - originalStates: migrationSnapshot.systemStates, - currentStates: postMigrationSystemStates, - lifecycleTargets: [ - ...systemMigrationPlan.targetRegistrations.map((target) => ({ - systemId: target.systemId, - address: target.system, - publicAccess: target.publicAccess, - })), - ...systemMigrationPlan.renames.map((rename) => ({ - systemId: rename.targetSystemId, - address: rename.targetSystem, - publicAccess: rename.targetPublicAccess, - })), - ], - }); - const namespaceTxs = await ensureNamespaceOwner({ ...commonDeployOptions, - worldDeploy: { ...worldDeploy, stateBlock: postMigrationBlockNumber }, - indexerUrl: lifecycleNamespaceTxs.length > 0 || migrationTxs.length > 0 ? undefined : indexerUrl, - chainId: lifecycleNamespaceTxs.length > 0 || migrationTxs.length > 0 ? undefined : chainId, resourceIds: [...tables.map(({ tableId }) => tableId), ...systems.map(({ systemId }) => systemId)], }); // Wait for namespaces to be available, otherwise referencing them below may fail. @@ -266,25 +144,14 @@ export async function deploy({ await waitForTransactions({ client, hashes: namespaceTxs, debugLabel: "namespace registrations" }); const tableTxs = await ensureTables({ - client, - worldDeploy, - plan: tablePlan, + ...commonDeployOptions, + tables, }); const systemTxs = await ensureSystems({ ...commonDeployOptions, - worldDeploy: { ...worldDeploy, stateBlock: postMigrationBlockNumber }, - // A just-mined lifecycle batch may not be reflected by the indexer yet. - // Read the authoritative post-migration System state from RPC instead. - indexerUrl: migrationTxs.length > 0 ? undefined : indexerUrl, - chainId: migrationTxs.length > 0 ? undefined : chainId, - deployerAddress: resolvedDeployerAddress, + deployerAddress, libraryMap, systems, - systemStates: migrationSnapshot.systemStates, - accessSystemStates: postMigrationSystemStates, - useNativeSystemReplacement: - bootstrapPlan != null || - hasExactNativeRegistrationFunction(migrationSnapshot.routes, replaceSystemFunctionSignature), }); // Wait for tables and systems to be available, otherwise referencing their resource IDs below may fail. // This is only here because OPStack chains don't let us estimate gas with pending block tag. @@ -296,11 +163,11 @@ export async function deploy({ const functionTxs = await ensureFunctions({ ...commonDeployOptions, - plan: migrationSnapshot.plan.functionPlan, + functions: systems.flatMap((system) => system.worldFunctions), }); const moduleTxs = await ensureModules({ ...commonDeployOptions, - deployerAddress: resolvedDeployerAddress, + deployerAddress, libraryMap, modules, }); @@ -331,7 +198,7 @@ export async function deploy({ const tagTxs = await ensureResourceTags({ ...commonDeployOptions, - deployerAddress: resolvedDeployerAddress, + deployerAddress, libraryMap, tags: [...namespaceTags, ...tableTags, ...systemTags], valueToHex: stringToHex, @@ -343,28 +210,6 @@ export async function deploy({ debugLabel: "remaining transactions", }); - const latestBlockNumber = await getBlockNumber(client); - await verifySystems({ - client, - worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, - deployerAddress: resolvedDeployerAddress, - libraryMap, - systems, - }); - await verifyFunctions({ - client, - worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, - functions, - }); - await verifyFunctionMigrations({ - client, - worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, - migrationPlan: migrationSnapshot.plan, - systemRenames: systemMigrationPlan.renames, - targetRegistrations: systemMigrationPlan.targetRegistrations, - bootstrapPlan, - }); - debug("deploy complete"); return { ...worldDeploy, diff --git a/packages/cli/src/deploy/ensureFunctionMigrations.test.ts b/packages/cli/src/deploy/ensureFunctionMigrations.test.ts deleted file mode 100644 index d5fc42cf71..0000000000 --- a/packages/cli/src/deploy/ensureFunctionMigrations.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { decodeFunctionData, toFunctionSelector, type Address, type Hex } from "viem"; -import { describe, expect, it } from "vitest"; -import { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config"; -import { resourceToHex } from "@latticexyz/common"; -import { - encodeFunctionMigrationCalls, - encodeLifecycleBatchSystemCall, - assertLegacyRegistrationBootstrapCurrent, - batchCallSystemId, - nativeRegistrationFunctionSignatures, - nativeRegistrationSystemAbi, - planRegistrationSystemBootstrap, - protectedCoreSystemIds, - requiresRegistrationBootstrap, - getConfiguredNamespaceIds, - getSystemMigrationPlan, - type RegistrationBootstrapPlan, - validateFunctionMigrationConfig, -} from "./ensureFunctionMigrations"; -import type { FunctionMigrationPlan, PlannedSystemRename } from "./functionMigrationPlan"; -import type { World } from "@latticexyz/world"; -import { worldAbi, type System } from "./common"; - -const registrationAddress = `0x${"99".repeat(20)}` as Address; -const oldRegistrationAddress = `0x${"98".repeat(20)}` as Address; -const sourceSystemId = `0x7379${"00".repeat(14)}${"11".repeat(16)}` as Hex; -const targetSystemId = `0x7379${"00".repeat(14)}${"22".repeat(16)}` as Hex; -const otherRetirementId = `0x7379${"00".repeat(14)}${"33".repeat(16)}` as Hex; -const implementation = `0x${"44".repeat(20)}` as Address; -const worldAddress = `0x${"aa".repeat(20)}` as Address; -const registrationSystemId = worldSystemsConfig.systems.RegistrationSystem.systemId; - -function nativeRegistrationRoutes() { - return nativeRegistrationFunctionSignatures.map((signature) => { - const selector = toFunctionSelector(signature); - return { selector, systemId: registrationSystemId, systemFunctionSelector: selector }; - }); -} - -function migrationPlan(): FunctionMigrationPlan { - return { - functionPlan: { toAdd: [], toSkip: [] }, - migrationsToApply: [ - { - worldSelector: "0x12345678", - fromSystemId: sourceSystemId, - fromSystemFunctionSelector: "0x87654321", - toSystemId: targetSystemId, - toSystemFunctionSelector: "0x87654321", - toSystemFunctionSignature: "run()", - }, - ], - migrationsAlreadyApplied: [], - migrationsNotApplicable: [], - removalsToApply: [ - { - worldSelector: "0xaaaaaaaa", - expectedSystemId: sourceSystemId, - expectedSystemFunctionSelector: "0xbbbbbbbb", - }, - ], - removalsAlreadyApplied: [], - retirementsToApply: [ - { systemId: sourceSystemId, expectedSystem: implementation, expectedPublicAccess: true }, - { - systemId: otherRetirementId, - expectedSystem: `0x${"55".repeat(20)}`, - expectedPublicAccess: false, - }, - ], - retirementsAlreadyApplied: [], - retirementsNotFound: [], - }; -} - -function bootstrapPlan(): RegistrationBootstrapPlan { - return { - desiredSystem: { - address: registrationAddress, - bytecode: "0x1234", - deployedBytecodeSize: 2, - debugLabel: "core registration system", - }, - expectedSystem: registrationAddress, - expectedPublicAccess: true, - upgrade: { currentSystem: oldRegistrationAddress, publicAccess: true }, - selectorsToRegister: [nativeRegistrationFunctionSignatures[0]], - selectorsToVerify: [nativeRegistrationFunctionSignatures[0]], - }; -} - -describe("encodeFunctionMigrationCalls", () => { - it("bootstraps first, then atomically frees/registers rename addresses before replacing routes", () => { - const rename: PlannedSystemRename = { - systemId: sourceSystemId, - expectedSystem: implementation, - expectedPublicAccess: true, - targetSystemId, - targetSystem: implementation, - targetPublicAccess: false, - }; - const calls = encodeFunctionMigrationCalls({ - migrationPlan: migrationPlan(), - bootstrapPlan: bootstrapPlan(), - systemRenames: [rename], - targetRegistrations: [], - }); - - const decoded = calls.map(({ callData }) => - decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: callData }), - ); - expect(decoded.map(({ functionName }) => functionName)).toEqual([ - "registerSystem", - "registerRootFunctionSelector", - "retireSystem", - "replaceSystem", - "replaceFunctionRoute", - "unregisterFunctionSelector", - "retireSystem", - ]); - expect(decoded[2]).toMatchObject({ - functionName: "retireSystem", - args: [sourceSystemId, implementation, true], - }); - expect(decoded[3]).toMatchObject({ - functionName: "replaceSystem", - args: [targetSystemId, "0x0000000000000000000000000000000000000000", false, implementation, false], - }); - expect(decoded[4]).toMatchObject({ - functionName: "replaceFunctionRoute", - args: ["0x12345678", sourceSystemId, "0x87654321", targetSystemId, "run()"], - }); - expect(decoded.at(-1)).toMatchObject({ - functionName: "retireSystem", - args: [otherRetirementId, `0x${"55".repeat(20)}`, false], - }); - }); - - it("targets the immutable kernel call path for lifecycle batches", () => { - const calls = encodeFunctionMigrationCalls({ - migrationPlan: { ...migrationPlan(), retirementsToApply: [] }, - bootstrapPlan: { ...bootstrapPlan(), upgrade: undefined, selectorsToRegister: [] }, - systemRenames: [], - targetRegistrations: [], - }); - const batch = encodeLifecycleBatchSystemCall(calls); - - expect(batch.systemId).toBe(batchCallSystemId); - expect(decodeFunctionData({ abi: worldAbi, data: batch.callData })).toMatchObject({ - functionName: "batchCall", - args: [calls], - }); - }); - - it("does not encode an implementation upgrade when the bootstrap is already current", () => { - const bootstrap = { ...bootstrapPlan(), upgrade: undefined, selectorsToRegister: [] }; - const calls = encodeFunctionMigrationCalls({ - migrationPlan: { ...migrationPlan(), retirementsToApply: [] }, - bootstrapPlan: bootstrap, - systemRenames: [], - targetRegistrations: [], - }); - - expect( - calls.map( - ({ callData }) => decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: callData }).functionName, - ), - ).toEqual(["replaceFunctionRoute", "unregisterFunctionSelector"]); - }); - - it("encodes migration target upgrades with the snapshot address as a CAS guard", () => { - const currentTarget = `0x${"66".repeat(20)}` as Address; - const desiredTarget = `0x${"77".repeat(20)}` as Address; - const calls = encodeFunctionMigrationCalls({ - migrationPlan: { ...migrationPlan(), retirementsToApply: [] }, - bootstrapPlan: { ...bootstrapPlan(), upgrade: undefined, selectorsToRegister: [] }, - systemRenames: [], - targetRegistrations: [ - { - systemId: targetSystemId, - expectedSystem: currentTarget, - expectedPublicAccess: false, - system: desiredTarget, - publicAccess: true, - }, - ], - }); - const replacement = decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: calls[0].callData }); - - expect(replacement).toMatchObject({ - functionName: "replaceSystem", - args: [targetSystemId, currentTarget, false, desiredTarget, true], - }); - }); -}); - -describe("assertLegacyRegistrationBootstrapCurrent", () => { - it("rejects a RegistrationSystem change after preflight", () => { - expect(() => - assertLegacyRegistrationBootstrapCurrent({ - bootstrapPlan: bootstrapPlan(), - registration: { system: implementation, publicAccess: true }, - }), - ).toThrowError("RegistrationSystem changed after selector migration preflight"); - - expect(() => - assertLegacyRegistrationBootstrapCurrent({ - bootstrapPlan: bootstrapPlan(), - registration: { system: oldRegistrationAddress, publicAccess: true }, - }), - ).not.toThrow(); - }); -}); - -describe("getConfiguredNamespaceIds", () => { - it("includes configured table namespaces in the pre-bootstrap ownership inventory", () => { - const tableId = resourceToHex({ type: "table", namespace: "foreign", name: "Counter" }); - const namespaceId = resourceToHex({ type: "namespace", namespace: "foreign", name: "" }); - - expect(getConfiguredNamespaceIds([tableId])).toEqual([namespaceId]); - }); -}); - -describe("getSystemMigrationPlan", () => { - it("reconciles an exact System tuple when its default namespace grant is missing", () => { - const noLifecycleWrites: FunctionMigrationPlan = { - ...migrationPlan(), - migrationsToApply: [], - removalsToApply: [], - retirementsToApply: [], - }; - const system = { - systemId: targetSystemId, - allowAll: true, - prepareDeploy: () => ({ address: implementation, bytecode: "0x" }), - deployedBytecodeSize: 0, - abi: [], - label: "target", - namespaceLabel: "", - namespace: "", - name: "target", - allowedAddresses: [], - allowedSystemIds: [], - worldFunctions: [], - metadata: { abi: [], worldAbi: [] }, - } satisfies System; - const state = { systemId: targetSystemId, exists: true, address: implementation, publicAccess: true }; - const libraryMap = { getAddress: () => implementation }; - - expect(getSystemMigrationPlan(noLifecycleWrites, [state], [system], worldAddress, libraryMap, [])).toMatchObject({ - requiresSystemReconciliation: true, - reconciliationSystemIds: [targetSystemId], - }); - - const rootNamespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); - expect( - getSystemMigrationPlan(noLifecycleWrites, [state], [system], worldAddress, libraryMap, [ - { resourceId: rootNamespaceId, address: implementation }, - ]), - ).toMatchObject({ requiresSystemReconciliation: false, reconciliationSystemIds: [] }); - }); - - it("repairs a migration target's missing namespace grant inside the lifecycle batch", () => { - const pendingMigration: FunctionMigrationPlan = { - ...migrationPlan(), - removalsToApply: [], - retirementsToApply: [], - }; - const system = { - systemId: targetSystemId, - allowAll: true, - prepareDeploy: () => ({ address: implementation, bytecode: "0x" }), - deployedBytecodeSize: 0, - abi: [], - label: "target", - namespaceLabel: "", - namespace: "", - name: "target", - allowedAddresses: [], - allowedSystemIds: [], - worldFunctions: [], - metadata: { abi: [], worldAbi: [] }, - } satisfies System; - const state = { systemId: targetSystemId, exists: true, address: implementation, publicAccess: true }; - const libraryMap = { getAddress: () => implementation }; - - expect(getSystemMigrationPlan(pendingMigration, [state], [system], worldAddress, libraryMap, [])).toMatchObject({ - targetRegistrations: [ - { - systemId: targetSystemId, - expectedSystem: implementation, - expectedPublicAccess: true, - system: implementation, - publicAccess: true, - }, - ], - }); - - const rootNamespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); - expect( - getSystemMigrationPlan(pendingMigration, [state], [system], worldAddress, libraryMap, [ - { resourceId: rootNamespaceId, address: implementation }, - ]).targetRegistrations, - ).toEqual([]); - }); -}); - -describe("planRegistrationSystemBootstrap", () => { - it("requires bootstrap planning for ordinary pending System reconciliation", () => { - const noLifecycleWrites = { - ...migrationPlan(), - migrationsToApply: [], - removalsToApply: [], - retirementsToApply: [], - }; - - expect(requiresRegistrationBootstrap(noLifecycleWrites, false)).toBe(false); - expect(requiresRegistrationBootstrap(noLifecycleWrites, true)).toBe(true); - expect(() => - planRegistrationSystemBootstrap({ - worldAddress, - desiredSystem: bootstrapPlan().desiredSystem, - registration: { system: oldRegistrationAddress, publicAccess: true }, - routes: [], - registrationSystemMigration: undefined, - }), - ).toThrowError("replacing core Systems is opt-in"); - }); - - it("preserves a custom RegistrationSystem that exposes the complete native API", () => { - const plan = planRegistrationSystemBootstrap({ - worldAddress, - desiredSystem: bootstrapPlan().desiredSystem, - registration: { system: oldRegistrationAddress, publicAccess: false }, - routes: nativeRegistrationRoutes(), - registrationSystemMigration: undefined, - }); - - expect(plan.upgrade).toBeUndefined(); - expect(plan.expectedSystem).toBe(oldRegistrationAddress); - expect(plan.expectedPublicAccess).toBe(false); - expect(plan.selectorsToRegister).toEqual([]); - expect(plan.selectorsToVerify).toEqual(nativeRegistrationFunctionSignatures); - }); - - it("requires explicit consent before replacing a legacy/custom core", () => { - const input = { - worldAddress, - desiredSystem: bootstrapPlan().desiredSystem, - registration: { system: oldRegistrationAddress, publicAccess: true }, - routes: [], - } as const; - - expect(() => planRegistrationSystemBootstrap({ ...input, registrationSystemMigration: undefined })).toThrowError( - "replacing core Systems is opt-in", - ); - expect(() => - planRegistrationSystemBootstrap({ - ...input, - registrationSystemMigration: { expectedSystem: implementation }, - }), - ).toThrowError("RegistrationSystem migration guard mismatch"); - - const plan = planRegistrationSystemBootstrap({ - ...input, - registrationSystemMigration: { expectedSystem: oldRegistrationAddress }, - }); - expect(plan.upgrade).toEqual({ currentSystem: oldRegistrationAddress, publicAccess: true }); - expect(plan.selectorsToRegister).toEqual(nativeRegistrationFunctionSignatures); - }); - - it("does not require consent for the current fork implementation", () => { - const plan = planRegistrationSystemBootstrap({ - worldAddress, - desiredSystem: bootstrapPlan().desiredSystem, - registration: { system: registrationAddress, publicAccess: true }, - routes: [], - registrationSystemMigration: undefined, - }); - - expect(plan.upgrade).toBeUndefined(); - expect(plan.selectorsToRegister).toEqual(nativeRegistrationFunctionSignatures); - }); -}); - -describe("validateFunctionMigrationConfig", () => { - function validate( - deploy: Partial, - systems: readonly Pick[] = [], - ): void { - validateFunctionMigrationConfig({ - config: { - deploy: { - functionRouteMigrations: [], - functionSelectorRemovals: [], - systemRetirements: [], - ...deploy, - }, - } as World, - systems, - }); - } - - it("rejects malformed selectors before planning", () => { - expect(() => - validate({ - functionRouteMigrations: [ - { - worldSelector: "0x1234" as Hex, - fromSystemId: sourceSystemId, - fromSystemFunctionSelector: "0x87654321", - toSystemId: targetSystemId, - toSystemFunctionSelector: "0x87654321", - }, - ], - }), - ).toThrowError("worldSelector must be exactly 4 bytes"); - }); - - it("rejects non-System resource IDs and zero IDs", () => { - expect(() => validate({ systemRetirements: [{ systemId: `0x${"00".repeat(32)}` }] })).toThrowError( - "must be nonzero", - ); - - expect(() => validate({ systemRetirements: [{ systemId: `0x7462${"00".repeat(30)}` }] })).toThrowError( - "must be a System resource ID", - ); - }); - - it.each(protectedCoreSystemIds)("rejects retirement of core System %s", (systemId) => { - expect(() => validate({ systemRetirements: [{ systemId }] })).toThrowError("core System"); - }); - - it("rejects case-insensitive duplicate selector entries", () => { - expect(() => - validate({ - functionSelectorRemovals: [ - { - worldSelector: "0xaabbccdd", - expectedSystemId: sourceSystemId, - expectedSystemFunctionSelector: "0x12345678", - }, - { - worldSelector: "0xAABBCCDD", - expectedSystemId: sourceSystemId, - expectedSystemFunctionSelector: "0x12345678", - }, - ], - }), - ).toThrowError("duplicate selector removal"); - }); - - it("rejects malformed or zero RegistrationSystem migration guards", () => { - expect(() => validate({ registrationSystemMigration: { expectedSystem: "0x1234" as Address } })).toThrowError( - "expectedSystem must be exactly 20 bytes", - ); - expect(() => - validate({ - registrationSystemMigration: { - expectedSystem: "0x0000000000000000000000000000000000000000", - }, - }), - ).toThrowError("expectedSystem must be nonzero"); - }); - - it("rejects access-list references to a System being retired", () => { - const grantee = { - systemId: targetSystemId, - allowedSystemIds: [sourceSystemId], - }; - - expect(() => validate({ systemRetirements: [{ systemId: sourceSystemId }] }, [grantee])).toThrowError( - "is also declared in systemRetirements", - ); - }); - - it("allows access to a distinct configured migration target while retiring its legacy source", () => { - const grantee = { - systemId: otherRetirementId, - allowedSystemIds: [targetSystemId], - }; - const target = { systemId: targetSystemId, allowedSystemIds: [] }; - - expect(() => validate({ systemRetirements: [{ systemId: sourceSystemId }] }, [grantee, target])).not.toThrow(); - }); -}); diff --git a/packages/cli/src/deploy/ensureFunctionMigrations.ts b/packages/cli/src/deploy/ensureFunctionMigrations.ts deleted file mode 100644 index 441318d6ed..0000000000 --- a/packages/cli/src/deploy/ensureFunctionMigrations.ts +++ /dev/null @@ -1,1152 +0,0 @@ -import type { Address, Hex } from "viem"; -import { encodeFunctionData, getAddress, toFunctionSelector, zeroAddress } from "viem"; -import { getBlockNumber } from "viem/actions"; -import { hexToResource, resourceToHex, writeContract } from "@latticexyz/common"; -import { ensureContractsDeployed } from "@latticexyz/common/internal"; -import storeConfig from "@latticexyz/store/mud.config"; -import worldConfig, { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config"; -import type { World } from "@latticexyz/world"; -import type { CommonDeployOptions, System, WorldFunction } from "./common"; -import { worldAbi } from "./common"; -import { debug } from "./debug"; -import { - type DesiredSystem, - type FunctionMigrationPlan, - type PlannedSystemRename, - planFunctionMigrations, - planSystemRenames, - type SystemState, -} from "./functionMigrationPlan"; -import type { FunctionRoute } from "./functionPlan"; -import { getAllFunctionRoutes, getFunctionRoutes } from "./getFunctionRoutes"; -import { getResourceAccess } from "./getResourceAccess"; -import { hasSystemNamespaceGrant, type SystemAccess } from "./systemAccess"; -import { getRecord } from "./getRecord"; -import { getWorldContracts } from "./getWorldContracts"; -import type { LibraryMap } from "./getLibraryMap"; - -export const registrationSystemId = worldSystemsConfig.systems.RegistrationSystem.systemId; -export const batchCallSystemId = worldSystemsConfig.systems.BatchCallSystem.systemId; -export const accessManagementSystemId = worldSystemsConfig.systems.AccessManagementSystem.systemId; -export const balanceTransferSystemId = worldSystemsConfig.systems.BalanceTransferSystem.systemId; -export const protectedCoreSystemIds = [ - accessManagementSystemId, - balanceTransferSystemId, - batchCallSystemId, - registrationSystemId, -] as const; - -export const nativeRegistrationFunctionSignatures = [ - "replaceFunctionRoute(bytes4,bytes32,bytes4,bytes32,string)", - "unregisterFunctionSelector(bytes4,bytes32,bytes4)", - "retireSystem(bytes32,address,bool)", - "replaceSystem(bytes32,address,bool,address,bool)", -] as const; - -export const replaceSystemFunctionSignature = nativeRegistrationFunctionSignatures[3]; - -export function hasExactNativeRegistrationFunction( - routes: readonly FunctionRoute[], - signature: (typeof nativeRegistrationFunctionSignatures)[number], -): boolean { - const selector = toFunctionSelector(signature); - return routes.some( - (route) => - sameHex(route.selector, selector) && - sameHex(route.systemId, registrationSystemId) && - sameHex(route.systemFunctionSelector, selector), - ); -} - -export const nativeRegistrationSystemAbi = [ - { - type: "function", - name: "registerNamespace", - inputs: [{ name: "namespaceId", type: "bytes32" }], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "registerTable", - inputs: [ - { name: "tableId", type: "bytes32" }, - { name: "fieldLayout", type: "bytes32" }, - { name: "keySchema", type: "bytes32" }, - { name: "valueSchema", type: "bytes32" }, - { name: "keyNames", type: "string[]" }, - { name: "fieldNames", type: "string[]" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "registerSystem", - inputs: [ - { name: "systemId", type: "bytes32" }, - { name: "system", type: "address" }, - { name: "publicAccess", type: "bool" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "replaceSystem", - inputs: [ - { name: "systemId", type: "bytes32" }, - { name: "expectedSystem", type: "address" }, - { name: "expectedPublicAccess", type: "bool" }, - { name: "system", type: "address" }, - { name: "publicAccess", type: "bool" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "registerFunctionSelector", - inputs: [ - { name: "systemId", type: "bytes32" }, - { name: "systemFunctionSignature", type: "string" }, - ], - outputs: [{ name: "worldFunctionSelector", type: "bytes4" }], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "registerRootFunctionSelector", - inputs: [ - { name: "systemId", type: "bytes32" }, - { name: "worldFunctionSignature", type: "string" }, - { name: "systemFunctionSignature", type: "string" }, - ], - outputs: [{ name: "worldFunctionSelector", type: "bytes4" }], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "replaceFunctionRoute", - inputs: [ - { name: "worldFunctionSelector", type: "bytes4" }, - { name: "expectedFromSystemId", type: "bytes32" }, - { name: "expectedSystemFunctionSelector", type: "bytes4" }, - { name: "newSystemId", type: "bytes32" }, - { name: "newSystemFunctionSignature", type: "string" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "unregisterFunctionSelector", - inputs: [ - { name: "worldFunctionSelector", type: "bytes4" }, - { name: "expectedSystemId", type: "bytes32" }, - { name: "expectedSystemFunctionSelector", type: "bytes4" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "retireSystem", - inputs: [ - { name: "systemId", type: "bytes32" }, - { name: "expectedSystem", type: "address" }, - { name: "expectedPublicAccess", type: "bool" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, -] as const; - -export type FunctionMigrationSnapshot = { - readonly routes: readonly FunctionRoute[]; - readonly systemStates: readonly SystemState[]; - readonly resourceAccess: readonly SystemAccess[]; - readonly plan: FunctionMigrationPlan; -}; - -export type RegistrationBootstrapPlan = { - readonly desiredSystem: ReturnType["RegistrationSystem"]; - readonly expectedSystem: Address; - readonly expectedPublicAccess: boolean; - readonly upgrade?: { - readonly currentSystem: Address; - readonly publicAccess: boolean; - }; - readonly selectorsToRegister: readonly (typeof nativeRegistrationFunctionSignatures)[number][]; - readonly selectorsToVerify: readonly (typeof nativeRegistrationFunctionSignatures)[number][]; -}; - -export type DirectSystemCall = { - readonly systemId: Hex; - readonly callData: Hex; -}; - -export type PlannedMigrationTargetRegistration = { - readonly systemId: Hex; - readonly expectedSystem: Address; - readonly expectedPublicAccess: boolean; - readonly system: Address; - readonly publicAccess: boolean; -}; - -export type SystemMigrationPlan = { - readonly renames: readonly PlannedSystemRename[]; - readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; - readonly requiresSystemReconciliation: boolean; - readonly reconciliationSystemIds: readonly Hex[]; -}; - -function normalizeHex(value: Hex): string { - return value.toLowerCase(); -} - -function sameHex(a: Hex, b: Hex): boolean { - return normalizeHex(a) === normalizeHex(b); -} - -function configError(message: string): never { - throw new Error(`Invalid selector lifecycle config: ${message}`); -} - -function assertBytes(value: Hex, bytes: number, label: string): void { - const pattern = new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`); - if (!pattern.test(value)) configError(`${label} must be exactly ${bytes} bytes, received ${String(value)}.`); -} - -function assertSystemId(value: Hex, label: string): void { - assertBytes(value, 32, label); - if (/^0x0{64}$/i.test(value)) configError(`${label} must be nonzero.`); - let type: string; - try { - type = hexToResource(value).type; - } catch { - configError(`${label} is not a valid MUD resource ID.`); - } - if (type !== "system") configError(`${label} must be a System resource ID, received type ${type}.`); -} - -function addUniqueConfigValue(values: Set, value: Hex, label: string): void { - const normalized = normalizeHex(value); - if (values.has(normalized)) configError(`duplicate ${label} ${value}.`); - values.add(normalized); -} - -/** Validate lifecycle config shapes locally, before any RPC-dependent planning. */ -export function validateFunctionMigrationConfig({ - config, - systems, -}: { - readonly config: World; - readonly systems: readonly Pick[]; -}): void { - const migrationSelectors = new Map< - string, - { - readonly toSystemId: Hex; - readonly toSystemFunctionSelector: Hex; - readonly sourceTuples: Set; - } - >(); - for (const [index, migration] of config.deploy.functionRouteMigrations.entries()) { - const label = `functionRouteMigrations[${index}]`; - assertBytes(migration.worldSelector, 4, `${label}.worldSelector`); - assertBytes(migration.fromSystemFunctionSelector, 4, `${label}.fromSystemFunctionSelector`); - assertBytes(migration.toSystemFunctionSelector, 4, `${label}.toSystemFunctionSelector`); - assertSystemId(migration.fromSystemId, `${label}.fromSystemId`); - assertSystemId(migration.toSystemId, `${label}.toSystemId`); - if ( - sameHex(migration.fromSystemId, migration.toSystemId) && - sameHex(migration.fromSystemFunctionSelector, migration.toSystemFunctionSelector) - ) { - configError(`${label} must use different source and destination route tuples.`); - } - const selector = normalizeHex(migration.worldSelector); - const group = migrationSelectors.get(selector); - if (group == null) { - migrationSelectors.set(selector, { - toSystemId: migration.toSystemId, - toSystemFunctionSelector: migration.toSystemFunctionSelector, - sourceTuples: new Set([ - `${normalizeHex(migration.fromSystemId)}/${normalizeHex(migration.fromSystemFunctionSelector)}`, - ]), - }); - } else { - if ( - !sameHex(group.toSystemId, migration.toSystemId) || - !sameHex(group.toSystemFunctionSelector, migration.toSystemFunctionSelector) - ) { - configError(`${label} must share the same final destination tuple as its selector alternatives.`); - } - const source = `${normalizeHex(migration.fromSystemId)}/${normalizeHex(migration.fromSystemFunctionSelector)}`; - if (group.sourceTuples.has(source)) { - configError( - `duplicate migration source ${migration.fromSystemId}/${migration.fromSystemFunctionSelector} for selector ${migration.worldSelector}.`, - ); - } - group.sourceTuples.add(source); - } - } - - const removalSelectors = new Set(); - for (const [index, removal] of config.deploy.functionSelectorRemovals.entries()) { - const label = `functionSelectorRemovals[${index}]`; - assertBytes(removal.worldSelector, 4, `${label}.worldSelector`); - assertBytes(removal.expectedSystemFunctionSelector, 4, `${label}.expectedSystemFunctionSelector`); - assertSystemId(removal.expectedSystemId, `${label}.expectedSystemId`); - addUniqueConfigValue(removalSelectors, removal.worldSelector, "selector removal"); - if (migrationSelectors.has(normalizeHex(removal.worldSelector))) { - configError(`selector ${removal.worldSelector} cannot be both migrated and removed.`); - } - } - - const retirementIds = new Set(); - for (const [index, retirement] of config.deploy.systemRetirements.entries()) { - const label = `systemRetirements[${index}].systemId`; - assertSystemId(retirement.systemId, label); - addUniqueConfigValue(retirementIds, retirement.systemId, "System retirement"); - if (protectedCoreSystemIds.some((systemId) => sameHex(retirement.systemId, systemId))) { - configError(`core System ${retirement.systemId} cannot be retired.`); - } - } - - const desiredSystemIds = new Set(); - for (const [index, system] of systems.entries()) { - assertSystemId(system.systemId, `systems[${index}].systemId`); - addUniqueConfigValue(desiredSystemIds, system.systemId, "configured System ID"); - for (const [allowedIndex, allowedSystemId] of system.allowedSystemIds.entries()) { - const label = `systems[${index}].allowedSystemIds[${allowedIndex}]`; - assertSystemId(allowedSystemId, label); - if (retirementIds.has(normalizeHex(allowedSystemId))) { - configError( - `${label} references ${allowedSystemId}, which is also declared in systemRetirements. Point the access list at the distinct configured migration target ID instead.`, - ); - } - } - } - - const registrationMigration = config.deploy.registrationSystemMigration; - if (registrationMigration != null) { - assertBytes(registrationMigration.expectedSystem, 20, "registrationSystemMigration.expectedSystem"); - if (sameHex(registrationMigration.expectedSystem, zeroAddress)) { - configError("registrationSystemMigration.expectedSystem must be nonzero."); - } - } -} - -function hasNativeWrites(plan: FunctionMigrationPlan): boolean { - return plan.migrationsToApply.length > 0 || plan.removalsToApply.length > 0 || plan.retirementsToApply.length > 0; -} - -export function requiresRegistrationBootstrap( - migrationPlan: FunctionMigrationPlan, - requiresSystemReconciliation: boolean, -): boolean { - return hasNativeWrites(migrationPlan) || requiresSystemReconciliation; -} - -export async function getSystemStates({ - client, - worldDeploy, - systemIds, -}: Pick & { - readonly systemIds: readonly Hex[]; -}): Promise { - const uniqueSystemIds = [...new Map(systemIds.map((systemId) => [normalizeHex(systemId), systemId])).values()]; - - return Promise.all( - uniqueSystemIds.map(async (systemId): Promise => { - const [resource, system] = await Promise.all([ - getRecord({ - client, - worldDeploy, - table: storeConfig.namespaces.store.tables.ResourceIds, - key: { resourceId: systemId }, - }), - getRecord({ - client, - worldDeploy, - table: worldConfig.namespaces.world.tables.Systems, - key: { systemId }, - }), - ]); - - return { - systemId, - exists: resource.exists, - address: system.system, - publicAccess: system.publicAccess, - }; - }), - ); -} - -/** Read and classify all configured selector lifecycle changes without writing. */ -export async function getFunctionMigrationSnapshot({ - config, - functions, - systems, - ...options -}: CommonDeployOptions & { - readonly config: World; - readonly functions: readonly WorldFunction[]; - readonly systems: readonly System[]; -}): Promise { - validateFunctionMigrationConfig({ config, systems }); - - const explicitSelectors = [ - ...functions.map((func) => func.selector), - ...config.deploy.functionRouteMigrations.map((migration) => migration.worldSelector), - ...config.deploy.functionSelectorRemovals.map((removal) => removal.worldSelector), - ...nativeRegistrationFunctionSignatures.map((signature) => toFunctionSelector(signature)), - ]; - - const [systemStates, resourceAccess] = await Promise.all([ - getSystemStates({ - client: options.client, - worldDeploy: options.worldDeploy, - systemIds: [ - ...config.deploy.systemRetirements.map((retirement) => retirement.systemId), - ...config.deploy.functionRouteMigrations.map((migration) => migration.fromSystemId), - ...systems.map((system) => system.systemId), - ...systems.flatMap((system) => system.allowedSystemIds), - ], - }), - // Default System namespace grants affect whether an exact registration is usable. - // Read them authoritatively because this snapshot gates the core bootstrap plan. - getResourceAccess({ client: options.client, worldDeploy: options.worldDeploy }), - ]); - const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); - const hasActiveRetirement = config.deploy.systemRetirements.some(({ systemId }) => { - const state = statesById.get(normalizeHex(systemId)); - return state?.exists === true && !sameHex(state.address, zeroAddress); - }); - const routes = await getAllFunctionRoutes({ - ...options, - additionalSelectors: explicitSelectors, - // A lagging indexer can omit an otherwise-unplanned route and make a destructive - // retirement look safe. Active retirements therefore inventory RPC logs directly. - authoritative: hasActiveRetirement, - }); - - for (const state of systemStates) { - if (!state.exists && (!sameHex(state.address, zeroAddress) || state.publicAccess)) { - throw new Error( - `World System tables are inconsistent for ${state.systemId}: a nonzero System tuple exists without a ResourceId.`, - ); - } - } - for (const system of systems) { - const state = statesById.get(normalizeHex(system.systemId)); - if (state?.exists && sameHex(state.address, zeroAddress)) { - throw new Error( - [ - `Configured System ${system.systemId} is permanently retired on this World.`, - "Its ResourceId is a tombstone and registerSystem cannot resurrect it.", - "Choose a new System resource ID and declare explicit selector migrations from the retired ID.", - ].join("\n"), - ); - } - } - - const plan = planFunctionMigrations({ - functions, - routes, - migrations: config.deploy.functionRouteMigrations, - removals: config.deploy.functionSelectorRemovals, - retirements: config.deploy.systemRetirements, - systemStates, - desiredSystems: systems, - }); - - return { routes, systemStates, resourceAccess, plan }; -} - -/** Classify the live core without RPC so replacement consent can be unit tested. */ -export function planRegistrationSystemBootstrap({ - worldAddress, - desiredSystem, - registration, - routes, - registrationSystemMigration, -}: { - readonly worldAddress: Address; - readonly desiredSystem: ReturnType["RegistrationSystem"]; - readonly registration: { readonly system: Address; readonly publicAccess: boolean }; - readonly routes: readonly FunctionRoute[]; - readonly registrationSystemMigration: World["deploy"]["registrationSystemMigration"]; -}): RegistrationBootstrapPlan { - if (sameHex(registration.system, zeroAddress)) { - throw new Error("The World has no active core RegistrationSystem, so selector migrations cannot be bootstrapped."); - } - - const routeBySelector = new Map(routes.map((route) => [normalizeHex(route.selector), route])); - const exactNativeSelectors = new Set(); - const missingNativeSelectors: (typeof nativeRegistrationFunctionSignatures)[number][] = []; - for (const signature of nativeRegistrationFunctionSignatures) { - const selector = toFunctionSelector(signature); - const route = routeBySelector.get(normalizeHex(selector)); - if (route == null) { - missingNativeSelectors.push(signature); - continue; - } - if (sameHex(route.systemId, registrationSystemId) && sameHex(route.systemFunctionSelector, selector)) { - exactNativeSelectors.add(signature); - continue; - } - - // TODO: Replace this manual-recovery boundary with a selector-independent, - // one-shot bootstrap module that can install the native lifecycle API. - throw new Error( - [ - `Native registration selector ${signature} (${selector}) is already routed unexpectedly.`, - `Expected: ${registrationSystemId}/${selector}`, - `Current: ${route.systemId}/${route.systemFunctionSelector}`, - ].join("\n"), - ); - } - - const isForkRegistrationSystem = getAddress(registration.system) === getAddress(desiredSystem.address); - const hasAnyNativeSelector = exactNativeSelectors.size > 0; - const hasCompleteNativeApi = exactNativeSelectors.size === nativeRegistrationFunctionSignatures.length; - - let upgrade: RegistrationBootstrapPlan["upgrade"]; - let selectorsToRegister: RegistrationBootstrapPlan["selectorsToRegister"]; - if (isForkRegistrationSystem) { - selectorsToRegister = missingNativeSelectors; - } else if (hasCompleteNativeApi) { - // Respect a custom/already-native RegistrationSystem. Its exact public routes - // are our capability marker; do not replace it merely due to address drift. - selectorsToRegister = []; - } else { - if (registrationSystemMigration == null) { - throw new Error( - [ - `World ${worldAddress} requires a legacy RegistrationSystem bootstrap, but replacing core Systems is opt-in.`, - `Live RegistrationSystem: ${registration.system}`, - ...(hasAnyNativeSelector - ? [ - "The live implementation exposes only part of the native lifecycle API:", - ...missingNativeSelectors.map((signature) => `- missing ${signature}`), - ] - : []), - "Set deploy.registrationSystemMigration.expectedSystem to this exact address after reviewing the core replacement.", - ].join("\n"), - ); - } - if (getAddress(registration.system) !== getAddress(registrationSystemMigration.expectedSystem)) { - throw new Error( - [ - "RegistrationSystem migration guard mismatch.", - `Configured expectedSystem: ${registrationSystemMigration.expectedSystem}`, - `Live RegistrationSystem: ${registration.system}`, - "Refusing to replace an unexpected core System.", - ].join("\n"), - ); - } - upgrade = { currentSystem: registration.system, publicAccess: registration.publicAccess }; - selectorsToRegister = missingNativeSelectors; - } - - return { - desiredSystem, - expectedSystem: upgrade == null ? registration.system : desiredSystem.address, - expectedPublicAccess: registration.publicAccess, - upgrade, - selectorsToRegister, - selectorsToVerify: nativeRegistrationFunctionSignatures, - }; -} - -/** - * Plan the legacy RegistrationSystem bootstrap after the deterministic deployer is known. - * Native methods are invoked through World.call, while their public World selectors are - * registered for future operators as part of the same atomic batch. - */ -export async function getRegistrationBootstrapPlan({ - client, - worldDeploy, - deployerAddress, - migrationPlan, - requiresSystemReconciliation, - routes, - registrationSystemMigration, -}: Pick & { - readonly deployerAddress: Hex; - readonly migrationPlan: FunctionMigrationPlan; - readonly requiresSystemReconciliation: boolean; - readonly routes: readonly FunctionRoute[]; - readonly registrationSystemMigration: World["deploy"]["registrationSystemMigration"]; -}): Promise { - if (!requiresRegistrationBootstrap(migrationPlan, requiresSystemReconciliation)) return undefined; - - const desiredSystem = getWorldContracts(deployerAddress).RegistrationSystem; - const [registration, batchCallSystem] = await Promise.all([ - getRecord({ - client, - worldDeploy, - table: worldConfig.namespaces.world.tables.Systems, - key: { systemId: registrationSystemId }, - }), - getRecord({ - client, - worldDeploy, - table: worldConfig.namespaces.world.tables.Systems, - key: { systemId: batchCallSystemId }, - }), - ]); - const bootstrapPlan = planRegistrationSystemBootstrap({ - worldAddress: worldDeploy.address, - desiredSystem, - registration, - routes, - registrationSystemMigration, - }); - const requiresBatchCall = - hasNativeWrites(migrationPlan) || bootstrapPlan.upgrade != null || bootstrapPlan.selectorsToRegister.length > 0; - if (requiresBatchCall && sameHex(batchCallSystem.system, zeroAddress)) { - throw new Error( - `The core BatchCallSystem ${batchCallSystemId} is inactive, so the selector lifecycle transaction cannot be submitted safely.`, - ); - } - return bootstrapPlan; -} - -/** Assert every authority needed by the planned atomic transaction before contract deployment. */ -export async function assertFunctionMigrationOwnership({ - client, - worldDeploy, - migrationPlan, - bootstrapPlan, - systemRenames, - targetRegistrations, - reconciliationSystemIds, - configuredResourceIds, -}: Pick & { - readonly migrationPlan: FunctionMigrationPlan; - readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; - readonly systemRenames: readonly PlannedSystemRename[]; - readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; - readonly reconciliationSystemIds: readonly Hex[]; - readonly configuredResourceIds: readonly Hex[]; -}): Promise { - const namespaces = new Map(); - const addNamespace = (namespaceId: Hex, allowMissing: boolean): void => { - const key = normalizeHex(namespaceId); - const existing = namespaces.get(key); - namespaces.set(key, { namespaceId, allowMissing: (existing?.allowMissing ?? true) && allowMissing }); - }; - const needsRoot = - migrationPlan.migrationsToApply.length > 0 || - migrationPlan.removalsToApply.length > 0 || - bootstrapPlan?.upgrade != null || - (bootstrapPlan?.selectorsToRegister.length ?? 0) > 0; - - if (needsRoot) { - const rootNamespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); - addNamespace(rootNamespaceId, false); - } - for (const retirement of migrationPlan.retirementsToApply) { - const namespace = hexToResource(retirement.systemId).namespace; - const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); - addNamespace(namespaceId, false); - } - for (const rename of systemRenames) { - const namespace = hexToResource(rename.targetSystemId).namespace; - const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); - addNamespace(namespaceId, true); - } - for (const target of targetRegistrations) { - const namespace = hexToResource(target.systemId).namespace; - const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); - addNamespace(namespaceId, true); - } - for (const systemId of reconciliationSystemIds) { - const namespace = hexToResource(systemId).namespace; - const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); - addNamespace(namespaceId, true); - } - for (const namespaceId of getConfiguredNamespaceIds(configuredResourceIds)) { - addNamespace(namespaceId, true); - } - - const unauthorized = ( - await Promise.all( - [...namespaces.values()].map(async ({ namespaceId, allowMissing }) => { - const [resource, namespace] = await Promise.all([ - getRecord({ - client, - worldDeploy, - table: storeConfig.namespaces.store.tables.ResourceIds, - key: { resourceId: namespaceId }, - }), - getRecord({ - client, - worldDeploy, - table: worldConfig.namespaces.world.tables.NamespaceOwner, - key: { namespaceId }, - }), - ]); - if (!resource.exists && allowMissing) return undefined; - if (!resource.exists) return { namespaceId, owner: namespace.owner, missing: true }; - return getAddress(namespace.owner) === getAddress(client.account.address) - ? undefined - : { namespaceId, owner: namespace.owner, missing: false }; - }), - ) - ).filter((entry): entry is { namespaceId: Hex; owner: Address; missing: boolean } => entry != null); - - if (unauthorized.length > 0) { - throw new Error( - [ - "The deployment signer does not own every namespace required by the selector migration:", - ...unauthorized.map(({ namespaceId, owner, missing }) => - missing ? `- ${namespaceId} does not exist` : `- ${namespaceId} is owned by ${owner}`, - ), - ].join("\n"), - ); - } -} - -/** Resolve every configured table/System resource to the namespace that must be owned before bootstrap writes. */ -export function getConfiguredNamespaceIds(resourceIds: readonly Hex[]): readonly Hex[] { - return [ - ...new Map( - resourceIds.map((resourceId) => { - const namespace = hexToResource(resourceId).namespace; - const namespaceId = resourceToHex({ type: "namespace", namespace, name: "" }); - return [normalizeHex(namespaceId), namespaceId] as const; - }), - ).values(), - ]; -} - -export async function ensureFunctionMigrationContract({ - client, - deployerAddress, - bootstrapPlan, -}: Pick & { - readonly deployerAddress: Hex; - readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; -}): Promise { - if (bootstrapPlan?.upgrade == null) return; - await ensureContractsDeployed({ - client, - deployerAddress, - contracts: [bootstrapPlan.desiredSystem], - }); -} - -/** Purely encode the ordered direct System calls executed inside World.batchCall. */ -export function encodeFunctionMigrationCalls({ - migrationPlan, - bootstrapPlan, - systemRenames, - targetRegistrations, -}: { - readonly migrationPlan: FunctionMigrationPlan; - readonly bootstrapPlan: RegistrationBootstrapPlan; - readonly systemRenames: readonly PlannedSystemRename[]; - readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; -}): readonly DirectSystemCall[] { - const calls: DirectSystemCall[] = []; - - if (bootstrapPlan.upgrade != null) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "registerSystem", - args: [registrationSystemId, bootstrapPlan.desiredSystem.address, bootstrapPlan.upgrade.publicAccess], - }), - }); - } - - for (const signature of bootstrapPlan.selectorsToRegister) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "registerRootFunctionSelector", - args: [registrationSystemId, signature, signature], - }), - }); - } - - // A same-implementation rename must release the SystemRegistry entry before - // registering that address at the unused destination ID. Selector rows can - // safely continue to reference the source tombstone until the later calls. - for (const rename of systemRenames) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "retireSystem", - args: [rename.systemId, rename.expectedSystem, rename.expectedPublicAccess], - }), - }); - } - for (const target of targetRegistrations) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "replaceSystem", - args: [target.systemId, target.expectedSystem, target.expectedPublicAccess, target.system, target.publicAccess], - }), - }); - } - for (const rename of systemRenames) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "replaceSystem", - args: [rename.targetSystemId, zeroAddress, false, rename.targetSystem, rename.targetPublicAccess], - }), - }); - } - - for (const migration of migrationPlan.migrationsToApply) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "replaceFunctionRoute", - args: [ - migration.worldSelector, - migration.fromSystemId, - migration.fromSystemFunctionSelector, - migration.toSystemId, - migration.toSystemFunctionSignature, - ], - }), - }); - } - - for (const removal of migrationPlan.removalsToApply) { - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "unregisterFunctionSelector", - args: [removal.worldSelector, removal.expectedSystemId, removal.expectedSystemFunctionSelector], - }), - }); - } - - const renamedSourceIds = new Set(systemRenames.map((rename) => normalizeHex(rename.systemId))); - for (const retirement of migrationPlan.retirementsToApply) { - if (renamedSourceIds.has(normalizeHex(retirement.systemId))) continue; - calls.push({ - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "retireSystem", - args: [retirement.systemId, retirement.expectedSystem, retirement.expectedPublicAccess], - }), - }); - } - - return calls; -} - -/** Bound the legacy registerSystem TOCTOU window immediately before submission. */ -export function assertLegacyRegistrationBootstrapCurrent({ - bootstrapPlan, - registration, -}: { - readonly bootstrapPlan: RegistrationBootstrapPlan; - readonly registration: { readonly system: Address; readonly publicAccess: boolean }; -}): void { - if (bootstrapPlan.upgrade == null) return; - if ( - !sameHex(registration.system, bootstrapPlan.upgrade.currentSystem) || - registration.publicAccess !== bootstrapPlan.upgrade.publicAccess - ) { - throw new Error( - [ - "RegistrationSystem changed after selector migration preflight.", - `Expected: ${bootstrapPlan.upgrade.currentSystem} (publicAccess=${String(bootstrapPlan.upgrade.publicAccess)})`, - `Current: ${registration.system} (publicAccess=${String(registration.publicAccess)})`, - "Refusing to submit the legacy bootstrap batch; re-run deployment against the new state.", - ].join("\n"), - ); - } -} - -/** Encode the kernel-directed batch call without relying on the mutable public alias. */ -export function encodeLifecycleBatchSystemCall(calls: readonly DirectSystemCall[]): DirectSystemCall { - return { - systemId: batchCallSystemId, - callData: encodeFunctionData({ - abi: worldAbi, - functionName: "batchCall", - args: [calls], - }), - }; -} - -export async function ensureFunctionMigrations({ - client, - worldDeploy, - migrationPlan, - bootstrapPlan, - systemRenames, - targetRegistrations, -}: Pick & { - readonly migrationPlan: FunctionMigrationPlan; - readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; - readonly systemRenames: readonly PlannedSystemRename[]; - readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; -}): Promise { - if (bootstrapPlan == null) return []; - - const calls = encodeFunctionMigrationCalls({ - migrationPlan, - bootstrapPlan, - systemRenames, - targetRegistrations, - }); - if (calls.length === 0) return []; - - debug( - "applying selector lifecycle batch:", - `${migrationPlan.migrationsToApply.length} route replacements,`, - `${migrationPlan.removalsToApply.length} removals,`, - `${migrationPlan.retirementsToApply.length} system retirements`, - ); - - if (bootstrapPlan.upgrade != null) { - const latestBlockNumber = await getBlockNumber(client); - const registration = await getRecord({ - client, - worldDeploy: { ...worldDeploy, stateBlock: latestBlockNumber }, - table: worldConfig.namespaces.world.tables.Systems, - key: { systemId: registrationSystemId }, - }); - assertLegacyRegistrationBootstrapCurrent({ bootstrapPlan, registration }); - } - - const batchCall = encodeLifecycleBatchSystemCall(calls); - return [ - await writeContract(client, { - chain: client.chain ?? null, - address: worldDeploy.address, - abi: worldAbi, - functionName: "call", - args: [batchCall.systemId, batchCall.callData], - }), - ]; -} - -export async function verifyFunctionMigrations({ - client, - worldDeploy, - migrationPlan, - systemRenames, - targetRegistrations, - bootstrapPlan, -}: Pick & { - readonly migrationPlan: FunctionMigrationPlan; - readonly systemRenames: readonly PlannedSystemRename[]; - readonly targetRegistrations: readonly PlannedMigrationTargetRegistration[]; - readonly bootstrapPlan: RegistrationBootstrapPlan | undefined; -}): Promise { - const selectors = [ - ...migrationPlan.migrationsToApply.map((migration) => migration.worldSelector), - ...migrationPlan.migrationsAlreadyApplied.map((migration) => migration.worldSelector), - ...migrationPlan.removalsToApply.map((removal) => removal.worldSelector), - ...migrationPlan.removalsAlreadyApplied.map((removal) => removal.worldSelector), - ]; - const routes = await getFunctionRoutes({ client, worldDeploy, selectors }); - const routesBySelector = new Map(routes.map((route) => [normalizeHex(route.selector), route])); - - for (const migration of [...migrationPlan.migrationsToApply, ...migrationPlan.migrationsAlreadyApplied]) { - const route = routesBySelector.get(normalizeHex(migration.worldSelector)); - if ( - route == null || - !sameHex(route.systemId, migration.toSystemId) || - !sameHex(route.systemFunctionSelector, migration.toSystemFunctionSelector) - ) { - throw new Error(`Selector migration verification failed for ${migration.worldSelector}.`); - } - } - for (const removal of [...migrationPlan.removalsToApply, ...migrationPlan.removalsAlreadyApplied]) { - if (routesBySelector.has(normalizeHex(removal.worldSelector))) { - throw new Error(`Selector removal verification failed for ${removal.worldSelector}.`); - } - } - - const retired = await getSystemStates({ - client, - worldDeploy, - systemIds: [ - ...migrationPlan.retirementsToApply.map((retirement) => retirement.systemId), - ...migrationPlan.retirementsAlreadyApplied.map((retirement) => retirement.systemId), - ...migrationPlan.retirementsNotFound.map((retirement) => retirement.systemId), - ], - }); - for (const state of retired) { - if (state.exists && !sameHex(state.address, zeroAddress)) { - throw new Error(`System retirement verification failed for ${state.systemId}.`); - } - } - - const renameTargets = await getSystemStates({ - client, - worldDeploy, - systemIds: systemRenames.map((rename) => rename.targetSystemId), - }); - for (const rename of systemRenames) { - const target = renameTargets.find((state) => sameHex(state.systemId, rename.targetSystemId)); - if ( - target == null || - !target.exists || - !sameHex(target.address, rename.targetSystem) || - target.publicAccess !== rename.targetPublicAccess - ) { - throw new Error(`System rename verification failed for target ${rename.targetSystemId}.`); - } - } - - const registeredTargets = await getSystemStates({ - client, - worldDeploy, - systemIds: targetRegistrations.map((target) => target.systemId), - }); - for (const expected of targetRegistrations) { - const actual = registeredTargets.find((state) => sameHex(state.systemId, expected.systemId)); - if ( - actual == null || - !actual.exists || - !sameHex(actual.address, expected.system) || - actual.publicAccess !== expected.publicAccess - ) { - throw new Error(`Migration target System verification failed for ${expected.systemId}.`); - } - } - - if (bootstrapPlan != null) { - const [registration] = await getSystemStates({ - client, - worldDeploy, - systemIds: [registrationSystemId], - }); - if ( - registration == null || - !registration.exists || - !sameHex(registration.address, bootstrapPlan.expectedSystem) || - registration.publicAccess !== bootstrapPlan.expectedPublicAccess - ) { - throw new Error("RegistrationSystem bootstrap verification failed."); - } - - const nativeRoutes = await getFunctionRoutes({ - client, - worldDeploy, - selectors: bootstrapPlan.selectorsToVerify.map((signature) => toFunctionSelector(signature)), - }); - const nativeRoutesBySelector = new Map(nativeRoutes.map((route) => [normalizeHex(route.selector), route])); - for (const signature of bootstrapPlan.selectorsToVerify) { - const selector = toFunctionSelector(signature); - const route = nativeRoutesBySelector.get(normalizeHex(selector)); - if ( - route == null || - !sameHex(route.systemId, registrationSystemId) || - !sameHex(route.systemFunctionSelector, selector) - ) { - throw new Error(`Native RegistrationSystem selector verification failed for ${signature}.`); - } - } - } -} - -export function getSystemMigrationPlan( - migrationPlan: FunctionMigrationPlan, - systemStates: readonly SystemState[], - systems: readonly System[], - deployerAddress: Hex, - libraryMap: LibraryMap, - resourceAccess: readonly SystemAccess[], -): SystemMigrationPlan { - const desiredSystems: DesiredSystem[] = systems.map((system) => ({ - systemId: system.systemId, - address: system.prepareDeploy(deployerAddress, libraryMap).address, - publicAccess: system.allowAll, - })); - const renames = planSystemRenames(migrationPlan, desiredSystems, systemStates); - const renameTargetIds = new Set(renames.map((rename) => normalizeHex(rename.targetSystemId))); - const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); - const desiredById = new Map(desiredSystems.map((system) => [normalizeHex(system.systemId), system])); - const targetRegistrations: PlannedMigrationTargetRegistration[] = []; - const reconciliationSystemIds = desiredSystems.flatMap((desired) => { - const state = statesById.get(normalizeHex(desired.systemId)); - const hasDefaultNamespaceAccess = hasSystemNamespaceGrant({ - systemId: desired.systemId, - systemAddress: desired.address, - worldAccess: resourceAccess, - }); - const needsReconciliation = - state == null || - !state.exists || - !sameHex(state.address, desired.address) || - state.publicAccess !== desired.publicAccess || - !hasDefaultNamespaceAccess; - return needsReconciliation ? [desired.systemId] : []; - }); - const requiresSystemReconciliation = reconciliationSystemIds.length > 0; - - const pendingTargetIds = new Map( - migrationPlan.migrationsToApply.map((migration) => [normalizeHex(migration.toSystemId), migration.toSystemId]), - ); - for (const targetSystemId of pendingTargetIds.values()) { - if (renameTargetIds.has(normalizeHex(targetSystemId))) continue; - const desired = desiredById.get(normalizeHex(targetSystemId)); - const state = statesById.get(normalizeHex(targetSystemId)); - if (desired == null || state == null) { - throw new Error(`Missing configured deployment or preflight state for migration target ${targetSystemId}.`); - } - if (state.exists && sameHex(state.address, zeroAddress)) { - throw new Error(`Migration target ${targetSystemId} is a permanent retirement tombstone.`); - } - const hasDefaultNamespaceAccess = hasSystemNamespaceGrant({ - systemId: targetSystemId, - systemAddress: desired.address, - worldAccess: resourceAccess, - }); - if ( - !sameHex(state.address, desired.address) || - state.publicAccess !== desired.publicAccess || - !hasDefaultNamespaceAccess - ) { - const occupyingSource = systemStates.find( - (candidate) => - candidate.exists && - !sameHex(candidate.systemId, targetSystemId) && - sameHex(candidate.address, desired.address), - ); - if (occupyingSource != null) { - throw new Error( - [ - `Migration target implementation ${desired.address} is still registered at ${occupyingSource.systemId}.`, - "Declare that source System in systemRetirements so it can be atomically renamed.", - ].join("\n"), - ); - } - targetRegistrations.push({ - systemId: desired.systemId, - expectedSystem: state.address, - expectedPublicAccess: state.publicAccess, - system: desired.address, - publicAccess: desired.publicAccess, - }); - } - } - - return { renames, targetRegistrations, requiresSystemReconciliation, reconciliationSystemIds }; -} diff --git a/packages/cli/src/deploy/ensureFunctions.test.ts b/packages/cli/src/deploy/ensureFunctions.test.ts index 9e17bf0019..ea503fc19d 100644 --- a/packages/cli/src/deploy/ensureFunctions.test.ts +++ b/packages/cli/src/deploy/ensureFunctions.test.ts @@ -1,9 +1,16 @@ -import { decodeFunctionData, toFunctionSelector } from "viem"; +import { padHex, toFunctionSelector, zeroAddress } from "viem"; import { describe, expect, it } from "vitest"; import { resourceToHex } from "@latticexyz/common"; +import worldConfig from "@latticexyz/world/mud.config"; import type { WorldFunction } from "./common"; -import { encodeFunctionRegistrationCall } from "./ensureFunctions"; -import { nativeRegistrationSystemAbi, registrationSystemId } from "./ensureFunctionMigrations"; +import { + assertFunctionSelectorsWriteAccess, + assertTargetSystemActive, + getFunctionReconciliationAction, + getFunctionSystemIdWrite, +} from "./ensureFunctions"; + +const sourceSystemId = `0x${"11".repeat(32)}` as const; function worldFunction(namespace: string): WorldFunction { const systemFunctionSignature = "run()"; @@ -17,26 +24,85 @@ function worldFunction(namespace: string): WorldFunction { }; } -describe("encodeFunctionRegistrationCall", () => { - it("encodes namespaced function registration for direct RegistrationSystem dispatch", () => { - const func = worldFunction("app"); - const call = encodeFunctionRegistrationCall(func); +describe("FunctionSelectors System ID reconciliation", () => { + it("encodes a write to field zero and the exact world-selector key", () => { + const func = worldFunction("valhalla"); - expect(call.systemId).toBe(registrationSystemId); - expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ - functionName: "registerFunctionSelector", - args: [func.systemId, func.systemFunctionSignature], + expect(getFunctionSystemIdWrite(func)).toMatchObject({ + keyTuple: [padHex(func.selector, { dir: "right", size: 32 })], + fieldIndex: 0, + data: func.systemId, }); }); - it("encodes root function registration for direct RegistrationSystem dispatch", () => { - const func = worldFunction(""); - const call = encodeFunctionRegistrationCall(func); + it("accepts either namespace-level or table-level Store access", () => { + const caller = "0x1111111111111111111111111111111111111111"; + const tableId = worldConfig.namespaces.world.tables.FunctionSelectors.tableId; - expect(call.systemId).toBe(registrationSystemId); - expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ - functionName: "registerRootFunctionSelector", - args: [func.systemId, func.systemFunctionSignature, func.systemFunctionSignature], - }); + expect(() => + assertFunctionSelectorsWriteAccess({ caller, namespaceAccess: true, tableAccess: false }), + ).not.toThrow(); + expect(() => + assertFunctionSelectorsWriteAccess({ caller, namespaceAccess: false, tableAccess: true }), + ).not.toThrow(); + expect(() => + assertFunctionSelectorsWriteAccess({ caller, namespaceAccess: false, tableAccess: false }), + ).toThrowError( + [ + `Deployer ${caller} cannot reconcile World function routes because it lacks Store write access to the FunctionSelectors table (${tableId}).`, + `The owner of the \`world\` namespace must grant ${caller} access to that table once.`, + ].join("\n"), + ); + }); + + it("requires an active destination System", () => { + const func = worldFunction("valhalla"); + + expect(() => assertTargetSystemActive(func.systemId, "0x2222222222222222222222222222222222222222")).not.toThrow(); + expect(() => assertTargetSystemActive(func.systemId, zeroAddress)).toThrowError("inactive target System"); + }); + + it("writes only while the latest route is still the planned exact source", () => { + const func = worldFunction("valhalla"); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + const reconciliation = { func, expectedRoute }; + + expect(getFunctionReconciliationAction({ reconciliation, currentRoute: expectedRoute })).toBe("write"); + expect( + getFunctionReconciliationAction({ + reconciliation, + currentRoute: { ...expectedRoute, systemId: func.systemId }, + }), + ).toBe("skip"); + }); + + it("fails closed on an absent, selector-changed, or System-ID-drifted latest route", () => { + const func = worldFunction("valhalla"); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + const reconciliation = { func, expectedRoute }; + + expect(() => getFunctionReconciliationAction({ reconciliation, currentRoute: undefined })).toThrowError( + "unknown current route", + ); + expect(() => + getFunctionReconciliationAction({ + reconciliation, + currentRoute: { ...expectedRoute, systemFunctionSelector: "0xaaaaaaaa" }, + }), + ).toThrowError("changed System function selector"); + expect(() => + getFunctionReconciliationAction({ + reconciliation, + currentRoute: { ...expectedRoute, systemId: `0x${"33".repeat(32)}` }, + }), + ).toThrowError("changed after deployment planning"); }); }); diff --git a/packages/cli/src/deploy/ensureFunctions.ts b/packages/cli/src/deploy/ensureFunctions.ts index 3da3d0df42..e90aa243da 100644 --- a/packages/cli/src/deploy/ensureFunctions.ts +++ b/packages/cli/src/deploy/ensureFunctions.ts @@ -1,35 +1,226 @@ -import { encodeFunctionData, type Hex } from "viem"; -import { hexToResource, writeContract } from "@latticexyz/common"; +import { AbortError } from "p-retry"; +import { getAddress, type Address, type Hex, zeroAddress } from "viem"; +import { getBlockNumber } from "viem/actions"; +import { getKeyTuple } from "@latticexyz/protocol-parser/internal"; +import { hexToResource, resourceToHex, writeContract } from "@latticexyz/common"; +import { waitForTransactions } from "@latticexyz/common/internal"; +import { isDefined } from "@latticexyz/common/utils"; +import worldConfig from "@latticexyz/world/mud.config"; import { CommonDeployOptions, WorldFunction, worldAbi } from "./common"; import { debug } from "./debug"; import pRetry from "p-retry"; import { assertFunctionPlanApplied, FunctionRegistrationPlan, planFunctionRegistrations } from "./functionPlan"; import { getFunctionRoutes } from "./getFunctionRoutes"; -import { nativeRegistrationSystemAbi, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; - -export function encodeFunctionRegistrationCall(func: WorldFunction): DirectSystemCall { - const { namespace } = hexToResource(func.systemId); - const callData = - namespace === "" - ? encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "registerRootFunctionSelector", - args: [ - func.systemId, - // use system function signature as world signature - func.systemFunctionSignature, - func.systemFunctionSignature, - ], - }) - : encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "registerFunctionSelector", - args: [func.systemId, func.systemFunctionSignature], - }); - return { systemId: registrationSystemId, callData }; +import { getRecord } from "./getRecord"; + +type FunctionReconciliation = FunctionRegistrationPlan["toReconcile"][number]; + +function sameHex(a: Hex, b: Hex): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +export function getFunctionSystemIdWrite(func: WorldFunction) { + const table = worldConfig.namespaces.world.tables.FunctionSelectors; + return { + tableId: table.tableId, + keyTuple: getKeyTuple(table, { worldFunctionSelector: func.selector }), + fieldIndex: 0, + data: func.systemId, + } as const; +} + +export function assertFunctionSelectorsWriteAccess({ + caller, + namespaceAccess, + tableAccess, +}: { + readonly caller: Address; + readonly namespaceAccess: boolean; + readonly tableAccess: boolean; +}): void { + if (namespaceAccess || tableAccess) return; + + const tableId = worldConfig.namespaces.world.tables.FunctionSelectors.tableId; + throw new Error( + [ + `Deployer ${caller} cannot reconcile World function routes because it lacks Store write access to the FunctionSelectors table (${tableId}).`, + `The owner of the \`world\` namespace must grant ${caller} access to that table once.`, + ].join("\n"), + ); +} + +export function assertTargetSystemActive(systemId: Hex, system: Address): void { + if (getAddress(system) !== zeroAddress) return; + throw new Error(`Cannot reconcile a World function route to inactive target System ${systemId}.`); +} + +export function getFunctionReconciliationAction({ + reconciliation, + currentRoute, +}: { + readonly reconciliation: FunctionReconciliation; + readonly currentRoute: Parameters[1][number] | undefined; +}): "skip" | "write" { + const { func, expectedRoute } = reconciliation; + if (currentRoute == null || !sameHex(currentRoute.selector, func.selector)) { + throw new Error( + `World function ${func.signature} (${func.selector}) has an unknown current route; refusing to write.`, + ); + } + + if ( + sameHex(currentRoute.systemId, func.systemId) && + sameHex(currentRoute.systemFunctionSelector, func.systemFunctionSelector) + ) { + return "skip"; + } + + if (!sameHex(currentRoute.systemFunctionSelector, func.systemFunctionSelector)) { + throw new Error( + [ + `World function ${func.signature} (${func.selector}) changed System function selector before reconciliation.`, + `Expected: ${func.systemFunctionSelector}`, + `Current: ${currentRoute.systemFunctionSelector}`, + "Refusing to update its System ID.", + ].join("\n"), + ); + } + + if ( + !sameHex(currentRoute.systemId, expectedRoute.systemId) || + !sameHex(currentRoute.systemFunctionSelector, expectedRoute.systemFunctionSelector) + ) { + throw new Error( + [ + `World function ${func.signature} (${func.selector}) changed after deployment planning.`, + `Expected source System ID: ${expectedRoute.systemId}`, + `Current System ID: ${currentRoute.systemId}`, + "Refusing to overwrite the newer route.", + ].join("\n"), + ); + } + + return "write"; +} + +async function getLatestWorldDeploy({ + client, + worldDeploy, +}: Pick): Promise { + return { ...worldDeploy, stateBlock: await getBlockNumber(client) }; +} + +async function assertReconciliationTargetsActive({ + client, + worldDeploy, + reconciliations, +}: Pick & { + readonly reconciliations: readonly FunctionReconciliation[]; +}): Promise { + const systemsTable = worldConfig.namespaces.world.tables.Systems; + const uniqueTargets = [ + ...new Map(reconciliations.map(({ func }) => [func.systemId.toLowerCase(), func.systemId])).values(), + ]; + await Promise.all( + uniqueTargets.map(async (systemId) => { + const record = await getRecord({ client, worldDeploy, table: systemsTable, key: { systemId } }); + assertTargetSystemActive(systemId, record.system); + }), + ); +} + +async function assertFunctionSelectorsAccess({ + client, + worldDeploy, +}: Pick): Promise { + const functionSelectorsTable = worldConfig.namespaces.world.tables.FunctionSelectors; + const resourceAccessTable = worldConfig.namespaces.world.tables.ResourceAccess; + const namespaceId = resourceToHex({ + type: "namespace", + namespace: hexToResource(functionSelectorsTable.tableId).namespace, + name: "", + }); + const caller = client.account.address; + const [namespaceAccess, tableAccess] = await Promise.all([ + getRecord({ + client, + worldDeploy, + table: resourceAccessTable, + key: { resourceId: namespaceId, caller }, + }), + getRecord({ + client, + worldDeploy, + table: resourceAccessTable, + key: { resourceId: functionSelectorsTable.tableId, caller }, + }), + ]); + assertFunctionSelectorsWriteAccess({ + caller, + namespaceAccess: namespaceAccess.access, + tableAccess: tableAccess.access, + }); +} + +async function assertReconciliationPreconditions({ + client, + worldDeploy, + reconciliations, +}: Pick & { + readonly reconciliations: readonly FunctionReconciliation[]; +}): Promise { + const latestWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + await Promise.all([ + assertReconciliationTargetsActive({ client, worldDeploy: latestWorldDeploy, reconciliations }), + assertFunctionSelectorsAccess({ client, worldDeploy: latestWorldDeploy }), + ]); + return latestWorldDeploy; +} + +async function reconcileFunctionSystemId({ + client, + worldDeploy, + reconciliation, +}: Pick & { + readonly reconciliation: FunctionReconciliation; +}): Promise { + // Legacy Worlds expose no compare-and-swap Store write. Re-read immediately + // before each attempt and verify after mining; deploys must still be serialized. + return pRetry( + async () => { + const latestWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + const [currentRoute] = await getFunctionRoutes({ + client, + worldDeploy: latestWorldDeploy, + selectors: [reconciliation.func.selector], + }); + + let action: "skip" | "write"; + try { + action = getFunctionReconciliationAction({ reconciliation, currentRoute }); + } catch (error) { + throw new AbortError(error instanceof Error ? error : new Error(String(error))); + } + if (action === "skip") return undefined; + + const write = getFunctionSystemIdWrite(reconciliation.func); + return writeContract(client, { + chain: client.chain ?? null, + address: worldDeploy.address, + abi: worldAbi, + functionName: "setField", + args: [write.tableId, write.keyTuple, write.fieldIndex, write.data], + }); + }, + { + retries: 3, + onFailedAttempt: () => + debug(`failed to reconcile function ${reconciliation.func.signature}, re-reading route before retry`), + }, + ); } -export async function getFunctionPlan({ +async function getFunctionPlan({ client, worldDeploy, functions, @@ -44,50 +235,85 @@ export async function getFunctionPlan({ return planFunctionRegistrations(functions, registeredRoutes); } -export async function verifyFunctions({ - client, - worldDeploy, - functions, -}: Pick & { - readonly functions: readonly WorldFunction[]; -}): Promise { - const plan = await getFunctionPlan({ client, worldDeploy, functions }); - assertFunctionPlanApplied(plan); -} - export async function ensureFunctions({ client, worldDeploy, - plan, + functions, }: CommonDeployOptions & { - readonly plan: FunctionRegistrationPlan; + readonly functions: readonly WorldFunction[]; }): Promise { + const planningWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + const plan = await getFunctionPlan({ client, worldDeploy: planningWorldDeploy, functions }); + if (plan.toSkip.length) { debug("functions already registered:", plan.toSkip.map((func) => func.signature).join(", ")); } - if (!plan.toAdd.length) return []; - - debug("registering functions:", plan.toAdd.map((func) => func.signature).join(", ")); - - return Promise.all( - plan.toAdd.map((func) => { - const call = encodeFunctionRegistrationCall(func); - - return pRetry( - () => - writeContract(client, { - chain: client.chain ?? null, - address: worldDeploy.address, - abi: worldAbi, - functionName: "call", - args: [call.systemId, call.callData], - }), - { - retries: 3, - onFailedAttempt: () => debug(`failed to register function ${func.signature}, retrying...`), - }, - ); - }), - ); + if (!plan.toAdd.length && !plan.toReconcile.length) return []; + + if (plan.toReconcile.length) { + debug("reconciling function System IDs:", plan.toReconcile.map(({ func }) => func.signature).join(", ")); + await assertReconciliationPreconditions({ client, worldDeploy, reconciliations: plan.toReconcile }); + } + + if (plan.toAdd.length) { + debug("registering functions:", plan.toAdd.map((func) => func.signature).join(", ")); + } + + const hashes = ( + await Promise.all([ + ...plan.toAdd.map((func) => { + const { namespace } = hexToResource(func.systemId); + + // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) + const params = + namespace === "" + ? ({ + functionName: "registerRootFunctionSelector", + args: [ + func.systemId, + // use system function signature as world signature + func.systemFunctionSignature, + func.systemFunctionSignature, + ], + } as const) + : ({ + functionName: "registerFunctionSelector", + args: [func.systemId, func.systemFunctionSignature], + } as const); + + return pRetry( + () => + writeContract(client, { + chain: client.chain ?? null, + address: worldDeploy.address, + abi: worldAbi, + ...params, + }), + { + retries: 3, + onFailedAttempt: () => debug(`failed to register function ${func.signature}, retrying...`), + }, + ); + }), + ...plan.toReconcile.map((reconciliation) => reconcileFunctionSystemId({ client, worldDeploy, reconciliation })), + ]) + ).filter(isDefined); + + if (hashes.length > 0) { + await waitForTransactions({ client, hashes, debugLabel: "function registrations and System ID reconciliation" }); + } + + const latestWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + const finalPlan = await getFunctionPlan({ client, worldDeploy: latestWorldDeploy, functions }); + assertFunctionPlanApplied(finalPlan); + if (plan.toReconcile.length > 0) { + await assertReconciliationTargetsActive({ + client, + worldDeploy: latestWorldDeploy, + reconciliations: plan.toReconcile, + }); + } + + return hashes; } diff --git a/packages/cli/src/deploy/ensureModules.test.ts b/packages/cli/src/deploy/ensureModules.test.ts deleted file mode 100644 index 553862851a..0000000000 --- a/packages/cli/src/deploy/ensureModules.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { decodeFunctionData, type Address, type Hex } from "viem"; -import { describe, expect, it } from "vitest"; -import { worldAbi } from "./common"; -import { encodeModuleInstallationCall, registrationSystemAbi } from "./ensureModules"; -import { batchCallSystemId, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; - -const moduleAddress = `0x${"11".repeat(20)}` as Address; -const installData = "0x1234" as Hex; - -describe("encodeModuleInstallationCall", () => { - it("dispatches ordinary installs directly to RegistrationSystem", () => { - const call = encodeModuleInstallationCall({ moduleAddress, installData, installStrategy: "default" }); - - expect(call.functionName).toBe("call"); - expect(call.args[0]).toBe(registrationSystemId); - expect(decodeFunctionData({ abi: registrationSystemAbi, data: call.args[1] })).toMatchObject({ - functionName: "installModule", - args: [moduleAddress, installData], - }); - }); - - it("dispatches delegated install batches directly to BatchCallSystem", () => { - const call = encodeModuleInstallationCall({ moduleAddress, installData, installStrategy: "delegation" }); - - expect(call.functionName).toBe("call"); - expect(call.args[0]).toBe(batchCallSystemId); - const batch = decodeFunctionData({ abi: worldAbi, data: call.args[1] }); - expect(batch.functionName).toBe("batchCall"); - if (batch.functionName !== "batchCall") throw new Error("Expected batchCall encoding"); - const [systemCalls] = batch.args as readonly [readonly DirectSystemCall[]]; - expect(systemCalls.map(({ systemId }) => systemId)).toEqual([ - registrationSystemId, - registrationSystemId, - registrationSystemId, - ]); - expect( - systemCalls.map( - ({ callData }) => decodeFunctionData({ abi: registrationSystemAbi, data: callData }).functionName, - ), - ).toEqual(["registerDelegation", "installModule", "unregisterDelegation"]); - }); - - it("uses the immutable World kernel for root module installs", () => { - expect(encodeModuleInstallationCall({ moduleAddress, installData, installStrategy: "root" })).toEqual({ - functionName: "installRootModule", - args: [moduleAddress, installData], - }); - }); -}); diff --git a/packages/cli/src/deploy/ensureModules.ts b/packages/cli/src/deploy/ensureModules.ts index 69f770ad92..1dd10f4231 100644 --- a/packages/cli/src/deploy/ensureModules.ts +++ b/packages/cli/src/deploy/ensureModules.ts @@ -1,4 +1,4 @@ -import { Client, Transport, Chain, Account, Hex, BaseError, Address, encodeFunctionData } from "viem"; +import { Client, Transport, Chain, Account, Hex, BaseError } from "viem"; import { resourceToHex, writeContract } from "@latticexyz/common"; import { Module, WorldDeploy, worldAbi } from "./common"; import { debug } from "./debug"; @@ -7,70 +7,7 @@ import pRetry from "p-retry"; import { LibraryMap } from "./getLibraryMap"; import { ensureContractsDeployed } from "@latticexyz/common/internal"; import { encodeSystemCalls } from "@latticexyz/world/internal"; -import { batchCallSystemId, registrationSystemId } from "./ensureFunctionMigrations"; - -export function encodeModuleInstallationCall({ - moduleAddress, - installData, - installStrategy, -}: { - readonly moduleAddress: Address; - readonly installData: Hex; - readonly installStrategy: Module["installStrategy"]; -}): - | { readonly functionName: "installRootModule"; readonly args: readonly [Address, Hex] } - | { readonly functionName: "call"; readonly args: readonly [Hex, Hex] } { - if (installStrategy === "root") { - // installRootModule is implemented by the immutable World kernel itself. - return { functionName: "installRootModule", args: [moduleAddress, installData] }; - } - - if (installStrategy === "delegation") { - const [calls] = encodeSystemCalls([ - { - abi: registrationSystemAbi, - systemId: registrationSystemId, - functionName: "registerDelegation", - args: [moduleAddress, unlimitedDelegationControlId, "0x"], - }, - { - abi: registrationSystemAbi, - systemId: registrationSystemId, - functionName: "installModule", - args: [moduleAddress, installData], - }, - { - abi: registrationSystemAbi, - systemId: registrationSystemId, - functionName: "unregisterDelegation", - args: [moduleAddress], - }, - ]); - return { - functionName: "call", - args: [ - batchCallSystemId, - encodeFunctionData({ - abi: worldAbi, - functionName: "batchCall", - args: [calls], - }), - ], - }; - } - - return { - functionName: "call", - args: [ - registrationSystemId, - encodeFunctionData({ - abi: registrationSystemAbi, - functionName: "installModule", - args: [moduleAddress, installData], - }), - ], - }; -} +import { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config"; export async function ensureModules({ client, @@ -105,11 +42,48 @@ export async function ensureModules({ async () => { try { const moduleAddress = mod.prepareDeploy(deployerAddress, libraryMap).address; - const params = encodeModuleInstallationCall({ - moduleAddress, - installData: mod.installData, - installStrategy: mod.installStrategy, - }); + + // TODO: fix strong types for world ABI etc + // TODO: add return types to get better type safety + const params = (() => { + if (mod.installStrategy === "root") { + return { + functionName: "installRootModule", + args: [moduleAddress, mod.installData], + } as const; + } + + if (mod.installStrategy === "delegation") { + return { + functionName: "batchCall", + args: encodeSystemCalls([ + { + abi: registrationSystemAbi, + systemId: registrationSystemId, + functionName: "registerDelegation", + args: [moduleAddress, unlimitedDelegationControlId, "0x"], + }, + { + abi: registrationSystemAbi, + systemId: registrationSystemId, + functionName: "installModule", + args: [moduleAddress, mod.installData], + }, + { + abi: registrationSystemAbi, + systemId: registrationSystemId, + functionName: "unregisterDelegation", + args: [moduleAddress], + }, + ]), + } as const; + } + + return { + functionName: "installModule", + args: [moduleAddress, mod.installData], + } as const; + })(); return await writeContract(client, { chain: client.chain ?? null, @@ -144,9 +118,11 @@ export async function ensureModules({ // TODO: export from world const unlimitedDelegationControlId = resourceToHex({ type: "system", namespace: "", name: "unlimited" }); +const registrationSystemId = worldSystemsConfig.systems.RegistrationSystem.systemId; + // world/src/modules/init/RegistrationSystem.sol // TODO: import from world once we fix strongly typed JSON imports -export const registrationSystemAbi = [ +const registrationSystemAbi = [ { type: "function", name: "installModule", diff --git a/packages/cli/src/deploy/ensureNamespaceOwner.test.ts b/packages/cli/src/deploy/ensureNamespaceOwner.test.ts deleted file mode 100644 index eef8b37ca4..0000000000 --- a/packages/cli/src/deploy/ensureNamespaceOwner.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { decodeFunctionData } from "viem"; -import { describe, expect, it } from "vitest"; -import { resourceToHex } from "@latticexyz/common"; -import { encodeNamespaceRegistrationCall } from "./ensureNamespaceOwner"; -import { nativeRegistrationSystemAbi, registrationSystemId } from "./ensureFunctionMigrations"; - -describe("encodeNamespaceRegistrationCall", () => { - it("encodes namespace registration for direct RegistrationSystem dispatch", () => { - const namespaceId = resourceToHex({ type: "namespace", namespace: "app", name: "" }); - const call = encodeNamespaceRegistrationCall(namespaceId); - - expect(call.systemId).toBe(registrationSystemId); - expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ - functionName: "registerNamespace", - args: [namespaceId], - }); - }); -}); diff --git a/packages/cli/src/deploy/ensureNamespaceOwner.ts b/packages/cli/src/deploy/ensureNamespaceOwner.ts index 271174ac3d..4250b113e0 100644 --- a/packages/cli/src/deploy/ensureNamespaceOwner.ts +++ b/packages/cli/src/deploy/ensureNamespaceOwner.ts @@ -1,22 +1,10 @@ -import { encodeFunctionData, type Hex, getAddress } from "viem"; +import { Hex, getAddress } from "viem"; import { CommonDeployOptions, worldAbi } from "./common"; import { hexToResource, resourceToHex, writeContract } from "@latticexyz/common"; import { getResourceIds } from "./getResourceIds"; import { getTableValue } from "./getTableValue"; import { debug } from "./debug"; import worldConfig from "@latticexyz/world/mud.config"; -import { nativeRegistrationSystemAbi, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; - -export function encodeNamespaceRegistrationCall(namespaceId: Hex): DirectSystemCall { - return { - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "registerNamespace", - args: [namespaceId], - }), - }; -} export async function ensureNamespaceOwner({ client, @@ -69,16 +57,15 @@ export async function ensureNamespaceOwner({ debug("registering namespaces:", Array.from(missingNamespaces).join(", ")); } const registrationTxs = Promise.all( - missingNamespaces.map((namespace) => { - const call = encodeNamespaceRegistrationCall(resourceToHex({ namespace, type: "namespace", name: "" })); - return writeContract(client, { + missingNamespaces.map((namespace) => + writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "call", - args: [call.systemId, call.callData], - }); - }), + functionName: "registerNamespace", + args: [resourceToHex({ namespace, type: "namespace", name: "" })], + }), + ), ); return registrationTxs; diff --git a/packages/cli/src/deploy/ensureSystems.test.ts b/packages/cli/src/deploy/ensureSystems.test.ts deleted file mode 100644 index f696a440fb..0000000000 --- a/packages/cli/src/deploy/ensureSystems.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { decodeFunctionData, type Address, type Hex } from "viem"; -import { describe, expect, it } from "vitest"; -import { resourceToHex } from "@latticexyz/common"; -import { accessManagementSystemId, nativeRegistrationSystemAbi } from "./ensureFunctionMigrations"; -import { - accessManagementSystemAbi, - assertNativeSystemReplacementAvailable, - assertPostLifecycleSystemStates, - encodeAccessManagementCall, - encodeSystemRegistrationCallData, - getSystemAccessDiff, - resolveAllowedSystemAddress, -} from "./ensureSystems"; -import { hasSystemNamespaceGrant } from "./systemAccess"; - -const systemId = `0x7379${"00".repeat(14)}${"11".repeat(16)}` as Hex; -const desiredSystem = `0x${"22".repeat(20)}` as Address; - -describe("encodeSystemRegistrationCallData", () => { - it("uses zero as the compare-and-swap expectation for an unused System ID", () => { - const call = decodeFunctionData({ - abi: nativeRegistrationSystemAbi, - data: encodeSystemRegistrationCallData({ - systemId, - expectedSystem: "0x0000000000000000000000000000000000000000", - expectedPublicAccess: false, - system: desiredSystem, - publicAccess: true, - }), - }); - - expect(call).toMatchObject({ - functionName: "replaceSystem", - args: [systemId, "0x0000000000000000000000000000000000000000", false, desiredSystem, true], - }); - }); - - it("uses the pinned implementation as the expectation for an upgrade", () => { - const currentSystem = `0x${"33".repeat(20)}` as Address; - const call = decodeFunctionData({ - abi: nativeRegistrationSystemAbi, - data: encodeSystemRegistrationCallData({ - systemId, - expectedSystem: currentSystem, - expectedPublicAccess: true, - system: desiredSystem, - publicAccess: false, - }), - }); - - expect(call).toMatchObject({ - functionName: "replaceSystem", - args: [systemId, currentSystem, true, desiredSystem, false], - }); - }); -}); - -describe("encodeAccessManagementCall", () => { - it.each(["grantAccess", "revokeAccess"] as const)( - "dispatches %s directly to the immutable AccessManagementSystem ID", - (functionName) => { - const grantee = `0x${"66".repeat(20)}` as Address; - const call = encodeAccessManagementCall({ functionName, resourceId: systemId, grantee }); - - expect(call.systemId).toBe(accessManagementSystemId); - expect(decodeFunctionData({ abi: accessManagementSystemAbi, data: call.callData })).toMatchObject({ - functionName, - args: [systemId, grantee], - }); - }, - ); -}); - -describe("getSystemAccessDiff", () => { - it("removes an authoritative extra grant omitted from the desired config", () => { - const desired = `0x${"66".repeat(20)}` as Address; - const stale = `0x${"77".repeat(20)}` as Address; - - expect( - getSystemAccessDiff({ - currentAccess: [ - { resourceId: systemId, address: desired }, - { resourceId: systemId, address: stale }, - ], - desiredAccess: [{ resourceId: systemId, address: desired }], - }), - ).toEqual({ - accessToAdd: [], - accessToRemove: [{ resourceId: systemId, address: stale }], - }); - }); -}); - -describe("hasSystemNamespaceGrant", () => { - it("requires the desired implementation's automatic namespace grant", () => { - const namespaceId = resourceToHex({ type: "namespace", namespace: "", name: "" }); - - expect( - hasSystemNamespaceGrant({ - systemId, - systemAddress: desiredSystem, - worldAccess: [{ resourceId: namespaceId, address: desiredSystem }], - }), - ).toBe(true); - expect( - hasSystemNamespaceGrant({ - systemId, - systemAddress: desiredSystem, - worldAccess: [{ resourceId: namespaceId, address: `0x${"88".repeat(20)}` }], - }), - ).toBe(false); - }); -}); - -describe("resolveAllowedSystemAddress", () => { - it("never resolves a tombstoned System to address(0)", () => { - expect(() => - resolveAllowedSystemAddress({ - granteeSystemId: systemId, - allowedSystemId: `0x7379${"00".repeat(14)}${"44".repeat(16)}`, - worldSystemAddress: "0x0000000000000000000000000000000000000000", - desiredSystemAddress: undefined, - }), - ).toThrowError("inactive/tombstoned System"); - }); - - it("uses a configured replacement address instead of the stale active implementation", () => { - expect( - resolveAllowedSystemAddress({ - granteeSystemId: systemId, - allowedSystemId: `0x7379${"00".repeat(14)}${"44".repeat(16)}`, - worldSystemAddress: `0x${"55".repeat(20)}`, - desiredSystemAddress: desiredSystem, - }), - ).toBe(desiredSystem); - }); -}); - -describe("assertNativeSystemReplacementAvailable", () => { - it("fails closed when a legacy World drifts after an exact preflight", () => { - expect(() => - assertNativeSystemReplacementAvailable({ pendingSystems: 1, useNativeSystemReplacement: false }), - ).toThrowError("Configured System state changed after legacy bootstrap preflight"); - expect(() => - assertNativeSystemReplacementAvailable({ pendingSystems: 0, useNativeSystemReplacement: false }), - ).not.toThrow(); - }); -}); - -describe("assertPostLifecycleSystemStates", () => { - const originalSystem = `0x${"33".repeat(20)}` as Address; - const concurrentSystem = `0x${"44".repeat(20)}` as Address; - const replacementSystem = `0x${"55".repeat(20)}` as Address; - const originalState = { systemId, exists: true, address: originalSystem, publicAccess: true }; - - it("rejects late drift instead of adopting it as a new CAS expectation", () => { - expect(() => - assertPostLifecycleSystemStates({ - systemIds: [systemId], - originalStates: [originalState], - currentStates: [{ ...originalState, address: concurrentSystem }], - lifecycleTargets: [], - }), - ).toThrowError("changed unexpectedly after deployment preflight"); - }); - - it("accepts only the exact projected result for a lifecycle target", () => { - const lifecycleTargets = [{ systemId, address: replacementSystem, publicAccess: false }]; - expect(() => - assertPostLifecycleSystemStates({ - systemIds: [systemId], - originalStates: [originalState], - currentStates: [{ ...originalState, address: replacementSystem, publicAccess: false }], - lifecycleTargets, - }), - ).not.toThrow(); - expect(() => - assertPostLifecycleSystemStates({ - systemIds: [systemId], - originalStates: [originalState], - currentStates: [{ ...originalState, address: concurrentSystem, publicAccess: false }], - lifecycleTargets, - }), - ).toThrowError("changed unexpectedly after deployment preflight"); - }); -}); diff --git a/packages/cli/src/deploy/ensureSystems.ts b/packages/cli/src/deploy/ensureSystems.ts index 8ab8d09212..1cabbf2423 100644 --- a/packages/cli/src/deploy/ensureSystems.ts +++ b/packages/cli/src/deploy/ensureSystems.ts @@ -1,340 +1,90 @@ -import { type Address, type Hex, encodeFunctionData, getAddress, zeroAddress } from "viem"; -import { resourceToLabel, writeContract } from "@latticexyz/common"; +import { Hex, getAddress, Address } from "viem"; +import { writeContract, resourceToLabel } from "@latticexyz/common"; import { CommonDeployOptions, System, worldAbi } from "./common"; import { debug } from "./debug"; +import { getSystems } from "./getSystems"; import { getResourceAccess } from "./getResourceAccess"; import pRetry from "p-retry"; import { LibraryMap } from "./getLibraryMap"; import { ensureContractsDeployed } from "@latticexyz/common/internal"; -import { - accessManagementSystemId, - getSystemStates, - nativeRegistrationSystemAbi, - registrationSystemId, - type DirectSystemCall, -} from "./ensureFunctionMigrations"; -import worldConfig from "@latticexyz/world/mud.config"; -import { getRecord } from "./getRecord"; -import type { SystemState } from "./functionMigrationPlan"; -import { hasSystemNamespaceGrant, type SystemAccess } from "./systemAccess"; // TODO: move each system registration+access to batch call to be atomic -function normalizeHex(value: Hex): string { - return value.toLowerCase(); -} - -export function encodeSystemRegistrationCallData({ - systemId, - expectedSystem, - expectedPublicAccess, - system, - publicAccess, -}: { - readonly systemId: Hex; - readonly expectedSystem: Address; - readonly expectedPublicAccess: boolean; - readonly system: Address; - readonly publicAccess: boolean; -}): Hex { - return encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "replaceSystem", - args: [systemId, expectedSystem, expectedPublicAccess, system, publicAccess], - }); -} - -export const accessManagementSystemAbi = [ - { - type: "function", - name: "grantAccess", - inputs: [ - { name: "resourceId", type: "bytes32" }, - { name: "grantee", type: "address" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "revokeAccess", - inputs: [ - { name: "resourceId", type: "bytes32" }, - { name: "grantee", type: "address" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, -] as const; - -export function encodeAccessManagementCall({ - functionName, - resourceId, - grantee, -}: { - readonly functionName: "grantAccess" | "revokeAccess"; - readonly resourceId: Hex; - readonly grantee: Address; -}): DirectSystemCall { - return { - systemId: accessManagementSystemId, - callData: encodeFunctionData({ - abi: accessManagementSystemAbi, - functionName, - args: [resourceId, grantee], - }), - }; -} - -export function getSystemAccessDiff({ - currentAccess, - desiredAccess, -}: { - readonly currentAccess: readonly SystemAccess[]; - readonly desiredAccess: readonly SystemAccess[]; -}): { readonly accessToAdd: readonly SystemAccess[]; readonly accessToRemove: readonly SystemAccess[] } { - const key = (access: SystemAccess): string => `${normalizeHex(access.resourceId)}/${normalizeHex(access.address)}`; - const currentByKey = new Map(currentAccess.map((access) => [key(access), access])); - const desiredByKey = new Map(desiredAccess.map((access) => [key(access), access])); - return { - accessToAdd: [...desiredByKey].filter(([accessKey]) => !currentByKey.has(accessKey)).map(([, access]) => access), - accessToRemove: [...currentByKey].filter(([accessKey]) => !desiredByKey.has(accessKey)).map(([, access]) => access), - }; -} - -export function resolveAllowedSystemAddress({ - granteeSystemId, - allowedSystemId, - worldSystemAddress, - desiredSystemAddress, -}: { - readonly granteeSystemId: Hex; - readonly allowedSystemId: Hex; - readonly worldSystemAddress: Address | undefined; - readonly desiredSystemAddress: Address | undefined; -}): Address { - if (worldSystemAddress != null && getAddress(worldSystemAddress) === zeroAddress) { - throw new Error( - `Cannot grant ${granteeSystemId} access to inactive/tombstoned System ${allowedSystemId} (address(0)).`, - ); - } - const address = desiredSystemAddress ?? worldSystemAddress; - if (address == null || getAddress(address) === zeroAddress) { - throw new Error(`Cannot grant ${granteeSystemId} access to inactive or unregistered System ${allowedSystemId}.`); - } - return address; -} - -export function getDesiredSystemAccess({ - systems, - systemStates, - desiredSystemAddresses, -}: { - readonly systems: readonly Pick[]; - readonly systemStates: readonly SystemState[]; - readonly desiredSystemAddresses: ReadonlyMap; -}): readonly SystemAccess[] { - const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); - return [ - ...systems.flatMap((system) => - system.allowedAddresses.map((address) => ({ resourceId: system.systemId, address })), - ), - ...systems.flatMap((system) => - system.allowedSystemIds.map((allowedSystemId) => { - const key = normalizeHex(allowedSystemId); - const state = statesById.get(key); - return { - resourceId: system.systemId, - address: resolveAllowedSystemAddress({ - granteeSystemId: system.systemId, - allowedSystemId, - worldSystemAddress: state?.exists === true ? state.address : undefined, - desiredSystemAddress: desiredSystemAddresses.get(key), - }), - }; - }), - ), - ]; -} - -export function assertNativeSystemReplacementAvailable({ - pendingSystems, - useNativeSystemReplacement, -}: { - readonly pendingSystems: number; - readonly useNativeSystemReplacement: boolean; -}): void { - if (pendingSystems > 0 && !useNativeSystemReplacement) { - throw new Error( - [ - "Configured System state changed after legacy bootstrap preflight.", - "Native replaceSystem is not available, so the deployer will not overwrite the late state with registerSystem.", - "Re-run deployment against the latest World state and explicitly approve registrationSystemMigration if prompted.", - ].join("\n"), - ); - } -} - -export function assertPostLifecycleSystemStates({ - systemIds, - originalStates, - currentStates, - lifecycleTargets, -}: { - readonly systemIds: readonly Hex[]; - readonly originalStates: readonly SystemState[]; - readonly currentStates: readonly SystemState[]; - readonly lifecycleTargets: readonly { - readonly systemId: Hex; - readonly address: Address; - readonly publicAccess: boolean; - }[]; -}): void { - const originalsById = new Map(originalStates.map((state) => [normalizeHex(state.systemId), state])); - const currentById = new Map(currentStates.map((state) => [normalizeHex(state.systemId), state])); - const lifecycleTargetsById = new Map(lifecycleTargets.map((target) => [normalizeHex(target.systemId), target])); - - for (const systemId of systemIds) { - const key = normalizeHex(systemId); - const original = originalsById.get(key); - const current = currentById.get(key); - const lifecycleTarget = lifecycleTargetsById.get(key); - if (original == null || current == null) { - throw new Error(`Missing direct System state while checking post-lifecycle drift for ${systemId}.`); - } - - const expectedExists = lifecycleTarget == null ? original.exists : true; - const expectedAddress = lifecycleTarget?.address ?? original.address; - const expectedPublicAccess = lifecycleTarget?.publicAccess ?? original.publicAccess; - if ( - current.exists !== expectedExists || - getAddress(current.address) !== getAddress(expectedAddress) || - current.publicAccess !== expectedPublicAccess - ) { - throw new Error( - [ - `System ${systemId} changed unexpectedly after deployment preflight.`, - `Expected: exists=${String(expectedExists)}, address=${expectedAddress}, publicAccess=${String(expectedPublicAccess)}`, - `Current: exists=${String(current.exists)}, address=${current.address}, publicAccess=${String(current.publicAccess)}`, - "Refusing to authorize the late state as a new compare-and-swap expectation; re-run deployment.", - ].join("\n"), - ); - } - } -} - export async function ensureSystems({ client, deployerAddress, libraryMap, worldDeploy, systems, - useNativeSystemReplacement, - systemStates, - accessSystemStates, + indexerUrl, + chainId, }: CommonDeployOptions & { readonly deployerAddress: Hex; readonly libraryMap: LibraryMap; readonly systems: readonly System[]; - readonly useNativeSystemReplacement: boolean; - readonly systemStates: readonly SystemState[]; - readonly accessSystemStates: readonly SystemState[]; }): Promise { - // Access reconciliation is destructive: an incomplete indexer snapshot could - // preserve a stale grant. Inventory authoritative RPC logs at the pinned block. - const worldAccess = await getResourceAccess({ client, worldDeploy }); + const [worldSystems, worldAccess] = await Promise.all([ + getSystems({ client, worldDeploy, indexerUrl, chainId }), + getResourceAccess({ client, worldDeploy, indexerUrl, chainId }), + ]); // Register or replace systems - const systemStatesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); - const desiredSystemAddresses = new Map( - systems.map((system) => [normalizeHex(system.systemId), system.prepareDeploy(deployerAddress, libraryMap).address]), + const existingSystems = systems.filter((system) => + worldSystems.some( + (worldSystem) => + worldSystem.systemId === system.systemId && + getAddress(worldSystem.address) === getAddress(system.prepareDeploy(deployerAddress, libraryMap).address), + ), ); - const existingSystems = systems.filter((system) => { - const state = systemStatesById.get(normalizeHex(system.systemId)); - return ( - state?.exists === true && - getAddress(state.address) === getAddress(desiredSystemAddresses.get(normalizeHex(system.systemId))!) && - state.publicAccess === system.allowAll && - hasSystemNamespaceGrant({ - systemId: system.systemId, - systemAddress: desiredSystemAddresses.get(normalizeHex(system.systemId))!, - worldAccess, - }) - ); - }); if (existingSystems.length) { debug("existing systems:", existingSystems.map(resourceToLabel).join(", ")); } - const existingSystemIds = new Set(existingSystems.map((system) => normalizeHex(system.systemId))); + const existingSystemIds = existingSystems.map((system) => system.systemId); - const missingSystems = systems.filter((system) => !existingSystemIds.has(normalizeHex(system.systemId))); + const missingSystems = systems.filter((system) => !existingSystemIds.includes(system.systemId)); + if (!missingSystems.length) return []; - const systemsToUpgrade = missingSystems.filter( - (system) => systemStatesById.get(normalizeHex(system.systemId))?.exists === true, + const systemsToUpgrade = missingSystems.filter((system) => + worldSystems.some( + (worldSystem) => + worldSystem.systemId === system.systemId && + getAddress(worldSystem.address) !== getAddress(system.prepareDeploy(deployerAddress, libraryMap).address), + ), ); if (systemsToUpgrade.length) { debug("upgrading systems:", systemsToUpgrade.map(resourceToLabel).join(", ")); } const systemsToAdd = missingSystems.filter( - (system) => systemStatesById.get(normalizeHex(system.systemId))?.exists !== true, + (system) => !worldSystems.some((worldSystem) => worldSystem.systemId === system.systemId), ); if (systemsToAdd.length) { debug("registering new systems:", systemsToAdd.map(resourceToLabel).join(", ")); } - assertNativeSystemReplacementAvailable({ - pendingSystems: missingSystems.length, - useNativeSystemReplacement, - }); - // Resolve access targets before any deployment writes. A historical System resource - // with address(0) is a permanent tombstone and must never become an access grant. - const systemIds = systems.map((system) => system.systemId); - const currentAccess = worldAccess.filter(({ resourceId }) => - systemIds.some((systemId) => normalizeHex(systemId) === normalizeHex(resourceId)), - ); - const desiredAccess = getDesiredSystemAccess({ - systems, - systemStates: accessSystemStates, - desiredSystemAddresses, + await ensureContractsDeployed({ + client, + deployerAddress, + contracts: missingSystems.map((system) => ({ + bytecode: system.prepareDeploy(deployerAddress, libraryMap).bytecode, + deployedBytecodeSize: system.deployedBytecodeSize, + debugLabel: `${resourceToLabel(system)} system`, + })), }); - if (missingSystems.length > 0) { - await ensureContractsDeployed({ - client, - deployerAddress, - contracts: missingSystems.map((system) => ({ - bytecode: system.prepareDeploy(deployerAddress, libraryMap).bytecode, - deployedBytecodeSize: system.deployedBytecodeSize, - debugLabel: `${resourceToLabel(system)} system`, - })), - }); - } - const registerTxs = await Promise.all( missingSystems.map((system) => pRetry( - () => { - const desiredAddress = desiredSystemAddresses.get(normalizeHex(system.systemId))!; - const currentState = systemStatesById.get(normalizeHex(system.systemId)); - const callData = encodeSystemRegistrationCallData({ - systemId: system.systemId, - expectedSystem: currentState?.address ?? zeroAddress, - expectedPublicAccess: currentState?.publicAccess ?? false, - system: desiredAddress, - publicAccess: system.allowAll, - }); - return writeContract(client, { + () => + writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - // Invoke the core implementation through the immutable kernel path; - // never depend on a mutable public RegistrationSystem alias. - functionName: "call", - args: [registrationSystemId, callData], - }); - }, + // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) + functionName: "registerSystem", + args: [system.systemId, system.prepareDeploy(deployerAddress, libraryMap).address, system.allowAll], + }), { retries: 3, onFailedAttempt: () => debug(`failed to register system ${resourceToLabel(system)}, retrying...`), @@ -345,7 +95,39 @@ export async function ensureSystems({ // Adjust system access - const { accessToAdd, accessToRemove } = getSystemAccessDiff({ currentAccess, desiredAccess }); + const systemIds = systems.map((system) => system.systemId); + const currentAccess = worldAccess.filter(({ resourceId }) => systemIds.includes(resourceId)); + const desiredAccess = [ + ...systems.flatMap((system) => + system.allowedAddresses.map((address) => ({ resourceId: system.systemId, address })), + ), + ...systems.flatMap((system) => + system.allowedSystemIds + .map((systemId) => ({ + resourceId: system.systemId, + address: + worldSystems.find((s) => s.systemId === systemId)?.address ?? + systems.find((s) => s.systemId === systemId)?.prepareDeploy(deployerAddress, libraryMap).address, + })) + .filter((access): access is typeof access & { address: Address } => access.address != null), + ), + ]; + + const accessToAdd = desiredAccess.filter( + (access) => + !currentAccess.some( + ({ resourceId, address }) => + resourceId === access.resourceId && getAddress(address) === getAddress(access.address), + ), + ); + + const accessToRemove = currentAccess.filter( + (access) => + !desiredAccess.some( + ({ resourceId, address }) => + resourceId === access.resourceId && getAddress(address) === getAddress(access.address), + ), + ); if (accessToRemove.length) { debug("revoking", accessToRemove.length, "access grants"); @@ -357,20 +139,14 @@ export async function ensureSystems({ const accessTxs = await Promise.all([ ...accessToRemove.map((access) => pRetry( - () => { - const call = encodeAccessManagementCall({ - functionName: "revokeAccess", - resourceId: access.resourceId, - grantee: access.address, - }); - return writeContract(client, { + () => + writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "call", - args: [call.systemId, call.callData], - }); - }, + functionName: "revokeAccess", + args: [access.resourceId, access.address], + }), { retries: 3, onFailedAttempt: () => debug("failed to revoke access, retrying..."), @@ -379,20 +155,14 @@ export async function ensureSystems({ ), ...accessToAdd.map((access) => pRetry( - () => { - const call = encodeAccessManagementCall({ - functionName: "grantAccess", - resourceId: access.resourceId, - grantee: access.address, - }); - return writeContract(client, { + () => + writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "call", - args: [call.systemId, call.callData], - }); - }, + functionName: "grantAccess", + args: [access.resourceId, access.address], + }), { retries: 3, onFailedAttempt: () => debug("failed to grant access, retrying..."), @@ -403,87 +173,3 @@ export async function ensureSystems({ return [...registerTxs, ...accessTxs]; } - -/** Verify configured System address and public access directly at one authoritative block. */ -export async function verifySystems({ - client, - worldDeploy, - deployerAddress, - libraryMap, - systems, -}: Pick & { - readonly deployerAddress: Hex; - readonly libraryMap: LibraryMap; - readonly systems: readonly System[]; -}): Promise { - const desiredSystemAddresses = new Map( - systems.map((system) => [normalizeHex(system.systemId), system.prepareDeploy(deployerAddress, libraryMap).address]), - ); - const [results, worldAccess, allowedSystemStates] = await Promise.all([ - Promise.all( - systems.map(async (system) => { - const actual = await getRecord({ - client, - worldDeploy, - table: worldConfig.namespaces.world.tables.Systems, - key: { systemId: system.systemId }, - }); - const expectedAddress = desiredSystemAddresses.get(normalizeHex(system.systemId))!; - return getAddress(actual.system) === getAddress(expectedAddress) && actual.publicAccess === system.allowAll - ? undefined - : { system, actual, expectedAddress }; - }), - ), - // Final verification must not trust an indexer that can omit stale grants. - getResourceAccess({ client, worldDeploy }), - getSystemStates({ - client, - worldDeploy, - systemIds: systems.flatMap((system) => system.allowedSystemIds), - }), - ]); - const mismatches = results.flatMap((mismatch) => (mismatch == null ? [] : [mismatch])); - const configuredSystemIds = new Set(systems.map((system) => normalizeHex(system.systemId))); - const currentAccess = worldAccess.filter(({ resourceId }) => configuredSystemIds.has(normalizeHex(resourceId))); - const desiredAccess = getDesiredSystemAccess({ - systems, - systemStates: allowedSystemStates, - desiredSystemAddresses, - }); - const { accessToAdd, accessToRemove } = getSystemAccessDiff({ currentAccess, desiredAccess }); - const missingNamespaceGrants = systems.filter( - (system) => - !hasSystemNamespaceGrant({ - systemId: system.systemId, - systemAddress: desiredSystemAddresses.get(normalizeHex(system.systemId))!, - worldAccess, - }), - ); - - if ( - mismatches.length > 0 || - missingNamespaceGrants.length > 0 || - accessToAdd.length > 0 || - accessToRemove.length > 0 - ) { - throw new Error( - [ - "Configured System verification failed:", - ...mismatches.map( - ({ system, actual, expectedAddress }) => - `- ${system.systemId}: expected ${expectedAddress} (publicAccess=${String(system.allowAll)}), current ${actual.system} (publicAccess=${String(actual.publicAccess)})`, - ), - ...missingNamespaceGrants.map( - (system) => - `- missing default namespace access for ${desiredSystemAddresses.get(normalizeHex(system.systemId))!} (${system.systemId})`, - ), - ...accessToAdd.map( - ({ resourceId, address }) => `- missing access grant: resource ${resourceId}, grantee ${address}`, - ), - ...accessToRemove.map( - ({ resourceId, address }) => `- unexpected access grant: resource ${resourceId}, grantee ${address}`, - ), - ].join("\n"), - ); - } -} diff --git a/packages/cli/src/deploy/ensureTables.test.ts b/packages/cli/src/deploy/ensureTables.test.ts deleted file mode 100644 index 438990cfa2..0000000000 --- a/packages/cli/src/deploy/ensureTables.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { decodeFunctionData, type Hex } from "viem"; -import { describe, expect, it } from "vitest"; -import { resourceToHex } from "@latticexyz/common"; -import { nativeRegistrationSystemAbi, registrationSystemId } from "./ensureFunctionMigrations"; -import { encodeTableRegistrationCall } from "./ensureTables"; - -describe("encodeTableRegistrationCall", () => { - it("encodes table registration for direct RegistrationSystem dispatch", () => { - const tableId = resourceToHex({ type: "table", namespace: "app", name: "Counter" }); - const fieldLayout = `0x${"11".repeat(32)}` as Hex; - const keySchema = `0x${"22".repeat(32)}` as Hex; - const valueSchema = `0x${"33".repeat(32)}` as Hex; - const call = encodeTableRegistrationCall({ - tableId, - fieldLayout, - keySchema, - valueSchema, - keyNames: ["id"], - fieldNames: ["value"], - }); - - expect(call.systemId).toBe(registrationSystemId); - expect(decodeFunctionData({ abi: nativeRegistrationSystemAbi, data: call.callData })).toMatchObject({ - functionName: "registerTable", - args: [tableId, fieldLayout, keySchema, valueSchema, ["id"], ["value"]], - }); - }); -}); diff --git a/packages/cli/src/deploy/ensureTables.ts b/packages/cli/src/deploy/ensureTables.ts index 683e39a4ee..14178cb5d3 100644 --- a/packages/cli/src/deploy/ensureTables.ts +++ b/packages/cli/src/deploy/ensureTables.ts @@ -1,4 +1,4 @@ -import { encodeFunctionData, type Hex } from "viem"; +import { Hex } from "viem"; import { resourceToLabel, writeContract } from "@latticexyz/common"; import { CommonDeployOptions, WorldDeploy, worldAbi } from "./common"; import { @@ -14,46 +14,17 @@ import { getTables } from "./getTables"; import pRetry from "p-retry"; import { Table } from "@latticexyz/config"; import { isDefined } from "@latticexyz/common/utils"; -import { nativeRegistrationSystemAbi, registrationSystemId, type DirectSystemCall } from "./ensureFunctionMigrations"; -export function encodeTableRegistrationCall({ - tableId, - fieldLayout, - keySchema, - valueSchema, - keyNames, - fieldNames, -}: { - readonly tableId: Hex; - readonly fieldLayout: Hex; - readonly keySchema: Hex; - readonly valueSchema: Hex; - readonly keyNames: readonly string[]; - readonly fieldNames: readonly string[]; -}): DirectSystemCall { - return { - systemId: registrationSystemId, - callData: encodeFunctionData({ - abi: nativeRegistrationSystemAbi, - functionName: "registerTable", - args: [tableId, fieldLayout, keySchema, valueSchema, [...keyNames], [...fieldNames]], - }), - }; -} - -export type TableRegistrationPlan = { - readonly missingTables: readonly Table[]; -}; - -/** Read and validate immutable table schemas before any core bootstrap/deployment writes. */ -export async function getTablePlan({ +export async function ensureTables({ client, worldDeploy, tables, + indexerUrl, + chainId, }: CommonDeployOptions & { readonly worldDeploy: WorldDeploy; readonly tables: readonly Table[]; -}): Promise { +}): Promise { const configTables = new Map( tables.map((table) => { const keySchema = getSchemaTypes(getKeySchema(table)); @@ -73,10 +44,7 @@ export async function getTablePlan({ }), ); - // This check gates a potentially irreversible core bootstrap. Enumerate from - // authoritative RPC logs instead of trusting an indexer that may omit a live - // immutable table and defer its schema conflict until after bootstrap. - const worldTables = await getTables({ client, worldDeploy }); + const worldTables = await getTables({ client, worldDeploy, indexerUrl, chainId }); const existingTables = worldTables.filter(({ tableId }) => configTables.has(tableId)); if (existingTables.length) { debug("existing tables:", existingTables.map(resourceToLabel).join(", ")); @@ -107,39 +75,28 @@ export async function getTablePlan({ const existingTableIds = new Set(existingTables.map(({ tableId }) => tableId)); const missingTables = tables.filter((table) => !existingTableIds.has(table.tableId)); - return { missingTables }; -} - -export async function ensureTables({ - client, - worldDeploy, - plan, -}: Pick & { - readonly plan: TableRegistrationPlan; -}): Promise { - const { missingTables } = plan; if (missingTables.length) { debug("registering tables:", missingTables.map(resourceToLabel).join(", ")); return await Promise.all( missingTables.map((table) => { const keySchema = getSchemaTypes(getKeySchema(table)); const valueSchema = getSchemaTypes(getValueSchema(table)); - const call = encodeTableRegistrationCall({ - tableId: table.tableId, - fieldLayout: valueSchemaToFieldLayoutHex(valueSchema), - keySchema: keySchemaToHex(keySchema), - valueSchema: valueSchemaToHex(valueSchema), - keyNames: Object.keys(keySchema), - fieldNames: Object.keys(valueSchema), - }); return pRetry( () => writeContract(client, { chain: client.chain ?? null, address: worldDeploy.address, abi: worldAbi, - functionName: "call", - args: [call.systemId, call.callData], + // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) + functionName: "registerTable", + args: [ + table.tableId, + valueSchemaToFieldLayoutHex(valueSchema), + keySchemaToHex(keySchema), + valueSchemaToHex(valueSchema), + Object.keys(keySchema), + Object.keys(valueSchema), + ], }), { retries: 3, diff --git a/packages/cli/src/deploy/functionMigrationPlan.test.ts b/packages/cli/src/deploy/functionMigrationPlan.test.ts deleted file mode 100644 index 33f81bd75b..0000000000 --- a/packages/cli/src/deploy/functionMigrationPlan.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { toFunctionSelector, type Address, type Hex } from "viem"; -import type { WorldFunction } from "./common"; -import { planFunctionMigrations, planSystemRenames, type FunctionRouteMigration } from "./functionMigrationPlan"; - -const sourceSystemId = `0x7379${"00".repeat(14)}${"11".repeat(16)}` as Hex; -const targetSystemId = `0x7379${"00".repeat(14)}${"22".repeat(16)}` as Hex; -const otherSystemId = `0x7379${"00".repeat(14)}${"33".repeat(16)}` as Hex; -const sourceAddress = `0x${"11".repeat(20)}` as Address; -const runSelector = toFunctionSelector("run()"); - -function worldFunction(overrides: Partial = {}): WorldFunction { - return { - signature: "app__run()", - selector: "0x12345678", - systemId: targetSystemId, - systemFunctionSignature: "run()", - systemFunctionSelector: runSelector, - ...overrides, - }; -} - -function migration(overrides: Partial = {}): FunctionRouteMigration { - return { - worldSelector: "0x12345678", - fromSystemId: sourceSystemId, - fromSystemFunctionSelector: runSelector, - toSystemId: targetSystemId, - toSystemFunctionSelector: runSelector, - ...overrides, - }; -} - -function plan( - overrides: Partial[0]> = {}, -): ReturnType { - const func = worldFunction(); - return planFunctionMigrations({ - functions: [func], - routes: [ - { - selector: func.selector, - systemId: sourceSystemId, - systemFunctionSelector: func.systemFunctionSelector, - }, - ], - migrations: [migration()], - removals: [], - retirements: [], - systemStates: [], - desiredSystems: [{ systemId: targetSystemId }], - ...overrides, - }); -} - -describe("planFunctionMigrations", () => { - it("plans an exact source tuple and projects the destination for function registration", () => { - const result = plan(); - - expect(result.migrationsToApply).toEqual([{ ...migration(), toSystemFunctionSignature: "run()" }]); - expect(result.migrationsAlreadyApplied).toEqual([]); - expect(result.functionPlan.toAdd).toEqual([]); - expect(result.functionPlan.toSkip).toEqual([worldFunction()]); - }); - - it("treats the exact destination tuple as an idempotent no-op", () => { - const func = worldFunction(); - const result = plan({ - routes: [ - { - selector: func.selector, - systemId: targetSystemId, - systemFunctionSelector: func.systemFunctionSelector, - }, - ], - }); - - expect(result.migrationsToApply).toEqual([]); - expect(result.migrationsAlreadyApplied).toEqual([migration()]); - }); - - it("treats a missing route as fresh-world not-applicable only when its sources never existed", () => { - const result = plan({ routes: [], systemStates: [] }); - - expect(result.migrationsNotApplicable).toEqual([migration()]); - expect(result.functionPlan.toAdd).toEqual([worldFunction()]); - }); - - it("rejects a missing route when a declared source System exists or is retired", () => { - expect(() => - plan({ - routes: [], - systemStates: [ - { - systemId: sourceSystemId, - exists: true, - address: "0x0000000000000000000000000000000000000000", - publicAccess: false, - }, - ], - }), - ).toThrowError("Selector migration preflight failed"); - }); - - it("accepts chained alternative source tuples with one canonical destination", () => { - const priorCanonicalId = `0x7379${"00".repeat(14)}${"44".repeat(16)}` as Hex; - const alternatives = [migration(), migration({ fromSystemId: priorCanonicalId })]; - const func = worldFunction(); - const result = plan({ - migrations: alternatives, - routes: [ - { - selector: func.selector, - systemId: priorCanonicalId, - systemFunctionSelector: func.systemFunctionSelector, - }, - ], - }); - - expect(result.migrationsToApply).toEqual([{ ...alternatives[1], toSystemFunctionSignature: "run()" }]); - }); - - it("accepts alternative source selectors from the same legacy System ID", () => { - const alternate = migration({ fromSystemFunctionSelector: "0xaaaaaaaa" }); - const result = plan({ - migrations: [migration(), alternate], - routes: [ - { - selector: alternate.worldSelector, - systemId: alternate.fromSystemId, - systemFunctionSelector: alternate.fromSystemFunctionSelector, - }, - ], - }); - - expect(result.migrationsToApply).toEqual([{ ...alternate, toSystemFunctionSignature: "run()" }]); - }); - - it("rejects alternative sources with a non-canonical final destination", () => { - expect(() => - plan({ - migrations: [migration(), migration({ fromSystemId: otherSystemId, toSystemId: sourceSystemId })], - }), - ).toThrowError("does not match an exact configured destination route"); - }); - - it("rejects every route other than the exact source or destination", () => { - const func = worldFunction(); - - expect(() => - plan({ - routes: [ - { - selector: func.selector, - systemId: otherSystemId, - systemFunctionSelector: func.systemFunctionSelector, - }, - ], - }), - ).toThrowError(`Selector migration preflight failed for ${func.selector}`); - }); - - it("requires migrations to match an exact configured destination", () => { - expect(() => plan({ migrations: [migration({ toSystemFunctionSelector: "0xaaaaaaaa" })] })).toThrowError( - "does not match an exact configured destination route", - ); - }); - - it("plans exact removals and treats an absent selector as already applied", () => { - const removal = { - worldSelector: "0xaaaaaaaa" as Hex, - expectedSystemId: sourceSystemId, - expectedSystemFunctionSelector: "0xbbbbbbbb" as Hex, - }; - const base = { - functions: [] as WorldFunction[], - migrations: [], - removals: [removal], - retirements: [], - systemStates: [], - desiredSystems: [], - }; - - const pending = planFunctionMigrations({ - ...base, - routes: [ - { - selector: removal.worldSelector, - systemId: removal.expectedSystemId, - systemFunctionSelector: removal.expectedSystemFunctionSelector, - }, - ], - }); - expect(pending.removalsToApply).toEqual([removal]); - - const applied = planFunctionMigrations({ ...base, routes: [] }); - expect(applied.removalsAlreadyApplied).toEqual([removal]); - }); - - it("rejects a removal that is still desired", () => { - const func = worldFunction(); - expect(() => - plan({ - migrations: [], - removals: [ - { - worldSelector: func.selector, - expectedSystemId: sourceSystemId, - expectedSystemFunctionSelector: func.systemFunctionSelector, - }, - ], - }), - ).toThrowError("is still present in the configured World ABI"); - }); - - it("skips missing and tombstoned retirements", () => { - const tombstonedId = `0x7379${"00".repeat(14)}${"44".repeat(16)}` as Hex; - const result = plan({ - retirements: [{ systemId: otherSystemId }, { systemId: tombstonedId }], - systemStates: [ - { - systemId: tombstonedId, - exists: true, - address: "0x0000000000000000000000000000000000000000", - publicAccess: false, - }, - ], - }); - - expect(result.retirementsNotFound).toEqual([{ systemId: otherSystemId }]); - expect(result.retirementsAlreadyApplied).toEqual([{ systemId: tombstonedId }]); - }); - - it("refuses to retire a system while any unplanned selector still references it", () => { - const func = worldFunction(); - expect(() => - plan({ - routes: [ - { - selector: func.selector, - systemId: sourceSystemId, - systemFunctionSelector: func.systemFunctionSelector, - }, - { - selector: "0xaaaaaaaa", - systemId: sourceSystemId, - systemFunctionSelector: "0xbbbbbbbb", - }, - ], - retirements: [{ systemId: sourceSystemId }], - systemStates: [{ systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }], - }), - ).toThrowError("live selector routes would still reference it"); - }); - - it("allows retirement after all live routes are migrated or removed", () => { - const result = plan({ - retirements: [{ systemId: sourceSystemId }], - systemStates: [{ systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }], - }); - - expect(result.retirementsToApply).toEqual([ - { systemId: sourceSystemId, expectedSystem: sourceAddress, expectedPublicAccess: true }, - ]); - }); - - it("plans a same-implementation system rename when the target ID is unused", () => { - const result = plan({ - retirements: [{ systemId: sourceSystemId }], - systemStates: [ - { systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }, - { - systemId: targetSystemId, - exists: false, - address: "0x0000000000000000000000000000000000000000", - publicAccess: false, - }, - ], - }); - - expect( - planSystemRenames( - result, - [{ systemId: targetSystemId, address: sourceAddress, publicAccess: true }], - [ - { systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }, - { - systemId: targetSystemId, - exists: false, - address: "0x0000000000000000000000000000000000000000", - publicAccess: false, - }, - ], - ), - ).toEqual([ - { - systemId: sourceSystemId, - expectedSystem: sourceAddress, - expectedPublicAccess: true, - targetSystemId, - targetSystem: sourceAddress, - targetPublicAccess: true, - }, - ]); - }); - - it("rejects a same-implementation rename into an active or tombstoned target ID", () => { - const result = plan({ - retirements: [{ systemId: sourceSystemId }], - systemStates: [{ systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }], - }); - - expect(() => - planSystemRenames( - result, - [{ systemId: targetSystemId, address: sourceAddress, publicAccess: true }], - [ - { systemId: sourceSystemId, exists: true, address: sourceAddress, publicAccess: true }, - { - systemId: targetSystemId, - exists: true, - address: "0x0000000000000000000000000000000000000000", - publicAccess: false, - }, - ], - ), - ).toThrowError("target resource ID is not unused"); - }); - - it("keeps the ordinary no-config selector guard fail-closed", () => { - const func = worldFunction(); - expect(() => - planFunctionMigrations({ - functions: [func], - routes: [ - { - selector: func.selector, - systemId: sourceSystemId, - systemFunctionSelector: func.systemFunctionSelector, - }, - ], - migrations: [], - removals: [], - retirements: [], - systemStates: [], - desiredSystems: [{ systemId: targetSystemId }], - }), - ).toThrowError("is already registered with a different route"); - }); -}); diff --git a/packages/cli/src/deploy/functionMigrationPlan.ts b/packages/cli/src/deploy/functionMigrationPlan.ts deleted file mode 100644 index 1db5bd693f..0000000000 --- a/packages/cli/src/deploy/functionMigrationPlan.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { toFunctionSelector, type Address, type Hex } from "viem"; -import type { WorldFunction } from "./common"; -import { type FunctionRegistrationPlan, type FunctionRoute, planFunctionRegistrations } from "./functionPlan"; - -export type FunctionRouteMigration = { - readonly worldSelector: Hex; - readonly fromSystemId: Hex; - readonly fromSystemFunctionSelector: Hex; - readonly toSystemId: Hex; - readonly toSystemFunctionSelector: Hex; -}; - -export type PlannedFunctionRouteMigration = FunctionRouteMigration & { - /** Canonical generated signature whose selector must equal toSystemFunctionSelector. */ - readonly toSystemFunctionSignature: string; -}; - -export type FunctionSelectorRemoval = { - readonly worldSelector: Hex; - readonly expectedSystemId: Hex; - readonly expectedSystemFunctionSelector: Hex; -}; - -export type SystemRetirement = { - readonly systemId: Hex; -}; - -export type SystemState = { - readonly systemId: Hex; - readonly exists: boolean; - readonly address: Address; - readonly publicAccess: boolean; -}; - -export type DesiredSystem = { - readonly systemId: Hex; - readonly address: Address; - readonly publicAccess: boolean; -}; - -export type PlannedSystemRename = PlannedSystemRetirement & { - readonly targetSystemId: Hex; - readonly targetSystem: Address; - readonly targetPublicAccess: boolean; -}; - -export type PlannedSystemRetirement = { - readonly systemId: Hex; - readonly expectedSystem: Address; - readonly expectedPublicAccess: boolean; -}; - -export type FunctionMigrationPlan = { - /** Function registrations to perform after applying the selector migrations. */ - readonly functionPlan: FunctionRegistrationPlan; - readonly migrationsToApply: readonly PlannedFunctionRouteMigration[]; - readonly migrationsAlreadyApplied: readonly FunctionRouteMigration[]; - readonly migrationsNotApplicable: readonly FunctionRouteMigration[]; - readonly removalsToApply: readonly FunctionSelectorRemoval[]; - readonly removalsAlreadyApplied: readonly FunctionSelectorRemoval[]; - readonly retirementsToApply: readonly PlannedSystemRetirement[]; - readonly retirementsAlreadyApplied: readonly SystemRetirement[]; - readonly retirementsNotFound: readonly SystemRetirement[]; -}; - -const zeroAddress = "0x0000000000000000000000000000000000000000"; - -function normalizeHex(value: Hex): string { - return value.toLowerCase(); -} - -function sameHex(a: Hex, b: Hex): boolean { - return normalizeHex(a) === normalizeHex(b); -} - -function sameRoute( - route: Pick, - systemId: Hex, - systemFunctionSelector: Hex, -): boolean { - return sameHex(route.systemId, systemId) && sameHex(route.systemFunctionSelector, systemFunctionSelector); -} - -function formatRoute(route: Pick | undefined): string { - return route == null - ? "" - : `systemId=${route.systemId}, systemFunctionSelector=${route.systemFunctionSelector}`; -} - -function setUnique(map: Map, key: Hex, value: T, description: string): void { - const normalizedKey = normalizeHex(key); - if (map.has(normalizedKey)) { - throw new Error(`Duplicate ${description} for ${key}.`); - } - map.set(normalizedKey, value); -} - -/** - * Build the complete selector lifecycle plan from an exact onchain snapshot. - * - * The configured source tuples are compare-and-swap guards. A migration or removal - * may only be pending or already applied; every other state fails before deployment. - */ -export function planFunctionMigrations({ - functions, - routes, - migrations, - removals, - retirements, - systemStates, - desiredSystems, -}: { - readonly functions: readonly WorldFunction[]; - readonly routes: readonly FunctionRoute[]; - readonly migrations: readonly FunctionRouteMigration[]; - readonly removals: readonly FunctionSelectorRemoval[]; - readonly retirements: readonly SystemRetirement[]; - readonly systemStates: readonly SystemState[]; - readonly desiredSystems: readonly Pick[]; -}): FunctionMigrationPlan { - const routesBySelector = new Map(); - for (const route of routes) { - setUnique(routesBySelector, route.selector, route, "live selector route"); - } - - const desiredFunctionsBySelector = new Map(); - for (const func of functions) { - const key = normalizeHex(func.selector); - const existing = desiredFunctionsBySelector.get(key); - if (existing == null) { - desiredFunctionsBySelector.set(key, func); - } else if ( - existing.signature !== func.signature || - existing.systemFunctionSignature !== func.systemFunctionSignature || - !sameHex(existing.systemId, func.systemId) || - !sameHex(existing.systemFunctionSelector, func.systemFunctionSelector) - ) { - throw new Error(`Configured functions collide on world selector ${func.selector}.`); - } - } - - const desiredSystemsById = new Map>(); - for (const system of desiredSystems) { - setUnique(desiredSystemsById, system.systemId, system, "configured system ID"); - } - - const systemStatesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); - const migrationsBySelector = new Map(); - for (const migration of migrations) { - if ( - sameHex(migration.fromSystemId, migration.toSystemId) && - sameHex(migration.fromSystemFunctionSelector, migration.toSystemFunctionSelector) - ) { - throw new Error(`Selector migration ${migration.worldSelector} must replace the current route tuple.`); - } - - const desired = desiredFunctionsBySelector.get(normalizeHex(migration.worldSelector)); - if ( - desired == null || - !sameHex(desired.systemId, migration.toSystemId) || - !sameHex(desired.systemFunctionSelector, migration.toSystemFunctionSelector) || - !sameHex(toFunctionSelector(desired.systemFunctionSignature), migration.toSystemFunctionSelector) - ) { - throw new Error( - [ - `Selector migration ${migration.worldSelector} does not match an exact configured destination route.`, - `Migration destination: systemId=${migration.toSystemId}, systemFunctionSelector=${migration.toSystemFunctionSelector}`, - desired == null - ? "Configured destination: " - : `Configured destination: systemId=${desired.systemId}, systemFunctionSelector=${desired.systemFunctionSelector}`, - ].join("\n"), - ); - } - - if (!desiredSystemsById.has(normalizeHex(migration.toSystemId))) { - throw new Error( - `Selector migration ${migration.worldSelector} targets system ${migration.toSystemId}, which is not configured for deployment.`, - ); - } - - const key = normalizeHex(migration.worldSelector); - const alternatives = migrationsBySelector.get(key) ?? []; - const first = alternatives[0]; - if ( - first != null && - (!sameHex(first.toSystemId, migration.toSystemId) || - !sameHex(first.toSystemFunctionSelector, migration.toSystemFunctionSelector)) - ) { - throw new Error( - `Alternative migrations for ${migration.worldSelector} must share the same final destination tuple.`, - ); - } - if ( - alternatives.some( - (alternative) => - sameHex(alternative.fromSystemId, migration.fromSystemId) && - sameHex(alternative.fromSystemFunctionSelector, migration.fromSystemFunctionSelector), - ) - ) { - throw new Error( - `Duplicate migration source ${migration.fromSystemId}/${migration.fromSystemFunctionSelector} for selector ${migration.worldSelector}.`, - ); - } - migrationsBySelector.set(key, [...alternatives, migration]); - } - - const removalsBySelector = new Map(); - for (const removal of removals) { - setUnique(removalsBySelector, removal.worldSelector, removal, "selector removal"); - if (migrationsBySelector.has(normalizeHex(removal.worldSelector))) { - throw new Error(`Selector ${removal.worldSelector} cannot be both migrated and removed.`); - } - if (desiredFunctionsBySelector.has(normalizeHex(removal.worldSelector))) { - throw new Error(`Selector removal ${removal.worldSelector} is still present in the configured World ABI.`); - } - } - - const migrationsToApply: PlannedFunctionRouteMigration[] = []; - const migrationsAlreadyApplied: FunctionRouteMigration[] = []; - const migrationsNotApplicable: FunctionRouteMigration[] = []; - const effectiveRoutesBySelector = new Map(routesBySelector); - - for (const alternatives of migrationsBySelector.values()) { - const migration = alternatives[0]; - const key = normalizeHex(migration.worldSelector); - const current = routesBySelector.get(key); - if (current != null && sameRoute(current, migration.toSystemId, migration.toSystemFunctionSelector)) { - // TODO: An exact destination route does not currently audit the offchain FunctionSignatures table. - // Future work can derive missing metadata from authoritative logs and schedule a one-shot repair - // without requiring root authority on every idempotent deployment. - migrationsAlreadyApplied.push(migration); - continue; - } - const matchingSource = alternatives.find( - (alternative) => - current != null && sameRoute(current, alternative.fromSystemId, alternative.fromSystemFunctionSelector), - ); - if (matchingSource != null) { - const desired = desiredFunctionsBySelector.get(key); - if (desired == null) throw new Error(`Missing configured destination for ${matchingSource.worldSelector}.`); - migrationsToApply.push({ - ...matchingSource, - toSystemFunctionSignature: desired.systemFunctionSignature, - }); - effectiveRoutesBySelector.set(key, { - selector: migration.worldSelector, - systemId: migration.toSystemId, - systemFunctionSelector: migration.toSystemFunctionSelector, - }); - continue; - } - - if (current == null) { - const existingSources = alternatives.filter((alternative) => { - const state = systemStatesById.get(normalizeHex(alternative.fromSystemId)); - return state?.exists || (state != null && !sameHex(state.address, zeroAddress)); - }); - if (existingSources.length === 0) { - migrationsNotApplicable.push(migration); - continue; - } - } - - throw new Error( - [ - `Selector migration preflight failed for ${migration.worldSelector}.`, - "Accepted sources:", - ...alternatives.map( - (alternative) => - `- systemId=${alternative.fromSystemId}, systemFunctionSelector=${alternative.fromSystemFunctionSelector}`, - ), - `Expected destination: systemId=${migration.toSystemId}, systemFunctionSelector=${migration.toSystemFunctionSelector}`, - `Current: ${formatRoute(current)}`, - ].join("\n"), - ); - } - - const removalsToApply: FunctionSelectorRemoval[] = []; - const removalsAlreadyApplied: FunctionSelectorRemoval[] = []; - for (const removal of removals) { - const key = normalizeHex(removal.worldSelector); - const current = routesBySelector.get(key); - if (current == null) { - removalsAlreadyApplied.push(removal); - continue; - } - if (sameRoute(current, removal.expectedSystemId, removal.expectedSystemFunctionSelector)) { - removalsToApply.push(removal); - effectiveRoutesBySelector.delete(key); - continue; - } - - throw new Error( - [ - `Selector removal preflight failed for ${removal.worldSelector}.`, - `Expected: systemId=${removal.expectedSystemId}, systemFunctionSelector=${removal.expectedSystemFunctionSelector}`, - `Current: ${formatRoute(current)}`, - ].join("\n"), - ); - } - - // Running the ordinary planner against the projected post-migration routes preserves - // the fail-closed behavior when no lifecycle config is present. - const functionPlan = planFunctionRegistrations(functions, [...effectiveRoutesBySelector.values()]); - - const retirementIds = new Set(); - const retirementsToApply: PlannedSystemRetirement[] = []; - const retirementsAlreadyApplied: SystemRetirement[] = []; - const retirementsNotFound: SystemRetirement[] = []; - - for (const retirement of retirements) { - const key = normalizeHex(retirement.systemId); - if (retirementIds.has(key)) { - throw new Error(`Duplicate system retirement for ${retirement.systemId}.`); - } - retirementIds.add(key); - - if (desiredSystemsById.has(key)) { - throw new Error(`System ${retirement.systemId} cannot be both configured and retired.`); - } - - const state = systemStatesById.get(key); - if (state == null || !state.exists) { - retirementsNotFound.push(retirement); - continue; - } - if (sameHex(state.address, zeroAddress)) { - retirementsAlreadyApplied.push(retirement); - continue; - } - retirementsToApply.push({ - systemId: retirement.systemId, - expectedSystem: state.address, - expectedPublicAccess: state.publicAccess, - }); - } - - for (const retirement of retirementsToApply) { - const remainingRoutes = [...effectiveRoutesBySelector.values()].filter((route) => - sameHex(route.systemId, retirement.systemId), - ); - if (remainingRoutes.length > 0) { - throw new Error( - [ - `Cannot retire system ${retirement.systemId}: live selector routes would still reference it.`, - ...remainingRoutes.map((route) => `- ${route.selector} -> ${route.systemId}/${route.systemFunctionSelector}`), - "Declare an exact selector migration or removal for every remaining route.", - ].join("\n"), - ); - } - } - - return { - functionPlan, - migrationsToApply, - migrationsAlreadyApplied, - migrationsNotApplicable, - removalsToApply, - removalsAlreadyApplied, - retirementsToApply, - retirementsAlreadyApplied, - retirementsNotFound, - }; -} - -/** - * Plan same-implementation renames that must be kept out of ordinary ensureSystems. - * The atomic batch retires the old ID before registering the implementation at the - * unused target ID, and only then replaces/removes the old selector routes. - */ -export function planSystemRenames( - plan: Pick, - desiredSystems: readonly DesiredSystem[], - systemStates: readonly SystemState[], -): readonly PlannedSystemRename[] { - const statesById = new Map(systemStates.map((state) => [normalizeHex(state.systemId), state])); - const renames: PlannedSystemRename[] = []; - - for (const retirement of plan.retirementsToApply) { - const reusedAddresses = desiredSystems.filter( - (system) => !sameHex(system.systemId, retirement.systemId) && sameHex(system.address, retirement.expectedSystem), - ); - if (reusedAddresses.length === 0) continue; - if (reusedAddresses.length > 1) { - throw new Error( - `System ${retirement.systemId} implementation ${retirement.expectedSystem} has multiple configured rename targets.`, - ); - } - - const target = reusedAddresses[0]; - const targetState = statesById.get(normalizeHex(target.systemId)); - if (targetState == null) { - throw new Error(`Missing preflight state for system rename target ${target.systemId}.`); - } - if (targetState.exists || !sameHex(targetState.address, zeroAddress) || targetState.publicAccess) { - throw new Error( - [ - `Cannot rename system ${retirement.systemId} to ${target.systemId}: the target resource ID is not unused.`, - `Target exists=${String(targetState.exists)}, address=${targetState.address}, publicAccess=${String(targetState.publicAccess)}.`, - "Retired system IDs are permanent tombstones and cannot be reused.", - ].join("\n"), - ); - } - - renames.push({ - ...retirement, - targetSystemId: target.systemId, - targetSystem: target.address, - targetPublicAccess: target.publicAccess, - }); - } - - return renames; -} diff --git a/packages/cli/src/deploy/functionPlan.test.ts b/packages/cli/src/deploy/functionPlan.test.ts index b71aaa0bd6..1057b051df 100644 --- a/packages/cli/src/deploy/functionPlan.test.ts +++ b/packages/cli/src/deploy/functionPlan.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { Hex } from "viem"; +import { Hex, toFunctionSelector, zeroHash } from "viem"; +import { resourceToHex } from "@latticexyz/common"; import { WorldFunction } from "./common"; import { assertFunctionPlanApplied, planFunctionRegistrations } from "./functionPlan"; @@ -21,7 +22,7 @@ describe("planFunctionRegistrations", () => { it("adds missing routes", () => { const func = worldFunction(); - expect(planFunctionRegistrations([func], [])).toEqual({ toAdd: [func], toSkip: [] }); + expect(planFunctionRegistrations([func], [])).toEqual({ toAdd: [func], toSkip: [], toReconcile: [] }); }); it("skips routes only when the complete tuple matches", () => { @@ -38,10 +39,25 @@ describe("planFunctionRegistrations", () => { }, ], ), - ).toEqual({ toAdd: [], toSkip: [func] }); + ).toEqual({ toAdd: [], toSkip: [func], toReconcile: [] }); }); - it("rejects a selector registered to another system", () => { + it("reconciles a selector registered to another System when the System function selector is exact", () => { + const func = worldFunction(); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + + expect(planFunctionRegistrations([func], [expectedRoute])).toEqual({ + toAdd: [], + toSkip: [], + toReconcile: [{ func, expectedRoute }], + }); + }); + + it("rejects a selector registered to another system function", () => { const func = worldFunction(); expect(() => @@ -50,35 +66,52 @@ describe("planFunctionRegistrations", () => { [ { selector: func.selector, - systemId: sourceSystemId, - systemFunctionSelector: func.systemFunctionSelector, + systemId: func.systemId, + systemFunctionSelector: "0xaaaaaaaa", }, ], ), - ).toThrowError( - [ - `World function ${func.signature} (${func.selector}) is already registered with a different route.`, - `Configured: systemId=${targetSystemId}, systemFunctionSelector=${func.systemFunctionSelector}`, - `Registered: systemId=${sourceSystemId}, systemFunctionSelector=${func.systemFunctionSelector}`, - ].join("\n"), - ); + ).toThrowError(`Registered: systemId=${targetSystemId}, systemFunctionSelector=0xaaaaaaaa`); }); - it("rejects a selector registered to another system function", () => { + it("rejects malformed partial routes instead of treating them as reconcilable", () => { const func = worldFunction(); expect(() => planFunctionRegistrations( [func], - [ - { - selector: func.selector, - systemId: func.systemId, - systemFunctionSelector: "0xaaaaaaaa", - }, - ], + [{ selector: func.selector, systemId: zeroHash, systemFunctionSelector: func.systemFunctionSelector }], ), - ).toThrowError(`Registered: systemId=${targetSystemId}, systemFunctionSelector=0xaaaaaaaa`); + ).toThrowError("has a malformed registered route"); + }); + + it("plans the two real PlayerStash selector repairs", () => { + const legacySystemId = resourceToHex({ type: "system", namespace: "valhalla", name: "PlayerStashSyste" }); + const desiredSystemId = resourceToHex({ type: "system", namespace: "valhalla", name: "p_stash" }); + const systemSignatures = [ + "exchangeItems(uint64[],uint64[],uint64[],uint64[])", + "exchangeAmulets(uint64[],uint64[])", + ]; + const functions = systemSignatures.map( + (systemFunctionSignature): WorldFunction => ({ + signature: `valhalla__${systemFunctionSignature}`, + selector: toFunctionSelector(`valhalla__${systemFunctionSignature}`), + systemId: desiredSystemId, + systemFunctionSignature, + systemFunctionSelector: toFunctionSelector(systemFunctionSignature), + }), + ); + const routes = functions.map((func) => ({ + selector: func.selector, + systemId: legacySystemId, + systemFunctionSelector: func.systemFunctionSelector, + })); + + expect(planFunctionRegistrations(functions, routes)).toEqual({ + toAdd: [], + toSkip: [], + toReconcile: functions.map((func, index) => ({ func, expectedRoute: routes[index] })), + }); }); it("rejects conflicting configured functions", () => { @@ -97,7 +130,7 @@ describe("planFunctionRegistrations", () => { it("deduplicates exact configured functions", () => { const func = worldFunction(); - expect(planFunctionRegistrations([func, func], [])).toEqual({ toAdd: [func], toSkip: [] }); + expect(planFunctionRegistrations([func, func], [])).toEqual({ toAdd: [func], toSkip: [], toReconcile: [] }); }); }); @@ -105,8 +138,21 @@ describe("assertFunctionPlanApplied", () => { it("rejects routes that remain missing", () => { const func = worldFunction(); - expect(() => assertFunctionPlanApplied({ toAdd: [func], toSkip: [] })).toThrowError( + expect(() => assertFunctionPlanApplied({ toAdd: [func], toSkip: [], toReconcile: [] })).toThrowError( `Function route verification failed after deployment`, ); }); + + it("rejects routes that still need reconciliation", () => { + const func = worldFunction(); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + + expect(() => + assertFunctionPlanApplied({ toAdd: [], toSkip: [], toReconcile: [{ func, expectedRoute }] }), + ).toThrowError("Routes still pointing at stale System IDs"); + }); }); diff --git a/packages/cli/src/deploy/functionPlan.ts b/packages/cli/src/deploy/functionPlan.ts index 32512fc73f..a59070d67f 100644 --- a/packages/cli/src/deploy/functionPlan.ts +++ b/packages/cli/src/deploy/functionPlan.ts @@ -1,4 +1,4 @@ -import { Hex } from "viem"; +import { Hex, zeroHash } from "viem"; import { WorldFunction } from "./common"; export type FunctionRoute = { @@ -10,8 +10,15 @@ export type FunctionRoute = { export type FunctionRegistrationPlan = { readonly toAdd: readonly WorldFunction[]; readonly toSkip: readonly WorldFunction[]; + /** Existing routes whose System ID can be reconciled without changing their function selector. */ + readonly toReconcile: readonly { + readonly func: WorldFunction; + readonly expectedRoute: FunctionRoute; + }[]; }; +const emptyFunctionSelector = "0x00000000"; + function normalizeHex(value: Hex): string { return value.toLowerCase(); } @@ -26,6 +33,16 @@ function routesMatch( ); } +function sameHex(a: Hex, b: Hex): boolean { + return normalizeHex(a) === normalizeHex(b); +} + +export function isMalformedFunctionRoute(route: FunctionRoute): boolean { + const hasSystemId = !sameHex(route.systemId, zeroHash); + const hasSystemFunctionSelector = !sameHex(route.systemFunctionSelector, emptyFunctionSelector); + return hasSystemId !== hasSystemFunctionSelector; +} + function formatRoute(route: Pick): string { return `systemId=${route.systemId}, systemFunctionSelector=${route.systemFunctionSelector}`; } @@ -62,7 +79,9 @@ function uniqueConfiguredFunctions(functions: readonly WorldFunction[]): readonl /** * Build a write plan from configured functions and a read-only snapshot of registered routes. - * Existing selectors are immutable here: a non-exact route must be handled by an explicit migration. + * An existing selector may be reconciled only when its System function selector is already exact. + * This permits correcting a stale System ID with the World's narrow Store write API without + * changing which function is dispatched inside the destination System. */ export function planFunctionRegistrations( functions: readonly WorldFunction[], @@ -71,6 +90,7 @@ export function planFunctionRegistrations( const routesBySelector = new Map(registeredRoutes.map((route) => [normalizeHex(route.selector), route])); const toAdd: WorldFunction[] = []; const toSkip: WorldFunction[] = []; + const toReconcile: { func: WorldFunction; expectedRoute: FunctionRoute }[] = []; for (const func of uniqueConfiguredFunctions(functions)) { const registered = routesBySelector.get(normalizeHex(func.selector)); @@ -84,26 +104,47 @@ export function planFunctionRegistrations( continue; } + if (isMalformedFunctionRoute(registered)) { + throw new Error( + [ + `World function ${func.signature} (${func.selector}) has a malformed registered route.`, + `Registered: ${formatRoute(registered)}`, + "Refusing to infer or overwrite an unknown selector state.", + ].join("\n"), + ); + } + + if (sameHex(func.systemFunctionSelector, registered.systemFunctionSelector)) { + toReconcile.push({ func, expectedRoute: registered }); + continue; + } + throw new Error( [ `World function ${func.signature} (${func.selector}) is already registered with a different route.`, `Configured: ${formatRoute(func)}`, `Registered: ${formatRoute(registered)}`, - "Refusing to overwrite the selector. Apply an explicit selector migration before deploying this config.", + "Refusing to change the System function selector through deployment reconciliation.", ].join("\n"), ); } - return { toAdd, toSkip }; + return { toAdd, toSkip, toReconcile }; } export function assertFunctionPlanApplied(plan: FunctionRegistrationPlan): void { - if (plan.toAdd.length === 0) return; + if (plan.toAdd.length === 0 && plan.toReconcile.length === 0) return; throw new Error( [ - "Function route verification failed after deployment. The following configured routes are still missing:", + "Function route verification failed after deployment:", + ...(plan.toAdd.length > 0 ? ["Missing routes:"] : []), ...plan.toAdd.map((func) => `- ${func.signature} (${func.selector}): ${formatRoute(func)}`), + ...(plan.toReconcile.length > 0 ? ["Routes still pointing at stale System IDs:"] : []), + ...plan.toReconcile.map( + ({ func, expectedRoute }) => + `- ${func.signature} (${func.selector}): expected ${formatRoute(func)}, current ${formatRoute(expectedRoute)}`, + ), ].join("\n"), ); } diff --git a/packages/cli/src/deploy/getFunctionRoutes.test.ts b/packages/cli/src/deploy/getFunctionRoutes.test.ts deleted file mode 100644 index e9e71fc94b..0000000000 --- a/packages/cli/src/deploy/getFunctionRoutes.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CommonDeployOptions } from "./common"; -import { getFunctionDiscoveryOptions } from "./getFunctionRoutes"; - -const common = { - client: {} as CommonDeployOptions["client"], - worldDeploy: { - address: "0x1111111111111111111111111111111111111111", - worldVersion: "2.1.0", - storeVersion: "2.0.2", - deployBlock: 10n, - stateBlock: 20n, - }, - indexerUrl: "https://indexer.invalid", - chainId: 31337, -} satisfies CommonDeployOptions; - -describe("getFunctionDiscoveryOptions", () => { - it("omits indexer options for an authoritative retirement inventory", () => { - const options = getFunctionDiscoveryOptions({ ...common, authoritative: true }); - - expect(options).not.toHaveProperty("indexerUrl"); - expect(options).not.toHaveProperty("chainId"); - expect(options).toMatchObject({ fromBlock: 10n, toBlock: 20n }); - }); - - it("retains indexer options for non-destructive discovery", () => { - expect(getFunctionDiscoveryOptions({ ...common, authoritative: false })).toMatchObject({ - indexerUrl: common.indexerUrl, - chainId: common.chainId, - }); - }); -}); diff --git a/packages/cli/src/deploy/getFunctionRoutes.ts b/packages/cli/src/deploy/getFunctionRoutes.ts index 79dc8ab210..9eb2b5befa 100644 --- a/packages/cli/src/deploy/getFunctionRoutes.ts +++ b/packages/cli/src/deploy/getFunctionRoutes.ts @@ -1,6 +1,5 @@ import type { Hex } from "viem"; import { zeroHash } from "viem"; -import { getFunctions } from "@latticexyz/store-sync/world"; import worldConfig from "@latticexyz/world/mud.config"; import type { CommonDeployOptions } from "./common"; import type { FunctionRoute } from "./functionPlan"; @@ -48,47 +47,3 @@ export async function getFunctionRoutes({ return routes.filter((route) => !isEmptyFunctionRoute(route)); } - -/** - * Enumerate every selector row from Store events/indexer, then re-read every tuple - * directly at the deployment snapshot block. Explicit selectors are always included - * so stale or incomplete indexes cannot make a declared migration look unregistered. - */ -export function getFunctionDiscoveryOptions({ - client, - worldDeploy, - indexerUrl, - chainId, - authoritative, -}: CommonDeployOptions & { readonly authoritative: boolean }) { - const rpcOptions = { - client, - worldAddress: worldDeploy.address, - fromBlock: worldDeploy.deployBlock, - toBlock: worldDeploy.stateBlock, - }; - return authoritative ? rpcOptions : { ...rpcOptions, indexerUrl, chainId }; -} - -export async function getAllFunctionRoutes({ - client, - worldDeploy, - indexerUrl, - chainId, - additionalSelectors = [], - authoritative = false, -}: CommonDeployOptions & { - readonly additionalSelectors?: readonly Hex[]; - /** Force exhaustive RPC log enumeration at the pinned state block. */ - readonly authoritative?: boolean; -}): Promise { - const discovered = await getFunctions( - getFunctionDiscoveryOptions({ client, worldDeploy, indexerUrl, chainId, authoritative }), - ); - - return getFunctionRoutes({ - client, - worldDeploy, - selectors: [...discovered.map((func) => func.selector), ...additionalSelectors], - }); -} diff --git a/packages/cli/src/deploy/systemAccess.ts b/packages/cli/src/deploy/systemAccess.ts deleted file mode 100644 index c3185f24d9..0000000000 --- a/packages/cli/src/deploy/systemAccess.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getAddress, type Address, type Hex } from "viem"; -import { hexToResource, resourceToHex } from "@latticexyz/common"; - -export type SystemAccess = { readonly resourceId: Hex; readonly address: Address }; - -export function getSystemNamespaceId(systemId: Hex): Hex { - return resourceToHex({ type: "namespace", namespace: hexToResource(systemId).namespace, name: "" }); -} - -export function hasSystemNamespaceGrant({ - systemId, - systemAddress, - worldAccess, -}: { - readonly systemId: Hex; - readonly systemAddress: Address; - readonly worldAccess: readonly SystemAccess[]; -}): boolean { - const namespaceId = getSystemNamespaceId(systemId); - return worldAccess.some( - ({ resourceId, address }) => - resourceId.toLowerCase() === namespaceId.toLowerCase() && getAddress(address) === getAddress(systemAddress), - ); -} diff --git a/packages/store-sync/src/world/getFunctions.ts b/packages/store-sync/src/world/getFunctions.ts index b6bddc9af5..4ae3fd8858 100644 --- a/packages/store-sync/src/world/getFunctions.ts +++ b/packages/store-sync/src/world/getFunctions.ts @@ -19,7 +19,7 @@ export async function getFunctions({ readonly indexerUrl?: string; readonly chainId?: number; }): Promise { - // getRecords folds Set/Splice/Delete logs into the current live selector rows. + // This assumes we only use `FunctionSelectors._set(...)`, which is true as of this writing. debug("looking up function selectors for", worldAddress); const { records: selectors } = await getRecords({ diff --git a/packages/world/src/IWorldErrors.sol b/packages/world/src/IWorldErrors.sol index 4438b8c52b..e702f86695 100644 --- a/packages/world/src/IWorldErrors.sol +++ b/packages/world/src/IWorldErrors.sol @@ -55,44 +55,6 @@ interface IWorldErrors { */ error World_SystemAlreadyExists(address system); - /** - * @notice Raised when trying to register a system at a permanently retired system ID. - * @param systemId The retired system ID. - * @param systemIdString The string representation of the retired system ID. - */ - error World_SystemAlreadyRetired(ResourceId systemId, string systemIdString); - - /** - * @notice Raised when trying to permanently retire a protected core system. - * @param systemId The protected system ID. - * @param systemIdString The string representation of the protected system ID. - */ - error World_SystemCannotBeRetired(ResourceId systemId, string systemIdString); - - /** - * @notice Raised when the current system state does not match the expected system state. - * @param systemId The system ID being checked. - * @param expectedSystem The expected system address. - * @param expectedPublicAccess The expected public access flag. - * @param actualSystem The current system address. - * @param actualPublicAccess The current public access flag. - */ - error World_SystemStateMismatch( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - address actualSystem, - bool actualPublicAccess - ); - - /** - * @notice Raised when a system's reverse registry entry does not match its system ID. - * @param system The system address being checked. - * @param expectedSystemId The system ID expected in the reverse registry. - * @param actualSystemId The current system ID in the reverse registry. - */ - error World_SystemRegistryMismatch(address system, ResourceId expectedSystemId, ResourceId actualSystemId); - /** * @notice Raised when trying to register a function selector that already exists. * @param functionSelector The function selector in question. @@ -105,22 +67,6 @@ interface IWorldErrors { */ error World_FunctionSelectorNotFound(bytes4 functionSelector); - /** - * @notice Raised when a function selector route does not match the expected route. - * @param worldFunctionSelector The World function selector being checked. - * @param expectedSystemId The expected system ID. - * @param expectedSystemFunctionSelector The expected system function selector. - * @param actualSystemId The current system ID. - * @param actualSystemFunctionSelector The current system function selector. - */ - error World_FunctionSelectorMismatch( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId actualSystemId, - bytes4 actualSystemFunctionSelector - ); - /** * @notice Raised when the specified delegation is not found. * @param delegator The address of the delegator. diff --git a/packages/world/src/IWorldEvents.sol b/packages/world/src/IWorldEvents.sol index 04c9299efe..03fa598f11 100644 --- a/packages/world/src/IWorldEvents.sol +++ b/packages/world/src/IWorldEvents.sol @@ -17,39 +17,4 @@ interface IWorldEvents { * @param worldVersion The protocol version of the World. */ event HelloWorld(bytes32 indexed worldVersion); - - /** - * @notice Emitted when a World function selector's complete route is replaced. - */ - event WorldFunctionRouteReplaced( - bytes4 indexed worldFunctionSelector, - ResourceId indexed oldSystemId, - ResourceId indexed newSystemId, - bytes4 oldSystemFunctionSelector, - bytes4 newSystemFunctionSelector - ); - - /** - * @notice Emitted when a World function selector is unregistered. - */ - event WorldFunctionSelectorUnregistered( - bytes4 indexed worldFunctionSelector, - ResourceId indexed systemId, - bytes4 systemFunctionSelector - ); - - /** - * @notice Emitted when a system is registered or replaced through the compare-and-swap registration primitive. - */ - event WorldSystemReplaced( - ResourceId indexed systemId, - address indexed oldSystem, - address indexed newSystem, - bool publicAccess - ); - - /** - * @notice Emitted when a system is permanently retired. - */ - event WorldSystemRetired(ResourceId indexed systemId, address indexed system); } diff --git a/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol b/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol index 0806f9d274..7ce9beabc5 100644 --- a/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol +++ b/packages/world/src/codegen/experimental/systems/WorldRegistrationSystemLib.sol @@ -69,33 +69,6 @@ library WorldRegistrationSystemLib { return CallWrapper(self.toResourceId(), address(0)).registerSystem(systemId, system, publicAccess); } - function replaceSystem( - WorldRegistrationSystemType self, - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess - ) internal { - return - CallWrapper(self.toResourceId(), address(0)).replaceSystem( - systemId, - expectedSystem, - expectedPublicAccess, - newSystem, - publicAccess - ); - } - - function retireSystem( - WorldRegistrationSystemType self, - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess - ) internal { - return CallWrapper(self.toResourceId(), address(0)).retireSystem(systemId, expectedSystem, expectedPublicAccess); - } - function registerFunctionSelector( WorldRegistrationSystemType self, ResourceId systemId, @@ -118,38 +91,6 @@ library WorldRegistrationSystemLib { ); } - function replaceFunctionRoute( - WorldRegistrationSystemType self, - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature - ) internal { - return - CallWrapper(self.toResourceId(), address(0)).replaceFunctionRoute( - worldFunctionSelector, - expectedSystemId, - expectedSystemFunctionSelector, - newSystemId, - newSystemFunctionSignature - ); - } - - function unregisterFunctionSelector( - WorldRegistrationSystemType self, - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector - ) internal { - return - CallWrapper(self.toResourceId(), address(0)).unregisterFunctionSelector( - worldFunctionSelector, - expectedSystemId, - expectedSystemFunctionSelector - ); - } - function registerDelegation( WorldRegistrationSystemType self, address delegatee, @@ -236,44 +177,6 @@ library WorldRegistrationSystemLib { : _world().callFrom(self.from, self.systemId, systemCall); } - function replaceSystem( - CallWrapper memory self, - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess - ) internal { - // if the contract calling this function is a root system, it should use `callAsRoot` - if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); - - bytes memory systemCall = abi.encodeCall( - _replaceSystem_ResourceId_address_bool_System_bool.replaceSystem, - (systemId, expectedSystem, expectedPublicAccess, newSystem, publicAccess) - ); - self.from == address(0) - ? _world().call(self.systemId, systemCall) - : _world().callFrom(self.from, self.systemId, systemCall); - } - - function retireSystem( - CallWrapper memory self, - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess - ) internal { - // if the contract calling this function is a root system, it should use `callAsRoot` - if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); - - bytes memory systemCall = abi.encodeCall( - _retireSystem_ResourceId_address_bool.retireSystem, - (systemId, expectedSystem, expectedPublicAccess) - ); - self.from == address(0) - ? _world().call(self.systemId, systemCall) - : _world().callFrom(self.from, self.systemId, systemCall); - } - function registerFunctionSelector( CallWrapper memory self, ResourceId systemId, @@ -319,44 +222,6 @@ library WorldRegistrationSystemLib { } } - function replaceFunctionRoute( - CallWrapper memory self, - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature - ) internal { - // if the contract calling this function is a root system, it should use `callAsRoot` - if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); - - bytes memory systemCall = abi.encodeCall( - _replaceFunctionRoute_bytes4_ResourceId_bytes4_ResourceId_string.replaceFunctionRoute, - (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector, newSystemId, newSystemFunctionSignature) - ); - self.from == address(0) - ? _world().call(self.systemId, systemCall) - : _world().callFrom(self.from, self.systemId, systemCall); - } - - function unregisterFunctionSelector( - CallWrapper memory self, - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector - ) internal { - // if the contract calling this function is a root system, it should use `callAsRoot` - if (address(_world()) == address(this)) revert WorldRegistrationSystemLib_CallingFromRootSystem(); - - bytes memory systemCall = abi.encodeCall( - _unregisterFunctionSelector_bytes4_ResourceId_bytes4.unregisterFunctionSelector, - (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector) - ); - self.from == address(0) - ? _world().call(self.systemId, systemCall) - : _world().callFrom(self.from, self.systemId, systemCall); - } - function registerDelegation( CallWrapper memory self, address delegatee, @@ -450,34 +315,6 @@ library WorldRegistrationSystemLib { SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); } - function replaceSystem( - RootCallWrapper memory self, - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess - ) internal { - bytes memory systemCall = abi.encodeCall( - _replaceSystem_ResourceId_address_bool_System_bool.replaceSystem, - (systemId, expectedSystem, expectedPublicAccess, newSystem, publicAccess) - ); - SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); - } - - function retireSystem( - RootCallWrapper memory self, - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess - ) internal { - bytes memory systemCall = abi.encodeCall( - _retireSystem_ResourceId_address_bool.retireSystem, - (systemId, expectedSystem, expectedPublicAccess) - ); - SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); - } - function registerFunctionSelector( RootCallWrapper memory self, ResourceId systemId, @@ -513,34 +350,6 @@ library WorldRegistrationSystemLib { } } - function replaceFunctionRoute( - RootCallWrapper memory self, - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature - ) internal { - bytes memory systemCall = abi.encodeCall( - _replaceFunctionRoute_bytes4_ResourceId_bytes4_ResourceId_string.replaceFunctionRoute, - (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector, newSystemId, newSystemFunctionSignature) - ); - SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); - } - - function unregisterFunctionSelector( - RootCallWrapper memory self, - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector - ) internal { - bytes memory systemCall = abi.encodeCall( - _unregisterFunctionSelector_bytes4_ResourceId_bytes4.unregisterFunctionSelector, - (worldFunctionSelector, expectedSystemId, expectedSystemFunctionSelector) - ); - SystemCall.callWithHooksOrRevert(self.from, self.systemId, systemCall, msg.value); - } - function registerDelegation( RootCallWrapper memory self, address delegatee, @@ -637,20 +446,6 @@ interface _registerSystem_ResourceId_System_bool { function registerSystem(ResourceId systemId, System system, bool publicAccess) external; } -interface _replaceSystem_ResourceId_address_bool_System_bool { - function replaceSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess - ) external; -} - -interface _retireSystem_ResourceId_address_bool { - function retireSystem(ResourceId systemId, address expectedSystem, bool expectedPublicAccess) external; -} - interface _registerFunctionSelector_ResourceId_string { function registerFunctionSelector(ResourceId systemId, string memory systemFunctionSignature) external; } @@ -663,24 +458,6 @@ interface _registerRootFunctionSelector_ResourceId_string_string { ) external; } -interface _replaceFunctionRoute_bytes4_ResourceId_bytes4_ResourceId_string { - function replaceFunctionRoute( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature - ) external; -} - -interface _unregisterFunctionSelector_bytes4_ResourceId_bytes4 { - function unregisterFunctionSelector( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector - ) external; -} - interface _registerDelegation_address_ResourceId_bytes { function registerDelegation(address delegatee, ResourceId delegationControlId, bytes memory initCallData) external; } diff --git a/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol b/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol index 6b0dcd3868..276f3ea674 100644 --- a/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol +++ b/packages/world/src/codegen/interfaces/IWorldRegistrationSystem.sol @@ -21,16 +21,6 @@ interface IWorldRegistrationSystem { function registerSystem(ResourceId systemId, System system, bool publicAccess) external; - function replaceSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess - ) external; - - function retireSystem(ResourceId systemId, address expectedSystem, bool expectedPublicAccess) external; - function registerFunctionSelector( ResourceId systemId, string memory systemFunctionSignature @@ -42,20 +32,6 @@ interface IWorldRegistrationSystem { string memory systemFunctionSignature ) external returns (bytes4 worldFunctionSelector); - function replaceFunctionRoute( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature - ) external; - - function unregisterFunctionSelector( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector - ) external; - function registerDelegation(address delegatee, ResourceId delegationControlId, bytes memory initCallData) external; function unregisterDelegation(address delegatee) external; diff --git a/packages/world/src/modules/init/InitModule.sol b/packages/world/src/modules/init/InitModule.sol index 35ccd819b3..417b2f91b3 100644 --- a/packages/world/src/modules/init/InitModule.sol +++ b/packages/world/src/modules/init/InitModule.sol @@ -143,7 +143,7 @@ contract InitModule is Module { _registerRootFunctionSelector(BATCH_CALL_SYSTEM_ID, functionSignaturesBatchCall[i]); } - string[18] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); + string[14] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); for (uint256 i = 0; i < functionSignaturesRegistration.length; i++) { _registerRootFunctionSelector(REGISTRATION_SYSTEM_ID, functionSignaturesRegistration[i]); } diff --git a/packages/world/src/modules/init/functionSignatures.sol b/packages/world/src/modules/init/functionSignatures.sol index f3a07390f0..e9a7fe13bd 100644 --- a/packages/world/src/modules/init/functionSignatures.sol +++ b/packages/world/src/modules/init/functionSignatures.sol @@ -39,7 +39,7 @@ function getFunctionSignaturesBatchCall() pure returns (string[2] memory) { /** * @dev Function signatures for registration system */ -function getFunctionSignaturesRegistration() pure returns (string[18] memory) { +function getFunctionSignaturesRegistration() pure returns (string[14] memory) { return [ // --- ModuleInstallationSystem --- "installModule(address,bytes)", @@ -52,12 +52,8 @@ function getFunctionSignaturesRegistration() pure returns (string[18] memory) { "registerSystemHook(bytes32,address,uint8)", "unregisterSystemHook(bytes32,address)", "registerSystem(bytes32,address,bool)", - "replaceSystem(bytes32,address,bool,address,bool)", - "retireSystem(bytes32,address,bool)", "registerFunctionSelector(bytes32,string)", "registerRootFunctionSelector(bytes32,string,string)", - "replaceFunctionRoute(bytes4,bytes32,bytes4,bytes32,string)", - "unregisterFunctionSelector(bytes4,bytes32,bytes4)", "registerDelegation(address,bytes32,bytes)", "unregisterDelegation(address)", "registerNamespaceDelegation(bytes32,bytes32,bytes)", diff --git a/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol b/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol index 8b460bb9db..ab89a6964d 100644 --- a/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol +++ b/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol @@ -20,7 +20,6 @@ import { UserDelegationControl } from "../../../codegen/tables/UserDelegationCon import { NamespaceDelegationControl } from "../../../codegen/tables/NamespaceDelegationControl.sol"; import { ISystemHook } from "../../../ISystemHook.sol"; import { IWorldErrors } from "../../../IWorldErrors.sol"; -import { IWorldEvents } from "../../../IWorldEvents.sol"; import { IDelegationControl } from "../../../IDelegationControl.sol"; import { SystemHooks } from "../../../codegen/tables/SystemHooks.sol"; @@ -32,7 +31,6 @@ import { requireNamespace } from "../../../requireNamespace.sol"; import { requireValidNamespace } from "../../../requireValidNamespace.sol"; import { LimitedCallContext } from "../LimitedCallContext.sol"; -import { ACCESS_MANAGEMENT_SYSTEM_ID, BALANCE_TRANSFER_SYSTEM_ID, BATCH_CALL_SYSTEM_ID, REGISTRATION_SYSTEM_ID } from "../constants.sol"; import { createDelegation } from "./createDelegation.sol"; /** @@ -89,8 +87,8 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo // Require the provided address to implement the ISystemHook interface requireInterface(address(hookAddress), type(ISystemHook).interfaceId); - // Require the system to be active (retired system IDs remain registered as tombstones) - _requireActiveSystem(systemId); + // Require the system to exist + AccessControl._requireExistence(systemId); // Require the system's namespace to exist AccessControl._requireExistence(systemId.getNamespaceId()); @@ -130,149 +128,55 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo * @param publicAccess Flag indicating if access control check is bypassed */ function registerSystem(ResourceId systemId, System system, bool publicAccess) public virtual onlyDelegatecall { - ResourceId namespaceId = _validateSystemRegistration(systemId, system); - - // Check if a system already exists at this system ID - address existingSystem = Systems._getSystem(systemId); - - // A resource ID without an active system is a permanent retirement tombstone - if (existingSystem == address(0) && ResourceIds._getExists(systemId)) { - revert World_SystemAlreadyRetired(systemId, systemId.toString()); - } - - _requireSystemAddressAvailable(systemId, system); - _setSystemRegistration(systemId, namespaceId, existingSystem, system, publicAccess); - } - - /** - * @notice Registers or replaces a system only if its current state matches the expected state. - * @dev This compare-and-swap primitive prevents a deployment planned against stale state from replacing a - * concurrently upgraded system. An expected zero address is only valid for a never-used system ID; retired IDs - * remain permanent tombstones. Repeating an already-applied replacement is a no-op when all desired state matches. - * If the expected and new implementation are the same, `publicAccess` and the system's default namespace access - * are safely reconciled. - * @param systemId The unique identifier for the system - * @param expectedSystem The implementation expected to be registered, or zero for a never-used ID - * @param expectedPublicAccess The public access flag expected in the current registration - * @param newSystem The new implementation to register - * @param publicAccess Flag indicating if access control checks are bypassed when calling the system - */ - function replaceSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess, - System newSystem, - bool publicAccess - ) public virtual onlyDelegatecall { - ResourceId namespaceId = _validateSystemRegistration(systemId, newSystem); - (address actualSystem, bool actualPublicAccess) = Systems._get(systemId); - - if (actualSystem != address(0)) { - // Fail closed if the forward and reverse registries disagree about the implementation being replaced. - ResourceId registrySystemId = SystemRegistry._get(actualSystem); - if (ResourceId.unwrap(registrySystemId) != ResourceId.unwrap(systemId)) { - revert World_SystemRegistryMismatch(actualSystem, systemId, registrySystemId); - } - } - - // An exact retry of a completed replacement is a no-op even though its original expectation is now stale. - if ( - actualSystem == address(newSystem) && - actualPublicAccess == publicAccess && - ResourceAccess._get(namespaceId, address(newSystem)) - ) return; - - if (actualSystem != expectedSystem || actualPublicAccess != expectedPublicAccess) { - revert World_SystemStateMismatch( - systemId, - expectedSystem, - expectedPublicAccess, - actualSystem, - actualPublicAccess - ); - } - - if (actualSystem == address(0) && ResourceIds._getExists(systemId)) { - // A registered resource without an active implementation is a permanent retirement tombstone. - revert World_SystemAlreadyRetired(systemId, systemId.toString()); + // Require the provided system ID to have type RESOURCE_SYSTEM + if (systemId.getType() != RESOURCE_SYSTEM) { + revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); } - _requireSystemAddressAvailable(systemId, newSystem); + // Require the system's namespace to exist + ResourceId namespaceId = systemId.getNamespaceId(); + AccessControl._requireExistence(namespaceId); - _setSystemRegistration(systemId, namespaceId, actualSystem, newSystem, publicAccess); + // Require the caller to own the namespace + AccessControl._requireOwner(namespaceId, _msgSender()); - emit IWorldEvents.WorldSystemReplaced(systemId, actualSystem, address(newSystem), publicAccess); - } + // Require the provided address to implement the WorldContextConsumer interface + requireInterface(address(system), type(IWorldContextConsumer).interfaceId); - /** - * @notice Permanently retires an active system. - * @dev The resource ID remains registered as a tombstone and can never be reused. - * Core init Systems cannot be retired; replace their implementations at the stable IDs instead. - * Repeated calls for an already retired system are no-ops. - * @param systemId The ID of the system to retire - * @param expectedSystem The system address expected to be registered at the ID - * @param expectedPublicAccess The public access flag expected in the current registration - */ - function retireSystem( - ResourceId systemId, - address expectedSystem, - bool expectedPublicAccess - ) public virtual onlyDelegatecall { - // Require the provided system ID to have type RESOURCE_SYSTEM - if (systemId.getType() != RESOURCE_SYSTEM) { - revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); - } + // Require the name to not be the namespace's root name + if (systemId.getName() == ROOT_NAME) revert World_InvalidResourceId(systemId, systemId.toString()); - // Core init System IDs are permanent protocol entrypoints. They may be - // compare-and-swap replaced, but a tombstone would make them unrecoverable. - bytes32 unwrappedSystemId = ResourceId.unwrap(systemId); + // Require this system to not be registered at a different system ID yet + ResourceId existingSystemId = SystemRegistry._get(address(system)); if ( - unwrappedSystemId == ResourceId.unwrap(ACCESS_MANAGEMENT_SYSTEM_ID) || - unwrappedSystemId == ResourceId.unwrap(BALANCE_TRANSFER_SYSTEM_ID) || - unwrappedSystemId == ResourceId.unwrap(BATCH_CALL_SYSTEM_ID) || - unwrappedSystemId == ResourceId.unwrap(REGISTRATION_SYSTEM_ID) + ResourceId.unwrap(existingSystemId) != 0 && ResourceId.unwrap(existingSystemId) != ResourceId.unwrap(systemId) ) { - revert World_SystemCannotBeRetired(systemId, systemId.toString()); + revert World_SystemAlreadyExists(address(system)); } - ResourceId namespaceId = systemId.getNamespaceId(); - - // Require the system's namespace to exist and the caller to own it - AccessControl._requireExistence(namespaceId); - AccessControl._requireOwner(systemId, _msgSender()); - - (address actualSystem, bool actualPublicAccess) = Systems._get(systemId); + // Check if a system already exists at this system ID + address existingSystem = Systems._getSystem(systemId); - // A registered system resource without an active address is already retired - if (actualSystem == address(0)) { - if (ResourceIds._getExists(systemId)) return; - revert World_ResourceNotFound(systemId, systemId.toString()); - } + // If there is an existing system with this system ID, remove it + if (existingSystem != address(0)) { + // Remove the existing system from the system registry + SystemRegistry._deleteRecord(existingSystem); - // Compare-and-swap guard against retiring a replacement system - if (actualSystem != expectedSystem || actualPublicAccess != expectedPublicAccess) { - revert World_SystemStateMismatch( - systemId, - expectedSystem, - expectedPublicAccess, - actualSystem, - actualPublicAccess - ); + // Remove the existing system's access to its namespace + ResourceAccess._deleteRecord(namespaceId, existingSystem); + } else { + // Otherwise, this is a new system, so register its resource ID + ResourceIds._setExists(systemId, true); } - // Verify the reverse registry before deleting it, so inconsistent state fails closed - ResourceId registrySystemId = SystemRegistry._get(actualSystem); - if (ResourceId.unwrap(registrySystemId) != ResourceId.unwrap(systemId)) { - revert World_SystemRegistryMismatch(actualSystem, systemId, registrySystemId); - } + // Systems = mapping from system ID to system address and public access flag + Systems._set(systemId, address(system), publicAccess); - // Remove all state that makes the system active, but keep ResourceIds as a permanent tombstone - SystemHooks._deleteRecord(systemId); - SystemRegistry._deleteRecord(actualSystem); - ResourceAccess._deleteRecord(namespaceId, actualSystem); - Systems._deleteRecord(systemId); + // SystemRegistry = mapping from system address to system ID + SystemRegistry._set(address(system), systemId); - emit IWorldEvents.WorldSystemRetired(systemId, actualSystem); + // Grant the system access to its namespace + ResourceAccess._set(namespaceId, address(system), true); } /** @@ -291,8 +195,8 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); } - // Require the system to be active (retired system IDs remain registered as tombstones) - _requireActiveSystem(systemId); + // Require the resource to exist + AccessControl._requireExistence(systemId); // Require the caller to own the namespace AccessControl._requireOwner(systemId, _msgSender()); @@ -332,13 +236,6 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo // Require the caller to own the root namespace AccessControl._requireOwner(ROOT_NAMESPACE_ID, _msgSender()); - // Root aliases must still resolve to an active system. In particular, a - // retired resource ID is a tombstone and must not become callable again. - if (systemId.getType() != RESOURCE_SYSTEM) { - revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); - } - _requireActiveSystem(systemId); - // Compute the function selector from the provided signature worldFunctionSelector = bytes4(keccak256(bytes(worldFunctionSignature))); bytes4 systemFunctionSelector = bytes4(keccak256(bytes(systemFunctionSignature))); @@ -356,117 +253,6 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo FunctionSignatures._set(worldFunctionSelector, worldFunctionSignature); } - /** - * @notice Replaces the complete route for an existing World function selector. - * @dev This is a root-owner recovery primitive. Both sides of the transition are explicit so a migration can - * safely change the destination System ID, the destination function selector, or both. Destination signature - * metadata is written from the supplied signature, including on an idempotent route retry. - * @param worldFunctionSelector The World function selector whose route is replaced - * @param expectedSystemId The system ID expected in the current route - * @param expectedSystemFunctionSelector The system function selector expected in the current route - * @param newSystemId The active destination system ID - * @param newSystemFunctionSignature The destination system function signature; its selector is derived onchain - */ - function replaceFunctionRoute( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector, - ResourceId newSystemId, - string memory newSystemFunctionSignature - ) public virtual onlyDelegatecall { - // Raw selector routing is global, so only the root namespace owner may change it - AccessControl._requireOwner(ROOT_NAMESPACE_ID, _msgSender()); - - // Require the destination to be an active system - if (newSystemId.getType() != RESOURCE_SYSTEM) { - revert World_InvalidResourceType(RESOURCE_SYSTEM, newSystemId, newSystemId.toString()); - } - _requireActiveSystem(newSystemId); - - bytes4 newSystemFunctionSelector = bytes4(keccak256(bytes(newSystemFunctionSignature))); - - (ResourceId actualSystemId, bytes4 actualSystemFunctionSelector) = FunctionSelectors._get(worldFunctionSelector); - - // Idempotent retry after a successful replacement. Reconcile the offchain signature metadata as well. - if ( - ResourceId.unwrap(actualSystemId) == ResourceId.unwrap(newSystemId) && - actualSystemFunctionSelector == newSystemFunctionSelector - ) { - FunctionSignatures._set(newSystemFunctionSelector, newSystemFunctionSignature); - return; - } - - // Compare-and-swap guard against overwriting an unexpected route - if ( - ResourceId.unwrap(actualSystemId) != ResourceId.unwrap(expectedSystemId) || - actualSystemFunctionSelector != expectedSystemFunctionSelector - ) { - revert World_FunctionSelectorMismatch( - worldFunctionSelector, - expectedSystemId, - expectedSystemFunctionSelector, - actualSystemId, - actualSystemFunctionSelector - ); - } - - FunctionSelectors._set(worldFunctionSelector, newSystemId, newSystemFunctionSelector); - FunctionSignatures._set(newSystemFunctionSelector, newSystemFunctionSignature); - - emit IWorldEvents.WorldFunctionRouteReplaced( - worldFunctionSelector, - expectedSystemId, - newSystemId, - expectedSystemFunctionSelector, - newSystemFunctionSelector - ); - } - - /** - * @notice Unregisters a World function selector if it still matches the expected route. - * @dev This is a root-owner recovery primitive. FunctionSignatures metadata is intentionally - * retained because signatures are globally keyed and can be shared by other routes. - * Repeated calls after a successful unregister are no-ops. - * @param worldFunctionSelector The World function selector to unregister - * @param expectedSystemId The system ID expected in the current route - * @param expectedSystemFunctionSelector The system function selector expected in the current route - */ - function unregisterFunctionSelector( - bytes4 worldFunctionSelector, - ResourceId expectedSystemId, - bytes4 expectedSystemFunctionSelector - ) public virtual onlyDelegatecall { - // Raw selector routing is global, so only the root namespace owner may change it - AccessControl._requireOwner(ROOT_NAMESPACE_ID, _msgSender()); - - (ResourceId actualSystemId, bytes4 actualSystemFunctionSelector) = FunctionSelectors._get(worldFunctionSelector); - - // Idempotent retry after a successful unregister - if (ResourceId.unwrap(actualSystemId) == 0 && actualSystemFunctionSelector == bytes4(0)) return; - - // Compare-and-swap guard against deleting an unexpected route - if ( - ResourceId.unwrap(actualSystemId) != ResourceId.unwrap(expectedSystemId) || - actualSystemFunctionSelector != expectedSystemFunctionSelector - ) { - revert World_FunctionSelectorMismatch( - worldFunctionSelector, - expectedSystemId, - expectedSystemFunctionSelector, - actualSystemId, - actualSystemFunctionSelector - ); - } - - FunctionSelectors._deleteRecord(worldFunctionSelector); - - emit IWorldEvents.WorldFunctionSelectorUnregistered( - worldFunctionSelector, - expectedSystemId, - expectedSystemFunctionSelector - ); - } - /** * @notice Registers a delegation for the caller * @dev Creates a new delegation from the caller to the specified delegatee @@ -551,67 +337,4 @@ abstract contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallCo // Delete the delegation control NamespaceDelegationControl.deleteRecord(namespaceId); } - - /** - * @dev Validate the invariant and authorization checks shared by system registration primitives. - */ - function _validateSystemRegistration( - ResourceId systemId, - System system - ) internal view returns (ResourceId namespaceId) { - if (systemId.getType() != RESOURCE_SYSTEM) { - revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); - } - - namespaceId = systemId.getNamespaceId(); - AccessControl._requireExistence(namespaceId); - AccessControl._requireOwner(namespaceId, _msgSender()); - - requireInterface(address(system), type(IWorldContextConsumer).interfaceId); - - if (systemId.getName() == ROOT_NAME) revert World_InvalidResourceId(systemId, systemId.toString()); - } - - /** - * @dev Require an implementation address to be unused or already associated with the target system ID. - */ - function _requireSystemAddressAvailable(ResourceId systemId, System system) internal view { - ResourceId existingSystemId = SystemRegistry._get(address(system)); - if ( - ResourceId.unwrap(existingSystemId) != 0 && ResourceId.unwrap(existingSystemId) != ResourceId.unwrap(systemId) - ) { - revert World_SystemAlreadyExists(address(system)); - } - } - - /** - * @dev Apply the forward registry, reverse registry, resource ID, and default namespace access atomically. - */ - function _setSystemRegistration( - ResourceId systemId, - ResourceId namespaceId, - address existingSystem, - System newSystem, - bool publicAccess - ) internal { - if (existingSystem == address(0)) { - ResourceIds._setExists(systemId, true); - } else if (existingSystem != address(newSystem)) { - SystemRegistry._deleteRecord(existingSystem); - ResourceAccess._deleteRecord(namespaceId, existingSystem); - } - - Systems._set(systemId, address(newSystem), publicAccess); - SystemRegistry._set(address(newSystem), systemId); - ResourceAccess._set(namespaceId, address(newSystem), true); - } - - /** - * @dev Require a system ID to currently resolve to an implementation address. - */ - function _requireActiveSystem(ResourceId systemId) internal view { - if (Systems._getSystem(systemId) == address(0)) { - revert World_ResourceNotFound(systemId, systemId.toString()); - } - } } diff --git a/packages/world/src/version.sol b/packages/world/src/version.sol index 5a9d3463a5..c09c418bc9 100644 --- a/packages/world/src/version.sol +++ b/packages/world/src/version.sol @@ -8,4 +8,4 @@ pragma solidity >=0.8.24; */ /// @dev Identifier for the current World protocol version. -bytes32 constant WORLD_VERSION = "2.1.0"; +bytes32 constant WORLD_VERSION = "2.0.2"; diff --git a/packages/world/test/FunctionSelectorSystemIdRepair.t.sol b/packages/world/test/FunctionSelectorSystemIdRepair.t.sol new file mode 100644 index 0000000000..e70b3fd586 --- /dev/null +++ b/packages/world/test/FunctionSelectorSystemIdRepair.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; + +import { IBaseWorld } from "../src/codegen/interfaces/IBaseWorld.sol"; +import { FunctionSelectors } from "../src/codegen/tables/FunctionSelectors.sol"; +import { IWorldErrors } from "../src/IWorldErrors.sol"; +import { System } from "../src/System.sol"; +import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "../src/WorldResourceId.sol"; +import { RESOURCE_SYSTEM } from "../src/worldResourceTypes.sol"; +import { createWorld } from "./createWorld.sol"; + +contract StaleRouteSystem is System { + function routedValue() external pure returns (uint256) { + return 1; + } +} + +contract TargetRouteSystem is System { + function routedValue() external pure returns (uint256) { + return 2; + } +} + +contract FunctionSelectorSystemIdRepairTest is Test { + using WorldResourceIdInstance for ResourceId; + + IBaseWorld internal world; + ResourceId internal staleSystemId; + ResourceId internal targetSystemId; + bytes4 internal worldFunctionSelector; + + function setUp() public { + world = createWorld(); + StoreSwitch.setStoreAddress(address(world)); + + staleSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "repair", name: "stale" }); + targetSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "repair", name: "target" }); + + world.registerNamespace(staleSystemId.getNamespaceId()); + world.registerSystem(staleSystemId, new StaleRouteSystem(), true); + world.registerSystem(targetSystemId, new TargetRouteSystem(), true); + worldFunctionSelector = world.registerFunctionSelector(staleSystemId, "routedValue()"); + } + + function testWorldOwnerCanRepairOnlyFunctionSelectorSystemId() public { + assertEq(world.worldVersion(), bytes32("2.0.2")); + assertEq(_callRoutedValue(), 1); + + bytes4 systemFunctionSelector = FunctionSelectors.getSystemFunctionSelector(worldFunctionSelector); + assertEq(ResourceId.unwrap(FunctionSelectors.getSystemId(worldFunctionSelector)), ResourceId.unwrap(staleSystemId)); + assertEq(systemFunctionSelector, StaleRouteSystem.routedValue.selector); + + bytes32[] memory keyTuple = _functionSelectorKeyTuple(); + bytes memory targetSystemIdData = abi.encodePacked(ResourceId.unwrap(targetSystemId)); + + address unauthorizedCaller = address(0xBEEF); + vm.prank(unauthorizedCaller); + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_AccessDenied.selector, + FunctionSelectors._tableId.toString(), + unauthorizedCaller + ) + ); + world.setField(FunctionSelectors._tableId, keyTuple, 0, targetSystemIdData); + + assertEq(ResourceId.unwrap(FunctionSelectors.getSystemId(worldFunctionSelector)), ResourceId.unwrap(staleSystemId)); + assertEq(FunctionSelectors.getSystemFunctionSelector(worldFunctionSelector), systemFunctionSelector); + + world.setField(FunctionSelectors._tableId, keyTuple, 0, targetSystemIdData); + + assertEq( + ResourceId.unwrap(FunctionSelectors.getSystemId(worldFunctionSelector)), + ResourceId.unwrap(targetSystemId) + ); + assertEq(FunctionSelectors.getSystemFunctionSelector(worldFunctionSelector), systemFunctionSelector); + assertEq(_callRoutedValue(), 2); + } + + function _functionSelectorKeyTuple() internal view returns (bytes32[] memory keyTuple) { + keyTuple = new bytes32[](1); + keyTuple[0] = bytes32(worldFunctionSelector); + } + + function _callRoutedValue() internal returns (uint256 value) { + (bool success, bytes memory returnData) = address(world).call(abi.encodePacked(worldFunctionSelector)); + assertTrue(success, "World dispatch failed"); + value = abi.decode(returnData, (uint256)); + } +} diff --git a/packages/world/test/InitSystems.t.sol b/packages/world/test/InitSystems.t.sol index 4834c7508e..99c9714911 100644 --- a/packages/world/test/InitSystems.t.sol +++ b/packages/world/test/InitSystems.t.sol @@ -63,7 +63,7 @@ contract LimitedCallContextTest is Test { } function testRegistrationSystem() public { - string[18] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); + string[14] memory functionSignaturesRegistration = getFunctionSignaturesRegistration(); for (uint256 i; i < functionSignaturesRegistration.length; i++) { callSystem(REGISTRATION_SYSTEM_ID, functionSignaturesRegistration[i]); diff --git a/packages/world/test/SystemMigration.t.sol b/packages/world/test/SystemMigration.t.sol deleted file mode 100644 index 6c91082c53..0000000000 --- a/packages/world/test/SystemMigration.t.sol +++ /dev/null @@ -1,1035 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.24; - -import { Test } from "forge-std/Test.sol"; - -import { ResourceIds } from "@latticexyz/store/src/codegen/tables/ResourceIds.sol"; -import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; -import { IStoreEvents } from "@latticexyz/store/src/IStoreEvents.sol"; - -import { System } from "../src/System.sol"; -import { SystemHook } from "../src/SystemHook.sol"; -import { IWorldContextConsumer } from "../src/WorldContext.sol"; -import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "../src/WorldResourceId.sol"; -import { RESOURCE_SYSTEM } from "../src/worldResourceTypes.sol"; -import { BEFORE_CALL_SYSTEM } from "../src/systemHookTypes.sol"; -import { ROOT_NAME } from "../src/constants.sol"; -import { AccessControl } from "../src/AccessControl.sol"; -import { requireInterface } from "../src/requireInterface.sol"; - -import { IWorldErrors } from "../src/IWorldErrors.sol"; -import { IWorldEvents } from "../src/IWorldEvents.sol"; -import { IBaseWorld } from "../src/codegen/interfaces/IBaseWorld.sol"; -import { IWorldRegistrationSystem } from "../src/codegen/interfaces/IWorldRegistrationSystem.sol"; - -import { FunctionSelectors } from "../src/codegen/tables/FunctionSelectors.sol"; -import { FunctionSignatures } from "../src/codegen/tables/FunctionSignatures.sol"; -import { ResourceAccess } from "../src/codegen/tables/ResourceAccess.sol"; -import { SystemHooks } from "../src/codegen/tables/SystemHooks.sol"; -import { SystemRegistry } from "../src/codegen/tables/SystemRegistry.sol"; -import { Systems } from "../src/codegen/tables/Systems.sol"; - -import { ACCESS_MANAGEMENT_SYSTEM_ID, BALANCE_TRANSFER_SYSTEM_ID, BATCH_CALL_SYSTEM_ID, REGISTRATION_SYSTEM_ID } from "../src/modules/init/constants.sol"; -import { RegistrationSystem } from "../src/modules/init/RegistrationSystem.sol"; -import { LimitedCallContext } from "../src/modules/init/LimitedCallContext.sol"; -import { SystemCallData } from "../src/modules/init/types.sol"; - -import { createWorld } from "./createWorld.sol"; - -contract MigrationTestSystem is System { - address private immutable implementationAddress = address(this); - - function implementation() public view returns (address) { - return implementationAddress; - } - - function alternateImplementation() public view returns (address) { - return implementationAddress; - } -} - -/** - * @dev Minimal pre-2.1 RegistrationSystem fixture. It intentionally exposes only - * the legacy, unconditional registerSystem primitive needed to bootstrap the new - * implementation. The implementation matches the 2.0.2 registration semantics. - */ -contract LegacyMigrationRegistrationSystem is System, IWorldErrors, LimitedCallContext { - using WorldResourceIdInstance for ResourceId; - - function registerSystem(ResourceId systemId, System system, bool publicAccess) public onlyDelegatecall { - if (systemId.getType() != RESOURCE_SYSTEM) { - revert World_InvalidResourceType(RESOURCE_SYSTEM, systemId, systemId.toString()); - } - - ResourceId namespaceId = systemId.getNamespaceId(); - AccessControl._requireExistence(namespaceId); - AccessControl._requireOwner(namespaceId, _msgSender()); - requireInterface(address(system), type(IWorldContextConsumer).interfaceId); - if (systemId.getName() == ROOT_NAME) revert World_InvalidResourceId(systemId, systemId.toString()); - - ResourceId existingSystemId = SystemRegistry._get(address(system)); - if ( - ResourceId.unwrap(existingSystemId) != 0 && ResourceId.unwrap(existingSystemId) != ResourceId.unwrap(systemId) - ) { - revert World_SystemAlreadyExists(address(system)); - } - - address existingSystem = Systems._getSystem(systemId); - if (existingSystem != address(0)) { - SystemRegistry._deleteRecord(existingSystem); - ResourceAccess._deleteRecord(namespaceId, existingSystem); - } else { - ResourceIds._setExists(systemId, true); - } - - Systems._set(systemId, address(system), publicAccess); - SystemRegistry._set(address(system), systemId); - ResourceAccess._set(namespaceId, address(system), true); - } -} - -contract RevertingMigrationHook is SystemHook { - function onBeforeCallSystem(address, ResourceId, bytes memory) public pure { - revert("retired system hook executed"); - } - - function onAfterCallSystem(address, ResourceId, bytes memory) public pure {} -} - -contract SystemMigrationTest is Test { - using WorldResourceIdInstance for ResourceId; - - struct LegacyMigrationFixture { - ResourceId sourceSystemId; - ResourceId targetSystemId; - MigrationTestSystem sourceSystem; - MigrationTestSystem targetSystem; - LegacyMigrationRegistrationSystem legacyRegistrationSystem; - RegistrationSystem newRegistrationSystem; - bytes4 worldFunctionSelector; - bytes4 systemFunctionSelector; - string[4] nativeSignatures; - } - - IBaseWorld internal world; - - function setUp() public { - world = createWorld(); - StoreSwitch.setStoreAddress(address(world)); - } - - function testReplaceFunctionRoute() public { - ( - ResourceId fromSystemId, - ResourceId toSystemId, - , - MigrationTestSystem toSystem, - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - string memory newSystemFunctionSignature = "alternateImplementation()"; - bytes4 newSystemFunctionSelector = MigrationTestSystem.alternateImplementation.selector; - - vm.expectEmit(true, true, true, true); - emit IWorldEvents.WorldFunctionRouteReplaced( - worldFunctionSelector, - fromSystemId, - toSystemId, - systemFunctionSelector, - newSystemFunctionSelector - ); - world.replaceFunctionRoute( - worldFunctionSelector, - fromSystemId, - systemFunctionSelector, - toSystemId, - newSystemFunctionSignature - ); - - (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); - assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(toSystemId)); - assertEq(registeredSystemSelector, newSystemFunctionSelector); - - (bool success, bytes memory returnData) = address(world).call(abi.encodeWithSelector(worldFunctionSelector)); - assertTrue(success); - assertEq(abi.decode(returnData, (address)), address(toSystem)); - } - - function testReplaceFunctionRouteRetryReconcilesSignatureMetadata() public { - ( - ResourceId fromSystemId, - ResourceId toSystemId, - , - , - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - string memory newSystemFunctionSignature = "alternateImplementation()"; - bytes4 newSystemFunctionSelector = MigrationTestSystem.alternateImplementation.selector; - - world.replaceFunctionRoute( - worldFunctionSelector, - fromSystemId, - systemFunctionSelector, - toSystemId, - newSystemFunctionSignature - ); - - // A retry leaves the route unchanged but repairs missing offchain signature metadata. - FunctionSignatures.deleteRecord(newSystemFunctionSelector); - _expectFunctionSignatureSet(newSystemFunctionSelector, newSystemFunctionSignature); - world.replaceFunctionRoute( - worldFunctionSelector, - fromSystemId, - systemFunctionSelector, - toSystemId, - newSystemFunctionSignature - ); - (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); - assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(toSystemId)); - assertEq(registeredSystemSelector, newSystemFunctionSelector); - } - - function _expectFunctionSignatureSet(bytes4 selector, string memory signature) internal { - bytes32[] memory signatureKey = new bytes32[](1); - signatureKey[0] = bytes32(selector); - vm.expectEmit(true, false, false, true, address(world)); - emit IStoreEvents.Store_SetRecord( - FunctionSignatures._tableId, - signatureKey, - new bytes(0), - FunctionSignatures.encodeLengths(signature), - FunctionSignatures.encodeDynamic(signature) - ); - } - - function testReplaceFunctionRouteRequiresRootOwner() public { - ( - ResourceId fromSystemId, - ResourceId toSystemId, - , - , - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - address unauthorized = makeAddr("unauthorized"); - - vm.prank(unauthorized); - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_AccessDenied.selector, - WorldResourceIdLib.encodeNamespace("").toString(), - unauthorized - ) - ); - world.replaceFunctionRoute( - worldFunctionSelector, - fromSystemId, - systemFunctionSelector, - toSystemId, - "implementation()" - ); - } - - function testReplaceFunctionRouteRejectsUnexpectedRoute() public { - ( - ResourceId fromSystemId, - ResourceId toSystemId, - , - , - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - ResourceId unexpectedSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "source", - name: "unexpected" - }); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_FunctionSelectorMismatch.selector, - worldFunctionSelector, - unexpectedSystemId, - systemFunctionSelector, - fromSystemId, - systemFunctionSelector - ) - ); - world.replaceFunctionRoute( - worldFunctionSelector, - unexpectedSystemId, - systemFunctionSelector, - toSystemId, - "implementation()" - ); - - bytes4 unexpectedSystemSelector = bytes4(keccak256("unexpected()")); - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_FunctionSelectorMismatch.selector, - worldFunctionSelector, - fromSystemId, - unexpectedSystemSelector, - fromSystemId, - systemFunctionSelector - ) - ); - world.replaceFunctionRoute( - worldFunctionSelector, - fromSystemId, - unexpectedSystemSelector, - toSystemId, - "implementation()" - ); - } - - function testReplaceFunctionRouteBatchRollbackOnCasMismatch() public { - ( - ResourceId fromSystemId, - ResourceId toSystemId, - , - , - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - - SystemCallData[] memory calls = new SystemCallData[](2); - calls[0] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.replaceFunctionRoute, - (worldFunctionSelector, fromSystemId, systemFunctionSelector, toSystemId, "implementation()") - ) - }); - calls[1] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.unregisterFunctionSelector, - (worldFunctionSelector, fromSystemId, systemFunctionSelector) - ) - }); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_FunctionSelectorMismatch.selector, - worldFunctionSelector, - fromSystemId, - systemFunctionSelector, - toSystemId, - systemFunctionSelector - ) - ); - world.batchCall(calls); - - (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); - assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(fromSystemId)); - assertEq(registeredSystemSelector, systemFunctionSelector); - } - - function testUnregisterFunctionSelector() public { - ( - ResourceId fromSystemId, - , - , - , - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - - vm.expectEmit(true, true, true, true); - emit IWorldEvents.WorldFunctionSelectorUnregistered(worldFunctionSelector, fromSystemId, systemFunctionSelector); - world.unregisterFunctionSelector(worldFunctionSelector, fromSystemId, systemFunctionSelector); - - (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); - assertEq(ResourceId.unwrap(registeredSystemId), bytes32(0)); - assertEq(registeredSystemSelector, bytes4(0)); - - (bool success, bytes memory returnData) = address(world).call(abi.encodeWithSelector(worldFunctionSelector)); - assertFalse(success); - assertEq( - returnData, - abi.encodeWithSelector(IWorldErrors.World_FunctionSelectorNotFound.selector, worldFunctionSelector) - ); - - // A retry after the record was removed is an idempotent no-op. - world.unregisterFunctionSelector(worldFunctionSelector, fromSystemId, systemFunctionSelector); - } - - function testUnregisterFunctionSelectorRequiresRootOwnerAndMatchingRoute() public { - ( - ResourceId fromSystemId, - ResourceId toSystemId, - , - , - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - address unauthorized = makeAddr("unauthorized"); - - vm.prank(unauthorized); - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_AccessDenied.selector, - WorldResourceIdLib.encodeNamespace("").toString(), - unauthorized - ) - ); - world.unregisterFunctionSelector(worldFunctionSelector, fromSystemId, systemFunctionSelector); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_FunctionSelectorMismatch.selector, - worldFunctionSelector, - toSystemId, - systemFunctionSelector, - fromSystemId, - systemFunctionSelector - ) - ); - world.unregisterFunctionSelector(worldFunctionSelector, toSystemId, systemFunctionSelector); - } - - function testUnregisterFunctionSelectorOnlyTreatsExactZeroRouteAsAbsent() public { - bytes4 worldFunctionSelector = bytes4(keccak256("partiallyDeleted()")); - bytes4 actualSystemFunctionSelector = bytes4(keccak256("stale()")); - ResourceId expectedSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "source", - name: "system" - }); - FunctionSelectors.set(worldFunctionSelector, ResourceId.wrap(bytes32(0)), actualSystemFunctionSelector); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_FunctionSelectorMismatch.selector, - worldFunctionSelector, - expectedSystemId, - actualSystemFunctionSelector, - ResourceId.wrap(bytes32(0)), - actualSystemFunctionSelector - ) - ); - world.unregisterFunctionSelector(worldFunctionSelector, expectedSystemId, actualSystemFunctionSelector); - } - - function testReplaceSystemRegistersNeverUsedId() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "new" }); - ResourceId namespaceId = systemId.getNamespaceId(); - world.registerNamespace(namespaceId); - MigrationTestSystem system = new MigrationTestSystem(); - - vm.expectEmit(true, true, true, true); - emit IWorldEvents.WorldSystemReplaced(systemId, address(0), address(system), true); - world.replaceSystem(systemId, address(0), false, system, true); - - (address registeredSystem, bool publicAccess) = Systems.get(systemId); - assertEq(registeredSystem, address(system)); - assertTrue(publicAccess); - assertTrue(ResourceIds.getExists(systemId)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(system))), ResourceId.unwrap(systemId)); - assertTrue(ResourceAccess.get(namespaceId, address(system))); - } - - function testReplaceSystemUpgradesOnlyExpectedImplementation() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "upgrade" }); - ResourceId namespaceId = systemId.getNamespaceId(); - world.registerNamespace(namespaceId); - MigrationTestSystem oldSystem = new MigrationTestSystem(); - MigrationTestSystem newSystem = new MigrationTestSystem(); - world.registerSystem(systemId, oldSystem, true); - - vm.expectEmit(true, true, true, true); - emit IWorldEvents.WorldSystemReplaced(systemId, address(oldSystem), address(newSystem), false); - world.replaceSystem(systemId, address(oldSystem), true, newSystem, false); - - (address registeredSystem, bool publicAccess) = Systems.get(systemId); - assertEq(registeredSystem, address(newSystem)); - assertFalse(publicAccess); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(oldSystem))), bytes32(0)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(newSystem))), ResourceId.unwrap(systemId)); - assertFalse(ResourceAccess.get(namespaceId, address(oldSystem))); - assertTrue(ResourceAccess.get(namespaceId, address(newSystem))); - - // Retrying the original transition is an exact no-op after the desired state is reached. - world.replaceSystem(systemId, address(oldSystem), true, newSystem, false); - } - - function testReplaceSystemRejectsStateMismatchWithoutWrites() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "mismatch" }); - ResourceId namespaceId = systemId.getNamespaceId(); - world.registerNamespace(namespaceId); - MigrationTestSystem currentSystem = new MigrationTestSystem(); - MigrationTestSystem newSystem = new MigrationTestSystem(); - world.registerSystem(systemId, currentSystem, true); - address unexpectedSystem = makeAddr("unexpected system"); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_SystemStateMismatch.selector, - systemId, - unexpectedSystem, - true, - address(currentSystem), - true - ) - ); - world.replaceSystem(systemId, unexpectedSystem, true, newSystem, false); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_SystemStateMismatch.selector, - systemId, - address(currentSystem), - false, - address(currentSystem), - true - ) - ); - world.replaceSystem(systemId, address(currentSystem), false, newSystem, false); - - (address registeredSystem, bool publicAccess) = Systems.get(systemId); - assertEq(registeredSystem, address(currentSystem)); - assertTrue(publicAccess); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(currentSystem))), ResourceId.unwrap(systemId)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(newSystem))), bytes32(0)); - assertTrue(ResourceAccess.get(namespaceId, address(currentSystem))); - assertFalse(ResourceAccess.get(namespaceId, address(newSystem))); - } - - function testReplaceSystemRequiresNamespaceOwner() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "owned" }); - ResourceId namespaceId = systemId.getNamespaceId(); - world.registerNamespace(namespaceId); - MigrationTestSystem currentSystem = new MigrationTestSystem(); - MigrationTestSystem newSystem = new MigrationTestSystem(); - world.registerSystem(systemId, currentSystem, true); - address unauthorized = makeAddr("unauthorized replacement"); - - vm.prank(unauthorized); - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, namespaceId.toString(), unauthorized) - ); - world.replaceSystem(systemId, address(currentSystem), true, newSystem, false); - - assertEq(Systems.getSystem(systemId), address(currentSystem)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(newSystem))), bytes32(0)); - } - - function testReplaceSystemReconcilesAccessForExpectedImplementation() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "access" }); - ResourceId namespaceId = systemId.getNamespaceId(); - world.registerNamespace(namespaceId); - MigrationTestSystem system = new MigrationTestSystem(); - world.registerSystem(systemId, system, true); - - ResourceAccess.set(namespaceId, address(system), false); - - vm.expectEmit(true, true, true, true); - emit IWorldEvents.WorldSystemReplaced(systemId, address(system), address(system), false); - world.replaceSystem(systemId, address(system), true, system, false); - - (address registeredSystem, bool publicAccess) = Systems.get(systemId); - assertEq(registeredSystem, address(system)); - assertFalse(publicAccess); - assertTrue(ResourceAccess.get(namespaceId, address(system))); - } - - function testReplaceSystemRejectsRetiredId() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "retired" }); - world.registerNamespace(systemId.getNamespaceId()); - MigrationTestSystem system = new MigrationTestSystem(); - world.registerSystem(systemId, system, true); - world.retireSystem(systemId, address(system), true); - MigrationTestSystem replacement = new MigrationTestSystem(); - - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_SystemAlreadyRetired.selector, systemId, systemId.toString()) - ); - world.replaceSystem(systemId, address(0), false, replacement, true); - - assertEq(Systems.getSystem(systemId), address(0)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(replacement))), bytes32(0)); - assertTrue(ResourceIds.getExists(systemId)); - } - - function testReplaceSystemRejectsInconsistentReverseRegistry() public { - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "corrupt" }); - world.registerNamespace(systemId.getNamespaceId()); - MigrationTestSystem currentSystem = new MigrationTestSystem(); - MigrationTestSystem replacement = new MigrationTestSystem(); - world.registerSystem(systemId, currentSystem, true); - ResourceId inconsistentSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "cas", - name: "other" - }); - SystemRegistry.set(address(currentSystem), inconsistentSystemId); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_SystemRegistryMismatch.selector, - address(currentSystem), - systemId, - inconsistentSystemId - ) - ); - world.replaceSystem(systemId, address(currentSystem), true, replacement, false); - - assertEq(Systems.getSystem(systemId), address(currentSystem)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(currentSystem))), ResourceId.unwrap(inconsistentSystemId)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(replacement))), bytes32(0)); - } - - function testReplaceSystemRejectsImplementationRegisteredElsewhere() public { - ResourceId firstSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "cas", name: "first" }); - ResourceId secondSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "cas", - name: "second" - }); - world.registerNamespace(firstSystemId.getNamespaceId()); - MigrationTestSystem firstSystem = new MigrationTestSystem(); - MigrationTestSystem secondSystem = new MigrationTestSystem(); - world.registerSystem(firstSystemId, firstSystem, true); - world.registerSystem(secondSystemId, secondSystem, false); - - vm.expectRevert(abi.encodeWithSelector(IWorldErrors.World_SystemAlreadyExists.selector, address(secondSystem))); - world.replaceSystem(firstSystemId, address(firstSystem), true, secondSystem, false); - - assertEq(Systems.getSystem(firstSystemId), address(firstSystem)); - assertEq(Systems.getSystem(secondSystemId), address(secondSystem)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(firstSystem))), ResourceId.unwrap(firstSystemId)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(secondSystem))), ResourceId.unwrap(secondSystemId)); - } - - function testRetireSystemClearsActiveStateAndLeavesTombstone() public { - bytes14 namespace = "retirement"; - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: namespace, name: "system" }); - world.registerNamespace(systemId.getNamespaceId()); - MigrationTestSystem system = new MigrationTestSystem(); - world.registerSystem(systemId, system, true); - - RevertingMigrationHook hook = new RevertingMigrationHook(); - world.registerSystemHook(systemId, hook, BEFORE_CALL_SYSTEM); - - vm.expectEmit(true, true, true, true); - emit IWorldEvents.WorldSystemRetired(systemId, address(system)); - world.retireSystem(systemId, address(system), true); - - (address registeredSystem, bool publicAccess) = Systems.get(systemId); - assertEq(registeredSystem, address(0)); - assertFalse(publicAccess); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(system))), bytes32(0)); - assertFalse(ResourceAccess.get(systemId.getNamespaceId(), address(system))); - assertEq(SystemHooks.get(systemId).length, 0); - assertTrue(ResourceIds.getExists(systemId)); - - // The hook must be deleted before any subsequent call attempts to resolve the retired system. - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, systemId, systemId.toString()) - ); - world.call(systemId, abi.encodeCall(MigrationTestSystem.implementation, ())); - - // Repeated retirement is an idempotent no-op. - world.retireSystem(systemId, address(system), true); - - MigrationTestSystem replacementSystem = new MigrationTestSystem(); - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_SystemAlreadyRetired.selector, systemId, systemId.toString()) - ); - world.registerSystem(systemId, replacementSystem, true); - } - - function testRetireSystemRequiresNamespaceOwnerAndExpectedState() public { - bytes14 namespace = "retirement"; - ResourceId systemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: namespace, name: "system" }); - world.registerNamespace(systemId.getNamespaceId()); - MigrationTestSystem system = new MigrationTestSystem(); - world.registerSystem(systemId, system, true); - address unauthorized = makeAddr("unauthorized"); - - vm.prank(unauthorized); - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_AccessDenied.selector, systemId.toString(), unauthorized) - ); - world.retireSystem(systemId, address(system), true); - - address unexpectedSystem = makeAddr("unexpected system"); - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_SystemStateMismatch.selector, - systemId, - unexpectedSystem, - true, - address(system), - true - ) - ); - world.retireSystem(systemId, unexpectedSystem, true); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_SystemStateMismatch.selector, - systemId, - address(system), - false, - address(system), - true - ) - ); - world.retireSystem(systemId, address(system), false); - - assertEq(Systems.getSystem(systemId), address(system)); - } - - function testRetireSystemRejectsInconsistentReverseRegistry() public { - ResourceId systemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "retirement", - name: "system" - }); - world.registerNamespace(systemId.getNamespaceId()); - MigrationTestSystem system = new MigrationTestSystem(); - world.registerSystem(systemId, system, true); - - ResourceId inconsistentSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "retirement", - name: "other" - }); - SystemRegistry.set(address(system), inconsistentSystemId); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_SystemRegistryMismatch.selector, - address(system), - systemId, - inconsistentSystemId - ) - ); - world.retireSystem(systemId, address(system), true); - - assertEq(Systems.getSystem(systemId), address(system)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(system))), ResourceId.unwrap(inconsistentSystemId)); - } - - function testRetireSystemRejectsNeverRegisteredSystem() public { - ResourceId systemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "retirement", - name: "missing" - }); - world.registerNamespace(systemId.getNamespaceId()); - - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, systemId, systemId.toString()) - ); - world.retireSystem(systemId, makeAddr("missing system"), false); - } - - function testRetireSystemRejectsCoreSystems() public { - ResourceId[4] memory coreSystemIds = [ - ACCESS_MANAGEMENT_SYSTEM_ID, - BALANCE_TRANSFER_SYSTEM_ID, - BATCH_CALL_SYSTEM_ID, - REGISTRATION_SYSTEM_ID - ]; - - for (uint256 i; i < coreSystemIds.length; i++) { - ResourceId coreSystemId = coreSystemIds[i]; - (address coreSystem, bool corePublicAccess) = Systems.get(coreSystemId); - - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_SystemCannotBeRetired.selector, coreSystemId, coreSystemId.toString()) - ); - world.retireSystem(coreSystemId, coreSystem, corePublicAccess); - - assertEq(Systems.getSystem(coreSystemId), coreSystem); - assertEq(ResourceId.unwrap(SystemRegistry.get(coreSystem)), ResourceId.unwrap(coreSystemId)); - assertTrue(ResourceIds.getExists(coreSystemId)); - } - } - - function testRetiredSystemCannotReceiveHooksFunctionsOrMovedSelectors() public { - ( - ResourceId fromSystemId, - ResourceId retiredSystemId, - , - MigrationTestSystem retiredSystem, - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) = _registerSystemPairAndSelector(); - world.retireSystem(retiredSystemId, address(retiredSystem), true); - - RevertingMigrationHook hook = new RevertingMigrationHook(); - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) - ); - world.registerSystemHook(retiredSystemId, hook, BEFORE_CALL_SYSTEM); - - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) - ); - world.registerFunctionSelector(retiredSystemId, "implementation()"); - - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) - ); - world.registerRootFunctionSelector(retiredSystemId, "retiredImplementation()", "implementation()"); - - vm.expectRevert( - abi.encodeWithSelector(IWorldErrors.World_ResourceNotFound.selector, retiredSystemId, retiredSystemId.toString()) - ); - world.replaceFunctionRoute( - worldFunctionSelector, - fromSystemId, - systemFunctionSelector, - retiredSystemId, - "implementation()" - ); - - (ResourceId registeredSystemId, bytes4 registeredSystemSelector) = FunctionSelectors.get(worldFunctionSelector); - assertEq(ResourceId.unwrap(registeredSystemId), ResourceId.unwrap(fromSystemId)); - assertEq(registeredSystemSelector, systemFunctionSelector); - } - - function testLegacyWorldBootstrapAtomicallyMigratesSelectorAndRetiresSource() public { - LegacyMigrationFixture memory fixture = _createLegacyMigrationFixture(); - - (bool success, bytes memory returnData) = address(world).call( - abi.encodeWithSelector(fixture.worldFunctionSelector) - ); - assertTrue(success); - assertEq(abi.decode(returnData, (address)), address(fixture.sourceSystem)); - - // A stale plan failing after the core replacement and target registration - // must roll back the entire batch, including the bootstrap itself. - bytes4 unexpectedSystemSelector = bytes4(keccak256("unexpected()")); - SystemCallData[] memory staleCalls = _legacyBootstrapCalls(fixture, unexpectedSystemSelector); - - vm.expectRevert( - abi.encodeWithSelector( - IWorldErrors.World_FunctionSelectorMismatch.selector, - fixture.worldFunctionSelector, - fixture.sourceSystemId, - unexpectedSystemSelector, - fixture.sourceSystemId, - fixture.systemFunctionSelector - ) - ); - world.batchCall(staleCalls); - _assertLegacyMigrationRollback(fixture); - - SystemCallData[] memory migrationCalls = _legacyBootstrapCalls(fixture, fixture.systemFunctionSelector); - world.batchCall(migrationCalls); - _assertLegacyMigrationApplied(fixture); - - // Native lifecycle retries remain harmless after the CLI replans against - // the already-migrated selector and the source tombstone. - SystemCallData[] memory retryCalls = new SystemCallData[](2); - retryCalls[0] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.replaceFunctionRoute, - ( - fixture.worldFunctionSelector, - fixture.sourceSystemId, - fixture.systemFunctionSelector, - fixture.targetSystemId, - "implementation()" - ) - ) - }); - retryCalls[1] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.retireSystem, - (fixture.sourceSystemId, address(fixture.sourceSystem), true) - ) - }); - world.batchCall(retryCalls); - } - - function _createLegacyMigrationFixture() internal returns (LegacyMigrationFixture memory fixture) { - fixture.sourceSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "", - name: "LegacySource" - }); - fixture.targetSystemId = WorldResourceIdLib.encode({ - typeId: RESOURCE_SYSTEM, - namespace: "", - name: "DesiredTarget" - }); - fixture.sourceSystem = new MigrationTestSystem(); - fixture.targetSystem = new MigrationTestSystem(); - world.registerSystem(fixture.sourceSystemId, fixture.sourceSystem, true); - - fixture.worldFunctionSelector = world.registerRootFunctionSelector( - fixture.sourceSystemId, - "migratedImplementation()", - "implementation()" - ); - fixture.systemFunctionSelector = MigrationTestSystem.implementation.selector; - - // Recreate the relevant 2.0.2 state: a legacy core implementation, none of - // the native lifecycle routes, and a selector still routed to its old System. - fixture.nativeSignatures[0] = "replaceFunctionRoute(bytes4,bytes32,bytes4,bytes32,string)"; - fixture.nativeSignatures[1] = "unregisterFunctionSelector(bytes4,bytes32,bytes4)"; - fixture.nativeSignatures[2] = "retireSystem(bytes32,address,bool)"; - fixture.nativeSignatures[3] = "replaceSystem(bytes32,address,bool,address,bool)"; - for (uint256 i; i < fixture.nativeSignatures.length; i++) { - if (i == 1) continue; - bytes4 selector = bytes4(keccak256(bytes(fixture.nativeSignatures[i]))); - world.unregisterFunctionSelector(selector, REGISTRATION_SYSTEM_ID, selector); - } - - // Remove unregisterFunctionSelector last because this call uses that route. - bytes4 unregisterSelector = bytes4(keccak256(bytes(fixture.nativeSignatures[1]))); - world.unregisterFunctionSelector(unregisterSelector, REGISTRATION_SYSTEM_ID, unregisterSelector); - - fixture.legacyRegistrationSystem = new LegacyMigrationRegistrationSystem(); - world.registerSystem(REGISTRATION_SYSTEM_ID, fixture.legacyRegistrationSystem, true); - fixture.newRegistrationSystem = new RegistrationSystem(); - } - - function _assertLegacyMigrationRollback(LegacyMigrationFixture memory fixture) internal { - assertEq(Systems.getSystem(REGISTRATION_SYSTEM_ID), address(fixture.legacyRegistrationSystem)); - assertEq(Systems.getSystem(fixture.sourceSystemId), address(fixture.sourceSystem)); - assertEq(Systems.getSystem(fixture.targetSystemId), address(0)); - assertFalse(ResourceIds.getExists(fixture.targetSystemId)); - for (uint256 i; i < fixture.nativeSignatures.length; i++) { - (ResourceId nativeSystemId, bytes4 nativeSystemSelector) = FunctionSelectors.get( - bytes4(keccak256(bytes(fixture.nativeSignatures[i]))) - ); - assertEq(ResourceId.unwrap(nativeSystemId), bytes32(0)); - assertEq(nativeSystemSelector, bytes4(0)); - } - } - - function _assertLegacyMigrationApplied(LegacyMigrationFixture memory fixture) internal { - (address registeredCore, bool corePublicAccess) = Systems.get(REGISTRATION_SYSTEM_ID); - assertEq(registeredCore, address(fixture.newRegistrationSystem)); - assertTrue(corePublicAccess); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(fixture.legacyRegistrationSystem))), bytes32(0)); - - for (uint256 i; i < fixture.nativeSignatures.length; i++) { - bytes4 selector = bytes4(keccak256(bytes(fixture.nativeSignatures[i]))); - (ResourceId nativeSystemId, bytes4 nativeSystemSelector) = FunctionSelectors.get(selector); - assertEq(ResourceId.unwrap(nativeSystemId), ResourceId.unwrap(REGISTRATION_SYSTEM_ID)); - assertEq(nativeSystemSelector, selector); - } - - (ResourceId migratedSystemId, bytes4 migratedSystemSelector) = FunctionSelectors.get(fixture.worldFunctionSelector); - assertEq(ResourceId.unwrap(migratedSystemId), ResourceId.unwrap(fixture.targetSystemId)); - assertEq(migratedSystemSelector, fixture.systemFunctionSelector); - - (address registeredTarget, bool targetPublicAccess) = Systems.get(fixture.targetSystemId); - assertEq(registeredTarget, address(fixture.targetSystem)); - assertTrue(targetPublicAccess); - assertEq( - ResourceId.unwrap(SystemRegistry.get(address(fixture.targetSystem))), - ResourceId.unwrap(fixture.targetSystemId) - ); - - (address registeredSource, bool sourcePublicAccess) = Systems.get(fixture.sourceSystemId); - assertEq(registeredSource, address(0)); - assertFalse(sourcePublicAccess); - assertTrue(ResourceIds.getExists(fixture.sourceSystemId)); - assertEq(ResourceId.unwrap(SystemRegistry.get(address(fixture.sourceSystem))), bytes32(0)); - assertFalse(ResourceAccess.get(fixture.sourceSystemId.getNamespaceId(), address(fixture.sourceSystem))); - - (bool success, bytes memory returnData) = address(world).call( - abi.encodeWithSelector(fixture.worldFunctionSelector) - ); - assertTrue(success); - assertEq(abi.decode(returnData, (address)), address(fixture.targetSystem)); - } - - function _legacyBootstrapCalls( - LegacyMigrationFixture memory fixture, - bytes4 expectedSystemFunctionSelector - ) internal pure returns (SystemCallData[] memory calls) { - calls = new SystemCallData[](8); - calls[0] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - LegacyMigrationRegistrationSystem.registerSystem, - (REGISTRATION_SYSTEM_ID, System(address(fixture.newRegistrationSystem)), true) - ) - }); - - for (uint256 i; i < fixture.nativeSignatures.length; i++) { - calls[i + 1] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.registerRootFunctionSelector, - (REGISTRATION_SYSTEM_ID, fixture.nativeSignatures[i], fixture.nativeSignatures[i]) - ) - }); - } - - calls[5] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.replaceSystem, - (fixture.targetSystemId, address(0), false, System(address(fixture.targetSystem)), true) - ) - }); - calls[6] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.replaceFunctionRoute, - ( - fixture.worldFunctionSelector, - fixture.sourceSystemId, - expectedSystemFunctionSelector, - fixture.targetSystemId, - "implementation()" - ) - ) - }); - calls[7] = SystemCallData({ - systemId: REGISTRATION_SYSTEM_ID, - callData: abi.encodeCall( - IWorldRegistrationSystem.retireSystem, - (fixture.sourceSystemId, address(fixture.sourceSystem), true) - ) - }); - - // Keep this parameter explicit: it documents that the first direct call is - // executed by the legacy implementation before the in-batch replacement. - assert(address(fixture.legacyRegistrationSystem) != address(fixture.newRegistrationSystem)); - } - - function _registerSystemPairAndSelector() - internal - returns ( - ResourceId fromSystemId, - ResourceId toSystemId, - MigrationTestSystem fromSystem, - MigrationTestSystem toSystem, - bytes4 worldFunctionSelector, - bytes4 systemFunctionSelector - ) - { - fromSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "source", name: "system" }); - toSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "target", name: "system" }); - world.registerNamespace(fromSystemId.getNamespaceId()); - world.registerNamespace(toSystemId.getNamespaceId()); - - fromSystem = new MigrationTestSystem(); - toSystem = new MigrationTestSystem(); - world.registerSystem(fromSystemId, fromSystem, true); - world.registerSystem(toSystemId, toSystem, true); - - string memory worldFunctionSignature = "migrationImplementation()"; - worldFunctionSelector = world.registerRootFunctionSelector( - fromSystemId, - worldFunctionSignature, - "implementation()" - ); - systemFunctionSelector = MigrationTestSystem.implementation.selector; - } -} diff --git a/packages/world/test/World.t.sol b/packages/world/test/World.t.sol index adc737de7b..5522abe3cd 100644 --- a/packages/world/test/World.t.sol +++ b/packages/world/test/World.t.sol @@ -223,7 +223,7 @@ contract WorldTest is Test, GasReporter { // Should have registered the core system function selectors RegistrationSystem registrationSystem = RegistrationSystem(Systems.getSystem(REGISTRATION_SYSTEM_ID)); - bytes4[26] memory funcSelectors = [ + bytes4[22] memory funcSelectors = [ // --- AccessManagementSystem --- AccessManagementSystem.grantAccess.selector, AccessManagementSystem.revokeAccess.selector, @@ -246,12 +246,8 @@ contract WorldTest is Test, GasReporter { registrationSystem.registerSystemHook.selector, registrationSystem.unregisterSystemHook.selector, registrationSystem.registerSystem.selector, - registrationSystem.replaceSystem.selector, - registrationSystem.retireSystem.selector, registrationSystem.registerFunctionSelector.selector, registrationSystem.registerRootFunctionSelector.selector, - registrationSystem.replaceFunctionRoute.selector, - registrationSystem.unregisterFunctionSelector.selector, registrationSystem.registerDelegation.selector, registrationSystem.unregisterDelegation.selector, registrationSystem.registerNamespaceDelegation.selector, diff --git a/packages/world/ts/config/v2/defaults.ts b/packages/world/ts/config/v2/defaults.ts index 3d8054980d..c377fb642f 100644 --- a/packages/world/ts/config/v2/defaults.ts +++ b/packages/world/ts/config/v2/defaults.ts @@ -39,10 +39,6 @@ export const DEPLOY_DEFAULTS = { deploysDirectory: "./deploys", worldsFile: "./worlds.json", upgradeableWorldImplementation: false, - functionRouteMigrations: [], - functionSelectorRemovals: [], - systemRetirements: [], - registrationSystemMigration: undefined, } as const satisfies DeployInput; export type DEPLOY_DEFAULTS = typeof DEPLOY_DEFAULTS; diff --git a/packages/world/ts/config/v2/input.ts b/packages/world/ts/config/v2/input.ts index 9ecd656788..fc2c0f7702 100644 --- a/packages/world/ts/config/v2/input.ts +++ b/packages/world/ts/config/v2/input.ts @@ -1,7 +1,6 @@ import { StoreInput, NamespaceInput as StoreNamespaceInput } from "@latticexyz/store/internal"; import { DynamicResolution, ValueWithType } from "./dynamicResolution"; import { Codegen, SystemDeploy } from "./output"; -import type { Address, Hex } from "viem"; export type SystemDeployInput = Partial; @@ -84,37 +83,6 @@ export type ModuleInput = ModuleInputArtifactPath & { readonly args?: readonly (ValueWithType | DynamicResolution)[]; }; -/** - * An explicit, compare-and-swap migration for an existing World function selector. - * The migration is only valid when the complete current route matches the declared - * source tuple, or when it already matches the destination tuple. - */ -export type FunctionRouteMigrationInput = { - readonly worldSelector: Hex; - readonly fromSystemId: Hex; - readonly fromSystemFunctionSelector: Hex; - readonly toSystemId: Hex; - readonly toSystemFunctionSelector: Hex; -}; - -/** An explicit, compare-and-swap removal of an obsolete World function selector. */ -export type FunctionSelectorRemovalInput = { - readonly worldSelector: Hex; - readonly expectedSystemId: Hex; - readonly expectedSystemFunctionSelector: Hex; -}; - -/** An explicit declaration that a legacy System should become permanently inactive. */ -export type SystemRetirementInput = { - readonly systemId: Hex; -}; - -/** Explicit consent to replace a legacy/custom core RegistrationSystem during bootstrap. */ -export type RegistrationSystemMigrationInput = { - /** The exact live RegistrationSystem implementation that may be replaced. */ - readonly expectedSystem: Address; -}; - export type DeployInput = { /** * Script to execute after the deployment is complete (Default "PostDeploy"). @@ -127,14 +95,6 @@ export type DeployInput = { readonly worldsFile?: string; /** Deploy the World as an upgradeable proxy */ readonly upgradeableWorldImplementation?: boolean; - /** Explicit selector routes to replace during an existing World deployment. */ - readonly functionRouteMigrations?: readonly FunctionRouteMigrationInput[]; - /** Explicit selector routes to remove during an existing World deployment. */ - readonly functionSelectorRemovals?: readonly FunctionSelectorRemovalInput[]; - /** Legacy Systems to retire after their selector routes have been migrated or removed. */ - readonly systemRetirements?: readonly SystemRetirementInput[]; - /** Guarded opt-in for bootstrapping native lifecycle APIs onto a legacy World. */ - readonly registrationSystemMigration?: RegistrationSystemMigrationInput; /** * Deploy the World using a custom implementation. This world must implement the same interface as `World.sol` so that it can initialize core modules, etc. * If you want to extend the world with new functions or override existing registered functions, we recommend using [root systems](https://mud.dev/world/systems#root-systems). diff --git a/packages/world/ts/config/v2/output.ts b/packages/world/ts/config/v2/output.ts index 79bf436bbd..f8bf3e6fd0 100644 --- a/packages/world/ts/config/v2/output.ts +++ b/packages/world/ts/config/v2/output.ts @@ -1,7 +1,7 @@ import { Store } from "@latticexyz/store"; import { Namespace as StoreNamespace } from "@latticexyz/store/internal"; import { DynamicResolution, ValueWithType } from "./dynamicResolution"; -import { Address, Hex } from "viem"; +import { Hex } from "viem"; export type Module = { /** Should this module be installed as a root module? */ @@ -97,30 +97,6 @@ export type Deploy = { readonly worldsFile: string; /** Deploy the World as an upgradeable proxy */ readonly upgradeableWorldImplementation: boolean; - /** Explicit selector routes to replace during an existing World deployment. */ - readonly functionRouteMigrations: readonly { - readonly worldSelector: Hex; - readonly fromSystemId: Hex; - readonly fromSystemFunctionSelector: Hex; - readonly toSystemId: Hex; - readonly toSystemFunctionSelector: Hex; - }[]; - /** Explicit selector routes to remove during an existing World deployment. */ - readonly functionSelectorRemovals: readonly { - readonly worldSelector: Hex; - readonly expectedSystemId: Hex; - readonly expectedSystemFunctionSelector: Hex; - }[]; - /** Legacy Systems to retire after their selector routes have been migrated or removed. */ - readonly systemRetirements: readonly { - readonly systemId: Hex; - }[]; - /** Guarded opt-in for replacing a legacy/custom core RegistrationSystem. */ - readonly registrationSystemMigration: - | { - readonly expectedSystem: Address; - } - | undefined; /** * Deploy the World using a custom implementation. This world must implement the same interface as `World.sol` so that it can initialize core modules, etc. * If you want to extend the world with new functions or override existing registered functions, we recommend using [root systems](https://mud.dev/world/systems#root-systems). diff --git a/packages/world/ts/config/v2/world.test.ts b/packages/world/ts/config/v2/world.test.ts index c0612d6c32..02a67466e7 100644 --- a/packages/world/ts/config/v2/world.test.ts +++ b/packages/world/ts/config/v2/world.test.ts @@ -90,39 +90,6 @@ describe("defineWorld", () => { attest>(); }); - it("should preserve explicit selector migrations and System retirements", () => { - const migration = { - worldSelector: "0x12345678", - fromSystemId: `0x${"11".repeat(32)}`, - fromSystemFunctionSelector: "0x10203040", - toSystemId: `0x${"22".repeat(32)}`, - toSystemFunctionSelector: "0x90abcdef", - } as const; - const removal = { - worldSelector: "0x87654321", - expectedSystemId: migration.fromSystemId, - expectedSystemFunctionSelector: migration.fromSystemFunctionSelector, - } as const; - const retirement = { systemId: migration.fromSystemId } as const; - const registrationSystemMigration = { - expectedSystem: "0x1111111111111111111111111111111111111111", - } as const; - - const config = defineWorld({ - deploy: { - functionRouteMigrations: [migration], - functionSelectorRemovals: [removal], - systemRetirements: [retirement], - registrationSystemMigration, - }, - }); - - attest(config.deploy.functionRouteMigrations).equals([migration]); - attest(config.deploy.functionSelectorRemovals).equals([removal]); - attest(config.deploy.systemRetirements).equals([retirement]); - attest(config.deploy.registrationSystemMigration).equals(registrationSystemMigration); - }); - it("should only allow for single namespace or multiple namespaces, not both", () => { attest(() => defineWorld({ @@ -233,10 +200,6 @@ describe("defineWorld", () => { deploysDirectory: "./deploys", worldsFile: "./worlds.json", upgradeableWorldImplementation: false, - functionRouteMigrations: [], - functionSelectorRemovals: [], - systemRetirements: [], - registrationSystemMigration: undefined as never, }, modules: [], }).type.toString.snap(`{ @@ -337,10 +300,6 @@ describe("defineWorld", () => { readonly deploysDirectory: "./deploys" readonly worldsFile: "./worlds.json" readonly upgradeableWorldImplementation: false - readonly functionRouteMigrations: readonly [] - readonly functionSelectorRemovals: readonly [] - readonly systemRetirements: readonly [] - readonly registrationSystemMigration: undefined } }`); }); @@ -428,10 +387,6 @@ describe("defineWorld", () => { deploysDirectory: "./deploys", worldsFile: "./worlds.json", upgradeableWorldImplementation: false, - functionRouteMigrations: [], - functionSelectorRemovals: [], - systemRetirements: [], - registrationSystemMigration: undefined as never, }, modules: [], }).type.toString.snap(`{ @@ -532,10 +487,6 @@ describe("defineWorld", () => { readonly deploysDirectory: "./deploys" readonly worldsFile: "./worlds.json" readonly upgradeableWorldImplementation: false - readonly functionRouteMigrations: readonly [] - readonly functionSelectorRemovals: readonly [] - readonly systemRetirements: readonly [] - readonly registrationSystemMigration: undefined } }`); }); diff --git a/packages/world/ts/protocol-snapshots/2.1.0.snap b/packages/world/ts/protocol-snapshots/2.1.0.snap deleted file mode 100644 index 886bddea12..0000000000 --- a/packages/world/ts/protocol-snapshots/2.1.0.snap +++ /dev/null @@ -1,115 +0,0 @@ -[ - "error EncodedLengths_InvalidLength(uint256 length)", - "error FieldLayout_Empty()", - "error FieldLayout_InvalidStaticDataLength(uint256 staticDataLength, uint256 computedStaticDataLength)", - "error FieldLayout_StaticLengthDoesNotFitInAWord(uint256 index)", - "error FieldLayout_StaticLengthIsNotZero(uint256 index)", - "error FieldLayout_StaticLengthIsZero(uint256 index)", - "error FieldLayout_TooManyDynamicFields(uint256 numFields, uint256 maxFields)", - "error FieldLayout_TooManyFields(uint256 numFields, uint256 maxFields)", - "error Module_AlreadyInstalled()", - "error Module_MissingDependency(address dependency)", - "error Module_NonRootInstallNotSupported()", - "error Module_RootInstallNotSupported()", - "error Schema_InvalidLength(uint256 length)", - "error Schema_StaticTypeAfterDynamicType()", - "error Slice_OutOfBounds(bytes data, uint256 start, uint256 end)", - "error Store_IndexOutOfBounds(uint256 length, uint256 accessedIndex)", - "error Store_InvalidBounds(uint256 start, uint256 end)", - "error Store_InvalidFieldNamesLength(uint256 expected, uint256 received)", - "error Store_InvalidKeyNamesLength(uint256 expected, uint256 received)", - "error Store_InvalidResourceType(bytes2 expected, bytes32 resourceId, string resourceIdString)", - "error Store_InvalidSplice(uint40 startWithinField, uint40 deleteCount, uint40 fieldLength)", - "error Store_InvalidStaticDataLength(uint256 expected, uint256 received)", - "error Store_InvalidValueSchemaDynamicLength(uint256 expected, uint256 received)", - "error Store_InvalidValueSchemaLength(uint256 expected, uint256 received)", - "error Store_InvalidValueSchemaStaticLength(uint256 expected, uint256 received)", - "error Store_TableAlreadyExists(bytes32 tableId, string tableIdString)", - "error Store_TableNotFound(bytes32 tableId, string tableIdString)", - "error World_AccessDenied(string resource, address caller)", - "error World_AlreadyInitialized()", - "error World_CallbackNotAllowed(bytes4 functionSelector)", - "error World_DelegationNotFound(address delegator, address delegatee)", - "error World_FunctionSelectorAlreadyExists(bytes4 functionSelector)", - "error World_FunctionSelectorMismatch(bytes4 worldFunctionSelector, bytes32 expectedSystemId, bytes4 expectedSystemFunctionSelector, bytes32 actualSystemId, bytes4 actualSystemFunctionSelector)", - "error World_FunctionSelectorNotFound(bytes4 functionSelector)", - "error World_InsufficientBalance(uint256 balance, uint256 amount)", - "error World_InterfaceNotSupported(address contractAddress, bytes4 interfaceId)", - "error World_InvalidNamespace(bytes14 namespace)", - "error World_InvalidResourceId(bytes32 resourceId, string resourceIdString)", - "error World_InvalidResourceType(bytes2 expected, bytes32 resourceId, string resourceIdString)", - "error World_ResourceAlreadyExists(bytes32 resourceId, string resourceIdString)", - "error World_ResourceNotFound(bytes32 resourceId, string resourceIdString)", - "error World_SystemAlreadyExists(address system)", - "error World_SystemAlreadyRetired(bytes32 systemId, string systemIdString)", - "error World_SystemCannotBeRetired(bytes32 systemId, string systemIdString)", - "error World_SystemRegistryMismatch(address system, bytes32 expectedSystemId, bytes32 actualSystemId)", - "error World_SystemStateMismatch(bytes32 systemId, address expectedSystem, bool expectedPublicAccess, address actualSystem, bool actualPublicAccess)", - "error World_UnlimitedDelegationNotAllowed()", - "event HelloStore(bytes32 indexed storeVersion)", - "event HelloWorld(bytes32 indexed worldVersion)", - "event Store_DeleteRecord(bytes32 indexed tableId, bytes32[] keyTuple)", - "event Store_SetRecord(bytes32 indexed tableId, bytes32[] keyTuple, bytes staticData, bytes32 encodedLengths, bytes dynamicData)", - "event Store_SpliceDynamicData(bytes32 indexed tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint48 start, uint40 deleteCount, bytes32 encodedLengths, bytes data)", - "event Store_SpliceStaticData(bytes32 indexed tableId, bytes32[] keyTuple, uint48 start, bytes data)", - "event WorldFunctionRouteReplaced(bytes4 indexed worldFunctionSelector, bytes32 indexed oldSystemId, bytes32 indexed newSystemId, bytes4 oldSystemFunctionSelector, bytes4 newSystemFunctionSelector)", - "event WorldFunctionSelectorUnregistered(bytes4 indexed worldFunctionSelector, bytes32 indexed systemId, bytes4 systemFunctionSelector)", - "event WorldSystemReplaced(bytes32 indexed systemId, address indexed oldSystem, address indexed newSystem, bool publicAccess)", - "event WorldSystemRetired(bytes32 indexed systemId, address indexed system)", - "function batchCall((bytes32 systemId, bytes callData)[] systemCalls) returns (bytes[] returnDatas)", - "function batchCallFrom((address from, bytes32 systemId, bytes callData)[] systemCalls) returns (bytes[] returnDatas)", - "function call(bytes32 systemId, bytes callData) payable returns (bytes)", - "function callFrom(address delegator, bytes32 systemId, bytes callData) payable returns (bytes)", - "function creator() view returns (address)", - "function deleteRecord(bytes32 tableId, bytes32[] keyTuple)", - "function getDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex) view returns (bytes)", - "function getDynamicFieldLength(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex) view returns (uint256)", - "function getDynamicFieldSlice(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint256 start, uint256 end) view returns (bytes data)", - "function getField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes32 fieldLayout) view returns (bytes data)", - "function getField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex) view returns (bytes data)", - "function getFieldLayout(bytes32 tableId) view returns (bytes32 fieldLayout)", - "function getFieldLength(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes32 fieldLayout) view returns (uint256)", - "function getFieldLength(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex) view returns (uint256)", - "function getKeySchema(bytes32 tableId) view returns (bytes32 keySchema)", - "function getRecord(bytes32 tableId, bytes32[] keyTuple, bytes32 fieldLayout) view returns (bytes staticData, bytes32 encodedLengths, bytes dynamicData)", - "function getRecord(bytes32 tableId, bytes32[] keyTuple) view returns (bytes staticData, bytes32 encodedLengths, bytes dynamicData)", - "function getStaticField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes32 fieldLayout) view returns (bytes32)", - "function getValueSchema(bytes32 tableId) view returns (bytes32 valueSchema)", - "function grantAccess(bytes32 resourceId, address grantee)", - "function initialize(address initModule)", - "function installModule(address module, bytes encodedArgs)", - "function installRootModule(address module, bytes encodedArgs)", - "function popFromDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint256 byteLengthToPop)", - "function pushToDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, bytes dataToPush)", - "function registerDelegation(address delegatee, bytes32 delegationControlId, bytes initCallData)", - "function registerFunctionSelector(bytes32 systemId, string systemFunctionSignature) returns (bytes4 worldFunctionSelector)", - "function registerNamespace(bytes32 namespaceId)", - "function registerNamespaceDelegation(bytes32 namespaceId, bytes32 delegationControlId, bytes initCallData)", - "function registerRootFunctionSelector(bytes32 systemId, string worldFunctionSignature, string systemFunctionSignature) returns (bytes4 worldFunctionSelector)", - "function registerStoreHook(bytes32 tableId, address hookAddress, uint8 enabledHooksBitmap)", - "function registerSystem(bytes32 systemId, address system, bool publicAccess)", - "function registerSystemHook(bytes32 systemId, address hookAddress, uint8 enabledHooksBitmap)", - "function registerTable(bytes32 tableId, bytes32 fieldLayout, bytes32 keySchema, bytes32 valueSchema, string[] keyNames, string[] fieldNames)", - "function renounceOwnership(bytes32 namespaceId)", - "function replaceFunctionRoute(bytes4 worldFunctionSelector, bytes32 expectedSystemId, bytes4 expectedSystemFunctionSelector, bytes32 newSystemId, string newSystemFunctionSignature)", - "function replaceSystem(bytes32 systemId, address expectedSystem, bool expectedPublicAccess, address newSystem, bool publicAccess)", - "function retireSystem(bytes32 systemId, address expectedSystem, bool expectedPublicAccess)", - "function revokeAccess(bytes32 resourceId, address grantee)", - "function setDynamicField(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, bytes data)", - "function setField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes data, bytes32 fieldLayout)", - "function setField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes data)", - "function setRecord(bytes32 tableId, bytes32[] keyTuple, bytes staticData, bytes32 encodedLengths, bytes dynamicData)", - "function setStaticField(bytes32 tableId, bytes32[] keyTuple, uint8 fieldIndex, bytes data, bytes32 fieldLayout)", - "function spliceDynamicData(bytes32 tableId, bytes32[] keyTuple, uint8 dynamicFieldIndex, uint40 startWithinField, uint40 deleteCount, bytes data)", - "function spliceStaticData(bytes32 tableId, bytes32[] keyTuple, uint48 start, bytes data)", - "function storeVersion() view returns (bytes32 version)", - "function transferBalanceToAddress(bytes32 fromNamespaceId, address toAddress, uint256 amount)", - "function transferBalanceToNamespace(bytes32 fromNamespaceId, bytes32 toNamespaceId, uint256 amount)", - "function transferOwnership(bytes32 namespaceId, address newOwner)", - "function unregisterDelegation(address delegatee)", - "function unregisterFunctionSelector(bytes4 worldFunctionSelector, bytes32 expectedSystemId, bytes4 expectedSystemFunctionSelector)", - "function unregisterNamespaceDelegation(bytes32 namespaceId)", - "function unregisterStoreHook(bytes32 tableId, address hookAddress)", - "function unregisterSystemHook(bytes32 systemId, address hookAddress)", - "function worldVersion() view returns (bytes32)", -] \ No newline at end of file diff --git a/packages/world/ts/protocolVersions.ts b/packages/world/ts/protocolVersions.ts index ca59b7681b..b91dcbbbc2 100644 --- a/packages/world/ts/protocolVersions.ts +++ b/packages/world/ts/protocolVersions.ts @@ -1,7 +1,5 @@ // History of protocol versions and a short description of what changed in each. export const protocolVersions = { - "2.1.0": - "Added compare-and-swap World function selector and system migrations, plus permanent guarded system retirement.", "2.0.2": "Patched `StoreCore.registerTable` to prevent registering both an offchain and onchain table with the same name.", "2.0.1": "Patched `StoreRead.getDynamicFieldLength` to use the correct method to read the dynamic field length.", diff --git a/scripts/package-fork-release.mjs b/scripts/package-fork-release.mjs index f64a55e8c9..21088230ef 100644 --- a/scripts/package-fork-release.mjs +++ b/scripts/package-fork-release.mjs @@ -18,41 +18,24 @@ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; export const baseVersion = "2.2.23"; -export const defaultRepository = "Floki-Inu/mud"; +export const forkRepository = { + type: "git", + url: "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/Floki-Inu/mud.git", + directory: "packages/cli", +}; const dependencySections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; const packageDefinitions = [ - { - name: "@latticexyz/world", - sourceDirectory: "packages/world", - requiredFiles: [ - "dist/index.js", - "dist/internal.js", - "dist/mud.config.js", - "dist/node.js", - "out/IBaseWorld.sol/IBaseWorld.abi.json", - "out/World.sol/World.json", - "src/World.sol", - "test/MudTest.t.sol", - ], - }, { name: "@latticexyz/cli", sourceDirectory: "packages/cli", requiredFiles: ["bin/mud.js", "dist/index.js", "dist/mud.js", "dist/version.js"], }, ]; -const lifecycleFunctions = [ - { name: "replaceFunctionRoute", inputs: ["bytes4", "bytes32", "bytes4", "bytes32", "string"] }, - { name: "unregisterFunctionSelector", inputs: ["bytes4", "bytes32", "bytes4"] }, - { name: "retireSystem", inputs: ["bytes32", "address", "bool"] }, - { name: "replaceSystem", inputs: ["bytes32", "address", "bool", "address", "bool"] }, -]; const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const safeTagPattern = /^[0-9A-Za-z][0-9A-Za-z._-]*$/; -const repositoryPattern = /^[0-9A-Za-z_.-]+\/[0-9A-Za-z_.-]+$/; function compareStrings(left, right) { return left < right ? -1 : left > right ? 1 : 0; @@ -66,15 +49,13 @@ Options: --version Fork package version, for example 2.2.24-floki.1 --tag GitHub Release tag; must equal v --output New or existing output directory (target assets must not exist) - --repository GitHub owner/repository used in the CLI's World tarball URL - (default: $GITHUB_REPOSITORY or ${defaultRepository}) --help Show this help `; } -export function parseArguments(argv, environment = process.env) { +export function parseArguments(argv) { const values = {}; - const allowed = new Set(["version", "tag", "output", "repository"]); + const allowed = new Set(["version", "tag", "output"]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; @@ -98,7 +79,6 @@ export function parseArguments(argv, environment = process.env) { if (values[required] == null) throw new Error(`Missing required option: --${required}`); } - const repository = values.repository ?? environment.GITHUB_REPOSITORY ?? defaultRepository; if (!semverPattern.test(values.version)) throw new Error(`Invalid semantic version: ${values.version}`); if (!values.version.includes("-floki.")) { throw new Error(`Fork version must include the "-floki." prerelease identifier: ${values.version}`); @@ -107,14 +87,11 @@ export function parseArguments(argv, environment = process.env) { if (values.tag !== `v${values.version}`) { throw new Error(`GitHub Release tag must be exactly v${values.version}; received ${values.tag}.`); } - if (!repositoryPattern.test(repository)) throw new Error(`Invalid GitHub repository: ${repository}`); - return { help: false, version: values.version, tag: values.tag, output: resolve(values.output), - repository, }; } @@ -122,12 +99,7 @@ export function packageTarballName(packageName, version) { return `${packageName.replace(/^@/, "").replaceAll("/", "-")}-${version}.tgz`; } -export function worldReleaseAssetUrl({ repository, tag, version }) { - const filename = packageTarballName("@latticexyz/world", version); - return `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${filename}`; -} - -export function stageManifest({ manifest, packageName, version, worldAssetUrl }) { +export function stageManifest({ manifest, packageName, version }) { if (manifest.name !== packageName) { throw new Error(`Expected raw package ${packageName}, received ${String(manifest.name)}.`); } @@ -140,12 +112,12 @@ export function stageManifest({ manifest, packageName, version, worldAssetUrl }) const staged = structuredClone(manifest); staged.version = version; + staged.repository = structuredClone(forkRepository); for (const section of dependencySections) { if (staged[section] == null) continue; for (const dependencyName of Object.keys(staged[section])) { if (!dependencyName.startsWith("@latticexyz/")) continue; - staged[section][dependencyName] = - packageName === "@latticexyz/cli" && dependencyName === "@latticexyz/world" ? worldAssetUrl : baseVersion; + staged[section][dependencyName] = baseVersion; } staged[section] = Object.fromEntries( Object.entries(staged[section]).sort(([left], [right]) => compareStrings(left, right)), @@ -213,19 +185,6 @@ function assertFile(path, label) { if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Missing ${label}: ${path}`); } -function validateLifecycleAbi(packageRoot) { - const abi = readJson(join(packageRoot, "out/IBaseWorld.sol/IBaseWorld.abi.json")); - for (const expected of lifecycleFunctions) { - const abiFunction = abi.find((item) => item.type === "function" && item.name === expected.name); - const inputs = abiFunction?.inputs?.map((input) => input.type); - if (abiFunction?.stateMutability !== "nonpayable" || JSON.stringify(inputs) !== JSON.stringify(expected.inputs)) { - throw new Error( - `World ABI is missing ${expected.name}(${expected.inputs.join(",")}) with nonpayable mutability.`, - ); - } - } -} - function readCliRuntimePackageInfo(packageRoot) { const moduleUrl = pathToFileURL(join(packageRoot, "dist/version.js")).href; const output = run(process.execPath, [ @@ -240,7 +199,7 @@ function readCliRuntimePackageInfo(packageRoot) { } } -export function validateStagedManifest({ manifest, packageName, version, worldAssetUrl }) { +export function validateStagedManifest({ manifest, packageName, version }) { if (manifest.name !== packageName) throw new Error(`Unexpected package name: ${String(manifest.name)}`); if (manifest.version !== version) { throw new Error(`${packageName} has version ${String(manifest.version)} instead of ${version}.`); @@ -252,11 +211,9 @@ export function validateStagedManifest({ manifest, packageName, version, worldAs throw new Error(`${packageName} ${section}.${dependencyName} still uses a workspace dependency.`); } if (!dependencyName.startsWith("@latticexyz/")) continue; - const expected = - packageName === "@latticexyz/cli" && dependencyName === "@latticexyz/world" ? worldAssetUrl : baseVersion; - if (dependencyVersion !== expected) { + if (dependencyVersion !== baseVersion) { throw new Error( - `${packageName} ${section}.${dependencyName} must be exactly ${expected}, received ${String( + `${packageName} ${section}.${dependencyName} must be exactly ${baseVersion}, received ${String( dependencyVersion, )}.`, ); @@ -264,23 +221,29 @@ export function validateStagedManifest({ manifest, packageName, version, worldAs } } - if (packageName === "@latticexyz/cli" && manifest.dependencies?.["@latticexyz/world"] !== worldAssetUrl) { - throw new Error("The CLI package must depend on the tag-specific World release asset URL."); + if (packageName === "@latticexyz/cli" && manifest.dependencies?.["@latticexyz/world"] !== baseVersion) { + throw new Error(`The CLI package must depend on official @latticexyz/world ${baseVersion}.`); + } + if ( + packageName === "@latticexyz/cli" && + (manifest.repository?.type !== forkRepository.type || + manifest.repository?.url !== forkRepository.url || + manifest.repository?.directory !== forkRepository.directory) + ) { + throw new Error(`The CLI package repository must be ${forkRepository.url}.`); } } -function validateArchive({ archive, definition, version, worldAssetUrl, verificationRoot }) { +function validateArchive({ archive, definition, version, verificationRoot }) { const packageRoot = extractArchive(archive, join(verificationRoot, definition.name.replaceAll("/", "-"))); const manifest = readJson(join(packageRoot, "package.json")); - validateStagedManifest({ manifest, packageName: definition.name, version, worldAssetUrl }); + validateStagedManifest({ manifest, packageName: definition.name, version }); for (const relativePath of definition.requiredFiles) { assertFile(join(packageRoot, relativePath), `${definition.name} package content ${relativePath}`); } - if (definition.name === "@latticexyz/world") { - validateLifecycleAbi(packageRoot); - } else if (definition.name === "@latticexyz/cli") { + if (definition.name === "@latticexyz/cli") { if (manifest.bin?.mud !== "./bin/mud.js") throw new Error("The CLI manifest does not expose ./bin/mud.js."); const executable = join(packageRoot, "bin/mud.js"); if ((statSync(executable).mode & 0o111) === 0) throw new Error("The packaged mud executable has no execute bit."); @@ -351,7 +314,6 @@ export function packageForkRelease(options) { const stagedRoot = join(temporaryRoot, "staged"); const repackedRoot = join(temporaryRoot, "repacked"); const verificationRoot = join(temporaryRoot, "verified"); - const worldAssetUrl = worldReleaseAssetUrl(options); const archives = []; for (const definition of packageDefinitions) { @@ -362,7 +324,6 @@ export function packageForkRelease(options) { manifest: readJson(manifestPath), packageName: definition.name, version: options.version, - worldAssetUrl, }); writeJson(manifestPath, manifest); @@ -375,14 +336,13 @@ export function packageForkRelease(options) { archive, definition, version: options.version, - worldAssetUrl, verificationRoot, }); archives.push(archive); } const checksums = copyReleaseAssets({ archives, output: options.output }); - return { archives: archives.map((archive) => join(options.output, basename(archive))), checksums, worldAssetUrl }; + return { archives: archives.map((archive) => join(options.output, basename(archive))), checksums }; } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } @@ -395,7 +355,6 @@ function main() { return; } const result = packageForkRelease(options); - process.stdout.write(`World dependency URL: ${result.worldAssetUrl}\n`); for (const { digest, filename } of result.checksums) process.stdout.write(`${digest} ${filename}\n`); process.stdout.write(`Wrote ${result.archives.length} packages and SHA256SUMS to ${options.output}\n`); } diff --git a/scripts/package-fork-release.test.mjs b/scripts/package-fork-release.test.mjs index 11440e74eb..403187df4c 100644 --- a/scripts/package-fork-release.test.mjs +++ b/scripts/package-fork-release.test.mjs @@ -3,33 +3,24 @@ import { describe, it } from "node:test"; import { baseVersion, + forkRepository, packageTarballName, parseArguments, stageManifest, validateStagedManifest, - worldReleaseAssetUrl, } from "./package-fork-release.mjs"; const version = "2.2.24-floki.1"; const tag = `v${version}`; -const repository = "Floki-Inu/mud"; -const worldAssetUrl = - "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/Floki-Inu/mud/releases/download/v2.2.24-floki.1/latticexyz-world-2.2.24-floki.1.tgz"; describe("fork release packaging", () => { it("parses and validates the required release arguments", () => { - assert.deepEqual( - parseArguments(["--", "--version", version, "--tag", tag, "--output", "release"], { - GITHUB_REPOSITORY: repository, - }), - { - help: false, - version, - tag, - output: new URL("../release", import.meta.url).pathname, - repository, - }, - ); + assert.deepEqual(parseArguments(["--", "--version", version, "--tag", tag, "--output", "release"]), { + help: false, + version, + tag, + output: new URL("../release", import.meta.url).pathname, + }); assert.throws(() => parseArguments(["--version", "2.2.24", "--tag", "v2.2.24", "--output", "release"]), /-floki\./); assert.throws( () => parseArguments(["--version", version, "--tag", "refs/tags/unsafe", "--output", "release"]), @@ -41,16 +32,20 @@ describe("fork release packaging", () => { ); }); - it("uses stable package filenames and a tag-specific World URL", () => { - assert.equal(packageTarballName("@latticexyz/world", version), `latticexyz-world-${version}.tgz`); - assert.equal(worldReleaseAssetUrl({ repository, tag, version }), worldAssetUrl); + it("uses a stable CLI package filename", () => { + assert.equal(packageTarballName("@latticexyz/cli", version), `latticexyz-cli-${version}.tgz`); }); - it("stages only World as a fork dependency and pins every other internal dependency", () => { + it("pins every internal dependency, including World, to the official base version", () => { const staged = stageManifest({ manifest: { name: "@latticexyz/cli", version: baseVersion, + repository: { + type: "git", + url: "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/latticexyz/mud.git", + directory: "packages/cli", + }, dependencies: { "@latticexyz/common": "workspace:*", "@latticexyz/world": "workspace:*", @@ -62,11 +57,11 @@ describe("fork release packaging", () => { }, packageName: "@latticexyz/cli", version, - worldAssetUrl, }); assert.equal(staged.version, version); - assert.equal(staged.dependencies["@latticexyz/world"], worldAssetUrl); + assert.deepEqual(staged.repository, forkRepository); + assert.equal(staged.dependencies["@latticexyz/world"], baseVersion); assert.equal(staged.dependencies["@latticexyz/common"], baseVersion); assert.equal(staged.devDependencies["@latticexyz/abi-ts"], baseVersion); assert.equal(staged.dependencies.viem, "2.35.1"); @@ -76,7 +71,6 @@ describe("fork release packaging", () => { manifest: staged, packageName: "@latticexyz/cli", version, - worldAssetUrl, }), ); }); @@ -85,26 +79,58 @@ describe("fork release packaging", () => { assert.throws( () => stageManifest({ - manifest: { name: "@latticexyz/world", version: "2.2.22" }, - packageName: "@latticexyz/world", + manifest: { name: "@latticexyz/cli", version: "2.2.22" }, + packageName: "@latticexyz/cli", version, - worldAssetUrl, }), - /Expected @latticexyz\/world raw package version 2\.2\.23/, + /Expected @latticexyz\/cli raw package version 2\.2\.23/, ); assert.throws( () => validateStagedManifest({ manifest: { - name: "@latticexyz/world", + name: "@latticexyz/cli", version, - dependencies: { "@latticexyz/store": "workspace:*" }, + dependencies: { + "@latticexyz/store": "workspace:*", + "@latticexyz/world": baseVersion, + }, }, - packageName: "@latticexyz/world", + packageName: "@latticexyz/cli", version, - worldAssetUrl, }), /still uses a workspace dependency/, ); + assert.throws( + () => + validateStagedManifest({ + manifest: { + name: "@latticexyz/cli", + version, + dependencies: { "@latticexyz/world": version }, + }, + packageName: "@latticexyz/cli", + version, + }), + /@latticexyz\/world must be exactly 2\.2\.23/, + ); + assert.throws( + () => + validateStagedManifest({ + manifest: { + name: "@latticexyz/cli", + version, + repository: { + type: "git", + url: "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/latticexyz/mud.git", + directory: "packages/cli", + }, + dependencies: { "@latticexyz/world": baseVersion }, + }, + packageName: "@latticexyz/cli", + version, + }), + /repository must be https:\/\/github\.com\/Floki-Inu\/mud\.git/, + ); }); }); From 1e80c6081e9ad7e52634225a3a34d0bb93be7d9c Mon Sep 17 00:00:00 2001 From: Jackie Xu Date: Mon, 17 Aug 2026 16:11:29 +0200 Subject: [PATCH 3/4] fix(cli): bypass block cache during route verification --- .../cli/src/deploy/ensureFunctions.test.ts | 30 +++++++++++++++++-- packages/cli/src/deploy/ensureFunctions.ts | 4 +-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/deploy/ensureFunctions.test.ts b/packages/cli/src/deploy/ensureFunctions.test.ts index ea503fc19d..2649677c35 100644 --- a/packages/cli/src/deploy/ensureFunctions.test.ts +++ b/packages/cli/src/deploy/ensureFunctions.test.ts @@ -1,13 +1,14 @@ -import { padHex, toFunctionSelector, zeroAddress } from "viem"; -import { describe, expect, it } from "vitest"; +import { padHex, toFunctionSelector, toHex, zeroAddress } from "viem"; +import { describe, expect, it, vi } from "vitest"; import { resourceToHex } from "@latticexyz/common"; import worldConfig from "@latticexyz/world/mud.config"; -import type { WorldFunction } from "./common"; +import type { CommonDeployOptions, WorldFunction } from "./common"; import { assertFunctionSelectorsWriteAccess, assertTargetSystemActive, getFunctionReconciliationAction, getFunctionSystemIdWrite, + getLatestWorldDeploy, } from "./ensureFunctions"; const sourceSystemId = `0x${"11".repeat(32)}` as const; @@ -25,6 +26,29 @@ function worldFunction(namespace: string): WorldFunction { } describe("FunctionSelectors System ID reconciliation", () => { + it("bypasses viem's block-number cache for immediately consecutive latest reads", async () => { + const request = vi.fn().mockResolvedValueOnce(toHex(100n)).mockResolvedValueOnce(toHex(101n)); + const client = { + uid: "ensure-functions-latest-block-test", + cacheTime: 10_000, + request, + } as unknown as CommonDeployOptions["client"]; + const worldDeploy: CommonDeployOptions["worldDeploy"] = { + address: zeroAddress, + worldVersion: "2.0.2", + storeVersion: "2.0.2", + deployBlock: 1n, + stateBlock: 99n, + }; + + const first = await getLatestWorldDeploy({ client, worldDeploy }); + const second = await getLatestWorldDeploy({ client, worldDeploy }); + + expect(first.stateBlock).toBe(100n); + expect(second.stateBlock).toBe(101n); + expect(request).toHaveBeenCalledTimes(2); + }); + it("encodes a write to field zero and the exact world-selector key", () => { const func = worldFunction("valhalla"); diff --git a/packages/cli/src/deploy/ensureFunctions.ts b/packages/cli/src/deploy/ensureFunctions.ts index e90aa243da..8bf3161938 100644 --- a/packages/cli/src/deploy/ensureFunctions.ts +++ b/packages/cli/src/deploy/ensureFunctions.ts @@ -103,11 +103,11 @@ export function getFunctionReconciliationAction({ return "write"; } -async function getLatestWorldDeploy({ +export async function getLatestWorldDeploy({ client, worldDeploy, }: Pick): Promise { - return { ...worldDeploy, stateBlock: await getBlockNumber(client) }; + return { ...worldDeploy, stateBlock: await getBlockNumber(client, { cacheTime: 0 }) }; } async function assertReconciliationTargetsActive({ From 8642e61721b4b2c4c5ac41d91dc307768c4a1aa5 Mon Sep 17 00:00:00 2001 From: Jackie Xu Date: Mon, 17 Aug 2026 16:11:45 +0200 Subject: [PATCH 4/4] fix(docs): normalize source links in fork builds --- scripts/render-api-docs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/render-api-docs.ts b/scripts/render-api-docs.ts index c5650a31b7..0cc52ce46d 100755 --- a/scripts/render-api-docs.ts +++ b/scripts/render-api-docs.ts @@ -398,7 +398,7 @@ function formatHeadings(content: string) { } function fixGithubLinks(content: string, packageName: string) { - const pattern = /https:\/\/github.com\/latticexyz\/mud\/blob\/[^/]+\/(.*)/g; + const pattern = /https:\/\/github\.com\/[^/]+\/mud\/blob\/[^/]+\/(.*)/g; const replacement = `https://github.com/latticexyz/mud/blob/main/packages/${packageName}/$1`; return content.replace(pattern, replacement); }