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 <charon@lethean.io>
This commit is contained in:
Claude 2026-02-21 19:09:34 +00:00
parent dd04cc9dee
commit 8335b11a85
No known key found for this signature in database
GPG key ID: AF404715446AEB41
8 changed files with 856 additions and 34 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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<signature>
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<BGE_proof_s>
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<signature> (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<BGE_proof_s>
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
}

View file

@ -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",

157
wire/transaction_v2_test.go Normal file
View file

@ -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")
}
}