Docs

How the Enjin Governance client works, the EGOV1 off-chain metadata standard, and integration notes for indexers, wallets, and other front-ends.

What this is

A production web client for Enjin OpenGov - the on-chain governance system that lives on the Enjin Relaychain. Any tokenholder can browse referenda, vote with conviction, unlock expired locks, and file new treasury proposals end-to-end without leaving the browser. Mainnet is supported today; Canary is available from the chain selector for testing flows before committing real funds.

The chain RPC is the source of truth for everything that votes, moves money, or locks tokens. The off-chain layer is for the parts a chain shouldn't carry: long-form narrative, comments, attachments, proposer profiles.

How a proposal flows

The wizard composes up to three calls and wraps them in a single utility.batchAll so the submission either lands whole or reverts whole:

  1. preimage.notePreimage(call_bytes) - stores the actual treasury call. Returns a (hash, length) pair the referendum will reference.
  2. referenda.submit(origin, Lookup {hash, len}, enactment) - opens the referendum on the smallest treasury track whose authority covers the spend amount.
  3. system.remark("EGOV1:{u, h}") - pins a content-addressed pointer to the off-chain JSON (see EGOV1 standard).

Step 1 is skipped when the preimage for these exact bytes is already on chain. The wizard checks api.query.preimage.requestStatusFor(hash) before building the batch - if the status is Unrequested or Requested, re-noting would abort with AlreadyNoted and revert the whole batch, so the wizard signs only referenda.submit + system.remark. The referendum still references the same (hash, len) pair, so the on-chain outcome is identical.

The proposer separately places the per-track decision deposit (any account can pay it) - it is never part of the submission batch. Both the submission and decision deposits refund automatically when the referendum reaches a terminal state. Voters cast convictionVoting.vote over the decision period; the chain dispatches the noted call at enactment if approval and support curves are satisfied.

Spend authority is mapped from amount to track by pickOriginForAmount. The runtime tiers:

  • SmallTipper - up to 250 ENJ
  • BigTipper - up to 1,000 ENJ
  • SmallSpender - up to 10,000 ENJ
  • MediumSpender - up to 100,000 ENJ
  • BigSpender - up to 1,000,000 ENJ (cap)

Enjin has no Treasurer track, so BigSpender is the top tier. Requests above 1,000,000 ENJ are not supported and the wizard blocks them.

EGOV1 off-chain metadata standard

EGOV1 is the standard the client uses to attach a title, summary, body, attachments, and signature to an on-chain referendum without bloating chain state. It's designed so any third party - Polkassembly, Subscan, an indexer, or another client - can rebuild the proposal corpus by decoding system.remark call args from finalised blocks for the EGOV1: magic prefix.

On-chain envelope

The submission batch includes a system.remark with the UTF-8 payload:

EGOV1:{"u":"<url>","h":"<sha256-hex>"}
  • u - public URL of the canonical proposal.json.
  • h - sha256 of the canonical (sorted-keys-at-every-level) JSON bytes. Pinned forever by the on-chain remark.

proposal.json shape

{
  "schema": "enjin-governance-proposal",
  "version": "1.1.0",
  "network": "enjin-relay" | "enjin-matrix" | "canary-relay" | "canary-matrix",
  "proposer": "<SS58 address>",
  "title": "<≤ 200 chars>",
  "summary": "<≤ 500 chars> | null",
  "body_markdown": "<≤ 100000 chars>",
  "track": "SmallTipper" | "BigTipper" | ... | null,
  "spend": {
    "beneficiary": "<SS58 address>",
    "amount_planck": "<decimal string>"
  } | null,
  "attachments": [{
    "name": "<filename>",
    "url": "<public URL>",
    "sha256": "<hex>",
    "content_type": "<MIME>",
    "size_bytes": <int>
  }],
  "preimage_hash": "<0x… hex> | null",
  "preimage_len": <int> | null,
  "created_at": "<ISO 8601>",
  "edited_at": "<ISO 8601> | null",
  "edit_count": <int>,
  "signature": {
    "address": "<SS58>",
    "sig": "<hex>"
  } | null
}

All amount_planck values are decimal strings (not numbers) to preserve precision - the relay has 18 decimals, which overflows Number.

Discovery for indexers

  1. Scan finalised blocks and decode each system.remark call (the proposal nests it inside a utility.batchAll).
  2. Filter remarks whose UTF-8 payload starts with EGOV1:.
  3. JSON-parse the suffix to get {u, h}.
  4. Fetch u. The bytes are guaranteed to start as the JSON committed on chain at h; if the proposer edited later (see Editing & withdrawal) the bucket bytes will differ from h- that's the public signal an edit happened.
  5. Link the remark to its referendum via the same extrinsic index: the batch that emitted the remark is the same one that emitted referenda.Submitted{index}.

Result: anyone can rebuild the proposal corpus without any dependency on this app's database.

Editing & withdrawal

Proposers can update the off-chain narrative after submission from the Edit Proposal page on the referendum. The PATCH endpoint overwrites proposal.json at the same bucket key, so existing URLs keep resolving - but the sha256 changes, and the on-chain remark still pins the original hash. The detail page shows an (edited) chip and the EGOV1 source modal explains the divergence.

What you can edit: title, summary, body, attachments. What you can't: spend amount, beneficiary, preimage, proposer address, track - those are baked into the referendum and would invalidate the on-chain state.

Withdraw is the social signal for "I filed this in error, please vote NAY": it records a withdrawn_attimestamp plus an optional reason, and the detail page renders a destructive-tone banner. The on-chain referendum is unchanged - substrate's referenda.cancel is locked to the ReferendumCancellerorigin, not the proposer. To actually move the tally you can vote NAY yourself with high conviction, file a separate cancellation referendum, or wait for the decision period to expire if the deposit isn't placed (it is here, so that path is moot).

Voting & conviction locks

Votes carry a conviction multiplier between 1x and 6x. Higher conviction multiplies the vote's tally weight but locks the underlying balance for a period after the referendum resolves. The lock grows with conviction, roughly doubling each step: 1x = 1, 2x = 2, 3x = 4, 4x = 8, 5x = 16, 6x = 32 lock periods. Lock duration is per track-class - the same conviction on BigSpender is much longer than on SmallTipper.

What voters can do

  • Cast a vote - vote(pollIndex, accountVote, currency) with an Aye or Nay verdict, a 1x-6x conviction multiplier, and a vote source (liquid ENJ or a specific sENJ pool).
  • Change a vote - re-submit vote for the same referendum to swap the verdict, conviction, or source. The chain overwrites the previous AccountVotein the same lock class - no separate "edit" extrinsic.
  • Remove a vote - removeVote(trackId, pollIndex) pulls your weight back out of the tally. Called before the decision period ends, it withdraws the vote and starts the lock from now; called after the referendum resolves, it just frees the vote record so the lock can be unlocked.
  • Unlock locked balance - the /unlock page reads convictionVoting.classLocksFor(account) and lets you submit unlock(class, target) for any class whose lock period has expired.

Vote source: liquid ENJ or sENJ

Enjin's extended AccountVote carries a currency arg so a single voter can source a vote from either:

  • Liquid ENJ - { Enj: null }. The default.
  • Staked ENJ from a specific pool - { SEnj: <poolId> }. sENJ lives in the multiTokens pallet under collection 1 with tokenId === poolId. Your balance for pool N is tokenAccounts(1, N, account).balance. Pool art (the "Degens" NFTs) lives separately in collection 2.

sENJ accrues staking rewards, so 1 sENJ > 1 ENJ. The per-pool stake factor is staking.ledger(stash).active / multiTokens.tokens(1, N).supply, and the ENJ-equivalent is senjBalance * stakeFactor. The vote panel shows both numbers next to each source so voters know what they're contributing in real terms. The on-chain extrinsic still takes the sENJ amount - the stake factor is display-only.

The vote panel automatically detects every pool you hold sENJ in and lets you pick which source to vote from. When you have no sENJ, the selector hides and you just vote with liquid ENJ. Conviction multipliers and lock periods apply the same way to either source.

voteManager vs convictionVoting

Enjin Relay's governance runs on a multi-token fork of the standard convictionVoting pallet - the same OpenGov semantics (Aye/Nay verdict, 1x-6x conviction multipliers, per-class lock schedule, support/approval curves), with one addition: every vote, delegation, and unlock carries a currency argument so it can source liquid ENJ or a staked-ENJ pool token (sENJ).

  • convictionVoting.vote(pollIndex, accountVote, currency) - the deployed Enjin surface (3-arg, multi-token). Stock Substrate / Polkadot is the 2-arg version without currency.
  • voteManager - a fallback surface the client also supports for any runtime that exposes it; the current Enjin Relay runtime does not, so governance goes through convictionVoting.

The client probes runtime metadata to pick the right pallet and the right call arity automatically, so the per-vote currency argument is never dropped. If you compare past Subscan extrinsics they may show votemanager (vote) or Convictionvoting (Vote) depending on which pallet the runtime exposed at the time.

Verification

The badge on every proposal's About card maps the bucket JSON's integrity to one of four colour-coded states:

  • EGOV1 · VerifiedBucket sha256 matches our DB record and the hash pinned on chain at submission - the narrative you see is byte-for-byte what the proposer submitted.
  • EGOV1 · EditedThe proposer set edited_at: they overwrote the bucket JSON after submission. Bucket sha256 still matches our (updated) DB record, but it diverges from the sha256 pinned on chain - that divergence is the public edit signal.
  • EGOV1 · UnverifiedBucket sha256 doesn't match what we expected and no edit is recorded. Treat the narrative with suspicion until the issue is investigated.
  • EGOV1 · Fetch FailedThe bucket URL didn't respond. Not a verification result, just a transport failure - retry when the CDN recovers.

External indexers should additionally check that the bucket sha256 matches the h pinned by the on-chain remark. When that fails but edited_at is present in the JSON, the proposer edited after submission; when edited_at is absent, treat the content as tampered.

Wallet integration

Supported connectors:

  • Enjin Wallet - WalletConnect v2, desktop QR or mobile deep link
  • Polkadot browser extensions - Polkadot.js, Talisman, SubWallet, PolkaGate

All connectors implement the same Connector interface and produce a Polkadot Signer the rest of the app uses identically.

Known issues

WalletConnect deep links on iOS Safari must fire from inside a fresh user-gesture frame, not a promise callback. The sign-request modal wraps the "Open in Enjin Wallet" button accordingly - if it stops working, check that no async hop sits between the click and the deep link.

Double sign prompts on iOS - iOS Safari can fire two click handlers when the app-switcher prompt returns. Sign flows guard against this with a module-level in-flight lock.

SS58 prefix drift - some wallets return addresses in their default prefix instead of the active chain's.encodeForChain normalises before display, and ownership checks compare by public key (samePublicKey) so the same wallet on Canary (cn…) still matches a proposal filed on Enjin Relay (en…).

RPC reconnects - the relay RPC can drop briefly under load. Tx builders wait up to a few seconds for the API to come back before failing with a friendly "please try again" error; the proposal list auto-refreshes on tab focus.

Identity & comments

A connected wallet is enough to browse referenda and cast votes - everything that touches chain state runs through a signed extrinsic, so the wallet is the only authority that matters. On top of that, the client adds a lightweight social layer so voters can put a face to an address and talk through proposals before they go to tally. It's deliberately minimal: a profile, a verified comment thread per referendum, no DMs, no upvotes, no forum.

Sign-in with wallet (proof of control)

Connecting a wallet only proves the wallet exposed an address; signing a server-issued nonce proves the wallet controls the private key. The sign-in flow follows the SIWE pattern adapted for Substrate: POST /api/auth/noncehands back a 16-byte hex nonce, the client builds a plain-text message ("Enjin Governance - sign in" + address + nonce + issued timestamp), the wallet signs it, and POST /api/auth/verify checks the signature with @polkadot/util-crypto's signatureVerify (accepts the wallet's <Bytes> wrapping, plain, and 0x-hex forms).

On success the server returns a 32-byte opaque bearer token in the enjin-governance:session cookie (HttpOnly, Secure in production, SameSite=Lax, 30-day TTL). Only the SHA-256 hash of the token lives in wallet_sessions- the raw secret never persists, so a DB leak doesn't hand out live sessions. Sessions never authorise on-chain transactions; they only gate the off-chain social writes below.

Profile

Once signed in, an address can attach a profile to itself:

  • Handle - case-insensitive unique, 3-32 chars, [a-z0-9_].
  • Display name - up to 80 chars, free text.
  • Bio - up to 500 chars.
  • Avatar- PNG/JPEG/WebP/GIF up to 6 MB, transcoded server-side to a 150×150 PNG with EXIF stripped, stored in R2 at user-avatars/{user_uuid}.png.

Profiles are read publicly via GET /api/users/by-address/{address} (no auth, cached 60s edge / 600s stale-while-revalidate) and written only by the owner via PATCH /api/users/me. A separate is_verifiedboolean is reserved for manual attestation by the maintainers - there's no self-service path to turn it on.

Public comments

Each referendum carries a flat comment thread. GET /api/proposals/{uuid}/comments is public; POSTrequires a valid session, accepts 1-10,000 chars of markdown, and stamps the comment with the signer's user_id and address so authorship is always traceable to a wallet that proved control. Comment rows surface author_handle, author_display_name,author_avatar_url, and author_is_verified for the UI.

Authors can soft-delete their own comments via DELETE /api/comments/{id} - the row stays with is_deleted = true and the body replaced by [deleted]. There is no edit endpoint, no moderator delete, no flag/report flow, and no reaction or upvote UI. The parent_id column on comments reserves space for threading, but the current UI renders the thread flat by created_at.

Address page

/user/{address} is the public profile view: avatar, display name (or @handle, or shortened address as a fallback), verified badge, bio, full address, and a list of referenda the address has authored. Addresses that have never signed in still resolve - the page just shows "No profile yet" in place of the social fields and continues to list their on-chain activity.

What's intentionally out of scope

No direct messages, no upvotes or reactions, no proposal-agnostic forum, no threaded reply UI, no moderation tools, no comment edit history. The social layer exists to give voters context and let proposers respond - if the conversation outgrows what a flat per-referendum thread can carry, that's the signal to move the discussion to a dedicated venue, not to build one inside this client.

Chain coordinates

Values come from lib/chain/chains.ts. Mainnet is Enjin Relay; Canary mirrors the same governance pallets for testing flows before committing real funds.

Enjin Relaychain

Mainnet
RPC
wss://rpc.relay.blockchain.enjin.io
SS58 prefix
2135 (en…)
Decimals
18
Ticker
ENJ
Treasury
enD9wdMEaQa3MEDUc7dtsCC86JYGMN5JBE2NBRoMyC37dX4iA
CAIP-2
d8761d3c88f26dc12875c00d3165f7d6

Canary Relaychain

Testnet
RPC
wss://rpc.relay.canary.enjin.io
SS58 prefix
69 (cn…)
Decimals
18
Ticker
cENJ
Treasury
enD9wdMEaQa3MEDUc7dtsCC86JYGMN5JBE2NBRoMyC37dX4iA
CAIP-2
735d8773c63e74ff8490fee5751ac07e

Timing primitives

Each track has its own periods:

  • Prepare period - from submission until a decision can start. Decision deposit must be placed before it expires or the referendum times out.
  • Decision period - voting is open; approval and support curves are evaluated continuously.
  • Confirm period - the curves must hold continuously for the full confirm duration to pass.
  • Enactment delay - after approval, the noted call dispatches this many blocks later.

Read the live values from api.consts.referenda.tracks. The lifecycle progress bar on each proposal mirrors them.

Cancellation paths

A proposer cannot dispatch referenda.cancel(idx) themselves - it's gated to ReferendumCanceller. The realistic levers:

  • Don't place the decision deposit - the referendum times out after the prepare period. Deposits refund.
  • Self-NAY - vote against your own proposal with high conviction. Only on-chain action that actually shifts the tally.
  • File a cancellation referendum - a separate proposal on a privileged track that dispatches referenda.cancel(idx) if it passes. Heavy and rarely justified.
  • Off-chain withdrawal (this app) - the Withdraw button sets withdrawn_atand surfaces a banner. Doesn't change on-chain state, but tells voters clearly to vote NAY.