diff --git a/common/darkside.go b/common/darkside.go index 5544c115..e4a8de51 100644 --- a/common/darkside.go +++ b/common/darkside.go @@ -112,10 +112,11 @@ type darksideProtocolSubtreeRoots struct { var DarksideEnabled bool func darksideSetTxID(tx *parser.Transaction) { - // SHA256d - // This is correct for V4 transactions, but not for V5, but in this test - // environment, it's harmless (the incorrect txid calculation can't be - // detected). This will be fixed when lightwalletd calculates txids correctly . + if tx.IsV5() { + // V5 txids are computed via ZIP 244 during ParseFromSlice. + return + } + // SHA256d (correct for pre-v5 transactions). digest := sha256.Sum256(tx.Bytes()) digest = sha256.Sum256(digest[:]) tx.SetTxID(digest) @@ -935,11 +936,17 @@ func DarksideRemoveTreeState(arg *walletrpc.BlockID) error { } if arg.Height > 0 { treestate := state.stagedTreeStates[arg.Height] + if treestate == nil { + return fmt.Errorf("no tree state at height %d", arg.Height) + } delete(state.stagedTreeStatesByHash, treestate.Hash) delete(state.stagedTreeStates, treestate.Height) } else { h := hex.EncodeToString(arg.Hash) treestate := state.stagedTreeStatesByHash[h] + if treestate == nil { + return fmt.Errorf("no tree state for hash %s", h) + } delete(state.stagedTreeStatesByHash, treestate.Hash) delete(state.stagedTreeStates, treestate.Height) } diff --git a/parser/internal/blake2b/LICENSE b/parser/internal/blake2b/LICENSE new file mode 100644 index 00000000..2a7cf70d --- /dev/null +++ b/parser/internal/blake2b/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/parser/internal/blake2b/blake2b.go b/parser/internal/blake2b/blake2b.go new file mode 100644 index 00000000..db3e749e --- /dev/null +++ b/parser/internal/blake2b/blake2b.go @@ -0,0 +1,158 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Vendored from golang.org/x/crypto@v0.45.0/blake2b with +// personalization support added for ZIP 244 transaction ID +// computation. Only BLAKE2b-256 is retained; ASM, XOF, +// marshal, and larger sizes have been removed. + +package blake2b + +import ( + "encoding/binary" + "hash" +) + +const ( + // BlockSize is the block size of BLAKE2b in bytes. + BlockSize = 128 + // Size256 is the hash size of BLAKE2b-256 in bytes. + Size256 = 32 + // size is the internal full state size (64 bytes). + size = 64 +) + +var iv = [8]uint64{ + 0x6a09e667f3bcc908, 0xbb67ae8584caa73b, 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, 0x9b05688c2b3e6c1f, 0x1f83d9abfb41bd6b, 0x5be0cd19137e2179, +} + +type digest struct { + h [8]uint64 + c [2]uint64 + sz int + block [BlockSize]byte + offset int + key [BlockSize]byte + keyLen int + personalization [16]byte +} + +// New256Personalized returns a new hash.Hash computing BLAKE2b-256 with the +// given 16-byte personalization string (as required by ZIP 244). +func New256Personalized(personalization [16]byte) hash.Hash { + d := &digest{ + sz: Size256, + personalization: personalization, + } + d.Reset() + return d +} + +// Sum256Personalized returns the BLAKE2b-256 checksum of data with the given +// 16-byte personalization string. +func Sum256Personalized(personalization [16]byte, data []byte) [Size256]byte { + h := iv + h[0] ^= uint64(Size256) | (1 << 16) | (1 << 24) + h[6] ^= binary.LittleEndian.Uint64(personalization[:8]) + h[7] ^= binary.LittleEndian.Uint64(personalization[8:16]) + + var c [2]uint64 + + if length := len(data); length > BlockSize { + n := length &^ (BlockSize - 1) + if length == n { + n -= BlockSize + } + hashBlocksGeneric(&h, &c, 0, data[:n]) + data = data[n:] + } + + var block [BlockSize]byte + offset := copy(block[:], data) + remaining := uint64(BlockSize - offset) + if c[0] < remaining { + c[1]-- + } + c[0] -= remaining + + hashBlocksGeneric(&h, &c, 0xFFFFFFFFFFFFFFFF, block[:]) + + var sum [Size256]byte + for i := 0; i < Size256/8; i++ { + binary.LittleEndian.PutUint64(sum[8*i:], h[i]) + } + return sum +} + +func (d *digest) BlockSize() int { return BlockSize } +func (d *digest) Size() int { return d.sz } + +func (d *digest) Reset() { + d.h = iv + d.h[0] ^= uint64(d.sz) | (uint64(d.keyLen) << 8) | (1 << 16) | (1 << 24) + d.h[6] ^= binary.LittleEndian.Uint64(d.personalization[:8]) + d.h[7] ^= binary.LittleEndian.Uint64(d.personalization[8:16]) + d.offset, d.c[0], d.c[1] = 0, 0, 0 + if d.keyLen > 0 { + d.block = d.key + d.offset = BlockSize + } +} + +func (d *digest) Write(p []byte) (n int, err error) { + n = len(p) + + if d.offset > 0 { + remaining := BlockSize - d.offset + if n <= remaining { + d.offset += copy(d.block[d.offset:], p) + return + } + copy(d.block[d.offset:], p[:remaining]) + hashBlocksGeneric(&d.h, &d.c, 0, d.block[:]) + d.offset = 0 + p = p[remaining:] + } + + if length := len(p); length > BlockSize { + nn := length &^ (BlockSize - 1) + if length == nn { + nn -= BlockSize + } + hashBlocksGeneric(&d.h, &d.c, 0, p[:nn]) + p = p[nn:] + } + + if len(p) > 0 { + d.offset += copy(d.block[:], p) + } + + return +} + +func (d *digest) Sum(sum []byte) []byte { + var hash [size]byte + d.finalize(&hash) + return append(sum, hash[:d.sz]...) +} + +func (d *digest) finalize(hash *[size]byte) { + var block [BlockSize]byte + copy(block[:], d.block[:d.offset]) + remaining := uint64(BlockSize - d.offset) + + c := d.c + if c[0] < remaining { + c[1]-- + } + c[0] -= remaining + + h := d.h + hashBlocksGeneric(&h, &c, 0xFFFFFFFFFFFFFFFF, block[:]) + + for i, v := range h { + binary.LittleEndian.PutUint64(hash[8*i:], v) + } +} diff --git a/parser/internal/blake2b/blake2b_test.go b/parser/internal/blake2b/blake2b_test.go new file mode 100644 index 00000000..eecf450c --- /dev/null +++ b/parser/internal/blake2b/blake2b_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2025 The Zcash developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php . + +package blake2b + +import ( + "encoding/hex" + "testing" +) + +func TestSum256NoPersonalization(t *testing.T) { + // BLAKE2b-256 of empty string (no personalization = all zeros). + // Verified with Python: hashlib.blake2b(b"", digest_size=32).hexdigest() + got := Sum256Personalized([16]byte{}, []byte{}) + want := "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8" + if hex.EncodeToString(got[:]) != want { + t.Fatalf("empty hash mismatch:\n got %s\n want %s", hex.EncodeToString(got[:]), want) + } +} + +func TestSum256Abc(t *testing.T) { + // BLAKE2b-256 of "abc". + // Verified with Python: hashlib.blake2b(b"abc", digest_size=32).hexdigest() + got := Sum256Personalized([16]byte{}, []byte("abc")) + want := "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319" + if hex.EncodeToString(got[:]) != want { + t.Fatalf("abc hash mismatch:\n got %s\n want %s", hex.EncodeToString(got[:]), want) + } +} + +func TestNew256PersonalizedStreaming(t *testing.T) { + // Verify streaming mode matches one-shot for "abc". + h := New256Personalized([16]byte{}) + h.Write([]byte("a")) + h.Write([]byte("bc")) + got := h.Sum(nil) + want := "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319" + if hex.EncodeToString(got) != want { + t.Fatalf("streaming abc hash mismatch:\n got %s\n want %s", hex.EncodeToString(got), want) + } +} + +func TestPersonalizedHash(t *testing.T) { + // Verified with Python: + // hashlib.blake2b(b"", digest_size=32, + // person=b"ZcashTxHash_\xbb\x09\xb8\x76").hexdigest() + person := [16]byte{ + 'Z', 'c', 'a', 's', 'h', 'T', 'x', 'H', + 'a', 's', 'h', '_', 0xbb, 0x09, 0xb8, 0x76, + } + got := Sum256Personalized(person, []byte{}) + want := "da5ea35a7ceb9507dbdd7a1dd0c1c2bf5d61f12781704e5613c8c8d3226f6e26" + if hex.EncodeToString(got[:]) != want { + t.Fatalf("personalized empty hash mismatch:\n got %s\n want %s", hex.EncodeToString(got[:]), want) + } +} + +func TestPersonalizedHashWithData(t *testing.T) { + // Verified with Python: + // hashlib.blake2b(b"Zcash", digest_size=32, + // person=b"ZTxIdHeadersHash").hexdigest() + person := [16]byte{ + 'Z', 'T', 'x', 'I', 'd', 'H', 'e', 'a', + 'd', 'e', 'r', 's', 'H', 'a', 's', 'h', + } + got := Sum256Personalized(person, []byte("Zcash")) + want := "1a9162a394083a3a8020bff265625864f9a4cb7f8a28038822f78c6a17bc4f45" + if hex.EncodeToString(got[:]) != want { + t.Fatalf("personalized data hash mismatch:\n got %s\n want %s", hex.EncodeToString(got[:]), want) + } +} + +func TestReset(t *testing.T) { + person := [16]byte{ + 'Z', 'T', 'x', 'I', 'd', 'H', 'e', 'a', + 'd', 'e', 'r', 's', 'H', 'a', 's', 'h', + } + h := New256Personalized(person) + h.Write([]byte("garbage")) + h.Reset() + h.Write([]byte("Zcash")) + got := h.Sum(nil) + want := "1a9162a394083a3a8020bff265625864f9a4cb7f8a28038822f78c6a17bc4f45" + if hex.EncodeToString(got) != want { + t.Fatalf("reset hash mismatch:\n got %s\n want %s", hex.EncodeToString(got), want) + } +} diff --git a/parser/internal/blake2b/compress.go b/parser/internal/blake2b/compress.go new file mode 100644 index 00000000..ee56dc81 --- /dev/null +++ b/parser/internal/blake2b/compress.go @@ -0,0 +1,185 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Vendored from golang.org/x/crypto@v0.45.0/blake2b/blake2b_generic.go +// (pure-Go compression function, no modifications). + +package blake2b + +import ( + "encoding/binary" + "math/bits" +) + +// the precomputed values for BLAKE2b +// there are 12 16-byte arrays - one for each round +// the entries are calculated from the sigma constants. +var precomputed = [12][16]byte{ + {0, 2, 4, 6, 1, 3, 5, 7, 8, 10, 12, 14, 9, 11, 13, 15}, + {14, 4, 9, 13, 10, 8, 15, 6, 1, 0, 11, 5, 12, 2, 7, 3}, + {11, 12, 5, 15, 8, 0, 2, 13, 10, 3, 7, 9, 14, 6, 1, 4}, + {7, 3, 13, 11, 9, 1, 12, 14, 2, 5, 4, 15, 6, 10, 0, 8}, + {9, 5, 2, 10, 0, 7, 4, 15, 14, 11, 6, 3, 1, 12, 8, 13}, + {2, 6, 0, 8, 12, 10, 11, 3, 4, 7, 15, 1, 13, 5, 14, 9}, + {12, 1, 14, 4, 5, 15, 13, 10, 0, 6, 9, 8, 7, 3, 2, 11}, + {13, 7, 12, 3, 11, 14, 1, 9, 5, 15, 8, 2, 0, 4, 6, 10}, + {6, 14, 11, 0, 15, 9, 3, 8, 12, 13, 1, 10, 2, 7, 4, 5}, + {10, 8, 7, 1, 2, 4, 6, 5, 15, 9, 3, 13, 11, 14, 12, 0}, + {0, 2, 4, 6, 1, 3, 5, 7, 8, 10, 12, 14, 9, 11, 13, 15}, // same as first + {14, 4, 9, 13, 10, 8, 15, 6, 1, 0, 11, 5, 12, 2, 7, 3}, // same as second +} + +func hashBlocksGeneric(h *[8]uint64, c *[2]uint64, flag uint64, blocks []byte) { + var m [16]uint64 + c0, c1 := c[0], c[1] + + for i := 0; i < len(blocks); { + c0 += BlockSize + if c0 < BlockSize { + c1++ + } + + v0, v1, v2, v3, v4, v5, v6, v7 := h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7] + v8, v9, v10, v11, v12, v13, v14, v15 := iv[0], iv[1], iv[2], iv[3], iv[4], iv[5], iv[6], iv[7] + v12 ^= c0 + v13 ^= c1 + v14 ^= flag + + for j := range m { + m[j] = binary.LittleEndian.Uint64(blocks[i:]) + i += 8 + } + + for j := range precomputed { + s := &(precomputed[j]) + + v0 += m[s[0]] + v0 += v4 + v12 ^= v0 + v12 = bits.RotateLeft64(v12, -32) + v8 += v12 + v4 ^= v8 + v4 = bits.RotateLeft64(v4, -24) + v1 += m[s[1]] + v1 += v5 + v13 ^= v1 + v13 = bits.RotateLeft64(v13, -32) + v9 += v13 + v5 ^= v9 + v5 = bits.RotateLeft64(v5, -24) + v2 += m[s[2]] + v2 += v6 + v14 ^= v2 + v14 = bits.RotateLeft64(v14, -32) + v10 += v14 + v6 ^= v10 + v6 = bits.RotateLeft64(v6, -24) + v3 += m[s[3]] + v3 += v7 + v15 ^= v3 + v15 = bits.RotateLeft64(v15, -32) + v11 += v15 + v7 ^= v11 + v7 = bits.RotateLeft64(v7, -24) + + v0 += m[s[4]] + v0 += v4 + v12 ^= v0 + v12 = bits.RotateLeft64(v12, -16) + v8 += v12 + v4 ^= v8 + v4 = bits.RotateLeft64(v4, -63) + v1 += m[s[5]] + v1 += v5 + v13 ^= v1 + v13 = bits.RotateLeft64(v13, -16) + v9 += v13 + v5 ^= v9 + v5 = bits.RotateLeft64(v5, -63) + v2 += m[s[6]] + v2 += v6 + v14 ^= v2 + v14 = bits.RotateLeft64(v14, -16) + v10 += v14 + v6 ^= v10 + v6 = bits.RotateLeft64(v6, -63) + v3 += m[s[7]] + v3 += v7 + v15 ^= v3 + v15 = bits.RotateLeft64(v15, -16) + v11 += v15 + v7 ^= v11 + v7 = bits.RotateLeft64(v7, -63) + + v0 += m[s[8]] + v0 += v5 + v15 ^= v0 + v15 = bits.RotateLeft64(v15, -32) + v10 += v15 + v5 ^= v10 + v5 = bits.RotateLeft64(v5, -24) + v1 += m[s[9]] + v1 += v6 + v12 ^= v1 + v12 = bits.RotateLeft64(v12, -32) + v11 += v12 + v6 ^= v11 + v6 = bits.RotateLeft64(v6, -24) + v2 += m[s[10]] + v2 += v7 + v13 ^= v2 + v13 = bits.RotateLeft64(v13, -32) + v8 += v13 + v7 ^= v8 + v7 = bits.RotateLeft64(v7, -24) + v3 += m[s[11]] + v3 += v4 + v14 ^= v3 + v14 = bits.RotateLeft64(v14, -32) + v9 += v14 + v4 ^= v9 + v4 = bits.RotateLeft64(v4, -24) + + v0 += m[s[12]] + v0 += v5 + v15 ^= v0 + v15 = bits.RotateLeft64(v15, -16) + v10 += v15 + v5 ^= v10 + v5 = bits.RotateLeft64(v5, -63) + v1 += m[s[13]] + v1 += v6 + v12 ^= v1 + v12 = bits.RotateLeft64(v12, -16) + v11 += v12 + v6 ^= v11 + v6 = bits.RotateLeft64(v6, -63) + v2 += m[s[14]] + v2 += v7 + v13 ^= v2 + v13 = bits.RotateLeft64(v13, -16) + v8 += v13 + v7 ^= v8 + v7 = bits.RotateLeft64(v7, -63) + v3 += m[s[15]] + v3 += v4 + v14 ^= v3 + v14 = bits.RotateLeft64(v14, -16) + v9 += v14 + v4 ^= v9 + v4 = bits.RotateLeft64(v4, -63) + + } + + h[0] ^= v0 ^ v8 + h[1] ^= v1 ^ v9 + h[2] ^= v2 ^ v10 + h[3] ^= v3 ^ v11 + h[4] ^= v4 ^ v12 + h[5] ^= v5 ^ v13 + h[6] ^= v6 ^ v14 + h[7] ^= v7 ^ v15 + } + c[0], c[1] = c0, c1 +} diff --git a/parser/transaction.go b/parser/transaction.go index a45ba60e..d0abcd06 100644 --- a/parser/transaction.go +++ b/parser/transaction.go @@ -646,6 +646,11 @@ func (tx *Transaction) isZip225V5() bool { tx.version == ZIP225_TX_VERSION } +// IsV5 reports whether the transaction uses the ZIP 225 v5 format. +func (tx *Transaction) IsV5() bool { + return tx.isZip225V5() +} + func (tx *Transaction) isGroth16Proof() bool { // Sapling changed the joinSplit proof from PHGR (BCTV14) to Groth16; // this applies also to versions beyond Sapling. @@ -691,6 +696,14 @@ func (tx *Transaction) ParseFromSlice(data []byte) ([]byte, error) { txLen := len(data) - len(s) tx.rawBytes = data[:txLen] + if tx.isZip225V5() { + txid, err := computeV5TxID(tx.rawBytes) + if err != nil { + return nil, fmt.Errorf("error computing v5 txid: %w", err) + } + tx.txID = txid + } + return []byte(s), nil } diff --git a/parser/transaction_test.go b/parser/transaction_test.go index 86493e50..7a226ac7 100644 --- a/parser/transaction_test.go +++ b/parser/transaction_test.go @@ -84,9 +84,11 @@ func TestV5TransactionParser(t *testing.T) { if len(rest) != 0 { t.Fatalf("Test did not consume entire buffer, %d remaining", len(rest)) } - // Currently, we can't check the txid because we get that from - // zcashd (getblock rpc) rather than computing it ourselves. - // https://github.com/zcash/lightwalletd/issues/392 + // Verify computed txid matches expected (ZIP 244, issue #392). + gotTxid := tx.GetDisplayHashString() + if gotTxid != txtestdata.Txid { + t.Fatalf("txid mismatch: got %s, want %s", gotTxid, txtestdata.Txid) + } if tx.version != uint32(txtestdata.Version) { t.Fatal("version miscompare") } diff --git a/parser/zip244.go b/parser/zip244.go new file mode 100644 index 00000000..3cccf445 --- /dev/null +++ b/parser/zip244.go @@ -0,0 +1,440 @@ +// Copyright (c) 2025 The Zcash developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php . + +package parser + +import ( + "encoding/binary" + "errors" + "fmt" + "hash" + + "github.com/zcash/lightwalletd/hash32" + "github.com/zcash/lightwalletd/parser/internal/blake2b" + "github.com/zcash/lightwalletd/parser/internal/bytestring" +) + +// personalization converts a 16-byte string to a [16]byte personalization parameter. +func personalization(s string) [16]byte { + if len(s) != 16 { + panic(fmt.Sprintf("personalization string must be exactly 16 bytes, got %d", len(s))) + } + var p [16]byte + copy(p[:], s) + return p +} + +func txidPersonalization(consensusBranchID uint32) [16]byte { + var p [16]byte + copy(p[:12], "ZcashTxHash_") + binary.LittleEndian.PutUint32(p[12:], consensusBranchID) + return p +} + +func sumDigest(h hash.Hash) [32]byte { + var d [32]byte + copy(d[:], h.Sum(nil)) + return d +} + +// writeCompactSize writes a Bitcoin-style CompactSize encoding to a hash. +func writeCompactSize(h hash.Hash, n int) { + if n < 253 { + h.Write([]byte{byte(n)}) + } else if n < 0x10000 { + var buf [3]byte + buf[0] = 0xfd + binary.LittleEndian.PutUint16(buf[1:], uint16(n)) + h.Write(buf[:]) + } else { + var buf [5]byte + buf[0] = 0xfe + binary.LittleEndian.PutUint32(buf[1:], uint32(n)) + h.Write(buf[:]) + } +} + +// computeV5TxID computes the transaction ID for a v5 transaction per ZIP 244. +// It re-parses rawBytes to extract the fields needed for the hash tree. +// +// ZIP 244 txid_digest tree: +// txid = H("ZcashTxHash_"||branchID, +// header_digest || transparent_digest || sapling_digest || orchard_digest) +// +// Proofs and signatures are excluded from the txid per the spec, so the +// remaining bytes after readAndHashOrchard are intentionally not consumed. +func computeV5TxID(rawBytes []byte) (hash32.T, error) { + s := bytestring.String(rawBytes) + + headerDigest, consensusBranchID, err := readAndHashHeader(&s) + if err != nil { + return hash32.T{}, fmt.Errorf("header: %w", err) + } + + transparentDigest, err := readAndHashTransparent(&s) + if err != nil { + return hash32.T{}, fmt.Errorf("transparent: %w", err) + } + + saplingDigest, spendCount, outputCount, err := readAndHashSapling(&s) + if err != nil { + return hash32.T{}, fmt.Errorf("sapling: %w", err) + } + + if err := skipSaplingProofsAndSigs(&s, spendCount, outputCount); err != nil { + return hash32.T{}, fmt.Errorf("sapling proofs: %w", err) + } + + orchardDigest, err := readAndHashOrchard(&s) + if err != nil { + return hash32.T{}, fmt.Errorf("orchard: %w", err) + } + + // Remaining bytes (orchard proofs, auth sigs, binding sig) are + // intentionally not consumed -- they are excluded from the txid. + + h := blake2b.New256Personalized(txidPersonalization(consensusBranchID)) + h.Write(headerDigest[:]) + h.Write(transparentDigest[:]) + h.Write(saplingDigest[:]) + h.Write(orchardDigest[:]) + + return hash32.FromSlice(h.Sum(nil)), nil +} + +// readAndHashHeader reads the 20-byte v5 header and returns the header digest +// and consensus branch ID. +func readAndHashHeader(s *bytestring.String) ([32]byte, uint32, error) { + // header(4) + nVersionGroupId(4) + consensusBranchId(4) + nLockTime(4) + nExpiryHeight(4) + var headerBytes []byte + if !s.ReadBytes(&headerBytes, 20) { + return [32]byte{}, 0, errors.New("could not read header fields") + } + consensusBranchID := binary.LittleEndian.Uint32(headerBytes[8:12]) + digest := blake2b.Sum256Personalized(personalization("ZTxIdHeadersHash"), headerBytes) + return digest, consensusBranchID, nil +} + +// readAndHashTransparent parses transparent inputs/outputs and returns +// the transparent digest. +func readAndHashTransparent(s *bytestring.String) ([32]byte, error) { + var txInCount int + if !s.ReadCompactSize(&txInCount) { + return [32]byte{}, errors.New("could not read tx_in_count") + } + + prevoutsHasher := blake2b.New256Personalized(personalization("ZTxIdPrevoutHash")) + sequenceHasher := blake2b.New256Personalized(personalization("ZTxIdSequencHash")) + + for i := 0; i < txInCount; i++ { + // prevout: PrevTxHash(32) + PrevTxOutIndex(4) + var prevout []byte + if !s.ReadBytes(&prevout, 36) { + return [32]byte{}, fmt.Errorf("could not read input %d prevout", i) + } + prevoutsHasher.Write(prevout) + + if !s.SkipCompactLengthPrefixed() { + return [32]byte{}, fmt.Errorf("could not skip input %d scriptSig", i) + } + + var seq []byte + if !s.ReadBytes(&seq, 4) { + return [32]byte{}, fmt.Errorf("could not read input %d sequence", i) + } + sequenceHasher.Write(seq) + } + + var txOutCount int + if !s.ReadCompactSize(&txOutCount) { + return [32]byte{}, errors.New("could not read tx_out_count") + } + + outputsHasher := blake2b.New256Personalized(personalization("ZTxIdOutputsHash")) + + for i := 0; i < txOutCount; i++ { + var value []byte + if !s.ReadBytes(&value, 8) { + return [32]byte{}, fmt.Errorf("could not read output %d value", i) + } + outputsHasher.Write(value) + + var scriptLen int + if !s.ReadCompactSize(&scriptLen) { + return [32]byte{}, fmt.Errorf("could not read output %d script length", i) + } + writeCompactSize(outputsHasher, scriptLen) + + var script []byte + if !s.ReadBytes(&script, scriptLen) { + return [32]byte{}, fmt.Errorf("could not read output %d script", i) + } + outputsHasher.Write(script) + } + + if txInCount == 0 && txOutCount == 0 { + return blake2b.Sum256Personalized(personalization("ZTxIdTranspaHash"), nil), nil + } + + h := blake2b.New256Personalized(personalization("ZTxIdTranspaHash")) + prevoutsDigest := sumDigest(prevoutsHasher) + sequenceDigest := sumDigest(sequenceHasher) + outputsDigest := sumDigest(outputsHasher) + h.Write(prevoutsDigest[:]) + h.Write(sequenceDigest[:]) + h.Write(outputsDigest[:]) + return sumDigest(h), nil +} + +// readAndHashSapling parses the sapling spend/output descriptions plus +// valueBalance and anchor, and returns the sapling digest along with counts +// needed by skipSaplingProofsAndSigs. +func readAndHashSapling(s *bytestring.String) (digest [32]byte, spendCount, outputCount int, err error) { + if !s.ReadCompactSize(&spendCount) { + err = errors.New("could not read spend count") + return + } + + // Parse spend descriptions: cv(32) + nullifier(32) + rk(32) per spend. + // Hash nullifiers to compact digest incrementally. + // Buffer cv and rk for noncompact digest (need shared anchor later). + var compactHasher hash.Hash + var spendCvRk []byte + + if spendCount > 0 { + compactHasher = blake2b.New256Personalized(personalization("ZTxIdSSpendCHash")) + spendCvRk = make([]byte, 0, spendCount*64) + } + + for i := 0; i < spendCount; i++ { + var cv, nullifier, rk []byte + if !s.ReadBytes(&cv, 32) { + err = fmt.Errorf("could not read spend %d cv", i) + return + } + if !s.ReadBytes(&nullifier, 32) { + err = fmt.Errorf("could not read spend %d nullifier", i) + return + } + if !s.ReadBytes(&rk, 32) { + err = fmt.Errorf("could not read spend %d rk", i) + return + } + compactHasher.Write(nullifier) + spendCvRk = append(spendCvRk, cv...) + spendCvRk = append(spendCvRk, rk...) + } + + if !s.ReadCompactSize(&outputCount) { + err = errors.New("could not read output count") + return + } + + // Parse output descriptions incrementally: + // cv(32) + cmu(32) + ephemeralKey(32) + encCiphertext(580) + outCiphertext(80) + var outCompactHasher, outMemosHasher, outNoncompactHasher hash.Hash + + if outputCount > 0 { + outCompactHasher = blake2b.New256Personalized(personalization("ZTxIdSOutC__Hash")) + outMemosHasher = blake2b.New256Personalized(personalization("ZTxIdSOutM__Hash")) + outNoncompactHasher = blake2b.New256Personalized(personalization("ZTxIdSOutN__Hash")) + } + + for i := 0; i < outputCount; i++ { + var cv, cmu, ephemeralKey, encCiphertext, outCiphertext []byte + if !s.ReadBytes(&cv, 32) { + err = fmt.Errorf("could not read output %d cv", i) + return + } + if !s.ReadBytes(&cmu, 32) { + err = fmt.Errorf("could not read output %d cmu", i) + return + } + if !s.ReadBytes(&ephemeralKey, 32) { + err = fmt.Errorf("could not read output %d ephemeralKey", i) + return + } + if !s.ReadBytes(&encCiphertext, 580) { + err = fmt.Errorf("could not read output %d encCiphertext", i) + return + } + if !s.ReadBytes(&outCiphertext, 80) { + err = fmt.Errorf("could not read output %d outCiphertext", i) + return + } + + outCompactHasher.Write(cmu) + outCompactHasher.Write(ephemeralKey) + outCompactHasher.Write(encCiphertext[:52]) + + outMemosHasher.Write(encCiphertext[52:564]) + + outNoncompactHasher.Write(cv) + outNoncompactHasher.Write(encCiphertext[564:]) + outNoncompactHasher.Write(outCiphertext) + } + + // Read valueBalance and anchor (after all spend/output descriptions). + var valueBalance, anchor []byte + if spendCount+outputCount > 0 { + if !s.ReadBytes(&valueBalance, 8) { + err = errors.New("could not read valueBalanceSapling") + return + } + } + if spendCount > 0 { + if !s.ReadBytes(&anchor, 32) { + err = errors.New("could not read anchorSapling") + return + } + } + + // Empty sapling section. + if spendCount+outputCount == 0 { + digest = blake2b.Sum256Personalized(personalization("ZTxIdSaplingHash"), nil) + return + } + + // Compute spends sub-digest. + var spendsDigest [32]byte + if spendCount == 0 { + spendsDigest = blake2b.Sum256Personalized(personalization("ZTxIdSSpendsHash"), nil) + } else { + compactDigest := sumDigest(compactHasher) + + noncompactHasher := blake2b.New256Personalized(personalization("ZTxIdSSpendNHash")) + for i := 0; i < spendCount; i++ { + off := i * 64 + noncompactHasher.Write(spendCvRk[off : off+32]) // cv + noncompactHasher.Write(anchor) // shared anchor + noncompactHasher.Write(spendCvRk[off+32 : off+64]) // rk + } + noncompactDigest := sumDigest(noncompactHasher) + + h := blake2b.New256Personalized(personalization("ZTxIdSSpendsHash")) + h.Write(compactDigest[:]) + h.Write(noncompactDigest[:]) + spendsDigest = sumDigest(h) + } + + // Compute outputs sub-digest. + var outputsDigest [32]byte + if outputCount == 0 { + outputsDigest = blake2b.Sum256Personalized(personalization("ZTxIdSOutputHash"), nil) + } else { + compactDigest := sumDigest(outCompactHasher) + memosDigest := sumDigest(outMemosHasher) + noncompactDigest := sumDigest(outNoncompactHasher) + + h := blake2b.New256Personalized(personalization("ZTxIdSOutputHash")) + h.Write(compactDigest[:]) + h.Write(memosDigest[:]) + h.Write(noncompactDigest[:]) + outputsDigest = sumDigest(h) + } + + // Combine into sapling digest. + saplingHasher := blake2b.New256Personalized(personalization("ZTxIdSaplingHash")) + saplingHasher.Write(spendsDigest[:]) + saplingHasher.Write(outputsDigest[:]) + saplingHasher.Write(valueBalance) + digest = sumDigest(saplingHasher) + return +} + +func skipSaplingProofsAndSigs(s *bytestring.String, spendCount, outputCount int) error { + if !s.Skip(192 * spendCount) { + return errors.New("could not skip vSpendProofsSapling") + } + if !s.Skip(64 * spendCount) { + return errors.New("could not skip vSpendAuthSigsSapling") + } + if !s.Skip(192 * outputCount) { + return errors.New("could not skip vOutputProofsSapling") + } + if spendCount+outputCount > 0 && !s.Skip(64) { + return errors.New("could not skip bindingSigSapling") + } + return nil +} + +// readAndHashOrchard parses orchard actions and metadata, returning the +// orchard digest. +func readAndHashOrchard(s *bytestring.String) ([32]byte, error) { + var actionsCount int + if !s.ReadCompactSize(&actionsCount) { + return [32]byte{}, errors.New("could not read orchard actions count") + } + + if actionsCount == 0 { + return blake2b.Sum256Personalized(personalization("ZTxIdOrchardHash"), nil), nil + } + + compactHasher := blake2b.New256Personalized(personalization("ZTxIdOrcActCHash")) + memosHasher := blake2b.New256Personalized(personalization("ZTxIdOrcActMHash")) + noncompactHasher := blake2b.New256Personalized(personalization("ZTxIdOrcActNHash")) + + for i := 0; i < actionsCount; i++ { + // cv(32) + nullifier(32) + rk(32) + cmx(32) + ephemeralKey(32) + encCiphertext(580) + outCiphertext(80) + var cv, nullifier, rk, cmx, ephemeralKey, encCiphertext, outCiphertext []byte + if !s.ReadBytes(&cv, 32) { + return [32]byte{}, fmt.Errorf("could not read action %d cv", i) + } + if !s.ReadBytes(&nullifier, 32) { + return [32]byte{}, fmt.Errorf("could not read action %d nullifier", i) + } + if !s.ReadBytes(&rk, 32) { + return [32]byte{}, fmt.Errorf("could not read action %d rk", i) + } + if !s.ReadBytes(&cmx, 32) { + return [32]byte{}, fmt.Errorf("could not read action %d cmx", i) + } + if !s.ReadBytes(&ephemeralKey, 32) { + return [32]byte{}, fmt.Errorf("could not read action %d ephemeralKey", i) + } + if !s.ReadBytes(&encCiphertext, 580) { + return [32]byte{}, fmt.Errorf("could not read action %d encCiphertext", i) + } + if !s.ReadBytes(&outCiphertext, 80) { + return [32]byte{}, fmt.Errorf("could not read action %d outCiphertext", i) + } + + compactHasher.Write(nullifier) + compactHasher.Write(cmx) + compactHasher.Write(ephemeralKey) + compactHasher.Write(encCiphertext[:52]) + + memosHasher.Write(encCiphertext[52:564]) + + noncompactHasher.Write(cv) + noncompactHasher.Write(rk) + noncompactHasher.Write(encCiphertext[564:]) + noncompactHasher.Write(outCiphertext) + } + + // flags(1) + valueBalance(8) + anchor(32) + var flags, valueBalance, anchor []byte + if !s.ReadBytes(&flags, 1) { + return [32]byte{}, errors.New("could not read flagsOrchard") + } + if !s.ReadBytes(&valueBalance, 8) { + return [32]byte{}, errors.New("could not read valueBalanceOrchard") + } + if !s.ReadBytes(&anchor, 32) { + return [32]byte{}, errors.New("could not read anchorOrchard") + } + + compactDigest := sumDigest(compactHasher) + memosDigest := sumDigest(memosHasher) + noncompactDigest := sumDigest(noncompactHasher) + + h := blake2b.New256Personalized(personalization("ZTxIdOrchardHash")) + h.Write(compactDigest[:]) + h.Write(memosDigest[:]) + h.Write(noncompactDigest[:]) + h.Write(flags) + h.Write(valueBalance) + h.Write(anchor) + return sumDigest(h), nil +} diff --git a/parser/zip244_test.go b/parser/zip244_test.go new file mode 100644 index 00000000..3fd1766c --- /dev/null +++ b/parser/zip244_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2025 The Zcash developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php . + +package parser + +import ( + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/zcash/lightwalletd/hash32" +) + +func TestComputeV5TxID(t *testing.T) { + s, err := os.ReadFile("../testdata/tx_v5.json") + if err != nil { + t.Fatal(err) + } + + var testdata []json.RawMessage + if err := json.Unmarshal(s, &testdata); err != nil { + t.Fatal(err) + } + if len(testdata) < 3 { + t.Fatal("tx_v5.json has too few lines") + } + testdata = testdata[2:] + + for i, onetx := range testdata { + var td TxTestData + if err := json.Unmarshal(onetx, &td); err != nil { + t.Fatal(err) + } + + rawTxData, err := hex.DecodeString(td.Tx) + if err != nil { + t.Fatalf("test %d: bad hex: %v", i, err) + } + + txid, err := computeV5TxID(rawTxData) + if err != nil { + t.Fatalf("test %d (txid %s): computeV5TxID: %v", i, td.Txid, err) + } + + // Test vector txids are in big-endian display format. + got := hash32.Encode(hash32.Reverse(txid)) + if got != td.Txid { + t.Fatalf("test %d txid mismatch:\n got %s\n want %s", i, got, td.Txid) + } + } +} + +func TestComputeV5TxIDViaParseFromSlice(t *testing.T) { + s, err := os.ReadFile("../testdata/tx_v5.json") + if err != nil { + t.Fatal(err) + } + + var testdata []json.RawMessage + if err := json.Unmarshal(s, &testdata); err != nil { + t.Fatal(err) + } + testdata = testdata[2:] + + for i, onetx := range testdata { + var td TxTestData + if err := json.Unmarshal(onetx, &td); err != nil { + t.Fatal(err) + } + + rawTxData, _ := hex.DecodeString(td.Tx) + tx := NewTransaction() + rest, err := tx.ParseFromSlice(rawTxData) + if err != nil { + t.Fatalf("test %d: ParseFromSlice: %v", i, err) + } + if len(rest) != 0 { + t.Fatalf("test %d: %d bytes remaining", i, len(rest)) + } + + got := tx.GetDisplayHashString() + if got != td.Txid { + t.Fatalf("test %d txid mismatch:\n got %s\n want %s", i, got, td.Txid) + } + } +}