zkvm: a transaction ID that commits to the transaction

ComputeID hashed the type, version, nullifiers, commitments, proof, fee and
expiry — and nothing else. The transparent inputs and outputs were absent, so
who is paid and how much could be changed without changing the transaction's
identity. Consensus decides between blocks by ID and the mempool keys on it, so
that is a payee an attacker can rewrite for free; the block-level test failed for
the same reason one level up.

The variable-length fields were also concatenated with nothing between them,
which lets a byte move from the end of one field into the start of the next
without the hash noticing — ["ab","c"] and ["a","bc"] are the same bytes. Every
field now carries its length and every list its count, so one preimage means one
transaction. The memo and the remaining shielded fields are covered too, since a
field that is not hashed is a field that can be changed.

The signature stays out: it is made over this value, so including it would be
circular and would let a second valid signature give a different identity to a
transaction moving the same value.

Identities change, which is a hard fork. Z-Chain has produced no blocks — until
the WaitForEvent fix it could not — so there is nothing to migrate.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-25 12:12:39 -07:00
co-authored by Hanzo Dev
parent 7cce60fbe7
commit fdf55575b2
2 changed files with 163 additions and 12 deletions
+65 -12
View File
@@ -119,32 +119,85 @@ type Note struct {
Nullifier []byte `json:"nullifier"` // Computed nullifier
}
// ComputeID computes the transaction ID
// ComputeID is the transaction's identity: a hash over everything the
// transaction means.
//
// Every variable-length field is written with its length first and every list
// with its count. Concatenated raw, a byte could move from the end of one field
// to the start of the next without the hash noticing — ["ab","c"] and ["a","bc"]
// are the same bytes — and two transactions sharing an identity is what
// consensus decides between blocks with.
//
// The signature is left out because it is made over this value. Including it
// would be circular, and would let a second valid signature give a different
// identity to a transaction moving the same value.
func (tx *Transaction) ComputeID() ids.ID {
h := sha256.New()
num := func(v uint64) { binary.Write(h, binary.BigEndian, v) }
blob := func(b []byte) { num(uint64(len(b))); h.Write(b) }
count := func(n int) { num(uint64(n)) }
present := func(yes bool) {
if yes {
h.Write([]byte{1})
return
}
h.Write([]byte{0})
}
// Include transaction type and version
h.Write([]byte{byte(tx.Type), tx.Version})
// Include nullifiers
count(len(tx.TransparentInputs))
for _, in := range tx.TransparentInputs {
h.Write(in.TxID[:])
num(uint64(in.OutputIdx))
num(in.Amount)
blob(in.Address)
}
count(len(tx.TransparentOutputs))
for _, out := range tx.TransparentOutputs {
num(out.Amount)
h.Write(out.AssetID[:])
blob(out.Address)
}
count(len(tx.Nullifiers))
for _, nullifier := range tx.Nullifiers {
h.Write(nullifier)
blob(nullifier)
}
// Include output commitments
count(len(tx.Outputs))
for _, output := range tx.Outputs {
h.Write(output.Commitment)
blob(output.Commitment)
blob(output.EncryptedNote)
blob(output.EphemeralPubKey)
blob(output.OutputProof)
}
// Include proof
present(tx.Proof != nil)
if tx.Proof != nil {
h.Write([]byte(tx.Proof.ProofType))
h.Write(tx.Proof.ProofData)
blob([]byte(tx.Proof.ProofType))
blob(tx.Proof.ProofData)
count(len(tx.Proof.PublicInputs))
for _, input := range tx.Proof.PublicInputs {
blob(input)
}
}
// Include fee and expiry
binary.Write(h, binary.BigEndian, tx.Fee)
binary.Write(h, binary.BigEndian, tx.Expiry)
present(tx.FHEData != nil)
if tx.FHEData != nil {
count(len(tx.FHEData.EncryptedInputs))
for _, input := range tx.FHEData.EncryptedInputs {
blob(input)
}
blob([]byte(tx.FHEData.CircuitID))
blob(tx.FHEData.EncryptedResult)
blob(tx.FHEData.ComputationProof)
}
num(tx.Fee)
num(tx.Expiry)
blob(tx.Memo)
return ids.ID(h.Sum(nil))
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"testing"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
func shieldTx() *Transaction {
return &Transaction{
Type: TransactionTypeShield,
Version: 1,
TransparentInputs: []*TransparentInput{{TxID: ids.ID{1}, OutputIdx: 0, Amount: 100, Address: []byte("payer")}},
TransparentOutputs: []*TransparentOutput{{Amount: 90, AssetID: ids.ID{2}, Address: []byte("payee")}},
Nullifiers: [][]byte{[]byte("n")},
Outputs: []*ShieldedOutput{{Commitment: []byte("c")}},
Proof: &ZKProof{ProofType: "groth16", ProofData: []byte("p")},
Fee: 7,
}
}
// TestIDCommitsToEverythingTheTransactionMeans. Consensus decides between blocks
// by ID and the mempool keys on it, so any field a transaction can be changed in
// without changing its ID is a field an attacker can change for free. The
// transparent inputs and outputs — who is paid, and how much — were not
// committed to at all.
func TestIDCommitsToEverythingTheTransactionMeans(t *testing.T) {
for _, tc := range []struct {
name string
alter func(*Transaction)
}{
{"type", func(tx *Transaction) { tx.Type = TransactionTypeUnshield }},
{"version", func(tx *Transaction) { tx.Version = 2 }},
{"fee", func(tx *Transaction) { tx.Fee = 8 }},
{"expiry", func(tx *Transaction) { tx.Expiry = 1 }},
{"memo", func(tx *Transaction) { tx.Memo = []byte("m") }},
{"nullifier", func(tx *Transaction) { tx.Nullifiers = [][]byte{[]byte("m")} }},
{"commitment", func(tx *Transaction) { tx.Outputs[0].Commitment = []byte("d") }},
{"encrypted note", func(tx *Transaction) { tx.Outputs[0].EncryptedNote = []byte("e") }},
{"ephemeral key", func(tx *Transaction) { tx.Outputs[0].EphemeralPubKey = []byte("k") }},
{"output proof", func(tx *Transaction) { tx.Outputs[0].OutputProof = []byte("r") }},
{"proof type", func(tx *Transaction) { tx.Proof.ProofType = "plonk" }},
{"proof bytes", func(tx *Transaction) { tx.Proof.ProofData = []byte("q") }},
{"public inputs", func(tx *Transaction) { tx.Proof.PublicInputs = [][]byte{[]byte("i")} }},
{"payer", func(tx *Transaction) { tx.TransparentInputs[0].Address = []byte("other") }},
{"input amount", func(tx *Transaction) { tx.TransparentInputs[0].Amount = 999 }},
{"input source", func(tx *Transaction) { tx.TransparentInputs[0].TxID = ids.ID{0xAB} }},
{"input index", func(tx *Transaction) { tx.TransparentInputs[0].OutputIdx = 1 }},
{"payee", func(tx *Transaction) { tx.TransparentOutputs[0].Address = []byte("thief") }},
{"output amount", func(tx *Transaction) { tx.TransparentOutputs[0].Amount = 999 }},
{"asset", func(tx *Transaction) { tx.TransparentOutputs[0].AssetID = ids.ID{0xCD} }},
{"dropped input", func(tx *Transaction) { tx.TransparentInputs = nil }},
{"dropped proof", func(tx *Transaction) { tx.Proof = nil }},
} {
t.Run(tc.name, func(t *testing.T) {
altered := shieldTx()
tc.alter(altered)
require.NotEqual(t, shieldTx().ComputeID(), altered.ComputeID(),
"changing the %s left the transaction ID alone, so it can be changed for free", tc.name)
})
}
}
// TestIDCannotBeForgedByMovingAByteBetweenFields. Variable-length fields written
// one after another with no length between them let a byte move from the end of
// one to the start of the next: ["ab","c"] and ["a","bc"] are the same bytes. An
// attacker who can produce two transactions with one ID chooses which one the
// network keeps after the other has been accepted.
func TestIDCannotBeForgedByMovingAByteBetweenFields(t *testing.T) {
split := shieldTx()
split.Nullifiers = [][]byte{[]byte("ab"), []byte("c")}
moved := shieldTx()
moved.Nullifiers = [][]byte{[]byte("a"), []byte("bc")}
require.NotEqual(t, split.ComputeID(), moved.ComputeID(),
"two different nullifier sets hash the same, so their transactions share an ID")
// The same across a field boundary rather than within one list.
long := shieldTx()
long.Outputs = []*ShieldedOutput{{Commitment: []byte("cd"), EncryptedNote: nil}}
short := shieldTx()
short.Outputs = []*ShieldedOutput{{Commitment: []byte("c"), EncryptedNote: []byte("d")}}
require.NotEqual(t, long.ComputeID(), short.ComputeID(),
"a byte moved from the commitment into the note left the ID unchanged")
}
// The identity has to be stable, or a transaction changes ID by being asked for
// it twice.
func TestIDIsStable(t *testing.T) {
require.Equal(t, shieldTx().ComputeID(), shieldTx().ComputeID())
}