[V132] fix(bifrost/zec): observe NU6.3 Ironwood inbounds and correct shielded fee sign

Summary

ZEC deposits spent from the NU6.3 "Ironwood" shielded pool are silently dropped by Bifrost and never observed. The funds land in the asgard vault with no observation, so they are unaccounted for and require manual recovery.

Found via mainnet tx 730d0c32dd48ae05eb26327fc3e4d04975067c0241bc67164c750880cf8823c8 — a real 0.068 ZEC deposit into the active asgard vault t1RBkNhHAwZcrhN3YmJ9wS8eCcAVWFQg7oh at block 3439622. Scanners were at tip the entire time, so nothing was lagging: Bifrost scanned the block and threw the transaction away.

Root cause

NU6.3 (ZIP 2006, mainnet activation 3,428,143) makes the Orchard pool spend-only and serves the replacement pool under a new top-level ironwood field in getrawtransaction. A v6 tx spending Ironwood notes still emits an orchard bundle — but with an empty action list:

"version": 6, "versiongroupid": "d884b698",
"vin": [],
"vjoinsplit": [], "valueBalanceZat": 0,
"vShieldedSpend": [], "vShieldedOutput": [],
"orchard":  {"actions": [], "valueBalance": 0, "valueBalanceZat": 0},
"ironwood": {"actions": [ ...2 actions... ], "valueBalanceZat": 6815000, ...}

rpc.TxVerbose has no Ironwood field, so HasSprout(), HasSapling() and HasOrchard() are all false → IsShielded() false → ignoreTx takes the transparent-only branch and drops the tx on:

if len(tx.Vin) == 0 {
    return true, "0 vins"
}

getTxIn logs that at Debug level and returns nil, nil, which is why it left no trace in production logs. Note that ignoreShieldedTx would have accepted it — it has an asgard vout.

Scope

The drop condition is IsShielded() == false && len(tx.Vin) == 0:

Deposit type Behaviour before this MR
Transparent → transparent Fine — has vins
Sapling / Orchard sourced (v4/v5) Fine — HasSapling/HasOrchard catch it
Ironwood-only, no transparent vins Silently dropped
Ironwood + transparent vins (mixed) Observed, but gas is wrong and an Ironwood-note memo is lost (OP_RETURN memo still works)

Ordinary transparent ZEC volume is entirely unaffected, which is why this went unnoticed.

There is no mimir that mitigates this. The drop happens inside Bifrost's ignoreTx, before any mimir is consulted. ZECShieldedMemoEnabled only gates memo decryption (querier.go:869) and turning it off makes matters worse. HALTZECTRADING sets halted: true on inbound_addresses so well-behaved wallets stop quoting, but it does not stop a user broadcasting directly, and an unobserved tx cannot be refunded.

Changes

File Change
bifrost/pkg/chainclients/zcash/rpc/types.go Add the Ironwood bundle, HasIronwood() and ShieldedValueBalanceZat(); fold Ironwood into IsShielded(). Bundle probing factored into shared bundleHasActions / bundleValueBalanceZat helpers.
bifrost/pkg/chainclients/zcash/client.go Route Ironwood txs through shielded memo decryption; include the Ironwood value balance in the fee calculation; correct the getGas sign convention (below).
chain/zec/src/memo_decrypt.rs librustzcash exposes ironwood_bundle() separately from orchard_bundle(), so try_decrypt_orchard_memo and decrypt_shielded_outputs probed only Orchard and missed these entirely. Both bundles are now probed.
test/fixtures/zcash/tx-ironwood-real.json New — the real dropped transaction, as a regression fixture.
test/fixtures/zcash/tx-orchard-real.json, tx-sapling-real.json Replaced with genuine node responses (see below).
bifrost/pkg/chainclients/zcash/client_test.go Two new Ironwood tests; gas tests corrected to the real sign convention.

No UDL change, so no UniFFI binding regeneration is required — chain/zec/go/zec/zec.go is untouched.

The prebuilt static libraries do need rebuilding and are committed here: lib/libzec.a, lib/sim/libzec.a and their .source-hash / .symbols files. CI's chain-bindings job only verifies the recorded source hash — it never rebuilds — so editing chain/zec/src/*.rs without regenerating the libraries fails that job.

Worth noting for reviewers: that check compares only the Rust source hash, not the library contents. Bumping the .source-hash files alone would turn the pipeline green while shipping a libzec.a built from the old source — the Go-side detection and fee fixes would work, but the Rust-side Ironwood memo/recipient decryption would be silently absent from every build. The libraries are genuinely rebuilt here.

Second bug: getGas sign convention

Verifying the fee on the Ironwood tx surfaced a pre-existing error. ZIP 225 defines each pool's valueBalance as spends minus outputs, so a positive balance is value leaving the pool, and value conservation gives:

fee = sum(vin) - sum(vout) + shieldedDelta

The code used - shieldedDelta as its primary formula and only tried + when transparent inputs were present. Any tx funded purely from a shielded pool therefore computed a negative fee, clamped it to zero, and fell through to the ZIP-317 estimate — reporting 25,000 zats where the true fee is 15,000.

Confirmed against four real mainnet transactions, all of which yield exactly 15,000 under the corrected formula:

tx pool vin vout valueBalance
8f41c0bc… Orchard (v5) 0 375,309,760 +375,324,760
38f3a4a1… Sapling (v6) 0 875,957,626 +875,972,626
e8548e64… Ironwood (v6) 0 10,000,000,000 +10,000,015,000
730d0c32… Ironwood (v6) 0 6,800,000 +6,815,000

The error was masked by two bad fixtures, both replaced here:

  • tx-orchard-real.json carried a hand-negated valueBalanceZat (-375324760) alongside a byte-identical hex — the live node returns +375324760.
  • tx-sapling-real.json was synthetic and not the transaction its comment referenced (live tx has 29 vins / 0 vouts / valueBalanceZat -7256024835; the fixture had 2 vins / +21713000000). Replaced with a genuine Sapling unshielding tx.

The synthetic cases in TestGetGas also encoded the inverted convention in their comments and values; they now use the ZIP 225 convention and assert the same fees.

Version

Bumped 1.132.1 → 1.132.2 (version, openapi/openapi.yaml, openapi/gen/api/openapi.yaml).

A patch bump is correct — this is not a consensus-breaking change and does not need 1.133.0:

Check Result
Files touched outside bifrost/ + chain/zec/ None — no x/, proto/, constants/, no OpenAPI schema change
Version-gated logic added (LatestVersion, version.GTE, manager/handler switch) None
Store migration None
Callers of the changed Rust fns (TryDecryptOrchardMemo, DecryptShieldedOutputs) Only bifrost/pkg/chainclients/zcash/client.go — nothing in x/mayachain or common

The state machine is byte-identical. A minor bump would be required only if this altered handler/manager logic, added a common.LatestVersion switch, changed protobuf or mimir/constant defaults, or needed a store migration. Precedent: 1.132.0 → 1.132.1 (9d349fd6a) was likewise Bifrost-only and took a patch bump with these same three files.

To be precise about the caveat below: the gas change causes observation divergence, not a consensus break. handler_observed_txin has no version gate on observed gas — it accepts whatever 2/3 of validators agree on — so mixed 1.132.1/1.132.2 nodes are safe to run together.

⚠️ Rollout considerations

  • The gas correction is consensus-visible. It changes reported gas on existing Sapling and Orchard inbounds, so ZEC observations will not reach 2/3 consensus until most of the active set has upgraded. Worth coordinating the Bifrost rollout.
  • The Ironwood detection change carries no such risk — un-upgraded nodes report nothing for these transactions today, so there is nothing to disagree with.
  • Bifrost-only; no store migration.

Testing

  • make test TEST_DIR=./bifrost/pkg/chainclients/zcash/... — green. Suite goes 45 → 47 passed under mocknet (the two new tests; the suite is skipped under mainnet/stagenet tags).
  • cargo check --lib on chain/zec — clean. cargo fmt --check introduces no new diffs.
  • go build ./bifrost/... ./common/... ./x/... — clean.
  • New tests: TestIronwoodInboundObserved (real fixture: asserts the tx is no longer dropped on "0 vins", IsShielded() is true, and gas is 15,000) and TestIronwoodInboundToAsgardNotIgnored (same shape paying the suite's own vault: asserted accepted outright).

Follow-ups (not in this MR)

  • Sweep the vault's history for other dropped Ironwood deposits since 3,428,143 and recover them — any tx to the vault with vin: [] and a non-empty ironwood.actions.
  • Vault-side Ironwood note scanning (chain/zec/src/orchard_sender.rs) also probes only orchard_bundle(). Not on the observation path, but it will matter if a vault ever holds Ironwood notes.
Edited by Bitol Maya

Merge request reports

Loading
Loading