// ========================================================================================= // SoulboundBadge — a credential that cannot be sold, because there is no function that moves // it. // // WHAT IT IS FOR // A non-transferable record that one address holds something an issuer says it holds: a // course completion, a membership, a licence, an audit pass, a "this desk is authorised" // marker. It is the non-transferable profile of docs/ZK13_NFT_STANDARD.md §9: the same ids, // the same events, and no transfer. // // NON-TRANSFERABILITY IS ENFORCED BY ABSENCE, WHICH IS THE ONLY WAY IT CAN BE ENFORCED HERE. // There is no `transferToken`, no `approve`, no operator, and nothing in the ABI that writes // `badgeHolder` for an id that already has one. A holder cannot sell a badge, an issuer // cannot move one to a different address, and there is no upgrade path that could add // either: bytecode is part of the contract's address preimage and there is no `setCode` // anywhere in Sindri. What you deploy is what holders get, forever. // // WHAT IT IS NOT // It is not identity, and it is not an STR.domain. Identity on SourceLess is the native // WNFT, minted only by the domain registry. It is also not a proof about the HOLDER: a // contract cannot ask whether an address holds a proved domain (`AresHost` has no domain // accessor), so a badge records the ISSUER'S ASSERTION about an address and nothing more. A // verifier who trusts the badge is trusting the issuer, which is the correct amount of trust // to be placing and should be placed knowingly. // // THE TWO POWERS, BOTH DECLARED // * `revoke(id)` — the issuer takes a badge back. This is a confiscation power and it is the // one credentials genuinely need: a licence that cannot be withdrawn is not a licence. // Whether it exists AT ALL is fixed at deployment by `revocableFlag`, it is readable as // `revocable()` before anybody accepts a badge, and when the flag is false EVERY call to // `revoke` reverts with `not revocable` for the life of the contract. // * `renounce(id)` — the holder gives their own badge up, and NOTHING can stop them. It is // deliberately not gated by the pause: a paused contract must not be able to trap a // credential on somebody who no longer wants it. // // WHAT IT GUARANTEES // * One badge per holder per kind. `issue` refuses a second live badge of the same kind to // the same address, which is what makes `badgeOfKind(account, kind)` a single exact answer // rather than a list a verifier has to search. // * A badge's holder, kind, metadata pointer and expiry are written once, at issue, and NO // FUNCTION CHANGES ANY OF THEM. A credential whose terms the issuer can rewrite after the // fact is not evidence of anything. // * `valid(id)` is the whole verification: false for an id that was never issued, for one // that was renounced or revoked, and for one whose expiry has passed. It reads the SEALED // BLOCK's timestamp, never a wall clock, so two nodes replaying the same block agree. // * A kind's label is write-once. `labelKind` refuses to overwrite one, so a verifier who // read "Auditor, 2026 cohort" cannot later find the same number meaning something else. // * A revert leaves every badge byte-identical. Storage commits only on success. // // HOW IT IS TYPICALLY MISUSED // * Reading `badgeOfKind` and stopping there. It returns an id, including the id of an // EXPIRED badge; `valid(id)` is the check. Two reads, both constant cost, and skipping the // second is how an expired credential passes. // * Issuing with an expiry and expecting something to happen at that moment. Nothing happens. // Expiry is evaluated when somebody asks, and the badge stays in storage; the holder's // `balanceOf` still counts it. An expired badge is refused by `valid`, not deleted. // * Deploying with `revocableFlag = true` and calling the badge permanent. It is not, and // `revocable()` says so to anyone who asks. // * Issuing to a contract address. A contract cannot be `caller`, so the badge could never // be renounced — only revoked, and only if this contract is revocable. It is also // meaningless: a contract holds no credential. // * Under-declaring the gas limit on the call record. Post-mining covers only // min(gasUsed, gasLimit / 10), so the network silently absorbs the rest, forever, on every // call. MEASURED, on the v2 gas table, with 53-character addresses and a 38-character // metadata URI — the cost rises with the length of the URI, which is stored: issue 2,270 // with no expiry and 2,272 with one, renounce 1,386, revoke 1,560, labelKind 531, // proposeOwner 412, // acceptOwnership 612, setPaused 363, setMinter 471, closeMinting 470. Declare a gas limit // of at least 23,000. // // TO MAKE THIS YOUR CREDENTIAL // Edit the five constants below and nothing else. The deployer is owner and issuer, so the // sequence after deployment is `labelKind(1, "Auditor")` and then // `issue(holder, 1, "https://...", 0)`. // ========================================================================================= contract SoulboundBadge { // ---- edit these five ----------------------------------------------------------------- state badgeName: string = "Example Credential"; state badgeSymbol: string = "EXCR"; // FIXED AT DEPLOYMENT AND READABLE AS `revocable()`. False means `revoke` can never succeed, // for anybody, ever — the function stays in the ABI and always reverts, because a holder // must be able to see that the power was refused rather than merely unused. state revocableFlag: bool = true; // 0 means uncapped. Counts badges ever issued, not badges currently held. state maxBadges: uint = 0; // A policy bound on the metadata pointer, in UTF-16 code units. The VM prices a storage // write by the slot name it builds and NOT by the value it stores, so a 60,000-character URI // costs the same gas as a short one while adding 60,000 characters to state every node keeps // forever. The meter will not stop that; this will. state maxUriLength: uint = 512; // ------------------------------------------------------------------------------------- // `caller` inside a state initializer is the DEPLOYER: Sindri runs the compiler-generated // initializer with the authenticated deployer as caller, so there is no claim race. state contractOwner: address = caller; state pendingOwner: address = ""; state mintAuthority: address = caller; state frozen: bool = false; state issuedCount: uint = 0; state removedCount: uint = 0; // Per badge, all written once at issue. state badgeHolder: map; state badgeKind: map; state badgeUriOf: map; // 0 means never expires. state expiryOf: map; // holder -> kind -> badge id, and 0 for "holds none of this kind". This map is only // single-valued because `issue` enforces one live badge per kind per holder; without that // rule it would have to be a list, and a verifier would have to search it. state badgeOfKindMap: map>; state heldCount: map; // Write-once, by the issuer, so a kind number cannot come to mean something else later. state kindLabel: map; // Transfer is the SINGLE record of every badge appearing or disappearing, in the same shape // the NFT standard uses: an issue is Transfer("", to, id) and a removal is // Transfer(from, "", id). There is no from-to-to case, because there is no transfer. event Transfer(from: address, to: address, badgeId: uint); // The terms of the badge, emitted once, because they are written once. event BadgeIssued(badgeId: uint, kind: uint, uri: string, expiresAtMs: uint); // Which of the two removal paths was taken, and by whom. `Transfer(from, "", id)` says a // badge is gone; these say whether the holder gave it up or the issuer took it. event Renounced(holder: address, badgeId: uint); event Revoked(holder: address, badgeId: uint, by: address); event KindLabelled(kind: uint, label: string); event OwnerProposed(current: address, pending: address); event OwnerChanged(previous: address, next: address); event PausedChanged(paused: bool, by: address); event MinterChanged(previous: address, next: address); // ---- views ---------------------------------------------------------------------------- // The soulbound PROFILE of the NFT standard, declared as such: a client reading this knows // to expect ids and Transfer events in the NFT shape and NO transfer function at all. A // SELF-DECLARATION that proves nothing — the proof that nothing moves a badge is the absence // of a mover in `catalog(address)`, which a client can check. view fn standard() -> string { return "zk13-nft/1+soulbound"; } view fn name() -> string { return badgeName; } view fn symbol() -> string { return badgeSymbol; } // Read this before accepting a badge as evidence of anything. view fn revocable() -> bool { return revocableFlag; } // "" means never issued, renounced, or revoked. view fn holderOf(badgeId: uint) -> address { return badgeHolder[badgeId]; } view fn kindOf(badgeId: uint) -> uint { return badgeKind[badgeId]; } view fn badgeUri(badgeId: uint) -> string { return badgeUriOf[badgeId]; } // 0 means it never expires. view fn expiresAt(badgeId: uint) -> uint { return expiryOf[badgeId]; } // THE verification. False for an id never issued, for one no longer held, and for one whose // expiry has passed as of the block being executed. `timestamp` is the sealed header's // timestamp of that block, not a clock on any node, so every node agrees on the answer. view fn valid(badgeId: uint) -> bool { if (len(badgeHolder[badgeId]) == 0) { return false; } if (expiryOf[badgeId] == 0) { return true; } return timestamp < expiryOf[badgeId]; } // The holder's badge of this kind, or 0. An id here is not a promise that the badge is // valid — check `valid(id)`; an expired badge keeps its slot. view fn badgeOfKind(account: address, kind: uint) -> uint { return badgeOfKindMap[account][kind]; } // How many badges this address holds, expired ones included. view fn balanceOf(account: address) -> uint { return heldCount[account]; } view fn kindLabelOf(kind: uint) -> string { return kindLabel[kind]; } view fn totalIssued() -> uint { return issuedCount; } view fn totalRemoved() -> uint { return removedCount; } // Badges currently held, expired ones included, which is why a verifier checks `valid`. view fn totalSupply() -> uint { return issuedCount - removedCount; } view fn maxSupply() -> uint { return maxBadges; } view fn owner() -> address { return contractOwner; } // "" means issuance is closed permanently. view fn minter() -> address { return mintAuthority; } view fn paused() -> bool { return frozen; } view fn pendingOwnerIs() -> address { return pendingOwner; } // ---- issuing ---------------------------------------------------------------------------- // `expiresAtMs` is an absolute block time in milliseconds, or 0 for never. Absolute rather // than a duration because a duration would have to be added to `timestamp` here, and a // reader of the record could not then tell when the badge lapses without knowing which block // the call landed in. fn issue(to: address, kind: uint, uri: string, expiresAtMs: uint) -> uint { require(frozen == false, "paused"); require(len(mintAuthority) > 0, "mint closed"); require(caller == mintAuthority, "not minter"); // Unreachable through a call record — `validateArgs` checksum-validates every `address` // argument before the first opcode — and kept because a future caller path that does not // run it would need the guard. require(len(to) > 0, "empty recipient"); // A position at this contract's own address could never move again: a contract cannot be // `caller`, and the value ledger refuses a contract that pays itself. Every `transfer` in // this catalogue has refused this since it was written; the ISSUANCE path did not, so the // guard sat on the call a holder makes and was absent from the call an operator makes. // MEASURED 2026-10-07: minting here succeeded and produced a position nothing can spend. require(to != self, "self is not payable"); // There is no kind 0: it is the value `badgeOfKind` returns for "holds none", so a badge // of kind 0 would be indistinguishable from no badge at all. require(kind > 0, "no such kind"); require(len(uri) <= maxUriLength, "uri too long"); // An expiry already in the past would issue a badge that is invalid the moment it exists. // Refuse it rather than record it: a credential nobody can use is a mistake, not a policy. require(expiresAtMs == 0 || expiresAtMs > timestamp, "expiry in the past"); // The one-per-kind rule that makes `badgeOfKind` single-valued. An issuer re-issuing after // expiry must revoke the expired badge first, which leaves both facts on the record. require(badgeOfKindMap[to][kind] == 0, "already holds this kind"); if (maxBadges > 0) { require(issuedCount < maxBadges, "supply cap"); } let id = issuedCount + 1; issuedCount = id; badgeHolder[id] = to; badgeKind[id] = kind; badgeUriOf[id] = uri; expiryOf[id] = expiresAtMs; badgeOfKindMap[to][kind] = id; heldCount[to] = heldCount[to] + 1; emit Transfer("", to, id); emit BadgeIssued(id, kind, uri, expiresAtMs); return id; } // Write-once. An issuer who mislabels a kind must use a new kind number; the alternative is // a number whose meaning changes under everyone who already read it. fn labelKind(kind: uint, label: string) { require(caller == mintAuthority, "not minter"); require(kind > 0, "no such kind"); require(len(kindLabel[kind]) == 0, "kind already labelled"); require(len(label) > 0, "empty label"); require(len(label) <= maxUriLength, "uri too long"); kindLabel[kind] = label; emit KindLabelled(kind, label); } // ---- removal: one path for the holder, one for the issuer ------------------------------- // The holder gives up their own badge. NOT gated by the pause, deliberately: a stopped // contract must not be able to keep a credential on somebody who has renounced it. fn renounce(badgeId: uint) -> bool { require(badgeId > 0 && badgeId <= issuedCount, "no such badge"); require(badgeHolder[badgeId] == caller, "not the badge holder"); badgeHolder[badgeId] = ""; badgeOfKindMap[caller][badgeKind[badgeId]] = 0; heldCount[caller] = heldCount[caller] - 1; removedCount = removedCount + 1; emit Transfer(caller, "", badgeId); emit Renounced(caller, badgeId); return true; } // The issuer takes a badge back, if and only if this contract was deployed revocable. The // holder's other badges are untouched; this contract has no function that clears a holder. fn revoke(badgeId: uint) -> bool { require(frozen == false, "paused"); require(revocableFlag, "not revocable"); require(caller == mintAuthority, "not minter"); require(badgeId > 0 && badgeId <= issuedCount, "no such badge"); let holder = badgeHolder[badgeId]; require(len(holder) > 0, "no such badge"); badgeHolder[badgeId] = ""; badgeOfKindMap[holder][badgeKind[badgeId]] = 0; heldCount[holder] = heldCount[holder] - 1; removedCount = removedCount + 1; emit Transfer(holder, "", badgeId); emit Revoked(holder, badgeId, caller); return true; } // ---- powers the deployer keeps, all of them visible in the ABI -------------------------- fn proposeOwner(next: address) { require(caller == contractOwner, "not owner"); pendingOwner = next; emit OwnerProposed(contractOwner, next); } // Two steps, because a one-step handover to a mistyped address is permanent: there is no // administrator anywhere in SourceLess who can reach into a contract's slots. fn acceptOwnership() { require(caller == pendingOwner, "not the pending owner"); emit OwnerChanged(contractOwner, caller); contractOwner = caller; pendingOwner = ""; } // Covers `issue` and `revoke`. It does not cover `renounce`, and it covers no view, so a // stopped credential register stays verifiable at exactly the moment somebody needs to // verify it. fn setPaused(next: bool) { require(caller == contractOwner, "not owner"); frozen = next; emit PausedChanged(next, caller); } fn setMinter(next: address) { require(caller == contractOwner, "not owner"); require(len(mintAuthority) > 0, "mint closed"); emit MinterChanged(mintAuthority, next); mintAuthority = next; } // One-way, and it closes REVOCATION as well as issuance, because both are the issuer's // powers and `revoke` requires `caller == mintAuthority`. After this call no badge can be // issued and no badge can be taken away: every badge outstanding is in its holder's hands // until that holder renounces it or its expiry passes. That is the strongest state this // contract has, and it is the reason `closeMinting` is worth calling. fn closeMinting() { require(caller == contractOwner, "not owner"); require(len(mintAuthority) > 0, "mint closed"); emit MinterChanged(mintAuthority, ""); mintAuthority = ""; } }