feat(consensus): V2 Zarcanum signature and proof verification

Add full V2 transaction verification pipeline: parse SignaturesRaw variant
vector into structured ZC signature data, verify CLSAG GGX ring signatures
per ZC input, verify BPP range proofs, and verify BGE asset surjection
proofs with correct ring construction (mul8 point arithmetic).

Fix three wire format bugs that caused V2 parsing failures:
- RefTypeGlobalIndex (tag 0x1A) uses 8-byte LE, not varint
- Raw uint64_t variant (tagUint64) uses 8-byte LE, not varint
- zarcanum_tx_data_v1 fee uses FIELD() → 8-byte LE, not VARINT_FIELD()

Add cn_point_sub to C++ bridge and Go wrapper for BGE ring construction.
Add GetZCRingOutputs to chain for fetching ZC ring member data.

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Claude 2026-02-22 00:06:10 +00:00
parent 3c76dd7070
commit a803fd1794
No known key found for this signature in database
GPG key ID: AF404715446AEB41
14 changed files with 811 additions and 20 deletions

View file

@ -8,6 +8,7 @@ package chain
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go-blockchain/consensus"
"forge.lthn.ai/core/go-blockchain/types" "forge.lthn.ai/core/go-blockchain/types"
) )
@ -41,3 +42,40 @@ func (c *Chain) GetRingOutputs(amount uint64, offsets []uint64) ([]types.PublicK
} }
return pubs, nil return pubs, nil
} }
// GetZCRingOutputs fetches ZC ring members (stealth address, amount commitment,
// blinded asset ID) for the given global output indices. This implements the
// consensus.ZCRingOutputsFn signature for post-HF4 CLSAG GGX verification.
//
// ZC outputs are indexed at amount=0 (confidential amounts).
func (c *Chain) GetZCRingOutputs(offsets []uint64) ([]consensus.ZCRingMember, error) {
members := make([]consensus.ZCRingMember, len(offsets))
for i, gidx := range offsets {
txHash, outNo, err := c.GetOutput(0, gidx)
if err != nil {
return nil, fmt.Errorf("ZC ring output %d (gidx=%d): %w", i, gidx, err)
}
tx, _, err := c.GetTransaction(txHash)
if err != nil {
return nil, fmt.Errorf("ZC ring output %d: tx %s: %w", i, txHash, err)
}
if int(outNo) >= len(tx.Vout) {
return nil, fmt.Errorf("ZC ring output %d: tx %s has %d outputs, want index %d",
i, txHash, len(tx.Vout), outNo)
}
switch out := tx.Vout[outNo].(type) {
case types.TxOutputZarcanum:
members[i] = consensus.ZCRingMember{
StealthAddress: [32]byte(out.StealthAddress),
AmountCommitment: [32]byte(out.AmountCommitment),
BlindedAssetID: [32]byte(out.BlindedAssetID),
}
default:
return nil, fmt.Errorf("ZC ring output %d: expected TxOutputZarcanum, got %T", i, out)
}
}
return members, nil
}

View file

@ -227,7 +227,7 @@ func (c *Chain) processBlockBlobs(blockBlob []byte, txBlobs [][]byte,
// Optionally verify signatures using the chain's output index. // Optionally verify signatures using the chain's output index.
if opts.VerifySignatures { if opts.VerifySignatures {
if err := consensus.VerifyTransactionSignatures(&tx, opts.Forks, height, c.GetRingOutputs); err != nil { if err := consensus.VerifyTransactionSignatures(&tx, opts.Forks, height, c.GetRingOutputs, c.GetZCRingOutputs); err != nil {
return fmt.Errorf("verify tx signatures %s: %w", txHash, err) return fmt.Errorf("verify tx signatures %s: %w", txHash, err)
} }
} }

338
consensus/v2sig.go Normal file
View file

@ -0,0 +1,338 @@
// 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 consensus
import (
"bytes"
"fmt"
"forge.lthn.ai/core/go-blockchain/types"
"forge.lthn.ai/core/go-blockchain/wire"
)
// zcSigData holds the parsed components of a ZC_sig variant element
// needed for CLSAG GGX verification.
type zcSigData struct {
pseudoOutCommitment [32]byte // premultiplied by 1/8 on chain
pseudoOutAssetID [32]byte // premultiplied by 1/8 on chain
clsagFlatSig []byte // flat: c(32) | r_g[N*32] | r_x[N*32] | K1(32) | K2(32)
ringSize int
}
// v2SigEntry is one parsed entry from the V2 signature variant vector.
type v2SigEntry struct {
tag uint8
zcSig *zcSigData // non-nil when tag == SigTypeZC
}
// parseV2Signatures parses the SignaturesRaw variant vector into a slice
// of v2SigEntry. The order matches the transaction inputs.
func parseV2Signatures(raw []byte) ([]v2SigEntry, error) {
if len(raw) == 0 {
return nil, nil
}
dec := wire.NewDecoder(bytes.NewReader(raw))
count := dec.ReadVarint()
if dec.Err() != nil {
return nil, fmt.Errorf("read sig count: %w", dec.Err())
}
entries := make([]v2SigEntry, 0, count)
for i := uint64(0); i < count; i++ {
tag := dec.ReadUint8()
if dec.Err() != nil {
return nil, fmt.Errorf("read sig tag %d: %w", i, dec.Err())
}
entry := v2SigEntry{tag: tag}
switch tag {
case types.SigTypeZC:
zc, err := parseZCSig(dec)
if err != nil {
return nil, fmt.Errorf("parse ZC_sig %d: %w", i, err)
}
entry.zcSig = zc
case types.SigTypeVoid:
// Empty struct — nothing to read.
case types.SigTypeNLSAG:
// Skip: varint(count) + count * 64-byte signatures.
n := dec.ReadVarint()
if n > 0 && dec.Err() == nil {
_ = dec.ReadBytes(int(n) * 64)
}
case types.SigTypeZarcanum:
// Skip: 10 scalars + bppe + public_key + CLSAG_GGXXG.
// Use skipZarcanumSig to advance past the data.
skipZarcanumSig(dec)
default:
return nil, fmt.Errorf("unsupported sig tag 0x%02x", tag)
}
if dec.Err() != nil {
return nil, fmt.Errorf("parse sig %d (tag 0x%02x): %w", i, tag, dec.Err())
}
entries = append(entries, entry)
}
return entries, nil
}
// parseZCSig parses a ZC_sig element (after the tag byte) from the decoder.
// Wire: pseudo_out_amount_commitment(32) + pseudo_out_blinded_asset_id(32) + CLSAG_GGX_serialized.
func parseZCSig(dec *wire.Decoder) (*zcSigData, error) {
var zc zcSigData
dec.ReadBlob32(&zc.pseudoOutCommitment)
dec.ReadBlob32(&zc.pseudoOutAssetID)
if dec.Err() != nil {
return nil, dec.Err()
}
// CLSAG_GGX_serialized wire format:
// c(32) + varint(N) + r_g[N*32] + varint(N) + r_x[N*32] + K1(32) + K2(32)
//
// C bridge expects flat:
// c(32) | r_g[N*32] | r_x[N*32] | K1(32) | K2(32)
var c [32]byte
dec.ReadBlob32(&c)
rgCount := dec.ReadVarint()
rgBytes := dec.ReadBytes(int(rgCount) * 32)
rxCount := dec.ReadVarint()
rxBytes := dec.ReadBytes(int(rxCount) * 32)
if dec.Err() != nil {
return nil, dec.Err()
}
if rgCount != rxCount {
return nil, fmt.Errorf("CLSAG r_g count %d != r_x count %d", rgCount, rxCount)
}
zc.ringSize = int(rgCount)
var k1, k2 [32]byte
dec.ReadBlob32(&k1)
dec.ReadBlob32(&k2)
if dec.Err() != nil {
return nil, dec.Err()
}
// Build flat sig for C bridge.
flat := make([]byte, 0, 32+len(rgBytes)+len(rxBytes)+64)
flat = append(flat, c[:]...)
flat = append(flat, rgBytes...)
flat = append(flat, rxBytes...)
flat = append(flat, k1[:]...)
flat = append(flat, k2[:]...)
zc.clsagFlatSig = flat
return &zc, nil
}
// skipZarcanumSig advances the decoder past a zarcanum_sig element.
// Wire: 10 scalars + bppe_serialized + public_key(32) + CLSAG_GGXXG.
func skipZarcanumSig(dec *wire.Decoder) {
// 10 fixed scalars/points (320 bytes).
_ = dec.ReadBytes(10 * 32)
// bppe_serialized: vec(L) + vec(R) + 7 scalars (224 bytes).
skipVecOfPoints(dec) // L
skipVecOfPoints(dec) // R
_ = dec.ReadBytes(7 * 32)
// pseudo_out_amount_commitment (32 bytes).
_ = dec.ReadBytes(32)
// CLSAG_GGXXG: c(32) + vec(r_g) + vec(r_x) + K1(32) + K2(32) + K3(32) + K4(32).
_ = dec.ReadBytes(32) // c
skipVecOfPoints(dec) // r_g
skipVecOfPoints(dec) // r_x
_ = dec.ReadBytes(128) // K1+K2+K3+K4
}
// skipVecOfPoints advances the decoder past a varint(count) + count*32 vector.
func skipVecOfPoints(dec *wire.Decoder) {
n := dec.ReadVarint()
if n > 0 && dec.Err() == nil {
_ = dec.ReadBytes(int(n) * 32)
}
}
// v2ProofData holds parsed proof components from the Proofs raw bytes.
type v2ProofData struct {
// bgeProofs contains one BGE proof blob per output (wire-serialised).
// Each blob can be passed directly to crypto.VerifyBGE.
bgeProofs [][]byte
// bppProofBytes is the bpp_signature blob (wire-serialised).
// Can be passed directly to crypto.VerifyBPP.
bppProofBytes []byte
// bppCommitments are the amount_commitments_for_rp_aggregation (E'_j).
// Premultiplied by 1/8 as stored on chain.
bppCommitments [][32]byte
// balanceProof is the generic_double_schnorr_sig (96 bytes: c, y0, y1).
balanceProof []byte
}
// parseV2Proofs parses the Proofs raw variant vector.
func parseV2Proofs(raw []byte) (*v2ProofData, error) {
if len(raw) == 0 {
return &v2ProofData{}, nil
}
dec := wire.NewDecoder(bytes.NewReader(raw))
count := dec.ReadVarint()
if dec.Err() != nil {
return nil, fmt.Errorf("read proof count: %w", dec.Err())
}
var data v2ProofData
for i := uint64(0); i < count; i++ {
tag := dec.ReadUint8()
if dec.Err() != nil {
return nil, fmt.Errorf("read proof tag %d: %w", i, dec.Err())
}
switch tag {
case 46: // zc_asset_surjection_proof: varint(nBGE) + nBGE * BGE_proof
nBGE := dec.ReadVarint()
if dec.Err() != nil {
return nil, fmt.Errorf("parse BGE count: %w", dec.Err())
}
data.bgeProofs = make([][]byte, nBGE)
for j := uint64(0); j < nBGE; j++ {
data.bgeProofs[j] = readBGEProofBytes(dec)
if dec.Err() != nil {
return nil, fmt.Errorf("parse BGE proof %d: %w", j, dec.Err())
}
}
case 47: // zc_outs_range_proof: bpp_serialized + aggregation_proof
data.bppProofBytes = readBPPBytes(dec)
if dec.Err() != nil {
return nil, fmt.Errorf("parse BPP proof: %w", dec.Err())
}
data.bppCommitments = readAggregationCommitments(dec)
if dec.Err() != nil {
return nil, fmt.Errorf("parse aggregation proof: %w", dec.Err())
}
case 48: // zc_balance_proof: 96 bytes (c, y0, y1)
data.balanceProof = dec.ReadBytes(96)
if dec.Err() != nil {
return nil, fmt.Errorf("parse balance proof: %w", dec.Err())
}
default:
return nil, fmt.Errorf("unsupported proof tag 0x%02x", tag)
}
}
return &data, nil
}
// readBGEProofBytes reads a BGE_proof_s and returns the raw wire bytes.
// Wire: A(32) + B(32) + vec(Pk) + vec(f) + y(32) + z(32).
func readBGEProofBytes(dec *wire.Decoder) []byte {
var raw []byte
// A + B
ab := dec.ReadBytes(64)
raw = append(raw, ab...)
// Pk vector
pkCount := dec.ReadVarint()
if dec.Err() != nil {
return nil
}
raw = append(raw, wire.EncodeVarint(pkCount)...)
if pkCount > 0 {
pkData := dec.ReadBytes(int(pkCount) * 32)
raw = append(raw, pkData...)
}
// f vector
fCount := dec.ReadVarint()
if dec.Err() != nil {
return nil
}
raw = append(raw, wire.EncodeVarint(fCount)...)
if fCount > 0 {
fData := dec.ReadBytes(int(fCount) * 32)
raw = append(raw, fData...)
}
// y + z
yz := dec.ReadBytes(64)
raw = append(raw, yz...)
return raw
}
// readBPPBytes reads a bpp_signature_serialized and returns the raw wire bytes.
// Wire: vec(L) + vec(R) + A0(32) + A(32) + B(32) + r(32) + s(32) + delta(32).
func readBPPBytes(dec *wire.Decoder) []byte {
var raw []byte
// L vector
lCount := dec.ReadVarint()
if dec.Err() != nil {
return nil
}
raw = append(raw, wire.EncodeVarint(lCount)...)
if lCount > 0 {
raw = append(raw, dec.ReadBytes(int(lCount)*32)...)
}
// R vector
rCount := dec.ReadVarint()
if dec.Err() != nil {
return nil
}
raw = append(raw, wire.EncodeVarint(rCount)...)
if rCount > 0 {
raw = append(raw, dec.ReadBytes(int(rCount)*32)...)
}
// 6 fixed scalars
raw = append(raw, dec.ReadBytes(6*32)...)
return raw
}
// readAggregationCommitments reads the aggregation proof and extracts
// the amount_commitments_for_rp_aggregation (the first vector).
// Wire: vec(commitments) + vec(y0s) + vec(y1s) + c(32).
func readAggregationCommitments(dec *wire.Decoder) [][32]byte {
// Read commitments vector.
nCommit := dec.ReadVarint()
if dec.Err() != nil {
return nil
}
commits := make([][32]byte, nCommit)
for i := uint64(0); i < nCommit; i++ {
dec.ReadBlob32(&commits[i])
}
// Skip y0s vector.
skipVecOfPoints(dec)
// Skip y1s vector.
skipVecOfPoints(dec)
// Skip c scalar.
_ = dec.ReadBytes(32)
return commits
}

165
consensus/v2sig_test.go Normal file
View file

@ -0,0 +1,165 @@
// 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 consensus
import (
"bytes"
"encoding/hex"
"os"
"testing"
"forge.lthn.ai/core/go-blockchain/config"
"forge.lthn.ai/core/go-blockchain/types"
"forge.lthn.ai/core/go-blockchain/wire"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// loadTestTx loads and decodes a hex-encoded transaction from testdata.
func loadTestTx(t *testing.T, filename string) *types.Transaction {
t.Helper()
hexData, err := os.ReadFile(filename)
require.NoError(t, err, "read %s", filename)
blob, err := hex.DecodeString(string(bytes.TrimSpace(hexData)))
require.NoError(t, err, "decode hex")
dec := wire.NewDecoder(bytes.NewReader(blob))
tx := wire.DecodeTransaction(dec)
require.NoError(t, dec.Err(), "decode transaction")
return &tx
}
func TestParseV2Signatures_Mixin0(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
require.Equal(t, uint64(3), tx.Version, "expected v3 transaction")
// Should have 1 ZC input.
require.Len(t, tx.Vin, 1)
_, ok := tx.Vin[0].(types.TxInputZC)
require.True(t, ok, "expected TxInputZC")
// Parse signatures.
entries, err := parseV2Signatures(tx.SignaturesRaw)
require.NoError(t, err)
require.Len(t, entries, 1, "should have 1 signature entry")
// Should be a ZC_sig.
assert.Equal(t, types.SigTypeZC, entries[0].tag)
require.NotNil(t, entries[0].zcSig)
zc := entries[0].zcSig
// Ring size should be 16 (mixin 0 + ZC default = 16).
assert.Equal(t, 16, zc.ringSize, "expected ring size 16")
// Flat sig size: c(32) + r_g[16*32] + r_x[16*32] + K1(32) + K2(32) = 1120.
expectedSigSize := 32 + 16*32 + 16*32 + 64
assert.Equal(t, expectedSigSize, len(zc.clsagFlatSig))
// Pseudo-out commitment and asset ID should be non-zero.
assert.NotEqual(t, [32]byte{}, zc.pseudoOutCommitment)
assert.NotEqual(t, [32]byte{}, zc.pseudoOutAssetID)
}
func TestParseV2Signatures_Mixin10(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin10.hex")
require.Equal(t, uint64(3), tx.Version)
// Mixin10 tx has 2 ZC inputs.
require.Len(t, tx.Vin, 2)
entries, err := parseV2Signatures(tx.SignaturesRaw)
require.NoError(t, err)
require.Len(t, entries, 2, "should have 2 signature entries (one per input)")
for i, entry := range entries {
assert.Equal(t, types.SigTypeZC, entry.tag, "entry %d", i)
require.NotNil(t, entry.zcSig, "entry %d", i)
zc := entry.zcSig
// Both inputs use ring size 16 (ZC default).
assert.Equal(t, 16, zc.ringSize, "entry %d: expected ring size 16", i)
// Flat sig size: c(32) + r_g[16*32] + r_x[16*32] + K1(32) + K2(32) = 1120.
expectedSigSize := 32 + 16*32 + 16*32 + 64
assert.Equal(t, expectedSigSize, len(zc.clsagFlatSig), "entry %d", i)
}
}
func TestParseV2Proofs_Mixin0(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
proofs, err := parseV2Proofs(tx.Proofs)
require.NoError(t, err)
// Should have BGE proofs (one per output).
assert.Len(t, proofs.bgeProofs, 2, "expected 2 BGE proofs (one per output)")
for i, p := range proofs.bgeProofs {
assert.True(t, len(p) > 0, "BGE proof %d should be non-empty", i)
}
// Should have BPP range proof.
assert.True(t, len(proofs.bppProofBytes) > 0, "BPP proof should be non-empty")
// Should have BPP commitments (one per output).
assert.Len(t, proofs.bppCommitments, 2, "expected 2 BPP commitments")
for i, c := range proofs.bppCommitments {
assert.NotEqual(t, [32]byte{}, c, "BPP commitment %d should be non-zero", i)
}
// Should have balance proof (96 bytes).
assert.Len(t, proofs.balanceProof, 96, "balance proof should be 96 bytes")
}
func TestParseV2Proofs_Mixin10(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin10.hex")
proofs, err := parseV2Proofs(tx.Proofs)
require.NoError(t, err)
assert.Len(t, proofs.bgeProofs, 2)
assert.True(t, len(proofs.bppProofBytes) > 0)
assert.Len(t, proofs.bppCommitments, 2)
assert.Len(t, proofs.balanceProof, 96)
}
func TestVerifyV2Signatures_StructuralOnly(t *testing.T) {
// Test structural validation (no ring output function).
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
// With nil getZCRingOutputs, should pass structural checks.
err := VerifyTransactionSignatures(tx, config.TestnetForks, 2823, nil, nil)
require.NoError(t, err)
}
func TestVerifyV2Signatures_BadSigCount(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
// Corrupt SignaturesRaw to have wrong count.
tx.SignaturesRaw = wire.EncodeVarint(5) // 5 sigs but only 1 input
err := verifyV2Signatures(tx, nil)
assert.Error(t, err, "should fail with mismatched sig count")
}
func TestVerifyV2Signatures_TxHash(t *testing.T) {
// Verify the known tx hash matches.
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
txHash := wire.TransactionHash(tx)
expectedHash := "89c8839e3c6be3bb3616a5c2e7028fd8f33992e4f9ff218f8224825702865b8b"
assert.Equal(t, expectedHash, hex.EncodeToString(txHash[:]))
}
func TestVerifyV2Signatures_TxHashMixin10(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin10.hex")
txHash := wire.TransactionHash(tx)
expectedHash := "87fbc60cde013579e1ad6ab403dee81c4da7a6b4621bea44f6973568c37b0af6"
assert.Equal(t, expectedHash, hex.EncodeToString(txHash[:]))
}

View file

@ -18,14 +18,29 @@ import (
// and offsets. Used to decouple consensus/ from chain storage. // and offsets. Used to decouple consensus/ from chain storage.
type RingOutputsFn func(amount uint64, offsets []uint64) ([]types.PublicKey, error) type RingOutputsFn func(amount uint64, offsets []uint64) ([]types.PublicKey, error)
// ZCRingMember holds the three public keys per ring entry needed for
// CLSAG GGX verification (HF4+). All fields are premultiplied by 1/8
// as stored on chain.
type ZCRingMember struct {
StealthAddress [32]byte
AmountCommitment [32]byte
BlindedAssetID [32]byte
}
// ZCRingOutputsFn fetches ZC ring members for the given global output indices.
// Used for post-HF4 CLSAG GGX signature verification.
type ZCRingOutputsFn func(offsets []uint64) ([]ZCRingMember, error)
// VerifyTransactionSignatures verifies all ring signatures in a transaction. // VerifyTransactionSignatures verifies all ring signatures in a transaction.
// For coinbase transactions, this is a no-op (no signatures). // For coinbase transactions, this is a no-op (no signatures).
// For pre-HF4 transactions, NLSAG ring signatures are verified. // For pre-HF4 transactions, NLSAG ring signatures are verified.
// For post-HF4, CLSAG signatures and proofs are verified. // For post-HF4, CLSAG signatures and proofs are verified.
// //
// getRingOutputs may be nil for coinbase-only checks. // getRingOutputs is used for pre-HF4 (V1) signature verification.
// getZCRingOutputs is used for post-HF4 (V2) CLSAG GGX verification.
// Either may be nil for structural-only checks.
func VerifyTransactionSignatures(tx *types.Transaction, forks []config.HardFork, func VerifyTransactionSignatures(tx *types.Transaction, forks []config.HardFork,
height uint64, getRingOutputs RingOutputsFn) error { height uint64, getRingOutputs RingOutputsFn, getZCRingOutputs ZCRingOutputsFn) error {
// Coinbase: no signatures. // Coinbase: no signatures.
if isCoinbase(tx) { if isCoinbase(tx) {
@ -38,7 +53,7 @@ func VerifyTransactionSignatures(tx *types.Transaction, forks []config.HardFork,
return verifyV1Signatures(tx, getRingOutputs) return verifyV1Signatures(tx, getRingOutputs)
} }
return verifyV2Signatures(tx, getRingOutputs) return verifyV2Signatures(tx, getZCRingOutputs)
} }
// verifyV1Signatures checks NLSAG ring signatures for pre-HF4 transactions. // verifyV1Signatures checks NLSAG ring signatures for pre-HF4 transactions.
@ -111,7 +126,188 @@ func verifyV1Signatures(tx *types.Transaction, getRingOutputs RingOutputsFn) err
} }
// verifyV2Signatures checks CLSAG signatures and proofs for post-HF4 transactions. // verifyV2Signatures checks CLSAG signatures and proofs for post-HF4 transactions.
func verifyV2Signatures(tx *types.Transaction, getRingOutputs RingOutputsFn) error { func verifyV2Signatures(tx *types.Transaction, getZCRingOutputs ZCRingOutputsFn) error {
// TODO: Wire up CLSAG verification and proof checks. // Parse the signature variant vector.
sigEntries, err := parseV2Signatures(tx.SignaturesRaw)
if err != nil {
return fmt.Errorf("consensus: %w", err)
}
// Match signatures to inputs: each input must have a corresponding signature.
if len(sigEntries) != len(tx.Vin) {
return fmt.Errorf("consensus: V2 signature count %d != input count %d",
len(sigEntries), len(tx.Vin))
}
// Validate that ZC inputs have ZC_sig and vice versa.
for i, vin := range tx.Vin {
switch vin.(type) {
case types.TxInputZC:
if sigEntries[i].tag != types.SigTypeZC {
return fmt.Errorf("consensus: input %d is ZC but signature tag is 0x%02x",
i, sigEntries[i].tag)
}
case types.TxInputToKey:
if sigEntries[i].tag != types.SigTypeNLSAG && sigEntries[i].tag != types.SigTypeVoid {
return fmt.Errorf("consensus: input %d is to_key but signature tag is 0x%02x",
i, sigEntries[i].tag)
}
}
}
// Without ring output data, we can only check structural correctness.
if getZCRingOutputs == nil {
return nil
}
// Compute tx prefix hash for CLSAG verification.
// For normal transactions (not TX_FLAG_SIGNATURE_MODE_SEPARATE),
// the hash is simply the transaction prefix hash.
prefixHash := wire.TransactionPrefixHash(tx)
// Verify CLSAG GGX signature for each ZC input.
for i, vin := range tx.Vin {
zcIn, ok := vin.(types.TxInputZC)
if !ok {
continue
}
zc := sigEntries[i].zcSig
if zc == nil {
return fmt.Errorf("consensus: input %d: missing ZC_sig data", i)
}
// Extract absolute global indices from key offsets.
offsets := make([]uint64, len(zcIn.KeyOffsets))
for j, ref := range zcIn.KeyOffsets {
offsets[j] = ref.GlobalIndex
}
ringMembers, err := getZCRingOutputs(offsets)
if err != nil {
return fmt.Errorf("consensus: failed to fetch ZC ring outputs for input %d: %w", i, err)
}
if len(ringMembers) != zc.ringSize {
return fmt.Errorf("consensus: input %d: ring size %d from chain != %d from sig",
i, len(ringMembers), zc.ringSize)
}
// Build flat ring: [stealth(32) | commitment(32) | blinded_asset_id(32)] per entry.
ring := make([]byte, 0, len(ringMembers)*96)
for _, m := range ringMembers {
ring = append(ring, m.StealthAddress[:]...)
ring = append(ring, m.AmountCommitment[:]...)
ring = append(ring, m.BlindedAssetID[:]...)
}
if !crypto.VerifyCLSAGGGX(
[32]byte(prefixHash),
ring, zc.ringSize,
zc.pseudoOutCommitment,
zc.pseudoOutAssetID,
[32]byte(zcIn.KeyImage),
zc.clsagFlatSig,
) {
return fmt.Errorf("consensus: CLSAG GGX verification failed for input %d", i)
}
}
// Parse and verify proofs.
proofs, err := parseV2Proofs(tx.Proofs)
if err != nil {
return fmt.Errorf("consensus: %w", err)
}
// Verify BPP range proof if present.
if len(proofs.bppProofBytes) > 0 && len(proofs.bppCommitments) > 0 {
if !crypto.VerifyBPP(proofs.bppProofBytes, proofs.bppCommitments) {
return fmt.Errorf("consensus: BPP range proof verification failed")
}
}
// Verify BGE asset surjection proofs.
// One proof per output, with a ring of (pseudo_out_asset_id - output_asset_id)
// per ZC input.
if len(proofs.bgeProofs) > 0 {
if err := verifyBGEProofs(tx, sigEntries, proofs, prefixHash); err != nil {
return err
}
}
// TODO: Verify balance proof (generic_double_schnorr_sig).
// Requires computing commitment_to_zero and a new bridge function.
return nil
}
// verifyBGEProofs verifies the BGE asset surjection proofs.
// There is one BGE proof per Zarcanum output. For each output j, the proof
// demonstrates that the output's blinded asset ID matches one of the
// pseudo-out asset IDs from the ZC inputs.
//
// The BGE ring for output j has one entry per ZC input i:
//
// ring[i] = mul8(pseudo_out_blinded_asset_id_i) - mul8(output_blinded_asset_id_j)
//
// The context hash is the transaction prefix hash (tx_id).
func verifyBGEProofs(tx *types.Transaction, sigEntries []v2SigEntry,
proofs *v2ProofData, prefixHash types.Hash) error {
// Collect Zarcanum output blinded asset IDs.
var outputAssetIDs [][32]byte
for _, out := range tx.Vout {
if zOut, ok := out.(types.TxOutputZarcanum); ok {
outputAssetIDs = append(outputAssetIDs, [32]byte(zOut.BlindedAssetID))
}
}
if len(proofs.bgeProofs) != len(outputAssetIDs) {
return fmt.Errorf("consensus: BGE proof count %d != Zarcanum output count %d",
len(proofs.bgeProofs), len(outputAssetIDs))
}
// Collect pseudo-out asset IDs from ZC signatures and expand to full points.
var pseudoOutAssetIDs [][32]byte
for _, entry := range sigEntries {
if entry.tag == types.SigTypeZC && entry.zcSig != nil {
pseudoOutAssetIDs = append(pseudoOutAssetIDs, entry.zcSig.pseudoOutAssetID)
}
}
// mul8 all pseudo-out asset IDs (stored premultiplied by 1/8 on chain).
mul8PseudoOuts := make([][32]byte, len(pseudoOutAssetIDs))
for i, p := range pseudoOutAssetIDs {
full, err := crypto.PointMul8(p)
if err != nil {
return fmt.Errorf("consensus: mul8 pseudo-out asset ID %d: %w", i, err)
}
mul8PseudoOuts[i] = full
}
// For each output, build the BGE ring and verify.
context := [32]byte(prefixHash)
for j, outAssetID := range outputAssetIDs {
// mul8 the output's blinded asset ID.
mul8Out, err := crypto.PointMul8(outAssetID)
if err != nil {
return fmt.Errorf("consensus: mul8 output asset ID %d: %w", j, err)
}
// ring[i] = mul8(pseudo_out_i) - mul8(output_j)
ring := make([][32]byte, len(mul8PseudoOuts))
for i, mul8Pseudo := range mul8PseudoOuts {
diff, err := crypto.PointSub(mul8Pseudo, mul8Out)
if err != nil {
return fmt.Errorf("consensus: BGE ring[%d][%d] sub: %w", j, i, err)
}
ring[i] = diff
}
if !crypto.VerifyBGE(context, ring, proofs.bgeProofs[j]) {
return fmt.Errorf("consensus: BGE proof verification failed for output %d", j)
}
}
return nil return nil
} }

View file

@ -52,7 +52,7 @@ func TestVerifyV1Signatures_Good_MockRing(t *testing.T) {
return []types.PublicKey{types.PublicKey(pub)}, nil return []types.PublicKey{types.PublicKey(pub)}, nil
} }
err = VerifyTransactionSignatures(tx, config.MainnetForks, 100, getRing) err = VerifyTransactionSignatures(tx, config.MainnetForks, 100, getRing, nil)
require.NoError(t, err) require.NoError(t, err)
} }
@ -86,6 +86,6 @@ func TestVerifyV1Signatures_Bad_WrongSig(t *testing.T) {
return []types.PublicKey{types.PublicKey(pub)}, nil return []types.PublicKey{types.PublicKey(pub)}, nil
} }
err = VerifyTransactionSignatures(tx, config.MainnetForks, 100, getRing) err = VerifyTransactionSignatures(tx, config.MainnetForks, 100, getRing, nil)
assert.Error(t, err) assert.Error(t, err)
} }

View file

@ -13,13 +13,13 @@ import (
func TestVerifyTransactionSignatures_Good_Coinbase(t *testing.T) { func TestVerifyTransactionSignatures_Good_Coinbase(t *testing.T) {
// Coinbase transactions have no signatures to verify. // Coinbase transactions have no signatures to verify.
tx := validMinerTx(100) tx := validMinerTx(100)
err := VerifyTransactionSignatures(tx, config.MainnetForks, 100, nil) err := VerifyTransactionSignatures(tx, config.MainnetForks, 100, nil, nil)
require.NoError(t, err) require.NoError(t, err)
} }
func TestVerifyTransactionSignatures_Bad_MissingSigs(t *testing.T) { func TestVerifyTransactionSignatures_Bad_MissingSigs(t *testing.T) {
tx := validV1Tx() tx := validV1Tx()
tx.Signatures = nil // no signatures tx.Signatures = nil // no signatures
err := VerifyTransactionSignatures(tx, config.MainnetForks, 100, nil) err := VerifyTransactionSignatures(tx, config.MainnetForks, 100, nil, nil)
assert.Error(t, err) assert.Error(t, err)
} }

View file

@ -330,6 +330,19 @@ int cn_point_div8(const uint8_t pk[32], uint8_t result[32]) {
return 0; return 0;
} }
int cn_point_sub(const uint8_t a[32], const uint8_t b[32], uint8_t result[32]) {
crypto::public_key pa, pb;
memcpy(&pa, a, 32);
memcpy(&pb, b, 32);
crypto::point_t pta(pa);
crypto::point_t ptb(pb);
crypto::point_t diff = pta - ptb;
crypto::public_key dst;
diff.to_public_key(dst);
memcpy(result, &dst, 32);
return 0;
}
// ── CLSAG (HF4+) ──────────────────────────────────────── // ── CLSAG (HF4+) ────────────────────────────────────────
// Signature layout for GG: c(32) | r[N*32] | K1(32) // Signature layout for GG: c(32) | r[N*32] | K1(32)

View file

@ -57,6 +57,9 @@ int cn_point_mul8(const uint8_t pk[32], uint8_t result[32]);
// Premultiply by 1/8 (cofactor inverse). Stored form on-chain. // Premultiply by 1/8 (cofactor inverse). Stored form on-chain.
int cn_point_div8(const uint8_t pk[32], uint8_t result[32]); int cn_point_div8(const uint8_t pk[32], uint8_t result[32]);
// Subtract two curve points: result = a - b.
int cn_point_sub(const uint8_t a[32], const uint8_t b[32], uint8_t result[32]);
// ── CLSAG Verification (HF4+) ──────────────────────────── // ── CLSAG Verification (HF4+) ────────────────────────────
// Ring entries are flat arrays of 32-byte public keys per entry: // Ring entries are flat arrays of 32-byte public keys per entry:
// GG: [stealth_addr(32) | amount_commitment(32)] per entry = 64 bytes // GG: [stealth_addr(32) | amount_commitment(32)] per entry = 64 bytes

View file

@ -39,6 +39,20 @@ func PointDiv8(pk [32]byte) ([32]byte, error) {
return result, nil return result, nil
} }
// PointSub computes a - b on the Ed25519 curve.
func PointSub(a, b [32]byte) ([32]byte, error) {
var result [32]byte
rc := C.cn_point_sub(
(*C.uint8_t)(unsafe.Pointer(&a[0])),
(*C.uint8_t)(unsafe.Pointer(&b[0])),
(*C.uint8_t)(unsafe.Pointer(&result[0])),
)
if rc != 0 {
return result, fmt.Errorf("crypto: point_sub failed")
}
return result, nil
}
// CLSAGGGSigSize returns the byte size of a CLSAG_GG signature for a given ring size. // CLSAGGGSigSize returns the byte size of a CLSAG_GG signature for a given ring size.
func CLSAGGGSigSize(ringSize int) int { func CLSAGGGSigSize(ringSize int) int {
return int(C.cn_clsag_gg_sig_size(C.size_t(ringSize))) return int(C.cn_clsag_gg_sig_size(C.size_t(ringSize)))

24
testdata/README vendored Normal file
View file

@ -0,0 +1,24 @@
Testnet V2 Spending Transaction Test Vectors
=============================================
Generated on testnet at height ~2880.
v2_spending_tx_mixin0.hex:
TX hash: 89c8839e3c6be3bb3616a5c2e7028fd8f33992e4f9ff218f8224825702865b8b
Block: 2823
Version: 3
Ring size: 16 (mixin 0 + ZC default)
Inputs: 1 txin_zc_input
Outputs: 2 tx_out_zarcanum
Signatures: CLSAG GGX
Proofs: BGE (2), BPP (1 with aggregation), ZC balance
v2_spending_tx_mixin10.hex:
TX hash: 87fbc60cde013579e1ad6ab403dee81c4da7a6b4621bea44f6973568c37b0af6
Block: ~2878
Version: 3
Ring size: 26 (mixin 10 + ZC default)
Inputs: 1 txin_zc_input
Outputs: 2 tx_out_zarcanum
Signatures: CLSAG GGX
Proofs: BGE (2), BPP (1 with aggregation), ZC balance

1
testdata/v2_spending_tx_mixin0.hex vendored Normal file

File diff suppressed because one or more lines are too long

1
testdata/v2_spending_tx_mixin10.hex vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -209,7 +209,7 @@ func encodeKeyOffsets(enc *Encoder, refs []types.TxOutRef) {
enc.WriteVariantTag(ref.Tag) enc.WriteVariantTag(ref.Tag)
switch ref.Tag { switch ref.Tag {
case types.RefTypeGlobalIndex: case types.RefTypeGlobalIndex:
enc.WriteVarint(ref.GlobalIndex) enc.WriteUint64LE(ref.GlobalIndex)
case types.RefTypeByID: case types.RefTypeByID:
enc.WriteBlob32((*[32]byte)(&ref.TxID)) enc.WriteBlob32((*[32]byte)(&ref.TxID))
enc.WriteVarint(ref.N) enc.WriteVarint(ref.N)
@ -227,7 +227,7 @@ func decodeKeyOffsets(dec *Decoder) []types.TxOutRef {
refs[i].Tag = dec.ReadVariantTag() refs[i].Tag = dec.ReadVariantTag()
switch refs[i].Tag { switch refs[i].Tag {
case types.RefTypeGlobalIndex: case types.RefTypeGlobalIndex:
refs[i].GlobalIndex = dec.ReadVarint() refs[i].GlobalIndex = dec.ReadUint64LE()
case types.RefTypeByID: case types.RefTypeByID:
dec.ReadBlob32((*[32]byte)(&refs[i].TxID)) dec.ReadBlob32((*[32]byte)(&refs[i].TxID))
refs[i].N = dec.ReadVarint() refs[i].N = dec.ReadVarint()
@ -446,8 +446,8 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte {
case tagTxComment, tagString, tagTxDerivationHint, tagExtraUserData: case tagTxComment, tagString, tagTxDerivationHint, tagExtraUserData:
return readStringBlob(dec) return readStringBlob(dec)
// Varint fields // Varint fields (structs with VARINT_FIELD)
case tagUnlockTime, tagExpirationTime, tagTxDetailsFlags, tagUint64, tagEtcTxTime: case tagUnlockTime, tagExpirationTime, tagTxDetailsFlags, tagEtcTxTime:
v := dec.ReadVarint() v := dec.ReadVarint()
if dec.err != nil { if dec.err != nil {
return nil return nil
@ -455,6 +455,8 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte {
return EncodeVarint(v) return EncodeVarint(v)
// Fixed-size integer fields // Fixed-size integer fields
case tagUint64: // raw uint64_t — do_serialize → serialize_int → 8-byte LE
return dec.ReadBytes(8)
case tagTxCryptoChecksum: // two uint32 LE case tagTxCryptoChecksum: // two uint32 LE
return dec.ReadBytes(8) return dec.ReadBytes(8)
case tagUint32: // uint32 LE case tagUint32: // uint32 LE
@ -485,12 +487,8 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte {
return readTxServiceAttachment(dec) return readTxServiceAttachment(dec)
// Zarcanum extra variant // Zarcanum extra variant
case tagZarcanumTxDataV1: // fee (varint) case tagZarcanumTxDataV1: // fee — FIELD(fee) → serialize_int → 8-byte LE
v := dec.ReadVarint() return dec.ReadBytes(8)
if dec.err != nil {
return nil
}
return EncodeVarint(v)
// Signature variants // Signature variants
case tagNLSAGSig: // vector<signature> (64 bytes each) case tagNLSAGSig: // vector<signature> (64 bytes each)