diff --git a/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts b/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts index 7011eb9f2e..eb1c23eba1 100644 --- a/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts +++ b/modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts @@ -193,3 +193,93 @@ export function toBufferV5( // https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L1081 writeEmptyOrchardBundle(bufferWriter); } + +/** + * Returns `true` if the transaction buffer contains any Sapling or Orchard shielded components. + * + * This helper is intended as a lightweight preflight check for code paths that only support + * fully transparent transactions. It reuses existing parsing/assertion helpers and relies on + * try/catch to detect non-empty shielded sections. + * + * Notes: + * - Sapling detection uses `readEmptySaplingBundle()`. This will return `true` for *any* non-empty + * Sapling bundle (spends or outputs). It does not distinguish between shielded inputs vs outputs. + * - Orchard detection uses `readEmptyOrchardBundle()` (v5 only). + */ +export function hasSaplingOrOrchardShieldedComponentsFromBuffer(buffer: Buffer): boolean { + const bufferReader = new BufferReader(buffer); + + // Split the header into fOverwintered and nVersion + const header = bufferReader.readInt32(); + const overwintered = header >>> 31; + const version = header & 0x07fffffff; + + if (!overwintered) { + return false; + } + + // Overwinter-compatible transactions serialize nVersionGroupId. + if (version >= ZcashTransaction.VERSION_OVERWINTER) { + bufferReader.readUInt32(); // nVersionGroupId + } + + if (version === 5) { + // https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L815 + bufferReader.readUInt32(); // consensusBranchId + bufferReader.readUInt32(); // locktime + bufferReader.readUInt32(); // expiryHeight + + // https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L828 + readInputs(bufferReader); + readOutputs(bufferReader, 'number'); + + // https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L835 + try { + readEmptySaplingBundle(bufferReader); + } catch (e) { + if (e instanceof UnsupportedTransactionError) { + return true; + } + throw e; + } + + try { + readEmptyOrchardBundle(bufferReader); + } catch (e) { + if (e instanceof UnsupportedTransactionError) { + return true; + } + throw e; + } + + return false; + } + + // v4-style encoding for non-v5 overwintered txs (as used by this library). + readInputs(bufferReader); + readOutputs(bufferReader, 'number'); + bufferReader.readUInt32(); // locktime + + // expiryHeight is serialized for overwinter-compatible tx (v3+) + bufferReader.readUInt32(); // expiryHeight + + if (version >= ZcashTransaction.VERSION_SAPLING) { + const valueBalance = bufferReader.readSlice(8); + if (!valueBalance.equals(VALUE_INT64_ZERO)) { + // Non-zero valueBalance implies shielded; keep consistent with existing parser behavior. + return true; + } + + try { + readEmptySaplingBundle(bufferReader); + } catch (e) { + if (e instanceof UnsupportedTransactionError) { + return true; + } + throw e; + } + } + + // No Orchard in pre-v5 encoding. + return false; +} diff --git a/modules/utxo-lib/test/bitgo/zcash/ZcashBufferutils.ts b/modules/utxo-lib/test/bitgo/zcash/ZcashBufferutils.ts new file mode 100644 index 0000000000..2412ed350e --- /dev/null +++ b/modules/utxo-lib/test/bitgo/zcash/ZcashBufferutils.ts @@ -0,0 +1,76 @@ +import * as assert from 'assert'; +import { BufferWriter } from 'bitcoinjs-lib/src/bufferutils'; + +import { hasSaplingOrOrchardShieldedComponentsFromBuffer } from '../../../src/bitgo/zcash/ZcashBufferutils'; + +function finalize(w: BufferWriter): Buffer { + return w.buffer.slice(0, w.offset); +} + +describe('ZcashBufferutils.hasSaplingOrOrchardShieldedComponentsFromBuffer', function () { + it('returns false for minimal v4 transparent-only (empty Sapling bundle)', function () { + const w = new BufferWriter(Buffer.alloc(256)); + w.writeInt32((1 << 31) | 4); + w.writeUInt32(0x892f2085); // SAPLING_VERSION_GROUP_ID + w.writeVarInt(0); // vin + w.writeVarInt(0); // vout + w.writeUInt32(0); // locktime + w.writeUInt32(0); // expiryHeight + w.writeSlice(Buffer.alloc(8, 0)); // valueBalance + w.writeVarInt(0); // vSpendsSapling + w.writeVarInt(0); // vOutputsSapling + // JoinSplits omitted: this helper does not read them. + const tx = finalize(w); + + assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), false); + }); + + it('returns true for v4 when Sapling bundle is non-empty', function () { + const w = new BufferWriter(Buffer.alloc(256)); + w.writeInt32((1 << 31) | 4); + w.writeUInt32(0x892f2085); + w.writeVarInt(0); + w.writeVarInt(0); + w.writeUInt32(0); + w.writeUInt32(0); + w.writeSlice(Buffer.alloc(8, 0)); + w.writeVarInt(1); // vSpendsSapling (non-empty -> readEmptySaplingBundle throws) + const tx = finalize(w); + + assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), true); + }); + + it('returns false for minimal v5 transparent-only (empty Sapling + Orchard)', function () { + const w = new BufferWriter(Buffer.alloc(256)); + w.writeInt32((1 << 31) | 5); + w.writeUInt32(0x26a7270a); // ZIP225_VERSION_GROUP_ID + w.writeUInt32(0xc2d6d0b4); // consensusBranchId (NU5; arbitrary for this test) + w.writeUInt32(0); // locktime + w.writeUInt32(0); // expiryHeight + w.writeVarInt(0); // vin + w.writeVarInt(0); // vout + w.writeVarInt(0); // vSpendsSapling + w.writeVarInt(0); // vOutputsSapling + w.writeUInt8(0x00); // orchard bundle empty marker + const tx = finalize(w); + + assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), false); + }); + + it('returns true for v5 when Orchard bundle is non-empty', function () { + const w = new BufferWriter(Buffer.alloc(256)); + w.writeInt32((1 << 31) | 5); + w.writeUInt32(0x26a7270a); + w.writeUInt32(0xc2d6d0b4); + w.writeUInt32(0); + w.writeUInt32(0); + w.writeVarInt(0); + w.writeVarInt(0); + w.writeVarInt(0); + w.writeVarInt(0); + w.writeUInt8(0x01); // orchard bundle present -> readEmptyOrchardBundle throws + const tx = finalize(w); + + assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), true); + }); +});