Idea: Standard digital receipts using ERC-721

Been thinking about this problem… in a more general sense and have a counter proposal: The core idea is that:

A receipt is a Merklized signed off-chain document, and the issuer signs the root. The chain is a shared status layer that applies to line items (physical or virtual objects), not the receipt database.

What you bought, paid, and who you are stay in a signed off-chain document (in an email, a file, a wallet). The chain is touched only when several mutually-distrusting parties need to agree on a fact about state. Who controls an entitlement now, whether it was sold on, returned or flagged stolen, or that a lifecycle hash provably existed by some block. Most receipts never touch the chain at all.

This approach gives you three things:

  • privacy - purchase data never lands on a public ledger

  • scale - no per-receipt gas

  • honesty - the chain records control of an entitlement (the warranty, return, and resale rights) associated with a single item.

The signed receipt document

A receipt is a json document and a corresponding Merkle tree, the issuer signs the root.

  • Leaf 0 is the header (issuer, issuedAt, type, a fiscal-evidence hash for tax or expenses purposes).

  • Each subsequent leaf is one line item.

  • Each line-item leaf is itself a small commitment entitlementId = keccak256(T_ENT, receiptRoot, lineIndex, saltCommit) over field groups: core, commercial, warranty, descriptive, provenance. Absent groups use a fixed sentinel.

That sub-leaf split means that a verifier can be shown core (what was bought) without commercial (what was paid).

Off-chain proofs (Merkle selective disclosure, no ZK required)

To prove a line item, the holder reveals the sub-leaves the situation calls for, plus the Merkle path, plus the signed root. The verifier rehashes upward to the root and checks the signature. That’s it — plain Merkle disclosure, no circuit.

Disclosure levels are structural:

Level Reveals
full the entire receipt
core header + selected line core (no price)
core+<group> header + whatever groups the verifier requires (e.g. core+warranty)
zk (future) a predicate over the same commitments

So a warranty desk gets core+warranty and learns the terms but not the price; an expense tool gets full; an on-chain reward gets core only. (Note: these full/core/zk disclosure levels are about how much a proof reveals — nothing to do with L1/L2.)

Entitlement IDs and bearer secrets. Each line item carries a random 32-byte salt. The on-chain ID is derived from it, but documents and proofs only ever carry the commitment, never the raw salt:

saltCommit    = keccak256(abi.encode(T_SC,  entitlementSalt))
entitlementId = keccak256(abi.encode(T_ENT, receiptRoot, lineIndex, saltCommit))

Consequences worth noting:

  • the registry keys on entitlementId and learns nothing about product, price, or merchant;

  • whoever holds the document and the salt can claim the entitlement — a bearer instrument, which mirrors how paper receipts already behave;

  • anyone shown a proof (carrying only saltCommit) cannot register or claim it.

Because of this, VRS separates the powerless reference from the bearer authority into two link types — vrs1:ref (safe to hand a desk or employer) and vrs1:claim (bearer authority, redeemed once, only for the holder). This also means the highest-volume case (expenses) needs no wallet and no chain — just a signed document and structured ingest.

On-chain proofs / the status layer

Use the chain only where no single party should be the database:

  • entitlement transfer and current holder,

  • returns across branches/retailers,

  • stolen / voided / returned flags,

  • lifecycle-event hashes (proven time),

  • serial nullifiers (clone detection),

  • consumption by smart contracts (rewards, escrow, gating).

Proven time vs asserted time. A signature proves who and what, never when — a key can sign any date. issuedAt inside the document is asserted time. For provenance, the document hash is included in an anchored batch root (muliple receipts in a batch) published on-chain, giving proven time: a record anchored in 2026 demonstrably didn’t exist in 2024, whatever date it claims. The main attack on a resale history isn’t forgery, it’s backdating — inventing years of service the week before a sale — and proven time is what defeats it.

Lifecycle hashes, not documents. A repairer signs a service attestation; only its hash goes on chain via recordEvent. The document stays private and is selectively disclosed at resale. The chain never sees details, notes, photos, or locations.

Example Registering the entitlement on-chain

Most receipts never get here — registration is only for items you want stateful (resale, warranty, theft flags). When you do, the holder derives entId from receiptRoot + lineIndex + saltCommit and calls register(). Registration is consumer-pull: the registry recomputes entId from the salt and issuer binding, so the issuer never has to transact. The resulting registry slot is keyed by the opaque entId and carries only a status flag and holder addresses — no product, price, or merchant on chain. The raw salt stays with the holder; only its commitment ever appears anywhere

Proving and Transferring ownership on resale

A receipt proof on its own only shows the line exists — not that the seller still controls the registered entitlement. So the buyer reads statusOf/holderOf from the registry, then issues a holder challenge: the seller must sign as the current holder (ERC-1271 means smart-account wallets work as holders). transferWithAuth then moves holder to the buyer while firstHolderstays unchanged — so “original purchaser” gates survive resale while “current owner” gates follow the item. As always, this proves entitlement control, not legal title or physical condition, and VRS labels it that way.

The contracts

The on-chain layer is a small set of contracts — the reference implementation of the canonical Ethereum State Profile. (Reference contracts are draft and unaudited.) Addresses come from a signed deployment manifest; a non-canonical deployment must not be treated as VRS state.

AttestationRegistry (+ AnchorRegistry) — issuer identity and proven time. Turns “a key signed” into “an attested merchant / manufacturer / courier.” It’s verifier-relative (each consumer picks which attesters it trusts) and is explicitly not a DID system — roles are bytes32 tags.

solidity

// issuer identity
function attest(address subject, bytes32 role, uint64 validFrom, uint64 validUntil);
function revoke(address subject, bytes32 role);
function isAttested(address subject, bytes32 role, address attester, uint64 at) returns (bool);
​
// proven time
function anchor(bytes32 root);
function anchoredAt(bytes32 root) returns (uint64);

EntitlementRegistry — the shared ownership / status / lifecycle state, keyed by the opaque entitlementId. Registration is consumer-pull (the issuer never has to transact), with a commit–reveal variant for public mempools.

solidity

struct Entitlement { Status status; address holder; address firstHolder; address issuer; }
// Status: Unregistered | Active | Returned | Voided | Stolen
​
function register(IssuerBinding b, uint256 lineIndex, bytes32 salt); // + registerCommitted
function transferWithAuth(bytes32 id, address to, uint64 deadline, bytes holderSig);
function recordEvent(bytes32 id, bytes32 attestationHash);           // logs a hash, never contents
function flagStolen(bytes32 id);                                     // + clearStolen / markReturned / void
function holderOf(bytes32 id) returns (address);

MintAllowanceRegistry — manufacturer authenticity / clone detection. Revealing a serial salt sets a set-once nullifier; a second use of the same serial reverts — that’s the clone signal. Deliberately transparent, unlike the holder scheme which must hide.

ReceiptVerifier — the stateless, permissionless bridge from an off-chain proof to an on-chain consumer. It checks issuer signature + Merkle path + delegation against its attester policy and returns the facts a contract needs:

solidity

constructor(IAttestationRegistry attestations, IAnchorRegistry anchors, bytes32 role, address[] attesters);
​
function verifyLineItem(HeaderDisclosure h, CoreDisclosure c, /* … */)
    returns (bytes32 receiptRoot, bytes32 entId, bytes32 productIdHash, address identity);

One signature recovery, a handful of hashes up the Merkle path, one attester lookup.

How a smart contract consumes a proof

This is the capability that’s hard to get any other way: a contract checks a proof of purchase itself and acts on it; a reward, a token gate, an escrow release, with no merchant API, no off-chain voucher, and no one’s permission. Proof of purchase becomes a composable credential.

The canonical pattern is one call:verifyLineItem → (entId, productIdHash, identity) → apply policy:

solidity

function claim(HeaderDisclosure h, CoreDisclosure c) external {
    (, bytes32 entId, bytes32 productIdHash, address identity) = VERIFIER.verifyLineItem(h, c, /* … */);
    require(authorisedRetailer[identity]);              // optional grey-market gate
    require(!claimed[entId]); claimed[entId] = true;    // ★ once per ITEM, never per presenter
    points[msg.sender] += rateFor[productIdHash];       // chain learns WHAT, never the price
}

Three properties make this safe and private:

  1. Bind to the item, not the presenter. Keying on entId means an item can’t farm a reward again every time it’s resold — consumed once, forever. (For “original buyer only,” gate on firstHolder, which never changes through resale.)

  2. Core-only disclosure. The chain learns what was bought (inherent to a product-specific reward) but never what was paid — the price sub-leaf stays off-chain.

  3. Issuer policy is the contract’s. The verifier checks the signature against an accepted attester set; the contract can add its own gate (e.g. authorised retailer) with no merchant cooperation at claim time.

The same verify → gate shape covers DAO voting for current owners (holderOf(entId) == voter && status == Active), IoT config that follows the current holder, and — because sale/delivery/service are one signed format with three issuer roles — deterministic escrow that releases on a courier-signed delivery proof exactly as a reward pays out on a sale proof. One spec, three oracles.

Privacy roadmap

Today the EntitlementRegistry stores holder / firstHolder as plaintext addresses and emits Transferred(from, to), so a passive observer can reconstruct an item’s whole ownership chain once any address is deanonymised. Treat this as the main open problem. Potential mitigations:

  1. (Easy) Stealth-address holders (ERC-5564/6538) each holder a fresh, unlinkable address, provided gas is paid externally (relayer / ERC-4337 paymaster), else the funding address re-links them.

  2. (Doable but more complex) Holder commitments + a nullifier set replacing the plaintext holderOf mapping (the Zcash/RAILGUN note model, adopted natively so VRS keeps its own status semantics). Transfer becomes a note re-commitment, not a public owner update. This reuses the same set-once-nullifier + commit–reveal machinery as MintAllowanceRegistry, domain-separated.

  3. (Future research) ZK membership proofs for the proving paths “I hold an Active entitlement for product X” with a per-context nullifier, so a DAO vote / reward / device update verifies ownership without revealing which item or who holds it. (Possible conflict with keccak-everywhere choice as AFAK it isn’t SNARK friendly)

Note that even with these mitigations an observer still sees that some entitlement had an event, but this is probably acceptable as they never see who holds it or its history.