Skip to content

Commit 90cab8d

Browse files
feat(utxo-lib): add zcash shielded components detection utility
Add a utility function `hasSaplingOrOrchardShieldedComponentsFromBuffer` to detect if a Zcash transaction buffer contains any shielded components (Sapling or Orchard). This is useful for quickly checking transaction compatibility without performing full parsing. BTC-2887
1 parent 8eff174 commit 90cab8d

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

modules/utxo-lib/src/bitgo/zcash/ZcashBufferutils.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,93 @@ export function toBufferV5<TNumber extends number | bigint>(
193193
// https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L1081
194194
writeEmptyOrchardBundle(bufferWriter);
195195
}
196+
197+
/**
198+
* Returns `true` if the transaction buffer contains any Sapling or Orchard shielded components.
199+
*
200+
* This helper is intended as a lightweight preflight check for code paths that only support
201+
* fully transparent transactions. It reuses existing parsing/assertion helpers and relies on
202+
* try/catch to detect non-empty shielded sections.
203+
*
204+
* Notes:
205+
* - Sapling detection uses `readEmptySaplingBundle()`. This will return `true` for *any* non-empty
206+
* Sapling bundle (spends or outputs). It does not distinguish between shielded inputs vs outputs.
207+
* - Orchard detection uses `readEmptyOrchardBundle()` (v5 only).
208+
*/
209+
export function hasSaplingOrOrchardShieldedComponentsFromBuffer(buffer: Buffer): boolean {
210+
const bufferReader = new BufferReader(buffer);
211+
212+
// Split the header into fOverwintered and nVersion
213+
const header = bufferReader.readInt32();
214+
const overwintered = header >>> 31;
215+
const version = header & 0x07fffffff;
216+
217+
if (!overwintered) {
218+
return false;
219+
}
220+
221+
// Overwinter-compatible transactions serialize nVersionGroupId.
222+
if (version >= ZcashTransaction.VERSION_OVERWINTER) {
223+
bufferReader.readUInt32(); // nVersionGroupId
224+
}
225+
226+
if (version === 5) {
227+
// https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L815
228+
bufferReader.readUInt32(); // consensusBranchId
229+
bufferReader.readUInt32(); // locktime
230+
bufferReader.readUInt32(); // expiryHeight
231+
232+
// https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L828
233+
readInputs(bufferReader);
234+
readOutputs(bufferReader, 'number');
235+
236+
// https://github.com/zcash/zcash/blob/v4.5.1/src/primitives/transaction.h#L835
237+
try {
238+
readEmptySaplingBundle(bufferReader);
239+
} catch (e) {
240+
if (e instanceof UnsupportedTransactionError) {
241+
return true;
242+
}
243+
throw e;
244+
}
245+
246+
try {
247+
readEmptyOrchardBundle(bufferReader);
248+
} catch (e) {
249+
if (e instanceof UnsupportedTransactionError) {
250+
return true;
251+
}
252+
throw e;
253+
}
254+
255+
return false;
256+
}
257+
258+
// v4-style encoding for non-v5 overwintered txs (as used by this library).
259+
readInputs(bufferReader);
260+
readOutputs(bufferReader, 'number');
261+
bufferReader.readUInt32(); // locktime
262+
263+
// expiryHeight is serialized for overwinter-compatible tx (v3+)
264+
bufferReader.readUInt32(); // expiryHeight
265+
266+
if (version >= ZcashTransaction.VERSION_SAPLING) {
267+
const valueBalance = bufferReader.readSlice(8);
268+
if (!valueBalance.equals(VALUE_INT64_ZERO)) {
269+
// Non-zero valueBalance implies shielded; keep consistent with existing parser behavior.
270+
return true;
271+
}
272+
273+
try {
274+
readEmptySaplingBundle(bufferReader);
275+
} catch (e) {
276+
if (e instanceof UnsupportedTransactionError) {
277+
return true;
278+
}
279+
throw e;
280+
}
281+
}
282+
283+
// No Orchard in pre-v5 encoding.
284+
return false;
285+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as assert from 'assert';
2+
import { BufferWriter } from 'bitcoinjs-lib/src/bufferutils';
3+
4+
import { hasSaplingOrOrchardShieldedComponentsFromBuffer } from '../../../src/bitgo/zcash/ZcashBufferutils';
5+
6+
function finalize(w: BufferWriter): Buffer {
7+
return w.buffer.slice(0, w.offset);
8+
}
9+
10+
describe('ZcashBufferutils.hasSaplingOrOrchardShieldedComponentsFromBuffer', function () {
11+
it('returns false for minimal v4 transparent-only (empty Sapling bundle)', function () {
12+
const w = new BufferWriter(Buffer.alloc(256));
13+
w.writeInt32((1 << 31) | 4);
14+
w.writeUInt32(0x892f2085); // SAPLING_VERSION_GROUP_ID
15+
w.writeVarInt(0); // vin
16+
w.writeVarInt(0); // vout
17+
w.writeUInt32(0); // locktime
18+
w.writeUInt32(0); // expiryHeight
19+
w.writeSlice(Buffer.alloc(8, 0)); // valueBalance
20+
w.writeVarInt(0); // vSpendsSapling
21+
w.writeVarInt(0); // vOutputsSapling
22+
// JoinSplits omitted: this helper does not read them.
23+
const tx = finalize(w);
24+
25+
assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), false);
26+
});
27+
28+
it('returns true for v4 when Sapling bundle is non-empty', function () {
29+
const w = new BufferWriter(Buffer.alloc(256));
30+
w.writeInt32((1 << 31) | 4);
31+
w.writeUInt32(0x892f2085);
32+
w.writeVarInt(0);
33+
w.writeVarInt(0);
34+
w.writeUInt32(0);
35+
w.writeUInt32(0);
36+
w.writeSlice(Buffer.alloc(8, 0));
37+
w.writeVarInt(1); // vSpendsSapling (non-empty -> readEmptySaplingBundle throws)
38+
const tx = finalize(w);
39+
40+
assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), true);
41+
});
42+
43+
it('returns false for minimal v5 transparent-only (empty Sapling + Orchard)', function () {
44+
const w = new BufferWriter(Buffer.alloc(256));
45+
w.writeInt32((1 << 31) | 5);
46+
w.writeUInt32(0x26a7270a); // ZIP225_VERSION_GROUP_ID
47+
w.writeUInt32(0xc2d6d0b4); // consensusBranchId (NU5; arbitrary for this test)
48+
w.writeUInt32(0); // locktime
49+
w.writeUInt32(0); // expiryHeight
50+
w.writeVarInt(0); // vin
51+
w.writeVarInt(0); // vout
52+
w.writeVarInt(0); // vSpendsSapling
53+
w.writeVarInt(0); // vOutputsSapling
54+
w.writeUInt8(0x00); // orchard bundle empty marker
55+
const tx = finalize(w);
56+
57+
assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), false);
58+
});
59+
60+
it('returns true for v5 when Orchard bundle is non-empty', function () {
61+
const w = new BufferWriter(Buffer.alloc(256));
62+
w.writeInt32((1 << 31) | 5);
63+
w.writeUInt32(0x26a7270a);
64+
w.writeUInt32(0xc2d6d0b4);
65+
w.writeUInt32(0);
66+
w.writeUInt32(0);
67+
w.writeVarInt(0);
68+
w.writeVarInt(0);
69+
w.writeVarInt(0);
70+
w.writeVarInt(0);
71+
w.writeUInt8(0x01); // orchard bundle present -> readEmptyOrchardBundle throws
72+
const tx = finalize(w);
73+
74+
assert.strictEqual(hasSaplingOrOrchardShieldedComponentsFromBuffer(tx), true);
75+
});
76+
});

0 commit comments

Comments
 (0)