Skip to content

fix(firo): private spends are not coinbase, mempool parse, Spark fees, EX addresses - #33

Merged
stultusmundi merged 2 commits into
masterfrom
fix/firo-private-spends
Oct 29, 2026
Merged

stultusmundi merged 2 commits into
masterfrom
fix/firo-private-spends

Conversation

@stultusmundi

Copy link
Copy Markdown
Collaborator

Four Firo problems found by an audit. Fixing them lets the FIRO Insight explorer move to Blockbook's native Insight listener without changing what the ZelCore wallet sees.

1. Private-spend payouts were flagged coinbase

GetAddressUtxo treats "one input, no address, zero value" in the index as coinbase. Lelantus (type 8), Spark (types 9/11), Sigma and Zerocoin spends look exactly like that. ZelCore refuses coinbase UTXOs under 100 confirmations, so funds received from a private spend would have stayed locked.

  • parseFiroTx (bchain/coins/firo/firoparser.go) now gives private-spend inputs an empty txid and no coinbase data, and keeps the scriptSig. It mirrors firod's CTransaction::IsCoinBase, which checks the opcodes c2/c4/c7/c8/c9/d3. Inputs with an empty txid are still skipped by the indexer, so no fake previous output is ever looked up (mempool included). Insight isCoinBase follows from this.
  • New optional interface bchain.PrivateSpendParser (bchain/types.go). api/worker.go isCoinbaseTx loads the tx and asks the parser, but only for a coin whose parser implements the interface (Firo) and only for UTXOs that are under the coinbase-maturity limit and that the index heuristic already flags. For other coins nothing changes: a test runs a BTC worker with no chain and no tx cache and gets the same result.
  • UnpackTx normalizes tx-cache records written by the old parser, so no cache flush is needed.

2. Mempool parse

FiroParser.ParseTx decodes the v3 extra payload the same way ParseBlock does. Special txs in the mempool now get the correct txid, and a type 6 quorum commitment no longer fails to parse.

3. Fees of private spends

  • Spark (9/11): the fee is read from the field f of spark::SpendTransaction. It comes from the raw extra payload or from the sparkData in firod's JSON. V1 and V2 layouts are both handled (libspark/spend_transaction.h). When a tx comes from the tx cache, the worker fetches the JSON once (GetTransactionSpecific). Verified against firod nFees and against stock Insight (insight-api-zcoin on .69:3001): 526e7a05… = 0.00003115, 3c667f00… = 0.00006351, 7842d789… = 0.00003115. All match.
  • Lelantus / Sigma / Zerocoin: the fee sits after the variable-size proof, firod's JSON does not show it, and firod 0.14.18 no longer ships liblelantus. Blockbook returns fees: 0. Stock Insight returns no fees for these either: checked Lelantus 4d36244e, 2e96152d, 72e40fa4; Sigma 200204f6, 1fd01e81, b11bc3a9; Zerocoin c2a2e51b. The values agree (0 vs absent); only the representation differs. valueIn stays the transparent input sum (stock: null).
  • fees in Insight address tx lists still comes from the index (inputs minus outputs). Private-spend fees there stay 0.

4. Exchange addresses (EX…)

OP_EXCHANGEADDR (0xe0) + P2PKH scripts now decode to searchable EX… addresses, and GetAddrDescFromAddress accepts them. The 3-byte prefixes come from firod src/chainparams.cpp EXCHANGE_PUBKEY_ADDRESS: main 01 b9 bb, test 01 b9 b1, regtest 01 b9 ac. Checked against firod: a8156ab6… vout 3 → EXXaSB8H9NUcFscQFuEMeKYTAKpq2BYjxdVS.

Reindex

None needed. The index keys by script bytes, so EX outputs are already indexed. Private-spend inputs were already stored without a txid. Fees and coinbase flags are computed when read.

Not done

  • Stock-style vin labels (addr: "Sparkspend", "Lelantusjsplit", …). These are optional and the wallet does not use them.
  • Stock custom routes (sigma/zerocoin/masternode/…).

Tests

Table tests use real mainnet hex/JSON (bchain/coins/firo/testdata/mainnet_special_txs.json): ParseTx txids for types 0/1/5/6/8/9/11, IsCoinbaseTx for raw, JSON and legacy-cache formats, Spark fees with invalid-payload cases, EX addresses on main and test. Worker tests index real txs and check UTXO coinbase flags and fees in the tx, cache and mempool paths.

go test -tags unittest ./bchain/coins/firo/ ./api/ ./server/ ./build/tools/ . ./bchain/ ./bchain/coins/btc/ ./db/ all pass; gofmt and vet are clean.

…, EX addresses

- Lelantus/Spark/Zerocoin/Sigma spend inputs keep their scriptSig and no
  longer carry coinbase data. GetAddressUtxo asks the parser (optional
  bchain.PrivateSpendParser) before flagging a UTXO coinbase, so outputs
  paid by private spends are not locked for 100 confirmations. Other coins
  keep the index heuristic without loading the tx.
- FiroParser.ParseTx decodes the v3 extra payload: correct txids for special
  txs in the mempool and no error on quorum commitments.
- Spark spend fees (types 9 and 11) are read from the spend payload or the
  sparkData of firod's JSON. Lelantus/Sigma/Zerocoin fees stay 0.
- OP_EXCHANGEADDR outputs decode to searchable EX addresses and EX addresses
  are accepted as input (prefixes from firod chainparams.cpp).
- Tx cache records written by the old parser are normalized in UnpackTx.
…ror first

FiroDecode allocated whatever length the payload varint claimed, and
GetTransaction set CoinSpecificData on a nil tx when ParseTxFromJson failed.
@stultusmundi
stultusmundi merged commit f471c80 into master Oct 29, 2026
4 checks passed
@stultusmundi
stultusmundi deleted the fix/firo-private-spends branch October 29, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant