This repo houses the Ruby app and Solidity predeploys that build the Ethscriptions chain on top of Ethereum. It started life as a Postgres-backed indexer; it now runs the derivation pipeline that turns L1 activity into canonical L2 blocks. You run it alongside an ethscriptions-geth execution client.
- Overview
- Run with Docker Compose
- How Ethscriptions Work
- Protocol System
- ERC-721 Collections Protocol
- ERC-20 Fixed Denomination Tokens
- Technical Architecture
- Validator
- Local Development
- Directory Structure
- Testing
- Observe Ethereum L1 via JSON-RPC. The importer follows L1 blocks, receipts, and logs to find Ethscriptions intents (Data URIs plus ESIP events).
- Translate matching intents into deposit-style EVM transactions that call Ethscriptions predeploy contracts (storage, transfers, collections tooling).
- Send those transactions to geth through the Engine API, producing new L2 payloads. Geth seals the block, the predeploys mutate state, and the chain advances with the Ethscriptions rules baked in.
The result is an OP-style Stage-2 "app chain" that keeps Ethscriptions UX unchanged while providing Merkle-state, receipts, and compatibility with standard EVM tooling.
- Ruby derivation app — importer loop and Engine API driver; it is meant to stay stateless across runs.
- Solidity contracts — the Ethscriptions and token/collection predeploys plus Foundry scripts for generating the L2 genesis allocations. The Ethscriptions contract stores content with SSTORE2 chunked pointers and routes protocol calls through on-chain handlers.
- Genesis + tooling — scripts in
lib/andcontracts/script/to produce the genesis file consumed by geth. - Reference validator — optional job queue that compares L2 receipts/storage against a reference Ethscriptions API to make sure derivation matches expectations.
Anything that executes L2 transactions (the ethscriptions-geth client) runs out-of-repo. This project focuses on deriving state and providing reference contracts.
Ethscriptions behavior and APIs remain identical to the pre-chain era: inscribe and transfer as before, and existing clients can keep using the public API. The difference is that the data now lives in an L2 with cryptographic state, receipts, and interoperability with EVM tooling.
- Docker Desktop (includes the Compose plugin)
- Access to an Ethereum L1 RPC endpoint (archive-quality recommended for historical sync)
# 1. Copy the environment template
cp docker-compose/.env.example docker-compose/.env
# 2. Edit .env with your settings (see Environment Reference below)
# At minimum, set L1_RPC_URL to your L1 endpoint
# 3. Bring up the stack
cd docker-compose
docker compose --env-file .env up -d
# 4. Follow logs while it syncs
docker compose logs -f node
# 5. Query the L2 RPC (default port 8545)
curl -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
# 6. Shut down when done
docker compose downThe stack runs two containers:
| Service | Description |
|---|---|
geth |
Ethscriptions-customized Ethereum execution client (L2) |
node |
Ruby derivation app that processes L1 data into L2 blocks |
The node waits for geth to be healthy before starting. Both services communicate via a shared IPC socket.
Key variables in docker-compose/.env:
| Variable | Description | Default |
|---|---|---|
COMPOSE_PROJECT_NAME |
Docker resource naming prefix | ethscriptions-evm |
JWT_SECRET |
32-byte hex for Engine API auth (must match geth) | — |
L1_NETWORK |
Ethereum network (mainnet, sepolia, etc.) | mainnet |
L1_RPC_URL |
Archive-quality L1 RPC endpoint | — |
L1_GENESIS_BLOCK |
L1 block where the rollup anchors | 17478949 |
GENESIS_FILE |
Genesis snapshot filename | ethscriptions-mainnet.json |
GETH_EXTERNAL_PORT |
Host port for L2 RPC | 8545 |
| Variable | Description | Default |
|---|---|---|
L1_PREFETCH_FORWARD |
Blocks to prefetch ahead | 200 |
L1_PREFETCH_THREADS |
Prefetch worker threads | 10 |
JOB_CONCURRENCY |
SolidQueue worker concurrency | 6 |
JOB_THREADS |
Job worker threads | 3 |
| Variable | Description | Default |
|---|---|---|
GC_MODE |
full (pruned) or archive (full history) |
full |
STATE_HISTORY |
State trie history depth | 100000 |
TX_HISTORY |
Transaction history depth | 100000 |
ENABLE_PREIMAGES |
Retain preimages | true |
CACHE_SIZE |
State cache size | 25000 |
| Variable | Description | Default |
|---|---|---|
VALIDATION_ENABLED |
Enable validator against reference API | false |
ETHSCRIPTIONS_API_BASE_URL |
Reference API endpoint | — |
ETHSCRIPTIONS_API_KEY |
API authentication key | — |
Ethscriptions are digital artifacts created by encoding data in Ethereum transaction calldata or emitting specific events. This section explains how to create and transfer them.
Send a transaction to any address with hex-encoded Data URI as calldata:
To: 0xAnyAddress
Value: 0 ETH
Data: 0x646174613a2c48656c6c6f (hex of "data:,Hello")
The derivation node recognizes the Data URI pattern and creates an ethscription:
- Creator: The transaction sender (
msg.sender) - Initial Owner: The transaction recipient (
toaddress) - Content: Decoded payload from the Data URI
Smart contracts can create ethscriptions by emitting:
event ethscriptions_protocol_CreateEthscription(
address indexed initialOwner,
string contentUri
);This allows contracts to programmatically create ethscriptions on behalf of users.
The basic format:
data:[<mediatype>][;base64],<data>
Examples:
data:,Hello World # Plain text
data:text/plain,Hello World # Explicit MIME type
data:image/png;base64,iVBORw0KGgo... # Base64-encoded image
data:application/json,{"name":"test"} # JSON data
Extended format with protocol parameters:
data:<mediatype>;rule=esip6;p=<protocol>;op=<operation>;d=<base64-params>;base64,<content>
| Parameter | Description |
|---|---|
rule=esip6 |
Allow duplicate content URIs |
p=<protocol> |
Protocol handler name |
op=<operation> |
Operation to invoke on handler |
d=<base64> |
Base64-encoded operation parameters |
Send a transaction with the ethscription ID (L1 tx hash) as calldata:
Single transfer (32 bytes):
To: 0xRecipient
Value: 0 ETH
Data: 0xa1654c9db8847e197bbc72c880d1c269d974b15a2e606e4f5b1be2c5da81ba86
Batch transfer (multiple of 32 bytes, ESIP-5):
To: 0xRecipient
Value: 0 ETH
Data: 0x<txhash1><txhash2><txhash3> (concatenated 32-byte hashes)
Contracts can transfer by emitting:
ESIP-1 (basic transfer):
event ethscriptions_protocol_TransferEthscription(
address indexed recipient,
bytes32 indexed ethscriptionId
);ESIP-2 (with previous owner validation):
event ethscriptions_protocol_TransferEthscriptionForPreviousOwner(
address indexed previousOwner,
address indexed recipient,
bytes32 indexed ethscriptionId
);| ESIP | Name | Description |
|---|---|---|
| ESIP-1 | Event Transfers | Transfer via TransferEthscription event |
| ESIP-2 | Previous Owner Validation | Transfer with chain-of-custody check |
| ESIP-3 | Event Creation | Create via CreateEthscription event |
| ESIP-5 | Batch Transfers | Multiple 32-byte transfers in one tx |
| ESIP-6 | Duplicate Content | Allow same content URI to be inscribed multiple times |
| ESIP-7 | Gzip Compression | Support for gzip-compressed content |
Consider mainnet transaction 0xa165...ba86:
- From:
0x42e8...d67d - To:
0xC217...a97 - Calldata:
0x646174613a2c6d6964646c656d61726368 - Decoded:
data:,middlemarch
The derivation node:
- Recognizes the Data URI pattern
- Builds creation parameters:
ethscriptionId: The L1 tx hashcontentUriHash: SHA256 of the raw Data URIinitialOwner:0xC217...a97(the recipient)content:middlemarch(decoded bytes)mimetype:text/plain;charset=utf-8
- Creates a deposit transaction calling
Ethscriptions.createEthscription() - The L2 contract stores content via SSTORE2, mints an NFT to the initial owner
Ethscriptions supports pluggable protocol handlers that extend functionality. When an ethscription includes protocol parameters, the main contract routes the call to a registered handler.
-
Registration: Handlers register with the main contract:
Ethscriptions.registerProtocol("my-protocol", handlerAddress);
-
Invocation: When creating an ethscription with protocol params:
data:image/png;p=my-protocol;op=mint;d=<base64-params>;base64,<image> -
Handler Call: The contract calls
handler.op_mint(params)after storing the ethscription.
| Protocol | Purpose |
|---|---|
erc-721-ethscriptions-collection |
Curated NFT collections with merkle enforcement |
erc-20-fixed-denomination |
Fungible tokens with fixed-denomination notes |
Two encoding styles are supported:
Header-based (for binary content like images):
data:image/png;p=erc-721-ethscriptions-collection;op=add_self_to_collection;d=<base64-json>;base64,<image-bytes>
JSON body (for text-based operations):
data:application/json,{"p":"erc-20-fixed-denomination","op":"deploy","tick":"mytoken","max":"1000000","lim":"1000"}
The collections protocol allows creators to build curated NFT collections with optional merkle proof enforcement.
- Collection: A named set of ethscriptions with metadata (name, symbol, description, max supply)
- Items: Individual ethscriptions added to a collection
- Merkle Enforcement: Optional cryptographic restriction on which items can be added
Use the create_collection_and_add_self operation:
data:image/png;rule=esip6;p=erc-721-ethscriptions-collection;op=create_collection_and_add_self;d=<base64-json>;base64,<image>
Where the base64-decoded JSON contains:
{
"name": "My Collection",
"symbol": "MYC",
"maxSupply": 100,
"description": "A curated collection of...",
"logoImageUri": "data:image/png;base64,...",
"bannerImageUri": "data:image/png;base64,...",
"website": "https://example.com",
"twitterHandle": "myhandle",
"discordUrl": "https://discord.gg/...",
"backgroundColor": "#000000",
"merkleRoot": "0x06fbc22a...",
"itemIndex": 0,
"itemName": "Item #1",
"itemBackgroundColor": "#FF0000",
"itemDescription": "The first item",
"itemAttributes": [["Rarity", "Legendary"], ["Color", "Red"]]
}When a collection has a non-zero merkleRoot, non-owners must provide a merkle proof to add items. This ensures only pre-approved items with exact metadata can be added.
How it works:
- Creator generates a merkle tree from approved items
- Each leaf is computed as:
keccak256(abi.encode( contentHash, // keccak256 of content bytes itemIndex, // uint256 name, // string backgroundColor, // string description, // string attributes // (string,string)[] ))
- Creator sets the merkle root when creating the collection
- Non-owners provide proofs when adding items:
{ "collectionId": "0x...", "itemIndex": 1, "itemName": "Item #2", "merkleProof": ["0xaab5a305...", "0x58672b0c..."] }
Owner bypass: Collection owners can always add items without proofs.
| Operation | Description |
|---|---|
create_collection_and_add_self |
Create collection and add first item |
add_self_to_collection |
Add item to existing collection |
edit_collection |
Update collection metadata |
edit_collection_item |
Update item metadata |
transfer_ownership |
Transfer collection ownership |
renounce_ownership |
Surrender ownership |
remove_items |
Delete items from collection |
lock_collection |
Prevent further additions |
For a complete walkthrough with code, see docs/merkle-collection-demo.md.
The fixed-denomination protocol creates fungible tokens where balances move in fixed batches (denominations) tied to NFT notes.
- No direct transfers: ERC-20
transfer()is disabled - Note-based: Each mint creates an NFT "note" representing a fixed token amount
- Coupled movement: Transferring the note automatically moves the ERC-20 balance
- Inscription-driven: All operations flow through ethscription creation/transfer
Create an ethscription with JSON content:
{
"p": "erc-20-fixed-denomination",
"op": "deploy",
"tick": "mytoken",
"max": "1000000",
"lim": "1000"
}| Field | Description |
|---|---|
tick |
Token symbol (lowercase alphanumeric, max 28 chars) |
max |
Maximum total supply |
lim |
Amount per mint note (the denomination) |
After deployment, mint notes with:
{
"p": "erc-20-fixed-denomination",
"op": "mint",
"tick": "mytoken",
"id": "0",
"amt": "1000"
}| Field | Description |
|---|---|
tick |
Token symbol |
id |
Unique note identifier |
amt |
Token amount (typically equals lim) |
When you transfer the mint inscription (the NFT note):
- The inscription moves to the new owner
- The ERC-20 balance automatically moves with it
- Both the ERC-721 note and ERC-20 balance are synchronized
This ensures tokens can only move in fixed denominations and are always tied to their note NFTs.
L1 Block
│
▼
┌─────────────────────────────────────────────────────────┐
│ L1 RPC Prefetcher (threaded) │
│ - Fetches blocks, receipts, logs ahead of import │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ EthBlockImporter │
│ - Parses calldata for Data URIs │
│ - Extracts ESIP events from receipts │
│ - Builds EthscriptionTransaction objects │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ GethDriver (Engine API) │
│ - Creates L1Attributes system transaction │
│ - Combines with ethscription transactions │
│ - engine_forkchoiceUpdatedV3 │
│ - engine_getPayloadV3 │
│ - engine_newPayloadV3 │
└─────────────────────────────────────────────────────────┘
│
▼
L2 Block Sealed
│
▼
┌─────────────────────────────────────────────────────────┐
│ Ethscriptions Contract (L2) │
│ - createEthscription() → SSTORE2 storage │
│ - transferEthscription() → NFT transfer │
│ - Protocol handler invocations │
│ - Events emitted │
└─────────────────────────────────────────────────────────┘
The derivation node creates EIP-2718 type 0x7d deposit transactions:
Type: 0x7d (Deposit)
Fields:
- sourceHash: Unique identifier derived from L1 tx
- from: Original L1 sender (spoofed via deposit semantics)
- to: Ethscriptions contract address
- mint: 0 (no ETH minted)
- value: 0
- gasLimit: Allocated gas for execution
- isSystemTx: false
- data: ABI-encoded contract call
Deposit semantics allow the derivation process to set msg.sender to the original L1 transaction sender, even though the payload is submitted by the node.
Large content is stored using SSTORE2:
- Content is split into chunks
- Each chunk is deployed as contract bytecode
- Pointers are stored in the main contract
- Retrieval concatenates chunks
Benefits:
- Cheaper than SSTORE for large content
- Content is immutable
- Gas-efficient reads
Each deposit transaction gets a unique sourceHash following Optimism conventions:
sourceHash = keccak256(
domain (0) ||
keccak256(blockHash || sourceTypeHash || selector || sourceIndex)
)
This ensures deterministic, reproducible derivation.
The validator reads expected creations/transfers from your Ethscriptions API and compares them with receipts and storage pulled from geth. It pauses the importer when discrepancies appear so you can investigate mismatches or RPC issues.
VALIDATION_ENABLED=true
ETHSCRIPTIONS_API_BASE_URL=https://your-api-endpoint.com
ETHSCRIPTIONS_API_KEY=your-api-keyThe temporary SQLite databases in storage/ and the SolidQueue worker pool exist only to support this reconciliation. Once historical import is verified, the goal is to remove that persistence and keep the derivation app stateless.
If you want to modify the Ruby code outside of Docker:
# Install Ruby 3.4.x (via rbenv, rvm, or asdf)
ruby --version # Should show 3.4.x
# Install dependencies
bundle install
# Initialize local SQLite files
bin/setup
# Run the derivation (requires running ethscriptions-geth and L1 RPC)
# See bin/jobs and config/derive_ethscriptions_blocks.rbThe Compose stack is the recommended path for production-like runs.
ethscriptions-indexer/
├── app/
│ ├── models/ # Ethscription transaction models, protocol parsers
│ └── services/ # Derivation logic, Engine API driver
├── contracts/
│ ├── src/ # Solidity predeploys
│ │ ├── Ethscriptions.sol
│ │ ├── ERC721EthscriptionsCollectionManager.sol
│ │ └── ERC20FixedDenominationManager.sol
│ ├── script/ # Genesis allocation scripts
│ └── test/ # Foundry tests
├── docker-compose/
│ ├── docker-compose.yml
│ ├── .env.example
│ └── docs/ # Protocol documentation
├── docs/ # Additional documentation
├── lib/ # Genesis builders, utilities
├── spec/ # RSpec tests
└── storage/ # SQLite databases (validation)
bundle exec rspeccd contracts
forge testbundle exec rspec --format documentation
forge test -vvvOpen an issue or reach out in the Ethscriptions community channels.