Skip to content

Commit

Permalink
feat: install module with delegation (#3586)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Feb 11, 2025
1 parent 06e48e0 commit 3187081
Show file tree
Hide file tree
Showing 24 changed files with 349 additions and 150 deletions.
6 changes: 6 additions & 0 deletions .changeset/dirty-pumas-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@latticexyz/cli": patch
"@latticexyz/world": patch
---

Added `useDelegation` module config option to install modules using a temporary, unlimited delegation. This allows modules to install or upgrade systems and tables on your behalf.
6 changes: 6 additions & 0 deletions .changeset/hot-pans-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@latticexyz/cli": patch
"@latticexyz/world-module-metadata": patch
---

Metadata module has been updated to install via delegation, making it easier for later module upgrades and to demonstrate modules installed via delegation.
25 changes: 25 additions & 0 deletions .changeset/purple-houses-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@latticexyz/world": patch
---

Updated `encodeSystemCalls` and `encodeSystemCallsFrom` to include the `abi` in each call so that different systems/ABIs can be called in batch. Types have been improved to properly hint/narrow the expected arguments for each call.

```diff
-encodeSystemCalls(abi, [{
+encodeSystemCalls([{
+ abi,
systemId: '0x...',
functionName: '...',
args: [...],
}]);
```

```diff
-encodeSystemCallsFrom(from, abi, [{
+encodeSystemCallsFrom(from, [{
+ abi,
systemId: '0x...',
functionName: '...',
args: [...],
}]);
```
11 changes: 6 additions & 5 deletions e2e/packages/sync-test/registerDelegationWithSignature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { createAsyncErrorHandler } from "./asyncErrors";
import { deployContracts, startViteServer, startBrowserAndPage, openClientWithRootAccount } from "./setup";
import { rpcHttpUrl } from "./setup/constants";
import { waitForInitialSync } from "./data/waitForInitialSync";
import { createBurnerAccount, resourceToHex, transportObserver } from "@latticexyz/common";
import { createBurnerAccount, hexToResource, resourceToHex, transportObserver } from "@latticexyz/common";
import { http, createWalletClient, ClientConfig, encodeFunctionData, toHex } from "viem";
import { mudFoundry } from "@latticexyz/common/chains";
import { encodeEntity } from "@latticexyz/store-sync/recs";
import { callPageFunction } from "./data/callPageFunction";
import worldConfig from "@latticexyz/world/mud.config";
import worldConfig, { systemsConfig as worldSystemsConfig } from "@latticexyz/world/mud.config";
import { callWithSignatureTypes } from "@latticexyz/world-module-callwithsignature/internal";
import { getWorld } from "./data/getWorld";
import { callWithSignature } from "./data/callWithSignature";
Expand Down Expand Up @@ -59,7 +59,8 @@ describe("callWithSignature", async () => {
});

const worldContract = await getWorld(page);
const systemId = resourceToHex({ type: "system", namespace: "", name: "Registration" });
const systemId = worldSystemsConfig.systems.RegistrationSystem.systemId;
const systemResource = hexToResource(systemId);

// Declare delegation parameters
const delegatee = "0x7203e7ADfDF38519e1ff4f8Da7DCdC969371f377";
Expand All @@ -84,8 +85,8 @@ describe("callWithSignature", async () => {
primaryType: "Call",
message: {
signer: delegator.address,
systemNamespace: "",
systemName: "Registration",
systemNamespace: systemResource.namespace,
systemName: systemResource.name,
callData,
nonce,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/deploy/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export type DeployedSystem = Omit<

export type Module = DeterministicContract & {
readonly name: string;
readonly installAsRoot: boolean;
readonly installStrategy: "root" | "delegation" | "default";
readonly installData: Hex; // TODO: figure out better naming for this
/**
* @internal
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/deploy/compat/moduleArtifactPathFromName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Module } from "@latticexyz/world/internal";
import path from "node:path";

// Please don't add to this list!
//
// These are kept for backwards compatibility and assumes the downstream project has this module installed as a dependency.
const knownModuleArtifacts = {
KeysWithValueModule: "@latticexyz/world-modules/out/KeysWithValueModule.sol/KeysWithValueModule.json",
KeysInTableModule: "@latticexyz/world-modules/out/KeysInTableModule.sol/KeysInTableModule.json",
UniqueEntityModule: "@latticexyz/world-modules/out/UniqueEntityModule.sol/UniqueEntityModule.json",
};

/** @internal For use with `config.modules.map(...)` */
export function moduleArtifactPathFromName(
forgeOutDir: string,
): (mod: Module) => Module & { readonly artifactPath: string } {
return (mod) => {
if (mod.artifactPath) return mod as never;
if (!mod.name) throw new Error("No `artifactPath` provided for module.");

const artifactPath =
knownModuleArtifacts[mod.name as keyof typeof knownModuleArtifacts] ??
path.join(forgeOutDir, `${mod.name}.sol`, `${mod.name}.json`);

console.warn(
[
"",
`⚠️ Your \`mud.config.ts\` is using a module with a \`name\`, but this option is deprecated.`,
"",
"To resolve this, you can replace this:",
"",
` name: ${JSON.stringify(mod.name)}`,
"",
"with this:",
"",
` artifactPath: ${JSON.stringify(artifactPath)}`,
"",
].join("\n"),
);

return { ...mod, artifactPath };
};
}
86 changes: 27 additions & 59 deletions packages/cli/src/deploy/configToModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,10 @@ import { resolveWithContext } from "@latticexyz/world/internal";
import callWithSignatureModule from "@latticexyz/world-module-callwithsignature/out/CallWithSignatureModule.sol/CallWithSignatureModule.json" assert { type: "json" };
import { getContractArtifact } from "../utils/getContractArtifact";
import { excludeCallWithSignatureModule } from "./compat/excludeUnstableCallWithSignatureModule";
import { moduleArtifactPathFromName } from "./compat/moduleArtifactPathFromName";

const callWithSignatureModuleArtifact = getContractArtifact(callWithSignatureModule);

/** Please don't add to this list! These are kept for backwards compatibility and assumes the downstream project has this module installed as a dependency. */
const knownModuleArtifacts = {
KeysWithValueModule: "@latticexyz/world-modules/out/KeysWithValueModule.sol/KeysWithValueModule.json",
KeysInTableModule: "@latticexyz/world-modules/out/KeysInTableModule.sol/KeysInTableModule.json",
UniqueEntityModule: "@latticexyz/world-modules/out/UniqueEntityModule.sol/UniqueEntityModule.json",
};

export async function configToModules<config extends World>(
config: config,
// TODO: remove/replace `forgeOutDir`
Expand All @@ -32,7 +26,7 @@ export async function configToModules<config extends World>(
// TODO: figure out approach to install on existing worlds where deployer may not own root namespace
optional: true,
name: "CallWithSignatureModule",
installAsRoot: true,
installStrategy: "root",
installData: "0x",
prepareDeploy: createPrepareDeploy(
callWithSignatureModuleArtifact.bytecode,
Expand All @@ -44,60 +38,34 @@ export async function configToModules<config extends World>(
];

const modules = await Promise.all(
config.modules.filter(excludeCallWithSignatureModule).map(async (mod): Promise<Module> => {
let artifactPath = mod.artifactPath;

// Backwards compatibility
// TODO: move this up a level so we don't need `forgeOutDir` in here?
if (!artifactPath) {
if (mod.name) {
artifactPath =
knownModuleArtifacts[mod.name as keyof typeof knownModuleArtifacts] ??
path.join(forgeOutDir, `${mod.name}.sol`, `${mod.name}.json`);
console.warn(
[
"",
`⚠️ Your \`mud.config.ts\` is using a module with a \`name\`, but this option is deprecated.`,
"",
"To resolve this, you can replace this:",
"",
` name: ${JSON.stringify(mod.name)}`,
"",
"with this:",
"",
` artifactPath: ${JSON.stringify(artifactPath)}`,
"",
].join("\n"),
);
} else {
throw new Error("No `artifactPath` provided for module.");
}
}
config.modules
.filter(excludeCallWithSignatureModule)
.map(moduleArtifactPathFromName(forgeOutDir))
.map(async (mod): Promise<Module> => {
const name = path.basename(mod.artifactPath, ".json");
const artifact = await importContractArtifact({ artifactPath: mod.artifactPath });

const name = path.basename(artifactPath, ".json");
const artifact = await importContractArtifact({ artifactPath });
// TODO: replace args with something more strongly typed
const installArgs = mod.args
.map((arg) => resolveWithContext(arg, { config }))
.map((arg) => {
const value = arg.value instanceof Uint8Array ? bytesToHex(arg.value) : arg.value;
return encodeField(arg.type as SchemaAbiType, value as SchemaAbiTypeToPrimitiveType<SchemaAbiType>);
});

// TODO: replace args with something more strongly typed
const installArgs = mod.args
.map((arg) => resolveWithContext(arg, { config }))
.map((arg) => {
const value = arg.value instanceof Uint8Array ? bytesToHex(arg.value) : arg.value;
return encodeField(arg.type as SchemaAbiType, value as SchemaAbiTypeToPrimitiveType<SchemaAbiType>);
});

if (installArgs.length > 1) {
throw new Error(`${name} module should only have 0-1 args, but had ${installArgs.length} args.`);
}
if (installArgs.length > 1) {
throw new Error(`${name} module should only have 0-1 args, but had ${installArgs.length} args.`);
}

return {
name,
installAsRoot: mod.root,
installData: installArgs.length === 0 ? "0x" : installArgs[0],
prepareDeploy: createPrepareDeploy(artifact.bytecode, artifact.placeholders),
deployedBytecodeSize: artifact.deployedBytecodeSize,
abi: artifact.abi,
};
}),
return {
name,
installStrategy: mod.root ? "root" : mod.useDelegation ? "delegation" : "default",
installData: installArgs.length === 0 ? "0x" : installArgs[0],
prepareDeploy: createPrepareDeploy(artifact.bytecode, artifact.placeholders),
deployedBytecodeSize: artifact.deployedBytecodeSize,
abi: artifact.abi,
};
}),
);

return [...defaultModules, ...modules];
Expand Down
120 changes: 112 additions & 8 deletions packages/cli/src/deploy/ensureModules.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Client, Transport, Chain, Account, Hex, BaseError } from "viem";
import { writeContract } from "@latticexyz/common";
import { resourceToHex, writeContract } from "@latticexyz/common";
import { Module, WorldDeploy, worldAbi } from "./common";
import { debug } from "./debug";
import { isDefined } from "@latticexyz/common/utils";
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";

export async function ensureModules({
client,
Expand Down Expand Up @@ -39,17 +41,55 @@ export async function ensureModules({
pRetry(
async () => {
try {
// append module's ABI so that we can decode any custom errors
const abi = [...worldAbi, ...mod.abi];
const moduleAddress = mod.prepareDeploy(deployerAddress, libraryMap).address;
// TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645)
const params = mod.installAsRoot
? ({ functionName: "installRootModule", args: [moduleAddress, mod.installData] } as const)
: ({ functionName: "installModule", args: [moduleAddress, mod.installData] } as const);

// 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,
address: worldDeploy.address,
abi,
// append module's ABI so that we can decode any custom errors
abi: [...worldAbi, ...mod.abi],
...params,
});
} catch (error) {
Expand All @@ -74,3 +114,67 @@ export async function ensureModules({
)
).filter(isDefined);
}

// 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 = [
{
type: "function",
name: "installModule",
inputs: [
{
name: "module",
type: "address",
internalType: "contract IModule",
},
{
name: "encodedArgs",
type: "bytes",
internalType: "bytes",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "registerDelegation",
inputs: [
{
name: "delegatee",
type: "address",
internalType: "address",
},
{
name: "delegationControlId",
type: "bytes32",
internalType: "ResourceId",
},
{
name: "initCallData",
type: "bytes",
internalType: "bytes",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "unregisterDelegation",
inputs: [
{
name: "delegatee",
type: "address",
internalType: "address",
},
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
Loading

0 comments on commit 3187081

Please sign in to comment.