7 Key Insights for Smart Contract Developers: What We’ve Learned Building Splice

Stefan Adolf
Coinmonks
13 min readDec 14, 2021

--

We’re just about to launch a new web3 product, and want to share some of the most important things we’ve learned along the way:

  • How to separate concerns into different contracts?
  • Verifying ownership and allowing preliminary minting
  • How to ensure that no one can cheat on metadata?
  • Combined Token IDs
  • Shall we reduce gas fees by moving to an L2 network?
  • How to reduce storage costs?
  • How to keep payments safe?

First a quick overview of what we’ve built: Splice lets you use NFTs you already own to seed a new generated NFT. Just visit our Dapp at https://getsplice.io and select one of your NFTs. Splice will extract its metadata and colors, and inject that into the code of a generative art style you choose. The result is a unique artwork you can mint as an NFT. We’re launching with art styles to generate banner images you can seed with your PFP and use on Twitter or Discord, like this:

A Splice background image minted of Fangster #5409 and the Style “A Beginning is A Very Delicate Time”

Splice scales beyond banner images and will evolve in various ways. Our contracts are prepared to take more than one NFT as origin and we’ll ultimately hand over far more input parameters to the style code, including the origin NFT’s traits, additional image analysis data or rarity scores. That’ll enable artists to build styles for far more advanced use cases. Splice will be able to select Loots or Colors or DevDAO’s NFTs as inputs and create new metaverse items.

This idea took us into the finale of EthOnline21. You can watch our final project pitch on the hackathon’s version of Splice from October 21 here (7:16):

This is how our Dapp looks like after roughly 2 months of finetuning and feature additions. Feel free to give it a try — at the time of writing we support previewing styles on your mainnet assets and you’ll be able to mint Splices on selected collections on the Rinkeby test network.

How to separate concerns into different contracts?

Our Splice contracts cover two major usecases. First, the Splice contract itself is an ERC721 compatible NFT that tracks ownership of minted splices. Second, Splice’s styles are managed by another contract (Splice Style NFT) that controls ownership of styles and stores the fixed locations of style code and its metadata. Each style NFT enforces rules like how many splices of a style can be minted, whether minting is restricted to certain origin collections, if a minter has exclusive minting rights and, most importantly, how expensive a mint is.

To separate concerns we decided to split the Splice architecture into three contracts: Splice, Style and PriceStrategy(Static). We’re wiring up all these contracts during the deployment / the style minting process by keeping member properties typed as the composed contract.

contract Splice {  SpliceStyleNFT public styleNFT;  function setStyleNFT(SpliceStyleNFT _styleNFT) public onlyOwner {
styleNFT = _styleNFT;
}
function quote(IERC721 nft, uint32 style_token_id)
public view
returns (uint256 fee) {
return styleNFT.quoteFee(nft, style_token_id);
}
}
contract SpliceStyleNFT { address public spliceNFT; function setSplice(address _spliceNFT) external onlyOwner {
spliceNFT = _spliceNFT;
}
function quoteFee(IERC721 nft, uint32 style_token_id)
external
view
returns (uint256 fee) {
fee = styleSettings[style_token_id].priceStrategy.quote(
this,
nft,
style_token_id,
styleSettings[style_token_id]
);
}
function mint(
...
ISplicePriceStrategy _priceStrategy,
bytes32 _priceStrategyParameters
) {
styleSettings[style_token_id] = StyleSettings({
priceStrategy: _priceStrategy,
priceParameters: _priceStrategyParameters
})
}
}struct StyleSettings {
uint32 mintedOfStyle;
uint32 cap;
ISplicePriceStrategy priceStrategy;
bytes32 priceParameters;
bool salesIsActive;
bool collectionConstrained;
bool isFrozen;
string styleCID;
}
contract SplicePriceStrategyStatic { function quote(
SpliceStyleNFT styleNFT,
IERC721 collection,
uint256 token_id,
StyleSettings memory styleSettings
) external pure override returns (uint256) {
return uint256(styleSettings.priceParameters);
}
}

Separation of concerns in Solidity can be an architectural challenge because once contracts are deployed, they usually can’t be changed. Our priceStrategy seems unnecessary at first glance as it just returns the static fee that a style minter has provided during the minting process. The reason we built it this way is due to forward looking assumptions. The design of smart contracts must be open enough to be extended from day one, since it’s a nightmare to change them once they carry state and are used in the wild.

Our PriceStrategy abstraction allows far more sophisticated price calculations to be added in the future, without us needing to change anything on the existing contracts. Its implementations receive enough information to enable arbitrary complex pricing strategies. A simple extension could be a linearly bonded minting fee algorithm that increases the fee slightly with every Splice mint, or a Dutch auction that varies the fee depending on the time that’s passed since the last mint.

Who gets to mint? Verifying ownership and allowing preliminary minting

Ensuring that someone owns an origin is rather simple, you just cast the origin collection’s address to an IERC721 and check that its ownerOf method yields msg.sender for the incoming token ID:

function mint(
IERC721[] memory origin_collections,
uint256[] memory origin_token_ids,
uint32 style_token_id,
bytes32[] memory allowlistProof,
bytes calldata input_params
) {
for (uint256 i = 0; i < origin_collections.length; i++) {
if (origin_collections[i].ownerOf(origin_token_ids[i]) != msg.sender) {
revert NotOwningOrigin();
}
}

But there’s more to it. An artist might decide to build a style that only works for, say, three dedicated origin collections. If that’s the case those collections’ addresses can be provided in the collectionAllowed member on our Style NFT. A style with a collection constraint will revert Splice mints when the origin collection(s) aren’t listed in the constraint list. By default styles aren’t constrained to particular collections, so Splices can be minted off any origin NFT.

Lastly we decided to add an allowList feature that’s unique to each Style NFT. If you wanted to build allowlists naively, you could just add some member of type mapping(uint32 => mapping(address => bool)) but would have to initialize that mapping when deploying a new style. If a style creator wants to have, say, 200 addresses on an allowlist, that would require a significant amount of gas to be sent along the minting transaction. Since we wanted this feature to be gas efficient, we had to come up with a better solution.

We decided to use Merkle tree proofs to determine whether a mint request is issued by an allowed user. Luckily, OpenZeppelin had us covered with their reference implementation for Merkle proofs, so implementing this feature takes only one line of actual code:

///SpliceStyleNFT.solfunction verifyAllowlistEntryProof(
uint32 style_token_id,
bytes32[] memory allowlistProof,
address requestor
) public view returns (bool) {
return MerkleProof.verify(
allowlistProof,
allowlists[style_token_id].merkleRoot,
keccak256(abi.encodePacked(requestor))
);
}
///Splice.sol:mintif (
allowlistProof.length == 0 ||
!styleNFT.verifyAllowlistEntryProof(
style_token_id,
allowlistProof,
msg.sender
) {
revert NotAllowedToMint('no reservations left or proof failed');
}
)

The downside of this approach is that the allowlist members must be kept in a safe place and proofs must be generated for each user individually but since this feature deals with permissioned data anyway, it’s not necessary to solve it in a 100% decentralized way. The client code that creates the respective proof array looks like this:

import { MerkleTree } from 'merkletreejs';
import keccak256 from 'keccak256';
function createMerkleProof(allowedAddresses: string[]): MerkleTree {
const leaves = allowedAddresses.map((x) => keccak256(x));
return new MerkleTree(leaves, keccak256, {
sort: true
});
}
const allowedAddresses: string[] = [/* many 0xaddresses */]const merkleTree = createMerkleProof(allowedAddresses);
const leaf = utils.keccak256(_allowedAddresses[0]);
// this grows linearly with the overall amount of allowed addresses
const proof = merkleTree.getHexProof(leaf);
const verified = await styleNft.verifyAllowlistEntryProof(
1,
proof,
allowedAddresses[0]
);
expect(verified).to.be.true;

Hot to ensure that no one can cheat on metadata?

Our initial implementation of Splice was based on the assumption that users are in full control of their spliced results: the code renders a Splice result on the user’s machine, encodes it as PNG, publishes that on IPFS (using nft.storage) and then calls the contract’s mint method using its IPFS content hash.

We then realized that this implementation could easily be tricked by users: they actually could feed any IPFS hash they like into the mint function, effectively being able to mint virtually anything. That’s certainly not what we wanted to allow so we came up with the idea to use an oracle that requests dedicated validation backends to validate that our users’ mint “requests” actually contain valid Splice images that match the input parameters the user had used on their machine (you find an implementation for that in our repo’s history).

With the introduction of dedicated Style NFTs we deprecated this validation step. As of now Splice itself doesn’t store any IPFS metadata at all. The only information we keep for each Splice on chain is its “provenance” which is computed during a Splice mint:

/// Splice.sol:mintbytes32 provenanceHash = keccak256(
abi.encodePacked(
origin_collections, origin_token_ids, style_token_id
)
);

The provenance (or heritage as we called it historically) is unique for each Splice input. The users themselves are not providing any other information than their desired origin inputs and a style token id that determines which style code is executed on the inputs and which itself is anchored on IPFS. Thus, anyone who knows the input that had lead to the provenance, can deterministically reproduce the rendered image.

Combined Token IDs

If you look at Splice’s token IDs, for example “12884901894”, you’ll notice they’re seemingly not generated incrementally. Basically, they are, but it’s hard to see. Splice combines two incremental IDs into one unique ID value for each token. It combines the chosen style ID and the incremental (per style) splice token id into a unique token id (uint64, stored as uint256 as required by ERC721):

/// SpliceStyleNFTfunction incrementMintedPerStyle(uint32 style_token_id)
external
onlySplice
returns (uint32)
{
if (mintsLeft(style_token_id) == 0) {
revert StyleIsFullyMinted();
}

styleSettings[style_token_id].mintedOfStyle += 1;
return styleSettings[style_token_id].mintedOfStyle;
}
/// Splice.sol
uint32 nextStyleMintId = styleNFT.incrementMintedPerStyle(style_token_id);
token_id = BytesLib.toUint64(
abi.encodePacked(style_token_id, nextStyleMintId),
0
);

This approach lets you compute the style heritage of each Splice NFT. You take its least significant 64 bits and split it into two uint32 values. The first one denominates the style ID, the second the incremental splice ID for that style:

/// Splice.tspublic static tokenIdToStyleAndToken(tokenId: BigNumber) {
const hxToken = ethers.utils.arrayify(
utils.zeroPad(tokenId.toHexString(), 8)
);
return {
style_token_id: BigNumber.from(hxToken.slice(0, 4),
token_id: BigNumber.from(hxToken.slice(4))
};
}

A token ID that decimally reads 12884901894 translates to the hex string 0x0300000006 (or padded to 8 bytes / uint64: 0x0000000300000006), so it effectively translates to “splice #6 of style #3”.

Shall we reduce gas fees by moving to an L2 network?

Minimizing additional network costs for a mint on L1 should be a primary focus for each and every web3 project. At first glance Splice actually is a good candidate for L2 minting, but doing so would introduce an additional layer of architectural complexity. Remember, Splice relies on knowledge about the ownership of certain assets (the origin NFTs). To mint a Splice, the contract must ensure that the minter actually owns their origin NFTs. If you wanted to mint a Splice on an L2 network on assets that exist on L1, you’d need to create a cryptographically deterministic proof of that ownership relationship and communicate that using a message bridge that transfers knowledge between layers. Even worse, that proof could be negated immediately after its creation by transferring the origin asset to another account which only could be remedied by locking the origin into an escrow that keeps it safe until the mint has been finalized on L2. Ultimately, you would either decide to make Splice an L2 first protocol which implies that users cannot mint Splices on mainnet or you would have to come up with a pretty smart ID gap so that users could migrate Splices from L2 to L1 that don’t collide with already existing IDs.

Given that most valuable assets are still anchored on mainnet and Splice’s primary goal is to add value to those, we believe that deploying it as a pure L1 protocol is the best way to go for now (which isn’t implying that there won’t be a Polygon, zkSync or Arbitrum version of Splice coming up anytime soon).

How to reduce storage costs?

The significant driver of high gas costs on Ethereum is data storage. Each storage slot on the state tree must be replicated by all participating nodes and the most expensive operation of them all is allocating a new storage slot, which occurs when new mapping elements are created or array elements are appended. As it turns out, one of the most convenient NFT additions of all times makes exhaustive use of these operations: Enumerable collections. They allow clients to iterate over all items of an user, which internally is solved by adding another mapping and an indexing table.

Removing this feature shaved off a third (!) of all gas points of a mint transaction and others report to have witnessed comparable results.

Another storage slot we could remove from our first draft of the Splice contract was the “heritage” of a Splice token. Turns out the only information that needs to be stored directly on chain is the provenance hash (32 bytes) of a Splice’s heritage to figure out whether an item already had been minted. This optimization reduced the amount of gas used for a mint by another 30%.

Where to store the origin information then? The Graph to the rescue!

With every storage slot that you save on chain you lose some amount of functionality. When applying the aforementioned optimizations we lost two important view functions that formerly could be requested directly on chain: 1. what is the exact origin of a Splice token and 2. which Splice tokens are owned by an account.

It turns out both features are perfect candidates to be indexed by and queried from a subgraph. Subgraphs consist of WASM (AssemblyScript) based indexing scripts that are executed on indexer nodes to build up a secondary database of onchain information and make it publicly queryable using a GraphQL API. Though The Graph offers a fully decentralized way to deploy subgraphs on an incentivized indexing and curation layer, we chose to go with their pretty convenient (and free) hosted service.

A subgraph mapping listens to the events emitted by a contract and reacts to them accordingly. In Splice a minting transaction emits two events: a custom Minted(bytes32 origin, uint64 token_id, uint32 style_token_id) and Transfer (emitted by the base ERC721 contract). Note that you still cannot figure out the origin collection or token id just by following Minted events: what we actually have to analyze are the transaction inputs for the mint call that emitted the event. Our subgraph’s handleMinted handler shows how to achieve that:

After extracting some unencoded values from our Mint event we’re getting the input data of the initiating mint method call (line 18) and strip off the method signature’s first 4 bytes. Now we’re hitting a shortcoming of the underlying ABI decoder’s Rust implementation: it can only decode single values, e.g. tuples, uints or arrays but it is not capable of decoding plain function calls directly like those stored in event.transaction.input. The cheat we used to trick the decoder into decoding our function calls correctly is to prefix the argument with the ABI encoder’s signature for tuple types (0x20) and have the decoder operate on that value instead (line 21–32):

const decoded = ethereum.decode(
'(address[],uint[],uint,bytes32[],bytes)',
tupleInputBytes
);

We’ve also created a Rust sample that proves this approach to work:

The Splice subgraph is openly available for queries on The Graph’s hosted service: https://thegraph.com/hosted-service/subgraph/elmariachi111/splicemultirinkeby and it powers e.g. the “My Splices” page of the Splice Dapp. (Note, that for still undiscovered reasons the GraphQL schema’s plural version of splice is spliceice)

a query to retrieve all splice tokens owned by an user

How to keep payments safe?

Finally we took great care that all payments hitting our contracts are kept as safe as possible with regards to Consensys’ smart contract best practices for security. We’re not pushing minting fees to the recipients but rather ask them to withdraw them by issuing a pull transaction themselves. To keep funds safe for all participants we’ve integrated an internal token escrow that only transfers funds to entitled participants and that’s owned by the main contract so nobody could hijack the escrow itself. Upon minting all fees are split according to the currently active protocol settings (85% for artists, 15% for Splice) and escrowed until withdrawn by their beneficiaries:

import '@openzeppelin/contracts-upgradeable/utils/escrow/EscrowUpgradeable.sol';EscrowUpgradeable private feesEscrow;function initialize(...) public initializer {
/// ...
feesEscrow = new EscrowUpgradeable();
feesEscrow.initialize();
}
function withdrawShares() external nonReentrant whenNotPaused {
uint256 balance = shareBalanceOf(msg.sender);
feesEscrow.withdraw(payable(msg.sender));
emit Withdrawn(msg.sender, balance);
}
function shareBalanceOf(address payee) public view returns (uint256) {
return feesEscrow.depositsOf(payee);
}
//called inside the mint method
function splitMintFee(uint256 amount, uint32 style_token_id) internal {
uint256 feeForArtist = ARTIST_SHARE * (amount / 100);
uint256 feeForPlatform = amount - feeForArtist;
address beneficiaryArtist = styleNFT.ownerOf(style_token_id);
feesEscrow.deposit{ value: feeForArtist }(beneficiaryArtist);
feesEscrow.deposit{ value: feeForPlatform }(platformBeneficiary); }

Rollup

We certainly could’ve talked about a lot of other learnings we’ve discovered during the implementation of the Splice protocol. First and foremost we can recommend one thing, though: read the code of public contracts yourself! You’ll stumble upon a lot of stuff others have figured out before you. In our case we learnt a lot by analysing the contracts of ArtBlocks, Brotchain and Divine Anarchy.

If you like what you’ve read and you want to become part of the Splice journey, don’t hesitate to approach us directly on Discord or on Twitter. You also might find it helpful to read our documentation for developers to gain more insights about the Splice protocol’s inner workings. We’re happy to take any questions and suggestions.

Join Coinmonks Telegram Channel and Youtube Channel learn about crypto trading and investing

Also Read

--

--

Stefan Adolf
Coinmonks

molecule.to | getsplice.io. EthOnline finalist. React, Typescript, web3, Solidity, Gatsby, Ionic, Fastify, Mongo. Dev#7079