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
| Tag | Field |
|---|---|
0 | body flag |
1 | divisibility (0–38) |
2 | premine |
4 | name (base-26 A–Z) |
5 / 6 | spacers / symbol |
20 | mint = soqune_id |
77 / 78 | amount / cap |
79 / 80 | height_start / height_end |
81 / 82 | offset_start / offset_end |
91 | pointer (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).
2. Mint lifecycle (on-chain)
- The user submits a public Soqucoin recipient address and a quantity.
- 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.
- After the deposit confirms, the worker settles a confirmation-gated burst: split the deposit into buckets → sub-splits into chunk UTXOs → one
MINTtransaction per mint (each chunk spends to a recipient output + the runestone), all broadcast at once and fanned out to peers. - The indexer credits
amountunits to the recipient address from the chain. - Any remainder is swept to the treasury once the mint is complete.
| Mint state | Transition |
|---|---|
pending | deposit seen but received < price × quantity — received_sats updated. |
minted | deposit covers the price; every mint fired and confirmed on-chain. |
failed | supply 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
previousblockhashis verified (see §4). - Malformed messages never abort a block (cenotaph).
Transaction parsing
| Step | Result |
|---|---|
| Coinbase detection | vin[0].coinbase → transactions.is_coinbase=1, fee 0. |
| Inputs | Resolve the previous outpoint in the in-memory UTXO map → tx_inputs.prev_value_sats, prev_address. |
| Spends | Mark tx_outputs and utxos spent, record spend height/time, debit sender. |
| Outputs | Decimal SOQ → integer sats (Decimal). Address resolution: scriptPubKey.addresses[0] → singular address → decode 5120<prog> to bech32m (sq). Credit recipient. |
| Fees | max(0, sum(inputs) − sum(outputs)) for non-coinbase transactions. |
to_sats | Output value excluding change back to the tx's own input addresses. |
| Address history | One 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.
| Suite | Covers |
|---|---|
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.