DocsPrepaid stamps
One stamp pays for one operation.
A stamp is e-cash for gas. You buy a booklet in ETH from any wallet; your device blinds a random serial for each stamp and the relay signs the serials without seeing them. Later, a fresh address that never held ETH uses one stamp for one operation, and the paymaster pays its gas. The relay meets each stamp twice, blinded and unblinded, and cannot tell that it is the same stamp. Stamps pay gas only: they are never refunded and never carry value. This is not a mixer.
- BuyAny wallet pays PostOffice.buy for N blinded serials. The purchase is public and commits to the blinded messages.PostOffice.buy
- IssueThe relay checks the mined purchase and blind-signs exactly the committed messages. It never sees a serial.stamp_issue
- KeepThe client unblinds the signatures into stamps and keeps them, encrypted when exported. A stamp is a bearer token.local only
- UseA fresh address with 0 ETH sends one operation with one stamp. The relay verifies it and co-signs the op with its nullifier.stamp_sendUserOperation
- CancelThe paymaster pays the gas from the stamp float and records the nullifier in postOp. A second use is public.StampCancelled
The relay meets a stamp twice, blinded at issuance and unblinded at use, and cannot match the two. What it can match is your IP address and your timing: see the privacy guide.
Face value and price#
One stamp pays for one operation of an allowed template (a token transfer or approve, or an ETH send), with the
gas limits of that template's published profile, inside the epoch's caps. It works for a first op (with its EIP-7702
delegation) as for any later one.
The price is set by the relay's keeper from its own gas quote, within bounds the PostOffice enforces on chain:
quote = max over templates (first and regular) of
(verificationGasLimit + callGasLimit + PVG quote + pmVerificationGasLimit + stampPostOpGasLimit)
× max(refFeePerGas, base fee) × PRICE_SAFETY_BPS / 10,000 // × 1.5
price = quote × (10,000 + markupBps) / 10,000 // markup 25% on mainnet- Bounded. The pricer moves the price by at most ±50% per update and at most once per 10 minutes, always inside the
owner's
[minPrice, maxPrice]. A pricer key alone cannot jump it. - Split at purchase. Of each sale, the markup share
markupBps / (10,000 + markupBps)(20% at a 25% markup) goes to the DeadLetterOffice; the rest is the gas share, credited to the paymaster's deposit as the stamp float. - Never refunded. An unused stamp is not redeemable for ETH. Only its gas is ever spent.
| Parameter | Value | Why |
|---|---|---|
| Face value | 1 operation | One sponsored op of an allowed template, gas capped by its profile and the epoch's caps. |
| Booklet | 1 to 100 stamps | PostOffice.MAX_BOOKLET; one purchase, one commitment, paid once. |
| Initial price | 0.0000125 ETH per stamp | The relay's quote for one op plus the markup, at deployment. |
| Price bounds | 0.000001 ETH – 0.001 ETH | Owner bounds; the pricer can never leave them. |
| Markup | 2,500 bps (25%) | Goes to the DeadLetterOffice, which buys $STAMP and sends it to 0x…dEaD. At most 10,000 bps. |
| Pricer step | ± 50% per update, at most once per 10 minutes | PostOffice.MAX_PRICE_STEP_BPS and MIN_PRICE_INTERVAL: a pricer key alone cannot jump the price. |
| Gas quote safety | × 1.5 (PRICE_SAFETY_BPS 15,000) | The quote is the heaviest stamp op the relay sponsors, at max(reference fee, base fee). |
| Re-price threshold | 10% drift (PRICE_DRIFT_BPS 1,000) | Fewer price transactions; small moves are absorbed by the safety factor. |
| Issuance window | 30 days | Every stamp bought in one window shares one key and one metadata string, so a stamp hides among all the stamps of its window. |
| Validity after the window | 120 days | A stamp bought on the last day of a window is still good for 120 days. |
| Issuer keys | 1 pinned: avion-1 | Pinned in deployments/<chainId>.json (stamps.issuers); clients refuse any other key or window. |
| Blind-signature suite | RSAPBSSA-SHA384-PSS-Deterministic, RSA 2048 | RFC 9474 with public metadata; deterministic, so issuance is idempotent. |
Buy a booklet#
- Read the issuer.
stamp_getIssuerreturns the key, the window and its metadata (info), and the price. The SDK checks them against the published pins first (checkStampIssuer, below): it never blinds anything for an unpublished key or window. - Blind. Your device draws N random 32-byte serials (1 ≤ N ≤ 100) and blinds each one for the issuer key under the
public metadata
info, with RFC 9474 partially blind RSA (@cloudflare/blindrsa-ts, suiteRSAPBSSA-SHA384-PSS-Deterministic). - Store. The pending booklet (serials, blinded messages, blinding inverses) is saved before anything is paid: without the inverses, paid stamps cannot be unblinded.
- Pay. Any wallet calls
PostOffice.buy(commitment, N)withN × price; any excess comes back in the same transaction. A commitment can be paid for once.
info = "paravion:v1;chain=4663;paymaster=<lowercase address>;key=<keyId>;face=1op;from=<unix>;until=<unix>"
commitment = keccak256( keccak256(utf8(info)) ‖ blinded_1 ‖ … ‖ blinded_N ) // plain concatenation| Field | Value | Meaning |
|---|---|---|
| prefix | paravion:v1 | Format and version. |
| chain | 4663 | The chain the stamp pays for operations on. |
| paymaster | lowercase address | The paymaster that sponsors them. |
| key | keyId | The issuer master key (published in the pins). |
| face | 1op | Face value: one operation of an allowed template, gas capped by its profile. |
| from | unix seconds | Start of the 30-day issuance window, a multiple of the window length. |
| until | unix seconds | End of validity: from + 30 days + 120 days. |
import { createWalletClient, custom } from 'viem'
import { robinhood, buyStamps, createRelayClient } from '@paravion/sdk'
const relay = createRelayClient('https://relay-production-fb81.up.railway.app')
const wallet = createWalletClient({ chain: robinhood, transport: custom(window.ethereum) })
const [buyer] = await wallet.getAddresses() // any wallet, even your main one
const { stamps } = await buyStamps({
relay,
count: 10, // 1 to 100 per booklet
// PostOffice.buy(commitment, count) with count × price; the excess comes back
pay: (call) => wallet.sendTransaction({ account: buyer, ...call }),
// called before paying: the pending booklet holds the blinding inverses
savePending: (booklet) => db.put('pending', booklet),
onProgress: (p) => console.log(p.stage), // issuer, blinding, paying, paid, issuing, done
})
await db.put('stamps', stamps) // bearer tokens: guard them like cashIssuance#
stamp_issue([{ orderTx, info, blinded }]) asks the relay to sign. It answers only when:
- the purchase transaction is mined, succeeded and has the required confirmations (1 by default), else
ORDER_UNKNOWNorORDER_PENDING; - it emitted
StampsBoughtfrom the PostOffice withcount = Nand exactly this commitment ofinfoand the blinded messages, elseORDER_MISMATCH; - its block time falls in the window
infonames (or at most an hour after it ends), andinfonames the key that issues in that window.
The relay then blind-signs exactly the committed messages under the key derived for info. Signing is deterministic, so
asking again returns the same signatures and the relay stores nothing. It learns the buyer and the blinded messages; it
never learns a serial.
Keeping stamps#
The SDK unblinds the signatures (finalizeBooklet) and verifies every stamp under the issuer key before keeping it. The
pending booklet can then be dropped, and with it any trace of which purchase made which stamps.
| Field | What it is | Sent to the relay at use |
|---|---|---|
| serial | 32 random bytes drawn on your device; the message the signature covers. | yes |
| signature | The unblinded RSA-PSS signature (256 bytes with a 2048-bit key). | yes |
| info | The window's public metadata, above. | yes |
| nullifier | keccak256(abi.encode(NULLIFIER_TAG, serial)): what the paymaster records when the stamp is used. | in the op |
| key, chainId, paymaster, from, until | Copies for offline checks (verifyStamp) and expiry (isStampUsable: 5 minutes before until). | no |
- In the browser the app keeps your stamps in IndexedDB. Export them as an encrypted file (PBKDF2-SHA-256 with
600,000 iterations, then AES-256-GCM, format
paravion-carnetv1) to move them to another device or to the agent, which keeps the same format inPARAVION_HOME/carnet/<network>.jsonunderPARAVION_PASSPHRASE. - A stamp is a bearer token. Whoever holds the serial and the signature can spend it. Lost stamps cannot be reissued: nobody, the relay included, knows which serials were yours.
import { decryptCarnet, encryptCarnet } from '@paravion/sdk'
// PBKDF2-SHA-256 (600,000 iterations) and AES-256-GCM: the same file the web app and the agent read
const file = await encryptCarnet({ stamps, pending }, passphrase)
const back = await decryptCarnet(file, passphrase) // CARNET_LOCKED on a wrong passphraseUse a stamp for an operation#
A fresh address with 0 ETH builds its op with paymasterData version 2 and the stamp's nullifier, signs it, and sends it
with stamp_sendUserOperation(op, entryPoint, { stamp: { info, serial, signature } }). The relay:
- runs the same checks as for any op (EntryPoint, epoch, nonce key 0, authorization,
userOpHash, templates, exact gas profile, PVG, fees, pending ops), without proof of work; - verifies the stamp under its window's derived key, refuses it within 60 s of its
until(STAMP_EXPIRED), and checks that the op's nullifier iskeccak256(abi.encode(NULLIFIER_TAG, serial)); - refuses a stamp already used by an op in flight (
STAMP_IN_FLIGHT) or already cancelled on chain, from the paymaster'scancelledAt(nullifier)(STAMP_SPENT); - admits it against the deposit's headroom only (prepaid gas does not compete for the free lane's budget or pacing);
- co-signs
stampDigest(userOpHash, nullifier)with the epoch's co-signer key and forwards the op to its bundler.
Prepaid stamp · version 2 · 164 bytes
- In
userOpHash, signed by the account - The stamp's nullifier,
keccak256(abi.encode(NULLIFIER_TAG, serial)) - Relay co-signature over userOpHash and the nullifier, and the length
paymasterAndData, drawn to scale (164 bytes). No work: the suffix holds the nullifier the paymaster records in postOp, and the relay's co-signature, which only the relay can produce once it has checked the stamp. The stamp's serial and signature go to the relay alone, never on chain.import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import {
createRelayClient, freshAccountState, listedTokens,
sendStampedUserOperation, startEpochPolling, transferCall,
} from '@paravion/sdk'
const relay = createRelayClient('https://relay-production-fb81.up.railway.app')
const epochs = startEpochPolling(relay) // fixed 60 s background poll
const owner = privateKeyToAccount(generatePrivateKey()) // 0 ETH, never funded
const NVDA = listedTokens(4663).find((t) => t.symbol === 'NVDA')!.address
const stamp = saved.shift()! // bought days ago, not right now
const { receipt, state } = await sendStampedUserOperation({
relay, epochs, owner, stamp,
state: freshAccountState(owner.address), // first op: carries the EIP-7702 delegation
call: transferCall(NVDA, to, 10n ** 18n),
})
console.log(receipt.success) // the paymaster cancelled the stamp (StampCancelled)Cancellation#
- Validation checks the policy, the delegate, a non-zero nullifier and the relay's co-signature over the op and that
nullifier. Only the relay, which checked the stamp, can produce it. Validation never reads a nullifier: it reads only
the epoch's write-once slots, so no other op's
postOpcan invalidate an op the bundler already simulated (ERC-7562). - postOp takes the op's cost (
actualGasCostplus 36,000 gas for itself and the EntryPoint's bookkeeping) from the float, records the nullifier with the time of its first cancellation, and emitsStampCancelled. This happens whether the call succeeded or reverted: the gas was paid either way. - A double spend is public. If a nullifier is ever recorded twice (a relay that co-signed one stamp twice), postOp
emits
DoubleSpendwith the first cancellation's time, for anyone to see. postOp is accounting only and never reverts.
The free stamp#
No ETH at all? The original path is still there: the client mines 8 keccak sub-solutions bound to the op's hash
(paymasterData version 1), and the relay co-signs it like any other op. Free stamps are paid from the free lane, a small
budget per 15-minute epoch, refilled from the treasury (which the $STAMP creator fees fund), with difficulty and
pacing rationing demand. When the epoch's budget is spent, wait for the next epoch or use a bought stamp.
How it works has the challenge and the work formula.
Windows, keys and pins#
- Windows. Stamps are issued in 30-day windows aligned on multiples of the window length, and stay valid
120 days after their window closes. Every stamp bought in one window carries the same
info, so it hides among all the stamps of that window. - Per-window keys. The relay signs each window with a key derived from its master key and that window's
info(the partially blind scheme's key derivation). A new window, or a new master key, never invalidates a stamp in circulation: each verifies under the key of its owninfountil itsuntil. - Rotation. A new master key is added with a later
activeFromand issues from the first window starting at or after it; the old key stays until its last window expires. - Pins. The issuer keys and the window policy are published in
contracts/deployments/<chainId>.jsonunderstamps(windowSec,validitySec,issuers[{ keyId, n, e, activeFrom }]). The SDK refuses (ISSUER_MISMATCH) a key that is not pinned, a window that is not aligned or has another length or validity, or a window that is not the current one. A relay therefore cannot hand one buyer a private key or window that would mark that buyer's stamps.
| keyId | Active from | Modulus (base64url) |
|---|---|---|
| avion-1 | 2026-10-24 | 1nOPkuUw6hk8zKJP… |
Where the money goes#
| Station | What happens |
|---|---|
| PostOffice | A stamp costs the gas quote plus the markup. The gas share goes to paymaster.creditStamps(): the stamp float in the EntryPoint deposit, kept out of the free lane. The markup goes to the DeadLetterOffice. |
| DeadLetterOffice | The relay's keeper calls burn(ethIn, minOut): $STAMP is bought on its Pons v2 bonding curve (before graduation) or its Uniswap v4 pool (after) and sent straight to 0x…dEaD. Pons tokens have no burn(), so the dead address is the burn. ETH can leave only that way. |
| Pons v2 | $STAMP trades on its Pons v2 market; its creator fees accrue in ETH. |
| CreatorFeeSink | Anyone may call collect(): the creator fees are swept, claimed and sent to the paymaster's treasury with fund("pons-creator-fees"). No owner, no withdrawal. |
| StampPaymaster | The treasury tops up the free lane at each roll. The owner may write off the float of expired stamps (writeOffFloat; the ETH stays in the deposit) and move treasury ETH back after a drain (refloat). |
Use cases#
- Stock tokens out of Robinhood, without a top-up. Robinhood's terms cover network fees on Robinhood Chain only for in-app swaps above $0.50, until December 31, 2026, and not for wallet-to-wallet transfers (Robinhood support (opens a new tab)); the wider 90-day gas subsidy of Robinhood Wallet transactions was reported to end on September 29, 2026. Send your Stock Tokens to a fresh address once; from there, every later transfer is paid with a stamp, and the fresh address is never funded. The transfer in links your Robinhood wallet to the fresh address, publicly: plan for it.
- Fresh receive addresses. One address per payer, invoice or counterparty. Whatever it receives, it can forward later with a stamp, without a funding transaction from your main wallet.
- New identities. A fresh key has no past. Stamps let it act on chain without the one transaction that would give it one.
- AI agents. The MCP server buys booklets from a payer account (
paravion_buy_stamps), keeps its stamps encrypted (paravion_list_stamps) and sends from fresh accounts with postagestamp. See For AI agents.
What stamps do not hide#
Hidden
- The funding link
- No ETH is ever sent to the fresh address, so no top-up transaction ties it to the wallet that would have paid for its gas.
- Which purchase paid, even for the relay
- The relay signs blinded serials at purchase and sees unblinded ones at use; nothing in either links them. Every stamp of one 30-day window shares one key and one metadata string, so a stamp says nothing about its booklet.
- Your key
- Generated where you run the app or the agent, and kept there. The relay never sees it.
Not hidden
- Your purchase
- Buying a booklet is a public transaction: StampsBought names the buyer, the count and the price.
- What the fresh address receives
- Assets sent to the fresh address link their sender to it, publicly and permanently. Every operation is public on chain: sender, recipient, token, amount and time.
- Your IP address, twice
- The relay sees the IP address at purchase (stamp_issue) and at use (stamp_sendUserOperation). It keeps no logs, but you are trusting that.
- Your timing
- A purchase followed at once by the first operation sent with a stamp pairs the two in time, for the relay and for anyone reading the chain.
What to do
- Wait before using fresh stamps.
- Buy several at once and use them over time.
- Reach the relay over Tor or a VPN.
The privacy guide goes through each line.