# Verifiable NFT Composition from IPFS Token URIs

> A RISC Zero zkVM app that proves an NFT's off-chain IPFS metadata matches its on-chain tokenURI, then composes verified NFTs into new ones — our Best ZKVM Application winner at ZKHack Montreal.

- Canonical URL: https://blog.eggtech.io/posts/verifiable-nft-composition
- Markdown URL: https://blog.eggtech.io/posts/verifiable-nft-composition/index.md
- Published: 2024-10-15
- Updated: 2024-10-15
- Author: Kai Aldag
- Reading time: 10 min read
- Tags: zk, nfts, crypto

## Referenced Links

- [Devfolio](https://devfolio.co/projects/zkompose-77da)
- [GitHub](https://github.com/KaiCode2/ipfs-risc0)

---

At ZKHack Montreal, this project won the **Best ZKVM Application** bounty from **RISC0**. The idea we explored is simple to describe and surprisingly powerful: what if an NFT's `tokenURI` could become a trustless input to new applications, not just a pointer that frontends render?

Most NFT metadata lives off-chain. On-chain, the contract usually publishes a URI such as:

```text
ipfs://Qm...
```

That URI is an IPFS content identifier. It commits to bytes, but smart contracts generally cannot afford to fetch the bytes, parse JSON, validate the schema, and run application logic over it. As a result, anything interesting that depends on metadata tends to move into trusted servers, indexers, or frontends.

We built a different path: a RISC Zero zkVM application that proves a supplied metadata object is exactly the content committed to by an on-chain IPFS token URI, then composes those verified objects into new NFT state.

In our demo, the NFTs are soccer player cards. Each player NFT publishes only an IPFS CID on-chain. The off-chain metadata contains player attributes like jersey number, tier, overall rating, speed, shooting, passing, dribbling, defense, physicality, and goal tending. The system proves that a claimed `Player` object really hashes back to the CID published by the ERC-721 contract. Once that fact is proven, the player metadata can be used inside another proof to build higher-level assets, such as a team NFT.

The important part is the trust model: the downstream application does not need to trust the caller, an API, an indexer, or a metadata server. It trusts the on-chain `tokenURI` and a zkVM proof.

## Why This Matters

NFT metadata is a huge amount of latent application state. It can encode game stats, attributes, inventory, generative traits, identity claims, membership properties, or arbitrary JSON. But because that data usually sits behind IPFS URIs, most contracts treat it as opaque.

That limits composability. You can transfer the NFT, check ownership, and maybe read a URI, but you cannot easily say:

- "Build a team from these verified player cards."
- "Upgrade this NFT only if its existing metadata has a qualifying trait."
- "Create a derivative asset whose attributes are computed from several source NFTs."
- "Migrate metadata to a new format without trusting a centralized migration service."
- "Prove that an edited IPFS object preserves selected fields from the original object."

RISC Zero changes the boundary. The expensive and awkward work happens inside the zkVM: decoding data, recomputing CIDs, checking application rules, and producing new outputs. Ethereum only verifies a succinct proof and consumes the verified journal.

## The Core Mechanism

The project has four main pieces:

1. A shared Rust metadata and CID library.
2. A player-verification zkVM guest.
3. A team-composition zkVM guest.
4. Solidity contracts that publish and verify the resulting commitments.

Here is the high-level split between the chain, the publisher, and the zkVM:

```mermaid
flowchart LR
    subgraph Chain["On-chain state and verification"]
        Players["Players ERC-721<br/>ownerOf(tokenId)<br/>tokenURI(tokenId)"]
        Team["Team contract<br/>buildTeam(...)"]
        Verifier["RISC0 verifier<br/>checks seal and image ID"]
    end

    subgraph Host["Off-chain host"]
        Metadata["IPFS metadata bytes<br/>Player JSON"]
        Publisher["Publisher CLI<br/>preflight, prove, submit"]
        PlayerReceipt["Player proof receipt<br/>owner + state commitment"]
        TeamProof["Team proof<br/>seal + journal"]
    end

    subgraph Zkvm["RISC0 zkVM"]
        VerifyCid["verify_cid guest<br/>prove metadata matches tokenURI"]
        MakeTeam["make_team guest<br/>compose verified players"]
    end

    Metadata --> Publisher
    Publisher -->|"Steel preflight calls"| Players
    Players -->|"tokenURI + owner + EVM input"| Publisher
    Publisher -->|"Player JSON + tokenId + EVM input"| VerifyCid
    VerifyCid --> PlayerReceipt
    Publisher -->|"player receipts as assumptions"| MakeTeam
    MakeTeam --> TeamProof
    Publisher -->|"playerIds + teamURI + seal"| Team
    Team -->|"verify(seal, imageId, journalHash)"| Verifier
    Verifier -->|"valid proof"| Team
```

The sequence looks like this when someone builds a derived NFT from existing IPFS-backed NFTs:

```mermaid
sequenceDiagram
    actor Holder
    participant Publisher as Publisher CLI
    participant Players as Players ERC-721
    participant VerifyCid as RISC0 verify_cid guest
    participant MakeTeam as RISC0 make_team guest
    participant Team as Team contract
    participant Verifier as RISC0 verifier

    Holder->>Publisher: Provide player metadata and token IDs
    Publisher->>Players: Preflight ownerOf(tokenId) and tokenURI(tokenId)
    Players-->>Publisher: Return owner, URI, and EVM proof input
    Publisher->>VerifyCid: Prove Player JSON matches on-chain tokenURI
    VerifyCid-->>Publisher: Return receipt with owner and state commitment
    Publisher->>MakeTeam: Add player receipts as assumptions
    MakeTeam->>MakeTeam: Verify player proofs and run team rules
    MakeTeam-->>Publisher: Return team proof seal and public journal
    Publisher->>Team: Submit player IDs, team URI, and proof seal
    Team->>Verifier: Verify seal against MAKE_TEAM_ID and journal hash
    Verifier-->>Team: Accept or reject proof
```

And at the zkVM-program level, the two proving steps are:

```mermaid
flowchart TB
    subgraph VerifyProgram["verify_cid zkVM program"]
        A["Inputs<br/>Player JSON<br/>tokenId<br/>Steel EVM input"] --> B["Execute authenticated view calls<br/>ownerOf(tokenId)<br/>tokenURI(tokenId)"]
        B --> C["Serialize Player JSON"]
        C --> D["Compute UnixFS CID"]
        D --> E["Format as ipfs://CIDv0"]
        E --> F{"Computed URI equals<br/>on-chain tokenURI?"}
        F -->|"yes"| G["Commit journal<br/>state commitment + owner"]
        F -->|"no"| H["Abort proof"]
    end

    subgraph TeamProgram["make_team zkVM program"]
        I["Inputs<br/>owner<br/>players<br/>token IDs<br/>player proof receipts"] --> J["Verify each receipt<br/>against VERIFY_CID_ID"]
        J --> K{"Receipts prove same owner<br/>and committed chain state?"}
        K -->|"yes"| L["Apply team composition rules"]
        L --> M["Compute derived team metadata CID"]
        M --> N["Commit journal<br/>teamCID + playerIds + commitment"]
        K -->|"no"| O["Abort proof"]
    end

    G -->|"receipt used as assumption"| J
```

### 1. Recomputing IPFS CIDs in Rust

The common Rust crate defines a serializable `Player` metadata type and a `ComputeCid` trait. Given any serializable object, the code serializes it to JSON bytes and feeds those bytes through the same UnixFS-style CID construction used by IPFS.

That gives us two useful forms:

- The raw CID bytes, including the multihash prefix.
- The formatted URI string, `ipfs://<cid>`.

This is the bridge between ordinary NFT metadata and verifiable computation. A prover can present a JSON object to the zkVM, and the zkVM can independently derive the CID that object should have.

### 2. Reading the Real On-Chain Token URI

The player verification guest uses RISC Zero Steel to make authenticated Ethereum view calls inside the proof. It reads:

- `ownerOf(tokenId)`
- `tokenURI(tokenId)`

from the Player ERC-721 contract on Sepolia.

The guest then recomputes the expected IPFS URI from the supplied `Player` metadata and asserts that it exactly matches the on-chain `tokenURI`.

If the assertion passes, the proof journal commits to the Ethereum state commitment and the owner. That means a verifier learns: at the proven chain state, this player object is the content committed to by this token's published URI, and this address owned the token.

### 3. Composing Verified NFTs

The next step is composition. A team should not be built from arbitrary JSON that merely looks like player metadata. It should be built from player objects that have already been proven to correspond to real player NFTs.

RISC Zero proof composition gives us that structure. The team-building guest can accept player verification receipts as assumptions, verify those receipts inside the zkVM, and then run higher-level application logic over the verified player data.

In the soccer demo, that means composing player NFTs into a team. The same pattern generalizes to any rule system:

- Combine multiple source NFTs into one derived NFT.
- Enforce ownership or approval checks.
- Preserve selected metadata fields.
- Compute new attributes from old attributes.
- Produce a new IPFS CID for the derived object.

Instead of treating metadata as a frontend convention, the application can treat it as proven input.

### 4. Verifying the Result On-Chain

On-chain, the contracts use RISC Zero's Groth16 verifier interface. The generated image IDs bind each proof to a specific zkVM program:

- `VERIFY_CID_ID` identifies the player CID verification guest.
- `MAKE_TEAM_ID` identifies the team composition guest.

The Player contract stores compact CID commitments as `bytes32` values and reconstructs CIDv0 URIs by prepending the multihash prefix and Base58 encoding the result. That keeps on-chain storage small while still exposing standard `ipfs://...` token URIs.

The Team contract is the receiving side for the composed proof. It checks player approvals, verifies the RISC Zero seal against the team-building image ID, and only accepts the submitted team URI when the proof's journal matches the expected public output.

## What We Built

The repository contains an end-to-end prototype of verifiable IPFS-backed NFT composition:

- `common` defines the player metadata schema and CID derivation logic.
- `methods-player` contains the zkVM guest that proves a player object matches the on-chain IPFS `tokenURI`.
- `methods-team` contains the zkVM guest for composing verified player proofs into a team-building proof.
- `apps` contains a publisher CLI that preflights Ethereum calls with Steel, generates player proofs, passes them as assumptions into the team proof, and prepares the proof output for on-chain verification.
- `contracts` contains the ERC-721 player and team contracts, RISC Zero image ID integration, and CIDv0 URI formatting logic.

The demo domain is intentionally concrete. Soccer cards make the value easy to see: a player card has metadata-rich stats, and a team is a meaningful composition of multiple players. But the actual primitive is broader than sports or games.

We proved that an NFT's off-chain metadata can become a verifiable input to another application, as long as the original token publishes an IPFS CID on-chain.

## Trustlessly Modifying IPFS URIs

One way to describe this project is "trustless IPFS URI modification."

That does not mean changing the contents behind an existing CID. IPFS CIDs are content-addressed, so changing the bytes necessarily creates a new CID. Instead, the system lets anyone prove a valid transformation:

```text
old on-chain tokenURI -> verified old metadata -> application rule -> new metadata -> new IPFS CID
```

The proof can show that the new URI was derived correctly from the old one according to a known program.

That unlocks a useful design pattern for NFT applications:

1. Read the authoritative source URI from an existing on-chain NFT.
2. Provide the corresponding IPFS content to the zkVM.
3. Recompute the CID and prove it matches the source URI.
4. Apply deterministic transformation rules.
5. Compute the new CID.
6. Publish or accept the new URI on-chain only if the proof verifies.

No trusted backend has to certify the transformation. No indexer has to be treated as an oracle. No contract has to parse arbitrary JSON or implement IPFS hashing in Solidity. The zkVM handles the heavy computation, and the chain verifies the result.

## Where This Can Go

This pattern is useful anywhere IPFS metadata is already the source of truth but is too expensive or inconvenient for smart contracts to inspect directly.

Games can use it to craft items, combine characters, update stats, or enforce progression rules based on existing NFT metadata. NFT collections can use it for trustless migrations, where holders prove that a new metadata object faithfully preserves traits from an old collection while adding new fields. Creator tools can use it to generate derivative assets with provenance that is verifiable on-chain. DAOs and membership systems can use it to prove properties of off-chain metadata without exposing every detail in contract storage.

The broader point is that IPFS URIs do not have to be dead strings. With a zkVM, they can become composable commitments.

## The Takeaway

The project turns a common NFT pattern into a programmable primitive. An ERC-721 publishes an IPFS CID. A prover supplies the content. The zkVM recomputes the CID, proves it matches the on-chain URI, runs application logic over the verified content, and outputs a new commitment. Ethereum verifies the proof and can safely accept the result.

That is why this was a strong fit for the RISC0 bounty at ZKHack Montreal. It uses the zkVM for exactly the kind of work that is natural off-chain but hard on-chain: IPFS hashing, JSON-shaped application logic, authenticated chain reads, and proof composition.

The result is a system for composing NFTs in verifiably correct ways from the only thing the original NFT had to publish on-chain: its IPFS token URI.
