diff --git a/core/types/hash_test.go b/core/types/hash_test.go index 99c27a171..78aea334b 100644 --- a/core/types/hash_test.go +++ b/core/types/hash_test.go @@ -142,3 +142,79 @@ func TestHashIsZero(t *testing.T) { } }) } + +func TestHashTextMarshaling(t *testing.T) { + t.Run("marshal text valid hash", func(t *testing.T) { + h := Hash{0xff, 0xee, 0xdd, 0xcc} + data, err := h.MarshalText() + if err != nil { + t.Fatal(err) + } + expected := "ffeeddcc00000000000000000000000000000000000000000000000000000000" + if string(data) != expected { + t.Errorf("got %s, want %s", string(data), expected) + } + + // now test that it can be unmarshaled + var h2 Hash + err = h2.UnmarshalText(data) + if err != nil { + t.Fatal(err) + } + if h != h2 { + t.Errorf("got %v, want %v", h2, h) + } + }) + + t.Run("unmarshal text valid hash", func(t *testing.T) { + input := "ffeeddcc00000000000000000000000000000000000000000000000000000000" + var h Hash + err := h.UnmarshalText([]byte(input)) + if err != nil { + t.Fatal(err) + } + expected := Hash{0xff, 0xee, 0xdd, 0xcc} + if h != expected { + t.Errorf("got %v, want %v", h, expected) + } + }) + + t.Run("unmarshal text odd length", func(t *testing.T) { + input := "ffeeddccc" + var h Hash + err := h.UnmarshalText([]byte(input)) + if err == nil { + t.Error("expected error for odd length hex string") + } + }) + + t.Run("unmarshal text invalid characters", func(t *testing.T) { + input := "gghhiijj00000000000000000000000000000000000000000000000000000000" + var h Hash + err := h.UnmarshalText([]byte(input)) + if err == nil { + t.Error("expected error for invalid hex characters") + } + }) + + t.Run("marshal empty hash", func(t *testing.T) { + var h Hash + data, err := h.MarshalText() + if err != nil { + t.Fatal(err) + } + expected := "0000000000000000000000000000000000000000000000000000000000000000" + if string(data) != expected { + t.Errorf("got %s, want %s", string(data), expected) + } + }) + + t.Run("unmarshal text with whitespace", func(t *testing.T) { + input := " ffeeddcc00000000000000000000000000000000000000000000000000000000 " + var h Hash + err := h.UnmarshalText([]byte(input)) + if err == nil { + t.Error("expected error for input with whitespace") + } + }) +} diff --git a/core/types/hex_test.go b/core/types/hex_test.go index 1af8d7464..511c1e1ef 100644 --- a/core/types/hex_test.go +++ b/core/types/hex_test.go @@ -183,6 +183,25 @@ func TestHexBytes_JSON(t *testing.T) { t.Errorf("roundtrip failed: got %v, want %v", decoded, original) } }) + + t.Run("roundtrip null", func(t *testing.T) { + original := HexBytes(nil) + encoded, err := original.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + if string(encoded) != `null` { // unquoted JSON null + t.Errorf("expected null, got %s", encoded) + } + var decoded HexBytes + err = decoded.UnmarshalJSON(encoded) + if err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + if decoded != nil { + t.Errorf("expected nil, got %v", decoded) + } + }) } func TestHexBytes_Format(t *testing.T) { diff --git a/core/types/params.go b/core/types/params.go index ace8a3f0e..a9de9edd6 100644 --- a/core/types/params.go +++ b/core/types/params.go @@ -613,7 +613,11 @@ func (np NetworkParameters) String() string { func (np *NetworkParameters) Hash() Hash { hasher := NewHasher() - hasher.Write(np.Leader.Bytes()) + if np.Leader.PublicKey == nil { // this is not valid in use, but don't panic + hasher.Write([]byte{0}) + } else { + hasher.Write(np.Leader.Bytes()) + } binary.Write(hasher, SerializationByteOrder, np.MaxBlockSize) binary.Write(hasher, SerializationByteOrder, np.JoinExpiry) binary.Write(hasher, SerializationByteOrder, np.DisabledGasCosts) diff --git a/core/types/params_test.go b/core/types/params_test.go index 7d1e7baf3..b79894dc5 100644 --- a/core/types/params_test.go +++ b/core/types/params_test.go @@ -1,6 +1,7 @@ package types import ( + "bytes" "encoding/hex" "reflect" "testing" @@ -435,12 +436,23 @@ func TestParamUpdatesMerge(t *testing.T) { } func TestPublicKeyJSON(t *testing.T) { + keyBts, _ := hex.DecodeString("02e4f82ae8d6ecac4ff0c26be1b7a3a7e7cb18b0dd77ddbe19ae10ddeafc747949") + testKey, err := crypto.UnmarshalSecp256k1PublicKey(keyBts) + if err != nil { + t.Fatal(err) + } + tests := []struct { name string pk PublicKey json string wantErr bool }{ + { + name: "marshal", + pk: PublicKey{testKey}, + json: `{"type":"secp256k1","key":"02e4f82ae8d6ecac4ff0c26be1b7a3a7e7cb18b0dd77ddbe19ae10ddeafc747949"}`, + }, { name: "unmarshal invalid hex string", json: `{"type":"public_key","key":"XYZ"}`, @@ -598,3 +610,212 @@ func TestParamUpdatesUnmarshalJSON(t *testing.T) { }) } } + +func TestNetworkParametersEquals(t *testing.T) { + pub0, err := crypto.UnmarshalSecp256k1PublicKey([]byte{0x2, 0xe0, 0x9d, 0x79, 0x32, 0xde, 0xf1, 0x1d, 0x82, 0x72, 0xdd, 0x3b, 0x58, 0x9d, 0xf8, 0xb1, 0xcf, 0x7a, 0xff, 0xb0, 0x41, 0x50, 0x19, 0x4f, 0xc2, 0x28, 0xf8, 0x17, 0xae, 0xba, 0xb2, 0xc9, 0xda}) + require.NoError(t, err) + + pub1, err := crypto.UnmarshalSecp256k1PublicKey([]byte{0x3, 0x16, 0xb4, 0x4c, 0xab, 0xfb, 0xc, 0xc, 0xa1, 0x3b, 0x58, 0xc4, 0x69, 0x3f, 0x71, 0xd8, 0xd0, 0xf1, 0x6e, 0xcb, 0x16, 0xe9, 0xb6, 0xed, 0xd3, 0xa2, 0x23, 0x74, 0xef, 0x38, 0xc7, 0xf0, 0xb}) + require.NoError(t, err) + + tests := []struct { + name string + np1 *NetworkParameters + np2 *NetworkParameters + expected bool + }{ + { + name: "both nil", + np1: nil, + np2: nil, + expected: true, + }, + { + name: "first nil", + np1: nil, + np2: &NetworkParameters{}, + expected: false, + }, + { + name: "second nil", + np1: &NetworkParameters{}, + np2: nil, + expected: false, + }, + { + name: "both empty leaders", + np1: &NetworkParameters{}, + np2: &NetworkParameters{}, + expected: true, + }, + { + name: "different leaders", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub1}, + }, + expected: false, + }, + { + name: "different max block size", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxBlockSize: 1000, + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxBlockSize: 2000, + }, + expected: false, + }, + { + name: "different join expiry", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + JoinExpiry: Duration(10 * time.Second), + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub0}, + JoinExpiry: Duration(20 * time.Second), + }, + expected: false, + }, + { + name: "different disabled gas costs", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + DisabledGasCosts: true, + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub0}, + DisabledGasCosts: false, + }, + expected: false, + }, + { + name: "different max votes per tx", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxVotesPerTx: 10, + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxVotesPerTx: 20, + }, + expected: false, + }, + { + name: "different migration status", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + MigrationStatus: MigrationStatus("pending"), + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub0}, + MigrationStatus: MigrationStatus("completed"), + }, + expected: false, + }, + { + name: "identical complete parameters", + np1: &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxBlockSize: 1000, + JoinExpiry: Duration(10 * time.Second), + DisabledGasCosts: true, + MaxVotesPerTx: 10, + MigrationStatus: MigrationStatus("pending"), + }, + np2: &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxBlockSize: 1000, + JoinExpiry: Duration(10 * time.Second), + DisabledGasCosts: true, + MaxVotesPerTx: 10, + MigrationStatus: MigrationStatus("pending"), + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.np1.Equals(tt.np2) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestNetworkParametersHash(t *testing.T) { + pub0, err := crypto.UnmarshalSecp256k1PublicKey([]byte{0x2, 0xe0, 0x9d, 0x79, 0x32, 0xde, 0xf1, 0x1d, 0x82, 0x72, 0xdd, 0x3b, 0x58, 0x9d, 0xf8, 0xb1, 0xcf, 0x7a, 0xff, 0xb0, 0x41, 0x50, 0x19, 0x4f, 0xc2, 0x28, 0xf8, 0x17, 0xae, 0xba, 0xb2, 0xc9, 0xda}) + require.NoError(t, err) + pub1, err := crypto.UnmarshalSecp256k1PublicKey([]byte{0x3, 0x16, 0xb4, 0x4c, 0xab, 0xfb, 0xc, 0xc, 0xa1, 0x3b, 0x58, 0xc4, 0x69, 0x3f, 0x71, 0xd8, 0xd0, 0xf1, 0x6e, 0xcb, 0x16, 0xe9, 0xb6, 0xed, 0xd3, 0xa2, 0x23, 0x74, 0xef, 0x38, 0xc7, 0xf0, 0xb}) + require.NoError(t, err) + + baseParams := &NetworkParameters{ + Leader: PublicKey{pub0}, + MaxBlockSize: 1000, + JoinExpiry: Duration(3600), + DisabledGasCosts: false, + MaxVotesPerTx: 10, + MigrationStatus: "active", + } + + tests := []struct { + name string + mutator func(*NetworkParameters) + }{ + { + name: "different leader", + mutator: func(np *NetworkParameters) { + np.Leader = PublicKey{pub1} + }, + }, + { + name: "different max block size", + mutator: func(np *NetworkParameters) { + np.MaxBlockSize = 2000 + }, + }, + { + name: "different join expiry", + mutator: func(np *NetworkParameters) { + np.JoinExpiry = Duration(7200) + }, + }, + { + name: "different disabled gas costs", + mutator: func(np *NetworkParameters) { + np.DisabledGasCosts = true + }, + }, + { + name: "different max votes per tx", + mutator: func(np *NetworkParameters) { + np.MaxVotesPerTx = 20 + }, + }, + { + name: "different migration status", + mutator: func(np *NetworkParameters) { + np.MigrationStatus = "inactive" + }, + }, + } + + baseHash := baseParams.Hash() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modifiedParams := baseParams.Clone() + tt.mutator(modifiedParams) + modifiedHash := modifiedParams.Hash() + + if bytes.Equal(baseHash[:], modifiedHash[:]) { + t.Errorf("hash should be different when changing %s", tt.name) + } + }) + } +} diff --git a/core/types/time_test.go b/core/types/time_test.go new file mode 100644 index 000000000..551aa904a --- /dev/null +++ b/core/types/time_test.go @@ -0,0 +1,95 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDuration_MarshalText(t *testing.T) { + t.Run("marshal zero duration", func(t *testing.T) { + d := Duration(0) + data, err := d.MarshalText() + require.NoError(t, err) + require.Equal(t, "0s", string(data)) + }) + + t.Run("marshal positive duration", func(t *testing.T) { + d := Duration(2*time.Hour + 30*time.Minute) + data, err := d.MarshalText() + require.NoError(t, err) + require.Equal(t, "2h30m0s", string(data)) + }) + + t.Run("marshal negative duration", func(t *testing.T) { + d := Duration(-1 * time.Minute) + data, err := d.MarshalText() + require.NoError(t, err) + require.Equal(t, "-1m0s", string(data)) + }) +} + +func TestDuration_UnmarshalText(t *testing.T) { + t.Run("unmarshal valid duration", func(t *testing.T) { + var d Duration + err := d.UnmarshalText([]byte("1h30m")) + require.NoError(t, err) + require.Equal(t, Duration(90*time.Minute), d) + }) + + t.Run("unmarshal zero duration", func(t *testing.T) { + var d Duration + err := d.UnmarshalText([]byte("0s")) + require.NoError(t, err) + require.Equal(t, Duration(0), d) + }) + + t.Run("unmarshal invalid duration", func(t *testing.T) { + var d Duration + err := d.UnmarshalText([]byte("invalid")) + require.Error(t, err) + }) + + t.Run("unmarshal empty duration", func(t *testing.T) { + var d Duration + err := d.UnmarshalText([]byte("")) + require.Error(t, err) + }) + + t.Run("unmarshal complex duration", func(t *testing.T) { + var d Duration + err := d.UnmarshalText([]byte("2h45m30.5s")) + require.NoError(t, err) + expected := Duration(2*time.Hour + 45*time.Minute + 30*time.Second + 500*time.Millisecond) + require.Equal(t, expected, d) + }) +} + +func TestDurationRoundTrip(t *testing.T) { + tests := []struct { + name string + duration Duration + }{ + {"1 hour", Duration(time.Hour)}, + {"2 minutes", Duration(2 * time.Minute)}, + {"500 ms", Duration(500 * time.Millisecond)}, + {"90 minutes", Duration(90 * time.Minute)}, + {"zero", Duration(0)}, + {"mixed", Duration(time.Hour + 2*time.Minute + 6*time.Second)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + text, err := tt.duration.MarshalText() + require.NoError(t, err) + + var decoded Duration + err = decoded.UnmarshalText(text) + require.NoError(t, err) + + assert.Equal(t, tt.duration, decoded) + }) + } +} diff --git a/core/types/transaction_test.go b/core/types/transaction_test.go index 2fd2e8e5b..2802a29ce 100644 --- a/core/types/transaction_test.go +++ b/core/types/transaction_test.go @@ -20,10 +20,12 @@ import ( type TestPayload struct { Key string Value string + + marshalError error } func (p *TestPayload) MarshalBinary() ([]byte, error) { - return []byte(fmt.Sprintf("%s=%s", p.Key, p.Value)), nil + return []byte(fmt.Sprintf("%s=%s", p.Key, p.Value)), p.marshalError } func (p *TestPayload) UnmarshalBinary(data []byte) error { @@ -1132,3 +1134,76 @@ type regularReader struct { func (r *regularReader) Read(p []byte) (n int, err error) { return r.r.Read(p) } +func TestCreateTransaction(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + payload Payload + chainID string + nonce uint64 + expectError bool + }{ + { + name: "valid payload", + payload: &TestPayload{ + Key: "test", + Value: "value", + }, + chainID: "test-chain", + nonce: 1, + expectError: false, + }, + { + name: "payload with marshal fail", + payload: &TestPayload{ + Key: "test", + Value: "value", + marshalError: errors.New("boom"), + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tx, err := CreateTransaction(tc.payload, tc.chainID, tc.nonce) + if tc.expectError { + require.Error(t, err) + require.Nil(t, tx) + return + } + + require.NoError(t, err) + require.NotNil(t, tx) + require.Equal(t, DefaultSignedMsgSerType, tx.Serialization) + require.Equal(t, tc.chainID, tx.Body.ChainID) + require.Equal(t, tc.nonce, tx.Body.Nonce) + require.Equal(t, big.NewInt(0), tx.Body.Fee) + + if tc.payload != nil { + require.Equal(t, tc.payload.Type(), tx.Body.PayloadType) + payloadData, err := tc.payload.MarshalBinary() + require.NoError(t, err) + require.Equal(t, payloadData, tx.Body.Payload) + } + }) + } +} + +func TestCreateNodeTransaction(t *testing.T) { + t.Parallel() + + payload := &TestPayload{ + Key: "node", + Value: "test", + } + chainID := "test-chain" + nonce := uint64(1) + + tx, err := CreateNodeTransaction(payload, chainID, nonce) + + require.NoError(t, err) + require.NotNil(t, tx) + require.Equal(t, SignedMsgDirect, tx.Serialization) +}