# Architecture Portal Meter has two on-chain halves, one on each of Portaldot V3's contract VMs, and three off-chain programs. ```mermaid flowchart LR subgraph Client["Client (buyer)"] B["buyer CLI / library
src/buyer.mjs"] end subgraph Provider["API provider"] S["HTTP seller
src/seller.mjs"] D["paid data
src/data.mjs"] end subgraph Portaldot["Portaldot V3 testnet"] subgraph EVM["pallet-revive (EVM)"] M["MeterSettlement.sol
endpoints · escrow · settle · refund"] P["pallet-assets ERC-20 precompile
tUSDm (native asset)"] end subgraph WASM["pallet-contracts (WASM)"] R["MeterRegistry (ink!)
endpoint URLs · outcome anchors · buyer feedback"] end RP["runtime pallets address
0x6d6f…6472 (revive.ethSubstrateCall)"] end V["verifier
src/verify.mjs + live page"] B -- "1 GET" --> S S -- "2 402 + quote" --> B B -- "3 approve / pay(endpoint, nonce, maxPrice)" --> M M -- "transferFrom" --> P B -- "4 GET + signed proof" --> S S -- "reads payment" --> M S --> D S -- "5 one Ethereum tx: utility.batchAll" --> RP RP -- "revive.call settle / refund" --> M RP -- "contracts.call record" --> R S -- "6 body + signed receipt" --> B B -. "rate (contracts.call from its EVM key)" .-> RP V -. "events, payment state" .-> M V -. "anchor, reputation" .-> R ``` ## On-chain ### `MeterSettlement` (Solidity, pallet-revive) - `registerEndpoint(payee, token, price, refundDelay)`: the caller becomes the endpoint's provider. The token must answer `totalSupply()`, which rules out addresses without code (a plain call to them would "succeed" without moving anything). - `pay(endpointId, nonce, maxPrice)`: escrows `price` and derives `requestId = keccak256(abi.encode(chainId, this, msg.sender, endpointId, nonce))`. The id is bound to the payer, so nobody else can present the payment as theirs, and it can be used once, ever. - `settle(requestId, responseHash)`: provider only, once: escrow goes to the payee, the response hash is logged. - `refund(requestId, reason)`: the provider at any time; the payer after `refundDelay` if the provider did nothing. - Storage is sized for pallet-revive's deposits: 3 slots per endpoint, 2 per payment, and the amount slot is zeroed when a payment is final so its deposit comes back. ### `MeterRegistry` (ink! 5, pallet-contracts) - Namespaced by the caller's **EVM address**, computed with pallet-revive's own rule (`H160 ++ 0xEE*12` for Ethereum keys, `keccak256(account)[12..]` otherwise). A provider writes under the same address that owns its endpoints in `MeterSettlement`; nobody can write into someone else's namespace. - `register_endpoint(id, uri)`, `set_uri`: the URL buyers use to find the endpoint. - `record(endpoint, requestId, outcome, amount, receiptHash)`: anchors the outcome of a paid request once and updates the served / refunded counters and volume. - `rate(provider, endpoint, nonce, ok)`: the payer confirms or disputes a served request. The contract recomputes the request id from the **caller's own address** and the nonce with the same keccak/ABI formula as Solidity, so only the account that paid on the EVM side can rate it, without any bridge, oracle or cross-VM read. - `reputation(provider, endpoint)`: served, refunded, confirmed, disputed and a score in basis points. ### `MeterSession` (Solidity, pallet-revive) Prepaid budgets for high-frequency APIs, on top of the same endpoints. `open` locks a budget and names the session key, `redeem` pays out a voucher signed by that key, `close` returns what is unspent. It reads provider, payee and token from `MeterSettlement`, so an endpoint is sold both ways. Protocol, guarantees and limits: [SESSIONS.md](SESSIONS.md). ## Cross-VM path A Solidity contract on pallet-revive cannot call a pallet or an ink! contract (we checked: a call from a contract to the runtime pallets address behaves like a call to an empty account). An **Ethereum transaction** can: when a transaction is sent to `0x6d6f646c70792f70616464720000000000000000`, the runtime decodes its input as a SCALE-encoded runtime call and dispatches it (`revive.ethSubstrateCall`) with the sender's twin account as a signed origin. Portal Meter uses that for the provider's write path. One transaction, signed by the provider's Ethereum key: ```text utility.batchAll([ revive.call(MeterSettlement, settle(requestId, responseHash)), // msg.sender = provider H160 contracts.call(MeterRegistry, record(endpoint, requestId, 1, amount, receiptHash)), ]) ``` `batchAll` is all-or-nothing: either the Solidity settlement and the ink! anchor both happen, or neither does. On chain this is one `revive.ethTransact` extrinsic whose events come from both VMs, for example [block 276658](https://scanner-testnet-v3.portaldot.dev/#/blocks/276658?tab=events): `assets.Transferred`, `revive.ContractEmitted`, `utility.ItemCompleted`, `contracts.ContractEmitted`, `contracts.Called`, `utility.BatchCompleted`. The same path lists endpoints in the registry and lets the buyer's Ethereum key call `rate` on the ink! contract. All of this ran on the testnet: see the transactions in the [README](../README.md#testnet-deployment). `eth_getLogs` returns the `Served` and `Refunded` events of these runtime-dispatched calls under the Ethereum transaction's hash, so the verifier treats them like any other settlement. The fallback when the cross-VM path is not wanted is `EvmSettler`: plain Solidity calls, no anchor. ## Hosted seller `worker/` is a Cloudflare Worker with one Durable Object: the same metering core (`src/meter.mjs`), a ledger in Durable Object storage and a cron trigger. It has no server and no database of its own. `src/runtime-calls.mjs` encodes `utility.batchAll([revive.call, contracts.call, ...])` without polkadot.js, byte-identical to it on spec 3002, so a small runtime can settle across both VMs. ## Off-chain | Program | Role | |---|---| | `src/seller.mjs` | HTTP 402 seller: quotes, checks the payment proof and the on-chain payment, serves once, settles or refunds, signs the receipt | | `src/data.mjs` | the paid data: a cross-VM view of Portaldot accounts and native assets (both VMs read and compared) | | `src/buyer.mjs` | client: checks the quote, the on-chain endpoint and the provider's ink! record before paying, then pays and retries | | `src/verify.mjs` | receipt verifier: ACCEPT / REJECT with every check, in Node and in the browser | | `src/inkraw.mjs` | dependency-free ink! read client (SCALE over `state_call`), used by the live page | | `scripts/deploy-testnet.mjs` | resumable deployment and live demo | See [PROTOCOL.md](PROTOCOL.md) for the wire format and [../SECURITY.md](../SECURITY.md) for the threat model.