feat: complete mldsafx — ML-DSA-65 PQ signing for X-Chain UTXO

This commit is contained in:
Hanzo AI
2026-04-13 05:34:03 -07:00
parent 4ef60fb2b0
commit 566a3f4fed
14 changed files with 1297 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/crypto/mldsa"
)
func TestCredentialVerify(t *testing.T) {
require := require.New(t)
sig := make([]byte, MLDSA65SigLen)
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
require.NoError(cred.Verify())
}
func TestCredentialVerifyNil(t *testing.T) {
var cred *Credential
require.ErrorIs(t, cred.Verify(), ErrNilCredential)
}
func TestCredentialVerifyWrongSigLen(t *testing.T) {
sig := make([]byte, 100) // wrong length
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
require.ErrorIs(t, cred.Verify(), ErrMismatchedSecLevel)
}
func TestCredentialVerifyBadSecLevel(t *testing.T) {
cred := &Credential{
Level: SecurityLevel(99),
Sigs: [][]byte{make([]byte, 100)},
}
require.ErrorIs(t, cred.Verify(), ErrInvalidSecLevel)
}
func TestCredentialVerifyMultipleSigs(t *testing.T) {
require := require.New(t)
sig1 := make([]byte, MLDSA65SigLen)
sig2 := make([]byte, MLDSA65SigLen)
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig1, sig2},
}
require.NoError(cred.Verify())
}
func TestCredentialVerifyMLDSA44(t *testing.T) {
require := require.New(t)
sig := make([]byte, MLDSA44SigLen)
cred := &Credential{
Level: SecLevelMLDSA44,
Sigs: [][]byte{sig},
}
require.NoError(cred.Verify())
}
func TestCredentialVerifyMLDSA87(t *testing.T) {
require := require.New(t)
sig := make([]byte, MLDSA87SigLen)
cred := &Credential{
Level: SecLevelMLDSA87,
Sigs: [][]byte{sig},
}
require.NoError(cred.Verify())
}
func TestNewCredential65(t *testing.T) {
require := require.New(t)
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
msg := []byte("test")
sig, err := sk.Sign(rand.Reader, msg, nil)
require.NoError(err)
cred, err := NewCredential65([][]byte{sig})
require.NoError(err)
require.Equal(SecLevelMLDSA65, cred.Level)
require.Len(cred.Sigs, 1)
}
func TestCredentialJSON(t *testing.T) {
require := require.New(t)
sig := make([]byte, MLDSA65SigLen)
for i := range sig {
sig[i] = byte(i % 256)
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
data, err := cred.MarshalJSON()
require.NoError(err)
var decoded Credential
require.NoError(decoded.UnmarshalJSON(data))
require.Equal(SecLevelMLDSA65, decoded.Level)
require.Len(decoded.Sigs, 1)
require.Equal(sig, decoded.Sigs[0])
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"github.com/luxfi/vm/fx"
)
const Name = "mldsafx"
var _ fx.Factory = (*Factory)(nil)
type Factory struct{}
func (*Factory) New() any {
return &Fx{}
}
+257
View File
@@ -0,0 +1,257 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"errors"
"fmt"
"strings"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/vm/components/verify"
)
var (
ErrWrongVMType = errors.New("wrong vm type")
ErrWrongTxType = errors.New("wrong tx type")
ErrWrongOpType = errors.New("wrong operation type")
ErrWrongUTXOType = errors.New("wrong utxo type")
ErrWrongInputType = errors.New("wrong input type")
ErrWrongCredentialType = errors.New("wrong credential type")
ErrWrongOwnerType = errors.New("wrong owner type")
ErrMismatchedAmounts = errors.New("utxo amount and input amount are not equal")
ErrWrongNumberOfUTXOs = errors.New("wrong number of utxos for the operation")
ErrWrongMintCreated = errors.New("wrong mint output created from the operation")
ErrTimelocked = errors.New("output is time locked")
ErrTooManySigners = errors.New("input has more signers than expected")
ErrTooFewSigners = errors.New("input has less signers than expected")
ErrInputOutputIndexOutOfBounds = errors.New("input referenced a nonexistent address in the output")
ErrInputCredentialSignersMismatch = errors.New("input expected a different number of signers than provided in the credential")
ErrWrongSig = errors.New("wrong signature")
)
// Fx describes the ML-DSA feature extension for post-quantum secure UTXO spending
type Fx struct {
VM VM
bootstrapped bool
}
func (fx *Fx) Initialize(vmIntf interface{}) error {
if err := fx.InitializeVM(vmIntf); err != nil {
return err
}
log := fx.VM.Logger()
if !log.IsZero() {
log.Debug("initializing mldsafx")
}
if fx.VM == nil {
return nil
}
c := fx.VM.CodecRegistry()
if c == nil {
return nil
}
errs := []error{}
if err := c.RegisterType(&TransferInput{}); err != nil && !strings.Contains(err.Error(), "duplicate type registration") {
errs = append(errs, err)
}
if err := c.RegisterType(&MintOutput{}); err != nil && !strings.Contains(err.Error(), "duplicate type registration") {
errs = append(errs, err)
}
if err := c.RegisterType(&TransferOutput{}); err != nil && !strings.Contains(err.Error(), "duplicate type registration") {
errs = append(errs, err)
}
if err := c.RegisterType(&MintOperation{}); err != nil && !strings.Contains(err.Error(), "duplicate type registration") {
errs = append(errs, err)
}
if err := c.RegisterType(&Credential{}); err != nil && !strings.Contains(err.Error(), "duplicate type registration") {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func (fx *Fx) InitializeVM(vmIntf interface{}) error {
vm, ok := vmIntf.(VM)
if !ok {
return ErrWrongVMType
}
fx.VM = vm
return nil
}
func (*Fx) Bootstrapping() error {
return nil
}
func (fx *Fx) Bootstrapped() error {
fx.bootstrapped = true
return nil
}
// VerifyPermission returns nil iff [credIntf] proves that [ownerIntf] assents to [txIntf]
func (fx *Fx) VerifyPermission(txIntf, inIntf, credIntf, ownerIntf interface{}) error {
tx, ok := txIntf.(UnsignedTx)
if !ok {
return ErrWrongTxType
}
in, ok := inIntf.(*Input)
if !ok {
return ErrWrongInputType
}
cred, ok := credIntf.(*Credential)
if !ok {
return ErrWrongCredentialType
}
owner, ok := ownerIntf.(*OutputOwners)
if !ok {
return ErrWrongOwnerType
}
if err := verify.All(in, cred, owner); err != nil {
return err
}
return fx.VerifyCredentials(tx, in, cred, owner)
}
func (fx *Fx) VerifyOperation(txIntf, opIntf, credIntf interface{}, utxosIntf []interface{}) error {
tx, ok := txIntf.(UnsignedTx)
if !ok {
return ErrWrongTxType
}
op, ok := opIntf.(*MintOperation)
if !ok {
return ErrWrongOpType
}
cred, ok := credIntf.(*Credential)
if !ok {
return ErrWrongCredentialType
}
if len(utxosIntf) != 1 {
return ErrWrongNumberOfUTXOs
}
out, ok := utxosIntf[0].(*MintOutput)
if !ok {
return ErrWrongUTXOType
}
return fx.verifyOperation(tx, op, cred, out)
}
func (fx *Fx) verifyOperation(tx UnsignedTx, op *MintOperation, cred *Credential, utxo *MintOutput) error {
if err := verify.All(op, cred, utxo); err != nil {
return err
}
if !utxo.OutputOwners.Equals(&op.MintOutput.OutputOwners) {
return ErrWrongMintCreated
}
return fx.VerifyCredentials(tx, &op.MintInput, cred, &utxo.OutputOwners)
}
func (fx *Fx) VerifyTransfer(txIntf, inIntf, credIntf, utxoIntf interface{}) error {
tx, ok := txIntf.(UnsignedTx)
if !ok {
return ErrWrongTxType
}
in, ok := inIntf.(*TransferInput)
if !ok {
return ErrWrongInputType
}
cred, ok := credIntf.(*Credential)
if !ok {
return ErrWrongCredentialType
}
out, ok := utxoIntf.(*TransferOutput)
if !ok {
return ErrWrongUTXOType
}
return fx.VerifySpend(tx, in, cred, out)
}
// VerifySpend ensures that the utxo can be sent to any address
func (fx *Fx) VerifySpend(utx UnsignedTx, in *TransferInput, cred *Credential, utxo *TransferOutput) error {
if err := verify.All(utxo, in, cred); err != nil {
return err
}
if utxo.Amt != in.Amt {
return fmt.Errorf("%w: %d != %d", ErrMismatchedAmounts, utxo.Amt, in.Amt)
}
return fx.VerifyCredentials(utx, &in.Input, cred, &utxo.OutputOwners)
}
// VerifyCredentials ensures that the output can be spent by the input with the
// credential. ML-DSA-65 signatures are verified directly against the public key
// stored in OutputOwners.Addrs.
func (fx *Fx) VerifyCredentials(utx UnsignedTx, in *Input, cred *Credential, out *OutputOwners) error {
numSigs := len(in.SigIndices)
switch {
case out.Locktime > fx.VM.Clock().Unix():
return ErrTimelocked
case out.Threshold < uint32(numSigs):
return ErrTooManySigners
case out.Threshold > uint32(numSigs):
return ErrTooFewSigners
case numSigs != len(cred.Sigs):
return ErrInputCredentialSignersMismatch
case !fx.bootstrapped: // disable signature verification during bootstrapping
return nil
}
txBytes := utx.Bytes()
for i, index := range in.SigIndices {
if index >= uint32(len(out.Addrs)) {
return ErrInputOutputIndexOutOfBounds
}
sig := cred.Sigs[i]
pkBytes := out.Addrs[index]
// Reconstruct public key and verify ML-DSA-65 signature
pk, err := mldsa.PublicKeyFromBytes(pkBytes, mldsaMode(out.Level))
if err != nil {
return fmt.Errorf("%w: invalid public key at index %d: %v", ErrWrongSig, index, err)
}
if !pk.VerifySignature(txBytes, sig) {
// Derive address for error message
addressBytes := hash.PubkeyBytesToAddress(pkBytes)
return fmt.Errorf("%w: ML-DSA-65 verification failed for address %x",
ErrWrongSig, addressBytes)
}
}
return nil
}
// CreateOutput creates a new output with the provided control group worth
// the specified amount
func (*Fx) CreateOutput(amount uint64, ownerIntf interface{}) (interface{}, error) {
owner, ok := ownerIntf.(*OutputOwners)
if !ok {
return nil, ErrWrongOwnerType
}
if err := owner.Verify(); err != nil {
return nil, err
}
return &TransferOutput{
Amt: amount,
OutputOwners: *owner,
}, nil
}
// mldsaMode converts SecurityLevel to mldsa.Mode
func mldsaMode(level SecurityLevel) mldsa.Mode {
switch level {
case SecLevelMLDSA44:
return mldsa.MLDSA44
case SecLevelMLDSA65:
return mldsa.MLDSA65
case SecLevelMLDSA87:
return mldsa.MLDSA87
default:
return mldsa.MLDSA65
}
}
+412
View File
@@ -0,0 +1,412 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"crypto/rand"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/codec/linearcodec"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
log "github.com/luxfi/log"
)
func newTestFx(t *testing.T) (*Fx, *mldsa.PrivateKey, []byte) {
t.Helper()
require := require.New(t)
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
vm := &TestVM{
Codec: linearcodec.NewDefault(),
Log: log.NewNoOpLogger(),
}
vm.Clk.Set(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC))
fx := &Fx{}
require.NoError(fx.Initialize(vm))
require.NoError(fx.Bootstrapping())
require.NoError(fx.Bootstrapped())
pkBytes := sk.PublicKey.Bytes()
return fx, sk, pkBytes
}
func TestFxInitialize(t *testing.T) {
vm := TestVM{
Codec: linearcodec.NewDefault(),
Log: log.NewNoOpLogger(),
}
fx := Fx{}
require.NoError(t, fx.Initialize(&vm))
}
func TestFxInitializeInvalid(t *testing.T) {
fx := Fx{}
err := fx.Initialize(nil)
require.ErrorIs(t, err, ErrWrongVMType)
}
func TestFxVerifyTransfer(t *testing.T) {
require := require.New(t)
fx, sk, pkBytes := newTestFx(t)
txBytes := []byte{0, 1, 2, 3, 4, 5}
tx := &TestTx{UnsignedBytes: txBytes}
sig, err := sk.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
out := &TransferOutput{
Amt: 1,
OutputOwners: OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
},
}
in := &TransferInput{
Amt: 1,
Input: Input{
SigIndices: []uint32{0},
},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
require.NoError(fx.VerifyTransfer(tx, in, cred, out))
}
func TestFxVerifyTransferWrongSig(t *testing.T) {
require := require.New(t)
fx, _, pkBytes := newTestFx(t)
txBytes := []byte{0, 1, 2, 3, 4, 5}
tx := &TestTx{UnsignedBytes: txBytes}
// Create a bad signature
badSig := make([]byte, mldsa.MLDSA65SignatureSize)
out := &TransferOutput{
Amt: 1,
OutputOwners: OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
},
}
in := &TransferInput{
Amt: 1,
Input: Input{
SigIndices: []uint32{0},
},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{badSig},
}
err := fx.VerifyTransfer(tx, in, cred, out)
require.ErrorIs(err, ErrWrongSig)
}
func TestFxVerifyTransferMismatchedAmounts(t *testing.T) {
require := require.New(t)
fx, sk, pkBytes := newTestFx(t)
txBytes := []byte{0, 1, 2, 3, 4, 5}
tx := &TestTx{UnsignedBytes: txBytes}
sig, err := sk.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
out := &TransferOutput{
Amt: 1,
OutputOwners: OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
},
}
in := &TransferInput{
Amt: 2, // mismatched
Input: Input{
SigIndices: []uint32{0},
},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
err = fx.VerifyTransfer(tx, in, cred, out)
require.ErrorIs(err, ErrMismatchedAmounts)
}
func TestFxVerifyTransferTimelocked(t *testing.T) {
require := require.New(t)
fx, sk, pkBytes := newTestFx(t)
txBytes := []byte{0, 1, 2, 3, 4, 5}
tx := &TestTx{UnsignedBytes: txBytes}
sig, err := sk.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
out := &TransferOutput{
Amt: 1,
OutputOwners: OutputOwners{
Level: SecLevelMLDSA65,
Locktime: uint64(time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC).Unix()),
Threshold: 1,
Addrs: [][]byte{pkBytes},
},
}
in := &TransferInput{
Amt: 1,
Input: Input{
SigIndices: []uint32{0},
},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
err = fx.VerifyTransfer(tx, in, cred, out)
require.ErrorIs(err, ErrTimelocked)
}
func TestFxVerifyCredentials(t *testing.T) {
require := require.New(t)
fx, sk, pkBytes := newTestFx(t)
txBytes := []byte("test transaction")
tx := &TestTx{UnsignedBytes: txBytes}
sig, err := sk.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
out := &OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
}
in := &Input{
SigIndices: []uint32{0},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
require.NoError(fx.VerifyCredentials(tx, in, cred, out))
}
func TestFxVerifyCredentialsMultiSig(t *testing.T) {
require := require.New(t)
fx, _, _ := newTestFx(t)
sk1, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
sk2, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
pk1 := sk1.PublicKey.Bytes()
pk2 := sk2.PublicKey.Bytes()
// Sort keys lexicographically
if string(pk1) > string(pk2) {
pk1, pk2 = pk2, pk1
sk1, sk2 = sk2, sk1
}
txBytes := []byte("multi-sig tx")
tx := &TestTx{UnsignedBytes: txBytes}
sig1, err := sk1.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
sig2, err := sk2.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
out := &OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 2,
Addrs: [][]byte{pk1, pk2},
}
in := &Input{
SigIndices: []uint32{0, 1},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig1, sig2},
}
require.NoError(fx.VerifyCredentials(tx, in, cred, out))
}
func TestFxVerifyPermission(t *testing.T) {
require := require.New(t)
fx, sk, pkBytes := newTestFx(t)
txBytes := []byte("permission tx")
tx := &TestTx{UnsignedBytes: txBytes}
sig, err := sk.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
owner := &OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
}
in := &Input{
SigIndices: []uint32{0},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
require.NoError(fx.VerifyPermission(tx, in, cred, owner))
}
func TestFxVerifyPermissionWrongTypes(t *testing.T) {
fx, _, _ := newTestFx(t)
require.ErrorIs(t, fx.VerifyPermission("bad", nil, nil, nil), ErrWrongTxType)
tx := &TestTx{}
require.ErrorIs(t, fx.VerifyPermission(tx, "bad", nil, nil), ErrWrongInputType)
require.ErrorIs(t, fx.VerifyPermission(tx, &Input{}, "bad", nil), ErrWrongCredentialType)
require.ErrorIs(t, fx.VerifyPermission(tx, &Input{}, &Credential{Level: SecLevelMLDSA65}, "bad"), ErrWrongOwnerType)
}
func TestFxVerifyTransferBootstrapping(t *testing.T) {
require := require.New(t)
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
pkBytes := sk.PublicKey.Bytes()
vm := &TestVM{
Codec: linearcodec.NewDefault(),
Log: log.NewNoOpLogger(),
}
vm.Clk.Set(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC))
fx := &Fx{}
require.NoError(fx.Initialize(vm))
require.NoError(fx.Bootstrapping())
// NOT calling Bootstrapped — should skip sig verification
txBytes := []byte{0, 1, 2, 3, 4, 5}
tx := &TestTx{UnsignedBytes: txBytes}
// Use a bad signature — should still pass during bootstrap
badSig := make([]byte, mldsa.MLDSA65SignatureSize)
out := &TransferOutput{
Amt: 1,
OutputOwners: OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
},
}
in := &TransferInput{
Amt: 1,
Input: Input{
SigIndices: []uint32{0},
},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{badSig},
}
require.NoError(fx.VerifyTransfer(tx, in, cred, out))
}
func TestFxCreateOutput(t *testing.T) {
require := require.New(t)
fx, _, pkBytes := newTestFx(t)
owner := &OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
}
result, err := fx.CreateOutput(100, owner)
require.NoError(err)
out, ok := result.(*TransferOutput)
require.True(ok)
require.Equal(uint64(100), out.Amt)
}
func TestFxVerifyOperation(t *testing.T) {
require := require.New(t)
fx, sk, pkBytes := newTestFx(t)
txBytes := []byte("mint operation tx")
tx := &TestTx{UnsignedBytes: txBytes}
sig, err := sk.Sign(rand.Reader, txBytes, nil)
require.NoError(err)
owners := OutputOwners{
Level: SecLevelMLDSA65,
Locktime: 0,
Threshold: 1,
Addrs: [][]byte{pkBytes},
}
utxo := &MintOutput{OutputOwners: owners}
op := &MintOperation{
MintInput: Input{SigIndices: []uint32{0}},
MintOutput: MintOutput{
OutputOwners: owners,
},
TransferOutput: TransferOutput{
Amt: 1,
OutputOwners: owners,
},
}
cred := &Credential{
Level: SecLevelMLDSA65,
Sigs: [][]byte{sig},
}
require.NoError(fx.VerifyOperation(tx, op, cred, []interface{}{utxo}))
}
func TestFxAddressDerivation(t *testing.T) {
require := require.New(t)
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(err)
pkBytes := sk.PublicKey.Bytes()
addressBytes := hash.PubkeyBytesToAddress(pkBytes)
addr, err := ids.ToShortID(addressBytes)
require.NoError(err)
require.NotEqual(ids.ShortEmpty, addr)
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"errors"
"github.com/luxfi/math"
"github.com/luxfi/utils"
)
const (
// CostPerSignature is the compute cost per ML-DSA signature verification.
// Higher than secp256k1 due to larger signatures and lattice math.
CostPerSignature uint64 = 3000
)
var (
ErrNilInput = errors.New("nil input")
ErrInputIndicesNotSortedUnique = errors.New("address indices not sorted and unique")
)
// Input references signature indices into the credential for spending.
type Input struct {
SigIndices []uint32 `serialize:"true" json:"signatureIndices"`
}
func (in *Input) Cost() (uint64, error) {
numSigs := uint64(len(in.SigIndices))
return math.Mul64(numSigs, CostPerSignature)
}
// Verify this input is syntactically valid
func (in *Input) Verify() error {
switch {
case in == nil:
return ErrNilInput
case !utils.IsSortedAndUniqueOrdered(in.SigIndices):
return ErrInputIndicesNotSortedUnique
default:
return nil
}
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestInputVerify(t *testing.T) {
require := require.New(t)
in := &Input{
SigIndices: []uint32{0, 1, 2},
}
require.NoError(in.Verify())
}
func TestInputVerifyNil(t *testing.T) {
var in *Input
require.ErrorIs(t, in.Verify(), ErrNilInput)
}
func TestInputVerifyUnsorted(t *testing.T) {
in := &Input{
SigIndices: []uint32{2, 0, 1},
}
require.ErrorIs(t, in.Verify(), ErrInputIndicesNotSortedUnique)
}
func TestInputVerifyDuplicate(t *testing.T) {
in := &Input{
SigIndices: []uint32{0, 0, 1},
}
require.ErrorIs(t, in.Verify(), ErrInputIndicesNotSortedUnique)
}
func TestInputCost(t *testing.T) {
require := require.New(t)
in := &Input{
SigIndices: []uint32{0, 1},
}
cost, err := in.Cost()
require.NoError(err)
require.Equal(uint64(6000), cost)
}
func TestTransferInputVerify(t *testing.T) {
require := require.New(t)
in := &TransferInput{
Amt: 100,
Input: Input{
SigIndices: []uint32{0},
},
}
require.NoError(in.Verify())
}
func TestTransferInputVerifyNoValue(t *testing.T) {
in := &TransferInput{
Amt: 0,
Input: Input{
SigIndices: []uint32{0},
},
}
require.ErrorIs(t, in.Verify(), ErrNoValueInput)
}
func TestTransferInputVerifyNil(t *testing.T) {
var in *TransferInput
require.ErrorIs(t, in.Verify(), ErrNilInput)
}
func TestTransferInputAmount(t *testing.T) {
in := &TransferInput{Amt: 42}
require.Equal(t, uint64(42), in.Amount())
}
+155
View File
@@ -0,0 +1,155 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"crypto/rand"
"errors"
"fmt"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
"github.com/luxfi/keychain"
"github.com/luxfi/math/set"
"github.com/luxfi/vm/components/verify"
)
var (
errCantSpend = errors.New("unable to spend this UTXO")
_ keychain.Signer = (*mldsaSigner)(nil)
_ keychain.Keychain = (*Keychain)(nil)
)
// mldsaSigner wraps an ML-DSA private key to implement keychain.Signer
type mldsaSigner struct {
key *mldsa.PrivateKey
}
func (s *mldsaSigner) SignHash(h []byte) ([]byte, error) {
// ML-DSA signs messages directly, not hashes. Use the hash as the message.
return s.key.Sign(rand.Reader, h, nil)
}
func (s *mldsaSigner) Sign(msg []byte) ([]byte, error) {
return s.key.Sign(rand.Reader, msg, nil)
}
func (s *mldsaSigner) Address() ids.ShortID {
pkBytes := s.key.PublicKey.Bytes()
addressBytes := hash.PubkeyBytesToAddress(pkBytes)
addr, _ := ids.ToShortID(addressBytes)
return addr
}
// Keychain is a collection of ML-DSA keys that can be used to spend outputs
type Keychain struct {
addrToKeyIndex map[ids.ShortID]int
Addrs set.Set[ids.ShortID]
Keys []*mldsa.PrivateKey
}
// NewKeychain returns a new keychain containing [keys]
func NewKeychain(keys ...*mldsa.PrivateKey) *Keychain {
kc := &Keychain{
addrToKeyIndex: make(map[ids.ShortID]int),
Addrs: make(set.Set[ids.ShortID]),
}
for _, key := range keys {
kc.Add(key)
}
return kc
}
// Add a new key to the key chain
func (kc *Keychain) Add(key *mldsa.PrivateKey) {
pkBytes := key.PublicKey.Bytes()
addressBytes := hash.PubkeyBytesToAddress(pkBytes)
addr, _ := ids.ToShortID(addressBytes)
if _, ok := kc.addrToKeyIndex[addr]; !ok {
kc.addrToKeyIndex[addr] = len(kc.Keys)
kc.Keys = append(kc.Keys, key)
kc.Addrs.Add(addr)
}
}
// Get a key from the keychain. Returns keychain.Signer.
func (kc Keychain) Get(id ids.ShortID) (keychain.Signer, bool) {
if i, ok := kc.addrToKeyIndex[id]; ok {
return &mldsaSigner{key: kc.Keys[i]}, true
}
return nil, false
}
// Addresses returns the set of addresses this keychain manages
func (kc Keychain) Addresses() set.Set[ids.ShortID] {
return kc.Addrs
}
// New generates a new ML-DSA-65 key pair and adds it to the keychain
func (kc *Keychain) New() (*mldsa.PrivateKey, error) {
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
return nil, err
}
kc.Add(sk)
return sk, nil
}
// Spend attempts to create an input for the given output
func (kc *Keychain) Spend(out verify.Verifiable, time uint64) (verify.Verifiable, []*mldsa.PrivateKey, error) {
switch out := out.(type) {
case *MintOutput:
if sigIndices, keys, able := kc.Match(&out.OutputOwners, time); able {
return &Input{
SigIndices: sigIndices,
}, keys, nil
}
return nil, nil, errCantSpend
case *TransferOutput:
if sigIndices, keys, able := kc.Match(&out.OutputOwners, time); able {
return &TransferInput{
Amt: out.Amt,
Input: Input{
SigIndices: sigIndices,
},
}, keys, nil
}
return nil, nil, errCantSpend
}
return nil, nil, fmt.Errorf("can't spend UTXO because it is unexpected type %T", out)
}
// Match attempts to match a list of addresses up to the provided threshold
func (kc *Keychain) Match(owners *OutputOwners, time uint64) ([]uint32, []*mldsa.PrivateKey, bool) {
if time < owners.Locktime {
return nil, nil, false
}
sigs := make([]uint32, 0, owners.Threshold)
keys := make([]*mldsa.PrivateKey, 0, owners.Threshold)
for i := uint32(0); i < uint32(len(owners.Addrs)) && uint32(len(keys)) < owners.Threshold; i++ {
// Derive address from stored public key
addressBytes := hash.PubkeyBytesToAddress(owners.Addrs[i])
addr, err := ids.ToShortID(addressBytes)
if err != nil {
continue
}
if idx, exists := kc.addrToKeyIndex[addr]; exists {
sigs = append(sigs, i)
keys = append(keys, kc.Keys[idx])
}
}
return sigs, keys, uint32(len(keys)) == owners.Threshold
}
// get returns the raw private key for the given address
func (kc Keychain) get(id ids.ShortID) (*mldsa.PrivateKey, bool) {
if i, ok := kc.addrToKeyIndex[id]; ok {
return kc.Keys[i], true
}
return nil, false
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"errors"
"github.com/luxfi/vm/components/verify"
)
var errNilMintOperation = errors.New("nil mint operation")
type MintOperation struct {
MintInput Input `serialize:"true" json:"mintInput"`
MintOutput MintOutput `serialize:"true" json:"mintOutput"`
TransferOutput TransferOutput `serialize:"true" json:"transferOutput"`
}
func (op *MintOperation) Cost() (uint64, error) {
return op.MintInput.Cost()
}
func (op *MintOperation) Outs() []verify.State {
return []verify.State{&op.MintOutput, &op.TransferOutput}
}
func (op *MintOperation) Verify() error {
if op == nil {
return errNilMintOperation
}
return verify.All(&op.MintInput, &op.MintOutput, &op.TransferOutput)
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"github.com/luxfi/vm/components/verify"
)
var _ verify.State = (*MintOutput)(nil)
type MintOutput struct {
verify.IsState `serialize:"-" json:"-"`
OutputOwners `serialize:"true"`
}
func (out *MintOutput) Verify() error {
if out == nil {
return ErrNilOutputOwners
}
return out.OutputOwners.Verify()
}
+18
View File
@@ -99,6 +99,24 @@ func (out *OutputOwners) MarshalJSON() ([]byte, error) {
})
}
// Equals returns true if the provided owners create the same condition
func (out *OutputOwners) Equals(other *OutputOwners) bool {
if out == other {
return true
}
if out == nil || other == nil || out.Level != other.Level ||
out.Locktime != other.Locktime || out.Threshold != other.Threshold ||
len(out.Addrs) != len(other.Addrs) {
return false
}
for i, addr := range out.Addrs {
if string(addr) != string(other.Addrs[i]) {
return false
}
}
return true
}
// NewOutputOwners creates a new ML-DSA output owners
func NewOutputOwners(level SecurityLevel, locktime uint64, threshold uint32, addrs [][]byte) (*OutputOwners, error) {
out := &OutputOwners{
+36
View File
@@ -0,0 +1,36 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"errors"
"github.com/luxfi/runtime"
)
var ErrNoValueInput = errors.New("input has no value")
type TransferInput struct {
Amt uint64 `serialize:"true" json:"amount"`
Input `serialize:"true"`
}
func (*TransferInput) InitRuntime(*runtime.Runtime) {}
// Amount returns the quantity of the asset this input produces
func (in *TransferInput) Amount() uint64 {
return in.Amt
}
// Verify this input is syntactically valid
func (in *TransferInput) Verify() error {
switch {
case in == nil:
return ErrNilInput
case in.Amt == 0:
return ErrNoValueInput
default:
return in.Input.Verify()
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"errors"
"github.com/luxfi/vm/components/verify"
)
var (
_ verify.State = (*TransferOutput)(nil)
ErrNoValueOutput = errors.New("output has no value")
)
type TransferOutput struct {
verify.IsState `serialize:"-" json:"-"`
Amt uint64 `serialize:"true" json:"amount"`
OutputOwners `serialize:"true"`
}
// Amount returns the quantity of the asset this output consumes
func (out *TransferOutput) Amount() uint64 {
return out.Amt
}
func (out *TransferOutput) Verify() error {
switch {
case out == nil:
return ErrNilOutputOwners
case out.Amt == 0:
return ErrNoValueOutput
default:
return out.OutputOwners.Verify()
}
}
func (out *TransferOutput) Owners() interface{} {
return &out.OutputOwners
}
func (*TransferOutput) isState() {}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
// UnsignedTx that this Fx is supporting
type UnsignedTx interface {
Bytes() []byte
}
var _ UnsignedTx = (*TestTx)(nil)
// TestTx is a minimal implementation of a Tx
type TestTx struct{ UnsignedBytes []byte }
// Bytes returns UnsignedBytes
func (tx *TestTx) Bytes() []byte {
return tx.UnsignedBytes
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsafx
import (
"github.com/luxfi/codec"
log "github.com/luxfi/log"
"github.com/luxfi/timer/mockable"
)
// VM that this Fx must be run by
type VM interface {
CodecRegistry() codec.Registry
Clock() *mockable.Clock
Logger() log.Logger
}
var _ VM = (*TestVM)(nil)
// TestVM is a minimal implementation of a VM
type TestVM struct {
Clk mockable.Clock
Codec codec.Registry
Log log.Logger
}
func (vm *TestVM) Clock() *mockable.Clock {
return &vm.Clk
}
func (vm *TestVM) CodecRegistry() codec.Registry {
return vm.Codec
}
func (vm *TestVM) Logger() log.Logger {
return vm.Log
}