feat(consensus): verify HF4 balance proofs

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Virgil 2026-04-04 12:10:02 +00:00
parent 09729c0aeb
commit 0de491b394
6 changed files with 370 additions and 10 deletions

View file

@ -7,6 +7,7 @@ package consensus
import (
"bytes"
"encoding/binary"
"fmt"
coreerr "dappco.re/go/core/log"
@ -26,8 +27,9 @@ type zcSigData struct {
// v2SigEntry is one parsed entry from the V2 signature variant vector.
type v2SigEntry struct {
tag uint8
zcSig *zcSigData // non-nil when tag == SigTypeZC
tag uint8
zcSig *zcSigData // non-nil when tag == SigTypeZC
pseudoOutCommitment *[32]byte
}
// parseV2Signatures parses the SignaturesRaw variant vector into a slice
@ -59,6 +61,7 @@ func parseV2Signatures(raw []byte) ([]v2SigEntry, error) {
return nil, coreerr.E("parseV2Signatures", fmt.Sprintf("parse ZC_sig %d", i), err)
}
entry.zcSig = zc
entry.pseudoOutCommitment = &zc.pseudoOutCommitment
case types.SigTypeVoid:
// Empty struct — nothing to read.
@ -71,9 +74,11 @@ func parseV2Signatures(raw []byte) ([]v2SigEntry, error) {
}
case types.SigTypeZarcanum:
// Skip: 10 scalars + bppe + public_key + CLSAG_GGXXG.
// Use skipZarcanumSig to advance past the data.
skipZarcanumSig(dec)
pseudoOut, err := parseZarcanumSigPseudoOut(dec)
if err != nil {
return nil, coreerr.E("parseV2Signatures", fmt.Sprintf("parse zarcanum_sig %d", i), err)
}
entry.pseudoOutCommitment = &pseudoOut
default:
return nil, coreerr.E("parseV2Signatures", fmt.Sprintf("unsupported sig tag 0x%02x", tag), nil)
@ -142,9 +147,12 @@ func parseZCSig(dec *wire.Decoder) (*zcSigData, error) {
return &zc, nil
}
// skipZarcanumSig advances the decoder past a zarcanum_sig element.
// parseZarcanumSigPseudoOut advances past a zarcanum_sig while preserving
// its pseudo-out amount commitment for balance-proof verification.
// Wire: 10 scalars + bppe_serialized + public_key(32) + CLSAG_GGXXG.
func skipZarcanumSig(dec *wire.Decoder) {
func parseZarcanumSigPseudoOut(dec *wire.Decoder) ([32]byte, error) {
var pseudoOut [32]byte
// 10 fixed scalars/points (320 bytes).
_ = dec.ReadBytes(10 * 32)
@ -154,13 +162,22 @@ func skipZarcanumSig(dec *wire.Decoder) {
_ = dec.ReadBytes(7 * 32)
// pseudo_out_amount_commitment (32 bytes).
_ = dec.ReadBytes(32)
dec.ReadBlob32(&pseudoOut)
if dec.Err() != nil {
return pseudoOut, dec.Err()
}
// 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
if dec.Err() != nil {
return pseudoOut, dec.Err()
}
return pseudoOut, nil
}
// skipVecOfPoints advances the decoder past a varint(count) + count*32 vector.
@ -246,6 +263,91 @@ func parseV2Proofs(raw []byte) (*v2ProofData, error) {
return &data, nil
}
type v2ExtraMetadata struct {
txPublicKey [32]byte
hasTxPubKey bool
fee uint64
hasFee bool
}
func parseV2ExtraMetadata(raw []byte) (*v2ExtraMetadata, error) {
if len(raw) == 0 {
return &v2ExtraMetadata{}, nil
}
dec := wire.NewDecoder(bytes.NewReader(raw))
count := dec.ReadVarint()
if dec.Err() != nil {
return nil, coreerr.E("parseV2ExtraMetadata", "read extra count", dec.Err())
}
var meta v2ExtraMetadata
for i := uint64(0); i < count; i++ {
tag := dec.ReadUint8()
if dec.Err() != nil {
return nil, coreerr.E("parseV2ExtraMetadata", fmt.Sprintf("read extra tag %d", i), dec.Err())
}
switch tag {
case 22: // public_key
dec.ReadBlob32(&meta.txPublicKey)
meta.hasTxPubKey = true
case 39: // zarcanum_tx_data_v1.fee — 8-byte little-endian
feeBytes := dec.ReadBytes(8)
if dec.Err() == nil {
meta.fee = binary.LittleEndian.Uint64(feeBytes)
meta.hasFee = true
}
default:
if err := skipExtraVariantElement(dec, tag); err != nil {
return nil, coreerr.E("parseV2ExtraMetadata", fmt.Sprintf("parse extra tag 0x%02x", tag), err)
}
}
if dec.Err() != nil {
return nil, coreerr.E("parseV2ExtraMetadata", fmt.Sprintf("parse extra tag 0x%02x", tag), dec.Err())
}
}
return &meta, nil
}
func skipExtraVariantElement(dec *wire.Decoder, tag uint8) error {
switch tag {
case 7, 9, 11, 19:
skipVarintBytes(dec)
case 10:
_ = dec.ReadBytes(8)
case 14, 15, 16, 26, 27:
_ = dec.ReadVarint()
case 17, 28:
_ = dec.ReadBytes(4)
case 18:
skipVarintBytes(dec)
_ = dec.ReadBytes(32)
_ = dec.ReadVarint()
case 20, 21, 24, 31, 32, 33, 40:
skipVarintBytes(dec)
case 23:
_ = dec.ReadBytes(2)
case 25:
_ = dec.ReadVarint()
case 29:
_ = dec.ReadBytes(64)
default:
return fmt.Errorf("unsupported extra tag 0x%02x", tag)
}
return dec.Err()
}
func skipVarintBytes(dec *wire.Decoder) {
n := dec.ReadVarint()
if dec.Err() == nil && n > 0 {
_ = dec.ReadBytes(int(n))
}
}
// 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 {

View file

@ -163,3 +163,57 @@ func TestVerifyV2Signatures_TxHashMixin10(t *testing.T) {
expectedHash := "87fbc60cde013579e1ad6ab403dee81c4da7a6b4621bea44f6973568c37b0af6"
assert.Equal(t, expectedHash, hex.EncodeToString(txHash[:]))
}
func TestParseV2ExtraMetadata_Good(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
meta, err := parseV2ExtraMetadata(tx.Extra)
require.NoError(t, err)
require.True(t, meta.hasTxPubKey)
require.True(t, meta.hasFee)
assert.NotEqual(t, [32]byte{}, meta.txPublicKey)
assert.NotZero(t, meta.fee)
}
func TestVerifyBalanceProof_Good_Mixin0(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
sigEntries, err := parseV2Signatures(tx.SignaturesRaw)
require.NoError(t, err)
proofs, err := parseV2Proofs(tx.Proofs)
require.NoError(t, err)
err = verifyBalanceProof(tx, sigEntries, proofs, wire.TransactionPrefixHash(tx))
require.NoError(t, err)
}
func TestVerifyBalanceProof_Bad_CorruptProof(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
sigEntries, err := parseV2Signatures(tx.SignaturesRaw)
require.NoError(t, err)
proofs, err := parseV2Proofs(tx.Proofs)
require.NoError(t, err)
proofs.balanceProof[0] ^= 0x01
err = verifyBalanceProof(tx, sigEntries, proofs, wire.TransactionPrefixHash(tx))
require.Error(t, err)
assert.ErrorContains(t, err, "balance proof verification failed")
}
func TestVerifyBalanceProof_Bad_MissingProof(t *testing.T) {
tx := loadTestTx(t, "../testdata/v2_spending_tx_mixin0.hex")
sigEntries, err := parseV2Signatures(tx.SignaturesRaw)
require.NoError(t, err)
proofs, err := parseV2Proofs(tx.Proofs)
require.NoError(t, err)
proofs.balanceProof = nil
err = verifyBalanceProof(tx, sigEntries, proofs, wire.TransactionPrefixHash(tx))
require.Error(t, err)
assert.ErrorContains(t, err, "zc_balance_proof is missing")
}

View file

@ -245,8 +245,9 @@ func verifyV2Signatures(tx *types.Transaction, getZCRingOutputs ZCRingOutputsFn)
}
}
// TODO: Verify balance proof (generic_double_schnorr_sig).
// Requires computing commitment_to_zero and a new bridge function.
if err := verifyBalanceProof(tx, sigEntries, proofs, prefixHash); err != nil {
return err
}
return nil
}
@ -320,3 +321,70 @@ func verifyBGEProofs(tx *types.Transaction, sigEntries []v2SigEntry,
return nil
}
func verifyBalanceProof(tx *types.Transaction, sigEntries []v2SigEntry,
proofs *v2ProofData, prefixHash types.Hash) error {
if len(proofs.balanceProof) == 0 {
return coreerr.E("verifyBalanceProof", "consensus: zc_balance_proof is missing in tx proofs", nil)
}
if len(proofs.balanceProof) != 96 {
return coreerr.E("verifyBalanceProof", fmt.Sprintf("consensus: zc_balance_proof length %d != 96", len(proofs.balanceProof)), nil)
}
extraMeta, err := parseV2ExtraMetadata(tx.Extra)
if err != nil {
return coreerr.E("verifyBalanceProof", "consensus", err)
}
if !extraMeta.hasTxPubKey {
return coreerr.E("verifyBalanceProof", "consensus: tx public key missing from extra", nil)
}
if !extraMeta.hasFee && !isCoinbase(tx) {
return coreerr.E("verifyBalanceProof", "consensus: fee missing from extra", nil)
}
bareInputsSum, err := sumInputs(tx)
if err != nil {
return coreerr.E("verifyBalanceProof", "consensus: sum bare inputs", err)
}
outputCommitments := make([][32]byte, 0, len(tx.Vout))
for i, out := range tx.Vout {
zcOut, ok := out.(types.TxOutputZarcanum)
if !ok {
return coreerr.E("verifyBalanceProof", fmt.Sprintf("consensus: output %d has unexpected type %T", i, out), nil)
}
outputCommitments = append(outputCommitments, [32]byte(zcOut.AmountCommitment))
}
var pseudoOutCommitments [][32]byte
zcInputsCount := 0
for _, vin := range tx.Vin {
if _, ok := vin.(types.TxInputZC); ok {
zcInputsCount++
}
}
for _, entry := range sigEntries {
if entry.pseudoOutCommitment != nil {
pseudoOutCommitments = append(pseudoOutCommitments, *entry.pseudoOutCommitment)
}
}
if zcInputsCount != len(pseudoOutCommitments) {
return coreerr.E("verifyBalanceProof", fmt.Sprintf("consensus: zc inputs count %d and zc sigs count %d mismatch", zcInputsCount, len(pseudoOutCommitments)), nil)
}
if !crypto.VerifyBalanceProof(
[32]byte(prefixHash),
extraMeta.txPublicKey,
proofs.balanceProof,
outputCommitments,
pseudoOutCommitments,
bareInputsSum,
extraMeta.fee,
zcInputsCount > 0,
) {
return coreerr.E("verifyBalanceProof", "consensus: balance proof verification failed", nil)
}
return nil
}

View file

@ -135,6 +135,16 @@ bool deserialise_bge(const uint8_t *buf, size_t len, crypto::BGE_proof &proof) {
return off == len;
}
bool deserialise_double_schnorr(const uint8_t *buf, size_t len,
crypto::generic_double_schnorr_sig &sig) {
if (len != 96) return false;
size_t off = 0;
if (!read_scalar(buf, len, &off, sig.c)) return false;
if (!read_scalar(buf, len, &off, sig.y0)) return false;
if (!read_scalar(buf, len, &off, sig.y1)) return false;
return off == len;
}
} // anonymous namespace
extern "C" {
@ -639,6 +649,70 @@ int cn_bge_verify(const uint8_t context[32], const uint8_t *ring,
}
}
// ── Transaction Balance Proof (HF4+) ─────────────────────
int cn_balance_proof_verify(const uint8_t hash[32], const uint8_t tx_pub_key[32],
const uint8_t proof[96],
const uint8_t *output_commitments, size_t num_outputs,
const uint8_t *pseudo_out_commitments, size_t num_pseudo_outs,
uint64_t bare_inputs_sum, uint64_t fee,
int has_zc_inputs) {
if (hash == nullptr || tx_pub_key == nullptr || proof == nullptr ||
output_commitments == nullptr || num_outputs == 0) {
return 1;
}
if (num_pseudo_outs > 0 && pseudo_out_commitments == nullptr) {
return 1;
}
try {
crypto::hash tx_hash;
memcpy(&tx_hash, hash, 32);
crypto::public_key tx_pub;
memcpy(&tx_pub, tx_pub_key, 32);
crypto::generic_double_schnorr_sig dss;
if (!deserialise_double_schnorr(proof, 96, dss)) {
return 1;
}
crypto::point_t outs_commitments_sum = crypto::c_point_0;
for (size_t i = 0; i < num_outputs; i++) {
crypto::public_key pk;
memcpy(&pk, output_commitments + i * 32, 32);
outs_commitments_sum += crypto::point_t(pk);
}
crypto::point_t pseudo_outs_sum = crypto::c_point_0;
for (size_t i = 0; i < num_pseudo_outs; i++) {
crypto::public_key pk;
memcpy(&pk, pseudo_out_commitments + i * 32, 32);
pseudo_outs_sum += crypto::point_t(pk);
}
outs_commitments_sum.modify_mul8();
pseudo_outs_sum.modify_mul8();
crypto::point_t commitment_to_zero =
(crypto::scalar_t(bare_inputs_sum) - crypto::scalar_t(fee)) * crypto::c_point_H +
pseudo_outs_sum - outs_commitments_sum;
bool ok = false;
if (has_zc_inputs != 0) {
ok = crypto::verify_double_schnorr_sig<crypto::gt_X, crypto::gt_G>(
tx_hash, commitment_to_zero, tx_pub, dss);
} else {
ok = crypto::verify_double_schnorr_sig<crypto::gt_G, crypto::gt_G>(
tx_hash, commitment_to_zero, tx_pub, dss);
}
return ok ? 0 : 1;
} catch (...) {
return 1;
}
}
// ── Zarcanum PoS ────────────────────────────────────────
// Zarcanum verification requires many parameters beyond what the current
// bridge API exposes (kernel_hash, ring, last_pow_block_id, stake_ki,

View file

@ -125,6 +125,21 @@ int cn_bppe_verify(const uint8_t *proof, size_t proof_len,
int cn_bge_verify(const uint8_t context[32], const uint8_t *ring,
size_t ring_size, const uint8_t *proof, size_t proof_len);
// ── Transaction Balance Proof (HF4+) ─────────────────────
// Verifies zc_balance_proof (generic_double_schnorr_sig_s, 96 bytes).
// output_commitments and pseudo_out_commitments are flat arrays of 32-byte
// public keys, each stored in the on-chain 1/8-premultiplied form.
// bare_inputs_sum and fee are expressed in atomic units.
// has_zc_inputs selects the gt_X/gt_G proof variant:
// 0 => verify_double_schnorr_sig<gt_G, gt_G>
// 1 => verify_double_schnorr_sig<gt_X, gt_G>
int cn_balance_proof_verify(const uint8_t hash[32], const uint8_t tx_pub_key[32],
const uint8_t proof[96],
const uint8_t *output_commitments, size_t num_outputs,
const uint8_t *pseudo_out_commitments, size_t num_pseudo_outs,
uint64_t bare_inputs_sum, uint64_t fee,
int has_zc_inputs);
// ── Zarcanum PoS ──────────────────────────────────────────
// TODO: extend API to accept kernel_hash, ring, last_pow_block_id,
// stake_ki, pos_difficulty. Currently returns -1 (not implemented).

View file

@ -74,6 +74,53 @@ func VerifyBGE(context [32]byte, ring [][32]byte, proof []byte) bool {
) == 0
}
// VerifyBalanceProof verifies a post-HF4 zc_balance_proof.
// outputCommitments and pseudoOutCommitments must use the on-chain 1/8-premultiplied form.
func VerifyBalanceProof(hash [32]byte, txPubKey [32]byte, proof []byte,
outputCommitments [][32]byte, pseudoOutCommitments [][32]byte,
bareInputsSum uint64, fee uint64, hasZCInputs bool) bool {
if len(proof) != 96 || len(outputCommitments) == 0 {
return false
}
flatOutputs := make([]byte, len(outputCommitments)*32)
for i, c := range outputCommitments {
copy(flatOutputs[i*32:], c[:])
}
var flatPseudoOuts []byte
if len(pseudoOutCommitments) > 0 {
flatPseudoOuts = make([]byte, len(pseudoOutCommitments)*32)
for i, c := range pseudoOutCommitments {
copy(flatPseudoOuts[i*32:], c[:])
}
}
hasZCInputsFlag := 0
if hasZCInputs {
hasZCInputsFlag = 1
}
var pseudoPtr *C.uint8_t
if len(flatPseudoOuts) > 0 {
pseudoPtr = (*C.uint8_t)(unsafe.Pointer(&flatPseudoOuts[0]))
}
return C.cn_balance_proof_verify(
(*C.uint8_t)(unsafe.Pointer(&hash[0])),
(*C.uint8_t)(unsafe.Pointer(&txPubKey[0])),
(*C.uint8_t)(unsafe.Pointer(&proof[0])),
(*C.uint8_t)(unsafe.Pointer(&flatOutputs[0])),
C.size_t(len(outputCommitments)),
pseudoPtr,
C.size_t(len(pseudoOutCommitments)),
C.uint64_t(bareInputsSum),
C.uint64_t(fee),
C.int(hasZCInputsFlag),
) == 0
}
// VerifyZarcanum verifies a Zarcanum PoS proof.
// Currently returns false — bridge API needs extending to pass kernel_hash,
// ring, last_pow_block_id, stake_ki, and pos_difficulty.