Splice for Developers
Project Setup / Tech Stack
We have structured the Splice codebase as monorepo that can be easily built using pnpm. Most packages require some environment variables to be set, so make sure to scan all README
s and .env.sample
files in the repo. To run all Splice services and Dapps on your own box, you'll certainly need API keys from Etherscan, NFTPort, nft.storage and Infura. Once you've got your env vars in place, you should be able to simply run
pnpm install
pnpm -r build
dapp|backend <- common <- contracts
. The language of choice for all code is Typescript.
Contracts
Our smart contracts are written in Solidity 0.8.10 and make heavy use of OpenZeppelin's base contracts. To compile / deploy them on your local machine you can use the local hardhat
binary that's part of the package's dependencies. Have a look at the package's README file to get an idea of how to get started and make sure to have a good understanding on Hardhat's tooling.
Randomness
Each style relies on a certain randomness that makes its results unique. We're precomputing this entropy seed out of deterministic inputs a minter provides: the origin collection's address and the chosen origin token id. This is the secret formula in pseudocode:
randomSeed = uint32(keccak256(abi.encode([origin_address],[origin_token_id])))
Since most Javascript libraries can only deal with 32 bits of numeric precision we're stripping the least significant 32 bits of the 256 bit long keccak hash. In theory that might lead to collisions but we consider that neglectible because the randomness is only one factor that determines the Splice result.
Provenance
The Splice contract has been written with gas efficiency in mind, hence we kept all bookkeeping structures to an absolute minimum. Foremost, Splice is intentionally not inheriting OpenZeppelin's Enumerable base which saves us a lot of gas. Additionally, we're not mapping plain origins to token ids directly, but rather use a provenance hash that allows the contract to figure out if a combination of inputs has been minted before. The provenance hash is computed as
bytes32 _provenanceHash = keccak256(
abi.encodePacked(origin_collections, origin_token_ids, style_token_id)
);
Since the explicit provenances aren't part of the contract's state and Splice owners aren't iterable, a developer who wants to list all available Splices and their origins needs to scan the contract for Mint
and Transfer
events:
emit Minted(
keccak256(abi.encode(origin_collections, origin_token_ids)),
token_id,
style_token_id
);
public async findProvenances(
collectionAddress: string,
tokenId: string
): Promise<TokenProvenance[]> {
const originHash = Splice.originHash(collectionAddress, tokenId);
const filter = this.contract.filters.Minted(originHash);
const mintedEvents = await this.contract.queryFilter(
filter,
this.deployedAtBlock
);
if (mintedEvents.length === 0) return [];
return mintedEvents.map((ev) => {
const { style_token_id, token_id: style_token_token_id } =
Splice.tokenIdToStyleAndToken(ev.args.token_id);
return {
origin_collection: collectionAddress,
origin_token_id: ethers.BigNumber.from(tokenId),
splice_token_id: ev.args.token_id,
style_token_id,
style_token_token_id
};
});
}
public async getProvenance(
spliceTokenId: BigNumber
): Promise<TokenProvenance | null> {
const bnTokenId: BigNumber =
'string' === typeof spliceTokenId
? BigNumber.from(spliceTokenId)
: spliceTokenId;
const filter = this.contract.filters.Minted(null, spliceTokenId);
const mintedEvents = await this.contract.queryFilter(
filter,
this.deployedAtBlock
);
if (mintedEvents.length == 0) return null;
if (mintedEvents.length > 1)
throw new Error('a token can only be minted once');
const mintEvent = mintedEvents[0];
const tx = await mintEvent.getTransaction();
const inputData = this.contract.interface.decodeFunctionData(
this.contract.interface.functions[
'mint(address[],uint256[],uint32,bytes32[],bytes)'
],
tx.data
);
const { style_token_id, token_id: style_token_token_id } =
Splice.tokenIdToStyleAndToken(spliceTokenId);
return {
origin_collection: inputData.origin_collections[0],
origin_token_id: inputData.origin_token_ids[0],
splice_token_id: bnTokenId,
style_token_id,
style_token_token_id
};
}
This obviously will become very expensive in terms of RPC requests and latency so we've created a Subgraph that does all this heavylifting for you. Find out more about subgraph usage in the Subgraph section.
Token IDs
You might've wondered why Splice's token ids seem to be so "big" and unpredictable (e.g. 4294967298
). Truth is: that's only because you're looking at them from a human perspective :). Like many other generative art collections we've decided to make the unique token id a combination of style id and an incremental token number. In Splice's case we're thinking big and use the full range of uint32 for both components.
Hence, a token id is represented as an Uint64
value with its higher significant 32 bits being the style token id (e.g. 0x00000001
) and the lower 32 bits representing the incremental part. The above mentioned token id is actually the decimal representation of the hexadecimal number (padded to 64 bits) 0x0000000100000002
ethers.utils.zeroPad(ethers.BigNumber.from("4294967298").toHexString(), 8)
// Uint8Array(8) [
// 0, 0, 0, 1,
// 0, 0, 0, 2
// ]
Style Features
Collection constraints
The owner of a style token may choose to restrict minting of their style to certain origin collections using the style contract's restrictToCollections
method. That feature particularly makes sense when the artist had a certain collection in mind when creating their style.
Allowlists
We've added an allowlist feature to our style contract that makes it possible for an artist (or whoever mints an artist's style) to reserve mints for friends, family or a community. That way an artist can set a low cap for a style (like cap of 500), but still be sure certain collectors get access to mint before her collection's sold out.
Instead of going with a plain allowlist array we decided to make use of Merkle tree proofs. We haven't implemented a nice snapshotting / tree creation tool as of now, but you can checkout the contracts
packages' allowlist.test.ts
tests to see how it's supposed to work.
Dynamic Pricing
The first styles we're launching will require a static minting fee that's set by the style minter role who mints the style NFT on behalf of an artist. We plan to implement more sophisticated approaches to pricing of mints, including Dutch auctions, bonding curves or even oracle based price indicators based on a collection's current floor price.
To be flexible in terms of mint price indication we've decoupled price computations from the main contracts: upon minting the style minter decides which pricing strategy should be in effect for the new style. Each strategy is implemented as a dedicated smart contract that must implement the ISplicePriceStrategy
interface to return a price denoted in wei:
interface ISplicePriceStrategy {
function quote(
uint256 style_token_id,
IERC721[] memory collections,
uint256[] memory token_ids
) external view returns (uint256);
}
Each style NFT is parametrized with a pricing contract address when minted. That contract is responsible for determining the price point for a requested Splice mint. A basic example is SplicePriceStrategyStatic.sol
that returns a fixed price unique to each style token.
Subgraphs
We've built the splice contract as gas efficient as possible, reducing bookkeeping on chain to a minimum: each splice NFT contains a hash to quickly prove its origins and the style token id that had been chosen for minting. As mentioned in the Provenance section, the subgraph
package contains a subgraph definition that's deployed on The Graph protocol's hosted service. It reads all Mint
and Transfer
events, extracts the minting parameters by using the transaction inputs, and provides a GraphQL API to query certain aspects of the Splice contracts' current state. Here's an example of how to get all splices of an user:
query SplicesOfOwner($owner: String) {
spliceice(where: { owner: $owner }) {
id
metadata_url
style {
id
metadata_url
}
origin_collection
origin_token_id
origin_metadata_url
}
}
{
"data": {
"spliceice": [
{
"id": "4294967298",
"metadata_url": "https://validate.getsplice.io/splice/4/4294967298",
"origin_collection": "0xf5aa8981e44a0f218b260c99f9c89ff7c833d36e",
"origin_metadata_url": "https://api.coolcatsnft.com/cat/26",
"origin_token_id": "26",
"style": {
"id": "1",
"metadata_url": "ipfs://bafyreiedlvkrjowkrs6u74ogyritsfaitsanhujzbciyaoedg33fndbxuu/metadata.json"
}
},
...
]
}
}
Common package
The common
package contains code that's shared between our Dapp and the backend. Most importantly we make sure serverside and frontend renderers use exactly the same code so as not to confuse users with varying visual results.
Other exports that are exposed by the common
package are
-
Splice.ts
: a contract class that wraps a typechain-typed instance of an ethers based Splice contract interface and adds some convenience methods to interact with it. -
Style.ts
: manages a style's code and wraps it into an executable JS function -
types/NFTs
: contains type definitions for NFT metadata and secondary services. -
indexers
: contains code to read existing NFTs from chain and extract their metadata in an abstract way: if you know which collections you'd like to read and you have access to a web3 provider, you can use theOnChain
indexer. If you need to find all assets owned by an user on mainnet, you can use theNFTPort
indexer class instead. Our dapp uses theFallback
indexer that tries reading from NFTPort and falls back to the on chain implementation if NFTPort hasn't fully indexed the collection yet.
Colors
The colors
package contains code to extract primary / dominant colors from an image. We experimented with quite a lot of solutions but found that some algorithms implemented by the image-q library yield the best results. Color extraction is a pretty delicate field since it deals with image quantization and adaptive weighting under the hood, not unrelated to palette generators. Splice uses a combination of Xiaolin Wu's matrix quantizer and Euclidean color distance to build a palette of 10 primary colors.
If you look at the colors/src/index.ts
file you'll notice that the same code to compute an image's dominant colors is used on the frontend (dapp/src/components/organisms/CreativePanel:extractPixels
) and on the backend (backend/src/lib/Origin.ts
).
Backend
Splice generally doesn't depend on a backend at all: since styles and their code are stored on IPFS and one can recover origin minting parameters by scanning transaction parameters on the Splice contract, you can rebuild your Splice NFT anytime. (We'll be providing dedicated tools for fully trustless rebuilding soon).
Since IPFS lookups and chain queries are usually related to high latencies or aren't free (Infura's free tier is high, but their RPC timeouts are rather low), we're providing a backend service that speeds up many use cases significantly.
Our backend package is a rather plain express server that responds to 6 API endpoints (see backend/src/server.ts
) and its base URI is https://validate.getsplice.io
:
GET /styles/:network
returns all styles (without code) including their metadata that are deployed onnetwork
(e.g.4
forrinkeby
)GET /render/:network/:style_token_id
renders a grayscale preview of a style.GET /styles/:network/:style_token_id
returns metadata and inline code of a style tokenGET /splice/:network/:tokenid
returns the metadata for Splicetokenid
GET /splice/:network/:tokenid/image.png
returns the Splice image fortokenid
onnetwork
GET /colors/:network/:collection/:token_id
extracts metadata, features and colors of the given NFT.
What makes the backend so powerful is its builtin caching mechanism: Instead of fetching origins, extracting colors and rerendering NFT metadata on every request, we're caching all results once they have been created for the first time. There's an open issue to align the cache layout in a way that's suitable for simple style freezing, too.
Dapp
Our dapp is building on a plain and simple CRA + Typescript foundation. We're using Chakra UI for dynamic styling and inject the most important dependencies using a shared context provider (see dapp/src/context/SpliceContext.tsx
). You can start the CRA dev server as you're used to:
pnpm run start
An important word of notice: since we're trying to adhere to web3 principles as closely as possible, the build output is hosted on a decentralized network. We don't want to bother with IPNS and DNSLink details, and that's why we've decided to hand over the final build and deployment process to the awesome services of Fleek.co