diff --git a/consensus/v2sig.go b/consensus/v2sig.go index c836358..028d3f3 100644 --- a/consensus/v2sig.go +++ b/consensus/v2sig.go @@ -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 { diff --git a/consensus/v2sig_test.go b/consensus/v2sig_test.go index 463cf98..3f1247c 100644 --- a/consensus/v2sig_test.go +++ b/consensus/v2sig_test.go @@ -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") +} diff --git a/consensus/verify.go b/consensus/verify.go index 1438aba..d7f7321 100644 --- a/consensus/verify.go +++ b/consensus/verify.go @@ -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 +} diff --git a/crypto/bridge.cpp b/crypto/bridge.cpp index 4b03416..fae5d8a 100644 --- a/crypto/bridge.cpp +++ b/crypto/bridge.cpp @@ -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( + tx_hash, commitment_to_zero, tx_pub, dss); + } else { + ok = crypto::verify_double_schnorr_sig( + 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, diff --git a/crypto/bridge.h b/crypto/bridge.h index 468d088..c1789e0 100644 --- a/crypto/bridge.h +++ b/crypto/bridge.h @@ -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 +// 1 => verify_double_schnorr_sig +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). diff --git a/crypto/proof.go b/crypto/proof.go index e1974ae..36cea88 100644 --- a/crypto/proof.go +++ b/crypto/proof.go @@ -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.