Live on the Portaldot V3 testnet

A cent per API call. A hundred calls per transaction.

If you run an API, Portal Meter lets AI agents pay you a cent per call on Portaldot, and you settle more than a hundred calls in one transaction instead of paying a fee on each one.

No wallet needed to try it. Solidity and ink! in one transaction. 123 tests, MIT licensed.

portal-meter.swiftbotlab.workers.dev/demo/
From session 0x58bb…8bc2 To endpoint 3, the hosted seller
Portal Meter 0.01pUSD voucher 1
  1. 402Quoted, 0.01 pUSD
  2. sigSigned, no gas
  3. 200Served, receipt
The batch of block 2825593 sessions 137 requests, 1.37 pUSD
0x08de…94c917/17
0x58bb…8bc2100/100
0x8ccb…0b3720/20
Settled in one Ethereum transactionSolidity redeem and ink! record, 3 sessions 0xe503…7f09

Replay of a real batch, sped up: block 282559 settled these 137 requests from 3 sessions in one transaction. The order of the calls is illustrative.

Paying on-chain for each call locked 7.53 tPOT for good. A session locks 0.076 per call.

On Portaldot every new storage slot locks a deposit. One paid request left 7.53 tPOT locked against a price of 0.025 test dollars: fine for a call worth a few cents, fatal for one worth a fraction of a cent. So we built prepaid sessions.

Storage deposit locked for good, per request tPOT, Portaldot V3 testnet, measured with our own keys

Pay on-chain for each request (escrow) 7.53tPOT
Prepaid session (100 requests, 1 batch) about 100 times less 0.076tPOT
Transactions per request
2down to 0
escrow vs prepaid session
Locked for 100 requests
753down to 7.6 tPOT
about 753 tPOT through escrow
Requests in one transaction
137
from 3 sessions, block 282559

Per request: 7.70 locked at pay, 0.17 back at settle (blocks 276657 to 276660). Session of 100 requests: 11.55 locked at open (block 282490), 3.68 for its ink! anchor (block 282559), 7.67 back at close (block 297818). Data. Test tokens have no value; the ratio is the point. Designed from these measured numbers, and published for other builders as Portaldot V3 notes; for calls worth more, per-request escrow is there too.

Open once. Pay every call with a signature. Settle many in one transaction.

No transaction per request, and no trust in the seller's word: every batch it redeems carries the buyer's own signature over the requests made and the responses received.

  1. Open a session once

    The payer locks a budget in MeterSession. It can name a fresh session key, so an agent holds a key that can spend this budget and nothing else. Every pallet-assets token is reachable from Solidity at a precompile address: the hosted seller is priced in pUSD, asset 7777, Portaldot's own test dollar.

    Meter creditMeterSession
    0.25pUSD
    budget
    0.25 pUSD, locked in MeterSession
    key
    a fresh session key that spends this budget only
    expiry
    1 day; unspent budget back at close
    $ npx portal-meter session-open 3 --budget 0.25 --days 1
    session 0xbf59f966…78ab: budget 0.25, tx 0xc7dcc4a5…06c6, block 329592
  2. Every call is a signed voucher

    HTTP 402 with a price, a voucher signed by the client, HTTP 200 with the data. Each voucher commits to the hash of the previous response, so the vouchers form a chain the seller cannot rewrite.

    402GET /v1/asset/77770.01 pUSD
    sigvoucher 1, signedno gas
    200GET + voucherdata + receipt
    head[n] = keccak256(head[n-1], request[n], response[n-1])
  3. One transaction settles many

    One Ethereum transaction carries utility.batchAll: the money moves in Solidity and the public record lands in ink!, together or not at all, for many sessions at once. An Ethereum-signed transaction to the runtime pallets address dispatches revive.call and contracts.call together; a single-VM chain would need a bridge or a second transaction.

    1 Ethereum txutility.batchAll
    EVMMeterSession.redeempallet-revive, the money
    WASMMeterRegistry.recordpallet-contracts, the public record
    × each session in the batch

Receipts you can open on the explorer.

Open any block on the Portaldot explorer, or replay the public session log against the chain from the live page.

  1. 282470
    Unattended batch cron

    Sent by the seller's own cron: 67 requests from 2 sessions.

    0xe388295c45e60931abadb2c8bc9d6b9dad805d4cea8b9cdfe6aeac729c3992db
  2. 329697
    Quickstart batch 5 Nov

    The five-line quickstart below, settled: redeem and ink! anchor in one transaction.

    0x24aa5a1947432405911e0cd0bd2ce4f6e600901c6b2390eb91f5e8d89a18b770
  3. 282234
    Endpoint registered both VMs

    Endpoint 3 registered in Solidity and listed in ink!, one Ethereum transaction.

    0xeca4784ca0ba03ce7f110cbdc9af984dc2956f8d881edb5577261fcd7fd27b2c
  4. 276658
    Escrow, served settle + anchor

    Paid into escrow, served once, settled with its ink! anchor in the same transaction.

    0xf5264278d35ec75998992133da8fe8bc7bb8de5f7040137ba3353f1e7e0bacb6
  5. 276660
    Escrow, refunded refund + anchor

    A refunded request, recorded in ink! in the same transaction.

    0x1a52029d1a3006600c7ad50c2424c18fefdf0df499505dd35e85c9d68a36f314

Checked, not claimed.

The contracts behind every receipt above, and the checks anyone can run against them.

Contracts on the PortaldotV3 testnet

EVMMeterSession Solidity, block 282417
0xCc2aBc8AC1B5676e8f9678bb5B6881A0b743B3d5
EVMMeterSettlement Solidity, block 276611
0x9E7Ad2Aa754A0EBA70CFbd1db01d25ef97e5781e
WASMMeterRegistry ink!, block 276655
5DHgN8JZRpyo9mDaG1BSNh224AZXcQw8p3Y8GZK1K2Vd8Ku4
assetpUSD asset 7777 as ERC-20
0x00001e6100000000000000000000000000010000

What has been checked

  • Log replay: ACCEPT. The seller's public log against the payer-signed head in the Redeemed event and the ink! anchor. Run it yourself.
  • 22 of 22 bad vouchers refused by the live seller and contract. Negative checks.
  • Source matches the chain. Bytematch, another Hacker House team's verifier, lists all 3 Solidity contracts we deployed as byte-for-byte matches. Bytematch.
  • 123 tests: 42 Solidity (unit, fuzz, invariant), 73 Node, 8 ink!.

{{HEALTH}}

New during this Hacker House

Prepaid sessions (MeterSession, hash-chained vouchers, the meter and the session client), the ink! registry, cross-VM settlement in one Ethereum transaction and its SCALE encoder, the Cloudflare Worker seller, the settler, the verifier and the paid-indexer example. All {{COUNT}} commits of this repository, the first on 1 Nov 2026: commit history.

Reused from our earlier HSK Meter

From HSK Meter (Oct 2026, HashKey Chain, MIT), as patterns rewritten here, no file copied: the x402-style payment headers, a request id bound to its payer, the provider refund and single-use request id, the endpoint record, and the escrow seller's quote, pay and retry flow.

What each side can and cannot do.

"Contract" means the deployed MeterSession, MeterSettlement and MeterRegistry. Off-chain parts are named as off-chain.

A malicious seller

Cannot, enforced by the contract

  • Take more than the buyer's session key signed: redeem checks the signature, caps the total at the budget and accepts only newer vouchers.
  • Touch the unspent budget: only the payer may close.
  • Redeem a session of an endpoint it does not run, or switch the token.

Can, and what limits it

  • Take one price for a request it never answers, because each voucher is signed before its response exists. The client stops signing at the first bad receipt.
  • Serve wrong data: a redeemed head proves which bytes the client accepted, not that they were good.

A malicious buyer

Cannot, enforced by the contract

  • Close before 15 minutes after expiry, which leaves the seller time to redeem.
  • Take back redeemed money, reopen a closed session or replay its vouchers.
  • Spend past the budget, or make the seller pay the session's storage.

Can, and what limits it

  • Stop at any time: the seller risks only what it has not redeemed, and it holds a signed voucher for every request it answered.
  • Open many small sessions: the hosted seller refuses budgets under 0.25 pUSD (off-chain).

Refunds: close after expiry returns the unspent budget plus most of the storage deposit (7.67 tPOT measured). A failed request is not charged and a lost response is sent again for free, by seller code. Full model: Sessions: protocol, guarantees and limits and Security.

Five lines from a terminal, with their real output.

You need a testnet EVM key with about 12 tPOT and 0.25 pUSD (asset 7777, from the native exchange) in buyer.env. What we got on 5 Nov 2026, as printed; JSON bodies condensed to one line and long hashes shortened.

buyer, zsh
$ npm i github:swiftbotlabs/portal-meter
npm WARN deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
npm WARN deprecated @substrate/connect@0.8.11: versions below 1.x are no longer maintained
added 79 packages in 13s

$ curl -s https://portal-meter.swiftbotlab.workers.dev/v1/asset/7777
{ "error": "payment required", "accepts": [ { … "provider": "0xE13A4d566A1f6492C1511EF100DC0417ccE18E59", "endpointId": 3, "tokenSymbol": "pUSD", "price": "10000", … } ] }

$ npx portal-meter session-open 3 --keys buyer.env --budget 0.25 --days 1 --out s.json
session 0xbf59f966153d082007c9a1b36c6361dc1365adc1522c71badc1c6d0f7f1778ab: budget 0.25, tx 0xc7dcc4a5…06c6, block 329592, state in s.json

$ npx portal-meter session-buy https://portal-meter.swiftbotlab.workers.dev/v1/asset/7777 --session s.json
· HTTP 200, request 1, spent 10000, head 0xebcfba2e…b065
{ "at": { "number": 329593, … }, "assetId": 7777, "name": "Portaldot USD (test)", "symbol": "pUSD", "decimals": 6, … }

$ npx portal-meter session-verify https://portal-meter.swiftbotlab.workers.dev --session s.json
NOT YET   1 request in the log, no batch on-chain yet: the seller settles on its own schedule, run this again later
# the seller's batch landed in block 329697; line 5 again:
ACCEPT    1 request in the log, 1 batch on-chain

Check our session yourself, no key needed

npx portal-meter session-verify https://portal-meter.swiftbotlab.workers.dev \
  --id 0xbf59f966153d082007c9a1b36c6361dc1365adc1522c71badc1c6d0f7f1778ab \
  --provider 0xE13A4d566A1f6492C1511EF100DC0417ccE18E59 --endpoint 3

The same package is mirrored on the hosted seller: npm i https://portal-meter.swiftbotlab.workers.dev/source/{{TGZ}}

Add it to your API

  • Node.js
  • Cloudflare Workers
  • Bun
  • Deno
  • Express

The metering core works on Web standard Request and Response, so it runs on Node.js, Cloudflare Workers, Bun and Deno, with an adapter for Express. Register an endpoint on both VMs with one command, wrap a handler, done. The hosted seller is itself built on it.

Reuse, tested: a second seller in a folder of its own, built on the published entry points only, live as endpoint 4 at paid-indexer.swiftbotlab.workers.dev.

server.mjs
import { createMeter, metered } from 'portal-meter';

const meter = createMeter({ chainId, sessionContract, endpointId, wallet, token, tokenSymbol, decimals, readSession });

// Express: the handler returns the body, the meter hashes and signs it
app.get('/v1/weather/:city', metered(meter, { price: 10_000n }, async req => forecast(req.params.city)));

// Cloudflare Workers, Bun, Deno, Hono: Web standard Request and Response
export default { fetch: req => meter.charge(req, { price: 10_000n }, () => forecast('oslo')) };