PROTOCOL · ETCH · MINT · UTXO

Protocol documentation

On-chain Soqunes etch/mint/transfer, the soqindexer chain index, the UTXO model and the verification test suite. This page summarizes the source-level specification in PROTOCOL.md and the soqindexer package.

1. Soqunes protocol

Soqunes is a Runes-style protocol carried in an OP_RETURN OP_13 (6a5d) message whose data push is a list of LEB128 tag/value integers. The chain is the source of truth: the indexer credits balances from the chain, there is no off-chain ledger.

Etch = commit / reveal

  • Commit (tx 1): pays a fresh Dilithium address plus OP_RETURN COMMIT:HASH256(message) in the same transaction.
  • Reveal (tx 2 = the etch): spends that commit output and carries the message. Valid only if the spent prevout-tx carries the matching COMMIT: hash and is ≥6 blocks old (COMMIT_DEPTH); otherwise it is a cenotaph (no soqune).
  • Only the holder of the commit output can reveal, so a message copied from the mempool cannot be etched by someone else. soqune_id = height << 32 | tx_index; names are unique (first valid etch wins).

Wire tags

TagField
0body flag
1divisibility (0–38)
2premine
4name (base-26 A–Z)
5 / 6spacers / symbol
20mint = soqune_id
77 / 78amount / cap
79 / 80height_start / height_end
81 / 82offset_start / offset_end
91pointer (output index)

Mint

Tag 20 = soqune_id; mints exactly amount once per transaction, within cap and the height_start…height_end window. Units are credited to the output chosen by pointer (default: the first address output).

Transfer

There is no transfer opcode: units move by spending the token UTXO with a pointer runestone; the moved units land on the pointed output. A malformed or truncated message is a cenotaph (recorded, no effect).

Per mint1 DILI
Price5 SOQ
Cap21,000,000
Decimals8

2. Mint lifecycle (on-chain)

  1. The user submits a public Soqucoin recipient address and a quantity.
  2. For each mint the worker generates a fresh self-custodied ML-DSA-44 address (the deposit address) and stores its key alongside the mint — the node wallet is not used.
  3. After the deposit confirms, the worker settles a confirmation-gated burst: split the deposit into buckets → sub-splits into chunk UTXOs → one MINT transaction per mint (each chunk spends to a recipient output + the runestone), all broadcast at once and fanned out to peers.
  4. The indexer credits amount units to the recipient address from the chain.
  5. Any remainder is swept to the treasury once the mint is complete.
Mint stateTransition
pendingdeposit seen but received < price × quantity — received_sats updated.
minteddeposit covers the price; every mint fired and confirmed on-chain.
failedsupply cap reached at settlement.

The chain is the source of truth. One mint per transaction at amount. Fees are sized by transaction vsize; a dropped mint transaction is re-fired automatically (self-heal). The composer creates the commit/reveal etch; the worker broadcasts the reveal after COMMIT_DEPTH confirmations.

3. Indexer (soqindexer)

The indexer is a standalone, single-writer component. It follows a local Soqucoin node over JSON-RPC (getblock(getblockhash(h), 2), getrawmempool, getrawtransaction) and maintains a relational index in SQLite (default) or PostgreSQL (SOQUNES_DB=postgres://…). Only the indexer writes the chain and protocol tables; the explorer/API reads them.

Guarantees

  • One transaction per block — chain rows and Soqunes protocol state commit together.
  • Idempotent — re-indexing an already indexed (height, hash) is a no-op.
  • Failure-safe — a failed block is rolled back and the in-memory UTXO cache is restored, so a retry is consistent.
  • Reorg-aware — every block's previousblockhash is verified (see §4).
  • Malformed messages never abort a block (cenotaph).

Transaction parsing

StepResult
Coinbase detectionvin[0].coinbase → transactions.is_coinbase=1, fee 0.
InputsResolve the previous outpoint in the in-memory UTXO map → tx_inputs.prev_value_sats, prev_address.
SpendsMark tx_outputs and utxos spent, record spend height/time, debit sender.
OutputsDecimal SOQ → integer sats (Decimal). Address resolution: scriptPubKey.addresses[0] → singular address → decode 5120<prog> to bech32m (sq). Credit recipient.
Feesmax(0, sum(inputs) − sum(outputs)) for non-coinbase transactions.
to_satsOutput value excluding change back to the tx's own input addresses.
Address historyOne address_txs row per address/tx with received/sent totals.

Mempool watcher (separate process): keeps the mempool table in sync, fetching only new txids. Tip is recorded in meta.last_height, meta.last_hash, meta.chain_height, meta.node_synced. Difficulty is stored per block; recent zero values are backfilled for the hashrate chart.

4. Reorg & rollback

  • Detection: before indexing block h, the indexer checks previousblockhash == hash(h−1); a mismatch (or a different hash at an already indexed height) raises a reorg.
  • Recovery: it finds the highest indexed height the node still agrees with (the fork point) and rolls back to it via a per-block undo journal (block_undo + height-stamped protocol state). Reorgs deeper than --max-rollback (default 2000) fall back to a full reindex.
  • What is rolled back: the chain tables and the Soqunes protocol state (tokens, balances, token UTXOs, events) are re-derived on reindex. Application tables (mints, mint keys, etches, fees) are never touched by a chain rebuild.
  • Manual: soqindexer rollback --to <height>; soqindexer reindex.

5. UTXO model

The utxos table is the canonical outpoint state; tx_outputs keeps every output. Each row has the outpoint, value, address, creation point and optional spend point.

utxos(
  txid, vout, value_sats, address,
  created_height, created_time,
  spent_by_txid, spent_height, spent_time,
  status  -- 0 = unspent, 1 = spent
)

Addressless outputs are indexed but excluded from the address→UTXO map; zero-value outputs are indexed. Unresolved inputs record zero value and no previous address. The UTXO API returns utxos[], total, total_value_sats, offset, limit and a stats object (total, unspent, spent, unspent_value_sats, spent_value_sats).

6. Legacy SOQ1: envelope (deprecated)

Earlier releases anchored intent as UTF-8 text in a plain OP_RETURN: SOQ1:<KIND>:<fields…> (kinds ETCH, MINT, TRANSFER, META, DEPOSIT, NOTE; max 80 bytes, OP_PUSHDATA1 above 75). It is kept for decoding/back-compat only — it is not the Soqunes protocol and creates no soqune.

ETCH   payload SOQ1:ETCH:TKN:2100000000000000:500000000:100000000:21000000:1
       hex     6a3d534f51313a455443483a544b4e3a323130303030303030303030303030303a3530303030303030303a3130303030303030303a32313030303030303a31

MINT   payload SOQ1:MINT:TKN:3
       hex     6a0f534f51313a4d494e543a544b4e3a33

DEPOSIT hex    6a17534f51313a4445504f5349543a6f726465725f64656d6f
NOTE   hex     6a0e534f51313a4e4f54453a64656d6f

Decode with core.decode_op_return(hex). The indexer stores the exact script in tx_outputs.script_hex with script_type=nulldata and no address (excluded from the address→UTXO map).

7. Tests

Both suites are deterministic — synthetic verbose blocks, a fake RPC and temporary SQLite databases; no node required. The indexer package also runs its suite against PostgreSQL.

SuiteCovers
soqindexer (81)chain linkage & reorg detection, exact fork-point rollback, atomic failure injection, Soqunes flows (commit/reveal, cap, height window, pointer, transfer), codec edge cases, mempool diffing, RPC retry policy — on SQLite and PostgreSQL.
service (149)indexer basics, protocol/etch/mint flows, fees, OP_RETURN, lifecycle simulation, on-chain ledger, the soqindexer package API.
# indexer package
cd soqunes-indexer && python -m unittest discover -s tests -v
# SQLite only; for PostgreSQL: SOQINDEXER_TEST_DB=postgres://… python -m unittest discover -s tests

# service
cd soqunes-service && python -m unittest discover -s tests -v

8. Operations

PostgreSQL stack (current, docker-compose.yml): separate node + postgres + indexer + mempool containers (the API runs with SOQUNES_ROLE=api so it never writes chain tables).

cp .env.example .env      # SOQ_RPC_PASSWORD, POSTGRES_PASSWORD, SOQUNES_TREASURY_ADDRESS
docker compose up -d --build
docker run --rm --network soqunes_default soqindexer:local \
  status   --db postgres://soq:$POSTGRES_PASSWORD@postgres:5432/soqunes
docker run --rm --network soqunes_default soqindexer:local \
  rollback --to <height> --db postgres://…

Legacy SQLite stack (docker-compose.sqlite.yml): one container with the node, indexer, worker and web app on the volume soqunes-db (/db/soqunes.db).

docker stop -t 120 soqunes
docker run --rm -v soqunes-db:/db -v "$PWD:/backup" busybox \
  tar czf /backup/soqunes-db-$(date +%F).tgz -C /db .
docker start soqunes

Use docker stop -t 120, never rm -f. The entrypoint waits for the Python workers and Soqucoin to flush, then checkpoints the SQLite WAL.