Verifiable data and query middleware for the Portaldot V3 chain.
Portaldot V3 runs the same chain in two execution environments: ink!/WASM contracts through
pallet_contracts, and Solidity contracts through the EVM endpoint. That gives builders a
choice, and it hands every consumer of the chain a problem: the two environments expose
different event models, there is no shared index, and an application that reads the chain
through somebody's indexer has no way to check what it is told.
Rootmark indexes both environments into one record shape, serves them through a query API, and commits a Merkle root of every batch of records on chain. A reader can fetch a result with its Merkle path, rebuild the root in their own process, and compare it with the root the anchor contract holds. If the indexer invents a record, the proof fails.
Built for the Portaldot Hacker House 2026, track 1, Modular Cloud and Middleware. Pages: / landing, /about why it exists, /console query and verify, /anchors batches, /contracts live state, /privacy note — full map in public/llms.txt and /sitemap.xml.
Demo video (30 s, real screens, cursor clicks Verify): https://youtu.be/HhubgdyP3U8
- Tails the EVM endpoint and the substrate node at the same time and normalises both into one
EventRecord:{ vm, chainId, blockNumber, blockHash, txHash, contract, event, args, leaf }. - Decodes Solidity logs with the contract ABIs the project knows, and ink! events, including
the
Anchoredevent emitted by the ink! anchor contract. - Groups records into batches, hashes each record into a leaf, builds a Merkle tree, and
commits the root to two contracts:
PortalAnchoron the EVM side andanchoron the ink! side. - Serves the index over a small REST API and a console that lets you query, inspect, and verify any record against on-chain state.
The same picture in text, for reading in a terminal:
Portaldot V3 testnet, chain 420420777
┌───────────────────────────┬──────────────────────────────┐
│ EVM endpoint │ Substrate node │
│ test-evm-rpc-v3... │ test-node-v3... │
│ Solidity, logs │ ink!/WASM, typed events │
└─────────────┬─────────────┴──────────────┬───────────────┘
│ eth_getBlockByNumber │ system.events
│ eth_getTransactionReceipt │ at(blockHash)
▼ ▼
┌─────────────────────────────────────────┐
│ indexer (npm run indexer) │
│ normalise, hash each record into a leaf│
└───────────────┬─────────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ SQLite │ │ batch every N │
│ events, anchors │ │ records: Merkle root │
└────────┬─────────┘ └──────────┬───────────┘
│ │ anchor(root)
│ ▼
│ ┌──────────────────────────────┐
│ │ PortalAnchor.sol (EVM) │
│ │ anchor.ink (WASM) │
│ └──────────────┬───────────────┘
▼ │ latestRoot()
┌──────────────────────┐ │
│ Next.js API + console│◄───────────┘
│ /api/v1/... │ reads the root back from the chain
└──────────────────────┘
The console recomputes the root in the browser from the leaf and the sibling path. The server never gets to say "trust me".
Requires Node 22 or newer. Node 24 is what this was built and tested on.
npm install
# 1. index both execution environments (writes to ./data/portalidx.db)
npm run indexer
# 2. in a second terminal, run the console
npm run dev
# open http://localhost:3000Without a signing key the indexer still indexes, and batches are recorded as unsigned. That
is stated plainly in the interface rather than hidden.
Both contracts need testnet tPOT. Print the addresses a mnemonic controls first:
PORTALDOT_MNEMONIC="word word word ..." npm run walletThat prints three things:
| Address | What it is for |
|---|---|
| EVM address | the deployer on the EVM side |
| gas account | the SS58 account that actually holds the tPOT paying for EVM gas |
| native sr25519 | the deployer for ink! |
The EVM address and the gas account are not the same string, and the balance lives in the gas
account. Portaldot maps an H160 to a 32 byte account by keeping the 20 address bytes and
padding with 0xee. A known answer test for that mapping, taken from the official V3 testnet
guide, lives in tests/address.test.ts.
Request testnet tokens for the addresses above, then:
npm run compile:evm
PORTALDOT_MNEMONIC="..." npm run deploy:evm
# needs the Rust toolchain and cargo-contract
npm run build:wasm
PORTALDOT_MNEMONIC="..." npm run deploy:wasmBoth deploy scripts write contracts/deployments.json, which the app and the indexer read, so
no environment variable is needed afterwards. Start the indexer with the same mnemonic to have
it anchor each batch:
PORTALDOT_MNEMONIC="..." npm run indexerLive on the Portaldot V3 testnet, chain 420420777.
| Chain | Contract | Address | Transaction |
|---|---|---|---|
| Portaldot V3 testnet, EVM | PortalAnchor |
0x92B79C5cD9CC8235e3e2718a739BE825e2bd5430 |
0x6e33aa2d09f1a32b076df8297529e8cf87f57bb9a777570ed2624ec9e822a923 (block 204392) |
| Portaldot V3 testnet, EVM | Emitter (test events) |
0x178B9ED9dB766d2ecda393ca0b780A643a12C31E |
0x618ca3df675f64d0cf4cecfdc9a807c9df01031ed4a73dcf6c410720d2b759e2 (block 204668) |
| Portaldot V3 testnet, WASM | anchor |
not yet deployed | deploy with npm run deploy:wasm once the native account is funded |
All 4 anchors on PortalAnchor, read back from contract storage on 2026-11-02 (explorer: 0x92B79C5cD9CC8235e3e2718a739BE825e2bd5430, chain 420420777):
- batch
0xbbc2596f…36277a8croot0x7b32f4cb…dbaf448b, count 7, blocks 204669 to 204672, tx0x3c3659c1…(block 204686) - batch
0x1f7a6813…834145d795root0x1ef46c61…05b07cc0, count 1, block 204686, tx0x7c5160f6…(block 204709) - batch
0xa66a8338…85ae15b8a5root0x624facad…faca372f78, count 3, blocks 204669 to 204672, tx0xf1cbeeb2…(block 204768) - batch
0x068e2ffd…0bf8f798root0x3aa9be70…c0c7f21083, count 1, record block 204924, anchor tx0x3f5a2af5…mined in block 204947 - this is the live tip shown on the landing page. Separator note: snapshot records 11 to 13 belong to an unsigned batch (0x30e538e6…), so liveGET /api/v1/proof/11returnsanchorStatus: unsigned,onChainRoot: null,verified: true(local Merkle recompute passes). For a contract-backed proof, anchor the batch withnpm run indexerplus a key, or check batch0x068e2ffd…withgetAnchordirectly.
contracts/deployments.json is gitignored as live state; the addresses above are the public record. Reward payout address for this deployment: 5FjPSC5abXRfgNyNUQ5bQPiQC48mhk8PND7TCQovrdB1PpHt (native sr25519).
The fastest path from a fresh clone to a deployed setup is:
export PORTALDOT_MNEMONIC="word word word ..."
npm run setupThe public site at https://rootmark-seven.vercel.app is a stateless deployment.
Vercel functions cannot run a long lived indexer or keep a SQLite file between requests,
so the site ships with a fixed snapshot of 26 indexed records and 8 batches seeded into
an in memory database on cold start, plus the one on chain tip batch #00
(0x3aa9…) read live from PortalAnchor storage. Every count on the page is a real
count, and no batch pretends to be new. The 8 snapshot batches are unsigned by
design: their batch ids were never written to the contract, so GET /api/v1/proof/:id
returns anchorStatus: unsigned with onChainRoot: null and a locally recomputed root
(verified: true means the Merkle math checks out, not that the chain agrees).
The 4 real on-chain anchors live in PortalAnchor storage and are enumerated below;
the tip row on the landing page is batch 0x068e2ffd… root 0x3aa9… covering block 204924, anchor tx 0x3f5a2af5… mined in block
204947, count 1.
For a live, self extending run that picks up new records and anchors batches, run the indexer locally. That is the path judges should use.
Everything below assumes a fresh clone and Node 22 or newer.
-
Install and verify the build:
npm install npm run typecheck npm test npm run build -
Index the live V3 testnet (unsigned, no key needed):
npm run indexer # in another terminal npm run dev # open http://localhost:3000
The console at
/consolere-queries on load and/anchorsloads its batches on load, so records and batches appear without pressing anything. -
To exercise the full signed path (new records plus anchoring), fund a mnemonic:
PORTALDOT_MNEMONIC="word word word ..." npm run wallet # request tPOT for the printed gas account and native sr25519 address PORTALDOT_MNEMONIC="..." npm run indexer
With a key present the indexer anchors each full batch to
PortalAnchoron the EVM side. To produce a new record on demand:PORTALDOT_MNEMONIC="..." npm run emit -- --note "judge run" --value 7
The indexer picks the
Pingup within ~6 seconds,/consoleshows it, and/anchorsshows the new batch once 25 records accumulate (or 90 seconds pass). -
Verify any record end to end:
npm run verify:proof -- 42 # or press Verify on any row in /consoleThe proof panel recomputes the Merkle root in the browser and compares it with the root read back from
PortalAnchorstorage. -
Optional: stand up a local chain that mirrors the testnet chain id (no tokens):
npm run local-evm # ganache on 8545, deploys anchor + emitter, sends 3 Pings PORTALIDX_EVM_RPC=http://127.0.0.1:8545 PORTALDOT_MNEMONIC="test test test test test test test test test test test junk" npm run indexer PORTALIDX_EVM_RPC=http://127.0.0.1:8545 npm run dev
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/events |
query records, filters vm, contract, event, address, fromBlock, toBlock, cursor, limit |
| GET | /api/v1/events/:id |
one record |
| GET | /api/v1/proof/:id |
leaf, Merkle path, anchored root, on-chain root, verdict |
| GET | /api/v1/anchors |
anchored batches |
| GET | /api/v1/contracts |
live anchor contract state read from the chain |
| GET | /api/v1/filters |
distinct contracts and events for the query builder |
| GET | /api/v1/health |
indexed counts, chain heads, anchoring status |
Every query parameter is validated on the server. An unknown environment, a malformed address, a negative block, or an inverted block range returns a 400 with a readable message.
From the console, press Verify on any row and the panel shows the leaf, the path, the root rebuilt in the browser, and the root in contract storage.
From the command line:
npm run verify:proof -- 42npm test # 38 unit tests: leaf hashing, Merkle proofs, address mapping, query validation
npm run typecheck
npm run lint
npm run buildapp/ Next.js routes: landing (/), console, anchors, contracts, /api/v1
src/landing/ landing page (own visual world, shared proof data)
src/lib/ proof, hashing, database, chain clients, query services
src/components/ design system primitives and the console
indexer/ the indexer and its two chain sources
contracts/evm/ PortalAnchor.sol and its compiled artifact
contracts/wasm/anchor/ the ink! anchor contract
scripts/ compile, deploy, wallet, proof verification
tests/ unit tests
| Route | Purpose |
|---|---|
/ |
landing: hero, how it works, proof in one sentence, numbers, FAQ, ledger paper |
/about |
why the middleware exists, four hash verification, live contracts, track fit, honest limits |
/console |
query and verify every record (one shape, one proof) |
/anchors |
all anchored batches, Merkle roots committed on chain |
/contracts |
anchor contract state live from chain |
/privacy |
privacy note, no tracking, wallet guidance |
SEO: metadataBase plus per route canonical, openGraph and twitter with /landing/ledger-paper.jpg 1920x1485, full favicon set (/favicon.ico, /icon.png, /favicon.svg, /apple-icon.png), app/manifest.ts (PWA, theme #062b8a, bg #e6dbc7), app/opengraph-image.tsx (1200x630, edge), robots.txt, sitemap.xml, llms.txt, custom 404, breadcrumbs plus JSON-LD.
Why anchor at all. An index is a convenience until it is checkable. Committing a Merkle root per batch costs one transaction and turns "believe my database" into "check this hash", without putting the whole index on chain.
Why one record shape. A consumer should not need to know whether a transfer came from
balances.Transfer or an ERC-20 Transfer log to query it. The vm field keeps the origin
visible without splitting the query surface.
Why permissionless anchoring. Anyone can add a batch, and a batch id can only be claimed once, so a later writer cannot replace a root that was already committed. The cost is that the batch space can be spammed, which the MVP accepts.
- The ink! side is anchored through a
contracts.callextrinsic with a fixed weight estimate. If a call needs more weight thanrefTime: 5_000_000_000, it will fail and the batch stays EVM anchored only. - Reorgs are not handled. The indexer reads the head rather than waiting for finality, which is fine on a testnet with a quiet chain but would need a finality lag on a busy one.
- The Merkle tree duplicates the last node on an odd level. This is simple and verifiable, but it is not the same construction as RFC 6962, so proofs are not portable to other tree implementations.
- SQLite is a single writer. One indexer process is assumed.