Skip to content

fix(utxo-lib): serialize Zcash Sapling valueBalance as signed int64 - #9454

Draft
bitgo-ai-agent-dev[bot] wants to merge 1 commit into
masterfrom
WCN-1961-zcash-negative-valueBalance
Draft

fix(utxo-lib): serialize Zcash Sapling valueBalance as signed int64#9454
bitgo-ai-agent-dev[bot] wants to merge 1 commit into
masterfrom
WCN-1961-zcash-negative-valueBalance

Conversation

@bitgo-ai-agent-dev

Copy link
Copy Markdown
Contributor

What

  • Add saplingValueBalance: Buffer field to ZcashTransaction (initialized to all-zero bytes, copied through clone())
  • fromBufferV4 now stores the raw 8-byte valueBalance field in tx.saplingValueBalance instead of throwing UnsupportedTransactionError when the value is non-zero; non-empty shielded spend/output vectors still throw (unchanged restriction)
  • toBufferV4 writes tx.saplingValueBalance back verbatim (was hardcoded to VALUE_INT64_ZERO), enabling exact round-tripping for any Sapling tx with empty shielded bundles
  • hashForSignatureByNetwork uses tx.saplingValueBalance so sighash computation is also correct
  • Add test/bitgo/zcash/ZcashTransaction.ts with round-trip tests for a minimal Sapling v4 tx with valueBalance = -5

Why

  • Zcash's Sapling valueBalance is a signed 64-bit field — it is negative whenever value flows from the transparent pool into the shielded pool (t→z shielding). These are ordinary, consensus-valid mainnet transactions.
  • The previous code threw UnsupportedTransactionError("valueBalance must be zero") in fromBuffer and always wrote zeros in toBuffer and hashForSignature. This meant any custody or deposit-detection pipeline calling Transaction.fromBuffer(rawTx, zcash).getId() on a shielding transaction would crash, leaving funds stranded in the processing pipeline with no way to confirm receipt.
  • Security finding WCN-1961 / FND-002 (class: funds-loss, severity: medium, confidence: high) verified the crash scenario in-container. The fix allows these transactions to round-trip correctly through fromBuffertoBuffer / getId as long as the shielded bundle vectors are empty (the case where our lib can faithfully represent the tx bytes). Populated shielded bundles still throw UnsupportedTransactionError via readEmptySaplingBundle, preserving the existing restriction on fully-shielded transactions.

Test plan

  • New test file test/bitgo/zcash/ZcashTransaction.ts: parses a Sapling v4 tx with valueBalance = -5, verifies saplingValueBalance raw bytes, round-trips toBuffer, calls getId() without throwing, and verifies clone() preserves the field
  • Existing hashZip0244.ts test vectors continue to pass (zero valueBalance path is unchanged)
  • Existing Zcash integration fixture tests continue to pass

Ticket: WCN-1961

Zcash's Sapling valueBalance is a signed 64-bit field — it is negative
whenever value flows from the transparent pool into the shielded pool
(t->z shielding). The previous code in fromBufferV4 threw
UnsupportedTransactionError for any non-zero valueBalance, preventing
deposit-detection pipelines from computing txids for shielding
transactions even when the shielded bundle vectors are empty.

In toBufferV4 and hashForSignatureByNetwork the field was always written
as eight zero bytes (VALUE_INT64_ZERO), so even if parsing had succeeded
the re-serialized transaction would have had the wrong bytes and produced
a wrong txid.

Changes:
- ZcashTransaction gains a `saplingValueBalance: Buffer` field,
  initialised to VALUE_INT64_ZERO and preserved through clone().
- fromBufferV4 stores the raw 8-byte slice in saplingValueBalance instead
  of throwing when the field is non-zero. Non-empty shielded bundles still
  throw UnsupportedTransactionError via readEmptySaplingBundle, keeping
  the existing restriction against fully-shielded transactions.
- toBufferV4 writes tx.saplingValueBalance back verbatim, enabling exact
  round-tripping of fromBuffer→toBuffer/getId for any Sapling tx whose
  shielded bundle vectors are empty.
- hashForSignatureByNetwork uses tx.saplingValueBalance so sighash
  computation is also correct.
- Add test/bitgo/zcash/ZcashTransaction.ts with round-trip tests for a
  minimal Sapling v4 tx with valueBalance = -5.

Security: WCN-1961 / FND-002 (funds-loss, medium severity). A
custody pipeline calling fromBuffer(shieldingTx).getId() would
previously crash or silently produce a wrong txid; with this fix it
correctly round-trips for the case of empty shielded bundles, and
throws UnsupportedTransactionError for populated shielded bundles.

Ticket: WCN-1961
Session-Id: e7382333-471f-41ac-9109-8dbfc6acb8ea
Task-Id: ffe9c655-62ea-463d-a151-7d634b267c81
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

WCN-1961

@ralph-bitgo
ralph-bitgo Bot force-pushed the WCN-1961-zcash-negative-valueBalance branch from c561d96 to 3f508c3 Compare August 7, 2026 20:10
@bitgo-ai-agent-dev
bitgo-ai-agent-dev Bot force-pushed the WCN-1961-zcash-negative-valueBalance branch from 3f508c3 to 9d7a86c Compare August 7, 2026 20:11
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