mirror of
https://github.com/luxfi/threshold.git
synced 2026-10-30 15:15:53 +00:00
The stack had two different numbers called "threshold". cmp.Keygen and
frost.Keygen take t, the polynomial degree, and need t+1 signers. Operators,
dashboards and runbooks say k-of-n. Provisioning a "3-of-5" wallet by passing
threshold=3 produces a 4-of-5, silently — nothing errors, and the mistake only
surfaces when three custodians hold a ceremony and cannot sign a key that
already holds value.
pkg/quorum is now the single place those two numbers meet. Policy{K,N} states
what operators mean; Degree() == K-1 is the only k->t conversion in the tree.
Callers pass policy.Degree() and never compute the off-by-one themselves.
Proofs, all against the real protocols rather than mocks:
protocols/quorum_3of5_test.go — five-party CGGMP21 DKG at the policy degree,
three parties sign, and the signature both verifies under the group key and
ECRECOVERS to the group's EVM address. Recovery is the part that matters for
custody: a key can verify correctly and still resolve to an address no one
controls. Four different 3-subsets all sign for one address. Negatives: every
2-of-5 subset is refused, and a forged third share cannot produce a signature
that verifies. FROST covered at the same policy.
protocols/safe_3of5_test.go — the custody claim end to end. Real SafeL2 v1.5.0
bytecode behind the real SafeProxyFactory on the luxfi/geth EVM, owned solely
by the 3-of-5 group address. The digest comes from the deployed Safe's own
getTransactionHash, so the test cannot sign something the contract would not
check. execTransaction succeeds, value moves, the nonce advances, a replay is
rejected, and a valid signature from a different 3-of-5 key is rejected.
Fixes found along the way:
protocols/lss/adapters/evm.go — GenerateEVMAddress hashed the COMPRESSED
point, i.e. 32 bytes of X, where the EVM requires Keccak over the 64-byte
uncompressed X||Y. Every address it produced was well-formed and belonged to
no key; value sent to one is unspendable. It had no callers, so this was a
loaded gun rather than a live wound. Now decompresses, and reports failure
instead of returning a plausible wrong answer.
internal/test/harness.go — WithTimeout moved the harness deadline but not the
handler's own ProtocolTimeout, which was hard-coded to five minutes, so a
raised timeout silently did nothing and a CGGMP21 DKG under -race died at
five minutes blaming the context. The harness now owns one deadline. Also
removes a duplicate RunProtocolWithTimeout.
protocols/cmp/cmp_threshold_test.go — cases labelled "3-of-5" passed degree 3
and signed with four parties. They were 4-of-5. Labels now derive from the
policy, so the name and the behaviour are one fact.
go.sum — was missing entries for luxfi/metric and mattn/go-isatty; the FROST
packages did not compile from a clean checkout.
Co-authored-by: Hanzo Dev <[email protected]>
547 lines
14 KiB
Go
547 lines
14 KiB
Go
// Package adapters - Generic EVM blockchain adapter
|
|
// Supports: Ethereum, BSC, Polygon, Lux, Arbitrum, Optimism, Base, etc.
|
|
package adapters
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
|
"github.com/luxfi/threshold/pkg/math/curve"
|
|
"golang.org/x/crypto/sha3"
|
|
)
|
|
|
|
// EVMChain represents different EVM-compatible chains
|
|
type EVMChain string
|
|
|
|
const (
|
|
Ethereum EVMChain = "ethereum"
|
|
BSC EVMChain = "bsc"
|
|
Polygon EVMChain = "polygon"
|
|
Lux EVMChain = "lux"
|
|
Arbitrum EVMChain = "arbitrum"
|
|
Optimism EVMChain = "optimism"
|
|
Base EVMChain = "base"
|
|
Fantom EVMChain = "fantom"
|
|
Cronos EVMChain = "cronos"
|
|
Harmony EVMChain = "harmony"
|
|
Moonbeam EVMChain = "moonbeam"
|
|
Aurora EVMChain = "aurora"
|
|
Gnosis EVMChain = "gnosis"
|
|
Scroll EVMChain = "scroll"
|
|
zkSync EVMChain = "zksync"
|
|
Linea EVMChain = "linea"
|
|
Mantle EVMChain = "mantle"
|
|
Celo EVMChain = "celo"
|
|
Kava EVMChain = "kava"
|
|
Metis EVMChain = "metis"
|
|
)
|
|
|
|
// ChainConfig contains chain-specific configuration
|
|
type ChainConfig struct {
|
|
ChainID *big.Int
|
|
Name string
|
|
Symbol string
|
|
ExplorerURL string
|
|
RPCURL string
|
|
IsL2 bool
|
|
SupportsEIP1559 bool
|
|
SupportsBlobTx bool
|
|
}
|
|
|
|
// GetChainConfig returns configuration for known chains
|
|
func GetChainConfig(chain EVMChain) *ChainConfig {
|
|
configs := map[EVMChain]*ChainConfig{
|
|
Ethereum: {
|
|
ChainID: big.NewInt(1),
|
|
Name: "Ethereum Mainnet",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://etherscan.io",
|
|
SupportsEIP1559: true,
|
|
SupportsBlobTx: true,
|
|
},
|
|
BSC: {
|
|
ChainID: big.NewInt(56),
|
|
Name: "BNB Smart Chain",
|
|
Symbol: "BNB",
|
|
ExplorerURL: "https://bscscan.com",
|
|
SupportsEIP1559: true,
|
|
},
|
|
Polygon: {
|
|
ChainID: big.NewInt(137),
|
|
Name: "Polygon",
|
|
Symbol: "MATIC",
|
|
ExplorerURL: "https://polygonscan.com",
|
|
SupportsEIP1559: true,
|
|
},
|
|
Lux: {
|
|
ChainID: big.NewInt(43114),
|
|
Name: "Lux C-Chain",
|
|
Symbol: "LUX",
|
|
ExplorerURL: "https://snowtrace.io",
|
|
SupportsEIP1559: true,
|
|
},
|
|
Arbitrum: {
|
|
ChainID: big.NewInt(42161),
|
|
Name: "Arbitrum One",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://arbiscan.io",
|
|
IsL2: true,
|
|
SupportsEIP1559: true,
|
|
},
|
|
Optimism: {
|
|
ChainID: big.NewInt(10),
|
|
Name: "OP Mainnet",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://optimistic.etherscan.io",
|
|
IsL2: true,
|
|
SupportsEIP1559: true,
|
|
},
|
|
Base: {
|
|
ChainID: big.NewInt(8453),
|
|
Name: "Base",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://basescan.org",
|
|
IsL2: true,
|
|
SupportsEIP1559: true,
|
|
},
|
|
Fantom: {
|
|
ChainID: big.NewInt(250),
|
|
Name: "Fantom Opera",
|
|
Symbol: "FTM",
|
|
ExplorerURL: "https://ftmscan.com",
|
|
},
|
|
Cronos: {
|
|
ChainID: big.NewInt(25),
|
|
Name: "Cronos",
|
|
Symbol: "CRO",
|
|
ExplorerURL: "https://cronoscan.com",
|
|
},
|
|
Harmony: {
|
|
ChainID: big.NewInt(1666600000),
|
|
Name: "Harmony",
|
|
Symbol: "ONE",
|
|
ExplorerURL: "https://explorer.harmony.one",
|
|
},
|
|
Moonbeam: {
|
|
ChainID: big.NewInt(1284),
|
|
Name: "Moonbeam",
|
|
Symbol: "GLMR",
|
|
ExplorerURL: "https://moonscan.io",
|
|
SupportsEIP1559: true,
|
|
},
|
|
Aurora: {
|
|
ChainID: big.NewInt(1313161554),
|
|
Name: "Aurora",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://aurorascan.dev",
|
|
},
|
|
Gnosis: {
|
|
ChainID: big.NewInt(100),
|
|
Name: "Gnosis Chain",
|
|
Symbol: "xDAI",
|
|
ExplorerURL: "https://gnosisscan.io",
|
|
SupportsEIP1559: true,
|
|
},
|
|
Scroll: {
|
|
ChainID: big.NewInt(534352),
|
|
Name: "Scroll",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://scrollscan.com",
|
|
IsL2: true,
|
|
SupportsEIP1559: true,
|
|
},
|
|
zkSync: {
|
|
ChainID: big.NewInt(324),
|
|
Name: "zkSync Era",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://explorer.zksync.io",
|
|
IsL2: true,
|
|
SupportsEIP1559: true,
|
|
},
|
|
Linea: {
|
|
ChainID: big.NewInt(59144),
|
|
Name: "Linea",
|
|
Symbol: "ETH",
|
|
ExplorerURL: "https://lineascan.build",
|
|
IsL2: true,
|
|
SupportsEIP1559: true,
|
|
},
|
|
Mantle: {
|
|
ChainID: big.NewInt(5000),
|
|
Name: "Mantle",
|
|
Symbol: "MNT",
|
|
ExplorerURL: "https://explorer.mantle.xyz",
|
|
IsL2: true,
|
|
},
|
|
Celo: {
|
|
ChainID: big.NewInt(42220),
|
|
Name: "Celo",
|
|
Symbol: "CELO",
|
|
ExplorerURL: "https://celoscan.io",
|
|
SupportsEIP1559: true,
|
|
},
|
|
Kava: {
|
|
ChainID: big.NewInt(2222),
|
|
Name: "Kava EVM",
|
|
Symbol: "KAVA",
|
|
ExplorerURL: "https://explorer.kava.io",
|
|
},
|
|
Metis: {
|
|
ChainID: big.NewInt(1088),
|
|
Name: "Metis Andromeda",
|
|
Symbol: "METIS",
|
|
ExplorerURL: "https://andromeda-explorer.metis.io",
|
|
IsL2: true,
|
|
},
|
|
}
|
|
|
|
if config, ok := configs[chain]; ok {
|
|
return config
|
|
}
|
|
|
|
// Default Ethereum config
|
|
return configs[Ethereum]
|
|
}
|
|
|
|
// EVMAdapter implements SignerAdapter for all EVM-compatible chains
|
|
type EVMAdapter struct {
|
|
group curve.Curve
|
|
chain EVMChain
|
|
config *ChainConfig
|
|
}
|
|
|
|
// NewEVMAdapter creates a new adapter for any EVM chain
|
|
func NewEVMAdapter(chain EVMChain) *EVMAdapter {
|
|
return &EVMAdapter{
|
|
group: curve.Secp256k1{},
|
|
chain: chain,
|
|
config: GetChainConfig(chain),
|
|
}
|
|
}
|
|
|
|
// SetCustomChainID allows setting custom chain ID for private/test networks
|
|
func (e *EVMAdapter) SetCustomChainID(chainID *big.Int) {
|
|
e.config.ChainID = chainID
|
|
}
|
|
|
|
// Digest computes transaction digest for EVM chains
|
|
func (e *EVMAdapter) Digest(tx interface{}) ([]byte, error) {
|
|
switch v := tx.(type) {
|
|
case *EVMTransaction:
|
|
return e.digestTransaction(v)
|
|
case []byte:
|
|
// Message hash for signing
|
|
return e.hashMessage(v), nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported transaction type: %T", tx)
|
|
}
|
|
}
|
|
|
|
// digestTransaction computes digest based on transaction type
|
|
func (e *EVMAdapter) digestTransaction(tx *EVMTransaction) ([]byte, error) {
|
|
h := sha3.NewLegacyKeccak256()
|
|
|
|
// Encode based on transaction type
|
|
var encoded []byte
|
|
switch tx.Type {
|
|
case LegacyTxType:
|
|
encoded = e.encodeLegacyTx(tx)
|
|
case AccessListTxType:
|
|
encoded = e.encodeAccessListTx(tx)
|
|
case DynamicFeeTxType:
|
|
if !e.config.SupportsEIP1559 {
|
|
return nil, fmt.Errorf("chain %s does not support EIP-1559", e.chain)
|
|
}
|
|
encoded = e.encodeDynamicFeeTx(tx)
|
|
case BlobTxType:
|
|
if !e.config.SupportsBlobTx {
|
|
return nil, fmt.Errorf("chain %s does not support blob transactions", e.chain)
|
|
}
|
|
encoded = e.encodeBlobTx(tx)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported transaction type: %d", tx.Type)
|
|
}
|
|
|
|
h.Write(encoded)
|
|
return h.Sum(nil), nil
|
|
}
|
|
|
|
// hashMessage creates EVM message hash with prefix
|
|
func (e *EVMAdapter) hashMessage(message []byte) []byte {
|
|
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
|
|
h := sha3.NewLegacyKeccak256()
|
|
h.Write([]byte(prefix))
|
|
h.Write(message)
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
// SignEC creates ECDSA partial signature
|
|
func (e *EVMAdapter) SignEC(digest []byte, share Share) (PartialSig, error) {
|
|
// For testing, provide a placeholder R value
|
|
return &ECDSAPartialSig{
|
|
PartyID: share.ID,
|
|
R: e.group.NewScalar(), // Placeholder for testing
|
|
S: share.Value,
|
|
}, nil
|
|
}
|
|
|
|
// AggregateEC combines ECDSA partial signatures
|
|
func (e *EVMAdapter) AggregateEC(parts []PartialSig) (FullSig, error) {
|
|
if len(parts) == 0 {
|
|
return nil, errors.New("no partial signatures")
|
|
}
|
|
|
|
var r, s curve.Scalar
|
|
|
|
for _, part := range parts {
|
|
ecdsaPart, ok := part.(*ECDSAPartialSig)
|
|
if !ok {
|
|
return nil, errors.New("invalid ECDSA partial signature")
|
|
}
|
|
|
|
if r == nil && ecdsaPart.R != nil {
|
|
r = ecdsaPart.R
|
|
}
|
|
|
|
if s == nil {
|
|
s = e.group.NewScalar()
|
|
}
|
|
s = s.Add(ecdsaPart.S)
|
|
}
|
|
|
|
// Apply low-s normalization for malleability protection
|
|
s = e.normalizeLowS(s)
|
|
|
|
return &ECDSAFullSig{
|
|
R: r,
|
|
S: s,
|
|
}, nil
|
|
}
|
|
|
|
// normalizeLowS ensures S is in lower half of curve order
|
|
func (e *EVMAdapter) normalizeLowS(s curve.Scalar) curve.Scalar {
|
|
// EVM chains require low-s normalization
|
|
orderMod := e.group.Order()
|
|
order := orderMod.Big()
|
|
halfOrder := new(big.Int).Div(order, big.NewInt(2))
|
|
|
|
sBig := new(big.Int)
|
|
sBytes, _ := s.MarshalBinary()
|
|
sBig.SetBytes(sBytes)
|
|
|
|
if sBig.Cmp(halfOrder) > 0 {
|
|
sBig.Sub(order, sBig)
|
|
s = e.group.NewScalar().SetNat(orderMod.Nat().SetBytes(sBig.Bytes()))
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// Encode formats ECDSA signature for EVM
|
|
func (e *EVMAdapter) Encode(full FullSig) ([]byte, error) {
|
|
ecdsaSig, ok := full.(*ECDSAFullSig)
|
|
if !ok {
|
|
return nil, errors.New("invalid ECDSA signature")
|
|
}
|
|
|
|
// r(32) + s(32) + v(1) = 65 bytes
|
|
sig := make([]byte, 65)
|
|
|
|
// Copy r
|
|
rBytes, _ := ecdsaSig.R.MarshalBinary()
|
|
copy(sig[32-len(rBytes):32], rBytes)
|
|
|
|
// Copy s
|
|
sBytes, _ := ecdsaSig.S.MarshalBinary()
|
|
copy(sig[64-len(sBytes):64], sBytes)
|
|
|
|
// Recovery ID (v) - chain-specific calculation
|
|
v := e.calculateV()
|
|
sig[64] = v
|
|
|
|
return sig, nil
|
|
}
|
|
|
|
// calculateV calculates recovery ID based on chain
|
|
func (e *EVMAdapter) calculateV() byte {
|
|
// EIP-155: v = chainId * 2 + 35 or chainId * 2 + 36
|
|
// For simplicity, using standard value
|
|
return 27
|
|
}
|
|
|
|
// ValidateConfig validates EVM configuration
|
|
func (e *EVMAdapter) ValidateConfig(config *UnifiedConfig) error {
|
|
if config.SignatureScheme != SignatureECDSA {
|
|
return errors.New("EVM chains require ECDSA signatures")
|
|
}
|
|
|
|
// Verify Secp256k1 curve
|
|
if _, ok := config.Group.(curve.Secp256k1); !ok {
|
|
return errors.New("EVM chains require Secp256k1 curve")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Transaction types
|
|
const (
|
|
LegacyTxType = 0x00
|
|
AccessListTxType = 0x01
|
|
DynamicFeeTxType = 0x02
|
|
BlobTxType = 0x03
|
|
)
|
|
|
|
// EVMTransaction represents a generic EVM transaction
|
|
type EVMTransaction struct {
|
|
Type byte
|
|
ChainID *big.Int
|
|
Nonce uint64
|
|
GasPrice *big.Int // Legacy
|
|
MaxPriorityFeePerGas *big.Int // EIP-1559
|
|
MaxFeePerGas *big.Int // EIP-1559
|
|
GasLimit uint64
|
|
To *[20]byte
|
|
Value *big.Int
|
|
Data []byte
|
|
AccessList []AccessListEntry
|
|
BlobVersionedHashes [][32]byte // EIP-4844
|
|
MaxFeePerBlobGas *big.Int // EIP-4844
|
|
}
|
|
|
|
// Encoding functions for different transaction types
|
|
func (e *EVMAdapter) encodeLegacyTx(tx *EVMTransaction) []byte {
|
|
// Simplified RLP encoding for legacy transactions
|
|
var encoded []byte
|
|
|
|
// Nonce
|
|
nonceBytes := make([]byte, 8)
|
|
for i := 0; i < 8; i++ {
|
|
nonceBytes[7-i] = byte(tx.Nonce >> (8 * i))
|
|
}
|
|
encoded = append(encoded, nonceBytes...)
|
|
|
|
// Gas price
|
|
if tx.GasPrice != nil {
|
|
priceBytes := tx.GasPrice.Bytes()
|
|
encoded = append(encoded, priceBytes...)
|
|
}
|
|
|
|
// Gas limit
|
|
gasBytes := make([]byte, 8)
|
|
for i := 0; i < 8; i++ {
|
|
gasBytes[7-i] = byte(tx.GasLimit >> (8 * i))
|
|
}
|
|
encoded = append(encoded, gasBytes...)
|
|
|
|
// To
|
|
if tx.To != nil {
|
|
encoded = append(encoded, tx.To[:]...)
|
|
} else {
|
|
encoded = append(encoded, 0x80) // Contract creation
|
|
}
|
|
|
|
// Value
|
|
if tx.Value != nil {
|
|
valueBytes := tx.Value.Bytes()
|
|
encoded = append(encoded, valueBytes...)
|
|
} else {
|
|
encoded = append(encoded, 0x80)
|
|
}
|
|
|
|
// Data
|
|
encoded = append(encoded, tx.Data...)
|
|
|
|
// EIP-155 chain ID
|
|
if e.config.ChainID != nil {
|
|
encoded = append(encoded, e.config.ChainID.Bytes()...)
|
|
encoded = append(encoded, 0x80, 0x80) // r, s placeholders
|
|
}
|
|
|
|
return encoded
|
|
}
|
|
|
|
func (e *EVMAdapter) encodeAccessListTx(tx *EVMTransaction) []byte {
|
|
// Type 1 (EIP-2930) transaction encoding
|
|
encoded := []byte{AccessListTxType}
|
|
// Add RLP encoding of access list transaction fields
|
|
return encoded
|
|
}
|
|
|
|
func (e *EVMAdapter) encodeDynamicFeeTx(tx *EVMTransaction) []byte {
|
|
// Type 2 (EIP-1559) transaction encoding
|
|
encoded := []byte{DynamicFeeTxType}
|
|
// Add RLP encoding of dynamic fee transaction fields
|
|
return encoded
|
|
}
|
|
|
|
func (e *EVMAdapter) encodeBlobTx(tx *EVMTransaction) []byte {
|
|
// Type 3 (EIP-4844) blob transaction encoding
|
|
encoded := []byte{BlobTxType}
|
|
// Add RLP encoding of blob transaction fields
|
|
return encoded
|
|
}
|
|
|
|
// GenerateEVMAddress derives the 20-byte EVM account address of a public key:
|
|
// the low 20 bytes of Keccak256 over the 64-byte UNCOMPRESSED encoding, X‖Y,
|
|
// with no format prefix.
|
|
//
|
|
// The point must be decompressed first. curve.Point.MarshalBinary returns the
|
|
// 33-byte compressed form (0x02/0x03 ‖ X), so hashing it — or its 32-byte X
|
|
// tail — produces a well-formed address that belongs to no key anyone holds.
|
|
// Nothing about such an address looks wrong, and value sent to it is
|
|
// unspendable, so this function returns ok=false rather than a plausible
|
|
// wrong answer when the point cannot be decompressed.
|
|
func (e *EVMAdapter) GenerateEVMAddress(publicKey curve.Point) ([20]byte, bool) {
|
|
var addr [20]byte
|
|
|
|
compressed, err := publicKey.MarshalBinary()
|
|
if err != nil || len(compressed) != 33 {
|
|
return addr, false
|
|
}
|
|
pub, err := secp256k1.ParsePubKey(compressed)
|
|
if err != nil {
|
|
return addr, false
|
|
}
|
|
|
|
h := sha3.NewLegacyKeccak256()
|
|
h.Write(pub.SerializeUncompressed()[1:]) // X‖Y, dropping the 0x04 prefix
|
|
copy(addr[:], h.Sum(nil)[12:])
|
|
return addr, true
|
|
}
|
|
|
|
// EstimateGas estimates gas for transaction
|
|
func (e *EVMAdapter) EstimateGas(tx *EVMTransaction) uint64 {
|
|
// Base cost
|
|
baseCost := uint64(21000)
|
|
|
|
// Data cost (4 gas per zero byte, 16 gas per non-zero)
|
|
dataCost := uint64(0)
|
|
for _, b := range tx.Data {
|
|
if b == 0 {
|
|
dataCost += 4
|
|
} else {
|
|
dataCost += 16
|
|
}
|
|
}
|
|
|
|
// Access list cost (if present)
|
|
accessListCost := uint64(0)
|
|
if len(tx.AccessList) > 0 {
|
|
accessListCost = uint64(len(tx.AccessList)) * 2400
|
|
for _, entry := range tx.AccessList {
|
|
accessListCost += uint64(len(entry.StorageKeys)) * 1900
|
|
}
|
|
}
|
|
|
|
// L2 specific costs
|
|
l2Cost := uint64(0)
|
|
if e.config.IsL2 {
|
|
// L2s have additional data availability costs
|
|
l2Cost = uint64(len(tx.Data)) * 16
|
|
}
|
|
|
|
return baseCost + dataCost + accessListCost + l2Cost
|
|
}
|