diff --git a/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts b/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts index 7011eb9f2e..ad0f297700 100644 --- a/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts +++ b/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts @@ -91,11 +91,10 @@ export function fromBufferV4( } if (tx.isSaplingCompatible()) { - const valueBalance = bufferReader.readSlice(8); - if (!valueBalance.equals(VALUE_INT64_ZERO)) { - /* istanbul ignore next */ - throw new UnsupportedTransactionError(`valueBalance must be zero`); - } + // valueBalance is a signed int64; negative for t->z shielding transactions. + // Store raw bytes so toBuffer/getId can round-trip the tx without mutation. + // https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L283 + tx.saplingValueBalance = bufferReader.readSlice(8); // https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L863 readEmptySaplingBundle(bufferReader); @@ -167,7 +166,7 @@ export function toBufferV4( } if (tx.isSaplingCompatible()) { - bufferWriter.writeSlice(VALUE_INT64_ZERO); + bufferWriter.writeSlice(tx.saplingValueBalance ?? VALUE_INT64_ZERO); bufferWriter.writeVarInt(0); // vShieldedSpendLength bufferWriter.writeVarInt(0); // vShieldedOutputLength } diff --git a/modules/utxo-lib/src/bitgo/zcash/ZcashTransaction.ts b/modules/utxo-lib/src/bitgo/zcash/ZcashTransaction.ts index f44fdabe66..858f9aa24a 100644 --- a/modules/utxo-lib/src/bitgo/zcash/ZcashTransaction.ts +++ b/modules/utxo-lib/src/bitgo/zcash/ZcashTransaction.ts @@ -114,6 +114,10 @@ export class ZcashTransaction extends // Block height after which this transactions will expire, or 0 to disable expiry expiryHeight = 0; consensusBranchId: number; + // Raw 8-byte little-endian signed int64 valueBalance field for Sapling v4 txs. + // Negative when value flows from the transparent pool into the shielded pool + // (t->z shielding). Stored verbatim so toBuffer/getId round-trip correctly. + saplingValueBalance: Buffer = VALUE_INT64_ZERO; constructor(public network: ZcashNetwork, tx?: ZcashTransaction, amountType?: 'bigint' | 'number') { super(network, tx, amountType); @@ -123,6 +127,7 @@ export class ZcashTransaction extends this.overwintered = tx.overwintered; this.versionGroupId = tx.versionGroupId; this.expiryHeight = tx.expiryHeight; + this.saplingValueBalance = tx.saplingValueBalance; if (tx.consensusBranchId !== undefined) { consensusBranchId = tx.consensusBranchId; @@ -378,7 +383,7 @@ export class ZcashTransaction extends bufferWriter.writeUInt32(this.locktime); bufferWriter.writeUInt32(this.expiryHeight); if (this.isSaplingCompatible()) { - bufferWriter.writeSlice(VALUE_INT64_ZERO); + bufferWriter.writeSlice(this.saplingValueBalance); } bufferWriter.writeInt32(hashType); diff --git a/modules/utxo-lib/test/bitgo/zcash/ZcashTransaction.ts b/modules/utxo-lib/test/bitgo/zcash/ZcashTransaction.ts new file mode 100644 index 0000000000..0df0c32fb9 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/zcash/ZcashTransaction.ts @@ -0,0 +1,43 @@ +import * as assert from 'assert'; +import { networks } from '../../../src'; +import { ZcashTransaction } from '../../../src/bitgo'; + +// Minimal Sapling v4 transaction with valueBalance = -5 (t->z shielding). +// Built as: header(0x80000004) | versionGroupId(0x892F2085) | vin(0) | vout(0) | +// locktime(0) | expiryHeight(0) | valueBalance(-5 as int64le) | +// vSpendsSapling(0) | vOutputsSapling(0) | vJoinSplit(0) +const SHIELDING_TX_HEX = '0400008085202f8900000000000000000000fbffffffffffffff000000'; + +describe('ZcashTransaction (Sapling valueBalance)', function () { + describe('round-trip for t->z shielding tx (negative valueBalance)', function () { + it('parses without throwing', function () { + const tx = ZcashTransaction.fromBuffer(Buffer.from(SHIELDING_TX_HEX, 'hex'), false, 'number', networks.zcash); + // saplingValueBalance stores raw bytes; -5 in int64le is fb ff ff ff ff ff ff ff + assert.strictEqual(tx.saplingValueBalance.toString('hex'), 'fbffffffffffffff'); + }); + + it('re-serializes to the same hex (toBuffer round-trip)', function () { + const tx = ZcashTransaction.fromBuffer(Buffer.from(SHIELDING_TX_HEX, 'hex'), false, 'number', networks.zcash); + assert.strictEqual(tx.toBuffer().toString('hex'), SHIELDING_TX_HEX); + }); + + it('computes getId() without throwing', function () { + const tx = ZcashTransaction.fromBuffer(Buffer.from(SHIELDING_TX_HEX, 'hex'), false, 'number', networks.zcash); + // Just verify it does not throw; the exact txid is deterministic from the hex above. + assert.doesNotThrow(() => tx.getId()); + }); + + it('clones correctly', function () { + const tx = ZcashTransaction.fromBuffer(Buffer.from(SHIELDING_TX_HEX, 'hex'), false, 'number', networks.zcash); + const cloned = tx.clone(); + assert.strictEqual(cloned.toBuffer().toString('hex'), SHIELDING_TX_HEX); + }); + }); + + describe('round-trip for transparent tx (zero valueBalance)', function () { + it('saplingValueBalance defaults to all-zero bytes', function () { + const tx = new ZcashTransaction(networks.zcash); + assert.strictEqual(tx.saplingValueBalance.toString('hex'), '0000000000000000'); + }); + }); +});