Cover photo

Technical Case Study: Elephants

Take a look under the hood and see how we built our Elephants NFT contracts.

We developed Elephants, a dynamic and composable PFP, to show off the power of Patchwork. With Elephants, attributes get minted as Fragment NFTs which are able to be assigned and unassigned to an Elephant NFT. This post is a deep-dive on some of the technical work that brought Elephants to life.

Configuration and contract logic

Letā€™s take a look at the contract configuration JSON files:

Elephant.json

{
   "scopeName": "elephants",
   "name": "Elephants",
   "symbol": "ELEPHANT",
   "schemaURI": "https://elephants.fun/schemas/elephant.json",
   "imageURI": "https://elephants.fun/assets/elephant/{tokenID}",
   "fields": [
       {
           "id": 1,
           "key": "attributeLiteRefs",
           "type": "literef",
           "description": "The attributes equipped by this elephant",
           "arrayLength": 8
       },
       {
           "id": 2,
           "key": "name",
           "type": "char32",
           "description": "Name"
       }
   ]
}

Attribute.json

{
   "scopeName": "elephants",
   "name": "Elephants: Attributes",
   "symbol": "ATTRIBUTE",
   "schemaURI": "https://elephants.fun/schemas/attribute.json",
   "imageURI": "https://elephants.fun/assets/attribute/{tokenID}",
   ā€œfeaturesā€: [ā€œfragmentsingleā€, ā€œmintableā€],
   "fields": [
       {
           "id": 1,
           "key": "attributeType",
           "type": "uint8",
           "description": "The attribute type (enum)"
       },
       {
           "id": 2,
           "key": "attributeId",
           "type": "uint16",
           "description": "The attribute ID (enum)"
       },
       {
           "id": 3,
           "key": "tier",
           "type": "uint8",
           "description": "Tier"
       },
       {
           "id": 4,
           "key": "name",
           "type": "char16",
           "description": "Name"
       }
   ]
}

The configurations define two entities: ā€œAttributeā€, which has a type enum (reconciled to uint8), ID, tier, and name, and ā€œElephantā€, with a name and 8 slots for attributes. We can create our Patchwork contracts with PDK by running:

pdk generate Elephant.json
pdk generate Attribute.json

Just like that, you have the majority of your application structure written for you. Letā€™s check out some of the highlights.

Byte packed metadata

function packMetadata(Metadata memory data) public pure returns (uint256[] memory slots) {
    slots = new uint256[](1);
    slots[0] = uint256(data.attributeType)
        | uint256(data.attributeId) << 8
        | uint256(data.tier) << 24
        | PatchworkUtils.strToUint256(data.name) >> 128 << 32;
    return slots;
}

PDK worked out the most efficient way to byte pack your metadata fields in as few storage slots as possible. It also wrote efficient getters and setters that allows for writing multiple fields in a single write.

While the EVM will pack structs into individual slots, access isnā€™t always optimal. Explicit packing and access gives the benefit of being able to write to and update multiple fields with a single SLOAD/SSTORE, resulting in onchain gas savings. All schema fields are also described in the generated schema function:

function schema() external pure override returns (MetadataSchema memory) {
    MetadataSchemaEntry[] memory entries = new MetadataSchemaEntry[](4);
    entries[0] = MetadataSchemaEntry(0, 0, FieldType.UINT8, 1, FieldVisibility.PUBLIC, 0, 0, "attributeType");
    entries[1] = MetadataSchemaEntry(1, 1, FieldType.UINT16, 1, FieldVisibility.PUBLIC, 0, 8, "attributeId");
    entries[2] = MetadataSchemaEntry(2, 2, FieldType.UINT8, 1, FieldVisibility.PUBLIC, 0, 24, "tier");
    entries[3] = MetadataSchemaEntry(3, 3, FieldType.CHAR16, 1, FieldVisibility.PUBLIC, 0, 32, "name");
    return MetadataSchema(1, entries);
}

This schema functions allows for apps to discover what data is available onchain and access it via conventional ABI or by reading one or all of the storage slots and decoding according to this description.

EIP-4906

event MetadataUpdate(uint256 indexed _tokenId);
event BatchMetadataUpdate(uint256 indexed fromTokenId, uint256 indexed toTokenId);

When metadata changes, 4906-compliant events will allow for indexers and marketplaces to invalidate their cache and pull the latest metadata.

Data integrity

The ownership model used for Elephants is a Single-Assignable Fragment with inherited ownership. This is specified by using the feature fragmentsingle in the contract configuration. The default logic built will allow for an Attribute to be minted and owned by a user and then assigned to an Elephant. Once assigned to the Elephant, the Attribute will report the owner of the Elephant as the Attribute owner. This makes the Attributes travel with the Elephant when it's is transferred.

Data integrity is maintained in unison by the contracts and Patchwork Protocol. The protocol checks both the assignee and assignable for permissions and integrity as well as internal data on assignment and unassignment.

Patchwork 721s use ERC-5192 to emit lock-related events and handle queries.

event Locked(uint256 indexed tokenId);
event Unlocked(uint256 indexed tokenId);
function locked(uint256 tokenId) external view returns (bool);

The default behavior for a Single-Assignable Fragment is to implicitly lock when assigned. A locked 721 cannot be transferred, so once the attributes are assigned to the Elephant, data integrity, ownership and transfers are all handled correctly.

Assigning Attribute fragments

In our Elephants contract, we wrote one small piece of application logic for creating Elephants, which we call forging. This function does some simple validation (you own the Attributes, you've included one of each of the required Attribute types, etc.) and mints the Elephant for you.

    function forge(address[] calldata attributeAddresses, uint256[] calldata tokenIds) public returns (uint256) {
        require(
            attributeAddresses.length == tokenIds.length, "Attribute addresses and token Ids must be the same length"
        );

        address owner_ = msg.sender;

        // Mint the elephant token
        uint256 newTokenId = _nextTokenId;
        _safeMint(owner_, newTokenId);
        _metadataStorage[newTokenId] = new uint256[](3);
        _nextTokenId++;

        // Check first to save gas on revert
        _checkRules(owner_, attributeAddresses, tokenIds);

        IPatchworkProtocol(_manager).assignBatch(attributeAddresses, tokenIds, address(this), newTokenId);
        // Emit a forge event that the node can watch for and generate art without waiting
        emit Forge(owner_, newTokenId, attributeAddresses, tokenIds);

        // Return address of and token Id of elephant
        return newTokenId;
    } 

The real magic happens in the call to Patchwork's assignBatch method that updates the assignments & ownership of the Attribute fragments. This has the following implications:

  • Calling ownerOf() on the assigned Attribute will now return the ownerOf the Elephant token

  • These attributes are locked and cannot be transferred until they are unassigned

  • Transferring the Elephant will automatically update the owner of all assigned Attributes to be the new Elephant owner. In Patchwork since ownerOf is proxied to the assignee owner, we can do this cheaply without actually needing to do an onchain transfer. Patchwork does however still emit cheap transfer logs so that block explorers and indexers are aware of this ownership change

We also have additional utility functions for editing and Elephant (lets users swap out attributes and add new ones to empty slots) and decomposing an Elephant (which burns the Elephant after unassigning all its fragments and makes them transferable & assignable again). You can check all those out in our verified source code on Basescan.

Extending the application

An Elephant has 8 static slots for attributes but the schema does not specifically define which contract the attributes must be. This is because the deployed contract may dynamically determine this via register calls made by the contract owner. When we deployed Elephants and Attributes, we registered the Attribute contract as a fragment for Elephants. We designed Attributes to give us a number of controls for expansion, however we may not have thought of everything we wanted to do in the future. Should we need a new attribute contract, we can deploy it and register the new attribute contract with Elephant, allowing entirely new functionality or graphics that we hadnā€™t previously designed.

Off-chain logic

Listening for events

Since Elephants are dynamic, we need to constantly listen for events emitted by the Elephants contractā€”namely Forge, Change, and Burnā€”so we can generate images and update the offchain metadata used by OpenSea et al. Here are the emitters from Elephant.sol:

emit Forge(owner_, newTokenId, attributeAddresses, tokenIds);
emit Change(owner, tokenId, attrAddresses, attrTokenIds);
emit Burn(ownerOf(tokenId), tokenId);

And here's a basic event listener using ethers, where elephantContract is an ethers contract instance in our cryptoService class. This watches for the Change event from our contract and calls the relevant functions that handle metadata and image generation.

this.cryptoService.elephantContract
.on('Change', async (owner, tokenId, attributeAddresses, tokenIds) => {
    console.log(`Change event received. owner: ${owner}, tokenId: ${tokenId}, attributeAddresses: ${attributeAddresses}, tokenId: ${tokenIds}`);
    // Call our metadata service to delete old json and create new json file
    ...
    // Call our image service to delete old image and create new svg image
    ...
})

For basic projects, you'll be able to listen directly to the protocol's assign and unassign events and watch for transfers to & from the null address to get the info you need. Since we're doing a lot of batch operations, we're emitting our own specialized events to make things easier.

Generating images in realtime

Generated Elephants images are just a stack of concatenated SVG elements composed from our Attribute SVGs. When we see a Forge or Change event come through, we iterate over the Elephant's array of assigned attributes, grab the attribute's corresponding raw SVG, layer it on top of the previous attribute, and rinse-&-repeat until all the attributes have been layered in.

If your SVGs were simple and compact enough (e.g. pixel art), you could easily store your image data and do this composition logic onchain.

Ready to give it a try?

Mint some attributes at elephants.fun or on mint.fun. Build your Elephant and join us in Discord to share what you've made!

Loading...
highlight
Collect this post to permanently own it.
PATCHWORK logo
Subscribe to PATCHWORK and never miss a post.
#case-study#patchwork