diff --git a/protocols/frost/sign/round1.go b/protocols/frost/sign/round1.go index 14c6f33f..15f1eead 100644 --- a/protocols/frost/sign/round1.go +++ b/protocols/frost/sign/round1.go @@ -2,6 +2,7 @@ package sign import ( "crypto/rand" + "sync" "github.com/luxfi/threshold/internal/round" "github.com/luxfi/threshold/pkg/math/curve" @@ -122,6 +123,7 @@ func (r *round1) Finalize(out chan<- *round.Message) (round.Session, error) { e_i: eI, D: D, E: E, + deMu: &sync.Mutex{}, }, nil } diff --git a/protocols/frost/sign/round2.go b/protocols/frost/sign/round2.go index 5606e431..b78ebb86 100644 --- a/protocols/frost/sign/round2.go +++ b/protocols/frost/sign/round2.go @@ -3,6 +3,7 @@ package sign import ( "fmt" "sort" + "sync" "github.com/cronokirby/saferith" "github.com/gtank/merlin" @@ -35,7 +36,8 @@ type round2 struct { // D[i] = Dᵢ will contain all of the commitments created by each party, ourself included. D map[party.ID]curve.Point // E[i] = Eᵢ will contain all of the commitments created by each party, ourself included. - E map[party.ID]curve.Point + E map[party.ID]curve.Point + deMu *sync.Mutex } type broadcast2 struct { @@ -70,12 +72,6 @@ func (r *round2) StoreBroadcastMessage(msg round.Message) error { return fmt.Errorf("nonce commitment is the identity point") } - // Only skip if we already have BOTH; otherwise we could drop one - if r.D[msg.From] != nil && r.E[msg.From] != nil { - // Already have both values for this party, skip - return nil - } - // Deep copy points to avoid aliasing issues - use marshal/unmarshal for clean copy dBytes, err := body.D_i.MarshalBinary() if err != nil { @@ -95,8 +91,15 @@ func (r *round2) StoreBroadcastMessage(msg round.Message) error { return fmt.Errorf("failed to unmarshal E_i: %w", err) } + r.deMu.Lock() + // Only skip if we already have BOTH; otherwise we could drop one + if r.D[msg.From] != nil && r.E[msg.From] != nil { + r.deMu.Unlock() + return nil + } r.D[msg.From] = dCopy r.E[msg.From] = eCopy + r.deMu.Unlock() return nil } @@ -111,6 +114,8 @@ func (r *round2) Finalize(out chan<- *round.Message) (round.Session, error) { // Check if we have all D and E values from ALL signers // This is critical - we MUST have D,E from every signer before proceeding signers := r.PartyIDs() + + r.deMu.Lock() missingCount := 0 for _, l := range signers { if r.D[l] == nil || r.E[l] == nil { @@ -118,18 +123,34 @@ func (r *round2) Finalize(out chan<- *round.Message) (round.Session, error) { } // Also verify they're not identity points (shouldn't happen but double-check) if r.D[l] != nil && r.D[l].IsIdentity() { + r.deMu.Unlock() return r, fmt.Errorf("party %s has identity point for D", l) } if r.E[l] != nil && r.E[l].IsIdentity() { + r.deMu.Unlock() return r, fmt.Errorf("party %s has identity point for E", l) } } if missingCount > 0 { + r.deMu.Unlock() // Not ready yet, return self to continue waiting for broadcasts return r, nil } + // Snapshot D and E under the lock, then release. + // After this point no new StoreBroadcastMessage calls will arrive + // for this round (protocol guarantees), so the copies are final. + D := make(map[party.ID]curve.Point, len(r.D)) + E := make(map[party.ID]curve.Point, len(r.E)) + for k, v := range r.D { + D[k] = v + } + for k, v := range r.E { + E[k] = v + } + r.deMu.Unlock() + // This essentially follows parts of Figure 3. // 4. "Each Pᵢ then computes the set of binding values ρₗ = H₁(l, m, B). @@ -165,13 +186,13 @@ func (r *round2) Finalize(out chan<- *round.Message) (round.Session, error) { Bytes: []byte(l), }) // Write canonical encoding of D[l] - dBytes, _ := r.D[l].MarshalBinary() + dBytes, _ := D[l].MarshalBinary() _ = rhoPreHash.WriteAny(&hash.BytesWithDomain{ TheDomain: "D", Bytes: dBytes, }) // Write canonical encoding of E[l] - eBytes, _ := r.E[l].MarshalBinary() + eBytes, _ := E[l].MarshalBinary() _ = rhoPreHash.WriteAny(&hash.BytesWithDomain{ TheDomain: "E", Bytes: eBytes, @@ -190,8 +211,8 @@ func (r *round2) Finalize(out chan<- *round.Message) (round.Session, error) { RShares := make(map[party.ID]curve.Point) // Use sorted order to ensure consistent R computation for _, l := range sortedSigners { - RShares[l] = rho[l].Act(r.E[l]) - RShares[l] = RShares[l].Add(r.D[l]) + RShares[l] = rho[l].Act(E[l]) + RShares[l] = RShares[l].Add(D[l]) R = R.Add(RShares[l]) } var c curve.Scalar @@ -302,6 +323,7 @@ func (r *round2) Finalize(out chan<- *round.Message) (round.Session, error) { RShares: RShares, c: c, z: map[party.ID]curve.Scalar{r.SelfID(): zI}, + zMu: &sync.Mutex{}, Lambda: Lambdas, }, nil } diff --git a/protocols/frost/sign/round3.go b/protocols/frost/sign/round3.go index 1422ae73..4f6d5f75 100644 --- a/protocols/frost/sign/round3.go +++ b/protocols/frost/sign/round3.go @@ -2,6 +2,7 @@ package sign import ( "fmt" + "sync" "github.com/luxfi/threshold/internal/round" "github.com/luxfi/threshold/pkg/math/curve" @@ -28,7 +29,8 @@ type round3 struct { // z contains the response from each participant // // z[i] corresponds to zᵢ in the Frost paper - z map[party.ID]curve.Scalar + z map[party.ID]curve.Scalar + zMu *sync.Mutex // Lambda contains all Lagrange coefficients of the parties participating in this session. // Lambda[l] = λₗ @@ -75,7 +77,9 @@ func (r *round3) StoreBroadcastMessage(msg round.Message) error { return fmt.Errorf("failed to verify response from %v", from) } + r.zMu.Lock() r.z[from] = body.ZI + r.zMu.Unlock() return nil } @@ -91,8 +95,15 @@ func (r *round3) Finalize(chan<- *round.Message) (round.Session, error) { // These steps come from Figure 3 of the Frost paper. // 7.c "Compute the group's response z = ∑ᵢ zᵢ" + r.zMu.Lock() + zMap := make(map[party.ID]curve.Scalar, len(r.z)) + for k, v := range r.z { + zMap[k] = v + } + r.zMu.Unlock() + z := r.Group().NewScalar() - for _, z_l := range r.z { + for _, z_l := range zMap { z.Add(z_l) } diff --git a/protocols/lss/adapters/l2_chains_test.go b/protocols/lss/adapters/l2_chains_test.go new file mode 100644 index 00000000..f576e93f --- /dev/null +++ b/protocols/lss/adapters/l2_chains_test.go @@ -0,0 +1,254 @@ +// Package adapters — L2 chain-specific threshold signing tests. +// +// Validates that threshold signatures produce valid, chain-specific +// transactions for major Ethereum L2s: Arbitrum, Optimism, Base, Scroll. +// +// Each L2 has subtle differences in transaction handling: +// - Chain ID encoding (EIP-155 replay protection across L2s) +// - EIP-1559 support and fee market dynamics +// - EIP-4844 blob transaction support (post-Dencun) +// - L2-specific deposit transaction types +// +// Contributed by kcolbchain (https://kcolbchain.com) — independent +// blockchain research collective focused on L2 infrastructure. +package adapters + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// L2TestChain bundles config + expected behavior for each L2. +type L2TestChain struct { + Chain EVMChain + ChainID int64 + Name string + IsL2 bool + SupportsEIP1559 bool + SupportsBlobTx bool +} + +var l2TestChains = []L2TestChain{ + {Arbitrum, 42161, "Arbitrum One", true, true, false}, + {Optimism, 10, "OP Mainnet", true, true, false}, + {Base, 8453, "Base", true, true, false}, + {Scroll, 534352, "Scroll", true, true, false}, + {zkSync, 324, "zkSync Era", true, true, false}, + {Linea, 59144, "Linea", true, true, false}, +} + +// TestL2ChainConfigs verifies all L2 chain configs are correctly registered. +func TestL2ChainConfigs(t *testing.T) { + for _, tc := range l2TestChains { + t.Run(tc.Name, func(t *testing.T) { + cfg := GetChainConfig(tc.Chain) + require.NotNil(t, cfg, "chain config not found for %s", tc.Chain) + + assert.Equal(t, big.NewInt(tc.ChainID), cfg.ChainID, + "%s: wrong chain ID", tc.Name) + assert.Equal(t, tc.Name, cfg.Name) + assert.True(t, cfg.IsL2, "%s should be marked as L2", tc.Name) + assert.Equal(t, tc.SupportsEIP1559, cfg.SupportsEIP1559, + "%s: EIP-1559 support mismatch", tc.Name) + }) + } +} + +// TestL2EthereumAdapterChainID ensures the Ethereum adapter correctly +// handles chain ID switching for each L2. +func TestL2EthereumAdapterChainID(t *testing.T) { + for _, tc := range l2TestChains { + t.Run(tc.Name, func(t *testing.T) { + adapter := NewEthereumAdapter() + adapter.SetChainID(big.NewInt(tc.ChainID)) + + // Verify the adapter uses the correct chain ID in EIP-155 signatures. + // The v value of an ECDSA signature encodes the chain ID: + // v = chainID * 2 + 35 (for legacy tx) + // v = {0, 1} (for EIP-1559 tx, chain ID in tx envelope) + assert.Equal(t, big.NewInt(tc.ChainID), adapter.chainID) + }) + } +} + +// TestL2LegacyTransactionDigest validates EIP-155 digest for each L2. +func TestL2LegacyTransactionDigest(t *testing.T) { + for _, tc := range l2TestChains { + t.Run(tc.Name, func(t *testing.T) { + adapter := NewEthereumAdapter() + adapter.SetChainID(big.NewInt(tc.ChainID)) + + tx := &LegacyTransaction{ + Nonce: 0, + GasPrice: big.NewInt(20000000), + GasLimit: 21000, + To: [20]byte{0x01}, // dummy address + Value: big.NewInt(1000000000000000), + Data: nil, + } + + digest, err := adapter.Digest(tx) + require.NoError(t, err) + assert.Len(t, digest, 32, "digest should be 32 bytes (keccak256)") + + // Digests for different chain IDs must be different (replay protection) + adapter2 := NewEthereumAdapter() + adapter2.SetChainID(big.NewInt(1)) // Ethereum mainnet + digest2, err := adapter2.Digest(tx) + require.NoError(t, err) + + if tc.ChainID != 1 { + assert.NotEqual(t, digest, digest2, + "%s: digest should differ from mainnet (EIP-155 replay protection)", tc.Name) + } + }) + } +} + +// TestL2EIP1559TransactionDigest validates EIP-1559 digest for each L2. +func TestL2EIP1559TransactionDigest(t *testing.T) { + for _, tc := range l2TestChains { + if !tc.SupportsEIP1559 { + continue + } + t.Run(tc.Name, func(t *testing.T) { + adapter := NewEthereumAdapter() + adapter.SetChainID(big.NewInt(tc.ChainID)) + + tx := &EIP1559Transaction{ + ChainID: big.NewInt(tc.ChainID), + Nonce: 0, + MaxPriorityFeePerGas: big.NewInt(1000000), + MaxFeePerGas: big.NewInt(30000000), + GasLimit: 21000, + To: [20]byte{0x01}, + Value: big.NewInt(1000000000000000), + Data: nil, + } + + digest, err := adapter.Digest(tx) + require.NoError(t, err) + assert.Len(t, digest, 32, "digest should be 32 bytes") + + // EIP-1559 tx encodes chain ID in the envelope, so different chains + // produce different digests even with same params. + adapter2 := NewEthereumAdapter() + adapter2.SetChainID(big.NewInt(1)) + tx2 := &EIP1559Transaction{ + ChainID: big.NewInt(1), + Nonce: 0, + MaxPriorityFeePerGas: big.NewInt(1000000), + MaxFeePerGas: big.NewInt(30000000), + GasLimit: 21000, + To: [20]byte{0x01}, + Value: big.NewInt(1000000000000000), + } + digest2, err := adapter2.Digest(tx2) + require.NoError(t, err) + + if tc.ChainID != 1 { + assert.NotEqual(t, digest, digest2, + "%s: EIP-1559 digest should differ from mainnet", tc.Name) + } + }) + } +} + +// TestL2CrossChainReplayProtection is the critical test: ensures a signature +// produced for one L2 cannot be replayed on another L2 or on mainnet. +func TestL2CrossChainReplayProtection(t *testing.T) { + digests := make(map[string][]byte) + + for _, tc := range l2TestChains { + adapter := NewEthereumAdapter() + adapter.SetChainID(big.NewInt(tc.ChainID)) + + tx := &LegacyTransaction{ + Nonce: 42, + GasPrice: big.NewInt(20000000), + GasLimit: 100000, + To: [20]byte{0xDE, 0xAD}, + Value: big.NewInt(1e18), + Data: []byte("transfer"), + } + + digest, err := adapter.Digest(tx) + require.NoError(t, err) + digests[tc.Name] = digest + } + + // Also add mainnet + adapter := NewEthereumAdapter() + adapter.SetChainID(big.NewInt(1)) + tx := &LegacyTransaction{ + Nonce: 42, + GasPrice: big.NewInt(20000000), + GasLimit: 100000, + To: [20]byte{0xDE, 0xAD}, + Value: big.NewInt(1e18), + Data: []byte("transfer"), + } + digest, err := adapter.Digest(tx) + require.NoError(t, err) + digests["Ethereum Mainnet"] = digest + + // Every digest must be unique across all chains + seen := make(map[string]string) + for name, d := range digests { + key := string(d) + if other, exists := seen[key]; exists { + t.Fatalf("REPLAY VULNERABILITY: %s and %s produce identical digests", name, other) + } + seen[key] = name + } +} + +// TestL2GasEstimation validates that gas estimation handles L2 chains. +func TestL2GasEstimation(t *testing.T) { + for _, tc := range l2TestChains { + t.Run(tc.Name, func(t *testing.T) { + adapter := NewEthereumAdapter() + adapter.SetChainID(big.NewInt(tc.ChainID)) + + tx := &EIP1559Transaction{ + ChainID: big.NewInt(tc.ChainID), + Nonce: 0, + MaxPriorityFeePerGas: big.NewInt(1000000), + MaxFeePerGas: big.NewInt(30000000), + GasLimit: 21000, + To: [20]byte{0x01}, + Value: big.NewInt(1000000000000000), + } + + gas, err := adapter.EstimateGas(tx) + assert.NoError(t, err) + assert.True(t, gas > 0, "%s: gas estimate should be positive", tc.Name) + }) + } +} + +// TestL2ChainIDEdgeCases checks boundary conditions for chain IDs. +func TestL2ChainIDEdgeCases(t *testing.T) { + adapter := NewEthereumAdapter() + + // Test with zero chain ID (pre-EIP-155, should still work) + adapter.SetChainID(big.NewInt(0)) + tx := &LegacyTransaction{ + Nonce: 0, + GasPrice: big.NewInt(1), + GasLimit: 21000, + To: [20]byte{0x01}, + Value: big.NewInt(0), + } + _, err := adapter.Digest(tx) + assert.NoError(t, err, "zero chain ID should not error") + + // Test with very large chain ID (future chains) + largeCID := new(big.Int).SetUint64(999999999) + adapter.SetChainID(largeCID) + _, err = adapter.Digest(tx) + assert.NoError(t, err, "large chain ID should not error") +} diff --git a/protocols/lss/keygen/round2.go b/protocols/lss/keygen/round2.go index 6aae3c78..181edd90 100644 --- a/protocols/lss/keygen/round2.go +++ b/protocols/lss/keygen/round2.go @@ -1,6 +1,7 @@ package keygen import ( + "bytes" "errors" "sync" @@ -83,7 +84,10 @@ func (r *round2) VerifyMessage(msg round.Message) error { } sharePoint := share.ActOnBase() - if !sharePoint.Equal(expectedCommitment) { + // Use MarshalBinary for comparison to avoid race in dcrd/secp256k1 ToAffine + spBytes, _ := sharePoint.MarshalBinary() + ecBytes, _ := expectedCommitment.MarshalBinary() + if !bytes.Equal(spBytes, ecBytes) { return errors.New("share doesn't match commitment") }