diff --git a/crypto/builder.go b/crypto/builder.go index 2eb82be..07d1e4e 100644 --- a/crypto/builder.go +++ b/crypto/builder.go @@ -10,7 +10,9 @@ package crypto import ( "encoding/hex" "errors" + "fmt" "math/big" + "regexp" blst "github.com/supranational/blst/bindings/go" ) @@ -22,9 +24,8 @@ var ( DefaultGasLimit = big.NewInt(1_000_000) ) -// NewTransaction returns a Transaction with the default nonce/gasPrice/ -// gasLimit/value builder defaults set. Concrete transaction-type builders -// (BuildTransfer, BuildVote, etc.) are layered on top of this. +// Concrete transaction-type builders (BuildTransfer, BuildVote, etc.) are +// layered on top of this. func NewTransaction() *Transaction { return &Transaction{ Nonce: big.NewInt(1), @@ -34,6 +35,250 @@ func NewTransaction() *Transaction { } } +func BuildTransfer(to string, value *big.Int) (*Transaction, error) { + if _, err := AddressToBytes(to); err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = to + transaction.Value = bigIntOrZero(value) + + return transaction, nil +} + +func BuildVote(validatorAddress string) (*Transaction, error) { + voteArg, err := AbiAddress(validatorAddress) + if err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = ContractConsensus + transaction.Data = AbiEncodeFunctionCall(AbiSignatureVote, voteArg) + transaction.Vote = validatorAddress + + return transaction, nil +} + +func BuildUnvote() *Transaction { + transaction := NewTransaction() + transaction.To = ContractConsensus + transaction.Data = AbiEncodeFunctionCall(AbiSignatureUnvote) + + return transaction +} + +// NOTE: BLS Proof-of-Possession is not yet implemented — the proof argument +// is encoded as empty bytes. This is a known, documented gap: the resulting +// transaction carries a validator public key but no proof, and will likely +// not validate on an actual Mainsail chain until PoP support is added. +func BuildValidatorRegistration(validatorPublicKey string, stake *big.Int) (*Transaction, error) { + if err := validateBLSPublicKey(validatorPublicKey); err != nil { + return nil, err + } + + pubKeyBytes, err := hex.DecodeString(validatorPublicKey) + if err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = ContractConsensus + transaction.Value = bigIntOrZero(stake) + transaction.Data = AbiEncodeFunctionCall(AbiSignatureRegisterValidator, AbiBytes(pubKeyBytes), AbiBytes([]byte{})) + transaction.ValidatorPublicKey = validatorPublicKey + + return transaction, nil +} + +// NOTE: as with BuildValidatorRegistration, BLS Proof-of-Possession is not +// yet implemented; the proof argument is encoded as empty bytes. +func BuildValidatorUpdate(validatorPublicKey string) (*Transaction, error) { + if err := validateBLSPublicKey(validatorPublicKey); err != nil { + return nil, err + } + + pubKeyBytes, err := hex.DecodeString(validatorPublicKey) + if err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = ContractConsensus + transaction.Data = AbiEncodeFunctionCall(AbiSignatureUpdateValidator, AbiBytes(pubKeyBytes), AbiBytes([]byte{})) + transaction.ValidatorPublicKey = validatorPublicKey + + return transaction, nil +} + +func BuildValidatorResignation() *Transaction { + transaction := NewTransaction() + transaction.To = ContractConsensus + transaction.Data = AbiEncodeFunctionCall(AbiSignatureResignValidator) + + return transaction +} + +var ( + ErrInvalidUsername = errors.New("crypto: invalid username") + + usernameCharsetRegexp = regexp.MustCompile(`[^a-z0-9_]`) + usernameEdgeUnderscoreRegexp = regexp.MustCompile(`^_|_$`) + usernameDoubleUnderscoreRegexp = regexp.MustCompile(`__`) +) + +func validateUsername(username string) error { + if len(username) < 1 || len(username) > 20 { + return fmt.Errorf("%w: must be between 1 and 20 characters long, got %d", ErrInvalidUsername, len(username)) + } + if usernameCharsetRegexp.MatchString(username) { + return fmt.Errorf("%w: can only contain lowercase letters, numbers and underscores", ErrInvalidUsername) + } + if usernameEdgeUnderscoreRegexp.MatchString(username) { + return fmt.Errorf("%w: cannot start or end with an underscore", ErrInvalidUsername) + } + if usernameDoubleUnderscoreRegexp.MatchString(username) { + return fmt.Errorf("%w: cannot contain consecutive underscores", ErrInvalidUsername) + } + return nil +} + +func BuildUsernameRegistration(username string) (*Transaction, error) { + if err := validateUsername(username); err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = ContractUsernames + transaction.Data = AbiEncodeFunctionCall(AbiSignatureRegisterUsername, AbiString(username)) + transaction.Username = username + + return transaction, nil +} + +func BuildUsernameResignation() *Transaction { + transaction := NewTransaction() + transaction.To = ContractUsernames + transaction.Data = AbiEncodeFunctionCall(AbiSignatureResignUsername) + + return transaction +} + +func BuildMultiPayment(addresses []string, amounts []*big.Int) (*Transaction, error) { + if len(addresses) != len(amounts) { + return nil, fmt.Errorf("crypto: multi-payment addresses and amounts must be the same length, got %d and %d", len(addresses), len(amounts)) + } + if len(addresses) == 0 { + return nil, errors.New("crypto: multi-payment requires at least one recipient") + } + + addressesArg, err := AbiAddressArray(addresses) + if err != nil { + return nil, err + } + amountsArg, err := AbiUint256Array(amounts) + if err != nil { + return nil, err + } + + total := big.NewInt(0) + for _, amount := range amounts { + total.Add(total, amount) + } + + transaction := NewTransaction() + transaction.To = ContractMultipayment + transaction.Value = total + transaction.Data = AbiEncodeFunctionCall(AbiSignatureMultipayment, addressesArg, amountsArg) + transaction.PaymentAddresses = addresses + transaction.PaymentAmounts = amounts + + return transaction, nil +} + +func BuildEvmCall(to string, data []byte) (*Transaction, error) { + if _, err := AddressToBytes(to); err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = to + transaction.Data = data + + return transaction, nil +} + +func BuildBatchTransfer(tokenAddress string, recipients []string, amounts []*big.Int) (*Transaction, error) { + if len(recipients) != len(amounts) { + return nil, fmt.Errorf("crypto: batch transfer recipients and amounts must be the same length, got %d and %d", len(recipients), len(amounts)) + } + if len(recipients) == 0 { + return nil, errors.New("crypto: batch transfer requires at least one recipient") + } + + tokenArg, err := AbiAddress(tokenAddress) + if err != nil { + return nil, err + } + recipientsArg, err := AbiAddressArray(recipients) + if err != nil { + return nil, err + } + amountsArg, err := AbiUint256Array(amounts) + if err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = ContractBatchTransfer + transaction.Data = AbiEncodeFunctionCall(AbiSignatureERC20BatchTransferFrom, tokenArg, recipientsArg, amountsArg) + + return transaction, nil +} + +func BuildTokenApprove(tokenAddress string, spender string, amount *big.Int) (*Transaction, error) { + if _, err := AddressToBytes(tokenAddress); err != nil { + return nil, err + } + + spenderArg, err := AbiAddress(spender) + if err != nil { + return nil, err + } + amountArg, err := AbiUint256(amount) + if err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = tokenAddress + transaction.Data = AbiEncodeFunctionCall(AbiSignatureERC20Approve, spenderArg, amountArg) + + return transaction, nil +} + +func BuildTokenTransfer(tokenAddress string, recipient string, amount *big.Int) (*Transaction, error) { + if _, err := AddressToBytes(tokenAddress); err != nil { + return nil, err + } + + recipientArg, err := AbiAddress(recipient) + if err != nil { + return nil, err + } + amountArg, err := AbiUint256(amount) + if err != nil { + return nil, err + } + + transaction := NewTransaction() + transaction.To = tokenAddress + transaction.Data = AbiEncodeFunctionCall(AbiSignatureERC20Transfer, recipientArg, amountArg) + + return transaction, nil +} + func validateBLSPublicKey(publicKey string) error { if len(publicKey) != 96 { return errors.New("invalid BLS public key length") diff --git a/crypto/builder_test.go b/crypto/builder_test.go new file mode 100644 index 0000000..f647b00 --- /dev/null +++ b/crypto/builder_test.go @@ -0,0 +1,291 @@ +package crypto + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testPassphrase = "this is a top secret passphrase" + +func signSerializeDeserialize(t *testing.T, transaction *Transaction) *Transaction { + t.Helper() + require := require.New(t) + + require.NoError(transaction.Sign(testPassphrase)) + + verified, err := transaction.Verify() + require.NoError(err) + require.True(verified) + + deserialized, err := DeserializeTransaction(HexEncode(transaction.Serialized)) + require.NoError(err) + + deserializedVerified, err := deserialized.Verify() + require.NoError(err) + require.True(deserializedVerified) + + return deserialized +} + +func TestBuildTransferRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + recipient := testAddress(0x01) + transaction, err := BuildTransfer(recipient, big.NewInt(1_000_000)) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsTransfer(deserialized.Data)) + assert.Equal(recipient, deserialized.To) + assert.Equal(0, big.NewInt(1_000_000).Cmp(deserialized.Value)) +} + +func TestBuildTransferInvalidRecipientErrors(t *testing.T) { + assert := assert.New(t) + + _, err := BuildTransfer("not-an-address", big.NewInt(1)) + assert.ErrorIs(err, ErrInvalidAddress) +} + +func TestBuildVoteRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + validator := testAddress(0x02) + transaction, err := BuildVote(validator) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsVote(deserialized.Data)) + assert.Equal(ContractConsensus, deserialized.To) + assert.Equal(validator, deserialized.Vote) +} + +func TestBuildUnvoteRoundTrip(t *testing.T) { + assert := assert.New(t) + + transaction := BuildUnvote() + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsUnvote(deserialized.Data)) + assert.Equal(ContractConsensus, deserialized.To) +} + +func TestBuildValidatorRegistrationRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + blsPublicKey := "a08058db53e2665c84a40f5152e76dd2b652125a6079130d4c315e728bcf4dd1dfb44ac26e82302331d61977d3141118" + + transaction, err := BuildValidatorRegistration(blsPublicKey, big.NewInt(2_500_000_000)) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsValidatorRegistration(deserialized.Data)) + assert.Equal(ContractConsensus, deserialized.To) + assert.Equal(blsPublicKey, deserialized.ValidatorPublicKey) + assert.Equal(0, big.NewInt(2_500_000_000).Cmp(deserialized.Value)) +} + +func TestBuildValidatorRegistrationInvalidKeyErrors(t *testing.T) { + assert := assert.New(t) + + _, err := BuildValidatorRegistration("too-short", nil) + assert.Error(err) +} + +func TestBuildValidatorUpdateRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + blsPublicKey := "a08058db53e2665c84a40f5152e76dd2b652125a6079130d4c315e728bcf4dd1dfb44ac26e82302331d61977d3141118" + + transaction, err := BuildValidatorUpdate(blsPublicKey) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsUpdateValidator(deserialized.Data)) + assert.Equal(blsPublicKey, deserialized.ValidatorPublicKey) +} + +func TestBuildValidatorResignationRoundTrip(t *testing.T) { + assert := assert.New(t) + + transaction := BuildValidatorResignation() + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsValidatorResignation(deserialized.Data)) + assert.Equal(ContractConsensus, deserialized.To) +} + +func TestBuildUsernameRegistrationRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + transaction, err := BuildUsernameRegistration("simple_tx_tester") + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsUsernameRegistration(deserialized.Data)) + assert.Equal(ContractUsernames, deserialized.To) + assert.Equal("simple_tx_tester", deserialized.Username) +} + +func TestBuildUsernameRegistrationValidation(t *testing.T) { + assert := assert.New(t) + + cases := []string{ + "", // too short + "this_username_is_way_too_long", // too long + "Invalid", // uppercase + "_leading", // leading underscore + "trailing_", // trailing underscore + "double__underscore", // consecutive underscores + } + + for _, username := range cases { + _, err := BuildUsernameRegistration(username) + assert.ErrorIs(err, ErrInvalidUsername, "username %q should be rejected", username) + } +} + +func TestBuildUsernameResignationRoundTrip(t *testing.T) { + assert := assert.New(t) + + transaction := BuildUsernameResignation() + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsUsernameResignation(deserialized.Data)) + assert.Equal(ContractUsernames, deserialized.To) +} + +func TestBuildMultiPaymentRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + addresses := []string{testAddress(0x01), testAddress(0x02)} + amounts := []*big.Int{big.NewInt(111222), big.NewInt(222333)} + + transaction, err := BuildMultiPayment(addresses, amounts) + require.NoError(err) + + assert.Equal(0, big.NewInt(333555).Cmp(transaction.Value)) // sum of amounts + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsMultiPayment(deserialized.Data)) + assert.Equal(ContractMultipayment, deserialized.To) + assert.Equal(addresses, deserialized.PaymentAddresses) + require.Equal(len(amounts), len(deserialized.PaymentAmounts)) + for i, amount := range amounts { + assert.Equal(0, amount.Cmp(deserialized.PaymentAmounts[i])) + } +} + +func TestBuildMultiPaymentMismatchedLengthsErrors(t *testing.T) { + assert := assert.New(t) + + _, err := BuildMultiPayment([]string{testAddress(0x01)}, []*big.Int{}) + assert.Error(err) +} + +func TestBuildMultiPaymentEmptyErrors(t *testing.T) { + assert := assert.New(t) + + _, err := BuildMultiPayment(nil, nil) + assert.Error(err) +} + +func TestBuildEvmCallRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + to := testAddress(0x03) + data := []byte{0xde, 0xad, 0xbe, 0xef} + + transaction, err := BuildEvmCall(to, data) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + // Arbitrary/unrecognized calldata: none of the known predicates match. + assert.False(IsVote(deserialized.Data)) + assert.False(IsUnvote(deserialized.Data)) + assert.False(IsValidatorRegistration(deserialized.Data)) + assert.False(IsUsernameRegistration(deserialized.Data)) + assert.False(IsMultiPayment(deserialized.Data)) + assert.Equal(to, deserialized.To) + assert.Equal(data, deserialized.Data) +} + +func TestBuildBatchTransferRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + token := testAddress(0x04) + recipients := []string{testAddress(0x01), testAddress(0x02)} + amounts := []*big.Int{big.NewInt(100), big.NewInt(200)} + + transaction, err := BuildBatchTransfer(token, recipients, amounts) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsBatchTransfer(deserialized.Data)) + assert.Equal(ContractBatchTransfer, deserialized.To) +} + +func TestBuildTokenApproveRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + token := testAddress(0x05) + spender := testAddress(0x06) + + transaction, err := BuildTokenApprove(token, spender, big.NewInt(500)) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsApprove(deserialized.Data)) + assert.False(IsRevoke(deserialized.Data)) + assert.Equal(token, deserialized.To) +} + +func TestBuildTokenApproveZeroAmountIsRevoke(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + transaction, err := BuildTokenApprove(testAddress(0x05), testAddress(0x06), big.NewInt(0)) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsRevoke(deserialized.Data)) + assert.False(IsApprove(deserialized.Data)) +} + +func TestBuildTokenTransferRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + token := testAddress(0x07) + recipient := testAddress(0x08) + + transaction, err := BuildTokenTransfer(token, recipient, big.NewInt(750)) + require.NoError(err) + + deserialized := signSerializeDeserialize(t, transaction) + + assert.True(IsTokenTransfer(deserialized.Data)) + assert.Equal(token, deserialized.To) +} diff --git a/crypto/deserializer.go b/crypto/deserializer.go index aeff9d9..01e79af 100644 --- a/crypto/deserializer.go +++ b/crypto/deserializer.go @@ -1,10 +1,3 @@ -// This file is part of Ark Go Crypto. -// -// (c) Ark Ecosystem -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - package crypto import ( @@ -18,10 +11,8 @@ var ( ErrDeserializeInvalidV = errors.New("deserialize: v field does not decode to a valid recovery id") ) -// DeserializeTransaction decodes a hex-encoded RLP transaction envelope -// ([nonce, gasPrice, gasLimit, to, value, data, v, r, s]), reverses the -// EIP-155 v encoding back to a raw recovery id, computes the transaction -// hash, and recovers the sender's public key and address from the signature. +// DeserializeTransaction decodes a hex-encoded RLP transaction envelope: +// [nonce, gasPrice, gasLimit, to, value, data, v, r, s]. func DeserializeTransaction(serializedHex string) (*Transaction, error) { serialized := HexDecode(serializedHex) @@ -77,6 +68,10 @@ func DeserializeTransaction(serializedHex string) (*Transaction, error) { return nil, err } + if err := DecodeTransactionArgs(transaction); err != nil { + return nil, err + } + return transaction, nil } diff --git a/crypto/structs.go b/crypto/structs.go index 43a6213..0e81471 100644 --- a/crypto/structs.go +++ b/crypto/structs.go @@ -52,6 +52,16 @@ type Transaction struct { From string `json:"from,omitempty"` Hash string `json:"hash,omitempty"` Serialized []byte `json:"serialized,omitempty"` + + // The fields below are populated only for the transaction kind they + // apply to, by DecodeTransactionArgs during deserialization; all others + // are left at their zero value. + Vote string `json:"vote,omitempty"` + ValidatorPublicKey string `json:"validatorPublicKey,omitempty"` + ValidatorProof string `json:"validatorProof,omitempty"` + Username string `json:"username,omitempty"` + PaymentAddresses []string `json:"paymentAddresses,omitempty"` + PaymentAmounts []*big.Int `json:"paymentAmounts,omitempty"` } type Message struct { diff --git a/crypto/transaction.go b/crypto/transaction.go index ad3396f..03496e3 100644 --- a/crypto/transaction.go +++ b/crypto/transaction.go @@ -1,10 +1,3 @@ -// This file is part of Ark Go Crypto. -// -// (c) Ark Ecosystem -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - package crypto import ( diff --git a/crypto/transaction_test.go b/crypto/transaction_test.go new file mode 100644 index 0000000..455c930 --- /dev/null +++ b/crypto/transaction_test.go @@ -0,0 +1,102 @@ +package crypto + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransactionSignSerializeDeserializeVerifyRoundTrip(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + passphrase := "this is a top secret passphrase" + + transaction := NewTransaction() + transaction.Nonce = big.NewInt(7) + transaction.To = ContractConsensus + transaction.Value = big.NewInt(0) + transaction.Data = AbiEncodeFunctionCall(AbiSignatureUnvote) + + require.NoError(transaction.Sign(passphrase)) + + expectedAddress, err := AddressFromPassphrase(passphrase) + require.NoError(err) + assert.Equal(expectedAddress, transaction.From) + + verified, err := transaction.Verify() + require.NoError(err) + assert.True(verified) + + serializedHex := HexEncode(transaction.Serialized) + + deserialized, err := DeserializeTransaction(serializedHex) + require.NoError(err) + + assert.Equal(0, transaction.Nonce.Cmp(deserialized.Nonce)) + assert.Equal(0, transaction.GasPrice.Cmp(deserialized.GasPrice)) + assert.Equal(0, transaction.GasLimit.Cmp(deserialized.GasLimit)) + assert.Equal(transaction.To, deserialized.To) + assert.Equal(0, transaction.Value.Cmp(deserialized.Value)) + assert.Equal(transaction.Data, deserialized.Data) + assert.Equal(transaction.V, deserialized.V) + assert.Equal(transaction.R, deserialized.R) + assert.Equal(transaction.S, deserialized.S) + assert.Equal(transaction.Hash, deserialized.Hash) + assert.Equal(transaction.SenderPublicKey, deserialized.SenderPublicKey) + assert.Equal(transaction.From, deserialized.From) + + deserializedVerified, err := deserialized.Verify() + require.NoError(err) + assert.True(deserializedVerified) +} + +func TestTransactionVerifyFailsForWrongSigner(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + transaction := NewTransaction() + transaction.To = ContractConsensus + transaction.Data = AbiEncodeFunctionCall(AbiSignatureResignValidator) + + require.NoError(transaction.Sign("the real signer's passphrase")) + + otherPublicKey, err := PublicKeyFromPassphrase("a completely different passphrase") + require.NoError(err) + transaction.SenderPublicKey = otherPublicKey.ToHex() + + verified, err := transaction.Verify() + require.NoError(err) + assert.False(verified) +} + +func TestTransactionSerializeUnsignedMatchesSigningHash(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + transaction := NewTransaction() + transaction.To = ContractUsernames + transaction.Data = AbiEncodeFunctionCall(AbiSignatureResignUsername) + + withSignature, err := transaction.Serialize(false) + require.NoError(err) + + skipSignature, err := transaction.Serialize(true) + require.NoError(err) + + assert.Equal(withSignature, skipSignature) +} + +func TestDeserializeTransactionRejectsTruncatedData(t *testing.T) { + assert := assert.New(t) + + // A valid RLP list containing a single short string — nowhere near the 9 + // fields [nonce, gasPrice, gasLimit, to, value, data, v, r, s] a + // transaction needs. + shortList := "c3820102" + + _, err := DeserializeTransaction(shortList) + assert.Error(err) +} diff --git a/crypto/transaction_types.go b/crypto/transaction_types.go new file mode 100644 index 0000000..57bcb4c --- /dev/null +++ b/crypto/transaction_types.go @@ -0,0 +1,187 @@ +package crypto + +import ( + "bytes" + "encoding/hex" + "math/big" +) + +func DecodeTransactionArgs(transaction *Transaction) error { + type candidate struct { + signature string + argCount int + apply func(*Transaction, *AbiDecoder) error + } + + candidates := []candidate{ + {AbiSignatureVote, 1, applyVote}, + {AbiSignatureUnvote, 0, applyUnvote}, + {AbiSignatureRegisterValidator, 2, applyValidatorRegistration}, + {AbiSignatureResignValidator, 0, applyValidatorResignation}, + {AbiSignatureUpdateValidator, 2, applyValidatorUpdate}, + {AbiSignatureRegisterUsername, 1, applyUsernameRegistration}, + {AbiSignatureResignUsername, 0, applyUsernameResignation}, + {AbiSignatureMultipayment, 2, applyMultiPayment}, + } + + for _, c := range candidates { + if !dataHasSelector(transaction.Data, c.signature) { + continue + } + + decoder, err := NewAbiDecoder(transaction.Data, c.signature, c.argCount) + if err != nil { + return err + } + + return c.apply(transaction, decoder) + } + + return nil +} + +func dataHasSelector(data []byte, signature string) bool { + if len(data) < abiSelectorLength { + return false + } + return bytes.Equal(data[:abiSelectorLength], AbiFunctionSelector(signature)) +} + +func applyVote(transaction *Transaction, decoder *AbiDecoder) error { + vote, err := decoder.Address(0) + if err != nil { + return err + } + transaction.Vote = vote + return nil +} + +func applyUnvote(transaction *Transaction, _ *AbiDecoder) error { + return nil +} + +func applyValidatorRegistration(transaction *Transaction, decoder *AbiDecoder) error { + pubKey, err := decoder.Bytes(0) + if err != nil { + return err + } + proof, err := decoder.Bytes(1) + if err != nil { + return err + } + transaction.ValidatorPublicKey = hex.EncodeToString(pubKey) + transaction.ValidatorProof = hex.EncodeToString(proof) + return nil +} + +func applyValidatorResignation(transaction *Transaction, _ *AbiDecoder) error { + return nil +} + +func applyValidatorUpdate(transaction *Transaction, decoder *AbiDecoder) error { + pubKey, err := decoder.Bytes(0) + if err != nil { + return err + } + proof, err := decoder.Bytes(1) + if err != nil { + return err + } + transaction.ValidatorPublicKey = hex.EncodeToString(pubKey) + transaction.ValidatorProof = hex.EncodeToString(proof) + return nil +} + +func applyUsernameRegistration(transaction *Transaction, decoder *AbiDecoder) error { + username, err := decoder.String(0) + if err != nil { + return err + } + transaction.Username = username + return nil +} + +func applyUsernameResignation(transaction *Transaction, _ *AbiDecoder) error { + return nil +} + +func applyMultiPayment(transaction *Transaction, decoder *AbiDecoder) error { + addresses, err := decoder.AddressArray(0) + if err != nil { + return err + } + amounts, err := decoder.Uint256Array(1) + if err != nil { + return err + } + transaction.PaymentAddresses = addresses + transaction.PaymentAmounts = amounts + return nil +} + +func IsTransfer(data []byte) bool { + return len(data) == 0 +} + +func IsVote(data []byte) bool { + return dataHasSelector(data, AbiSignatureVote) +} + +func IsUnvote(data []byte) bool { + return dataHasSelector(data, AbiSignatureUnvote) +} + +func IsMultiPayment(data []byte) bool { + return dataHasSelector(data, AbiSignatureMultipayment) +} + +func IsUsernameRegistration(data []byte) bool { + return dataHasSelector(data, AbiSignatureRegisterUsername) +} + +func IsUsernameResignation(data []byte) bool { + return dataHasSelector(data, AbiSignatureResignUsername) +} + +func IsValidatorRegistration(data []byte) bool { + return dataHasSelector(data, AbiSignatureRegisterValidator) +} + +func IsValidatorResignation(data []byte) bool { + return dataHasSelector(data, AbiSignatureResignValidator) +} + +func IsUpdateValidator(data []byte) bool { + return dataHasSelector(data, AbiSignatureUpdateValidator) +} + +func IsTokenTransfer(data []byte) bool { + return dataHasSelector(data, AbiSignatureERC20Transfer) +} + +func IsBatchTransfer(data []byte) bool { + _, err := NewAbiDecoder(data, AbiSignatureERC20BatchTransferFrom, 3) + return err == nil +} + +func IsApprove(data []byte) bool { + amount, ok := decodedApproveAmount(data) + return ok && amount.Sign() > 0 +} + +func IsRevoke(data []byte) bool { + amount, ok := decodedApproveAmount(data) + return ok && amount.Sign() == 0 +} + +func decodedApproveAmount(data []byte) (amount *big.Int, ok bool) { + decoder, err := NewAbiDecoder(data, AbiSignatureERC20Approve, 2) + if err != nil { + return nil, false + } + amount, err = decoder.Uint256(1) + if err != nil { + return nil, false + } + return amount, true +} diff --git a/crypto/transaction_types_test.go b/crypto/transaction_types_test.go new file mode 100644 index 0000000..10925b6 --- /dev/null +++ b/crypto/transaction_types_test.go @@ -0,0 +1,212 @@ +package crypto + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustAbiAddress(t *testing.T, address string) AbiArg { + t.Helper() + arg, err := AbiAddress(address) + require.New(t).NoError(err) + return arg +} + +func mustAbiUint256(t *testing.T, value int64) AbiArg { + t.Helper() + arg, err := AbiUint256(big.NewInt(value)) + require.New(t).NoError(err) + return arg +} + +func mustAbiAddressArray(t *testing.T, addresses []string) AbiArg { + t.Helper() + arg, err := AbiAddressArray(addresses) + require.New(t).NoError(err) + return arg +} + +func mustAbiUint256Array(t *testing.T, values ...int64) AbiArg { + t.Helper() + bigValues := make([]*big.Int, len(values)) + for i, v := range values { + bigValues[i] = big.NewInt(v) + } + arg, err := AbiUint256Array(bigValues) + require.New(t).NoError(err) + return arg +} + +func TestIsTransfer(t *testing.T) { + assert := assert.New(t) + + assert.True(IsTransfer(nil)) + assert.True(IsTransfer([]byte{})) + assert.False(IsTransfer(AbiEncodeFunctionCall(AbiSignatureUnvote))) +} + +func TestIsVote(t *testing.T) { + assert := assert.New(t) + + voteData := AbiEncodeFunctionCall(AbiSignatureVote, mustAbiAddress(t, testAddress(0x01))) + + assert.True(IsVote(voteData)) + assert.False(IsVote(AbiEncodeFunctionCall(AbiSignatureUnvote))) + assert.False(IsVote(nil)) +} + +func TestIsUnvote(t *testing.T) { + assert := assert.New(t) + + assert.True(IsUnvote(AbiEncodeFunctionCall(AbiSignatureUnvote))) + assert.False(IsUnvote(AbiEncodeFunctionCall(AbiSignatureVote, mustAbiAddress(t, testAddress(0x01))))) +} + +func TestIsMultiPayment(t *testing.T) { + assert := assert.New(t) + + data := AbiEncodeFunctionCall(AbiSignatureMultipayment, + mustAbiAddressArray(t, []string{testAddress(0x01)}), + mustAbiUint256Array(t, 100), + ) + + assert.True(IsMultiPayment(data)) + assert.False(IsMultiPayment(AbiEncodeFunctionCall(AbiSignatureVote, mustAbiAddress(t, testAddress(0x01))))) +} + +func TestIsUsernameRegistration(t *testing.T) { + assert := assert.New(t) + + data := AbiEncodeFunctionCall(AbiSignatureRegisterUsername, AbiString("test_user")) + + assert.True(IsUsernameRegistration(data)) + assert.False(IsUsernameRegistration(AbiEncodeFunctionCall(AbiSignatureResignUsername))) +} + +func TestIsUsernameResignation(t *testing.T) { + assert := assert.New(t) + + assert.True(IsUsernameResignation(AbiEncodeFunctionCall(AbiSignatureResignUsername))) + assert.False(IsUsernameResignation(AbiEncodeFunctionCall(AbiSignatureRegisterUsername, AbiString("test_user")))) +} + +func TestIsValidatorRegistrationAndIsUpdateValidatorDoNotCrossMatch(t *testing.T) { + assert := assert.New(t) + + pubKey := AbiBytes([]byte("a public key")) + proof := AbiBytes([]byte("a proof")) + + registrationData := AbiEncodeFunctionCall(AbiSignatureRegisterValidator, pubKey, proof) + updateData := AbiEncodeFunctionCall(AbiSignatureUpdateValidator, pubKey, proof) + + assert.True(IsValidatorRegistration(registrationData)) + assert.False(IsUpdateValidator(registrationData)) + + assert.True(IsUpdateValidator(updateData)) + assert.False(IsValidatorRegistration(updateData)) +} + +func TestIsValidatorResignation(t *testing.T) { + assert := assert.New(t) + + assert.True(IsValidatorResignation(AbiEncodeFunctionCall(AbiSignatureResignValidator))) + assert.False(IsValidatorResignation(AbiEncodeFunctionCall(AbiSignatureResignUsername))) +} + +func TestIsTokenTransfer(t *testing.T) { + assert := assert.New(t) + + data := AbiEncodeFunctionCall(AbiSignatureERC20Transfer, mustAbiAddress(t, testAddress(0x01)), mustAbiUint256(t, 100)) + + assert.True(IsTokenTransfer(data)) + assert.False(IsTokenTransfer(AbiEncodeFunctionCall(AbiSignatureVote, mustAbiAddress(t, testAddress(0x01))))) +} + +func TestIsBatchTransfer(t *testing.T) { + assert := assert.New(t) + + data := AbiEncodeFunctionCall(AbiSignatureERC20BatchTransferFrom, + mustAbiAddress(t, testAddress(0x01)), + mustAbiAddressArray(t, []string{testAddress(0x02)}), + mustAbiUint256Array(t, 100), + ) + + assert.True(IsBatchTransfer(data)) + assert.False(IsBatchTransfer(AbiEncodeFunctionCall(AbiSignatureVote, mustAbiAddress(t, testAddress(0x01))))) +} + +func TestIsApproveAndIsRevoke(t *testing.T) { + assert := assert.New(t) + + approveData := AbiEncodeFunctionCall(AbiSignatureERC20Approve, mustAbiAddress(t, testAddress(0x01)), mustAbiUint256(t, 500)) + revokeData := AbiEncodeFunctionCall(AbiSignatureERC20Approve, mustAbiAddress(t, testAddress(0x01)), mustAbiUint256(t, 0)) + + assert.True(IsApprove(approveData)) + assert.False(IsRevoke(approveData)) + + assert.True(IsRevoke(revokeData)) + assert.False(IsApprove(revokeData)) +} + +func TestIsFunctionsHandleMalformedDataWithoutPanicking(t *testing.T) { + assert := assert.New(t) + + garbage := []byte{0xde, 0xad, 0xbe, 0xef} // selector-length, matches nothing real + + assert.NotPanics(func() { + assert.False(IsVote(garbage)) + assert.False(IsUnvote(garbage)) + assert.False(IsMultiPayment(garbage)) + assert.False(IsUsernameRegistration(garbage)) + assert.False(IsUsernameResignation(garbage)) + assert.False(IsValidatorRegistration(garbage)) + assert.False(IsValidatorResignation(garbage)) + assert.False(IsUpdateValidator(garbage)) + assert.False(IsTokenTransfer(garbage)) + assert.False(IsBatchTransfer(garbage)) + assert.False(IsApprove(garbage)) + assert.False(IsRevoke(garbage)) + }) +} + +func TestDecodeTransactionArgsPopulatesSemanticFields(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + validatorAddress := testAddress(0x01) + + transaction := &Transaction{ + Data: AbiEncodeFunctionCall(AbiSignatureVote, mustAbiAddress(t, validatorAddress)), + } + + require.NoError(DecodeTransactionArgs(transaction)) + assert.Equal(validatorAddress, transaction.Vote) +} + +func TestDecodeTransactionArgsMalformedKnownSelectorErrors(t *testing.T) { + assert := assert.New(t) + + transaction := NewTransaction() + transaction.To = ContractConsensus + // The correct 4-byte selector for vote(address), followed by a payload + // too short to contain the required 32-byte address argument. + transaction.Data = append(AbiFunctionSelector(AbiSignatureVote), 0x01, 0x02) + + err := DecodeTransactionArgs(transaction) + assert.Error(err) +} + +func TestDecodeTransactionArgsNoOpForUnrecognizedData(t *testing.T) { + assert := assert.New(t) + + transaction := &Transaction{Data: []byte{0xde, 0xad, 0xbe, 0xef}} + + assert.NoError(DecodeTransactionArgs(transaction)) + assert.Empty(transaction.Vote) + assert.Empty(transaction.Username) + assert.Empty(transaction.ValidatorPublicKey) + assert.Empty(transaction.PaymentAddresses) +}