From 720f34cf3a413a23bb19dbd6322a8b916931eb8e Mon Sep 17 00:00:00 2001 From: evlekht Date: Fri, 20 Sep 2024 08:58:15 +0400 Subject: [PATCH] Matrix-message types and cheque signing (#28) --- internal/matrix/matrix_compressor.go | 25 ++--- internal/matrix/matrix_compressor_test.go | 9 +- internal/matrix/matrix_messenger.go | 15 ++- internal/matrix/msg_assembler.go | 21 ++-- internal/matrix/msg_assembler_test.go | 31 +++--- internal/metadata/metadata.go | 15 +-- pkg/cheques/cheques.go | 116 +++++++++++++++++++++ pkg/cheques/eth_typed_data.go | 93 +++++++++++++++++ pkg/cheques/signer.go | 119 ++++++++++++++++++++++ {internal => pkg}/matrix/types.go | 19 ++++ 10 files changed, 405 insertions(+), 58 deletions(-) create mode 100644 pkg/cheques/cheques.go create mode 100644 pkg/cheques/eth_typed_data.go create mode 100644 pkg/cheques/signer.go rename {internal => pkg}/matrix/types.go (94%) diff --git a/internal/matrix/matrix_compressor.go b/internal/matrix/matrix_compressor.go index ac6a06fc..782346cc 100644 --- a/internal/matrix/matrix_compressor.go +++ b/internal/matrix/matrix_compressor.go @@ -12,13 +12,14 @@ import ( "github.com/chain4travel/camino-messenger-bot/internal/compression" "github.com/chain4travel/camino-messenger-bot/internal/messaging" "github.com/chain4travel/camino-messenger-bot/internal/metadata" + "github.com/chain4travel/camino-messenger-bot/pkg/matrix" "maunium.net/go/mautrix/event" ) var ( - _ compression.Compressor[messaging.Message, []CaminoMatrixMessage] = (*ChunkingCompressor)(nil) - ErrCompressionProducedNoChunks = errors.New("compression produced no chunks") - ErrEncodingMsg = errors.New("error while encoding msg for compression") + _ compression.Compressor[messaging.Message, []matrix.CaminoMatrixMessage] = (*ChunkingCompressor)(nil) + ErrCompressionProducedNoChunks = errors.New("compression produced no chunks") + ErrEncodingMsg = errors.New("error while encoding msg for compression") ) // ChunkingCompressor is a concrete implementation of Compressor with chunking functionality @@ -27,8 +28,8 @@ type ChunkingCompressor struct { } // Compress implements the Compressor interface for ChunkingCompressor -func (c *ChunkingCompressor) Compress(msg messaging.Message) ([]CaminoMatrixMessage, error) { - var matrixMessages []CaminoMatrixMessage +func (c *ChunkingCompressor) Compress(msg messaging.Message) ([]matrix.CaminoMatrixMessage, error) { + var matrixMessages []matrix.CaminoMatrixMessage // 1. CompressBytes the message compressedContent, err := compress(msg) @@ -42,7 +43,7 @@ func (c *ChunkingCompressor) Compress(msg messaging.Message) ([]CaminoMatrixMess return matrixMessages, err } - // 3. Create CaminoMatrixMessage objects for each chunk + // 3. Create matrix.CaminoMatrixMessage objects for each chunk return splitCaminoMatrixMsg(msg, splitCompressedContent) } @@ -67,16 +68,16 @@ func compress(msg messaging.Message) ([]byte, error) { return compression.CompressBytes(bytes), nil } -func splitCaminoMatrixMsg(msg messaging.Message, splitCompressedContent [][]byte) ([]CaminoMatrixMessage, error) { - messages := make([]CaminoMatrixMessage, 0, len(splitCompressedContent)) +func splitCaminoMatrixMsg(msg messaging.Message, splitCompressedContent [][]byte) ([]matrix.CaminoMatrixMessage, error) { + messages := make([]matrix.CaminoMatrixMessage, 0, len(splitCompressedContent)) // add first chunk to messages slice { - caminoMatrixMsg := CaminoMatrixMessage{ + caminoMatrixMsg := matrix.CaminoMatrixMessage{ MessageEventContent: event.MessageEventContent{MsgType: event.MessageType(msg.Type)}, Metadata: msg.Metadata, } - caminoMatrixMsg.Metadata.NumberOfChunks = uint(len(splitCompressedContent)) + caminoMatrixMsg.Metadata.NumberOfChunks = uint64(len(splitCompressedContent)) caminoMatrixMsg.Metadata.ChunkIndex = 0 caminoMatrixMsg.CompressedContent = splitCompressedContent[0] messages = append(messages, caminoMatrixMsg) @@ -84,9 +85,9 @@ func splitCaminoMatrixMsg(msg messaging.Message, splitCompressedContent [][]byte // if multiple chunks were produced upon compression, add them to messages slice for i, chunk := range splitCompressedContent[1:] { - messages = append(messages, CaminoMatrixMessage{ + messages = append(messages, matrix.CaminoMatrixMessage{ MessageEventContent: event.MessageEventContent{MsgType: event.MessageType(msg.Type)}, - Metadata: metadata.Metadata{RequestID: msg.Metadata.RequestID, NumberOfChunks: uint(len(splitCompressedContent)), ChunkIndex: uint(i + 1)}, + Metadata: metadata.Metadata{RequestID: msg.Metadata.RequestID, NumberOfChunks: uint64(len(splitCompressedContent)), ChunkIndex: uint64(i + 1)}, CompressedContent: chunk, }) } diff --git a/internal/matrix/matrix_compressor_test.go b/internal/matrix/matrix_compressor_test.go index 8c527c11..dbccff04 100644 --- a/internal/matrix/matrix_compressor_test.go +++ b/internal/matrix/matrix_compressor_test.go @@ -11,6 +11,7 @@ import ( activityv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/activity/v1" "github.com/chain4travel/camino-messenger-bot/internal/messaging" "github.com/chain4travel/camino-messenger-bot/internal/metadata" + "github.com/chain4travel/camino-messenger-bot/pkg/matrix" "github.com/stretchr/testify/require" "maunium.net/go/mautrix/event" ) @@ -27,7 +28,7 @@ func TestChunkingCompressorCompress(t *testing.T) { } tests := map[string]struct { args args - want []CaminoMatrixMessage + want []matrix.CaminoMatrixMessage err error }{ "err: unknown message type": { @@ -54,7 +55,7 @@ func TestChunkingCompressorCompress(t *testing.T) { }, maxSize: 100, }, - want: []CaminoMatrixMessage{ + want: []matrix.CaminoMatrixMessage{ { MessageEventContent: event.MessageEventContent{ MsgType: event.MessageType(messaging.ActivitySearchResponse), @@ -95,7 +96,7 @@ func TestChunkingCompressorCompress(t *testing.T) { }, maxSize: 23, // compressed size of msgType=ActivitySearchResponse and serviceCode="test" }, - want: []CaminoMatrixMessage{ + want: []matrix.CaminoMatrixMessage{ { MessageEventContent: event.MessageEventContent{ MsgType: event.MessageType(messaging.ActivitySearchResponse), @@ -136,7 +137,7 @@ func TestChunkingCompressorCompress(t *testing.T) { }, maxSize: 22, // < 23 = compressed size of msgType=ActivitySearchResponse and serviceCode="test" }, - want: []CaminoMatrixMessage{ + want: []matrix.CaminoMatrixMessage{ { MessageEventContent: event.MessageEventContent{ MsgType: event.MessageType(messaging.ActivitySearchResponse), diff --git a/internal/matrix/matrix_messenger.go b/internal/matrix/matrix_messenger.go index 6bea60d7..ec9f2b5a 100644 --- a/internal/matrix/matrix_messenger.go +++ b/internal/matrix/matrix_messenger.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "reflect" "sync" "time" @@ -13,6 +12,7 @@ import ( "github.com/chain4travel/camino-messenger-bot/config" "github.com/chain4travel/camino-messenger-bot/internal/compression" "github.com/chain4travel/camino-messenger-bot/internal/messaging" + "github.com/chain4travel/camino-messenger-bot/pkg/matrix" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -27,8 +27,6 @@ import ( var _ messaging.Messenger = (*messenger)(nil) -var C4TMessage = event.Type{Type: "m.room.c4t-msg", Class: event.MessageEventType} - type client struct { *mautrix.Client ctx context.Context @@ -46,7 +44,7 @@ type messenger struct { client client roomHandler RoomHandler msgAssembler MessageAssembler - compressor compression.Compressor[messaging.Message, []CaminoMatrixMessage] + compressor compression.Compressor[messaging.Message, []matrix.CaminoMatrixMessage] } func NewMessenger(cfg *config.MatrixConfig, logger *zap.SugaredLogger) messaging.Messenger { @@ -72,10 +70,9 @@ func (m *messenger) Checkpoint() string { func (m *messenger) StartReceiver() (string, error) { syncer := m.client.Syncer.(*mautrix.DefaultSyncer) - event.TypeMap[C4TMessage] = reflect.TypeOf(CaminoMatrixMessage{}) // custom message event types have to be registered properly - syncer.OnEventType(C4TMessage, func(ctx context.Context, evt *event.Event) { - msg := evt.Content.Parsed.(*CaminoMatrixMessage) + syncer.OnEventType(matrix.EventTypeC4TMessage, func(ctx context.Context, evt *event.Event) { + msg := evt.Content.Parsed.(*matrix.CaminoMatrixMessage) traceID, err := trace.TraceIDFromHex(msg.Metadata.RequestID) if err != nil { m.logger.Warnf("failed to parse traceID from hex [requestID:%s]: %v", msg.Metadata.RequestID, err) @@ -189,10 +186,10 @@ func (m *messenger) SendAsync(ctx context.Context, msg messaging.Message) error } compressSpan.End() - return m.sendMessageEvents(ctx, roomID, C4TMessage, messages) + return m.sendMessageEvents(ctx, roomID, matrix.EventTypeC4TMessage, messages) } -func (m *messenger) sendMessageEvents(ctx context.Context, roomID id.RoomID, eventType event.Type, messages []CaminoMatrixMessage) error { +func (m *messenger) sendMessageEvents(ctx context.Context, roomID id.RoomID, eventType event.Type, messages []matrix.CaminoMatrixMessage) error { // TODO add retry logic? for _, msg := range messages { _, err := m.client.SendMessageEvent(ctx, roomID, eventType, msg) diff --git a/internal/matrix/msg_assembler.go b/internal/matrix/msg_assembler.go index 43046e7b..36c9f6ad 100644 --- a/internal/matrix/msg_assembler.go +++ b/internal/matrix/msg_assembler.go @@ -12,6 +12,7 @@ import ( "sync" "github.com/chain4travel/camino-messenger-bot/internal/compression" + "github.com/chain4travel/camino-messenger-bot/pkg/matrix" ) var ( @@ -20,34 +21,32 @@ var ( ) type MessageAssembler interface { - AssembleMessage(msg *CaminoMatrixMessage) (assembledMsg *CaminoMatrixMessage, complete bool, err error) // returns assembled message and true if message is complete. Otherwise, it returns an empty message and false + AssembleMessage(msg *matrix.CaminoMatrixMessage) (assembledMsg *matrix.CaminoMatrixMessage, complete bool, err error) // returns assembled message and true if message is complete. Otherwise, it returns an empty message and false } type messageAssembler struct { - partialMessages map[string][]*CaminoMatrixMessage + partialMessages map[string][]*matrix.CaminoMatrixMessage decompressor compression.Decompressor mu sync.RWMutex } func NewMessageAssembler() MessageAssembler { - return &messageAssembler{decompressor: &compression.ZSTDDecompressor{}, partialMessages: make(map[string][]*CaminoMatrixMessage)} + return &messageAssembler{decompressor: &compression.ZSTDDecompressor{}, partialMessages: make(map[string][]*matrix.CaminoMatrixMessage)} } -func (a *messageAssembler) AssembleMessage(msg *CaminoMatrixMessage) (*CaminoMatrixMessage, bool, error) { +func (a *messageAssembler) AssembleMessage(msg *matrix.CaminoMatrixMessage) (*matrix.CaminoMatrixMessage, bool, error) { if msg.Metadata.NumberOfChunks == 1 { - decompressedCaminoMsg, err := a.assembleAndDecompressCaminoMatrixMessages([]*CaminoMatrixMessage{msg}) + decompressedCaminoMsg, err := a.assembleAndDecompressCaminoMatrixMessages([]*matrix.CaminoMatrixMessage{msg}) return decompressedCaminoMsg, err == nil, err } a.mu.Lock() defer a.mu.Unlock() id := msg.Metadata.RequestID if _, ok := a.partialMessages[id]; !ok { - a.partialMessages[id] = []*CaminoMatrixMessage{} + a.partialMessages[id] = []*matrix.CaminoMatrixMessage{} } a.partialMessages[id] = append(a.partialMessages[id], msg) - // TODO: I believe it's safe to assume the number of chunks will not overflow - // #nosec G115 if len(a.partialMessages[id]) == int(msg.Metadata.NumberOfChunks) { decompressedCaminoMsg, err := a.assembleAndDecompressCaminoMatrixMessages(a.partialMessages[id]) delete(a.partialMessages, id) @@ -56,11 +55,11 @@ func (a *messageAssembler) AssembleMessage(msg *CaminoMatrixMessage) (*CaminoMat return nil, false, nil } -func (a *messageAssembler) assembleAndDecompressCaminoMatrixMessages(messages []*CaminoMatrixMessage) (*CaminoMatrixMessage, error) { +func (a *messageAssembler) assembleAndDecompressCaminoMatrixMessages(messages []*matrix.CaminoMatrixMessage) (*matrix.CaminoMatrixMessage, error) { compressedPayloads := make([][]byte, 0, len(messages)) // chunks have to be sorted - sort.Sort(ByChunkIndex(messages)) + sort.Sort(matrix.ByChunkIndex(messages)) for _, msg := range messages { compressedPayloads = append(compressedPayloads, msg.CompressedContent) } @@ -71,7 +70,7 @@ func (a *messageAssembler) assembleAndDecompressCaminoMatrixMessages(messages [] return nil, fmt.Errorf("%w: %w", ErrDecompressFailed, err) } - msg := CaminoMatrixMessage{ + msg := matrix.CaminoMatrixMessage{ MessageEventContent: messages[0].MessageEventContent, Metadata: messages[0].Metadata, } diff --git a/internal/matrix/msg_assembler_test.go b/internal/matrix/msg_assembler_test.go index 5b3a536b..175ee0c2 100644 --- a/internal/matrix/msg_assembler_test.go +++ b/internal/matrix/msg_assembler_test.go @@ -14,6 +14,7 @@ import ( "github.com/chain4travel/camino-messenger-bot/internal/compression" "github.com/chain4travel/camino-messenger-bot/internal/messaging" "github.com/chain4travel/camino-messenger-bot/internal/metadata" + "github.com/chain4travel/camino-messenger-bot/pkg/matrix" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -32,11 +33,11 @@ func TestAssembleMessage(t *testing.T) { }, } type fields struct { - partialMessages map[string][]*CaminoMatrixMessage + partialMessages map[string][]*matrix.CaminoMatrixMessage } type args struct { - msg *CaminoMatrixMessage + msg *matrix.CaminoMatrixMessage } // mocks @@ -47,13 +48,13 @@ func TestAssembleMessage(t *testing.T) { fields fields args args prepare func() - want *CaminoMatrixMessage + want *matrix.CaminoMatrixMessage isComplete bool err error }{ "err: decoder failed to decompress": { args: args{ - msg: &CaminoMatrixMessage{ + msg: &matrix.CaminoMatrixMessage{ Metadata: metadata.Metadata{ RequestID: "test", NumberOfChunks: 1, @@ -68,7 +69,7 @@ func TestAssembleMessage(t *testing.T) { }, "err: unknown message type": { args: args{ - msg: &CaminoMatrixMessage{ + msg: &matrix.CaminoMatrixMessage{ Metadata: metadata.Metadata{ RequestID: "test", NumberOfChunks: 1, @@ -83,20 +84,20 @@ func TestAssembleMessage(t *testing.T) { }, "empty input": { fields: fields{ - partialMessages: map[string][]*CaminoMatrixMessage{}, + partialMessages: map[string][]*matrix.CaminoMatrixMessage{}, }, args: args{ - msg: &CaminoMatrixMessage{}, + msg: &matrix.CaminoMatrixMessage{}, }, isComplete: false, err: nil, }, "partial message delivery [metadata number fo chunks do not match provided messages]": { fields: fields{ - partialMessages: map[string][]*CaminoMatrixMessage{}, + partialMessages: map[string][]*matrix.CaminoMatrixMessage{}, }, args: args{ - msg: &CaminoMatrixMessage{ + msg: &matrix.CaminoMatrixMessage{ Metadata: metadata.Metadata{ RequestID: "test", NumberOfChunks: 2, @@ -108,10 +109,10 @@ func TestAssembleMessage(t *testing.T) { }, "success: single chunk message": { fields: fields{ - partialMessages: map[string][]*CaminoMatrixMessage{}, + partialMessages: map[string][]*matrix.CaminoMatrixMessage{}, }, args: args{ - msg: &CaminoMatrixMessage{ + msg: &matrix.CaminoMatrixMessage{ MessageEventContent: event.MessageEventContent{ MsgType: event.MessageType(messaging.ActivitySearchResponse), }, @@ -127,7 +128,7 @@ func TestAssembleMessage(t *testing.T) { require.NoError(t, err) mockedDecompressor.EXPECT().Decompress(gomock.Any()).Times(1).Return(msgBytes, nil) }, - want: &CaminoMatrixMessage{ + want: &matrix.CaminoMatrixMessage{ Metadata: metadata.Metadata{ RequestID: "id", NumberOfChunks: 1, @@ -142,14 +143,14 @@ func TestAssembleMessage(t *testing.T) { }, "success: multi-chunk message": { fields: fields{ - partialMessages: map[string][]*CaminoMatrixMessage{"id": { + partialMessages: map[string][]*matrix.CaminoMatrixMessage{"id": { // only 2 chunks because the last one is passed as the last argument triggering the call of AssembleMessage // msgType is necessary only for 1st chunk {MessageEventContent: event.MessageEventContent{MsgType: event.MessageType(messaging.ActivitySearchResponse)}}, {}, }}, }, args: args{ - msg: &CaminoMatrixMessage{ + msg: &matrix.CaminoMatrixMessage{ Metadata: metadata.Metadata{ RequestID: "id", NumberOfChunks: 3, @@ -162,7 +163,7 @@ func TestAssembleMessage(t *testing.T) { require.NoError(t, err) mockedDecompressor.EXPECT().Decompress(gomock.Any()).Times(1).Return(msgBytes, nil) }, - want: &CaminoMatrixMessage{ + want: &matrix.CaminoMatrixMessage{ MessageEventContent: event.MessageEventContent{ MsgType: event.MessageType(messaging.ActivitySearchResponse), }, diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index e25ceb56..4f66de2a 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -7,17 +7,18 @@ import ( "strings" "time" + "github.com/chain4travel/camino-messenger-bot/pkg/cheques" "google.golang.org/grpc/metadata" ) type Metadata struct { - RequestID string `json:"request_id"` - Sender string `json:"sender"` - Recipient string `json:"recipient"` - Cheques []map[string]interface{} `json:"cheques"` - Timestamps map[string]int64 `json:"timestamps"` // map of checkpoints to timestamps in unix milliseconds - NumberOfChunks uint `json:"number_of_chunks"` - ChunkIndex uint `json:"chunk_index"` + RequestID string `json:"request_id"` + Sender string `json:"sender"` + Recipient string `json:"recipient"` + Cheques []cheques.SignedCheque `json:"cheques"` + Timestamps map[string]int64 `json:"timestamps"` // map of checkpoints to timestamps in unix milliseconds + NumberOfChunks uint64 `json:"number_of_chunks"` + ChunkIndex uint64 `json:"chunk_index"` // Deprecated: this metadata serves only as a temp solution and should be removed and addressed on the protocol level ProviderOperator string `json:"provider_operator"` diff --git a/pkg/cheques/cheques.go b/pkg/cheques/cheques.go new file mode 100644 index 00000000..7701a131 --- /dev/null +++ b/pkg/cheques/cheques.go @@ -0,0 +1,116 @@ +package cheques + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +var ( + ErrChequeAlreadyExpired = errors.New("cheque already expired") + ErrChequeExpiresTooSoon = errors.New("cheque expires too soon") + ErrChequeAmountLessThanPrevious = errors.New("new cheque amount less than previous cheque amount") + ErrChequeCounterNotGreaterThanPrevious = errors.New("new cheque counter not greater than previous cheque counter") +) + +type SignedCheque struct { + Cheque `json:"cheque"` + Signature []byte `json:"signature"` +} + +type Cheque struct { + FromCMAccount common.Address `json:"fromCMAccount"` + ToCMAccount common.Address `json:"toCMAccount"` + ToBot common.Address `json:"toBot"` + Counter *big.Int `json:"counter"` + Amount *big.Int `json:"amount"` + CreatedAt *big.Int `json:"createdAt"` + ExpiresAt *big.Int `json:"expiresAt"` +} + +type signedChequeJSON struct { + Cheque chequeJSON `json:"cheque"` + Signature string `json:"signature"` +} + +type chequeJSON struct { + FromCMAccount string `json:"fromCMAccount"` + ToCMAccount string `json:"toCMAccount"` + ToBot string `json:"toBot"` + Counter string `json:"counter"` + Amount string `json:"amount"` + CreatedAt uint64 `json:"createdAt"` + ExpiresAt uint64 `json:"expiresAt"` +} + +func (sc *SignedCheque) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedChequeJSON{ + Cheque: chequeJSON{ + FromCMAccount: sc.Cheque.FromCMAccount.Hex(), + ToCMAccount: sc.Cheque.ToCMAccount.Hex(), + ToBot: sc.Cheque.ToBot.Hex(), + Counter: hexutil.EncodeBig(sc.Cheque.Counter), + Amount: hexutil.EncodeBig(sc.Cheque.Amount), + CreatedAt: sc.Cheque.CreatedAt.Uint64(), + ExpiresAt: sc.Cheque.ExpiresAt.Uint64(), + }, + Signature: hex.EncodeToString(sc.Signature), + }) +} + +func (sc *SignedCheque) UnmarshalJSON(data []byte) error { + var raw signedChequeJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + counter, err := hexutil.DecodeBig(raw.Cheque.Counter) + if err != nil { + return err + } + sc.Cheque.Counter = counter + + amount, err := hexutil.DecodeBig(raw.Cheque.Amount) + if err != nil { + return err + } + sc.Cheque.Amount = amount + + signatureBytes, err := hex.DecodeString(raw.Signature) + if err != nil { + return fmt.Errorf("invalid signature hex string: %w", err) + } + sc.Signature = signatureBytes + + sc.Cheque.FromCMAccount = common.HexToAddress(raw.Cheque.FromCMAccount) + sc.Cheque.ToCMAccount = common.HexToAddress(raw.Cheque.ToCMAccount) + sc.Cheque.ToBot = common.HexToAddress(raw.Cheque.ToBot) + sc.Cheque.CreatedAt = big.NewInt(0).SetUint64(raw.Cheque.CreatedAt) + sc.Cheque.ExpiresAt = big.NewInt(0).SetUint64(raw.Cheque.ExpiresAt) + + return nil +} + +func VerifyCheque(previousCheque, newCheque *SignedCheque, timestamp, minDurationUntilExpiration *big.Int) error { + if newCheque.ExpiresAt.Cmp(timestamp) < 1 { // cheque.ExpiresAt <= timestamp + return fmt.Errorf("cheque expired at %s; %w", newCheque.ExpiresAt, ErrChequeAlreadyExpired) + } + durationUntilExpiration := big.NewInt(0).Sub(newCheque.ExpiresAt, timestamp) + if durationUntilExpiration.Cmp(minDurationUntilExpiration) < 0 { // durationUntilExpiration < minDurationUntilExpiration + return fmt.Errorf("duration until expiration less than min (%s < %s): %w", durationUntilExpiration, minDurationUntilExpiration, ErrChequeExpiresTooSoon) + } + switch { + case previousCheque == nil: + return nil + case previousCheque.Amount.Cmp(newCheque.Amount) > 0: // previous.Amount > new.Amount + return fmt.Errorf("new cheque amount (%s) < (%s) previous cheque amount: %w", newCheque.Amount, previousCheque.Amount, ErrChequeAmountLessThanPrevious) + case previousCheque.Counter.Cmp(newCheque.Counter) > -1: // previous.Counter >= new.Counter + return fmt.Errorf("new cheque counter (%s) <= (%s) previous cheque counter: %w", newCheque.Counter, previousCheque.Counter, ErrChequeCounterNotGreaterThanPrevious) + } + return nil +} diff --git a/pkg/cheques/eth_typed_data.go b/pkg/cheques/eth_typed_data.go new file mode 100644 index 00000000..18e15a7d --- /dev/null +++ b/pkg/cheques/eth_typed_data.go @@ -0,0 +1,93 @@ +package cheques + +import ( + "bytes" + "fmt" + "reflect" + "strings" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +// TODO@ code was copy-pasted (with small modifications) from eth; does it need license header? + +func hashStructWithTypeHash(typedData *apitypes.TypedData, dataType string, typeHash []byte) ([]byte, error) { + if exp, got := len(typedData.Types[dataType]), len(typedData.Message); exp < got { + return nil, fmt.Errorf("there is extra data provided in the message (%d < %d)", exp, got) + } + + depth := 1 + buffer := bytes.Buffer{} + buffer.Write(typeHash) + + for _, field := range typedData.Types[chequeType] { + encType := field.Type + encValue := typedData.Message[field.Name] + switch { + case encType[len(encType)-1:] == "]": + arrayValue, err := convertDataToSlice(encValue) + if err != nil { + return nil, dataMismatchError(encType, encValue) + } + + arrayBuffer := bytes.Buffer{} + parsedType := strings.Split(encType, "[")[0] + for _, item := range arrayValue { + if typedData.Types[parsedType] != nil { + mapValue, ok := item.(map[string]interface{}) + if !ok { + return nil, dataMismatchError(parsedType, item) + } + encodedData, err := typedData.EncodeData(parsedType, mapValue, depth+1) + if err != nil { + return nil, err + } + arrayBuffer.Write(crypto.Keccak256(encodedData)) + } else { + bytesValue, err := typedData.EncodePrimitiveValue(parsedType, item, depth) + if err != nil { + return nil, err + } + arrayBuffer.Write(bytesValue) + } + } + + buffer.Write(crypto.Keccak256(arrayBuffer.Bytes())) + case typedData.Types[field.Type] != nil: + mapValue, ok := encValue.(map[string]interface{}) + if !ok { + return nil, dataMismatchError(encType, encValue) + } + encodedData, err := typedData.EncodeData(field.Type, mapValue, depth+1) + if err != nil { + return nil, err + } + buffer.Write(crypto.Keccak256(encodedData)) + default: + byteValue, err := typedData.EncodePrimitiveValue(encType, encValue, depth) + if err != nil { + return nil, err + } + buffer.Write(byteValue) + } + } + return crypto.Keccak256(buffer.Bytes()), nil +} + +func convertDataToSlice(encValue interface{}) ([]interface{}, error) { + var outEncValue []interface{} + rv := reflect.ValueOf(encValue) + if rv.Kind() == reflect.Slice { + for i := 0; i < rv.Len(); i++ { + outEncValue = append(outEncValue, rv.Index(i).Interface()) + } + } else { + return outEncValue, fmt.Errorf("provided data '%v' is not slice", encValue) + } + return outEncValue, nil +} + +func dataMismatchError(encType string, encValue interface{}) error { + return fmt.Errorf("provided data '%v' doesn't match type '%s'", encValue, encType) +} diff --git a/pkg/cheques/signer.go b/pkg/cheques/signer.go new file mode 100644 index 00000000..e32308dd --- /dev/null +++ b/pkg/cheques/signer.go @@ -0,0 +1,119 @@ +package cheques + +import ( + "crypto/ecdsa" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +const ( + domainType = "EIP712Domain" + chequeType = "MessengerCheque" +) + +var ( + hashPrefix = []byte{0x19, 0x01} + types = apitypes.Types{ + domainType: { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + }, + chequeType: { + {Name: "fromCMAccount", Type: "address"}, + {Name: "toCMAccount", Type: "address"}, + {Name: "toBot", Type: "address"}, + {Name: "counter", Type: "uint256"}, + {Name: "amount", Type: "uint256"}, + {Name: "createdAt", Type: "uint256"}, + {Name: "expiresAt", Type: "uint256"}, + }, + } +) + +type Signer interface { + SignCheque(cheque *Cheque) (*SignedCheque, error) +} + +type signer struct { + privateKey *ecdsa.PrivateKey + domainSeparator []byte + chequeTypeHash []byte + domain *apitypes.TypedDataDomain +} + +func NewSigner(privateKey *ecdsa.PrivateKey, chainID *big.Int) (Signer, error) { + domain := apitypes.TypedDataDomain{ + Name: "CaminoMessenger", + Version: "1", + ChainId: (*math.HexOrDecimal256)(chainID), + } + + data := apitypes.TypedData{ + Domain: domain, + Types: types, + } + + domainSeparator, err := data.HashStruct(domainType, apitypes.TypedDataMessage{ + "name": domain.Name, + "version": domain.Version, + "chainId": domain.ChainId, + }) + if err != nil { + return nil, err + } + + return &signer{ + privateKey: privateKey, + domainSeparator: domainSeparator, + chequeTypeHash: data.TypeHash(chequeType), + domain: &domain, + }, nil +} + +func (cs *signer) SignCheque(cheque *Cheque) (*SignedCheque, error) { + message := apitypes.TypedDataMessage{ + "fromCMAccount": cheque.FromCMAccount.Hex(), + "toCMAccount": cheque.ToCMAccount.Hex(), + "toBot": cheque.ToBot.Hex(), + "counter": cheque.Counter, + "amount": cheque.Amount, + "createdAt": cheque.CreatedAt, + "expiresAt": cheque.ExpiresAt, + } + + data := &apitypes.TypedData{ + Types: types, + Domain: *cs.domain, + Message: message, + PrimaryType: chequeType, + } + + typedDataHash, err := hashStructWithTypeHash(data, chequeType, cs.chequeTypeHash) + if err != nil { + return nil, fmt.Errorf("failed to hash struct: %w", err) + } + + finalHash := crypto.Keccak256( + hashPrefix, + cs.domainSeparator, + typedDataHash, + ) + + signature, err := crypto.Sign(finalHash, cs.privateKey) + if err != nil { + return nil, fmt.Errorf("failed to sign the hash: %w", err) + } + + // adjust recovery byte for compatibility + signature[64] += 27 + + return &SignedCheque{ + Cheque: *cheque, + Signature: signature, + }, nil +} diff --git a/internal/matrix/types.go b/pkg/matrix/types.go similarity index 94% rename from internal/matrix/types.go rename to pkg/matrix/types.go index a59351a3..0752aacb 100644 --- a/internal/matrix/types.go +++ b/pkg/matrix/types.go @@ -1,6 +1,8 @@ package matrix import ( + "reflect" + accommodationv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/accommodation/v1" activityv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/activity/v1" bookv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/book/v1" @@ -12,10 +14,18 @@ import ( transportv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/transport/v1" "github.com/chain4travel/camino-messenger-bot/internal/messaging" "github.com/chain4travel/camino-messenger-bot/internal/metadata" + "github.com/chain4travel/camino-messenger-bot/pkg/cheques" + "github.com/ethereum/go-ethereum/common" "google.golang.org/protobuf/proto" "maunium.net/go/mautrix/event" ) +var EventTypeC4TMessage = event.Type{Type: "m.room.c4t-msg", Class: event.MessageEventType} + +func init() { + event.TypeMap[EventTypeC4TMessage] = reflect.TypeOf(CaminoMatrixMessage{}) +} + // CaminoMatrixMessage is a matrix-specific message format used for communication between the messenger and the service type CaminoMatrixMessage struct { event.MessageEventContent @@ -128,3 +138,12 @@ func (m *CaminoMatrixMessage) UnmarshalContent(src []byte) error { return messaging.ErrUnknownMessageType } } + +func (m *CaminoMatrixMessage) GetChequeFor(addr common.Address) *cheques.SignedCheque { + for _, cheque := range m.Metadata.Cheques { + if cheque.Cheque.ToCMAccount == addr { + return &cheque + } + } + return nil +}