Paste a GitHub repo and a Portaldot V3 contract address. OneScan rebuilds the code and tells you whether it is what is really running.
Live app · Demo video · Watch it refuse a false claim · A real proof · All proofs · API · Architecture · Audit
For builders who want to prove a contract, and for judges, integrators and users who want to check someone else's. No wallet, no account.
"This repo says it is the contract at that address. Is it?"
Built for Portaldot Hacker House 2026, track Node Operations & Tooling.
On Portaldot V3, anyone can put a GitHub link next to a contract address. The chain stores only compiled bytes, so nothing shows that the repo is the code that is running. A judge, an integrator and a user all have to take it on trust.
Sourcify and Etherscan solve this on other chains. In my GitHub searches I found nothing doing it for Portaldot V3 EVM contracts, which is the gap OneScan fills. It is not an explorer: for blocks and transactions, use Pindex or the node console. OneScan answers one question.
repo + commit contract address
| |
compile with the exact eth_getCode on the
solc version the chain's Portaldot V3 EVM RPC
bytecode names in its metadata |
| |
+---- strip metadata, mask immutables -----------+
|
same? yes: public proof, badge, on-chain record
no: refused, never listed
- Reads the code at the address with
eth_getCode. Empty code and PolkaVM code are reported as such, not verified. - Reads the solc version from the CBOR metadata at the end of the bytecode.
- Fetches the repo at one exact commit, extracting only Solidity and build config files.
- Resolves imports from the repo, git submodules and npm packages. Build config is read as text. Nothing from the repo is ever executed.
- Compiles with that exact solc release in a worker thread with a timeout, trying the repo's own settings first and then a ladder of common ones.
- Compares runtime bytecode with immutables masked. Identical including the metadata hash is an
exactmatch. Identical apart from metadata is acodematch. - Looks back to the repo's last commit before deployment when the branch you gave no longer matches, because repos move on after they deploy.
- Publishes a match as a proof page and a README badge. Both compare the stored hash with the live code again each time they are opened, and the badge stops saying it matches if the code changes.
- Records the match on the
ProofRegistrycontract, which reverts unless the target'sEXTCODEHASHequals the recorded hash.
Open the check page and paste any of these. Times were measured on the production server, a single CPU, in November 2026.
| Repo | Address | What you should see | Time |
|---|---|---|---|
Firzadf/nodescope |
0xd48da7eff3264bdd51a86b873da9ff6997aae99c |
Exact match | 3 s |
Gieez/portaldot-revivescope |
0xd48da7eff3264bdd51a86b873da9ff6997aae99c |
Refused: a real repo that compiles, but is not this contract. Every setting runs before it says no | 34 s |
Rohitamalraj/DotProof |
0x7e3444c1f9dbd53dea5ebc8160522b154857566b |
Fails on the default branch, then matches at the last commit before deployment | 173 s |
Firzadf/nodescope |
0xeebc363c7af7ed8e888da07e959043517ae4b97d |
PolkaVM contract: unsupported, not guessed at | under 1 s |
These are other builders' public repos, used as test inputs.
From a terminal:
curl -X POST https://onescan-portaldot.vercel.app/api/check \
-H 'content-type: application/json' \
-d '{"repo":"Firzadf/nodescope","address":"0xd48da7eff3264bdd51a86b873da9ff6997aae99c"}'
# {"id":"..."} then poll GET /api/check/<id> until state is matched, no_match or failedA verifier that says "yes" is easy to build. OneScan is built around when it is allowed to say no.
A mismatch, a timeout and an outage are three different answers, and only a completed comparison can say "no". "Does not match" requires that every settings combination ran, on the dependencies the repo pinned, with every source file built. Anything less is reported as unable to finish, never as a verdict against the repo.
I learned this the hard way. The first engine refused 4 pairs in real testing, and all 4 were real matches, missed because of untried settings or code changed after deployment. Those false negatives are why the rule exists.
| Input | Result | Where it is exercised |
|---|---|---|
| Right repo, right address | exact match |
live.test.ts; production |
| Real repo, wrong address, both directions | no_match |
live.test.ts; production |
| Code changed after deployment | Fails on the branch, matches at the last commit before deployment | oz.test.ts; production |
| Metadata stripped from the deployed code | code match using the compiler the repo names, or unable to finish, never no_match |
nometa.test.ts, nometa-unit.test.ts |
| Budget too small to finish a real match | inconclusive, never no_match |
live.test.ts |
| PolkaVM contract | not_evm, unsupported |
live.test.ts; production |
| Address with no code, or a funded wallet | no_code |
live.test.ts; production |
| Repo that does not exist | repo_not_found |
production |
Hostile archive paths (.., absolute) |
Skipped, nothing written outside the work folder | verify.test.ts |
| Source files built to stall the parser | Found in my audit, fixed, linear time now | parse-limits.test.ts |
| Faked visitor headers to dodge the rate limit | Still limited, through the front door and directly at the server | util.test.ts; production |
ProofRegistry.record with a wrong hash, a zero hash, a wallet with no code, or from a stranger |
Reverts with the contract's own error | forge test; deployed contract, simulated |
| Contract deployed from code that was never committed | Cannot match. Listed under limitations | n/a |
Real repos checked against other teams' contracts never matched in any test run.
A read-only scan at block 284,796 found 132 contracts deployed directly on the EVM side of Portaldot V3. I ran OneScan over 43 repo and contract pairs found in public Hacker House repos, through the public site. They are listed in docs/coverage-candidates.json, where two addresses appear twice because two repos name them.
| Result | Count | Notes |
|---|---|---|
| Matched | 40 | Public proof pages |
| Does not match | 0 | No pair in the list was a false claim. Refusals are tested separately, above |
| No verdict | 3 | Two appear hand-written in Yul and one repo has no Solidity. OneScan says so instead of guessing |
With 2 proofs from earlier test runs, that makes 42 public proofs across 32 repos, covering 32 of the 132 direct deployments. The landing page shows the live count. I have not measured adoption: these contracts were checked from public repos, and their teams have not been asked to use OneScan yet.
| Item | Value |
|---|---|
| Network | Portaldot V3 testnet, chain ID 420420777 |
ProofRegistry |
0x2a643655e0c8bc41d0f535462b266ad36237dda1 |
| Deployment | 0x657beae83fe86c825539a2ea7cce51f15876e69ddd19f543912721072539dd55, block 291278 |
| Attester | 0xDc474EfaBB69fAe76c5201A986a06CDA3A696d26 |
Five real projects are recorded so far, each with its stored hash checked against the live code. The first is Firzadf/nodescope, transaction 0xa326887ff8493c4fb6b94bdc782f99f3cc1329f7e1ef4f8a15d46e05e10cf386. The others are Anshv784/potdrive, Rohitamalraj/DotProof, a252937166/ReviveCheck and lazydevpro/reachpot. A record costs about 3.9 tPOT, so the other 37 proofs show none until they are checked again.
What a record means. OneScan's checker attested that the repo matches the contract. It is not trustless, because OneScan holds the attester key. The contract enforces two things: only the attester can write, and a record can only point at code that is live. You do not have to trust it: every proof page lists the steps to repeat the check with git, solc and one eth_getCode call.
Portaldot gives one account two addresses, and that confused me as a user too. Look up any address to see both forms and both balances. From there, your own wallet can map a 5... account so its balance works with 0x tools, or send tPOT to a MetaMask address. OneScan never sees a key.
- A 0x account's 5... form is the address followed by twelve
0xEEbytes. A 5... account's 0x form is the last 20 bytes of its keccak hash. - After
revive.mapAccount, the native balance is usable as the 0x balance, minus the 0.1 tPOT existential deposit. I checked this on-chain. - MetaMask cannot sign for a mapped address, because its key is the 5... wallet.
- tPOT has 14 decimals natively and 18 on the EVM RPC.
- Deployments are expensive:
ProofRegistry, at 1,530 bytes of code, cost about 133 tPOT. 8 of the 43 pairs had metadata stripped from their code, and I suspect cost is why, though that is an inference.
- EVM bytecode from Solidity only. PolkaVM, ink! and Yul sources are not checked.
- Public GitHub repos on the Portaldot V3 testnet only.
- A match says the code is what the repo shows. It is not an audit, and it does not say the code is safe.
- A contract deployed from code that was never committed can never match.
- A contract with no stored compiler version is tried with the version its repo names. A failure there is never a verdict.
- If GitHub's API cannot read a submodule pin, the fetch uses the latest code, and a failed comparison is reported as unable to finish.
- Contracts created by a factory show no creation block or deployer.
- Sending tPOT was tested with SubWallet and real testnet tokens, and confirmed on-chain. Mapping has only run on an account that was already mapped, so a first-time map has not been tried with a real wallet.
- One check runs at a time, on one server, with state in a local SQLite file.
Needs Node 22.13 or later and network access to the Portaldot RPC, GitHub and the Solidity compiler binaries. No environment variable is required.
npm install
npm run dev # http://localhost:3000Then check it answers, and run a real check against it:
curl http://localhost:3000/api/pulse
curl -X POST http://localhost:3000/api/check \
-H 'content-type: application/json' \
-d '{"repo":"Firzadf/nodescope","address":"0xd48da7eff3264bdd51a86b873da9ff6997aae99c"}'| Tests | Command | Result |
|---|---|---|
| Quick, no network | npx vitest run src/lib/address src/lib/verify/pins-unit src/lib/verify/parse-limits src/lib/store src/app/api/_lib |
24 tests, about 10 s |
| Everything | npm test |
14 files, 106 tests passed, about 10 minutes. 84 are the check engine, some running live against the real chain, GitHub and compiler |
| Contract | forge test in contracts/ |
8 tests passed |
attest.test.ts starts a local anvil, so the full run needs Foundry on your PATH. It never contacts Portaldot. These results come from a clean copy of this repository.
Optional settings
| Variable | Used for | Default |
|---|---|---|
EVM_RPC_URL |
EVM JSON-RPC | https://test-evm-rpc-v3.portaldot.dev |
NATIVE_WS_URL |
Substrate node for the account page | wss://test-node-v3.portaldot.dev |
NEXT_PUBLIC_NATIVE_WS_URL |
Same, for the wallet tools in the browser | same as above |
DATA_DIR |
SQLite database | ./data |
ONESCAN_DATA_DIR |
Compiler cache. Set it to the same folder as DATA_DIR |
./data |
GITHUB_TOKEN |
Raises the GitHub API limit for the look-back | unset |
ONESCAN_VERIFY_BUDGET_MS |
Total time for one check | 300000 |
ONESCAN_COMPILE_TIMEOUT_MS |
Time for one compile | 120000 |
REGISTRY_ADDRESS, ATTESTER_PRIVATE_KEY |
Together enable on-chain records. Server only, in .env.local |
unset: no records |
NEXT_PUBLIC_REPO_URL |
Adds a source link to the footer | unset |
ONESCAN_PROXY_SECRET |
Shared with the front door, so it can name the visitor | unset: X-Forwarded-For only |
node scripts/new-attester.mjs makes an attester key and prints only the address. scripts/deploy-registry.mjs is a dry run unless you pass --send.
To deploy, run npm run build and npm start behind a reverse proxy that overwrites X-Forwarded-For, because the rate limit of 10 checks per 10 minutes trusts that header. The public address is a free Vercel front door (deploy/vercel) that passes every request to one server. It names the visitor to the rate limit, and the server believes it only with ONESCAN_PROXY_SECRET. Allow outbound HTTPS to the RPC, github.com, codeload.github.com, raw.githubusercontent.com, api.github.com, cdn.jsdelivr.net and binaries.soliditylang.org.
The diagram and full module map are in docs/ARCHITECTURE.md.
| Module | Job |
|---|---|
src/lib/verify/index.ts |
Runs one check and decides the verdict |
src/lib/verify/github.ts |
Resolves refs, downloads the archive within limits |
src/lib/verify/imports.ts, graph.ts |
Finds imports, remappings, submodules, npm sources |
src/lib/verify/config.ts, pragma.ts |
Reads build settings as text, never runs them |
src/lib/verify/compiler.ts |
Fetches solc, checks its published hash, compiles in a worker |
src/lib/verify/metadata.ts |
Splits metadata off bytecode and compares |
src/lib/jobs.ts, store.ts |
Serial queue and SQLite storage |
src/lib/chain.ts, native.ts, address.ts |
Chain reads and address conversion |
src/lib/attest.ts |
Writes the on-chain record, server only |
contracts/ProofRegistry.sol |
Accepts a record only if it matches live code |
The home page illustration uses Stageshow by Benji Taylor (MIT). Interface copy was edited with the unslop process by Damir Sokolovsky (CC BY 4.0). Everything third-party is listed in NOTICE.md.