Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,93 @@ export function toBufferV5<TNumber extends number | bigint>(
// 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;
}
76 changes: 76 additions & 0 deletions modules/utxo-lib/test/bitgo/zcash/ZcashBufferutils.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});