diff --git a/.changeset/cold-apes-play.md b/.changeset/cold-apes-play.md
new file mode 100644
index 0000000000..b84a424b76
--- /dev/null
+++ b/.changeset/cold-apes-play.md
@@ -0,0 +1,9 @@
+---
+"@latticexyz/cli": patch
+"@latticexyz/world-modules": patch
+"@latticexyz/world": patch
+---
+
+Added a new preview module, `Unstable_DelegationWithSignatureModule`, which allows registering delegations with a signature.
+
+Note: this module is marked as `Unstable`, because it will be removed and included in the default `World` deployment once it is audited.
diff --git a/docs/pages/quickstart.mdx b/docs/pages/quickstart.mdx
index e1f456b25c..c77ba68168 100644
--- a/docs/pages/quickstart.mdx
+++ b/docs/pages/quickstart.mdx
@@ -145,11 +145,11 @@ To run any of the TypeScript templates:
1. Select the template:
- - [Vanilla](./vanilla)
- - [React-ECS](./react-ecs)
+ - [Vanilla](./templates/typescript/vanilla)
+ - [React-ECS](./templates/typescript/react-ecs)
- React
- - [Phaser](./phaser)
- - [Three.js](./three)
+ - Phaser
+ - [Three.js](./templates/typescript/threejs)
1. Start the development server, which updates automatically when you modify files.
diff --git a/docs/pages/store/store-hooks.mdx b/docs/pages/store/store-hooks.mdx
index 4ba8bff6b5..44c91d425b 100644
--- a/docs/pages/store/store-hooks.mdx
+++ b/docs/pages/store/store-hooks.mdx
@@ -1,7 +1,13 @@
import { CollapseCode } from "../../components/CollapseCode";
+import { Callout } from "nextra/components";
# Store hooks
+
+ This page is about hooks before and after information is modified. You can also use [`System`
+ hooks](../world/system-hooks) that execute before or after a `System` call.
+
+
Store hooks allow you to react to state changes onchain.
There are many ways to [interact with tables](./reference/store-core), but they all boil down to four types of state changes:
diff --git a/docs/pages/store/table-libraries.mdx b/docs/pages/store/table-libraries.mdx
index 41863b8ecc..dcda439740 100644
--- a/docs/pages/store/table-libraries.mdx
+++ b/docs/pages/store/table-libraries.mdx
@@ -11,27 +11,25 @@ The [CLI config](/cli/config) section goes into more detail on the available con
To illustrate table library functionality on this page, we'll use two example tables with the following configurations:
-```ts
-const Position = {
- keySchema: {
- entity: "address",
- },
- valueSchema: {
- // Two static length fields
- x: "uint32",
- y: "uint32",
- },
-};
-
-const Inventory = {
- keySchema: {
- entity: "address",
- },
- valueSchema: {
- // One dynamic length field
- slots: "uint8[]",
- },
-};
+```ts filename="Position"
+schema: {
+ entity: "address",
+
+ // Two static length fields
+ x: "uint32",
+ y: "uint32",
+},
+key: ["entity"],
+```
+
+```ts filename="Inventory"
+schema: {
+ entity: "address",
+
+ // One dynamic length field
+ slots: "uint8[]",
+},
+key: ["entity"],
```
## Usage
diff --git a/docs/pages/store/tables.mdx b/docs/pages/store/tables.mdx
index 70db84ccf9..0e7c10caf3 100644
--- a/docs/pages/store/tables.mdx
+++ b/docs/pages/store/tables.mdx
@@ -3,12 +3,12 @@ import { Callout } from "nextra/components";
# Tables
Each piece of data in `Store` is stored as a _record_ in a _table_.
-You can think of tables in two ways, either [as a relational database](./how-mud-models-data) or as a key-value store.
+You can think of tables in two ways, either [as a relational database](./data-model) or as a key-value store.
- Each table is identified by a unique `ResourceId tableId`.
- Each record in a table is identified by a unique `bytes32[] keyTuple`.
You can think of the key tuple as a composite key in a relational database, or as a nested mapping in a key-value store.
-- Each table has a `ValueSchema` that defines the types of data stored in the table.
+- Each table has a value schema (all the `schema` fields that aren't part of the `key`) that defines the types of data stored in the table.
You can think of the value schema as the column types in a table in a relational database, or the type of structs stored in a key-value store.
Tables are registered in the `Store` contract at runtime.
@@ -46,6 +46,8 @@ The table ID encodes the table's type and name in a 32-byte `ResourceId` value a
import { ResourceId, ResourceIdLib } from "@latticexyz/store/src/ResourceId.sol";
import { RESOURCE_TABLE, RESOURCE_OFFCHAIN_TABLE } from "@latticexyz/store/src/storeResourceTypes.sol";
+...
+
// Using the `RESOURCE_TABLE` type makes it an onchain table
ResourceId tableId = ResourceIdLib.encode({
typeId: RESOURCE_TABLE,
@@ -103,7 +105,6 @@ Key and (value) field names are simple string arrays, and must have the same len
string[] memory keyNames = new string[](1);
keyNames[0] = "owner";
-
string[] memory fieldNames = new string[](1);
fieldNames[0] = "balance";
```
@@ -118,17 +119,14 @@ import { FieldLayout, FieldLayoutLib } from "@latticexyz/store/src/FieldLayout.s
uint256[] memory staticFields = new uint256[](1);
staticFields[0] = 32; // The byte length of the first field
-FieldLayout fieldLayout = FieldLayoutLib.encode({
- staticFields: staticFields,
- numDynamicFields: 0
-});
+FieldLayout fieldLayout = FieldLayoutLib.encode(staticFields, 0);
```
### Manually register a table
Registering a table is a prerequisite for writing to the table, and allows [offchain indexers](../services/indexer) to [replicate the onchain state](/guides/replicating-onchain-state).
-```solidity
+```solidity copy
import { IStore } from "@latticexyz/store/src/IStore.sol";
IStore store = IStore(STORE_ADDRESS);
diff --git a/docs/pages/templates/typescript/_meta.js b/docs/pages/templates/typescript/_meta.js
index 8b28299da6..ce65287c92 100644
--- a/docs/pages/templates/typescript/_meta.js
+++ b/docs/pages/templates/typescript/_meta.js
@@ -3,4 +3,5 @@ export default {
vanilla: "Vanilla",
"react-ecs": "React-ECS",
threejs: "Three.js",
+ vue: "Vue",
};
diff --git a/docs/pages/templates/typescript/vue.mdx b/docs/pages/templates/typescript/vue.mdx
new file mode 100644
index 0000000000..b2233d52e5
--- /dev/null
+++ b/docs/pages/templates/typescript/vue.mdx
@@ -0,0 +1,8 @@
+import Disclaimer from "../disclaimer.mdx";
+
+# Vue
+
+[Vue](https://vuejs.org/) is a framework for building web user interfaces.
+[You can see the MUD integration here](https://github.com/LidamaoHub/MUD-Template-VUE/tree/master).
+
+
diff --git a/docs/pages/world/account-delegation.mdx b/docs/pages/world/account-delegation.mdx
index 796086d463..cd1a3bb1d8 100644
--- a/docs/pages/world/account-delegation.mdx
+++ b/docs/pages/world/account-delegation.mdx
@@ -1,3 +1,5 @@
+import { Callout } from "nextra/components";
+
# Account delegation
_Account delegation_ allows a _delegator_ address to permit a _delegatee_ address to call a `System` on its behalf.
@@ -17,6 +19,16 @@ This feature enables multiple use cases.
This is conceptually similar to how ERC20's [`approve`/`transferFrom`](https://eips.ethereum.org/EIPS/eip-20#transferfrom) enables atomic swaps by allowing contracts to withdraw from a user's balance and then deposit a different asset in a single transaction.
For example, an agent could exchange two in-game assets atomically if the two sides of the trade give it the necessary permission.
+
+
+Delegation and Account Abstraction
+
+While there are some overlaps in use cases between [ERC-4337](https://www.erc4337.io/) and MUD's account delegation, they are different things.
+In ERC-4337, a smart contract wallet becomes your main onchain identity.
+With MUD delegation, you keep your existing account, but (temporarily) approve other accounts to act on its behalf.
+
+
+
## User delegation
The most common type of delegation is when a delegator address allows a specific delegatee address to act on its behalf.
diff --git a/docs/pages/world/namespaces-access-control.mdx b/docs/pages/world/namespaces-access-control.mdx
index dcd29da4db..e83595163e 100644
--- a/docs/pages/world/namespaces-access-control.mdx
+++ b/docs/pages/world/namespaces-access-control.mdx
@@ -98,11 +98,11 @@ import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
import { IWorld } from "../src/codegen/world/IWorld.sol";
-import { CounterTableId } from "../src/codegen/index.sol";
+import { Counter } from "../src/codegen/index.sol";
contract Permissions is Script {
function run() external {
- address worldAddress = 0x6E9474e9c83676B9A71133FF96Db43E7AA0a4342;
+ address worldAddress = 0x4F4DDaFBc93Cf8d11a253f21dDbcF836139efdeC;
// Load the private key from the `PRIVATE_KEY` environment variable (in .env)
uint256 namespaceOwnerPrivateKey = vm.envUint("PRIVATE_KEY");
@@ -110,8 +110,8 @@ contract Permissions is Script {
// Start broadcasting transactions from the account owning the namespace
vm.startBroadcast(namespaceOwnerPrivateKey);
- IWorld(worldAddress).grantAccess(CounterTableId, address(0));
- IWorld(worldAddress).revokeAccess(CounterTableId, address(1));
+ IWorld(worldAddress).grantAccess(Counter._tableId, address(0));
+ IWorld(worldAddress).revokeAccess(Counter._tableId, address(1));
vm.stopBroadcast();
}
diff --git a/docs/pages/world/system-hooks.mdx b/docs/pages/world/system-hooks.mdx
index 7f0e90f886..4d922b2144 100644
--- a/docs/pages/world/system-hooks.mdx
+++ b/docs/pages/world/system-hooks.mdx
@@ -1,5 +1,13 @@
+import { Callout } from "nextra/components";
+
# System hooks
+
+ This page is about hooks that are called before or after a `System` call. You can also use [store
+ hooks](../store/store-hooks) that are called whenever information in a table is modified, regardless of the source of
+ the change.
+
+
The [namespace owner](/world/namespaces-access-control#ownership) can [register](https://github.com/latticexyz/mud/blob/main/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol#L68-L99) hooks that are called before and/or after calls to a specific `System`.
## System hook contracts
diff --git a/docs/pages/world/tables.mdx b/docs/pages/world/tables.mdx
index 393a182de0..d6563b8729 100644
--- a/docs/pages/world/tables.mdx
+++ b/docs/pages/world/tables.mdx
@@ -24,55 +24,27 @@ When a `System` reads or writes storage via [table libraries](/store/table-libra
### Reading from a table
-Anybody connected to the blockchain can run the `view` functions that read table content.
+Anybody connected to the blockchain can run the `view` functions that read table content, provided they know which key to use (by default MUD does not keep a list of keys written to a table onchain, to save on storage operations).
+
+All the functions to [read from a MUD store](/store/table-libraries#reading-data) are available.
+In this example we use the `Counter` table in the [vanilla](../templates/typescript/vanilla) template, which is a singleton so there is no key to worry about.
-```solidity filename="ReadTableInformation.s.sol" copy showLineNumbers {6,8-14,22-24,36-42}
+```solidity filename="ReadCounter.s.sol" copy showLineNumbers {6-7,11-13}
// SPDX-License-Identifier: MIT
-pragma solidity >=0.8.21;
+pragma solidity >=0.8.24;
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
+import { Counter } from "../src/codegen/index.sol";
-// Read and manipulate the Systems table
-import { Systems, SystemsTableId } from "@latticexyz/world/src/codegen/index.sol";
-
-// The key is a ResourceId
-import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
-import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";
-import { WorldResourceIdLib } from "@latticexyz/world/src/WorldResourceId.sol";
-
-contract ReadTableInformation is Script {
+contract ReadCounter is Script {
function run() external {
- // Load the world address and specify it as the Store address
- address worldAddress = vm.envAddress("WORLD_ADDRESS");
+ address worldAddress = 0x4F4DDaFBc93Cf8d11a253f21dDbcF836139efdeC;
StoreSwitch.setStoreAddress(worldAddress);
-
- // Table metainformation (field names)
- string[] memory keyNames = Systems.getKeyNames();
- string[] memory valueNames = Systems.getFieldNames();
-
- console.log("Key fields:");
- for (uint i = 0; i < keyNames.length; i++) {
- console.log("\t", i, keyNames[i]);
- }
-
- console.log("Value fields:");
- for (uint i = 0; i < valueNames.length; i++) {
- console.log("\t", i, valueNames[i]);
- }
-
- // Read information about the :AccessManagement System
- ResourceId accessManagementSystem = WorldResourceIdLib.encode(
- RESOURCE_SYSTEM, // System
- "", // Root namespace
- "AccessManagement" // Called AccessManagement
- );
- (address systemAddress, bool publicAccess) = Systems.get(accessManagementSystem);
- console.log("The address for the :AccessManagement System:", systemAddress);
- console.log("Public access:", publicAccess);
+ console.log("Counter value:", Counter.get());
}
}
```
@@ -90,81 +62,48 @@ import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
We need [the `StoreSwitch` library](https://github.com/latticexyz/mud/blob/main/packages/store/src/StoreSwitch.sol) library to specify the address of the `World` with the data.
```solidity
-// Read and manipulate the Systems table
-import { Systems, SystemsTableId } from "@latticexyz/world/src/codegen/index.sol";
+import { Counter } from "../src/codegen/index.sol";
```
It is easiest if we import the definitions of the table that were generated using [`mud tablegen`](./cli/tablegen).
```solidity
-// The key is a ResourceId
-import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
-import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";
-import { WorldResourceIdLib } from "@latticexyz/world/src/WorldResourceId.sol";
-```
-
-The key of the `store:Tables` table is the resource ID for the various tables.
-To read the information of a specific table later we need to create the appropriate resource ID.
-
-```solidity
-
-contract ReadTableInformation is Script {
- function run() external {
-
- // Load the world address and specify it as the Store address
- address worldAddress = vm.envAddress("WORLD_ADDRESS");
+ address worldAddress = 0x4F4DDaFBc93Cf8d11a253f21dDbcF836139efdeC;
StoreSwitch.setStoreAddress(worldAddress);
```
-[`StoreSwitch.setStoreAddress`](https://github.com/latticexyz/mud/blob/main/packages/store/src/StoreSwitch.sol#L58-L65) is the function we call to specify the `World`'s address.
-
-```solidity
- // Table metainformation (field names)
- string[] memory keyNames = Systems.getKeyNames();
- string[] memory valueNames = Systems.getFieldNames();
-```
-
-These functions give us the names of the key fields and value field.
-
-```solidity
- // Read information about the :AccessManagement System
- ResourceId accessManagementSystem = WorldResourceIdLib.encode(
- RESOURCE_SYSTEM, // System
- "", // Root namespace
- "AccessManagement" // Called AccessManagement
- );
-```
-
-Here we create the resource ID for the table whose information we want.
+Use [`StoreSwitch`](https://github.com/latticexyz/mud/blob/main/packages/store/src/StoreSwitch.sol) with the `World` address.
```solidity
- (address systemAddress, bool publicAccess) = Systems.get(accessManagementSystem);
+ console.log("Counter value:", Counter.get());
```
-And actually read the information.
+Read the information.
+If this had been a table with a key, we'd need to provide the key as a parameter to `.get()`.
+
### Writing to table
-This code is taken from [the React template](https://github.com/latticexyz/mud/tree/main/templates/react).
+All the functions to [write to a MUD store](/store/table-libraries#writing-data) are available.
+In this example we reset `Counter` to zero.
Note that only [authorized addresses](/world/namespaces-access-control#access) are allowed to write directly to a table.
-```solidity filename="PostDeploy.s.sol" copy showLineNumbers {6,9,13-14,19-23,31}
+```solidity filename="ResetCounter.s.sol" copy showLineNumbers {20}
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
+import { Counter } from "../src/codegen/index.sol";
-import { IWorld } from "../src/codegen/world/IWorld.sol";
-import { Tasks, TasksData } from "../src/codegen/index.sol";
-
-contract PostDeploy is Script {
- function run(address worldAddress) external {
- // Specify a store so that you can use tables directly in PostDeploy
+contract ResetCounter is Script {
+ function run() external {
+ // Specify a store so that you can use tables directly
+ address worldAddress = 0x4F4DDaFBc93Cf8d11a253f21dDbcF836139efdeC;
StoreSwitch.setStoreAddress(worldAddress);
// Load the private key from the `PRIVATE_KEY` environment variable (in .env)
@@ -172,17 +111,10 @@ contract PostDeploy is Script {
// Start broadcasting transactions from the deployer account
vm.startBroadcast(deployerPrivateKey);
-
- // We can set table records directly
- Tasks.set("1", TasksData({ description: "Walk the dog", createdAt: block.timestamp, completedAt: 0 }));
-
- // Or we can call our own systems
- IWorld(worldAddress).addTask("Take out the trash");
-
- bytes32 key = IWorld(worldAddress).addTask("Do the dishes");
- IWorld(worldAddress).completeTask(key);
-
+ Counter.set(0);
vm.stopBroadcast();
+
+ console.log("Counter value:", Counter.get());
}
}
```
@@ -194,51 +126,9 @@ contract PostDeploy is Script {
Explanation
```solidity
-import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
-```
-
-We need [the `StoreSwitch` library](https://github.com/latticexyz/mud/blob/main/packages/store/src/StoreSwitch.sol) library to specify the address of the `World` with the data.
-
-```solidity
-import { Tasks, TasksData } from "../src/codegen/index.sol";
-```
-
-It is easiest if we import the definitions of the table that were generated using [`mud tablegen`](./cli/tablegen).
-
-```solidity
-contract PostDeploy is Script {
- function run(address worldAddress) external {
- // Specify a store so that you can use tables directly in PostDeploy
- StoreSwitch.setStoreAddress(worldAddress);
-```
-
-[`StoreSwitch.setStoreAddress`](https://github.com/latticexyz/mud/blob/main/packages/store/src/StoreSwitch.sol#L58-L65) is the function we call to specify the `World`'s address.
-
-```solidity
- // Start broadcasting transactions from the deployer account
- vm.startBroadcast(deployerPrivateKey);
-```
-
-`.set` changes the state of the blockchain, so it requires an address.
-This address is necessary for two reasons:
-
-- To spend ETH for gas.
-- To [check permissions](./namespaces-access-control#access).
-
-```solidity
-
- // We can set table records directly
- Tasks.set("1", TasksData({ description: "Walk the dog", createdAt: block.timestamp, completedAt: 0 }));
-```
-
-Create a new `TasksData` and set that value with the key `"1"` (in hex the key is `0x310...0`).
-
-```solidity
- vm.stopBroadcast();
- }
-}
+ Counter.set(0);
```
-Stop broadcasting transactions as the authorized address.
+This is how you modify a table's value. If there was a key, it would be `.set(,)`.
diff --git a/e2e/packages/client-vanilla/src/index.ts b/e2e/packages/client-vanilla/src/index.ts
index 44edec539f..4fcfce055f 100644
--- a/e2e/packages/client-vanilla/src/index.ts
+++ b/e2e/packages/client-vanilla/src/index.ts
@@ -3,11 +3,12 @@ import { setup } from "./mud/setup";
import { decodeEntity } from "@latticexyz/store-sync/recs";
const {
- network: { components, latestBlock$, worldContract, waitForTransaction },
+ network: { components, latestBlock$, walletClient, worldContract, waitForTransaction },
} = await setup();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _window = window as any;
+_window.walletClient = walletClient;
_window.worldContract = worldContract;
_window.waitForTransaction = waitForTransaction;
diff --git a/e2e/packages/contracts/mud.config.ts b/e2e/packages/contracts/mud.config.ts
index 35af3f6a37..6fb9e0471e 100644
--- a/e2e/packages/contracts/mud.config.ts
+++ b/e2e/packages/contracts/mud.config.ts
@@ -50,4 +50,11 @@ export default defineWorld({
key: [],
},
},
+ modules: [
+ {
+ name: "Unstable_DelegationWithSignatureModule",
+ root: true,
+ args: [],
+ },
+ ],
});
diff --git a/e2e/packages/sync-test/data/callRegisterDelegationWithSignature.ts b/e2e/packages/sync-test/data/callRegisterDelegationWithSignature.ts
new file mode 100644
index 0000000000..78611c3e2d
--- /dev/null
+++ b/e2e/packages/sync-test/data/callRegisterDelegationWithSignature.ts
@@ -0,0 +1,108 @@
+import { Page } from "@playwright/test";
+import { GetContractReturnType, PublicClient, WalletClient } from "viem";
+import { AbiParametersToPrimitiveTypes, ExtractAbiFunction, ExtractAbiFunctionNames } from "abitype";
+
+const DelegationAbi = [
+ {
+ type: "function",
+ name: "registerDelegationWithSignature",
+ inputs: [
+ {
+ name: "delegatee",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "delegationControlId",
+ type: "bytes32",
+ internalType: "ResourceId",
+ },
+ {
+ name: "initCallData",
+ type: "bytes",
+ internalType: "bytes",
+ },
+ {
+ name: "delegator",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "signature",
+ type: "bytes",
+ internalType: "bytes",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+] as const;
+
+type DelegationAbi = typeof DelegationAbi;
+
+type WorldContract = GetContractReturnType;
+
+type WriteMethodName = ExtractAbiFunctionNames;
+type WriteMethod = ExtractAbiFunction;
+type WriteArgs = AbiParametersToPrimitiveTypes["inputs"]>;
+
+export function callRegisterDelegationWithSignature(page: Page, args?: WriteArgs<"registerDelegationWithSignature">) {
+ return page.evaluate(
+ ([_args]) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const walletClient = (window as any).walletClient as WalletClient;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const worldContract = (window as any).worldContract as WorldContract;
+
+ return walletClient
+ .writeContract({
+ address: worldContract.address,
+ abi: [
+ {
+ type: "function",
+ name: "registerDelegationWithSignature",
+ inputs: [
+ {
+ name: "delegatee",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "delegationControlId",
+ type: "bytes32",
+ internalType: "ResourceId",
+ },
+ {
+ name: "initCallData",
+ type: "bytes",
+ internalType: "bytes",
+ },
+ {
+ name: "delegator",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "signature",
+ type: "bytes",
+ internalType: "bytes",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ ],
+ functionName: "registerDelegationWithSignature",
+ args: _args,
+ })
+ .then((tx) => window["waitForTransaction"](tx))
+ .catch((error) => {
+ console.error(error);
+ throw new Error(
+ [`Error executing registerDelegationWithSignature with args:`, JSON.stringify(_args), error].join("\n\n"),
+ );
+ });
+ },
+ [args],
+ );
+}
diff --git a/e2e/packages/sync-test/data/getWorld.ts b/e2e/packages/sync-test/data/getWorld.ts
new file mode 100644
index 0000000000..6ef7590d8f
--- /dev/null
+++ b/e2e/packages/sync-test/data/getWorld.ts
@@ -0,0 +1,15 @@
+import { Page } from "@playwright/test";
+import IWorldAbi from "../../contracts/out/IWorld.sol/IWorld.abi.json";
+import { GetContractReturnType, PublicClient, WalletClient } from "viem";
+
+type WorldAbi = typeof IWorldAbi;
+
+type WorldContract = GetContractReturnType;
+
+export function getWorld(page: Page) {
+ return page.evaluate(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const worldContract = (window as any).worldContract as WorldContract;
+ return worldContract;
+ }, []);
+}
diff --git a/e2e/packages/sync-test/registerDelegationWithSignature.test.ts b/e2e/packages/sync-test/registerDelegationWithSignature.test.ts
new file mode 100644
index 0000000000..857c692e1d
--- /dev/null
+++ b/e2e/packages/sync-test/registerDelegationWithSignature.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { ViteDevServer } from "vite";
+import { Browser, Page } from "@playwright/test";
+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 { http, createWalletClient, ClientConfig } 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 { worldToV1 } from "@latticexyz/world/config/v2";
+import { delegationWithSignatureTypes } from "@latticexyz/world/internal";
+import { getWorld } from "./data/getWorld";
+import { callRegisterDelegationWithSignature } from "./data/callRegisterDelegationWithSignature";
+
+const DELEGATOR_PRIVATE_KEY = "0x67bbd1575ecc79b3247c7d7b87a5bc533ccb6a63955a9fefdfaf75853f7cd543";
+
+const worldConfigV1 = worldToV1(worldConfig);
+
+describe("registerDelegationWithSignature", async () => {
+ const asyncErrorHandler = createAsyncErrorHandler();
+ let webserver: ViteDevServer;
+ let browser: Browser;
+ let page: Page;
+
+ beforeEach(async () => {
+ asyncErrorHandler.resetErrors();
+
+ await deployContracts(rpcHttpUrl);
+
+ // Start client and browser
+ webserver = await startViteServer();
+ const browserAndPage = await startBrowserAndPage(asyncErrorHandler.reportError);
+ browser = browserAndPage.browser;
+ page = browserAndPage.page;
+ });
+
+ afterEach(async () => {
+ await browser.close();
+ await webserver.close();
+ });
+
+ it("can generate a signature and register a delegation", async () => {
+ await openClientWithRootAccount(page);
+ await waitForInitialSync(page);
+
+ // Set up client
+ const clientOptions = {
+ chain: mudFoundry,
+ transport: transportObserver(http(mudFoundry.rpcUrls.default.http[0] ?? undefined)),
+ } as const satisfies ClientConfig;
+
+ const delegator = createBurnerAccount(DELEGATOR_PRIVATE_KEY);
+ const delegatorWalletClient = createWalletClient({
+ ...clientOptions,
+ account: delegator,
+ });
+
+ const worldContract = await getWorld(page);
+
+ // Declare delegation parameters
+ const delegatee = "0x7203e7ADfDF38519e1ff4f8Da7DCdC969371f377";
+ const delegationControlId = resourceToHex({ type: "system", namespace: "", name: "unlimited" });
+ const initCallData = "0x";
+ const nonce = 0n;
+
+ // Sign registration message
+ const signature = await delegatorWalletClient.signTypedData({
+ domain: {
+ chainId: delegatorWalletClient.chain.id,
+ verifyingContract: worldContract.address,
+ },
+ types: delegationWithSignatureTypes,
+ primaryType: "Delegation",
+ message: {
+ delegatee,
+ delegationControlId,
+ initCallData,
+ delegator: delegator.address,
+ nonce,
+ },
+ });
+
+ // Register the delegation
+ await callRegisterDelegationWithSignature(page, [
+ delegatee,
+ delegationControlId,
+ initCallData,
+ delegator.address,
+ signature,
+ ]);
+
+ // Expect delegation to have been created
+ const value = await callPageFunction(page, "getComponentValue", [
+ "UserDelegationControl",
+ encodeEntity(worldConfigV1.tables.UserDelegationControl.keySchema, {
+ delegator: delegator.address,
+ delegatee,
+ }),
+ ]);
+
+ expect(value).toMatchObject({
+ __staticData: delegationControlId,
+ delegationControlId,
+ });
+ });
+});
diff --git a/packages/cli/src/utils/defaultModuleContracts.ts b/packages/cli/src/utils/defaultModuleContracts.ts
index debb7b6828..fba76f5355 100644
--- a/packages/cli/src/utils/defaultModuleContracts.ts
+++ b/packages/cli/src/utils/defaultModuleContracts.ts
@@ -1,6 +1,8 @@
import KeysWithValueModuleData from "@latticexyz/world-modules/out/KeysWithValueModule.sol/KeysWithValueModule.json" assert { type: "json" };
import KeysInTableModuleData from "@latticexyz/world-modules/out/KeysInTableModule.sol/KeysInTableModule.json" assert { type: "json" };
import UniqueEntityModuleData from "@latticexyz/world-modules/out/UniqueEntityModule.sol/UniqueEntityModule.json" assert { type: "json" };
+// eslint-disable-next-line max-len
+import Unstable_DelegationWithSignatureModuleData from "@latticexyz/world-modules/out/Unstable_DelegationWithSignatureModule.sol/Unstable_DelegationWithSignatureModule.json" assert { type: "json" };
import { Abi, Hex, size } from "viem";
import { findPlaceholders } from "./findPlaceholders";
@@ -27,4 +29,11 @@ export const defaultModuleContracts = [
placeholders: findPlaceholders(UniqueEntityModuleData.bytecode.linkReferences),
deployedBytecodeSize: size(UniqueEntityModuleData.deployedBytecode.object as Hex),
},
+ {
+ name: "Unstable_DelegationWithSignatureModule",
+ abi: Unstable_DelegationWithSignatureModuleData.abi as Abi,
+ bytecode: Unstable_DelegationWithSignatureModuleData.bytecode.object as Hex,
+ placeholders: findPlaceholders(Unstable_DelegationWithSignatureModuleData.bytecode.linkReferences),
+ deployedBytecodeSize: size(Unstable_DelegationWithSignatureModuleData.deployedBytecode.object as Hex),
+ },
];
diff --git a/packages/world-modules/gas-report.json b/packages/world-modules/gas-report.json
index 8bfcd3de5e..61eee5d9ad 100644
--- a/packages/world-modules/gas-report.json
+++ b/packages/world-modules/gas-report.json
@@ -1,4 +1,16 @@
[
+ {
+ "file": "test/DelegationWithSignatureModule.t.sol",
+ "test": "testInstallRoot",
+ "name": "install delegation module",
+ "gasUsed": 689194
+ },
+ {
+ "file": "test/DelegationWithSignatureModule.t.sol",
+ "test": "testRegisterDelegationWithSignature",
+ "name": "register an unlimited delegation with signature",
+ "gasUsed": 117588
+ },
{
"file": "test/ERC20.t.sol",
"test": "testApprove",
@@ -267,7 +279,7 @@
"file": "test/StandardDelegationsModule.t.sol",
"test": "testCallFromCallboundDelegation",
"name": "register a callbound delegation",
- "gasUsed": 139044
+ "gasUsed": 138994
},
{
"file": "test/StandardDelegationsModule.t.sol",
@@ -279,7 +291,7 @@
"file": "test/StandardDelegationsModule.t.sol",
"test": "testCallFromSystemDelegation",
"name": "register a systembound delegation",
- "gasUsed": 136166
+ "gasUsed": 136116
},
{
"file": "test/StandardDelegationsModule.t.sol",
@@ -291,7 +303,7 @@
"file": "test/StandardDelegationsModule.t.sol",
"test": "testCallFromTimeboundDelegation",
"name": "register a timebound delegation",
- "gasUsed": 132709
+ "gasUsed": 132659
},
{
"file": "test/StandardDelegationsModule.t.sol",
diff --git a/packages/world-modules/mud.config.ts b/packages/world-modules/mud.config.ts
index c0f0c34992..bd6368d233 100644
--- a/packages/world-modules/mud.config.ts
+++ b/packages/world-modules/mud.config.ts
@@ -273,6 +273,24 @@ export default defineWorld({
tableIdArgument: true,
},
},
+ /************************************************************************
+ *
+ * REGISTER DELEGATION WITH SIGNATURE MODULE
+ *
+ ************************************************************************/
+ UserDelegationNonces: {
+ schema: { delegator: "address", nonce: "uint256" },
+ key: ["delegator"],
+ codegen: {
+ outputDirectory: "modules/delegation/tables",
+ },
+ },
},
- excludeSystems: ["UniqueEntitySystem", "PuppetFactorySystem", "ERC20System", "ERC721System"],
+ excludeSystems: [
+ "UniqueEntitySystem",
+ "PuppetFactorySystem",
+ "ERC20System",
+ "ERC721System",
+ "Unstable_DelegationWithSignatureSystem",
+ ],
});
diff --git a/packages/world-modules/src/index.sol b/packages/world-modules/src/index.sol
index 138d5a9e37..3834739512 100644
--- a/packages/world-modules/src/index.sol
+++ b/packages/world-modules/src/index.sol
@@ -22,3 +22,4 @@ import { Owners } from "./modules/erc721-puppet/tables/Owners.sol";
import { TokenApproval } from "./modules/erc721-puppet/tables/TokenApproval.sol";
import { OperatorApproval } from "./modules/erc721-puppet/tables/OperatorApproval.sol";
import { ERC721Registry } from "./modules/erc721-puppet/tables/ERC721Registry.sol";
+import { UserDelegationNonces } from "./modules/delegation/tables/UserDelegationNonces.sol";
diff --git a/packages/world-modules/src/interfaces/IUnstable_DelegationWithSignatureSystem.sol b/packages/world-modules/src/interfaces/IUnstable_DelegationWithSignatureSystem.sol
new file mode 100644
index 0000000000..b00fa22860
--- /dev/null
+++ b/packages/world-modules/src/interfaces/IUnstable_DelegationWithSignatureSystem.sol
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+/* Autogenerated file. Do not edit manually. */
+
+import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
+
+/**
+ * @title IUnstable_DelegationWithSignatureSystem
+ * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz)
+ * @dev This interface is automatically generated from the corresponding system contract. Do not edit manually.
+ */
+interface IUnstable_DelegationWithSignatureSystem {
+ error InvalidSignature(address signer);
+
+ function registerDelegationWithSignature(
+ address delegatee,
+ ResourceId delegationControlId,
+ bytes memory initCallData,
+ address delegator,
+ bytes memory signature
+ ) external;
+}
diff --git a/packages/world-modules/src/modules/delegation/ECDSA.sol b/packages/world-modules/src/modules/delegation/ECDSA.sol
new file mode 100644
index 0000000000..01201021db
--- /dev/null
+++ b/packages/world-modules/src/modules/delegation/ECDSA.sol
@@ -0,0 +1,174 @@
+// SPDX-License-Identifier: MIT
+// OpenZeppelin Contracts (last updated v5.0.0) (utils/cryptography/ECDSA.sol)
+
+pragma solidity >=0.8.24;
+
+/**
+ * @dev Elliptic Curve Digital Signature Algorithm (ECDSA) operations.
+ *
+ * These functions can be used to verify that a message was signed by the holder
+ * of the private keys of a given address.
+ */
+library ECDSA {
+ enum RecoverError {
+ NoError,
+ InvalidSignature,
+ InvalidSignatureLength,
+ InvalidSignatureS
+ }
+
+ /**
+ * @dev The signature derives the `address(0)`.
+ */
+ error ECDSAInvalidSignature();
+
+ /**
+ * @dev The signature has an invalid length.
+ */
+ error ECDSAInvalidSignatureLength(uint256 length);
+
+ /**
+ * @dev The signature has an S value that is in the upper half order.
+ */
+ error ECDSAInvalidSignatureS(bytes32 s);
+
+ /**
+ * @dev Returns the address that signed a hashed message (`hash`) with `signature` or an error. This will not
+ * return address(0) without also returning an error description. Errors are documented using an enum (error type)
+ * and a bytes32 providing additional information about the error.
+ *
+ * If no error is returned, then the address can be used for verification purposes.
+ *
+ * The `ecrecover` EVM precompile allows for malleable (non-unique) signatures:
+ * this function rejects them by requiring the `s` value to be in the lower
+ * half order, and the `v` value to be either 27 or 28.
+ *
+ * IMPORTANT: `hash` _must_ be the result of a hash operation for the
+ * verification to be secure: it is possible to craft signatures that
+ * recover to arbitrary addresses for non-hashed data. A safe way to ensure
+ * this is by receiving a hash of the original message (which may otherwise
+ * be too long), and then calling {MessageHashUtils-toEthSignedMessageHash} on it.
+ *
+ * Documentation for signature generation:
+ * - with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js]
+ * - with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers]
+ */
+ function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError, bytes32) {
+ if (signature.length == 65) {
+ bytes32 r;
+ bytes32 s;
+ uint8 v;
+ // ecrecover takes the signature parameters, and the only way to get them
+ // currently is to use assembly.
+ /// @solidity memory-safe-assembly
+ assembly {
+ r := mload(add(signature, 0x20))
+ s := mload(add(signature, 0x40))
+ v := byte(0, mload(add(signature, 0x60)))
+ }
+ return tryRecover(hash, v, r, s);
+ } else {
+ return (address(0), RecoverError.InvalidSignatureLength, bytes32(signature.length));
+ }
+ }
+
+ /**
+ * @dev Returns the address that signed a hashed message (`hash`) with
+ * `signature`. This address can then be used for verification purposes.
+ *
+ * The `ecrecover` EVM precompile allows for malleable (non-unique) signatures:
+ * this function rejects them by requiring the `s` value to be in the lower
+ * half order, and the `v` value to be either 27 or 28.
+ *
+ * IMPORTANT: `hash` _must_ be the result of a hash operation for the
+ * verification to be secure: it is possible to craft signatures that
+ * recover to arbitrary addresses for non-hashed data. A safe way to ensure
+ * this is by receiving a hash of the original message (which may otherwise
+ * be too long), and then calling {MessageHashUtils-toEthSignedMessageHash} on it.
+ */
+ function recover(bytes32 hash, bytes memory signature) internal pure returns (address) {
+ (address recovered, RecoverError error, bytes32 errorArg) = tryRecover(hash, signature);
+ _throwError(error, errorArg);
+ return recovered;
+ }
+
+ /**
+ * @dev Overload of {ECDSA-tryRecover} that receives the `r` and `vs` short-signature fields separately.
+ *
+ * See https://eips.ethereum.org/EIPS/eip-2098[ERC-2098 short signatures]
+ */
+ function tryRecover(bytes32 hash, bytes32 r, bytes32 vs) internal pure returns (address, RecoverError, bytes32) {
+ unchecked {
+ bytes32 s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
+ // We do not check for an overflow here since the shift operation results in 0 or 1.
+ uint8 v = uint8((uint256(vs) >> 255) + 27);
+ return tryRecover(hash, v, r, s);
+ }
+ }
+
+ /**
+ * @dev Overload of {ECDSA-recover} that receives the `r and `vs` short-signature fields separately.
+ */
+ function recover(bytes32 hash, bytes32 r, bytes32 vs) internal pure returns (address) {
+ (address recovered, RecoverError error, bytes32 errorArg) = tryRecover(hash, r, vs);
+ _throwError(error, errorArg);
+ return recovered;
+ }
+
+ /**
+ * @dev Overload of {ECDSA-tryRecover} that receives the `v`,
+ * `r` and `s` signature fields separately.
+ */
+ function tryRecover(
+ bytes32 hash,
+ uint8 v,
+ bytes32 r,
+ bytes32 s
+ ) internal pure returns (address, RecoverError, bytes32) {
+ // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
+ // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
+ // the valid range for s in (301): 0 < s < secp256k1n รท 2 + 1, and for v in (302): v โ {27, 28}. Most
+ // signatures from current libraries generate a unique signature with an s-value in the lower half order.
+ //
+ // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value
+ // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
+ // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
+ // these malleable signatures as well.
+ if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
+ return (address(0), RecoverError.InvalidSignatureS, s);
+ }
+
+ // If the signature is valid (and not malleable), return the signer address
+ address signer = ecrecover(hash, v, r, s);
+ if (signer == address(0)) {
+ return (address(0), RecoverError.InvalidSignature, bytes32(0));
+ }
+
+ return (signer, RecoverError.NoError, bytes32(0));
+ }
+
+ /**
+ * @dev Overload of {ECDSA-recover} that receives the `v`,
+ * `r` and `s` signature fields separately.
+ */
+ function recover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) {
+ (address recovered, RecoverError error, bytes32 errorArg) = tryRecover(hash, v, r, s);
+ _throwError(error, errorArg);
+ return recovered;
+ }
+
+ /**
+ * @dev Optionally reverts with the corresponding custom error according to the `error` argument provided.
+ */
+ function _throwError(RecoverError error, bytes32 errorArg) private pure {
+ if (error == RecoverError.NoError) {
+ return; // no error: do nothing
+ } else if (error == RecoverError.InvalidSignature) {
+ revert ECDSAInvalidSignature();
+ } else if (error == RecoverError.InvalidSignatureLength) {
+ revert ECDSAInvalidSignatureLength(uint256(errorArg));
+ } else if (error == RecoverError.InvalidSignatureS) {
+ revert ECDSAInvalidSignatureS(errorArg);
+ }
+ }
+}
diff --git a/packages/world-modules/src/modules/delegation/Unstable_DelegationWithSignatureModule.sol b/packages/world-modules/src/modules/delegation/Unstable_DelegationWithSignatureModule.sol
new file mode 100644
index 0000000000..58c2ee1770
--- /dev/null
+++ b/packages/world-modules/src/modules/delegation/Unstable_DelegationWithSignatureModule.sol
@@ -0,0 +1,49 @@
+// 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 { revertWithBytes } from "@latticexyz/world/src/revertWithBytes.sol";
+
+import { UserDelegationNonces } from "./tables/UserDelegationNonces.sol";
+import { Unstable_DelegationWithSignatureSystem } from "./Unstable_DelegationWithSignatureSystem.sol";
+
+import { DELEGATION_SYSTEM_ID } from "./constants.sol";
+
+contract Unstable_DelegationWithSignatureModule is Module {
+ Unstable_DelegationWithSignatureSystem private immutable delegationWithSignatureSystem =
+ new Unstable_DelegationWithSignatureSystem();
+
+ function installRoot(bytes memory encodedArgs) public {
+ requireNotInstalled(__self, encodedArgs);
+
+ IBaseWorld world = IBaseWorld(_world());
+
+ // Register table
+ UserDelegationNonces._register();
+
+ // Register system
+ (bool success, bytes memory data) = address(world).delegatecall(
+ abi.encodeCall(world.registerSystem, (DELEGATION_SYSTEM_ID, delegationWithSignatureSystem, true))
+ );
+ if (!success) revertWithBytes(data);
+
+ // Register system's functions
+ (success, data) = address(world).delegatecall(
+ abi.encodeCall(
+ world.registerRootFunctionSelector,
+ (
+ DELEGATION_SYSTEM_ID,
+ "registerDelegationWithSignature(address,bytes32,bytes,address,bytes)",
+ "registerDelegationWithSignature(address,bytes32,bytes,address,bytes)"
+ )
+ )
+ );
+ if (!success) revertWithBytes(data);
+ }
+
+ function install(bytes memory) public pure {
+ revert Module_NonRootInstallNotSupported();
+ }
+}
diff --git a/packages/world-modules/src/modules/delegation/Unstable_DelegationWithSignatureSystem.sol b/packages/world-modules/src/modules/delegation/Unstable_DelegationWithSignatureSystem.sol
new file mode 100644
index 0000000000..292457b69d
--- /dev/null
+++ b/packages/world-modules/src/modules/delegation/Unstable_DelegationWithSignatureSystem.sol
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
+import { System } from "@latticexyz/world/src/System.sol";
+import { createDelegation } from "@latticexyz/world/src/modules/init/implementations/createDelegation.sol";
+
+import { UserDelegationNonces } from "./tables/UserDelegationNonces.sol";
+import { getSignedMessageHash } from "./getSignedMessageHash.sol";
+import { ECDSA } from "./ECDSA.sol";
+
+contract Unstable_DelegationWithSignatureSystem is System {
+ /**
+ * @dev Mismatched signature.
+ */
+ error InvalidSignature(address signer);
+
+ /**
+ * @notice Registers a delegation for `delegator` with a signature
+ * @dev Creates a new delegation from the caller to the specified delegatee
+ * @param delegatee The address of the delegatee
+ * @param delegationControlId The ID controlling the delegation
+ * @param initCallData The initialization data for the delegation
+ * @param delegator The address of the delegator
+ * @param signature The EIP712 signature
+ */
+ function registerDelegationWithSignature(
+ address delegatee,
+ ResourceId delegationControlId,
+ bytes memory initCallData,
+ address delegator,
+ bytes memory signature
+ ) public {
+ uint256 nonce = UserDelegationNonces.get(delegator);
+ bytes32 hash = getSignedMessageHash(delegatee, delegationControlId, initCallData, delegator, nonce, _world());
+
+ // If the message was not signed by the delegator or is invalid, revert
+ address signer = ECDSA.recover(hash, signature);
+ if (signer != delegator) {
+ revert InvalidSignature(signer);
+ }
+
+ UserDelegationNonces.set(delegator, nonce + 1);
+
+ createDelegation(delegator, delegatee, delegationControlId, initCallData);
+ }
+}
diff --git a/packages/world-modules/src/modules/delegation/constants.sol b/packages/world-modules/src/modules/delegation/constants.sol
new file mode 100644
index 0000000000..d5a53e285b
--- /dev/null
+++ b/packages/world-modules/src/modules/delegation/constants.sol
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+import { ResourceId } from "@latticexyz/world/src/WorldResourceId.sol";
+import { ROOT_NAMESPACE } from "@latticexyz/world/src/constants.sol";
+import { RESOURCE_TABLE, RESOURCE_SYSTEM, RESOURCE_NAMESPACE } from "@latticexyz/world/src/worldResourceTypes.sol";
+
+ResourceId constant DELEGATION_SYSTEM_ID = ResourceId.wrap(
+ (bytes32(abi.encodePacked(RESOURCE_SYSTEM, ROOT_NAMESPACE, "Delegation")))
+);
diff --git a/packages/world-modules/src/modules/delegation/getSignedMessageHash.sol b/packages/world-modules/src/modules/delegation/getSignedMessageHash.sol
new file mode 100644
index 0000000000..8fe4bbf008
--- /dev/null
+++ b/packages/world-modules/src/modules/delegation/getSignedMessageHash.sol
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
+
+// Implements EIP712 signatures https://eips.ethereum.org/EIPS/eip-712
+
+bytes32 constant DELEGATION_TYPEHASH = keccak256(
+ "Delegation(address delegatee,bytes32 delegationControlId,bytes initCallData,address delegator,uint256 nonce)"
+);
+
+/**
+ * @notice Generate the message hash for a given delegation signature.
+ * @dev We include the delegator to prevent generating a garbage signature and registering a delegation for the corresponding recovered address.
+ * @param delegatee The address of the delegatee
+ * @param delegationControlId The ID controlling the delegation
+ * @param initCallData The initialization data for the delegation
+ * @param delegator The address of the delegator
+ * @param nonce The nonce of the delegator
+ * @param worldAddress The world address
+ */
+function getSignedMessageHash(
+ address delegatee,
+ ResourceId delegationControlId,
+ bytes memory initCallData,
+ address delegator,
+ uint256 nonce,
+ address worldAddress
+) view returns (bytes32) {
+ bytes32 domainSeperator = keccak256(
+ abi.encode(keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, worldAddress)
+ );
+
+ return
+ keccak256(
+ abi.encodePacked(
+ "\x19\x01",
+ domainSeperator,
+ keccak256(
+ abi.encode(DELEGATION_TYPEHASH, delegatee, delegationControlId, keccak256(initCallData), delegator, nonce)
+ )
+ )
+ );
+}
diff --git a/packages/world-modules/src/modules/delegation/tables/UserDelegationNonces.sol b/packages/world-modules/src/modules/delegation/tables/UserDelegationNonces.sol
new file mode 100644
index 0000000000..c629d55f06
--- /dev/null
+++ b/packages/world-modules/src/modules/delegation/tables/UserDelegationNonces.sol
@@ -0,0 +1,199 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+/* Autogenerated file. Do not edit manually. */
+
+// Import store internals
+import { IStore } from "@latticexyz/store/src/IStore.sol";
+import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
+import { StoreCore } from "@latticexyz/store/src/StoreCore.sol";
+import { Bytes } from "@latticexyz/store/src/Bytes.sol";
+import { Memory } from "@latticexyz/store/src/Memory.sol";
+import { SliceLib } from "@latticexyz/store/src/Slice.sol";
+import { EncodeArray } from "@latticexyz/store/src/tightcoder/EncodeArray.sol";
+import { FieldLayout } from "@latticexyz/store/src/FieldLayout.sol";
+import { Schema } from "@latticexyz/store/src/Schema.sol";
+import { EncodedLengths, EncodedLengthsLib } from "@latticexyz/store/src/EncodedLengths.sol";
+import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
+
+library UserDelegationNonces {
+ // Hex below is the result of `WorldResourceIdLib.encode({ namespace: "", name: "UserDelegationNo", typeId: RESOURCE_TABLE });`
+ ResourceId constant _tableId = ResourceId.wrap(0x746200000000000000000000000000005573657244656c65676174696f6e4e6f);
+
+ FieldLayout constant _fieldLayout =
+ FieldLayout.wrap(0x0020010020000000000000000000000000000000000000000000000000000000);
+
+ // Hex-encoded key schema of (address)
+ Schema constant _keySchema = Schema.wrap(0x0014010061000000000000000000000000000000000000000000000000000000);
+ // Hex-encoded value schema of (uint256)
+ Schema constant _valueSchema = Schema.wrap(0x002001001f000000000000000000000000000000000000000000000000000000);
+
+ /**
+ * @notice Get the table's key field names.
+ * @return keyNames An array of strings with the names of key fields.
+ */
+ function getKeyNames() internal pure returns (string[] memory keyNames) {
+ keyNames = new string[](1);
+ keyNames[0] = "delegator";
+ }
+
+ /**
+ * @notice Get the table's value field names.
+ * @return fieldNames An array of strings with the names of value fields.
+ */
+ function getFieldNames() internal pure returns (string[] memory fieldNames) {
+ fieldNames = new string[](1);
+ fieldNames[0] = "nonce";
+ }
+
+ /**
+ * @notice Register the table with its config.
+ */
+ function register() internal {
+ StoreSwitch.registerTable(_tableId, _fieldLayout, _keySchema, _valueSchema, getKeyNames(), getFieldNames());
+ }
+
+ /**
+ * @notice Register the table with its config.
+ */
+ function _register() internal {
+ StoreCore.registerTable(_tableId, _fieldLayout, _keySchema, _valueSchema, getKeyNames(), getFieldNames());
+ }
+
+ /**
+ * @notice Get nonce.
+ */
+ function getNonce(address delegator) internal view returns (uint256 nonce) {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ bytes32 _blob = StoreSwitch.getStaticField(_tableId, _keyTuple, 0, _fieldLayout);
+ return (uint256(bytes32(_blob)));
+ }
+
+ /**
+ * @notice Get nonce.
+ */
+ function _getNonce(address delegator) internal view returns (uint256 nonce) {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ bytes32 _blob = StoreCore.getStaticField(_tableId, _keyTuple, 0, _fieldLayout);
+ return (uint256(bytes32(_blob)));
+ }
+
+ /**
+ * @notice Get nonce.
+ */
+ function get(address delegator) internal view returns (uint256 nonce) {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ bytes32 _blob = StoreSwitch.getStaticField(_tableId, _keyTuple, 0, _fieldLayout);
+ return (uint256(bytes32(_blob)));
+ }
+
+ /**
+ * @notice Get nonce.
+ */
+ function _get(address delegator) internal view returns (uint256 nonce) {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ bytes32 _blob = StoreCore.getStaticField(_tableId, _keyTuple, 0, _fieldLayout);
+ return (uint256(bytes32(_blob)));
+ }
+
+ /**
+ * @notice Set nonce.
+ */
+ function setNonce(address delegator, uint256 nonce) internal {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ StoreSwitch.setStaticField(_tableId, _keyTuple, 0, abi.encodePacked((nonce)), _fieldLayout);
+ }
+
+ /**
+ * @notice Set nonce.
+ */
+ function _setNonce(address delegator, uint256 nonce) internal {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ StoreCore.setStaticField(_tableId, _keyTuple, 0, abi.encodePacked((nonce)), _fieldLayout);
+ }
+
+ /**
+ * @notice Set nonce.
+ */
+ function set(address delegator, uint256 nonce) internal {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ StoreSwitch.setStaticField(_tableId, _keyTuple, 0, abi.encodePacked((nonce)), _fieldLayout);
+ }
+
+ /**
+ * @notice Set nonce.
+ */
+ function _set(address delegator, uint256 nonce) internal {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ StoreCore.setStaticField(_tableId, _keyTuple, 0, abi.encodePacked((nonce)), _fieldLayout);
+ }
+
+ /**
+ * @notice Delete all data for given keys.
+ */
+ function deleteRecord(address delegator) internal {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ StoreSwitch.deleteRecord(_tableId, _keyTuple);
+ }
+
+ /**
+ * @notice Delete all data for given keys.
+ */
+ function _deleteRecord(address delegator) internal {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ StoreCore.deleteRecord(_tableId, _keyTuple, _fieldLayout);
+ }
+
+ /**
+ * @notice Tightly pack static (fixed length) data using this table's schema.
+ * @return The static data, encoded into a sequence of bytes.
+ */
+ function encodeStatic(uint256 nonce) internal pure returns (bytes memory) {
+ return abi.encodePacked(nonce);
+ }
+
+ /**
+ * @notice Encode all of a record's fields.
+ * @return The static (fixed length) data, encoded into a sequence of bytes.
+ * @return The lengths of the dynamic fields (packed into a single bytes32 value).
+ * @return The dynamic (variable length) data, encoded into a sequence of bytes.
+ */
+ function encode(uint256 nonce) internal pure returns (bytes memory, EncodedLengths, bytes memory) {
+ bytes memory _staticData = encodeStatic(nonce);
+
+ EncodedLengths _encodedLengths;
+ bytes memory _dynamicData;
+
+ return (_staticData, _encodedLengths, _dynamicData);
+ }
+
+ /**
+ * @notice Encode keys as a bytes32 array using this table's field layout.
+ */
+ function encodeKeyTuple(address delegator) internal pure returns (bytes32[] memory) {
+ bytes32[] memory _keyTuple = new bytes32[](1);
+ _keyTuple[0] = bytes32(uint256(uint160(delegator)));
+
+ return _keyTuple;
+ }
+}
diff --git a/packages/world-modules/test/DelegationWithSignatureModule.t.sol b/packages/world-modules/test/DelegationWithSignatureModule.t.sol
new file mode 100644
index 0000000000..50e9ba06c1
--- /dev/null
+++ b/packages/world-modules/test/DelegationWithSignatureModule.t.sol
@@ -0,0 +1,142 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+import { Test } from "forge-std/Test.sol";
+import { GasReporter } from "@latticexyz/gas-report/src/GasReporter.sol";
+
+import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
+import { World } from "@latticexyz/world/src/World.sol";
+import { IModule } from "@latticexyz/world/src/IModule.sol";
+import { IModuleErrors } from "@latticexyz/world/src/IModuleErrors.sol";
+import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol";
+import { IWorldErrors } from "@latticexyz/world/src/IWorldErrors.sol";
+import { System } from "@latticexyz/world/src/System.sol";
+import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";
+import { UNLIMITED_DELEGATION } from "@latticexyz/world/src/constants.sol";
+import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
+
+import { createWorld } from "@latticexyz/world/test/createWorld.sol";
+import { WorldTestSystem } from "@latticexyz/world/test/World.t.sol";
+
+import { Unstable_DelegationWithSignatureModule } from "../src/modules/delegation/Unstable_DelegationWithSignatureModule.sol";
+import { Unstable_DelegationWithSignatureSystem } from "../src/modules/delegation/Unstable_DelegationWithSignatureSystem.sol";
+import { getSignedMessageHash } from "../src/modules/delegation/getSignedMessageHash.sol";
+import { ECDSA } from "../src/modules/delegation/ECDSA.sol";
+
+contract Unstable_DelegationWithSignatureModuleTest is Test, GasReporter {
+ using WorldResourceIdInstance for ResourceId;
+
+ IBaseWorld world;
+ Unstable_DelegationWithSignatureModule delegationWithSignatureModule = new Unstable_DelegationWithSignatureModule();
+
+ function setUp() public {
+ world = createWorld();
+ StoreSwitch.setStoreAddress(address(world));
+ }
+
+ function testInstallRoot() public {
+ startGasReport("install delegation module");
+ world.installRootModule(delegationWithSignatureModule, new bytes(0));
+ endGasReport();
+ }
+
+ function testRegisterDelegationWithSignature() public {
+ // Register a new system
+ WorldTestSystem system = new WorldTestSystem();
+ ResourceId systemId = WorldResourceIdLib.encode({
+ typeId: RESOURCE_SYSTEM,
+ namespace: "namespace",
+ name: "testSystem"
+ });
+ world.registerNamespace(systemId.getNamespaceId());
+ world.registerSystem(systemId, system, true);
+
+ world.installRootModule(delegationWithSignatureModule, new bytes(0));
+
+ // Register a limited delegation using signature
+ (address delegator, uint256 delegatorPk) = makeAddrAndKey("delegator");
+ address delegatee = address(2);
+
+ bytes32 hash = getSignedMessageHash(delegatee, UNLIMITED_DELEGATION, new bytes(0), delegator, 0, address(world));
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatorPk, hash);
+ bytes memory signature = abi.encodePacked(r, s, v);
+
+ // Attempt to register a limited delegation using an empty signature
+ vm.expectRevert(abi.encodeWithSelector(ECDSA.ECDSAInvalidSignatureLength.selector, 0));
+ Unstable_DelegationWithSignatureSystem(address(world)).registerDelegationWithSignature(
+ delegatee,
+ UNLIMITED_DELEGATION,
+ new bytes(0),
+ delegator,
+ new bytes(0)
+ );
+
+ startGasReport("register an unlimited delegation with signature");
+ Unstable_DelegationWithSignatureSystem(address(world)).registerDelegationWithSignature(
+ delegatee,
+ UNLIMITED_DELEGATION,
+ new bytes(0),
+ delegator,
+ signature
+ );
+ endGasReport();
+
+ // Call a system from the delegatee on behalf of the delegator
+ vm.prank(delegatee);
+ bytes memory returnData = world.callFrom(delegator, systemId, abi.encodeCall(WorldTestSystem.msgSender, ()));
+ address returnedAddress = abi.decode(returnData, (address));
+
+ // Expect the system to have received the delegator's address
+ assertEq(returnedAddress, delegator);
+
+ // Unregister delegation
+ vm.prank(delegator);
+ world.unregisterDelegation(delegatee);
+
+ // Expect a revert when attempting to perform a call via callFrom after a delegation was unregistered
+ vm.expectRevert(abi.encodeWithSelector(IWorldErrors.World_DelegationNotFound.selector, delegator, delegatee));
+ vm.prank(delegatee);
+ world.callFrom(delegator, systemId, abi.encodeCall(WorldTestSystem.msgSender, ()));
+
+ // Attempt to register a limited delegation using an old signature
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ Unstable_DelegationWithSignatureSystem.InvalidSignature.selector,
+ 0x1Ee32CcbA4C692C5b89e0858F2C0779C8a3D98AB
+ )
+ );
+ Unstable_DelegationWithSignatureSystem(address(world)).registerDelegationWithSignature(
+ delegatee,
+ UNLIMITED_DELEGATION,
+ new bytes(0),
+ delegator,
+ signature
+ );
+
+ // Expect a revert when attempting to perform a call via callFrom after a delegation was unregistered
+ vm.expectRevert(abi.encodeWithSelector(IWorldErrors.World_DelegationNotFound.selector, delegator, delegatee));
+ vm.prank(delegatee);
+ world.callFrom(delegator, systemId, abi.encodeCall(WorldTestSystem.msgSender, ()));
+
+ // Register a limited delegation using a new signature
+ hash = getSignedMessageHash(delegatee, UNLIMITED_DELEGATION, new bytes(0), delegator, 1, address(world));
+ (v, r, s) = vm.sign(delegatorPk, hash);
+ signature = abi.encodePacked(r, s, v);
+
+ Unstable_DelegationWithSignatureSystem(address(world)).registerDelegationWithSignature(
+ delegatee,
+ UNLIMITED_DELEGATION,
+ new bytes(0),
+ delegator,
+ signature
+ );
+
+ // Call a system from the delegatee on behalf of the delegator
+ vm.prank(delegatee);
+ returnData = world.callFrom(delegator, systemId, abi.encodeCall(WorldTestSystem.msgSender, ()));
+ returnedAddress = abi.decode(returnData, (address));
+
+ // Expect the system to have received the delegator's address
+ assertEq(returnedAddress, delegator);
+ }
+}
diff --git a/packages/world/gas-report.json b/packages/world/gas-report.json
index dfc6b5ab43..70f6d42296 100644
--- a/packages/world/gas-report.json
+++ b/packages/world/gas-report.json
@@ -81,7 +81,7 @@
"file": "test/World.t.sol",
"test": "testCallFromUnlimitedDelegation",
"name": "register an unlimited delegation",
- "gasUsed": 74727
+ "gasUsed": 74771
},
{
"file": "test/World.t.sol",
diff --git a/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol b/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol
index a7d3dc1fe3..bd91822a85 100644
--- a/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol
+++ b/packages/world/src/modules/init/implementations/WorldRegistrationSystem.sol
@@ -31,6 +31,7 @@ import { requireNamespace } from "../../../requireNamespace.sol";
import { requireValidNamespace } from "../../../requireValidNamespace.sol";
import { LimitedCallContext } from "../LimitedCallContext.sol";
+import { createDelegation } from "./createDelegation.sol";
/**
* @title WorldRegistrationSystem
@@ -264,27 +265,7 @@ contract WorldRegistrationSystem is System, IWorldErrors, LimitedCallContext {
ResourceId delegationControlId,
bytes memory initCallData
) public onlyDelegatecall {
- // Store the delegation control contract address
- UserDelegationControl._set({
- delegator: _msgSender(),
- delegatee: delegatee,
- delegationControlId: delegationControlId
- });
-
- // If the delegation is limited...
- if (Delegation.isLimited(delegationControlId)) {
- // Require the delegationControl contract to implement the IDelegationControl interface
- address delegationControl = Systems._getSystem(delegationControlId);
- requireInterface(delegationControl, type(IDelegationControl).interfaceId);
-
- // Call the delegation control contract's init function
- SystemCall.callWithHooksOrRevert({
- caller: _msgSender(),
- systemId: delegationControlId,
- callData: initCallData,
- value: 0
- });
- }
+ createDelegation(_msgSender(), delegatee, delegationControlId, initCallData);
}
/**
diff --git a/packages/world/src/modules/init/implementations/createDelegation.sol b/packages/world/src/modules/init/implementations/createDelegation.sol
new file mode 100644
index 0000000000..8aee3b7a95
--- /dev/null
+++ b/packages/world/src/modules/init/implementations/createDelegation.sol
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.24;
+
+import { ResourceId } from "@latticexyz/store/src/ResourceId.sol";
+
+import { SystemCall } from "../../../SystemCall.sol";
+import { Delegation } from "../../../Delegation.sol";
+import { requireInterface } from "../../../requireInterface.sol";
+import { NamespaceOwner } from "../../../codegen/tables/NamespaceOwner.sol";
+import { UserDelegationControl } from "../../../codegen/tables/UserDelegationControl.sol";
+import { IDelegationControl } from "../../../IDelegationControl.sol";
+
+import { Systems } from "../../../codegen/tables/Systems.sol";
+
+function createDelegation(
+ address delegator,
+ address delegatee,
+ ResourceId delegationControlId,
+ bytes memory initCallData
+) {
+ // Store the delegation control contract address
+ UserDelegationControl._set({ delegator: delegator, delegatee: delegatee, delegationControlId: delegationControlId });
+
+ // If the delegation is limited...
+ if (Delegation.isLimited(delegationControlId)) {
+ // Require the delegationControl contract to implement the IDelegationControl interface
+ address delegationControl = Systems._getSystem(delegationControlId);
+ requireInterface(delegationControl, type(IDelegationControl).interfaceId);
+
+ // Call the delegation control contract's init function
+ SystemCall.callWithHooksOrRevert({
+ caller: delegator,
+ systemId: delegationControlId,
+ callData: initCallData,
+ value: 0
+ });
+ }
+}
diff --git a/packages/world/ts/delegationWithSignatureTypes.ts b/packages/world/ts/delegationWithSignatureTypes.ts
new file mode 100644
index 0000000000..f61d7c0d52
--- /dev/null
+++ b/packages/world/ts/delegationWithSignatureTypes.ts
@@ -0,0 +1,11 @@
+// Follows https://viem.sh/docs/actions/wallet/signTypedData#usage
+
+export const delegationWithSignatureTypes = {
+ Delegation: [
+ { name: "delegatee", type: "address" },
+ { name: "delegationControlId", type: "bytes32" },
+ { name: "initCallData", type: "bytes" },
+ { name: "delegator", type: "address" },
+ { name: "nonce", type: "uint256" },
+ ],
+} as const;
diff --git a/packages/world/ts/exports/internal.ts b/packages/world/ts/exports/internal.ts
index a07919cb95..7657e8d688 100644
--- a/packages/world/ts/exports/internal.ts
+++ b/packages/world/ts/exports/internal.ts
@@ -9,3 +9,5 @@ export * from "../encodeSystemCalls";
export * from "../encodeSystemCallsFrom";
export * from "../actions/callFrom";
+
+export * from "../delegationWithSignatureTypes";