height 23718 of 23718 on the network. 9 peer(s) connected, 77 in its peer directory; 1014 block(s) followed and 843 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
- On Asgard
- the same template in the catalogue
Edit the draft
Set the constants the template marks for editing, and name a deployer to see where it would land.
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.
- Save the draft. Download it above, or start from the catalogue text with
strblk contract template soulbound-badge > soulbound-badge.aresand edit the marked constants yourself. - 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. - Find your fork's RPC. It is the
rpc (Iris, wallets)line of the fork's banner, and therpcUrlof each node in<data root>/fork.json. - 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 point | Gas |
|---|---|
| issue | 2,270 |
| revoke | 1,560 |
| renounce | 1,386 |
| labelKind | 531 |
- Declare at least
- 22,700 ten times the most expensive call
Functions
| Kind | Name | Inputs | Outputs | Mutability |
|---|---|---|---|---|
| function | standard | string | view | |
| function | name | string | view | |
| function | symbol | string | view | |
| function | revocable | bool | view | |
| function | holderOf | badgeId: uint | address | view |
| function | kindOf | badgeId: uint | uint | view |
| function | badgeUri | badgeId: uint | string | view |
| function | expiresAt | badgeId: uint | uint | view |
| function | valid | badgeId: uint | bool | view |
| function | badgeOfKind | account: address, kind: uint | uint | view |
| function | balanceOf | account: address | uint | view |
| function | kindLabelOf | kind: uint | string | view |
| function | totalIssued | uint | view | |
| function | totalRemoved | uint | view | |
| function | totalSupply | uint | view | |
| function | maxSupply | uint | view | |
| function | owner | address | view | |
| function | minter | address | view | |
| function | paused | bool | view | |
| function | pendingOwnerIs | address | view | |
| function | issue | to: address, kind: uint, uri: string, expiresAtMs: uint | uint | nonpayable |
| function | labelKind | kind: uint, label: string | nonpayable | |
| function | renounce | badgeId: uint | bool | nonpayable |
| function | revoke | badgeId: uint | bool | nonpayable |
| function | proposeOwner | next: address | nonpayable | |
| function | acceptOwnership | nonpayable | ||
| function | setPaused | next: bool | nonpayable | |
| function | setMinter | next: address | nonpayable | |
| function | closeMinting | nonpayable | ||
| event | Transfer | from: address, to: address, badgeId: uint | ||
| event | BadgeIssued | badgeId: uint, kind: uint, uri: string, expiresAtMs: uint | ||
| 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 |
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 = "";
}
}