Folio is a portable receipt layer for digital commerce. A receipt is a living, verifiable document whose integrity is enforced at every mutation: product, buyer, seller, purchase date, payment reference, transfer history, rights and warranty, wrapped in a tamper-evident signature and optionally anchored on-chain.
Executive summary
In traditional e-commerce a receipt is a blob your vendor controls, with no portable way to prove purchase, ownership or rights to a third party. Folio fixes that with three synchronized guarantees.
Signed portability. Every receipt carries an HMAC-SHA256 signature over a deterministic canonical JSON payload, stable regardless of key order, so it verifies byte for byte across languages and systems.
Ownership and provenance. Receipts can move between buyers. Each transfer appends a signed history entry, and ownership is checked against normalized identities (checksummed, case-folded wallets or human-readable ids).
On-chain verifiability. Each receipt is optionally anchored in a ReceiptRegistry.sol contract that stores the receipt hash, issuer, owner, issued-at time and a revocation flag. The full receipt stays off-chain and portable.
Tamper with the payload and the signature breaks. Rewrite the payload and re-sign it, and the on-chain hash match breaks. Try both on the
Verify page.
Architecture
Folio is composed of six synchronized layers, in one loop: the dashboard or SDK issues a receipt, the integrity engine canonicalises and signs it, the receipt is stored in Postgres and (when configured) anchored in the registry, verification re-derives the signature and compares the on-chain hash, and the public receipt page renders the verified payload and its status.
| Layer | Role |
|---|
| Frontend | Next.js 14 (App Router), TypeScript, Tailwind: landing, dashboard, public /receipt/[id] |
| Web3 and connectivity | wagmi + RainbowKit with WalletConnect, wallet session restore |
| API and SDK | /api/auth/*, /api/receipts, verify, transfer, revoke, plus the TypeScript SDK |
| Integrity engine | Canonical serialization, HMAC-SHA256 signing, identity normalization |
| Data | Supabase Postgres (transaction pooler, auto-schema) |
| EVM network | Local Hardhat, Sepolia or a custom RPC network the issuer deploys to |
Smart contract
A single purpose-built contract is deployed on the target network. It is deliberately minimal: the full receipt lives off-chain and the registry stores only the non-reversible hash, so it cannot be rewritten or repudiated.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract ReceiptRegistry {
struct ReceiptProof {
bytes32 receiptHash;
address issuer;
address owner;
uint64 issuedAt;
bool exists;
bool revoked;
}
mapping(bytes32 => ReceiptProof) private receipts;
mapping(address => bool) public issuers;
address public admin;
}| Field | Meaning |
|---|
| receiptHash | keccak256(canonical signed receipt payload), the tamper anchor |
| issuer | The whitelisted business that anchored the receipt |
| owner | Current owner per the chain (gates transfers) |
| issuedAt | block.timestamp at issue |
| exists | Whether the receipt id was ever anchored |
| revoked | Revocation flag; a revoked receipt cannot be transferred |
| Function | Access | Purpose |
|---|
| issue(receiptId, receiptHash, owner) | onlyIssuer | Anchor a new receipt; rejects duplicates |
| transfer(receiptId, to) | onlyOwner | Move on-chain ownership; blocks revoked receipts |
| revoke(receiptId) | owner / issuer / admin | Set the revoked flag |
| get(receiptId) | public view | Read the on-chain proof |
| setIssuer(address, bool) | onlyAdmin | Grant or revoke issuer status |
event ReceiptIssued(bytes32 indexed receiptId, bytes32 indexed receiptHash, address indexed owner, address issuer);
event ReceiptTransferred(bytes32 indexed receiptId, address indexed from, address indexed to);
event ReceiptRevoked(bytes32 indexed receiptId, address indexed by);
event IssuerUpdated(address indexed issuer, bool allowed);
Anchoring is optional: it engages only when an RPC URL, the deployed registry address and an issuer key are all configured. When unset, everything works fully off-chain and verification reports onchain.configured: false.
Signature engine
The core differentiator is deterministic, language-agnostic signing. stableStringify produces byte-identical JSON for structurally identical receipts.
Object keys are sorted recursively and there is no whitespace.
undefined properties are dropped, so a store and parse round-trip never changes the canonical bytes.
signature = HMAC-SHA256(RECEIPT_SECRET, stableStringify(unsignedReceipt))
The proof field is excluded from the signed payload and recomputed after every mutation. Comparisons are constant-time. Secrets have no fallback in production, and the signing secret must never rotate after launch or existing signatures stop validating.
Identities may be a human-readable id (alex@example.com, acme#20435) or a wallet. Normalization checksums and case-folds wallets and lowercases human ids, so ownership checks match regardless of casing.
Receipt lifecycle
type Receipt = {
id: string; // "rcpt_<uuid>"
version: "1.0";
product: { name; description?; sku?; metadataUrl? };
buyer: { id; wallet?; email? };
seller: { id; name; website? };
purchasedAt: string; // ISO 8601
payment: { amount; currency; reference; transactionHash?; chainId? };
ownership: { status: "owned" | "transferred" | "revoked";
currentOwner; transferable; revokedAt?; revocationReason? };
transfers: { id; from; to; at; reference? }[];
rights: { name; description?; expiresAt? }[];
warranty?: { provider; expiresAt?; supportUrl? };
proof: { algorithm: "HMAC-SHA256"; signature; issuedAt; onchain?: { txHash } };
};Issue
Required fields are validated, ownership defaults to the buyer, and the canonical payload is signed and persisted.
When anchoring is configured, the registry records keccak256(receipt) keyed by the receipt id and the transaction hash is stored back as proof.
Verification model
Verification is the product. Anyone, a buyer, a warranty desk or a future seller, can independently check a receipt in two layers that must both hold.
Layer 1: off-chain signature
The signature is recomputed over the canonical payload and compared. A mismatch means the stored payload was tampered with.
Layer 2: on-chain anchor
exists === true AND receiptHash === keccak256(canonical payload) AND no read error
GET /api/receipts/:id/verify
{ "valid": true, "receipt": { ... },
"onchain": { "configured": true, "exists": true, "matchesHash": true,
"owner": "0x...", "registry": "0x...", "chainId": 11155111, "txHash": "0x..." } }A failed check returns { "valid": false, "reason": "..." } with HTTP 404.
Transfer and revocation
Secondary trading here is the movement of ownership: the receipt-ground truth when a product is resold, gifted or reclaimed.
| Operation | Guards |
|---|
| Transfer | destination is non-empty, receipt is transferable, not revoked, caller is the current owner or the issuing business |
| Revoke | not already revoked, caller is the owner, the issuing business or the admin |
Every mutation rebuilds the payload, appends the history entry, re-signs and (when configured) anchors the chain write, so the provenance chain stays cryptographically intact. A revoked receipt cannot be transferred, enforced both off-chain and on-chain.
Wallet and sign-in
Folio uses wagmi and RainbowKit with a WalletConnect project id so mobile wallets connect by QR or deep link. Injected wallets such as MetaMask, Rabby, Coinbase Wallet and Trust Wallet work without it. Sign-in is SIWE-style and non-custodial.
The server issues a random nonce stored in an httpOnly cookie that expires in five minutes.
The wallet signs the exact EIP-191 message below.
The server recovers the signer, checks it matches the claimed address and sets a signed session cookie (7 days, httpOnly, SameSite=Lax).
Sign in to Folio
Nonce: ${nonce}Use the Connect wallet button on this site to try the same message signing.
Developer guide
Requires Node.js 22+, npm and a Postgres database (tables are created automatically). Repository: the Folio repository.
git clone <your-folio-repo-url>
cd folio
npm install
cp .env.example .env
npm run dev # http://localhost:3000
import { FolioReceipts } from "./src/lib/folio-receipts-sdk";
const folio = new FolioReceipts(process.env.BASE_URL!, process.env.API_KEY!);
const { receipt } = await folio.issue({
product: { name: "Creator Pass", sku: "CP-2026" },
buyer: { id: "alex@example.com", wallet: "0xAbC..." },
seller: { id: "studio", name: "Studio Inc." },
purchasedAt: new Date().toISOString(),
payment: { amount: 49, currency: "USD", reference: "order_123" },
rights: [{ name: "Lifetime access" }],
});
const { valid, reason } = await folio.verify(receipt.id);
await folio.transfer(receipt.id, "0x0d...", "gift");
await folio.revoke(receipt.id, "chargeback");| Variable | Required | Purpose |
|---|
| API key | yes | Business key (x-api-key) for issuing, transferring and revoking |
| Receipt secret | yes | HMAC key signing every receipt, never rotate after launch |
| Session secret | yes | Signs the wallet session cookie |
| DATABASE_URL | yes | Postgres transaction pooler URL |
| WalletConnect project id | mobile | Enables QR and deep links |
| RPC URL, registry address, issuer key | on-chain only | Enables anchoring |
npm run test:contract # Hardhat: ReceiptRegistry behaviour
npm run test:lib # signing, canonicalization, transfer and revoke rules
npm run deploy:sepolia # deploy the registry to a public testnet
Deployment and security
Deploy on Vercel with Supabase: copy the transaction pooler connection string into the database variable, import the repository with the Next.js preset and set the environment variables. Back up the database together with the signing secret, since without the key the database cannot validate its own receipts.
Constant-time comparisons for signatures and session tokens.
httpOnly, SameSite=Lax and Secure cookies for nonce and session.
A same-origin guard blocks CSRF on browser-initiated writes, and every route has per-IP rate limiting.
No secret defaults in production, and wallet signatures are verified by key recovery rather than trusting client claims.
Defense in depth: the off-chain signature and the on-chain anchor must both pass.