SourceLessExplorer · SourceLess AsgardSourceLess Asgard · chain 1313
LiveHeight23,745Head age12 minLast interval4 minFinality23,734Behind−11Pending0 / 8,192Tx in head0
XPlorer following the chain as its own read-only node

height 23745 of 23745 on the network. 9 peer(s) connected, 77 in its peer directory; 1041 block(s) followed and 865 pulled in by sync since this follower started. Caught up: this follower holds every block any peer has claimed, so every panel below is the whole chain. Genesis 1cf8655125cc1bbb…, producers genesis.

What this follower is, locally

Following 7 seed(s) — their addresses are not published here, for the reason the peer table gives. Its store is held under its own Cerberus lease — no other node’s store is opened, touched or stopped.

SoulboundBadge for your fork

Status
a draft, not deployed nothing here is on any chain; never send value to a draft
Profile
ZK13 non-fungible, soulbound
Holds
its own badge map
The property that matters
there is no transfer function; renounce is not pausable

Edit the draft

Set the constants the template marks for editing, and name a deployer to see where it would land.

string, shipped as "Example Credential"

string, shipped as "EXCR"

bool, shipped as true

uint, shipped as 0

uint, shipped as 512

The form asks this explorer to compile your draft and show it back. It signs nothing, stores nothing and reaches no node.

The draft as shipped

Contract
SoulboundBadge
Compiler
areslang-1.3.0
Bytecode SHA-256
560cc9e1f0bc84c38c9bd76ad97c865f8130c4867c79bee23f4e07a77b2055c4
Bytecode
3,806 bytes
Source SHA-256
41088312c8a37f8c5865a8af2f98de0daa2f33ef19c1215b5385f4be278e1bad
The catalogue text?
Yes, byte for byte: matches the published vector
Ready to deploy?
not yet a deployment refuses while these remain: badgeName = "Example Credential", badgeSymbol = "EXCR"
Would land at
name a deployer to see it

Deploy it to your fork

Only the last step signs, and only it reaches a node.

  1. Save the draft. Download it above, or start from the catalogue text with strblk contract template soulbound-badge > soulbound-badge.ares and edit the marked constants yourself.
  2. Dry-run it on your machine. strblk wallet deploy soulbound-badge.ares --dry-run --address <your address on the fork> compiles it locally and prints its digest and address. They must match this page; if they do not, the file is not this draft.
  3. Find your fork's RPC. It is the rpc (Iris, wallets) line of the fork's banner, and the rpcUrl of each node in <data root>/fork.json.
  4. Deploy it. strblk wallet deploy soulbound-badge.ares --node <your fork's RPC>, from a wallet whose account holds a proved STR.domain on your fork. Before it signs, the wallet prints the network: the realm reads unregistered and the genesis is your fork's hash. Check that hash; it is what the signature binds.

Measured gas

Charged by the STARW VM under areslang-1.3.0, the same on your fork. Measured, not estimated.

Entry pointGas
issue2,270
revoke1,560
renounce1,386
labelKind531
Declare at least
22,700 ten times the most expensive call

Functions

KindNameInputsOutputsMutability
functionstandardstringview
functionnamestringview
functionsymbolstringview
functionrevocableboolview
functionholderOfbadgeId: uintaddressview
functionkindOfbadgeId: uintuintview
functionbadgeUribadgeId: uintstringview
functionexpiresAtbadgeId: uintuintview
functionvalidbadgeId: uintboolview
functionbadgeOfKindaccount: address, kind: uintuintview
functionbalanceOfaccount: addressuintview
functionkindLabelOfkind: uintstringview
functiontotalIssueduintview
functiontotalRemoveduintview
functiontotalSupplyuintview
functionmaxSupplyuintview
functionowneraddressview
functionminteraddressview
functionpausedboolview
functionpendingOwnerIsaddressview
functionissueto: address, kind: uint, uri: string, expiresAtMs: uintuintnonpayable
functionlabelKindkind: uint, label: stringnonpayable
functionrenouncebadgeId: uintboolnonpayable
functionrevokebadgeId: uintboolnonpayable
functionproposeOwnernext: addressnonpayable
functionacceptOwnershipnonpayable
functionsetPausednext: boolnonpayable
functionsetMinternext: addressnonpayable
functioncloseMintingnonpayable
eventTransferfrom: address, to: address, badgeId: uint
eventBadgeIssuedbadgeId: uint, kind: uint, uri: string, expiresAtMs: uint
eventRenouncedholder: address, badgeId: uint
eventRevokedholder: address, badgeId: uint, by: address
eventKindLabelledkind: uint, label: string
eventOwnerProposedcurrent: address, pending: address
eventOwnerChangedprevious: address, next: address
eventPausedChangedpaused: bool, by: address
eventMinterChangedprevious: address, next: address

Your draft's source

templates/soulbound-badge.ares, as shipped

// =========================================================================================
// 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<uint, address>;
  state badgeKind: map<uint, uint>;
  state badgeUriOf: map<uint, string>;
  // 0 means never expires.
  state expiryOf: map<uint, uint>;

  // 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<address, map<uint, uint>>;
  state heldCount: map<address, uint>;

  // Write-once, by the issuer, so a kind number cannot come to mean something else later.
  state kindLabel: map<uint, string>;

  // 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 = "";
  }
}