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/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";