diff --git a/contract_scripts.go b/contract_scripts.go index ac4d466..ce27999 100644 --- a/contract_scripts.go +++ b/contract_scripts.go @@ -2,7 +2,9 @@ package tezosprotocol import ( "bytes" + "encoding" "encoding/binary" + "math" "golang.org/x/xerrors" ) @@ -78,3 +80,203 @@ func (c *ContractScript) UnmarshalBinary(data []byte) error { return nil } + +// EntrypointTag captures the possible tag values for $entrypoint.Tag +type EntrypointTag byte + +// EntrypointTag values +const ( + EntrypointTagDefault EntrypointTag = 0 + EntrypointTagRoot EntrypointTag = 1 + EntrypointTagDo EntrypointTag = 2 + EntrypointTagSetDelegate EntrypointTag = 3 + EntrypointTagRemoveDelegate EntrypointTag = 4 + EntrypointTagNamed EntrypointTag = 255 +) + +// Entrypoint models $entrypoint +type Entrypoint struct { + tag EntrypointTag + name string +} + +// Preset entrypoints (those with an implicit name) +var ( + EntrypointDefault = Entrypoint{tag: EntrypointTagDefault} + EntrypointRoot = Entrypoint{tag: EntrypointTagRoot} + EntrypointDo = Entrypoint{tag: EntrypointTagDo} + EntrypointSetDelegate = Entrypoint{tag: EntrypointTagDefault} + EntrypointRemoveDelegate = Entrypoint{tag: EntrypointTagRemoveDelegate} +) + +// NewNamedEntrypoint creates a named entrypoint. This should be used when attempting to +// invoke a custom entrypoint that is not one of the reserved ones (%default, %root, %do, etcetera...). +func NewNamedEntrypoint(name string) (Entrypoint, error) { + if len(name) > math.MaxUint8 { + return Entrypoint{}, xerrors.Errorf("entrypoint name %s exceeds maximum length %d", math.MaxUint8) + } + return Entrypoint{tag: EntrypointTagNamed, name: name}, nil +} + +// Tag returns the entrypoint tag +func (e Entrypoint) Tag() EntrypointTag { + return e.tag +} + +// Name returns the entrypoint name +func (e Entrypoint) Name() (string, error) { + switch e.tag { + case EntrypointTagDefault: + return "default", nil + case EntrypointTagRoot: + return "root", nil + case EntrypointTagDo: + return "do", nil + case EntrypointTagSetDelegate: + return "set_delegate", nil + case EntrypointTagRemoveDelegate: + return "remove_delegate", nil + case EntrypointTagNamed: + if e.name == "" { + return "", xerrors.Errorf("entrypoint is not named") + } + return e.name, nil + default: + return "", xerrors.Errorf("unrecognized entrypoint tag: %d", uint8(e.tag)) + } +} + +// String implements fmt.Stringer +func (e Entrypoint) String() string { + name, err := e.Name() + if err != nil { + return "" + } + return "%" + name +} + +// MarshalBinary implements encoding.BinaryMarshaler. +func (e Entrypoint) MarshalBinary() ([]byte, error) { //nolint:golint,unparam + buffer := new(bytes.Buffer) + buffer.WriteByte(byte(e.tag)) + if e.tag == EntrypointTagNamed { + buffer.WriteByte(uint8(len(e.name))) + buffer.WriteString(e.name) + } + return buffer.Bytes(), nil +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler. +func (e *Entrypoint) UnmarshalBinary(data []byte) error { + if len(data) < 1 { + return xerrors.Errorf("too few bytes to unmarshal Entrypoint") + } + e.tag = EntrypointTag(data[0]) + if e.tag == EntrypointTagNamed { + data = data[1:] + if len(data) < 1 { + return xerrors.Errorf("too few bytes to unmarshal Entrypoint name length") + } + nameLength := data[0] + data = data[1:] + if len(data) < int(nameLength) { + return xerrors.Errorf("too few bytes to unmarshal Entrypoint name") + } + e.name = string(data[:nameLength]) + } + return nil +} + +// TransactionParametersValue models $X_o.value +type TransactionParametersValue interface { + encoding.BinaryMarshaler + encoding.BinaryUnmarshaler +} + +// note: want to create a rich type for this modeling Michelson instructions. +// This stopgap approach allows just using raw byte arrays in the meantime without +// sacrificing forward compatibility. + +// TransactionParametersValueRawBytes is an interim way to provide the value for +// transaction parameters, until support for Michelson is added. +type TransactionParametersValueRawBytes []byte + +// MarshalBinary implements encoding.BinaryMarshaler. +func (t *TransactionParametersValueRawBytes) MarshalBinary() ([]byte, error) { + var parameters []byte + if t != nil { + parameters = []byte(*t) + } + outputBuf := new(bytes.Buffer) + err := binary.Write(outputBuf, binary.BigEndian, uint32(len(parameters))) + if err != nil { + return nil, xerrors.Errorf("failed to marshal parameters length: %w', err") + } + outputBuf.Write(parameters) + return outputBuf.Bytes(), nil +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler. +func (t *TransactionParametersValueRawBytes) UnmarshalBinary(data []byte) error { + var length uint32 + err := binary.Read(bytes.NewReader(data), binary.BigEndian, &length) + if err != nil { + return xerrors.Errorf("invalid transaction parameters value: %w", err) + } + if len(data) != int(4+length) { + return xerrors.Errorf("parameters should be %d bytes, but was %d", length, len(data)-4) + } + *t = data[4:] + return nil +} + +// TransactionParameters models $X_o. +// Reference: http://tezos.gitlab.io/babylonnet/api/p2p.html#x-0 +type TransactionParameters struct { + Entrypoint Entrypoint + Value TransactionParametersValue +} + +// MarshalBinary implements encoding.BinaryMarshaler. +func (t TransactionParameters) MarshalBinary() ([]byte, error) { + buffer := new(bytes.Buffer) + entrypointBytes, err := t.Entrypoint.MarshalBinary() + if err != nil { + return nil, xerrors.Errorf("failed to marshal entrypoint: %w", err) + } + buffer.Write(entrypointBytes) + valueBytes, err := t.Value.MarshalBinary() + if err != nil { + return nil, xerrors.Errorf("failed to marshal value: %w", err) + } + buffer.Write(valueBytes) + return buffer.Bytes(), nil +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler. +func (t *TransactionParameters) UnmarshalBinary(data []byte) (err error) { + // cleanly recover from out of bounds exceptions + defer func() { + if err == nil { + if r := recover(); r != nil { + err = catchOutOfRangeExceptions(r) + } + } + }() + dataPtr := data + err = t.Entrypoint.UnmarshalBinary(dataPtr) + if err != nil { + return xerrors.Errorf("failed to unmarshal entrypoint: %w", err) + } + entrypointBytes, err := t.Entrypoint.MarshalBinary() + if err != nil { + return err + } + dataPtr = dataPtr[len(entrypointBytes):] + t.Value = &TransactionParametersValueRawBytes{} + err = t.Value.UnmarshalBinary(dataPtr) + if err != nil { + return xerrors.Errorf("failed to unmarshal value: %w", err) + } + return nil +} diff --git a/contract_scripts_test.go b/contract_scripts_test.go index d9a5a69..3b4d18d 100644 --- a/contract_scripts_test.go +++ b/contract_scripts_test.go @@ -2,6 +2,8 @@ package tezosprotocol_test import ( "encoding/hex" + "math" + "strings" "testing" tezosprotocol "github.com/anchorageoss/tezosprotocol/v2" @@ -37,3 +39,74 @@ func TestContractScriptUnmarshalBinary(t *testing.T) { require.Error(err) require.Contains(err.Error(), "failed to read storage") } + +func TestSerializeTransactionParameters(t *testing.T) { + require := require.New(t) + + // "do" entrypoint + // --------------- + // tezos-client rpc post /chains/main/blocks/head/helpers/forge/operations with '{ + // "branch": "BMTiv62VhjkVXZJL9Cu5s56qTAJxyciQB2fzA9vd2EiVMsaucWB", + // "contents": + // [ { "kind": "transaction", + // "source": "tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx", + // "fee": "1266", "counter": "1", "gas_limit": "10100", + // "storage_limit": "277", "amount": "0", + // "destination": "KT1GrStTuhgMMpzbNWKTt7NoXGrYiufrHDYq", + // "parameters": {"entrypoint": "do", "value": {}} } ] + // }' + // e655948a282fcfc31b98abe9b37a82038c4c0e9b8e11f60ea0c7b33e6ecc625f6c0002298c03ed7d454a101eb7022bc95f7e5f41ac78f20901f44e950200015ab81204ccd229281b9c462edaf0a43e78075f4600ff02000000050200000000 + paramsValueBytes, err := hex.DecodeString("0200000000") + require.NoError(err) + paramsValue := tezosprotocol.TransactionParametersValueRawBytes(paramsValueBytes) + params := tezosprotocol.TransactionParameters{ + Entrypoint: tezosprotocol.EntrypointDo, + Value: ¶msValue, + } + expectedBytes := "02000000050200000000" + observedBytes, err := params.MarshalBinary() + require.NoError(err) + require.Equal(expectedBytes, hex.EncodeToString(observedBytes)) + reserialized := tezosprotocol.TransactionParameters{} + require.NoError(reserialized.UnmarshalBinary(observedBytes)) + require.Equal(params, reserialized) +} + +func TestSerializeNamedEntrypoint(t *testing.T) { + require := require.New(t) + + // misc named entrypoint + // --------------------- + // tezos-client rpc post /chains/main/blocks/head/helpers/forge/operations with '{ + // "branch": "BMTiv62VhjkVXZJL9Cu5s56qTAJxyciQB2fzA9vd2EiVMsaucWB", + // "contents": + // [ { "kind": "transaction", + // "source": "tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx", + // "fee": "1266", "counter": "1", "gas_limit": "10100", + // "storage_limit": "277", "amount": "0", + // "destination": "KT1GrStTuhgMMpzbNWKTt7NoXGrYiufrHDYq", + // "parameters": {"entrypoint": "dummy", "value": {}} } ] + // }' + // e655948a282fcfc31b98abe9b37a82038c4c0e9b8e11f60ea0c7b33e6ecc625f6c0002298c03ed7d454a101eb7022bc95f7e5f41ac78f20901f44e950200015ab81204ccd229281b9c462edaf0a43e78075f4600ffff0564756d6d79000000050200000000 + paramsValueBytes, err := hex.DecodeString("0200000000") + require.NoError(err) + entrypoint, err := tezosprotocol.NewNamedEntrypoint("dummy") + require.NoError(err) + paramsValue := tezosprotocol.TransactionParametersValueRawBytes(paramsValueBytes) + expectedBytes := "ff0564756d6d79000000050200000000" + params := tezosprotocol.TransactionParameters{ + Entrypoint: entrypoint, + Value: ¶msValue, + } + observedBytes, err := params.MarshalBinary() + require.NoError(err) + require.Equal(expectedBytes, hex.EncodeToString(observedBytes)) + reserialized := tezosprotocol.TransactionParameters{} + require.NoError(reserialized.UnmarshalBinary(observedBytes)) + require.Equal(params, reserialized) +} + +func TestEndpointNameTooLong(t *testing.T) { + _, err := tezosprotocol.NewNamedEntrypoint(strings.Repeat("a", math.MaxUint8+1)) + require.Error(t, err) +} diff --git a/p2p_messages.go b/p2p_messages.go index 841c1c9..2443971 100644 --- a/p2p_messages.go +++ b/p2p_messages.go @@ -684,8 +684,7 @@ type Transaction struct { StorageLimit *big.Int Amount *big.Int Destination ContractID - //nolint:godox - // TODO: parameters + Parameters *TransactionParameters } func (t *Transaction) String() string { @@ -758,8 +757,16 @@ func (t *Transaction) MarshalBinary() ([]byte, error) { } buf.Write(destinationBytes) - // no parameters follow - buf.WriteByte(0) + // parameters + paramsFollow := t.Parameters != nil + buf.WriteByte(serializeBoolean(paramsFollow)) + if paramsFollow { + paramsBytes, err := t.Parameters.MarshalBinary() + if err != nil { + return nil, xerrors.Errorf("failed to write transaction parameters: %w", err) + } + buf.Write(paramsBytes) + } return buf.Bytes(), nil } @@ -836,11 +843,16 @@ func (t *Transaction) UnmarshalBinary(data []byte) (err error) { // parameters hasParameters, err := deserializeBoolean(dataPtr[0]) + dataPtr = dataPtr[1:] if err != nil { return xerrors.Errorf("failed to deserialialize presence of field \"parameters\": %w", err) } if hasParameters { - return xerrors.Errorf("deserializing parameters not supported") + t.Parameters = &TransactionParameters{Value: &TransactionParametersValueRawBytes{}} + err = t.Parameters.UnmarshalBinary(dataPtr) + if err != nil { + return xerrors.Errorf("failed to deserialize transaction parameters: %w", err) + } } return nil diff --git a/p2p_messages_test.go b/p2p_messages_test.go index 9aa8859..b597bf7 100644 --- a/p2p_messages_test.go +++ b/p2p_messages_test.go @@ -139,6 +139,65 @@ func TestDecodeTransaction(t *testing.T) { require.Equal("0", transaction.StorageLimit.String()) require.Equal("100000000", transaction.Amount.String()) require.Equal(tezosprotocol.ContractID("tz1gjaF81ZRRvdzjobyfVNsAeSC6PScjfQwN"), transaction.Destination) + require.Nil(transaction.Parameters) +} + +func TestEncodeTransactionWithParameters(t *testing.T) { + require := require.New(t) + // tezos-client rpc post /chains/main/blocks/head/helpers/forge/operations with '{ + // "branch": "BMTiv62VhjkVXZJL9Cu5s56qTAJxyciQB2fzA9vd2EiVMsaucWB", + // "contents": + // [ { "kind": "transaction", + // "source": "tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx", + // "fee": "1266", "counter": "1", "gas_limit": "10100", + // "storage_limit": "277", "amount": "0", + // "destination": "KT1GrStTuhgMMpzbNWKTt7NoXGrYiufrHDYq", + // "parameters": {"entrypoint": "do", "value": {}} } ] + // }' + // e655948a282fcfc31b98abe9b37a82038c4c0e9b8e11f60ea0c7b33e6ecc625f6c0002298c03ed7d454a101eb7022bc95f7e5f41ac78f20901f44e950200015ab81204ccd229281b9c462edaf0a43e78075f4600ff02000000050200000000 + paramsValueBytes, err := hex.DecodeString("0200000000") + require.NoError(err) + paramsValue := tezosprotocol.TransactionParametersValueRawBytes(paramsValueBytes) + transaction := &tezosprotocol.Transaction{ + Source: tezosprotocol.ContractID("tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx"), + Fee: big.NewInt(1266), + Counter: big.NewInt(1), + GasLimit: big.NewInt(10100), + StorageLimit: big.NewInt(277), + Amount: big.NewInt(0), + Destination: tezosprotocol.ContractID("KT1GrStTuhgMMpzbNWKTt7NoXGrYiufrHDYq"), + Parameters: &tezosprotocol.TransactionParameters{ + Entrypoint: tezosprotocol.EntrypointDo, + Value: ¶msValue, + }, + } + encodedBytes, err := transaction.MarshalBinary() + require.NoError(err) + encoded := hex.EncodeToString(encodedBytes) + expected := "6c0002298c03ed7d454a101eb7022bc95f7e5f41ac78f20901f44e950200015ab81204ccd229281b9c462edaf0a43e78075f4600ff02000000050200000000" + require.Equal(expected, encoded) +} + +func TestDecodeTransactionWithParameters(t *testing.T) { + require := require.New(t) + encoded, err := hex.DecodeString("6c0002298c03ed7d454a101eb7022bc95f7e5f41ac78f20901f44e950200015ab81204ccd229281b9c462edaf0a43e78075f4600ff02000000050200000000") + require.NoError(err) + transaction := tezosprotocol.Transaction{} + require.NoError(transaction.UnmarshalBinary(encoded)) + require.Equal(tezosprotocol.ContractID("tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx"), transaction.Source) + require.Equal("1266", transaction.Fee.String()) + require.Equal("1", transaction.Counter.String()) + require.Equal("10100", transaction.GasLimit.String()) + require.Equal("277", transaction.StorageLimit.String()) + require.Equal("0", transaction.Amount.String()) + require.Equal(tezosprotocol.ContractID("KT1GrStTuhgMMpzbNWKTt7NoXGrYiufrHDYq"), transaction.Destination) + require.NotNil(transaction.Parameters) + require.Equal(tezosprotocol.EntrypointDo, transaction.Parameters.Entrypoint) + expectedParamsValue, err := hex.DecodeString("000000050200000000") + require.NoError(err) + observedParamsValue, err := transaction.Parameters.Value.MarshalBinary() + require.NoError(err) + require.Equal(expectedParamsValue, observedParamsValue) } func TestEncodeOrigination(t *testing.T) {