-
Notifications
You must be signed in to change notification settings - Fork 206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: Data Modelling v2 #347
Comments
Not sure if this is already demonstrated in the code above (wasn't clear how to call the There's also some potential for function sig collisions between systems, so maybe it makes sense to prefix each with the contract name or some other identifier, e.g. |
Before we land on anything, it'd be nice to turn some of this pseudocode into real code (and manually write the helpers that will be auto generated) and run some tests against it to see 1) how it feels to use in practice and 2) how much more gas efficient is it compared to MUD's current data model/approach. |
Is |
I think "always has to come last" already solves the efficiency problem, "only allow one dynamic" may be an excessive restriction
(I assume you are putting off 165 because of interface volatility)
Have you thought about name squatting?
I'm not sure
Devex can probably be salvaged with some helpers, it's gonna be on the client at that point anyways (except for internal systems).
Looks like a non-gsn use case for ERC2771. Tho it will hardly be more convenient - instead of 1 mandated argument, you get 1 mandated interface to inherit + 1 internal func (_msgSender) to think about |
Re: supportsInterface
I agree, we can use Re: calling systems via fallback()
I agree and came to the same conclusion as you, see pseudocode for bytes4 worldSelector = bytes4(keccak(abi.encodePacked(contractName, "_", functionSig))); The idea would be to create a new function selector in the World contract based on a unique system prefix (ie the system name) and the system's function selector. Registering the
Haha, interesting point. I don't think there is much prestige associated with function name prefixes, but if a World gets popular enough for this to be a problem, and gas prices are not high enough to disincentivise squatting, there could always be some small ETH amount required to register a new system / prefix to avoid excessive registration of systems / prefixes.
The big advantage of using the fallback function is we can easily generate an ABI for the World that includes all registered systems, and thereby make it possible to call system functions with typed argument instead of raw bytes. I agree that on the client we could just have helpers adding types and encode the parameters automatically, but I would like encourage on-chain composability (ie creating new more complex systems by plugging together existing simpler ones), and the developer experience of doing that by manually encoding raw bytes is much worse in my opinion. (We could generate libraries to add types to system calls similar to how we'd have to do it for storing values in tables/components, but if we can get away with native Solidity features I'd prefer that over more code generation.) Re: implementing prototype to benchmark and see how it feels
Very much agree with this! Will start implementing a prototype version today to create some benchmarks and share the results here. Re: update events
The final name of the event is tbd but if we decide to move away from About "unset": I agree, this is definitely relevant for indexers (on-chain and off-chain). Btw, speaking of indexers, this made it not into the proposal above, but I think all tables/components should be "bare" by default (no reverse mapping) and instead we should have a built-in Re: restricting table schema to limit dynamic types
Yeah this is a fair point. We could just allow multiple dynamic types per schema and make it the users' responsibility to be aware that values may get shifted in storage if they're placed behind a dynamic length type. Re: call forwarding
True, conceptually it has a lot of similarities to ERC2771 meta transactions. I wonder if it would be worth implementing ERC2771 though, since systems would only accept transactions from a |
Re: Re: call forwarding
Oh you wouldn't even use // The extra complexity is the mandatory ERC2771Context that you need to inherit, and _msgSender it provides.
// Arguments are typed as before, that's unchanged.
contract MoveSystem is ERC2711Context {
function move(bytes32 _entity, Position _position) public {
// Check if the `_from` address owns the given entity
require(OwnerTable.get(_entity) == _msgSender(), "only owner can move entity");
// Set the new entity's new position value
PositionTable.set(entity, position);
}
} |
Ohh I wasn't aware of that, thanks for pointing this out. In that case I think the ERC2711 approach provides a much better dev ex, since it doesn't require a potentially stale default param. |
Instead of detecting the presence of the store on the contract storage via a call, i recommend doing it via a storage read. Reads get cheaper if accessed multiple time (if a system makes multiple store writes as an example); they also are cheaper than calls if optimised (a call has to go through an ABI encode). |
@alvrs if u're working on this rn and wanna use some of my methods: |
Very cool! I'm working on a prototype on this branch: https://github.com/latticexyz/mud/tree/alvrs/datamodel (also very WIP still) |
Started a branch too: https://github.com/dk1a/mud/tree/dk1a/datamodel |
Some preliminary gas metrics (both branches aren't particularly optimized): StoreCoreTest::testRegisterAndGetSchema() StoreCoreTest::testSetAndGetAndSplitData() |
Thanks @dk1a! Your branch has a lot of useful logic for dynamic length schemas, will port some of that over. In general I think we should first agree on an "interface" (how to register new tables and systems and how to interact with new tables and systems), and then go deep into gas optimization for the "reference implementation". For the gas optimization step it would be really useful to have a set of tests for every low level function and a github action to run the tests and parse out the gas results in comparison to before so we have an easy way to compare. For now I created a draft PR for the prototype implementation: #352 The "end to end" test is in |
@alvrs dynamic stuff (bytes, arrays) and storage functions are pretty bug-filled atm, should be ready tomorrow with a thorough TestTable Haven't gotten around to it yet, but something like this might be cleaner for World: target.call(
abi.encodePacked(
// not encodeWithSelector because arguments are already encoded
selector,
arguments,
// forward msg.sender as last 20 bytes
msgSender
)
);
returndatasize can be longer than 0x40, which'd violate memory safety; you can copy it to the unallocated space instead |
FWIW, I think it's a useful exercise to try some of the gas optimizing steps while designing the interfaces, because they can greatly inform one another. That's how I ultimately came to EthFS using a But I also wanna be mindful that one particular interface/design isn't gonna constrain us in the future if we want to expand wider or go deeper in terms of features, gas optimizing, etc. |
good point - this part of the code was taken from EIP-2535 Diamonds, ooc do you know how they handle returndata longer than 0x40? |
Added TestTable, and the full assortment of SchemaTypes seems to work now: https://github.com/dk1a/mud/tree/dk1a/datamodel The only unimplemented types are @alvrs now that I'm done with slice packing, I imagine I should wait for you to update ur branch and then fork it to avoid 2 parallel ones. |
(untested pseudocode just to show the general idea): assembly {
let unallocPtr := mload(0x40)
returndatacopy(unallocPtr, 0, returndatasize())
} The idea is to use the unallocated memory as scratch space https://github.com/solidstate-network/solidstate-solidity/blob/master/contracts/proxy/Proxy.sol |
I'm still on the fence about how to best support dynamic length schema types. If we allow multiple dynamic length schema types and store them contiguously in storage, modifying one of them has the potential of exploding gas cost, since we'd have to shift all storage behind it. So I'm thinking it would be better to store each dynamic schema type in a different location in storage; We could store the lengths of each dynamic schema type in a single deterministic slot per table, and then store each compressed array at a different location. That would allow us to support things like pushing/popping to/from arrays and would prevent shifting all memory. Since static data and dynamic data would be handled differently, I think it would be best to have different methods for setting/modifying them. Also, we might want to add another field to the event to allow emitting updates of individual dynamic array entries without having to read the entire array from storage. // data index to update individual items of dynamic data types
event StoreUpdate(bytes32 table, bytes32[] key, bytes memory data, uint8 schemaIndex, uint8 dataIndex);
// Set full static data
// Approx gas cost: 20k * ceil(data.length / 32) for sstore + 2.1k for sload (verifying schema)
function setStaticData(bytes32 table, bytes32[] key, bytes memory data) {
// Verify data matches schema length
// Set data at keccak(table, key)
// Emit StoreUpdate(table, key, data, 0, 0)
}
// Set an individual static data column
// Approx gas cost: 20k * ceil(data.length / 32) for sstore + 2.1k for sload (verifying schema)
function setStaticDataColumn(bytes32 table, bytes32[] key, bytes memory data, uint256 schemaIndex) {
// Verify data matches schema length
// Set data at keccak(table, key)
// Emit StoreUpdate(table, key, data, schemaIndex, 0)
}
// Set an individual dynamic data column
// Approx gas cost: 20k * ceil((data.length + 2) / 32) for sstore
function setDynamicDataColumn(bytes32 table, bytes32[] key, bytes memory data, uint256 schemaIndex) {
// Set length at keccak(table, key, DYNAMIC_DATA_LENGTH) + (2 * schemaIndex bytes)
// -> length of all dynamic indices is stored in the same slot, so writing to multiple dynamic data schema indices per tx gets cheaper
// Set data at keccak(table, key, schemaIndex)
// Emit StoreUpdate(table, key, data, schemaIndex, 0)
}
// Set an individual item of a dynamic data column
// Approx gas cost: 20k * ceil((data.length + 2) / 32) for sstore + 2.1k for sload (verifying schema)
function setDynamicDataColumnItem(bytes32 table, bytes32[] key, bytes memory data, uint256 schemaIndex, uint256 dataIndex) {
// Verify dataItem fits in one item of schema type at schemaIndex
// If dataIndex > length: increase data length to dataIndex + 1
// Set data at keccak(table, key, schemaIndex) + dataIndex
// Emit StoreUpdate(table, key, data, schemaIndex, dataIndex)
}
// Push an individual item into a dynamic data array table
function pushDynamicDataColumnItem(bytes32 table, bytes32[] key, bytes memory dataItem, uint256 schemaIndex) {
// Like like setDynamicDataColumnItem with dataIndex = length+1
}
// Pop an individual item from a dynamic data array table
function popDynamicDataColumnItem(bytes32 table, bytes32[] key, bytes memory dataItem, uint256 schemaIndex) {
// Return item at length
// Decrease length by 1
} On the other hand this makes the core interface much more convoluted than before. I'm still in the process of thinking this through and am happy for your input too. But for now it would probably be good to hold back on further implementations to avoid double work. |
It's actually pretty easy to change my implementation to account for this. Some parts would become even simpler than they are now (no need to compute slot offset for dynamic values). It's probably better to pack lengths together with the data they refer to (the way I do it currently, in a 2-byte prefix. For arrays like uint32[] this also warms up the slot with a bunch of initial elements).
I'd say the more functions the better. Start with generic setters (like what we have now), then add more granular stuff. If you end up using my StoreCore I can then augment it with whatever granularity we decide upon |
Updated store core specTo move forward with this change and unblock follow up projects building on top of the core storage library /
MotivationSee #347 (comment) (initial comment) - the motivation and design goals stayed the same. Explanation of terms
Store Interface// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { SchemaType } from "./Types.sol";
import { Schema } from "./Schema.sol";
interface IStore {
/* Event emitted when a full record is updated.
* table: unique identifier of the table
* key: tuple uniquely identifying a single row/record in the table
* data: packed bytes of the full record (see below for packing layout)
*/
event StoreSetRecord(bytes32 table, bytes32[] key, bytes data);
/* Event emitted when a single field is updated.
* table: unique identifier of the table
* key: tuple uniquely identifying a single row/record in the table
* schemaIndex: index in the schema (= column) that is being updated
* data: packed bytes of the field being updated
*/
event StoreSetField(bytes32 table, bytes32[] key, uint8 schemaIndex, bytes data);
/* Event emitted when a record is being removed (as opposed to just setting a value to 0)
* table: unique identifier of the table
* key: tuple uniquely identifying a single row/record in the table
*/
event StoreDeleteRecord(bytes32 table, bytes32[] key);
/* Called to create a new table with the given schema.
* The schema is stored into a `SchemaTable`, so a `StoreSetRecord` event is emitted
* when a new table is emitted. This event can be used by indexers to create
* a new database table corresponding to the given schema.
*/
function registerSchema(bytes32 table, Schema schema) external;
/* Return the registered schema for the given table */
function getSchema(bytes32 table) external view returns (Schema schema);
/* Set a full record (including values for the entire schema / all columns)
* table: unique identifier of the table
* key: tuple uniquely identifying a single row/record in the table
* data: packed bytes of the full record (see below for packing layout)
*/
function setRecord(
bytes32 table,
bytes32[] memory key,
bytes memory data
) external;
/* Update a single field
* table: unique identifier of the table
* key: tuple uniquely identifying a single row/record in the table
* schemaIndex: index in the schema (= column) that is being updated
* data: packed bytes of the field being updated
*/
function setField(
bytes32 table,
bytes32[] memory key,
uint8 schemaIndex,
bytes memory data
) external;
/* Return the full record identified by the given table/key as packed bytes
* (see below for packing layout)
*/
function getRecord(bytes32 table, bytes32[] memory key) external view returns (bytes memory data);
/* Return a single field of the record identified by the given table/key as packed bytes
*/
function getField(
bytes32 table,
bytes32[] memory key,
uint8 schemaIndex
) external view returns (bytes memory data);
/* Register a callback to be called when a record is updated */
function registerOnUpdateHook(bytes32 table, IOnUpdateHook onUpdateHook) external;
}
interface IOnUpdateHook {
/* Function to call when setRecord is called */
function onUpdateRecord(
bytes32 table,
bytes32[] memory key,
bytes memory data
) external;
/* Function to call when setField is called */
function onUpdateField(
bytes32 table,
bytes32[] memory key,
uint8 schemaIndex,
bytes memory data
) external;
} Data layoutData passed to and from storage libraryData sent to the store when setting a record and returned by the store when receiving a record needs to be a single blob of bytes (in order to have a single interface for arbitrary data types). To minimise gas costs, the size of this blob should be as small as possible (ie not 32 bytes for each array element like in native solidity). In storage however, we need to store dynamic length data types (string, arrays) in different storage locations to avoid following data being shifted when the dynamic data length of a single field changes. Therefore the data blob passed to the store should be packed and also include the sizes of all dynamic length data types. I propose the following packing: | static data (tightly packed according to schema)
| 32 bytes of encoded lengths of the dynamic data
| dynamic data (tightly packed according to schema) The static data (all data types except Following the static data is a 32 bytes word including the lengths of any dynamic data packets. The lengths are encoded as follows: 4 bytes for the total length of dynamic data, then 2 bytes per dynamic data length item (= up to 14 dynamic length data types per schema) If the schema doesn’t include any dynamic length data types, these 32 bytes of encoded lengths can be omitted. If the schema includes dynamic length data types, the dynamic data is appended after the encoded lengths. It is tightly packed, the minimal number of bytes per element required by the data type (eg 4 bytes per element for Data stored in storageHow the data is represented in storage is an implementation detail and can be handled differently by different store implementations if they keep the interface and data packing for data passed into and out of the store described above. In the reference implementation, all static length fields are stored tightly packed in a single storage location (spanning as many words as necessary). The encoded lengths of all dynamic length fields are stored in a single storage slot instead of a different slots for each dynamic length field. This limits us to 14 dynamic length fields per schema, but also saves a storage access per dynamic length fields and allows us to store data cheaper than native solidity. Lastly every dynamic length field is stored at a separate storage location to allow it to grow or shrink in size without shifting subsequent data. Typed librariesTo improve the developer experience of working with the store, we automatically generate libraries wrapping the core storage library and handle encoding and decoding of the data. Example interface of a // SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { Schema } from "../Schema.sol";
// -- User defined schema and id --
bytes32 constant id = keccak256("mud.store.table.vector3");
struct Vector2 {
uint32 x;
uint32 y;
}
// -- Autogenerated schema and library --
library Vector2Table {
/** Get the table's schema */
function getSchema() internal pure returns (Schema schema);
/** Register the table's schema */
function registerSchema() internal;
/** Set the table's data */
function set(bytes32 key, Vector2 memory vector) internal;
function setX(bytes32 key, uint32 x) internal;
function setY(bytes32 key, uint32 y) internal;
/** Get the table's data */
function get(bytes32 key) internal view returns (Vector2 memory vec2);
function getX(bytes32 key) internal view returns (uint32);
function getY(bytes32 key) internal view returns (uint32);
function decode(bytes memory blob) internal pure returns (Vector2 memory vec2)
} Example implementation of a (See also https://github.com/latticexyz/mud/blob/alvrs/datamodel/packages/store/src/schemas/Vector2.sol) // SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { Store } from "../Store.sol";
import { SchemaType } from "../Types.sol";
import { Bytes } from "../Bytes.sol";
import { Schema, Schema_ } from "../Schema.sol";
// -- User defined schema and id --
bytes32 constant id = keccak256("mud.store.table.vector3");
struct Vector2 {
uint32 x;
uint32 y;
}
// -- Autogenerated schema and library --
library Vector2Table {
/** Get the table's schema */
function getSchema() internal pure returns (Schema schema) {
schema = Schema_.encode(SchemaType.Uint32, SchemaType.Uint32);
}
/** Register the table's schema */
function registerSchema() internal {
Store.registerSchema(id, getSchema());
}
/** Set the table's data */
function set(
bytes32 key,
uint32 x,
uint32 y
) internal {
bytes memory data = bytes.concat(bytes4(x), bytes4(y));
bytes32[] memory keyTuple = new bytes32[](1);
keyTuple[0] = key;
StoreSwitch.setStaticData(id, keyTuple, data);
}
function setX(bytes32 key, uint32 x) internal {
bytes32[] memory keyTuple = new bytes32[](1);
keyTuple[0] = key;
StoreSwitch.setField(id, keyTuple, 0, bytes.concat(bytes4(x)));
}
function setY(bytes32 key, uint32 y) internal {
bytes32[] memory keyTuple = new bytes32[](1);
keyTuple[0] = key;
StoreSwitch.setField(id, keyTuple, 1, bytes.concat(bytes4(y)));
}
/** Get the table's data */
function get(bytes32 key) internal view returns (Vector2 memory vec2) {
bytes32[] memory keyTuple = new bytes32[](1);
keyTuple[0] = key;
bytes memory blob = StoreSwitch.getRecord(id, keyTuple);
return decode(blob);
}
function decode(bytes memory blob) internal pure returns (Vector2 memory vec2) {
return Vector2({ x: uint32(Bytes.slice4(blob, 0)), y: uint32(Bytes.slice4(blob, 4)) });
}
} Indexers / OnUpdateHook
FAQWhy use a custom encoding/decoding instead of abi.encode/decode?While Example: Encoding the following struct using Mixed memory mixed = Mixed({ u32: 1, u128: 2, a32: new uint32[](3), s: "hello" });
bytes memory abiEncoded = abi.encode(mixed); // length: 352 bytes
bytes memory customEncoded = customEncode(mixed); // length: 37 bytes For saving data to storage it is clear that we should try to access as few storage slots per storage operation as possible and can’t use a bloated encoding like The issue with this approach arises once we want to use the store library via an external function call (like when modifying/querying table data via an access controlled World framework). The cost of external function calls scales with the amount of data transferred with the call, the impact can be seen in the gas report below (last two rows). Passing the 352 bytes of abi.encoded data to a function costs 6.5k gas, while passing the 37 bytes of custom encoded data only costs 1.3k gas. Why store array lengths packed in a single storage slot instead of with the respective array?
Next steps
|
I imagine it can be eventually added to codegen as some optional thing to avoid manual boilerplate, since it's often useful in my experience.
I agree, came to the same conclusion eventually, having tried the 2-bytes-prefix approach
A little tangent: |
100% agree! We should make it easy in table definitions to extend them with on-chain indexers and provide a couple out of the box (eg reverse mapping)
Good point!
Yeah, in general I'm still a little undecided on the best way to trigger meaningful errors for clients to work with - custom errors have the downside that the client needs the ABI to make sense of them. @holic proposed having a set of standard MUD errors similar to HTTP error codes. On the topic of naming, do you know what's solidstate's reason to use two |
Systems already require abi with something like
Nope, my take on it: |
@alvrs wdyt about full-caps SchemaType props ( |
No strong preference, happy with all caps! |
Make sure to also check if
|
Started refactoring To clarify about the frontend - I'm thinking autogen can generate both solidity tables and typescript table types, bypassing the concept of |
We need to store the table's schema on-chain to allow indexers and other integrations to dynamically decode data from any table that is registered. Even if the |
I agree, it does seems necessary for 3rd party integrations to work well. Was a bit too focused on 1st party aspects
I guess we haven't really decided whether to actually do onchain validation. I don't think it would even be all that expensive. Anyways can be added later down the line |
This is great! My single comment is on data packing:
I'm assuming we still want to guarantee that fields won't span multiple words if Edit: Actually, another remark. All this seems rather cumbersome for "top-level non-datastructure fields". For instance, this could be the address of another contract that is upgradeable, or a version number, or some kind of running total. In practice, you can group them into a table that will contain a single row. Is better UX for that particular common use case warranted? |
It turns out that dynamically splitting up the data such that fields are guaranteed to never wrap words is more gas-costly and complex than leaving this optimisation to the wrapper libraries, which can automatically insert the dummy fields / optimise the order of elements (statically once at the time of their generation instead of dynamically in
I'm not sure I can follow, could you elaborate what
Do you mean the use case of grouping multiple unrelated fields together in a single word? If so: we have |
All this seems rather cumbersome for "top-level non-datastructure
fields". For instance, this could be the address of another contract that
is upgradeable, or a version number, or some kind of running total.
Tablegen supports "Singleton" (which are the top level non-datastructure
fields, like "isPaused" or "ownerAddress").
It uses the empty key in order to create a singleton table.
…On Thu, 2 Mar 2023 at 20:36, alvarius ***@***.***> wrote:
I'm assuming we still want to guarantee that fields won't span multiple
words if
they are of length < 32 bytes? Not doing so means data access & writes
becomes
considerably costlier, and the dev might need to insert dummy fields to
avoid
fields straddling multiple words.
StoreCore is intended as a pretty low level library and probably never
used directly, but instead interacted with via auto-generated wrapper
libraries that handle packing and unpacking of the data. (We have a
prototype of that here:
https://github.com/latticexyz/mud/blob/main/packages/cli/src/commands/tablegen.ts
)
It turns out that dynamically splitting up the data such that fields are
guaranteed to never wrap words is more gas-costly and complex than leaving
this optimisation to the wrapper libraries, which can automatically insert
the dummy fields / optimise the order of elements (statically once at the
time of their generation instead of dynamically in StoreCore).
All this seems rather cumbersome for "top-level non-datastructure fields".
For instance, this could be the address of another contract that is
upgradeable, or a version number, or some kind of running total.
I'm not sure I can follow, could you elaborate what this refers to in For
instance, >this< could be the address [...]?
In practice, you can group them into a table that will contain a single
row. Is better UX for that particular common use case warranted?
Do you mean the use case of grouping multiple unrelated fields together in
a single word? If so: we have set/getField in the low level StoreCore and
support for "singleton tables" (= just a single row, no need to specify a
key in getter/setter) in the autogenerated wrapper libraries, so you could
eg. create a table with schema { field1: uint128, field2: uint64, field3:
uint64 } and interact with it via RandomDataTable.getField1() etc.
(Similar to how you can define structs to group data in a single storage
slot).
—
Reply to this email directly, view it on GitHub
<#347 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AFBYUK3XNVHO4BX6IS2SLETW2EAETANCNFSM6AAAAAAT5C3ULI>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
this == top-level non-datastructure field. Just imagine a contract that has a But both you and @ludns gave a good explanation on how to do this conveniently, sounds good 👍 |
Closing this since we it got implemented as part of v2 |
Note: this proposal contains a lot of pseudo code and some of the core aspects of the proposal are contained in the code comments - don't skip over it
Table of contents
Abstract
This proposal addresses a couple of issues that come with MUD’s current approach to on-chain data modelling and state management.
We propose to move away from individual component contracts to store application state, and instead create a core spec and library for on-chain for data modelling and storage. The core library can be used in any contract to store state in a MUD compatible way and emit MUD compatible events for general-purpose indexers. (The core library doesn’t implement access control.)
We then use this core library to create a framework to add storage access control and the ability for third party developers to register new data pools and mount new contracts (similar to the current
World
contract).Issues with the previous approach
Currently, state is organised into separate components to manage access control and implement functions with typed parameters and typed return values (since Solidity doesn’t support generic types)
abi.encode
under the hood, which leads to unnecessarily high gas costs (becauseabi.encode
reserves one 32 byte word per struct key)Currently, developers have to opt-in to MUD’s entire framework to benefit from conceptually independent features like general purpose indexers, instead of being able to upgrade their existing codebases
Currently, developers have to manually define their components' schemas using a DSL, which is not intuitive for Solidity developers and leads to easy to miss bugs (when the defined schema doesn’t match the
abi.encoded
value)Currently, developers using MUD have to implement a lot of “boilerplate” code to read or write component values compared to setting vanilla Solidity storage variables
Current MUD:
Vanilla Solidity:
Currently, MUD is limited to the ECS pattern (Entity-Component-System), requiring every piece of data to be associated with a single
uint256
id. This makes some data modelling harder than desired, for example using a composite key (consisting of two values)keccak(entity1, entity2)
, but this approach obfuscates the used entity ids and is cumbersome to work withThe following is a proposal to address all of the issues above and more.
Design goals
Core storage management library
bytes
everywhere - typing is responsibility of wrapping libraries (see below)IMudStore
interface / extend theMudStore
base contract to become compatible with a large chunk of MUD’s toolchain, like general-purpose indexerstable
and index into the table, where the index can be a tuple of multiplebytes32
keysIllustration of data model
Pseudo-code implementation with more details
Notes
Wrapping typed libraries
MudStore
(eg if the contract using the library is called viadelegatecall
from aMudStore
) or if themsg.sender
is aMudStore
(eg if the contract using the library is called viacall
from aMudStore
) and automatically switches between writing to own storage using the core library and calling the respective access controlled methods on the callingMudStore
.Pseudo-code implementation with more details
Usage examples
Notes
deletegatecall
in the storage library called in the systemdelegatecall
, it means it can write to storage usingMudStoreCore
directly without having to call functions with access control on aMudStore
contract. This saves (700 call base gas +x
calldata gas +y
access control check gas) per storage operationdelegatecall
inside of a library, we can check ifthis
has theisMudStore()
functionisMudStore
function, ifthis
supportsisMudStore
, it means the current context is aMudStore
and we can use libraries directly (this could be turned into something like ERC165’ssupportsInterface
)Framework (aka World)
Edit: the original proposal included a section on the World framework. Since then we reworked the World framework concept and moved the discussion about it to a new issue (#393). For reference this toggle includes the original proposal.
MudStoreCore
library, any contract can become compatible with MUD’s toolchainMudStoreCore
(like the current World contract and conventions)DELEGATE
systems, meaning they are called viadelegatecall
from the World contractDELEGATE
systems have full access to all storage, so they can only be registered and upgraded by the World’s owneraddress(0)
DELEGATE
systems can be registered and the existingDELEGATE
systems can not be upgraded anymoreAUTONOMOUS
systems, meaning they are called viacall
from the World contractAUTONOMOUS
systems set state via the World’s access controlledsetData
methodAUTONOMOUS
systemAUTONOMOUS
system can upgrade the system (by overwriting the existing entry in theSystemTable
)fallback
methodmsg.sender
to be the World contract (if called viacall
) and therefore read and write data via World’s access controlled methods, or have write access to the delegated storage directly (if called viadelegatecall
). All of this can be abstracted into the autogenerated libraries per table.DELEGATE
systems or in “autonomous mode” withAUTONOMOUS
systems.CombatSystem
’sattack
function:world.registerSystem(<contractAddr>, "Combat", "attack(bytes32)")
world.Combat_attack(bytes32)
(the call will be forwarded toCombatSystem.attack(bytes32)
)msg.sender
is either the externalmsg.sender
(if the system is called viadelegatecall
) or the World contract (if the system is called viacall
).address _from
as their first parameter, which will be populated by the World contract with the externalmsg.sender
, or other addresses based on some approval pattern (see discussion in Proposal: General approval pattern (for modular systems and session wallets) #327)Pseudo-code implementation with more details
Usage example
Further work / extensions
Table migrations
setData
andgetData
that includes auint16 version
parameterMudStoreCore._getLocation
includes the version to get the storage location hash0
by defaultsetData
with an incremented index value and the new schema, and implements the migration in the getter functions.Acknowledgements
fallback
method, as well as using delegated storage is based on Nick Mudge, "EIP-2535: Diamonds, Multi-Facet Proxy," Ethereum Improvement Proposals, no. 2535, February 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-2535.The text was updated successfully, but these errors were encountered: