From 0c7f9f34a04c7f3eacf427aa5199b265d9d43d5e Mon Sep 17 00:00:00 2001 From: Emil Sawicki Date: Fri, 11 Oct 2024 13:53:32 +0200 Subject: [PATCH] feat_: Unfurl transaction deep link --- protocol/common/message_linkpreview.go | 72 ++++++++++++++++++++----- protocol/common/message_test.go | 69 +++++++++++++++++++++++- protocol/linkpreview_unfurler_status.go | 19 +++++++ protocol/protobuf/chat_message.proto | 10 ++++ server/handlers_test.go | 49 +++++++++++++++++ 5 files changed, 205 insertions(+), 14 deletions(-) diff --git a/protocol/common/message_linkpreview.go b/protocol/common/message_linkpreview.go index 04e42703e..e0bb783ff 100644 --- a/protocol/common/message_linkpreview.go +++ b/protocol/common/message_linkpreview.go @@ -54,6 +54,15 @@ type StatusCommunityLinkPreview struct { Banner LinkPreviewThumbnail `json:"banner,omitempty"` } +type StatusTransactionLinkPreview struct { + TxType int `json:"txType"` + Amount string `json:"amount"` + Asset string `json:"asset"` + ToAsset string `json:"toAsset"` + Address string `json:"address"` + ChainID int `json:"chainId"` +} + type StatusCommunityChannelLinkPreview struct { ChannelUUID string `json:"channelUuid"` Emoji string `json:"emoji"` @@ -64,10 +73,11 @@ type StatusCommunityChannelLinkPreview struct { } type StatusLinkPreview struct { - URL string `json:"url,omitempty"` - Contact *StatusContactLinkPreview `json:"contact,omitempty"` - Community *StatusCommunityLinkPreview `json:"community,omitempty"` - Channel *StatusCommunityChannelLinkPreview `json:"channel,omitempty"` + URL string `json:"url,omitempty"` + Contact *StatusContactLinkPreview `json:"contact,omitempty"` + Community *StatusCommunityLinkPreview `json:"community,omitempty"` + Channel *StatusCommunityChannelLinkPreview `json:"channel,omitempty"` + Transaction *StatusTransactionLinkPreview `json:"transaction,omitempty"` } func (thumbnail *LinkPreviewThumbnail) IsEmpty() bool { @@ -161,17 +171,23 @@ func (preview *StatusLinkPreview) validateForProto() error { } // At least and only one of Contact/Community/Channel should be present in the preview - if preview.Contact != nil && preview.Community != nil { - return fmt.Errorf("both contact and community are set at the same time") + var linkTypes []string + if preview.Contact != nil { + linkTypes = append(linkTypes, "Contact") } - if preview.Community != nil && preview.Channel != nil { - return fmt.Errorf("both community and channel are set at the same time") + if preview.Community != nil { + linkTypes = append(linkTypes, "Community") } - if preview.Channel != nil && preview.Contact != nil { - return fmt.Errorf("both contact and channel are set at the same time") + if preview.Channel != nil { + linkTypes = append(linkTypes, "Channel") } - if preview.Contact == nil && preview.Community == nil && preview.Channel == nil { - return fmt.Errorf("none of contact/community/channel are set") + if preview.Transaction != nil { + linkTypes = append(linkTypes, "Transaction") + } + if len(linkTypes) > 1 { + return fmt.Errorf("multiple components set at the same time: %v", linkTypes) + } else if len(linkTypes) == 0 { + return fmt.Errorf("none of contact/community/channel/transaction are set") } if preview.Contact != nil { @@ -200,6 +216,14 @@ func (preview *StatusLinkPreview) validateForProto() error { } return nil } + + if preview.Transaction != nil { + if preview.Transaction.Asset == "" && preview.Transaction.Amount == "" && preview.Transaction.Address == "" && preview.Transaction.ToAsset == "" { + return fmt.Errorf("transaction fields are empty") + } + return nil + } + return nil } @@ -444,6 +468,19 @@ func (m *Message) ConvertStatusLinkPreviewsToProto() (*protobuf.UnfurledStatusLi } + if preview.Transaction != nil { + ul.Payload = &protobuf.UnfurledStatusLink_Transaction{ + Transaction: &protobuf.UnfurledStatusTransactionLink{ + TxType: uint32(preview.Transaction.TxType), + Amount: preview.Transaction.Amount, + Asset: preview.Transaction.Asset, + ToAsset: preview.Transaction.ToAsset, + Address: preview.Transaction.Address, + ChainId: uint32(preview.Transaction.ChainID), + }, + } + } + unfurledLinks = append(unfurledLinks, ul) } @@ -508,6 +545,17 @@ func (m *Message) ConvertFromProtoToStatusLinkPreviews(makeMediaServerURL func(m } } + if c := link.GetTransaction(); c != nil { + lp.Transaction = &StatusTransactionLinkPreview{ + TxType: int(c.TxType), + Amount: c.Amount, + Asset: c.Asset, + ToAsset: c.ToAsset, + Address: c.Address, + ChainID: int(c.ChainId), + } + } + previews = append(previews, lp) } diff --git a/protocol/common/message_test.go b/protocol/common/message_test.go index cce0d707b..e528fcdd3 100644 --- a/protocol/common/message_test.go +++ b/protocol/common/message_test.go @@ -338,6 +338,15 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { }, } + transaction := &StatusTransactionLinkPreview{ + TxType: 2, + Amount: "Amount_22", + Asset: "Asset_23", + ToAsset: "ToAsset_24", + Address: "Address_25", + ChainID: 26, + } + message := Message{ StatusLinkPreviews: []StatusLinkPreview{ { @@ -352,6 +361,10 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { URL: "https://status.app/cc/", Channel: channel, }, + { + URL: "https://status.app/tx/", + Transaction: transaction, + }, }, } @@ -360,7 +373,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { unfurledLinks, err := message.ConvertStatusLinkPreviewsToProto() require.NoError(t, err) - require.Len(t, unfurledLinks.UnfurledStatusLinks, 3) + require.Len(t, unfurledLinks.UnfurledStatusLinks, 4) // Contact link @@ -369,6 +382,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { require.NotNil(t, l1.GetContact()) require.Nil(t, l1.GetCommunity()) require.Nil(t, l1.GetChannel()) + require.Nil(t, l1.GetTransaction()) c1 := l1.GetContact() require.Equal(t, compressedContactPublicKey, c1.PublicKey) require.Equal(t, contact.DisplayName, c1.DisplayName) @@ -385,6 +399,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { require.NotNil(t, l2.GetCommunity()) require.Nil(t, l2.GetContact()) require.Nil(t, l2.GetChannel()) + require.Nil(t, l2.GetTransaction()) c2 := l2.GetCommunity() require.Equal(t, compressedCommunityPublicKey, c2.CommunityId) require.Equal(t, community.DisplayName, c2.DisplayName) @@ -407,6 +422,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { require.NotNil(t, l3.GetChannel()) require.Nil(t, l3.GetContact()) require.Nil(t, l3.GetCommunity()) + require.Nil(t, l3.GetTransaction()) c3 := l3.GetChannel() require.Equal(t, channel.ChannelUUID, c3.ChannelUuid) @@ -430,6 +446,23 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) { require.Equal(t, uint32(channel.Community.Banner.Height), c3.Community.Banner.Height) require.Equal(t, expectedThumbnailPayload, c3.Community.Banner.Payload) + // Transaction link + + l4 := unfurledLinks.UnfurledStatusLinks[3] + require.Equal(t, "https://status.app/tx/", l4.Url) + require.NotNil(t, l4.GetTransaction()) + require.Nil(t, l4.GetContact()) + require.Nil(t, l4.GetCommunity()) + require.Nil(t, l4.GetChannel()) + + t4 := l4.GetTransaction() + require.Equal(t, uint32(transaction.TxType), t4.TxType) + require.Equal(t, transaction.Amount, t4.Amount) + require.Equal(t, transaction.Asset, t4.Asset) + require.Equal(t, transaction.ToAsset, t4.ToAsset) + require.Equal(t, transaction.Address, t4.Address) + require.Equal(t, uint32(transaction.ChainID), t4.ChainId) + // Test any invalid link preview causes an early return. invalidContactPreview := contact invalidContactPreview.PublicKey = "" @@ -516,6 +549,15 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { }, } + transaction := &protobuf.UnfurledStatusTransactionLink{ + TxType: 1, + Amount: "100", + Asset: "ETH", + ToAsset: "DAI", + Address: "0x1234567890", + ChainId: 11, + } + msg := Message{ ID: "42", ChatMessage: &protobuf.ChatMessage{ @@ -539,6 +581,12 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { Channel: channel, }, }, + { + Url: "https://status.app/tx/", + Payload: &protobuf.UnfurledStatusLink_Transaction{ + Transaction: transaction, + }, + }, }, }, }, @@ -549,7 +597,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { } previews := msg.ConvertFromProtoToStatusLinkPreviews(urlMaker) - require.Len(t, previews, 3) + require.Len(t, previews, 4) // Contact preview @@ -558,6 +606,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { require.NotNil(t, p1.Contact) require.Nil(t, p1.Community) require.Nil(t, p1.Channel) + require.Nil(t, p1.Transaction) c1 := p1.Contact require.NotNil(t, c1) @@ -577,6 +626,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { require.NotNil(t, p2.Community) require.Nil(t, p2.Contact) require.Nil(t, p2.Channel) + require.Nil(t, p2.Transaction) c2 := p2.Community require.Equal(t, communityID, c2.CommunityID) @@ -602,6 +652,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { require.NotNil(t, p3.Channel) require.Nil(t, p3.Contact) require.Nil(t, p3.Community) + require.Nil(t, p3.Transaction) c3 := previews[2].Channel require.Equal(t, channel.ChannelUuid, c3.ChannelUUID) @@ -627,6 +678,20 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) { require.Equal(t, "", c3.Community.Banner.DataURI) require.Equal(t, "https://localhost:6666/42-https://status.app/cc/-community-channel-banner", c3.Community.Banner.URL) + p4 := previews[3] + require.Equal(t, "https://status.app/tx/", p4.URL) + require.NotNil(t, p4.Transaction) + require.Nil(t, p4.Contact) + require.Nil(t, p4.Community) + require.Nil(t, p4.Channel) + + t1 := p4.Transaction + require.Equal(t, transaction.TxType, uint32(t1.TxType)) + require.Equal(t, transaction.Amount, t1.Amount) + require.Equal(t, transaction.Asset, t1.Asset) + require.Equal(t, transaction.ToAsset, t1.ToAsset) + require.Equal(t, transaction.Address, t1.Address) + require.Equal(t, transaction.ChainId, uint32(t1.ChainID)) } func assertMarshalAndUnmarshalJSON[T any](t *testing.T, obj *T, msgAndArgs ...any) { diff --git a/protocol/linkpreview_unfurler_status.go b/protocol/linkpreview_unfurler_status.go index d4e6e8e4d..1bee054fd 100644 --- a/protocol/linkpreview_unfurler_status.go +++ b/protocol/linkpreview_unfurler_status.go @@ -83,6 +83,17 @@ func (u *StatusUnfurler) buildContactData(publicKey string) (*common.StatusConta return c, nil } +func (u *StatusUnfurler) buildTransactionData(urlData *TransactionURLData) (*common.StatusTransactionLinkPreview, error) { + return &common.StatusTransactionLinkPreview{ + TxType: urlData.TxType, + Asset: urlData.Asset, + Amount: urlData.Amount, + Address: urlData.Address, + ChainID: urlData.ChainID, + ToAsset: urlData.ToAsset, + }, nil +} + func (u *StatusUnfurler) buildCommunityData(communityID string, shard *shard.Shard) (*communities.Community, *common.StatusCommunityLinkPreview, error) { // This automatically checks the database community, err := u.m.FetchCommunity(&FetchCommunityRequest{ @@ -172,5 +183,13 @@ func (u *StatusUnfurler) Unfurl() (*common.StatusLinkPreview, error) { return preview, nil } + if resp.Transaction != nil { + preview.Transaction, err = u.buildTransactionData(resp.Transaction) + if err != nil { + return nil, fmt.Errorf("error when building transaction data: %w", err) + } + return preview, nil + } + return nil, fmt.Errorf("shared url does not contain contact, community or channel data") } diff --git a/protocol/protobuf/chat_message.proto b/protocol/protobuf/chat_message.proto index 9cbfe303b..d2de1ce82 100644 --- a/protocol/protobuf/chat_message.proto +++ b/protocol/protobuf/chat_message.proto @@ -171,12 +171,22 @@ message UnfurledStatusChannelLink { UnfurledStatusCommunityLink community = 6; } +message UnfurledStatusTransactionLink { + uint32 txType = 1; + string address = 2; + string amount = 3; + string asset = 4; + uint32 chainId = 5; + string toAsset = 6; +} + message UnfurledStatusLink { string url = 1; oneof payload { UnfurledStatusContactLink contact = 2; UnfurledStatusCommunityLink community = 3; UnfurledStatusChannelLink channel = 4; + UnfurledStatusTransactionLink transaction = 5; } } diff --git a/server/handlers_test.go b/server/handlers_test.go index cb5cd7603..922ae778c 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -265,6 +265,15 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() { }, } + transaction := &protobuf.UnfurledStatusTransactionLink{ + TxType: 2, + Asset: "Asset_1", + Amount: "Amount_1", + Address: "Address_1", + ToAsset: "ToAsset_1", + ChainId: 11, + } + unfurledContact := &protobuf.UnfurledStatusLink{ Url: "https://status.app/u/", Payload: &protobuf.UnfurledStatusLink_Contact{ @@ -293,12 +302,20 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() { }, } + unfurledTransaction := &protobuf.UnfurledStatusLink{ + Url: "https://status.app/tx/", + Payload: &protobuf.UnfurledStatusLink_Transaction{ + Transaction: transaction, + }, + } + const ( messageIDContactOnly = "1" messageIDCommunityOnly = "2" messageIDChannelOnly = "3" messageIDAllLinks = "4" messageIDUnsupportedImage = "5" + messageIDTransactionOnly = "6" ) s.saveUserMessage(&common.Message{ @@ -342,6 +359,7 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() { unfurledContact, unfurledCommunity, unfurledChannel, + unfurledTransaction, }, }, }, @@ -358,6 +376,17 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() { }, }) + s.saveUserMessage(&common.Message{ + ID: messageIDTransactionOnly, + ChatMessage: &protobuf.ChatMessage{ + UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ + UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ + unfurledTransaction, + }, + }, + }, + }) + testCases := []struct { Name string ExpectedHTTPStatusCode int @@ -538,6 +567,26 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() { s.Require().Equal("missing query parameter 'image-id'\n", rr.Body.String()) }, }, + { + Name: "Test request with missing 'message-id' parameter", + Parameters: url.Values{ + "url": {unfurledTransaction.Url}, + }, + ExpectedHTTPStatusCode: http.StatusBadRequest, + CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { + s.Require().Equal("missing query parameter 'message-id'\n", rr.Body.String()) + }, + }, + { + Name: "Test request with missing 'url' parameter", + Parameters: url.Values{ + "message-id": {messageIDTransactionOnly}, + }, + ExpectedHTTPStatusCode: http.StatusBadRequest, + CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { + s.Require().Equal("missing query parameter 'url'\n", rr.Body.String()) + }, + }, } handler := handleStatusLinkPreviewThumbnail(s.db, s.logger)