One key, both runtimes. TwinKey makes a Portaldot SS58 wallet sign for Solidity dApps on Portaldot V3 — the same key that holds your POT pays gas, calls contracts, and reads state over a standard EVM interface. No second wallet, no seed import, no bridging between two identities.
Status: live on Portaldot V3 testnet (Portaldot Hacker House 2026, track Modular Cloud & Middleware). Live chain facts, including the ones this product depends on, are in
docs/VERIFIED-FACTS.md— every one reproducible with acurl.
The screenshot above is the deployed app: both faces of one account resolved from the chain, live
contract state over eth_call, a real write, decoded revive.ContractEmitted events, and the independent
verifier's verdict for block 90995.
Live demo: https://twinkey.vercel.app
Unmodified viem against a JSON-RPC endpoint: https://twinkey.vercel.app/viem
wagmi v3, one transport line different: https://twinkey.vercel.app/wagmi
The chain's traps, with the command that reproduces each: https://twinkey.vercel.app/pitfalls
(machine-readable: https://twinkey.vercel.app/traps.json)
ERC-20 written by the ss58 key: 0x06f6e882ada6cd5cab74214ad90c3bd1b070e4e9 (TKT, 18 dec)
Verifier: https://twinkey-verifier.kiter0211.workers.dev/ready (Cloudflare Worker — stateless, no cold start, so the first
request after an idle spell takes ~30–60 s to cold start). /ready is the deep check (both upstream
RPCs, head block); /health is liveness only and answers from memory, because Render kills the
instance when its own probe takes more than 5 s
The deployed instance runs the write path with a dedicated throwaway demo account
(5He7dvDWyMs7DAiAVBsfczFZPEKVa5tAQwLQjNTSq3beUAHZ → 0xfd2d914e…62e7), funded with 5 tPOT and
rate limited to 3 writes per minute per IP, so the write button works for a judge without an
extension. It is deliberately not the project key. Connect your own wallet to sign as yourself.
Portaldot Network V3 gives every account two faces: a native AccountId32 (SS58, prefix 42,
the one every Portaldot/polkadot.js wallet produces) and an H160 address on the EVM side. The two
are linked by the runtime — ReviveApi.address(account_id32) returns the H160 — but nothing in the
ecosystem exposes that link to users or to dApps.
The consequence, today, on the live testnet:
- A Portaldot user opens any Solidity dApp and is told to install a different wallet with a different seed phrase. Their POT cannot sign an EVM transaction.
- A dApp built with
viem/wagmi/etherscannot talk to a Portaldot wallet at all: those libraries expect an EOA that signseth_transact-style transactions, while the account that actually holds the funds is an sr25519 Substrate account. - The runtime does allow a native account to drive contracts (
revive.call,revive.instantiate_with_code), and the Portaldot team's own guide documents it as a manual four-step procedure in the node console: generate calldata, dry-run throughreviveApi.callto obtainweight_limitandstorage_deposit_limit, then submitRevive.callfrom the Extrinsics tab. Nothing in the ecosystem implements that flow — no wallet, no SDK, no dApp. - In the other direction, the EVM write path that MetaMask/Remix users are told to use does not
accept transactions from an external EOA on the current testnet build (every type, fee and gas
combination returns
Invalid Transaction;revive.originalAccountis unset for the EOA while its balance is displayed). Reads,eth_callandeth_estimateGaswork normally. Full reproduction:docs/FINDINGS.md.
So the chain's headline feature — EVM compatibility — is unusable by the people the chain already has. TwinKey closes that gap.
One key, two endpoints: reads go to the EVM RPC at latest, writes go to the Substrate RPC as
revive.call extrinsics signed by the SS58 key, and a separate stateless Worker checks that both sides
agree. The editable source of that diagram is
docs/diagrams/architecture.html — open it in a browser, no build
step.
- A signer bridge. Connect the Substrate wallet you already have (Portaldot extension,
polkadot.js extension, or any injected SS58 signer). TwinKey resolves your
H160through the chain's own runtime API, shows both identities as one account, and submits contract calls as nativerevive.callextrinsics signed by your sr25519 key. - An EIP-1193-shaped provider. The same capability as a drop-in provider
(
window.ethereum-shaped) so existing EVM tooling —viem,wagmi, plainetherssnippets — works against a Substrate signer with no rewrite. The dApp developer changes one line. - A parity receipt. For every call, TwinKey can execute the same input through both paths
(native
revive.calland EVMeth_transact/eth_call) and have an independent verifier assert that post-state, events, and value accounting agree — including across the 14-decimal native / 18-decimal EVM denomination split. This is the proof that the dual runtime is one runtime, and it is what turns a wallet adapter into infrastructure.
- Portaldot users with an SS58 wallet who want to use Solidity dApps without a second identity.
- dApp developers on Portaldot V3 who want the wallet base that already exists, instead of asking new users to install MetaMask and buy gas twice.
- The next chain running
pallet-revive— the same adapter is not Portaldot-specific: it works against anypallet-reviveruntime that exposes the same runtime API.
Portaldot V3 (runtime portaldot-v3 v3002) is the first Portaldot runtime with pallet-revive
alongside pallet-contracts, and the EVM endpoint has been public since October 2026. The chain
has issuance, validators and a live testnet, and — at the time of writing — no deployed
contracts and no wallet path from the native side. The adapter has to exist before the first
dApp can onboard its first user.
- Land on the app. Connect the Substrate wallet you already have.
- Your account appears once, with both faces:
5DM…Bko9C7and0xe70f…d934, with the derivation fetched live from the chain (not guessed, not truncated). - Pick a contract, call a function, sign once in your existing wallet. TwinKey encodes the call, submits it, tracks the receipt, decodes the events.
- Flip to the parity pane: the same call through the EVM path, with the verifier's verdict.
[Substrate signer] ──sign revive.call──▶ [TwinKey app] ──▶ [Substrate RPC] ──▶ pallet-revive
│ │ │
│ ReviveApi.address ◀── H160 ── │ viem/EIP-1193 shim │ state+events
▼ ▼ ▼
one account [verifier] ◀── eth-rpc / Substrate RPC ──
/verify/:tx, /parity/:tx
- Web app: Next.js (App Router) + TypeScript + Tailwind, deployed on Vercel.
- Signer + chain access:
@polkadot/api,@polkadot/extension-dapp,viemfor encoding. - Verifier: Cloudflare Worker (stateless HTTPS JSON-RPC, no websocket) — reads the chain independently of the browser and returns
a verdict a judge can
curl.
app/ Next.js front end + the EIP-1193 provider shim
agent/ Rust verifier service (superseded by verifier-worker/ — kept for reference) (/verify/:tx, /parity/:tx)
tooling/ Node scripts used during development (inspect revive extrinsics, deploy, parity runs)
contracts/ Solidity contracts used for the demo and the parity harness
docs/ VERIFIED-FACTS.md (chain facts with the commands that produced them), notes
# 1. the app (Next.js) — provider shim + console + decoded EVM event feed
cd app
npm install
npm run dev # http://localhost:3000
# optional: enable the in-app demo write with a testnet key file
# PORTALDOT_KEY=~/.hermes/keys/portaldot-testnet.json npm run dev
# 2. the end-to-end proof: drives the provider like viem would and prints a verdict
npx tsx scripts/e2e-provider.ts 2077
# 3. the Rust verifier (independent of the browser)
cd ../agent
cargo run --release # http://localhost:8088/verify/block/90990Exact commands and their output are in docs/BUILD-LOG.md.
| Claim | State |
|---|---|
| Portaldot V3 testnet reachable, chain id, decimals, address mapping | real, verified by curl — docs/VERIFIED-FACTS.md |
| A registered testnet wallet is funded | real — mapped H160 holds ~401.7 tPOT as of 2026-10-19 |
Native AccountId32 → H160 mapping via runtime API |
real — ReviveApi_address returns 0xe70f…d934 for 5DM…Bko9C7 |
| Contract deployed on the testnet by an ss58 key | real — 0x19d5aa109cbf0d4e0a126f1438ea73b619cb7725, deploy block 90990 |
| Contract called by the same ss58 key, state read back over EVM RPC | real — call block 90995; later calls at 102680, 102716, 102852 |
An EIP-1193 eth_sendTransaction executes on chain via an ss58 signature |
real — app/scripts/e2e-provider.ts 12/12, tx 0x6aac8a66…34f8 |
eth_getTransactionReceipt returns a receipt with a decoded EVM log |
real — ValueSet(caller=0xe70f…d934, value=2077, callCount=4) |
eth_estimateGas returns the real cost, not the EVM endpoint's inflated value |
real — 50,604,168 vs 417,905,553,159 (8,258×) |
Dry-run → exact weight_limit / storage_deposit → execute |
real — weightRequired {506041677, 31937} on an earlier call |
| External EOA can write over the EVM JSON-RPC | no — Invalid Transaction for every tx type; see docs/FINDINGS.md. Reads work. |
eth_getLogs returns logs from native-path calls |
no — returns [] for our own contract across the full range. The app scans Substrate events and merges receipts instead. |
| Rust verifier verdict for a call | see agent/ — endpoints and their real output are in the agent README |
Any contract, any calldata, signed as revive.call |
real — /api/call wrote setValue(4242) at block 227275, tx 0x6bcd0dfa…5efd84 |
| Ordinary viem code settles a write through a JSON-RPC URL | real — /viem + /api/rpc, tx 0x2485483d…6f5a77, receipt at block 230867 |
| A receipt resolvable without the serving instance | real — /api/receipt and /api/rpc rebuild it from the archive by scanning for the extrinsic hash |
| An ERC-20 deployed and owned by an ss58 account | real — 0x06f6e882…e4e9, owner = mapped H160, deploy tx 0x0af3d62c…e7ba9 |
| The cost of a call, told honestly | real, live — /api/estimate returns eth_estimateGas alongside the runtime dry run: 11,274× apart |
No mocked behaviour is presented as real here. Anything simulated is labelled as such in the app itself and in the README at submission time.
- TwinKey never holds keys and never asks for a seed. All signatures happen inside the user's own wallet extension.
- Native
revive.calltransfers are signed as Substrate extrinsics, so the trust model is Substrate's, not an EVM relayer's: there is no custodial relayer in the loop. - The app validates the address mapping against the chain on every connect; it never derives the H160 locally (a local derivation that disagrees with the chain is the failure mode that loses funds).
- Amounts are handled as bigint with an explicit decimal context per side (14 native / 18 EVM) — the single most common way to misreport value on this chain is to assume 18 decimals everywhere.
- Native path requires a Substrate signer; a wallet that only exposes secp256k1 EVM keys cannot use
revive.call(it uses the EVM path instead — supported, but a different flow). pallet-revivesemantics differ from the EVM in places (PolkaVM); the parity harness exists to make those differences explicit instead of surprising.- Roadmap: multi-contract batching, ERC-20 precompile support, and a packaged provider
(
npm i twinkey) that any Portaldot dApp can drop in.
- PortaldotV3 Testnet Guide (official, English translation) —
https://github.com/ItsCogumellum/portaldot-v3-testnet-guide. Its §3.3.2/§3.4.4 document the
manual native-call flow this project automates; its §3.4.2 documents the H160 ↔ SS58 funding trick;
demo/04_solidity_demo.pyis the reference implementation of the dry-run in Python. TwinKey'stooling/call-dryrun.mjsis the same flow in TypeScript. - Polkadot SDK
pallet_revive—AddressMapper,ContractResult, storage-deposit semantics.
MIT. Portaldot Hacker House 2026 submission.
Published on GitHub Packages as @memeshee/twinkey-provider (see this repo's Packages
section) and on npm as twinkey-provider.
npm i twinkey-providertwinkey-provider is the provider as a
library — createTwinKeyProvider, keyringSigner, extensionSigner, fromExtension, getApi,
the decimals helpers and the probe ABI (ESM + CJS + .d.ts, 21 kB, no build step for consumers).
Source lives in packages/twinkey-provider.
Verified from the registry, not from this checkout: a clean npm i twinkey-provider in an empty
directory returns eth_chainId 0x190f1ca9, maps the demo ss58 to 0xfd2d…62e7, and reads a
balance that round-trips through the 14/18-decimal conversion exactly.
The provider is also exposed at runtime as window.twinKey on the deployed app, so the
extension-facing path can be inspected without installing anything.

