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 theownerOf
the Elephant tokenThese 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!