How it works, and how to check.
QRToken puts a verifiable on‑chain token behind a physical object. A QR code under a tamper sticker carries a single‑use key. The holder scans it, and the key signs a claim that moves the object’s token into their own wallet.
From then on, a public page shows every fact about that object with a tag saying where the fact came from: read from the blockchain, or read from IPFS. There is no company database anywhere in that path.
Your metadata is a folder
A series is one IPFS folder — the folder is the series. Its content hash becomes its on-chain identity, so nothing is assigned, nothing is sequential, and nothing can collide.
- Metadata is never right the first time. Update the folder in place while the run is still settling; patch mode reads back the currently pinned folder so you retype nothing, matching attributes by name rather than by position.
- If your new content hashes to the CID already on chain, the run exits as a no-op: zero uploads, zero transactions, zero gas.
- Freeze it when it stops moving, and the freeze is permanent — a later update fails before it can touch anything. Metadata, royalties, and the contract URI each freeze separately, on their own schedule.
- Freezing a ten-thousand-token series does not die on gas. Event emission is streamed across transactions behind a cursor, so series size is not a design constraint.
- Token ids are yours to choose. Sparse and vanity ids are first-class — the pipeline validates a set, not a range.
Show the evidence
- seriesId = keccak256(cid). The tooling computes the id locally before it asks the chain, and knows what the answer should be — the same folder yields the same id on any chain.
- First mint writes tokenId → seriesId permanently. A token can never be silently re-pointed at different metadata.
- freezeSeriesInit sets the series immutable and emits OpenSea’s PermanentURI for every token in it. Any later update reverts with SeriesFrozenError before any side effect.
- updateSeriesInit / updateSeriesEmit(limit) and freezeSeriesInit / freezeSeriesEmit(limit) walk a persistent cursor, emitting up to limit events per transaction and returning hasMore. State flips immediately on Init; emission catches up behind it. The batch ceiling (maxAtomicOperation, default 250) is admin-tunable as chain gas limits evolve.
- A non-fungible series enforces amount == 1 on-chain; a duplicate mint reverts with DuplicateTokenId. ERC-2981 royalties are capped at 10% by MAX_BPS in the contract itself — a constant, not a policy.
The object claims itself
The claim is a signature, not a key handover — and that distinction is the whole security model. The private key lives on the physical object, inside the QR code under the tamper sticker.
- The holder scans. The key is decoded in their browser and never leaves the device — not to a server, not to us.
- It signs a message naming the holder’s own wallet. Their wallet then sends the transaction and pays the gas.
- The contract recovers the signer, looks up which token that address was registered to, transfers it, and marks it activated — permanently.
- A stolen signature is worthless. It only ever authorises a transfer to the wallet named inside the signed message.
- The scanner reads the token’s status live, so a holder sees “already activated” or “not registered” before being asked to sign anything.
Show the evidence
- Each token is pre-registered to an ephemeral address whose matching private key exists in exactly one place: the ASCII85-encoded QR on the object. The signed payload is keccak256(timestamp, holderWalletAddress).
- A timestamp window (maxTimestampAge, owner-tunable) bounds replay; future-dated signatures are rejected outright.
- One-shot by construction: activate deletes the holder registration and sets activatedTokens[id] = true. There is no second claim.
- The escrow refuses strays. onERC1155Received rejects any token from any contract other than its configured one, and any token that was not pre-registered — you cannot accidentally strand an asset in it.
- tokenStatus(id) is public and returns Valid / Invalid / Activated.
Dead QR codes never ship
For a physical product, a batch of dead QR codes is an unrecoverable failure — the objects are already in boxes. So the pipeline treats the QR payload as something to be proven, not trusted. You supply the holder files; it refuses to take a single value in them on faith.
- Every declared address is re-derived from its private key rather than believed.
- Every secret — the exact string that will be printed on the physical object — is decoded and checked to reconstruct the same key that gets registered on-chain. A well-formed-but-wrong secret would otherwise ship a dead QR.
- The encoding alphabet is a shared constant between the pipeline and the scanner, so a code the scanner would reject cannot pass registration.
- Your holder set must exactly match your metadata token-id set, and you are told both what is missing and what is extra.
- All of it runs in a dry run, in full, before anything is uploaded or written.
Show the evidence
- Secrets are ASCII85-decoded and checked to reconstruct the same 32 bytes as the private key whose address is registered on-chain.
- Set equality is enforced between the holder set and the metadata token-id set, reporting missing and extra ids separately.
- Note on scope: the pipeline validates operator-supplied holder files. Keypair and QR generation is a separate tool and is not part of this validation path.
Every value says where it came from
The public token page is not a product page with a blockchain badge. It is a verification surface, built so that it cannot quietly lie. Every value on it is tagged with its source — read from the chain, or read from IPFS — and carries the evidence for that tag.
- The tag is not a badge someone remembered to add. The source is locked into each field’s type, so a function that produces metadata physically cannot return a chain-tagged value.
- There is no third source. Even the site’s own configuration — the brand name, the domain — is tagged as metadata. The site’s opinion of itself is treated as a claim, not as evidence. There is no “trust us” tier.
- Every chain read on the page is pinned to a single block, and a runtime check proves no field drifted to a different one. The page is a consistent snapshot, not a smear of reads across time.
- The gateway is never trusted. Metadata is fetched as raw IPLD blocks and hash-verified against its CID, with the fetcher resolving directory paths itself. Gateways are raced, not depended on: one that lies is dropped, one that stalls is outrun.
- Contradictions are shown, not smoothed. If the metadata declares a serial number that disagrees with the on-chain one, both rows render side by side, differently tagged. The disagreement is the feature.
- The page is assembled from chain reads and CID-verified IPFS blocks. There is no company database anywhere in that path.
Show the evidence
- An on-chain tag carries the exact contract address, method, arguments and block number. A metadata tag carries the exact CID, ipfs:// URI and gateway URL.
- The “ERC-1155” label is a chain fact, not a string: the page calls getCode to prove bytecode exists at the address at that block, and supportsInterface(0xd9b67a26) to prove the standard. If either fails, the fields anchored on them visibly degrade rather than rendering a comforting lie.
- Directory resolution is done block by block by the fetcher itself, including HAMT-sharded directories for large series. Multi-block files are reassembled from individually verified blocks.
- Scope: CID verification covers the metadata JSON. Media bytes are served from a gateway URL and are not hash-verified today.
- /{id}/raw returns the exact object the HTML rendered from — every field, its source, its status, its full reference. Anyone can replay every call at the pinned block and re-derive the page independently. It returns the full picture even for unminted tokens, so you can see why something is absent instead of getting an opaque 404.
Rehearse the run, then run it
One command runs the entire onboarding of a physical batch: render, validate, upload, verify, create the series, register the holders, mint, verify again, and hand you a receipt. Every safety property exists because the alternative is a pallet of ruined product.
- The dry run is not a linter — it simulates. It performs real chain reads, checks your wallet holds the roles it needs, estimates worst-case gas against your actual balance, cryptographically validates every QR code, computes the exact CIDs the real run would produce, and simulates the transactions against the live chain. Then it exits without a single side effect.
- Resume treats the chain as the authority, not a journal. Crash, Ctrl-C, dropped RPC, dead laptop: re-run the identical command and it re-derives every skip-or-do decision from live chain reads. No double-mint, no double-register.
- Edit the source folder mid-flight and it refuses to resume — you cannot accidentally resume into a different CID than the one you started registering.
- CIDs are computed locally and verified three separate times: against what the pinning provider returns, against two independent gateways, and again from the bytes a gateway actually serves after the chain write.
- Because a token id binds to its series forever, an id that already belongs to someone else’s series would otherwise leave you with tokens resolving to someone else’s metadata. The pipeline detects this before doing anything and aborts, listing every offending id.
- Nothing is trusted, including receipts. Every transaction is simulated before it is sent, waits for confirmations, and is then independently re-read from chain state.
- Mainnet is opt-in: it refuses to run without an explicit confirmation flag.
Show the evidence
- Preflight checks METADATA_ROLE, MINTER_ROLE and ownership of the activation contract, estimates gas with 20% headroom against your balance, and runs simulateContract against the live chain.
- The state file is a hint, not a source of truth, and is written atomically (temp file + rename), so a crash mid-write cannot leave a torn file. The source folder is content-hashed; a change since the last run aborts the resume and demands --force-restart.
- The third CID check is deliberately a full derivation from the returned bytes, not a length or checksum shortcut — a tampered-but-same-length payload is caught.
- Transactions are simulated with custom-error names decoded, wait 2 confirmations, and are then re-read from chain state — an explicit hard gate against shallow reorgs and optimistic-confirmation lies. Registration and minting run in batches of 100, each verified on its own.
- Before it says “done”, a sample of minted tokens is round-tripped end to end: read uri(tokenId) from the chain, fetch that exact path through both gateways, re-derive the CID from the returned bytes, re-validate the JSON against the schema. If any stage fails, the run never completes.
- You get a structured JSON audit report: series id, token ids, both CIDs, every transaction hash, gateway URLs, the on-chain URI template, holder count, status. It deliberately omits your wallet address and RPC URL.
Your deployment, your keys, no backend
You get your own contracts — not a slot in a shared registry. Your own token contract, your own activation contract, deployed with one command and recorded as a versioned artifact the apps build against.
- Five separate roles, so the key that mints is not the key that can upgrade.
- Admin authority is transferable and revocable. Hand it to a multisig, hand it to a timelock, or renounce it and make the implementation permanent. That is your ratchet to choose, not ours.
- Trust is deliberately asymmetric. The token contract is upgradeable, because a metadata schema evolves. The activation contract is not — it holds custody, so its code is fixed at deployment and cannot be changed by anyone, including you. Custody logic does not get a back door.
- ERC-1155 with royalties capped at 10%, full supply tracking, burnable, and a pauser role that halts transfers and mints without touching anything else.
- No servers. No database, no API tier, no backend to breach and no backend to keep paying for. The read path is the chain and IPFS.
- Safe by default in the field: the app hard-fails if it is served from a domain other than the configured one, so a cloned phishing site gets a 404 rather than a working scanner. The network badge is always visible and turns red on an unknown chain. A single environment variable puts either app into maintenance mode with your message, no deploy required.
Show the evidence
- Roles: MINTER, METADATA, PAUSER, ROYALTY, ADMIN. ADMIN_ROLE gates upgrade authority and is transferable and revocable.
- The token contract is a UUPS proxy (Series1155V1); SeriesActivation is non-upgradeable by design. Multiple deployments coexist per chain.
- The runtime is two Cloudflare Workers. Royalties are clearable and freezable; the 10% ceiling is enforced in the contract.
- The claim app and the token page consume a single shared component package and theme file. Brand name, company name, domain, product name, logos and favicons are environment configuration.
Putting a physical run on-chain?
Each issuer gets their own contracts and holds their own keys. Tell us what you are minting and we will tell you plainly whether this fits.
hello@qrtoken.xyz