# Why Molecule?

Mission & Vision of Molecule

### **What is Molecule?**

Molecule is the infrastructure for recording, tokenizing, funding, and accelerating scientific research via human and agent-driven science and blockchain technology. It does so through several technical and operational structures;

* A **modular Lab (our flagship product)** that acts as a sovereign, programmable container for research projects
* A **data layer** that securely captures and encrypts sensitive research data, while simultaneously creating an immutable and verifiable record of progress
* An **AI agent layer** where autonomous research agents operate within Labs to analyze data, generate hypotheses, and assist research teams
* A **legal layer** that ensures research projects can bridge from the onchain, web-based world into a fully compliant company-based structure
* A **community layer** consisting of over 30K+ biotech professionals, funders, founders, web3 builders, content creators, and more

These primitives create a framework for scientists to develop and commercialise critical healthcare discoveries, armed with the strength of blockchain technology, artificial intelligence, and an established "Decentralised Science" ecosystem.

### **Core problem**

Early-stage research projects, often the ones with the most transformative potential, struggle to raise capital because they don't fit neatly into the risk models of traditional funders. Meanwhile, we continue to see R\&D spending across the industry climbing while outcomes decline (*see Eroom's Law: "drug discovery is becoming slower and more expensive over time, despite improvements in technology"*). The problem is an inability to move science from concept to market at a cost and speed that makes sense, largely because researchers are still forced to work within legacy structures built for a different era.

Compounding this is the state of scientific IP itself. Much of it sits locked in analog formats, siloed within individual institutions, and gatekept by processes that make it difficult to license, share, or build upon. Discoveries that could accelerate other research, or attract funding on their own merits, instead sit dormant because there's no efficient way to represent, value, or transfer them.

When researchers and funders want to collaborate, the coordination layer between them is broken. What should be a straightforward relationship between the people funding research and the people conducting it instead becomes an exercise in administrative friction.

### Solution

Molecule exists to address these problems at the infrastructure level, replacing slow and gatekept processes with a system built for speed, transparency, and liquidity. The root of our solution begins with the Lab. Each Lab acts as a sovereign container with a persistent identity, where research outputs are recorded and managed, IP is registered and tokenized, and funding is raised transparently. This turns scientific research from illiquid institutional output into liquid, investable, and verifiable onchain assets with a continuous track record. Researchers can fund work directly, funders can access early-stage science, and ownership is shared through transparent, programmable structures.

At its core, Molecule provides primitives to:

* **Create a Lab** that unifies all research assets under one identity
* **Store research data** with encryption and granular access control
* **Tokenize** intellectual property
* **Raise capital** through transparent, community-driven funding
* **Deploy AI agents** that operate autonomously within Labs

### **The Three Pillars**

#### *(i) Modular Lab*

A sovereign smart contract wallet bound to an NFT. It holds assets, executes transactions, stores data references, and interacts with protocols - all under a persistent onchain identity. Its modular architecture (ERC-7579) allows new capabilities to be installed over time: licensing, governance, oracles, tokenization, and AI agent execution. The Lab is also a platform for tokenizing any research asset, and made available for trading and community ownership. Transfer the LabNFT and you transfer the entire project - treasury, data, IP, and history - in a single transaction.

#### *(ii) Data Infrastructure & API Access*

Every action within a Lab generates data. Datasets are stored on decentralized infrastructure with provenance tracking and encryption. Access is configurable (see *Roles & Permissions*). The Molecule API provides programmatic read and write access to Lab data rooms, transforming each Lab from a static wallet into a live data endpoint that agents and integrations can operate on continuously.

#### *(iii) AI Agents for Autonomous Science*

Labs are designed to be operated by agents, not only by people. Through the Labs API, an agent reads datasets from a Lab's data room, runs analysis, and writes findings back as versioned, content-addressed records carrying the same provenance and access controls as human-generated data. Agents authenticate with service tokens, or pay per call through the x402 Gateway. For agents that need to act inside a Lab's onchain context — treasury operations, permissions, protocol interactions — executor modules define the boundaries they can transact within, supporting both Human-Directed operation (agents assist, humans approve) and Fully Autonomous operation within onchain constraints. MIRA, Molecule's research assistant, sits on top of this layer, delivering ecosystem-wide research insights and standardized project scoring.

### **Who Uses It?**

Researchers create Labs, upload data, share files via role-based access, share updates, and raise funding — with or without AI assistance. AI agents operate within Labs as authorised modules, reading data, running analyses, and recording findings. Funders discover projects through our open discovery platform and onchain track records, and fund research through token purchases. Developers build and register modules that extend Lab capabilities.


# Why Desci?

#### 1. Enabling Novel Funding Models + New Asset Class

***Funding:*** Traditional grant systems are slow, competitive, and risk-averse. Programmable, decentralized funding mechanisms enable research to be supported earlier and more directly, without reliance on traditional bureaucratic processes. Crypto also enable for novel funding models not possible in traditional finance, such as quadratic funding, retroactive public goods funding, and continuous funding streams that allocate capital dynamically over time.

#### 2. Asset Ownership Across the Scientific Discovery Lifecycle

***Asset Class:*** Scientific discovery has historically been illiquid and inaccessible until late-stage commercialization, with value locked in private markets and ownership limited to a small set of investors. Crypto enables scientific outputs and early IP to function as a new asset class by making them representable and fundable across the discovery lifecycle. This allows broader participation and economic exposure to scientific progress at multiple stages—from early ideas and validation through development—rather than only at company formation or IPO.

#### 3. Liquidity and Price Discovery for Scientific Assets

***Liquidity*****:** In traditional research systems, scientific ideas and early discoveries are treated as R\&D expenses and remain illiquid within organizations, with no clear market-based valuation. Crypto enables these research outputs to become programmable assets that can be transferred and traded in open markets. This introduces secondary liquidity and price discovery, allowing scientific value to be surfaced earlier and giving contributors direct control over the assets they help create.

#### 4. Programmable and Transparent Scientific Outputs

***Modular & Verifiable Provenance:*** Scientific ideas and research outputs are often fragmented across institutions, poorly attributed, and difficult to track or reuse, leading to duplication and inefficient replication of work. Crypto enables these outputs to be represented digitally with verifiable provenance and programmable ownership, allowing contributions, peer review records, licensing terms, and value flows to be transparently recorded across platforms, improving attribution, reuse, and coordination across the scientific ecosystem.

#### 5. Incentives Aligned with Scientific Contribution and Outcomes

***Incentives:*** Crypto enables incentive structures that reward scientific contributions beyond publication, including data, validation, and experimental work. By allowing contributors to participate in the outcomes of the assets they help create, it aligns incentives toward openness, reproducibility, and cumulative progress rather than isolated or hoarded results.

#### 6. Supporting global, permissionless collaboration

***Collaboration:*** Scientific collaboration is frequently constrained by institutional affiliation, geography, and access to funding networks. Crypto provides a neutral coordination layer that allows individuals and groups to collaborate without centralized intermediaries. Participation is based on contribution rather than credentials, enabling researchers, funders, and contributors worldwide to coordinate resources, govern projects, and work together across organizational boundaries.

#### 7. Integration with AI Agents and Cloud Laboratories

***AI doing Science 24/7:*** In traditional research environments, integrating AI-driven discovery with automated experimentation is difficult due to fragmented systems, manual procurement, slow contracting, and the lack of a standardized way for software agents to trigger and pay for experiments.

Crypto provides a programmable coordination layer that allows AI agents to autonomously fund, trigger, and pay for experiments via onchain execution and APIs, while results are transparently recorded and attributed. This enables closed-loop discovery workflows in which hypothesis generation, experimentation, and data ingestion can operate continuously across systems.


# Scientists

This guide is for the early-stage biotech researcher or scientist-founder moving science toward a company, often because the work is unconventional or too early for a clean venture or grant fit.

### Creating a Lab

Setting one up takes an email address, with no wallet or prior crypto experience needed, and the first Lab is free to create. A researcher begins with an image, a title, and a short description, then fills the Lab with files, datasets, and collaborators over time. Because there is no financial barrier to entry, a Lab can begin accumulating a verifiable track record from the moment it exists.

### Two Ways to Work

Everything inside a Lab is available through Molecule's interface, where a researcher signs in by email, uploads files, invites collaborators, and manages funding without touching any code.

For a workflow that already runs on scripts or AI agents, the same Lab is reachable through a consumer credential, and files, updates, and project activity move through the same underlying data whichever pathway is used. An agent-native researcher can pull work into their own tools and push new findings back, keeping the Lab's public record current throughout.

Both pathways carry the same security and access rules, so working through the API never changes who can see what.

### Storing and Sharing Research

Files are encrypted before they reach Molecule's servers, and only named people can open them, giving a researcher the ease of a cloud drive with security a drive does not provide. Access is set per file, so raw data can be shared publicly to build credibility while premium datasets and sensitive pre-publication results stay gated until the researcher chooses to release them. The Lab carries a public reporting surface alongside the confidential workspace, and the owner decides what stays private and what becomes visible. Collaborators join as a contributor or viewer.

### Building a Record Worth Showing

Files and the meaningful actions around them are timestamped and presented as a clean, credible history, so where a cloud drive only stores files, a Lab turns ongoing work into a record a funder can rely on. That record carries direct commercial weight. A funder can perform due diligence by inspecting how much data a Lab has generated and how its funding was deployed, seeing at a glance the progress the work has made, which replaces the opaque and fragmented diligence process common in early-stage biotech with something auditable. Labs that show consistent progress and responsible treasury management build the kind of track record that attracts more funding and better collaborators, creating a flywheel effect.

### Raising Funds

Alongside conventional routes like venture capital and grants, a researcher can run a token sale directly from the Lab to fund the science. Funds raised flow into the Lab's treasury under the researcher's control, and the community that forms becomes the base that Coin-to-Company later has the option to convert into real ownership.

### MIRA, a Living Assessment

MIRA, the Lab's built-in research assistant, gives a continuously updating read on the project. It analyses the non-confidential data in the Lab, surfaces concrete suggestions for strengthening the work, and rates the project against the Technology Readiness Level scale adapted from NASA. That TRL rating also appears on the public project page for funders and updates automatically as new information enters the Lab, so it never goes stale. MIRA does not read confidential files, and it does not conduct research or make decisions on the researcher's behalf.

### From Funding to a Company

Once a funding community has formed around a project's token, Coin-to-Company gives token holders a documented, legally grounded route to equity in the company behind the science, moving through locking, verification, application, and, if approved, holding both token and equity. Equity issued this way qualifies for United States tax advantages, and long-term shareholders may exclude a portion of capital gains under established rules for qualifying small business stock.

### What Changes for the Researcher

Rather than waiting on a grant cycle or a venture fit that may not exist, a researcher creates up a Lab in minutes, then works through the interface or the API depending on how the research already runs. Work can be stored securely and shared selectively from the first day, and a token sale opens funding. MIRA keeps a living read on how the project is maturing, and if a funding community forms around the work, Coin-to-Company can carry it all the way to a real company.

{% embed url="<https://desci-codes.gitbook.io/desci.codes/templates/model-agreements>" %}

Refer to DeSci codes for more Legal and governance templates.


# Funders

Discover, evaluate, and invest in tokenized scientific research

### Discovering Labs&#x20;

The platform surfaces active Labs across research areas, each with a public reporting surface: a project description, funding history, and recent activity. A funder can browse by area of interest or search for a research topic directly. The reporting surface is the same one the researcher controls, so what appears there reflects real, timestamped work.

### Evaluating a Project with MIRA&#x20;

MIRA gives every project a Technology Readiness Level (TRL) rating, a maturity scale adapted from NASA, shown on the project page. The rating comes from MIRA's analysis of the project's non-confidential data, and it updates as new information enters the Lab, so it stays current rather than fixed at a single point in time. A funder can also ask MIRA questions about a project directly, its funding structure, or its recent activity. MIRA does not give investment advice and does not read a Lab's confidential files, only what the researcher has made public.&#x20;

### Supporting Projects Through a Token Sale&#x20;

When a Lab is ready to raise, it runs a token sale through the platform. This is the primary way a community forms around early research. A funder can take part in an active sale directly from the project page, which carries MIRA's assessment and the Lab's public reporting surface alongside it, so the decision to join a sale can be made from the same view as the evaluation.&#x20;

### From Funding to Real Equity

Coin-to-Company gives a Lab's funders a documented, legally grounded route from holding a token to holding equity in the company behind the science, moving through locking, verification, application and, if approved, holding both token and equity. Equity issued this way qualifies for United States tax advantages, and long-term shareholders may exclude a portion of capital gains under established rules for qualifying small business stock.&#x20;

### Understanding the Risk&#x20;

Early-stage science carries real uncertainty.&#x20;

Most research hypotheses do not pan out, and a promising result at one stage does not guarantee success at the next. The TRL rating reflects a project's current scientific progress, not the probability that it eventually succeeds, and a funder should treat it as a starting point for evaluation rather than a guarantee.

### &#x20;Getting Started&#x20;

A funder can browse Labs, read their public reporting surfaces, and check MIRA's assessment before committing anything. Joining a token sale happens directly from the project page when one is active, and larger commitments or partnership discussions can be directed to the Molecule team.


# Developers/AI Agents

Build on Molecule: Integrate Labs, extend the protocol, and deploy autonomous research agents

### Who This Guide Is For

You're a developer building on the Molecule ecosystem. You might be integrating Lab data into a front-end, writing a smart contract module that adds new capabilities to Labs, deploying an AI agent that operates on research data, or building a tool that queries ecosystem state for analytics or trading. This guide maps out the integration surfaces, explains what's available today versus what's on the roadmap, and shows you the fastest path to a working integration for each use case.

The reference pages (Contracts, Labs API, MCP Tools) contain the full API specifications, type definitions, and code examples. This guide is the narrative layer that explains when to use which tool, how the pieces connect, and what the architecture expects from you.

### The Integration Surface

Molecule exposes four primary integration layers, each serving different developer needs.

The Labs API is a GraphQL endpoint for reading and writing to Lab data rooms — the offchain encrypted storage where research files, announcements, and metadata live. This is the primary interface for applications that need to manage scientific data: uploading files, querying project activity, searching across Labs, and managing announcements. Authentication uses consumer credentials for reads and service tokens for writes. The full specification, including every query and mutation, is documented in the Labs API reference.

The Smart Contracts are the onchain layer. The V2 contracts (IPNFT, CrowdSale, SchmackoSwap) on Ethereum mainnet underpin the existing IP-NFT assets, token sales, and trading. The V3 contracts (OnChainLab, OnChainLabFactory, ERC7484Registry, OclTokenizer, and associated modules) are deployed on Base mainnet and Base Sepolia and introduce the modular account architecture plus Lab tokenization. Contract addresses, ABIs, and upgrade patterns are documented in the Contracts reference. The Architecture page provides the full implementation-level breakdown of how these contracts compose.

The MCP Server is a Model Context Protocol endpoint that lets AI assistants query Molecule ecosystem data in real time — IPT prices, project activity, categories, and summaries. It's the fastest way to give an LLM context about the Molecule ecosystem without building a custom integration. Setup takes one config file. The MCP Tools reference covers available tools, self-hosting, and programmatic integration against the MCP endpoint.

### Building a Front-End or Dashboard

If you're building an interface that displays Lab data, IPT markets, or research activity, your primary tools are the Molecule API, the Labs API.

Use the Molecule API for market and token data — IPTs with prices, project summaries, and activity feeds. For real-time onchain state that isn't indexed — for example, live token balances, allowances, or contract state — call the contracts directly via viem.

For data room interactions (showing a Lab's files, uploading research data on behalf of a user), use the Labs API. File uploads follow a three-step flow: call `initiateCreateOrUpdateFile` to get a presigned S3 URL, PUT the file to that URL, then call `finishCreateOrUpdateFile` with metadata. If the file needs encryption, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK and its encrypted form — and AES-256-GCM encrypt the file locally via Web Crypto before uploading, attaching the encryption metadata on finish. On download, `decryptDataKey` returns the unwrapped DEK after the backend re-verifies the file's access conditions against live onchain state. The [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) page walks through both flows end-to-end.

Authentication splits into two paths. Unauthenticated calls work for all read operations against public data — IPT listings, market data, project summaries. Authenticated calls require a Privy JWT token and wallet address, and are needed for any write operation or access to private data rooms.

### Extending Labs with Smart Contract Modules

The module system is Molecule's primary extensibility mechanism. If you want to add new capabilities to Labs — automated royalty distribution, governance voting, milestone-based fund release, AI agent execution boundaries, licensing logic, oracle integrations — you build a module.

The module architecture is documented in detail in the Module Registry section, including the security model, attestation flow, and installation process. Here, we focus on what you need to know to actually build one.

There are two module types. Executor modules initiate transactions from a Lab account — they call executeFromExecutor on the Lab to perform actions on its behalf. This is the right pattern when external logic needs to trigger Lab actions: distributing funds, releasing escrowed payments, executing agent workflows, or responding to oracle events. Fallback modules extend a Lab's interface by registering new function selectors, allowing the Lab to respond to calls it doesn't natively support. This is the right pattern when you want to add queryable state or new interaction surfaces to a Lab — governance interfaces, custom data accessors, or protocol-specific compatibility layers.

Your module is a standalone Solidity contract. It does not inherit from the Lab contract. It interacts with Labs through the defined execution interfaces. The development cycle looks like this: write your module contract, test it against the Lab's execution interface using Foundry (the poc-protocol-modular-onchain-labs repo provides test fixtures), deploy it to the target chain, and submit it for attestation through the Molecule team. Once attested in the ERC-7484 Registry, any Lab owner can install your module via a UserOperation through the ERC-4337 EntryPoint.

A critical detail: modules never run via delegatecall — the account dispatches module calls as regular external calls, so a module cannot touch the Lab's storage directly. Your executor triggers Lab actions through `executeFromExecutor`, and the attestation process reviews what those actions can do. Reentrancy and unauthorized asset access remain the key risks the review looks for.

The Base Sepolia deployment includes all the infrastructure you need for testing: the OnChainLabFactory at `0xd629FE2310b4309a212495F10A47f8436dcEfD90`, the ERC7484Registry at `0x1Ab5Ba4300613F7346835b2BE7E2D10Ce6125eF5`, and the full set of supporting contracts listed in the [Contracts reference](/references/contracts).

### Deploying an AI Research Agent

AI agents that operate on Lab data — reading files, running analyses, writing findings back — interact through the Labs API. The protocol treats agent outputs the same as any other data: versioned records with content identifiers, permanent onchain references, and configurable access control.

The simplest agent integration is read-only: query a Lab's data room for files, download them, perform analysis, and present results. This requires only a consumer credential. Querying `labWithDataRoomAndFiles` gives you the complete file list with download URLs, content types, and encryption metadata. For encrypted files, the agent calls `decryptDataKey` with its service token; the backend evaluates the file's onchain access conditions and, if satisfied, returns the plaintext DEK. Access commonly resolves through a [Viewer or Contributor role grant](/technical-deep-dive/roles-and-permissions) on the Lab — granted by the Lab owner with `isAgent = true` and a bounded `expiry` matching the agent's session-key lifetime.

A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token:

* **Long-lived service token** — mint one via the `generateServiceToken` mutation (wallet signature or Privy session), or contact the Molecule team. The token is a JWT tied to your wallet; write authorization is resolved from that wallet's onchain role on the target Lab.
* **Pay-per-call via the** [**x402 Gateway**](/api-reference/x402-gateway) — for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`.

The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` — the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history.

For agents that need to operate autonomously within an Onchain Lab's smart contract context — executing treasury operations, managing permissions, or interacting with DeFi protocols — the path is through executor modules. The agent's logic is deployed as a module contract, attested in the ERC-7484 Registry, and installed on the target Lab by its owner. At that point, the agent can call executeFromExecutor to perform Lab actions within whatever boundaries the module enforces (spending limits, allowed function selectors, time windows). The V3 whitepaper describes two operational modes for this: Human-Directed (agents assist, humans approve) and Fully Autonomous (agents operate independently within onchain constraints).

### Building with BioAgents

BioAgents is an open-source AI scientist framework for biological research, and it's the reference implementation for autonomous research agents on Molecule. If you're building a research agent, starting from BioAgents gives you a proven architecture and a set of specialized agents you can extend or replace.

The framework provides a modular agent architecture where each agent is an independent function that performs a specific research task. The Planning Agent creates research plans from user questions and available data. The Literature Agent searches scientific literature with inline citations via multiple backends (a built-in semantic search with LLM reranking, OpenScholar, or Edison). The Analysis Agent runs data analysis on uploaded datasets. The Hypothesis Agent synthesizes findings into testable hypotheses. The Reflection Agent reviews overall research progress and adjusts methodology. The Reply Agent generates user-facing responses with preserved citations.

BioAgents operates through two routes: /api/chat for conversational research questions with automatic literature search, and /api/deep-research for iterative hypothesis-driven investigation with multi-step planning and reflection cycles.

To integrate BioAgents with a Molecule Lab, you connect the agent to the Labs API. The agent reads datasets from the Lab's data room, processes them through its analysis pipeline, and writes findings back as new versioned files. The Labs API's semantic search (searchLabs) lets agents discover relevant data across the entire ecosystem, not just a single Lab. The three-step file upload flow lets agents write results back with full metadata — descriptions, tags, categories — making agent outputs discoverable and attributable.

The framework supports two authentication systems. JWT authentication for production deployments where your backend authenticates users and issues signed tokens. And x402 micropayments for pay-per-request access using USDC on Base, which is relevant for agents that serve external users or charge for analysis — see the [x402 Gateway](/api-reference/x402-gateway) reference for the full protocol and endpoint list. The codebase also includes a job queue system (BullMQ + Redis) for production deployments that need reliable background processing, horizontal scaling, and automatic retries.

BioAgents also includes a customizable knowledge base backed by a vector database with semantic search and Cohere reranking. You can load domain-specific documents (PDFs, Markdown, DOCX) that the Literature Agent will search alongside public scientific literature — useful for giving your agent proprietary context about a specific research domain.

The full setup guide, architecture diagrams, and agent implementation details are in the BioAgents repository at github.com/bio-xyz/BioAgents.

### Giving AI Assistants Molecule Context via MCP

If you want existing AI assistants (Claude, GPT, or any MCP-compatible client) to have real-time access to Molecule ecosystem data, the MCP server is the lowest-friction path. No custom code required — just a configuration entry pointing at the public endpoint.

The MCP server exposes five tools: querying available IPTs with market data, fetching project activity for a specific token, listing IPT categories, getting comprehensive project summaries, and retrieving historical OHLCV price data. These cover the most common questions an AI assistant needs to answer about the ecosystem.

For programmatic integration — embedding Molecule tools into your own AI application — connect an MCP client to the endpoint and pass its tools to your LLM call, so the model can query Molecule data as part of its reasoning process. Any MCP-compatible client works, including the Vercel AI SDK's MCP client. The MCP Tools reference includes the full setup, self-hosting instructions for private deployments, and caching configuration.

### Where to Start

Your starting point depends on what you're building.

If you're building a front-end or dashboard, start with the Molecule API. Follow the query examples in those references and you'll have IPT market data rendering in minutes; add the Labs API when you need data room contents.

If you're building a smart contract module, start with the poc-protocol-modular-onchain-labs repository. Clone it, run the Foundry tests to understand the execution model, then write your module against the test fixtures. Deploy to Sepolia for testing and contact the Molecule team for attestation when you're ready for production.

If you're building an AI research agent, start with BioAgents. Fork the repository, configure your LLM providers, and connect it to a Lab's data room via the Labs API. The /api/deep-research route gives you a working multi-agent research pipeline out of the box.

If you just want to give an AI assistant Molecule context, add the MCP server URL to your client's config and you're done in sixty seconds.

For consumer credentials, service tokens, attestation requests, or any integration support, reach out on the Molecule Discord.


# Architecture

How the protocol's smart contracts are structured, deployed, and composed at the implementation level

### Contract Topology

The protocol is implemented as a set of interconnected Solidity contracts deployed on Ethereum and EVM-compatible chains. At the center is the `OnChainLab` — the account implementation that unifies the standards described in the Molecule Labs section into a single deployable contract.

Surrounding it are the factory, registry, proxy, and validation contracts that handle deployment, upgradeability, and security gating. The diagram below shows how these components relate to one another.

<figure><img src="/files/T5AjQbTnZQejFSbKZuXI" alt=""><figcaption></figcaption></figure>

#### `OnChainLabFactory`

The singular entry point for Lab creation. It deploys the `OnChainLab` implementation at construction time (making itself the only address authorized to call `initialize()` on new accounts) and deploys the Beacon and Router for the upgradeable pattern. It exposes two creation paths: `createAccount` for binding an existing LabNFT to an account, and `mintAndCreateAccount` for minting the NFT and creating the bound account in a single atomic transaction.

#### `OnChainLab`

The account implementation. It inherits from `ERC4337Account`, `EIP712`, `ERC7739`, `IERC165`, `IERC6551Account`, `IERC6551Executable`, `IERC7579Account`, `ValidationManager`, `ERC7484RegistryAdapter`, and `IOnChainLab`. It exposes three overloaded `execute()` signatures (one for each standard) and routes all execution through a shared `ExecLib`. Each execution increments a public `state` counter that external contracts (such as marketplaces) can use to detect front-running during NFT sales. The `owner()` function dynamically calls `ownerOf()` on the bound LabNFT contract on every invocation — ownership is never cached or stored.

#### `ERC-6551 Registry`

Deploys Lab accounts as EIP-1167 minimal proxies via CREATE2. The token binding data (chain ID, token contract, token ID) is appended to the proxy's bytecode at deployment and read at runtime via `extcodecopy`. This data is immutable.

#### `OnChainLabRouter` and `OnChainLabBeacon`

The upgradeability layer. The Router is a delegatecall proxy that always reads the current implementation address from the Beacon (its beacon reference is immutable). The Beacon is an ownable contract; when the owner updates the implementation, all Lab accounts upgrade simultaneously.

#### `RootValidator`

The default validation module bound during account initialization. It resolves the current LabNFT owner and validates ERC-4337 UserOperation signatures against that address. The root validator is not registry-attested — it is fixed at initialization and permanent (the registry only attests executor and fallback modules).

#### `ERC-7484 Module Registry`

The attestation-based gating layer. Every executor and fallback module must be attested by a trusted Molecule attestor and meet the configured attestation threshold before it can be installed on any Lab account.

### **Proxy Delegation Chain**

Each Lab account is deployed as a layered proxy. Understanding the call path is essential for developers interacting with or extending the protocol.

<figure><img src="/files/Y75cXI2n3ueQ2MBMQb47" alt=""><figcaption></figcaption></figure>

Storage resides in the minimal proxy. Logic resides in the implementation. The Beacon owner can upgrade the implementation for all accounts in a single transaction, without requiring any per-account migration. The token binding data (chain ID, token contract, token ID) is embedded in the proxy's bytecode at offset `0x4d` and is read via `extcodecopy` — it is never stored in contract storage and cannot be modified after deployment.

### Lab Creation

The canonical path for creating a Lab is `mintAndCreateAccount`, which executes the entire flow in a single transaction.

<figure><img src="/files/AnLSjyo6cfjCvRJcDiqr" alt=""><figcaption></figcaption></figure>

The factory also supports `createAccount` for cases where the LabNFT already exists (e.g., minted through a separate flow). Both paths converge on the same internal `_createAccount` and `_initializeAccount` logic, ensuring consistent initialization regardless of entry point.

### Execution Paths

The account supports two execution paths: direct calls by the NFT owner, and UserOperations mediated by the ERC-4337 EntryPoint.

<figure><img src="/files/iDclELdvaij6l7PKrvpR" alt=""><figcaption></figcaption></figure>

Path B enables gasless transactions (via Paymasters) and offchain signature relay — neither of which requires the user to hold ETH or manage gas directly. (Batched execution is defined by the standard but not yet supported — only single-call execution modes are enabled.) The ERC-7579 `execute(ExecMode, bytes)` variant is restricted to EntryPoint-only access, ensuring modular execution is always mediated by ERC-4337 validation.

### Ownership Transfer

Ownership transfer is not a dedicated protocol function — it is an emergent property of the dynamic ownership resolution. When the LabNFT is transferred via standard ERC-721 `transferFrom`, the Lab account's `owner()` function immediately resolves to the new holder on the next call. No migration, no re-initialization, no key rotation.

<figure><img src="/files/kduauqmlIFcdoSnadRiq" alt=""><figcaption></figcaption></figure>

The `state` counter provides a mechanism for marketplace contracts to guard against front-running. A buyer can record the Lab's `state` value at the time of listing and verify it has not changed at the time of purchase — if the seller executed any transactions between listing and sale (e.g., draining assets), the `state` will have incremented.

### Module Installation

Modules extend Lab functionality without modifying the core account contract. Installation is gated by both the EntryPoint (requiring a valid UserOperation) and the ERC-7484 Module Registry (requiring attestation), forming a defense-in-depth model.

<figure><img src="/files/cnjWaUKnHV2YOO9LWfm4" alt=""><figcaption></figcaption></figure>

The account currently supports two module types: executors (which can call `executeFromExecutor` on the Lab) and fallback modules (which are routed via the `fallback()` function based on function selector). Both types are subject to registry attestation checks at installation time and at execution time. Hooks exist only as an unused interface definition — there is no hook storage or dispatch in the current contracts.

### Signature Verification

The account implements a two-tier signature validation strategy via `isValidSignature` (ERC-1271). It first attempts ERC-7739 validation, which uses nested EIP-712 typed data with chain-specific context for cross-chain replay protection. If ERC-7739 does not recognize the signature format, the account falls back to standard ECDSA recovery against the current NFT owner. This dual approach provides strong cross-chain security while maintaining backward compatibility with contracts that use simple signatures.


# Molecule Labs

The programmable onchain identity that owns, controls, and extends everything in a research project

<figure><img src="/files/oXk18pTEveIcncfBcNJB" alt=""><figcaption></figcaption></figure>

### What is a Lab?

A Lab is the core primitive of Molecule Protocol V3. It is an NFT (ERC-721) that is permanently bound to its own smart contract wallet (ERC-6551), enhanced with account abstraction (ERC-4337), and extensible through a modular plugin architecture (ERC-7579) secured by an onchain attestation registry (ERC-7484).

Together, these standards give each Lab its own persistent onchain identity, a fully functional wallet capable of holding any onchain asset, a frictionless user experience that abstracts away gas and key management, and the ability to gain new capabilities over time through installable modules.

When a user creates a Lab, they mint a LabNFT that automatically receives its own smart contract account. This account can own assets, sign transactions, and interact with other protocols — independent of whoever currently holds the LabNFT. Transferring the NFT transfers control of the account and everything inside it in a single transaction.

### The Standards

A Lab converges five Ethereum standards into a single primitive.

*ERC-721* makes the Lab a tradable, transferable NFT compatible with the entire NFT ecosystem — marketplaces, wallets, and any contract that understands the ERC-721 interface. The LabNFT is implemented on Solady's gas-optimized ERC-721 with sequential minting, behind a UUPS-upgradeable proxy.

*ERC-6551* gives the NFT its own smart contract wallet, known as a Token Bound Account. The wallet has no private key of its own — it derives its authority entirely from the current NFT owner. The account address is deterministic, computed from the chain ID, the NFT contract address, the token ID, and a salt. This means the address is known before deployment and is consistent across any chain where the ERC-6551 Registry is deployed.

*ERC-4337* introduces account abstraction, enabling gasless transactions through paymasters and a validation pipeline that decouples signature verification from execution. Users interact with Labs by signing intents offchain; the protocol handles gas, relay, and execution.

*ERC-7579* defines a modular account interface. Labs can install executor modules (which perform actions on behalf of the Lab) and fallback modules (which extend the Lab with new function selectors) without changing the core contract. This means Labs can gain new capabilities — licensing logic, governance, oracle integrations, automated royalty distribution — through modules developed by Molecule or third-party developers.

*ERC-7484* secures the module system through an onchain attestation registry. Before any module can be installed on a Lab, it must be attested by a trusted Molecule attestor. This prevents malicious or unaudited code from being installed while keeping the module ecosystem permissionless for development.

### Identity & Ownership

A Lab maintains two distinct concepts that are often conflated in traditional systems: a persistent identity and a transferable controller.

The Lab's *identity* is its Token Bound Account address. This address is permanent and deterministic — it is derived from the LabNFT's contract address and token ID at deploy time and never changes, regardless of who owns the Lab. Reputation, transaction history, data provenance, and onchain relationships all accrue to this address permanently.

The Lab's *controller* is the wallet currently holding the LabNFT. This can change through sales, transfers, or marketplace transactions. When it does, the new holder immediately gains full control of the Lab account — the account's `owner()` function dynamically resolves the current NFT holder on every call. No migration, no re-keying, no governance vote.

This separation is what makes Labs composable. A Lab can be sold on a marketplace and the buyer receives not just an NFT, but the entire project: its treasury, its data references, its IP-NFTs, its transaction history, and its onchain reputation. The identity persists; only the controller changes.

### What Labs Own

<table><thead><tr><th width="174.08203125">Asset Type</th><th>Description</th></tr></thead><tbody><tr><td>Tokens</td><td>Fungible tokens created from tokenizing the LabNFT</td></tr><tr><td>Treasury</td><td>ETH, stablecoins, or any ERC-20 tokens</td></tr><tr><td>Data Anchor</td><td>The Lab's data room DID, bound onchain to the Lab's identity via DID linking — the verifiable pointer to its offchain research data</td></tr><tr><td>Child Labs</td><td>Nested Labs for hierarchical research programs <em>(roadmap)</em></td></tr><tr><td>Licenses</td><td>License NFTs for time-bound IP access — held as ordinary NFTs; rentable ERC-4907 mechanics are roadmap</td></tr><tr><td>External Assets</td><td>Any ERC-20/721/1155 from the broader ecosystem</td></tr></tbody></table>

Because the Token Bound Account is a general-purpose smart contract wallet, a Lab can hold any asset that any Ethereum account can hold. The asset types listed above represent the assets that are semantically meaningful within the Molecule ecosystem, but the wallet is not limited to these.

### Module Registry

Labs extend functionality through installable modules, governed by the ERC-7579 modular account standard and secured by the ERC-7484 attestation registry.

Modules come in two types. *Executor modules* can initiate transactions from the Lab account — they call into the Lab to execute actions on its behalf. This is the mechanism by which third-party logic (automated royalty distribution, milestone-based fund release, governance voting) can operate on a Lab without requiring the owner to manually trigger each action. *Fallback modules* extend the Lab's interface by registering new function selectors, allowing the Lab to respond to function calls it does not natively support.

All modules must be attested by a trusted Molecule attestor in the ERC-7484 registry before they can be installed. This creates a permissionless development model with a security gate: anyone can build a module, but it must pass attestation before any Lab can use it. Module installation requires a valid UserOperation through the ERC-4337 EntryPoint, meaning only the Lab's owner can authorize the installation.

### Role-Based Access Control

The modular architecture enables Labs to support delegated permissions without transferring ownership. Lab owners can authorize scoped, expiring access — for example, allowing a collaborator to upload data to the data room, or an AI agent to decrypt specific files for the duration of a research session — without surrendering the LabNFT or treasury control.

Delegated access is enforced onchain by the `AccessResolver` contract, which implements a hierarchical role system per-lab: **Owner** (the LabNFT holder, resolved through the ERC-6551 TBA), **Contributor** (full data-room access, can manage Viewers), and **Viewer** (read-only). Each grant carries an optional expiry and an `isAgent` flag to distinguish AI-agent session keys from human collaborators. The core Lab contract itself still only recognises the NFT owner and the ERC-4337 EntryPoint — roles are layered on top and consulted by the GraphQL API, encryption layer, and UI.

See [Roles & Permissions](/technical-deep-dive/roles-and-permissions) for the capability matrix, grant semantics, and the onchain interface.

### Onchain Activity Log

Every transaction executed through a Lab creates a permanent, timestamped onchain record. Because all execution flows through the Token Bound Account, the Lab's transaction history forms a complete audit trail of its lifecycle.

<table><thead><tr><th width="219.4296875">Event</th><th>What's Recorded</th></tr></thead><tbody><tr><td>Data upload</td><td>Timestamp, uploader, data reference, version</td></tr><tr><td>Funding received</td><td>Source address, amount, token type</td></tr><tr><td>Treasury transaction</td><td>Recipient, amount, purpose</td></tr><tr><td>Role change</td><td>Address, role granted/revoked, timestamp</td></tr></tbody></table>

This creates a verifiable chain of custody for the entire research lifecycle. Reputation accrues to the Lab's permanent address, not to a CV, not to an institution. When collaborators or funders evaluate a project, the evidence is cryptographic and publicly auditable.

### Lab Tokenization

Labs will be able to affiliate with or mint a Lab Token to create an economic layer around the Lab's assets. The planned model allows Lab owners to select or mint an ERC-20 token, attach a fee router to direct revenue from the Lab's assets (IP licensing royalties, dataset access payments, trading fees, DeFi yield), enable staking for token holders to receive a share of fee flows, and optionally configure automatic token buybacks from revenue.


# Roles & Permissions

How delegated access to a Lab works — role hierarchy, capability matrix, expiry, agent flag, and the onchain resolver.

## Why Roles Exist

A Lab's NFT holder is its sole ultimate controller — transferring the LabNFT transfers the entire project. In practice, most research projects need to delegate day-to-day data-room work (uploading files, posting announcements, decrypting confidential research) to collaborators and AI agents without surrendering ownership.

The role system lets a Lab owner grant scoped, expiring access to specific wallets — human or agent — while keeping ownership, treasury control, and the ability to revoke access at any time. Invites via email are possible, meaning team members do not have to be web3-native to participate.

\
Roles are enforced onchain by the `AccessResolver` contract and honoured by every downstream system (GraphQL API, file encryption, UI) that checks them.

## Role Hierarchy

Every lab has three effective roles, ordered from most to least privileged.

<table><thead><tr><th width="170">Role</th><th width="200">How it's held</th><th>Scope</th></tr></thead><tbody><tr><td><strong>Owner</strong></td><td>Holder of the LabNFT, resolved through the Lab's ERC-6551 Token Bound Account (TBA). Safe multisigs holding the NFT are resolved recursively through their signers.</td><td>Full control; passes every permission check. Can transfer the LabNFT.</td></tr><tr><td><strong>Contributor</strong></td><td>Explicit onchain grant: <code>ROLE_CONTRIBUTOR = 2</code>.</td><td>Full data-room access, can grant/revoke Viewers. Cannot add other Contributors or transfer the NFT.</td></tr><tr><td><strong>Viewer</strong></td><td>Explicit onchain grant: <code>ROLE_VIEWER = 1</code>.</td><td>Read-only. Can decrypt confidential files and read data-room contents.</td></tr></tbody></table>

The `hasRole` check is hierarchical: a Contributor automatically passes Viewer checks, and the Owner passes every check.

## Capability Matrix

| Capability                               | Owner | Contributor | Viewer |
| ---------------------------------------- | :---: | :---------: | :----: |
| View public data-room files              |   ✓   |      ✓      |    ✓   |
| Decrypt confidential data-room files     |   ✓   |      ✓      |    ✓   |
| Upload / update / delete data-room files |   ✓   |      ✓      |        |
| Create announcements                     |   ✓   |      ✓      |        |
| Grant / revoke Viewer role               |   ✓   |      ✓      |        |
| Grant / revoke Contributor role          |   ✓   |             |        |
| Transfer the LabNFT                      |   ✓   |             |        |
| Authorize / install modules on the Lab   |   ✓   |             |        |

A Contributor cannot "downgrade" another Contributor to Viewer — downgrades are treated as an admin-level action and rejected unless the caller is the Lab owner (or the protocol admin, below). There is no "manage owners" function: Lab ownership changes only by transferring the LabNFT (or changing the signer set of a Safe that holds it).

> **Protocol admin.** In addition to per-lab owners, the `AccessResolver` contract owner — Molecule's protocol multisig — is a global role admin: it can grant and revoke roles on any lab and passes every `hasRole` check. This is the operational escape hatch for support and recovery flows.

## Grants: Expiry & Agent Flag

Each grant is an onchain record with three fields:

```solidity
struct RoleGrant {
    uint8  role;     // 0 = none, 1 = Viewer, 2 = Contributor
    uint64 expiry;   // 0 = permanent, >0 = unix timestamp
    bool   isAgent;  // true if the grantee is an AI agent
}
```

* **Expiry** — A non-zero `expiry` makes the grant auto-expire. Expired grants still exist in storage (so `getRole` returns them for UI purposes) but are inactive: `hasRole` returns `false` once `block.timestamp >= expiry`. Expired grantees must be re-granted to regain access.
* **`isAgent`** — Purely informational metadata. It does **not** change onchain authorization, but downstream systems (the members list, the data-room UI, the agent-auth flow) surface it to clearly distinguish AI-agent session keys from human team members.

A Lab owner granting access to an agent should set `isAgent = true` and a short `expiry` — typically the agent's session-key lifetime. When the session expires, the agent must request a new grant before it can continue to decrypt files or post announcements.

## How Invites Work in the App

In the Labs app, members can be invited by wallet address, ENS name, or **email**. Email and social (Google / X) sign-ins are backed by a Privy-provisioned embedded wallet, so invitees don't need to be web3-native — the role grant still lands on a wallet address under the hood. If the invited email already belongs to a registered account, the app grants the role onchain immediately (the transaction is gas-sponsored); if the email isn't registered yet, the invitee receives a sign-up email and can be invited again once they've joined.

## Scope: Per-Lab, Not Per-File

Roles are scoped to a **lab** — identified by the canonical `oclId` (a packed 32-byte identifier combining version, namespace, tokenId, and the TBA address). There is no per-data-room or per-file role; file-level access is enforced by the Onchain-Verified Envelope Encryption layer, which ultimately resolves back to the same `AccessResolver` predicates (`hasRole`, `isAuthorizedSignerForTba`, `isAuthorizedSignerForIpnft`) when evaluating a decryption request.

For the concrete `accessControlConditions` JSON that turns a role grant into file-level decryption rights, see [Worked Example: Encrypt for Owner OR Contributor OR Viewer](/technical-deep-dive/data/data-privacy-and-access#worked-example-encrypt-for-owner-or-contributor-or-viewer).

## Chain Scoping

The role system exists only on **Base** (the canonical chain) and **Base Sepolia** — the v3 `AccessResolver` deployments. The older Ethereum Mainnet and Sepolia deployments run the deprecated v2, which has signer predicates but no role functions at all. Lab-owner self-administration also works only on Base: the ERC-6551 reference implementation returns `address(0)` for `owner()` when `block.chainid` doesn't match the chain the OCL was CREATE2-salted for.

Every `grantRole` / `revokeRole` / `hasRole` / `getRole` call runs `_validateOclId`, which verifies the `oclId`'s version byte, namespace byte, TBA code, LabNFT binding, and canonical-chain metadata. Malformed identifiers revert with `InvalidOclId`.

## Onchain Interface

```solidity
function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bool isAgent) external;
function revokeRole(bytes32 oclId, address account) external;

function hasRole(bytes32 oclId, address account, uint8 role) external view returns (bool);
function getRole(bytes32 oclId, address account)
    external view returns (uint8 role, uint64 expiry, bool isAgent);
```

### Who may call what

| Action                                     | Owner | Active Contributor |
| ------------------------------------------ | :---: | :----------------: |
| `grantRole(… Contributor)`                 |   ✓   |                    |
| `grantRole(… Viewer)` (fresh / same level) |   ✓   |          ✓         |
| `grantRole(…)` that downgrades a role      |   ✓   |                    |
| `revokeRole(…)` on a Contributor           |   ✓   |                    |
| `revokeRole(…)` on a Viewer                |   ✓   |          ✓         |

Revokes on accounts with no stored grant (`role == 0`) return silently without emitting an event, to prevent unauthorised callers from spamming `RoleRevoked` logs. (Revoking an expired-but-present grant still requires authorization and emits.) The protocol multisig can additionally perform any grant or revoke on any lab.

### Events

```solidity
event RoleGranted(
    bytes32 indexed oclId,
    address indexed account,
    uint8   indexed role,
    uint64  expiry,
    bool    isAgent,
    address grantedBy
);

event RoleRevoked(
    bytes32 indexed oclId,
    address indexed account,
    uint8   indexed role,
    address revokedBy
);
```

Use these events to reconstruct the team-members list for a lab offchain; the onchain storage is a sparse `mapping(oclId => mapping(account => RoleGrant))` and cannot be enumerated without event indexing.

### Errors

* `InvalidOclId(bytes32 oclId)` — malformed identifier or LabNFT binding mismatch.
* `InvalidRole(uint8 role)` — role must be `ROLE_VIEWER (1)` or `ROLE_CONTRIBUTOR (2)`.
* `UnauthorizedRoleAdmin(bytes32 oclId, address caller, uint8 role)` — caller lacks permission for the requested grant/revoke.

## See Also

* [AccessResolver contract reference](/references/contracts/accessresolver) — full ABI, deployments, signer-authorization predicates (`isAuthorizedSignerForIpnft`, `isAuthorizedSignerForTba`).
* [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) — how role checks feed into file encryption / decryption.
* [Molecule Labs](/technical-deep-dive/onchain-lab) — how `oclId` is derived and why ownership resolves through the TBA.


# Module Registry

How Labs discover, install, and secure modular extensions through the ERC-7484 attestation registry

### What is the Module Registry?

The Module Registry is the onchain attestation system that governs which modules can be installed on Labs. It implements the ERC-7484 standard — a registry where trusted attestors vouch for module contracts before any Lab can use them. This creates a permissionless development model with a security gate: anyone can build a module, but it must be attested before it can be installed.

The Module Registry is a separate contract from the Labs themselves. It stores attestation records for every module in the ecosystem and exposes query functions that Labs call during module installation to verify that a module has been approved.

### Why Modules?

An Lab's core contract handles identity, ownership, validation, and execution. It does not contain application logic for fundraising, data management, licensing, governance, or any other domain-specific capability. These are provided by modules.

This separation matters because scientific research projects evolve. A Lab might start with basic data storage, later add fundraising capabilities to run a token sale, then install licensing logic when results are ready for commercialisation, and eventually add governance modules when the community needs decision-making tools. At no point does the Lab need to migrate to a new contract, re-register assets, or break its onchain history. The identity, treasury, data references, and reputation all persist — only the capabilities change.

The alternative — building every possible feature into the core Lab contract — would produce bloated, expensive deployments where every Lab pays gas for functionality it may never use. It would also mean that new capabilities require upgrading the core contract, affecting every Lab in the system simultaneously.

### Module Types

Labs support two module types, defined by the ERC-7579 modular account standard.

#### (i) Executor modules

Contracts that can initiate transactions *from* a Lab account. When installed, an executor gains the ability to call into the Lab and execute actions on its behalf. This is the mechanism for automated and third-party logic: a royalty distribution module can move funds from the Lab's treasury, a milestone module can release payments when conditions are met, an AI agent can execute research workflows within defined boundaries. Executors operate under the authority of the module installation — the Lab owner authorised the module's capabilities when they installed it.

#### (ii) Fallback modules

Extend a Lab's interface by registering new function selectors. When a call arrives at the Lab for a function the core contract doesn't recognise, the Lab routes it to whichever fallback module is registered for that selector. This enables Labs to respond to entirely new interfaces without modifying the core contract. A fallback module might add ERC-1155 receiver support, implement a custom governance voting interface, or expose data querying functions.

### The Attestation Model

Before a module can be installed on any Lab, it must be attested in the ERC-7484 Registry. An attestation is a signed onchain record created by a trusted attestor that vouches for a specific module contract.

Each attestation record contains the creation timestamp, an optional expiration time, the attester's address, the module type (executor or fallback), and an optional field for custom attestation data. Attestations can be revoked by the attester at any time, which records a revocation timestamp and immediately prevents new installations of that module.

Labs are configured with an attester policy: a list of trusted attestor addresses and a threshold specifying how many must have attested a module before it can be installed. In the current deployment, Molecule acts as the sole attestor, but the architecture supports multi-attestor configurations where, for example, two out of three independent auditors must approve a module.

This model separates *development* from *approval*. A third-party developer can write, test, and deploy a module contract without needing permission from anyone. But before any Lab can install it, the module must pass the attestation gate. This keeps the ecosystem open for innovation while protecting Labs from malicious or unaudited code.

### Module Installation

Installing a module is an onchain transaction that must be authorised by the Lab owner through the ERC-4337 EntryPoint. The installation flow proceeds as follows:

The Lab owner submits a UserOperation requesting module installation with the module's contract address, its type (executor or fallback), and initialisation data. The Lab's account contract calls the ERC-7484 Registry to verify that the module has a valid, non-expired, non-revoked attestation from the required attestors. If the attestation check passes, the module is registered in the Lab's internal storage: executor modules are recorded in the ExecutorManager (with a snapshot of the owner who installed them), and fallback modules are registered in the SelectorManager with a mapping from function selectors to the module address.

From that point forward, the module is active. Executor modules can call into the Lab, and calls to the fallback module's registered selectors are routed to it automatically.

Modules can also be removed: `uninstallModule` clears the executor or fallback registration (and calls the module's `onUninstall` handler), again authorised by the Lab owner through the EntryPoint.

### How Modules Work

When a call arrives at a Lab's smart account for a function the core contract doesn't define, the account looks up the selector in its **own SelectorManager storage** (the ERC-7484 Registry is consulted only for attestation, not for routing). If a fallback module is registered for that selector — and its attestation is still valid — the account forwards the call to the module as a regular external call with the original sender appended ERC-2771-style. Modules never run via `delegatecall`: they execute in their own storage context, and the Lab's assets move only through the account's explicit execution paths.

The two module types differ by direction:

**Fallback modules** extend the Lab's *inbound* interface. Calls to their registered selectors arrive through the ERC-4337 EntryPoint (the account's caller policy enforces EntryPoint-only dispatch), letting the Lab respond to interfaces the core contract doesn't natively implement — data-reference recording, custom query functions, receiver interfaces.

**Executor modules** drive *outbound* execution. An installed executor **contract** (EOAs cannot be executors) calls `executeFromExecutor` on the Lab to trigger actions on its behalf — automated distributions, scheduled operations, cross-protocol integrations. At execution time the account re-checks the executor's attestation and that the Lab's owner hasn't changed since installation.

### Module Installation and Security

Installing a module records it in the Lab's own module storage — selector→module mappings for fallback modules, an installed flag plus owner snapshot for executors. The Lab owner controls which modules are installed and can remove them via `uninstallModule`.

Because modules execute as external calls rather than `delegatecall`, they cannot touch the Lab's storage directly. The account increments its public `state` counter on module-routed calls and re-validates attestations at execution time, so a revoked module stops working immediately even if it remains installed.

### Security Model

The module system has several layers of security that work together.

#### Attestation gating

Ensures that only reviewed and approved modules can be installed. The ERC-7484 Registry is the first line of defence — a module that hasn't been attested, or whose attestation has expired or been revoked, cannot be installed on any Lab.

#### Owner-only installation

Ensures that only the Lab owner can decide which modules to install. Module installation requires a valid UserOperation through the ERC-4337 EntryPoint, authenticated against the current LabNFT owner.

#### EntryPoint execution gating

Ensures that critical execution paths (including module-initiated actions) flow through the ERC-4337 EntryPoint, which validates every operation before execution.

#### Beacon upgradeability

Provides a system-wide upgrade mechanism. All Lab accounts share a single implementation behind a beacon proxy. The beacon owner (a Molecule-controlled address) can upgrade the implementation, affecting all Labs simultaneously. This is distinct from module installation — it upgrades the core account logic, not the installed modules. Upgrade authority should be understood as a trust assumption of the current deployment.

### Example Modules

The module architecture is designed to support a growing ecosystem of capabilities. Examples of module categories include automated royalty distribution, milestone-based fund release, IP licensing and rental logic, governance and voting mechanisms, data storage and retrieval, oracle integrations for external data, cross-lab collaboration protocols, and AI agent execution boundaries.

Each of these would be deployed as an independent contract, attested in the ERC-7484 Registry, and installable by any Lab owner who needs that capability.


# Validator Module

How Labs authenticate and authorise operations through modular, NFT-ownership-derived signature verification

### Validator Module

A Validator Module determines who is authorised to act on behalf of a Lab. It is the first and most fundamental security gate in the system — without a functioning validator, a Lab cannot process any transaction.

Every time a transaction is submitted through the ERC-4337 EntryPoint, the Lab's account contract delegates the signature check to its installed validator. The validator resolves the current owner of the Lab NFT onchain, recovers the signer from the submitted signature, and compares the two. If they match, the operation proceeds. If not, it is rejected before any execution takes place.

The validator answers one question: who controls this Lab right now?

### The Validation Flow

When a Lab owner wants to perform any action — installing a module, sending assets, or executing a function — the operation follows a deterministic path through four components:

**1. UserOperation Submission** The owner constructs and signs an ERC-4337 `UserOperation`, then submits it to a Bundler, which forwards it to the global `EntryPoint` contract.

**2. Account Delegation** The EntryPoint calls `validateUserOp` on the Lab's account contract (`OnChainLab`). The account delegates to the `ValidationManager` with its fixed root validator (`DEFAULT_ROOT_VALIDATOR`, bound at initialization), which routes the call to that validator.

**3. Ownership Resolution** The validator calls `signer()` on the Lab account, which internally calls `owner()`. This function invokes the ERC-6551 `token()` method to retrieve the bound NFT's contract address and token ID, then queries `ownerOf(tokenId)` on the NFT contract. The result is the current owner's address — resolved live from onchain state, never cached.

**4. Signature Verification** The validator recovers the signer from the UserOperation's signature using ECDSA and compares it against the resolved owner address. The implementation checks two encoding formats for maximum wallet compatibility:

* **Raw hash recovery** — the `userOpHash` is used directly, as some ERC-4337 signing flows produce signatures over the raw hash.
* **EIP-191 prefixed recovery** — the hash is wrapped with the standard Ethereum signed message prefix (`\\x19Ethereum Signed Message:\\n32`), as wallet UIs like MetaMask and libraries like ethers.js commonly apply this prefix.

If either recovery produces a match, the operation is approved. If neither does, it is rejected.

This dual-check approach ensures Labs are accessible from the widest range of wallet software and signing libraries without requiring users to configure anything.

### The Root Validator

Every Lab is created with a Root Validator installed during the factory's initialisation process. This is not optional — it is the bootstrap mechanism that makes all subsequent operations possible. A Lab without a validator cannot sign any transaction, which means it cannot install modules through the normal flow (since module installation itself requires a valid signature).

The Root Validator ships with the protocol and implements a straightforward ownership model: the wallet that currently owns the Lab NFT is the sole authorised signer.

When the Root Validator is installed via `onInstall`, it receives the Lab's account address as the first 20 bytes of the calldata and stores a mapping between the calling smart account and the Lab contract reference. From that point on, every time the validator is asked to verify a signature, it looks up the Lab, resolves the current NFT owner through the `signer()` function, and checks the signature against that address.

This design has an important property: if the Lab NFT is transferred to a new wallet, control of the Lab transfers immediately and automatically. The new owner can sign transactions, install modules, and manage assets. The previous owner loses all authority the moment the NFT leaves their wallet. No migration, no key rotation, no administrative action is required.

### ERC-1271 Signature Verification

Beyond validating UserOperations, the Root Validator also implements ERC-1271 contract signature verification through the `isValidSignatureWithSender` function (the ERC-7579 variant of the standard `isValidSignature`).

This allows external contracts to ask the Lab whether a given signature was produced by its authorised controller. The validator performs the same dual ECDSA check — raw hash recovery, then EIP-191 prefixed recovery — and returns the ERC-1271 magic value (`0x1626ba7e`) on success or an invalid marker on failure.

This capability is critical for the Lab's composability within the broader Ethereum ecosystem. It enables the Lab to sign offchain messages, approve token permits, participate in governance votes, interact with marketplaces, and integrate with any protocol that supports ERC-1271 contract signatures — all while deriving authority from NFT ownership.

### Validator vs. Other Module Types

Validator modules occupy a unique position in the module architecture. Executor modules and fallback modules add capabilities — they extend what a Lab can do. Validators, by contrast, govern access — they decide who gets to do anything at all.

This distinction has practical consequences. A Lab's root validator is installed at creation time by the factory contract, before the Lab owner has signed any transactions. It cannot be installed through the normal module installation flow because that flow itself requires a valid signature, which requires a working validator. The root validator is the trust anchor that makes everything else possible.

The `ValidationManager`'s type system defines three validation types — `VALIDATION_TYPE_ROOT`, `VALIDATION_TYPE_VALIDATOR`, and `VALIDATION_TYPE_PERMISSION` — but only root validation is active today; the others are architected but not yet enabled.

### Why This Matters for Molecule Labs

The validator module design reflects the core principle of the Molecule Labs architecture: the Lab's identity is persistent, but its controller is transient. A research project's onchain history, reputation, treasury, and data belong to the Lab. The validator simply determines who holds the keys at any given moment.

This separation makes ownership transfers clean and complete. When a DAO acquires a Lab by purchasing its NFT, the validator immediately recognises the new owner. When a research team transitions leadership, they transfer the NFT and the new lead gains full control. The Lab's accumulated history, installed modules, and recorded data remain intact and uninterrupted throughout.

The type system leaves the door open to alternative authentication schemes in future versions. Note that in the current deployment the root validator is deliberately **permanent**: validators cannot be installed, replaced, or removed through the module-installation flow (the registry only attests executors and fallback modules), so any alternative scheme would ship via a core upgrade. Candidate future schemes include:

* **Multisig validators** requiring multiple signatures for high-value operations
* **Session key validators** granting temporary, scoped access to automated services or AI agents
* **Social recovery validators** allowing trusted parties to recover access if keys are lost
* **Threshold validators** requiring m-of-n approval from a defined set of signers

### Contract Reference

The Root Validator is deployed and verified on Base mainnet (chain ID 8453) and Base Sepolia:

<table><thead><tr><th width="182.12109375">Contract</th><th>Address (Base mainnet &#x26; Base Sepolia)</th></tr></thead><tbody><tr><td>RootValidator</td><td><code>0xb31d39ECc0cb26478E258C8f7e9C906115f494f6</code></td></tr></tbody></table>

Source code: `src/modules/validator/RootValidator.sol`

#### IValidator Interface

The Root Validator implements the `IValidator` interface from ERC-7579. Any custom validator module must implement the following functions:

* `validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash)` — Called by the account during ERC-4337 validation. Returns `0` for success, `1` for failure.
* `isValidSignatureWithSender(address sender, bytes32 hash, bytes calldata sig)` — ERC-1271 signature check. Returns `0x1626ba7e` on success.
* `onInstall(bytes calldata data)` — Called when the module is installed on an account. Used to initialise storage.
* `onUninstall(bytes calldata data)` — Called when the module is removed. Used to clean up storage.
* `isModuleType(uint256 typeId)` — Returns `true` for `MODULE_TYPE_VALIDATOR` (`1`).
* `isInitialized(address smartAccount)` — Returns whether the module has been initialised for a given account.


# Fallback Modules

Fallback modules extend Lab functionality by handling function calls that are not natively defined on the account contract, dispatched through the Solidity fallback function via selector-based

## Fallback Modules

Fallback modules extend the native functionality of a Lab by handling calls to function selectors that are not defined on the account contract itself. When a transaction targets a selector that the Lab does not recognize, the Solidity `fallback()` function intercepts it and routes the call to a registered external module based on a per-selector configuration managed by the `SelectorManager`.

This mechanism allows Labs to support new interfaces, respond to new protocol interactions, and integrate with external systems — all without modifying the core account logic.

### How It Works

Every Lab inherits from `SelectorManager`, which maintains a mapping from 4-byte function selectors to a `SelectorConfig` struct:

```solidity
struct SelectorConfig {
    address module;            // Fallback module contract address
    CallType callType;         // Execution mode: CALLTYPE_SINGLE
    CallerPolicy callerPolicy; // Who may invoke the selector: ENTRYPOINT_ONLY
}
```

When an external call arrives at the Lab with a selector that does not match any native function, the `fallback()` handler executes the following sequence:

1. **State increment** — The Lab increments an internal state counter to track executions and support anti-fraud mechanisms.
2. **Selector lookup** — The handler reads the `SelectorConfig` for `msg.sig` from the selector storage slot.
3. **Installation check** — If `config.module` is the zero address, the selector has no registered module and the call reverts with `InvalidSelector()`.
4. **Access control** — The `callerPolicy` is enforced. The only policy today is `ENTRYPOINT_ONLY`: unless `msg.sender` is the ERC-4337 EntryPoint, the call reverts with `InvalidCaller()`.
5. **Registry verification** — The module address is checked against the ERC-7484 Module Registry to confirm it is an attested, approved fallback module (`MODULE_TYPE_FALLBACK`).
6. **Dispatch** — The call is forwarded to the module as a `CALLTYPE_SINGLE` external call.

### Call Types

**CALLTYPE\_SINGLE (0x00)** — the only supported execution mode. The call is forwarded to the module contract as a standard external call using the ERC-2771 trusted forwarder pattern: the Lab appends the original `msg.sender` address to the calldata before calling the module, allowing the module to identify the true caller even though the call is proxied through the Lab. Modules maintain their own storage.

**CALLTYPE\_DELEGATECALL (0xFF)** exists as a constant in the type system but is **disallowed for fallback modules** — installation with it reverts `InvalidCallType`, and dispatch would revert `UnsupportedCallType`. This is a deliberate security decision (hardened during the 2026 audit): module code never runs inside the Lab's storage context.

### Installation

Fallback modules are installed through the `installModule` function on the Lab account, callable only via the EntryPoint (ensuring the operation has been validated). The installation requires:

* **Module type** — `MODULE_TYPE_FALLBACK` (type ID `3`)
* **Module address** — The address of the fallback module contract
* **Initialization data** — an ABI-encoded `InstallFallbackData` struct: `{ bytes4 selector; CallType callType; CallerPolicy callerPolicy; bool overwrite; bytes selectorData }`. The `callType` must be `CALLTYPE_SINGLE` and the `callerPolicy` must be `ENTRYPOINT_ONLY`; `selectorData` is passed through to the module's `onInstall`.

During installation, the system performs a registry check via ERC-7484 to verify the module is attested for the fallback type. The `SelectorManager` then stores the configuration and emits a `ModuleInstalled` event.

### Hooks

ERC-7579 defines optional pre/post-execution hooks around module calls. In the current contracts, hooks exist only as an unused `IHook` interface — `SelectorConfig` carries no hook field and there is no hook dispatch. Per-selector hooks are a possible future extension, not a present capability.

### Use Cases

Fallback modules enable Labs to support capabilities that are not part of the core account contract. Example use cases include:

* **Token receiving** — Implementing `onERC721Received`, `onERC1155Received`, or similar callback interfaces so the Lab can receive NFTs and multi-token transfers
* **Flash loan participation** — Implementing flash loan callback interfaces to allow Labs to act as flash loan receivers
* **Protocol integrations** — Supporting interaction interfaces required by external DeFi protocols, governance systems, or marketplace contracts
* **Custom query interfaces** — Exposing read-only view functions that aggregate or compute data from the Lab's state for external consumers

### Security Considerations

Fallback modules are security-critical because they can execute arbitrary logic in response to any unrecognized function call on the Lab. Several safeguards are built into the architecture:

The ERC-7484 Module Registry provides attestation-based trust. Every fallback module must be attested as type `MODULE_TYPE_FALLBACK` before it can be dispatched, ensuring only reviewed and approved modules can handle calls.

The `ENTRYPOINT_ONLY` caller policy ensures fallback selectors can only be invoked through the EntryPoint — meaning every call must pass UserOperation validation first. This prevents unauthorized contracts or EOAs from directly triggering fallback logic.

Because `delegatecall` dispatch is disallowed, fallback modules can never read or corrupt the Lab's storage directly — they interact with the Lab only through its explicit external interfaces.

The state counter increment on every fallback invocation provides an additional anti-replay and execution tracking mechanism.

### Contract Reference

| Contract              | Role                                                        | Source                         |
| --------------------- | ----------------------------------------------------------- | ------------------------------ |
| `SelectorManager.sol` | Manages per-selector fallback configuration and storage     | `src/core/SelectorManager.sol` |
| `OnChainLab.sol`      | Contains the `fallback()` handler and `installModule` logic | `src/OnChainLab.sol`           |
| `ExecLib.sol`         | Provides the `doFallback2771Call` dispatch helper           | `src/utils/ExecLib.sol`        |
| `Constants.sol`       | Defines module types, call types, and storage slots         | `src/types/Constants.sol`      |


# Executor Modules

Executor modules are external smart contracts that can trigger transactions from a Lab's account, enabling automation, cross-protocol integrations, and programmable actions without requiring

## Executor Modules

Executor modules are smart contracts that can initiate transactions on behalf of a Lab. Unlike validator modules (which verify who can act) and fallback modules (which add new interfaces to the Lab), executor modules operate from the outside — they call into the Lab's `executeFromExecutor` function to trigger actions using the Lab's account as the sender.

This is what makes Labs programmable. An executor module can swap tokens, claim rewards, distribute funds, record data, or interact with any external contract — all as the Lab — without requiring the Lab owner to sign a UserOperation for each action. The Lab owner only needs to approve the executor's installation; after that, the executor can act within its configured scope.

### How Execution Works

The execution flow for executor modules is the inverse of the normal account flow. In a standard transaction, the Lab owner signs a UserOperation that the EntryPoint validates and executes. With an executor module, the module itself initiates the action by calling the Lab directly.

The sequence works as follows:

1. **Trigger** — An external event triggers the executor module. This could be a keeper network detecting a condition, a protocol callback after a DeFi operation, a scheduled automation, or a direct call from an authorised party.
2. **Registry check** — The executor calls `executeFromExecutor` on the Lab's TBA, passing an encoded execution mode and calldata. The Lab verifies the caller (`msg.sender`) against the ERC-7484 Module Registry to confirm it is attested as `MODULE_TYPE_EXECUTOR`.
3. **Installation check** — The Lab looks up the executor's configuration in the `ExecutorManager` storage. If the executor is not installed (`installed == false`), the call reverts with `InvalidExecutor()`. The Lab additionally verifies that its current owner matches the owner recorded at installation time — if the LabNFT has changed hands since the executor was installed, the call reverts with `ExecutorOwnerMismatch()`, so a new owner must explicitly re-approve inherited executors.
4. **Execution** — The Lab delegates to `ExecLib.execute`, which decodes the execution mode and performs the transaction from the Lab's account. The Lab is the `msg.sender` for the resulting call, meaning the target contract sees the action as coming directly from the Lab.

```solidity
function executeFromExecutor(ExecMode execMode, bytes calldata executionCalldata)
    external
    payable
    returns (bytes[] memory returnData)
```

The function currently supports `CALLTYPE_SINGLE` execution — one target address, one value, one calldata payload per call. Batch execution (`CALLTYPE_BATCH`) is planned but not yet implemented.

### Installation

Executor modules are installed through the `installModule` function, callable only via the EntryPoint (meaning the installation itself must be a validated UserOperation signed by the Lab owner). The installation requires:

* **Module type** — `MODULE_TYPE_EXECUTOR` (type ID `2`)
* **Module address** — The executor contract address
* **Initialization data** — an ABI-encoded `InstallExecutorData` struct: `{ bytes executorData }`, passed through to the executor's `onInstall`

During installation, the system performs a registry check via ERC-7484 to verify the module is attested for the executor type. The `ExecutorManager` marks the executor as installed, records the current Lab owner, and calls `onInstall` on the executor contract with the provided initialization data, allowing the executor to set up any internal state it needs.

A `ModuleInstalled` event is emitted on successful installation.

### Executor Configuration

Unlike fallback modules (which map individual function selectors to module addresses), executor modules are registered by their contract address. The `ExecutorManager` maintains a mapping from executor addresses to their configuration:

```solidity
struct ExecutorConfig {
    bool installed;         // whether the executor is active
    address ownerAtInstall; // the Lab owner who approved the installation
}
```

The configuration is intentionally minimal: an installed flag, plus a snapshot of the owner who approved the executor. The snapshot powers the owner-change guard — executors installed by a previous owner stop working the moment the LabNFT transfers.

### Executors vs Other Module Types

Understanding when to use an executor versus a fallback module is important for module developers:

**Executor modules** are the right choice when an external contract or service needs to trigger an action *from* the Lab. The executor calls into the Lab, and the Lab performs the resulting transaction as itself. The flow is: external trigger → executor → Lab executes. Use cases include automated operations (keeper-triggered rebalancing, scheduled distributions), protocol callbacks (post-swap hooks, liquidation responses), and any scenario where the Lab needs to act without the owner being online.

**Fallback modules** are the right choice when the Lab needs to *respond* to calls that target function selectors it doesn't natively implement. The flow is: external caller → Lab's fallback → module handles. Use cases include implementing token receiver interfaces, supporting flash loan callbacks, and adding queryable view functions.

The key distinction is directionality: executors push actions through the Lab, while fallback modules handle actions arriving at the Lab.

### Use Cases

**Automated treasury management.** An executor module connected to a keeper network (such as Chainlink Automation or Gelato) can rebalance Lab holdings, harvest yield, or execute DCA strategies on a schedule or when market conditions are met.

**Cross-protocol integration.** When a DeFi protocol needs to call back into the Lab after an operation — for example, after a swap completes or a lending position is liquidated — an executor module can handle the callback and trigger the Lab's response.

**Scheduled distributions.** A vesting or milestone-based distribution executor can release tokens from the Lab's treasury on a predetermined schedule, executing transfers as the Lab without requiring manual intervention from the owner.

**Agent-driven operations.** AI agents or research automation services can be granted executor access to perform data operations, submit transactions, or interact with protocols on the Lab's behalf within defined boundaries.

### Security Considerations

Executor modules are high-trust components. An installed executor can trigger arbitrary transactions from the Lab's account, making it functionally equivalent to a co-signer for execution purposes. Several safeguards constrain this power:

The ERC-7484 Module Registry ensures only attested executor contracts can be installed. The registry check occurs both at installation time and at every execution, so even if an executor is compromised after installation, revoking its registry attestation will prevent further actions.

Installation requires a validated UserOperation through the EntryPoint, meaning the Lab owner must explicitly approve every executor. No executor can self-install.

Lab owners can revoke an executor's access at any time via `uninstallModule`, which clears its `ExecutorManager` registration and calls the executor's `onUninstall` handler.

### Contract Reference

| Contract              | Role                                                                        | Source                         |
| --------------------- | --------------------------------------------------------------------------- | ------------------------------ |
| `ExecutorManager.sol` | Manages executor installation and configuration storage                     | `src/core/ExecutorManager.sol` |
| `OnChainLab.sol`      | Contains `executeFromExecutor` and `installModule` for executor type        | `src/OnChainLab.sol`           |
| `ExecLib.sol`         | Provides `execute` helper that decodes execution mode and performs the call | `src/utils/ExecLib.sol`        |
| `IERC7579Modules.sol` | Defines the `IExecutor` interface (extends `IModule`)                       |                                |


# Data

How Labs store, protect, and control access to scientific research data

### **Data in Molecule Labs**

Every Lab is a data-centric primitive. When a researcher uploads a dataset, publishes results, or records experimental observations, that data becomes part of the Lab — held alongside its treasury and other assets. The Lab does not just *reference* data; its onchain identity is cryptographically bound to its data room, so the link between Lab and scientific content is itself verifiable.

### Onchain and Offchain

Scientific research data — datasets, images, lab notebooks, analysis scripts — is too large and too sensitive to store directly on a blockchain. Labs solve this with a hybrid architecture that separates *data storage* from *data control*.

The actual research files are stored offchain using a combination of decentralised storage networks that provide persistence, content-addressing, and provenance tracking. Every file version receives a content identifier (CID) — derived from the content itself, so tamper-evident — and is tracked in the data room's provenance log. What lives onchain is the **anchor**: the data room's decentralized identifier (DID) is cryptographically bound to the Lab's `oclId` in the DID registry, in a dual co-attested record anyone can verify. See [Data Anchoring (DID Linking)](/technical-deep-dive/data/data-module).

This design gives Labs the best of both worlds. The blockchain provides an immutable, publicly verifiable anchor for the Lab's data identity, while the data room's provenance log records what data exists, when it was uploaded, and by whom. The offchain layer provides the storage capacity, encryption, and performance that scientific data requires.

### The Data Stack

<figure><img src="/files/0lInqdZEhJZBmOeP5vO6" alt=""><figcaption></figcaption></figure>

The data layer is built from a set of specialised technologies, each handling a different responsibility in the pipeline.

<table><thead><tr><th width="228.42578125">Layer</th><th width="133.84765625">Technology</th><th>Role</th></tr></thead><tbody><tr><td>Upload Gateway</td><td>Filebase</td><td>S3-compatible upload interface. Generates pre-signed URLs for secure browser uploads and automatically pins files to IPFS.</td></tr><tr><td>Decentralised Storage</td><td>IPFS</td><td>Content-addressed storage. Every file receives a unique CID derived from its contents, enabling verifiable retrieval from any IPFS node.</td></tr><tr><td>Permanent Persistence</td><td>Arweave</td><td>Immutable, permanent storage. Ensures research data remains available indefinitely, independent of any single service provider.</td></tr><tr><td>Provenance &#x26; Versioning</td><td>Kamu</td><td>Tracks the complete history of every dataset: versions, transformations, metadata changes, and activity events. Provides a verifiable provenance chain from raw data to published result.</td></tr><tr><td>Encryption &#x26; Access Control</td><td>Onchain-Verified Envelope Encryption + AccessResolver</td><td>Per-file AES-256 DEK wrapped by a protocol-operated key custodian (BLS threshold operator network on roadmap). Access conditions live on Kamu (ODF) and are re-verified against live onchain state (<code>AccessResolver</code>) before the plaintext DEK is released.</td></tr></tbody></table>

These components form a layered pipeline: data is encrypted client-side with a per-file wrapped DEK, uploaded through Filebase, pinned to IPFS, persisted on Arweave, and versioned through Kamu — with the data room's identity anchored onchain to the Lab via [DID linking](/technical-deep-dive/data/data-module).

### Data Lifecycle

<figure><img src="/files/pxvMnR3fMIikb6v9cEYq" alt=""><figcaption></figcaption></figure>

A typical data flow through an Onchain Lab follows this sequence:

**Upload** — A researcher selects a file and chooses an access level. The backend issues a fresh wrapped data encryption key; the file is AES-256-GCM encrypted client-side before it leaves the browser. The wrapped DEK and the file's onchain access conditions are stored as encryption metadata on Kamu (ODF).

**Store** — The encrypted file is uploaded through Filebase, which pins it to IPFS (producing a CID) and persists it to Arweave for permanent availability. Kamu records the provenance metadata: who uploaded the file, when, the file's version, and its relationship to other datasets in the Lab.

**Anchor** — The file's CID and metadata are committed to the data room's provenance log, and the data room's DID is bound onchain to the Lab's `oclId` in the DID registry (this happens automatically at Lab creation, with dual co-attestation). Together these create a permanent, tamper-evident link between the Lab's onchain identity and its offchain data: the anchored DID identifies the data room, and the content-addressed log inside it proves what the data room contains.

**Access** — When someone requests a file, the backend re-verifies the stored access conditions against live chain state. If the requester meets the conditions (holds the right tokens, holds an active role grant on the Lab, holds a valid license), the protocol key custodian unwraps the DEK and returns it to the client. The file is retrieved from IPFS or Arweave and decrypted client-side.

**Audit** — Every data-room action — uploads, version updates, metadata and access-level changes — is captured in the data room's tamper-evident provenance log, while onchain events (Lab creation, DID links, role grants and revocations) record the control-plane history. Together they form a verifiable chain of custody for the entire research lifecycle.

### Verifiable Research Record

Because the Lab's data room is anchored to its onchain identity and every version inside it is content-addressed, the combination of the Lab's transaction history and its provenance log forms a comprehensive, verifiable record of its scientific activity.

<table><thead><tr><th width="277.28515625">Event</th><th>What is recorded</th></tr></thead><tbody><tr><td>Dataset upload</td><td>Timestamp, uploader address, CID, file version</td></tr><tr><td>Data access</td><td>Requester address, file accessed, timestamp</td></tr><tr><td>Version update</td><td>Previous CID, new CID, change metadata</td></tr><tr><td>Access policy change</td><td>File, old conditions, new conditions, timestamp</td></tr></tbody></table>

This record exists independently of any institution, journal, or platform. It is cryptographic, publicly auditable, and permanently tied to the Lab's identity. When collaborators evaluate a project, when funders assess progress, or when reviewers verify provenance, the evidence is onchain.

### Design Principles

**Confidentiality by default.** Data is encrypted before it leaves the researcher's browser. The protocol assumes scientific data is sensitive unless explicitly made public.

**Per-file granularity.** Access conditions are configured at the individual file level, not at the Lab level. A single Lab can contain public datasets, token-gated research files, and time-locked results simultaneously.

**Onchain access control.** Who can access data is determined by smart contract conditions, not by a centralised permission list. The blockchain (via `AccessResolver`) is the access control layer; the backend only releases the unwrapped DEK after those conditions are satisfied.

**Permanent availability.** Research data is persisted to Arweave, ensuring it remains retrievable regardless of whether any individual service continues operating.

**Provenance from origin.** Every dataset is versioned and tracked from the moment of upload. Kamu records the full lineage: when data was created, how it was transformed, and what it produced.


# Data Storage

How research files are stored, content-addressed, versioned, and permanently persisted across the decentralised storage stack

### How Storage Works

Every file uploaded to a Lab passes through a layered storage pipeline that encrypts, distributes, versions, and references data across multiple decentralised systems. The result is a file that is encrypted before it leaves the researcher's browser, pinned to a content-addressed network for retrieval, persisted permanently independent of any single provider, versioned with full provenance history, and referenced onchain in the Lab's Token Bound Account.

### Upload Flow

When a researcher uploads a file to a Lab, the following sequence occurs:

**Authenticate.** The researcher authenticates with their wallet (Privy JWT) or a service token, establishing a session with the Molecule API that permits uploads to the target Lab.

**Prepare.** The client computes a content hash checksum of the raw file. If the file is confidential (access level Token-Holder or Admin-Only), the client encrypts it client-side before upload, so the bytes that leave the browser are already ciphertext; Public files proceed unencrypted. Either way, the content hash and any encryption metadata travel with the file into the storage pipeline. The client-side encryption and key-management model is documented in [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access); this page covers what happens to the bytes once they enter the pipeline.

**Upload.** The client initiates the upload through the Molecule API, which reserves an upload slot and returns a pre-signed upload URL. The encrypted file is uploaded directly from the browser to the pre-signed URL with progress tracking.

**Commit.** Once the upload completes, Kamu fetches the file from staging, creates a new version record (recording the timestamp, content hash, author, and data room path), and commits the file to IPFS via the pinning service. IPFS returns a content identifier (CID) derived from the file's contents. The file is also persisted to Arweave for permanent availability. The temporary staging file is deleted.

**Reference.** The CID and associated metadata are written onchain to the Lab's Token Bound Account. This creates a tamper-evident, publicly auditable link between the Lab's onchain identity and the offchain file. The transaction is timestamped and signed, becoming part of the Lab's activity log.

**Record.** Kamu stores the full file record — including the file DID, the Lab identifier, encryption metadata, access level, content hash, and version information — in its provenance database. The Molecule API stores a corresponding application record linking the file to the Lab's data room.

### Content Addressing

Every file stored through the pipeline receives a content identifier (CID) — a cryptographic hash derived from the file's contents using IPFS's multihash format. The CID serves as both the file's address and its integrity proof: requesting a CID from any IPFS node guarantees that the returned content is exactly what was originally stored. If even a single byte of the underlying file changes, the CID changes, and the onchain reference in the Lab's token bound account becomes a mismatch — making tampering immediately detectable.

Because CIDs are deterministic, the same file uploaded by different researchers at different times will always produce the same identifier. This property enables deduplication across the network and allows independent verification of data integrity without trusting any specific storage provider.

### Versioning and Provenance

Kamu maintains a complete, append-only history of every dataset in every Lab. When a file is uploaded, updated, or modified, Kamu creates a new version record that preserves the previous version's CID alongside the new one. No version is ever overwritten or deleted — the full lineage is permanently retrievable.

Each version record includes the content hash, the timestamp, the author's decentralised identifier (DID) linked to their wallet address, the data room path, and a reference to the previous version. This creates a verifiable provenance chain from the current state of any dataset back to its original upload. When a collaborator, funder, or reviewer needs to verify when data was created, who created it, or how it evolved over time, the evidence is in Kamu's version graph.

Kamu also records activity events — file access, metadata changes, announcements, and other Lab actions — providing a broader context for the dataset's history beyond just version changes.

### Permanent Persistence

IPFS provides content-addressed retrieval, but it does not guarantee permanent availability on its own. If every node that pins a file goes offline, the file becomes unreachable — the CID still exists as an address, but nothing answers the request.

Molecule Labs solve this by persisting files to Arweave in addition to IPFS. Arweave is a permanent, pay-once storage network — once a file is written, it remains available indefinitely regardless of whether any individual node or service continues operating. This dual-storage approach means files are retrievable from IPFS for fast, everyday access, and backed by Arweave for permanent, censorship-resistant availability.

Even if a file record is removed from a Lab's data room index, the underlying content persists on both IPFS (as long as it remains pinned) and Arweave (permanently). Because confidential files are encrypted before upload, this persistence does not compromise confidentiality — the ciphertext is publicly retrievable but unreadable without a key release, which is gated by onchain access checks (see [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access)).

### E2E Upload Flow

<figure><img src="/files/fW0oYjyyJVmJDeHF9l00" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/fb46PpmVdPPoPLWeK3UW" alt=""><figcaption></figcaption></figure>

### Storage Summary

| Data Type                  | Where Stored                                    | Managed By                           | Purpose                                                                                                                                                              |
| -------------------------- | ----------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Research files (encrypted) | IPFS + Arweave                                  | Filebase (upload), Kamu (versioning) | Decentralised, permanent, content-addressed                                                                                                                          |
| Onchain data references    | Lab's Token Bound Account                       | Smart contract                       | Tamper-evident CID pointers and metadata                                                                                                                             |
| tokenURI pointer           | Onchain (Lab TBA)                               | Lab smart contract                   | Permanent reference to the LabNFT's display metadata                                                                                                                 |
| File versions              | Kamu provenance DB                              | Kamu                                 | Append-only version history and audit trail                                                                                                                          |
| Encryption metadata        | In the file's encryption metadata on Kamu (ODF) | Onchain-Verified Envelope Encryption | Wrapped per-file DEK + access conditions; key custody and decryption rules are covered in [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) |
| File provenance            | Kamu provenance DB                              | Kamu                                 | DID-based authorship, timestamps, lineage                                                                                                                            |
| Activity events            | Kamu provenance DB + onchain                    | Kamu + Lab TBA                       | Access logs, metadata changes, announcements                                                                                                                         |


# Data Privacy & Access

How Labs protect confidential research data through client-side encryption, onchain access verification, and a condition-gated key-release flow.

### Why Privacy Matters

Scientific research data is often commercially sensitive, personally identifiable, or competitively valuable. Releasing raw experimental results, proprietary compounds, or patient-derived datasets without control can compromise patent applications, regulatory submissions, and competitive advantage. At the same time, the transparency benefits of onchain science — provenance, reproducibility, collaboration — require that data *exists* in a verifiable, shared infrastructure.

Labs resolve this tension by encrypting data before it enters the public infrastructure. The blockchain records *that* data exists, *who* uploaded it, and *who* can access it — but never the data itself. The underlying content is encrypted client-side, stored as ciphertext, and only decrypted inside an authorised client after access conditions have been verified against live onchain state.

### Onchain-Verified Envelope Encryption

Molecule uses **Onchain-Verified Envelope Encryption** for every confidential file in an Onchain Lab.

* **Client-side encryption.** Files are AES-256-GCM encrypted inside the client (browser or AI agent) before they leave the device, using a fresh per-file Data Encryption Key (DEK).
* **Decentralised storage.** Only ciphertext ever leaves the client; access conditions and encryption metadata are stored alongside the file's provenance record. How that content is addressed, persisted (IPFS + Arweave), and versioned is covered in [Data Storage](/technical-deep-dive/data/data-storage).
* **Onchain verification.** Every decryption is gated by a live onchain check: the stored access conditions are re-evaluated against current chain state (`AccessResolver` role checks; `IPNFT.canRead` for legacy files) before the DEK is released. There is no cached permission list.
* **Evolving key custody.** The DEK is wrapped by a protocol-operated key custodian today. Custody moves to a BLS threshold operator network (roadmap) without changes to clients, stored metadata, or the onchain interface.

Files marked as Public skip encryption entirely. The researcher explicitly chooses to make this data openly accessible. Public files still benefit from content addressing, versioning, and provenance tracking, but they carry no confidentiality guarantees by design.

### Upload Flow

```
1. Client → AppSync: generateDataEncryptionKey()
2. Backend: authenticate caller (Privy JWT or service token + role check)
3. Backend: issue a fresh per-file DEK →
            returns { plaintextDEK (one-shot), encryptedDek, encryptionSystem }
4. Backend: zero its copy of the plaintextDEK after the response is built
5. Client: AES-256-GCM encrypt(file, plaintextDEK) via SubtleCrypto
6. Client → AppSync: initiateCreateOrUpdateFile(oclId, contentType, contentLength)
                     → returns presigned upload URL + uploadToken
7. Client: PUT ciphertext to presigned S3 URL
8. Client: build accessControlConditions (EvmContractCondition array)
9. Client → AppSync: finishCreateOrUpdateFile(oclId, uploadToken,
                     encryptionMetadata: { encryptionSystem, encryptedDek,
                                           iv, contentHash, accessControlConditions,
                                           encryptedBy, encryptedAt })
10. Client: wipe plaintextDEK from memory
```

The client opts in to encryption by requesting a DEK via `generateDataEncryptionKey`; unencrypted uploads simply skip that step. The backend decides which encryption system to use and returns it in `encryptionSystem` — clients must echo this value verbatim, never hardcode it. This keeps the roadmap upgrade to BLS threshold key custody transparent to existing integrations.

### Access Conditions

Who may decrypt a file is determined by onchain conditions, not by a centralised permission list. When a file is uploaded, the client attaches an `accessControlConditions` array to the encryption metadata, stored on Kamu (ODF) alongside the file's provenance record. Conditions are **stored but not evaluated** at encrypt time — they're evaluated at decrypt time against live chain state.

Conditions resolve through the [`AccessResolver`](/references/contracts/accessresolver) contract, which exposes three principal predicates:

* **Public** — no condition; anyone can decrypt.
* **Token-Holder** — `isAuthorizedSignerForIpnft(signer, ipnftId)` / `isAuthorizedSignerForTba(signer, account)`: passes for IP-NFT holders and any authorized signer resolved recursively through Safe multisigs, Ownable contracts, and ERC-6551 TBAs.
* **Role-gated** — `hasRole(oclId, signer, ROLE_VIEWER | ROLE_CONTRIBUTOR)`: passes for accounts with an active (non-expired) role grant for the lab. See [Roles & Permissions](/technical-deep-dive/roles-and-permissions) for the full role model and grant lifecycle.

Conditions compose via boolean operators — a Lab can, for example, require both Contributor role AND a license NFT before granting access. Future releases will introduce additional composable conditions: credential-gating by minimum IPT holdings, access-list gating by specific wallet addresses, payment-gated unlocks, license-gated access via time-bound license NFTs (ERC-4907), and time-locked conditions that auto-release data at a specified date or block.

#### Condition Shape

Each entry in `accessControlConditions` is one of three TypeScript shapes — `EvmContractCondition` for arbitrary `view` calls, `EvmBasicCondition` for standard ERC reads, and `BooleanCondition` as a separator between predicates:

```ts
interface EvmContractCondition {
  conditionType: "evmContract";
  contractAddress: string;
  chain: string;
  functionName: string;
  functionParams: string[];
  functionAbi: {
    name: string;
    inputs:  Array<{ name: string; type: string; internalType?: string }>;
    outputs: Array<{ name: string; type: string; internalType?: string }>;
    stateMutability: string;
    type: string;
  };
  returnValueTest: { key: string; comparator: string; value: string };
}

interface BooleanCondition {
  operator: "and" | "or";
}
```

The placeholder `:userAddress` inside `functionParams` is substituted with the authenticated caller's wallet at evaluate time. For boolean predicates (`hasRole`, `isAuthorizedSigner*`) the `returnValueTest` is the literal `{ key: "", comparator: "=", value: "true" }`. The full array is JSON-stringified into [`encryptionMetadata.accessControlConditions`](/api-reference/labs-api/files) — typed as `String!` in the GraphQL schema and parsed back into an array on the backend.

#### Worked Example: Encrypt for Owner OR Contributor OR Viewer

To encrypt a file so the LabNFT owner, any active Contributor, and any active Viewer can all decrypt it, target the `AccessResolver` deployment on the chain whose RPC the backend evaluator uses. Substitute `<accessresolver-address>` below with the right deployment for that chain — see the [deployments table](/references/contracts/accessresolver) — and use `"chain": "base"` (canonical), `"ethereum"`, or `"sepolia"` to match.

`hasRole` already collapses the role hierarchy on the canonical chain — the LabNFT owner passes the admin path inside the contract, a Contributor passes because `ROLE_CONTRIBUTOR ≥ ROLE_VIEWER`, and a Viewer passes directly. So when conditions are evaluated against Base a **single condition** is enough:

```json
[
  {
    "conditionType": "evmContract",
    "contractAddress": "<accessresolver-address>",
    "chain": "base",
    "functionName": "hasRole",
    "functionParams": [
      "0x0101<20hex-tokenId><40hex-tba>",
      ":userAddress",
      "1"
    ],
    "functionAbi": {
      "name": "hasRole",
      "inputs": [
        { "name": "oclId",   "type": "bytes32" },
        { "name": "account", "type": "address" },
        { "name": "role",    "type": "uint8"   }
      ],
      "outputs": [{ "name": "", "type": "bool" }],
      "stateMutability": "view",
      "type": "function"
    },
    "returnValueTest": { "key": "", "comparator": "=", "value": "true" }
  }
]
```

The first `functionParams` entry is the lab's `oclId` — a packed `bytes32` of `0x01` (version) ‖ `0x01` (EVM namespace) ‖ 10-byte big-endian `tokenId` ‖ 20-byte TBA address. See [Onchain Lab](/technical-deep-dive/onchain-lab) for how this identifier is derived. The third entry, `"1"`, is `ROLE_VIEWER` — Contributor and Owner pass the same check thanks to hierarchy.

The **explicit OR-composite form** is recommended as the cross-chain-safe default. The contract's owner-check (`_isLabOwner`) returns `false` off the canonical chain (Mainnet / Sepolia) because the OCL TBA's `owner()` returns `address(0)` there, so the role-only condition above will not cover the LabNFT owner if conditions are ever evaluated against a non-Base RPC. OR'ing in `isAuthorizedSignerForTba` keeps the Owner branch explicit:

```json
[
  {
    "conditionType": "evmContract",
    "contractAddress": "<accessresolver-address>",
    "chain": "base",
    "functionName": "isAuthorizedSignerForTba",
    "functionParams": [":userAddress", "0x<40hex-tba>"],
    "functionAbi": {
      "name": "isAuthorizedSignerForTba",
      "inputs": [
        { "name": "signer",  "type": "address" },
        { "name": "account", "type": "address" }
      ],
      "outputs": [{ "name": "", "type": "bool" }],
      "stateMutability": "view",
      "type": "function"
    },
    "returnValueTest": { "key": "", "comparator": "=", "value": "true" }
  },
  { "operator": "or" },
  {
    "conditionType": "evmContract",
    "contractAddress": "<accessresolver-address>",
    "chain": "base",
    "functionName": "hasRole",
    "functionParams": [
      "0x0101<20hex-tokenId><40hex-tba>",
      ":userAddress",
      "1"
    ],
    "functionAbi": {
      "name": "hasRole",
      "inputs": [
        { "name": "oclId",   "type": "bytes32" },
        { "name": "account", "type": "address" },
        { "name": "role",    "type": "uint8"   }
      ],
      "outputs": [{ "name": "", "type": "bool" }],
      "stateMutability": "view",
      "type": "function"
    },
    "returnValueTest": { "key": "", "comparator": "=", "value": "true" }
  }
]
```

The TBA address (`<40hex-tba>`) is the lower 20 bytes of `oclId`; `tokenId` is 10 bytes big-endian sitting between the version/namespace prefix and the TBA. Substitute the AccessResolver address from the [deployments table](/references/contracts/accessresolver) when targeting a non-Base chain. To restrict access to Contributors-and-up only (excluding Viewers), pass `"2"` (`ROLE_CONTRIBUTOR`) instead of `"1"` for the role parameter.

#### How Conditions Are Evaluated

At decrypt time the backend walks the array left-to-right: each `EvmContractCondition` is dispatched as a viem `readContract` call against the configured RPC, the result is compared to `returnValueTest`, and `BooleanCondition` separators short-circuit the chain (`and` stops at the first false, `or` stops at the first true). Any RPC error fails closed — the DEK is not released. A `hasRole` call whose `oclId` does not match the lab's canonical binding reverts with `InvalidOclId` inside the contract and is treated as "condition not met".

### Decryption Flow

Decryption is **condition-authoritative**: the backend reads the stored conditions from their immutable source, verifies them against live chain state, and only then releases the plaintext DEK. A compromised client cannot substitute weaker conditions.

```
1. Client → AppSync: decryptDataKey(oclId, filePath | tokenUri + agreementUrl)
2. Backend: authenticate caller (Privy JWT or service token)
3. Backend: fetch stored encryptionMetadata
            • filePath → Kamu (ODF): file's accessControlConditions + encryptedDek
            • tokenUri → IPFS: IPNFT JSON → matching agreement's encryption block
4. Backend: evaluate accessControlConditions against live chain state (EVM RPC)
5. Backend: unwrap the DEK via the protocol key custodian → plaintextDEK
6. Backend: zero plaintextDEK buffer after the response is built
7. Client: download ciphertext from S3 (data room) or IPFS (agreement)
8. Client: AES-256-GCM decrypt(ciphertext, plaintextDEK, iv) via SubtleCrypto
9. Client: wipe plaintextDEK from memory
```

The GraphQL interface:

```graphql
mutation {
  decryptDataKey(
    oclId: "0x0101000000000000000000000000000000000000000000000000000000000042"
    filePath: "raw/experiment-01.csv"   # OR tokenUri + agreementUrl for IPFS agreements
  ) {
    plaintextDEK   # base64, client-only, wipe after use
    iv             # base64 AES-GCM IV from stored metadata
    message        # mirrors error.message on failure
    error { code message requestId retryable details }   # null on success
  }
}
```

For IPFS-pinned agreement files (immutable once minted), the client passes `tokenUri` (the IPNFT's onchain `tokenURI`) and `agreementUrl` — the backend fetches the IPNFT JSON, locates the matching agreement in `properties.agreements[]`, and extracts its encryption block. Because the `tokenURI` is onchain, conditions cannot be tampered with after minting.

### Agentic Encryption

AI agents encrypt and decrypt lab files through the same GraphQL interface, using a service-token auth path instead of a user Privy JWT:

* **Auth** — The agent authenticates with an `X-Service-Token` JWT. For short-lived access, the [x402 Gateway](/api-reference/x402-gateway) mints a per-request token scoped to one mutation after verifying a USDC payment. For long-lived agents, the Molecule team provisions a service token tied to a wallet and an `allowedMutations` list.
* **Role grant** — The Lab owner grants the agent's wallet a Contributor (or Viewer) role via `AccessResolver.grantRole` with `isAgent = true` and a bounded `expiry`. The `isAgent` flag is surfaced in the team-members UI so agent session keys are clearly distinguished from human collaborators.
* **Encrypt** — The agent calls `generateDataEncryptionKey`, receives a plaintext DEK, encrypts the file locally (Node.js `crypto` / Web Crypto), uploads the ciphertext via `initiateCreateOrUpdateFile` → PUT, then calls `finishCreateOrUpdateFile` with the encryption metadata.
* **Decrypt** — The agent calls `decryptDataKey(oclId, filePath)`. The backend evaluates the stored conditions against live chain state; a valid Viewer/Contributor grant satisfies the `hasRole` predicate. The backend returns the plaintext DEK over TLS; the agent decrypts locally.
* **Expiry** — When the role grant expires (`block.timestamp >= expiry`), `hasRole` returns `false` and `decryptDataKey` stops returning a key: the result comes back with `plaintextDEK: null` and a non-null in-band `error` (typically `code` `UNAUTHORIZED` — the wallet no longer holds a qualifying role) instead; branch on `error.code` as described in the Labs API [Error Handling](/api-reference/labs-api#error-handling) section. The agent must request a fresh grant — typically from an owner-controlled orchestrator — before it can continue.

See the [Developers / AI Agents guide](/user-guides/developers-ai-agents) for end-to-end agent integration patterns and the [MCP Tools reference](/references/mcp-tools) for the read-side agent toolset.

### Privacy Summary

The net result of this architecture is that no single party has unilateral access to confidential research data. The file content is encrypted before it leaves the client, transmitted as ciphertext, stored as ciphertext across the decentralised storage stack, and only ever decrypted inside an authorised client after access conditions have been re-verified against live onchain state. Every action against the data — uploads, version changes, access events — is recorded with the author's decentralised identifier, creating a tamper-evident provenance trail (see [Data Storage](/technical-deep-dive/data/data-storage) for the persistence and provenance layer).

<table><thead><tr><th width="177.015625">Layer</th><th>Protection</th><th>Mechanism</th></tr></thead><tbody><tr><td>At rest</td><td>File content encrypted before leaving client</td><td>Client-side AES-256-GCM with a per-file wrapped DEK</td></tr><tr><td>In transit</td><td>All communications over HTTPS; payload is ciphertext</td><td>TLS + pre-encryption</td></tr><tr><td>Key storage</td><td>Plaintext DEK is never persisted; the wrapped DEK is useless without the custodian</td><td>Protocol-operated key custodian today; BLS threshold operator network on roadmap</td></tr><tr><td>Access control</td><td>Decryption gated by a live onchain verification of stored conditions</td><td><code>AccessResolver</code> (<code>hasRole</code>, <code>isAuthorizedSigner*</code>); <code>IPNFT.canRead</code> for legacy files</td></tr><tr><td>During decryption</td><td>Plaintext DEK only exists inside the authorised client for the session</td><td>Client-side key assembly and decryption; backend zeroes its copy</td></tr><tr><td>Provenance</td><td>Every file action tracked with author's DID</td><td>Recorded in Kamu (ODF) — see <a href="/pages/RiUIZHhG19HI1G0Cp7xt">Data Storage</a></td></tr><tr><td>Permanence</td><td>Encrypted content persists even if the file record is removed; keys are stored separately</td><td>Ciphertext on IPFS + Arweave — see <a href="/pages/RiUIZHhG19HI1G0Cp7xt">Data Storage</a></td></tr></tbody></table>

### Roadmap

Key custody evolves from a single protocol-operated custodian to a **BLS threshold operator network**. In the target design the DEK is split across the operator set using threshold cryptography, so no single party — including Molecule — can unwrap it alone. Clients, stored metadata shape, and the onchain interface stay the same; the `encryptionSystem` value on new files rolls forward to indicate threshold custody, and the `decryptDataKey` flow continues to work transparently.


# Data Anchoring (DID Linking)

How a Lab's offchain data identity is anchored onchain — every Lab's data room DID is bound to its OCL-ID through a dual co-attested registry entry that anyone can verify.

## Data Anchoring (DID Linking)

Every Lab's research data lives offchain in its data room, and is versioned and provenance-tracked by Kamu (ODF). DID linking is the mechanism that anchors that offchain identity onchain: the data room (and the Lab's data-platform account) each carry a [W3C DID](https://www.w3.org/TR/did-core/) (`did:odf:…`), and the `MoleculeOclDidRegistry` contract binds those DIDs to the Lab's canonical `oclId` in a record that anyone can read and verify with nothing but an RPC connection.

This is a deliberately novel piece of infrastructure. Rather than storing data references in a database and asking the world to trust it, Molecule turns the Lab ↔ data binding itself into a first-class onchain object — cryptographically co-attested by two independent parties, versioned, and permanently auditable.

### The Trust Problem

Without an onchain anchor, the mapping between a Lab and its research data exists only inside an offchain service. The Lab's account would hold assets, manage its treasury, and control IP, but its link to the actual scientific work would rest on trusting a database. Anyone claiming "this dataset belongs to that Lab" would have nothing verifiable to point at.

DID linking solves this. Once linked, the association is public onchain state: a third party — an investor conducting due diligence, a protocol composing on top of Labs, an indexer building analytics — can resolve a Lab's data identity directly from the chain and check exactly who vouched for the binding.

### How a Link Is Created

Linking happens automatically when a Lab is created:

```
1. createLab → Kamu provisions the Lab's account + data room,
               each identified by a DID (did:odf:ed25519:…)
2. The backend queues the Lab for linking
3. The linking worker builds a signed, deadline-bound LinkDidRequest per DID
4. Kamu signs the request with the dataset's own ed25519 key (the proof)
   and attests to that proof with its registered attester key
5. Molecule co-signs an EIP-712 attestation binding the request to the proof
6. The relayer submits linkDidBatch — both DIDs are linked in one
   atomic transaction
7. The registry emits DidLinked events; the indexer confirms the link
```

Progress is queryable via the Labs API: `getDidLinkStatus(oclId)` returns the linked DIDs and a status of `PENDING`, `SUBMITTED`, `LINKED`, or `FAILED`. The terminal `LINKED` state is driven by the onchain events themselves — the backend doesn't declare success; the chain does.

### Dual Co-Attestation

A link is only accepted if **two independent parties** sign off on it, and the registry verifies both signatures onchain before writing anything:

* **Kamu (the data platform)** attests that the dataset's own ed25519 key genuinely signed the link request i.e. the data source itself consented to the binding. The raw ed25519 proof is recorded onchain alongside the link.
* **Molecule** attests, via an EIP-712 signature, that it authorized this specific `oclId → DID` binding for this exact request.

Attesters are resolved by recovered signature address — not by array position — and must be distinct; a missing, duplicated, or mismatched attester reverts the transaction. The relayer that submits the transaction is *untrusted for authenticity*: it holds the only role allowed to call `linkDid`, but it cannot forge a link, because it controls neither attester key. Every request is additionally replay-protected (single-use request IDs per Lab/provider/subject), deadline-bound, and EIP-712 domain-bound to the specific registry contract and chain.

Successful links carry the `CO_ATTESTED` verification tier. A stricter `ONCHAIN_VERIFIED` tier — where the data source's proof is verified directly onchain rather than attested to — is defined in the contract and reserved for a future verifier.

### What Anyone Can Verify

From public chain state alone:

* **A Lab's live data identity** — `getActiveDid(oclId, provider, subject)` returns the currently-linked DID string for the Lab's account and data room.
* **Who vouched for it** — every `DidLinked` event carries the full DID, the ed25519 proof, both attestation signatures, the request hash, and the verification tier. Anyone can re-recover both attesters and independently re-verify the dataset's proof offchain.
* **The full lineage** — links are never deleted. Re-linking a new DID deactivates the previous record (`DidDeactivated`) and increments a per-binding version counter, so the complete rotation history stays auditable forever.
* **That the Lab is really the Lab** — the `oclId` deterministically packs the LabNFT tokenId and the Lab's ERC-6551 account address, and the registry validates that packing against the protocol's canonical derivation config before accepting a link.

### Beyond Kamu: Anchoring Any Data Source

The registry itself is **data-source-agnostic** — this is what makes the design powerful. It stores DIDs as opaque strings (no DID method is parsed onchain), namespaces every binding by an arbitrary `(provider, subject)` pair, and delegates verification to a pluggable verifier contract registered per namespace. The ODF/Kamu co-attestation scheme is simply the first verifier.

That means any DID-identified data source — another data platform, an instrument feed, an institutional archive — could in principle be anchored to a Lab by deploying a new verifier and registering it, with **no change to the registry**. The Lab becomes a universal, verifiable onchain index of its offchain data identities, whatever systems those identities live in. Today, `did:odf` (Kamu) is the wired provider; the verifier interface explicitly anticipates additional policies over time.

### Contract Reference

| Item                                          | Value                                                                                                                           |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Registry (Base mainnet, proxy)                | [`0x6cd3Cf3c34a18Bf48F90590c3a57708F175b2eE3`](https://basescan.org/address/0x6cd3Cf3c34a18Bf48F90590c3a57708F175b2eE3)         |
| Verifier — OdfCoAttestVerifier (Base mainnet) | [`0x5a7a22bbad7B8B3c91EdB7FC2Af2DE5de9060D8B`](https://basescan.org/address/0x5a7a22bbad7B8B3c91EdB7FC2Af2DE5de9060D8B)         |
| Registry (Base Sepolia, proxy)                | [`0x8A23622967Cf7e3BB3219217ff685a5E4830C617`](https://sepolia.basescan.org/address/0x8A23622967Cf7e3BB3219217ff685a5E4830C617) |
| Verifier — OdfCoAttestVerifier (Base Sepolia) | [`0x8c1BD0120CDe6102E60F547F5c898b82F6075541`](https://sepolia.basescan.org/address/0x8c1BD0120CDe6102E60F547F5c898b82F6075541) |

Key functions:

```solidity
// Write — relayer-only (RELAYER_ROLE), pausable
function linkDid(LinkDidRequest calldata req, bytes calldata proof, LinkDidAttestation[] calldata attestations) external;
function linkDidBatch(LinkDidRequest[] calldata reqs, bytes[] calldata proofs, LinkDidAttestation[][] calldata attestations) external;

// Read
function getActiveDid(bytes32 oclId, bytes32 provider, bytes32 subject) external view returns (string memory);
```

Events: `DidLinked`, `DidDeactivated`, `VerifierSet`, `RelayerUpdated`, `DerivationConfigSet`. The contract is UUPS-upgradeable with an unusual safety guard: an upgrade that would change the EIP-712 signing domain reverts, so offchain signers can never be silently invalidated. Administration (verifier registration, pausing, attester rotation) sits with Molecule's governance multisigs; the relayer role is held by a dedicated ERC-4337 smart account.

### Current Status

DID linking is **live on Base mainnet** — Labs created through the app are linked automatically, and co-attested `DidLinked` records are accumulating on the registry since the v0.1.0 go-live. On the roadmap: the `ONCHAIN_VERIFIED` tier (direct onchain proof verification), additional verifier policies (attested, community, stake- or ZK-backed), and additional providers and subjects beyond the initial ODF account + data-room pair.

For the offchain half of the story — how files are stored, versioned, and encrypted inside the data room this anchor points at — see [Data Storage](/technical-deep-dive/data/data-storage) and [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access).


# Data API & Integration

How Lab data is indexed, aggregated, served, and consumed by the platform, AI tools, and external integrations

### The Data Access Layer

The Data Storage and Data Privacy & Access pages describe how research files enter a Lab — how they are encrypted, stored, versioned, and access-controlled. This page describes the other side of the pipeline: how onchain events are indexed into a queryable database, how that data is aggregated with research metadata and editorial content into a unified API layer, and how every consumer in the ecosystem — the frontend, MIRA, external developers, and AI agents — accesses it.

Every interaction a user has with Lab data on the Molecule platform — browsing project listings, viewing token prices, reading announcements, querying MIRA, or building an integration against the Labs API — is mediated by the Molecule API. The API is the central data hub that sits between the raw data sources and the consumers that need them.

### Data Sources

The Molecule API aggregates data from multiple backends, each responsible for a different category of information.

**Amazon Aurora (AWS relational database)** is the primary data store for indexed onchain and application data. Aurora is a cloud-native, fully managed relational database service from AWS. It stores IP-NFT ownership records, IPT contract state, token metadata, treasury balances, crowdsale contributions, Lab creation records, and transaction history — all pre-indexed into relational tables so the API can serve queries without reading directly from the blockchain at request time.

**Kamu** provides the research data layer. Every file uploaded to a Lab's data room — its content hash, version history, author DID, access level, encryption metadata, and provenance chain — is tracked in Kamu's provenance database. Kamu also records activity events: file access logs, metadata changes, and the complete append-only version graph for every dataset. The Labs API exposes this data through a GraphQL interface, enabling programmatic read and write access to data rooms.

**Sanity CMS** provides editorial and marketing content — project descriptions, team profiles, research category taxonomies, curated homepage content, and blog posts. This content is managed by the Molecule team through Sanity Studio, a customizable content editing interface. Sanity is a third-party headless CMS platform (sanity.io) that stores content on its hosted infrastructure and exposes it through its own API.

**GeckoTerminal** provides supplementary token price and market data. The MCP tools that power MIRA's market intelligence pull pricing data from GeckoTerminal in addition to the Molecule API's own indexed market data, providing OHLCV (open, high, low, close, volume) historical price charts and liquidity metrics.

### The Indexing Pipeline

Onchain events do not flow directly from the blockchain to the API. There is an indexing layer in between that parses blockchain events and writes them into the Aurora database in a structured, queryable format.

When a transaction occurs onchain — IPTs are transferred, a crowdsale receives a contribution, treasury funds are deployed, or any Lab transaction is executed — the blockchain records that event as a transaction receipt with associated logs. Service providers monitor the blockchain, detect relevant contract events from Molecule Protocol contracts, parse the event data (decoding function signatures, extracting parameter values, resolving token metadata), and write the resulting structured records into the Aurora database.

This indexing step is what makes the platform performant. Without it, every page load, every search query, and every MIRA conversation would require reading and parsing raw blockchain state — which is slow, expensive, and impractical for a real-time user experience. Instead, the indexer runs continuously in the background, keeping Aurora in sync with the onchain state, and the Molecule API queries Aurora directly.

The indexing pipeline handles several data transformations during this process. Raw contract events are decoded into human-readable records. Token metadata (names, symbols, images) is resolved from onchain tokenURI pointers. Pricing data is computed from DEX pool events (swaps, liquidity changes). Ownership graphs are maintained as tokens transfer between wallets. Treasury balances are aggregated across multiple token types. Crowdsale states are tracked through their lifecycle (created, running, settled, failed).

The result is a relational database where complex queries — "show me all Labs sorted by treasury size" or "find all IPTs with market cap above $100k" — can be resolved in milliseconds rather than requiring full blockchain scans.

### The API Layer

The Molecule API is a GraphQL API that sits on top of the Aurora database and serves aggregated data to all consumers. It is the primary interface through which the platform operates.

The API serves two broad categories of data. Market and token data — representing approximately 85% of current API traffic — includes IPT prices, market caps, liquidity depths, holder distributions, trading volumes, IP-NFT metadata, crowdsale states, treasury balances, project listings, and transaction histories. This data originates from onchain events, indexed through the pipeline described above into Aurora.

Research and Lab data — representing the remaining 15% — includes data room file listings, file versions, announcements, project activity feeds, and semantic search results. This data is served through the Labs API, which reads from Kamu for file metadata and provenance, and generates presigned S3 URLs through Filebase for file uploads and downloads.

The API uses a two-tier authentication model. Read operations (queries) require a consumer credential, issued by the Molecule team upon request. Write operations (mutations) — file uploads, metadata updates, announcements — require both a consumer credential and a service token, which is scoped to a specific wallet address and Lab. Service tokens have configurable expiration and can be extended or revoked through the API. For detailed authentication setup, credential management, and rate limits, see the Labs API reference page.

### How Consumers Access Data

Different consumers interact with the data layer through different interfaces, depending on their use case.

**The Molecule frontend (Molecule Screener)** consumes the Molecule API's GraphQL endpoints for project listings, token market data, Lab metadata, and activity feeds. It calls the Labs API for data room operations — file uploads, downloads, and encrypted file retrieval. It currently also calls Sanity's API directly for editorial content (project descriptions, blog posts, categories). It performs client-side decryption when users access encrypted files: for new files, it calls `decryptDataKey` to retrieve the unwrapped DEK after the backend re-verifies access conditions against live chain state, and decrypts locally via Web Crypto.

**MIRA** accesses data through MCP (Model Context Protocol) tools — structured function calls that the AI model invokes during conversations. MIRA's knowledge base — a curated corpus about Molecule, Molecule's architecture, and the broader ecosystem — is maintained separately and updated through an automated crawl pipeline.

**External developers and AI agents** access Lab data through the Labs API, which provides full GraphQL access to data room operations: listing projects, querying files, uploading and versioning research data, creating announcements, and performing semantic search across Labs. For market and token data, they query the Molecule API directly. All these are documented in the References section.

### The Integration Pipeline

When a researcher uploads a file to a Lab, the data flows through the full pipeline and becomes available to every consumer in the ecosystem. The file is AES-256-GCM encrypted client-side with a per-file wrapped data encryption key (Onchain-Verified Envelope Encryption), uploaded through the Labs API to Filebase, committed to IPFS and Arweave by Kamu (which records the version, content hash, author DID, and provenance metadata), and referenced onchain in the Lab's Token Bound Account. The onchain reference event is then picked up by the indexer and written to Aurora. At that point, the file is retrievable through the Labs API by any authenticated consumer with the appropriate access level, searchable via the semantic search endpoint, visible in MIRA's project activity responses, and reflected in the Lab's data room size and activity metrics on the frontend.

Market data flows through a different path. When a token event occurs onchain — an IPT trade on a DEX, a crowdsale contribution, a treasury deployment — the indexer picks up the event, computes the relevant metrics (new price, updated volume, changed holder count), and writes the results to Aurora. The Molecule API serves the updated data on the next query. MIRA's MCP tools access this data in real time during conversations, supplemented by GeckoTerminal for historical OHLCV charts.

Announcements follow the Labs API path. A Lab owner creates an announcement through the Labs API (or the platform UI), optionally attaching data room files. The announcement is stored via Kamu with its timestamp, author, and content, and immediately appears in the project's activity feed, the global activity feed, and MIRA's context when users ask about the project.

### Semantic Search

The API exposes a semantic search endpoint that queries across all Labs, files, and announcements in the ecosystem. Queries are processed as natural language — searching for "gene therapy for rare diseases" returns Labs whose data room contents, announcements, and metadata are semantically relevant, not just keyword matches.

Search results can be filtered by tags, categories, access levels, and content kinds (files or announcements). Each result includes the matching entity, its parent Lab, and a relevance score. This powers both the platform's search interface and MIRA's ability to discover related projects during conversations.

### Access Levels and Gating

The API enforces access levels at the file level, consistent with the access control model described in the Data Privacy & Access page. Public files are accessible to any API consumer without authentication. Admin-restricted files require a valid consumer credential and service token tied to an authorised wallet. Token-holder-gated files are accepted by the API but require onchain verification for decryption — the API serves the encrypted blob and encryption metadata, and only wallets that satisfy the stored access conditions (re-verified against live chain state) can obtain the unwrapped data encryption key (through `decryptDataKey` for current-flow files).

This means the API can serve file metadata (path, version, content type, access level) for any file regardless of access level, but the actual file content for encrypted files is only accessible to authorised parties who satisfy the stored access conditions and then decrypt client-side.

### Current Limitations

The current API and MCP tool suite is weighted toward market and token data. Several research-oriented capabilities are not yet exposed through the API or MCP tools. These include structured scientific data queries (querying dataset contents by schema or field values), dataset version diffs (comparing what changed between two versions of a file), cross-Lab provenance queries (tracing how a dataset or methodology was shared or derived across multiple Labs), and research file metadata queries for MIRA (the MCP tools currently cannot access data room contents, search across file metadata, or retrieve dataset version histories on behalf of the AI).

These gaps mean that MIRA can tell you a project's token price, market cap, and recent announcements, but cannot yet directly inspect the contents of a Lab's data room or compare the scientific substance of two projects' research outputs. Developers building integrations should be aware that the Labs API provides richer data room access than the MCP tools currently expose.

The Sanity CMS integration is also in transition. The frontend currently calls Sanity's API directly for editorial content, bypassing the Molecule API. The planned architecture consolidates Sanity content into the Molecule API so the frontend has a single data source. This migration is actively in progress.

### Data Architecture Summary

<table><thead><tr><th width="156.0859375">Layer</th><th>Component</th><th>Role</th></tr></thead><tbody><tr><td><strong>Data Origins</strong></td><td>Blockchain (Eth/Base)</td><td>Onchain events: transfers, crowdsales, treasury</td></tr><tr><td></td><td>Researcher uploads</td><td>Research files via Labs API / platform UI</td></tr><tr><td></td><td>Editorial team</td><td>Project content via Sanity Studio</td></tr><tr><td><strong>Indexing</strong></td><td>Indexer / Service Provider</td><td>Parses blockchain events, writes to Aurora</td></tr><tr><td><strong>Storage</strong></td><td>Amazon Aurora (AWS)</td><td>Relational DB for indexed onchain + application data</td></tr><tr><td></td><td>Kamu v2</td><td>Provenance DB for file versions, DIDs, activity events</td></tr><tr><td></td><td>IPFS + Arweave</td><td>Encrypted research file blobs (ciphertext)</td></tr><tr><td></td><td>Sanity CMS</td><td>Editorial content, categories, blog posts</td></tr><tr><td></td><td>Onchain-Verified Envelope Encryption + AccessResolver</td><td>Wrapped per-file DEKs with conditions stored on Kamu (ODF) and re-verified against live onchain state</td></tr><tr><td><strong>API</strong></td><td>Molecule API (GraphQL)</td><td>Aggregates Aurora data → serves market/token/project data</td></tr><tr><td></td><td>Labs API (GraphQL)</td><td>Aggregates Kamu data → serves data room operations</td></tr><tr><td></td><td>MCP Server</td><td>Exposes 5 tools for AI assistants (market data focused)</td></tr><tr><td><strong>Consumers</strong></td><td>DeSci Screener (frontend)</td><td>Platform UI — queries Molecule API + Labs API + Sanity</td></tr><tr><td></td><td>MIRA</td><td>AI assistant — queries via MCP tools</td></tr><tr><td></td><td>External devs / agents</td><td>Programmatic access via Labs API + Molecule API</td></tr></tbody></table>

### Related Pages

For the storage pipeline (how research files are stored and versioned): see **Data Storage**.

For encryption and access control (how data is protected): see **Data Privacy & Access**.

For the GraphQL API reference (data room endpoints, queries, mutations): see **Labs API**.

For MIRA's tool capabilities (what the AI can query): see **MCP Tools**.


# Coin-to-Company Model

The Coin-to-Company module bridges onchain Token ownership with offchain shareholder rights, enabling a compliant pathway from token holder to real-world equity participant

## Coin-to-Company (C2C)

The C2C module provides a compliant pathway for token holders to qualify for real-world equity in the research projects they support. It bridges the gap between onchain token ownership — which provides governance participation and economic exposure — and offchain shareholder rights, which confer legal ownership in the underlying entity.

This is an opt-in framework. Token holders who prefer the liquidity and simplicity of standard tokens continue operating exactly as before. Only those who choose to pursue equity enter the lock-and-qualify process.

### The Problem

Tokens give holders meaningful participation in a research project: governance voice and access to token-gated data rooms. But Tokens are not equity. They do not confer legal ownership, board representation, dividend rights, or the protections that come with being a registered shareholder in a legal entity.

For research projects that reach a stage where real-world equity matters — licensing deals, acquisition negotiations, regulatory milestones — there needs to be a mechanism that connects committed onchain supporters to the traditional equity stack without compromising regulatory compliance.

### How It Works

The C2C module implements a lock-and-qualify process managed through the Lab's modular account:

**Token locking.** A holder swaps their liquid tokens one-to-one for locked tokens through a locking contract. The locked tokens represent the same underlying value but cannot be freely transferred. This creates a commitment signal — the holder is signalling long-term alignment with the project by sacrificing immediate liquidity.

**Identity verification.** The holder completes a KYC/AML process to verify their identity. Upon successful verification, they receive an onchain credential (a non-transferable soulbound token) that attests to their verified status. This credential is required before any equity qualification can proceed.

**Equity qualification.** With locked tokens and a verified identity, the holder is eligible to enter into traditional legal agreements with the project's legal entity. The actual equity grant is executed offchain through standard corporate processes — share purchase agreements, subscription documents, or equivalent instruments depending on the jurisdiction.

**Ongoing enforcement.** Once a holder is designated as a shareholder, their locked tokens cannot be unlocked. Exit from the shareholder position requires an offchain request and administrative approval, ensuring that equity holders maintain their token commitment throughout their shareholder status.

Locking tokens does not automatically grant equity. It establishes eligibility. The locked token serves as proof of commitment — it prevents a holder from completing KYC, receiving equity, and immediately selling their tokens on the open market.

### Role in the Module Architecture

The framework is designed to operate as a module within the Lab's modular token-bound account. This modular approach means that C2C is an installable capability, not a mandatory feature of every Lab. Projects that operate purely in the token economy never install the module. Projects that want to offer an equity pathway install it when ready, and the ERC-7484 Module Registry ensures the equity module contracts are attested and audited before activation.

### Legal Design

The framework maintains a deliberate separation: the token is not equity. This is not a limitation — it is the core design principle that enables regulatory compliance.

Holding locked tokens is a prerequisite for pursuing equity through separate legal agreements. The onchain components (locking, credential verification, status tracking) provide the infrastructure, but the equity grant itself occurs through traditional legal channels. This structure means the token does not need to be classified as a security in jurisdictions where that would create compliance burdens for the broader holder base. Those who want equity follow the qualification path. Those who do not want equity continue holding standard, liquid IP Tokens with no additional obligations.

\
You can find the [full legal whitepaper](https://molecule.xyz/blog/the-coin-to-company-model) on the Molecule website, as well as the [legal templates on DeSci.Codes](https://desci-codes.gitbook.io/desci.codes/templates/v2-coin-to-company/governance-agreements).&#x20;


# API Overview

## Overview

The Molecule Protocol provides programmatic APIs for building applications, integrations, and automated workflows on top of decentralized science infrastructure. These APIs enable developers to query project data, tokenize research, and manage research datarooms.

## API Areas

### 📁 Labs API

Upload files to lab datarooms for secure, decentralized research data storage, and query labs, members, activity, and legal-agreement status.

**Purpose:**

* Create labs (datarooms) for onchain labs (OCLs)
* Automate file uploads to lab datarooms
* Integrate with data pipelines and CI/CD
* Batch upload research data
* Manage file versions, metadata, and LabNFT display metadata
* Query labs, files, members, activity, and onchain events (mostly public access)
* Manage service tokens and legal-agreement signing

**Authentication:**

* **Most queries** (read operations): consumer credential only — public. One exception, `legalAgreementTemplate`, needs a Service Token or an authenticated session.
* **Write mutations** (write operations): consumer credential plus **either** a Service Token (`X-Service-Token`) **or** a Privy user session (`Authorization` + `x-wallet-address`) — the two paths are interchangeable. Exceptions: `extendServiceToken` and `revokeServiceToken` are Service-Token-only, and `generateServiceToken` bootstraps a token from a Privy session or wallet signature.

[View Labs API Documentation →](/api-reference/labs-api)

***

### 🔐 Tokenization API

Tokenize Labs into fungible IP Tokens (IPTs) on Base.

**Purpose:**

* Tokenize Labs into tradeable ERC-20 tokens
* Generate Lab (OCL) membership agreements
* Manage the complete onchain tokenization workflow

**Authentication:** Consumer credential required

[View Tokenization API Documentation →](/api-reference/tokenization-api)

***

### 💳 x402 Gateway

Pay-per-call HTTP 402 gateway that fronts a set of Labs API write mutations with per-request USDC settlement on Base.

**Purpose:**

* Give autonomous agents and third-party tools write access without a long-lived service token
* Pay per mutation call in USDC, settled on Base
* Mint short-lived, scoped service tokens on the fly after payment

**Authentication:** Per-request stablecoin payment (no long-lived service token required)

[View x402 Gateway Documentation →](/api-reference/x402-gateway)

***

### 📊 IPNFT API (Deprecated)

Query and browse IP-NFTs, IP Tokens (IPTs), and market data across the Molecule ecosystem.

**Purpose:**

* Browse all IP-NFTs and IPTs on the platform
* Query metadata, ownership, and project details
* Access trading data and market metrics
* Build marketplace UIs and token screeners

**Authentication:** Consumer credential required

[View IPNFT API Documentation (Deprecated) →](/api-reference/ipnft-api-deprecated)

***

## Authentication

All Molecule APIs require a consumer credential; the Labs API additionally uses a Service Token for write operations. Obtaining credentials, the per-API header requirements, and the full Labs API authentication model (public queries vs. protected mutations) are documented on the dedicated [Authentication](/api-reference/authentication) page.

***

## API Endpoints

All APIs use the same GraphQL endpoint:

```
Production: https://production.graphql.api.molecule.xyz/graphql
Staging:    https://staging.graphql.api.molecule.xyz/graphql
```

***

## Quick Start Guide

### 1. Get API Access

Contact the Molecule team via [Discord](https://t.co/L0VEiy4Bjk) to obtain your consumer credential.

### 2. Choose Your API

| If you want to...                             | Use this API                                                  |
| --------------------------------------------- | ------------------------------------------------------------- |
| Upload files to a Lab dataroom                | [Labs API](/api-reference/labs-api)                           |
| Tokenize a Lab into IP Tokens (IPTs)          | [Tokenization API](/api-reference/tokenization-api)           |
| Pay per call without a long-lived token       | [x402 Gateway](/api-reference/x402-gateway)                   |
| Browse IP-NFTs and IPTs (legacy)              | [IPNFT API (Deprecated)](/api-reference/ipnft-api-deprecated) |
| Check market prices and trading data (legacy) | [IPNFT API (Deprecated)](/api-reference/ipnft-api-deprecated) |

### 3. Make Your First Request

**Example (Labs API — public `labs` query, consumer credential only):**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query { labs(perPage: 5) { nodes { oclId name shortname } totalCount } }"
  }'
```

***

## Getting Support

If you encounter any issues or have questions about the APIs:

* **Discord**: Join our [Discord community](https://t.co/L0VEiy4Bjk) for support
* **Documentation**: Check the specific API documentation pages linked above
* **Contact**: Reach out to the Molecule development team

***

## Additional Resources

* [Smart Contract Addresses](/references/contracts)

***

*Last updated: July 2026*


# Authentication

All Molecule APIs require authentication. This page covers how to obtain credentials, which headers each API expects, and the specific authentication model for the Labs API.

## Obtaining API Access

All Molecule APIs require authentication with a consumer credential. To request access:

1. Join our [Discord community](https://t.co/L0VEiy4Bjk)
2. Contact the Molecule team with your use case
3. You'll receive:
   * **Consumer credential** (`mol_<consumerId>_<secret>`) - Required for all APIs
   * **Service Token** - Additional token for Labs API (if needed)

Send the consumer credential as the `Authorization` header value directly — **no `Bearer` prefix**: `Authorization: mol_<consumerId>_<secret>`. This differs from the Privy path below, which does use `Bearer`; adding `Bearer` in front of a consumer credential makes the request fail authentication. Treat the entire string as a secret — it is not split into a public/private part.

## Authentication Headers

| API                                     | Required Headers                                                   | Example                                                                                                                |
| --------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **Labs API (queries)**                  | `Authorization`                                                    | `Authorization: mol_<consumerId>_<secret>`                                                                             |
| **Labs API (mutations, service token)** | <p><code>Authorization</code><br><code>X-Service-Token</code></p>  | <p><code>Authorization: mol\_\<consumerId>\_\<secret></code><br><code>X-Service-Token: YOUR\_SERVICE\_TOKEN</code></p> |
| **Labs API (mutations, Privy user)**    | <p><code>Authorization</code><br><code>x-wallet-address</code></p> | <p><code>Authorization: Bearer PRIVY\_TOKEN</code><br><code>x-wallet-address: 0x…</code></p>                           |
| **Tokenization API**                    | `Authorization`                                                    | `Authorization: mol_<consumerId>_<secret>`                                                                             |
| **IPNFT API (Deprecated)**              | `Authorization`                                                    | `Authorization: mol_<consumerId>_<secret>`                                                                             |

> **No `Bearer` prefix on consumer credentials.** `mol_<consumerId>_<secret>` goes directly in the `Authorization` header. Only a Privy user token uses `Authorization: Bearer <token>`.

***

## Labs API Authentication

The Labs API has different authentication requirements depending on the operation type:

> **Rule of thumb**: Most **queries** are public (consumer credential only). Write **mutations** are authenticated, and most accept **either** a Service Token **or** a Privy user session — pick whichever fits your caller. The exceptions are called out below: one query is gated, the two Service Token lifecycle mutations are service-token-only, and `generateServiceToken` bootstraps a token with a Privy session or wallet signature.

Summary of the model:

* **Most queries are public**: consumer credential only for read operations. Exception: `legalAgreementTemplate` requires a Service Token or an authenticated session.
* **Write mutations are authenticated, with two interchangeable paths**: consumer credential plus **either** `X-Service-Token` (machine callers — services, bots, agents) **or** `Authorization` + `x-wallet-address` (Privy user session — browser and app callers). Authorization is then evaluated against the caller's identity either way.
* **Exceptions**: `extendServiceToken` and `revokeServiceToken` accept **only** a Service Token. `generateServiceToken` accepts **only** a Privy session or wallet signature, since it mints the token in the first place.
* **Service Token**: Identifies which specific lab/dataroom you have write access to.
* File-level access control is handled via Molecule's Onchain-Verified Envelope Encryption, not query authentication — see [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access).

### Public Queries (Read-Only)

**These queries** are public and only require a consumer credential:

* `labs` - List all labs with pagination
* `labWithDataRoomAndFiles` - Get lab details and files
* `labActivity` - Get activity feed for a lab, (available filters: ANNOUNCEMENT | FILE)
* `activities` - Get global activity feed, (available filters: ANNOUNCEMENT | FILE)
* `dataRoomFile` - Get file by path
* `searchLabs` - Search across labs, files, and announcements
* `fileCategoriesAndTags` - List valid file categories and their tags
* `getServiceSignInMessage` - Get the message a service signs to obtain a token
* `getDidLinkStatus` - Get background DID-linking status for a lab
* `legalAgreementStatus` - Check whether a lab's legal agreement is signed
* `onChainActivity` - Onchain event feed for a lab or wallet
* `listLabMembers` - List a lab's members

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
```

**Authenticated query** — consumer credential **plus** a Service Token, or an authenticated user session:

* `legalAgreementTemplate` - Get the populated agreement to sign (the signer's authenticated session, or a service token)

### Protected Mutations (Write Operations)

All write mutations require a **consumer credential** plus proof of caller identity. For most mutations there are **two interchangeable ways** to prove identity — the resolver accepts a Service Token if one is present, and otherwise falls back to authenticating the Privy user:

**Option 1 — Service Token** (services, bots, agents, CI/CD):

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
X-Service-Token: YOUR_SERVICE_TOKEN
```

**Option 2 — Privy user session** (browser and app callers acting as a signed-in user):

```bash
Authorization: Bearer YOUR_PRIVY_TOKEN
x-wallet-address: YOUR_WALLET_ADDRESS
```

Either way, the caller still has to be authorized for the target lab — a Service Token carries its own lab scope, and a Privy session is checked against the wallet's onchain role (LabNFT owner, authorized multisig signer, or an active role on `AccessResolver`). Supplying neither returns a `NO_AUTH` error naming both paths.

**Mutations accepting either path:**

* `createLab` - Create a lab (data room) for an onchain lab (OCL) · 💳 also available pay-per-call via [x402 Gateway](/api-reference/x402-gateway)
* `initiateCreateOrUpdateFile` - Initiate file upload · 💳 also available pay-per-call via [x402 Gateway](/api-reference/x402-gateway)
* `finishCreateOrUpdateFile` - Complete file upload · 💳 also available pay-per-call via [x402 Gateway](/api-reference/x402-gateway)
* `updateFileMetadata` - Update file metadata
* `deleteDataRoomFile` - Delete a file
* `createAnnouncement` - Create an announcement · 💳 also available pay-per-call via [x402 Gateway](/api-reference/x402-gateway)
* `updateLabNftMetadata` - Update LabNFT display metadata (OCL admin only)
* `generateLabImageUploadUrl` - Get a presigned URL to upload a LabNFT image (OCL admin only)
* `signLegalAgreement` - Record acceptance of a legal agreement
* `generateDataEncryptionKey` - Generate a standalone data encryption key · 💳 also available pay-per-call via [x402 Gateway](/api-reference/x402-gateway)
* `decryptDataKey` - Decrypt a file's data key for an authorized caller · 💳 also available pay-per-call via [x402 Gateway](/api-reference/x402-gateway)

**Service-Token-only mutations** — these manage token lifecycle and reject Privy sessions:

* `extendServiceToken` - Extend service token expiration
* `revokeServiceToken` - Revoke a service token

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
X-Service-Token: YOUR_SERVICE_TOKEN
```

> **`generateServiceToken` is the bootstrap exception**, in the opposite direction: it mints a Service Token, so it accepts *only* a consumer credential plus either a Privy session or a wallet signature — not a pre-existing Service Token. See [Obtaining Tokens](/api-reference/labs-api/service-tokens#obtaining-tokens).

> **Pay-per-call alternative.** Mutations tagged 💳 above can also be called through the [x402 Gateway](/api-reference/x402-gateway), which settles a USDC payment on Base per request and mints a short-lived service token on the fly — no long-lived credentials required. Useful for autonomous AI agents and third-party tools that pay for users.

### Obtaining a Consumer Credential and Service Token

To obtain access credentials:

1. Join our [Discord community](https://t.co/L0VEiy4Bjk)
2. Contact the Molecule team and provide:
   * Your wallet address (will be linked to the service token)
   * Intended use case / service name
   * Which lab/dataroom you need access to
   * Desired token expiration period
3. The team will generate and provide you with:
   * **Consumer credential** (`mol_<consumerId>_<secret>`) - Used for all Molecule APIs
   * **Service Token** (JWT string) - Grants access to specific lab
   * **Token ID** - For management operations

### Using Your Credentials

**For all queries** (read-only operations):

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
```

**For mutations** (write operations) — as a service:

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
X-Service-Token: YOUR_SERVICE_TOKEN
```

**For mutations** — as a signed-in user (accepted by all mutations except `extendServiceToken` and `revokeServiceToken`):

```bash
Authorization: Bearer YOUR_PRIVY_TOKEN
x-wallet-address: YOUR_WALLET_ADDRESS
```

**Why more than one header for mutations?**

* **Consumer credential**: Authenticates you as a valid Molecule API consumer
* **Service Token**: Identifies which specific lab/dataroom your service has write access to
* **Privy token + wallet address**: Identifies the human caller instead, whose write access is derived from their wallet's onchain role

Which path to choose: use a Service Token for unattended callers (backends, bots, agents, CI/CD) where there is no user session to draw on. Use the Privy path when a signed-in user is driving the request, so the action is attributed to their wallet and governed by their onchain role rather than a shared service credential.

**Security Warnings:**

* Service tokens are shown only once during generation - store them securely immediately
* Never commit tokens or consumer credentials to version control
* Never log credentials in application logs
* Store in environment variables or secure secret management systems
* Rotate tokens regularly (quarterly recommended)

> Service Token lifecycle operations (extending, revoking) are documented in [Service Tokens](/api-reference/labs-api/service-tokens).


# Labs API

## Overview

The Labs API allows developers to interact with Molecule Labs datarooms without requiring browser-based user interaction. This enables integration with automated workflows, data pipelines, CI/CD systems, and external applications.

### Use Cases

* **Automated Data Pipelines**: Schedule regular data synchronization from research systems
* **CI/CD Integration**: Automatically publish build artifacts and test results
* **External System Integration**: Connect third-party tools and platforms to your Lab
* **Batch Operations**: Upload multiple files programmatically
* **Monitoring & Alerting**: Automated upload of logs and metrics

> **Ready for Production**: This API is production-ready and actively used by projects for automated data management. To request API access, please join our [Discord community](https://t.co/L0VEiy4Bjk) and reach out to our team.

***

## Authentication

The Labs API uses consumer-credential authentication for reads and an additional Service Token for writes. Full details — public queries vs. protected mutations, obtaining and using credentials — are on the [Authentication](/api-reference/authentication) page.

See also the functional sections: [Lab Management](/api-reference/labs-api/lab-management), [Files](/api-reference/labs-api/files), [Browse & Search](/api-reference/labs-api/browse-and-search), [Legal Agreements](/api-reference/labs-api/legal-agreements), and [Service Tokens](/api-reference/labs-api/service-tokens). For a full end-to-end walkthrough — mint a LabNFT, register its dataroom, sign the assignment agreement, then encrypt and upload a file — see [Example Workflow](/api-reference/labs-api/example-workflow).

***

## Error Handling

The Labs API is a GraphQL API: once a request is accepted, the response is HTTP `200` whether or not the operation succeeded — success and failure are signalled inside the JSON body, not by the status code. Errors surface through one of two channels, depending on the operation class:

* **Queries throw.** A failed query adds an entry to the top-level GraphQL `errors[]` array and returns `null` for that field. Most Labs query result types are non-null, so the null propagates and `data` itself comes back `null` (as in the example below); only `labWithDataRoomAndFiles` and `dataRoomFile` are nullable and null just their own field. `errorType` carries the error code (the only value to branch on) and `errorInfo` carries `{ requestId, retryable, details }`.
* **Mutations return errors in-band.** Every mutation result type carries an `error: ApiError` field. **Success ⇔ `error == null`.** Where the result type also has a top-level `message`, it mirrors `error.message` on failure and is never empty. A top-level `errors[]` entry on a mutation means a transport or infrastructure failure, or that the request document itself failed validation.

Branch on the code — never on `message` text, which may change without notice. Include `requestId` whenever you report a problem.

### Failed Query

```json
{
  "data": null,
  "errors": [
    {
      "path": ["listLabMembers"],
      "message": "Project not found: 0x0101000000000000000000000000000000000000000000000000000000000042",
      "errorType": "NOT_FOUND",
      "errorInfo": {
        "requestId": "8f1e4c9a-2b7d-4e10-9c3a-5d6f7a8b9c0d",
        "retryable": false,
        "details": { "reason": "PROJECT_NOT_FOUND" }
      }
    }
  ]
}
```

### Failed Mutation

Select `error { code message requestId retryable details }` on every mutation:

```graphql
type ApiError {
  code: String!       # error code from the catalogue below — the only field to branch on
  message: String!    # human-readable, never empty; not part of the contract
  requestId: String!  # correlation id — include it in bug reports
  retryable: Boolean! # whether retrying the same request unchanged can plausibly succeed
  details: AWSJSON    # optional structured context (JSON-encoded string)
}
```

```graphql
mutation InitiateFileUpload($oclId: String!, $contentType: String!, $contentLength: Int!) {
  initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) {
    uploadUrl
    error { code message requestId retryable details }
  }
}
```

```json
{
  "data": {
    "initiateCreateOrUpdateFile": {
      "uploadUrl": null,
      "error": {
        "code": "UNAUTHORIZED",
        "message": "You are not allowed to perform this operation.",
        "requestId": "8f1e4c9a-2b7d-4e10-9c3a-5d6f7a8b9c0d",
        "retryable": false,
        "details": "{\"reason\":\"UNAUTHORIZED\"}"
      }
    }
  }
}
```

In-band `details` is a JSON-encoded string — parse it with `JSON.parse(error.details ?? "{}")` (on thrown queries, `errorInfo.details` is already an object). Documented keys are `field` (the offending input field), `reason` (a more specific cause under the code, e.g. `PROJECT_NOT_FOUND` under `NOT_FOUND`), `hint` and `docs`; ignore unknown keys. `reason` values are diagnostic refinement and may be extended at any time — branch on `code` first.

```javascript
const result = (await response.json()).data.initiateCreateOrUpdateFile; // `response` from your fetch()

if (result.error) {
  const { code, message, requestId, retryable, details } = result.error;
  const { reason } = JSON.parse(details ?? "{}");
  if (retryable) return retryWithBackoff(); // RATE_LIMITED, TIMEOUT, UPSTREAM_UNAVAILABLE, INTERNAL_ERROR
  throw new Error(`${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`);
}
```

### Error Codes

| Code                        | `retryable` | Meaning                                                                                                                          |
| --------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHENTICATED`           | false       | Missing, invalid or expired credentials                                                                                          |
| `UNAUTHORIZED`              | false       | Authenticated, but not allowed (role/membership)                                                                                 |
| `NOT_FOUND`                 | false       | Referenced resource doesn't exist                                                                                                |
| `VALIDATION_FAILED`         | false       | Input failed validation (unknown filter/sort fields, out-of-range pagination, malformed ids); `details.field` names the offender |
| `CONFLICT`                  | false       | Valid request conflicts with current state (e.g. `details.reason` `SHORTNAME_TAKEN`, `ALREADY_SIGNED`)                           |
| `FAILED_PRECONDITION`       | false       | Resource state makes the operation impossible until the state changes (e.g. `TEMPLATE_EXPIRED`)                                  |
| `COMPLEXITY_LIMIT_EXCEEDED` | false       | Query shape or result size over limits                                                                                           |
| `RATE_LIMITED`              | **true**    | Throttled — retry with backoff                                                                                                   |
| `TIMEOUT`                   | **true**    | Execution exceeded the request budget                                                                                            |
| `UPSTREAM_UNAVAILABLE`      | **true**    | A dependency failed (`details.reason` `KAMU`, `CMS`, `IPFS`)                                                                     |
| `INTERNAL_ERROR`            | **true**    | Unexpected failure — details are only in our logs, joined by `requestId`                                                         |

When `retryable` is `true`, retry with exponential backoff; when `false`, the request (or the resource state) must change before retrying. Any code not listed here: preserve it for diagnostics, treat it as non-retryable and surface it to a human — new codes are announced in the [API Changelog](/api-reference/changelog). `PAYMENT_REQUIRED` is reserved for the [x402 Gateway](/api-reference/x402-gateway) and is not emitted by the GraphQL API.

### Troubleshooting

**`UNAUTHENTICATED`** — missing, invalid or expired service token:

* Ensure the `X-Service-Token` header is included in mutation requests
* Verify the token is not empty or malformed
* If the token has expired, request a new token from the Molecule team, or use the `extendServiceToken` mutation to extend expiration

A missing or malformed consumer credential is rejected before the GraphQL layer runs (an HTTP `401` from the API, not one of the error codes below) — check the `Authorization` header first, see [Authentication](/api-reference/authentication).

**`UNAUTHORIZED`** — the wallet behind the service token lacks the required role on the lab:

* Verify your wallet address (linked to the service token) has admin access to the lab/dataroom (or the role the operation requires)

**Upload to presigned URL fails:**

* Ensure binary file upload (use `--data-binary` in curl)
* Verify headers match those returned in Step 1
* Check that presigned URL hasn't expired (expires after \~15 minutes)

**`NOT_FOUND`** — lab, dataroom or file not found:

* Verify the `oclId` refers to a registered lab
* For `updateFileMetadata` / `deleteDataRoomFile`: verify the file `ref` (DID) or `path` is correct and the file exists in the specified dataroom

**`VALIDATION_FAILED`** — invalid parameters; `details.field` names the offending input:

* Check that the `oclId` format is correct: a 32-byte hex string with `0x` prefix
* For `searchLabs`: verify filter values match expected types (arrays of strings)

**Retryable errors (`RATE_LIMITED`, `TIMEOUT`, `UPSTREAM_UNAVAILABLE`, `INTERNAL_ERROR`):**

* Retry with exponential backoff; if the failure persists, report it with the `requestId`

***

## Best Practices

### Token Security

* **Never commit tokens** to version control (add to `.gitignore`)
* **Use environment variables** to store tokens
* **Rotate tokens regularly** (quarterly recommended)
* **Use secrets management systems** in production (AWS Secrets Manager, HashiCorp Vault, etc.)
* **Revoke immediately** if a token is compromised

### Storage Management

* Monitor your 5GB storage limit per project
* Organize files with meaningful names and metadata
* Use categories and tags for easy file discovery
* Clean up old or unnecessary files regularly

### Metadata Best Practices

* **Use descriptive tags**: `["experiment-1", "2024-q4", "preliminary"]`
* **Organize with categories**: `["raw-data", "analysis", "results"]`
* **Add descriptions**: Help collaborators understand file contents
* **Include searchable text** (`contentText`): Enables full-text search via `searchLabs`
* **Update metadata as needed**: Use `updateFileMetadata` to refine tags and descriptions without re-uploading files

### Search and Discovery

* **Use contentText**: Populate `contentText` field when uploading files to enable full-text search
* **Tag consistently**: Use consistent tag names across files for better filtering
* **Filter strategically**: Combine filters (tags + access levels) to narrow search results
* **Test search queries**: Use `searchLabs` to verify your files are discoverable

***

## Deprecated & Renamed Operations

The legacy `*V2` operations and the pre-OCL naming have been **removed**. The current API is `oclId`-based. If you are migrating from an older integration, see the query/mutation/field rename tables in the [API Changelog & Migration](/api-reference/changelog#labs-api) page.

***

## Getting Support

If you encounter any issues or have questions about the Programmatic File Upload API:

1. Check this documentation and [troubleshooting section](#troubleshooting)
2. Review the [complete example](/api-reference/labs-api/files#complete-example) for implementation guidance
3. Join our [Discord community](https://t.co/L0VEiy4Bjk) for support
4. Contact the Molecule Labs development team directly

***

*Last updated: July 2026*


# Lab Management

Operations for creating and administering a Lab: creating the dataroom, managing its LabNFT display metadata, managing members, and linking its decentralised identifier (DID).

***

## Mint the LabNFT

Before `createLab` can attach a dataroom, an onchain lab (OCL) has to exist: a LabNFT minted to your wallet with its ERC-6551 account (Token Bound Account) deployed. This step is **onchain only** — there is no Labs API mutation for it. See [Lab Creation](/technical-deep-dive/architecture#lab-creation) for the contract-level flow and [Molecule Labs](/technical-deep-dive/onchain-lab) for what a Lab is. If you'd rather not touch contracts directly, the Molecule app does this for you in [Step 1: Create Your Onchain Lab](/user-guides/scientists-researchers#step-1-create-your-onchain-lab).

### Contract Addresses

| Chain                  | `OnChainLabFactory`                          | `LabNFT` (proxy)                             |
| ---------------------- | -------------------------------------------- | -------------------------------------------- |
| Base Mainnet (`8453`)  | `0xECdF4f05384056507485C90aeAb0a83268760D6E` | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92` |
| Base Sepolia (`84532`) | `0xd629FE2310b4309a212495F10A47f8436dcEfD90` | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28` |

Full deployment list, including every other OCL contract: [Contracts reference](/references/contracts).

### Mint

Call `mintAndCreateAccount` on the factory. It mints the LabNFT to `to` and deploys the bound account in the same transaction, returning `(account, tokenId)`. The call is `payable` — read the current fee off the LabNFT's `mintFeeWei()` and send it as `value`, or the transaction reverts.

```solidity
function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId);
```

**Example (viem):**

```javascript
import {
  createPublicClient,
  createWalletClient,
  http,
  parseAbi,
  parseEventLogs,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains"; // use `base` + the mainnet addresses in production

const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90";
const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28";

const factoryAbi = parseAbi([
  "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)",
]);
const labNftAbi = parseAbi([
  "function mintFeeWei() external view returns (uint256)",
  "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)",
]);

const account = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY);
const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });
const walletClient = createWalletClient({ account, chain: baseSepolia, transport: http() });

const mintFeeWei = await publicClient.readContract({
  address: LABNFT_ADDRESS,
  abi: labNftAbi,
  functionName: "mintFeeWei",
});

const txHash = await walletClient.writeContract({
  address: FACTORY_ADDRESS,
  abi: factoryAbi,
  functionName: "mintAndCreateAccount",
  args: [account.address],
  value: mintFeeWei,
});

const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });

// The canonical oclId comes straight off the LabNFT's OclIdentityCreated
// event — no manual packing needed for the happy path.
const [identity] = parseEventLogs({
  abi: labNftAbi,
  eventName: "OclIdentityCreated",
  logs: receipt.logs.filter(
    (l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase(),
  ),
});

console.log("oclId:", identity.args.oclId);
console.log("tokenId:", identity.args.tokenId.toString());
console.log("labAccountAddress:", identity.args.account);
```

`OclIdentityCreated` fires on the LabNFT contract itself (not the factory); filter receipt logs to `LABNFT_ADDRESS` before decoding. The factory's own `AccountProvisioned` event confirms the account was deployed but does not carry `oclId` as a topic.

### How `oclId` Is Derived

`identity.args.oclId` above is already the value `createLab` expects — normalize to lowercase and you're done. For reference, or to cross-check the emitted value, `oclId` is a bit-packed `bytes32`:

| Bytes    | Field     | Value                                        |
| -------- | --------- | -------------------------------------------- |
| 1 (MSB)  | version   | `0x01`                                       |
| 1        | namespace | `0x01` (EVM)                                 |
| 10       | tokenId   | big-endian `uint80`                          |
| 20 (LSB) | account   | the ERC-6551 Token Bound Account, lowercased |

```javascript
function computeOclId(tokenId, accountAddress) {
  const packed =
    (0x01n << 248n) |
    (0x01n << 240n) |
    (tokenId << 160n) |
    BigInt(accountAddress.toLowerCase());
  return "0x" + packed.toString(16).padStart(64, "0");
}
```

Note the chain id is **not** encoded in `oclId` — it's implied by which `LabNFT` deployment minted the token.

Once you have `oclId`, continue to [Create Lab](#create-lab) below.

***

## Create Lab

Register a Kamu-backed lab (data room) for an onchain lab (OCL) that already exists onchain. The lab is identified by its canonical `oclId` (a 32-byte hex string, 0x-prefixed).

> **Prerequisite — the LabNFT must be minted first.** `createLab` does not mint anything; it attaches a dataroom to an OCL that already exists onchain. See [Mint the LabNFT](#mint-the-labnft) above for the contract call and how to derive `oclId` from the result. If you'd rather not touch the contracts directly, the Molecule app does this for you in [Step 1: Create Your Onchain Lab](/user-guides/scientists-researchers#step-1-create-your-onchain-lab).

> **Admin Authorization Required**: This mutation requires either a service token (JWT) from the Molecule team OR a valid Privy authentication token. The caller must be the LabNFT owner (or an authorized multisig signer) for the given `oclId`.

**GraphQL Mutation:**

```graphql
mutation CreateLab($oclId: String!) {
  createLab(input: { oclId: $oclId }) {
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
    lab {
      oclId
      shortname
      labAccountAddress
      labNftTokenId
    }
  }
}
```

**Parameters:**

The mutation takes a single `CreateLabInput` object:

| Field | Type   | Required | Description                                                   |
| ----- | ------ | -------- | ------------------------------------------------------------- |
| oclId | String | Yes      | Canonical 32-byte oclId (lowercase 0x-hex) of the onchain lab |

**Prerequisites:**

1. **LabNFT Ownership**: You must own the LabNFT for the `oclId` or be an authorized signer for it
   * For individual wallets: You must be the owner
   * For multisig/Safe wallets: You must be one of the Safe owners
   * For ERC-4337 accounts: You must be an authorized account owner
2. **Authentication**: One of the following:
   * **Service Token** (recommended for automation): Obtain from Molecule team via Discord
   * **Privy Token** (for user-initiated requests): Use your authenticated Privy session
3. **LabNFT Must Be Minted**: The onchain lab (LabNFT / `oclId`) must already exist onchain before registering the lab

**Authentication Options:**

**Option 1: Service Token (Recommended for Automation)**

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
X-Service-Token: YOUR_SERVICE_TOKEN
```

**Option 2: Privy Token (User-Initiated)**

```bash
Authorization: Bearer YOUR_PRIVY_TOKEN
x-wallet-address: YOUR_WALLET_ADDRESS
```

**Example Request (Service Token):**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } lab { oclId shortname labAccountAddress labNftTokenId } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042"
    }
  }'
```

**Success Response:**

```json
{
  "data": {
    "createLab": {
      "message": "Lab created successfully",
      "error": null,
      "lab": {
        "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
        "shortname": "apob-lab",
        "labAccountAddress": "0x1234567890123456789012345678901234567890",
        "labNftTokenId": "42"
      }
    }
  }
}
```

**Error Responses:**

`createLab` reports failures in-band: `error` is `null` on success and a full `ApiError` on failure. Branch on `error.code` (and, where documented, the `reason` key inside `error.details`, a JSON-encoded string) — never on message text. The top-level `message` mirrors `error.message` on failure.

**Not Authenticated (No Token):**

```json
{
  "data": {
    "createLab": {
      "message": "Authentication required. Please provide either a service token (x-service-token header) or Privy authentication token (Authorization header + x-wallet-address header). Contact Molecule tech team to obtain a service token.",
      "error": {
        "code": "UNAUTHENTICATED",
        "message": "Authentication required. Please provide either a service token (x-service-token header) or Privy authentication token (Authorization header + x-wallet-address header). Contact Molecule tech team to obtain a service token.",
        "requestId": "8f1e4c9a-2b7d-4e10-9c3a-5d6f7a8b9c0d",
        "retryable": false,
        "details": "{\"reason\":\"NO_AUTH\"}"
      },
      "lab": null
    }
  }
}
```

**Not the LabNFT Owner (Privy user token):**

```json
{
  "data": {
    "createLab": {
      "message": "You are not allowed to perform this operation.",
      "error": {
        "code": "UNAUTHORIZED",
        "message": "You are not allowed to perform this operation.",
        "requestId": "2b9c11d0-6f3e-4a71-8d52-c4e9b0a1f7d3",
        "retryable": false,
        "details": "{\"reason\":\"UNAUTHORIZED\"}"
      },
      "lab": null
    }
  }
}
```

**Lab Already Exists:**

```json
{
  "data": {
    "createLab": {
      "message": "Project already exists",
      "error": {
        "code": "CONFLICT",
        "message": "Project already exists",
        "requestId": "5c7d2e81-9a4b-4f06-b3e8-1d0f6a2c8e94",
        "retryable": false,
        "details": "{\"reason\":\"PROJECT_CONFLICT\"}"
      },
      "lab": null
    }
  }
}
```

**How It Works:**

1. **Authentication Check**: Validates service token or Privy token
2. **Onchain Verification**: Verifies you own or are an authorized signer for the LabNFT (`oclId`)
3. **Lab Creation**: Registers the Kamu-backed lab and its data room for the `oclId`
4. **Whitelist Update**: Automatically adds your wallet address to the lab whitelist
5. **Returns Result**: Lab details if successful, error details if failed

**Use Cases:**

* **Automate Lab Creation**: Register labs programmatically after minting LabNFTs
* **CI/CD Integration**: Automatically set up data rooms for new research labs
* **Batch Operations**: Register multiple labs for a portfolio of onchain labs
* **User Self-Service**: Allow users to create their own lab data rooms

**Getting Service Token Access:**

To obtain a service token for automated lab creation:

1. Join our [Discord community](https://t.co/L0VEiy4Bjk)
2. Contact the Molecule team
3. Provide:
   * Your wallet address
   * Use case description
   * Intended automation workflow
4. You'll receive:
   * Consumer credential (for all APIs)
   * Service Token (JWT for lab creation)
   * Token expiration date

***

## Get Single Project with Files

Retrieve complete details for a specific lab including all files. This is a **public endpoint** - no authentication required. Look up a lab by its `oclId` or, alternatively, by its human-readable `shortname` — provide exactly one.

> **🔓 Public Endpoint**: The `labWithDataRoomAndFiles` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed. File-level access control is handled via encryption rather than query-level authentication.

**GraphQL Query:**

```graphql
query GetProject($oclId: String!) {
  labWithDataRoomAndFiles(oclId: $oclId) {
    oclId
    shortname
    trlValue
    trlRationale
    isVerified
    dataRoom {
      id
      alias
      files {
        did
        path
        version
        contentType
        accessLevel
        description
        tags
        categories
        downloadUrl
      }
    }
  }
}
```

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query GetProject($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { oclId shortname dataRoom { id files { path contentType accessLevel tags } } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042"
    }
  }'
```

> The optional CMS-enriched fields `trlValue`, `trlRationale`, and `isVerified` (see [List All Projects](/api-reference/labs-api/browse-and-search#list-all-projects)) are also available on this query and are hydrated only when requested.

***

## LabNFT Metadata

### Update LabNFT Metadata

Partial update of the LabNFT display metadata (`name`, `description`, `image`, `externalUrl`). Omitted fields are left unchanged; an explicit `null` clears a field.

> **Authorization**: Restricted to the OCL admin (LabNFT owner + multisig signers).

```graphql
mutation UpdateLabNftMetadata(
  $oclId: String!
  $input: UpdateLabNftMetadataInput!
) {
  updateLabNftMetadata(oclId: $oclId, input: $input) {
    oclId
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter | Type                      | Required | Description                        |
| --------- | ------------------------- | -------- | ---------------------------------- |
| oclId     | String                    | Yes      | Canonical 32-byte oclId of the lab |
| input     | UpdateLabNftMetadataInput | Yes      | Patch object (all fields optional) |

`UpdateLabNftMetadataInput` fields (all optional): `name`, `description`, `image`, `externalUrl`.

### Generate LabNFT Image Upload URL

Generate a single-use presigned PUT URL to which the OCL admin uploads a LabNFT display image. `contentType` must be one of `image/jpeg`, `image/png`, `image/webp`, `image/gif`, or `image/svg+xml`. The public URL is patched onto the lab asynchronously by the image processor once the object lands in S3.

> **Authorization**: Restricted to the OCL admin (LabNFT owner + multisig signers).

```graphql
mutation GenerateLabImageUploadUrl($oclId: String!, $contentType: String!) {
  generateLabImageUploadUrl(oclId: $oclId, contentType: $contentType) {
    uploadUrl
    key
    expiresAt
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter   | Type   | Required | Description                                                                             |
| ----------- | ------ | -------- | --------------------------------------------------------------------------------------- |
| oclId       | String | Yes      | Canonical 32-byte oclId of the lab                                                      |
| contentType | String | Yes      | Image MIME type (`image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/svg+xml`) |

***

***

## Lab Members

### List Lab Members

Return the active members of a lab (owner, contributors, viewers), sourced from the indexed `ocl_user` table. Expired grants are excluded.

> **Public query** — only a consumer credential is required. The same data is also exposed on the public `Lab` / `LabRef.members` field.

```graphql
query ListLabMembers($oclId: String!) {
  listLabMembers(oclId: $oclId) {
    message
    members {
      walletAddress
      role
      source
      expiry
      isAgent
      grantedAt
    }
  }
}
```

Failures throw: they arrive as top-level GraphQL `errors[]` entries with `errorType` set to the catalogue code (an unknown `oclId` throws `NOT_FOUND`). The response carries `"data": null` (the field is non-nullable, so the error propagates to the root) and `errors[0].path` names `listLabMembers`.

**Parameters:**

| Parameter | Type   | Required | Description                        |
| --------- | ------ | -------- | ---------------------------------- |
| oclId     | String | Yes      | Canonical 32-byte oclId of the lab |

**Member fields:**

| Field         | Type            | Description                                                                                                            |
| ------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
| walletAddress | String          | Lowercased wallet address of the member                                                                                |
| role          | LabMemberRole   | Effective role: `OWNER`, `CONTRIBUTOR`, or `VIEWER`                                                                    |
| source        | LabMemberSource | Row that defines the membership: `ONCHAIN_EVENT`, `MULTISIG_RESOLUTION`, `ACCESS_CONTRACT`, or `ACCESS_RESOLVER_EVENT` |
| expiry        | String          | Unix-seconds expiry as a decimal string; `null` means the grant is permanent                                           |
| isAgent       | Boolean         | True if the member is an agent identity (surfaced for UI; not used for authorization)                                  |
| grantedAt     | String          | ISO-8601 timestamp the row was first persisted                                                                         |

***

***

## DID Linking

### Get DID Link Status

Public read-only snapshot of DID-linking state for an OCL. DID-linking runs automatically in the background after `createLab`; this query is for diagnostic and support visibility. No authentication required.

```graphql
query GetDidLinkStatus($oclId: String!) {
  getDidLinkStatus(oclId: $oclId) {
    message
    didLinkStatus {
      oclId
      status
      userOpHash
      txHash
      accountDid
      dataRoomDid
      linkedDidCount
      attempts
      updatedAt
    }
  }
}
```

Failures throw: they arrive as top-level GraphQL `errors[]` entries with `errorType` set to the catalogue code. The response carries `"data": null` (the field is non-nullable, so the error propagates to the root) and `errors[0].path` names `getDidLinkStatus`.

**Parameters:**

| Parameter | Type   | Required | Description                        |
| --------- | ------ | -------- | ---------------------------------- |
| oclId     | String | Yes      | Canonical 32-byte oclId of the lab |

`status` is a `DidLinkingStatus`: `PENDING`, `SUBMITTED`, `LINKED`, or `FAILED` (`null` before the first linking attempt). `linkedDidCount` reflects the number of active onchain DID links observed by the event indexer.

***


# Files

Working with files in a Lab dataroom: the three-step upload flow (initiate → upload → finish), plus announcements, metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](/api-reference/labs-api/lab-management).

> **Note**: Every mutation on this page returns its failure in-band: the result carries `error: ApiError`, and success means `error` is `null`. Branch on `error.code` — never on `message` text — and quote `requestId` when reporting a problem. See [Error Handling](/api-reference/labs-api#error-handling) for the `ApiError` shape, how to read `details`, and the list of error codes.

## Step 1: Initiate File Upload

Initiates the upload process and returns a presigned URL for direct file upload.

**GraphQL Mutation:**

```graphql
mutation InitiateFileUpload(
  $oclId: String!
  $contentType: String!
  $contentLength: Int!
) {
  initiateCreateOrUpdateFile(
    oclId: $oclId
    contentType: $contentType
    contentLength: $contentLength
  ) {
    uploadToken
    uploadUrl
    uploadUrlExpiry
    method
    headers {
      key
      value
    }
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter     | Type   | Required | Description                                                               |
| ------------- | ------ | -------- | ------------------------------------------------------------------------- |
| oclId         | String | Yes      | Canonical 32-byte oclId of the lab (lowercase 0x-hex, e.g. `0x0101…0042`) |
| contentType   | String | Yes      | MIME type of the file (e.g., `application/pdf`, `image/png`)              |
| contentLength | Int    | Yes      | File size in bytes                                                        |

**Example Request (curl):**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "mutation InitiateFileUpload($oclId: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { uploadToken uploadUrl uploadUrlExpiry method headers { key value } error { code message requestId retryable details } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "contentType": "application/pdf",
      "contentLength": 381846
    }
  }'
```

**Success Response:**

```json
{
  "data": {
    "initiateCreateOrUpdateFile": {
      "uploadToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "uploadUrl": "https://s3.amazonaws.com/bucket/path?signature=...",
      "uploadUrlExpiry": "2024-01-15T10:45:00.000Z",
      "method": "PUT",
      "headers": [
        {
          "key": "Content-Type",
          "value": "application/pdf"
        }
      ],
      "error": null
    }
  }
}
```

On failure `error` is set and the upload fields (`uploadToken`, `uploadUrl`, `uploadUrlExpiry`, `method`, `headers`) are `null`; do not proceed to Step 2 unless `error` is `null`.

## Step 2: Upload File to Storage

Upload the file directly to the presigned URL returned in Step 1.

**Example Request (curl):**

```bash
curl -X PUT "UPLOAD_URL_FROM_STEP_1" \
  -H "Content-Type: application/pdf" \
  --data-binary @your-file.pdf
```

**Example Request (JavaScript):**

```javascript
const uploadHeaders = {};
headers.forEach((h) => {
  uploadHeaders[h.key] = h.value;
});

const uploadResponse = await fetch(uploadUrl, {
  method: "PUT",
  headers: uploadHeaders,
  body: fileBuffer,
});

if (!uploadResponse.ok) {
  throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
```

## Step 3: Finish File Upload

Completes the upload process and registers the file in the dataroom.

**GraphQL Mutation:**

```graphql
mutation FinishFileUpload(
  $oclId: String!
  $uploadToken: String!
  $path: String
  $ref: String
  $accessLevel: String!
  $changeBy: String!
  $description: String
  $tags: [String!]
  $categories: [String!]
  $contentText: String
) {
  finishCreateOrUpdateFile(
    oclId: $oclId
    uploadToken: $uploadToken
    path: $path
    ref: $ref
    accessLevel: $accessLevel
    changeBy: $changeBy
    description: $description
    tags: $tags
    categories: $categories
    contentText: $contentText
  ) {
    datasetId
    contentHash
    version
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter   | Type      | Required | Description                                                 |
| ----------- | --------- | -------- | ----------------------------------------------------------- |
| oclId       | String    | Yes      | Same oclId used in Step 1                                   |
| uploadToken | String    | Yes      | Token received from Step 1                                  |
| path        | String    | No\*     | File name for NEW files (e.g., `research-data.pdf`)         |
| ref         | String    | No\*     | Dataset ID for NEW VERSIONS of existing files               |
| changeBy    | String    | Yes      | Wallet address of user making the change                    |
| description | String    | No       | Optional file description                                   |
| tags        | \[String] | No       | Optional tags for categorization                            |
| categories  | \[String] | No       | Optional categories for organization                        |
| contentText | String    | No       | Optional searchable text content (used for semantic search) |

*\*Use `path` for new files OR `ref` for versions - not both*

**Example Request (curl):**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "mutation FinishFileUpload($oclId: String!, $uploadToken: String!, $path: String, $accessLevel: String!, $changeBy: String!, $description: String, $tags: [String!], $categories: [String!], $contentText: String) { finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, description: $description, tags: $tags, categories: $categories, contentText: $contentText) { datasetId contentHash version message error { code message requestId retryable details } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "uploadToken": "TOKEN_FROM_STEP_1",
      "path": "research-results.pdf",
      "accessLevel": "PUBLIC",
      "changeBy": "0x1234567890123456789012345678901234567890",
      "description": "Q4 2024 Research Results",
      "tags": ["research", "results", "2024"],
      "categories": ["data"],
      "contentText": "Quarterly research findings and experimental data"
    }
  }'
```

**Success Response:**

```json
{
  "data": {
    "finishCreateOrUpdateFile": {
      "datasetId": "did:kamu:...",
      "contentHash": "sha256:abc123...",
      "version": 1,
      "message": "File uploaded successfully",
      "error": null
    }
  }
}
```

**Failure Response** (HTTP 200, no top-level `errors[]` — `message` mirrors `error.message`):

```json
{
  "data": {
    "finishCreateOrUpdateFile": {
      "datasetId": null,
      "contentHash": null,
      "version": null,
      "message": "You are not allowed to perform this operation.",
      "error": {
        "code": "UNAUTHORIZED",
        "message": "You are not allowed to perform this operation.",
        "requestId": "8f1e4c9a-2b7d-4e10-9c3a-5d6f7a8b9c0d",
        "retryable": false,
        "details": "{\"reason\":\"UNAUTHORIZED\"}"
      }
    }
  }
}
```

***

## Complete Example

Here's a complete Node.js example demonstrating the full 3-step workflow:

```javascript
#!/usr/bin/env node

const fs = require("fs");
const fetch = require("node-fetch");

async function uploadFileToLabs(filePath, oclId, serviceToken) {
  const apiUrl = "https://production.graphql.api.molecule.xyz/graphql";
  const fileBuffer = fs.readFileSync(filePath);
  const filename = require("path").basename(filePath);
  const fileSize = fs.statSync(filePath).size;

  try {
    // Step 1: Initiate upload
    console.log("Step 1: Initiating file upload...");
    const initiateResponse = await fetch(apiUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": process.env.CONSUMER_CREDENTIAL,
        "X-Service-Token": serviceToken,
      },
      body: JSON.stringify({
        query: `
          mutation InitiateFileUpload($oclId: String!, $contentType: String!, $contentLength: Int!) {
            initiateCreateOrUpdateFile(
              oclId: $oclId
              contentType: $contentType
              contentLength: $contentLength
            ) {
              uploadToken
              uploadUrl
              method
              headers { key value }
              error { code message requestId retryable details }
            }
          }
        `,
        variables: {
          oclId,
          contentType: "application/octet-stream",
          contentLength: fileSize,
        },
      }),
    });

    const initiateResult = await initiateResponse.json();
    if (initiateResult.errors?.length) {
      // Top-level errors on a mutation mean a transport or request-validation failure
      const e = initiateResult.errors[0];
      throw new Error(
        `${e.errorType ?? "Request failed"}: ${e.message}${
          e.errorInfo?.requestId ? ` (requestId ${e.errorInfo.requestId})` : ""
        }`,
      );
    }
    const initiateError = initiateResult.data.initiateCreateOrUpdateFile.error;
    if (initiateError) {
      throw new Error(
        `${initiateError.code}: ${initiateError.message} (requestId ${initiateError.requestId})`,
      );
    }

    const { uploadToken, uploadUrl, headers } =
      initiateResult.data.initiateCreateOrUpdateFile;
    console.log("✅ Upload initiated");

    // Step 2: Upload to presigned URL
    console.log("Step 2: Uploading file to storage...");
    const uploadHeaders = {};
    headers?.forEach((h) => {
      uploadHeaders[h.key] = h.value;
    });

    const uploadResponse = await fetch(uploadUrl, {
      method: "PUT",
      headers: uploadHeaders,
      body: fileBuffer,
    });

    if (!uploadResponse.ok) {
      throw new Error(`Upload failed: ${uploadResponse.statusText}`);
    }
    console.log("✅ File uploaded to storage");

    // Step 3: Finish upload
    console.log("Step 3: Finalizing upload...");
    const finishResponse = await fetch(apiUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": process.env.CONSUMER_CREDENTIAL,
        "X-Service-Token": serviceToken,
      },
      body: JSON.stringify({
        query: `
          mutation FinishFileUpload(
            $oclId: String!
            $uploadToken: String!
            $path: String!
            $accessLevel: String!
            $changeBy: String!
          ) {
            finishCreateOrUpdateFile(
              oclId: $oclId
              uploadToken: $uploadToken
              path: $path
              accessLevel: $accessLevel
              changeBy: $changeBy
            ) {
              datasetId
              message
              error { code message requestId retryable details }
            }
          }
        `,
        variables: {
          oclId,
          uploadToken,
          path: filename,
          accessLevel: "PUBLIC",
          changeBy: process.env.WALLET_ADDRESS,
        },
      }),
    });

    const finishResult = await finishResponse.json();
    if (finishResult.errors?.length) {
      const e = finishResult.errors[0];
      throw new Error(
        `${e.errorType ?? "Request failed"}: ${e.message}${
          e.errorInfo?.requestId ? ` (requestId ${e.errorInfo.requestId})` : ""
        }`,
      );
    }
    const finishError = finishResult.data.finishCreateOrUpdateFile.error;
    if (finishError) {
      throw new Error(
        `${finishError.code}: ${finishError.message} (requestId ${finishError.requestId})`,
      );
    }

    console.log("🎉 File upload completed successfully!");
    console.log(
      "Dataset ID:",
      finishResult.data.finishCreateOrUpdateFile.datasetId,
    );

    return {
      success: true,
      datasetId: finishResult.data.finishCreateOrUpdateFile.datasetId,
    };
  } catch (error) {
    console.error("❌ Upload failed:", error.message);
    throw error;
  }
}

// Usage
if (require.main === module) {
  const filePath = process.argv[2];
  const oclId = process.argv[3];
  const serviceToken = process.env.SERVICE_TOKEN;

  if (!filePath || !oclId || !serviceToken) {
    console.error(
      'Usage: SERVICE_TOKEN="token" WALLET_ADDRESS="0x..." node upload.js <file> <ocl-id>',
    );
    process.exit(1);
  }

  uploadFileToLabs(filePath, oclId, serviceToken);
}

module.exports = { uploadFileToLabs };
```

**Usage:**

```bash
CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" SERVICE_TOKEN="your-service-token" WALLET_ADDRESS="0x..." node upload.js data.pdf 0x0101000000000000000000000000000000000000000000000000000000000042
```

***

## Create Announcement

Create project announcements to share updates with your community.

**GraphQL Mutation:**

```graphql
mutation CreateAnnouncement(
  $oclId: String!
  $headline: String!
  $body: String!
  $attachments: [String!]
) {
  createAnnouncement(
    oclId: $oclId
    headline: $headline
    body: $body
    attachments: $attachments
  ) {
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter   | Type      | Required | Description                                      |
| ----------- | --------- | -------- | ------------------------------------------------ |
| oclId       | String    | Yes      | Canonical 32-byte oclId of the lab               |
| headline    | String    | Yes      | Announcement title/headline                      |
| body        | String    | Yes      | Announcement body (supports Markdown)            |
| attachments | \[String] | No       | Array of file DIDs to attach to the announcement |

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { message error { code message requestId retryable details } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "headline": "Research Milestone Achieved",
      "body": "We have completed Phase 2 trials with promising results.",
      "attachments": ["did:kamu:fed01..."]
    }
  }'
```

***

## Update File Metadata

Update file metadata (description, tags, categories, access level) without creating a new version.

**GraphQL Mutation:**

```graphql
mutation UpdateFileMetadata(
  $oclId: String!
  $ref: String!
  $accessLevel: String!
  $description: String
  $tags: [String!]
  $categories: [String!]
  $contentText: String
) {
  updateFileMetadata(
    oclId: $oclId
    ref: $ref
    accessLevel: $accessLevel
    description: $description
    tags: $tags
    categories: $categories
    contentText: $contentText
  ) {
    ref
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter   | Type      | Required | Description                                                   |
| ----------- | --------- | -------- | ------------------------------------------------------------- |
| oclId       | String    | Yes      | Canonical 32-byte oclId of the lab                            |
| ref         | String    | Yes      | File reference (DID) from `finishCreateOrUpdateFile` response |
| description | String    | No       | Updated file description                                      |
| tags        | \[String] | No       | Updated tags for categorization                               |
| categories  | \[String] | No       | Updated categories for organization                           |
| contentText | String    | No       | Updated searchable text content                               |

> **Note**: The `changeBy` field (wallet address) is automatically derived from your authentication and does not need to be provided as a parameter.

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "mutation UpdateFileMetadata($oclId: String!, $ref: String!, $accessLevel: String!, $description: String, $tags: [String!], $categories: [String!], $contentText: String) { updateFileMetadata(oclId: $oclId, ref: $ref, accessLevel: $accessLevel, description: $description, tags: $tags, categories: $categories, contentText: $contentText) { ref message error { code message requestId retryable details } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "ref": "did:kamu:fed01...",
      "accessLevel": "PUBLIC",
      "description": "Updated research findings with peer review",
      "tags": ["research", "peer-reviewed", "2024"],
      "categories": ["data", "validated"],
      "contentText": "Enhanced searchable content with key findings"
    }
  }'
```

***

## Delete File

Remove a file from the dataroom permanently.

**GraphQL Mutation:**

```graphql
mutation DeleteFile($oclId: String!, $path: String!, $changeBy: String!) {
  deleteDataRoomFile(oclId: $oclId, path: $path, changeBy: $changeBy) {
    oclId
    filePath
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter | Type   | Required | Description                        |
| --------- | ------ | -------- | ---------------------------------- |
| oclId     | String | Yes      | Canonical 32-byte oclId of the lab |
| path      | String | Yes      | File path to delete                |
| changeBy  | String | Yes      | Wallet address making the deletion |

> **Warning**: This is a destructive operation. The file will be permanently deleted from the dataroom and cannot be recovered.

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "mutation DeleteFile($oclId: String!, $path: String!, $changeBy: String!) { deleteDataRoomFile(oclId: $oclId, path: $path, changeBy: $changeBy) { oclId filePath error { code message requestId retryable details } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "path": "old-data.pdf",
      "changeBy": "0x1234567890123456789012345678901234567890"
    }
  }'
```

***

## Get File by Path

Retrieve a specific file using the lab's `oclId` and the file path.

**GraphQL Query:**

```graphql
query GetFile($oclId: String!, $path: String!) {
  dataRoomFile(oclId: $oclId, path: $path) {
    did
    path
    version
    contentType
    accessLevel
    description
    tags
    categories
    contentText
    downloadUrl
    downloadUrlExpiry
  }
}
```

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "query GetFile($oclId: String!, $path: String!) { dataRoomFile(oclId: $oclId, path: $path) { did path contentType accessLevel downloadUrl } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "path": "research-data.pdf"
    }
  }'
```

***

## File Categories & Tags

### Get File Categories and Tags

Return the valid file categories and their tags from the CMS. Use these when tagging or categorizing files. **Public query** — no authentication required.

```graphql
query FileCategoriesAndTags {
  fileCategoriesAndTags {
    data {
      name
      tags
    }
  }
}
```

Each entry in `data` is a `FileCategory` with a `name` and its list of allowed `tags`. Failures arrive as top-level GraphQL `errors[]` entries with `errorType` set to the catalogue code (e.g. `UPSTREAM_UNAVAILABLE`); the result type has no `error` field.

***

## File Requirements & Limits

### Storage Limits

* **Default Limit**: 5GB per lab/project
* **Custom Limits**: Can be increased upon request - contact the Molecule team
* **Note**: the Labs web app additionally caps individual uploads at 100 MB per file; API uploads are not subject to that app-side cap

### Supported File Types

* All file types are supported
* Common types: PDF, PNG, JPEG, CSV, JSON, ZIP, etc.

### Optional Metadata

Enhance file discoverability with optional metadata:

* **description**: Human-readable description of the file
* **tags**: Array of tags for categorization (e.g., `["research", "q4-2024"]`)
* **categories**: Array of categories for organization (e.g., `["data", "results"]`)
* **contentText**: Searchable text content for full-text search

***

## Advanced: Encrypted File Upload

For files requiring client-side encryption, obtain a data encryption key via the `generateDataEncryptionKey` mutation, encrypt locally, upload as normal, and include an `encryptionMetadata` object on `finishCreateOrUpdateFile`. The full end-to-end model — key wrapping, onchain access conditions, and condition-gated decryption — is documented on the [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) page.

### Obtain a DEK, then encrypt locally

`generateDataEncryptionKey` (no arguments) returns `plaintextDEK`, `encryptedDek`, and `encryptionSystem`. The client uses `plaintextDEK` to AES-256-GCM encrypt the file locally (Web Crypto `SubtleCrypto`), then wipes it from memory. The upload itself uses the standard `initiateCreateOrUpdateFile` → PUT → `finishCreateOrUpdateFile` flow, with the encrypted bytes uploaded to the presigned URL.

### Encryption Metadata Parameter (Onchain-Verified Envelope Encryption, current default)

```graphql
$encryptionMetadata: EncryptionMetadataInput
```

```json
{
  "encryptionMetadata": {
    "encryptionSystem": "<echo value returned by generateDataEncryptionKey>",
    "encryptedDek": "BASE64_WRAPPED_DEK",
    "iv": "BASE64_AES_GCM_IV",
    "contentHash": "sha256-...",
    "accessControlConditions": "[{...}]",
    "encryptedBy": "0x1234567890123456789012345678901234567890",
    "encryptedAt": "2026-01-15T10:30:00.000Z"
  }
}
```

`encryptionSystem` is **backend-set** — clients must echo the value returned by `generateDataEncryptionKey` rather than hardcode it. This keeps the roadmap rollover to BLS threshold key custody transparent to existing integrations.

#### `accessControlConditions` — gating decryption by role

`accessControlConditions` is a JSON-stringified array of `EvmContractCondition` predicates joined by `BooleanCondition` separators (`and` / `or`). The backend evaluates each predicate against live chain state at decrypt time via viem `readContract`, short-circuits booleans, and fails closed on RPC error. To gate decryption on *LabNFT owner OR active Contributor OR active Viewer*, OR `AccessResolver.isAuthorizedSignerForTba(:userAddress, tba)` against `AccessResolver.hasRole(oclId, :userAddress, ROLE_VIEWER)` — the role-hierarchy collapses Contributor + Viewer into one check on the canonical chain (Base).

The placeholder `:userAddress` in `functionParams` is substituted with the authenticated caller's wallet at evaluate time. The full `EvmContractCondition` JSON shape, the worked OR-composite example, and condition-evaluator semantics are documented on the [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access#worked-example-encrypt-for-owner-or-contributor-or-viewer) page.

#### Role Management (onchain, off this API surface)

Role grants are **onchain transactions on the `AccessResolver` contract**, not Labs API mutations. Lab owners (and active Contributors, for the Viewer slot) call `grantRole(oclId, account, role, expiry, isAgent)` / `revokeRole(oclId, account)` directly via viem / ethers / Safe. The Labs API only *consumes* role state at decrypt time through the `accessControlConditions` evaluator. See [Roles & Permissions](/technical-deep-dive/roles-and-permissions) for the capability matrix, grant lifecycle (expiry, `isAgent`), and the [`AccessResolver` reference](/references/contracts/accessresolver) for the onchain interface.

**When to Use Encryption:**

* Sensitive research data requiring access control
* Compliance requirements for data protection
* Conditional access based on token ownership or lab role

***

## Data Encryption Keys

### Generate a Data Encryption Key

Generate a standalone data encryption key (DEK) for client-side encryption outside the file-upload flow. Returns both the plaintext DEK (used to encrypt data locally, then wiped) and the KMS-encrypted DEK (stored alongside the ciphertext). Requires authentication (Privy user or service token). See [Advanced: Encrypted File Upload](#advanced-encrypted-file-upload) for the file-upload encryption path.

```graphql
mutation GenerateDataEncryptionKey {
  generateDataEncryptionKey {
    plaintextDEK
    encryptedDek
    encryptionSystem
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

| Field            | Type     | Description                                                |
| ---------------- | -------- | ---------------------------------------------------------- |
| plaintextDEK     | String   | Base64-encoded plaintext DEK (only present on success)     |
| encryptedDek     | String   | Base64-encoded KMS-encrypted DEK (only present on success) |
| encryptionSystem | String   | Encryption system used (always `"kms"`)                    |
| error            | ApiError | `null` on success; non-null means the mutation failed      |

***


# Browse & Search

Cross-cutting read operations that aren't scoped to a single lab you administer: browsing and reading labs and their files, full-text search, and activity feeds. Reads tied to a specific resource live with that resource — e.g. members and DID-link status in [Lab Management](/api-reference/labs-api/lab-management), and legal-agreement status in [Legal Agreements](/api-reference/labs-api/legal-agreements).

## Listing Labs & Activity

Query operations for listing all labs and reading their activity feeds. To read a single lab and its data-room files, see [Get Single Project with Files](/api-reference/labs-api/lab-management#get-single-project-with-files); to read one file, see [Get File by Path](/api-reference/labs-api/files#get-file-by-path).

### List All Projects

Get all labs. This is a **public endpoint** - no authentication required.

> **🔓 Public Endpoint**: The `labs` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed.

**GraphQL Query:**

```graphql
query ListProjects($walletAddress: String, $page: Int, $perPage: Int) {
  labs(walletAddress: $walletAddress, page: $page, perPage: $perPage) {
    nodes {
      oclId
      shortname
      name
      description
      labAccountAddress
      labNftTokenId
      latestContributionAt
      trlValue
      trlRationale
      isVerified
    }
    totalCount
    pageInfo {
      hasNextPage
      hasPreviousPage
      currentPage
      totalPages
    }
  }
}
```

> The `labs` list returns lightweight `LabRef` objects. Data-room contents and account details are not part of `LabRef` — fetch them per lab via [`labWithDataRoomAndFiles`](/api-reference/labs-api/lab-management#get-single-project-with-files).

**Parameters:**

| Parameter     | Type          | Required | Description                                                                                                                          |
| ------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| walletAddress | String        | No       | Filter to labs where this wallet holds any active role (owner/contributor/viewer). Omit to return all labs.                          |
| role          | LabMemberRole | No       | Only meaningful with `walletAddress`: restrict to labs where the wallet holds this specific role (`OWNER`, `CONTRIBUTOR`, `VIEWER`). |
| page          | Int           | No       | Page number (0-indexed, default: 0)                                                                                                  |
| perPage       | Int           | No       | Results per page (default: 20, max: 100)                                                                                             |

**CMS-enriched fields (optional):**

These fields are sourced from the Molecule CMS and hydrated only when requested in the selection set. They are `null` when the project has no corresponding CMS entry.

| Field               | Type     | Description                                                                                                                        |
| ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| trlValue            | String   | Technology Readiness Level (TRL) assessment for the project                                                                        |
| trlRationale        | String   | Explanation supporting the assigned TRL value                                                                                      |
| trlLastUpdated      | DateTime | Timestamp of the last change to `trlValue`                                                                                         |
| weightedScore       | Float    | AI-generated weighted project score derived from the `trlValue`                                                                    |
| scoreInterpretation | String   | Human-readable summary of the overall assessment behind `weightedScore`                                                            |
| criterionScores     | \[JSON]  | Per-criterion breakdown behind `weightedScore`. Each entry is a JSON object shaped like `{ "criterion": String, "score": Number }` |
| scoredAt            | DateTime | Timestamp the project scoring behind `weightedScore` was last computed                                                             |
| todos               | \[JSON]  | AI-generated action items for the lab, each a JSON object shaped like `{ "todo": String, "completed": Boolean }`                   |
| isVerified          | Boolean  | Whether the project has been verified by Molecule                                                                                  |

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query ListProjects($page: Int, $perPage: Int) { labs(page: $page, perPage: $perPage) { nodes { oclId shortname name labAccountAddress trlValue } totalCount pageInfo { hasNextPage currentPage totalPages } } }",
    "variables": {
      "page": 0,
      "perPage": 20
    }
  }'
```

### Project Activity Feed

Get activity timeline for a specific project including file events and announcements. This is a **public endpoint** - no authentication required.

> **🔓 Public Endpoint**: The `labActivity` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed.

> **Filtering**: By default, returns all activity types (file events and announcements). Use the optional `filter` parameter (`ANNOUNCEMENT` or `FILE`) to retrieve only a specific type.

**GraphQL Query:**

```graphql
query GetProjectActivity(
  $id: String!
  $page: Int!
  $perPage: Int!
  $filter: LabActivityFilter
) {
  labActivity(oclId: $id, page: $page, perPage: $perPage, filter: $filter) {
    pageInfo {
      hasNextPage
      hasPreviousPage
      currentPage
      totalPages
    }
    nodes {
      __typename
      ... on LabEventFileAdded {
        entry {
          ref
          path
          tags
          description
          version
          accessLevel
          eventTime
          systemTime
          changeBy
          categories
          contentType
          contentHash
          contentText
        }
      }
      ... on LabEventFileUpdated {
        entry {
          ref
          path
          tags
          description
          version
          accessLevel
          eventTime
          systemTime
          changeBy
          categories
          contentType
          contentHash
          contentText
        }
      }
      ... on LabEventFileRemoved {
        entry {
          ref
          path
          tags
          description
          version
          accessLevel
          eventTime
          systemTime
          changeBy
          categories
          contentType
          contentHash
          contentText
        }
      }
      ... on LabEventAnnouncement {
        announcement {
          id
          headline
          body
          attachments {
            id
            did
            path
            name
            contentType
            accessLevel
          }
          changeBy
          systemTime
          eventTime
        }
      }
    }
  }
}
```

> **⚠️ Breaking Change**: Announcement `attachments` changed from `[String!]!` (array of DIDs) to `[DataRoomFile!]!` (array of file objects). This enables querying file metadata directly without separate API calls.

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query GetActivity($oclId: String!, $page: Int) { labActivity(oclId: $oclId, page: $page, perPage: 20) { pageInfo { hasNextPage currentPage totalPages } nodes { __typename ... on LabEventAnnouncement { announcement { headline attachments { did path contentType } } } } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "page": 0
    }
  }'
```

**Use Cases:**

* Announcement detail pages requiring full file metadata
* Download links for announcement attachments
* Encrypted file access (Onchain-Verified Envelope Encryption for new files)
* Projects with many announcements (efficient pagination)

### Global Activity Feed

Get all activity across all projects. This is a **public endpoint** - no authentication required.

> **🔓 Public Endpoint**: The `activities` query does not require authentication. You only need a consumer credential (`Authorization: Bearer`) - no Service Token is needed.

> **Filtering**: By default, returns all activity types (file events and announcements). Use the optional `filter` parameter (`ANNOUNCEMENT` or `FILE`) to retrieve only a specific type.

**GraphQL Query:**

```graphql
query GetActivities($page: Int, $perPage: Int, $filter: LabActivityFilter) {
  activities(page: $page, perPage: $perPage, filter: $filter) {
    activities {
      __typename
      ... on LabEventFileAdded {
        entry {
          ref
          path
          tags
          description
          version
          accessLevel
          eventTime
          systemTime
          changeBy
          categories
          contentType
          contentHash
          contentText
        }
      }
      ... on LabEventFileUpdated {
        entry {
          ref
          path
          tags
          description
          version
          accessLevel
          eventTime
          systemTime
          changeBy
          categories
          contentType
          contentHash
          contentText
        }
      }
      ... on LabEventFileRemoved {
        entry {
          ref
          path
          tags
          description
          version
          accessLevel
          eventTime
          systemTime
          changeBy
          categories
          contentType
          contentHash
          contentText
        }
      }
      ... on LabEventAnnouncement {
        announcement {
          id
          headline
          body
          attachments {
            id
            did
            path
            name
            contentType
            accessLevel
          }
          changeBy
          systemTime
          eventTime
        }
      }
    }
  }
}
```

> **Errors**: a failed `activities` query returns `data: null` with a top-level GraphQL `errors[]` entry whose `errorType` is the error code — see [Error Handling](/api-reference/labs-api#error-handling).

***

## Searching Labs

Perform semantic search across all projects, files, and announcements in the Labs ecosystem.

**GraphQL Query:**

```graphql
query SearchLabs(
  $prompt: String!
  $filters: SearchLabsFilters
  $page: Int
  $perPage: Int
) {
  searchLabs(
    prompt: $prompt
    filters: $filters
    page: $page
    perPage: $perPage
  ) {
    nodes {
      __typename
      ... on SearchLabsFileHit {
        entry {
          lab {
            oclId
            shortname
          }
          path
          file {
            did
            contentType
            accessLevel
            description
            tags
            categories
            downloadUrl
          }
        }
      }
      ... on SearchLabsAnnouncementHit {
        announcement {
          id
          headline
          body
          systemTime
          attachments {
            id
            did
            path
            name
            contentType
            accessLevel
          }
        }
        lab {
          oclId
          shortname
        }
      }
    }
    totalCount
    pageInfo {
      hasNextPage
      hasPreviousPage
      currentPage
      totalPages
    }
  }
}
```

**Parameters:**

| Parameter | Type              | Required | Description                    |
| --------- | ----------------- | -------- | ------------------------------ |
| prompt    | String            | Yes      | Search query text              |
| filters   | SearchLabsFilters | No       | Filter criteria                |
| page      | Int               | No       | Page number (default: 0)       |
| perPage   | Int               | No       | Results per page (default: 10) |

**Available Filters:**

| Filter       | Type       | Description                   |
| ------------ | ---------- | ----------------------------- |
| byOclIds     | \[String!] | Filter by specific lab oclIds |
| byTags       | \[String!] | Filter files by tags          |
| byCategories | \[String!] | Filter files by categories    |
| byKinds      | \[String!] | Filter by result type         |

**Example - Basic Search:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "query SearchLabs($prompt: String!, $page: Int, $perPage: Int) { searchLabs(prompt: $prompt, page: $page, perPage: $perPage) { nodes { __typename ... on SearchLabsFileHit { entry { lab { oclId shortname } path file { contentType description tags } } } ... on SearchLabsAnnouncementHit { announcement { headline body } lab { shortname } } } totalCount pageInfo { hasNextPage currentPage totalPages } } }",
    "variables": {
      "prompt": "cancer research",
      "page": 0,
      "perPage": 10
    }
  }'
```

**Example - Filtered Search:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
  -d '{
    "query": "query SearchLabs($prompt: String!, $filters: SearchLabsFilters) { searchLabs(prompt: $prompt, filters: $filters) { nodes { __typename ... on SearchLabsFileHit { entry { path file { tags accessLevel } } } } totalCount } }",
    "variables": {
      "prompt": "experimental data",
      "filters": {
        "byAccessLevels": ["PUBLIC"],
        "byTags": ["research", "validated"]
      }
    }
  }'
```

**Understanding Results:**

Search results are returned as a union type. Use the `__typename` field to determine result type:

* **SearchLabsFileHit**: File search result
  * Access via: `entry.file`
  * Contains: file metadata, tags, categories, download URL
* **SearchLabsAnnouncementHit**: Announcement search result
  * Access via: `announcement`
  * Contains: headline, body, lab reference, **typed attachments** (file objects)

**JavaScript Example:**

```javascript
const searchResults = await fetch(apiUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": process.env.CONSUMER_CREDENTIAL,
    "X-Service-Token": process.env.SERVICE_TOKEN,
  },
  body: JSON.stringify({
    query: `query SearchLabs($prompt: String!) {
      searchLabs(prompt: $prompt) {
        nodes {
          __typename
          ... on SearchLabsFileHit {
            entry {
              path
              file { description tags }
            }
          }
          ... on SearchLabsAnnouncementHit {
            announcement {
              headline
              attachments {
                did
                path
                contentType
                accessLevel
              }
            }
          }
        }
        totalCount
      }
    }`,
    variables: { prompt: "latest results" },
  }),
});

const { nodes, totalCount } = (await searchResults.json()).data.searchLabs;

// Handle different result types
nodes.forEach((node) => {
  if (node.__typename === "SearchLabsFileHit") {
    console.log("File:", node.entry.path);
  } else if (node.__typename === "SearchLabsAnnouncementHit") {
    console.log("Announcement:", node.announcement.headline);
    // NEW: Attachments are now full file objects
    node.announcement.attachments.forEach((file) => {
      console.log("  Attachment:", file.path, file.contentType);
    });
  }
});
```

***

## Onchain Activity

### Onchain Activity Feed

Return the onchain event feed for an OCL or a wallet. Exactly one of `oclId` / `wallet` must be supplied. Paginate with a cursor of the form `"<block_number>:<log_index>"` — pass the last row's `id` to fetch the next page.

```graphql
query OnChainActivity(
  $oclId: String
  $wallet: String
  $limit: Int
  $cursor: String
) {
  onChainActivity(
    oclId: $oclId
    wallet: $wallet
    limit: $limit
    cursor: $cursor
  ) {
    id
    chainId
    contractAddress
    contractName
    eventName
    blockNumber
    blockTimestamp
    txHash
    logIndex
    args
  }
}
```

**Parameters:**

| Parameter | Type   | Required | Description                                                        |
| --------- | ------ | -------- | ------------------------------------------------------------------ |
| oclId     | String | No\*     | Canonical 32-byte oclId of the lab                                 |
| wallet    | String | No\*     | Wallet address to filter events by                                 |
| limit     | Int    | No       | Max rows to return (default: 50)                                   |
| cursor    | String | No       | Pagination cursor `"<block_number>:<log_index>"` (last row's `id`) |

\* Provide exactly one of `oclId` or `wallet`. `contractName` is one of `accessresolver`, `ocl`, `ipnft` or `ipt`. `args` is a JSON object of the decoded event arguments (BigInts as decimal strings, addresses lowercased).


# Legal Agreements

The legal-agreement flow is three operations: fetch the populated template + `contentHash`, sign it as an EIP-712 typed-data payload, then submit the signature. The backend regenerates and verifies the document server-side and stores the signed artifact in the lab's data room.

> `type` is a `LegalAgreementType` enum. Current value: `ASSIGNMENT_AGREEMENT`.

## EIP-712 Envelope

Every agreement type shares **one** signature schema — `LegalAgreementAcceptance` — forever; adding new agreement types never changes it, since the agreement type is itself a signed field. Sign this typed-data payload with the wallet returned by `legalAgreementTemplate`, then submit the signature to `signLegalAgreement` below.

### Domain

| Field               | Value                                                                                                                                                                                                            |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`              | `"MoleculeOcl"`                                                                                                                                                                                                  |
| `version`           | `"1"`                                                                                                                                                                                                            |
| `chainId`           | the **deployment's L2 chain** — `84532` (Base Sepolia) on dev/staging, `8453` (Base) in production. This is the chain the LabNFT lives on, not the OCL "canonical chain id" used for CREATE2 address derivation. |
| `verifyingContract` | the environment's LabNFT proxy, **lowercased** — `0x13ff210695fdb54a7f928eccc28bc3486c05bb28` (dev/staging), `0x9f96027eeafb9ad5f2b5d7043b36ee96b2eebe92` (production)                                           |

### Types

```
LegalAgreementAcceptance(
  bytes32 oclId,           // lowercased 0x… 32-byte lab id
  string  agreementType,   // registry SLUG — e.g. "assignment-agreement" — NOT the GraphQL enum value
  bytes32 contentHash,     // contentHash from legalAgreementTemplate, echoed verbatim
  string  templateVersion, // templateVersion from legalAgreementTemplate, echoed verbatim
  address signer,          // lowercased signer wallet — must equal walletAddress
  uint64  issuedAt         // unix seconds, echoed verbatim from legalAgreementTemplate
)
```

> **`agreementType` is the registry slug, not the `LegalAgreementType` enum value.** The GraphQL enum (`type: LegalAgreementType`) uses `ASSIGNMENT_AGREEMENT`; the signed `agreementType` field uses the human-readable slug the wallet renders to the user. Signing with the enum value instead of the slug produces a digest the backend can't match — `signLegalAgreement` returns `error.code` `UNAUTHENTICATED` with `details.reason` `INVALID_SIGNATURE`.

| `LegalAgreementType` (GraphQL enum) | `agreementType` (signed slug) |
| ----------------------------------- | ----------------------------- |
| `ASSIGNMENT_AGREEMENT`              | `assignment-agreement`        |

Verification is off-chain (viem `verifyTypedData` — EOA and EIP-1271, so Safe/smart-contract wallets work) against the LabNFT's current owner. `verifyingContract` is the LabNFT proxy purely as wallet-rendered scope and for forward compatibility with onchain verification — verification itself does not call the contract.

### Building the Typed Data (viem)

```javascript
const typedData = {
  domain: {
    name: "MoleculeOcl",
    version: "1",
    chainId: 84532, // 8453 in production
    verifyingContract: "0x13ff210695fdb54a7f928eccc28bc3486c05bb28", // per-environment LabNFT proxy, lowercased
  },
  types: {
    LegalAgreementAcceptance: [
      { name: "oclId", type: "bytes32" },
      { name: "agreementType", type: "string" },
      { name: "contentHash", type: "bytes32" },
      { name: "templateVersion", type: "string" },
      { name: "signer", type: "address" },
      { name: "issuedAt", type: "uint64" },
    ],
  },
  primaryType: "LegalAgreementAcceptance",
  message: {
    oclId: oclId.toLowerCase(),
    agreementType: "assignment-agreement", // registry slug for ASSIGNMENT_AGREEMENT
    contentHash, // from legalAgreementTemplate, verbatim
    templateVersion, // from legalAgreementTemplate, verbatim
    signer: walletAddress.toLowerCase(),
    issuedAt: BigInt(issuedAt), // from legalAgreementTemplate, verbatim
  },
};

const signature = await walletClient.signTypedData(typedData);
```

Pass `signature` — plus the same `issuedAt`, and any `signerName` / `entity` / `title` you got from the template call — into [Sign Legal Agreement](#sign-legal-agreement-mutation) below.

### Test Vector

Use this to verify your EIP-712 implementation produces byte-identical output before wiring it up against a live wallet. Signed with the well-known Anvil/Hardhat default test account #0 — **never use this key outside local testing.**

| Field       | Value                                                                |
| ----------- | -------------------------------------------------------------------- |
| private key | `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` |
| signer      | `0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266`                         |

Domain:

```json
{
  "name": "MoleculeOcl",
  "version": "1",
  "chainId": 84532,
  "verifyingContract": "0x13ff210695fdb54a7f928eccc28bc3486c05bb28"
}
```

Message:

```json
{
  "oclId": "0x0101000000000000000000007777777777777777777777777777777777777777",
  "agreementType": "assignment-agreement",
  "contentHash": "0xc0ffee0000000000000000000000000000000000000000000000000000000042",
  "templateVersion": "1.0.0",
  "signer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
  "issuedAt": 1781136000
}
```

Expected output:

| Name                             | Value                                                                                                                                  |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| EIP-712 digest (`hashTypedData`) | `0x054f310942709f9aabf643fec035b1b9a726356b4e78f0f5c1f93ae9ad659ba8`                                                                   |
| Signature (`signTypedData`)      | `0x327ecaa2da9382370bda32176794a231154e25be5d0e37f0fd4011ac067d94d752d816db038764f8a4a251457a42d2df3bbbc710b3909af421e5c7984c4464e61b` |

If your implementation doesn't reproduce these, check first for mixed-case addresses (EIP-712 hashes raw bytes so case doesn't affect the digest, but some libraries reject invalid-checksum mixed case before they get that far — use lowercase throughout) and for `issuedAt` sent as a `number` instead of the `uint64`/`bigint` the type expects.

## Get Legal Agreement Template (query)

Return the populated agreement the given wallet is expected to sign, plus the `contentHash` for the EIP-712 envelope. Read-only.

```graphql
query LegalAgreementTemplate(
  $oclId: String!
  $type: LegalAgreementType!
  $walletAddress: String!
  $signerName: String
  $entity: String
  $title: String
) {
  legalAgreementTemplate(
    oclId: $oclId
    type: $type
    walletAddress: $walletAddress
    signerName: $signerName
    entity: $entity
    title: $title
  ) {
    agreement
    contentHash
    templateVersion
    agreementType
    issuedAt
  }
}
```

This is a query: failures arrive as top-level GraphQL `errors[]` entries with `errorType` set to a catalogue code (e.g. `UNAUTHENTICATED`, `UNAUTHORIZED`, `NOT_FOUND`, `VALIDATION_FAILED`), and `data` comes back `null` (the field is non-nullable, so the error propagates to the root). The result type has no `error` field.

**Parameters:**

| Parameter     | Type               | Required | Description                                                                                           |
| ------------- | ------------------ | -------- | ----------------------------------------------------------------------------------------------------- |
| oclId         | String             | Yes      | Canonical 32-byte oclId of the lab                                                                    |
| type          | LegalAgreementType | Yes      | Agreement type (`ASSIGNMENT_AGREEMENT`)                                                               |
| walletAddress | String             | Yes      | Intended signer. On the user-auth path this must equal the authenticated wallet                       |
| signerName    | String             | No       | Signer identity (natural person). Echoed VERBATIM into `signLegalAgreement`; covered by `contentHash` |
| entity        | String             | No       | Signing entity, if the signer represents an organization                                              |
| title         | String             | No       | Signer title                                                                                          |

> `agreement` is for display only — the client must NOT re-serialize or re-hash it; sign over `contentHash` as given, and echo `issuedAt`, `signerName`, `entity`, and `title` back unchanged at sign time or the regenerated hash won't match.

## Check Legal Agreement Status (query)

Whether (and at which template versions) the agreement has been signed for the lab. **Public** — same surface as `labs`. Also available inline as `Lab` / `LabRef.legalAgreementStatus`.

```graphql
query LegalAgreementStatus($oclId: String!, $type: LegalAgreementType!) {
  legalAgreementStatus(oclId: $oclId, type: $type) {
    signed
    isCurrentVersionSigned
    currentTemplateVersion
    signedVersions {
      templateVersion
      path
      signer
      signedAt
      contentHash
      issuedAt
      signature
    }
  }
}
```

> **Two surfaces, two failure modes.** As the top-level `legalAgreementStatus(oclId, type)` query above, failures throw like every other query — a top-level GraphQL `errors[]` entry with `errorType` set to a catalogue code — and the payload's `error` field is always `null`. As the `Lab.legalAgreementStatus` / `LabRef.legalAgreementStatus` field, an upstream failure does not null the whole lab: the field returns the payload with `error` set (typically `UPSTREAM_UNAVAILABLE`). There, `error != null` means the status could not be determined and `signed` / `isCurrentVersionSigned` must not be trusted; only when `error == null` does `signed: false` mean "not signed". Select `error { code message requestId retryable details }` on that field and check it before routing a signing flow — for example, inline on `labs`:

```graphql
query LabsWithAgreementStatus {
  labs(perPage: 5) {
    nodes {
      oclId
      legalAgreementStatus(type: ASSIGNMENT_AGREEMENT) {
        signed
        isCurrentVersionSigned
        error {
          code
          message
          requestId
          retryable
          details
        }
      }
    }
  }
}
```

`signed` is true if any version has been signed; `isCurrentVersionSigned` reflects the current template version (the FE routes the signing flow on this). The `signedVersions` enrichment is fetched lazily, only when its sub-fields are selected.

## Sign Legal Agreement (mutation)

Record acceptance of a legal agreement. The backend regenerates the document from `(type, oclId, walletAddress, issuedAt)`, verifies the EIP-712 signature and LabNFT ownership, embeds the signature into a self-verifying envelope, and uploads it to the lab's data room. Rejects if the current template version is already signed.

```graphql
mutation SignLegalAgreement($input: SignLegalAgreementInput!) {
  signLegalAgreement(input: $input) {
    oclId
    path
    contentHash
    templateVersion
    datasetId
    version
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

Success ⇔ `error == null`. On failure `message` mirrors `error.message`; branch on `error.code` (and `details.reason` where documented — e.g. `CONFLICT` with reason `ALREADY_SIGNED` when the current template version is already signed, or `FAILED_PRECONDITION` with reason `TEMPLATE_EXPIRED`). `details` is a JSON-encoded string: `JSON.parse(error.details ?? "{}").reason`.

**`SignLegalAgreementInput` fields:**

| Field         | Type               | Required | Description                                                                                                              |
| ------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| oclId         | String             | Yes      | Canonical 32-byte oclId of the lab                                                                                       |
| type          | LegalAgreementType | Yes      | Agreement type (`ASSIGNMENT_AGREEMENT`)                                                                                  |
| walletAddress | String             | Yes      | Signer's wallet. Must equal the EIP-712 signer, the lab's current LabNFT owner, and (user path) the authenticated wallet |
| signature     | String             | Yes      | EIP-712 signature (0x…) over the `LegalAgreementAcceptance` typed data                                                   |
| issuedAt      | AWSTimestamp       | Yes      | Echoed VERBATIM from `legalAgreementTemplate` — used to regenerate the document                                          |
| signerName    | String             | No       | Echoed VERBATIM from the template call (covered by `contentHash`)                                                        |
| entity        | String             | No       | Echoed verbatim; see `signerName`                                                                                        |
| title         | String             | No       | Echoed verbatim; see `signerName`                                                                                        |

***


# Service Tokens

## Obtaining Tokens

Service tokens must be requested from the Molecule team (see [Authentication](/api-reference/authentication) section above).

Alternatively, a service can obtain a token **self-service** by proving control of its wallet — useful for autonomous agents, bots, and CI/CD pipelines that don't have a browser-based Privy session. This is a two-step flow: fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token.

**Step 1 — Get the sign-in message (`getServiceSignInMessage`):**

```graphql
query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
  getServiceSignInMessage(
    walletAddress: $walletAddress
    serviceName: $serviceName
  ) {
    message
  }
}
```

| Parameter     | Type   | Required | Description                                         |
| ------------- | ------ | -------- | --------------------------------------------------- |
| walletAddress | String | Yes      | Wallet address of the service (e.g. an agent's EOA) |
| serviceName   | String | Yes      | Name of the service requesting a token              |

Public query — no authentication required.

**Step 2 — Exchange the signature for a token (`generateServiceToken`):**

Sign the returned `message` with the service wallet, then submit the signature:

```graphql
mutation GenerateServiceToken(
  $serviceName: String!
  $walletAddress: String!
  $messageSignature: String!
  $expiresIn: String
) {
  generateServiceToken(
    serviceName: $serviceName
    walletAddress: $walletAddress
    messageSignature: $messageSignature
    expiresIn: $expiresIn
  ) {
    token
    tokenId
    serviceName
    expiresAt
    createdAt
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

| Parameter        | Type   | Required | Description                                                                  |
| ---------------- | ------ | -------- | ---------------------------------------------------------------------------- |
| serviceName      | String | Yes      | Name of the service the token is issued for                                  |
| walletAddress    | String | No\*     | Service wallet address (required together with `messageSignature`)           |
| messageSignature | String | No\*     | Hex-encoded signature of the sign-in message (required with `walletAddress`) |
| expiresIn        | String | No       | Token lifetime (e.g. `"30d"`, `"720h"`)                                      |

\* `walletAddress` and `messageSignature` must be provided together for signature-based issuance. The returned `token` is the JWT to pass as `X-Service-Token` on subsequent requests.

Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e.g. `UNAUTHENTICATED` when the signature does not verify), `message` mirrors `error.message`, and `token`, `tokenId`, `expiresAt` and `createdAt` are `null` (`serviceName` may echo the name you sent) — guard for `null`, not for empty strings, and branch on `error`, never on the token fields.

## Extending Token Expiration

You can extend your service token's expiration using the `extendServiceToken` mutation:

```graphql
mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) {
  extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) {
    token
    tokenId
    expiresAt
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

**Parameters:**

| Parameter | Type   | Description                                     |
| --------- | ------ | ----------------------------------------------- |
| tokenId   | String | Token ID provided when token was generated      |
| expiresIn | String | New duration (e.g., `"30d"`, `"720h"`, `"90d"`) |

**Example:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'X-Service-Token: YOUR_CURRENT_TOKEN' \
  -d '{
    "query": "mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) { token tokenId expiresAt message error { code message requestId retryable details } } }",
    "variables": {
      "tokenId": "your-token-id",
      "expiresIn": "90d"
    }
  }'
```

**Important:** Extension returns a **new JWT token** - update your stored token accordingly. On failure `error` is set and `token` and `expiresAt` are `null`; `tokenId` may echo the id you sent, so branch on `error`, not on the token fields.

## Revoking Tokens

Revoke a service token immediately (e.g., if compromised):

```graphql
mutation RevokeServiceToken($tokenId: String!) {
  revokeServiceToken(tokenId: $tokenId) {
    tokenId
    message
    revokedAt
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

Success ⇔ `error == null`. On failure `message` mirrors `error.message`; do not infer the outcome from `tokenId` or `revokedAt` — `tokenId` may echo the id you sent, and when the token was already revoked (`CONFLICT`, reason `ALREADY_REVOKED`) `revokedAt` carries the original revocation time.

**Example:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'X-Service-Token: YOUR_CURRENT_TOKEN' \
  -d '{
    "query": "mutation RevokeServiceToken($tokenId: String!) { revokeServiceToken(tokenId: $tokenId) { tokenId message revokedAt error { code message requestId retryable details } } }",
    "variables": {
      "tokenId": "your-token-id"
    }
  }'
```

***


# Example Workflow: Mint → Upload

A complete, runnable walkthrough that takes a wallet with **no prior credentials and no onchain lab** all the way to a file living in its dataroom: prove control of the wallet to mint a service token, mint the LabNFT, register the dataroom, sign the assignment agreement, then upload a file. Each step below links back to its full reference; the [Complete Script](#complete-script) at the end wires all five together.

> **Encryption is optional.** Step 5 below shows the encrypted path since it's the more involved one to get right, but most files don't need it — a plain `PUBLIC` upload skips the DEK request and `encryptionMetadata` entirely and is just the three-call `initiateCreateOrUpdateFile` → PUT → `finishCreateOrUpdateFile` flow from [Files](/api-reference/labs-api/files). Reach for encryption when the file is confidential and access should be gated by onchain role or ownership — see [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access).

This is the workflow an autonomous agent needs to run end-to-end without any browser-based user interaction or manually provisioned Service Token — the only thing it needs ahead of time is a consumer credential and a funded wallet. It's written against **staging** (Base Sepolia, testnet ETH) end to end; see [Running in Production](#running-in-production) at the bottom for the values to swap.

## Prerequisites

* A funded EOA on **Base Sepolia** — get testnet ETH from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets)
* A **consumer credential** — see [Authentication](/api-reference/authentication). No pre-issued Service Token needed; the workflow mints its own in Step 1.
* `viem` and `node-fetch` (`npm install viem node-fetch`)

Every environment-specific value used below — the GraphQL endpoint, contract addresses, and the viem chain — lives in this one block. Swapping to production later is a matter of replacing this block with the table in [Running in Production](#running-in-production).

```javascript
import { baseSepolia } from "viem/chains"; // production: `base`

// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ----
const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql";
const CHAIN = baseSepolia;
const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory
const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy)
const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver
const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions

const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol_<id>_<secret> — no "Bearer" prefix
const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY;

// Set once Step 1 exchanges a wallet signature for a token. Every call after
// that automatically starts sending it; public queries (like Step 1's own
// sign-in-message lookup) work fine without it.
let serviceToken;

async function graphql(query, variables) {
  // Authorization is always required. X-Service-Token is added once we have
  // one — omit it entirely rather than sending an empty header.
  const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL };
  if (serviceToken) headers["X-Service-Token"] = serviceToken;

  const res = await fetch(GRAPHQL_URL, {
    method: "POST",
    headers,
    body: JSON.stringify({ query, variables }),
  });
  const { data, errors } = await res.json();
  // Queries report failure here: a top-level errors[] entry whose errorType is
  // the catalogue code. Mutations report expected failures in-band instead (see
  // assertOk); a top-level entry on a mutation means a transport/infrastructure
  // failure or an invalid request document.
  if (errors) throw new Error(JSON.stringify(errors));
  return data;
}

// Mutations report failure in-band: `error` is null on success. Throw on a
// non-null `error` so a failed step stops the workflow with the catalogue
// `code` and the `requestId` to quote in a bug report.
function assertOk(result, op) {
  if (result.error) {
    // `details` is a JSON-encoded string; JSON.parse(result.error.details ?? "{}").reason
    // carries the specific cause when there is one.
    const { code, message, requestId } = result.error;
    throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`);
  }
  return result;
}
```

## Step 1: Get a Service Token

Prove control of the wallet instead of waiting on a manually issued token — the self-service path for agents, bots, and CI/CD. Fetch the deterministic sign-in message, sign it as a plain wallet message (EIP-191 `personal_sign` — not typed data), then exchange the signature for a token. Full reference: [Service Tokens — Obtaining Tokens](/api-reference/labs-api/service-tokens#obtaining-tokens).

```javascript
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const SERVICE_NAME = "example-workflow-agent";

const account = privateKeyToAccount(WALLET_PRIVATE_KEY);
// publicClient: read-only RPC calls (readContract, waitForTransactionReceipt).
const publicClient = createPublicClient({ chain: CHAIN, transport: http() });
// walletClient: everything that needs the private key — signing and sending.
const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() });

const signInMessage = await graphql(
  `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
    getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) {
      message
    }
  }`,
  { walletAddress: account.address, serviceName: SERVICE_NAME },
);

// Sign the message VERBATIM — the backend recomposes and verifies the same
// string server-side, so re-wording or re-formatting it breaks verification.
const messageSignature = await walletClient.signMessage({
  message: signInMessage.getServiceSignInMessage.message,
});

const tokenResult = await graphql(
  `mutation GenerateServiceToken(
    $serviceName: String!
    $walletAddress: String!
    $messageSignature: String!
  ) {
    generateServiceToken(
      serviceName: $serviceName
      walletAddress: $walletAddress
      messageSignature: $messageSignature
    ) {
      token
      tokenId
      expiresAt
      message
      error { code message requestId retryable details }
    }
  }`,
  { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature },
);
assertOk(tokenResult.generateServiceToken, "generateServiceToken");
serviceToken = tokenResult.generateServiceToken.token;
```

`generateServiceToken` reports failure the same way as every other mutation: `error` is `null` on success, and on failure it carries the catalogue `code` while `token`, `tokenId` and `expiresAt` are `null` (`message` mirrors `error.message`). The returned `token` authorizes this wallet's onchain-resolved role for whatever lab it acts on; it isn't scoped to a single `oclId` up front.

## Step 2: Mint the LabNFT

Mint onchain via `OnChainLabFactory.mintAndCreateAccount` and read `oclId` off the `OclIdentityCreated` event. Reuses `account` / `publicClient` / `walletClient` from Step 1 and `FACTORY_ADDRESS` / `LABNFT_ADDRESS` from the config block. Full detail — the fee call and how `oclId` is derived — is on [Lab Management](/api-reference/labs-api/lab-management#mint-the-labnft).

```javascript
import { parseAbi, parseEventLogs } from "viem";

const factoryAbi = parseAbi([
  "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)",
]);
const labNftAbi = parseAbi([
  "function mintFeeWei() external view returns (uint256)",
  "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)",
]);

const mintFeeWei = await publicClient.readContract({
  address: LABNFT_ADDRESS,
  abi: labNftAbi,
  functionName: "mintFeeWei",
});

const mintTxHash = await walletClient.writeContract({
  address: FACTORY_ADDRESS,
  abi: factoryAbi,
  functionName: "mintAndCreateAccount",
  args: [account.address],
  value: mintFeeWei,
});
const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash });

const [identity] = parseEventLogs({
  abi: labNftAbi,
  eventName: "OclIdentityCreated",
  logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()),
});
const oclId = identity.args.oclId;
```

## Step 3: Create the Lab

Register the Kamu-backed dataroom for the freshly-minted `oclId`. Full reference: [Create Lab](/api-reference/labs-api/lab-management#create-lab).

```javascript
const createLabResult = await graphql(
  `mutation CreateLab($oclId: String!) {
    createLab(input: { oclId: $oclId }) {
      message
      error { code message requestId retryable details }
      lab { oclId shortname labAccountAddress labNftTokenId }
    }
  }`,
  { oclId },
);
assertOk(createLabResult.createLab, "createLab");
const { labAccountAddress } = createLabResult.createLab.lab;
```

## Step 4: Sign the Assignment Agreement

Fetch the populated agreement, sign the `LegalAgreementAcceptance` EIP-712 payload, then submit it. Full schema and a self-test vector: [Legal Agreements — EIP-712 Envelope](/api-reference/labs-api/legal-agreements#eip-712-envelope).

```javascript
const template = await graphql(
  `query Template($oclId: String!, $walletAddress: String!) {
    legalAgreementTemplate(
      oclId: $oclId
      type: ASSIGNMENT_AGREEMENT
      walletAddress: $walletAddress
    ) {
      contentHash
      templateVersion
      issuedAt
    }
  }`,
  { oclId, walletAddress: account.address },
);
const { contentHash, templateVersion, issuedAt } = template.legalAgreementTemplate;

const signature = await walletClient.signTypedData({
  domain: {
    name: "MoleculeOcl",
    version: "1",
    chainId: CHAIN.id,
    verifyingContract: LABNFT_ADDRESS.toLowerCase(),
  },
  types: {
    LegalAgreementAcceptance: [
      { name: "oclId", type: "bytes32" },
      { name: "agreementType", type: "string" },
      { name: "contentHash", type: "bytes32" },
      { name: "templateVersion", type: "string" },
      { name: "signer", type: "address" },
      { name: "issuedAt", type: "uint64" },
    ],
  },
  primaryType: "LegalAgreementAcceptance",
  message: {
    oclId,
    agreementType: "assignment-agreement", // registry slug, NOT the "ASSIGNMENT_AGREEMENT" enum value
    contentHash,
    templateVersion,
    signer: account.address.toLowerCase(),
    issuedAt: BigInt(issuedAt),
  },
});

const signResult = await graphql(
  `mutation Sign($input: SignLegalAgreementInput!) {
    signLegalAgreement(input: $input) {
      path
      message
      error { code message requestId retryable details }
    }
  }`,
  {
    input: {
      oclId,
      type: "ASSIGNMENT_AGREEMENT",
      walletAddress: account.address,
      signature,
      issuedAt,
    },
  },
);
assertOk(signResult.signLegalAgreement, "signLegalAgreement");
```

## Step 5: Upload a File (Encrypted)

> **Skip 5a–5c if you don't need encryption.** For a `PUBLIC` file, go straight to 5d with `accessLevel: "PUBLIC"` and omit `encryptionMetadata` — that's the whole upload. The DEK request, local AES-256-GCM encryption, and `accessControlConditions` below are only for files that must be access-gated.

Request a data encryption key, AES-256-GCM encrypt the file locally via Web Crypto, then run the standard three-step upload with `encryptionMetadata` attached. Uses `ACCESS_RESOLVER_ADDRESS` / `ACCESS_CONDITION_CHAIN` from the config block. Full reference: [Files — Advanced: Encrypted File Upload](/api-reference/labs-api/files#advanced-encrypted-file-upload) and [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access).

```javascript
import { webcrypto, randomBytes, createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename } from "node:path";

const filePath = "./research-data.csv";
const plaintext = readFileSync(filePath);

// 5a. Get a DEK
const dekResult = await graphql(`
  mutation {
    generateDataEncryptionKey {
      plaintextDEK
      encryptedDek
      encryptionSystem
      error { code message requestId retryable details }
    }
  }
`);
assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey");
const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey;

// 5b. Encrypt locally (Web Crypto SubtleCrypto), then wipe the plaintext key
const cryptoKey = await webcrypto.subtle.importKey(
  "raw",
  Buffer.from(plaintextDEK, "base64"),
  "AES-GCM",
  false,
  ["encrypt"],
);
const iv = randomBytes(12);
const ciphertext = Buffer.from(
  await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext),
);
const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex");

// 5c. Gate decryption to the lab owner (LabNFT owner / authorized TBA signer).
// See the Data Privacy & Access worked example for OR-composing in
// Contributor/Viewer roles too.
const accessControlConditions = JSON.stringify([
  {
    conditionType: "evmContract",
    contractAddress: ACCESS_RESOLVER_ADDRESS,
    chain: ACCESS_CONDITION_CHAIN,
    functionName: "isAuthorizedSignerForTba",
    functionParams: [":userAddress", labAccountAddress],
    functionAbi: {
      name: "isAuthorizedSignerForTba",
      inputs: [
        { name: "signer", type: "address" },
        { name: "account", type: "address" },
      ],
      outputs: [{ name: "", type: "bool" }],
      stateMutability: "view",
      type: "function",
    },
    returnValueTest: { key: "", comparator: "=", value: "true" },
  },
]);

// 5d. Standard three-step upload, ciphertext in place of the raw file
const initiateResult = await graphql(
  `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) {
    initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) {
      uploadToken
      uploadUrl
      method
      headers { key value }
      error { code message requestId retryable details }
    }
  }`,
  { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length },
);
assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile");
const { uploadToken, uploadUrl, headers } = initiateResult.initiateCreateOrUpdateFile;

const uploadHeaders = {};
headers.forEach((h) => (uploadHeaders[h.key] = h.value));
const putResponse = await fetch(uploadUrl, { method: "PUT", headers: uploadHeaders, body: ciphertext });
if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.statusText}`);

const finishResult = await graphql(
  `mutation Finish(
    $oclId: String!
    $uploadToken: String!
    $path: String!
    $accessLevel: String!
    $changeBy: String!
    $encryptionMetadata: EncryptionMetadataInput
  ) {
    finishCreateOrUpdateFile(
      oclId: $oclId
      uploadToken: $uploadToken
      path: $path
      accessLevel: $accessLevel
      changeBy: $changeBy
      encryptionMetadata: $encryptionMetadata
    ) {
      datasetId
      message
      error { code message requestId retryable details }
    }
  }`,
  {
    oclId,
    uploadToken,
    path: basename(filePath),
    accessLevel: "HOLDERS", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN — encrypted files use HOLDERS or ADMIN
    changeBy: account.address,
    encryptionMetadata: {
      encryptionSystem, // echo verbatim — never hardcode
      encryptedDek,
      iv: iv.toString("base64"),
      contentHash: contentHashHex,
      accessControlConditions,
      encryptedBy: account.address.toLowerCase(),
      encryptedAt: new Date().toISOString(),
    },
  },
);
assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
console.log("Uploaded. datasetId:", finishResult.finishCreateOrUpdateFile.datasetId);
```

***

## Complete Script

All five steps combined into one file, against **staging**. Run with `WALLET_PRIVATE_KEY` and `CONSUMER_CREDENTIAL` set, and a file at the path passed on the command line — no pre-issued Service Token needed. See [Running in Production](#running-in-production) below to point this at mainnet instead.

```javascript
#!/usr/bin/env node
import { webcrypto, randomBytes, createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import {
  createPublicClient,
  createWalletClient,
  http,
  parseAbi,
  parseEventLogs,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains"; // production: `base`

// ---- Staging (Base Sepolia) config — see "Running in Production" to swap ----
const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql";
const CHAIN = baseSepolia;
const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90"; // OnChainLabFactory
const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28"; // LabNFT (proxy)
const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559"; // AccessResolver
const ACCESS_CONDITION_CHAIN = "baseSepolia"; // the `chain` string inside accessControlConditions
const SERVICE_NAME = "example-workflow-agent";

const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL; // mol_<id>_<secret> — no "Bearer" prefix
const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY;

// Set once Step 1 exchanges a wallet signature for a token.
let serviceToken;

async function graphql(query, variables) {
  // Authorization is always required. X-Service-Token is added once we have
  // one — omit it entirely rather than sending an empty header.
  const headers = { "Content-Type": "application/json", Authorization: CONSUMER_CREDENTIAL };
  if (serviceToken) headers["X-Service-Token"] = serviceToken;

  const res = await fetch(GRAPHQL_URL, {
    method: "POST",
    headers,
    body: JSON.stringify({ query, variables }),
  });
  const { data, errors } = await res.json();
  // Queries report failure here: a top-level errors[] entry whose errorType is
  // the catalogue code. Mutations report expected failures in-band instead (see
  // assertOk); a top-level entry on a mutation means a transport/infrastructure
  // failure or an invalid request document.
  if (errors) throw new Error(JSON.stringify(errors));
  return data;
}

// Mutations report failure in-band: `error` is null on success. Throw on a
// non-null `error` so a failed step stops the workflow with the catalogue
// `code` and the `requestId` to quote in a bug report.
function assertOk(result, op) {
  if (result.error) {
    // `details` is a JSON-encoded string; JSON.parse(result.error.details ?? "{}").reason
    // carries the specific cause when there is one.
    const { code, message, requestId } = result.error;
    throw new Error(`${op} failed: ${code}: ${message} (requestId ${requestId})`);
  }
  return result;
}

async function main() {
  const filePath = process.argv[2];
  if (!filePath) throw new Error("Usage: node workflow.js <file-to-upload>");

  const account = privateKeyToAccount(WALLET_PRIVATE_KEY);
  // publicClient: read-only RPC calls. walletClient: signing and sending.
  const publicClient = createPublicClient({ chain: CHAIN, transport: http() });
  const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() });

  // ---- Step 1: Get a Service Token ----
  const signInMessage = await graphql(
    `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
      getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) {
        message
      }
    }`,
    { walletAddress: account.address, serviceName: SERVICE_NAME },
  );
  const messageSignature = await walletClient.signMessage({
    message: signInMessage.getServiceSignInMessage.message,
  });
  const tokenResult = await graphql(
    `mutation GenerateServiceToken(
      $serviceName: String!
      $walletAddress: String!
      $messageSignature: String!
    ) {
      generateServiceToken(
        serviceName: $serviceName
        walletAddress: $walletAddress
        messageSignature: $messageSignature
      ) {
        token
        message
        error { code message requestId retryable details }
      }
    }`,
    { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature },
  );
  assertOk(tokenResult.generateServiceToken, "generateServiceToken");
  serviceToken = tokenResult.generateServiceToken.token;
  console.log("1/5 Got service token");

  // ---- Step 2: Mint the LabNFT ----
  const factoryAbi = parseAbi([
    "function mintAndCreateAccount(address to) external payable returns (address account, uint256 tokenId)",
  ]);
  const labNftAbi = parseAbi([
    "function mintFeeWei() external view returns (uint256)",
    "event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)",
  ]);

  const mintFeeWei = await publicClient.readContract({
    address: LABNFT_ADDRESS,
    abi: labNftAbi,
    functionName: "mintFeeWei",
  });
  const mintTxHash = await walletClient.writeContract({
    address: FACTORY_ADDRESS,
    abi: factoryAbi,
    functionName: "mintAndCreateAccount",
    args: [account.address],
    value: mintFeeWei,
  });
  const mintReceipt = await publicClient.waitForTransactionReceipt({ hash: mintTxHash });
  const [identity] = parseEventLogs({
    abi: labNftAbi,
    eventName: "OclIdentityCreated",
    logs: mintReceipt.logs.filter((l) => l.address.toLowerCase() === LABNFT_ADDRESS.toLowerCase()),
  });
  const oclId = identity.args.oclId;
  console.log("2/5 Minted LabNFT — oclId:", oclId);

  // ---- Step 3: Create the Lab ----
  const createLabResult = await graphql(
    `mutation CreateLab($oclId: String!) {
      createLab(input: { oclId: $oclId }) {
        message
        error { code message requestId retryable details }
        lab { labAccountAddress }
      }
    }`,
    { oclId },
  );
  assertOk(createLabResult.createLab, "createLab");
  const { labAccountAddress } = createLabResult.createLab.lab;
  console.log("3/5 Lab created — TBA:", labAccountAddress);

  // ---- Step 4: Sign the Assignment Agreement ----
  const template = await graphql(
    `query Template($oclId: String!, $walletAddress: String!) {
      legalAgreementTemplate(oclId: $oclId, type: ASSIGNMENT_AGREEMENT, walletAddress: $walletAddress) {
        contentHash
        templateVersion
        issuedAt
      }
    }`,
    { oclId, walletAddress: account.address },
  );
  const { contentHash, templateVersion, issuedAt } = template.legalAgreementTemplate;

  const signature = await walletClient.signTypedData({
    domain: {
      name: "MoleculeOcl",
      version: "1",
      chainId: CHAIN.id,
      verifyingContract: LABNFT_ADDRESS.toLowerCase(),
    },
    types: {
      LegalAgreementAcceptance: [
        { name: "oclId", type: "bytes32" },
        { name: "agreementType", type: "string" },
        { name: "contentHash", type: "bytes32" },
        { name: "templateVersion", type: "string" },
        { name: "signer", type: "address" },
        { name: "issuedAt", type: "uint64" },
      ],
    },
    primaryType: "LegalAgreementAcceptance",
    message: {
      oclId,
      agreementType: "assignment-agreement",
      contentHash,
      templateVersion,
      signer: account.address.toLowerCase(),
      issuedAt: BigInt(issuedAt),
    },
  });

  const signResult = await graphql(
    `mutation Sign($input: SignLegalAgreementInput!) {
      signLegalAgreement(input: $input) {
        path
        message
        error { code message requestId retryable details }
      }
    }`,
    { input: { oclId, type: "ASSIGNMENT_AGREEMENT", walletAddress: account.address, signature, issuedAt } },
  );
  assertOk(signResult.signLegalAgreement, "signLegalAgreement");
  console.log("4/5 Agreement signed —", signResult.signLegalAgreement.path);

  // ---- Step 5: Upload a File (encrypted — see the callout above Step 5) ----
  const plaintext = readFileSync(filePath);

  const dekResult = await graphql(`
    mutation {
      generateDataEncryptionKey {
        plaintextDEK
        encryptedDek
        encryptionSystem
        error { code message requestId retryable details }
      }
    }
  `);
  assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey");
  const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey;

  const cryptoKey = await webcrypto.subtle.importKey(
    "raw",
    Buffer.from(plaintextDEK, "base64"),
    "AES-GCM",
    false,
    ["encrypt"],
  );
  const iv = randomBytes(12);
  const ciphertext = Buffer.from(
    await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext),
  );
  const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex");

  const accessControlConditions = JSON.stringify([
    {
      conditionType: "evmContract",
      contractAddress: ACCESS_RESOLVER_ADDRESS,
      chain: ACCESS_CONDITION_CHAIN,
      functionName: "isAuthorizedSignerForTba",
      functionParams: [":userAddress", labAccountAddress],
      functionAbi: {
        name: "isAuthorizedSignerForTba",
        inputs: [
          { name: "signer", type: "address" },
          { name: "account", type: "address" },
        ],
        outputs: [{ name: "", type: "bool" }],
        stateMutability: "view",
        type: "function",
      },
      returnValueTest: { key: "", comparator: "=", value: "true" },
    },
  ]);

  const initiateResult = await graphql(
    `mutation Initiate($oclId: String!, $contentType: String!, $contentLength: Int!) {
      initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) {
        uploadToken
        uploadUrl
        headers { key value }
        error { code message requestId retryable details }
      }
    }`,
    { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length },
  );
  assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile");
  const { uploadToken, uploadUrl, headers } = initiateResult.initiateCreateOrUpdateFile;

  const uploadHeaders = {};
  headers.forEach((h) => (uploadHeaders[h.key] = h.value));
  const putResponse = await fetch(uploadUrl, { method: "PUT", headers: uploadHeaders, body: ciphertext });
  if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.statusText}`);

  const finishResult = await graphql(
    `mutation Finish(
      $oclId: String!
      $uploadToken: String!
      $path: String!
      $accessLevel: String!
      $changeBy: String!
      $encryptionMetadata: EncryptionMetadataInput
    ) {
      finishCreateOrUpdateFile(
        oclId: $oclId
        uploadToken: $uploadToken
        path: $path
        accessLevel: $accessLevel
        changeBy: $changeBy
        encryptionMetadata: $encryptionMetadata
      ) {
        datasetId
        message
        error { code message requestId retryable details }
      }
    }`,
    {
      oclId,
      uploadToken,
      path: basename(filePath),
      accessLevel: "HOLDERS",
      changeBy: account.address,
      encryptionMetadata: {
        encryptionSystem,
        encryptedDek,
        iv: iv.toString("base64"),
        contentHash: contentHashHex,
        accessControlConditions,
        encryptedBy: account.address.toLowerCase(),
        encryptedAt: new Date().toISOString(),
      },
    },
  );
  assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
  console.log("5/5 File uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

**Usage:**

```bash
WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" node workflow.js ./research-data.csv
```

***

## Running in Production

Everything above runs against staging (Base Sepolia, testnet ETH). To run the same script against production, replace the six values in the config block — nothing else in the script changes, since every step reads from these constants:

| Constant                  | Staging (this walkthrough)                         | Production                                            |
| ------------------------- | -------------------------------------------------- | ----------------------------------------------------- |
| `GRAPHQL_URL`             | `https://staging.graphql.api.molecule.xyz/graphql` | `https://production.graphql.api.molecule.xyz/graphql` |
| `CHAIN` (viem import)     | `baseSepolia` from `viem/chains`                   | `base` from `viem/chains`                             |
| `FACTORY_ADDRESS`         | `0xd629FE2310b4309a212495F10A47f8436dcEfD90`       | `0xECdF4f05384056507485C90aeAb0a83268760D6E`          |
| `LABNFT_ADDRESS`          | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28`       | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92`          |
| `ACCESS_RESOLVER_ADDRESS` | `0x5493F472602C87318EA5Eff753cDD593bf9bF559`       | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B`          |
| `ACCESS_CONDITION_CHAIN`  | `"baseSepolia"`                                    | `"base"`                                              |

```javascript
import { base } from "viem/chains"; // instead of baseSepolia

const GRAPHQL_URL = "https://production.graphql.api.molecule.xyz/graphql";
const CHAIN = base;
const FACTORY_ADDRESS = "0xECdF4f05384056507485C90aeAb0a83268760D6E";
const LABNFT_ADDRESS = "0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92";
const ACCESS_RESOLVER_ADDRESS = "0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B";
const ACCESS_CONDITION_CHAIN = "base";
```

A few things that follow automatically from that swap and don't need separate handling:

* **EIP-712 `chainId`** in Step 4 is read as `CHAIN.id` (`8453` for `base`, `84532` for `baseSepolia`) — it tracks `CHAIN` and needs no separate edit. See [Legal Agreements — EIP-712 Envelope](/api-reference/labs-api/legal-agreements#eip-712-envelope) for why this must match the LabNFT's actual deployment chain.
* **Headers and the `graphql()` helper** are identical in both environments — `Authorization` (consumer credential, no `Bearer` prefix) and the self-issued `X-Service-Token` from Step 1 work the same way against both endpoints. See [Authentication](/api-reference/authentication).
* **The `mintFeeWei()` read** in Step 2 already queries the live contract, so it picks up whatever fee production has configured without a code change.

What doesn't follow automatically, and is on you to handle:

* **Real funds.** Minting on `base` spends real ETH from the wallet behind `WALLET_PRIVATE_KEY`, and the assignment agreement you sign is a real one. Test the full flow on staging first.
* **`SERVICE_NAME`** should identify the real integration once you're not just testing — it's echoed into the sign-in message and stored against the issued token.
* Full deployment list, including every other OCL contract on both chains: [Contracts reference](/references/contracts).


# Tokenization API

## Overview

The Tokenization API enables developers to generate Lab (OCL) membership agreements and tokenize Labs into IP Tokens (IPTs) on Base. It combines offchain GraphQL mutations with onchain blockchain transactions to create legally-bound, tradeable research assets.

**Capabilities:**

* Generate Lab (OCL) membership agreements
* Tokenize Labs into IP Tokens (IPTs) on Base
* Manage the complete tokenization lifecycle
* Integrate with smart contracts via viem/ethers

***

## Authentication

All Tokenization API mutations require a consumer credential.

### Obtaining a Consumer Credential

To request a consumer credential and access to the full technical integration guide:

1. Join our [Discord community](https://t.co/L0VEiy4Bjk)
2. Contact the Molecule team with:
   * Your use case and project details
   * Expected tokenization volume
3. You'll receive:
   * **Consumer credential** (`mol_<consumerId>_<secret>`) for authentication
   * **Technical Integration Guide** with complete code examples and ABI files

### Using Your Consumer Credential

Include the consumer credential in all requests using the `Authorization` header, with no `Bearer` prefix:

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
```

***

## API Endpoint

```
Production: https://production.graphql.api.molecule.xyz/graphql
Staging:    https://staging.graphql.api.molecule.xyz/graphql
```

***

## OCL Membership Agreement

Generate the membership agreement for an onchain lab (OCL) — the terms document an IP Token holder accepts when the Lab is tokenized. It returns an `agreementKey` (an S3 object key) paired with a SHA-256 `agreementContentHash` that binds the signature to the exact document.

### generateOclMembershipAgreement

**Input:**

* `agreementData` (AWSJSON): Stringified object with the fields below.

**Required Fields in agreementData:**

```json
{
  "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
  "symbol": "LAB-SYMBOL",
  "title": "Lab Membership Agreement"
}
```

| Field  | Type   | Required | Description                                      |
| ------ | ------ | -------- | ------------------------------------------------ |
| oclId  | String | Yes      | Canonical 32-byte oclId of the lab (0x + 64 hex) |
| symbol | String | Yes      | Lab symbol                                       |
| title  | String | No       | Optional agreement title                         |

**Response:**

```json
{
  "agreementKey": "0x0101...0042/agreements/3f2a....json",
  "agreementUrl": "https://...",
  "agreementContentHash": "0xabcdef...",
  "agreementType": "OCL_MEMBERSHIP",
  "generatedAt": "2026-07-15T10:30:00.000Z",
  "isSuccess": true
}
```

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "mutation GenerateOclMembershipAgreement($agreementData: AWSJSON!) { generateOclMembershipAgreement(agreementData: $agreementData) { agreementKey agreementUrl agreementContentHash agreementType generatedAt isSuccess error { message } } }",
    "variables": {
      "agreementData": "{\"oclId\":\"0x0101000000000000000000000000000000000000000000000000000000000042\",\"symbol\":\"LAB-SYM\",\"title\":\"Lab Membership Agreement\"}"
    }
  }'
```

> The returned `agreementKey` and `agreementContentHash` are consumed by the `getOclTermsMessage(agreementKey, contentHash, labId, chainId)` query to produce the message the member signs.

***

## Lab (OCL) Tokenization Workflow

Labs are tokenized on **Base** through the `OclTokenizer` contract. It mints one fractional ERC-20 **IP Token (IPT)** per Lab — deployed as a `LabToken` contract — gated by a signed membership agreement.

### High-Level Flow

```
Step 1: Generate Membership Agreement (API)
↓ Mutation: generateOclMembershipAgreement
↓ Returns: agreementKey (S3 key), agreementContentHash (SHA-256)

Step 2: Get OCL Terms Message (API)
↓ Query: getOclTermsMessage(agreementKey, contentHash, labId, chainId)
↓ Returns: the exact terms text to sign

Step 3: Sign Terms (CLIENT-SIDE)
↓ Plain personal_sign (EIP-191) over the message — not EIP-712
↓ ECDSA signatures and ERC-1271 smart-account signatures both accepted

Step 4: Tokenize (BLOCKCHAIN, Base)
↓ Smart contract: OclTokenizer.tokenize()
↓ Fee: Gas only
↓ Result: ERC-20 IP Token (LabToken) contract deployed ✓
```

The terms message is reconstructed onchain by `OclTermsPermissioner.specificTermsV1()` — the backend's `getOclTermsMessage` returns byte-identical text, so always sign exactly the string the API returns.

### OclTokenizer Contract Functions

**tokenize():**

* Creates the Lab's ERC-20 IP Token
* Parameters:
  * `labId` (uint256): The LabNFT token ID
  * `amount` (uint256): Initial supply issued to the caller (in wei)
  * `symbol` (string): Token ticker symbol — the name is derived automatically as `Lab Tokens of Lab #<labId>`
  * `s3Key` (string): `agreementKey` from Step 1
  * `contentHash` (bytes32): `agreementContentHash` from Step 1
  * `signature` (bytes): Signed terms from Step 3
* Caller must be the Lab's controller (the current LabNFT owner); reverts `MustControlLab()` otherwise
* One token per Lab — a second call reverts `AlreadyTokenized()`

**attachToken():**

* "Bring your own token": wraps a pre-existing ERC-20 (≤ 18 decimals) in a read-only `WrappedLabToken` carrying the Lab's metadata instead of minting a new one
* Parameters: `labId`, `s3Key`, `contentHash`, `signature`, `tokenContract`

**issue() / cap():**

* `issue(labToken, amount, receiver)` — mint additional supply; controller-only
* `cap(labToken)` — permanently freeze issuance; controller-only, irreversible

### Networks (Lab Tokenization)

| Network      | Chain ID | OclTokenizer (proxy)                         |
| ------------ | -------- | -------------------------------------------- |
| Base Mainnet | 8453     | `0x62F532C3f563D974deEc103AAb8cC597f4f9c84E` |
| Base Sepolia | 84532    | `0xEe19e0Db8a7e59538710FAF6ed3ab655BCfCdB24` |

***

## Requirements

### For Lab Tokenization

* **Consumer credential**: Obtained from Molecule team
* **Lab Control**: The caller must be the Lab's controller (the current LabNFT owner)
* **Base ETH Balance**: Sufficient for gas fees on Base
* **Token Details**: Symbol and initial supply amount (the token name is derived automatically)

***

## Error Handling

All mutations follow a consistent error response format:

```json
{
  "isSuccess": false,
  "error": {
    "message": "Error description",
    "code": "ERROR_CODE",
    "retryable": true
  }
}
```

### Common Errors

| Error Code       | Description                            | Solution                             |
| ---------------- | -------------------------------------- | ------------------------------------ |
| 401 Unauthorized | Missing or invalid consumer credential | Check `Authorization: Bearer` header |
| 400 Bad Request  | Invalid parameters or malformed JSON   | Verify input data format             |
| `INVALID_INPUT`  | Required fields missing or malformed   | Verify the input object shape        |

Separately, the smart contracts revert with their own errors — most commonly `AlreadyTokenized()` (each Lab can only be tokenized once) and `MustControlLab()` (only the Lab's controller can tokenize) from the `OclTokenizer`.

### Troubleshooting

**Signature rejected onchain:**

* Sign exactly the string returned by `getOclTermsMessage` — the permissioner reconstructs the terms onchain and the texts must match byte-for-byte
* Use a plain `personal_sign` (EIP-191) signature, and sign with the Lab controller's wallet (or a smart account that validates via ERC-1271)

**Blockchain Transaction Failures:**

* Verify sufficient ETH balance for gas on Base
* Ensure using correct contract address for your network
* Wait for transaction confirmation before proceeding

***

## Complete End-to-End Examples

For complete code examples including:

* The full Lab tokenization flow with code
* Smart contract ABIs and interfaces
* Error handling and retry logic
* Safe multisig integration

**Contact the Molecule team** to receive the full **Technical Integration Guide**.

### Basic Example Structure

```javascript
// 1. Generate the membership agreement
const agreement = await generateOclMembershipAgreement(agreementData);

// 2. Get the exact terms text to sign
const terms = await getOclTermsMessage(
  agreement.agreementKey, agreement.agreementContentHash, labId, chainId
);

// 3. Sign the terms (plain personal_sign)
const signature = await walletClient.signMessage({ message: terms.message });

// 4. Tokenize the Lab onchain (Base)
const txHash = await tokenize(labId, initialSupply, symbol,
  agreement.agreementKey, agreement.agreementContentHash, signature);

console.log('Lab tokenized!', txHash);
```

***

## Smart Contract Details

**Contract ABIs:**

* Available from the verified [OclTokenizer on BaseScan](https://basescan.org/address/0x62F532C3f563D974deEc103AAb8cC597f4f9c84E), or request them from the Molecule team as part of the Technical Integration Guide

***

## Best Practices

### Workflow Management

* Store intermediate results (agreement keys, signatures) securely
* Implement retry logic for failed API calls
* Wait for blockchain transaction confirmations
* Validate each step before proceeding to next

### Security

* Never expose consumer credentials in client-side code
* Use environment variables for credentials
* Implement proper wallet key management
* Verify all signatures and authorizations

### Testing

* Use Base Sepolia for development
* Test the complete workflow end-to-end
* Verify the agreement document and terms text before signing

***

## Getting Support

For assistance with the Tokenization API:

* **Technical Integration Guide**: Contact Molecule team to receive complete documentation
* **Discord**: Join our [community](https://t.co/L0VEiy4Bjk) for support
* **Smart Contracts**: See [contract addresses](/references/contracts)

***

*Last updated: July 2026*


# x402 Gateway

Pay-per-call HTTP 402 gateway that fronts write mutations on the Labs API with per-request stablecoin settlement on Base.

## Overview

The x402 gateway wraps a small set of Labs API write mutations with the [HTTP 402 Payment Required](https://www.x402.org/) protocol. Callers — typically autonomous AI agents or external services that don't hold a long-lived service token — settle a USDC payment on Base per request, and the gateway forwards the underlying GraphQL mutation to AppSync.

Each successful request costs the configured mutation price in USDC, which is debited directly from the payer wallet via EIP-3009 `transferWithAuthorization` (or Permit2) and settled through the Coinbase facilitator.

### When to use x402

Use the gateway when:

* **An agent needs write access without provisioning a per-agent service token.** The gateway mints a short-lived, scoped service token on the fly after payment is verified.
* **You want pay-per-call economics.** Each mutation has its own price; no subscription or prepaid balance.
* **You're building third-party tooling that pays for users.** The payer wallet is recorded as the mutation author.

Use the standard [Labs API](/api-reference/labs-api) with a service token when you have long-lived credentials for a known lab.

***

## Endpoints

The gateway exposes one HTTP endpoint per allow-listed mutation. All endpoints accept `POST` with a JSON body containing a GraphQL mutation.

```
POST /x402/labs/{mutation}
```

| Path                                    | Wraps mutation               | Purpose                                                    |
| --------------------------------------- | ---------------------------- | ---------------------------------------------------------- |
| `/x402/labs/initiateCreateOrUpdateFile` | `initiateCreateOrUpdateFile` | Start a file upload; returns a presigned URL               |
| `/x402/labs/finishCreateOrUpdateFile`   | `finishCreateOrUpdateFile`   | Finalise a file upload with metadata                       |
| `/x402/labs/createAnnouncement`         | `createAnnouncement`         | Publish a lab announcement                                 |
| `/x402/labs/createLab`                  | `createLab`                  | Create a lab (data room) for an onchain lab (OCL)          |
| `/x402/labs/generateDataEncryptionKey`  | `generateDataEncryptionKey`  | Generate a data encryption key (DEK) for encrypted uploads |
| `/x402/labs/decryptDataKey`             | `decryptDataKey`             | Decrypt a file's data key for an authorized caller         |

The path mutation must match the top-level GraphQL mutation field in the request body, otherwise the gateway returns `400`. The allow-list above is the single source of truth in `lambda/x402-gateway-lambda/mutations.ts` (`X402_WRITE_MUTATIONS`).

***

## Payment Flow

The gateway implements the standard x402 three-phase flow: **verify → serve → settle**.

```
┌──────────┐                          ┌────────────┐                    ┌──────────────┐
│  Client  │                          │  Gateway   │                    │ Facilitator  │
│ (agent)  │                          │ (Lambda)   │                    │ (Coinbase)   │
└────┬─────┘                          └─────┬──────┘                    └──────┬───────┘
     │ 1. POST /x402/labs/{mutation}         │                                 │
     │    (no payment header)                │                                 │
     ├──────────────────────────────────────▶│                                 │
     │                                       │                                 │
     │ 2. 402 Payment Required               │                                 │
     │    + payment requirements             │                                 │
     │◀──────────────────────────────────────┤                                 │
     │                                       │                                 │
     │ 3. Sign EIP-3009/Permit2 auth         │                                 │
     │                                       │                                 │
     │ 4. POST again w/ Payment-Signature    │                                 │
     ├──────────────────────────────────────▶│ 5. verify                       │
     │                                       ├────────────────────────────────▶│
     │                                       │◀────────────── verified ────────┤
     │                                       │                                 │
     │                                       │ 6. mint scoped service token    │
     │                                       │ 7. forward GraphQL to AppSync   │
     │                                       │                                 │
     │                                       │ 8. settle                       │
     │                                       ├────────────────────────────────▶│
     │                                       │◀─────────── settled ────────────┤
     │ 9. 200 OK + mutation result           │                                 │
     │◀──────────────────────────────────────┤                                 │
```

1. **402 challenge** — The gateway returns an x402-standard payment-requirements response describing network, asset, price, and payTo address.
2. **Sign** — The client signs an EIP-3009 `transferWithAuthorization` (or Permit2) for the quoted amount to `X402_PAY_TO_ADDRESS` on the configured network.
3. **Retry with payment** — The signed authorization is submitted as a base64 JSON header under any of `Payment-Signature`, `X-Payment`, or `Payment`.
4. **Verify** — The gateway calls the Coinbase facilitator's `/verify` endpoint. On failure it returns `402` with the original requirements.
5. **Serve** — The gateway mints a scoped, short-lived JWT service token (`allowedMutations: [mutation]`, `authMethod: "x402"`, `ttl = X402_TOKEN_TTL_SECONDS`, default `300s`) with the payer wallet as `adminAddress`, then forwards the GraphQL mutation to AppSync using that token.
6. **Settle** — After a `2xx` upstream response, the gateway calls the facilitator's `/settle` endpoint to broadcast the transfer. Settlement headers are merged into the final response.

### Payer address resolution

The mutation is executed as if the **payer wallet** called it. The payer address is resolved from the verified payment payload, in this order:

1. `payload.authorization.from` (EIP-3009)
2. `payload.permit2Authorization.from` (Permit2)
3. `payerAddress` field on the JSON request body (fallback only — used when upstream wrappers strip the verified payload)

If none resolves to a valid address the gateway returns `400`. Source: `lambda/x402-gateway-lambda/index.ts` (`extractPayerAddress`).

***

## Request Format

```http
POST /x402/labs/createAnnouncement HTTP/1.1
content-type: application/json
payment-signature: <base64 x402 payment payload>

{
  "query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body) { message error { code message requestId retryable details } } }",
  "variables": {
    "oclId": "0x0101...abcd",
    "headline": "Milestone 1 complete",
    "body": "..."
  },
  "operationName": "CreateAnnouncement"
}
```

The `200` body is the mutation's GraphQL response verbatim, so read it exactly as on the Labs API: the mutation succeeded when `error` is `null`; otherwise branch on `error.code` — see [Error Handling](/api-reference/labs-api#error-handling). Note that settlement is triggered by the upstream `2xx`, not by mutation success — a `200` whose body carries a non-null `error` (e.g. `VALIDATION_FAILED`, `UNAUTHORIZED`) is still settled, so you pay for a mutation that failed in-band; only an upstream `4xx`/`5xx` skips settlement. Validate inputs (ids, categories/tags, role) before paying.

Constraints enforced by the gateway (`validateMutationQuery`):

* Exactly one GraphQL operation.
* Operation kind `mutation`.
* Exactly one top-level selection.
* Top-level field name must equal the path `{mutation}`.

***

## Pricing & Configuration

Pricing is environment-driven and resolved per-mutation. The gateway evaluates the following env vars in order and uses the first non-empty value:

```
X402_PRICE_<SNAKE_CASE_MUTATION>     e.g. X402_PRICE_CREATE_ANNOUNCEMENT
X402_PRICE_<UPPER_MUTATION>          e.g. X402_PRICE_CREATEANNOUNCEMENT
X402_PRICE_DEFAULT                   fallback when no per-mutation price is set
```

Values are interpreted as USDC amounts (e.g. `"2.50"` = $2.50). Prices are environment-configured — the authoritative price for a given mutation is the one quoted in the `402` payment-requirements response, so clients should always read it from the challenge rather than hardcoding amounts.

| Variable                              | Default                                         | Purpose                                                  |
| ------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
| `X402_NETWORK`                        | `base` (prod) / `base-sepolia` (non-prod)       | CAIP-2 network (`base` → `eip155:8453`)                  |
| `X402_PAY_TO_ADDRESS`                 | —                                               | Wallet that receives settlement                          |
| `X402_FACILITATOR_URL`                | `https://api.cdp.coinbase.com/platform/v2/x402` | Facilitator base URL                                     |
| `X402_PRICE_*` / `X402_PRICE_DEFAULT` | environment-configured                          | Per-mutation price in USDC (quoted in the 402 challenge) |
| `X402_TOKEN_TTL_SECONDS`              | `300`                                           | Lifetime of the minted service token                     |

Facilitator authentication uses Coinbase CDP API keys (`CDP_API_KEY_ID_SECRET_ARN` / `CDP_API_KEY_SECRET_SECRET_ARN` in Secrets Manager, or `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET` in local mode) to sign the `/verify`, `/settle`, and `/supported` requests.

***

## Response Semantics

| Status    | Meaning                                                                                                                     |
| --------- | --------------------------------------------------------------------------------------------------------------------------- |
| `200`     | Payment verified, upstream AppSync returned `2xx`. Body is the AppSync response verbatim; settlement headers are merged in. |
| `402`     | Payment required or payment verification failed. Body includes facilitator hints in headers.                                |
| `400`     | Path mutation mismatch, missing `query`, invalid GraphQL, or unresolvable payer address.                                    |
| `4xx/5xx` | Upstream AppSync error — settlement is skipped and the upstream response is returned as-is.                                 |

### Idempotency

Each minted service token has a unique `jti` claim, so requests are not idempotent by default — replaying the same signed payment may be rejected by the facilitator's replay protection, and the downstream AppSync call may succeed twice if you retry after a settlement failure. Agents should treat settlement failures as "payment not charged yet" and re-sign.

***

## Agent Usage Pattern

An autonomous agent typically wraps each gateway call in a helper:

```ts
// Pseudocode — actual wallet signing depends on your stack (viem, ethers, CDP SDK).
async function callX402(mutation: string, query: string, variables: any) {
  const url = `${GATEWAY_BASE}/x402/labs/${mutation}`;

  // 1. 402 challenge
  const challenge = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });

  const paymentRequirements = await challenge.json();
  const paymentHeader = await signX402Payment(paymentRequirements, agentWallet);

  // 2. Retry with payment
  const result = await fetch(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "payment-signature": paymentHeader,
    },
    body: JSON.stringify({ query, variables }),
  });

  return result.json();
}
```

See the [Developers / AI Agents guide](/user-guides/developers-ai-agents) for end-to-end agent integration patterns, and the [Labs API reference](/api-reference/labs-api) for the full GraphQL signatures of each gated mutation.

***

## Related

* [Labs API](/api-reference/labs-api) — full mutation signatures and variable types
* [Developers / AI Agents](/user-guides/developers-ai-agents) — agent integration guide
* [x402 specification](https://www.x402.org/)


# IPNFT API (Deprecated)

## Overview

The IPNFT API provides read-only access to query and browse intellectual property assets across the Molecule Protocol. Use these queries to build marketplaces, token screeners, portfolio trackers, and discovery interfaces for decentralized science projects.

**Features:**

* Query IP-NFTs (Intellectual Property NFTs) and their project details
* Browse IP Tokens (IPTs) with market data
* Access trading metrics and liquidity information
* Query users, research leads, chains, and agreements
* Filter, sort, and paginate results
* Build data-driven applications

***

## Authentication

All IPNFT API requests require a consumer credential (see [Authentication](/api-reference/authentication)).

### Obtaining a Consumer Credential

To request one:

1. Join our [Discord community](https://t.co/L0VEiy4Bjk)
2. Contact the Molecule team
3. Provide your intended use case
4. You'll receive a consumer credential (`mol_<consumerId>_<secret>`)

### Using Your Consumer Credential

Send it as the `Authorization` header value directly — **no `Bearer` prefix**:

```bash
Authorization: mol_<consumerId>_<secret>
```

***

## API Endpoint

```
Production: https://production.graphql.api.molecule.xyz/graphql
Staging:    https://staging.graphql.api.molecule.xyz/graphql
```

***

## Queries

### List IP-NFTs

Query and browse all IP-NFTs on the platform with filtering, sorting, and pagination.

**GraphQL Query:**

```graphql
query ListIPNFTs(
  $limit: Int
  $skip: Int
  $sortBy: IPNFTSortBy
  $sortOrder: SortOrder
  $filterBy: IPNFTFilterBy
) {
  ipnfts(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    createdAt
    updatedAt
    mintedAt
    chainId
    originalOwner
    tokenUri
    symbol
    name
    description
    image
    externalUrl
    initialSymbol
    organization
    topic
    trlValue
    trlRationale
    fundingAmountCurrency
    fundingAmountValue
    fundingAmountDecimals
    fundingAmountCurrencyType
    schemaVersion
    owner {
      id
      address
    }
    researchLead {
      name
      email
    }
    agreements {
      id
      contentHash
      mimeType
      type
      url
    }
    ipt {
      id
      symbol
      totalIssued
    }
  }
}
```

**Parameters:**

| Parameter | Type          | Description                                                                             |
| --------- | ------------- | --------------------------------------------------------------------------------------- |
| limit     | Int           | Maximum number of results (recommended: 20-50)                                          |
| skip      | Int           | Number of results to skip (for pagination)                                              |
| sortBy    | IPNFTSortBy   | Field to sort by (e.g., `createdAt`, `mintedAt`, `name`, `topic`, `fundingAmountValue`) |
| sortOrder | SortOrder     | Sort direction: `asc` or `desc`                                                         |
| filterBy  | IPNFTFilterBy | Filter criteria (owner, chainId, topic, etc.)                                           |

**Example Request (curl):**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query ListIPNFTs($limit: Int, $skip: Int, $sortBy: IPNFTSortBy, $sortOrder: SortOrder) { ipnfts(limit: $limit, skip: $skip, sortBy: $sortBy, sortOrder: $sortOrder) { id createdAt owner { address } name description image topic organization ipt { id symbol } } }",
    "variables": {
      "limit": 20,
      "skip": 0,
      "sortBy": "createdAt",
      "sortOrder": "desc"
    }
  }'
```

**Response Example:**

```json
{
  "data": {
    "ipnfts": [
      {
        "id": "37",
        "createdAt": "2024-01-15T10:30:00.000Z",
        "owner": {
          "address": "0x1234567890123456789012345678901234567890"
        },
        "name": "Novel Cancer Immunotherapy Research",
        "description": "Groundbreaking CAR-T cell therapy development",
        "image": "ipfs://QmXnnyufdzAWL...",
        "topic": "Oncology",
        "organization": "Research Institute",
        "ipt": {
          "id": "0xabcd...",
          "symbol": "CART-IPT"
        }
      }
    ]
  }
}
```

***

### Get Single IP-NFT

Retrieve detailed information about a specific IP-NFT by its ID.

**GraphQL Query:**

```graphql
query GetIPNFT($id: ID!) {
  ipnft(id: $id) {
    id
    createdAt
    updatedAt
    mintedAt
    chainId
    originalOwner
    tokenUri
    symbol
    name
    description
    image
    externalUrl
    initialSymbol
    organization
    topic
    trlValue
    trlRationale
    fundingAmountCurrency
    fundingAmountValue
    fundingAmountDecimals
    fundingAmountCurrencyType
    schemaVersion
    userId
    owner {
      id
      address
    }
    researchLead {
      id
      name
      email
    }
    agreements {
      id
      contentHash
      mimeType
      type
      url
    }
    ipt {
      id
      l2TokenAddress
      holderCount
      symbol
      name
      decimals
      totalIssued
      circulatingSupply
    }
  }
}
```

**Example Request:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query GetIPNFT($id: ID!) { ipnft(id: $id) { id name description topic symbol owner { address } ipt { symbol totalIssued } agreements { id type url } } }",
    "variables": {
      "id": "37"
    }
  }'
```

***

### List IP Tokens (IPTs)

Query and browse all IP Tokens with their associated IP-NFTs and market data.

**GraphQL Query:**

```graphql
query ListIPTs(
  $limit: Int
  $skip: Int
  $sortBy: IPTSortBy
  $sortOrder: SortOrder
  $filterBy: IPTFilterBy
) {
  ipts(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    createdAt
    updatedAt
    mintedAt
    l2TokenAddress
    holderCount
    symbol
    name
    decimals
    totalIssued
    circulatingSupply
    agreementCid
    agreementMimeType
    image
    links
    capped
    ipnftId
    originalOwnerId
    ipnft {
      id
      name
      description
      image
      topic
      organization
      owner {
        address
      }
    }
    originalOwner {
      id
      address
    }
    markets {
      id
      name
      chainId
      pairAddress
      liquidityUsd
      tradingVolume24hr
      usdPrice
      usdPrice24hrPercentageChange
      marketCapUsd
    }
  }
}
```

**Parameters:**

| Parameter | Type        | Description                                                           |
| --------- | ----------- | --------------------------------------------------------------------- |
| limit     | Int         | Maximum number of results                                             |
| skip      | Int         | Number of results to skip (for pagination)                            |
| sortBy    | IPTSortBy   | Field to sort by (e.g., `createdAt`, `holderCount`, `name`, `symbol`) |
| sortOrder | SortOrder   | Sort direction: `asc` or `desc`                                       |
| filterBy  | IPTFilterBy | Filter criteria (ipnftId, symbol, originalOwnerId, etc.)              |

**Example Request (curl):**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query ListIPTs($limit: Int, $sortBy: IPTSortBy, $sortOrder: SortOrder) { ipts(limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder) { id symbol name totalIssued markets { usdPrice liquidityUsd tradingVolume24hr } ipnft { name topic } } }",
    "variables": {
      "limit": 20,
      "sortBy": "createdAt",
      "sortOrder": "desc"
    }
  }'
```

**Response Example:**

```json
{
  "data": {
    "ipts": [
      {
        "id": "0xabcdef...",
        "symbol": "CART-IPT",
        "name": "Cancer Research IP Token",
        "totalIssued": "1000000000000000000000000",
        "markets": [
          {
            "usdPrice": 0.45,
            "liquidityUsd": 125000.5,
            "tradingVolume24hr": 8500.25
          }
        ],
        "ipnft": {
          "name": "Novel Cancer Immunotherapy Research",
          "topic": "Oncology"
        }
      }
    ]
  }
}
```

***

### Get Single IP Token

Retrieve detailed information about a specific IPT by its ID.

**GraphQL Query:**

```graphql
query GetIPT($id: ID!) {
  ipt(id: $id) {
    id
    createdAt
    updatedAt
    mintedAt
    l2TokenAddress
    holderCount
    symbol
    name
    decimals
    totalIssued
    circulatingSupply
    agreementCid
    agreementMimeType
    image
    links
    capped
    ipnft {
      id
      name
      description
      topic
    }
    originalOwner {
      id
      address
    }
    markets {
      chainId
      name
      pairAddress
      liquidityUsd
      usdPrice
      marketCapUsd
    }
  }
}
```

***

### Query Markets

Access trading and market data for IP Tokens.

**GraphQL Query:**

```graphql
query ListMarkets(
  $limit: Int
  $skip: Int
  $sortBy: MarketSortBy
  $sortOrder: SortOrder
  $filterBy: MarketFilterBy
) {
  markets(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    createdAt
    updatedAt
    name
    pairAddress
    chainId
    liquidityUsd
    tradingVolume24hr
    usdPrice
    usdPrice24hrPercentageChange
    marketCapUsd
    inverted
    iptId
    token {
      id
      symbol
      name
      ipnft {
        name
        topic
      }
    }
    chain {
      name
      chainId
      logoUrl
    }
  }
}
```

**Example - Get Markets by Trading Volume:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -d '{
    "query": "query ListMarkets($limit: Int, $sortBy: MarketSortBy, $sortOrder: SortOrder) { markets(limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder) { name usdPrice liquidityUsd tradingVolume24hr token { symbol } chain { name logoUrl } } }",
    "variables": {
      "limit": 10,
      "sortBy": "tradingVolume24hr",
      "sortOrder": "desc"
    }
  }'
```

A single market can also be fetched by its id:

```graphql
query GetMarket($id: ID!) {
  market(id: $id) {
    name
    usdPrice
    liquidityUsd
    tradingVolume24hr
    token {
      symbol
    }
  }
}
```

***

### Query Users

Query users and their associated IP-NFTs and IPTs.

**GraphQL Query:**

```graphql
query ListUsers(
  $limit: Int
  $skip: Int
  $sortBy: UserSortBy
  $sortOrder: SortOrder
  $filterBy: UserFilterBy
) {
  users(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    createdAt
    updatedAt
    address
    ipnfts {
      id
      name
      topic
    }
    ipts {
      id
      symbol
      name
    }
  }
}
```

**Get Single User:**

```graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    address
    createdAt
    updatedAt
    ipnfts {
      id
      name
      topic
      organization
    }
    ipts {
      id
      symbol
      name
      totalIssued
    }
  }
}
```

***

### Query Research Leads

Query research leads associated with IP-NFTs.

**GraphQL Query:**

```graphql
query ListResearchLeads(
  $limit: Int
  $skip: Int
  $sortBy: ResearchLeadSortBy
  $sortOrder: SortOrder
  $filterBy: ResearchLeadFilterBy
) {
  researchLeads(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    createdAt
    updatedAt
    name
    email
    ipnfts {
      id
      name
      topic
    }
  }
}
```

**Get Single Research Lead:**

```graphql
query GetResearchLead($id: ID!) {
  researchLead(id: $id) {
    id
    name
    email
    createdAt
    updatedAt
    ipnfts {
      id
      name
      topic
      organization
    }
  }
}
```

***

### Query Chains

Query blockchain networks where markets are deployed.

**GraphQL Query:**

```graphql
query ListChains(
  $limit: Int
  $skip: Int
  $sortBy: ChainSortBy
  $sortOrder: SortOrder
  $filterBy: ChainFilterBy
) {
  chains(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    createdAt
    updatedAt
    name
    chainId
    logoUrl
    markets {
      id
      name
      usdPrice
      liquidityUsd
    }
  }
}
```

**Get Single Chain:**

```graphql
query GetChain($id: ID!) {
  chain(id: $id) {
    id
    name
    chainId
    logoUrl
    createdAt
    updatedAt
    markets {
      id
      name
      usdPrice
      liquidityUsd
      tradingVolume24hr
    }
  }
}
```

***

### Query Agreements

Query legal agreements associated with IP-NFTs.

**GraphQL Query:**

```graphql
query ListAgreements(
  $limit: Int
  $skip: Int
  $sortBy: AgreementSortBy
  $sortOrder: SortOrder
  $filterBy: AgreementFilterBy
) {
  agreements(
    limit: $limit
    skip: $skip
    sortBy: $sortBy
    sortOrder: $sortOrder
    filterBy: $filterBy
  ) {
    id
    contentHash
    mimeType
    type
    url
    ipnftId
  }
}
```

**Get Single Agreement:**

```graphql
query GetAgreement($id: ID!) {
  agreement(id: $id) {
    id
    contentHash
    mimeType
    type
    url
    ipnftId
  }
}
```

***

## Common Patterns

### Pagination

Use `skip` and `limit` for pagination:

```javascript
// Page 1
{ "limit": 20, "skip": 0 }

// Page 2
{ "limit": 20, "skip": 20 }

// Page 3
{ "limit": 20, "skip": 40 }
```

### Sorting

Sort results by any field:

```javascript
{
  "sortBy": "createdAt",  // or "mintedAt", "updatedAt", "name", "topic", etc.
  "sortOrder": "desc"      // or "asc"
}
```

### Filtering

Filter results by specific criteria. The API supports both direct field filtering and nested relation filtering.

#### Basic Filtering

**Filter IP-NFTs by topic:**

```javascript
{
  "filterBy": {
    "topic": "Oncology"
  }
}
```

**Filter by owner (using user ID):**

```javascript
{
  "filterBy": {
    "userId": "0x1234567890123456789012345678901234567890"
  }
}
```

**Filter by chain:**

```javascript
{
  "filterBy": {
    "chainId": 1  // Ethereum mainnet
  }
}
```

**Filter IPTs by symbol:**

```javascript
{
  "filterBy": {
    "symbol": "VITA"
  }
}
```

**Filter IPTs by IPNFT:**

```javascript
{
  "filterBy": {
    "ipnftId": "37"
  }
}
```

#### Nested Relation Filtering

The API supports filtering by nested relation properties for more flexible queries.

**Filter IP-NFTs by owner address:**

```javascript
{
  "filterBy": {
    "owner": {
      "address": "0x1234567890123456789012345678901234567890"
    }
  }
}
```

**Filter IP-NFTs by owner ID:**

```javascript
{
  "filterBy": {
    "owner": {
      "id": "0x1234567890123456789012345678901234567890"
    }
  }
}
```

**Filter IP-NFTs by research lead:**

```javascript
{
  "filterBy": {
    "researchLead": {
      "email": "researcher@university.edu"
    }
  }
}
```

**Filter IP-NFTs by agreement properties:**

```javascript
{
  "filterBy": {
    "agreements": {
      "mimeType": "application/pdf"
    }
  }
}
```

**Filter IPTs by original owner:**

```javascript
{
  "filterBy": {
    "originalOwner": {
      "address": "0x1234567890123456789012345678901234567890"
    }
  }
}
```

**Filter IPTs by parent IPNFT properties:**

```javascript
{
  "filterBy": {
    "ipnft": {
      "topic": "Oncology"
    }
  }
}
```

**Filter markets by chain properties:**

```javascript
{
  "filterBy": {
    "chain": {
      "chainId": 1  // Ethereum mainnet
    }
  }
}
```

**Filter markets by token properties:**

```javascript
{
  "filterBy": {
    "token": {
      "symbol": "VITA-IPT"
    }
  }
}
```

**Filter IPTs by IPNFT owner (deeply nested):**

```javascript
{
  "filterBy": {
    "ipnft": {
      "owner": {
        "address": "0x1234567890123456789012345678901234567890"
      }
    }
  }
}
```

#### Combining Filters

You can combine multiple filters in a single query. All filters are combined with AND logic - results must match all criteria.

**Combine scalar and relation filters:**

```javascript
{
  "filterBy": {
    "chainId": 1,
    "owner": {
      "address": "0x1234567890123456789012345678901234567890"
    }
  }
}
```

**Combine multiple field filters:**

```javascript
{
  "filterBy": {
    "topic": "Oncology",
    "organization": "University Lab",
    "owner": {
      "address": "0x1234567890123456789012345678901234567890"
    }
  }
}
```

**Combine nested relation filters (IPT query):**

```javascript
{
  "filterBy": {
    "symbol": "VITA",
    "ipnft": {
      "owner": {
        "address": "0x1234567890123456789012345678901234567890"
      },
      "topic": "Longevity"
    }
  }
}
```

***

## Response Types

### IPNFT Type

```typescript
{
  id: String                    // Unique identifier — the onchain tokenId as a string (e.g. "37")
  oclId: String                 // Linked lab oclId, null when the IP-NFT has no linked lab
  createdAt: DateTime           // Creation timestamp
  updatedAt: DateTime           // Last update timestamp
  mintedAt: DateTime            // Minting timestamp
  chainId: Int                  // Blockchain network ID
  originalOwner: String         // Original minter address
  tokenUri: String              // Token metadata URI
  symbol: String                // Token symbol
  name: String                  // Project name
  description: String           // Project description
  image: String                 // IPFS image URL
  externalUrl: String           // External project URL
  initialSymbol: String         // Initial token symbol
  organization: String          // Organization name
  topic: String                 // Research topic
  trlValue: String              // Technology readiness levels value
  trlRationale: String          // Technology readiness levels rationale
  fundingAmountCurrency: String // Funding currency code
  fundingAmountValue: String    // Funding amount value
  fundingAmountDecimals: Int    // Funding currency decimals
  fundingAmountCurrencyType: String // Currency type (e.g., "ERC20", "native")
  schemaVersion: String         // Metadata schema version
  userId: String                // Owner user ID
  researchLeadId: String        // Research lead ID
  owner: {                      // Current owner
    id: String
    address: String
    createdAt: DateTime
    updatedAt: DateTime
  }
  researchLead: {               // Research lead
    id: String
    name: String
    email: String
  }
  agreements: [{                // Legal agreements
    id: String
    contentHash: String
    mimeType: String
    type: String
    url: String
    ipnftId: String
  }]
  ipt: {                        // Associated IP Token (if tokenized)
    id: String
    symbol: String
    totalIssued: String
  }
}
```

### IPT Type

```typescript
{
  id: String                    // Unique identifier
  createdAt: DateTime           // Creation timestamp
  updatedAt: DateTime           // Last update timestamp
  mintedAt: DateTime            // Minting timestamp
  l2TokenAddress: String        // ERC-20 contract address
  holderCount: Int              // Number of token holders
  symbol: String                // Token symbol
  name: String                  // Token name
  decimals: Int                 // Token decimals
  totalIssued: String           // Total supply (wei format)
  circulatingSupply: String     // Circulating supply
  agreementCid: String          // IPFS CID of membership agreement
  agreementMimeType: String     // Agreement file MIME type
  image: String                 // Token image URL
  links: [String]               // Related links
  capped: Boolean               // Whether token issuance is capped
  ipnftId: String               // Parent IP-NFT ID
  originalOwnerId: String       // Original owner user ID
  ipnft: {                      // Parent IP-NFT
    id: String
    name: String
    description: String
    topic: String
  }
  originalOwner: {              // Original token owner
    id: String
    address: String
  }
  markets: [{                   // Trading markets
    chainId: Int
    pairAddress: String
    liquidityUsd: Float
    usdPrice: Float
    tradingVolume24hr: Float
    marketCapUsd: Float
  }]
}
```

### Market Type

```typescript
{
  id: String; // Unique identifier
  createdAt: DateTime; // Creation timestamp
  updatedAt: DateTime; // Last update timestamp
  name: String; // Market name
  pairAddress: String; // DEX pair contract address
  chainId: Int; // Blockchain network ID
  liquidityUsd: Float; // Total liquidity in USD
  tradingVolume24hr: Float; // 24h trading volume in USD
  usdPrice: Float; // Current token price in USD
  usdPrice24hrPercentageChange: Float; // 24h price change %
  marketCapUsd: Float; // Market capitalization in USD
  inverted: Boolean; // Whether the pair is inverted
  iptId: String; // Associated IPT ID
  token: {
    // Associated IPT
    id: String;
    symbol: String;
    name: String;
  }
  chain: {
    // Blockchain info
    id: Int;
    name: String;
    chainId: Int;
    logoUrl: String;
  }
}
```

### User Type

```typescript
{
  id: String; // Unique identifier
  createdAt: DateTime; // Creation timestamp
  updatedAt: DateTime; // Last update timestamp
  address: String; // Wallet address
  ipnfts: [IPNFT]; // Owned IP-NFTs
  ipts: [IPT]; // Owned IPTs
}
```

### ResearchLead Type

```typescript
{
  id: String; // Unique identifier
  createdAt: DateTime; // Creation timestamp
  updatedAt: DateTime; // Last update timestamp
  name: String; // Research lead name
  email: String; // Research lead email
  ipnfts: [IPNFT]; // Associated IP-NFTs
}
```

### Chain Type

```typescript
{
  id: Int; // Unique identifier
  createdAt: DateTime; // Creation timestamp
  updatedAt: DateTime; // Last update timestamp
  name: String; // Chain name
  chainId: Int; // Blockchain network ID (e.g., 1 for Ethereum)
  logoUrl: String; // Chain logo URL
  markets: [Market]; // Markets on this chain
}
```

### Agreement Type

```typescript
{
  id: String; // Unique identifier
  contentHash: String; // Content hash
  mimeType: String; // File MIME type
  type: String; // Agreement type
  url: String; // Agreement URL
  ipnftId: String; // Parent IP-NFT ID
}
```

***

## Example Use Cases

### Building a Marketplace UI

```javascript
// Fetch recent IP-NFTs with full details
const response = await fetch(
  "https://production.graphql.api.molecule.xyz/graphql",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": process.env.CONSUMER_CREDENTIAL,
    },
    body: JSON.stringify({
      query: `
      query RecentIPNFTs {
        ipnfts(limit: 20, sortBy: createdAt, sortOrder: desc) {
          id
          name
          description
          image
          topic
          organization
          ipt {
            id
            symbol
          }
        }
      }
    `,
    }),
  },
);

const data = await response.json();
// Display IP-NFTs in marketplace grid
```

### Token Screener / Price Tracker

```javascript
// Get top IPTs by trading volume
const response = await fetch(
  "https://production.graphql.api.molecule.xyz/graphql",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": process.env.CONSUMER_CREDENTIAL,
    },
    body: JSON.stringify({
      query: `
      query TopIPTsByVolume {
        ipts(limit: 10, sortBy: createdAt, sortOrder: desc) {
          symbol
          name
          markets {
            usdPrice
            usdPrice24hrPercentageChange
            tradingVolume24hr
            liquidityUsd
            marketCapUsd
          }
        }
      }
    `,
    }),
  },
);

const data = await response.json();
// Display price table with 24h change indicators
```

### Portfolio Tracker

```javascript
// Get all IP-NFTs owned by a specific wallet
const walletAddress = "0x1234567890123456789012345678901234567890";

const response = await fetch(
  "https://production.graphql.api.molecule.xyz/graphql",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": process.env.CONSUMER_CREDENTIAL,
    },
    body: JSON.stringify({
      query: `
      query UserPortfolio($filterBy: IPNFTFilterBy) {
        ipnfts(filterBy: $filterBy) {
          id
          name
          topic
          ipt {
            symbol
            totalIssued
            markets {
              usdPrice
              marketCapUsd
            }
          }
        }
      }
    `,
      variables: {
        filterBy: {
          owner: {
            address: walletAddress,
          },
        },
      },
    }),
  },
);

const data = await response.json();
// Calculate total portfolio value
```

***

## Advanced Filtering

### Relation Filtering vs Direct Filtering

The IPNFT API supports two approaches to filtering:

1. **Direct Field Filtering**: Filter by the ID of a related entity
2. **Relation Filtering**: Filter by properties of related entities

Both approaches work and can be used based on your needs.

**Example - Finding IP-NFTs by Owner:**

```javascript
// Approach 1: Direct field filtering (when you know the user ID)
{
  "filterBy": {
    "userId": "0x1234..."
  }
}

// Approach 2: Relation filtering (when you want to filter by owner properties)
{
  "filterBy": {
    "owner": {
      "address": "0x1234..."
    }
  }
}
```

### Multi-Level Nested Filtering

You can filter through multiple levels of relations:

```javascript
// Find all IP Tokens whose parent IP-NFT is owned by a specific wallet
{
  "filterBy": {
    "ipnft": {
      "owner": {
        "address": "0x1234567890123456789012345678901234567890"
      }
    }
  }
}

// Find markets for tokens with a specific symbol
{
  "filterBy": {
    "token": {
      "symbol": "VITA-IPT"
    }
  }
}
```

### Available Relation Filters

| Query Type | Relation Field  | Supported Filters                              | Example                                       |
| ---------- | --------------- | ---------------------------------------------- | --------------------------------------------- |
| `ipnfts`   | `owner`         | `id`, `address`                                | `owner: { address: "0x..." }`                 |
| `ipnfts`   | `researchLead`  | `id`, `name`, `email`                          | `researchLead: { email: "..." }`              |
| `ipnfts`   | `agreements`    | `id`, `contentHash`, `mimeType`, `type`, `url` | `agreements: { mimeType: "application/pdf" }` |
| `ipts`     | `ipnft`         | All IPNFT filter fields                        | `ipnft: { topic: "Oncology" }`                |
| `ipts`     | `originalOwner` | `id`, `address`                                | `originalOwner: { address: "0x..." }`         |
| `markets`  | `chain`         | `id`, `chainId`, `name`                        | `chain: { chainId: 1 }`                       |
| `markets`  | `token`         | All IPT filter fields                          | `token: { symbol: "VITA" }`                   |

### Filter Matching

All filters use **exact equality matching** by default. For example:

```javascript
{
  "filterBy": {
    "topic": "Longevity"  // Exact match only
  }
}
```

***

## Error Handling

### Common Errors

| Status Code | Error                 | Description                            |
| ----------- | --------------------- | -------------------------------------- |
| 401         | Unauthorized          | Missing or invalid consumer credential |
| 400         | Bad Request           | Invalid query syntax or parameters     |
| 500         | Internal Server Error | Server error - retry the request       |

Missing resources are **not** signalled with an HTTP 404. Single-item queries (`ipnft`, `ipt`, `user`, …) return HTTP 200 with a GraphQL error in the `errors[]` array carrying a machine-readable `code`:

| GraphQL error `code`        | Meaning                                                     |
| --------------------------- | ----------------------------------------------------------- |
| `NOT_FOUND`                 | Requested resource doesn't exist                            |
| `COMPLEXITY_LIMIT_EXCEEDED` | Query too complex — max depth **5**, max **100** selections |
| `INVALID_INPUT`             | Malformed arguments                                         |

### Troubleshooting

**401 Unauthorized Error:**

* Verify the `Authorization` header is included, with no `Bearer` prefix
* Check that your consumer credential is valid and not expired
* Ensure no typos in the consumer credential

**Empty Results:**

* Check filter criteria - may be too restrictive
* Verify the chainId if filtering by chain
* Try removing filters to see all results

**GraphQL Errors:**

* Check query syntax is valid
* Ensure field names match the schema
* Verify variable types match parameter types

***

## Getting Support

For questions or issues with the IPNFT API:

* Join our [Discord community](https://t.co/L0VEiy4Bjk)
* Check the [API Overview](/api-reference/api-reference) for authentication help

***

## Recent Updates

The breaking changes, migration notes, and newly added fields for this API have moved to the [API Changelog & Migration](/api-reference/changelog#ipnft-api-deprecated) page (February 2026 changes).

***

*Last updated: February 2026*


# API Changelog & Migration

This page tracks breaking changes, deprecations, and additions across the Molecule APIs. Use it when upgrading an existing integration. Each API's most recent changes are listed first.

***

## Authentication

### Backend credential stores confined to the platform network

The data stores behind API authentication — the consumer credential registry, machine service tokens, and the access whitelist — are now network-confined to Molecule's private cloud network. They are unreachable from outside it, even with valid cloud-account credentials; only the API's own backend can read or write them. This is a hardening change with **no effect on any API, header, token format, or SDK** — consumer credentials, `X-Service-Token`, and Privy user tokens all work exactly as before.

**Migration:** None required.

### `x-api-key` replaced by consumer credentials

All Molecule APIs (Labs, Tokenization, and IPNFT (Deprecated) — they share one GraphQL endpoint) now authenticate with a consumer credential instead of an `x-api-key` header. A consumer credential has the shape `mol_<consumerId>_<secret>` and is sent directly as the `Authorization` header value, with **no `Bearer` prefix**.

```diff
- x-api-key: YOUR_API_KEY
+ Authorization: mol_<consumerId>_<secret>
```

**Migration:** Contact the Molecule team for a consumer credential and send it as `Authorization: mol_<consumerId>_<secret>` instead of `x-api-key` — do not prefix it with `Bearer`, which is reserved for Privy user tokens and will fail authentication. Nothing else changes: `X-Service-Token` for machine-authorized mutations, and `Authorization: Bearer <Privy token>` + `x-wallet-address` for user-authorized mutations, work exactly as before. See [Authentication](/api-reference/authentication) for the full header reference.

***

## Labs API

### GraphQL introspection disabled and query depth capped in production

The production endpoint (shared by all Molecule APIs — see [API Overview](/api-reference/api-reference)) no longer serves `__schema` / `__type` introspection queries: they now return a validation error. `__typename` still resolves. Selection-set depth is also capped at 10 in production, with scalar leaves counted as a level (`{ root { child { name } } }` is depth 3). A query beyond that limit fails at execution time with `errorType: "QueryDepthLimitReached"` and partial data — a plain GraphQL error, not the catalogued error shape used elsewhere, so handle both.

**Migration:** If your codegen or tooling discovers the schema by introspecting the production endpoint, that now fails — request a current copy of the schema from the Molecule team (see [Getting Support](/api-reference/api-reference)) rather than introspecting production. If you see `QueryDepthLimitReached`, flatten the query to 10 levels of nesting or fewer; this limit was not previously enforced.

### `isSuccess` removed (unified error contract)

The Labs API now reports failure one way per operation class, and the `isSuccess` envelope is gone:

* **Queries throw.** A failed query surfaces as an entry in the top-level GraphQL `errors[]` array; the failed field is `null`, and because most Labs query fields are non-null the null propagates and `data` itself comes back `null` (only `labWithDataRoomAndFiles` and `dataRoomFile` null just their own field). `errorType` carries the error code (table below) — the only value to branch on — and `errorInfo { requestId, retryable, details }` carries the correlation id and structured context. Query result types (`ActivitiesResult`, `FileCategoriesAndTagsResult`, `ListLabMembersResult`, `DidLinkStatusResult`, `LegalAgreementTemplateResult`, `ServiceSignInMessageResult`) no longer have an `isSuccess` or an `error` field.
* **Mutations return errors in-band.** Every mutation `*Result` carries a nullable `error: ApiError`. **Success ⇔ `error == null`.** A top-level `errors[]` entry on a mutation means a transport or infrastructure failure (or an invalid request document), not a business outcome. Where a result keeps a top-level `message`, it mirrors `error.message` on failure and is never empty.

`isSuccess` has been **removed** from every Labs result type, and the `error` field on mutation results is now typed `ApiError` (the previous error type no longer exists). A document that still selects `isSuccess` is rejected at validation (`Field 'isSuccess' in type '…Result' is undefined`) and never executes.

Behaviour that changed alongside the envelope:

* **Auth denials are code-accurate.** The former `AUTH_FAILED` catch-all is split into `UNAUTHENTICATED` (missing, invalid or expired credentials), `UNAUTHORIZED` (authenticated but lacking the role or membership), `NOT_FOUND` (the lab does not exist) and `VALIDATION_FAILED` (malformed input).
* **Legacy codes live on as `details.reason`.** Codes such as `PROJECT_NOT_FOUND`, `LAB_NOT_FOUND`, `INVALID_OCL_ID` or `SHORTNAME_TAKEN` are no longer top-level codes; they survive as the `reason` key inside `details`, beneath the catalogue code (for example `CONFLICT` with `reason: "SHORTNAME_TAKEN"`). Branch on `code` first and on `reason` only where a page documents it.
* **Service tokens.** `ServiceTokenResult` and `ServiceTokenRevocationResult` gained `error: ApiError`; `token`, `tokenId`, `serviceName`, `expiresAt`, `createdAt` and `revokedAt` are `null` on failure instead of `""`.
* **`InitiateFileUploadResult.method`** is nullable and `null` on failure.
* **`LegalAgreementStatusResult` is dual-surface.** As the `legalAgreementStatus(oclId, type)` query, failures throw and `error` is always `null`. As the `legalAgreementStatus` field on `Lab` / `LabRef`, an upstream failure returns the payload with `error` set (typically `UPSTREAM_UNAVAILABLE`) instead of nulling the lab: `error != null` means the status could not be determined, and only when `error == null` does `signed: false` mean "not signed".
* `listLabMembers` on an unknown lab throws `NOT_FOUND` (previously a `PROJECT_NOT_FOUND` envelope). `createLab` conflicts return `CONFLICT` with `details.reason` `PROJECT_CONFLICT` (lab already registered) or `ACCOUNT_NAME_CONFLICT`.

> **The Tokenization API is unaffected.** Its operations keep their previous result envelope (a success flag alongside an `error` object), exactly as documented in the [Tokenization API reference](/api-reference/tokenization-api). This section applies to the Labs API only.

#### `ApiError`

```graphql
type ApiError {
  code: String!       # error code from the table below — the only field to branch on
  message: String!    # human-readable, never empty; not part of the contract
  requestId: String!  # correlation id — include it in bug reports
  retryable: Boolean! # whether an unchanged retry can plausibly succeed
  details: AWSJSON    # optional structured context — keys: field, reason, hint, docs
}
```

On an in-band mutation error, `details` arrives as a JSON-encoded string (AppSync `AWSJSON`), e.g. `"details": "{\"reason\":\"NOT_LAB_OWNER\"}"` — read it with `JSON.parse(error.details ?? "{}")`. On a thrown query error, `errorInfo.details` is a plain object. Ignore keys you do not recognise, and never match on `message` — its wording may change without notice.

#### Error codes

| Code                        | `retryable` | Meaning                                                                 | Typical `details.reason`                                                         |
| --------------------------- | ----------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `UNAUTHENTICATED`           | false       | Missing, invalid or expired credentials.                                | `TOKEN_EXPIRED`, `INVALID_SIGNATURE`, `WALLET_MISMATCH`                          |
| `UNAUTHORIZED`              | false       | Authenticated but not allowed (role or membership).                     | `NOT_LAB_OWNER`, `NOT_CONTRIBUTOR`, `SERVICE_NOT_WHITELISTED`                    |
| `NOT_FOUND`                 | false       | The referenced resource does not exist.                                 | `LAB_NOT_FOUND`, `PROJECT_NOT_FOUND`, …                                          |
| `VALIDATION_FAILED`         | false       | Input failed validation; `details.field` names the offending field.     | `INVALID_OCL_ID`, …                                                              |
| `CONFLICT`                  | false       | A valid request conflicts with current state.                           | `SHORTNAME_TAKEN`, `ALREADY_SIGNED`, `PROJECT_CONFLICT`, `ACCOUNT_NAME_CONFLICT` |
| `FAILED_PRECONDITION`       | false       | Resource state makes the operation impossible until that state changes. | `TEMPLATE_EXPIRED`, `LEGACY_ENCRYPTION`, `NOT_ENCRYPTED`                         |
| `COMPLEXITY_LIMIT_EXCEEDED` | false       | Query shape or result size is over the limit.                           | `FILTER_COMPLEXITY_LIMIT`, `RESULT_CARDINALITY_LIMIT`                            |
| `RATE_LIMITED`              | **true**    | Throttled — retry with backoff.                                         | —                                                                                |
| `TIMEOUT`                   | **true**    | Execution exceeded the request budget.                                  | —                                                                                |
| `UPSTREAM_UNAVAILABLE`      | **true**    | A dependency failed — retry with backoff.                               | `KAMU`, `CMS`, `IPFS`                                                            |
| `INTERNAL_ERROR`            | **true**    | Unexpected failure; quote `requestId` when reporting it.                | —                                                                                |

Codes may be added over time, and each addition is announced on this page. Treat a code you do not recognise as non-retryable, keep the raw value for diagnostics and surface it to a human. `PAYMENT_REQUIRED` is reserved for the x402 gateway and is not emitted by the GraphQL API. `details.reason` values are diagnostic refinement, not a contract surface — they may be extended without notice.

#### Before / after

```diff
# Query selection (listLabMembers) — the envelope field is gone; failures
# arrive as top-level errors[] with errorType
  query ListLabMembers($oclId: String!) {
    listLabMembers(oclId: $oclId) {
-     isSuccess
      message
      members {
        walletAddress
        role
      }
    }
  }
```

```diff
# Mutation selection (createAnnouncement) — select `error` instead of `isSuccess`
  mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!) {
    createAnnouncement(oclId: $oclId, headline: $headline, body: $body) {
-     isSuccess
      message
-     error { message code retryable }
+     error { code message requestId retryable details }
    }
  }
```

```diff
- if (!result.isSuccess) {
-   handle(result.error?.code);
- }
+ if (result.error) {
+   const { reason } = JSON.parse(result.error.details ?? "{}");
+   handle(result.error.code, reason);
+ }
```

**Migration:** Remove `isSuccess` (and, except on `legalAgreementStatus`, any `error { … }` selection) from every Labs query document — a document that still selects it fails validation and the query never runs — and handle failures from the top-level `errors[]` array, keyed on `errorType`. On mutations, replace `isSuccess` with `error { code message requestId retryable details }`, treat `error == null` as success, and branch on `error.code` (plus `details.reason` where documented), never on `message` text. Replace any check on the former `AUTH_FAILED` catch-all with the split codes listed above, and any `token === ""` / `tokenId === ""` checks on service-token results with `null` checks. If you read `legalAgreementStatus` off a lab object, select `error { code message }` and check it before trusting `signed`. Leave your Tokenization API handling as it is. See [Labs API › Error Handling](/api-reference/labs-api#error-handling) for the full reference.

### `*V2` operations and pre-OCL naming removed

The legacy `*V2` operations and the pre-OCL naming have been **removed**. The current API is `oclId`-based. If you are migrating from an older integration, use the current names below.

#### Renamed queries

| Legacy (removed)                                   | Current                      | Notes                                                     |
| -------------------------------------------------- | ---------------------------- | --------------------------------------------------------- |
| `projects` / `projectsV2`                          | `labs`                       | Same paginated shape; adds an optional `role` filter      |
| `projectWithDataRoomAndFiles` / `…V2`              | `labWithDataRoomAndFiles`    | Look up by `oclId` (or `shortname`) instead of `ipnftUid` |
| `dataRoomFileV2`                                   | `dataRoomFile`               | Identified by `oclId` + `path`                            |
| `projectActivity` / `projectActivityV2`            | `labActivity`                | —                                                         |
| `activitiesV2`                                     | `activities`                 | —                                                         |
| `projectAnnouncementsV2` / `projectAnnouncementV2` | `labActivity` / `activities` | Removed — use the `filter: ANNOUNCEMENT` argument         |

#### Renamed mutations

| Legacy (removed)               | Current                      | Notes                                                                  |
| ------------------------------ | ---------------------------- | ---------------------------------------------------------------------- |
| `createProject`                | `createLab`                  | Now takes `input: { oclId }` instead of `ipnftSymbol` / `ipnftTokenId` |
| `initiateCreateOrUpdateFileV2` | `initiateCreateOrUpdateFile` | —                                                                      |
| `finishCreateOrUpdateFileV2`   | `finishCreateOrUpdateFile`   | —                                                                      |
| `createAnnouncementV2`         | `createAnnouncement`         | Takes `oclId`; the legacy `moleculeAccessLevel` param was removed      |
| `updateFileMetadataV2`         | `updateFileMetadata`         | —                                                                      |
| `deleteDataRoomFileV2`         | `deleteDataRoomFile`         | —                                                                      |

#### Renamed fields

Top-level identifiers on `Lab` / `LabRef` were renamed away from the legacy IP-NFT naming:

| Legacy field   | Current field       | Notes                                                       |
| -------------- | ------------------- | ----------------------------------------------------------- |
| `ipnftUid`     | `oclId`             | Now a 32-byte hex string (`0x…`), not `<address>_<tokenId>` |
| `ipnftSymbol`  | `shortname`         | Human-readable slug derived from the lab name               |
| `ipnftAddress` | `labAccountAddress` | ERC-6551 token-bound account address                        |
| `ipnftTokenId` | `labNftTokenId`     | LabNFT tokenId (decimal string)                             |

> The linked legacy IP-NFT (for labs migrated from one) is still available as the nested `ipnft` object on `Lab` / `LabRef`; `LabRef` additionally exposes a scalar `ipnftId` field.

***

## IPNFT API (Deprecated)

> The IPNFT API is deprecated. The changes below are preserved for integrations that have not yet migrated. See the [IPNFT API reference](/api-reference/ipnft-api-deprecated).

### February 2026

#### Breaking Changes

**Schema Flattening — `metadata` wrapper removed from IPNFT and IPT**

The intermediate `metadata` wrapper objects (`IPNFTMetadata`, `IPTMetadata`) have been removed. All metadata fields now live directly on the `IPNFT` and `IPT` types.

```diff
# IPNFT queries — before
- ipnft {
-   metadata {
-     name
-     description
-     topic
-   }
- }

# IPNFT queries — after
+ ipnft {
+   name
+   description
+   topic
+ }

# IPT queries — before
- ipt {
-   metadata {
-     symbol
-     name
-     totalIssued
-   }
- }

# IPT queries — after
+ ipt {
+   symbol
+   name
+   totalIssued
+ }
```

**Migration:** Remove all `metadata { ... }` wrappers and access fields directly on the parent type.

**`fundingAmount` JSON field decomposed into 4 typed fields**

The `fundingAmount` JSON field on IPNFT has been replaced with 4 strongly-typed fields:

```diff
- ipnft { metadata { fundingAmount } }  // JSON object
+ ipnft {
+   fundingAmountCurrency      // e.g., "USDC"
+   fundingAmountValue         // e.g., "1000000"
+   fundingAmountDecimals      // e.g., 6
+   fundingAmountCurrencyType  // e.g., "ERC20"
+ }
```

**Migration example:**

```javascript
// OLD
const amount = ipnft.metadata.fundingAmount; // JSON object

// NEW
const amount = {
  currency: ipnft.fundingAmountCurrency,
  value: ipnft.fundingAmountValue,
  decimals: ipnft.fundingAmountDecimals,
  currencyType: ipnft.fundingAmountCurrencyType
};
```

**`agreements` changed from JSON array to typed relation**

The `agreements` field on IPNFT has changed from a JSON array to a queryable typed relation with sub-field selection:

```diff
- ipnft { metadata { agreements } }  // JSON array
+ ipnft {
+   agreements {  // Typed relation
+     id
+     contentHash
+     mimeType
+     type
+     url
+     ipnftId
+   }
+ }
```

**Migration:** Update your queries to select specific agreement fields instead of receiving a raw JSON array.

**Filter changes — nested metadata filters removed**

All filter paths that previously went through `metadata` are now direct fields:

```diff
# Filtering IP-NFTs by topic
- filterBy: { metadata: { topic: "Oncology" } }
+ filterBy: { topic: "Oncology" }

# Filtering IP-NFTs by organization
- filterBy: { metadata: { organization: "University Lab" } }
+ filterBy: { organization: "University Lab" }

# Filtering IPTs by symbol
- filterBy: { metadata: { symbol: "VITA" } }
+ filterBy: { symbol: "VITA" }
```

**`researchLead` and `originalOwner` filter paths changed**

These relation filters are now direct on the parent type instead of nested inside metadata context:

```diff
# Filter IPNFT by research lead (now direct on IPNFTFilterBy)
- filterBy: { metadata: { researchLead: { email: "..." } } }
+ filterBy: { researchLead: { email: "..." } }

# Filter IPT by original owner (now direct on IPTFilterBy)
- filterBy: { metadata: { originalOwner: { address: "..." } } }
+ filterBy: { originalOwner: { address: "..." } }
```

#### New Features

**New query types**

The following new top-level queries are now available:

| Query                                     | Description                                              |
| ----------------------------------------- | -------------------------------------------------------- |
| `user(id)` / `users(...)`                 | Query users by address, list associated IP-NFTs and IPTs |
| `researchLead(id)` / `researchLeads(...)` | Query research leads and their associated IP-NFTs        |
| `chain(id)` / `chains(...)`               | Query blockchain networks and their markets              |
| `agreement(id)` / `agreements(...)`       | Query legal agreements associated with IP-NFTs           |

All new queries support `limit`, `skip`, `sortBy`, `sortOrder`, and `filterBy` parameters.

**New fields on IPNFT**

* `updatedAt` — Last update timestamp
* `tokenUri` — Token metadata URI
* `symbol` — Token symbol
* `schemaVersion` — Metadata schema version
* `userId` — Owner user ID (for direct filtering)
* `researchLeadId` — Research lead ID (for direct filtering)
* `fundingAmountCurrency`, `fundingAmountValue`, `fundingAmountDecimals`, `fundingAmountCurrencyType` — Decomposed funding amount fields

**New fields on IPT**

* `updatedAt` — Last update timestamp
* `mintedAt` — Minting timestamp
* `agreementMimeType` — Agreement file MIME type
* `originalOwner` — Full `User` type (with `id`, `address`)
* `originalOwnerId` — Original owner user ID (for direct filtering)
* `ipnftId` — Parent IP-NFT ID (for direct filtering)
* `links` — Related links
* `capped` — Whether token issuance is capped

**New fields on Market**

* `createdAt`, `updatedAt` — Timestamps
* `inverted` — Whether the pair is inverted
* `iptId` — Associated IPT ID (for direct filtering)
* `chain.logoUrl` — Chain logo URL now available

**`agreements` queryable as typed relation**

Agreements on IP-NFTs are now a fully queryable relation with sub-field selection, filtering, sorting, and pagination:

```graphql
ipnft {
  agreements(limit: 10, sortBy: type, sortOrder: asc) {
    id
    contentHash
    mimeType
    type
    url
  }
}
```


# Overview

What changed in each released version of the Molecule APIs, newest first, with migration notes for anything that breaks an existing integration.

Each page here tracks one API area. Entries are per released version, newest first, and cover only what a consumer can observe: contract changes, new and removed operations, and migrations.

| Area             | Page                                                              |
| ---------------- | ----------------------------------------------------------------- |
| Labs API         | [Labs API](/release-notes/release-notes/labs-api)                 |
| Tokenization API | [Tokenization API](/release-notes/release-notes/tokenization-api) |
| x402 Gateway     | [x402 Gateway](/release-notes/release-notes/x402-gateway)         |

**Looking for how to migrate off an older shape?** The [API Changelog & Migration](/api-reference/changelog) page organises the same breaking changes thematically, by what changed rather than by when. Use this section to answer "what shipped in a given version"; use that one to answer "how do I move off `ipnftUid`".

## What appears here

Only consumer-visible change. Internal refactors, test changes, dependency bumps, infrastructure and CI work are deliberately absent — most releases contain nothing but those, and produce no entry at all. A version missing from these pages shipped nothing that affects your integration.

## Entry format

```markdown
## 1.2.3

_Released 2026-08-04_

### Breaking changes

#### <short title>

<what changed, in consumer terms>

**Migration:** <what to do, with a before/after example — or "No action required.">

### Added
### Removed
```

Conventions:

* The heading is the bare version, matching the git tag. Molecule release tags carry **no `v` prefix** — the tag for 1.0.14 is `1.0.14`.
* Every breaking change carries either a migration note or an explicit "No action required."
* Link out to the reference page rather than restating it.


# Labs API

Consumer-visible changes to the Labs API, newest first.

Changes to the [Labs API](/api-reference/labs-api) that affect integrations. Versions not listed shipped nothing consumer-visible.

## 3.0.1

*Released 2026-08-25*

### Changed

#### Assignment Agreement no longer required before data-room writes

Data-room write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry`, and `createAnnouncement` (see [Files](/api-reference/labs-api/files)) — used to fail with `ASSIGNMENT_AGREEMENT_NOT_SIGNED` (`FAILED_PRECONDITION`) until a lab's Assignment Agreement was signed via `signLegalAgreement`. That gate is now disabled: these mutations succeed regardless of the agreement's sign state. [Sign Legal Agreement and Check Legal Agreement Status](/api-reference/labs-api/legal-agreements) are unchanged and remain fully functional — signing is now optional rather than a write precondition.

**Migration:** No action required. Integrations that retried after handling `ASSIGNMENT_AGREEMENT_NOT_SIGNED` can drop that handling; it is safe to leave in place, since the error is simply no longer emitted for this type.

## 2.0.1

*Released 2026-08-18*

### Breaking changes

#### GraphQL introspection disabled and query depth capped in production

The production endpoint no longer serves `__schema` / `__type` introspection queries — they return a validation error. `__typename` still resolves. Selection-set depth is also capped at 10 in production; a query beyond that fails at execution time with `errorType: "QueryDepthLimitReached"` rather than the usual error shape. See [API Changelog & Migration](/api-reference/changelog#graphql-introspection-disabled-and-query-depth-capped-in-production) for details and migration steps.

## 1.0.14

*Released 2026-08-04*

### Breaking changes

#### `isSuccess` removed (unified error contract)

Every Labs API result type lost its `isSuccess` flag, and mutation results now carry a nullable `error: ApiError` instead of the previous error type; a document that still selects `isSuccess` fails GraphQL validation. Queries throw: a failed query surfaces as a top-level GraphQL `errors[]` entry whose `errorType` is a catalogue code such as `UNAUTHENTICATED` or `NOT_FOUND`. Mutations return errors in-band, and success means `error == null`. Authentication denials, legacy error codes, service-token result fields and `InitiateFileUploadResult.method` changed alongside the envelope. The Tokenization API is unaffected and keeps its previous result envelope.

**Migration:** Drop `isSuccess` (and, except on `legalAgreementStatus`, any `error { … }` selection) from query documents and handle the top-level `errors[]` array keyed on `errorType`; on mutations select `error { code message requestId retryable details }` and branch on `error.code` instead of `message`. See [API Changelog & Migration](/api-reference/changelog#issuccess-removed-unified-error-contract) for the before/after examples, the `ApiError` shape, the full code table and the field-level changes.

For migrations off the removed `isSuccess` envelope, the pre-OCL naming and the `*V2` operations, see [API Changelog & Migration](/api-reference/changelog).


# Tokenization API

Consumer-visible changes to the Tokenization API, newest first.

Changes to the [Tokenization API](/api-reference/tokenization-api) that affect integrations. Versions not listed shipped nothing consumer-visible.

*No entries yet.* Entries are added per release by the docs-sync pipeline; see [Overview](/release-notes/release-notes) for the format.


# x402 Gateway

Consumer-visible changes to the x402 Gateway, newest first.

Changes to the [x402 Gateway](/api-reference/x402-gateway) that affect integrations. Versions not listed shipped nothing consumer-visible.

*No entries yet.*

Note that the gateway forwards to Labs API mutations, so a Labs API contract change can reach x402 callers even when the gateway itself is unchanged. Check [Labs API](/release-notes/release-notes/labs-api) as well.


# MIRA

Molecule Insights & Research Assistant

### Overview <a href="#overview" id="overview"></a>

[MIRA](https://mira.molecule.xyz/) serves as the connective layer between complex scientific research and the growing community participating in decentralized science. It combines a curated knowledge base, real-time market data via Model Context Protocol (MCP), and AI-powered project scoring to serve researchers, traders, and institutions. The name—Molecule Insights & Research Assistant—reflects its dual purpose: delivering deep insights while guiding users through the complexities of decentralized funding.

MIRA serves three core user groups:

* **Researchers** — Understanding DeSci, navigating Labs and Projects
* **Traders/Investors** — Accessing real-time token data, project insights, and market intelligence
* **Institutions** — Evaluating project maturity through standardized scoring frameworks

#### Key Capabilities

* **Scoped Expertise:** Answers queries specifically about Molecule's products and funding mechanisms using curated local sources and real-time API data.
* **Real-Time Market Intelligence:** Retrieves live token prices, holder distributions, liquidity\
  metrics, project activity, and historical price data through MCP tools connected to Molecule's\
  APIs.
* **AI-Powered Project Scoring:** Analyzes Data Room files to generate Technology Readiness Level (TRL) assessments and weighted evaluation scores covering therapeutic relevance, optionality, and IP strength.
* **Safe and Transparent:** Employs responsible AI design—it knows when to say "I don't know" and defers to human experts to avoid misinformation. Never provides investment advice.
* **Source Attribution:** Every response links back to its origin, ensuring traceability and\
  accountability. Performance and feedback are tracked via Langfuse to continually improve response quality.
* **Web Search Fallback:** If local knowledge is insufficient but relevant, MIRA augments its answers by retrieving current information from trusted online sources.

### Architecture & Workflow <a href="#architecture-and-workflow" id="architecture-and-workflow"></a>

1. User Query
2. Extract Context (current token/page being viewed)
3. Search Local Knowledge (LanceDB)
4. If sufficient → answer directly
5. If real-time data needed → execute MCP tools
6. If insufficient but relevant → perform web search
7. If off-topic → politely decline
8. Generate Response with linked sources
9. User Feedback & Tracking via Langfuse

**Tech Stack:**

<table><thead><tr><th width="225.05859375">Component</th><th>Purpose</th></tr></thead><tbody><tr><td>OpenAI GPT-4o</td><td>Powers reasoning, tool selection, and response generation</td></tr><tr><td>Model Context Protocol</td><td>Connects to Molecule APIs for real-time token and project data</td></tr><tr><td>LanceDB</td><td>Vector-based semantic search for curated documentation</td></tr><tr><td>Vercel AI SDK</td><td>Streaming responses and tool orchestration</td></tr><tr><td>Upstash Redis</td><td>Caching layer for API responses</td></tr><tr><td>Sanity CMS</td><td>Stores and manages AI-generated project scores</td></tr><tr><td>Langfuse</td><td>Monitors performance metrics and feedback</td></tr><tr><td>Tavily API</td><td>Web search for current information beyond local knowledge</td></tr></tbody></table>

**Evolution & Strategy**

* **v0 — Knowledge Foundation:** Launched as Molecule's first AI-driven tool, MIRA v0 established a\
  context-aware assistant built on curated knowledge from official documentation, protocol blogs, and DeSci standards. It responds only from verified sources, preventing hallucination and ensuring reliability.
* **v1 — Real-Time Integration:** Introduced MCP tool integration for live market data and was embedded directly into the Molecule Screener. Added context awareness to understand which token users are viewing, enabling natural conversational flows with quick actions for common queries.
* **v2 — Project Scoring (Current):** Expanded to analyze Data Room contents and generate standardized project assessments. Scores flow through a review pipeline—from AI generation through human validation to publication via Sanity CMS—ensuring appropriate oversight.
* **v3 — Knowledge Graphs (Planned):** Future iterations will introduce relationship mapping across the ecosystem, connecting projects, researchers, and institutions through interactive visualizations. This phase also addresses confidential data handling and explores token-gated access models.


# Molecule Skill

An agent plugin that runs the full Lab workflow — create an Onchain Lab, upload research data, and announce it — through AI coding agents

### Overview

The Molecule skill lets AI agents execute the complete Lab lifecycle end-to-end — create an Onchain Lab, upload research files (public or encrypted), publish announcements, and manage roles — without a browser and without hand-written API calls.

It ships as a cross-harness agent plugin with two parts:

* **The `aura-orchestrator` skill** (`SKILL.md`) — a step-by-step runbook the agent follows: resolve or create an Onchain Lab (LabNFT plus its token-bound account), register it, upload files to the data room, announce, and optionally grant roles or hand the Lab off to another owner.
* **The `molecule` MCP server** — a typed [Model Context Protocol](https://modelcontextprotocol.io) server that performs every network, onchain, and cryptographic operation as a single tool call. Paid mutations are settled automatically through the [x402 Gateway](/api-reference/x402-gateway).

The skill format (`SKILL.md`) and MCP are open standards, so the same plugin works under Claude Code, OpenAI Codex, and any other MCP-capable agent harness. To obtain and install it, jump to [Getting the Plugin](#getting-the-plugin).

{% hint style="info" %}
This is a different component from the read-only [MCP Tools](/references/mcp-tools) server, which answers ecosystem data questions (IPT prices, project activity). The Molecule skill's MCP server runs locally, holds your credentials, signs transactions, and writes to Labs.
{% endhint %}

### What the Skill Does

The workflow is sequential — each phase consumes the previous phase's output:

| Phase | Step                           | What happens                                                                           |
| ----- | ------------------------------ | -------------------------------------------------------------------------------------- |
| 0     | Wallet setup                   | The agent operates a wallet of your choice (see [Wallet Backends](#wallet-backends))   |
| 1     | Resolve or create the Lab      | Reuse a Lab the wallet already owns, or mint a new LabNFT with its token-bound account |
| 2     | Register the Lab               | `createLab` mutation, paid via x402                                                    |
| 3     | Upload a file to the data room | Public (plaintext) or private (client-side encrypted, access-controlled)               |
| 4     | Announce                       | `createAnnouncement` mutation attaching the uploaded dataset, paid via x402            |
| 5     | Grant roles / hand off         | Optionally grant a co-owner role or transfer the LabNFT to another wallet              |

#### Public vs. Private Uploads

Phase 3 is the only branch in the workflow:

* **Public** — the file is uploaded as-is with `accessLevel: PUBLIC`.
* **Private** — the file is encrypted client-side with AES-256-GCM before upload and finalized with encryption metadata plus onchain access conditions (a role on the Lab, or being an authorized signer of its token-bound account). Only wallets satisfying those conditions can later decrypt it — see [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) for how access is evaluated.

Both paths are billed per mutation through the x402 Gateway; the private path additionally uses a [service token](#the-service-token) for the key-management calls.

### MCP Server Tools

The `molecule` MCP server exposes typed tools grouped by concern:

| Group         | Tools (examples)                                                        | Purpose                                                                             |
| ------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Wallet        | `wallet_address`, `privy_*`, `eoa_send_transaction`                     | Dual-backend wallet operations: address lookup, transaction signing and sending     |
| Onchain reads | `ocl_read`, `ocl_tx_identity`                                           | Read Lab state (ownership, roles, mint fee) and parse mint receipts                 |
| Molecule API  | `labs_graphql`, `x402_pay`, `s3_upload`                                 | Labs API queries, paid mutations (the full x402 handshake in one call), file upload |
| Encryption    | `labs_generate_dek`, `labs_decrypt_dek`, `encrypt_file`, `decrypt_file` | Envelope encryption for private files                                               |
| Utilities     | `sha256_file`, `abi_encode`, `build_access_conditions`, `config_doctor` | Hashing, calldata encoding, access-condition JSON, configuration diagnostics        |
| Bootstrap     | `issue_service_token`, `issue_owner_service_token`                      | Issue the service token used for key-management calls                               |

`config_doctor` reports which environment profile and wallet backend are active and names exactly which configuration is still missing, instead of letting a tool guess.

### Wallet Backends

Every signing and spending step works with either of two backends — you choose, and you can switch later with a configuration change:

* **Privy agentic wallet** — transactions are signed server-side via the [Privy](https://privy.io) API. No private key ever exists on your machine. The skill can create the wallet for you on first run, with a single-chain, value-capped policy.
* **Raw EOA** — you provide a private key via an environment variable; the MCP server signs locally and the key never leaves that process.

If only one backend is configured it is selected automatically; if both are configured you must pin the choice explicitly — the server refuses to guess which wallet to spend from.

{% hint style="warning" %}
The operating wallet pays real costs: USDC on Base for x402-billed mutations plus native gas for onchain transactions (LabNFT mint, role grants, transfers). Fund it before running the workflow.
{% endhint %}

### Configuration

All configuration and secrets are plain **process environment variables** read by the MCP server subprocess — set them wherever your harness injects env into MCP servers (the `env` block of the MCP registration, or Claude Code's settings files as shown in [Installation](#claude-code)). Tools read credentials from the environment — the agent passes file paths, queries, and addresses, not keys. The x402 Gateway base URL and contract addresses for each environment are provided by the Molecule team (see [Getting the Plugin](#getting-the-plugin)).

| Variable                                                                   | Purpose                                                                                                                                                                    |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ENVIRONMENT`                                                              | Deployment profile: `staging` (Base Sepolia) or `production` (Base)                                                                                                        |
| `MOLECULE_LABS_URL`                                                        | Labs API GraphQL endpoint for the chosen environment — see [API Overview](/api-reference/api-reference) for the URLs                                                       |
| `MOLECULE_CLIENT_URL`                                                      | Labs app base URL, used to build project links in announcements                                                                                                            |
| `X402_GATEWAY_URL`                                                         | x402 Gateway base URL (endpoint paths are documented on the [x402 Gateway](/api-reference/x402-gateway) page)                                                              |
| `CHAIN_ID`                                                                 | `84532` (Base Sepolia) or `8453` (Base), matching `ENVIRONMENT`                                                                                                            |
| `EVM_RPC_URL`                                                              | RPC endpoint for onchain reads and broadcasts (optional; falls back to a public node)                                                                                      |
| `ONCHAIN_LAB_FACTORY_ADDRESS`, `LABNFT_ADDRESS`, `ACCESS_RESOLVER_ADDRESS` | Onchain Lab contract addresses for the selected environment — see the [Contracts reference](/references/contracts)                                                         |
| `WALLET_BACKEND`                                                           | Wallet backend selector: `privy` or `eoa` (auto-selected when only one is configured)                                                                                      |
| `EVM_WALLET_ADDRESS`                                                       | Watch-only address for reads and the optional hand-off target                                                                                                              |
| `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `PRIVY_WALLET_ID`                      | Privy backend credentials (secret)                                                                                                                                         |
| `WALLET_PRIVATE_KEY`                                                       | EOA backend private key (secret)                                                                                                                                           |
| `MOLECULE_CONSUMER_CREDENTIAL`                                             | Your `mol_<consumerId>_<secret>` consumer credential for Labs API calls, sent as the `Authorization` header — see [Authentication](/api-reference/authentication) (secret) |
| `MOLECULE_API_KEY`                                                         | Legacy shared API key — fallback only, while the `mol_` credential migration completes (secret)                                                                            |
| `MOLECULE_SERVICE_TOKEN`                                                   | Service token for private-upload key management (secret)                                                                                                                   |

The wallet variables are all optional until you pick a backend — configure the Privy trio or the EOA key, not both (unless you pin `WALLET_BACKEND`).

{% hint style="info" %}
**Use a `mol_` consumer credential.** The Labs API is moving from one shared API key to per-consumer credentials — a single `mol_<consumerId>_<secret>` string sent as the `Authorization` header with **no `Bearer` prefix** (see [Authentication](/api-reference/authentication)). Set it as `MOLECULE_CONSUMER_CREDENTIAL`; keep `MOLECULE_API_KEY` only if you still hold the legacy shared key. If both are set, the plugin sends both headers, so the same configuration works throughout the migration.
{% endhint %}

#### The Service Token

The service token is an **off-chain JWT bound to a wallet** — issued by signing a sign-in message with that wallet, not minted on chain. The skill needs it **only for private (encrypted) uploads**: the key-management calls that generate and decrypt the file's data-encryption key authenticate with it, while public uploads and all x402-paid mutations work without one.

Two things matter in practice:

* **Which wallet the token is bound to decides what it can decrypt.** The backend authorizes `decryptDataKey` against the token's bound wallet, so that wallet must satisfy the file's access conditions (a role on the Lab, or being an authorized signer of its token-bound account).
* **How to get one.** Preferably issue it once during setup and store it as `MOLECULE_SERVICE_TOKEN`. The plugin can do the issuance itself, matching your wallet backend: `issue_service_token` signs the sign-in message with the Privy agent wallet, `issue_owner_service_token` signs with the owner EOA. Both return the JWT for you to place in your harness's secret configuration. The underlying two-step GraphQL flow (plus extending and revoking tokens) is documented in [Service Token Management](/api-reference/labs-api/service-tokens).

If the token is missing or expired, the DEK tools fail with an error naming it — nothing falls back to an unauthenticated call.

### Security Model

The plugin is designed to keep secrets and confidential data out of the agent conversation:

* **Secrets stay in the environment.** Tools read credentials from environment variables; the agent passes file paths, queries, and addresses — no tool requires a key or token as an argument. The one deliberate exception is service-token bootstrapping: the `issue_service_token` tools return the issued JWT so you can store it in your harness's secret configuration. Prefer issuing it once during setup (and setting `MOLECULE_SERVICE_TOKEN`) over issuing per run, so the token stays out of agent transcripts.
* **The encryption key never leaves the server.** For private uploads, the data-encryption key is held in MCP server memory and referenced by an opaque, short-lived handle; the plaintext key is never returned to the agent, written to a file, or logged.
* **Fail-closed confidentiality.** Once a file enters the private upload path, the server refuses — for the lifetime of the server process, with no override flag — to upload that file's plaintext or to finalize it as public, even if the agent were instructed to. A failed private upload aborts; it never falls back to a public one.
* **Local encryption.** Files are encrypted with AES-256-GCM before upload, byte-for-byte compatible with the Labs client encryption, and verified by content hash after decryption.

### Getting the Plugin

The plugin is open source — install it from [moleculeprotocol/mol-labs-plugin](https://github.com/moleculeprotocol/mol-labs-plugin):

```bash
git clone https://github.com/moleculeprotocol/mol-labs-plugin.git
```

You'll still need the environment-specific configuration values that aren't published — the x402 Gateway base URL, contract addresses, and a `mol_` consumer credential — request them on our [Discord community](https://t.co/L0VEiy4Bjk). The repository layout:

```
mol-labs-plugin/
├── .claude-plugin/                     # Claude Code plugin manifest ("molecule-desci") + marketplace
├── .codex-plugin/                      # OpenAI Codex plugin manifest
├── .mcp.json                           # registers the "molecule" MCP server (uv run mcp/server.py)
├── skills/aura-orchestrator/SKILL.md   # the skill: the runbook the agent follows
└── mcp/server.py                       # the MCP server (Python, stdio transport)
```

The skill itself is a standard `SKILL.md` file — frontmatter that tells the harness when to use it, followed by the phase-by-phase runbook:

```yaml
---
name: aura-orchestrator
description: End-to-end DeSci molecule on the OCL (On-Chain Labs) surface —
  resolve-or-create an on-chain lab (LabNFT + token-bound account), register it,
  upload files (public or private/encrypted), and announce. Driven entirely
  through the `molecule` MCP server.
---
```

#### Prerequisite: `uv`

The MCP server is launched with [`uv`](https://docs.astral.sh/uv/), which reads the inline dependency header in `server.py` and provisions Python dependencies automatically on first run (a plain virtualenv works too — see the plugin's `mcp/README.md`):

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh   # or: brew install uv
```

#### Claude Code

Load the plugin directory directly:

```bash
claude --plugin-dir /path/to/mol-labs-plugin
```

or install it straight from GitHub via the plugin marketplace:

```
/plugin marketplace add moleculeprotocol/mol-labs-plugin
/plugin install molecule-desci@molecule-desci-marketplace
```

The `molecule` MCP server registers automatically from the plugin's `.mcp.json`. Put the environment variables in your project's Claude Code settings — non-secrets in `.claude/settings.json`, secrets in `.claude/settings.local.json` (which stays out of version control), both under the `"env"` key:

```json
{
  "env": {
    "ENVIRONMENT": "staging",
    "MOLECULE_LABS_URL": "https://staging.graphql.api.molecule.xyz/graphql",
    "CHAIN_ID": "84532",
    "WALLET_BACKEND": "privy"
  }
}
```

Then run the skill: `/molecule-desci:aura-orchestrator` (attach or point it at the research file you want published).

#### OpenAI Codex

Register the MCP server in `~/.codex/config.toml` and give it the same environment:

```toml
[mcp_servers.molecule]
command = "uv"
args = ["run", "/path/to/mol-labs-plugin/mcp/server.py"]

[mcp_servers.molecule.env]
ENVIRONMENT = "staging"
MOLECULE_LABS_URL = "https://staging.graphql.api.molecule.xyz/graphql"
CHAIN_ID = "84532"
WALLET_BACKEND = "privy"
# ...plus the gateway URL, contract addresses, and secrets from the Molecule team
```

Then copy `skills/aura-orchestrator/SKILL.md` into the skills directory your Codex version scans (check `/skills`), or surface it through `AGENTS.md`.

#### Other MCP hosts

Any harness that can spawn a stdio MCP server works — register it with the equivalent of:

```json
{
  "mcpServers": {
    "molecule": {
      "command": "uv",
      "args": ["run", "/path/to/mol-labs-plugin/mcp/server.py"],
      "env": { "ENVIRONMENT": "staging" }
    }
  }
}
```

#### Verify the install (offline, no secrets)

```bash
cd /path/to/mol-labs-plugin/mcp && uv run smoke.py
```

This lists every tool and exercises the pure-compute ones (encryption round-trip, ABI encoding, access-condition building) without any network access or credentials.

### Related Pages

* [Molecule Labs](/technical-deep-dive/onchain-lab) — what an Onchain Lab is
* [Roles & Permissions](/technical-deep-dive/roles-and-permissions) — the role model used by access conditions
* [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) — encryption and access evaluation in depth
* [Labs API](/api-reference/labs-api) — the GraphQL surface the skill drives
* [x402 Gateway](/api-reference/x402-gateway) — pay-per-call settlement for protected mutations
* [MCP Tools](/references/mcp-tools) — the read-only ecosystem-data MCP server


# Contracts

Molecule Protocol consists of smart contracts deployed across Ethereum Mainnet and Base L2. These contracts enable the creation, tokenization, and trading of intellectual property assets.

#### Base Mainnet — Molecule Labs core (v0.1.0)

The Lab smart-account stack is deployed on Base (chain ID 8453), the canonical chain for Molecule Labs:

| Contract                       | Address                                    | Verified URL                                                                        | Function                                    |
| ------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------- |
| OnChainLabFactory              | 0xECdF4f05384056507485C90aeAb0a83268760D6E | [BaseScan](https://basescan.org/address/0xECdF4f05384056507485C90aeAb0a83268760D6E) | Lab account creation (mint & create)        |
| LabNFT (proxy)                 | 0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92 | [BaseScan](https://basescan.org/address/0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92) | Lab ownership NFT (ERC-721)                 |
| ERC7484Registry                | 0x1Ab5Ba4300613F7346835b2BE7E2D10Ce6125eF5 | [BaseScan](https://basescan.org/address/0x1Ab5Ba4300613F7346835b2BE7E2D10Ce6125eF5) | Module attestation registry                 |
| RootValidator                  | 0xb31d39ECc0cb26478E258C8f7e9C906115f494f6 | [BaseScan](https://basescan.org/address/0xb31d39ECc0cb26478E258C8f7e9C906115f494f6) | Default signature validator                 |
| MoleculeOclDidRegistry (proxy) | 0x6cd3Cf3c34a18Bf48F90590c3a57708F175b2eE3 | [BaseScan](https://basescan.org/address/0x6cd3Cf3c34a18Bf48F90590c3a57708F175b2eE3) | DID linking for Labs                        |
| AccessResolver (v3)            | 0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B | [BaseScan](https://basescan.org/address/0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B) | Roles & file access control                 |
| OclTokenizer (proxy)           | 0x62F532C3f563D974deEc103AAb8cC597f4f9c84E | [BaseScan](https://basescan.org/address/0x62F532C3f563D974deEc103AAb8cC597f4f9c84E) | Lab tokenization (IPT factory)              |
| OclTermsPermissioner           | 0x125A12C880934826c80A54ce216B6c1F542603eE | [BaseScan](https://basescan.org/address/0x125A12C880934826c80A54ce216B6c1F542603eE) | Membership-agreement signature verification |

#### Base Sepolia Testnet

| Contract             | Address                                    | Verified Link                                                                                       |
| -------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| AccessResolver (v3)  | 0x5493F472602C87318EA5Eff753cDD593bf9bF559 | [View on BaseScan](https://sepolia.basescan.org/address/0x5493F472602C87318EA5Eff753cDD593bf9bF559) |
| OclTokenizer (proxy) | 0xEe19e0Db8a7e59538710FAF6ed3ab655BCfCdB24 | [View on BaseScan](https://sepolia.basescan.org/address/0xEe19e0Db8a7e59538710FAF6ed3ab655BCfCdB24) |
| OclTermsPermissioner | 0x2196e7181393b8045F34b66f571F1566922Aa4dB | [View on BaseScan](https://sepolia.basescan.org/address/0x2196e7181393b8045F34b66f571F1566922Aa4dB) |

#### Ethereum/Base Mainnet (IPNFT Related Contracts) UNMAINTAINED

| Network          | Contract       | Address                                    | Verified URL                                                                         | Function                                                                         |
| ---------------- | -------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| Ethereum Mainnet | IPNFT          | 0xcaD88677CA87a7815728C72D74B4ff4982d54Fc1 | [Etherscan](https://etherscan.io/address/0xcaD88677CA87a7815728C72D74B4ff4982d54Fc1) | IP-NFT ownership                                                                 |
| Ethereum Mainnet | CrowdSale      | 0xF0A8D23F38E9CbBe01C4Ed37f23BD519b65BC6C2 | [Etherscan](https://etherscan.io/address/0xF0A8D23F38E9CbBe01C4Ed37f23BD519b65BC6C2) | Token sales                                                                      |
| Ethereum Mainnet | AccessResolver | 0xc130e0b49840b266A49F62C0Cc77e353E0C99cD0 | [Etherscan](https://etherscan.io/address/0xc130e0b49840b266A49F62C0Cc77e353E0C99cD0) | File access control (v2 — signer predicates only; the role system lives on Base) |

#### Sepolia Testnet (IPNFT Related Contracts) UNMAINTAINED

<table><thead><tr><th>Contract</th><th width="297.61328125">Address</th><th>Verified Link</th></tr></thead><tbody><tr><td>IPNFT</td><td>0x152B444e60C526fe4434C721561a077269FcF61a</td><td><a href="https://sepolia.etherscan.io/address/0x152B444e60C526fe4434C721561a077269FcF61a">View on Etherscan</a></td></tr><tr><td>CrowdSale</td><td>0x8cA737E2cdaE1Ceb332bEf7ba9eA711a3a2f8037</td><td><a href="https://sepolia.etherscan.io/address/0x8cA737E2cdaE1Ceb332bEf7ba9eA711a3a2f8037">View on Etherscan</a></td></tr></tbody></table>

### Upgrade Pattern

The OclTokenizer contracts use the **UUPS (Universal Upgradeable Proxy Standard)** pattern:

* Proxy contracts hold state and delegate calls to implementation contracts
* Only the contract owner can authorize upgrades
* Contract addresses remain stable across upgrades

### IP Token Cloning

IP Tokens (IPTs) are deployed using the **EIP-1167 Minimal Proxy** pattern:

* Each Lab tokenization creates a new `LabToken` clone
* Clones share implementation code but have independent state
* No fixed deployment address - each IPT has a unique address
* Query `OclTokenizer.tokenized(labId)` or use the indexer to find IPT addresses

### Security Considerations

* Legacy core contracts (IPNFT, CrowdSale, TimelockedToken) have been audited by pashov
* The Molecule Labs core (OnChainLab account stack) was audited by Cyfrin in 2026 — see [Audits](/security/audits)
* Admin functions are protected by `onlyOwner` access control
* See individual contract pages for specific security notes


# IPT

## IP Token (IPT) Contract Documentation

### Overview

An **IP Token (IPT)** is a fungible ERC-20 token representing a fractional claim tied to a Lab. Each tokenized Lab has exactly one IPT, deployed by the [OclTokenizer](/references/contracts/tokenizer) as an EIP-1167 clone of the `LabToken` contract on Base. IPT holders gain economic exposure to the Lab and participate in its community — while control of the token's supply always follows the Lab's current owner.

### Contract Details

| Property     | Value                                                 |
| ------------ | ----------------------------------------------------- |
| **Contract** | LabToken (EIP-1167 clone per Lab)                     |
| **Standard** | ERC-20 (Burnable)                                     |
| **Owner**    | The OclTokenizer, which enforces lab-controller rules |
| **Decimals** | 18                                                    |
| **Chain**    | Base (8453) / Base Sepolia (84532)                    |
| **Solidity** | 0.8.33                                                |
| **License**  | MIT                                                   |

Clone templates: Base mainnet [`0xd13a5D5ab80c15c344aA0eAa36f16ABae940dB0c`](https://basescan.org/address/0xd13a5D5ab80c15c344aA0eAa36f16ABae940dB0c), Base Sepolia [`0x8038d3B220C635b3B7A7B4bcba0928a2D5B0B8F6`](https://sepolia.basescan.org/address/0x8038d3B220C635b3B7A7B4bcba0928a2D5B0B8F6). Each Lab's IPT is a distinct clone with its own address — resolve it via `OclTokenizer.tokenized(labId)`.

### Naming & Metadata

The token name is derived automatically at tokenization — `Lab Tokens of Lab #<labId>` — and the symbol is chosen by the Lab's controller. Every IPT carries onchain metadata linking it to its origin:

```solidity
struct Metadata {
    uint256 labId;         // the LabNFT tokenId this IPT derives from
    address originalOwner; // the Lab controller at tokenization time
    string  s3Key;         // membership agreement document key
    bytes32 contentHash;   // SHA-256 of the agreement, binding terms to exact bytes
}
```

`uri()` returns contract-level metadata as a base64 data URL (ERC-1155-compatible format), so explorers and integrations can render the token's Lab context without an offchain service.

### Supply Model

* **Issuance** — new tokens are minted via `issue(receiver, amount)`, callable only by the tokenizer or the Lab's current controller. `totalIssued` tracks everything ever minted (it can exceed the live supply, since tokens are burnable).
* **Capping** — `cap()` permanently freezes issuance. It is irreversible and idempotent (calling it again simply re-emits `Capped`). Once capped, `issue` reverts `TokenCapped()` forever, giving holders supply certainty.
* **Burning** — any holder can `burn` their own tokens (or `burnFrom` with allowance), reducing live supply.
* **Transfers** — standard ERC-20 transfers; there are no onchain transfer restrictions.

Because the contract's owner is the OclTokenizer and controller checks resolve live against `LabNFT.ownerOf`, supply authority moves automatically with the Lab: transfer the LabNFT and the new owner controls `issue`/`cap` immediately.

### Functions

```solidity
// Mint new tokens (tokenizer or Lab controller only; reverts once capped)
function issue(address receiver, uint256 amount) external

// Permanently freeze issuance (tokenizer or Lab controller only)
function cap() external

// Burn tokens
function burn(uint256 amount) external
function burnFrom(address account, uint256 amount) external

// Reads
function metadata() external view returns (Metadata memory)
function totalIssued() external view returns (uint256)
function capped() external view returns (bool)
function uri() external view returns (string memory)
```

### Events & Errors

* **`Capped(uint256 atSupply)`** — issuance frozen at `totalIssued`
* Standard ERC-20 `Transfer` / `Approval`
* **`TokenCapped()`** — issuance attempted after capping
* **`MustControlLab()`** — caller is neither the tokenizer nor the Lab's controller

### Wrapped IPTs (Bring Your Own Token)

A Lab can attach a pre-existing ERC-20 as its IPT instead of minting a new one. The tokenizer wraps it in a **`WrappedLabToken`** — a read-only decorator that carries the Lab's `Metadata` and `uri()` while delegating economics to the underlying token. `issue` and `cap` on a wrapped IPT revert: supply of the underlying token remains governed by its own contract.

### Related Contracts

* [Tokenizer](/references/contracts/tokenizer) — deploys and controls IPTs
* [Molecule Labs](/technical-deep-dive/onchain-lab) — the Lab the IPT is tied to

### Resources

* **Tokenization flow & API**: [Tokenization API](/api-reference/tokenization-api)
* **Query existing IPTs**: [IPNFT API (Deprecated)](/api-reference/ipnft-api-deprecated) (`ipts`, `markets`)


# Tokenizer

## OclTokenizer Contract Documentation

### Overview

The `OclTokenizer` turns a Lab into a liquid asset: it deploys one fractional ERC-20 **IP Token (IPT)** per Lab, gated by a signed membership agreement. The caller must be the Lab's controller — the current LabNFT owner — and each Lab can be tokenized exactly once. It also supports attaching a pre-existing ERC-20 ("bring your own token") as the Lab's IPT via a read-only wrapper.

### Contract Details

| Property     | Value                                                 |
| ------------ | ----------------------------------------------------- |
| **Contract** | OclTokenizer (UUPS proxy) — deploys `LabToken` clones |
| **Type**     | UUPS Upgradeable Proxy + EIP-1167 clone factory       |
| **Chain**    | Base (8453) / Base Sepolia (84532)                    |
| **Solidity** | 0.8.33                                                |
| **License**  | MIT                                                   |

### Deployments

**Base Mainnet:**

| Contract                         | Address                                                                                                                 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| OclTokenizer (proxy)             | [`0x62F532C3f563D974deEc103AAb8cC597f4f9c84E`](https://basescan.org/address/0x62F532C3f563D974deEc103AAb8cC597f4f9c84E) |
| OclTermsPermissioner             | [`0x125A12C880934826c80A54ce216B6c1F542603eE`](https://basescan.org/address/0x125A12C880934826c80A54ce216B6c1F542603eE) |
| LabToken (clone template)        | [`0xd13a5D5ab80c15c344aA0eAa36f16ABae940dB0c`](https://basescan.org/address/0xd13a5D5ab80c15c344aA0eAa36f16ABae940dB0c) |
| WrappedLabToken (clone template) | [`0x48Ab68bB775e4AC50eA0a9939dBcc71ae4A69817`](https://basescan.org/address/0x48Ab68bB775e4AC50eA0a9939dBcc71ae4A69817) |

**Base Sepolia:**

| Contract                         | Address                                                                                                                         |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| OclTokenizer (proxy)             | [`0xEe19e0Db8a7e59538710FAF6ed3ab655BCfCdB24`](https://sepolia.basescan.org/address/0xEe19e0Db8a7e59538710FAF6ed3ab655BCfCdB24) |
| OclTermsPermissioner             | [`0x2196e7181393b8045F34b66f571F1566922Aa4dB`](https://sepolia.basescan.org/address/0x2196e7181393b8045F34b66f571F1566922Aa4dB) |
| LabToken (clone template)        | [`0x8038d3B220C635b3B7A7B4bcba0928a2D5B0B8F6`](https://sepolia.basescan.org/address/0x8038d3B220C635b3B7A7B4bcba0928a2D5B0B8F6) |
| WrappedLabToken (clone template) | [`0x1Ba1Bd7d8824d6e7DB4dDF15aa41E00cE841abE2`](https://sepolia.basescan.org/address/0x1Ba1Bd7d8824d6e7DB4dDF15aa41E00cE841abE2) |

### How It Works

Tokenizing a Lab is a single transaction, authorized by two things:

1. **Lab control** — `controllerOf(labId)` resolves live to `LabNFT.ownerOf(labId)`. Only the Lab's current controller can tokenize, issue, or cap.
2. **A signed membership agreement** — the caller signs the exact terms text for the agreement (identified by its S3 `s3Key` and SHA-256 `contentHash`) with a plain `personal_sign` (EIP-191) signature. The configured `OclTermsPermissioner` reconstructs the terms onchain via `specificTermsV1()` and verifies the signature — ECDSA first, then ERC-1271 for smart-account signers.

On success, the tokenizer deploys an EIP-1167 clone of the `LabToken` template, mints the initial supply to the caller, records it in the `tokenized(labId)` registry, and emits `TokensCreated`. The token name is derived automatically as `Lab Tokens of Lab #<labId>`; the caller chooses only the symbol. A second tokenization attempt for the same Lab reverts.

The Lab's assets are never taken into custody — the IPT is a fractional claim token associated with the Lab, and the LabNFT stays exactly where it is.

### Functions

#### Write Functions

**`tokenize`**

Deploys the Lab's IP Token and mints the initial supply to the caller.

{% code overflow="wrap" %}

```solidity
function tokenize(uint256 labId, uint256 amount, string calldata symbol, string calldata s3Key, bytes32 contentHash, bytes calldata signature) external returns (LabToken token)
```

{% endcode %}

**`attachToken`**

"Bring your own token": registers a pre-existing ERC-20 (must have code and ≤ 18 decimals) as the Lab's IPT by wrapping it in a read-only `WrappedLabToken` that carries the Lab's metadata. `issue`/`cap` on a wrapped token revert.

{% code overflow="wrap" %}

```solidity
function attachToken(uint256 labId, string calldata s3Key, bytes32 contentHash, bytes calldata signature, IERC20Metadata tokenContract) external returns (ILabToken)
```

{% endcode %}

**`issue`**

Mints additional supply of a Lab's IPT. Controller-only; reverts once the token is capped.

```solidity
function issue(LabToken labToken, uint256 amount, address receiver) external
```

**`cap`**

Permanently freezes issuance for a Lab's IPT. Controller-only, irreversible.

```solidity
function cap(LabToken labToken) external
```

#### Read Functions

* **`controllerOf(uint256 labId)`** — the Lab's current controller (`LabNFT.ownerOf(labId)`)
* **`tokenized(uint256 labId)`** — the Lab's IPT contract, or the zero address if not tokenized
* **`labNft()`**, **`permissioner()`**, **`labTokenImplementation()`**, **`wrappedLabTokenImplementation()`** — current configuration

#### Admin Functions (owner-only)

`setPermissioner`, `setLabTokenImplementation`, `setWrappedLabTokenImplementation` — configuration updates by the owner multisig. Ownership transfers are two-step (`Ownable2Step`) with an explicit `cancelTransferOwnership`, and `renounceOwnership` is permanently disabled.

### Events

* **TokensCreated**(labId, oclId, tokenContract, emitter, amount, s3Key, contentHash, name, symbol) — a Lab was tokenized
* **TokenWrapped**(underlyingToken, wrappedLabToken, labId) — an existing ERC-20 was attached
* **PermissionerUpdated / LabTokenImplementationUpdated / WrappedLabTokenImplementationUpdated** — admin configuration changes

### Errors

* `MustControlLab()` — caller is not the Lab's current controller
* `AlreadyTokenized()` — the Lab already has an IPT
* `InvalidTokenContract()` / `InvalidTokenDecimals()` — attached token has no code or more than 18 decimals
* `InvalidS3Key()` — empty agreement key
* `ZeroAddress()`, `LabTokenNotControlledByTokenizer()`, `RenounceOwnershipDisabled()`

### Security Considerations

* **One token per Lab** — the `tokenized` registry is committed before external calls (checks-effects-interactions), and re-tokenization reverts.
* **Live controller checks** — authority follows the LabNFT: if the Lab changes hands, the new owner controls issuance and capping immediately.
* **Terms binding** — the signed agreement is bound to the exact document bytes via its SHA-256 `contentHash`, and the terms text is reconstructed onchain, so the signature can't be replayed against a different document.
* **Hardened ownership** — two-step transfers, renounce disabled, administered by a Molecule multisig.

### Related Contracts

* [IPT](/references/contracts/ipt) — the ERC-20 IP Token this factory deploys
* [Molecule Labs](/technical-deep-dive/onchain-lab) — the Lab primitive being tokenized

### Resources

* **Tokenization flow & API**: [Tokenization API](/api-reference/tokenization-api)
* **ABI**: Available from the verified contract on [BaseScan](https://basescan.org/address/0x62F532C3f563D974deEc103AAb8cC597f4f9c84E)


# AccessResolver

## AccessResolver Overview

The `AccessResolver` contract is the onchain authorization primitive for Molecule Labs. It answers two questions:

1. **"Is this wallet an authorized signer for a given IP-NFT or ERC-6551 Token Bound Account?"** — used by the file-encryption layer to gate decryption of confidential data-room files and by back-office flows that need to resolve Safe multisigs and Ownable contracts to their leaf EOAs.
2. **"What role does this wallet hold on a given lab, and is the grant still active?"** — the V3 role system (`ROLE_VIEWER`, `ROLE_CONTRIBUTOR`) with per-grant expiry and `isAgent` metadata, hierarchical (Owner > Contributor > Viewer), and administered per `oclId`.

See [Roles & Permissions](/technical-deep-dive/roles-and-permissions) for the product-level role model and [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) for how these predicates feed into the encryption / decryption pipeline.

### Contract Details

| Property | Value                                                                    |
| -------- | ------------------------------------------------------------------------ |
| Contract | AccessResolver (V3)                                                      |
| Type     | UUPS Upgradeable Proxy                                                   |
| Solidity | 0.8.30                                                                   |
| Storage  | Preserves V1/V2 layout; V3 adds `_roles` map and `labNftContractAddress` |

#### Deployments

The **V3 role system runs only on Base and Base Sepolia**. The Ethereum Mainnet and Sepolia deployments are **v2** — they expose the signer predicates (`isAuthorizedSignerForIpnft`, `isAuthorizedSignerForTba`, …) but have **no role functions** (`grantRole` / `hasRole` / `revokeRole` / `getRole`).

* **Base (canonical chain — v3, roles live here)**
  * Address: `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B`
  * [Verified on BaseScan](https://basescan.org/address/0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B)
* **Base Sepolia (v3)**
  * Address: `0x5493F472602C87318EA5Eff753cDD593bf9bF559`
  * [Verified on BaseScan](https://sepolia.basescan.org/address/0x5493F472602C87318EA5Eff753cDD593bf9bF559)
* **Ethereum Mainnet (v2 — signer predicates only)**
  * Address: `0xc130e0b49840b266A49F62C0Cc77e353E0C99cD0`
  * [Verified on Etherscan](https://etherscan.io/address/0xc130e0b49840b266A49F62C0Cc77e353E0C99cD0)
* **Sepolia Testnet (v2 — signer predicates only)**
  * Address: `0xd9b492fd34b1579C052b2EA25970178B3011Ce6B`
  * [Verified on Etherscan](https://sepolia.etherscan.io/address/0xd9b492fd34b1579C052b2EA25970178B3011Ce6B)

### How It Works

1. A caller (file-encryption layer, GraphQL resolver, UI) asks the `AccessResolver` whether a wallet is authorized for a given IP-NFT / TBA / lab role.
2. The resolver checks direct ownership and the ERC-6551 `isValidSigner` fast-path first, then walks the ownership graph — Safe `isOwner` → `Ownable.owner()` — recursively, up to depth 10.
3. For role checks it reads the per-lab `RoleGrant` struct, enforces the Owner > Contributor > Viewer hierarchy, and respects each grant's expiry timestamp.
4. Returns `true`/`false`. The resolver never mints, grants, or revokes encryption keys itself — it is read-only from the caller's perspective (except for the explicit `grantRole` / `revokeRole` entry points).

#### Signer Authorization (V1/V2)

* **isAuthorizedSignerForIpnft**: Checks if an address is authorized for a specific IP-NFT, resolving Safe multisigs and Ownable wrappers recursively.

  ```solidity
  function isAuthorizedSignerForIpnft(address signer, uint256 ipnftId)
      external view returns (bool);
  ```
* **isAuthorizedSignerForTba**: Determines if an address can act on behalf of an ERC-6551 Token Bound Account. Fast path uses `isValidSigner`; slow path resolves the TBA's bound NFT owner (handles Safe-held NFTs).

  ```solidity
  function isAuthorizedSignerForTba(address signer, address account)
      external view returns (bool);
  ```
* **ownersOfIpnft**: Returns the deduplicated leaf (EOA) owners of an IP-NFT after recursively unwrapping Safe multisigs and Ownable smart accounts.

  ```solidity
  function ownersOfIpnft(uint256 ipnftId) external view returns (address[] memory);
  ```
* **isApprovedLock**: Checks if a signer holds a locked token and is approved (used by locked-token-gated access conditions).

  ```solidity
  function isApprovedLock(address tokenAddress, address signer)
      external view returns (bool);
  ```

#### Role Management (V3)

The V3 role system adds hierarchical, per-lab roles (`ROLE_VIEWER = 1`, `ROLE_CONTRIBUTOR = 2`) administered per canonical `oclId`. `hasRole` is hierarchical: Contributor passes Viewer checks, and the Lab Owner (resolved via the OCL TBA) passes every check. See [Roles & Permissions](/technical-deep-dive/roles-and-permissions) for the full model and capability matrix.

```solidity
uint8 public constant ROLE_VIEWER = 1;
uint8 public constant ROLE_CONTRIBUTOR = 2;

struct RoleGrant {
    uint8  role;     // 0 = none, 1 = Viewer, 2 = Contributor
    uint64 expiry;   // 0 = permanent, >0 = unix timestamp
    bool   isAgent;  // true if grantee is an AI agent (metadata only)
}

function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bool isAgent) external;
function revokeRole(bytes32 oclId, address account) external;
function hasRole(bytes32 oclId, address account, uint8 role) external view returns (bool);
function getRole(bytes32 oclId, address account)
    external view returns (uint8 role, uint64 expiry, bool isAgent);
```

**`oclId` layout** (bytes32, MSB → LSB): version byte (`0x01`), namespace byte (`0x01` = EVM), 10 reserved / tokenId-high bytes, 20-byte TBA address. Every role entry point runs `_validateOclId`, which verifies the version / namespace bytes, that the TBA has code, that `LabNFT.accountOf(tokenId) == tba`, and that `IERC6551.token()` returns `(CANONICAL_CHAIN_ID = 8453, labNft, tokenId)`. Malformed identifiers revert with `InvalidOclId`.

**Chain scoping.** The role system exists only on the **Base and Base Sepolia (v3)** deployments — Ethereum Mainnet and Sepolia run v2, which has no role functions at all. Canonical lab state lives on Base: lab-owner self-administration (`grantRole` / `revokeRole` called by the NFT holder) works only there, because the reference ERC-6551 `owner()` returns `address(0)` off-canonical-chain.

**Global admin.** In addition to per-lab owners, the **contract owner (Molecule's protocol multisig)** is a global role admin: it can grant and revoke roles on any lab and passes every `hasRole` check. This is the operational escape hatch for support and recovery flows.

**Setup (owner-only).**

```solidity
function setLabNftContract(address labNftAddress) external; // onlyOwner
function setLockedTokenFactory(address factoryAddress) external; // onlyOwner
function initializeV3(address _labNftContractAddress) public; // onlyOwner, reinitializer(2)
```

#### Events

* **RoleGranted(oclId, account, role, expiry, isAgent, grantedBy)** — emitted when a role is granted.
* **RoleRevoked(oclId, account, role, revokedBy)** — emitted when a role is revoked. Revoking an account with no stored grant (`role == 0`) returns silently without emitting; revoking an expired-but-present grant still requires authorization and emits.
* **Initialized(uint64 version)** — emitted when the contract is initialized or reinitialized.
* **OwnershipTransferred(previousOwner, newOwner)** — emitted on contract-owner change.
* **Upgraded(implementation)** — emitted when the UUPS implementation is upgraded.

#### Errors

| Error                                                  | Description                                                                   |
| ------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `InvalidOclId(bytes32 oclId)`                          | Malformed `oclId` (bad version / namespace, no TBA code, or LabNFT mismatch). |
| `InvalidRole(uint8 role)`                              | Role must be `ROLE_VIEWER (1)` or `ROLE_CONTRIBUTOR (2)`.                     |
| `UnauthorizedRoleAdmin(bytes32 oclId, address, uint8)` | Caller lacks permission for the requested grant/revoke.                       |
| `OwnersOverflow(uint256)`                              | Owner-resolution exceeded `MAX_OWNERS (50)` or recursion depth 10.            |
| `OwnableUnauthorizedAccount(address)` *(inherited)*    | Caller is not the contract owner for owner-only functions.                    |
| `OwnableInvalidOwner(address)` *(inherited)*           | Invalid owner address provided.                                               |
| `UUPSUnauthorizedCallContext()` *(inherited)*          | Upgrade called in an incorrect context.                                       |

The first four are the contract's custom errors; the rest are inherited from OpenZeppelin.

### Integration Guide

The `accessControlConditions` shape is reused across both Molecule's Onchain-Verified Envelope Encryption — `AccessResolver` is the onchain oracle. New integrations should drive encryption through the Labs API (`initiateCreateOrUpdateFile` / `decryptDataKey`).

#### Use as an Access Control Condition (current)

For Onchain-Verified Envelope Encryption, attach an `accessControlConditions` array referencing this contract's predicates to the file's `encryptionMetadata`. The example below gates decryption on *LabNFT owner OR active Contributor OR active Viewer* by OR'ing `isAuthorizedSignerForTba` with `hasRole(oclId, :userAddress, ROLE_VIEWER)` (hierarchy makes one role check cover Contributor + Viewer). Substitute `<accessresolver-address>` with the deployment matching the chain the backend evaluator targets — see [Deployments](#deployments) above:

```json
[
  {
    "conditionType": "evmContract",
    "contractAddress": "<accessresolver-address>",
    "chain": "base",
    "functionName": "isAuthorizedSignerForTba",
    "functionParams": [":userAddress", "0x<40hex-tba>"],
    "functionAbi": {
      "name": "isAuthorizedSignerForTba",
      "inputs": [
        { "name": "signer", "type": "address" },
        { "name": "account", "type": "address" }
      ],
      "outputs": [{ "name": "", "type": "bool" }],
      "stateMutability": "view",
      "type": "function"
    },
    "returnValueTest": { "key": "", "comparator": "=", "value": "true" }
  },
  { "operator": "or" },
  {
    "conditionType": "evmContract",
    "contractAddress": "<accessresolver-address>",
    "chain": "base",
    "functionName": "hasRole",
    "functionParams": ["0x0101<20hex-tokenId><40hex-tba>", ":userAddress", "1"],
    "functionAbi": {
      "name": "hasRole",
      "inputs": [
        { "name": "oclId", "type": "bytes32" },
        { "name": "account", "type": "address" },
        { "name": "role", "type": "uint8" }
      ],
      "outputs": [{ "name": "", "type": "bool" }],
      "stateMutability": "view",
      "type": "function"
    },
    "returnValueTest": { "key": "", "comparator": "=", "value": "true" }
  }
]
```

`:userAddress` is substituted with the authenticated caller at evaluate time. See [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access) for the full upload / decrypt flow, condition shape definitions, and evaluator behaviour.

#### Direct Contract Call

Read a predicate directly with viem to check access outside of an encryption flow.

```js
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";

const client = createPublicClient({ chain: mainnet, transport: http() });

const isAuthorized = await client.readContract({
  address: "0xc130e0b49840b266A49F62C0Cc77e353E0C99cD0", // AccessResolver
  abi: accessResolverAbi,
  functionName: "isAuthorizedSignerForIpnft",
  args: [userAddress, 42n],
});
```

### Predicates Available as Access Control Conditions

Use any of the predicates below as the `functionName` of an `EvmContractCondition` pointing at this contract.

| Predicate                                                     | Gates                                                                                      | Role-aware |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | :--------: |
| `isAuthorizedSignerForIpnft(address signer, uint256 ipnftId)` | Direct + recursive ownership of the IP-NFT (Safe / Ownable / TBA).                         |            |
| `isAuthorizedSignerForTba(address signer, address account)`   | Authorized signer of an ERC-6551 TBA, including its bound NFT owner.                       |            |
| `hasRole(bytes32 oclId, address account, uint8 role)`         | Active, non-expired role grant on the lab; honours Owner > Contributor > Viewer hierarchy. |      ✓     |
| `isApprovedLock(address tokenAddress, address signer)`        | Holds and is approved on a locked token (used by locked-token gating).                     |            |

### Security Considerations

* **Read-only nature**: The contract performs authorization checks but does not modify ownership or access.
* **Failure handling**: Access is denied if contract call fails.
* **Network verification**: Ensure querying on the correct network.

### Related Contracts

* IP-NFT: The contract queried for ownership details.
* [Tokenizer](/references/contracts/tokenizer): Tokenizes Labs into IP Tokens.

### Resources

* **ABI**: Available from the verified contract on [BaseScan](https://basescan.org/address/0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B) (source lives in the Molecule Labs contracts repository — contact the team for access)


# MCP Tools

## Molecule MCP Server Documentation

### Overview

The Molecule MCP (Model Context Protocol) server enables AI assistants to access DeSci ecosystem data through natural language. Users can query AI assistants like Claude with questions like "What IPTs are available?" to receive real-time responses.

#### What is MCP?

Model Context Protocol is an open standard that allows AI assistants to utilize external tools, enabling access to current data from sources like Molecule's datasets.

### MCP Server Functionality

The MCP server bridges AI assistants and Molecule Protocol data. It allows AI to fetch real data about IPTs, prices, and project activity, rather than just relying on pre-trained knowledge.

#### Example Interaction

* **Query**: "What's the price history for HAIR?"
* **Process**:
  1. AI Assistant interprets the request.
  2. Selects the appropriate tool.
  3. Calls the Molecule MCP server at `https://molecule-mcp.vercel.app/mcp`.

#### Data Sources

The MCP server pulls information from various resources like:

* Molecule API (GraphQL)
* Sanity CMS (Categories)
* GeckoTerminal (Price Data)

### Quick Start Guide

#### Claude Desktop Integration

To enable Molecule tools in Claude Desktop:

* **macOS**: Add to `~/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**: Add to `%APPDATA%\Claude\claude_desktop_config.json`

```json
{
  "mcpServers": {
    "molecule": {
      "url": "https://molecule-mcp.vercel.app/mcp"
    }
  }
}
```

Restart Claude Desktop after saving.

#### Claude Code Integration

For CLI using Claude Code, add `.mcp.json` to your project root:

```json
{
  "mcpServers": {
    "molecule": {
      "type": "url",
      "url": "https://molecule-mcp.vercel.app/mcp"
    }
  }
}
```

#### Other MCP Clients

Connect using Streamable HTTP transport:

{% code overflow="wrap" %}

```javascript
import { experimental_createMCPClient as createMCPClient } from 'ai';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const client = await createMCPClient({
  transport: new StreamableHTTPClientTransport(
    new URL('https://molecule-mcp.vercel.app/mcp')
  )
});
const tools = await client.tools();
```

{% endcode %}

#### Example Conversations

* **Ecosystem Overview**: "What's happening in the Molecule ecosystem?"
* **Project Deep Dive**: "I'm interested in the HAIR project. What can you tell me?"
* **Price Analysis**: "How has VITA-FAST performed over the last month?"

### Self-Hosting

For private deployments or custom configurations:

#### Requirements

* Node.js 18 or higher
* Vercel account or any Node.js platform
* Molecule API key

#### Deploy Steps

1. Request access to the MCP server source from the Molecule team (the repository is not publicly listed).
2. Install dependencies:

   ```shell
   pnpm install
   ```
3. Deploy to Vercel:

   ```shell
   vercel --prod
   ```

Add environment variables in Vercel under Settings.

#### Local Development

* Start with:

  ```shell
  vercel dev
  ```

### Caching

The server caches upstream responses in Redis for a few minutes, enhancing performance and abiding by upstream limits — expect data freshness in the minutes range rather than real-time.

### Rate Limits

The public endpoint is rate-limited. Deploy your own instance for heavy or latency-sensitive workloads.

### Programmatic Integration

For AI applications requiring Molecule data, connect to the MCP endpoint directly. Any MCP-compatible client works — the example below uses the Vercel AI SDK's MCP client.

```javascript
import { experimental_createMCPClient as createMCPClient, generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const mcpClient = await createMCPClient({
  transport: new StreamableHTTPClientTransport(
    new URL('https://molecule-mcp.vercel.app/mcp')
  )
});
const tools = await mcpClient.tools();

const result = await generateText({
  model: openai('gpt-4o'),
  tools,
  prompt: 'What IPTs are available in the longevity category?'
});
```

### Troubleshooting

* Ensure configuration file syntax is valid.
* First request may be slower; consider using Redis for caching.
* Deploy your own instance if facing rate limit errors.

### Resources

* **Public Endpoint**: <https://molecule-mcp.vercel.app/mcp>
* **MCP Specification**: <https://modelcontextprotocol.io>
* **Source Code & API Key Request**: Contact the Molecule team.


# Audits

### Audit by Cyfrin

#### Q2 2026 Audit: OnChainLab (Molecule Labs core)

* **Scope:** The modular Lab smart-account stack — `OnChainLab` account (ERC-4337 / ERC-6551 / ERC-7579 / ERC-7739 / ERC-1271), `OnChainLabFactory`, beacon & router, `LabNFT`, ERC-7484 module registry, `RootValidator`, the DID registry, and the `OdfCoAttestVerifier`
* **Outcome:** All fixes were merged before the Base mainnet v0.1.0 deployment, which was made from the audited code

{% embed url="<https://github.com/Cyfrin/cyfrin-audit-reports/blob/main/reports/2026-05-12-cyfrin-molecule-onchainlab-v2.0.pdf>" %}

### Audits by Pashov

#### Q1 2023 Audit: Molecule Vesting

* **Scope:** Token Vesting

{% embed url="<https://github.com/pashov/audits/blob/master/solo/pdf/MoleculeVesting-security-review.pdf>" %}

#### Q2 2023 Audit: IPNFT

* **Scope:** IP-NFTs & Fundraises (CrowdSale, TimelockedToken)

{% embed url="<https://github.com/pashov/audits/blob/master/solo/pdf/IPNFT-security-review.pdf>" %}


