scroll (0%)
documentation

Technical documentation

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.
LayerRole
FrontendNext.js 14 (App Router), TypeScript, Tailwind: landing, dashboard, public /receipt/[id]
Web3 and connectivitywagmi + RainbowKit with WalletConnect, wallet session restore
API and SDK/api/auth/*, /api/receipts, verify, transfer, revoke, plus the TypeScript SDK
Integrity engineCanonical serialization, HMAC-SHA256 signing, identity normalization
DataSupabase Postgres (transaction pooler, auto-schema)
EVM networkLocal 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;
}
FieldMeaning
receiptHashkeccak256(canonical signed receipt payload), the tamper anchor
issuerThe whitelisted business that anchored the receipt
ownerCurrent owner per the chain (gates transfers)
issuedAtblock.timestamp at issue
existsWhether the receipt id was ever anchored
revokedRevocation flag; a revoked receipt cannot be transferred
FunctionAccessPurpose
issue(receiptId, receiptHash, owner)onlyIssuerAnchor a new receipt; rejects duplicates
transfer(receiptId, to)onlyOwnerMove on-chain ownership; blocks revoked receipts
revoke(receiptId)owner / issuer / adminSet the revoked flag
get(receiptId)public viewRead the on-chain proof
setIssuer(address, bool)onlyAdminGrant 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.
OperationGuards
Transferdestination is non-empty, receipt is transferable, not revoked, caller is the current owner or the issuing business
Revokenot 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");
VariableRequiredPurpose
API keyyesBusiness key (x-api-key) for issuing, transferring and revoking
Receipt secretyesHMAC key signing every receipt, never rotate after launch
Session secretyesSigns the wallet session cookie
DATABASE_URLyesPostgres transaction pooler URL
WalletConnect project idmobileEnables QR and deep links
RPC URL, registry address, issuer keyon-chain onlyEnables 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.