From 8335b11a85e1805aba3d271982c62ea791d0ab91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 19:09:34 +0000 Subject: [PATCH] feat(wire): v2+ transaction serialisation with real testnet verification Fix three bugs in the v2+ wire format and add complete variant tag handlers for Zarcanum proof and signature structures. Verified byte-identical round-trip against a real post-HF4 coinbase transaction from testnet block 101 (1323 bytes, tx hash 543bc3c2...3b61e0). Bugs fixed: - V2 suffix order was attachment+proofs, corrected to attachment+signatures+proofs - TransactionHash was hashing full blob, corrected to prefix-only (matching C++) - tagSignedParts was reading 4 fixed bytes, corrected to two varints New: TxInputZC type, SignaturesRaw field, tagZarcanumTxDataV1 handler, proof tags 46-48, signature tags 42-45, crypto blob readers for BPP/BPPE/ BGE/CLSAG GGX/GGXXG/aggregation proof/double Schnorr structures. Co-Authored-By: Charon --- docs/history.md | 47 ++- .../2026-02-21-v2-tx-serialisation-design.md | 116 ++++++ .../2026-02-21-v2-tx-serialisation-plan.md | 169 +++++++++ types/transaction.go | 22 ++ wire/hash.go | 14 +- wire/transaction.go | 333 +++++++++++++++++- wire/transaction_test.go | 32 +- wire/transaction_v2_test.go | 157 +++++++++ 8 files changed, 856 insertions(+), 34 deletions(-) create mode 100644 docs/plans/2026-02-21-v2-tx-serialisation-design.md create mode 100644 docs/plans/2026-02-21-v2-tx-serialisation-plan.md create mode 100644 wire/transaction_v2_test.go diff --git a/docs/history.md b/docs/history.md index 0d6dcc5..44118a5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -526,11 +526,52 @@ refresh. --- +## V2+ Transaction Serialisation + +Completed and verified v2+ (Zarcanum/HF4+) transaction serialisation. The code +is tested against a real post-HF4 coinbase transaction from testnet block 101 +(tx hash `543bc3c29e9f4c5d1fc566be03fb4da1f2ce2d70d4312fdcc3e4eed7ca3b61e0`, +1323 bytes). Round-trip encoding produces byte-identical output and the +prefix hash matches the known tx hash. + +### Bugs fixed + +- **V2 suffix field order** — was `attachment + proofs`, corrected to + `attachment + signatures + proofs` (matching C++ serialisation order). +- **`TransactionHash`** — was hashing the full transaction blob; corrected to + delegate to `TransactionPrefixHash` for all versions (matching C++ + `get_transaction_hash`). +- **`tagSignedParts` (17)** — was reading 4 fixed bytes (uint32 LE); corrected + to read two varints (`n_outs` + `n_extras`), matching C++ `VARINT_FIELD`. + +### New features + +- **`TxInputZC`** — Zarcanum confidential input (tag 0x25). Wire format: + `key_offsets + k_image + etc_details` (no amount field). +- **`SignaturesRaw`** — new Transaction field for v2+ raw signature bytes. +- **`tagZarcanumTxDataV1` (39)** — extra variant handler (varint fee). +- **Proof tag handlers (46-48)** — `zc_asset_surjection_proof` (vector of + BGE_proof_s), `zc_outs_range_proof` (BPP + aggregation proof), + `zc_balance_proof` (double Schnorr, 96 bytes). +- **Signature tag handlers (42-45)** — `NLSAG_sig`, `ZC_sig`, `void_sig`, + `zarcanum_sig` with full crypto blob readers for CLSAG GGX/GGXXG, BPP/BPPE. + +### Files + +| File | Action | +|------|--------| +| `types/transaction.go` | Added `TxInputZC`, `SignaturesRaw` field | +| `wire/transaction.go` | Fixed suffix, added InputTypeZC, 10 tag handlers | +| `wire/transaction_v2_test.go` | New -- v2 round-trip, hash, fields, prefix tests | +| `wire/hash.go` | Fixed `TransactionHash` to delegate to prefix hash | + +--- + ## Known Limitations -**v2+ transaction serialisation is stubbed.** The v0/v1 wire format is complete -and verified. The v2+ (Zarcanum) code paths compile but are untested -- they -will be validated in Phase 2 when post-HF4 transactions appear on-chain. +**V2+ spending transactions untested.** The v2 coinbase serialisation is +verified, but `TxInputZC` and signature tags 42-45 remain untested with real +spending transaction data (no spending txs on testnet yet). **BPP range proof verification tested with real data.** The `cn_bpp_verify` bridge function (Bulletproofs++, 1 delta, `bpp_crypto_trait_ZC_out`) is verified diff --git a/docs/plans/2026-02-21-v2-tx-serialisation-design.md b/docs/plans/2026-02-21-v2-tx-serialisation-design.md new file mode 100644 index 0000000..bb14f80 --- /dev/null +++ b/docs/plans/2026-02-21-v2-tx-serialisation-design.md @@ -0,0 +1,116 @@ +# V2+ Transaction Serialisation Design + +## Context + +Phase 1 implemented wire-format serialisation for v0/v1 (pre-HF4) transactions. +The v2+ code paths (`encodePrefixV2`, `decodePrefixV2`, `encodeSuffixV2`, +`decodeSuffixV2`, `encodeOutputsV2`, `decodeOutputsV2`) were written +speculatively but **never tested against real chain data**. + +The testnet has HF4 active at height 100 (currently ~511). Every coinbase from +block 101 onwards is a version-2 transaction with Zarcanum outputs and proofs. + +## Problem + +Testing against real testnet data reveals three bugs and several missing handlers: + +### Bugs + +1. **V2 suffix order wrong**: Code writes `attachment + proofs`, but C++ wire + format is `attachment + signatures + proofs`. The signatures variant vector + is completely absent from `encodeSuffixV2`/`decodeSuffixV2`. + +2. **`tagSignedParts` (17) reads 4 fixed bytes**: The C++ `signed_parts` struct + uses `VARINT_FIELD` for both `n_outs` and `n_extras` — two varints, not a + uint32 LE. This corrupts the stream when parsing real spending transactions. + +3. **`tagZarcanumTxDataV1` (39) declared but unhandled**: The constant exists + but `readVariantElementData` has no case for it. V2 transactions carry the + fee in this extra variant. Wire format: single varint (fee). + +### Missing Input Type + +`InputTypeZC` (tag 0x25 / 37) — `txin_zc_input` — is the standard input for +Zarcanum spending transactions. Wire format: + +``` +key_offsets — variant vector (txout_ref_v elements) +k_image — 32 bytes +etc_details — variant vector (txin_etc_details_v elements) +``` + +Note: unlike `txin_to_key`, there is **no amount field**. + +### Missing Variant Tag Handlers + +The `readVariantElementData` function needs handlers for proof and signature +tags so `decodeRawVariantVector` can determine element boundaries: + +**Proof tags:** +- 46 (`zc_asset_surjection_proof`): vector of BGE_proof_s +- 47 (`zc_outs_range_proof`): bpp_serialized + aggregation_proof_serialized +- 48 (`zc_balance_proof`): generic_double_schnorr_sig_s (96 bytes fixed) + +**Signature tags:** +- 42 (`NLSAG_sig`): vector of 64-byte signatures +- 43 (`ZC_sig`): 2 public_keys (64 bytes) + CLSAG_GGX_serialized +- 44 (`void_sig`): 0 bytes (empty struct) +- 45 (`zarcanum_sig`): 10 scalars (320 bytes) + bppe_serialized + public_key (32) + CLSAG_GGXXG_serialized + +## Crypto Blob Wire Layouts + +All crypto serialised structs use vectors of 32-byte scalars/points with varint +length prefixes, plus fixed-size blobs: + +``` +BGE_proof_s: A(32) + B(32) + vec(Pk) + vec(f) + y(32) + z(32) +bpp_signature: vec(L) + vec(R) + A0(32)+A(32)+B(32)+r(32)+s(32)+delta(32) +bppe_signature: vec(L) + vec(R) + A0(32)+A(32)+B(32)+r(32)+s(32)+d1(32)+d2(32) +aggregation_proof: vec(commitments) + vec(y0s) + vec(y1s) + c(32) +double_schnorr_sig: c(32) + y0(32) + y1(32) +CLSAG_GGX: c(32) + vec(r_g) + vec(r_x) + K1(32) + K2(32) +CLSAG_GGXXG: c(32) + vec(r_g) + vec(r_x) + K1(32)+K2(32)+K3(32)+K4(32) +``` + +Where `vec(X)` = `varint(count) + 32*count bytes`. + +## Approach + +Full variant-level parsing — add handlers for every v2+ variant tag in +`readVariantElementData`, following the existing pattern. Each handler reads +field-by-field based on the C++ serialisation order and returns raw bytes. + +The alternative (reading suffix as a raw blob) doesn't work because we need to +separate the three vectors (attachment, signatures, proofs) into distinct fields. + +## Type Changes + +Add `TxInputZC` struct to `types/transaction.go`: +```go +type TxInputZC struct { + KeyOffsets []TxOutRef + KeyImage KeyImage + EtcDetails []byte +} +``` + +Add `SignaturesRaw []byte` field to `Transaction` for v2+ raw signatures. +V0/v1 uses the structured `Signatures [][]Signature` field; v2+ uses +`SignaturesRaw` (raw variant vector bytes). + +## File Changes + +| File | Action | +|------|--------| +| `types/transaction.go` | Add `TxInputZC`, `SignaturesRaw` field | +| `wire/transaction.go` | Fix suffix, add InputTypeZC, add tag handlers, fix signed_parts | +| `wire/transaction_v2_test.go` | New — v2 round-trip tests with real testnet data | + +## Verification + +1. Fetch block 101 coinbase blob from testnet via RPC +2. Decode blob to Transaction struct +3. Re-encode Transaction to bytes +4. Assert byte-for-byte identity with original blob +5. Hash prefix with Keccak-256, compare to known tx hash +6. `go test -race ./...` — all tests pass diff --git a/docs/plans/2026-02-21-v2-tx-serialisation-plan.md b/docs/plans/2026-02-21-v2-tx-serialisation-plan.md new file mode 100644 index 0000000..9fedec5 --- /dev/null +++ b/docs/plans/2026-02-21-v2-tx-serialisation-plan.md @@ -0,0 +1,169 @@ +# V2+ Transaction Serialisation — Implementation Plan + +Design: `docs/plans/2026-02-21-v2-tx-serialisation-design.md` + +## Step 1: Fix types + +**File:** `types/transaction.go` + +- Add `TxInputZC` struct with `KeyOffsets []TxOutRef`, `KeyImage KeyImage`, `EtcDetails []byte` +- Add `InputType()` method returning `InputTypeZC` +- Add `SignaturesRaw []byte` field to `Transaction` struct +- Update doc comment on Transaction to mention SignaturesRaw for v2+ + +**Tests:** Compile-only — new types have no logic to test independently. + +## Step 2: Fix v2 suffix + +**File:** `wire/transaction.go` + +Fix `encodeSuffixV2`: +```go +func encodeSuffixV2(enc *Encoder, tx *types.Transaction) { + enc.WriteBytes(tx.Attachment) + enc.WriteBytes(tx.SignaturesRaw) + enc.WriteBytes(tx.Proofs) +} +``` + +Fix `decodeSuffixV2`: +```go +func decodeSuffixV2(dec *Decoder, tx *types.Transaction) { + tx.Attachment = decodeRawVariantVector(dec) + tx.SignaturesRaw = decodeRawVariantVector(dec) + tx.Proofs = decodeRawVariantVector(dec) +} +``` + +## Step 3: Add InputTypeZC handling + +**File:** `wire/transaction.go` + +In `encodeInputs`, add case for `types.TxInputZC`: +```go +case types.TxInputZC: + encodeKeyOffsets(enc, v.KeyOffsets) + enc.WriteBlob32((*[32]byte)(&v.KeyImage)) + enc.WriteBytes(v.EtcDetails) +``` + +In `decodeInputs`, add case for `types.InputTypeZC`: +```go +case types.InputTypeZC: + var in types.TxInputZC + in.KeyOffsets = decodeKeyOffsets(dec) + dec.ReadBlob32((*[32]byte)(&in.KeyImage)) + in.EtcDetails = decodeRawVariantVector(dec) + vin = append(vin, in) +``` + +## Step 4: Fix tagSignedParts handler + +**File:** `wire/transaction.go` + +Change `tagSignedParts` from reading 4 fixed bytes to reading two varints: +```go +case tagSignedParts: + return readSignedParts(dec) +``` + +New function: +```go +func readSignedParts(dec *Decoder) []byte { + v1 := dec.ReadVarint() // n_outs + if dec.err != nil { return nil } + raw := EncodeVarint(v1) + v2 := dec.ReadVarint() // n_extras + if dec.err != nil { return nil } + raw = append(raw, EncodeVarint(v2)...) + return raw +} +``` + +## Step 5: Add tagZarcanumTxDataV1 handler + +**File:** `wire/transaction.go` + +Add case in `readVariantElementData`: +```go +case tagZarcanumTxDataV1: + v := dec.ReadVarint() // fee + if dec.err != nil { return nil } + return EncodeVarint(v) +``` + +## Step 6: Add proof tag handlers (46, 47, 48) + +**File:** `wire/transaction.go` + +New constants: +```go +const ( + tagZCAssetSurjectionProof = 46 + tagZCOutsRangeProof = 47 + tagZCBalanceProof = 48 +) +``` + +New reader functions: +- `readBGEProof(dec)` — A(32)+B(32)+vec(32)+vec(32)+y(32)+z(32) +- `readBPPSerialized(dec)` — vec(32)+vec(32)+192 bytes +- `readAggregationProof(dec)` — vec(32)+vec(32)+vec(32)+32 bytes + +Tag handlers: +- 46: varint(count) + count * readBGEProof +- 47: readBPPSerialized + readAggregationProof +- 48: 96 fixed bytes + +## Step 7: Add signature tag handlers (42, 43, 44, 45) + +**File:** `wire/transaction.go` + +New constants: +```go +const ( + tagNLSAGSig = 42 + tagZCSig = 43 + tagVoidSig = 44 + tagZarcanumSig = 45 +) +``` + +New reader functions: +- `readCLSAG_GGX(dec)` — 32+vec(32)+vec(32)+64 bytes +- `readCLSAG_GGXXG(dec)` — 32+vec(32)+vec(32)+128 bytes +- `readBPPESerialized(dec)` — vec(32)+vec(32)+224 bytes + +Tag handlers: +- 42: readVariantVectorFixed(dec, 64) +- 43: 64 bytes + readCLSAG_GGX +- 44: 0 bytes (empty) +- 45: 320 bytes + readBPPESerialized + 32 bytes + readCLSAG_GGXXG + +## Step 8: Test with real testnet data + +**New file:** `wire/transaction_v2_test.go` + +1. `TestV2CoinbaseRoundTrip` — fetch block 101 coinbase blob from testnet + (or embed the hex literal), decode to Transaction, re-encode, assert + byte-for-byte identity. + +2. `TestV2CoinbaseTxHash` — decode prefix, hash with Keccak-256, compare + to known tx hash `543bc3c29e9f4c5d1fc566be03fb4da1f2ce2d70d4312fdcc3e4eed7ca3b61e0`. + +3. `TestV2SuffixFieldCount` — decode a v2 coinbase, verify that + `Attachment` is a zero-count vector, `SignaturesRaw` is a zero-count + vector, and `Proofs` is a 3-element vector. + +## Step 9: Verify + +```bash +go test -race ./... +go vet ./... +``` + +All tests pass, no race conditions, no vet warnings. + +## Step 10: Update docs + +Update `docs/history.md` with v2+ serialisation completion. diff --git a/types/transaction.go b/types/transaction.go index bfe9982..b7d4ba8 100644 --- a/types/transaction.go +++ b/types/transaction.go @@ -83,6 +83,11 @@ type Transaction struct { // Each element corresponds to one input; inner slice is the ring. Signatures [][]Signature + // SignaturesRaw holds the serialised variant vector of v2+ signatures + // (NLSAG_sig, ZC_sig, void_sig, zarcanum_sig). Stored as raw wire bytes. + // V0/v1 uses the structured Signatures field; v2+ uses this field. + SignaturesRaw []byte + // Attachment holds the serialised variant vector of transaction attachments. // Stored as raw wire bytes. Attachment []byte @@ -147,6 +152,23 @@ type TxInputToKey struct { // InputType returns the wire variant tag for key inputs. func (t TxInputToKey) InputType() uint8 { return InputTypeToKey } +// TxInputZC is a Zarcanum confidential input (HF4+). Unlike TxInputToKey, +// there is no amount field — amounts are hidden by commitments. +// Wire order: key_offsets, k_image, etc_details. +type TxInputZC struct { + // KeyOffsets contains the output references forming the decoy ring. + KeyOffsets []TxOutRef + + // KeyImage prevents double-spending of this input. + KeyImage KeyImage + + // EtcDetails holds the serialised variant vector of input-level details. + EtcDetails []byte +} + +// InputType returns the wire variant tag for ZC inputs. +func (t TxInputZC) InputType() uint8 { return InputTypeZC } + // TxOutputBare is a transparent (pre-Zarcanum) transaction output. type TxOutputBare struct { // Amount in atomic units. diff --git a/wire/hash.go b/wire/hash.go index ab4f7f6..1782863 100644 --- a/wire/hash.go +++ b/wire/hash.go @@ -52,16 +52,14 @@ func BlockHash(b *types.Block) types.Hash { return types.Hash(Keccak256(prefixed)) } -// TransactionHash computes the full transaction hash (tx_id). +// TransactionHash computes the transaction hash (tx_id). // -// For v0/v1 transactions this is Keccak-256 of the full serialised transaction -// (prefix + signatures + attachment). For v2+ it delegates to the prefix hash -// (Zano computes v2+ hashes from prefix data only). +// In the C++ daemon, get_transaction_hash delegates to +// get_transaction_prefix_hash for all versions. The tx_id is always +// Keccak-256 of the serialised prefix (version + inputs + outputs + extra, +// in version-dependent field order). func TransactionHash(tx *types.Transaction) types.Hash { - var buf bytes.Buffer - enc := NewEncoder(&buf) - EncodeTransaction(enc, tx) - return types.Hash(Keccak256(buf.Bytes())) + return TransactionPrefixHash(tx) } // TransactionPrefixHash computes the hash of a transaction prefix. diff --git a/wire/transaction.go b/wire/transaction.go index adf9a58..b5d4d60 100644 --- a/wire/transaction.go +++ b/wire/transaction.go @@ -130,16 +130,17 @@ func decodeSuffixV1(dec *Decoder, tx *types.Transaction) { tx.Attachment = decodeRawVariantVector(dec) } -// --- v2+ suffix (attachment + signatures_raw + proofs) --- +// --- v2+ suffix (attachment + signatures + proofs) --- func encodeSuffixV2(enc *Encoder, tx *types.Transaction) { enc.WriteBytes(tx.Attachment) - // v2+ signatures and proofs are stored as raw wire bytes + enc.WriteBytes(tx.SignaturesRaw) enc.WriteBytes(tx.Proofs) } func decodeSuffixV2(dec *Decoder, tx *types.Transaction) { tx.Attachment = decodeRawVariantVector(dec) + tx.SignaturesRaw = decodeRawVariantVector(dec) tx.Proofs = decodeRawVariantVector(dec) } @@ -157,6 +158,10 @@ func encodeInputs(enc *Encoder, vin []types.TxInput) { encodeKeyOffsets(enc, v.KeyOffsets) enc.WriteBlob32((*[32]byte)(&v.KeyImage)) enc.WriteBytes(v.EtcDetails) + case types.TxInputZC: + encodeKeyOffsets(enc, v.KeyOffsets) + enc.WriteBlob32((*[32]byte)(&v.KeyImage)) + enc.WriteBytes(v.EtcDetails) } } } @@ -182,6 +187,12 @@ func decodeInputs(dec *Decoder) []types.TxInput { dec.ReadBlob32((*[32]byte)(&in.KeyImage)) in.EtcDetails = decodeRawVariantVector(dec) vin = append(vin, in) + case types.InputTypeZC: + var in types.TxInputZC + in.KeyOffsets = decodeKeyOffsets(dec) + dec.ReadBlob32((*[32]byte)(&in.KeyImage)) + in.EtcDetails = decodeRawVariantVector(dec) + vin = append(vin, in) default: dec.err = fmt.Errorf("wire: unsupported input tag 0x%02x", tag) return vin @@ -393,7 +404,7 @@ const ( tagUnlockTime = 14 // etc_tx_details_unlock_time — varint tagExpirationTime = 15 // etc_tx_details_expiration_time — varint tagTxDetailsFlags = 16 // etc_tx_details_flags — varint - tagSignedParts = 17 // signed_parts — uint32 LE + tagSignedParts = 17 // signed_parts — two varints (n_outs, n_extras) tagExtraAttachmentInfo = 18 // extra_attachment_info — string + hash + varint tagExtraUserData = 19 // extra_user_data — string tagExtraAliasEntryOld = 20 // extra_alias_entry_old — complex @@ -409,7 +420,18 @@ const ( tagTxPayer = 31 // tx_payer — 2 keys + optional flag tagTxReceiver = 32 // tx_receiver — 2 keys + optional flag tagExtraAliasEntry = 33 // extra_alias_entry — complex - tagZarcanumTxDataV1 = 39 // zarcanum_tx_data_v1 — complex + tagZarcanumTxDataV1 = 39 // zarcanum_tx_data_v1 — varint (fee) + + // Signature variant tags (signature_v). + tagNLSAGSig = 42 // NLSAG_sig — vector + tagZCSig = 43 // ZC_sig — 2 public_keys + CLSAG_GGX + tagVoidSig = 44 // void_sig — empty + tagZarcanumSig = 45 // zarcanum_sig — complex + + // Proof variant tags (proof_v). + tagZCAssetSurjectionProof = 46 // vector + tagZCOutsRangeProof = 47 // bpp_serialized + aggregation_proof + tagZCBalanceProof = 48 // generic_double_schnorr_sig_s (96 bytes) ) // readVariantElementData reads the data portion of a variant element (after the @@ -435,8 +457,10 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte { // Fixed-size integer fields case tagTxCryptoChecksum: // two uint32 LE return dec.ReadBytes(8) - case tagSignedParts, tagUint32: // uint32 LE + case tagUint32: // uint32 LE return dec.ReadBytes(4) + case tagSignedParts: // two varints: n_outs + n_extras + return readSignedParts(dec) case tagEtcTxFlags16, tagUint16: // uint16 LE return dec.ReadBytes(2) @@ -460,6 +484,32 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte { case tagTxServiceAttachment: return readTxServiceAttachment(dec) + // Zarcanum extra variant + case tagZarcanumTxDataV1: // fee (varint) + v := dec.ReadVarint() + if dec.err != nil { + return nil + } + return EncodeVarint(v) + + // Signature variants + case tagNLSAGSig: // vector (64 bytes each) + return readVariantVectorFixed(dec, 64) + case tagZCSig: // 2 public_keys + CLSAG_GGX_serialized + return readZCSig(dec) + case tagVoidSig: // empty struct + return []byte{} + case tagZarcanumSig: // complex: 10 scalars + bppe + public_key + CLSAG_GGXXG + return readZarcanumSig(dec) + + // Proof variants + case tagZCAssetSurjectionProof: // vector + return readZCAssetSurjectionProof(dec) + case tagZCOutsRangeProof: // bpp_serialized + aggregation_proof + return readZCOutsRangeProof(dec) + case tagZCBalanceProof: // generic_double_schnorr_sig_s (3 scalars = 96 bytes) + return dec.ReadBytes(96) + default: dec.err = fmt.Errorf("wire: unsupported variant tag 0x%02x (%d)", tag, tag) return nil @@ -605,3 +655,276 @@ func readTxServiceAttachment(dec *Decoder) []byte { raw = append(raw, b) return raw } + +// readSignedParts reads signed_parts (tag 17). +// Structure: n_outs (varint) + n_extras (varint). +func readSignedParts(dec *Decoder) []byte { + v1 := dec.ReadVarint() + if dec.err != nil { + return nil + } + raw := EncodeVarint(v1) + v2 := dec.ReadVarint() + if dec.err != nil { + return nil + } + raw = append(raw, EncodeVarint(v2)...) + return raw +} + +// --- crypto blob readers --- +// These read variable-length serialised crypto structures and return raw bytes. +// All vectors are varint(count) + 32*count bytes (scalars or points). + +// readVectorOfPoints reads a vector of 32-byte points/scalars. +// Returns raw bytes including the varint count prefix. +func readVectorOfPoints(dec *Decoder) []byte { + return readVariantVectorFixed(dec, 32) +} + +// readBPPSerialized reads a bpp_signature_serialized. +// Wire: vec(L) + vec(R) + A0(32) + A(32) + B(32) + r(32) + s(32) + delta(32). +func readBPPSerialized(dec *Decoder) []byte { + var raw []byte + // L: vector of points + v := readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // R: vector of points + v = readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // 6 fixed scalars: A0, A, B, r, s, delta + b := dec.ReadBytes(6 * 32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + return raw +} + +// readBPPESerialized reads a bppe_signature_serialized. +// Wire: vec(L) + vec(R) + A0(32) + A(32) + B(32) + r(32) + s(32) + delta_1(32) + delta_2(32). +func readBPPESerialized(dec *Decoder) []byte { + var raw []byte + v := readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + v = readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // 7 fixed scalars: A0, A, B, r, s, delta_1, delta_2 + b := dec.ReadBytes(7 * 32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + return raw +} + +// readBGEProof reads a BGE_proof_s. +// Wire: A(32) + B(32) + vec(Pk) + vec(f) + y(32) + z(32). +func readBGEProof(dec *Decoder) []byte { + var raw []byte + // A + B: 2 fixed points + b := dec.ReadBytes(64) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + // Pk: vector of points + v := readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // f: vector of scalars + v = readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // y + z: 2 fixed scalars + b = dec.ReadBytes(64) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + return raw +} + +// readAggregationProof reads a vector_UG_aggregation_proof_serialized. +// Wire: vec(commitments) + vec(y0s) + vec(y1s) + c(32). +func readAggregationProof(dec *Decoder) []byte { + var raw []byte + // 3 vectors of points/scalars + for range 3 { + v := readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + } + // c: 1 fixed scalar + b := dec.ReadBytes(32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + return raw +} + +// readCLSAG_GGX reads a CLSAG_GGX_signature_serialized. +// Wire: c(32) + vec(r_g) + vec(r_x) + K1(32) + K2(32). +func readCLSAG_GGX(dec *Decoder) []byte { + var raw []byte + // c: 1 fixed scalar + b := dec.ReadBytes(32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + // r_g, r_x: 2 vectors + for range 2 { + v := readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + } + // K1 + K2: 2 fixed points + b = dec.ReadBytes(64) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + return raw +} + +// readCLSAG_GGXXG reads a CLSAG_GGXXG_signature_serialized. +// Wire: c(32) + vec(r_g) + vec(r_x) + K1(32) + K2(32) + K3(32) + K4(32). +func readCLSAG_GGXXG(dec *Decoder) []byte { + var raw []byte + // c: 1 fixed scalar + b := dec.ReadBytes(32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + // r_g, r_x: 2 vectors + for range 2 { + v := readVectorOfPoints(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + } + // K1 + K2 + K3 + K4: 4 fixed points + b = dec.ReadBytes(128) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + return raw +} + +// --- signature variant readers --- + +// readZCSig reads ZC_sig (tag 43). +// Wire: pseudo_out_amount_commitment(32) + pseudo_out_blinded_asset_id(32) + CLSAG_GGX. +func readZCSig(dec *Decoder) []byte { + var raw []byte + // 2 public keys + b := dec.ReadBytes(64) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + // CLSAG_GGX_serialized + v := readCLSAG_GGX(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + return raw +} + +// readZarcanumSig reads zarcanum_sig (tag 45). +// Wire: d(32) + C(32) + C'(32) + E(32) + c(32) + y0(32) + y1(32) + y2(32) + y3(32) + y4(32) +// +// + bppe_serialized + pseudo_out_amount_commitment(32) + CLSAG_GGXXG. +func readZarcanumSig(dec *Decoder) []byte { + var raw []byte + // 10 fixed scalars/points + b := dec.ReadBytes(10 * 32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + // E_range_proof: bppe_signature_serialized + v := readBPPESerialized(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // pseudo_out_amount_commitment: 1 public key + b = dec.ReadBytes(32) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + // clsag_ggxxg: CLSAG_GGXXG_signature_serialized + v = readCLSAG_GGXXG(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + return raw +} + +// --- proof variant readers --- + +// readZCAssetSurjectionProof reads zc_asset_surjection_proof (tag 46). +// Wire: varint(count) + count * BGE_proof_s. +func readZCAssetSurjectionProof(dec *Decoder) []byte { + count := dec.ReadVarint() + if dec.err != nil { + return nil + } + raw := EncodeVarint(count) + for i := uint64(0); i < count; i++ { + b := readBGEProof(dec) + if dec.err != nil { + return nil + } + raw = append(raw, b...) + } + return raw +} + +// readZCOutsRangeProof reads zc_outs_range_proof (tag 47). +// Wire: bpp_signature_serialized + vector_UG_aggregation_proof_serialized. +func readZCOutsRangeProof(dec *Decoder) []byte { + var raw []byte + // bpp + v := readBPPSerialized(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + // aggregation_proof + v = readAggregationProof(dec) + if dec.err != nil { + return nil + } + raw = append(raw, v...) + return raw +} diff --git a/wire/transaction_test.go b/wire/transaction_test.go index 025844e..853631a 100644 --- a/wire/transaction_test.go +++ b/wire/transaction_test.go @@ -115,8 +115,9 @@ func TestFullTxRoundTrip_Good(t *testing.T) { } func TestTransactionHash_Good(t *testing.T) { - // TransactionHash for v0/v1 should equal TransactionPrefixHash - // (confirmed from C++ source: get_transaction_hash delegates to prefix hash). + // TransactionHash should equal TransactionPrefixHash for all versions. + // Confirmed from C++ source: get_transaction_hash delegates to + // get_transaction_prefix_hash for all transaction versions. tx := types.Transaction{ Version: 1, Vin: []types.TxInput{types.TxInputGenesis{Height: 0}}, @@ -130,25 +131,20 @@ func TestTransactionHash_Good(t *testing.T) { txHash := TransactionHash(&tx) prefixHash := TransactionPrefixHash(&tx) - // For v1, full tx hash should differ from prefix hash because - // TransactionHash encodes the full transaction (prefix + suffix). - // The prefix is a subset of the full encoding. + + // TransactionHash always delegates to TransactionPrefixHash. + if txHash != prefixHash { + t.Error("TransactionHash should equal TransactionPrefixHash") + } + + // Verify manual consistency. var prefBuf bytes.Buffer - enc1 := NewEncoder(&prefBuf) - EncodeTransactionPrefix(enc1, &tx) + enc := NewEncoder(&prefBuf) + EncodeTransactionPrefix(enc, &tx) - var fullBuf bytes.Buffer - enc2 := NewEncoder(&fullBuf) - EncodeTransaction(enc2, &tx) - - // Prefix hash is over prefix bytes only. if Keccak256(prefBuf.Bytes()) != [32]byte(prefixHash) { t.Error("TransactionPrefixHash does not match manual prefix encoding") } - // TransactionHash is over full encoding. - if Keccak256(fullBuf.Bytes()) != [32]byte(txHash) { - t.Error("TransactionHash does not match manual full encoding") - } } func TestTxInputToKeyRoundTrip_Good(t *testing.T) { @@ -245,8 +241,8 @@ func TestExtraVariantTags_Good(t *testing.T) { }, { name: "signed_parts", - // count=1, tag=17, uint32 LE - data: []byte{0x01, tagSignedParts, 0xFF, 0x00, 0x00, 0x00}, + // count=1, tag=17, two varints: n_outs=2, n_extras=1 + data: []byte{0x01, tagSignedParts, 0x02, 0x01}, }, { name: "etc_tx_flags16", diff --git a/wire/transaction_v2_test.go b/wire/transaction_v2_test.go new file mode 100644 index 0000000..48dfca3 --- /dev/null +++ b/wire/transaction_v2_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package wire + +import ( + "bytes" + "encoding/hex" + "testing" + + "forge.lthn.ai/core/go-blockchain/types" +) + +// Block 101 coinbase transaction from testnet (post-HF4, version 2). +// TX hash: 543bc3c29e9f4c5d1fc566be03fb4da1f2ce2d70d4312fdcc3e4eed7ca3b61e0 +// 1323 bytes. +const testnetCoinbaseV2Hex = "020100650616f8b44403a658f6a5cf66a20edeb5cda69913a6057d5bda321e39e4ce69c197c41316362e302e312e325b666131363038632d64697274795d15000b02e35a0b020e620e6f02260b219a40a8799bd76dc19a4db2d3eda28997bcb85341475b33db8dc928a81e1bdf0cd0bbe4f58a988e6ca84e1925b45be8ada18b17908187da9a78d9e58553834ced9654f46406bb69a1cd6d39a574dac9ed62fb4f144640a6680ca7de072fc974c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b62685e583b2120b1052c0026875db29b157807ad75cb803dddc54892e4c96238e36773ae1e311a3032698c47a29ba8b274a9f113960a09f09b88d03c72ece51ad8034b7542a9e7c186a5f95d37fff60a0f2cf5885d2279f6e3750feb276a4d8f65d1725ad255e47bfd65c5cc74c32d3eaafafc623bf483e858d42e8bf4ec7df064ada2e34934469cff6b6268ee18b9ce799d5705000000032e002f0747c3d2db565bf368c9879dd1b08899a5e69bc956bc0a89b0cb456a54e066aa8589c6a14ac578409af177d9605f03fe61dfae8067338a40ca551b34580350f694bf389b62ae684146d33632e1ca5bf51dc6fed884780443489ee8fd68a535ddcf5299c9ca2f400fd0a978b1c0ef55a03922549ef78b8b5cd268bd4df5eb32f1fc4d4543466be7e9b9ceb6051955a815427bd773ad9a5cc9fec49fae4c0ab46fc753d1c86e8c04cea4b903fc8c42f404bc8c85b25eb2468f8de75171e2bf802ae96daa934cdb68b0609eb8942b3691c2bf1a122068bcd18d8ef4dd08abb292978007599626cfd549b317eb92667ab1783a730cab6528326b581034a60b8f2582ff61d8ac1ec8395699a170dc9ca5ae2a87cd70388083475e38763aa327e31b27bd691ebd37e22b4eda43001e908fdb211de548ed942766139c8197201d9cc53c744a88ee20995c5f0b64d1bc48293f9c3b8799b2866e473915871df9d55ac065c58ebd51887763709d9e9992d317c12bd27ad933452d06b821b4ee282de78e7cc56102ad119d2f1fb9a79a709614cb24dcb83119bb5734a70c923c4c9586afe1e5bdfedc244f7568a3cd9de95d9d240fb01ee0e0695f6d2066d085457054e78dead4b47ed0fcf2de7c5a9802352f7ab65667c468e32329964723a2084ae0dd38ae9779ff7e0870d6b664275b8207e545f4f80537e7cc4c9f81098eefa42e5efc1c8586bc9e7a48652c1f1e1e28e86b6e7ea80079dd6db7c78d083235ede6ae359631dbcb543d3a6e8b8692fb013832acb9b93ee717c72e832fd78c3a2d5f4fcb380fe3e21825ce80bda2c30b499ae87ce5e9daedde3f8885bcd7463fccaa88d375007c8c94c0f95faa4d0c9e14811442ecb3dc78bd46dafba93e648324b7d0a36d0c02c61a3937e37ff91acd74bd2877bb47e236c36315744a8031339a02c41481d52b5157b6954e712187a14edcd6faf0e6adbb8ec66f2d9260bd238106e076d3098e02943b66ec6f0ff40b3a9e7c9d0f6064317ff7ecc86a4891cfef46e82b0d9f940b4915135e692c042cbcedfe3220c71d6a8935a83b675d93dc70e9035181183f0402527d9d6113e2238807fa6f4646def4d88675ed76862f40918e7249de1d338a053abaed2c406e2824c3df27e147de78a0080a0ca633d4710c068ee126071dbd09c81ec2b948b73997f38c89ca1cd49a2338b49380bbe32c9ee6035b5c2532800e3096fcda7a6274966a8e4a72980655e3683a0ea9317585777add458cb728b2850ef1b6204572195b08407da42101436bfb768f5f13a27fc3766a41cc7812d4f1006e8c64688a9ce9d8666f29abbff012bc86a4b985697cccde23ec916b012ff40a" + +func TestV2CoinbaseRoundTrip_Good(t *testing.T) { + blob, err := hex.DecodeString(testnetCoinbaseV2Hex) + if err != nil { + t.Fatalf("bad test hex: %v", err) + } + + // Decode + dec := NewDecoder(bytes.NewReader(blob)) + tx := DecodeTransaction(dec) + if dec.Err() != nil { + t.Fatalf("decode failed: %v", dec.Err()) + } + + // Re-encode + var buf bytes.Buffer + enc := NewEncoder(&buf) + EncodeTransaction(enc, &tx) + if enc.Err() != nil { + t.Fatalf("encode failed: %v", enc.Err()) + } + + // Byte-for-byte comparison + got := buf.Bytes() + if !bytes.Equal(got, blob) { + t.Fatalf("round-trip mismatch: got %d bytes, want %d bytes", len(got), len(blob)) + } +} + +func TestV2CoinbaseTxHash_Good(t *testing.T) { + blob, _ := hex.DecodeString(testnetCoinbaseV2Hex) + + // Decode full transaction to get the prefix boundary. + dec := NewDecoder(bytes.NewReader(blob)) + tx := DecodeTransaction(dec) + if dec.Err() != nil { + t.Fatalf("decode failed: %v", dec.Err()) + } + + // Hash the prefix + got := TransactionHash(&tx) + + wantHex := "543bc3c29e9f4c5d1fc566be03fb4da1f2ce2d70d4312fdcc3e4eed7ca3b61e0" + wantBytes, _ := hex.DecodeString(wantHex) + var want types.Hash + copy(want[:], wantBytes) + + if got != want { + t.Fatalf("tx hash mismatch:\n got %x\n want %x", got, want) + } +} + +func TestV2CoinbaseFields_Good(t *testing.T) { + blob, _ := hex.DecodeString(testnetCoinbaseV2Hex) + dec := NewDecoder(bytes.NewReader(blob)) + tx := DecodeTransaction(dec) + if dec.Err() != nil { + t.Fatalf("decode failed: %v", dec.Err()) + } + + // Version + if tx.Version != types.VersionPostHF4 { + t.Errorf("version: got %d, want %d", tx.Version, types.VersionPostHF4) + } + + // Inputs: should be 1 coinbase input + if len(tx.Vin) != 1 { + t.Fatalf("input count: got %d, want 1", len(tx.Vin)) + } + gen, ok := tx.Vin[0].(types.TxInputGenesis) + if !ok { + t.Fatalf("input[0] type: got %T, want TxInputGenesis", tx.Vin[0]) + } + if gen.Height != 101 { + t.Errorf("coinbase height: got %d, want 101", gen.Height) + } + + // Outputs: should be 2 Zarcanum outputs + if len(tx.Vout) != 2 { + t.Fatalf("output count: got %d, want 2", len(tx.Vout)) + } + for i, out := range tx.Vout { + if _, ok := out.(types.TxOutputZarcanum); !ok { + t.Errorf("output[%d] type: got %T, want TxOutputZarcanum", i, out) + } + } + + // Suffix: attachment=empty, signatures=empty, proofs=3 elements + if !bytes.Equal(tx.Attachment, EncodeVarint(0)) { + t.Errorf("attachment: expected empty vector, got %d bytes", len(tx.Attachment)) + } + if !bytes.Equal(tx.SignaturesRaw, EncodeVarint(0)) { + t.Errorf("signatures_raw: expected empty vector, got %d bytes", len(tx.SignaturesRaw)) + } + + // Proofs should start with varint(3) = 0x03 + if len(tx.Proofs) == 0 { + t.Fatal("proofs: expected non-empty") + } + proofsCount, n, err := DecodeVarint(tx.Proofs) + if err != nil { + t.Fatalf("proofs: failed to decode count varint: %v", err) + } + if proofsCount != 3 { + t.Errorf("proofs count: got %d, want 3", proofsCount) + } + + // First proof should be tag 0x2E (46) = zc_asset_surjection_proof + if n < len(tx.Proofs) && tx.Proofs[n] != 0x2E { + t.Errorf("proofs[0] tag: got 0x%02x, want 0x2E", tx.Proofs[n]) + } +} + +func TestV2PrefixDecode_Good(t *testing.T) { + blob, _ := hex.DecodeString(testnetCoinbaseV2Hex) + dec := NewDecoder(bytes.NewReader(blob)) + tx := DecodeTransactionPrefix(dec) + if dec.Err() != nil { + t.Fatalf("prefix decode failed: %v", dec.Err()) + } + + // Prefix re-encodes correctly + var buf bytes.Buffer + enc := NewEncoder(&buf) + EncodeTransactionPrefix(enc, &tx) + if enc.Err() != nil { + t.Fatalf("prefix encode failed: %v", enc.Err()) + } + + // The prefix should be a proper prefix of the full blob + prefix := buf.Bytes() + if len(prefix) >= len(blob) { + t.Fatalf("prefix (%d bytes) not shorter than full blob (%d bytes)", len(prefix), len(blob)) + } + if !bytes.Equal(prefix, blob[:len(prefix)]) { + t.Fatal("prefix bytes don't match start of full blob") + } +}