Skip to content

Commit

Permalink
feat(world-module-metadata): add metadata module (#3026)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Aug 14, 2024
1 parent 542ea54 commit fad4e85
Show file tree
Hide file tree
Showing 30 changed files with 1,085 additions and 54 deletions.
6 changes: 6 additions & 0 deletions .changeset/spotty-camels-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@latticexyz/cli": patch
"@latticexyz/world-module-metadata": patch
---

Added metadata module to be automatically installed during world deploy. This module allows for tagging any resource with arbitrary metadata. Internally, we'll use this to tag resources with labels onchain so that we can use labels to create a MUD project from an existing world.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@latticexyz/store": "workspace:*",
"@latticexyz/utils": "workspace:*",
"@latticexyz/world": "workspace:*",
"@latticexyz/world-module-metadata": "workspace:*",
"abitype": "1.0.0",
"asn1.js": "^5.4.1",
"chalk": "^5.0.1",
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/deploy/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,9 @@ export type Module = DeterministicContract & {
readonly name: string;
readonly installAsRoot: boolean;
readonly installData: Hex; // TODO: figure out better naming for this
/**
* @internal
* Optional modules warn instead of throw if they revert while being installed.
*/
readonly optional?: boolean;
};
30 changes: 27 additions & 3 deletions packages/cli/src/deploy/configToModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,38 @@ import { bytesToHex } from "viem";
import { createPrepareDeploy } from "./createPrepareDeploy";
import { World } from "@latticexyz/world";
import { getContractArtifact } from "../utils/getContractArtifact";
import { knownModuleArtifacts } from "../utils/knownModuleArtifacts";
import { importContractArtifact } from "../utils/importContractArtifact";
import { resolveWithContext } from "@latticexyz/world/internal";
import metadataModule from "@latticexyz/world-module-metadata/out/MetadataModule.sol/MetadataModule.json" assert { type: "json" };

/** 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",
Unstable_CallWithSignatureModule:
"@latticexyz/world-modules/out/Unstable_CallWithSignatureModule.sol/Unstable_CallWithSignatureModule.json",
};

const metadataModuleArtifact = getContractArtifact(metadataModule);

export async function configToModules<config extends World>(
config: config,
// TODO: remove/replace `forgeOutDir`
forgeOutDir: string,
): Promise<readonly Module[]> {
const defaultModules: Module[] = [
{
optional: true,
name: "MetadataModule",
installAsRoot: false,
installData: "0x",
prepareDeploy: createPrepareDeploy(metadataModuleArtifact.bytecode, metadataModuleArtifact.placeholders),
deployedBytecodeSize: metadataModuleArtifact.deployedBytecodeSize,
abi: metadataModuleArtifact.abi,
},
];

const modules = await Promise.all(
config.modules.map(async (mod): Promise<Module> => {
let artifactPath = mod.artifactPath;
Expand Down Expand Up @@ -46,7 +70,7 @@ export async function configToModules<config extends World>(
}

const name = path.basename(artifactPath, ".json");
const artifact = await getContractArtifact({ artifactPath });
const artifact = await importContractArtifact({ artifactPath });

// TODO: replace args with something more strongly typed
const installArgs = mod.args
Expand All @@ -71,5 +95,5 @@ export async function configToModules<config extends World>(
}),
);

return modules;
return [...defaultModules, ...modules];
}
6 changes: 6 additions & 0 deletions packages/cli/src/deploy/ensureModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export async function ensureModules({
args: [moduleAddress, mod.installData],
});
} catch (error) {
if (mod.optional) {
debug(
`optional module ${mod.name} install failed, skipping\n ${error instanceof BaseError ? error.shortMessage : error}`,
);
return;
}
if (error instanceof BaseError && error.message.includes("Module_AlreadyInstalled")) {
debug(`module ${mod.name} already installed`);
return;
Expand Down
39 changes: 2 additions & 37 deletions packages/cli/src/utils/getContractArtifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,6 @@ import { LibraryPlaceholder } from "../deploy/common";
import { findPlaceholders } from "./findPlaceholders";
import { z } from "zod";
import { Abi as abiSchema } from "abitype/zod";
import { createRequire } from "node:module";
import { findUp } from "find-up";

export type GetContractArtifactOptions = {
/**
* Path to `package.json` where `artifactPath`s are resolved relative to.
*
* Defaults to nearest `package.json` relative to `process.cwd()`.
*/
packageJsonPath?: string;
/**
* Import path to contract's forge/solc JSON artifact with the contract's compiled bytecode.
*
* This path is resolved using node's module resolution relative to `configPath`, so this supports both
* relative file paths (`../path/to/MyModule.json`) as well as JS import paths (`@latticexyz/world-contracts/out/CallWithSignatureModule.sol/CallWithSignatureModule.json`).
*/
artifactPath: string;
};

export type GetContractArtifactResult = {
bytecode: Hex;
Expand Down Expand Up @@ -49,26 +31,9 @@ const artifactSchema = z.object({
abi: abiSchema,
});

export async function getContractArtifact({
packageJsonPath,
artifactPath,
}: GetContractArtifactOptions): Promise<GetContractArtifactResult> {
let importedArtifact;
try {
const requirePath = packageJsonPath ?? (await findUp("package.json", { cwd: process.cwd() }));
if (!requirePath) throw new Error("Could not find package.json to import relative to.");

const require = createRequire(requirePath);
importedArtifact = require(artifactPath);
} catch (error) {
console.error();
console.error("Could not import contract artifact at", artifactPath);
console.error();
throw error;
}

export function getContractArtifact(artifactJson: unknown): GetContractArtifactResult {
// TODO: improve errors or replace with arktype?
const artifact = artifactSchema.parse(importedArtifact);
const artifact = artifactSchema.parse(artifactJson);
const placeholders = findPlaceholders(artifact.bytecode.linkReferences);

return {
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/utils/importContractArtifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createRequire } from "node:module";
import { findUp } from "find-up";
import { GetContractArtifactResult, getContractArtifact } from "./getContractArtifact";

export type ImportContractArtifactOptions = {
/**
* Path to `package.json` where `artifactPath`s are resolved relative to.
*
* Defaults to nearest `package.json` relative to `process.cwd()`.
*/
packageJsonPath?: string;
/**
* Import path to contract's forge/solc JSON artifact with the contract's compiled bytecode.
*
* This path is resolved using node's module resolution relative to `configPath`, so this supports both
* relative file paths (`../path/to/MyModule.json`) as well as JS import paths (`@latticexyz/world-contracts/out/CallWithSignatureModule.sol/CallWithSignatureModule.json`).
*/
artifactPath: string;
};

export async function importContractArtifact({
packageJsonPath,
artifactPath,
}: ImportContractArtifactOptions): Promise<GetContractArtifactResult> {
let artfactJson;
try {
const requirePath = packageJsonPath ?? (await findUp("package.json", { cwd: process.cwd() }));
if (!requirePath) throw new Error("Could not find package.json to import relative to.");

const require = createRequire(requirePath);
artfactJson = require(artifactPath);
} catch (error) {
console.error();
console.error("Could not import contract artifact at", artifactPath);
console.error();
throw error;
}

return getContractArtifact(artfactJson);
}
8 changes: 0 additions & 8 deletions packages/cli/src/utils/knownModuleArtifacts.ts

This file was deleted.

2 changes: 2 additions & 0 deletions packages/world-module-metadata/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cache
out
8 changes: 8 additions & 0 deletions packages/world-module-metadata/.solhint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "solhint:recommended",
"rules": {
"compiler-version": ["error", ">=0.8.0"],
"avoid-low-level-calls": "off",
"func-visibility": ["warn", { "ignoreConstructors": true }]
}
}
1 change: 1 addition & 0 deletions packages/world-module-metadata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Metadata world module
15 changes: 15 additions & 0 deletions packages/world-module-metadata/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[profile.default]
solc = "0.8.24"
ffi = false
fuzz_runs = 256
optimizer = true
optimizer_runs = 3000
verbosity = 2
allow_paths = ["../../node_modules", "../"]
src = "src"
out = "out"
bytecode_hash = "none"
extra_output_files = [
"abi",
"evm.bytecode"
]
20 changes: 20 additions & 0 deletions packages/world-module-metadata/gas-report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
{
"file": "test/MetadataModule.t.sol",
"test": "testDeleteResourceTag",
"name": "delete resource tag",
"gasUsed": 70301
},
{
"file": "test/MetadataModule.t.sol",
"test": "testInstall",
"name": "install metadata module",
"gasUsed": 1106562
},
{
"file": "test/MetadataModule.t.sol",
"test": "testSetResourceTag",
"name": "set resource tag",
"gasUsed": 116708
}
]
18 changes: 18 additions & 0 deletions packages/world-module-metadata/mud.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineWorld } from "@latticexyz/world";

export default defineWorld({
namespace: "metadata",
userTypes: {
ResourceId: { filePath: "@latticexyz/store/src/ResourceId.sol", type: "bytes32" },
},
tables: {
ResourceTag: {
schema: {
resource: "ResourceId",
tag: "bytes32",
value: "bytes",
},
key: ["resource", "tag"],
},
},
});
63 changes: 63 additions & 0 deletions packages/world-module-metadata/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "@latticexyz/world-module-metadata",
"version": "2.1.0",
"description": "Metadata world module",
"repository": {
"type": "git",
"url": "https://github.com/latticexyz/mud.git",
"directory": "packages/world-module-metadata"
},
"license": "MIT",
"type": "module",
"exports": {
"./mud.config": "./dist/mud.config.js",
"./out/*": "./out/*"
},
"typesVersions": {
"*": {
"mud.config": [
"./dist/mud.config.d.ts"
]
}
},
"files": [
"dist",
"out",
"src"
],
"scripts": {
"build": "pnpm run build:mud && pnpm run build:abi && pnpm run build:abi-ts && pnpm run build:js",
"build:abi": "forge build",
"build:abi-ts": "abi-ts",
"build:js": "tsup",
"build:mud": "tsx ./ts/build.ts",
"clean": "pnpm run clean:abi && pnpm run clean:js && pnpm run clean:mud",
"clean:abi": "forge clean",
"clean:js": "rimraf dist",
"clean:mud": "rimraf src/**/codegen",
"dev": "tsup --watch",
"gas-report": "gas-report --save gas-report.json",
"lint": "solhint --config ./.solhint.json 'src/**/*.sol'",
"test": "forge test",
"test:ci": "pnpm run test"
},
"dependencies": {
"@latticexyz/schema-type": "workspace:*",
"@latticexyz/store": "workspace:*",
"@latticexyz/world": "workspace:*"
},
"devDependencies": {
"@latticexyz/abi-ts": "workspace:*",
"@latticexyz/gas-report": "workspace:*",
"@types/node": "^18.15.11",
"ds-test": "https://github.com/dapphub/ds-test.git#e282159d5170298eb2455a6c05280ab5a73a4ef0",
"forge-std": "https://github.com/foundry-rs/forge-std.git#74cfb77e308dd188d2f58864aaf44963ae6b88b1",
"solhint": "^3.3.7",
"tsup": "^6.7.0",
"tsx": "^3.12.6",
"vitest": "0.34.6"
},
"publishConfig": {
"access": "public"
}
}
3 changes: 3 additions & 0 deletions packages/world-module-metadata/remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ds-test/=node_modules/ds-test/src/
forge-std/=node_modules/forge-std/src/
@latticexyz/=node_modules/@latticexyz/
57 changes: 57 additions & 0 deletions packages/world-module-metadata/src/MetadataModule.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol";
import { Module } from "@latticexyz/world/src/Module.sol";
import { requireOwner } from "./common.sol";
import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
import { ResourceIds } from "@latticexyz/store/src/codegen/tables/ResourceIds.sol";
import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";

import { MetadataSystem } from "./MetadataSystem.sol";
import { ResourceTag } from "./codegen/tables/ResourceTag.sol";

/**
* @title MetadataModule
* @author MUD (https://mud.dev) by Lattice (https://lattice.xyz)
* @dev Adds metadata tables and systems for annotating data in MUD apps.
* For example, tagging resources with labels for better UX when reconstructing a MUD project from a world using onchain state.
*/
contract MetadataModule is Module {
using WorldResourceIdInstance for ResourceId;

MetadataSystem private immutable metadataSystem = new MetadataSystem();

function installRoot(bytes memory) public pure {
revert Module_RootInstallNotSupported();
}

function install(bytes memory) public {
IBaseWorld world = IBaseWorld(_world());

ResourceId namespace = ResourceTag._tableId.getNamespaceId();
if (!ResourceIds.getExists(namespace)) {
world.registerNamespace(namespace);
}
requireOwner(namespace, address(this));

if (!ResourceIds.getExists(ResourceTag._tableId)) {
ResourceTag.register();
}

ResourceId metadataSystemId = WorldResourceIdLib.encode(
RESOURCE_SYSTEM,
namespace.getNamespace(),
"MetadataSystem"
);
// TODO: add support for upgrading system and registering new function selectors
if (!ResourceIds.getExists(metadataSystemId)) {
world.registerSystem(metadataSystemId, metadataSystem, true);
world.registerFunctionSelector(metadataSystemId, "getResourceTag(bytes32,bytes32)");
world.registerFunctionSelector(metadataSystemId, "setResourceTag(bytes32,bytes32,bytes)");
world.registerFunctionSelector(metadataSystemId, "deleteResourceTag(bytes32,bytes32)");
}

world.transferOwnership(namespace, _msgSender());
}
}
Loading

0 comments on commit fad4e85

Please sign in to comment.