STAMP

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.

  1. BuyAny wallet pays PostOffice.buy for N blinded serials. The purchase is public and commits to the blinded messages.PostOffice.buy
  2. IssueThe relay checks the mined purchase and blind-signs exactly the committed messages. It never sees a serial.stamp_issue
  3. KeepThe client unblinds the signatures into stamps and keeps them, encrypted when exported. A stamp is a bearer token.local only
  4. 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
  5. 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.

One stamp, five steps. Only the purchase and the cancellation are on chain; the serial and the signature never are.

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:

text
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.
Configured prepaid-stamp parameters (contracts/config/mainnet.json postal, apps/relay defaults)
ParameterValueWhy
Face value1 operationOne sponsored op of an allowed template, gas capped by its profile and the epoch's caps.
Booklet1 to 100 stampsPostOffice.MAX_BOOKLET; one purchase, one commitment, paid once.
Initial price0.0000125 ETH per stampThe relay's quote for one op plus the markup, at deployment.
Price bounds0.000001 ETH – 0.001 ETHOwner bounds; the pricer can never leave them.
Markup2,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 minutesPostOffice.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 threshold10% drift (PRICE_DRIFT_BPS 1,000)Fewer price transactions; small moves are absorbed by the safety factor.
Issuance window30 daysEvery 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 window120 daysA stamp bought on the last day of a window is still good for 120 days.
Issuer keys1 pinned: avion-1Pinned in deployments/<chainId>.json (stamps.issuers); clients refuse any other key or window.
Blind-signature suiteRSAPBSSA-SHA384-PSS-Deterministic, RSA 2048RFC 9474 with public metadata; deterministic, so issuance is idempotent.

Buy a booklet#

  1. Read the issuer. stamp_getIssuer returns 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.
  2. 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, suite RSAPBSSA-SHA384-PSS-Deterministic).
  3. Store. The pending booklet (serials, blinded messages, blinding inverses) is saved before anything is paid: without the inverses, paid stamps cannot be unblinded.
  4. Pay. Any wallet calls PostOffice.buy(commitment, N) with N × price; any excess comes back in the same transaction. A commitment can be paid for once.
text
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
A stamp's public metadata (info), one string per window
FieldValueMeaning
prefixparavion:v1Format and version.
chain4663The chain the stamp pays for operations on.
paymasterlowercase addressThe paymaster that sponsors them.
keykeyIdThe issuer master key (published in the pins).
face1opFace value: one operation of an allowed template, gas capped by its profile.
fromunix secondsStart of the 30-day issuance window, a multiple of the window length.
untilunix secondsEnd of validity: from + 30 days + 120 days.
buy.ts
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 cash

Issuance#

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_UNKNOWN or ORDER_PENDING;
  • it emitted StampsBought from the PostOffice with count = N and exactly this commitment of info and the blinded messages, else ORDER_MISMATCH;
  • its block time falls in the window info names (or at most an hour after it ends), and info names 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.

A saved stamp
FieldWhat it isSent to the relay at use
serial32 random bytes drawn on your device; the message the signature covers.yes
signatureThe unblinded RSA-PSS signature (256 bytes with a 2048-bit key).yes
infoThe window's public metadata, above.yes
nullifierkeccak256(abi.encode(NULLIFIER_TAG, serial)): what the paymaster records when the stamp is used.in the op
key, chainId, paymaster, from, untilCopies 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-carnet v1) to move them to another device or to the agent, which keeps the same format in PARAVION_HOME/carnet/<network>.json under PARAVION_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.
export.ts
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 passphrase

Use 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:

  1. 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;
  2. 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 is keccak256(abi.encode(NULLIFIER_TAG, serial));
  3. refuses a stamp already used by an op in flight (STAMP_IN_FLIGHT) or already cancelled on chain, from the paymaster's cancelledAt(nullifier) (STAMP_SPENT);
  4. admits it against the deposit's headroom only (prepaid gas does not compete for the free lane's budget or pacing);
  5. 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
A prepaid stamp's 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.
send.ts
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 postOp can invalidate an op the bundler already simulated (ERC-7562).
  • postOp takes the op's cost (actualGasCost plus 36,000 gas for itself and the EntryPoint's bookkeeping) from the float, records the nullifier with the time of its first cancellation, and emits StampCancelled. 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 DoubleSpend with 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 own info until its until.
  • Rotation. A new master key is added with a later activeFrom and 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>.json under stamps (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.
Published issuer pins on Robinhood Chain (deployments/4663.json)
keyIdActive fromModulus (base64url)
avion-12026-10-241nOPkuUw6hk8zKJP…

Where the money goes#

The $STAMP loop
StationWhat happens
PostOfficeA 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.
DeadLetterOfficeThe 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.
CreatorFeeSinkAnyone 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.
StampPaymasterThe 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 postage stamp. 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

  1. Wait before using fresh stamps.
  2. Buy several at once and use them over time.
  3. Reach the relay over Tor or a VPN.
What a stamp hides, and what anyone can still read.

The privacy guide goes through each line.

Next steps#