# 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

> **Want to start writing code now?** Go to [🚀 Getting Started](/api-reference/getting-started) — it helps you pick a way in and gets you to a lab with a file in it in about ten minutes. This page is the narrative map of every integration surface, for when you need to decide *what* to build rather than *how* to make the first call. New to the ecosystem? The [Glossary](/references/glossary) defines every Molecule term these docs use.

### 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 and metadata live. This is the primary interface for applications that need to manage scientific data: uploading files, querying project activity, and searching across Labs. 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** — the agent mints its own via the `generateServiceToken` mutation, by signing a message with its wallet (a Privy session works too). No provisioning request. The token is a JWT tied to that wallet; write authorization is resolved per request from the wallet's onchain role on the target Lab. Content writes need **Contributor**. The end-to-end version — agent wallet, human grants the role, agent self-issues and uploads — is [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor).
* **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`, `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.

If you want an AI coding agent to run the whole Lab workflow for you, install the [Molecule Skill](/ai-tooling/molecule-skill) plugin — the skill plus MCP server that wraps every network, onchain and cryptographic step as one typed tool call.

Service tokens are self-issued, and every endpoint, gateway URL and contract address is published — see [Getting Started](/api-reference/getting-started). Reach out on the [Molecule Discord](https://t.co/L0VEiy4Bjk) for a consumer credential, a module attestation request, or any integration support — for a credential, post in [the general-chat channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**.


# 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FH9Fv8hBPZcXRnrnjtWKK%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-031553.png?alt=media&amp;token=77aad55a-1eb9-4991-a2a7-683d0a2dacab" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FEalFvAVpxAHp06GuWLoS%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-032106.png?alt=media&amp;token=ea50f093-3d41-46ea-ac4b-dfe013845565" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FDKO38CdztksPq6oa0OfY%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-032310.png?alt=media&amp;token=08fd6a29-8183-452b-afce-466349c6adb9" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FSuT6RE6xSlkyBH6K0n39%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-032520.png?alt=media&amp;token=f21c487d-1222-483d-9553-44550ac3d7ac" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2Fg6zGT4kZsJz5tkMLaq2j%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-032643.png?alt=media&amp;token=fbdc7832-7a49-43f2-8b35-15e8c61f451c" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FAqM7CybgZJ7mvQ39uIuE%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-032738.png?alt=media&amp;token=ec2bc4f3-f117-406c-97b5-0028e803e6c7" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FUUknC86IXxDKzbTeICqp%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-033551.png?alt=media&amp;token=dd5c40fc-5ecc-4bbf-b806-96fcb1a2bd21" 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.

{% hint style="info" %}
Roles are held by **wallets**, and a Lab involves up to three of them — the owner's wallet, an agent's wallet, and the Lab's own OCL account. For which is which and where each address belongs in an API call, see [the three wallets, side by side](/api-reference/authentication#the-three-wallets-side-by-side).
{% endhint %}

## 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, 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 |   ✓   |      ✓      |        |
| 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 write to the data room.

## 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2F5b9uZktaCeWhXbooNw70%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-152541.png?alt=media&amp;token=de2d89bc-88da-4933-a5d1-943c51c859db" 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FiuJxcCGFIW3zZDNPXTgU%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-06-152749.png?alt=media&amp;token=acb1f07c-7064-4bdc-810e-5f9bde7caef7" 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, 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="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2Fxwr35mTKoTS1kROlEkPv%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-07-071554.png?alt=media&amp;token=a09abde0-74f2-487c-9218-0765961e4570" alt=""><figcaption></figcaption></figure>

<figure><img src="https://3275698018-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdcuGmESNyqrbymueqNW%2Fuploads%2FP3NujkMjamjTS0RSEZZi%2FMermaid%20Chart%20-%20Create%20complex%2C%20visual%20diagrams%20with%20text.-2026-02-07-082524.png?alt=media&amp;token=eb01e026-cba3-4057-a273-ef261eed2a12" 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                                                                                                                                        |


# 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="/technical-deep-dive/data/data-storage">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="/technical-deep-dive/data/data-storage">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, 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, 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 — 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, 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.

### Semantic Search

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

Search results can be filtered by tags, categories, and access levels. 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 and market cap, 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.

> **New here? Start at** [**🚀 Getting Started**](/api-reference/getting-started)**.** It explains what a Lab is, lists the two prerequisites, and walks a ten-minute quickstart that ends in a lab with a file in it. Agents: the [one-pager](/api-reference/getting-started/for-agents) is the whole default flow on one page.

## 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

**Authentication:**

* **Queries** (read operations): consumer credential only — public.
* **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) · [Tutorials →](/api-reference/getting-started)

***

### 🔐 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)

***

### 🪄 Molecule Skill (agent plugin)

Not an API surface of its own — the whole Labs workflow packaged as an agent skill plus a typed MCP server, so an AI coding agent runs it as tool calls instead of hand-written requests.

**Purpose:**

* Give Claude Code, Codex, or any MCP-capable harness the full Lab lifecycle in one plugin
* Wrap every network, onchain, and cryptographic step as a single typed tool call
* Settle paid mutations automatically through the x402 Gateway

**Authentication:** Your `mol_` consumer credential, plus a wallet the plugin operates (Privy agentic wallet or raw EOA)

[View Molecule Skill Documentation →](/ai-tooling/molecule-skill)

***

## Authentication

All Molecule APIs require a consumer credential; the Labs API additionally uses a Service Token for write operations, which callers **issue for themselves** by signing a message with their wallet — no manual provisioning. 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

The full quickstart — prerequisites, costs, and a ten-minute path to a lab with a file in it — is on [**🚀 Getting Started**](/api-reference/getting-started). In short:

| If you want to...                               | Go to                                                                                                       |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Get from zero to a lab with a file in it        | [Getting Started](/api-reference/getting-started)                                                           |
| Point an AI agent at this API                   | [Agent one-pager](/api-reference/getting-started/for-agents) · [Molecule Skill](/ai-tooling/molecule-skill) |
| Upload files to a Lab dataroom                  | [Labs API](/api-reference/labs-api) · [Tutorials](/api-reference/getting-started)                           |
| Let an agent write into a lab someone else owns | [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor)                                   |
| 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)                                                                 |

### 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

* [Glossary](/references/glossary) — every Molecule term used in these docs, defined in a sentence
* [Getting Started](/api-reference/getting-started) — prerequisites, costs, ten-minute quickstart
* [Agent one-pager](/api-reference/getting-started/for-agents) — the default flow, paste-ready
* [Getting the schema](/api-reference/getting-started#getting-the-schema) — staging introspection is enabled; production's is not
* [Molecule Skill](/ai-tooling/molecule-skill) — the agent plugin that drives this API
* [Smart Contract Addresses](/references/contracts)

***

*Last updated: July 2026*


# Getting Started

What a Lab is, what you need before your first call, and a ten-minute path to a lab with a file in it.

The Molecule API lets you create a **Lab** — a research project with its own onchain identity and its own file store — and then read and write that Lab's files from code.

This page covers the two things you need before your first call, which way of calling the API fits you, and a ten-minute path that ends with a lab you can open in a browser.

{% hint style="info" %}
**New to the Molecule ecosystem?** The [Glossary](/references/glossary) defines every term used in these guides — Lab, LabNFT, oclId, data room, service token, indexer — in a sentence or two each. Worth keeping open in a second tab.
{% endhint %}

Everything here runs against **staging** (Base Sepolia, testnet funds), so nothing spends real money. Moving to mainnet later is a matter of swapping a handful of constants: [Running in Production](#running-in-production).

***

## Choose how you'll call the API

Three ways in. None is better than the others — pick by who is making the calls.

| If this is you                                                                                           | What you'll use                                                                                          | Start here                                   |
| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| **You're writing a script** in Node/TypeScript and want to see the raw calls                             | GraphQL requests plus [viem](https://viem.sh) for the one onchain step                                   | [The tutorials](#the-tutorials) below        |
| **You run an AI coding agent** (Claude Code, Codex, Cursor) and want it to do the whole workflow for you | The Molecule Skill plugin, which wraps every network, onchain and crypto operation as a single tool call | [Molecule Skill](/ai-tooling/molecule-skill) |
| **You'd rather pay per call** than hold a long-lived credential                                          | The x402 gateway, which settles USDC on Base per request                                                 | [x402 Gateway](/api-reference/x402-gateway)  |

These combine rather than compete — choosing one now doesn't lock you out of the others. A common setup is the plugin for the workflow and x402 for the calls that cost money.

### If you are an agent reading this

Go to the [**Agent one-pager**](/api-reference/getting-started/for-agents) instead. It is the whole default flow on a single page with no prose detours — written to be pasted into a system prompt.

***

## The tutorials

Each one is runnable end to end against staging, and shows the expected response and the failure modes at every step.

| Tutorial                                                                                                            | What you have when you finish                                             |
| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [**Create a lab and upload a public file**](/api-reference/getting-started/create-lab-and-upload-file) — start here | A Lab of your own, with a public file in it                               |
| [**Upload an encrypted file**](/api-reference/getting-started/upload-encrypted-file)                                | A confidential file that only certain people that you specify can decrypt |
| [**Agent access**](/api-reference/getting-started/agent-as-a-lab-contributor)                                       | An agent writing into a Lab that a human owns                             |

All three open with the same configuration constants and helper functions, which live on one page: [**Shared Setup**](/api-reference/getting-started/shared-setup). Copy that block once and every snippet in the tutorials runs against it.

***

## Prerequisites

Two things, and only one of them involves a human.

### 1. A `mol_` consumer credential — the one manual step

Every request to the API carries a consumer credential in the `Authorization` header. There is no self-service issuance yet (coming soon), so you will need to request this from the Molecule team.

Request it on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the general-chat channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first):

```
Consumer credential request
- Who: <your name / org>
- What you're building: <one line>
- Environment: staging   (add production if you need both)
- Contact: <Discord handle or email>
```

What comes back is a single opaque string per environment:

```
mol_<consumerId>_<secret>
```

Send it as the `Authorization` header value **directly — no `Bearer` prefix**:

```bash
Authorization: mol_your-consumer-id_your-secret
```

Treat the whole string as one secret: it is not split into a public and a private half. Full header reference: [Authentication](/api-reference/authentication).

{% hint style="warning" %}
`Authorization: Bearer mol_…` fails authentication. `Bearer` is reserved for Privy user tokens.
{% endhint %}

### 2. A funded wallet on Base Sepolia

**You only need this if you are creating a new Lab from code.** Creating a Lab means minting a LabNFT, which is an onchain transaction, and the wallet that sends it pays the gas. If you create your Lab in the Molecule app instead, you need no funds at all — Molecule covers those transactions for you. The app is at [labs.molecule.xyz](https://labs.molecule.xyz/), or [testnet.labs.molecule.xyz](https://testnet.labs.molecule.xyz/) for the Base Sepolia environment these tutorials run against.

For the programmatic path you need an [EOA](/references/glossary#calling-the-api) — an ordinary wallet with a private key — holding testnet ETH on Base Sepolia. Fund it from a [Base Sepolia faucet](https://docs.base.org/base-chain/tools/network-faucets).

If — and only if — you are using the **x402 gateway**, you also need testnet **USDC** on Base Sepolia: get it from the [Circle faucet](https://faucet.circle.com/) (select Base Sepolia). Paying with a service token needs no USDC at all.

You do **not** need a pre-issued **service token** — it is proof that you control a particular wallet, and every tutorial issues its own by signing a message in its first step. What it is, what it authorizes and how it is sent: [Authentication](/api-reference/authentication#what-a-service-token-actually-authorizes).

### What it costs

| Item                                              | Cost                                                                                                                                                                                                                        |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **LabNFT mint**                                   | Gas only, on Base Sepolia **and** on Base mainnet. Read the fee live with `mintFeeWei()` and, if it is ever non-zero, send it as `value` — the tutorials already do this, so a future fee needs no code change on your side |
| **`createLab`, uploads and other content writes** | Free                                                                                                                                                                                                                        |
| **The same mutations through the x402 gateway**   | Quoted per request in the `402` challenge, currently **$0.01 USDC** on both environments. [Read the price off the challenge](/api-reference/x402-gateway#reading-the-402-challenge) rather than hardcoding it               |
| **Storage**                                       | 5 GB per lab included — see [Limits](/api-reference/labs-api/files#storage-limits)                                                                                                                                          |

### Tooling

```bash
npm install viem          # Node 18+ has fetch and node:crypto built in
```

Or, if you are using the Molecule Skill plugin, install [`mol-labs-plugin`](/ai-tooling/molecule-skill#getting-the-plugin) and run `config_doctor` — it names exactly which configuration is still missing instead of letting a tool guess:

```bash
claude --plugin-dir /path/to/mol-labs-plugin
```

***

## Ten-minute quickstart

The shortest path from "I have a credential" to "there is a lab with my file in it". Four API calls and one transaction. Each step below is the condensed form of [Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file), which shows the expected response and the failure modes for every call.

```bash
export CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret"
export WALLET_PRIVATE_KEY="0x…"          # funded on Base Sepolia
```

1. **Issue yourself a service token** — `getServiceSignInMessage` → sign the message with your wallet (EIP-191 `personal_sign`) → `generateServiceToken`. No human in the loop. Keep the three calls together: the message carries a single-use nonce valid for 10 minutes.
2. **Mint the LabNFT** — `OnChainLabFactory.mintAndCreateAccount(yourAddress)` with `value: mintFeeWei()`. Read `oclId` off the `OclIdentityCreated` event.
3. **Register the lab** — `createLab(input: { oclId })`. This attaches the data room your files will live in.
4. **Upload a file** — `initiateCreateOrUpdateFile` → `PUT` the bytes to the returned presigned URL → `finishCreateOrUpdateFile` with `accessLevel: "PUBLIC"`.
5. **Verify it worked** — see below.

The runnable version is the [complete script](/api-reference/getting-started/create-lab-and-upload-file#complete-script):

```bash
node create-lab-and-upload-file.js ./research-data.csv
```

### Verify it worked

Two checks. The first works on every environment and needs nothing but your credential:

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

Your file appears in `dataRoom.files` with the `path` you sent and `accessLevel: "PUBLIC"`. If `labWithDataRoomAndFiles` comes back `null`, `createLab` did not complete — a missing lab nulls the field rather than throwing an error.

The second check is visual — the lab has a page of its own, at `/projects/<slug>`:

| Environment | Lab page                                            |
| ----------- | --------------------------------------------------- |
| Staging     | `https://testnet.labs.molecule.xyz/projects/<slug>` |
| Production  | `https://labs.molecule.xyz/projects/<slug>`         |

Until the lab is renamed, that slug is **`lab-<tokenId>`**, built from the `labNftTokenId` that `createLab` returns — so a lab you have just created is at `.../projects/lab-1274`. Once the owner renames the lab, the slug becomes the `shortname` derived from the new name, and the `lab-<tokenId>` form stops resolving.

Do not build this URL from `oclId`. It identifies the lab in API calls, it is not a page slug, and it does not resolve here.

***

## Running in Production

The tutorials all run against staging (Base Sepolia, testnet funds). To run the same scripts against production, replace the values in the [Shared Setup](/api-reference/getting-started/shared-setup) config block — nothing else changes, because every step reads from these constants:

| Constant                  | Staging (these tutorials)                          | 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"`                                              |
| `LAB_APP_URL`             | `https://testnet.labs.molecule.xyz`                | `https://labs.molecule.xyz`                           |

A few things follow automatically from that swap:

* **Headers and the `graphql()` helper** are identical — `Authorization` (consumer credential, no `Bearer`) and the self-issued `X-Service-Token` work the same against both endpoints. Note that credentials are **per environment**: a staging `mol_` credential does not authenticate against production.
* **The `mintFeeWei()` read** already queries the live contract, so it picks up whatever fee production has configured with no code change.
* **The access-condition ABIs** are unchanged; only `contractAddress` and `chain` differ, and both come from the config block.

What doesn't follow automatically, and is on you:

* **Real funds.** Minting on `base` spends real ETH. Test on staging first.
* **Introspection is off in production** and query depth is capped at 10. Generate types against staging — see [Getting the schema](#getting-the-schema).
* **`SERVICE_NAME`** should identify the real integration; it is 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).

***

## Getting the schema

**Staging has GraphQL introspection enabled** — point codegen, a playground or an SDK generator straight at it:

```bash
# graphql-codegen, apollo, gql.tada… all work against staging
DESCI_API_SCHEMA=https://staging.graphql.api.molecule.xyz/graphql npx graphql-codegen
```

Introspection requires only the `Authorization` consumer-credential header, the same as any query.

**Production has introspection disabled**, deliberately — `__schema` and `__type` return a validation error there (`__typename` still resolves). Generate against staging and point the generated client at production; the two environments serve the same schema.

Production also enforces a query-depth limit of 10, which fails at execution time with `errorType: "QueryDepthLimitReached"` and partial data — a plain GraphQL error, not the catalogued shape. Handle both.

***

## Where to go next

| Next                                          | Page                                                        |
| --------------------------------------------- | ----------------------------------------------------------- |
| What every term in these guides means         | [Glossary](/references/glossary)                            |
| The config and helpers every tutorial uses    | [Shared Setup](/api-reference/getting-started/shared-setup) |
| Every operation, parameter and error code     | [Labs API](/api-reference/labs-api)                         |
| What each error code means and how to read it | [Error handling](/api-reference/labs-api#error-handling)    |
| Paying per call, and the gateway base URLs    | [x402 Gateway](/api-reference/x402-gateway)                 |
| What a Lab actually is, onchain               | [Molecule Labs](/technical-deep-dive/onchain-lab)           |


# Shared Setup

The config constants and helpers every tutorial opens with — copy this block once and each tutorial's snippets run against it.

**Every tutorial in this section starts from the code on this page.** Creating a lab, uploading a file, encrypting and decrypting a file and adding an agent as a collaborator all assume the constants and helper functions below are already defined — their snippets call `graphql()`, `assertOk()` and `withIndexerLagRetry()` without redefining them, and read `GRAPHQL_URL`, `CHAIN`, `FACTORY_ADDRESS` and the rest from here. Copy this block into your script once, then follow whichever tutorial you need.

You do not have to copy it by hand if you only want to run one tutorial end to end: the **complete script** at the bottom of each tutorial page carries all of this inline and runs standalone. This page exists so the shared parts are documented and maintained in one place instead of three.

Everything here targets **staging** (Base Sepolia, testnet funds). To point the same code at mainnet, replace the constants using the table in [Running in Production](/api-reference/getting-started#running-in-production) — nothing else changes, because every step reads from these constants.

***

## The shared block

Every environment-specific value lives in this one block, and every helper the tutorials call is defined in it. Paste it at the top of your script.

```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 LAB_APP_URL = "https://testnet.labs.molecule.xyz"; // production: https://labs.molecule.xyz

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.
// `error.details` carries the specific cause under `reason`, but it arrives in
// more than one shape: a plain object on thrown query errors, a JSON string
// in-band, and currently a doubly-encoded JSON string in-band. Parse until it
// stops being a string, so one reader handles all three.
function parseDetails(details) {
  let value = details;
  for (let i = 0; i < 3 && typeof value === "string"; i++) {
    try {
      value = JSON.parse(value);
    } catch {
      break;
    }
  }
  return value && typeof value === "object" ? value : {};
}

function assertOk(result, op) {
  if (result.error) {
    const { code, message, requestId } = result.error;
    const { reason } = parseDetails(result.error.details);
    throw new Error(
      `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`,
    );
  }
  return result;
}

// Onchain state — a mint, a role grant — reaches the API through an event
// indexer, so a write issued immediately after one can fail on state the chain
// already has. Retry with backoff; re-issuing the token never helps.
async function withIndexerLagRetry(
  fn,
  { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {},
) {
  const laggy = new RegExp(codes.join("|"));
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!laggy.test(String(err)) || i === attempts - 1) throw err;
      const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s
      console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}
```

***

## Where each piece is used

| Piece                       | What it does                                                                                               | Where it shows up                                                                                                                                                                                                                                                                        |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The config constants        | Every environment-specific value in one place — endpoint, chain, contract addresses, app URL               | Every step of every tutorial                                                                                                                                                                                                                                                             |
| `graphql(query, variables)` | POSTs to the API with `Authorization` always set and `X-Service-Token` added once Step 1 has issued one    | Every GraphQL call                                                                                                                                                                                                                                                                       |
| `parseDetails(details)`     | Reads `error.details` tolerantly — it arrives as an object, a JSON string, or a doubly-encoded JSON string | Inside `assertOk`; also useful when you branch on `details.reason` yourself                                                                                                                                                                                                              |
| `assertOk(result, op)`      | Turns an in-band mutation `error` into a thrown error carrying the catalogue `code` and the `requestId`    | After every mutation                                                                                                                                                                                                                                                                     |
| `withIndexerLagRetry(fn)`   | Retries a call that failed only because onchain state has not been indexed yet                             | [Step 4 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-4-upload-the-file) (after a mint) and [Step 4 of Agent access](/api-reference/getting-started/agent-as-a-lab-contributor#step-4-the-agent-uploads) (after a role grant) |

***

## Next

| Next                                               | Page                                                                                               |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Prerequisites, costs and the ten-minute quickstart | [Getting Started](/api-reference/getting-started)                                                  |
| What every term used here means                    | [Glossary](/references/glossary)                                                                   |
| Create a lab and upload a public file              | [Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file) |
| Upload an encrypted file                           | [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file)                   |
| Give your agent access to a lab                    | [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor)                          |
| Run the same code against mainnet                  | [Running in Production](/api-reference/getting-started#running-in-production)                      |


# Create a lab and upload a public file

The default path: self-issue a token, mint a LabNFT, register the lab, upload a public file, and verify it landed.

The default path, and the one to run first. Five steps: get a token, mint the LabNFT, register the lab, upload the file, verify. A public file is stored as-is — no key management, no access conditions.

> **Want the file to be confidential instead?** Steps 1–3 are identical; branch at Step 4 into [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file).

{% hint style="info" %}
**Before you start:** you need the [two prerequisites](/api-reference/getting-started#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](/api-reference/getting-started/shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the [Glossary](/references/glossary).
{% endhint %}

## 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 a 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](/api-reference/labs-api/service-tokens#obtaining-a-token).

The message carries a server-issued single-use nonce and is valid for **10 minutes**, so these two calls belong together: fetch, sign, redeem. Neither the message nor the signature can be cached or reused.

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

const SERVICE_NAME = "tutorial-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() });

// Fetch this immediately before signing: the message embeds a single-use
// nonce that expires 10 minutes after issuance, and requesting a new one
// invalidates any previous message for this wallet + service.
const signInMessage = await graphql(
  `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
    getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) {
      message
      expiresAt
    }
  }`,
  { walletAddress: account.address, serviceName: SERVICE_NAME },
);

// Sign the message VERBATIM — the backend recomposes the same string from the
// stored nonce and verifies it, so re-wording, re-formatting or rebuilding it
// client-side 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;
```

**Expected response:**

```json
{
  "data": {
    "generateServiceToken": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
      "tokenId": "b3f1c0de-…",
      "expiresAt": "2027-02-23T10:31:07.000Z",
      "message": "Service token generated successfully for tutorial-agent",
      "error": null
    }
  }
}
```

**If it fails:**

| `error.code`                                        | What happened                                                                                                                                                                                                        | Fix                                                                                                                                                                                |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHENTICATED`, `reason: INVALID_SIGNATURE`      | The signed bytes are not the message the backend recomposes — altered text, a message superseded by a later `getServiceSignInMessage` call, or a `walletAddress` that is not the address that produced the signature | Sign the most recent `message` byte-for-byte, and send the signing address as `walletAddress`. Use `personal_sign` / viem's `signMessage`, not `signTypedData`                     |
| `UNAUTHENTICATED`, `reason: NONCE_NOT_FOUND`        | No nonce on file — never requested for this wallet + service, or already redeemed by an earlier token                                                                                                                | Re-run the query and sign the new `message`. Retrying the same signature never works                                                                                               |
| `UNAUTHENTICATED`, `reason: NONCE_EXPIRED`          | More than 10 minutes passed between fetching the message and redeeming it                                                                                                                                            | Re-run the query and sign the new `message`                                                                                                                                        |
| `VALIDATION_FAILED`                                 | Malformed `walletAddress`                                                                                                                                                                                            | Send a checksummed or lowercase `0x`-prefixed 20-byte address                                                                                                                      |
| `INTERNAL_ERROR`, `reason: TOKEN_GENERATION_FAILED` | Bad `expiresIn` — the value is not validated before use, so a malformed or out-of-bounds one surfaces as a masked server error                                                                                       | Validate before sending: format is `<int><unit>`, unit one of `s m h d w M y`, between 1 hour and 2 years. Despite `retryable: true` on `INTERNAL_ERROR`, retrying will not fix it |
| HTTP `401` before GraphQL runs                      | Consumer credential missing or malformed                                                                                                                                                                             | Check `Authorization` — no `Bearer` prefix. See [Authentication](/api-reference/authentication)                                                                                    |

`expiresIn` defaults to **`180d`** when omitted. The returned token is **wallet-bound, not lab-bound**: it carries this wallet's identity, and authorisation is resolved per request from that wallet's onchain role on whichever lab you name.

## 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)",
]);

// Read the fee live — it is 0 on both chains today, but never hardcode it.
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;
const labAccountAddress = identity.args.account;
```

**Expected result:** `mintReceipt.status === "success"`, and

```
oclId:              0x0101000000000000000000000000abc…  (32-byte hex)
tokenId:            1274
labAccountAddress:  0x… (the ERC-6551 Token Bound Account)
```

**If it fails:**

* **Transaction reverts** — the wallet is unfunded, or `value` didn't match `mintFeeWei()`. Read the fee live and forward it; don't hardcode `0`.
* **`parseEventLogs` returns `[]`** — the logs weren't filtered to the LabNFT. `OclIdentityCreated` fires on the **LabNFT contract**, not the factory; the factory's own `AccountProvisioned` event does not carry `oclId` as a topic.

## Step 3: Register the lab

Register the Kamu-backed data room 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");
```

**Expected response:**

```json
{
  "data": {
    "createLab": {
      "message": "Lab created successfully",
      "error": null,
      "lab": {
        "oclId": "0x0101000000000000000000000000abc…",
        "shortname": "lab-1274",
        "labAccountAddress": "0x…",
        "labNftTokenId": "1274"
      }
    }
  }
}
```

When a lab is minted `shortname` is `lab-<token-id>` by default. Once the owner renames the lab, the slug becomes the `shortname` derived from the new name and the `lab-<tokenId>` form stops resolving (see [Step 5](#step-5-verify-it-worked)).

**If it fails:**

| `error.code`                           | What happened                             | Fix                                                              |
| -------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------- |
| `CONFLICT`, `reason: PROJECT_CONFLICT` | The lab is already registered             | Treat as success and continue — this is what a re-run looks like |
| `NOT_FOUND`                            | The `oclId` isn't indexed yet             | The indexer trails the mint by a few seconds. Retry with backoff |
| `UNAUTHENTICATED`, `reason: NO_AUTH`   | No `X-Service-Token` and no Privy session | Step 1 didn't set `serviceToken`                                 |
| `UPSTREAM_UNAVAILABLE`                 | A dependency is down (`reason: KAMU`)     | `retryable: true` — retry with backoff                           |

DID-linking for the new lab starts automatically in the background; [`getDidLinkStatus`](/api-reference/labs-api/lab-management#get-did-link-status) reports its progress. You do not need to wait for it.

## Step 4: Upload the file

This section is for uploading public files, if you are interested in uploading an encrypted file, please jump to this tutorial instead [uploading an encrypted file](/api-reference/getting-started/upload-encrypted-file).

Three calls: get a presigned URL, `PUT` the bytes, finalise with metadata. Full reference: [Files](/api-reference/labs-api/files).

{% hint style="warning" %}
**This is the step that most often fails on a lab you have just created.**

Molecule runs onchain and offchain systems side by side, and the offchain side learns about onchain events through an indexer. Keeping the two in step takes a moment, so a call that succeeded does not mean every read has caught up yet.

That is exactly what happens here. `createLab` can succeed before your mint has been indexed, because it falls back to checking ownership onchain. The file mutations have no such fallback — they read the indexed record, and return `NOT_FOUND` until it arrives.

So wrap the first call in [`withIndexerLagRetry`](/api-reference/getting-started/shared-setup) so the helper will retry until the record arrives.
{% endhint %}

```javascript
import { readFileSync } from "node:fs";
import { basename } from "node:path";

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

// 4a. Get a presigned URL. Retried: the mint may not be indexed yet, even
// though createLab already returned success.
const initiateResult = await withIndexerLagRetry(async () => {
  const result = await graphql(
    `mutation Initiate($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 }
      }
    }`,
    { oclId, contentType: "text/csv", contentLength: bytes.length },
  );
  return assertOk(result.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile");
});
const { uploadToken, uploadUrl, method, headers } = initiateResult;

// 4b. PUT the bytes with EXACTLY the returned headers
const uploadHeaders = {};
headers.forEach((h) => (uploadHeaders[h.key] = h.value));
const putResponse = await fetch(uploadUrl, {
  method: method || "PUT",
  headers: uploadHeaders,
  body: bytes,
});
if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${putResponse.statusText}`);

// 4c. Finalise
const finishResult = await graphql(
  `mutation Finish(
    $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 }
    }
  }`,
  {
    oclId,
    uploadToken,
    path: basename(filePath),
    accessLevel: "PUBLIC", // DataRoomAccessLevel: PUBLIC | HOLDERS | ADMIN
    changeBy: account.address,
    description: "Baseline assay results",
    tags: ["preliminary"],
    categories: ["raw-data"],
    contentText: "assay,replicate,value",
  },
);
assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
const { datasetId } = finishResult.finishCreateOrUpdateFile;
```

**Expected responses:**

```json
{
  "data": {
    "initiateCreateOrUpdateFile": {
      "uploadToken": "eyJ…",
      "uploadUrl": "https://…s3….amazonaws.com/…?X-Amz-Signature=…",
      "uploadUrlExpiry": "2026-08-27T14:05:00.000Z",
      "method": "PUT",
      "headers": [{ "key": "Content-Type", "value": "text/csv" }],
      "error": null
    }
  }
}
```

The `PUT` returns HTTP `200` with an empty body. Then:

```json
{
  "data": {
    "finishCreateOrUpdateFile": {
      "datasetId": "did:odf:fed01…",
      "contentHash": "f162…",
      "version": 1,
      "message": "…",
      "error": null
    }
  }
}
```

`message` on this result is passed through from the storage layer, so its exact wording varies and is deliberately not shown here — it is **not part of the contract**. Assert on `error == null`, never on `message`.

Keep `datasetId` — it is the file's stable identifier for later reads and updates.

**If it fails:**

| Symptom                                                              | Cause                                                                                                            | Fix                                                                                                                                                                                                                        |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `initiate` → `NOT_FOUND`, "Project 0x… does not exist"               | The mint is not indexed yet. `createLab` can succeed before this is true, so a successful Step 3 is no guarantee | Retry with backoff — `withIndexerLagRetry` above. Usually seconds; observed up to \~4 minutes under indexer backlog. Do **not** re-run `createLab`, which returns `CONFLICT` once registered                               |
| `initiate` → `UNAUTHORIZED`                                          | The wallet behind the token has no write role on this lab                                                        | You must be Owner or Contributor. See [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor)                                                                                                            |
| `PUT` → `403`                                                        | URL expired (\~15 min), or headers altered                                                                       | Re-run `initiate`; send the returned `headers` verbatim                                                                                                                                                                    |
| `PUT` → `400`/`411`                                                  | Body wasn't sent as raw bytes                                                                                    | Send the buffer, not a JSON wrapper. In curl: `--data-binary`                                                                                                                                                              |
| `finish` → `VALIDATION_FAILED`, `details.field: "path"`              | `path` contains an underscore, or both `path` and `ref` were sent                                                | Underscores are not allowed in `path`; use `path` for a new file **or** `ref` for a new version, never both                                                                                                                |
| `finish` → `VALIDATION_FAILED`, `reason: INVALID_TAGS_OR_CATEGORIES` | Unknown tag or category                                                                                          | Valid values come from the public `fileCategoriesAndTags` query                                                                                                                                                            |
| `finish` → `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL`       | Bad `accessLevel`                                                                                                | One of `PUBLIC`, `HOLDERS`, `ADMIN`                                                                                                                                                                                        |
| `finish` → `UPSTREAM_UNAVAILABLE`, "Path is occupied"                | A file already lives at that `path` — the usual cause is re-running this tutorial against the same lab           | **Not retryable despite the code**: retrying sends the identical request and fails identically. Either pick a new `path`, or send `ref` (the previous `datasetId`) instead of `path` to add a version to the existing file |

## Step 5: Verify it worked

Two checks. The first needs nothing but your consumer credential:

```javascript
const verify = await graphql(
  `query Verify($oclId: String!) {
    labWithDataRoomAndFiles(oclId: $oclId) {
      oclId
      shortname
      name
      dataRoom {
        id
        files { path contentType accessLevel version createdBy downloadUrl }
      }
    }
  }`,
  { oclId },
);

const file = verify.labWithDataRoomAndFiles.dataRoom.files.find(
  (f) => f.path.endsWith(basename(filePath)),
);
if (!file) throw new Error("File not found in the data room");
console.log("Verified:", file.path, file.accessLevel, "v" + file.version);
```

Your file is in `dataRoom.files` with `accessLevel: "PUBLIC"` and `version: 1`. Because the file is public, `downloadUrl` is a fetchable presigned URL — `fetch` it and compare the bytes to what you uploaded for an end-to-end check.

If `labWithDataRoomAndFiles` comes back `null`, the lab is not registered: Step 3 didn't complete. This is one of only two nullable queries on the Labs API, so a missing lab nulls the field instead of throwing.

The second check is visual. The lab has a page at `${LAB_APP_URL}/projects/<slug>`. Until the lab is renamed, that slug is `lab-<labNftTokenId>` — the token id `createLab` returned in Step 3 — so a lab you have just created is at `https://testnet.labs.molecule.xyz/projects/lab-1274` on staging. Once the owner renames the lab, the slug becomes the `shortname` derived from the new name and the `lab-<tokenId>` form stops resolving. `oclId` does not work here.

## Complete script

All five steps in one file, against staging. No pre-issued service token needed.

```javascript
#!/usr/bin/env node
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 LAB_APP_URL = "https://testnet.labs.molecule.xyz";
const SERVICE_NAME = "tutorial-agent";

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

let serviceToken;

async function graphql(query, variables) {
  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();
  if (errors) throw new Error(JSON.stringify(errors));
  return data;
}

// `details` arrives as an object (thrown queries), a JSON string (in-band), or
// a doubly-encoded JSON string (in-band today) — parse until it is not a string.
function parseDetails(details) {
  let value = details;
  for (let i = 0; i < 3 && typeof value === "string"; i++) {
    try {
      value = JSON.parse(value);
    } catch {
      break;
    }
  }
  return value && typeof value === "object" ? value : {};
}

function assertOk(result, op) {
  if (result.error) {
    const { code, message, requestId } = result.error;
    const { reason } = parseDetails(result.error.details);
    throw new Error(
      `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`,
    );
  }
  return result;
}

// The mint reaches the API through an event indexer, so the lab's first write
// can return NOT_FOUND for a few seconds after createLab already succeeded.
async function withIndexerLagRetry(
  fn,
  { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {},
) {
  const laggy = new RegExp(codes.join("|"));
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!laggy.test(String(err)) || i === attempts - 1) throw err;
      const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s
      console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

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

  const account = privateKeyToAccount(WALLET_PRIVATE_KEY);
  const publicClient = createPublicClient({ chain: CHAIN, transport: http() });
  const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() });

  // ---- Step 1: service token ----
  // Fetch → sign → redeem, back to back: the message holds a single-use nonce
  // that expires 10 minutes after issuance.
  const signInMessage = await graphql(
    `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
      getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt }
    }`,
    { 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
        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 — tx:", mintTxHash, "oclId:", oclId);

  // ---- Step 3: register the lab ----
  const createLabResult = await graphql(
    `mutation CreateLab($oclId: String!) {
      createLab(input: { oclId: $oclId }) {
        message
        error { code message requestId retryable details }
        lab { shortname labAccountAddress labNftTokenId }
      }
    }`,
    { oclId },
  );
  assertOk(createLabResult.createLab, "createLab");
  const { labNftTokenId } = createLabResult.createLab.lab;
  console.log("3/5 Lab registered — TBA:", createLabResult.createLab.lab.labAccountAddress);

  // ---- Step 4: upload the file ----
  const bytes = readFileSync(filePath);
  // Retried: createLab returning success does not mean the mint is indexed yet.
  const { uploadToken, uploadUrl, method, headers } = await withIndexerLagRetry(async () => {
    const result = 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: bytes.length },
    );
    return assertOk(result.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile");
  });

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

  const finishResult = await graphql(
    `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!) {
      finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy) {
        datasetId version
        error { code message requestId retryable details }
      }
    }`,
    {
      oclId,
      uploadToken,
      path: basename(filePath),
      accessLevel: "PUBLIC",
      changeBy: account.address,
    },
  );
  assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
  const { datasetId } = finishResult.finishCreateOrUpdateFile;
  console.log("4/5 Uploaded — datasetId:", datasetId);

  // ---- Step 5: verify ----
  const verify = await graphql(
    `query Verify($oclId: String!) {
      labWithDataRoomAndFiles(oclId: $oclId) {
        shortname
        dataRoom { files { path accessLevel version } }
      }
    }`,
    { oclId },
  );
  const lab = verify.labWithDataRoomAndFiles;
  if (!lab) throw new Error("Lab not found — createLab did not complete");
  const file = lab.dataRoom.files.find((f) => f.path.endsWith(basename(filePath)));
  if (!file) throw new Error("File not found in the data room");
  console.log("5/5 Verified:", file.path, file.accessLevel, "v" + file.version);
  // Until the lab is renamed its page slug is `lab-<tokenId>`; after a rename it is
  // the derived shortname, and the lab-<tokenId> form stops resolving.
  const slug = lab.shortname ?? `lab-${labNftTokenId}`;
  console.log("Lab page:", `${LAB_APP_URL}/projects/${slug}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

**Usage:**

```bash
WALLET_PRIVATE_KEY="0x..." \
CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \
node create-lab-and-upload-file.js ./research-data.csv
```

***

## Next

|                                  |                                                                                                   |
| -------------------------------- | ------------------------------------------------------------------------------------------------- |
| Make the next file confidential  | [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file)                  |
| Let an agent write into this lab | [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor)                         |
| Run it against mainnet           | [Running in Production](/api-reference/getting-started#running-in-production)                     |
| Per-operation reference          | [Files](/api-reference/labs-api/files) · [Lab Management](/api-reference/labs-api/lab-management) |


# Upload an encrypted file

Encrypt a file locally with AES-256-GCM, gate decryption on live onchain roles, and verify with a full decrypt round trip.

Same lab, same three-call upload — but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles.

**Steps 1–3 are identical to** [**Create a lab and upload a public file**](/api-reference/getting-started/create-lab-and-upload-file) — get a service token, mint, register. Pick up here with `oclId`, `labAccountAddress`, `account` and `serviceToken` already in hand (or with any lab you already hold a role on).

Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms — plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access).

{% hint style="info" %}
**Before you start:** you need the [two prerequisites](/api-reference/getting-started#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](/api-reference/getting-started/shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the [Glossary](/references/glossary).
{% endhint %}

## Step 4a: Get a DEK

```javascript
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;
```

**Expected response:**

```json
{
  "data": {
    "generateDataEncryptionKey": {
      "plaintextDEK": "3q2+7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
      "encryptedDek": "AQIDAHjR…",
      "encryptionSystem": "kms",
      "error": null
    }
  }
}
```

`encryptionSystem` is **backend-set** — echo it verbatim in Step 4d, never hardcode `"kms"`. Key custody is on a path to threshold cryptography, and echoing the value is what keeps your integration working across that change.

Requires authentication (service token or Privy session), so Step 1 must have run.

## Step 4b: Encrypt locally

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

const filePath = "./confidential-results.csv";
const plaintext = readFileSync(filePath);

const cryptoKey = await webcrypto.subtle.importKey(
  "raw",
  Buffer.from(plaintextDEK, "base64"),
  "AES-GCM",
  false, // not extractable — the key cannot be read back out
  ["encrypt"],
);
const iv = randomBytes(12); // 96-bit IV, fresh per file — never reuse one
const ciphertext = Buffer.from(
  await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, plaintext),
);
// Hash of the PLAINTEXT — this is what a reader checks after decrypting.
const contentHashHex = "sha256-" + createHash("sha256").update(plaintext).digest("hex");
```

The plaintext DEK is now unreferenced; don't log it, don't persist it, don't send it anywhere. Web Crypto's `AES-GCM` appends the 16-byte authentication tag to the ciphertext, which is what the decrypt side expects.

## Step 4c: Write the access conditions

`accessControlConditions` is a JSON-stringified array of predicates the backend re-evaluates against live chain state every time someone asks to decrypt. Two recipes cover almost everything.

**Owner only** — only the LabNFT owner (and authorised signers of its Token Bound Account, so a Safe's signers resolve through) can decrypt:

```javascript
const ownerOnlyConditions = 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" },
  },
]);
```

**Owner or Contributor or Viewer** — the recipe to use when the lab has a team, and the one the contributor agent in [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor) needs. `hasRole` is hierarchical (`ROLE_VIEWER = 1`; a Contributor and the Owner both pass a Viewer check), and the explicit `isAuthorizedSignerForTba` branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain:

```javascript
const accessResolverAbi = {
  isAuthorizedSignerForTba: {
    name: "isAuthorizedSignerForTba",
    inputs: [
      { name: "signer", type: "address" },
      { name: "account", type: "address" },
    ],
    outputs: [{ name: "", type: "bool" }],
    stateMutability: "view",
    type: "function",
  },
  hasRole: {
    name: "hasRole",
    inputs: [
      { name: "oclId", type: "bytes32" },
      { name: "account", type: "address" },
      { name: "role", type: "uint8" },
    ],
    outputs: [{ name: "", type: "bool" }],
    stateMutability: "view",
    type: "function",
  },
};

const teamConditions = JSON.stringify([
  {
    conditionType: "evmContract",
    contractAddress: ACCESS_RESOLVER_ADDRESS,
    chain: ACCESS_CONDITION_CHAIN,
    functionName: "isAuthorizedSignerForTba",
    functionParams: [":userAddress", labAccountAddress],
    functionAbi: accessResolverAbi.isAuthorizedSignerForTba,
    returnValueTest: { key: "", comparator: "=", value: "true" },
  },
  { operator: "or" },
  {
    conditionType: "evmContract",
    contractAddress: ACCESS_RESOLVER_ADDRESS,
    chain: ACCESS_CONDITION_CHAIN,
    functionName: "hasRole",
    functionParams: [oclId, ":userAddress", "1"], // "1" = ROLE_VIEWER; "2" = ROLE_CONTRIBUTOR and up
    functionAbi: accessResolverAbi.hasRole,
    returnValueTest: { key: "", comparator: "=", value: "true" },
  },
]);
```

`labAccountAddress` here is the **Lab's own OCL account**, not the owner's wallet — passing the owner's address instead evaluates to false and silently locks everyone out of the file ([the three wallets](/api-reference/authentication#the-three-wallets-side-by-side)). `:userAddress` is substituted with the authenticated caller's wallet at evaluation time. Pass `"2"` instead of `"1"` to exclude Viewers. Evaluation walks the array left to right and short-circuits (`or` stops at the first true); **any RPC error fails closed** and the DEK is not released. The full condition grammar, including `EvmBasicCondition`, is on [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access#worked-example-encrypt-for-owner-or-contributor-or-viewer).

## Step 4d: Upload the ciphertext

The same three calls as the public upload, with the ciphertext in place of the raw file and `encryptionMetadata` attached on finish.

```javascript
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 }
    }
  }`,
  // Content-length is the CIPHERTEXT length, and the type is opaque bytes.
  { oclId, contentType: "application/octet-stream", contentLength: ciphertext.length },
);
assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile");
const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile;

const uploadHeaders = {};
headers.forEach((h) => (uploadHeaders[h.key] = h.value));
const putResponse = await fetch(uploadUrl, {
  method: method || "PUT",
  headers: uploadHeaders,
  body: ciphertext,
});
if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${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
      version
      message
      error { code message requestId retryable details }
    }
  }`,
  {
    oclId,
    uploadToken,
    path: basename(filePath),
    accessLevel: "HOLDERS", // encrypted files use HOLDERS or ADMIN, not PUBLIC
    changeBy: account.address,
    encryptionMetadata: {
      encryptionSystem, // echo verbatim — never hardcode
      encryptedDek,
      iv: iv.toString("base64"),
      contentHash: contentHashHex,
      accessControlConditions: teamConditions,
      encryptedBy: account.address.toLowerCase(),
      encryptedAt: new Date().toISOString(),
    },
  },
);
assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
```

**If it fails:**

| `error.code`                                           | What happened                                                                | Fix                                                                                                                                                                                                                       |
| ------------------------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_FAILED`, `reason: INVALID_CONDITIONS`      | `accessControlConditions` isn't a valid stringified condition array          | It is a **JSON string**, not an object. Check `functionAbi` is complete and `returnValueTest` present                                                                                                                     |
| `VALIDATION_FAILED`, `reason: INVALID_ACCESS_LEVEL`    | `PUBLIC` on an encrypted file                                                | Use `HOLDERS` or `ADMIN`                                                                                                                                                                                                  |
| `UNAUTHORIZED` on `generateDataEncryptionKey`          | No write role on the lab                                                     | Owner or Contributor required                                                                                                                                                                                             |
| `UPSTREAM_UNAVAILABLE`, "Path is occupied" on `finish` | A file already exists at that `path` — usually a re-run against the same lab | **Not retryable despite the code.** Pick a new `path`, or send `ref` (the previous `datasetId`) instead to add a version                                                                                                  |
| `NOT_FOUND` on the first call after a mint             | The mint is not indexed yet, even though `createLab` succeeded               | Retry with [`withIndexerLagRetry`](/api-reference/getting-started/shared-setup) — see [Step 4 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-4-upload-the-file) |

## Step 5: Verify by decrypting it

The real test is a round trip: ask the backend to unwrap the DEK, decrypt, and compare hashes. `decryptDataKey` re-evaluates the file's conditions against **live** chain state and your token's wallet, so a success here proves the gate works.

```javascript
const decryptResult = await graphql(
  `mutation DecryptDataKey($oclId: String!, $filePath: String!) {
    decryptDataKey(oclId: $oclId, filePath: $filePath) {
      plaintextDEK
      iv
      error { code message requestId retryable details }
    }
  }`,
  { oclId, filePath: basename(filePath) },
);
assertOk(decryptResult.decryptDataKey, "decryptDataKey");
const { plaintextDEK: unwrappedDEK, iv: returnedIv } = decryptResult.decryptDataKey;

// Fetch the ciphertext back from the data room
const fileQuery = await graphql(
  `query GetFile($oclId: String!, $path: String!) {
    dataRoomFile(oclId: $oclId, path: $path) {
      path
      accessLevel
      downloadUrl
      encryptionMetadata { encryptionSystem contentHash encryptedBy encryptedAt }
    }
  }`,
  { oclId, path: basename(filePath) },
);
const downloaded = Buffer.from(
  await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(),
);

const decryptKey = await webcrypto.subtle.importKey(
  "raw",
  Buffer.from(unwrappedDEK, "base64"),
  "AES-GCM",
  false,
  ["decrypt"],
);
const recovered = Buffer.from(
  await webcrypto.subtle.decrypt(
    { name: "AES-GCM", iv: Buffer.from(returnedIv, "base64") },
    decryptKey,
    downloaded,
  ),
);

const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex");
if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) {
  throw new Error("Content hash mismatch after decryption");
}
console.log("Round trip verified —", recovered.length, "bytes recovered");
```

**Expected:** the hashes match and `recovered` equals your original file byte-for-byte.

**If it fails:**

| `error.code`                                                   | What happened                                          | Fix                                                                                                                      |
| -------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `UNAUTHORIZED`                                                 | Your wallet does not satisfy the file's conditions     | Check the role with the public `listLabMembers(oclId)` query. After a fresh grant, allow for indexer/chain lag and retry |
| `FAILED_PRECONDITION`, `reason: LEGACY_ENCRYPTION`             | The file predates onchain-verified envelope encryption | Not decryptable through this mutation; use the original encryption client                                                |
| `FAILED_PRECONDITION`, `reason: NOT_ENCRYPTED` / `MISSING_DEK` | The file has no `encryptionMetadata`                   | You're pointing at a public file                                                                                         |
| `UPSTREAM_UNAVAILABLE`                                         | Condition evaluation could not reach the chain RPC     | Fails closed by design. `retryable: true`                                                                                |
| Web Crypto throws `OperationError`                             | Wrong IV, or ciphertext truncated                      | Use the `iv` **returned by `decryptDataKey`**, and pass the whole downloaded body including the trailing GCM tag         |

## Complete script

Steps 1–3 are verbatim from [Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file); this script carries them so it runs standalone.

```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`

const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql";
const CHAIN = baseSepolia;
const FACTORY_ADDRESS = "0xd629FE2310b4309a212495F10A47f8436dcEfD90";
const LABNFT_ADDRESS = "0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28";
const ACCESS_RESOLVER_ADDRESS = "0x5493F472602C87318EA5Eff753cDD593bf9bF559";
const ACCESS_CONDITION_CHAIN = "baseSepolia";
const SERVICE_NAME = "tutorial-agent";

const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL;
const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY;
// Optional: reuse an existing lab instead of minting a new one.
const EXISTING_OCL_ID = process.env.OCL_ID;

let serviceToken;

async function graphql(query, variables) {
  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();
  if (errors) throw new Error(JSON.stringify(errors));
  return data;
}

// `details` arrives as an object (thrown queries), a JSON string (in-band), or
// a doubly-encoded JSON string (in-band today) — parse until it is not a string.
function parseDetails(details) {
  let value = details;
  for (let i = 0; i < 3 && typeof value === "string"; i++) {
    try {
      value = JSON.parse(value);
    } catch {
      break;
    }
  }
  return value && typeof value === "object" ? value : {};
}

function assertOk(result, op) {
  if (result.error) {
    const { code, message, requestId } = result.error;
    const { reason } = parseDetails(result.error.details);
    throw new Error(
      `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`,
    );
  }
  return result;
}

const accessResolverAbi = {
  isAuthorizedSignerForTba: {
    name: "isAuthorizedSignerForTba",
    inputs: [
      { name: "signer", type: "address" },
      { name: "account", type: "address" },
    ],
    outputs: [{ name: "", type: "bool" }],
    stateMutability: "view",
    type: "function",
  },
  hasRole: {
    name: "hasRole",
    inputs: [
      { name: "oclId", type: "bytes32" },
      { name: "account", type: "address" },
      { name: "role", type: "uint8" },
    ],
    outputs: [{ name: "", type: "bool" }],
    stateMutability: "view",
    type: "function",
  },
};

function buildTeamConditions(oclId, labAccountAddress) {
  return JSON.stringify([
    {
      conditionType: "evmContract",
      contractAddress: ACCESS_RESOLVER_ADDRESS,
      chain: ACCESS_CONDITION_CHAIN,
      functionName: "isAuthorizedSignerForTba",
      functionParams: [":userAddress", labAccountAddress],
      functionAbi: accessResolverAbi.isAuthorizedSignerForTba,
      returnValueTest: { key: "", comparator: "=", value: "true" },
    },
    { operator: "or" },
    {
      conditionType: "evmContract",
      contractAddress: ACCESS_RESOLVER_ADDRESS,
      chain: ACCESS_CONDITION_CHAIN,
      functionName: "hasRole",
      functionParams: [oclId, ":userAddress", "1"],
      functionAbi: accessResolverAbi.hasRole,
      returnValueTest: { key: "", comparator: "=", value: "true" },
    },
  ]);
}

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

  const account = privateKeyToAccount(WALLET_PRIVATE_KEY);
  const publicClient = createPublicClient({ chain: CHAIN, transport: http() });
  const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() });

  // ---- Step 1: service token ----
  // Fetch → sign → redeem, back to back: the message holds a single-use nonce
  // that expires 10 minutes after issuance.
  const signInMessage = await graphql(
    `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
      getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt }
    }`,
    { 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
        error { code message requestId retryable details }
      }
    }`,
    { serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature },
  );
  assertOk(tokenResult.generateServiceToken, "generateServiceToken");
  serviceToken = tokenResult.generateServiceToken.token;
  console.log("1/6 Got service token");

  // ---- Steps 2 & 3: mint + register (skipped when OCL_ID is provided) ----
  let oclId = EXISTING_OCL_ID;
  let labAccountAddress;

  if (oclId) {
    const existing = await graphql(
      `query($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { labAccountAddress } }`,
      { oclId },
    );
    if (!existing.labWithDataRoomAndFiles) throw new Error(`Lab ${oclId} not found`);
    labAccountAddress = existing.labWithDataRoomAndFiles.labAccountAddress;
    console.log("2-3/6 Reusing lab", oclId);
  } else {
    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()),
    });
    oclId = identity.args.oclId;
    labAccountAddress = identity.args.account;
    console.log("2/6 Minted LabNFT — oclId:", oclId);

    const createLabResult = await graphql(
      `mutation CreateLab($oclId: String!) {
        createLab(input: { oclId: $oclId }) {
          error { code message requestId retryable details }
          lab { labAccountAddress }
        }
      }`,
      { oclId },
    );
    assertOk(createLabResult.createLab, "createLab");
    console.log("3/6 Lab registered");
  }

  // ---- Step 4a: 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;

  // ---- Step 4b: encrypt locally ----
  const plaintext = readFileSync(filePath);
  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");
  console.log("4/6 Encrypted locally —", plaintext.length, "→", ciphertext.length, "bytes");

  // ---- Step 4c + 4d: conditions + upload ----
  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, method, headers } = initiateResult.initiateCreateOrUpdateFile;

  const uploadHeaders = {};
  headers.forEach((h) => (uploadHeaders[h.key] = h.value));
  const putResponse = await fetch(uploadUrl, {
    method: method || "PUT",
    headers: uploadHeaders,
    body: ciphertext,
  });
  if (!putResponse.ok) throw new Error(`Upload failed: ${putResponse.status} ${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 version
        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: buildTeamConditions(oclId, labAccountAddress),
        encryptedBy: account.address.toLowerCase(),
        encryptedAt: new Date().toISOString(),
      },
    },
  );
  assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
  console.log("5/6 Uploaded — datasetId:", finishResult.finishCreateOrUpdateFile.datasetId);

  // ---- Step 5: verify by decrypting ----
  const decryptResult = await graphql(
    `mutation DecryptDataKey($oclId: String!, $filePath: String!) {
      decryptDataKey(oclId: $oclId, filePath: $filePath) {
        plaintextDEK iv
        error { code message requestId retryable details }
      }
    }`,
    { oclId, filePath: basename(filePath) },
  );
  assertOk(decryptResult.decryptDataKey, "decryptDataKey");

  const fileQuery = await graphql(
    `query GetFile($oclId: String!, $path: String!) {
      dataRoomFile(oclId: $oclId, path: $path) {
        downloadUrl
        encryptionMetadata { contentHash }
      }
    }`,
    { oclId, path: basename(filePath) },
  );
  const downloaded = Buffer.from(
    await (await fetch(fileQuery.dataRoomFile.downloadUrl)).arrayBuffer(),
  );
  const decryptKey = await webcrypto.subtle.importKey(
    "raw",
    Buffer.from(decryptResult.decryptDataKey.plaintextDEK, "base64"),
    "AES-GCM",
    false,
    ["decrypt"],
  );
  const recovered = Buffer.from(
    await webcrypto.subtle.decrypt(
      { name: "AES-GCM", iv: Buffer.from(decryptResult.decryptDataKey.iv, "base64") },
      decryptKey,
      downloaded,
    ),
  );
  const recoveredHash = "sha256-" + createHash("sha256").update(recovered).digest("hex");
  if (recoveredHash !== fileQuery.dataRoomFile.encryptionMetadata.contentHash) {
    throw new Error("Content hash mismatch after decryption");
  }
  console.log("6/6 Round trip verified —", recovered.length, "bytes recovered");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

**Usage:**

```bash
WALLET_PRIVATE_KEY="0x..." \
CONSUMER_CREDENTIAL="mol_your-consumer-id_your-secret" \
node upload-encrypted-file.js ./confidential-results.csv

# or against a lab you already have
OCL_ID="0x0101…" WALLET_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" \
node upload-encrypted-file.js ./confidential-results.csv
```

***

## Next

|                                         |                                                                               |
| --------------------------------------- | ----------------------------------------------------------------------------- |
| Let an agent decrypt and contribute too | [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor)     |
| Run it against mainnet                  | [Running in Production](/api-reference/getting-started#running-in-production) |
| How conditions are evaluated, in depth  | [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access)    |


# Agent access

A human owns the lab and never hands over a key: the agent gets its own wallet, the human grants it Contributor, the agent issues its own token.

The most common real-world shape: a researcher created their Lab in the Labs app with an email address — no wallet, no code — and now wants an agent contributing to it. The agent gets its **own** identity rather than borrowing the human's; the human grants it a role; the agent authenticates itself from then on.

{% hint style="info" %}
**Before you start:** you need the [two prerequisites](/api-reference/getting-started#prerequisites) — a `mol_` consumer credential and a funded Base Sepolia wallet — plus the [shared setup block](/api-reference/getting-started/shared-setup), which defines the config constants and the `graphql()` / `assertOk()` helpers every snippet below uses. The [complete script](#complete-script) at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the [Glossary](/references/glossary).
{% endhint %}

**Who does what:**

| # | Actor | Action                                                                              |
| - | ----- | ----------------------------------------------------------------------------------- |
| 1 | Agent | Generate a wallet and report its address                                            |
| 2 | Human | Add that address to the lab as **Contributor**, flagged as an agent, with an expiry |
| 3 | Agent | Self-issue a service token by signing the sign-in message                           |
| 4 | Agent | Upload files; the human sees the result in the app                                  |

The human never hands over a private key, a token, or their session. Revoking the agent is one onchain revoke, and it does not touch anything else.

Three addresses are in play here — the human's owner wallet, the agent's own wallet, and the Lab's own OCL account — and they are not interchangeable. If you are unsure which goes in which field, read [the three wallets, side by side](/api-reference/authentication#the-three-wallets-side-by-side) first.

## Step 1: The agent reports its address

With the plugin: run `wallet_address`. With viem:

```javascript
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";

// Generate once, store it as the agent's own secret — never the human's key.
const agentPrivateKey = process.env.AGENT_PRIVATE_KEY ?? generatePrivateKey();
const agentAccount = privateKeyToAccount(agentPrivateKey);
console.log("Agent wallet address:", agentAccount.address);
```

Give that address to the lab owner. The agent needs **no gas** for this tutorial — it never sends a transaction, only signs a message. (Persist `agentPrivateKey` if you generated it, or the next run is a different agent with no role.)

## Step 2: The human grants Contributor

In the Labs app, the lab owner adds the agent's address to the lab's members and grants it the **Contributor** role, setting:

* **`isAgent = true`** — informational metadata that marks the member as an agent identity in the members list and UI. It does not change authorisation.
* **an expiry** — typically the agent's session lifetime. When it lapses the agent loses access until it is re-granted; a permanent grant is possible but not the default you want for an agent.

Members can be invited by wallet address, ENS name or email, and the app sponsors the gas for the grant. Under the hood this is one onchain call on the `AccessResolver`, which the owner (or an existing Contributor, for Viewer grants) can also make directly:

```solidity
function grantRole(bytes32 oclId, address account, uint8 role, uint64 expiry, bool isAgent) external;
// role: 2 = ROLE_CONTRIBUTOR, 1 = ROLE_VIEWER
```

Only the **Owner** may grant Contributor. Contributors can grant Viewer, but not Contributor. Full capability matrix: [Roles & Permissions](/technical-deep-dive/roles-and-permissions).

**Why Contributor and not Viewer:** a Viewer can decrypt and read but cannot write. Uploading files needs Contributor.

Both parties can confirm the grant landed with a public query — no authentication beyond the consumer credential:

```javascript
const members = await graphql(
  `query ListLabMembers($oclId: String!) {
    listLabMembers(oclId: $oclId) {
      members { walletAddress role isAgent expiry grantedAt }
    }
  }`,
  { oclId },
);
const grant = members.listLabMembers.members.find(
  (m) => m.walletAddress.toLowerCase() === agentAccount.address.toLowerCase(),
);
console.log("Agent role:", grant?.role, "expiry:", grant?.expiry ?? "permanent");
```

Expect `role: "CONTRIBUTOR"`. `isAgent` simply echoes the flag the owner set — `false` there changes nothing about what the agent may do, so do not treat it as a failed grant. `expiry` is unix seconds as a decimal string, or `null` for a permanent grant. Expired grants are excluded from this list entirely, so a missing entry after a while means the grant lapsed.

## Step 3: The agent self-issues a service token

Identical to [Step 1 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-1-get-a-service-token), signed by the **agent's** wallet. Fetch the message and redeem it in one go — it embeds a single-use nonce valid for 10 minutes, so an agent that waits for the human's role grant between fetching and signing will hit `UNAUTHENTICATED` / `reason: NONCE_EXPIRED`. Poll for the grant first (Step 2), then sign in:

```javascript
const AGENT_SERVICE_NAME = "research-agent-1";

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

const messageSignature = await agentAccount.signMessage({
  message: signInMessage.getServiceSignInMessage.message,
});

const tokenResult = await graphql(
  `mutation GenerateServiceToken(
    $serviceName: String!
    $walletAddress: String!
    $messageSignature: String!
    $expiresIn: String
  ) {
    generateServiceToken(
      serviceName: $serviceName
      walletAddress: $walletAddress
      messageSignature: $messageSignature
      expiresIn: $expiresIn
    ) {
      token tokenId expiresAt
      error { code message requestId retryable details }
    }
  }`,
  {
    serviceName: AGENT_SERVICE_NAME,
    walletAddress: agentAccount.address,
    messageSignature,
    expiresIn: "30d", // match the role grant's expiry rather than taking the 180d default
  },
);
assertOk(tokenResult.generateServiceToken, "generateServiceToken");
serviceToken = tokenResult.generateServiceToken.token;
```

Issuance is **not** gated on holding a role — any wallet can mint a token for itself. The role is what makes the token *useful*: authorisation is resolved per request from the token's wallet against the lab you name. So a token issued before the grant lands keeps working once it does; you do not need to re-issue it.

## Step 4: The agent uploads

From here the agent is an ordinary caller. Run [Step 4 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-4-upload-the-file) with `changeBy: agentAccount.address`, or [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file) for a confidential file — the `hasRole` branch of the team conditions is exactly what lets the agent decrypt too.

Writes by a Contributor service token are gated per mutation, matching the Privy user path: `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata` and `moveEntry` all accept Contributor. A few surfaces remain **Owner-only** and an agent Contributor cannot reach them: `updateLabNftMetadata`, `generateLabImageUploadUrl` and the legal-agreement mutations.

{% hint style="warning" %}
**Retry on `UNAUTHORIZED` right after the grant.** Role state reaches the API through an event indexer, so for a window after `grantRole` confirms onchain a write still returns `UNAUTHORIZED` (`details.reason` is also `UNAUTHORIZED` — there is no separate `NOT_CONTRIBUTOR` reason; match on `error.code`). It is not a permissions problem and re-issuing the token will not help — wait and retry. Usually seconds, but the same indexer has taken minutes on staging, so retry generously:

```javascript
async function withIndexerLagRetry(
  fn,
  { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {},
) {
  const laggy = new RegExp(codes.join("|"));
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!laggy.test(String(err)) || i === attempts - 1) throw err;
      const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s
      console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

// Same helper as in Shared Setup, with the code this step expects.
await withIndexerLagRetry(() => uploadFile(oclId, "./findings.csv"), { codes: ["UNAUTHORIZED"] });
```

{% endhint %}

## Step 5: Verify from both sides

**The agent** verifies as in [Step 5 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-5-verify-it-worked) — the file is in `dataRoom.files` with `createdBy` set to the agent's address:

```javascript
const verify = await graphql(
  `query Verify($oclId: String!) {
    labWithDataRoomAndFiles(oclId: $oclId) {
      shortname
      dataRoom { files { path accessLevel version createdBy } }
    }
  }`,
  { oclId },
);
```

**The human** verifies in the app: the file appears in the lab's data room, attributed to the agent's address, which the members list shows flagged as an agent.

## Revoking the agent

One onchain call, and the agent's writes stop:

```solidity
function revokeRole(bytes32 oclId, address account) external;   // Owner only, for a Contributor
```

Or let the grant's `expiry` lapse. Independently, the agent's token can be killed with `revokeServiceToken(tokenId)` — a token can only revoke or extend **its own** record, so one agent cannot interfere with another's.

## Complete script

The agent's half of the flow, standalone: report the wallet address, wait for the human's grant to appear, self-issue a token, upload, verify. Steps 1 and 2 involve a human, so the script polls for the role rather than assuming it.

Run it with the agent's own key and the `oclId` of the human's lab — the agent never sees the owner's key.

```javascript
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";

const GRAPHQL_URL = "https://staging.graphql.api.molecule.xyz/graphql";
const LAB_APP_URL = "https://testnet.labs.molecule.xyz";
const AGENT_SERVICE_NAME = "research-agent-1";

const CONSUMER_CREDENTIAL = process.env.CONSUMER_CREDENTIAL;
const OCL_ID = process.env.OCL_ID; // the human's lab
// Persist this, or every run is a different agent with no role.
const AGENT_PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY;

let serviceToken;

async function graphql(query, variables) {
  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();
  if (errors) throw new Error(JSON.stringify(errors));
  return data;
}

// `details` arrives as an object (thrown queries), a JSON string (in-band), or
// a doubly-encoded JSON string (in-band today) — parse until it is not a string.
function parseDetails(details) {
  let value = details;
  for (let i = 0; i < 3 && typeof value === "string"; i++) {
    try {
      value = JSON.parse(value);
    } catch {
      break;
    }
  }
  return value && typeof value === "object" ? value : {};
}

function assertOk(result, op) {
  if (result.error) {
    const { code, message, requestId } = result.error;
    const { reason } = parseDetails(result.error.details);
    throw new Error(
      `${op} failed: ${code}${reason ? `/${reason}` : ""}: ${message} (requestId ${requestId})`,
    );
  }
  return result;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Writes can return UNAUTHORIZED for a few seconds after a role grant confirms
// onchain — the indexer trails the chain. Retry rather than re-issuing the
// token. Same helper as in Shared Setup, which retries NOT_FOUND after a mint.
async function withIndexerLagRetry(
  fn,
  { codes = ["NOT_FOUND"], attempts = 12, baseMs = 2000, capMs = 30000 } = {},
) {
  const laggy = new RegExp(codes.join("|"));
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!laggy.test(String(err)) || i === attempts - 1) throw err;
      const delay = Math.min(baseMs * 2 ** i, capMs); // 2s, 4s, 8s, 16s, then 30s
      console.warn(`indexer not caught up (attempt ${i + 1}/${attempts}); retrying in ${delay / 1000}s`);
      await sleep(delay);
    }
  }
}

async function main() {
  const filePath = process.argv[2];
  if (!filePath) throw new Error("Usage: node agent-as-a-lab-contributor.js <file-to-upload>");
  if (!OCL_ID) throw new Error("Set OCL_ID to the lab the human owns");

  // ---- Step 1: the agent's identity ----
  if (!AGENT_PRIVATE_KEY) {
    console.log("No AGENT_PRIVATE_KEY set. Generated one for this run only:");
    console.log("  AGENT_PRIVATE_KEY=" + generatePrivateKey());
    throw new Error("Store that key, grant it Contributor, then re-run.");
  }
  const agentAccount = privateKeyToAccount(AGENT_PRIVATE_KEY);
  console.log("1/5 Agent wallet:", agentAccount.address);
  console.log("    Ask the lab owner to add it as Contributor (isAgent = true).");

  // ---- Step 2: wait for the human's grant (public query, no auth needed) ----
  let grant;
  for (let i = 0; i < 60; i++) {
    const members = await graphql(
      `query ListLabMembers($oclId: String!) {
        listLabMembers(oclId: $oclId) {
          members { walletAddress role isAgent expiry }
        }
      }`,
      { oclId: OCL_ID },
    );
    grant = members.listLabMembers.members.find(
      (m) => m.walletAddress.toLowerCase() === agentAccount.address.toLowerCase(),
    );
    if (grant) break;
    await sleep(5000); // poll for up to 5 minutes
  }
  if (!grant) throw new Error("No role grant found for the agent wallet — ask the owner to add it");
  if (grant.role === "VIEWER") throw new Error("Agent holds VIEWER; uploading needs CONTRIBUTOR");
  console.log("2/5 Role:", grant.role, "isAgent:", grant.isAgent, "expiry:", grant.expiry ?? "permanent");

  // ---- Step 3: the agent self-issues a token ----
  // Only now, after the role poll above returned — the sign-in message holds a
  // single-use nonce that expires 10 minutes after issuance.
  const signInMessage = await graphql(
    `query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
      getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message expiresAt }
    }`,
    { walletAddress: agentAccount.address, serviceName: AGENT_SERVICE_NAME },
  );
  const messageSignature = await agentAccount.signMessage({
    message: signInMessage.getServiceSignInMessage.message,
  });
  const tokenResult = await graphql(
    `mutation GenerateServiceToken($serviceName: String!, $walletAddress: String!, $messageSignature: String!, $expiresIn: String) {
      generateServiceToken(serviceName: $serviceName, walletAddress: $walletAddress, messageSignature: $messageSignature, expiresIn: $expiresIn) {
        token expiresAt
        error { code message requestId retryable details }
      }
    }`,
    {
      serviceName: AGENT_SERVICE_NAME,
      walletAddress: agentAccount.address,
      messageSignature,
      expiresIn: "30d", // match the role grant rather than taking the 180d default
    },
  );
  assertOk(tokenResult.generateServiceToken, "generateServiceToken");
  serviceToken = tokenResult.generateServiceToken.token;
  console.log("3/5 Token issued, expires", tokenResult.generateServiceToken.expiresAt);

  // ---- Step 4: upload (public; see Upload an encrypted file for the encrypted variant) ----
  // Retried on UNAUTHORIZED: the role grant may not be indexed yet. NOT_FOUND
  // is included for the case where the lab itself was minted moments ago.
  const bytes = readFileSync(filePath);
  const { datasetId } = await withIndexerLagRetry(async () => {
    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: OCL_ID, contentType: "application/octet-stream", contentLength: bytes.length },
    );
    assertOk(initiateResult.initiateCreateOrUpdateFile, "initiateCreateOrUpdateFile");
    const { uploadToken, uploadUrl, method, headers } = initiateResult.initiateCreateOrUpdateFile;

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

    const finishResult = await graphql(
      `mutation Finish($oclId: String!, $uploadToken: String!, $path: String!, $accessLevel: String!, $changeBy: String!) {
        finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy) {
          datasetId
          error { code message requestId retryable details }
        }
      }`,
      {
        oclId: OCL_ID,
        uploadToken,
        path: basename(filePath),
        accessLevel: "PUBLIC",
        changeBy: agentAccount.address,
      },
    );
    assertOk(finishResult.finishCreateOrUpdateFile, "finishCreateOrUpdateFile");
    return finishResult.finishCreateOrUpdateFile;
  }, { codes: ["UNAUTHORIZED", "NOT_FOUND"] });
  console.log("4/5 Uploaded — datasetId:", datasetId);

  // ---- Step 5: verify ----
  const verify = await graphql(
    `query Verify($oclId: String!) {
      labWithDataRoomAndFiles(oclId: $oclId) {
        shortname
        dataRoom { files { path accessLevel version createdBy } }
      }
    }`,
    { oclId: OCL_ID },
  );
  const file = verify.labWithDataRoomAndFiles.dataRoom.files.find(
    (f) => f.path.endsWith(basename(filePath)),
  );
  if (!file) throw new Error("File not found in the data room");
  const attributed =
    file.createdBy?.toLowerCase() === agentAccount.address.toLowerCase();
  console.log(
    "5/5 Verified:", file.path, file.accessLevel,
    attributed ? "— attributed to the agent" : `— createdBy: ${file.createdBy}`,
  );
  if (verify.labWithDataRoomAndFiles.shortname) {
    console.log("Human can see it at:", `${LAB_APP_URL}/projects/${verify.labWithDataRoomAndFiles.shortname}`);
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

**Usage:**

```bash
# First run — prints a generated agent key, then stops so the owner can grant the role
CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" node agent-as-a-lab-contributor.js ./findings.csv

# Subsequent runs, once the owner has granted Contributor to that address
AGENT_PRIVATE_KEY="0x..." CONSUMER_CREDENTIAL="mol_…" OCL_ID="0x0101…" \
node agent-as-a-lab-contributor.js ./findings.csv
```

***

## Next

|                                                    |                                                                                                                                                                                       |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| What the agent uploads                             | [Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file) · [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file) |
| The role model in full                             | [Roles & Permissions](/technical-deep-dive/roles-and-permissions)                                                                                                                     |
| Let the agent run the whole workflow as tool calls | [Molecule Skill](/ai-tooling/molecule-skill)                                                                                                                                          |


# For Agents: One-Pager

The whole default Labs flow on one page, no prose — the page to paste into a system prompt.

The complete default flow — self-issue a token, mint a lab, upload a public file, verify — with nothing else on the page. Copy it into a system prompt or a context file. For the same flow with expected responses, failure modes and the encrypted variant, use [the tutorials](/api-reference/getting-started).

## Constants

Staging (Base Sepolia) — everything on this page runs against these:

```
GRAPHQL_URL              https://staging.graphql.api.molecule.xyz/graphql
CHAIN                    baseSepolia (84532)
ONCHAIN_LAB_FACTORY      0xd629FE2310b4309a212495F10A47f8436dcEfD90
LABNFT                   0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28
ACCESS_RESOLVER          0x5493F472602C87318EA5Eff753cDD593bf9bF559
ACCESS_CONDITION_CHAIN   "baseSepolia"
LAB_PAGE                 https://testnet.labs.molecule.xyz/projects/<slug>
```

Production (Base) — swap these in, nothing else changes:

```
GRAPHQL_URL              https://production.graphql.api.molecule.xyz/graphql
CHAIN                    base (8453)
ONCHAIN_LAB_FACTORY      0xECdF4f05384056507485C90aeAb0a83268760D6E
LABNFT                   0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92
ACCESS_RESOLVER          0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B
ACCESS_CONDITION_CHAIN   "base"
LAB_PAGE                 https://labs.molecule.xyz/projects/<slug>
```

## Headers

```
Content-Type:    application/json
Authorization:   mol_<consumerId>_<secret>      # NEVER prefixed with "Bearer"
X-Service-Token: <JWT>                          # mutations only; omit the header entirely if you have none
```

Public queries take `Authorization` alone. Sending `X-Service-Token` on a public query is unnecessary; sending an empty one is worse than omitting it.

## Error contract

* **Queries throw.** Failure lands in top-level `errors[]`. Branch on `errors[i].errorType`. `errors[i].errorInfo` is `{ requestId, retryable, details }` and `details` is already an object.
* **Mutations return errors in-band.** Every `*Result` has `error: ApiError`. **Success ⇔ `error == null`.** Select `error { code message requestId retryable details }` on every mutation.
* **Parse `details` tolerantly.** It is an object on thrown query errors, a JSON string in-band, and currently a **doubly-encoded** JSON string in-band — one `JSON.parse` there returns a string, and `.reason` on it is silently `undefined`. Loop until it is not a string:

```javascript
function parseDetails(d) {
  let v = d;
  for (let i = 0; i < 3 && typeof v === "string"; i++) { try { v = JSON.parse(v); } catch { break } }
  return v && typeof v === "object" ? v : {};
}
```

* Branch on `code`, never on `message`. Retry only when `retryable` is `true`, with exponential backoff. Quote `requestId` in any bug report.
* One exception to "retry when retryable": a malformed or out-of-bounds `expiresIn` on `generateServiceToken` is not pre-validated and comes back as `INTERNAL_ERROR` / `details.reason: TOKEN_GENERATION_FAILED`, which is flagged retryable but never will be. Validate `expiresIn` client-side (`<int><unit>`, unit in `s m h d w M y`, 1 hour to 2 years) and cap retries on that reason.
* Codes: `UNAUTHENTICATED`, `UNAUTHORIZED`, `NOT_FOUND`, `VALIDATION_FAILED`, `CONFLICT`, `FAILED_PRECONDITION`, `COMPLEXITY_LIMIT_EXCEEDED`, `RATE_LIMITED`*, `TIMEOUT`*, `UPSTREAM_UNAVAILABLE`*, `INTERNAL_ERROR`* (`*` = retryable). An unrecognised code: treat as non-retryable, preserve it, surface it.

## Step 1 — Self-issue a service token

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

Sign `message` **verbatim** with the wallet as a plain personal message (EIP-191 `personal_sign`, **not** typed data). `message` embeds a **single-use nonce valid for 10 minutes** — fetch it immediately before signing, never cache it or the signature, never rebuild the string yourself, and fetch a fresh one for every token. Then:

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

`token` → `X-Service-Token` on everything after this. `expiresIn` defaults to `180d`; format `<int><unit>` with unit one of `s m h d w M y`; bounds 1 hour to 2 years. The token is **wallet-bound, not lab-bound**: authorisation is resolved per request from that wallet's onchain role on the lab you name.

## Step 2 — Mint the LabNFT (onchain)

```
OnChainLabFactory.mintAndCreateAccount(address to) payable returns (address account, uint256 tokenId)
LabNFT.mintFeeWei() view returns (uint256)                     // send as `value`; reads 0 on both chains today
LabNFT event OclIdentityCreated(address indexed account, bytes32 indexed oclId, uint256 indexed tokenId, bytes32 salt, uint256 canonicalChainId)
```

Read `oclId` off `OclIdentityCreated`. The event fires on **LabNFT**, not the factory — filter receipt logs to the LabNFT address before decoding.

## Step 3 — Register the lab

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

`CONFLICT` / `reason: PROJECT_CONFLICT` means the lab is already registered — treat as success and continue.

## Step 4 — Upload a public file (3 calls)

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

`PUT` the raw bytes to `uploadUrl` with **exactly** the returned `headers` (key/value pairs) and the returned `method`. Presigned URLs expire in \~15 minutes.

```graphql
mutation Finish($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
    error { code message requestId retryable details }
  }
}
```

* `accessLevel`: `"PUBLIC"` | `"HOLDERS"` | `"ADMIN"`.
* `changeBy`: the caller's wallet address.
* `path` for a **new** file (no underscores), `ref` for a **new version** of an existing one. Use one or the other, never both.
* Valid `tags` / `categories` come from the public `fileCategoriesAndTags` query.

## Step 5 — Verify

```graphql
query Verify($oclId: String!) {
  labWithDataRoomAndFiles(oclId: $oclId) {
    oclId shortname name
    dataRoom { id files { path contentType accessLevel version createdBy } }
  }
}
```

Public query, `Authorization` only. Your `path` is in `dataRoom.files`. A `null` result means the lab is not registered — this query is nullable and does not throw for a missing lab.

The lab page slug is `lab-<labNftTokenId>` until the lab is renamed, then the `shortname` derived from its new name; the `lab-<tokenId>` form stops resolving at that point. Never use `oclId` as a slug — it does not resolve.

## Encrypted files, in four lines

1. `generateDataEncryptionKey` → `{ plaintextDEK, encryptedDek, encryptionSystem }`.
2. AES-256-GCM the bytes locally with `plaintextDEK` and a fresh 12-byte IV; discard the plaintext key.
3. `finishCreateOrUpdateFile` with `accessLevel: "HOLDERS"` (or `"ADMIN"`) and `encryptionMetadata: { encryptionSystem, encryptedDek, iv, contentHash, accessControlConditions, encryptedBy, encryptedAt }` — echo `encryptionSystem` verbatim, never hardcode it.
4. To read it back: `decryptDataKey(oclId:, filePath:)` → `{ plaintextDEK, iv }` after the backend re-evaluates the file's onchain access conditions against your wallet.

Full recipe including `accessControlConditions`: [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file).

## Rules that break runs when ignored

1. No `Bearer` in front of a `mol_` credential.
2. Sign the sign-in message **verbatim**; any reformatting fails verification. It is single-use and expires after 10 minutes, so a cached message or signature returns `UNAUTHENTICATED` — `NONCE_NOT_FOUND` once redeemed, `NONCE_EXPIRED` past the window, `INVALID_SIGNATURE` if a later fetch superseded it. The fix is always a fresh `getServiceSignInMessage`, never a retry of the same signature.
3. Success on a mutation is `error == null` — never a truthy payload field, and never a `message` string.
4. Read `error.details` with the tolerant `parseDetails` above — never a bare `JSON.parse`. In-band it is a JSON string (currently doubly encoded); on thrown query errors it is already an object.
5. Filter mint receipt logs to the **LabNFT** address before decoding `OclIdentityCreated`.
6. Send the presigned `PUT` with the returned headers unchanged, and the raw bytes as the body.
7. Writing into a lab you do not own needs a **Contributor** role on it — see [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor). After a role grant, an indexer lag of a few seconds can still return `UNAUTHORIZED`; retry with backoff.
8. **A successful `createLab` does not mean the lab is writable yet.** Step 4's first call can return `NOT_FOUND` ("Project 0x… does not exist") for a few seconds, because `createLab` falls back to an onchain ownership check while the file mutations read the indexed record. Retry `NOT_FOUND` with backoff on the first write after a mint; do not re-run `createLab`, which then returns `CONFLICT`.
9. **Three addresses, not interchangeable**: your own wallet (`walletAddress`, `changeBy`), the human owner's (`x-wallet-address`, their path only), and the Lab's OCL account (`labAccountAddress`, and the `account` argument in access conditions). Putting an owner's address where `labAccountAddress` belongs uploads fine and then locks everyone out of the file, with no error saying why. `oclId` is none of them — it is a lab id whose trailing 40 hex chars happen to be the OCL account address. See [the three wallets](/api-reference/authentication#the-three-wallets-side-by-side).
10. Production has introspection off and a depth limit of 10. Generate types against staging.

## Related

* [The three wallets](/api-reference/authentication#the-three-wallets-side-by-side) — owner vs agent vs OCL account, and which field each address goes in
* [Getting Started](/api-reference/getting-started) — how to interact with our products, prerequisites, costs
* [Glossary](/references/glossary) — every term used here, defined in a sentence
* [Tutorials](/api-reference/getting-started) — the same flow with responses and failure handling
* [Labs API](/api-reference/labs-api) — full operation reference
* [Molecule Skill](/ai-tooling/molecule-skill) — the same workflow as MCP tool calls
* [x402 Gateway](/api-reference/x402-gateway) — pay per call, no service token


# 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.

There are two credentials, and they do different jobs. If any term on this page is unfamiliar, the [Glossary](/references/glossary) defines it in a sentence.

| Credential                                            | Answers                                       | How you get it                                                         |
| ----------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------- |
| **Consumer credential** (`mol_<consumerId>_<secret>`) | *Which API consumer is calling?*              | Requested once from the Molecule team — the one manual step            |
| **Service Token** (JWT)                               | *Which wallet is calling, so what may it do?* | **Self-issued**: sign a message with your wallet. No human in the loop |

## Obtaining API Access

Every request carries a **consumer credential** in the `Authorization` header. Request one on the [Molecule Discord](https://t.co/L0VEiy4Bjk): post in [the general-chat channel](https://discord.com/channels/608198475598790656/832947534983987281) and ping **@ella**, using this template (the channel link needs you to be in the server — join with the invite first):

```
Consumer credential request
- Who: <your name / org>
- What you're building: <one line>
- Environment: staging   (add production if you need both)
- Contact: <Discord handle or email>
```

What comes back is one opaque string per environment:

```
mol_<consumerId>_<secret>
```

Send it 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. Credentials are per environment: a staging credential does not authenticate against production.

You do **not** need to ask anyone for a Service Token. Write mutations need one, and you mint it yourself by signing a message with your wallet — see [Service Tokens](/api-reference/labs-api/service-tokens#obtaining-a-token), or [Step 1 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-1-get-a-service-token) for the runnable version.

## 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>`                                                                             |

> **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**: **reads 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: the two Service Token lifecycle mutations are service-token-only, and `generateServiceToken` bootstraps a token with a wallet signature or a Privy session.

Summary of the model:

* **Reads are public**: consumer credential only for the queries listed below.
* **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 consumer credential plus a wallet signature or a Privy session, since it mints the token in the first place.
* **A Service Token is bound to a wallet, not to a lab.** It carries the wallet's identity; what it may do on a given lab is resolved per request from that wallet's onchain role. See [What a Service Token actually authorizes](#what-a-service-token-actually-authorizes).
* 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)

The Labs read surface is public — these queries need only a consumer credential:

* `labs` - List all labs with pagination
* `labWithDataRoomAndFiles` - Get lab details and files
* `labActivity` - Get the file-event activity feed for a lab
* `activities` - Get the global file-event activity feed
* `dataRoomFile` - Get file by path
* `searchLabs` - Search across labs and files
* `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
* `onChainActivity` - Onchain event feed for a lab or wallet
* `listLabMembers` - List a lab's members

```bash
Authorization: YOUR_CONSUMER_CREDENTIAL
```

Sending `X-Service-Token` on a public query is unnecessary, and sending an empty one is worse than omitting the header entirely.

### 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, and the check is the same on both paths: the wallet's onchain role on that lab (LabNFT owner, authorized multisig signer, or an active Contributor/Viewer grant on `AccessResolver`). Supplying neither path returns an `UNAUTHENTICATED` error with `details.reason` `NO_AUTH`, naming both.

**Mutations accepting either path:**

| Mutation                                                                | Minimum role                                | Notes                                                                                                                                                                        |
| ----------------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createLab` - Create a lab (data room) for an onchain lab (OCL)         | Owner of the OCL                            | 💳 also pay-per-call via [x402](/api-reference/x402-gateway)                                                                                                                 |
| `initiateCreateOrUpdateFile` - Initiate file upload                     | Contributor                                 | 💳 also pay-per-call via [x402](/api-reference/x402-gateway)                                                                                                                 |
| `finishCreateOrUpdateFile` - Complete file upload                       | Contributor                                 | 💳 also pay-per-call via [x402](/api-reference/x402-gateway)                                                                                                                 |
| `updateFileMetadata` - Update file metadata                             | Contributor                                 |                                                                                                                                                                              |
| `deleteDataRoomFile` - Delete a file                                    | Contributor                                 |                                                                                                                                                                              |
| `moveEntry` - Move a file or folder                                     | Contributor                                 |                                                                                                                                                                              |
| `updateLabNftMetadata` - Update LabNFT display metadata                 | **Owner only**                              |                                                                                                                                                                              |
| `generateLabImageUploadUrl` - Presigned URL for a LabNFT image          | **Owner only**                              |                                                                                                                                                                              |
| `generateDataEncryptionKey` - Generate a standalone data encryption key | Authenticated, no role                      | Takes no `oclId`, so there is no lab to check against. 💳 also pay-per-call via [x402](/api-reference/x402-gateway)                                                          |
| `decryptDataKey` - Decrypt a file's data key                            | **Viewer**, *and* the file's own conditions | Two gates: the Viewer check on the lab, then a live onchain evaluation of the file's `accessControlConditions`. 💳 also pay-per-call via [x402](/api-reference/x402-gateway) |

The Owner passes every check; a Contributor passes Contributor and Viewer checks. Full capability matrix: [Roles & Permissions](/technical-deep-dive/roles-and-permissions).

**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
```

Both are scoped to the caller's **own** tokens: the token presented must own the `tokenId` it names, so one caller cannot extend or revoke another's. A `tokenId` belonging to someone else returns the same `NOT_FOUND` as one that does not exist.

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

> **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 Service Token

Self-service, two calls, no human in the loop. Full reference with parameters and failure modes: [Service Tokens](/api-reference/labs-api/service-tokens#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-1-get-a-service-token).

1. **`getServiceSignInMessage(walletAddress, serviceName)`** — a public query returning the message to sign, plus the `expiresAt` of the nonce embedded in it.
2. **Sign it verbatim** with the wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). Re-wording or re-formatting the string breaks verification.
3. **`generateServiceToken(serviceName, walletAddress, messageSignature, expiresIn)`** — returns the JWT to send as `X-Service-Token`, plus a `tokenId` for lifecycle operations.

> **The sign-in message is single-use and short-lived — fetch a fresh one before every signing.** It embeds a server-issued nonce and an expiry, so it is **not** deterministic and a signature over it cannot be replayed or cached. The nonce is valid for **10 minutes**, is consumed by the first successful `generateServiceToken`, and there is one outstanding nonce per `(walletAddress, serviceName)` pair — fetching a new message supersedes the previous one. Never reconstruct the string client-side; sign exactly what the query returned. Failure reasons: [Obtaining a Token](/api-reference/labs-api/service-tokens#obtaining-a-token).

| `expiresIn`          | Value                                                                                       |
| -------------------- | ------------------------------------------------------------------------------------------- |
| Default when omitted | `180d`                                                                                      |
| Format               | `<integer><unit>`, unit one of `s` `m` `h` `d` `w` `M` `y` (e.g. `"30d"`, `"720h"`, `"6M"`) |
| Minimum              | 1 hour                                                                                      |
| Maximum              | 2 years                                                                                     |

**Validate this before you send it.** `expiresIn` is not checked by the resolver: a value outside those bounds, or in another format, fails inside token generation and comes back as `INTERNAL_ERROR` with `details.reason: TOKEN_GENERATION_FAILED` and a masked message, not as `VALIDATION_FAILED`. `INTERNAL_ERROR` is flagged `retryable: true`, but this one is permanent — fix the value rather than retrying.

Issuance is **not** gated on holding a role on any lab — any wallet can mint a token for itself. The role is what makes the token useful.

### What a Service Token actually authorizes

A Service Token is **wallet-bound, not lab-bound.** It says "this wallet is calling"; it does not carry a list of labs.

On every request, the API resolves what the token's wallet may do on the lab named in the call from that wallet's **live onchain role**. This has three practical consequences:

* **One token works across every lab the wallet has a role on.** You do not issue a token per lab.
* **A role granted after the token was issued takes effect without re-issuing it.** Likewise a revoked role stops the token on that lab immediately, while leaving it valid elsewhere.
* **A token for a wallet with no role authenticates but cannot write.** You will see `UNAUTHENTICATED` become `UNAUTHORIZED`: the caller is known, just not permitted.

This is why an agent can be handed access to a lab it does not own — the human grants the agent's wallet a role, and the agent's own token starts working on that lab. See [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor).

> Because role state reaches the API through an event indexer, there is a short window after a role grant confirms onchain in which a write can still return `UNAUTHORIZED`. Retry with backoff; re-issuing the token does not help.

### The three wallets, side by side

A working integration has up to three addresses in play at once, and they are not interchangeable. Sending the wrong one is the most common way a request fails for a reason the error message does not explain.

|                           | **Owner wallet**                                                                                                      | **Agent wallet**                                                                                                                   | **OCL account** (the Lab's own wallet)                                                                                                                                                                |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| What it is                | The human's wallet — typically a Privy embedded wallet created at email sign-in, but any wallet that holds the LabNFT | An EOA belonging to the agent, generated by the agent and never shared                                                             | The Lab itself: an [ERC-6551 Token Bound Account](/technical-deep-dive/onchain-lab) permanently bound to the LabNFT                                                                                   |
| Who holds the private key | The human (Privy custodies the embedded case)                                                                         | The agent, and only the agent                                                                                                      | **Nobody.** It has no key of its own — its authority derives from whoever currently owns the LabNFT                                                                                                   |
| How it gets its rights    | Implicitly: holding the LabNFT makes it **Owner**                                                                     | An explicit onchain grant of **Contributor** or **Viewer** from the Owner                                                          | It is the lab — rights are resolved *against* it, not held by it                                                                                                                                      |
| How it authenticates      | `Authorization: Bearer <Privy token>` + `x-wallet-address`                                                            | Signs the sign-in message → its own [service token](/api-reference/labs-api/service-tokens#obtaining-a-token) in `X-Service-Token` | It never authenticates. It signs nothing and is issued no token                                                                                                                                       |
| Where its address goes    | `x-wallet-address`                                                                                                    | `walletAddress` when issuing a token, `changeBy` on writes, and whatever `:userAddress` resolves to at condition-evaluation time   | `labAccountAddress` — including the `account` argument of `isAuthorizedSignerForTba` in [access conditions](/api-reference/getting-started/upload-encrypted-file#step-4c-write-the-access-conditions) |
| What it cannot do         | —                                                                                                                     | Transfer the LabNFT, call `updateLabNftMetadata` or `generateLabImageUploadUrl` — those stay Owner-only                            | Act as a caller: never pass it as `walletAddress` or `changeBy`                                                                                                                                       |

Two failure modes this prevents:

* **Passing the owner's address where the OCL account belongs** in `accessControlConditions`. Condition evaluation **fails closed**, so the file uploads fine and then nobody can decrypt it — with no error saying why. The `account` argument wants `labAccountAddress`; `:userAddress` is substituted with the caller's wallet automatically.
* **Expecting the agent to inherit the human's reach.** The agent authenticates as itself, so its permissions come from its own grant. That is the point — the human never hands over a key — and it is why a few Owner-only mutations stay out of reach. Walkthrough: [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor).

#### `oclId` is not a wallet address

`oclId` identifies a lab and looks like an address, but it is a 32-byte value that **packs the OCL account address inside it**, together with the LabNFT `tokenId`:

```
oclId  0x 01     01        000000000000000005f6      f923ca46329c8fcb2fcf8a03512f1483c52c63c5
          ^^     ^^        ^^^^^^^^^^^^^^^^^^^^      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
          version namespace tokenId (1526)           the OCL account address, verbatim
```

So the trailing 40 hex characters of an `oclId` are the lab's `labAccountAddress` — which is why a zeroed one is rejected with `VALIDATION_FAILED` / `"embedded address is zero"` rather than a not-found. Pass `oclId` wherever a lab is named; never pass it as a wallet address, and never truncate it to one.

### 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 the *wallet* the request acts as; its permissions come from that wallet's onchain role on the target lab
* **Privy token + wallet address**: Identifies a human caller instead, whose write access is derived from the same onchain role model

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.

**Security Warnings:**

* Service tokens are returned only once, at 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
* Give an agent's token an `expiresIn` matching its role grant's expiry rather than taking the 180-day default
* Rotate tokens regularly, and `revokeServiceToken` immediately on compromise

> 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 get started, see [🚀 Getting Started](/api-reference/getting-started) — it covers the one credential you need to request and gets you to a lab with a file in it in about ten minutes.

***

## Where to start

|                               |                                                                                                                                                                                                                                                                   |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **First time here**           | [🚀 Getting Started](/api-reference/getting-started) — prerequisites, costs, ten-minute quickstart                                                                                                                                                                |
| **A term here is unfamiliar** | [Glossary](/references/glossary) — Lab, `oclId`, data room, service token, indexer                                                                                                                                                                                |
| **You want runnable code**    | [Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file) · [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file) · [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor) |
| **You're an AI agent**        | [Agent one-pager](/api-reference/getting-started/for-agents), or drive this API through the [Molecule Skill](/ai-tooling/molecule-skill) plugin                                                                                                                   |
| **You want to pay per call**  | [x402 Gateway](/api-reference/x402-gateway)                                                                                                                                                                                                                       |

***

## Authentication

The Labs API uses consumer-credential authentication for reads and an additional Service Token for writes — which callers **issue for themselves** by signing a message with their wallet. 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), and [Service Tokens](/api-reference/labs-api/service-tokens). For runnable end-to-end walkthroughs, see the [tutorials](/api-reference/getting-started) under Getting Started.

***

## 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\"}"
      }
    }
  }
}
```

`details` reaches you in more than one shape, so **read it through a tolerant parse rather than a single `JSON.parse`**: it is a JSON-encoded string on in-band mutation errors, a plain object on thrown query errors (`errorInfo.details`), and the in-band string is currently encoded twice — a single parse there returns another string, and `.reason` on it is silently `undefined`. Parsing until the value stops being a string reads all three correctly and needs no change when the encoding is corrected.

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
// Handles all three shapes: object, JSON string, doubly-encoded JSON string.
function parseDetails(details) {
  let value = details;
  for (let i = 0; i < 3 && typeof value === "string"; i++) {
    try {
      value = JSON.parse(value);
    } catch {
      break;
    }
  }
  return value && typeof value === "object" ? value : {};
}

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

if (result.error) {
  const { code, message, requestId, retryable, details } = result.error;
  const { reason } = parseDetails(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 published 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, issue a new one yourself — the two-call [sign-in flow](/api-reference/labs-api/service-tokens#obtaining-a-token) needs no human — or extend the existing one with `extendServiceToken`

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:

* Check the wallet's role with the public `listLabMembers(oclId)` query. Content writes (uploads, metadata, moves, deletes) need **Contributor**; `createLab` and the LabNFT-metadata mutations need **Owner**
* Not the right role? The lab owner grants one onchain — see [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor)
* **Just granted the role?** Role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after the grant confirms onchain. Retry with backoff; re-issuing the token does not help

**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 Labs API:

1. Check this documentation and the [troubleshooting section](#troubleshooting)
2. Run the [Tutorials](/api-reference/getting-started) against staging — each step lists its expected response and failure modes
3. Join our [Discord community](https://t.co/L0VEiy4Bjk) for support, quoting the `requestId` from the failing response

***

*Last updated: July 2026*


# Service Tokens

A service token is the credential that proves *which wallet* a write request acts as. It is **self-issued** — you mint your own by proving control of the wallet, and nobody has to provision one for you.

> **Wallet-bound, not lab-bound.** A service token carries a wallet identity, not a list of labs. What it may do on a given lab is resolved per request from that wallet's live onchain role, so one token works across every lab the wallet has a role on, and a role granted after issuance takes effect without re-issuing. See [What a Service Token actually authorizes](/api-reference/authentication#what-a-service-token-actually-authorizes).

## Obtaining a Token

Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Step 1 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-1-get-a-service-token).

Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful.

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

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

Returns the deterministic message that a service must sign to authenticate via wallet signature. Public query, no auth required. Use cases: autonomous agents, bots, CI/CD pipelines, or any service that needs a token without a browser-based Privy session.

Returns [`ServiceSignInMessageResult!`](/api-reference/types#servicesigninmessageresult).

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

Public query — no authentication required.

{% hint style="warning" %}
**Single-use, and valid for 10 minutes.** The sign-in message is not deterministic — do not cache it, do not cache a signature over it, and never reconstruct the string client-side. Concretely:

* The nonce is **consumed** by the first successful `generateServiceToken`. Issuing a second token means fetching a new message and signing again.
* There is **one outstanding nonce per `(walletAddress, serviceName)`**, last-write-wins: calling this query again invalidates the message you have not yet redeemed.
* The window is **10 minutes** from issuance (`expiresAt`). Sign and redeem promptly rather than fetching a message ahead of time.
* Signatures over the older, nonce-free message format no longer verify.
  {% endhint %}

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

Sign the returned `message` **verbatim** with the service wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). The backend recomposes the same string from the stored nonce record and verifies it server-side, so re-wording or re-formatting it fails with `UNAUTHENTICATED` / `reason: INVALID_SIGNATURE`. 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
    }
  }
}
```

Issues a service token (JWT) for non-browser automation such as agents, bots and pipelines. Supply both `walletAddress` and `messageSignature` to authenticate by wallet signature over the message from `getServiceSignInMessage`, whose nonce is single-use and valid for 10 minutes, or supply neither to authenticate with the caller's session. Fails with `UNAUTHENTICATED` when the nonce is missing, expired or the signature does not match, and `VALIDATION_FAILED` when only one of the two is supplied.

Returns [`ServiceTokenResult!`](/api-reference/types#servicetokenresult).

| Name               | Type      | Description                                                                                                                                                     |
| ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serviceName`      | `String!` | Name of the service the token is for; recorded with the token and echoed in `ServiceTokenResult.serviceName`.                                                   |
| `expiresIn`        | `String`  | Lifetime of the token as `<number><unit>` with unit s, m, h, d, w, M (30 days) or y (365 days), e.g. "30d", "6M", "1y". Default "180d"; minimum 1h, maximum 2y. |
| `walletAddress`    | `String`  | Wallet address for signature-based auth (both walletAddress and messageSignature required together). Use cases: agents, bots, services.                         |
| `messageSignature` | `String`  | Hex-encoded signature of the service sign-in message.                                                                                                           |

\* `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.

**`expiresIn`:**

|                      |                                                                                                     |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| Default when omitted | `180d`                                                                                              |
| Format               | `<integer><unit>`, unit one of `s` `m` `h` `d` `w` `M` `y` — e.g. `"30d"`, `"720h"`, `"6M"`, `"1y"` |
| Minimum              | 1 hour                                                                                              |
| Maximum              | 2 years                                                                                             |

`M` is a 30-day month and `y` is a 365-day year. **Validate this client-side.** `expiresIn` is not checked before use: anything outside the bounds, or in another format, fails deep in token generation and surfaces as `INTERNAL_ERROR` with `details.reason: TOKEN_GENERATION_FAILED` and a masked message — not as `VALIDATION_FAILED`. `INTERNAL_ERROR` carries `retryable: true`, but this particular one is permanent: retrying the same `expiresIn` will never succeed. Prefer a short lifetime matched to the caller's purpose — for an agent, match the expiry of its role grant — over the 180-day default.

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.

**Failure modes on the signature path** — all `UNAUTHENTICATED`, distinguished by `details.reason`. Read it through the tolerant [`parseDetails`](/api-reference/labs-api#error-handling), not a bare `JSON.parse` — the in-band string is currently doubly encoded:

| `reason`            | What happened                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Fix                                                                                                                                |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `NONCE_NOT_FOUND`   | No nonce record at all — you never called `getServiceSignInMessage` for this wallet + service, or the nonce was already consumed by an earlier token                                                                                                                                                                                                                                                                                                                                              | Fetch a fresh message and sign it again. Do not retry the same signature                                                           |
| `NONCE_EXPIRED`     | The message is older than its 10-minute window                                                                                                                                                                                                                                                                                                                                                                                                                                                    | Fetch a fresh message and sign it again                                                                                            |
| `INVALID_SIGNATURE` | The signed bytes are not the string the backend recomposes. Either the message was altered (re-formatted, rebuilt client-side, typed-data signing, the retired nonce-free format), **it was superseded** — a later `getServiceSignInMessage` call replaced the stored nonce, so an earlier message no longer matches — **or the `walletAddress` you sent is not the address that produced the signature**: verification runs against the address you claim, so a wrong one simply fails to verify | Sign the `message` from the most recent call, byte-for-byte, with `personal_sign`, and send the signing address as `walletAddress` |

None of these are retryable as-is: every one of them means "get a new message and sign that". Note there is no distinct wallet-mismatch reason on this path — the nonce is looked up under the `walletAddress` you send and the signature is verified against it, so a wrong address returns `NONCE_NOT_FOUND` (no message was ever issued for that address) or `INVALID_SIGNATURE` (one was, but a different key signed). A `VALIDATION_FAILED` here refers to `walletAddress` format, or to sending only one of `walletAddress` / `messageSignature` — both must be present together.

## Extending Token Expiration

You can extend your service token's expiration using the `extendServiceToken` mutation. **Scoped to your own tokens:** the token you present must own the `tokenId` you name. A `tokenId` belonging to another wallet returns the same `NOT_FOUND` as one that does not exist, so token existence cannot be probed.

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

Extends the lifetime of one of the caller's own service tokens, counted from now. Authenticated by the `x-service-token` header (fails with `UNAUTHENTICATED` when missing or invalid). A token the caller does not own is reported as not found; a revoked token fails with `FAILED_PRECONDITION`. Success when `error == null`.

Returns [`ServiceTokenResult!`](/api-reference/types#servicetokenresult).

| Name        | Type      | Description                                                                                          |
| ----------- | --------- | ---------------------------------------------------------------------------------------------------- |
| `tokenId`   | `String!` | Value of `ServiceTokenResult.tokenId`, not the token itself.                                         |
| `expiresIn` | `String!` | New lifetime from now, in the `expiresIn` format of `generateServiceToken` (minimum 1h, maximum 2y). |

**Example:**

```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_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). Like `extendServiceToken`, this is **scoped to your own tokens** — the presented token must own the `tokenId`, and a foreign one returns `NOT_FOUND`.

```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 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -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"
    }
  }'
```

***


# 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 — see [Creating a Lab](/user-guides/scientists-researchers#creating-a-lab). For a runnable end-to-end version of the mint, see [Step 2 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-2-mint-the-labnft).

### 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

Registers a data room for an onchain lab whose LabNft has already been minted. Requires the LabNft owner or an authorized signer for it. Fails with `INVALID_OCL_ID` when `oclId` is not a canonical 32-byte value.

Returns [`CreateLabResult!`](/api-reference/types#createlabresult).

| Name    | Type                                                     | Description              |
| ------- | -------------------------------------------------------- | ------------------------ |
| `input` | [`CreateLabInput!`](/api-reference/types#createlabinput) | Onchain lab to register. |

> **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 — see [Creating a Lab](/user-guides/scientists-researchers#creating-a-lab).

> **Owner authorization required**: this mutation requires either a **self-issued** service token (see [Service Tokens](/api-reference/labs-api/service-tokens#obtaining-a-token) — no need to ask anyone for one) or a valid Privy session. 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
    }
  }
}
```

**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): [issue one yourself](/api-reference/labs-api/service-tokens#obtaining-a-token) by signing a message with the owner wallet
   * **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` — read via the tolerant [`parseDetails`](/api-reference/labs-api#error-handling), since the in-band string is currently doubly encoded) — never on message text. The top-level `message` mirrors `error.message` on failure.

**Not Authenticated (No Token):**

> The `message` below is returned verbatim by the API and its "contact Molecule tech team" wording is out of date: service tokens are self-issued. Branch on `error.code` / `details.reason`, never on message text — and [issue your own token](/api-reference/labs-api/service-tokens#obtaining-a-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 a service token:**

Issue it yourself — no request, no waiting. Two calls with the owner wallet:

1. `getServiceSignInMessage(walletAddress, serviceName)` — public query, returns the message to sign.
2. Sign it verbatim (EIP-191 `personal_sign`), then `generateServiceToken(serviceName, walletAddress, messageSignature)` — returns the JWT for `X-Service-Token`.

Full parameters and bounds: [Service Tokens](/api-reference/labs-api/service-tokens#obtaining-a-token). Runnable: [Step 1 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file#step-1-get-a-service-token).

The only credential you have to request is the **consumer credential** — see [Getting Started](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step) for the request template.

***

## 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: mol_<consumerId>_<secret>`, with **no `Bearer` prefix** — and no Service Token. 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. Restricted to the OCL admin (LabNft owner + multisig signers).

Returns [`UpdateLabNftMetadataResult!`](/api-reference/types#updatelabnftmetadataresult).

| Name    | Type                                                                           | Description                                                    |
| ------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| `oclId` | `String!`                                                                      | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive. |
| `input` | [`UpdateLabNftMetadataInput!`](/api-reference/types#updatelabnftmetadatainput) | Fields to change; omitted fields are left as they are.         |

> **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
    }
  }
}
```

### Generate LabNFT Image Upload URL

Issues a single-use presigned PUT URL for a LabNft display image, restricted to the OCL admin. `contentType` must be one of `image/jpeg`, `image/png`, `image/webp`, `image/gif` or `image/svg+xml`, the generated key's extension is derived from it, and the client must upload with the matching `Content-Type` header. The uploaded object's type and size are validated again before the lab's `image` is updated, and other content types fail with `UNSUPPORTED_IMAGE_TYPE`.

Returns [`GenerateLabImageUploadUrlResult!`](/api-reference/types#generatelabimageuploadurlresult).

| Name          | Type      | Description                                                                                               |
| ------------- | --------- | --------------------------------------------------------------------------------------------------------- |
| `oclId`       | `String!` | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive.                                            |
| `contentType` | `String!` | MIME type of the image (case-insensitive): 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 uploaded object lands in storage.

> **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
    }
  }
}
```

***

***

## Lab Members

### List Lab Members

Active members of a lab, from the indexed onchain role state, excluding grants that have expired. Membership is granted onchain through the AccessResolver contract rather than through this API. Public, with no authentication beyond an API key, mirroring the public onchain role state.

Returns [`ListLabMembersResult!`](/api-reference/types#listlabmembersresult).

| Name    | Type      | Description                                                    |
| ------- | --------- | -------------------------------------------------------------- |
| `oclId` | `String!` | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive. |

> **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`.

***

***

## DID Linking

### Get DID Link Status

Public read-only snapshot of DID-linking state for the given OCL. Exposes the state-machine fields (status, attempts, userOpHash, txHash, account/data room DIDs, count of active onchain DIDs). DID-linking runs automatically in the background after createLab; this query is provided for diagnostic and support visibility. No authentication required.

Returns [`DidLinkStatusResult!`](/api-reference/types#didlinkstatusresult).

| Name    | Type      | Description                                                    |
| ------- | --------- | -------------------------------------------------------------- |
| `oclId` | `String!` | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive. |

```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`.

`status` is `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 metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](/api-reference/labs-api/lab-management).

> **Looking for a runnable walkthrough?** This page is the per-operation reference. For a first upload with expected responses and failure handling at every step, use [Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file) or [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file) (encrypted, with a decrypt round trip).

> **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

Starts a file or file-version upload to a lab's data room, returning a pre-signed `uploadUrl` and the `uploadToken` for the finish step. PUT the bytes to that URL, then call `finishCreateOrUpdateFile` with the token.

Returns [`InitiateFileUploadResult!`](/api-reference/types#initiatefileuploadresult).

| Name            | Type      | Description                                                    |
| --------------- | --------- | -------------------------------------------------------------- |
| `oclId`         | `String!` | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive. |
| `contentType`   | `String!` | MIME type to store with the file.                              |
| `contentLength` | `Int!`    | Size of the upload in bytes.                                   |

**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
    }
  }
}
```

**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 an upload once the bytes have been PUT to the `uploadUrl` from `initiateCreateOrUpdateFile`. Provide `path` for a new file or `ref` for a new version of an existing one, never both.

Returns [`FinishFileUploadResult!`](/api-reference/types#finishfileuploadresult).

| Name                 | Type                                                                      | Description                                                                                                                                                                              |
| -------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oclId`              | `String!`                                                                 | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive.                                                                                                                           |
| `uploadToken`        | `String!`                                                                 | Value of `uploadToken` returned by `initiateCreateOrUpdateFile`.                                                                                                                         |
| `path`               | `String`                                                                  | Path for a new file, e.g. `foo.txt`. Underscores are not allowed.                                                                                                                        |
| `ref`                | `String`                                                                  | Dataset ref of an existing file, to store a new version of it.                                                                                                                           |
| `accessLevel`        | `String!`                                                                 | Access level of the file, `PUBLIC` or `ADMIN`, with `HOLDERS` accepted but unenforced. `ADMIN` restricts reading only for content encrypted at upload; the label alone protects nothing. |
| `changeBy`           | `String`                                                                  | Deprecated and ignored: the change is attributed to the authenticated caller. Removed after 2027-01-01.                                                                                  |
| `description`        | `String`                                                                  | Description of the file or version.                                                                                                                                                      |
| `tags`               | `[String!]`                                                               | Tags for the file or version.                                                                                                                                                            |
| `contentText`        | `String`                                                                  | Text content for searchability.                                                                                                                                                          |
| `categories`         | `[String!]`                                                               | Categories for the file or version.                                                                                                                                                      |
| `encryptionMetadata` | [`EncryptionMetadataInput`](/api-reference/types#encryptionmetadatainput) | Encryption metadata for encrypted files (KMS, BLS, or legacy).                                                                                                                           |

**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
    }
  }
}
```

*\*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
```

***

## Update File Metadata

Updates metadata for an existing file without creating a new version. Note: The wallet address for audit trail is automatically derived from authentication.

Returns [`UpdateFileMetadataResult!`](/api-reference/types#updatefilemetadataresult).

| Name          | Type        | Description                                                                                                                                                                              |
| ------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oclId`       | `String!`   | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive.                                                                                                                           |
| `ref`         | `String!`   | Reference (DID) of the file to update.                                                                                                                                                   |
| `accessLevel` | `String!`   | Access level of the file, `PUBLIC` or `ADMIN`, with `HOLDERS` accepted but unenforced. `ADMIN` restricts reading only for content encrypted at upload; the label alone protects nothing. |
| `description` | `String`    | Description to store for the file.                                                                                                                                                       |
| `tags`        | `[String!]` | Tags to store on the file.                                                                                                                                                               |
| `categories`  | `[String!]` | Categories to store on the file.                                                                                                                                                         |
| `contentText` | `String`    | Text content for searchability.                                                                                                                                                          |

**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
    }
  }
}
```

> **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

Deletes a file from the lab data room.

Returns [`DeleteDataRoomFileResult`](/api-reference/types#deletedataroomfileresult).

| Name       | Type      | Description                                                                                                               |
| ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------- |
| `oclId`    | `String!` | 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive.                                                            |
| `path`     | `String!` | Path within the data room. Paths under `agreements/` are reserved for signed legal agreements and cannot be deleted here. |
| `changeBy` | `String`  | Deprecated and ignored: the change is attributed to the authenticated caller. Removed after 2027-01-01.                   |

**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
    }
  }
}
```

> **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' \
  -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

> **Step-by-step version:** [Upload an encrypted file](/api-reference/getting-started/upload-encrypted-file), including both access-condition recipes (owner-only, and owner/contributor/viewer) and a decrypt round trip that verifies the gate actually works.

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

Generates a standalone data encryption key for client-side encryption, returning both the plaintext key and its wrapped form. Requires an authenticated caller.

Returns [`GenerateDataEncryptionKeyResult!`](/api-reference/types#generatedataencryptionkeyresult).

This operation takes no arguments.

The plaintext DEK encrypts data locally and is then wiped; the encrypted DEK is stored alongside the ciphertext. 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
    }
  }
}
```

***


# 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).

## 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

Onchain labs, with optional membership filtering and page-numbered pagination. Superseded by `labsConnection` for new consumers, which adds cursor pagination, sorting and richer filters.

Returns [`LabsResult!`](/api-reference/types#labsresult).

| Name            | Type                                                  | Description                                                                                                                                                                              |
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `walletAddress` | `String`                                              | Wallet address to filter labs by membership. When provided, returns labs where this wallet holds any active role (owner/contributor/viewer) unless narrowed by `role`.                   |
| `role`          | [`LabMemberRole`](/api-reference/types#labmemberrole) | Role filter, applied only when `walletAddress` is set, restricting the result to labs where that wallet holds this role. Omit to include every lab the wallet belongs to under any role. |
| `page`          | `Int`                                                 | Page number (0-indexed).                                                                                                                                                                 |
| `perPage`       | `Int`                                                 | Number of items per page (max 100).                                                                                                                                                      |

> **🔓 Public Endpoint**: The `labs` query does not require authentication. You only need a consumer credential — `Authorization: mol_<consumerId>_<secret>`, with **no `Bearer` prefix** — and no Service Token.

**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).

**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 the activity timeline for a specific project. This is a **public endpoint** - no authentication required.

Unfiltered, `nodes` is a `LabActivityNode` union that also includes `LabEventAnnouncement` entries. [Announcements are deprecated](/api-reference/changelog#announcements-are-deprecated), but labs created before the deprecation still carry them, so **pass `filter: FILE` if you want a file-only feed** and handle `__typename` defensively if you do not. `LabActivityFilter` accepts `FILE` and `ANNOUNCEMENT`.

> **🔓 Public Endpoint**: The `labActivity` query does not require authentication. You only need a consumer credential — `Authorization: mol_<consumerId>_<secret>`, with **no `Bearer` prefix** — and no Service Token.

**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
        }
      }
    }
  }
}
```

**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 LabEventFileAdded { entry { path contentType version accessLevel changeBy eventTime } } } } }",
    "variables": {
      "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
      "page": 0
    }
  }'
```

**Use Cases:**

* Project timelines showing what changed in a data room and when
* Download links for data-room files
* Encrypted file access (Onchain-Verified Envelope Encryption for new files)
* Projects with many file events (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: mol_<consumerId>_<secret>`, with **no `Bearer` prefix** — and no Service Token.

**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
        }
      }
    }
  }
}
```

> **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

Semantic search across labs' files and announcements. Public. A blank `prompt` fails with `VALIDATION_FAILED`, and an upstream outage fails with `UPSTREAM_UNAVAILABLE` or `TIMEOUT` rather than returning an empty page.

Returns [`SearchLabsResult!`](/api-reference/types#searchlabsresult).

| Name      | Type                                                          | Description                                                                                 |
| --------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `prompt`  | `String!`                                                     | Natural-language search text. Truncated to 2000 characters.                                 |
| `filters` | [`SearchLabsFilters`](/api-reference/types#searchlabsfilters) | Filters narrowing which labs, tags, categories, access levels and entry kinds are searched. |
| `page`    | `Int`                                                         | Page number, 0-indexed; negative or non-integer values fall back to 0.                      |
| `perPage` | `Int`                                                         | Hits per page, to a maximum of 100; values outside 1-100 and non-integers fall back to 10.  |

**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
          }
        }
      }
    }
    totalCount
    pageInfo {
      hasNextPage
      hasPreviousPage
      currentPage
      totalPages
    }
  }
}
```

`SearchLabsHit` is a union of `SearchLabsFileHit` **and** `SearchLabsAnnouncementHit`. The examples below match only the file arm; if you handle the union exhaustively, expect the announcement `__typename` too — [announcements are deprecated](/api-reference/changelog#announcements-are-deprecated) but pre-existing ones are still indexed and still returned.

**Example - Basic Search:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -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 } } } } 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' \
  -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

**JavaScript Example:**

```javascript
const searchResults = await fetch(apiUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": process.env.CONSUMER_CREDENTIAL,
  },
  body: JSON.stringify({
    query: `query SearchLabs($prompt: String!) {
      searchLabs(prompt: $prompt) {
        nodes {
          __typename
          ... on SearchLabsFileHit {
            entry {
              path
              file { description tags }
            }
          }
        }
        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, node.entry.file.description);
  }
});
```

***

## Onchain Activity

### Onchain Activity Feed

Per-transaction onchain activity for a lab or wallet, newest first; distinct from labActivity, the data room feed. Requires at least one of `oclId` or `wallet`, AND-ed when both are given, and fails with VALIDATION\_FAILED when both are missing or `oclId` is malformed.

Returns [`[OnChainEvent!]!`](/api-reference/types#onchainevent).

| Name     | Type     | Description                                                                                                                                                  |
| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `oclId`  | `String` | Restrict the feed to this lab. 32-byte OCL id of the lab, `0x` plus 64 hex, case-insensitive.                                                                |
| `wallet` | `String` | Restrict the feed to events involving this wallet address, case-insensitive. Covers role grants and revocations for the account and transfers to or from it. |
| `limit`  | `Int`    | Maximum transaction groups to return, 1 to 200. Values outside that range fall back to 50.                                                                   |
| `cursor` | `String` | `id` of the last entry of the previous page. A value missing either half, or with a non-integer block number, is ignored and the first page returned.        |

For the flat, one-row-per-event stream, use `rawOnChainActivity` (same filters and cursor semantics).

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

\* Provide at least one of `oclId` or `wallet`.

On a raw event, `contractName` is one of `accessresolver`, `ocl`, `ipnft`, `ipt` or `bio-agent`, and `args` is a JSON object of the decoded event arguments (BigInts as decimal strings, addresses lowercased).


# 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

The consumer credential is the same one every Molecule API uses — if you already have one, you are ready. To request one, use the [credential request template](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step) on our [Discord community](https://t.co/L0VEiy4Bjk), adding your expected tokenization volume.

You'll receive:

* **Consumer credential** (`mol_<consumerId>_<secret>`) for authentication
* **Technical Integration Guide** with complete code examples and ABI files (not yet published; requested with the credential)

### 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"
}
```

Generates the OCL membership agreement document for a Lab token and stores it in the public OCL agreements bucket. The returned `agreementKey` and `agreementContentHash` are the inputs to `getOclTermsMessage`. Fails with INVALID\_METADATA when `agreementData` is not valid JSON, INVALID\_INPUT when it fails validation or `oclId` is malformed, and INTERNAL\_ERROR (retryable) when generation or storage fails.

Returns [`GenerateOclMembershipAgreementResult!`](/api-reference/types#generateoclmembershipagreementresult).

| Name            | Type       | Description                                                                                                                                                                                |
| --------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `agreementData` | `AWSJSON!` | JSON object with `oclId` (32-byte OCL id as `0x` plus 64 hex, any case), `symbol` (ticker, 1-20 alphanumeric characters) and optionally `title` (display name). Any other key is rejected. |

**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**: the same credential every Molecule API uses — see [Getting Started](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step)
* **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 the `Authorization` header — the `mol_` credential goes in directly, with no `Bearer` prefix |
| 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

Contract addresses, ABIs and interfaces are published in the [Contracts reference](/references/contracts) — [`OclTokenizer`](/references/contracts/tokenizer), [`IPToken`](/references/contracts/ipt), [`AccessResolver`](/references/contracts/accessresolver) — so no request is needed for those. For the remaining narrative material (Safe multisig integration, worked retry logic), ask on the [Molecule Discord](https://t.co/L0VEiy4Bjk) using the [same request template](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step), naming the Technical Integration Guide instead of a credential.

### 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:

* **Smart contracts**: addresses, ABIs and interfaces are published in the [Contracts reference](/references/contracts) — no request needed
* **Consumer credential**: the [request template](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step) on Getting Started
* **Technical Integration Guide**: ask on Discord with that same template, naming the guide instead of a credential
* **Discord**: join our [community](https://t.co/L0VEiy4Bjk) for support

***

*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.

***

## Gateway base URLs

| Environment    | Base URL                                                         | Network                       | Asset                                                                                                    |
| -------------- | ---------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------- |
| **Staging**    | `https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod` | Base Sepolia (`eip155:84532`) | [USDC `0x036CbD…dCF7e`](https://sepolia.basescan.org/address/0x036CbD53842c5426634e7929541eC2318f3dCF7e) |
| **Production** | `https://0qb5gyw72f.execute-api.eu-central-2.amazonaws.com/prod` | Base (`eip155:8453`)          | [USDC `0x833589…02913`](https://basescan.org/address/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)         |

Endpoints are `POST {base}/x402/labs/{mutation}`. Note the `/prod` stage segment on the base URL: it is part of the path you call. (The `resource.url` echoed back inside the `402` challenge omits it — call the URL you built, not the one in the challenge.)

Testnet USDC for the staging gateway comes from the [Circle faucet](https://faucet.circle.com/) — select **Base Sepolia**. You also need a little Base Sepolia ETH for gas if you are doing anything onchain alongside; the payment itself is signed, not sent, so it costs the payer no gas.

Both base URLs are also what the [Molecule Skill](/ai-tooling/molecule-skill) plugin expects in `X402_GATEWAY_URL`.

***

## Endpoints

The gateway exposes one HTTP endpoint per allow-listed mutation. All endpoints accept `POST` with a JSON body containing a GraphQL mutation.

```
POST {base}/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/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                                                                                  |
| `/x402/labs/createAnnouncement`         | `createAnnouncement`         | **Deprecated** — announcements are no longer surfaced in the Molecule app. Still allow-listed and still charged; do not build on it |

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 responds `402` with the x402 payment requirements — network, asset, amount, and `payTo` address — as **base64-encoded JSON in the `payment-required` response header**. The response *body* is only `{"isSuccess":false,"message":"Payment required"}`; the requirements are not in it. See [Reading the 402 challenge](#reading-the-402-challenge).
2. **Sign** — The client signs an EIP-3009 `transferWithAuthorization` (or Permit2) for the quoted amount to the challenge's `payTo` on the quoted 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`).

***

## Reading the 402 challenge

The price is **quoted per request** — always read it from the challenge rather than hardcoding an amount.

**Step 1 — call without a payment header:**

```bash
curl -i -X POST \
  https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod/x402/labs/createLab \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } } }",
    "variables": { "oclId": "0x0101…" }
  }'
```

**Step 2 — read the `payment-required` header, not the body:**

```http
HTTP/2 402
content-type: application/json
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiw…

{"isSuccess":false,"message":"Payment required"}
```

Base64-decode the header:

```bash
curl -sD - -o /dev/null -X POST "$URL" -H 'Content-Type: application/json' -d "$BODY" \
  | grep -i '^payment-required:' | cut -d' ' -f2- | tr -d '\r' | base64 -d | jq
```

```json
{
  "x402Version": 2,
  "error": "Payment required",
  "resource": {
    "url": "https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/x402/labs/createLab",
    "description": "x402 payment for createLab",
    "mimeType": ""
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:84532",
      "amount": "10000",
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "payTo": "0xb016Eb733479874b67c6BeC2470e86a64b33AD76",
      "maxTimeoutSeconds": 300,
      "extra": { "name": "USDC", "version": "2" }
    }
  ]
}
```

`amount` is in the asset's smallest unit — USDC has 6 decimals, so `"10000"` is **$0.01**. That is the price on both staging and production today, for every allow-listed mutation. `extra.name` / `extra.version` are the EIP-712 domain values for the token's `transferWithAuthorization`.

**Step 3 — sign the authorization and retry.** Build an EIP-3009 `transferWithAuthorization` for `amount` to `payTo` on `network`, wrap it in the x402 payment payload, base64 it, and re-send the *same* request with the header:

```javascript
async function callX402(base, mutation, query, variables, wallet) {
  const url = `${base}/x402/labs/${mutation}`;
  const body = JSON.stringify({ query, variables });
  const init = { method: "POST", headers: { "content-type": "application/json" }, body };

  // 1. Challenge
  const challenge = await fetch(url, init);
  if (challenge.status !== 402) return challenge.json(); // already paid / different error

  const raw = challenge.headers.get("payment-required");
  if (!raw) throw new Error("402 without a payment-required header");
  const requirements = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
  const accepts = requirements.accepts[0];

  // 2. Sign for EXACTLY the quoted amount, asset, network and payTo.
  //    signX402Payment depends on your stack (viem, ethers, CDP SDK).
  const paymentHeader = await signX402Payment(accepts, wallet);

  // 3. Retry the same request with the payment attached
  const result = await fetch(url, {
    ...init,
    headers: { ...init.headers, "payment-signature": paymentHeader },
  });
  return result.json();
}
```

The signed payload is accepted under any of `Payment-Signature`, `X-Payment`, or `Payment`.

**Step 4 — read the result as an ordinary GraphQL response.** A `200` body is the AppSync response verbatim; success is `error == null`, exactly as on the Labs API.

{% hint style="warning" %}
**Authorization is still checked after payment, and settlement does not wait for the mutation to succeed.** Payment buys a short-lived service token for the payer wallet; it does not grant the payer a role. A `createLab` for an OCL the payer does not own, or a write into a lab the payer has no Contributor role on, comes back `200` with `error.code: "UNAUTHORIZED"` — and because settlement is triggered by the upstream `2xx`, you have paid for it. Validate the target lab and your role on it (`listLabMembers`) **before** signing.
{% endhint %}

***

## Request Format

```http
POST /x402/labs/createLab HTTP/1.1
content-type: application/json
payment-signature: <base64 x402 payment payload>

{
  "query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { message error { code message requestId retryable details } } }",
  "variables": {
    "oclId": "0x0101...abcd"
  },
  "operationName": "CreateLab"
}
```

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_LAB
X402_PRICE_<UPPER_MUTATION>          e.g. X402_PRICE_CREATELAB
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` challenge, so clients should always [read it from the challenge](#reading-the-402-challenge) rather than hardcoding amounts. As of 2026-08-27 every allow-listed mutation quotes **$0.01** on both staging and production.

| Variable                              | Default                                         | Purpose                                                  |
| ------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
| `X402_NETWORK`                        | `base` (prod) / `base-sepolia` (non-prod)       | CAIP-2 network (`base` → `eip155:8453`)                  |
| `X402_ASSET`                          | Base USDC (prod) / Base Sepolia USDC (non-prod) | Settlement asset — see [base URLs](#gateway-base-urls)   |
| `X402_PAY_TO_ADDRESS`                 | `0xb016Eb733479874b67c6BeC2470e86a64b33AD76`    | Wallet that receives settlement (echoed as `payTo`)      |
| `X402_FACILITATOR_URL`                | `https://api.cdp.coinbase.com/platform/v2/x402` | Facilitator base URL                                     |
| `X402_PRICE_*` / `X402_PRICE_DEFAULT` | `0.01`                                          | Per-mutation price in USDC (quoted in the 402 challenge) |
| `X402_TOKEN_TTL_SECONDS`              | `300`                                           | Lifetime of the minted service token                     |
| `X402_MAX_TIMEOUT_SECONDS`            | `60`                                            | Upstream request budget                                  |

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

The runnable helper is in [Reading the 402 challenge](#reading-the-402-challenge) above — the one detail agents get wrong is reading the requirements from the response *body* instead of the `payment-required` *header*.

Beyond that, three habits:

* **Read the price every time.** It is quoted per request and per mutation; nothing guarantees it stays at $0.01.
* **Check authorization before paying.** Confirm the lab exists and the payer wallet holds the role the mutation needs (`labWithDataRoomAndFiles`, `listLabMembers` — both public and free) before signing anything. Payment does not grant a role.
* **Treat a settlement failure as "not charged yet."** Re-sign rather than assuming the transfer went through — see [Idempotency](#idempotency).

If you would rather not implement the handshake at all, the [Molecule Skill](/ai-tooling/molecule-skill) plugin's `x402_pay` tool does the whole flow in one call. See the [Developers / AI Agents guide](/user-guides/developers-ai-agents) for broader integration patterns, and the [Labs API reference](/api-reference/labs-api) for the full GraphQL signatures of each gated mutation.

***

## Related

* [Getting Started](/api-reference/getting-started) — how to interact with our products, prerequisites, costs
* [Glossary](/references/glossary) — every Molecule term used in these docs, defined in a sentence
* [Molecule Skill](/ai-tooling/molecule-skill) — `x402_pay` does this handshake in one tool call
* [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/)


# API Types

Every type in the Molecule GraphQL API, generated from the schema.

Every object, input, enum and union the Molecule GraphQL API exposes, generated from the schema itself. An operation's own arguments are documented with that operation in the guide pages; the types those tables name are defined here.

## ActivitiesResult

Payload of `activities`. Failures are thrown as GraphQL errors, never encoded in this payload.

| Name         | Type                                      | Description                                                           |
| ------------ | ----------------------------------------- | --------------------------------------------------------------------- |
| `activities` | [`[LabActivityNode!]!`](#labactivitynode) | Activity entries of the requested page across all labs, newest first. |

## Agreement

Legal agreement document attached to an IP-NFT (e.g. the assignment agreement referenced from its metadata).

| Name          | Type      | Description                                                                                                                |
| ------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- |
| `id`          | `String!` | Random UUID, regenerated whenever the IP-NFT is re-indexed. Use `contentHash` as the stable identifier.                    |
| `contentHash` | `String!` | Hash of the agreement document's content, as recorded in the IP-NFT metadata.                                              |
| `mimeType`    | `String!` | MIME type recorded in the metadata; defaults to `application/json` when absent.                                            |
| `type`        | `String!` | Kind of agreement, as named in the IP-NFT metadata (e.g. the assignment agreement).                                        |
| `url`         | `String!` |                                                                                                                            |
| `ipnftId`     | `String!` |                                                                                                                            |
| `encryption`  | `AWSJSON` | Encryption details recorded for the document when it is encrypted, as an opaque JSON object; null for plaintext documents. |

## AgreementFilterBy

Exact-match filters for `agreements`. Every given field must match exactly (case-sensitive); fields are combined with AND. Timestamp fields are ISO-8601 strings.

| Name          | Type     | Description |
| ------------- | -------- | ----------- |
| `id`          | `String` |             |
| `contentHash` | `String` |             |
| `mimeType`    | `String` |             |
| `type`        | `String` |             |
| `url`         | `String` |             |
| `ipnftId`     | `String` |             |

## AgreementSortBy

* `id`
* `contentHash`
* `mimeType`
* `type`
* `url`
* `ipnftId`

## Announcement

Announcement posted to a lab's data room, with its attached files.

| Name          | Type                                | Description                                                                                                                                                                  |
| ------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lab`         | [`LabRef!`](#labref)                |                                                                                                                                                                              |
| `systemTime`  | `AWSDateTime!`                      | When the announcement was written by the data room backend.                                                                                                                  |
| `eventTime`   | `AWSDateTime!`                      | When the announcement was posted.                                                                                                                                            |
| `id`          | `String!`                           | Unique within the data room only, so key any cache on the lab as well.                                                                                                       |
| `headline`    | `String!`                           |                                                                                                                                                                              |
| `body`        | `String!`                           |                                                                                                                                                                              |
| `attachments` | [`[DataRoomFile!]!`](#dataroomfile) | Files attached to the announcement (at least one; announcements cannot be created without an attachment).                                                                    |
| `changeBy`    | `String!`                           | Identity of the member who posted the announcement, as `did:ethr:` plus the EIP-55 checksummed wallet address. Older records may hold a bare address or an unverified value. |

## ApiError

Standard error of the Molecule GraphQL API, returned inside a mutation's `*Result` envelope and thrown by queries as a GraphQL error with `code` in `errorType` and the rest in `errorInfo`. Null `error` means success. Some failures arrive instead as plain GraphQL errors with no `code`, which clients must handle too.

| Name        | Type       | Description                                                                                                                                                                       |
| ----------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`      | `String!`  | Stable machine-readable code from the error-code catalogue. The only field clients should branch on; treat an unknown code as a non-retryable failure.                            |
| `message`   | `String!`  | Human-readable explanation for developers; never empty. Not part of the contract, so do not parse or match on it.                                                                 |
| `requestId` | `String!`  | Correlation id for the request that failed. Include it in bug reports.                                                                                                            |
| `retryable` | `Boolean!` | Whether retrying the same request unchanged can plausibly succeed. Retry with exponential backoff.                                                                                |
| `details`   | `AWSJSON`  | Structured context for the failure. Keys: `field` (offending input), `reason` (second-level code), `hint` (next step), `docs` (URL); unknown keys may appear and must be ignored. |

## AssignmentAgreementType

Which assignment agreement was generated. Derived from the IPNFT id, never supplied by the caller.

| Value                 | Description                                                                      |
| --------------------- | -------------------------------------------------------------------------------- |
| `POI_ASSIGNMENT`      | Proof-of-Invention assignment, used when the IPNFT id exceeds the uint128 range. |
| `RESEARCH_ASSIGNMENT` | Research assignment, used when the IPNFT id is within the uint128 range.         |

## Chain

Blockchain on which IP-NFTs and IP Tokens are deployed.

| Name        | Type                    | Description                                                                                                                                                                                  |
| ----------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`        | `Int!`                  | Molecule's record id for the chain, and the value `Market.chainId` references.                                                                                                               |
| `createdAt` | `AWSDateTime!`          | When Molecule first indexed the chain.                                                                                                                                                       |
| `updatedAt` | `AWSDateTime!`          | When Molecule last refreshed the chain record.                                                                                                                                               |
| `name`      | `String!`               |                                                                                                                                                                                              |
| `chainId`   | `Int!`                  | EVM chain id (e.g. 1 for Ethereum mainnet, 8453 for Base).                                                                                                                                   |
| `logoUrl`   | `String!`               |                                                                                                                                                                                              |
| `markets`   | [`[Market!]!`](#market) | Markets on this chain; the arguments are accepted but ignored and the full relation is returned. Whether this field resolves data is unverified, so prefer `markets(filterBy: { chainId })`. |

**`markets` arguments**

| Name        | Type                                | Description                                |
| ----------- | ----------------------------------- | ------------------------------------------ |
| `limit`     | `Int`                               | Accepted but ignored on this nested field. |
| `skip`      | `Int`                               | Accepted but ignored on this nested field. |
| `sortBy`    | [`MarketSortBy`](#marketsortby)     | Accepted but ignored on this nested field. |
| `filterBy`  | [`MarketFilterBy`](#marketfilterby) | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder)           | Accepted but ignored on this nested field. |

## ChainFilterBy

Exact-match filters for `chains`. Every given field must match exactly (case-sensitive); fields are combined with AND. Timestamp fields are ISO-8601 strings.

| Name        | Type     | Description                                                     |
| ----------- | -------- | --------------------------------------------------------------- |
| `id`        | `Int`    |                                                                 |
| `createdAt` | `String` |                                                                 |
| `updatedAt` | `String` |                                                                 |
| `name`      | `String` |                                                                 |
| `chainId`   | `Int`    |                                                                 |
| `logoUrl`   | `String` |                                                                 |
| `market`    | `String` | Not usable: it does not name a `Chain` field, so it is ignored. |

## ChainSortBy

| Value       | Description                                                                                         |
| ----------- | --------------------------------------------------------------------------------------------------- |
| `id`        |                                                                                                     |
| `createdAt` |                                                                                                     |
| `updatedAt` |                                                                                                     |
| `name`      |                                                                                                     |
| `chainId`   |                                                                                                     |
| `logoUrl`   |                                                                                                     |
| `market`    | Not a usable sort key: it names no `Chain` field, so it is ignored and the order stays unspecified. |

## ConnectionPageInfo

Cursor-based (Relay-style) pagination info for connections. The page-numbered `PageInfo` serves `labs`, `searchLabs` and the activity feeds.

| Name              | Type       | Description                                                                                                            |
| ----------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| `hasNextPage`     | `Boolean!` | Whether more items exist after this page. When paginating backward it reflects whether a `before` cursor was supplied. |
| `hasPreviousPage` | `Boolean!` | Whether more items exist before this page. When paginating forward it reflects whether an `after` cursor was supplied. |
| `startCursor`     | `String`   | Cursor of the first edge; null on an empty page.                                                                       |
| `endCursor`       | `String`   | Cursor of the last edge; null on an empty page.                                                                        |

## CreateAnnouncementResult

Result of creating an announcement.

| Name      | Type                    | Description                                                        |
| --------- | ----------------------- | ------------------------------------------------------------------ |
| `message` | `String`                | Human-readable status message; mirrors `error.message` on failure. |
| `error`   | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.               |

## CreateLabInput

Input for creating a new lab. The onchain LabNft (oclId) must already exist; this mutation registers the lab and creates its data room.

| Name    | Type      | Description                                                    |
| ------- | --------- | -------------------------------------------------------------- |
| `oclId` | `String!` | Canonical 32-byte oclId (lowercase 0x-hex) of the onchain lab. |

## CreateLabResult

Result of creating a lab.

| Name      | Type                    | Description                                                 |
| --------- | ----------------------- | ----------------------------------------------------------- |
| `message` | `String!`               | Human-readable message; mirrors `error.message` on failure. |
| `error`   | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.        |
| `lab`     | [`LabRef`](#labref)     | Created lab details if successful (minimal fields only).    |

## DataRoom

Lab's data room: the container for its files and announcements.

| Name             | Type                               | Description                                                                                                             |
| ---------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `id`             | `ID!`                              |                                                                                                                         |
| `alias`          | `String!`                          |                                                                                                                         |
| `status`         | `String`                           | Free-text status reported by the backend, e.g. `In Progress` or `Completed`. The value set is not fixed and may change. |
| `telegramChatId` | `String`                           | Always null; no Telegram integration is served by this API.                                                             |
| `description`    | `String`                           |                                                                                                                         |
| `createdAt`      | `AWSDateTime!`                     |                                                                                                                         |
| `lastUpdatedAt`  | `AWSDateTime`                      |                                                                                                                         |
| `owner`          | [`DataRoomOwner!`](#dataroomowner) |                                                                                                                         |
| `keywords`       | `[String!]`                        | Empty array rather than null when the data room has no keywords.                                                        |
| `files`          | [`[DataRoomFile!]`](#dataroomfile) | Files in the data room. Populated by `labWithDataRoomAndFiles`; null when the files could not be listed.                |

## DataRoomAccessLevel

Who may read a data room file. The level is a label on the file; the restriction itself comes from encryption (see `ADMIN`).

| Value     | Description                                                                                                      |
| --------- | ---------------------------------------------------------------------------------------------------------------- |
| `PUBLIC`  | Anyone.                                                                                                          |
| `HOLDERS` | Intended for files readable by the lab's token holders, but neither assigned nor enforced today.                 |
| `ADMIN`   | Confidential, readable only by lab members. Enforced by envelope encryption, so the URL alone yields ciphertext. |

## DataRoomEntry

Data room file as it appeared in an activity-feed event: the version the event refers to, not necessarily the current one.

| Name                 | Type                                        | Description                                                                                                                                                                    |
| -------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ref`                | `String!`                                   | Stable reference (dataset id) of the file across versions. Use it with `finishCreateOrUpdateFile(ref)` to upload a new version and `updateFileMetadata(ref)` to edit metadata. |
| `path`               | `String!`                                   |                                                                                                                                                                                |
| `tags`               | `[String!]`                                 | Tags on this version. Null when none.                                                                                                                                          |
| `description`        | `String`                                    | Description of this version. Null when none.                                                                                                                                   |
| `version`            | `Int!`                                      | Version number of the file this event refers to (1 for the first upload).                                                                                                      |
| `accessLevel`        | `String!`                                   | Access level of this version: `PUBLIC`, `ADMIN` (confidential, encrypted; see `DataRoomAccessLevel`) or the unenforced `HOLDERS`.                                              |
| `eventTime`          | `AWSDateTime!`                              | When this version took effect.                                                                                                                                                 |
| `systemTime`         | `AWSDateTime!`                              | When this version was written by the data room backend.                                                                                                                        |
| `changeBy`           | `String!`                                   | Identity of the member who made the change, as `did:ethr:` plus the EIP-55 checksummed wallet address. Older records may hold a bare address or an unverified value.           |
| `categories`         | `[String!]`                                 | Categories of this version. Null when none.                                                                                                                                    |
| `contentType`        | `String!`                                   |                                                                                                                                                                                |
| `contentHash`        | `String!`                                   |                                                                                                                                                                                |
| `contentText`        | `String`                                    | Searchable text extracted from or supplied for this version. Null when none.                                                                                                   |
| `lab`                | [`LabRef!`](#labref)                        |                                                                                                                                                                                |
| `encryptionMetadata` | [`EncryptionMetadata`](#encryptionmetadata) | Encryption metadata when the file is encrypted (KMS, BLS or a legacy ciphertext); null for plaintext files.                                                                    |

## DataRoomFile

File in a lab's data room, with its current version's metadata and, when the caller may read it, a time-limited download URL.

| Name                 | Type                                           | Description                                                                                                                                                                |
| -------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | `ID!`                                          |                                                                                                                                                                            |
| `did`                | `String!`                                      |                                                                                                                                                                            |
| `path`               | `String!`                                      |                                                                                                                                                                            |
| `version`            | `Int`                                          | Version number of the file's current revision. Null when no version is reported.                                                                                           |
| `contentType`        | `String!`                                      | Defaults to `application/octet-stream` when the backend reports none.                                                                                                      |
| `accessLevel`        | [`DataRoomAccessLevel!`](#dataroomaccesslevel) | Access level of the file. `ADMIN` files are encrypted: read `encryptionMetadata` and unwrap the key with `decryptDataKey`.                                                 |
| `createdAt`          | `AWSDateTime!`                                 | When the file's data room entry took effect. On `dataRoomFile` it is the current version's time, the same as `updatedAt`.                                                  |
| `updatedAt`          | `AWSDateTime`                                  | When this version of the file took effect.                                                                                                                                 |
| `name`               | `String`                                       | Falls back through the dataset name, the file description, and finally the last path segment, so it may repeat `description`.                                              |
| `createdBy`          | `String`                                       | Identity of the member who last changed the file, not its original creator; the same value as `changeBy`. Older records may hold a bare address or an unverified value.    |
| `contentHash`        | `String`                                       |                                                                                                                                                                            |
| `downloadUrl`        | `String`                                       | Pre-signed URL that expires at `downloadUrlExpiry`. Send every entry of `downloadHeaders` with the request.                                                                |
| `downloadHeaders`    | [`[Header!]`](#header)                         | Headers that must be sent with the `downloadUrl` request; the download fails without them.                                                                                 |
| `downloadUrlExpiry`  | `AWSDateTime`                                  | When `downloadUrl` stops working. Request the file again for a fresh URL.                                                                                                  |
| `encryptionMetadata` | [`EncryptionMetadata`](#encryptionmetadata)    | Encryption metadata if the file is encrypted (KMS, BLS, or legacy ciphertext).                                                                                             |
| `description`        | `String`                                       |                                                                                                                                                                            |
| `tags`               | `[String!]`                                    | Empty array rather than null when the file has no tags.                                                                                                                    |
| `categories`         | `[String!]`                                    | Empty array rather than null when the file has no categories.                                                                                                              |
| `contentText`        | `String`                                       |                                                                                                                                                                            |
| `changeBy`           | `String`                                       | Identity of the member who last changed the file, as `did:ethr:` plus the EIP-55 checksummed wallet address. Older records may hold a bare address or an unverified value. |

## DataRoomFileSearchEntry

Lightweight data room entry for search results.

| Name         | Type                             | Description                                                           |
| ------------ | -------------------------------- | --------------------------------------------------------------------- |
| `lab`        | [`LabRef!`](#labref)             | Lab the file belongs to (search-result hydration tier; see `LabRef`). |
| `path`       | `String!`                        | Path of the file within the data room.                                |
| `ref`        | `String!`                        | Stable reference (dataset id) of the file across versions.            |
| `systemTime` | `AWSDateTime!`                   | When the matching version was written by the data room backend.       |
| `eventTime`  | `AWSDateTime!`                   | When the matching version took effect.                                |
| `file`       | [`DataRoomFile!`](#dataroomfile) | File's current metadata.                                              |

## DataRoomOwner

Owner of a data room. Data room IDs follow the format `<contract_address>_<token_id>` (e.g. `0xcaD88677CA87a7815728C72D74B4ff4982d54Fc1_9`), which keeps them globally unique across contracts.

| Name          | Type      | Description                                     |
| ------------- | --------- | ----------------------------------------------- |
| `id`          | `ID!`     |                                                 |
| `accountName` | `String!` |                                                 |
| `displayName` | `String!` | Display name reported by the data room backend. |

## DecryptDataKeyResult

Result of decrypting a data encryption key.

| Name           | Type                    | Description                                                                              |
| -------------- | ----------------------- | ---------------------------------------------------------------------------------------- |
| `plaintextDEK` | `String`                | Base64-encoded data encryption key for decrypting the file client-side. Null on failure. |
| `iv`           | `String`                | Base64-encoded initialization vector stored with the file. Null on failure.              |
| `message`      | `String`                | Human-readable status message; mirrors `error.message` on failure.                       |
| `error`        | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                                     |

## DeleteDataRoomFileResult

Result type for file deletion operations.

| Name       | Type                    | Description                                          |
| ---------- | ----------------------- | ---------------------------------------------------- |
| `oclId`    | `String`                |                                                      |
| `filePath` | `String`                |                                                      |
| `error`    | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed. |

## DidLinkingStatus

States of the background DID-linking state machine.

| Value       | Description                                                        |
| ----------- | ------------------------------------------------------------------ |
| `PENDING`   | Linking is queued or being prepared.                               |
| `SUBMITTED` | User operation has been submitted onchain and awaits confirmation. |
| `LINKED`    | Both DIDs are linked onchain; terminal.                            |
| `FAILED`    | Most recent attempt failed; the worker may retry.                  |

## DidLinkStatus

Snapshot of DID-linking state for an OCL. `status` is null until the first linking attempt reaches PENDING, and `linkedDidCount` counts the DIDs recorded as active onchain, so a count with a non-terminal `status` means the terminal transition has not landed yet.

| Name             | Type                                    | Description                                                                                                     |
| ---------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `oclId`          | `String!`                               | 32-byte 0x-hex OCL id of the lab.                                                                               |
| `status`         | [`DidLinkingStatus`](#didlinkingstatus) | Current state of the linking state machine; null before the first attempt.                                      |
| `userOpHash`     | `String`                                | Hash of the user operation submitted for the most recent attempt; null until one has been submitted.            |
| `txHash`         | `String`                                | Hash of the transaction that included the user operation; null until it was mined.                              |
| `accountDid`     | `String`                                | DID of the lab's smart account (the ERC-6551 account); null until known.                                        |
| `dataRoomDid`    | `String`                                | DID of the lab's data room; null until known.                                                                   |
| `linkedDidCount` | `Int!`                                  | Number of DIDs recorded as linked onchain for this lab (2 when both the account and data room DIDs are linked). |
| `attempts`       | `Int!`                                  | Number of linking attempts made so far (0 before the first).                                                    |
| `updatedAt`      | `AWSDateTime`                           | When the linking state last changed; null before the first attempt.                                             |

## DidLinkStatusResult

Payload of the public getDidLinkStatus query. Failures are thrown as GraphQL errors, never encoded in this payload.

| Name            | Type                              | Description                                                                                                                                                                             |
| --------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message`       | `String`                          | Human-readable status message.                                                                                                                                                          |
| `didLinkStatus` | [`DidLinkStatus`](#didlinkstatus) | DID-linking snapshot for the lab. Always present for a well-formed `oclId`, with an unknown lab or one having no linking record reporting null status, hashes and DIDs and zero counts. |

## EncryptionMetadata

Encryption metadata for encrypted files. Supports multiple encryption systems: - Legacy (pre-cutover ciphertexts): identified by absent/null encryptionSystem - KMS envelope encryption: encryptionSystem = "kms" - BLS threshold encryption: encryptionSystem = "bls" (future).

| Name                      | Type           | Description                                                                                                                                                                                            |
| ------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `encryptionSystem`        | `String`       | Encryption system identifier, one of "kms", "bls", or null. Null or absent indicates a legacy ciphertext written before the onchain-verified envelope cutover.                                         |
| `accessControlConditions` | `AWSJSON!`     | Access control conditions the file was encrypted with, shaped as documented on `EncryptionMetadataInput.accessControlConditions`. `decryptDataKey` evaluates them onchain against the caller's wallet. |
| `encryptedBy`             | `String!`      | Wallet address that performed the encryption.                                                                                                                                                          |
| `encryptedAt`             | `AWSDateTime!` | ISO 8601 timestamp when encryption was performed.                                                                                                                                                      |
| `encryptedDek`            | `String`       | KMS/BLS: Base64-encoded encrypted data encryption key.                                                                                                                                                 |
| `iv`                      | `String`       | KMS/BLS: Base64-encoded initialization vector used for AES-GCM encryption.                                                                                                                             |
| `contentHash`             | `String`       | KMS/BLS: Hash of the encrypted content for integrity verification.                                                                                                                                     |
| `keyId`                   | `String`       | BLS: Key identifier for the BLS key used.                                                                                                                                                              |
| `dataToEncryptHash`       | `String`       | Legacy: Hash of the original plaintext data from the legacy encryption client.                                                                                                                         |
| `chain`                   | `String`       | Legacy: Blockchain network the legacy conditions were authored against (e.g. 'ethereum', 'base').                                                                                                      |
| `litSdkVersion`           | `String`       | Legacy: SDK version that produced the legacy ciphertext.                                                                                                                                               |
| `litNetwork`              | `String`       | Legacy: Network identifier from the legacy client.                                                                                                                                                     |
| `templateName`            | `String`       | Legacy: Template name used for access control.                                                                                                                                                         |
| `contractVersion`         | `String`       | Legacy: Contract version for access control.                                                                                                                                                           |

## EncryptionMetadataInput

Encryption metadata stored with an encrypted (`ADMIN`) file, passed to `finishCreateOrUpdateFile` after the ciphertext upload. `kms` also requires `encryptedDek`, `iv` and `contentHash`, `bls` adds `keyId`, and omitting `encryptionSystem` requires every `Legacy:` field. A missing field fails with `INTERNAL_ERROR`.

| Name                      | Type      | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `encryptionSystem`        | `String`  | Encryption system identifier: "kms", "bls", or null/absent for a legacy ciphertext written before the onchain-verified envelope cutover.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `accessControlConditions` | `String!` | JSON-encoded array of access control conditions that `decryptDataKey` evaluates onchain, left to right, against the caller's wallet before releasing the key. Elements alternate between conditions and boolean operators `{ "operator": "and" \| "or" }`. A condition is either `{ "conditionType": "evmContract", "chain", "contractAddress", "functionName", "functionParams", "functionAbi", "returnValueTest": { "key", "comparator", "value" } }` or `{ "conditionType": "evmBasic", "chain", "contractAddress", "method", "parameters", "returnValueTest" }`. The literal `":userAddress"` in `functionParams` / `parameters` is replaced by the caller's wallet at evaluation time. Accepted `chain` values: "ethereum", "eth", "base", "sepolia", "sepolia-testnet", "sepolia-base", "baseSepolia". The Molecule app makes a confidential file readable by all lab members with two conditions joined by "or", both on the lab's AccessResolver contract: `hasRole(oclId, ":userAddress", "1")` = true (viewer or above) and `isAuthorizedSignerForTba(":userAddress", labAccountAddress)` = true (the LabNft owner). Evaluation fails closed. |
| `encryptedBy`             | `String!` | Address that performed the encryption.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `encryptedAt`             | `String!` | ISO 8601 timestamp when the file was encrypted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `encryptedDek`            | `String`  | KMS/BLS: Base64-encoded wrapped key, as returned by `generateDataEncryptionKey`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `iv`                      | `String`  | KMS/BLS: Base64-encoded initialization vector.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `contentHash`             | `String`  | KMS/BLS: Hash of the encrypted content.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `keyId`                   | `String`  | BLS: Key identifier.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `dataToEncryptHash`       | `String`  | Legacy: Hash of the data to be encrypted, recorded by the legacy client.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `chain`                   | `String`  | Legacy: Blockchain network used for access control by the legacy client.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `litSdkVersion`           | `String`  | Legacy: SDK version that produced the legacy ciphertext.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `litNetwork`              | `String`  | Legacy: Network identifier from the legacy client (e.g., datil-test, habanero).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `templateName`            | `String`  | Legacy: Template name for the access control pattern.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `contractVersion`         | `String`  | Legacy: Version of the encryption contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

## EvmTokenizationError

Failure detail of a tokenization-service operation, present exactly when the result's `isSuccess` is false. Codes: INVALID\_INPUT, INVALID\_METADATA, MISSING\_METADATA\_FIELD, METADATA\_UPLOAD\_FAILED, IMAGE\_UPLOAD\_FAILED, UNSUPPORTED\_IMAGE\_TYPE, INVALID\_TERMS\_SIGNATURE, SIGNOFF\_FAILED, TERMS\_MESSAGE\_FAILED, INTERNAL\_ERROR.

| Name        | Type       | Description                                                                                                                                                    |
| ----------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message`   | `String`   | Human-readable explanation for developers. Not part of the contract, so do not parse or match on it.                                                           |
| `code`      | `String`   | Machine-readable failure code, from the set listed on `EvmTokenizationError`.                                                                                  |
| `retryable` | `Boolean!` | Whether retrying the same request unchanged can plausibly succeed. True only for transient upload, storage or signing failures, never for validation failures. |
| `details`   | `AWSJSON`  | Structured context. Currently always null for this service.                                                                                                    |

## FileCategoriesAndTagsResult

Payload of `fileCategoriesAndTags`. Failures are thrown as GraphQL errors, never encoded in this payload.

| Name   | Type                               | Description                         |
| ------ | ---------------------------------- | ----------------------------------- |
| `data` | [`[FileCategory!]`](#filecategory) | Every valid category with its tags. |

## FileCategory

File category and tags valid within it, as configured by Molecule.

| Name   | Type         | Description |
| ------ | ------------ | ----------- |
| `name` | `String!`    |             |
| `tags` | `[String!]!` |             |

## FinishFileUploadResult

Result of finishing a file upload.

| Name          | Type                    | Description                                                                                                                            |
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `datasetId`   | `ID`                    | Dataset id of the file; its stable `ref` for later versions and metadata updates. Null on failure.                                     |
| `contentHash` | `String`                | Hash of the stored file content. Null on failure.                                                                                      |
| `version`     | `Int`                   | Version number of the file after this upload (1 for a new file). Null on failure.                                                      |
| `newHead`     | `String`                | Head hash of the data room after the commit, usable as `expectedHead` for optimistic concurrency in later operations. Null on failure. |
| `message`     | `String`                | Human-readable status message; mirrors `error.message` on failure.                                                                     |
| `error`       | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                                                                                   |

## GenerateAssignmentAgreementResult

Result of generateAssignmentAgreement. Success when `isSuccess` is true.

| Name                   | Type                                                  | Description                                                                                                                                                               |
| ---------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agreementCid`         | `String`                                              | IPFS CID of the generated agreement document. Null on failure.                                                                                                            |
| `agreementUrl`         | `String`                                              | HTTPS gateway URL of the agreement document. Null on failure.                                                                                                             |
| `agreementContentHash` | `String`                                              | SHA-256 of the agreement document as 64 lowercase hex characters without a `0x` prefix, referenced as an agreement `content_hash` in the IPNFT metadata. Null on failure. |
| `agreementUri`         | `String`                                              | IPFS URI of the agreement (`ipfs://<agreementCid>`). Null on failure.                                                                                                     |
| `agreementType`        | [`AssignmentAgreementType`](#assignmentagreementtype) | Which agreement was generated, derived from the IPNFT id. Null on failure.                                                                                                |
| `generatedAt`          | `AWSDateTime`                                         | When the agreement document was generated. Null on failure.                                                                                                               |
| `isSuccess`            | `Boolean!`                                            | True when the agreement was generated and stored.                                                                                                                         |
| `error`                | [`EvmTokenizationError`](#evmtokenizationerror)       | Null on success. Non-null means the mutation failed.                                                                                                                      |

## GenerateDataEncryptionKeyResult

Result of generating a standalone data encryption key.

| Name               | Type                    | Description                                                                             |
| ------------------ | ----------------------- | --------------------------------------------------------------------------------------- |
| `plaintextDEK`     | `String`                | Base64-encoded data encryption key for encrypting content client-side. Null on failure. |
| `encryptedDek`     | `String`                | Base64-encoded wrapped key to store alongside the ciphertext. Null on failure.          |
| `encryptionSystem` | `String`                | Encryption system that produced the key; always `kms`. Null on failure.                 |
| `error`            | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                                    |

## GenerateIptMembershipAgreementResult

Result of generateIptMembershipAgreement. Success when `isSuccess` is true.

| Name                   | Type                                            | Description                                                                                                 |
| ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `agreementCid`         | `String`                                        | IPFS CID of the generated agreement document. Null on failure.                                              |
| `agreementUrl`         | `String`                                        | HTTPS gateway URL of the agreement document. Null on failure.                                               |
| `agreementContentHash` | `String`                                        | SHA-256 of the agreement document as 64 lowercase hex characters without a `0x` prefix. Null on failure.    |
| `agreementUri`         | `String`                                        | IPFS URI of the agreement (`ipfs://<agreementCid>`); pass its CID to `getIptTermsMessage`. Null on failure. |
| `agreementType`        | [`IptAgreementType`](#iptagreementtype)         | Always IPT\_MEMBERSHIP on success. Null on failure.                                                         |
| `generatedAt`          | `AWSDateTime`                                   | When the agreement document was generated. Null on failure.                                                 |
| `isSuccess`            | `Boolean!`                                      | True when the agreement was generated and stored.                                                           |
| `error`                | [`EvmTokenizationError`](#evmtokenizationerror) | Null on success. Non-null means the mutation failed.                                                        |

## GenerateLabImageUploadUrlResult

Result of `generateLabImageUploadUrl`. The presigned PUT URL is single-use and expires per `expiresAt`, and the lab's `image` is updated asynchronously once the uploaded object has been processed.

| Name        | Type                    | Description                                                                                                                     |
| ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `uploadUrl` | `String`                | Pre-signed HTTPS URL for a single PUT of the image, sent with the same Content-Type the URL was generated for. Null on failure. |
| `key`       | `String`                | Storage key of the image (`<oclId>/<uuid>.<extension>`). Null on failure.                                                       |
| `expiresAt` | `AWSDateTime`           | When `uploadUrl` stops accepting uploads (15 minutes after issue). Null on failure.                                             |
| `error`     | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                                                                            |

## GenerateOclMembershipAgreementResult

Result of generateOclMembershipAgreement. The document is stored in the public OCL agreements bucket rather than on IPFS, so it is addressed by a storage key and URL instead of a CID. Success when `isSuccess` is true.

| Name                   | Type                                            | Description                                                                                                                                                                           |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agreementKey`         | `String`                                        | Storage key of the agreement document, of the form `<oclId>/agreements/<uuid>.json`, to pass to `getOclTermsMessage`. Null on failure.                                                |
| `agreementUrl`         | `String`                                        | Public HTTPS URL of the agreement document: the agreements base URL followed by `agreementKey`. Null on failure.                                                                      |
| `agreementContentHash` | `String`                                        | SHA-256 of the agreement document as `0x` plus 64 lowercase hex characters, the onchain `contentHash` format. Deterministic for a given oclId, symbol and title, and null on failure. |
| `agreementType`        | [`OclAgreementType`](#oclagreementtype)         | Always OCL\_MEMBERSHIP on success. Null on failure.                                                                                                                                   |
| `generatedAt`          | `AWSDateTime`                                   | When this response was produced, which is informational only and not part of the deterministic stored document. Null on failure.                                                      |
| `isSuccess`            | `Boolean!`                                      | True when the agreement was generated and stored.                                                                                                                                     |
| `error`                | [`EvmTokenizationError`](#evmtokenizationerror) | Null on success. Non-null means the mutation failed.                                                                                                                                  |

## GeneratePresignedUploadUrlResult

Result of generateImageUploadUrl. Success when `isSuccess` is true.

| Name        | Type                                            | Description                                                                                                                                                |
| ----------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadUrl` | `String`                                        | Pre-signed HTTPS URL for a single PUT of the image; the request's Content-Type must equal the `contentType` the URL was generated for. Null on failure.    |
| `key`       | `String`                                        | Storage key of the image (`ipnft-<ipnftId>/<filename>`), to pass as `imageKey` to `uploadMetadataWithImageKey` once the upload completes. Null on failure. |
| `expiresAt` | `AWSDateTime`                                   | When `uploadUrl` stops accepting uploads. Null on failure.                                                                                                 |
| `isSuccess` | `Boolean!`                                      | True when the URL was generated.                                                                                                                           |
| `error`     | [`EvmTokenizationError`](#evmtokenizationerror) | Null on success. Non-null means the mutation failed.                                                                                                       |

## GetTermsMessageResult

Terms message to be signed by a wallet, shared by the IPNFT, IPT and OCL terms queries. Success when `isSuccess` is true.

| Name        | Type                                            | Description                                                                           |
| ----------- | ----------------------------------------------- | ------------------------------------------------------------------------------------- |
| `message`   | `String`                                        | Exact text the wallet must sign (`personal_sign` / EIP-191). Empty string on failure. |
| `digest`    | `String`                                        | keccak256 of `message` as `0x`-hex. Empty string on failure.                          |
| `isSuccess` | `Boolean!`                                      | True when the message was produced.                                                   |
| `error`     | [`EvmTokenizationError`](#evmtokenizationerror) | Null on success. Non-null means the query failed.                                     |

## Header

One HTTP header to send with a file download or upload request.

| Name    | Type      | Description |
| ------- | --------- | ----------- |
| `key`   | `String!` |             |
| `value` | `String!` |             |

## InitiateFileUploadResult

Result of initiating a file upload.

| Name               | Type                    | Description                                                                                                               |
| ------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `datasetId`        | `ID`                    | Dataset id of the file. Currently always null at this step: the id is assigned when `finishCreateOrUpdateFile` completes. |
| `uploadToken`      | `String`                | Opaque token identifying this upload; pass it to `finishCreateOrUpdateFile`. Null on failure.                             |
| `uploadUrl`        | `String`                | Pre-signed URL to send the file bytes to. Null on failure.                                                                |
| `uploadUrlExpiry`  | `AWSDateTime`           | When `uploadUrl` stops accepting uploads. Currently always null; the URL is short-lived, so upload promptly.              |
| `method`           | `String`                | HTTP method to use against `uploadUrl` (normally "PUT"). Null on failure.                                                 |
| `headers`          | [`[Header!]`](#header)  | Headers that must be sent with the upload request. Null on failure.                                                       |
| `expectedHeadHash` | `String`                | Currently always null; not used by the upload protocol.                                                                   |
| `useMultipart`     | `Boolean`               | Whether the upload must be performed as a multipart upload. Null on failure.                                              |
| `error`            | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                                                                      |

## IntFilter

Positive integer filter requiring exactly one of `eq` or `in`. Invalid or empty operators fail with `VALIDATION_FAILED`.

| Name | Type     | Description                                            |
| ---- | -------- | ------------------------------------------------------ |
| `eq` | `Int`    | Exact match on a positive chain id.                    |
| `in` | `[Int!]` | Set of matching positive chain ids, with 1-100 values. |

## IPNFT

Onchain token representing the legal rights to a research project's IP and data. The project fields are a read-only snapshot of the metadata the token was minted with, not editable through this API.

| Name                        | Type                             | Description                                                                                                                                                                                   |
| --------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                        | `String!`                        | Onchain token id as a decimal string (e.g. `37`).                                                                                                                                             |
| `createdAt`                 | `AWSDateTime!`                   | When Molecule first indexed the IP-NFT.                                                                                                                                                       |
| `updatedAt`                 | `AWSDateTime!`                   | When Molecule last refreshed the record.                                                                                                                                                      |
| `mintedAt`                  | `AWSDateTime`                    | When the token was minted. Null when not yet known.                                                                                                                                           |
| `chainId`                   | `Int!`                           | EVM chain id the token lives on.                                                                                                                                                              |
| `owner`                     | [`User!`](#user)                 | Current owner.                                                                                                                                                                                |
| `userId`                    | `String!`                        | Id of the current owner (`owner.id`).                                                                                                                                                         |
| `originalOwner`             | `String!`                        | Wallet address of the original minter.                                                                                                                                                        |
| `ipt`                       | [`IPT`](#ipt)                    | **Deprecated.** Use `Lab.tokens`. Removed after 2027-01-01. IP Token minted against this IP-NFT, if it has been tokenized.                                                                    |
| `tokenUri`                  | `String!`                        | URI of the token metadata document, typically an `ipfs://` URI.                                                                                                                               |
| `symbol`                    | `String!`                        | Ticker taken from the mint event; `initialSymbol` is what the metadata proposed.                                                                                                              |
| `name`                      | `String!`                        |                                                                                                                                                                                               |
| `image`                     | `String!`                        |                                                                                                                                                                                               |
| `description`               | `String!`                        |                                                                                                                                                                                               |
| `externalUrl`               | `String!`                        |                                                                                                                                                                                               |
| `initialSymbol`             | `String!`                        | Symbol proposed at minting.                                                                                                                                                                   |
| `organization`              | `String!`                        |                                                                                                                                                                                               |
| `topic`                     | `String!`                        |                                                                                                                                                                                               |
| `trlValue`                  | `String`                         | Technology Readiness Level of the project, assessed by Molecule with AI assistance rather than derived onchain. Null when no assessment exists, and the rest of the IP-NFT is still returned. |
| `trlRationale`              | `String`                         | Human-readable explanation of `trlValue`. Null when no assessment exists.                                                                                                                     |
| `fundingAmountCurrency`     | `String!`                        | Funding currency code (e.g. "USD").                                                                                                                                                           |
| `fundingAmountValue`        | `String!`                        | Funding amount as an integer in the currency's smallest unit, as a decimal string (divide by 10^`fundingAmountDecimals`).                                                                     |
| `fundingAmountDecimals`     | `Int!`                           | Number of decimals of `fundingAmountValue`.                                                                                                                                                   |
| `fundingAmountCurrencyType` | `String!`                        | Kind of currency code in `fundingAmountCurrency` (e.g. "ISO4217").                                                                                                                            |
| `researchLead`              | [`ResearchLead!`](#researchlead) | Project's research lead.                                                                                                                                                                      |
| `researchLeadId`            | `String!`                        | Email address of the research lead, which is also `ResearchLead.id`.                                                                                                                          |
| `agreements`                | [`[Agreement!]!`](#agreement)    | Legal agreement documents attached to the IP-NFT. The arguments are accepted but ignored: the full list is returned.                                                                          |
| `schemaVersion`             | `String!`                        | Version of the metadata schema the token was minted with (e.g. "1.0.0").                                                                                                                      |
| `oclId`                     | `String`                         | Canonical 32-byte oclId (lowercase 0x-hex) of the lab associated with this IPNFT, if one exists. Null when the IPNFT has no linked lab.                                                       |

**`agreements` arguments**

| Name        | Type                                      | Description                                |
| ----------- | ----------------------------------------- | ------------------------------------------ |
| `limit`     | `Int`                                     | Accepted but ignored on this nested field. |
| `skip`      | `Int`                                     | Accepted but ignored on this nested field. |
| `sortBy`    | [`AgreementSortBy`](#agreementsortby)     | Accepted but ignored on this nested field. |
| `filterBy`  | [`AgreementFilterBy`](#agreementfilterby) | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder)                 | Accepted but ignored on this nested field. |

## IPNFTFilterBy

Exact-match filters for `ipnfts`. Every given field must match exactly (case-sensitive); fields are combined with AND. Nested filters (`owner`, `researchLead`) match on the related record's fields, and timestamp fields are ISO-8601 strings.

| Name                        | Type                                            | Description                                                                                                                     |
| --------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `id`                        | `String`                                        |                                                                                                                                 |
| `createdAt`                 | `String`                                        |                                                                                                                                 |
| `updatedAt`                 | `String`                                        |                                                                                                                                 |
| `chainId`                   | `Int`                                           |                                                                                                                                 |
| `owner`                     | [`UserFilterBy`](#userfilterby)                 | Match on the current owner's fields, e.g. `{ address }`.                                                                        |
| `userId`                    | `String`                                        |                                                                                                                                 |
| `originalOwner`             | `String`                                        | Match the stored casing exactly.                                                                                                |
| `ipt`                       | `String`                                        | Not usable: `ipt` is a relation, not a text field; a value here makes the request fail.                                         |
| `tokenUri`                  | `String`                                        |                                                                                                                                 |
| `symbol`                    | `String`                                        |                                                                                                                                 |
| `name`                      | `String`                                        |                                                                                                                                 |
| `image`                     | `String`                                        |                                                                                                                                 |
| `description`               | `String`                                        |                                                                                                                                 |
| `externalUrl`               | `String`                                        |                                                                                                                                 |
| `initialSymbol`             | `String`                                        |                                                                                                                                 |
| `organization`              | `String`                                        |                                                                                                                                 |
| `topic`                     | `String`                                        |                                                                                                                                 |
| `fundingAmountCurrency`     | `String`                                        |                                                                                                                                 |
| `fundingAmountValue`        | `String`                                        | Compared as a decimal string, not numerically.                                                                                  |
| `fundingAmountDecimals`     | `Int`                                           |                                                                                                                                 |
| `fundingAmountCurrencyType` | `String`                                        |                                                                                                                                 |
| `researchLead`              | [`ResearchLeadFilterBy`](#researchleadfilterby) | Match on the research lead's fields, e.g. `{ email }`.                                                                          |
| `researchLeadId`            | `String`                                        |                                                                                                                                 |
| `agreements`                | [`AgreementFilterBy`](#agreementfilterby)       | Not usable: `agreements` is a list relation, which this exact-match filter cannot express; a value here makes the request fail. |
| `schemaVersion`             | `String`                                        |                                                                                                                                 |

## IPNFTSortBy

| Value                       | Description                                                                                   |
| --------------------------- | --------------------------------------------------------------------------------------------- |
| `id`                        | Lexicographic, not numeric: `10` sorts before `9`.                                            |
| `createdAt`                 |                                                                                               |
| `updatedAt`                 |                                                                                               |
| `mintedAt`                  |                                                                                               |
| `chainId`                   |                                                                                               |
| `owner`                     | Not a usable sort key; the request fails with `INTERNAL_ERROR`. Use `userId` instead.         |
| `userId`                    |                                                                                               |
| `originalOwner`             |                                                                                               |
| `ipt`                       | Not a usable sort key; the request fails with `INTERNAL_ERROR`.                               |
| `tokenUri`                  |                                                                                               |
| `symbol`                    |                                                                                               |
| `name`                      |                                                                                               |
| `image`                     |                                                                                               |
| `description`               |                                                                                               |
| `externalUrl`               |                                                                                               |
| `initialSymbol`             |                                                                                               |
| `organization`              |                                                                                               |
| `topic`                     |                                                                                               |
| `fundingAmountCurrency`     |                                                                                               |
| `fundingAmountValue`        | Numeric, on the stored integer; `fundingAmountDecimals` varies, so the order is not monetary. |
| `fundingAmountDecimals`     |                                                                                               |
| `fundingAmountCurrencyType` |                                                                                               |
| `researchLead`              | Not a usable sort key; the request fails with `INTERNAL_ERROR`. Use `researchLeadId` instead. |
| `researchLeadId`            |                                                                                               |
| `agreements`                | Not a usable sort key; the request fails with `INTERNAL_ERROR`.                               |
| `schemaVersion`             |                                                                                               |

## IPT

IP Token (IPT): the ERC-20 token issued against an IP-NFT, whose holders are governed by the token's membership agreement.

| Name                | Type                    | Description                                                                                                                                                                                 |
| ------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                | `String!`               | ERC-20 contract address, not a numeric id. For a token issued against an IP-NFT this is its L1 contract; a token with no IP-NFT is identified by its own contract address.                  |
| `createdAt`         | `AWSDateTime!`          | When Molecule first indexed the token.                                                                                                                                                      |
| `updatedAt`         | `AWSDateTime!`          | When Molecule last refreshed the record.                                                                                                                                                    |
| `ipnft`             | [`IPNFT`](#ipnft)       | IP-NFT the token was issued against. Null for tokens with no IP-NFT.                                                                                                                        |
| `ipnftId`           | `String`                | Id of the parent IP-NFT (`IPNFT.id`). Null for tokens with no IP-NFT.                                                                                                                       |
| `l2TokenAddress`    | `String`                | Deterministic ERC-20 address on the default L2, derived at tokenization from the L1 contract, name, symbol and decimals. Equal to `id` when there is no IP-NFT, and not a sign of bridging. |
| `holderCount`       | `Int`                   | Null until the holder indexer has run for this token.                                                                                                                                       |
| `mintedAt`          | `AWSDateTime`           | Null until an indexer backfills it; it is not set at tokenization.                                                                                                                          |
| `markets`           | [`[Market!]!`](#market) | Markets trading this token. The arguments are accepted but ignored: the full list is returned.                                                                                              |
| `name`              | `String!`               |                                                                                                                                                                                             |
| `symbol`            | `String!`               |                                                                                                                                                                                             |
| `decimals`          | `Int!`                  | Always 18 for a token issued against an IP-NFT; a token with no IP-NFT reports its own value.                                                                                               |
| `agreementCid`      | `String`                | IPFS CID of the token's membership agreement. Null when none.                                                                                                                               |
| `agreementMimeType` | `String!`               | MIME type of the membership agreement (e.g. "application/json").                                                                                                                            |
| `originalOwner`     | [`User!`](#user)        |                                                                                                                                                                                             |
| `originalOwnerId`   | `String!`               | Wallet address of the original owner, which is also `User.id`.                                                                                                                              |
| `image`             | `String!`               | Inherited from the parent IP-NFT image. For a token with no IP-NFT it is that token's own logo, or an empty string when it has none.                                                        |
| `links`             | `[String!]!`            | Related links (URLs). The arguments are accepted but ignored: the full list is returned.                                                                                                    |
| `capped`            | `Boolean!`              | Whether token issuance is capped.                                                                                                                                                           |
| `circulatingSupply` | `String`                | Circulating supply in the token's smallest unit, as a decimal string. Null when not known.                                                                                                  |
| `totalIssued`       | `String!`               | Total issued supply in the token's smallest unit, as a decimal string.                                                                                                                      |

**`markets` arguments**

| Name        | Type                                | Description                                |
| ----------- | ----------------------------------- | ------------------------------------------ |
| `limit`     | `Int`                               | Accepted but ignored on this nested field. |
| `skip`      | `Int`                               | Accepted but ignored on this nested field. |
| `sortBy`    | [`MarketSortBy`](#marketsortby)     | Accepted but ignored on this nested field. |
| `filterBy`  | [`MarketFilterBy`](#marketfilterby) | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder)           | Accepted but ignored on this nested field. |

**`links` arguments**

| Name        | Type                      | Description                                |
| ----------- | ------------------------- | ------------------------------------------ |
| `limit`     | `Int`                     | Accepted but ignored on this nested field. |
| `skip`      | `Int`                     | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder) | Accepted but ignored on this nested field. |

## IptAgreementType

Kind of IPT (IP Token) agreement.

| Value            | Description                             |
| ---------------- | --------------------------------------- |
| `IPT_MEMBERSHIP` | Membership terms an IPT holder accepts. |

## IPTFilterBy

Exact-match filters for `ipts`. Every given field must match exactly (case-sensitive); fields are combined with AND. Nested filters (`ipnft`, `originalOwner`) match on the related record's fields, and timestamp fields are ISO-8601 strings.

| Name                | Type                              | Description                                                                                                       |
| ------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `id`                | `String`                          |                                                                                                                   |
| `createdAt`         | `String`                          |                                                                                                                   |
| `updatedAt`         | `String`                          |                                                                                                                   |
| `ipnft`             | [`IPNFTFilterBy`](#ipnftfilterby) | Match on the parent IP-NFT's fields, e.g. `{ topic }`.                                                            |
| `ipnftId`           | `String`                          |                                                                                                                   |
| `l2TokenAddress`    | `String`                          | Match the stored casing exactly.                                                                                  |
| `holderCount`       | `String`                          | Not usable: the holder count is numeric and this filter is text; a value here makes the request fail.             |
| `markets`           | `String`                          | Not usable: `markets` is a relation, not a text field; a value here makes the request fail.                       |
| `name`              | `String`                          |                                                                                                                   |
| `symbol`            | `String`                          |                                                                                                                   |
| `decimals`          | `Int`                             |                                                                                                                   |
| `agreementCid`      | `String`                          |                                                                                                                   |
| `agreementMimeType` | `String`                          |                                                                                                                   |
| `originalOwner`     | [`UserFilterBy`](#userfilterby)   | Match on the original owner's fields, e.g. `{ address }`.                                                         |
| `originalOwnerId`   | `String`                          |                                                                                                                   |
| `image`             | `String`                          |                                                                                                                   |
| `links`             | `String`                          | Not usable: `links` is a list, which this exact-match filter cannot express; a value here makes the request fail. |
| `capped`            | `String`                          | Not usable: `capped` is a boolean and this filter is text; a value here makes the request fail.                   |
| `circulatingSupply` | `String`                          | Compared as a decimal string, not numerically.                                                                    |
| `totalIssued`       | `String`                          | Compared as a decimal string, not numerically.                                                                    |

## IPTSortBy

| Value               | Description                                                                                    |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `id`                |                                                                                                |
| `createdAt`         |                                                                                                |
| `updatedAt`         |                                                                                                |
| `ipnft`             | Not a usable sort key; the request fails with `INTERNAL_ERROR`. Use `ipnftId` instead.         |
| `ipnftId`           |                                                                                                |
| `l2TokenAddress`    |                                                                                                |
| `holderCount`       |                                                                                                |
| `markets`           | Not a usable sort key; the request fails with `INTERNAL_ERROR`.                                |
| `name`              |                                                                                                |
| `symbol`            |                                                                                                |
| `decimals`          |                                                                                                |
| `agreementCid`      |                                                                                                |
| `agreementMimeType` |                                                                                                |
| `originalOwner`     | Not a usable sort key; the request fails with `INTERNAL_ERROR`. Use `originalOwnerId` instead. |
| `originalOwnerId`   |                                                                                                |
| `image`             |                                                                                                |
| `links`             | Not a usable sort key (a list); the request fails with `INTERNAL_ERROR`.                       |
| `capped`            |                                                                                                |
| `circulatingSupply` | Lexicographic, not numeric: the supply is stored as a decimal string.                          |
| `totalIssued`       | Lexicographic, not numeric: the total is stored as a decimal string.                           |

## Lab

Single onchain lab with its data room, as returned by `labWithDataRoomAndFiles`. Lightweight references to labs elsewhere use `LabRef`.

| Name                   | Type                                                        | Description                                                                                                                                                                                              |
| ---------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `systemTime`           | `AWSDateTime!`                                              | When the lab record was last written by the data room backend. Reflects backend processing (including migrations), not the lab's creation; use `mintedAt` for that.                                      |
| `eventTime`            | `AWSDateTime!`                                              | When the lab record's current state took effect in the data room backend.                                                                                                                                |
| `mintedAt`             | `AWSDateTime`                                               | LabNft mint block timestamp from the OclIdentityCreated event.                                                                                                                                           |
| `oclId`                | `String!`                                                   | Canonical 32-byte oclId (lowercase 0x-hex); primary identifier.                                                                                                                                          |
| `shortname`            | `String`                                                    | Human-readable slug used as the URL segment (e.g. "vita-fast"), derived from `name` and re-derived on rename, at which point the previous slug stops resolving. Persist `oclId`, not the slug.           |
| `labAccountAddress`    | `String!`                                                   | ERC-6551 token-bound smart-account address for this lab; the deterministic account address tied to the LabNft `tokenId`.                                                                                 |
| `labNftTokenId`        | `String!`                                                   | LabNft tokenId as a decimal string. Replaces the legacy `ipnftTokenId`.                                                                                                                                  |
| `account`              | [`LabAccount!`](#labaccount)                                | Owning account of this lab's data room.                                                                                                                                                                  |
| `dataRoom`             | [`DataRoom!`](#dataroom)                                    | Lab's data room: metadata plus, when `files` is selected, its files.                                                                                                                                     |
| `ipnft`                | [`IPNFT`](#ipnft)                                           | Legacy IPNFT this lab was migrated from, fully hydrated (owner, researchLead, agreements, ipt) for the single-lab query. Null when the lab has no linked IPNFT.                                          |
| `announcements`        | [`[Announcement!]!`](#announcement)                         | Not served by this API: nothing populates it, so selecting it nulls the whole `Lab` (the field is non-null). Use `labActivity(oclId, filter: ANNOUNCEMENT)` for a lab's announcements instead.           |
| `activity`             | [`LabActivityNode!`](#labactivitynode)                      | Not served by this API: nothing populates it, so selecting it nulls the whole `Lab` (the field is non-null). Use the top-level `labActivity(oclId)` query instead.                                       |
| `name`                 | `String`                                                    | LabNft display name. Defaults to "Lab #" until the owner sets a custom value via `updateLabNftMetadata`.                                                                                                 |
| `description`          | `String`                                                    | Owner-editable description (see `updateLabNftMetadata`). Null when unset.                                                                                                                                |
| `image`                | `String`                                                    | LabNft image URL; owner-editable. Null until the owner sets a custom image.                                                                                                                              |
| `externalUrl`          | `String`                                                    | External link surfaced by the LabNft tokenURI metadata.                                                                                                                                                  |
| `hasDataRoom`          | `Boolean!`                                                  | Always true on this type, which resolves only labs whose data room exists. Use the `lab` query to tell a lab still being set up from one that is ready.                                                  |
| `members`              | [`[LabMember!]`](#labmember)                                | Active members of the lab, the same data as the top-level `listLabMembers` query and public with no authentication required. Null only on lookup failure.                                                |
| `tokens`               | [`TokenConnection`](#tokenconnection)                       | Tokens linked to this lab, using `tokens` pagination. Invalid arguments, including `filter.oclId`, fail with `VALIDATION_FAILED`.                                                                        |
| `legalAgreementStatus` | [`LegalAgreementStatusResult`](#legalagreementstatusresult) | Signed status of a legal agreement for this lab, inlined so a client can fetch it per lab instead of calling `legalAgreementStatus` N times. Public, and null only when the lab cannot be resolved.      |
| `trlValue`             | `String`                                                    | Technology Readiness Level of the lab, from Molecule's AI-assisted assessment rather than onchain. Null when no assessment exists.                                                                       |
| `trlRationale`         | `String`                                                    | Human-readable explanation for the assigned `trlValue`. Null when no assessment exists.                                                                                                                  |
| `trlLastUpdated`       | `AWSDateTime`                                               | When `trlValue` last changed. Null when no assessment exists.                                                                                                                                            |
| `weightedScore`        | `Float`                                                     | Weighted project score from Molecule's AI-assisted assessment, summing all weighted criterion scores, where 5.0 is best and 1.0 worst. Not derived onchain, and null when no assessment exists.          |
| `scoreInterpretation`  | `String`                                                    | Human-readable summary of the overall assessment behind `weightedScore`. Null when no assessment exists.                                                                                                 |
| `criterionScores`      | `[AWSJSON!]`                                                | Per-criterion breakdown behind `weightedScore`, each entry shaped like `{ "criterion": String, "score": Number }` with further keys possible over time. Null when no assessment exists.                  |
| `scoredAt`             | `AWSDateTime`                                               | When the scoring behind `weightedScore` was last computed. Null when no assessment exists.                                                                                                               |
| `todos`                | `[AWSJSON!]`                                                | Action items for the lab from Molecule's assessment (AI-assisted), each a JSON object shaped like `{ "todo": String, "completed": Boolean }`; further keys may be added over time. Null when none exist. |
| `isVerified`           | `Boolean`                                                   | Whether Molecule has verified this lab. Null when no verification decision has been recorded.                                                                                                            |
| `websiteUrl`           | `AWSURL`                                                    | Lab website URL; null when unset or metadata is unavailable.                                                                                                                                             |
| `xUrl`                 | `AWSURL`                                                    | Lab X (Twitter) profile URL; null when unset or metadata is unavailable.                                                                                                                                 |
| `telegramUrl`          | `AWSURL`                                                    | Lab Telegram group or channel URL; null when unset or metadata is unavailable.                                                                                                                           |
| `governanceUrl`        | `AWSURL`                                                    | Lab governance forum or voting URL; null when unset or metadata is unavailable.                                                                                                                          |
| `projectFormat`        | `String`                                                    | Project format slug; active choices are in `LabTaxonomyResult.projectFormats`, but stored retired slugs remain readable. Null when unset or metadata is unavailable.                                     |
| `therapeuticFields`    | `[String!]`                                                 | Therapeutic-field slugs in their saved order; active choices are in `LabTaxonomyResult.therapeuticFields`. Empty when unset, null when metadata is unavailable.                                          |
| `isRwaEnabled`         | `Boolean`                                                   | Whether a `LOCKED` token is linked to this lab or a locked token's `LOCKS` relation targets one of its tokens; derived on each read. Null when the lab's metadata could not be read, never false.        |

**`activity` arguments**

| Name      | Type                                                        | Description                                      |
| --------- | ----------------------------------------------------------- | ------------------------------------------------ |
| `page`    | `Int`                                                       | Accepted but ignored; see the field description. |
| `perPage` | `Int`                                                       | Accepted but ignored; see the field description. |
| `filters` | [`MoleculeLabActivityFilters`](#moleculelabactivityfilters) | Accepted but ignored; see the field description. |

**`tokens` arguments**

| Name      | Type                                     | Description                                                             |
| --------- | ---------------------------------------- | ----------------------------------------------------------------------- |
| `first`   | `Int`                                    | Forward page size, from 1-100; mutually exclusive with `last`.          |
| `after`   | `String`                                 | Opaque cursor after which to continue; requires `first`.                |
| `last`    | `Int`                                    | Backward page size, from 1-100; mutually exclusive with `first`.        |
| `before`  | `String`                                 | Opaque cursor before which to continue; requires `last`.                |
| `filter`  | [`TokenFilter`](#tokenfilter)            | Typed filters, combined with AND; `oclId` is implied by the parent lab. |
| `orderBy` | [`[TokenOrderInput!]`](#tokenorderinput) | Up to 3 ordering keys. Default `CREATED_AT DESC`.                       |

**`legalAgreementStatus` arguments**

| Name   | Type                                         | Description                         |
| ------ | -------------------------------------------- | ----------------------------------- |
| `type` | [`LegalAgreementType!`](#legalagreementtype) | Which legal agreement to report on. |

## LabAccount

Owning account of a lab's data room in the data room backend, one per lab. Not an identifier for the lab itself: use `oclId` or `shortname`.

| Name          | Type      | Description                                              |
| ------------- | --------- | -------------------------------------------------------- |
| `accountName` | `String!` | Empty string when the backend reports no owning account. |

## LabActivityFilter

Kind of activity-feed entry. Omit the filter to receive every kind.

| Value          | Description                                 |
| -------------- | ------------------------------------------- |
| `ANNOUNCEMENT` | Announcements only.                         |
| `FILE`         | File events only (added, updated, removed). |

## LabActivityNode

One activity-feed entry. Select `__typename` to tell file events from announcements.

One of [`LabEventFileUpdated`](#labeventfileupdated), [`LabEventFileRemoved`](#labeventfileremoved), [`LabEventFileAdded`](#labeventfileadded), [`LabEventAnnouncement`](#labeventannouncement).

## LabActivityResult

Payload of `labActivity`. Failures are thrown as GraphQL errors, never encoded in this payload.

| Name       | Type                                      | Description                                           |
| ---------- | ----------------------------------------- | ----------------------------------------------------- |
| `nodes`    | [`[LabActivityNode!]!`](#labactivitynode) | Activity entries of the requested page, newest first. |
| `pageInfo` | [`PageInfo!`](#pageinfo)                  | Pagination information for the requested page.        |

## LabEventAnnouncement

Announcement was posted to a lab.

| Name           | Type                             | Description                         |
| -------------- | -------------------------------- | ----------------------------------- |
| `lab`          | [`LabRef!`](#labref)             | Lab the announcement was posted to. |
| `announcement` | [`Announcement!`](#announcement) |                                     |

## LabEventFileAdded

New file was added to a data room.

| Name    | Type                               | Description                  |
| ------- | ---------------------------------- | ---------------------------- |
| `lab`   | [`LabRef!`](#labref)               | Lab whose data room changed. |
| `entry` | [`DataRoomEntry!`](#dataroomentry) |                              |

## LabEventFileRemoved

Data room file was removed.

| Name    | Type                               | Description                           |
| ------- | ---------------------------------- | ------------------------------------- |
| `lab`   | [`LabRef!`](#labref)               | Lab whose data room changed.          |
| `entry` | [`DataRoomEntry!`](#dataroomentry) | Removed file, as of its last version. |

## LabEventFileUpdated

New version of an existing data room file was uploaded.

| Name    | Type                               | Description                  |
| ------- | ---------------------------------- | ---------------------------- |
| `lab`   | [`LabRef!`](#labref)               | Lab whose data room changed. |
| `entry` | [`DataRoomEntry!`](#dataroomentry) |                              |

## LabFilter

Filter for `labsConnection`. Populated fields combine with AND. Validation is strict: malformed values are VALIDATION\_FAILED, never silently ignored.

| Name          | Type                                          | Description                                                                                                                    |
| ------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `name`        | [`StringFilter`](#stringfilter)               | Match on the lab display name.                                                                                                 |
| `shortname`   | [`StringFilter`](#stringfilter)               | Match on the lab shortname.                                                                                                    |
| `oclIds`      | `[String!]`                                   | Batch lookup: labs whose oclId is in this set (1-100 ids).                                                                     |
| `ipnftIds`    | `[String!]`                                   | Batch lookup: labs whose linked IPNFT id is in this set (1-100 ids).                                                           |
| `isVerified`  | `Boolean`                                     | Molecule's verification decision. Labs with no recorded decision count as unverified, so `isVerified: false` matches them too. |
| `hasIpnft`    | `Boolean`                                     | True selects labs with a linked IPNFT, false selects labs without one, and omitting it returns all.                            |
| `hasDataRoom` | `Boolean`                                     | True selects labs whose data room exists, false selects minted labs without one, and omitting it returns all.                  |
| `trlValue`    | [`TrlValueFilter`](#trlvaluefilter)           | Minimum Technology Readiness Level; see `TrlValueFilter`.                                                                      |
| `member`      | [`LabMembershipFilter`](#labmembershipfilter) | Wallet address; selects labs where that wallet holds an active role.                                                           |

## LabMember

Single lab member entry, derived from the indexed onchain role state.

| Name            | Type                                   | Description                                                                                                                                          |
| --------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `walletAddress` | `String!`                              | Lowercased wallet address of the member.                                                                                                             |
| `role`          | [`LabMemberRole!`](#labmemberrole)     | Effective role on the lab.                                                                                                                           |
| `source`        | [`LabMemberSource!`](#labmembersource) | Source row that authoritatively defines this membership.                                                                                             |
| `expiry`        | `String`                               | Unix-seconds expiry as a decimal string, encoded as a string because BigInt unix-seconds is unsafe as a JSON Int. Null means the grant is permanent. |
| `isAgent`       | `Boolean!`                             | True if the member is an agent identity (separate from human auth, surfaced for UI but not used for authorization).                                  |
| `grantedAt`     | `String!`                              | ISO-8601 timestamp the row was first persisted.                                                                                                      |

## LabMemberRole

Onchain role of a lab member. Roles are granted and revoked onchain through the lab's AccessResolver contract (`grantRole(oclId, account, role, expiry, isAgent)`, role 1 = VIEWER, 2 = CONTRIBUTOR; OWNER is whoever holds the LabNft). This API reflects those grants and cannot change them.

| Value         | Description                                                                                      |
| ------------- | ------------------------------------------------------------------------------------------------ |
| `OWNER`       | Holds the lab's LabNft; full control of the lab and its data room.                               |
| `CONTRIBUTOR` | May write to the data room (upload files, post announcements).                                   |
| `VIEWER`      | Read-only membership: may read the data room, including confidential files, but not write to it. |

## LabMembershipFilter

Membership predicate: labs where the wallet holds an active role. Applied in SQL before pagination, so `totalCount` and cursors reflect the filtered set.

| Name            | Type                              | Description                                                        |
| --------------- | --------------------------------- | ------------------------------------------------------------------ |
| `walletAddress` | `String!`                         | EVM wallet address of the member; any checksum casing is accepted. |
| `role`          | [`LabMemberRole`](#labmemberrole) | Restrict to one role; omit to accept any active role.              |

## LabMemberSource

Where a membership record came from. `ONCHAIN_EVENT` and `MULTISIG_RESOLUTION` derive from canonical owner state, `ACCESS_CONTRACT` is the legacy V2 IPNFT-auth contract, and `ACCESS_RESOLVER_EVENT` records stream from AccessResolver V3 role events and may have an expiry.

| Value                   | Description                                                                                            |
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `ONCHAIN_EVENT`         | Derived from the LabNft ownership events onchain.                                                      |
| `MULTISIG_RESOLUTION`   | Derived by resolving a multisig owner to its signers.                                                  |
| `ACCESS_CONTRACT`       | Granted through the legacy V2 IPNFT access contract.                                                   |
| `ACCESS_RESOLVER_EVENT` | Granted by an AccessResolver V3 RoleGranted event; the only source whose grants can carry an `expiry`. |

## LabOrderField

Sort key for `labsConnection`. The server always appends the lab's unique `oclId` as a final tiebreaker in the same direction as the primary key, so the total order, and therefore every cursor, is stable.

| Value                    | Description                                                                                                          |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `MINTED_AT`              | LabNft mint block timestamp (`mintedAt`). Labs whose mint time is not yet recorded sort last.                        |
| `LATEST_CONTRIBUTION_AT` | Time of the lab's most recent data room activity (`latestContributionAt`). Labs with no recorded activity sort last. |
| `NAME`                   | Lab display name (`name`), in the server's string collation order. Labs without a name sort last.                    |
| `TRL_VALUE`              | Numeric rank of `trlValue`. Labs with no assessment or a `pre-trl-*` value sort last in either direction.            |

## LabOrderInput

One ordering key for `labsConnection`.

| Name        | Type                                 | Description                                                                      |
| ----------- | ------------------------------------ | -------------------------------------------------------------------------------- |
| `field`     | [`LabOrderField!`](#laborderfield)   | Lab attribute to order by.                                                       |
| `direction` | [`OrderDirection!`](#orderdirection) | Direction for this key; the server applies it to the `oclId` tiebreaker as well. |

## LabRef

Lightweight lab reference, returned wherever a lab is referenced rather than fully expanded. How much is hydrated depends on the query, so each field documents where it is null. Beyond the identifier fields a null can mean the source was briefly unavailable, or a value served from a mirror that lags by a few hours.

| Name                   | Type                                                        | Description                                                                                                                                                                                                                                          |
| ---------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oclId`                | `String!`                                                   | Canonical 32-byte oclId (lowercase 0x-hex); primary identifier.                                                                                                                                                                                      |
| `mintedAt`             | `AWSDateTime`                                               | LabNft mint block timestamp from the OclIdentityCreated event.                                                                                                                                                                                       |
| `shortname`            | `String`                                                    | Human-readable slug (see `Lab.shortname`), seeded as "lab-" at index time and re-derived on rename. Null on `searchLabs` and for unbackfilled legacy labs; replaces the legacy token `symbol`.                                                       |
| `ipnftId`              | `String`                                                    | Linked legacy IPNFT tokenId. Null on `searchLabs` and when no IPNFT is linked; `ipnft` carries the full object on the queries that hydrate it.                                                                                                       |
| `hasDataRoom`          | `Boolean`                                                   | Whether the lab's data room exists; it must be created before files or announcements can be added. Null on `searchLabs`, which does not mean false.                                                                                                  |
| `ipnft`                | [`IPNFT`](#ipnft)                                           | Legacy IPNFT this lab was migrated from, hydrated when selected. Null when no IPNFT is linked, and on the activity feeds and `searchLabs`, which expose `ipnftId` for a follow-up lookup.                                                            |
| `labAccountAddress`    | `String!`                                                   | ERC-6551 token-bound smart-account address for this lab; deterministic from the LabNft tokenId via the OnChainLabFactory. Replaces the misleadingly-named `ipnftAddress`.                                                                            |
| `labNftTokenId`        | `String!`                                                   | LabNft tokenId as a decimal string. Replaces the legacy `ipnftTokenId`.                                                                                                                                                                              |
| `latestContributionAt` | `AWSDateTime`                                               | Timestamp of the lab's latest data room activity, lagging a few hours on `labsConnection` and `lab`. Null on `labs` unless selected, on other queries, when there is none, or on lookup failure.                                                     |
| `name`                 | `String`                                                    | LabNft display name (defaults to "Lab #"). Null on `searchLabs`.                                                                                                                                                                                     |
| `description`          | `String`                                                    | Owner-editable description (see `updateLabNftMetadata`). Null when unset and on `searchLabs`.                                                                                                                                                        |
| `image`                | `String`                                                    | LabNft image URL, null until the owner sets a custom image. Null on `searchLabs`.                                                                                                                                                                    |
| `externalUrl`          | `String`                                                    | External link surfaced by the LabNft tokenURI metadata. Null on `searchLabs`.                                                                                                                                                                        |
| `members`              | [`[LabMember!]`](#labmember)                                | Active members of the lab, mirroring the public `listLabMembers` query. Null only on lookup failure.                                                                                                                                                 |
| `legalAgreementStatus` | [`LegalAgreementStatusResult`](#legalagreementstatusresult) | Signed status of a legal agreement for this lab, inlined so a client can fetch it per lab instead of calling `legalAgreementStatus` N times. Public, and null only when the lab cannot be resolved.                                                  |
| `trlValue`             | `String`                                                    | Technology Readiness Level from Molecule's AI-assisted assessment rather than onchain. Null when no assessment exists, and on the activity feeds and `searchLabs`.                                                                                   |
| `trlRationale`         | `String`                                                    | Human-readable explanation for the assigned `trlValue`. Null when no assessment exists, and on the activity feeds and `searchLabs`.                                                                                                                  |
| `trlLastUpdated`       | `AWSDateTime`                                               | When `trlValue` last changed. Null when no assessment exists, and on the activity feeds and `searchLabs`.                                                                                                                                            |
| `isVerified`           | `Boolean`                                                   | Whether Molecule has verified this lab. Null when no verification decision has been recorded, and on the activity feeds and `searchLabs`.                                                                                                            |
| `weightedScore`        | `Float`                                                     | Weighted score from Molecule's assessment, summing all weighted criterion scores, where 5.0 is best and 1.0 worst. Null when no assessment exists, and on the activity feeds and `searchLabs`.                                                       |
| `scoreInterpretation`  | `String`                                                    | Human-readable summary of the overall assessment behind `weightedScore`. Null when no assessment exists, and on the activity feeds and `searchLabs`.                                                                                                 |
| `criterionScores`      | `[AWSJSON!]`                                                | Per-criterion breakdown behind `weightedScore`, each entry shaped like `{ "criterion": String, "score": Number }` with further keys possible over time. Null when no assessment exists, and on the activity feeds and `searchLabs`.                  |
| `scoredAt`             | `AWSDateTime`                                               | When the scoring behind `weightedScore` was last computed. Null when no assessment exists, and on the activity feeds and `searchLabs`.                                                                                                               |
| `todos`                | `[AWSJSON!]`                                                | Action items for the lab from Molecule's assessment (AI-assisted), each a JSON object shaped like `{ "todo": String, "completed": Boolean }`; further keys may be added over time. Null when none exist, and on the activity feeds and `searchLabs`. |
| `websiteUrl`           | `AWSURL`                                                    | Lab website URL; null when unset or not hydrated, including on `searchLabs`.                                                                                                                                                                         |
| `xUrl`                 | `AWSURL`                                                    | Lab X (Twitter) profile URL; null when unset or not hydrated, including on `searchLabs`.                                                                                                                                                             |
| `telegramUrl`          | `AWSURL`                                                    | Lab Telegram group or channel URL; null when unset or not hydrated, including on `searchLabs`.                                                                                                                                                       |
| `governanceUrl`        | `AWSURL`                                                    | Lab governance forum or voting URL; null when unset or not hydrated, including on `searchLabs`.                                                                                                                                                      |
| `projectFormat`        | `String`                                                    | Project format slug, including stored retired slugs; active choices are in `LabTaxonomyResult.projectFormats`. Null when unset or not hydrated, including on `searchLabs`.                                                                           |
| `therapeuticFields`    | `[String!]`                                                 | Therapeutic-field slugs in their saved order; active choices are in `LabTaxonomyResult.therapeuticFields`. Empty when unset, null when not hydrated, including on `searchLabs`.                                                                      |
| `isRwaEnabled`         | `Boolean`                                                   | Whether the lab is RWA-enabled, as defined by `Lab.isRwaEnabled`. Null when metadata is not hydrated, including on `searchLabs`.                                                                                                                     |

**`legalAgreementStatus` arguments**

| Name   | Type                                         | Description                         |
| ------ | -------------------------------------------- | ----------------------------------- |
| `type` | [`LegalAgreementType!`](#legalagreementtype) | Which legal agreement to report on. |

## LabRefConnection

Relay-style connection over labs.

| Name         | Type                                         | Description                                                                                                                                                  |
| ------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `edges`      | [`[LabRefEdge!]!`](#labrefedge)              | Page's labs, each with its cursor, in the requested order.                                                                                                   |
| `nodes`      | [`[LabRef!]!`](#labref)                      | Convenience list of nodes (same order as edges), for callers that don't need per-item cursors.                                                               |
| `pageInfo`   | [`ConnectionPageInfo!`](#connectionpageinfo) | Cursors and has-more flags for continuing from this page.                                                                                                    |
| `totalCount` | `Int`                                        | Total labs matching the filter, ignoring pagination. Computed only when selected and the most expensive part of the query, so omit it when it is not needed. |

## LabRefEdge

Edge in the labs connection: one lab plus its opaque position cursor.

| Name     | Type                 | Description                                                                                                                                                                                |
| -------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `node`   | [`LabRef!`](#labref) | Lab at this position.                                                                                                                                                                      |
| `cursor` | `String!`            | Opaque cursor for this edge, to pass as `after` or `before` to continue from here. Never construct or parse one: the format is not part of the API contract and may change without notice. |

## LabsResult

Result type for paginated labs queries.

| Name         | Type                     | Description                                                                                                                                                                                           |
| ------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nodes`      | [`[LabRef!]!`](#labref)  | Labs on the current page. An empty list means the page is genuinely empty and never stands in for a failure, which fails the request with `UPSTREAM_UNAVAILABLE` or `TIMEOUT` instead.                |
| `totalCount` | `Int!`                   | Labs matching the query, ignoring pagination; `0` means nothing matched, never a failure. On the `walletAddress` form, labs whose identifier cannot be read are excluded from this count and `nodes`. |
| `pageInfo`   | [`PageInfo!`](#pageinfo) |                                                                                                                                                                                                       |

## LabTaxonomyResult

Active lab taxonomy terms in display order, for classification through `updateLabNftMetadata`.

| Name                | Type                                      | Description                                                       |
| ------------------- | ----------------------------------------- | ----------------------------------------------------------------- |
| `projectFormats`    | [`[LabTaxonomyTerm!]!`](#labtaxonomyterm) | Choices for `Lab.projectFormat`; each lab may select one.         |
| `therapeuticFields` | [`[LabTaxonomyTerm!]!`](#labtaxonomyterm) | Choices for `Lab.therapeuticFields`; each lab may select several. |

## LabTaxonomyTerm

Lab classification term with a stable slug and display title.

| Name    | Type      | Description                                                                                |
| ------- | --------- | ------------------------------------------------------------------------------------------ |
| `slug`  | `String!` | Stable identifier of lowercase letters, digits and single hyphens, such as `brain-health`. |
| `title` | `String!` |                                                                                            |

## LegalAgreementStatusResult

Status of a legal agreement for a lab. Queried through `legalAgreementStatus` a failure is thrown and `error` is always null; selected as a field on a lab, an upstream failure degrades in band instead of nulling the parent. Non-null `error` means the status is undetermined and the booleans are placeholders.

| Name                     | Type                                                             | Description                                                                                                                                                            |
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `signed`                 | `Boolean!`                                                       | Whether any template version of this agreement type has been signed for this lab. Always present, but a placeholder rather than a verdict when `error` is non-null.    |
| `isCurrentVersionSigned` | `Boolean!`                                                       | Whether the current template version has been signed, which is what clients route the signing flow on. A placeholder when `error` is non-null, so check `error` first. |
| `currentTemplateVersion` | `String`                                                         | Version of the agreement template a signer would be asked to sign today (e.g. "1.0.0"). Compare against `signedVersions[].templateVersion`.                            |
| `signedVersions`         | [`[SignedLegalAgreementVersion!]`](#signedlegalagreementversion) | Every template version signed for this lab, oldest first, and empty when nothing has been signed. Only authoritative when `error` is null.                             |
| `error`                  | [`ApiError`](#apierror)                                          | Field-surface degraded signal (see type docstring). Null on success.                                                                                                   |

## LegalAgreementTemplateResult

Payload of `legalAgreementTemplate`. Failures are thrown as GraphQL errors, never encoded in this payload.

| Name              | Type                                        | Description                                                                                                                                                                        |
| ----------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agreement`       | `AWSJSON`                                   | Populated agreement JSON, for display only. Clients must not re-serialize or re-hash it, and should sign over `contentHash` as given.                                              |
| `contentHash`     | `String`                                    | keccak256 (0x-prefixed) of the canonical JSON of `agreement`. This is the `contentHash` field of the EIP-712 LegalAgreementAcceptance payload (see AA-1).                          |
| `templateVersion` | `String`                                    | Version of the agreement template the document was populated from (the current version for this agreement type, e.g. "1.0.0").                                                     |
| `agreementType`   | [`LegalAgreementType`](#legalagreementtype) | Agreement type the document was populated for; echoes the `type` argument.                                                                                                         |
| `issuedAt`        | `AWSTimestamp`                              | Unix epoch seconds at generation. Clients MUST echo this verbatim into both the EIP-712 payload and the signLegalAgreement mutation; the backend regenerates the document from it. |

## LegalAgreementType

Legal agreements a lab owner can sign through this API. Distinct from the IPNFT-side `Agreement` type and `agreement(s)` queries, which describe the documents attached to an IP-NFT's metadata.

| Value                  | Description                                                |
| ---------------------- | ---------------------------------------------------------- |
| `ASSIGNMENT_AGREEMENT` | IP assignment agreement between the lab owner and the lab. |

## LinkTokenInput

Token to link to a lab. New tokens require a name and symbol, supplied here or read from the contract; unreadable metadata fails with `VALIDATION_FAILED`, reason `TOKEN_METADATA_UNREADABLE`. Tracked tokens retain their stored metadata.

| Name        | Type                                           | Description                                                                                                                                                 |
| ----------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oclId`     | `String!`                                      | Lab id as 32-byte 0x-hex; the caller must own this lab.                                                                                                     |
| `chainId`   | `Int!`                                         | Indexed chain id; others fail with `VALIDATION_FAILED`.                                                                                                     |
| `address`   | `String!`                                      | Contract address, accepting any checksum casing; stored lowercase.                                                                                          |
| `kind`      | [`TokenKind!`](#tokenkind)                     | Classification to assert: `EXTERNAL` or `BRIDGED` for new tokens. Existing kinds must match unless upgrading `EXTERNAL` to `BRIDGED`, or the request fails. |
| `name`      | `String`                                       | Display name for a new token, read from its contract when omitted; ignored for tracked tokens.                                                              |
| `symbol`    | `String`                                       | Ticker symbol for a new token, read from its contract when omitted; ignored for tracked tokens.                                                             |
| `decimals`  | `Int`                                          | Decimal places for a new token, from 0-255; ignored for tracked tokens. When omitted, read from the contract with a fallback of 18.                         |
| `relations` | [`[TokenRelationInput!]`](#tokenrelationinput) | Up to 10 relations to add, preserving existing edges. Duplicates and self-relations fail with `VALIDATION_FAILED`.                                          |

## LinkTokenResult

Result of `linkToken`.

| Name    | Type                    | Description                                                                |
| ------- | ----------------------- | -------------------------------------------------------------------------- |
| `token` | [`Token`](#token)       | Token after linking, hydrated as requested; null when `error` is non-null. |
| `error` | [`ApiError`](#apierror) | Null on success; otherwise the failure, with no writes applied.            |

## ListLabMembersResult

Result type for the listLabMembers query. Failures are thrown as GraphQL errors, never encoded in this payload.

| Name      | Type                          | Description                                                                  |
| --------- | ----------------------------- | ---------------------------------------------------------------------------- |
| `message` | `String!`                     | Human-readable status message.                                               |
| `members` | [`[LabMember!]!`](#labmember) | Active members on the lab. Empty array when the lab has no recorded members. |

## Market

DEX trading pair of an IP Token, with its latest price and liquidity figures.

| Name                           | Type               | Description                                                                                                                                                                                 |
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                           | `String!`          | Address of the pair contract, the same value as `pairAddress`.                                                                                                                              |
| `createdAt`                    | `AWSDateTime!`     | When Molecule first indexed the pair.                                                                                                                                                       |
| `updatedAt`                    | `AWSDateTime!`     | When the record was last updated (i.e. when the figures were refreshed).                                                                                                                    |
| `liquidityUsd`                 | `Float!`           | Total liquidity of the trading pair, in USD.                                                                                                                                                |
| `pairAddress`                  | `String!`          |                                                                                                                                                                                             |
| `usdPrice`                     | `Float!`           | Current token price, in USD.                                                                                                                                                                |
| `usdPrice24hrPercentageChange` | `Float`            | Price change over the last 24 hours, in percent. Null when not available.                                                                                                                   |
| `chain`                        | [`Chain!`](#chain) |                                                                                                                                                                                             |
| `chainId`                      | `Int!`             | Record id of the chain the market trades on (`Chain.id`).                                                                                                                                   |
| `marketCapUsd`                 | `Float!`           | USD market cap reported by the pool data provider, else its fully diluted valuation, else `usdPrice` times the circulating supply. An unknown supply then gives zero, not a zero valuation. |
| `tradingVolume24hr`            | `Float!`           | Trading volume over the last 24 hours, in USD.                                                                                                                                              |
| `token`                        | [`IPT!`](#ipt)     | **Deprecated.** Use `Token.markets`. Removed after 2027-01-01.                                                                                                                              |
| `iptId`                        | `String!`          | **Deprecated.** Use the parent token of `Token.markets`. Removed after 2027-01-01.                                                                                                          |
| `inverted`                     | `Boolean!`         | Whether the pair's token order is inverted relative to the IP Token (the IP Token is the pair's second token).                                                                              |
| `name`                         | `String!`          | Market name. May be empty.                                                                                                                                                                  |

## MarketFilterBy

Exact-match filters for `markets`. Every given field must match exactly (case-sensitive); fields are combined with AND. Nested filters (`chain`, `token`) match on the related record's fields, and timestamp fields are ISO-8601 strings.

| Name                           | Type                              | Description                                                                                           |
| ------------------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `id`                           | `String`                          |                                                                                                       |
| `createdAt`                    | `String`                          |                                                                                                       |
| `updatedAt`                    | `String`                          |                                                                                                       |
| `liquidityUsd`                 | `String`                          | Not usable: liquidity is numeric and this filter is text; a value here makes the request fail.        |
| `pairAddress`                  | `String`                          | Match the stored casing exactly.                                                                      |
| `usdPrice`                     | `String`                          | Not usable: the price is numeric and this filter is text; a value here makes the request fail.        |
| `usdPrice24hrPercentageChange` | `String`                          | Not usable: the price change is numeric and this filter is text; a value here makes the request fail. |
| `chain`                        | [`ChainFilterBy`](#chainfilterby) | Match on the chain's fields, e.g. `{ chainId }`.                                                      |
| `chainId`                      | `Int`                             |                                                                                                       |
| `marketCapUsd`                 | `String`                          | Not usable: market cap is numeric and this filter is text; a value here makes the request fail.       |
| `tradingVolume24hr`            | `String`                          | Not usable: trading volume is numeric and this filter is text; a value here makes the request fail.   |
| `token`                        | [`IPTFilterBy`](#iptfilterby)     | Match on the traded IP Token's fields, e.g. `{ symbol }`.                                             |
| `iptId`                        | `String`                          |                                                                                                       |
| `inverted`                     | `Boolean`                         |                                                                                                       |

## MarketSortBy

| Value                          | Description                                                                            |
| ------------------------------ | -------------------------------------------------------------------------------------- |
| `id`                           |                                                                                        |
| `createdAt`                    |                                                                                        |
| `updatedAt`                    |                                                                                        |
| `liquidityUsd`                 |                                                                                        |
| `pairAddress`                  |                                                                                        |
| `usdPrice`                     |                                                                                        |
| `usdPrice24hrPercentageChange` |                                                                                        |
| `chain`                        | Not a usable sort key; the request fails with `INTERNAL_ERROR`. Use `chainId` instead. |
| `chainId`                      |                                                                                        |
| `marketCapUsd`                 |                                                                                        |
| `tradingVolume24hr`            |                                                                                        |
| `token`                        | Not a usable sort key; the request fails with `INTERNAL_ERROR`. Use `iptId` instead.   |
| `iptId`                        |                                                                                        |

## MoleculeLabActivityFilters

Filters for the `Lab.activity` field. That field is not served by this API (see its description), so these filters have no effect.

| Name             | Type        | Description                                                                   |
| ---------------- | ----------- | ----------------------------------------------------------------------------- |
| `byTags`         | `[String!]` | Restrict to entries carrying any of these tags.                               |
| `byCategories`   | `[String!]` | Restrict to entries in any of these categories.                               |
| `byAccessLevels` | `[String!]` | Restrict to entries with any of these access levels (PUBLIC, HOLDERS, ADMIN). |

## MoveEntryResult

Result type for move entry operations.

| Name      | Type                    | Description                                                               |
| --------- | ----------------------- | ------------------------------------------------------------------------- |
| `message` | `String!`               | Human-readable status message; mirrors `error.message` on failure.        |
| `newHead` | `String`                | New head hash after successful move (for optimistic concurrency control). |
| `error`   | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                      |

## OclAgreementType

Kind of OCL (Onchain Lab) agreement.

| Value            | Description                                  |
| ---------------- | -------------------------------------------- |
| `OCL_MEMBERSHIP` | Membership terms a Lab token holder accepts. |

## OclIdFilter

Lab-id filter requiring exactly one of `eq` or `in`. Invalid or empty operators fail with `VALIDATION_FAILED`.

| Name | Type        | Description                                                                                  |
| ---- | ----------- | -------------------------------------------------------------------------------------------- |
| `eq` | `String`    | Exact match on a 32-byte 0x-hex lab id; case-insensitive.                                    |
| `in` | `[String!]` | Set of 1-100 matching lab ids, case-insensitive, for fetching several labs' tokens together. |

## OnChainEvent

One transaction's onchain activity classified into a single timeline entry. An OCL creation renders as one `New Onchain Lab created` entry rather than a burst of raw events, and its constituent events stay available in `events`.

| Name             | Type                                      | Description                                                                                                                                                                                                |
| ---------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | `ID!`                                     | Cursor-stable group id, formatted `<blockNumber>:<logIndex>` from the newest matching event of the transaction. Pass the last entry's value as `cursor` to fetch the next page.                            |
| `chainId`        | `Int!`                                    | EVM chain id the transaction was mined on (e.g. 8453 for Base).                                                                                                                                            |
| `txHash`         | `String!`                                 | Hash of the transaction, 0x-prefixed. Every entry in `events` shares it.                                                                                                                                   |
| `blockNumber`    | `String!`                                 | Block height of the newest matching event of the transaction, as a decimal string.                                                                                                                         |
| `blockTimestamp` | `AWSDateTime!`                            | Block timestamp of the transaction, from its latest event carrying block metadata. Falls back to `1970-01-01T00:00:00.000Z` only while every event is awaiting reconciliation.                             |
| `type`           | `String!`                                 | One of: OCL\_CREATED, OCL\_TOKENIZED, OCL\_TRANSFERRED, OCL\_DID\_LINKED, ROLE\_GRANTED, ROLE\_REVOKED, ROLE\_CHANGED, IPT\_TOKENIZED, IPNFT\_MINTED, IPNFT\_TRANSFERRED, IPNFT\_METADATA\_UPDATED, OTHER. |
| `title`          | `String!`                                 | Human-readable title, e.g. `New Onchain Lab created`. For `OTHER` it is the raw event name.                                                                                                                |
| `args`           | `AWSJSON!`                                | Structured facts of the classified action, such as oclId, from/to, role and account. Addresses appear lowercased in full; titles use shortened forms.                                                      |
| `events`         | [`[RawOnChainEvent!]!`](#rawonchainevent) | Raw events of the transaction in ascending log order. Includes events that did not match the oclId or wallet filter, giving full transaction context.                                                      |

## OrderDirection

Sort direction for an ordering key.

* `ASC`
* `DESC`

## PageInfo

Page-based pagination information. Pages are 0-indexed.

| Name              | Type       | Description                                                                     |
| ----------------- | ---------- | ------------------------------------------------------------------------------- |
| `hasNextPage`     | `Boolean!` |                                                                                 |
| `hasPreviousPage` | `Boolean!` | Whether a page before the current one exists (true for any page but the first). |
| `currentPage`     | `Int!`     | Page number, 0-indexed.                                                         |
| `totalPages`      | `Int!`     | Total pages; 0 when there are no results.                                       |

## RawOnChainEvent

One decoded onchain event emitted by a Molecule contract.

| Name              | Type           | Description                                                                                                                                                               |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`              | `ID!`          | Cursor-stable event id, formatted `<blockNumber>:<logIndex>`. Pass the last entry's value as `cursor` to fetch the next page.                                             |
| `chainId`         | `Int!`         | EVM chain id the event was emitted on (e.g. 8453 for Base).                                                                                                               |
| `contractAddress` | `String!`      | Address of the emitting contract, lowercase 0x-hex.                                                                                                                       |
| `contractName`    | `String!`      | Logical subsystem the event came from. One of `accessresolver`, `ocl`, `ipnft`, `ipt`, `bio-agent`.                                                                       |
| `eventName`       | `String!`      | Name of the decoded Solidity event exactly as emitted (e.g. `OclIdentityCreated`, `RoleGranted`, `Transfer`). Raw per-event name, not the classified `OnChainEvent.type`. |
| `blockNumber`     | `String!`      | Block height of the event as a decimal string, since block numbers can exceed the range of `Int`.                                                                         |
| `blockTimestamp`  | `AWSDateTime!` | Timestamp of the block that included the event. An event ingested before its block metadata was available carries `1970-01-01T00:00:00.000Z` until it is reconciled.      |
| `txHash`          | `String!`      | Hash of the transaction that emitted the event, 0x-prefixed. With `logIndex` it identifies the event uniquely.                                                            |
| `logIndex`        | `Int!`         | Position of the event's log within its block.                                                                                                                             |
| `args`            | `AWSJSON!`     | Decoded event arguments. BigInts are decimal strings; addresses are lowercased.                                                                                           |

## ResearchLead

Research lead of an IP-NFT's project.

| Name        | Type                  | Description                                                                                    |
| ----------- | --------------------- | ---------------------------------------------------------------------------------------------- |
| `id`        | `String!`             | Email address of the research lead, taken from the IP-NFT metadata. The same value as `email`. |
| `createdAt` | `AWSDateTime!`        | When Molecule first indexed the research lead.                                                 |
| `updatedAt` | `AWSDateTime!`        | When Molecule last refreshed the record.                                                       |
| `name`      | `String!`             |                                                                                                |
| `email`     | `String!`             |                                                                                                |
| `ipnfts`    | [`[IPNFT!]!`](#ipnft) | IP-NFTs led by this person. The arguments are accepted but ignored: the full list is returned. |

**`ipnfts` arguments**

| Name        | Type                              | Description                                |
| ----------- | --------------------------------- | ------------------------------------------ |
| `limit`     | `Int`                             | Accepted but ignored on this nested field. |
| `skip`      | `Int`                             | Accepted but ignored on this nested field. |
| `sortBy`    | [`IPNFTSortBy`](#ipnftsortby)     | Accepted but ignored on this nested field. |
| `filterBy`  | [`IPNFTFilterBy`](#ipnftfilterby) | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder)         | Accepted but ignored on this nested field. |

## ResearchLeadFilterBy

Exact-match filters for `researchLeads`. Every given field must match exactly (case-sensitive); fields are combined with AND. Timestamp fields are ISO-8601 strings.

| Name        | Type     | Description                                                                                |
| ----------- | -------- | ------------------------------------------------------------------------------------------ |
| `id`        | `String` |                                                                                            |
| `createdAt` | `String` |                                                                                            |
| `updatedAt` | `String` |                                                                                            |
| `name`      | `String` |                                                                                            |
| `email`     | `String` |                                                                                            |
| `ipnfts`    | `String` | Not usable: `ipnfts` is a relation, not a text field; a value here makes the request fail. |

## ResearchLeadSortBy

| Value       | Description                                                     |
| ----------- | --------------------------------------------------------------- |
| `id`        |                                                                 |
| `createdAt` |                                                                 |
| `updatedAt` |                                                                 |
| `name`      |                                                                 |
| `email`     |                                                                 |
| `ipnfts`    | Not a usable sort key; the request fails with `INTERNAL_ERROR`. |

## SearchLabsAnnouncement

Lightweight announcement type for search results (no embedded lab).

| Name          | Type                                | Description                                                                                                                                                                  |
| ------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | `String!`                           | Announcement id, unique within the data room.                                                                                                                                |
| `headline`    | `String!`                           | Title of the announcement.                                                                                                                                                   |
| `body`        | `String!`                           | Body text of the announcement.                                                                                                                                               |
| `attachments` | [`[DataRoomFile!]!`](#dataroomfile) | Files attached to the announcement.                                                                                                                                          |
| `changeBy`    | `String!`                           | Identity of the member who posted the announcement, as `did:ethr:` plus the EIP-55 checksummed wallet address. Older records may hold a bare address or an unverified value. |
| `systemTime`  | `AWSDateTime!`                      | When the announcement was written by the data room backend.                                                                                                                  |
| `eventTime`   | `AWSDateTime!`                      | When the announcement was posted.                                                                                                                                            |

## SearchLabsAnnouncementHit

Announcement search result.

| Name           | Type                                                 | Description                                                                      |
| -------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------- |
| `announcement` | [`SearchLabsAnnouncement!`](#searchlabsannouncement) | Matching announcement.                                                           |
| `lab`          | [`LabRef!`](#labref)                                 | Lab the announcement was posted to (search-result hydration tier; see `LabRef`). |

## SearchLabsFileHit

File search result.

| Name    | Type                                                   | Description                              |
| ------- | ------------------------------------------------------ | ---------------------------------------- |
| `entry` | [`DataRoomFileSearchEntry!`](#dataroomfilesearchentry) | Matching file and the lab it belongs to. |

## SearchLabsFilters

Filters for `searchLabs`. Each list restricts hits to entries matching any of its values; lists are combined with AND. Values are forwarded to the search backend as given.

| Name             | Type        | Description                                                                                                             |
| ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
| `byOclIds`       | `[String!]` | Restrict hits to these labs (32-byte 0x-hex OCL ids).                                                                   |
| `byTags`         | `[String!]` | Restrict hits to files and announcements carrying any of these tags.                                                    |
| `byCategories`   | `[String!]` | Restrict hits to files and announcements in any of these categories.                                                    |
| `byAccessLevels` | `[String!]` | Restrict hits to entries with any of these access levels (PUBLIC, HOLDERS, ADMIN).                                      |
| `byKinds`        | `[String!]` | Restrict hits to these kinds of entry: "FILE" and/or "ANNOUNCEMENT". Any other value is rejected by the search backend. |

## SearchLabsHit

Union type for search results - can be either a file or announcement.

One of [`SearchLabsFileHit`](#searchlabsfilehit), [`SearchLabsAnnouncementHit`](#searchlabsannouncementhit).

## SearchLabsResult

Search results with pagination.

| Name         | Type                                  | Description                                                                                                                                                                                             |
| ------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nodes`      | [`[SearchLabsHit!]!`](#searchlabshit) | Hits on the requested page, most relevant first. An empty list means the search matched nothing or this page held only hits this API cannot yet render, and never stands in for a failure.              |
| `totalCount` | `Int!`                                | Hits the search matched across all pages, counting matches rather than what this page could render, so it can exceed the length of `nodes`. Falls back to a lower bound when matches cannot be counted. |
| `pageInfo`   | [`PageInfo!`](#pageinfo)              | Pagination information for the requested page.                                                                                                                                                          |

## ServiceSignInMessageResult

Result type for the getServiceSignInMessage query.

| Name        | Type      | Description                                                                                                                              |
| ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `message`   | `String!` | Message the service must sign with its wallet. Embeds a server-issued single-use nonce, so it must be fetched fresh before each signing. |
| `expiresAt` | `String`  | ISO-8601 expiry of the embedded nonce; after this the signature is rejected and a fresh message must be requested.                       |

## ServiceTokenResult

Result of generating or extending a service token. Success when `error == null`; the token fields are populated only on success.

| Name          | Type                    | Description                                                              |
| ------------- | ----------------------- | ------------------------------------------------------------------------ |
| `token`       | `String`                | Generated JWT token for service authentication.                          |
| `tokenId`     | `String`                | Unique identifier for the token (for tracking/revocation).               |
| `serviceName` | `String`                |                                                                          |
| `expiresAt`   | `AWSDateTime`           |                                                                          |
| `createdAt`   | `AWSDateTime`           |                                                                          |
| `message`     | `String`                | Message with additional information; mirrors `error.message` on failure. |
| `error`       | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                     |

## ServiceTokenRevocationResult

Result of revoking a service token.

| Name        | Type                    | Description                                                                   |
| ----------- | ----------------------- | ----------------------------------------------------------------------------- |
| `tokenId`   | `String`                | ID of the revoked token.                                                      |
| `message`   | `String!`               | Message describing the revocation result; mirrors `error.message` on failure. |
| `revokedAt` | `AWSDateTime`           | Date/time when the token was revoked.                                         |
| `error`     | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                          |

## SignedLegalAgreementVersion

One signed template version of a legal agreement for a lab.

| Name              | Type           | Description                                                                                                                                                                         |
| ----------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `templateVersion` | `String!`      | Version of the agreement template that was signed (e.g. "1.0.0").                                                                                                                   |
| `path`            | `String!`      | Canonical data room path of the signed artifact.                                                                                                                                    |
| `signer`          | `String`       | Wallet address that signed. Null when the acceptance record is not available.                                                                                                       |
| `signedAt`        | `String`       | ISO-8601 time the backend recorded the acceptance, an audit clock only, the legally effective date being `issuedAt`. Null when the acceptance record is not available.              |
| `contentHash`     | `String`       | keccak256 (0x-prefixed) of the signed agreement document; the `contentHash` covered by the EIP-712 signature. Null when the acceptance record is not available.                     |
| `issuedAt`        | `AWSTimestamp` | Signed effective date in epoch seconds, the value the EIP-712 signature covers, and the one to render as the effective date. Distinct from `signedAt`, the audit-only commit clock. |
| `signature`       | `String`       | EIP-712 signature over the acceptance typed data, so a third party can re-verify it offline. The backend's verification at sign time is authoritative.                              |

## SignLegalAgreementInput

Input of `signLegalAgreement`. Everything that was passed to `legalAgreementTemplate` to produce the signed document must be echoed here verbatim; the backend regenerates the document and its hash from it.

| Name            | Type                                         | Description                                                                                                                                                                                  |
| --------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oclId`         | `String!`                                    | OCL identifier (0x..., 32-byte hex) of the lab.                                                                                                                                              |
| `type`          | [`LegalAgreementType!`](#legalagreementtype) | Which legal agreement is being signed.                                                                                                                                                       |
| `walletAddress` | `String!`                                    | Signer's wallet address. Must equal the EIP-712 signer, the lab's current LabNft owner, and (on the user auth path) the authenticated wallet.                                                |
| `signature`     | `String!`                                    | EIP-712 signature (0x...) over the LegalAgreementAcceptance typed data.                                                                                                                      |
| `issuedAt`      | `AWSTimestamp!`                              | Echoed VERBATIM from legalAgreementTemplate. Regeneration input + signed field.                                                                                                              |
| `signerName`    | `String`                                     | Signer identity (natural person) for the SIGNATURES block. Must be echoed verbatim from the `legalAgreementTemplate` call that produced the signature, since it is covered by `contentHash`. |
| `entity`        | `String`                                     | Signing entity (if applicable). Echoed verbatim; see signerName.                                                                                                                             |
| `title`         | `String`                                     | Signer title. Echoed verbatim; see signerName.                                                                                                                                               |

## SignLegalAgreementResult

Result of signLegalAgreement. Success when `error == null`; no partial writes. The payload fields are nullable and populated on success; `message` mirrors `error.message` on failure.

| Name              | Type                    | Description                                                                                              |
| ----------------- | ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `oclId`           | `String`                | OCL id of the lab the agreement was signed for.                                                          |
| `path`            | `String`                | Canonical data room path of the stored signed artifact (backend-derived).                                |
| `contentHash`     | `String`                | contentHash of the agreement node; matches the signed EIP-712 field.                                     |
| `templateVersion` | `String`                | Version of the agreement template that was signed (e.g. "1.0.0").                                        |
| `datasetId`       | `String`                | Data room dataset id of the stored signed artifact.                                                      |
| `version`         | `Int`                   | Version number of the stored artifact in the data room (1 for a first signing of this template version). |
| `message`         | `String`                | Human-readable status message; mirrors `error.message` on failure.                                       |
| `error`           | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.                                                     |

## SignoffMetadataResult

Result of signoffMetadata. Success when `isSuccess` is true.

| Name            | Type                                            | Description                                                                                                                                                                                 |
| --------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `authorization` | `String`                                        | Mint authorization for the IPNFT contract's mint call: a `0x`-hex EIP-191 signature by the Molecule key over `keccak256(abi.encodePacked(minter, to, ipnftId, tokenURI))`. Null on failure. |
| `isSuccess`     | `Boolean!`                                      | True when the terms signature was verified and the authorization issued.                                                                                                                    |
| `error`         | [`EvmTokenizationError`](#evmtokenizationerror) | Null on success. Non-null means the mutation failed.                                                                                                                                        |

## SortOrder

Sort direction for the `sortOrder` argument of the list queries.

| Value  | Description                                                         |
| ------ | ------------------------------------------------------------------- |
| `asc`  | Ascending (the default when `sortBy` is given without `sortOrder`). |
| `desc` |                                                                     |

## StringFilter

String operator object. All matching is case-insensitive. Provide at least one operator; an empty object is VALIDATION\_FAILED.

| Name       | Type     | Description                           |
| ---------- | -------- | ------------------------------------- |
| `eq`       | `String` | Exact match, case-insensitive.        |
| `contains` | `String` | Substring match, not list membership. |

## Token

Tracked token contract with its lab link, typed relations and markets. Manual lab links may be overridden by later onchain events.

| Name                   | Type                                   | Description                                                                                                                                        |
| ---------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                   | `ID!`                                  | Opaque token id, stable across re-indexing.                                                                                                        |
| `chainId`              | `Int!`                                 | Chain id the contract is deployed on.                                                                                                              |
| `address`              | `String!`                              | Contract address, lowercase 0x-hex.                                                                                                                |
| `kind`                 | [`TokenKind!`](#tokenkind)             |                                                                                                                                                    |
| `name`                 | `String!`                              |                                                                                                                                                    |
| `symbol`               | `String!`                              |                                                                                                                                                    |
| `decimals`             | `Int!`                                 | Number of decimal places for balances; `LOCKED` tokens use their underlying token's decimals.                                                      |
| `oclId`                | `String`                               | OclId of the linked lab; null when the token is not linked to a lab.                                                                               |
| `lab`                  | [`LabRef`](#labref)                    | Linked lab identity and display metadata; null when unlinked. Data-room, activity and assessment fields are null here; use `lab` to retrieve them. |
| `linkSource`           | [`TokenLinkSource!`](#tokenlinksource) | How the lab link was established.                                                                                                                  |
| `linkedBy`             | `String`                               | Lowercase wallet address that manually linked this token; null for links established by onchain events or migration.                               |
| `linkedAt`             | `AWSDateTime`                          | Timestamp when the lab link was last set or cleared, by any source; null if never linked.                                                          |
| `image`                | `String`                               | Image URL; null when none is set.                                                                                                                  |
| `links`                | `[String!]!`                           | Related URLs; empty when none.                                                                                                                     |
| `isCapped`             | `Boolean!`                             | Whether the token supply is capped.                                                                                                                |
| `initialSupply`        | `String`                               | Initial supply as a decimal string in base units; null when unknown.                                                                               |
| `totalIssued`          | `String`                               | Total issued supply as a decimal string in base units; null when unknown.                                                                          |
| `circulatingSupply`    | `String`                               | Circulating supply as a decimal string in base units; null when unknown.                                                                           |
| `holderCount`          | `Int`                                  | Number of distinct holders; null when not yet counted.                                                                                             |
| `totalLocked`          | `String`                               | Locked amount as a decimal string in base units; null except on `LOCKED` tokens.                                                                   |
| `unlockDelay`          | `String`                               | Unlock delay as a decimal string in seconds; null except on `LOCKED` tokens.                                                                       |
| `wrapperAddress`       | `String`                               | Lowercase wrapper contract address; null except on `WRAPPED_LAB_TOKEN` tokens.                                                                     |
| `agreementCid`         | `String`                               | Content identifier of the membership agreement document; null when none.                                                                           |
| `agreementMimeType`    | `String`                               | MIME type of the membership agreement document; null when none.                                                                                    |
| `agreementStorageKey`  | `String`                               | Storage key of the membership agreement document; null when none.                                                                                  |
| `agreementContentHash` | `String`                               | 0x-prefixed SHA-256 of the membership agreement document; null when none.                                                                          |
| `mintedAt`             | `AWSDateTime`                          | Block time the token contract was created; null when unknown.                                                                                      |
| `tokenizedAt`          | `AWSDateTime`                          | Block time the token was tokenized for its lab; null when unknown.                                                                                 |
| `tokenizedBy`          | `String`                               | Address that performed the tokenization, lowercase; null when unknown.                                                                             |
| `relations`            | [`[TokenRelation!]!`](#tokenrelation)  | Relations involving this token, oldest first. Above 50 items fails with `COMPLEXITY_LIMIT_EXCEEDED`, reason `RESULT_CARDINALITY_LIMIT`.            |
| `markets`              | [`[TokenMarket!]!`](#tokenmarket)      | Markets trading this token, highest liquidity first. Above 50 items fails with `COMPLEXITY_LIMIT_EXCEEDED`, reason `RESULT_CARDINALITY_LIMIT`.     |
| `createdAt`            | `AWSDateTime!`                         | When the record was created.                                                                                                                       |
| `updatedAt`            | `AWSDateTime!`                         | When the record was last updated.                                                                                                                  |

## TokenConnection

Cursor-paginated token connection.

| Name         | Type                                         | Description                                                                         |
| ------------ | -------------------------------------------- | ----------------------------------------------------------------------------------- |
| `edges`      | [`[TokenEdge!]!`](#tokenedge)                |                                                                                     |
| `nodes`      | [`[Token!]!`](#token)                        | Tokens in the same order as `edges`.                                                |
| `pageInfo`   | [`ConnectionPageInfo!`](#connectionpageinfo) |                                                                                     |
| `totalCount` | `Int`                                        | Number of tokens matching the filter across all pages; computed only when selected. |

## TokenEdge

Token and its opaque pagination cursor.

| Name     | Type               | Description                                                     |
| -------- | ------------------ | --------------------------------------------------------------- |
| `node`   | [`Token!`](#token) |                                                                 |
| `cursor` | `String!`          | Opaque position for `after` or `before`; its format may change. |

## TokenFilter

Filters for tokens, combined with AND. Malformed values fail with `VALIDATION_FAILED`.

| Name      | Type                                  | Description                                                                                     |
| --------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `kind`    | [`TokenKindFilter`](#tokenkindfilter) | Token classification filter.                                                                    |
| `chainId` | [`IntFilter`](#intfilter)             | Deployment chain filter.                                                                        |
| `address` | [`StringFilter`](#stringfilter)       | Contract address filter; case-insensitive.                                                      |
| `oclId`   | [`OclIdFilter`](#oclidfilter)         | Linked lab id filter. Fails with `VALIDATION_FAILED` on `Lab.tokens`, where the lab is implied. |
| `symbol`  | [`StringFilter`](#stringfilter)       | Token symbol filter; case-insensitive.                                                          |

## TokenKind

Classification of token contracts, established by onchain events or asserted for manually linked contracts.

| Value               | Description                                                                                            |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| `IPT`               | Legacy IP Token.                                                                                       |
| `LAB_TOKEN`         | Native lab token minted for the lab.                                                                   |
| `WRAPPED_LAB_TOKEN` | Existing ERC-20 used as a lab token, with its wrapper in `Token.wrapperAddress`.                       |
| `BRIDGED`           | Bridged copy of another token, connected to it by a `BRIDGE_OF` relation.                              |
| `LOCKED`            | Locked-token wrapper, connected to its underlying token by a `LOCKS` relation.                         |
| `AGENT`             | Agent persona token.                                                                                   |
| `EXTERNAL`          | Placeholder for a manually linked or related contract, upgraded when its classification becomes known. |

## TokenKindFilter

Token-kind filter requiring exactly one of `eq` or `in`. Invalid or empty operators fail with `VALIDATION_FAILED`.

| Name | Type                         | Description                                     |
| ---- | ---------------------------- | ----------------------------------------------- |
| `eq` | [`TokenKind`](#tokenkind)    | Exact match on a token kind.                    |
| `in` | [`[TokenKind!]`](#tokenkind) | Set of matching token kinds, with 1-100 values. |

## TokenLinkSource

How a token's lab link, or a relation between tokens, was established.

| Value           | Description                                              |
| --------------- | -------------------------------------------------------- |
| `ONCHAIN_EVENT` | Derived from an onchain event.                           |
| `MANUAL`        | Set by a lab owner through `linkToken` or `unlinkToken`. |
| `MIGRATION`     | Carried over from earlier records.                       |

## TokenMarket

Trading pair for a token, with the latest price and liquidity figures.

| Name                           | Type           | Description                                                               |
| ------------------------------ | -------------- | ------------------------------------------------------------------------- |
| `id`                           | `String!`      | Market id (the pool address).                                             |
| `chainId`                      | `Int!`         | Chain id of the pool.                                                     |
| `pairAddress`                  | `String!`      | Address of the pair contract.                                             |
| `name`                         | `String!`      | Market name; empty when not known.                                        |
| `isInverted`                   | `Boolean!`     | Whether the token is the pair's second token.                             |
| `liquidityUsd`                 | `Float!`       | Total liquidity in USD.                                                   |
| `usdPrice`                     | `Float!`       | Current token price in USD.                                               |
| `usdPrice24hrPercentageChange` | `Float`        | Price change over the last 24 hours, in percent. Null when not available. |
| `marketCapUsd`                 | `Float!`       | Market capitalization in USD.                                             |
| `tradingVolume24hr`            | `Float!`       | Trading volume over the last 24 hours, in USD.                            |
| `createdAt`                    | `AWSDateTime!` | When the record was created.                                              |
| `updatedAt`                    | `AWSDateTime!` | When the figures were last refreshed.                                     |

## TokenOrderField

Values for sorting tokens, with unique token `id` appended as a tiebreaker in the primary key's direction.

| Value          | Description                                                                |
| -------------- | -------------------------------------------------------------------------- |
| `CREATED_AT`   |                                                                            |
| `TOKENIZED_AT` | Tokenization block time; unknown timestamps sort last in either direction. |
| `SYMBOL`       | Token symbol, compared case-sensitively.                                   |
| `HOLDER_COUNT` | Number of holders; unknown counts sort last in either direction.           |

## TokenOrderInput

One ordering key for `tokens`.

| Name        | Type                                   | Description                      |
| ----------- | -------------------------------------- | -------------------------------- |
| `field`     | [`TokenOrderField!`](#tokenorderfield) | Field to order by.               |
| `direction` | [`OrderDirection!`](#orderdirection)   | Direction for this ordering key. |

## TokenRef

Token identity used at relation endpoints; full records are available through `tokenById`.

| Name       | Type                       | Description                                                          |
| ---------- | -------------------------- | -------------------------------------------------------------------- |
| `id`       | `ID!`                      |                                                                      |
| `chainId`  | `Int!`                     | Chain id the contract is deployed on.                                |
| `address`  | `String!`                  | Contract address, lowercase 0x-hex.                                  |
| `kind`     | [`TokenKind!`](#tokenkind) |                                                                      |
| `name`     | `String!`                  |                                                                      |
| `symbol`   | `String!`                  |                                                                      |
| `decimals` | `Int!`                     | Number of decimals of the contract.                                  |
| `oclId`    | `String`                   | OclId of the linked lab; null when the token is not linked to a lab. |

## TokenRelation

Directed relation from a dependent token to its canonical or underlying token.

| Name           | Type                                       | Description                                                |
| -------------- | ------------------------------------------ | ---------------------------------------------------------- |
| `relationType` | [`TokenRelationType!`](#tokenrelationtype) |                                                            |
| `token`        | [`TokenRef!`](#tokenref)                   | Dependent token: bridged copy, locked wrapper, or wrapper. |
| `related`      | [`TokenRef!`](#tokenref)                   | Canonical or underlying token.                             |
| `source`       | [`TokenLinkSource!`](#tokenlinksource)     | How the relation was established.                          |
| `createdAt`    | `AWSDateTime!`                             | When the relation was recorded.                            |

## TokenRelationInput

Canonical or underlying token related to the linked token. Unknown contracts become `EXTERNAL` placeholders without acquiring a lab link.

| Name       | Type                                       | Description                                                                                                                              |
| ---------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `type`     | [`TokenRelationType!`](#tokenrelationtype) | Kind of relationship from the linked token to this one.                                                                                  |
| `chainId`  | `Int!`                                     | Indexed chain id; others fail with `VALIDATION_FAILED`.                                                                                  |
| `address`  | `String!`                                  | Contract address, accepting any checksum casing. Self-relations fail with `VALIDATION_FAILED`.                                           |
| `name`     | `String`                                   | Display name for an unknown token, read from its contract when omitted; ignored for tracked tokens.                                      |
| `symbol`   | `String`                                   | Ticker symbol for an unknown token, read from its contract when omitted; ignored for tracked tokens.                                     |
| `decimals` | `Int`                                      | Decimal places for an unknown token, from 0-255; ignored for tracked tokens. When omitted, read from the contract with a fallback of 18. |

## TokenRelationType

Directed relationship from a dependent token to its canonical or underlying token.

| Value       | Description                                                  |
| ----------- | ------------------------------------------------------------ |
| `BRIDGE_OF` | `token` is a bridged copy of `related`, the canonical token. |
| `LOCKS`     | `token` locks `related`, the underlying token.               |
| `WRAPS`     | `token` wraps `related`, the underlying token.               |

## TrlValueFilter

Minimum-TRL filter over the numeric rank of `trlValue` (`trl-N` maps to N, `trl-gt-N` maps to N+1). Labs without an assessment, or with a `pre-trl-*` value, never match. Provide `gte`; an empty object is VALIDATION\_FAILED.

| Name  | Type  | Description            |
| ----- | ----- | ---------------------- |
| `gte` | `Int` | Rank between 0 and 10. |

## UnlinkTokenInput

Detach a token from a lab.

| Name      | Type      | Description                                             |
| --------- | --------- | ------------------------------------------------------- |
| `oclId`   | `String!` | Lab id as 32-byte 0x-hex; the caller must own this lab. |
| `chainId` | `Int!`    | Indexed chain id; others fail with `VALIDATION_FAILED`. |
| `address` | `String!` | Contract address, accepting any checksum casing.        |

## UnlinkTokenResult

Result of `unlinkToken`.

| Name    | Type                    | Description                                                                  |
| ------- | ----------------------- | ---------------------------------------------------------------------------- |
| `token` | [`Token`](#token)       | Token after unlinking, hydrated as requested; null when `error` is non-null. |
| `error` | [`ApiError`](#apierror) | Null on success; otherwise the failure, with no writes applied.              |

## UpdateFileMetadataResult

Result type for file metadata update operations.

| Name      | Type                    | Description                                                        |
| --------- | ----------------------- | ------------------------------------------------------------------ |
| `ref`     | `String`                | Reference (DID) of the updated file.                               |
| `message` | `String!`               | Human-readable status message; mirrors `error.message` on failure. |
| `error`   | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.               |

## UpdateLabNftMetadataInput

Patch input for LabNft display metadata. All fields optional: an omitted field is left unchanged and an explicit `null` clears it; `name` is the exception and cannot be cleared.

| Name                | Type        | Description                                                                                                                                                                                      |
| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`              | `String`    | New display name, 2-100 characters, from which the lab's shortname is rederived. Clearing it fails with `VALIDATION_FAILED`, and a shortname clash with another lab fails with `CONFLICT`.       |
| `description`       | `String`    | New description of the lab.                                                                                                                                                                      |
| `image`             | `String`    | See `generateLabImageUploadUrl` for hosting an image.                                                                                                                                            |
| `externalUrl`       | `String`    | New external link for the lab.                                                                                                                                                                   |
| `websiteUrl`        | `AWSURL`    | Website URL, using absolute HTTPS without credentials, at most 2048 characters. `null` clears it; invalid URLs fail with `VALIDATION_FAILED`.                                                    |
| `xUrl`              | `AWSURL`    | X (Twitter) profile URL; validation and clearing rules match `UpdateLabNftMetadataInput.websiteUrl`.                                                                                             |
| `telegramUrl`       | `AWSURL`    | Telegram group or channel URL; validation and clearing rules match `UpdateLabNftMetadataInput.websiteUrl`.                                                                                       |
| `governanceUrl`     | `AWSURL`    | Governance forum or voting URL; validation and clearing rules match `UpdateLabNftMetadataInput.websiteUrl`.                                                                                      |
| `projectFormat`     | `String`    | Active slug from `LabTaxonomyResult.projectFormats`; `null` clears it. Unknown or retired slugs fail with `VALIDATION_FAILED`, with the rejected slug in error details.                          |
| `therapeuticFields` | `[String!]` | Active slugs from `LabTaxonomyResult.therapeuticFields`, deduplicated in order; `null` or `[]` clears them. More than 10 unique slugs or unknown or retired slugs fail with `VALIDATION_FAILED`. |

## UpdateLabNftMetadataResult

Result of `updateLabNftMetadata`.

| Name      | Type                    | Description                                                        |
| --------- | ----------------------- | ------------------------------------------------------------------ |
| `oclId`   | `String`                | OCL id of the updated lab. Null on failure.                        |
| `message` | `String`                | Human-readable status message; mirrors `error.message` on failure. |
| `error`   | [`ApiError`](#apierror) | Null on success. Non-null means the mutation failed.               |

## UploadMetadataWithKeyResult

Result of uploadMetadataWithImageKey. Success when `isSuccess` is true.

| Name          | Type                                            | Description                                                                                                                                |
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `metadataCid` | `String`                                        | IPFS CID of the stored IPNFT metadata document, used as `metadataCid` for `getTermsMessage` and as the mint's `tokenURI`. Null on failure. |
| `metadataUrl` | `String`                                        | HTTPS gateway URL of the stored metadata document. Null on failure.                                                                        |
| `isSuccess`   | `Boolean!`                                      | True when the metadata was validated and stored.                                                                                           |
| `error`       | [`EvmTokenizationError`](#evmtokenizationerror) | Null on success. Non-null means the mutation failed.                                                                                       |

## User

Wallet known to the IP-NFT registry as an owner or original minter.

| Name        | Type                  | Description                                                                                                                                                                                                                                                          |
| ----------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`        | `String!`             | Checksummed wallet address, the same value as `address`.                                                                                                                                                                                                             |
| `createdAt` | `AWSDateTime!`        | When Molecule first indexed the wallet.                                                                                                                                                                                                                              |
| `updatedAt` | `AWSDateTime!`        | When Molecule last refreshed the record.                                                                                                                                                                                                                             |
| `address`   | `String!`             | Checksummed EVM address; match it exactly when filtering.                                                                                                                                                                                                            |
| `ipnfts`    | [`[IPNFT!]!`](#ipnft) | IP-NFTs owned by this wallet; the arguments are accepted but ignored. Whether this field resolves data is unverified, so prefer `ipnfts(filterBy: { userId })`.                                                                                                      |
| `ipts`      | [`[IPT!]!`](#ipt)     | **Deprecated.** Use `tokens` with an IPT kind filter. Removed after 2027-01-01. IP Tokens originally issued to this wallet; the arguments are accepted but ignored. Whether this field resolves data is unverified, so prefer `ipts(filterBy: { originalOwnerId })`. |

**`ipnfts` arguments**

| Name        | Type                              | Description                                |
| ----------- | --------------------------------- | ------------------------------------------ |
| `limit`     | `Int`                             | Accepted but ignored on this nested field. |
| `skip`      | `Int`                             | Accepted but ignored on this nested field. |
| `sortBy`    | [`IPNFTSortBy`](#ipnftsortby)     | Accepted but ignored on this nested field. |
| `filterBy`  | [`IPNFTFilterBy`](#ipnftfilterby) | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder)         | Accepted but ignored on this nested field. |

**`ipts` arguments**

| Name        | Type                          | Description                                |
| ----------- | ----------------------------- | ------------------------------------------ |
| `limit`     | `Int`                         | Accepted but ignored on this nested field. |
| `skip`      | `Int`                         | Accepted but ignored on this nested field. |
| `sortBy`    | [`IPTSortBy`](#iptsortby)     | Accepted but ignored on this nested field. |
| `filterBy`  | [`IPTFilterBy`](#iptfilterby) | Accepted but ignored on this nested field. |
| `sortOrder` | [`SortOrder`](#sortorder)     | Accepted but ignored on this nested field. |

## UserFilterBy

Exact-match filters for `users`. Every given field must match exactly (case-sensitive); fields are combined with AND. Timestamp fields are ISO-8601 strings.

| Name        | Type     | Description                                                    |
| ----------- | -------- | -------------------------------------------------------------- |
| `id`        | `String` |                                                                |
| `createdAt` | `String` |                                                                |
| `updatedAt` | `String` |                                                                |
| `address`   | `String` | Match the stored casing exactly.                               |
| `ipnft`     | `String` | Not usable: it does not name a `User` field, so it is ignored. |
| `ipt`       | `String` | Not usable: it does not name a `User` field, so it is ignored. |

## UserSortBy

| Value       | Description                                                                                        |
| ----------- | -------------------------------------------------------------------------------------------------- |
| `id`        |                                                                                                    |
| `createdAt` |                                                                                                    |
| `updatedAt` |                                                                                                    |
| `address`   |                                                                                                    |
| `ipnft`     | Not a usable sort key: it names no `User` field, so it is ignored and the order stays unspecified. |
| `ipt`       | Not a usable sort key: it names no `User` field, so it is ignored and the order stays unspecified. |


# 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

### The service-token sign-in message is now single-use and expires

`getServiceSignInMessage` used to return a deterministic string — a pure function of `(walletAddress, serviceName)` — which meant one captured signature could mint fresh tokens indefinitely. The message now embeds a **server-issued single-use nonce** and its expiry, and `generateServiceToken` verifies and consumes that nonce:

* The message is **valid for 10 minutes** from issuance. The new `expiresAt` field on `getServiceSignInMessage` reports the deadline.
* The nonce is **consumed on first successful redemption**. Issuing a second token requires a fresh message and a fresh signature.
* There is **one outstanding nonce per `(walletAddress, serviceName)`**, last-write-wins — calling the query again invalidates an unredeemed message.
* Signatures over the older nonce-free message format no longer verify.

Failures come back as `UNAUTHENTICATED` with `details.reason` one of `NONCE_NOT_FOUND` (never requested, or already consumed), `NONCE_EXPIRED`, or `INVALID_SIGNATURE` (altered text, a message superseded by a later call, or a `walletAddress` that is not the signer).

**Migration:** Fetch the message immediately before signing, and treat every one of those reasons as "request a new message and sign it again" rather than as a retryable call — re-submitting the same signature can never succeed. Callers that already ran `getServiceSignInMessage` → sign → `generateServiceToken` back to back need no change; callers that cached the message, cached a signature, or reconstructed the string client-side must stop doing so. See [Obtaining a Token](/api-reference/labs-api/service-tokens#obtaining-a-token).

### Service tokens are self-issued, and scoped to their own lifecycle

Two clarifications and one hardening, all now reflected across the API docs:

* **Nobody provisions a service token for you.** `generateServiceToken` accepts a wallet signature, so any caller mints its own: `getServiceSignInMessage` → sign the message verbatim (EIP-191 `personal_sign`) → `generateServiceToken`. The only credential that still comes from the Molecule team is the `mol_` consumer credential. Earlier pages described service tokens as team-issued; that was never the only path and is no longer the documented one.
* **A service token is bound to a wallet, not to a lab.** Docs that described it as identifying "which lab you have write access to" were wrong. It carries a wallet identity; what it may do on a given lab is resolved per request from that wallet's live onchain role. One token therefore works across every lab the wallet has a role on, and a role granted *after* issuance takes effect without re-issuing.
* **`extendServiceToken` and `revokeServiceToken` are scoped to the caller's own tokens.** The presented token must own the `tokenId` it names; a `tokenId` belonging to another wallet returns the byte-identical `NOT_FOUND` of one that does not exist, so token existence cannot be enumerated.

**Migration:** None required if you already self-issue. If you hold a team-provisioned token, it keeps working — but you can mint and rotate your own. If you built per-lab token issuance, you can collapse it to one token per wallet. If any code called `extendServiceToken` / `revokeServiceToken` for a `tokenId` issued to a different wallet, it now receives `NOT_FOUND`. See [Authentication](/api-reference/authentication#obtaining-a-service-token) and [Service Tokens](/api-reference/labs-api/service-tokens#obtaining-a-token).

### Contributor role parity for service-token content writes

The six content-write mutations — `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `deleteDataRoomFile`, `updateFileMetadata`, `moveEntry` and `createAnnouncement` (announcements have [since been deprecated](#announcements-are-deprecated)) — now gate a service token on the **Contributor** role, matching the Privy user path per mutation. Previously the service path required lab ownership for these, which meant a wallet granted Contributor on a lab could act through a user session but not through its own service token.

This is what unblocks the "human owns the lab, agent contributes to it" flow: the owner grants the agent's wallet Contributor, and the agent's self-issued token can write. Owner-gated surfaces are unchanged — `createLab`, `updateLabNftMetadata` and `generateLabImageUploadUrl` still require ownership.

**Migration:** None — this is a widening. Note that role state reaches the API through an event indexer, so a write can still return `UNAUTHORIZED` for a few seconds after a grant confirms onchain; retry with backoff rather than re-issuing the token. Walkthrough: [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor).

### 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 — 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:** Request a consumer credential from the Molecule team ([template](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step)) 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

### Announcements are deprecated

Announcements are no longer surfaced in the Molecule app, and they are out of every tutorial, how-to and feature-description page. **The API surface is unchanged and still works** — nothing has been removed from the schema and no call has started failing. This is a "stop building on it" notice, not a breaking change.

Still live, and still returned to callers who ask for it:

| Surface                                                  | Status                                                     |
| -------------------------------------------------------- | ---------------------------------------------------------- |
| `createAnnouncement` mutation                            | Live. Gated on **Contributor**, like the file writes       |
| `/x402/labs/createAnnouncement` gateway endpoint         | Live, still in `X402_WRITE_MUTATIONS`, still priced        |
| `LabEventAnnouncement` in the `LabActivityNode` union    | Live — returned by unfiltered `labActivity` / `activities` |
| `SearchLabsAnnouncementHit` in the `SearchLabsHit` union | Live — returned by `searchLabs`                            |
| `LabActivityFilter.ANNOUNCEMENT`                         | Live                                                       |

**Migration:** None required; existing integrations keep working. Do not add new dependencies on announcements. If you consume `labActivity` or `activities` and want a file-only feed, pass `filter: FILE` rather than assuming one — the unfiltered feed still contains announcement nodes for labs that have them. If you switch on `__typename` across `LabActivityNode` or `SearchLabsHit`, keep the announcement arms handled. See [Browse & Search](/api-reference/labs-api/browse-and-search).

### Assignment Agreement is no longer a gate, and is out of the API docs

Signing the assignment agreement is **not** a precondition for `createLab`, for uploading files, or for any other Labs API operation. It was previously presented as a required onboarding step, and the `<AGREEMENT_TYPE>_NOT_SIGNED` / `AGREEMENT_CHECK_UNAVAILABLE` failure causes are now dormant — reserved in the catalogue, but not emitted.

The `legalAgreement*` operations remain in the schema and are unchanged, but they have been removed from every onboarding and reference flow, and [Legal Agreements](https://github.com/moleculeprotocol/docs/tree/main/api-reference/labs-api/legal-agreements.md) is out of the site navigation. Do not build a new integration around them.

**Migration:** Delete any agreement-signing step from your workflow — it does nothing. If you branch on an agreement status before writing, remove the branch. Nothing in the API changed; only what is required of you did. The onboarding tutorials no longer include the step: [Tutorials](/api-reference/getting-started).

### Staging introspection is the supported way to get the schema

Production has introspection disabled (below), but **staging has it enabled** — point codegen, a playground or an SDK generator at `https://staging.graphql.api.molecule.xyz/graphql` with your consumer credential and generate normally. Both environments serve the same schema, so generate against staging and point the generated client at production.

This supersedes the earlier guidance to request a copy of the schema from the Molecule team.

**Migration:** If your codegen currently fails against production, repoint it at staging. See [Getting the schema](/api-reference/getting-started#getting-the-schema).

### 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 — introspect **staging** instead, where it is enabled, and point the generated client at production (see [Getting the schema](/api-reference/getting-started#getting-the-schema)). 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\"}"`; on a thrown query error, `errorInfo.details` is a plain object. The in-band string is currently encoded twice, so read it with the tolerant [`parseDetails`](/api-reference/labs-api#error-handling) rather than a single `JSON.parse`, which returns another string and makes `.reason` silently `undefined`. 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.                                | `AUTH_FAILED`, `SERVICE_AUTH_FAILED`, `INVALID_SIGNATURE`, `NONCE_NOT_FOUND`, `NONCE_EXPIRED`, `WALLET_MISMATCH`, `CONSUMER_CREDENTIAL_REQUIRED` |
| `UNAUTHORIZED`              | false       | Authenticated but not allowed (role or membership).                     | `UNAUTHORIZED` (role/membership denial), `NOT_LAB_OWNER`, `MUTATION_NOT_ALLOWED`                                                                 |
| `NOT_FOUND`                 | false       | The referenced resource does not exist.                                 | `LAB_NOT_FOUND`, `OCL_NOT_FOUND`, `TOKEN_NOT_FOUND`, `PROJECT_NOT_FOUND`, …                                                                      |
| `VALIDATION_FAILED`         | false       | Input failed validation; `details.field` names the offending field.     | `INVALID_OCL_ID`, `INVALID_INPUT`, `MISSING_INPUT`, …                                                                                            |
| `CONFLICT`                  | false       | A valid request conflicts with current state.                           | `SHORTNAME_TAKEN`, `PROJECT_CONFLICT`, `ACCOUNT_NAME_CONFLICT`, `ALREADY_REVOKED`                                                                |
| `FAILED_PRECONDITION`       | false       | Resource state makes the operation impossible until that state changes. | `TOKEN_REVOKED`, `LEGACY_ENCRYPTION`, `NOT_ENCRYPTED`, `MISSING_DEK`                                                                             |
| `COMPLEXITY_LIMIT_EXCEEDED` | false       | Query shape or result size is over the limit.                           | `COMPLEXITY_LIMIT_EXCEEDED`                                                                                                                      |
| `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.                | `TOKEN_GENERATION_FAILED`, `CREATE_LAB_FAILED`, `UPLOAD_INIT_ERROR`, `KMS_ERROR`, …                                                              |

Codes may be added over time, and each addition is published 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 (finishCreateOrUpdateFile) — select `error` instead of `isSuccess`
  mutation FinishCreateOrUpdateFile($oclId: String!, $uploadToken: String!, $path: String!) {
    finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path) {
-     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 } = parseDetails(result.error.details); // tolerant parse, see Error Handling
+   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`                | —                                                                                                         |
| `projectAnnouncementsV2` / `projectAnnouncementV2` | `labActivity` / `activities` | Use the `filter: ANNOUNCEMENT` argument (announcements [since deprecated](#announcements-are-deprecated)) |
| `activitiesV2`                                     | `activities`                 | —                                                                                                         |

#### Renamed mutations

| Legacy (removed)               | Current                      | Notes                                                                                                                              |
| ------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `createProject`                | `createLab`                  | Now takes `input: { oclId }` instead of `ipnftSymbol` / `ipnftTokenId`                                                             |
| `createAnnouncementV2`         | `createAnnouncement`         | Takes `oclId`; the legacy `moleculeAccessLevel` param was removed. Announcements [since deprecated](#announcements-are-deprecated) |
| `initiateCreateOrUpdateFileV2` | `initiateCreateOrUpdateFile` | —                                                                                                                                  |
| `finishCreateOrUpdateFileV2`   | `finishCreateOrUpdateFile`   | —                                                                                                                                  |
| `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.

***

## x402 Gateway

### Gateway base URLs published, and the 402 challenge is a header

The staging and production gateway base URLs are now published on the [x402 Gateway](/api-reference/x402-gateway#gateway-base-urls) page — they no longer have to be requested.

Along with them, one correction that matters for anyone implementing the handshake: the payment requirements arrive as **base64-encoded JSON in the `payment-required` response header**, not in the `402` response body. The body is only `{"isSuccess":false,"message":"Payment required"}`. Client code that parsed the body for `accepts` never saw a price.

**Migration:** Read the challenge from the `payment-required` header and base64-decode it; take `amount`, `asset`, `network` and `payTo` from `accepts[0]`. `amount` is in the asset's smallest unit (USDC has 6 decimals, so `"10000"` is $0.01). Worked example: [Reading the 402 challenge](/api-reference/x402-gateway#reading-the-402-challenge).

Also worth knowing: payment buys a short-lived service token for the payer wallet, **not** a role. A mutation the payer is not authorized for returns `200` with `error.code: "UNAUTHORIZED"` and is still settled — check the target lab and your role on it (both free, public queries) before signing.

***

### 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
query GetAgreements($id: ID!) {
  ipnft(id: $id) {
    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](broken://pages/HOhmZBFw3Ea3o7jJqG1c) 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 and upload research data — 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), 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, 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     | 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.

Every non-secret value is published: the GraphQL endpoints on [API Overview](/api-reference/api-reference), the [x402 Gateway base URLs](/api-reference/x402-gateway#gateway-base-urls), and the contract addresses in the [Contracts reference](/references/contracts). The only thing you have to request is a `mol_` consumer credential — see [Getting Started](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step) for the template.

**Ready-to-paste values per environment:**

| Variable                      | Staging                                                          | Production                                                       |
| ----------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- |
| `ENVIRONMENT`                 | `staging`                                                        | `production`                                                     |
| `CHAIN_ID`                    | `84532`                                                          | `8453`                                                           |
| `MOLECULE_LABS_URL`           | `https://staging.graphql.api.molecule.xyz/graphql`               | `https://production.graphql.api.molecule.xyz/graphql`            |
| `MOLECULE_CLIENT_URL`         | `https://testnet.labs.molecule.xyz`                              | `https://labs.molecule.xyz`                                      |
| `X402_GATEWAY_URL`            | `https://0go1j7o645.execute-api.eu-central-2.amazonaws.com/prod` | `https://0qb5gyw72f.execute-api.eu-central-2.amazonaws.com/prod` |
| `ONCHAIN_LAB_FACTORY_ADDRESS` | `0xd629FE2310b4309a212495F10A47f8436dcEfD90`                     | `0xECdF4f05384056507485C90aeAb0a83268760D6E`                     |
| `LABNFT_ADDRESS`              | `0x13Ff210695fdb54A7F928ECcc28BC3486c05BB28`                     | `0x9F96027eeAFb9ad5F2b5d7043B36Ee96B2EeBE92`                     |
| `ACCESS_RESOLVER_ADDRESS`     | `0x5493F472602C87318EA5Eff753cDD593bf9bF559`                     | `0x89a14Be8f7824d4775053Edad0f2fA2d6767b72B`                     |

Run **`config_doctor`** after setting these: it reports which environment profile and wallet backend are active and names exactly which configuration is still missing, instead of letting a tool guess.

| 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                                                                                                                             |
| `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
```

The one value you have to request is a `mol_` **consumer credential** — ask on our [Discord community](https://t.co/L0VEiy4Bjk) using the [template in Getting Started](/api-reference/getting-started#1-a-mol-consumer-credential-the-one-manual-step). Everything else — endpoints, gateway base URLs, contract addresses — is in the [Configuration](#configuration) table above. 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 (frontmatter abridged):

```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). 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 and contract addresses from the Configuration table, and your secrets
```

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

* [Getting Started](/api-reference/getting-started) — the ways in, prerequisites and costs; this plugin is the one for AI coding agents
* [Glossary](/references/glossary) — every Molecule term these docs use, defined in a sentence
* [Tutorials](/api-reference/getting-started) — the same workflow as raw GraphQL, if you want to see the calls underneath
* [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


# Glossary

Every Molecule-specific term used in the API docs, defined in one or two sentences, with a link to the page that goes deeper.

If a term in the tutorials is unfamiliar, it is defined here. Each entry is short on purpose — follow the link when you need the full picture.

***

## The Lab

**Lab** — the core object you build against. A Lab is an NFT that owns its own smart-contract wallet, so it can hold assets, store research files, and grant other people access to them. One research project, one Lab. Full model: [Molecule Labs](/technical-deep-dive/onchain-lab).

**LabNFT** — the ERC-721 token that represents a Lab. Whoever holds it controls the Lab. Minting a LabNFT is the onchain step that brings a new Lab into existence; you do it once, before any API call can attach data to it.

**Lab account (Token Bound Account, TBA)** — the smart-contract wallet permanently bound to the LabNFT (ERC-6551). It has no private key of its own: it takes its authority from whoever currently holds the NFT. Its address is the Lab's permanent identity, and it does not change when the NFT is sold or transferred.

**OCL / `oclId`** — "onchain lab". `oclId` is the Lab's canonical identifier: a 32-byte hex string (`0x…`) emitted in the `OclIdentityCreated` event when the LabNFT is minted. Every Labs API call that targets a Lab takes this value. How it is derived: [Lab Management](/api-reference/labs-api/lab-management#how-oclid-is-derived).

**`shortname`** — the slug in a Lab's public page address, `/projects/<slug>`. Until the Lab is renamed, the slug is `lab-<tokenId>`, built from the LabNFT's token id. Once it is renamed, `shortname` is derived server-side from the new name and the `lab-<tokenId>` form stops resolving. `oclId` is not a page slug and never resolves in this URL.

***

## Data

**Data room** — a Lab's file store. Every file you upload lands in the Lab's data room at a `path` you choose, and the data room keeps every version of it rather than overwriting.

**Kamu** — the data layer behind the data room. It records each file's version history, content hash, author and provenance. You never call Kamu directly; the Labs API does it for you. Details: [Data Storage](/technical-deep-dive/data/data-storage).

**`accessLevel`** — whether a file is `PUBLIC` (stored as-is, readable by anyone) or confidential (encrypted before upload, readable only by wallets that pass its access conditions).

**DEK (data encryption key)** — a fresh AES-256-GCM key generated per confidential file. You encrypt the file with it locally, and the API stores the key in wrapped form. It is released to you only when an onchain check confirms your wallet still qualifies. Details: [Data Privacy & Access](/technical-deep-dive/data/data-privacy-and-access).

**Access control conditions** — the rules attached to a confidential file that decide who may decrypt it, evaluated against live onchain state at the moment of the request rather than at upload time.

***

## Access and roles

**Owner** — the wallet holding the LabNFT. Passes every permission check and is the only role that can grant Contributor.

**Contributor** — an explicit onchain grant (`ROLE_CONTRIBUTOR = 2`). Can read and write the data room and grant Viewers, but cannot add other Contributors or transfer the NFT. This is the role an agent needs in order to upload.

**Viewer** — an explicit onchain grant (`ROLE_VIEWER = 1`). Read-only, including decrypting confidential files.

Role checks are hierarchical: a Contributor passes Viewer checks, and the Owner passes everything. Full matrix: [Roles & Permissions](/technical-deep-dive/roles-and-permissions).

**AccessResolver** — the contract that holds those role grants and answers `hasRole`. Granting a role is an onchain transaction sent by the Lab owner. Reference: [AccessResolver](/references/contracts/accessresolver).

***

## Calling the API

**Consumer credential** — the `mol_<consumerId>_<secret>` string that identifies your integration. It goes in the `Authorization` header on every request, with **no `Bearer` prefix**, and it is issued per environment. This is the one credential you have to request from the Molecule team.

**Service token** — proof that you control a particular wallet. You issue it yourself by signing a message with that wallet, then send it as the `X-Service-Token` header. What it lets you write is decided by that wallet's onchain role on the target Lab, not by the token itself. Reference: [Service Tokens](/api-reference/labs-api/service-tokens).

**Privy user token** — the alternative to a service token, used when a human is signed in through the Molecule app rather than a script. It is the one credential that does use `Authorization: Bearer …`. Reference: [Authentication](/api-reference/authentication).

**EOA (externally owned account)** — an ordinary wallet controlled by a private key, as opposed to a smart-contract wallet. This is what you sign with and what pays gas when you mint a LabNFT yourself.

**x402** — a gateway that lets you pay per request in USDC on Base instead of holding a long-lived service token. The price comes back in the `402` response; read it from there rather than hardcoding it. Reference: [x402 Gateway](/api-reference/x402-gateway).

**Indexer / indexer lag** — onchain events reach the API through an indexing service, which takes a moment to catch up. A transaction that has confirmed onchain is therefore not immediately visible to the API, which is why a write straight after a mint or a role grant can fail and should be retried rather than treated as a real error.

**Staging vs production** — staging runs on Base Sepolia with testnet funds and nothing costs real money; production runs on Base mainnet. Credentials are per environment and are not interchangeable.


# 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)


# 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>" %}


