diff --git a/src/app/modules/main/wallet_section/activity/controller.nim b/src/app/modules/main/wallet_section/activity/controller.nim index 7df2f847823..6b41c471a98 100644 --- a/src/app/modules/main/wallet_section/activity/controller.nim +++ b/src/app/modules/main/wallet_section/activity/controller.nim @@ -60,6 +60,7 @@ QtObject: # call updateAssetsIdentities after updating chainIds chainIds: seq[int] + # TODO #12120: refactor to sessionId and use the eventsHandler's sessionId requestId: int32 collectiblesToTokenConverter: CollectiblesToTokenConverter @@ -100,7 +101,7 @@ QtObject: if metadata.symbolOut.isSome() or metadata.amountOut > 0: result.outAmount = self.currencyService.parseCurrencyValue(metadata.symbolOut.get(""), metadata.amountOut) - proc backendToPresentation(self: Controller, backendEntities: seq[backend_activity.ActivityEntry]): seq[entry.ActivityEntry] = + proc backendToPresentation(self: Controller, backendEntities: seq[backend_activity.ActivityEntry], highlight: bool): seq[entry.ActivityEntry] = let amountToCurrencyConvertor = proc(amount: UInt256, symbol: string): CurrencyAmount = return currencyAmountToItem(self.currencyService.parseCurrencyValue(symbol, amount), self.currencyService.getCurrencyFormat(symbol)) @@ -112,7 +113,7 @@ QtObject: ae = entry.newMultiTransactionActivityEntry(backendEntry, extraData, amountToCurrencyConvertor) of SimpleTransaction, PendingTransaction: let extraData = self.buildTransactionExtraData(backendEntry) - ae = entry.newTransactionActivityEntry(backendEntry, self.addresses, extraData, amountToCurrencyConvertor) + ae = entry.newTransactionActivityEntry(backendEntry, self.addresses, extraData, amountToCurrencyConvertor, highlight) result.add(ae) proc fetchTxDetails*(self: Controller, entryIndex: int) {.slot.} = @@ -151,14 +152,14 @@ QtObject: error "error fetching activity entries: ", res.errorCode return - let entries = self.backendToPresentation(res.activities) + let entries = self.backendToPresentation(res.activities, false) self.model.setEntries(entries, res.offset, res.hasMore) if len(entries) > 0: self.eventsHandler.updateRelevantTimestamp(entries[len(entries) - 1].getTimestamp()) - proc updateFilter*(self: Controller) {.slot.} = + proc startFilterSession*(self: Controller) {.slot.} = self.status.setLoadingData(true) self.status.setIsFilterDirty(false) @@ -168,12 +169,38 @@ QtObject: self.eventsHandler.updateSubscribedChainIDs(self.chainIds) self.status.setNewDataAvailable(false) - let response = backend_activity.filterActivityAsync(self.requestId, self.addresses, self.allAddressesSelected, seq[backend_activity.ChainId](self.chainIds), self.currentActivityFilter, 0, FETCH_BATCH_COUNT_DEFAULT) - if response.error != nil: - error "error fetching activity entries: ", response.error + let (sessionId, ok) = backend_activity.newActivityFilterSession(self.addresses, self.allAddressesSelected, seq[backend_activity.ChainId](self.chainIds), self.currentActivityFilter, FETCH_BATCH_COUNT_DEFAULT) + echo "@dd sessionId: ", sessionId, " ok: ", ok + if not ok: self.status.setLoadingData(false) return + self.eventsHandler.setSessionId(sessionId) + self.requestId = sessionId.int32 + + # TODO #12120: deprecated, replace with [start|stop]FilterSession + proc updateFilter*(self: Controller) {.slot.} = + if self.requestId >= 0: + let res = backend_activity.stopActivityFilterSession(self.requestId) + if res.error != nil: + error "error stopping the previous session of activity fitlering: ", res.error + + self.startFilterSession() + # self.status.setLoadingData(true) + # self.status.setIsFilterDirty(false) + + # self.model.resetModel(@[]) + + # self.eventsHandler.updateSubscribedAddresses(self.addresses) + # self.eventsHandler.updateSubscribedChainIDs(self.chainIds) + # self.status.setNewDataAvailable(false) + + # let response = backend_activity.filterActivityAsync(self.requestId, self.addresses, self.allAddressesSelected, seq[backend_activity.ChainId](self.chainIds), self.currentActivityFilter, 0, FETCH_BATCH_COUNT_DEFAULT) + # if response.error != nil: + # error "error fetching activity entries: ", response.error + # self.status.setLoadingData(false) + # return + proc loadMoreItems(self: Controller) {.slot.} = self.status.setLoadingData(true) @@ -241,6 +268,20 @@ QtObject: self.model.updateEntries(entries) ) + self.eventsHandler.onFilteringSessionUpdated(proc (jn: JsonNode) = + echo "@dd onFilteringSessionUpdated: ", pretty(jn) + if jn.kind != JObject: + error "expected an object" + + let res = fromJson(jn, backend_activity.SessionUpdate) + + echo "@dd onFilteringSessionUpdated newEntries: ", res.newEntries.len + + let entries = self.backendToPresentation(res.newEntries, true) + + self.model.updateEntries(entries) + ) + self.eventsHandler.onGetRecipientsDone(proc (jsonObj: JsonNode) = defer: self.status.setLoadingRecipients(false) let res = fromJson(jsonObj, backend_activity.GetRecipientsResponse) @@ -282,6 +323,7 @@ QtObject: self.status.setNewDataAvailable(true) ) + # TODO #12120: no need for hardcoded requestId with sessions proc newController*(requestId: int32, currencyService: currency_service.Service, tokenService: token_service.Service, @@ -289,7 +331,7 @@ QtObject: collectiblesConverter: CollectiblesToTokenConverter): Controller = new(result, delete) - result.requestId = requestId + result.requestId = -1 #requestId result.model = newModel() result.recipientsModel = newRecipientsModel() result.collectiblesModel = newCollectiblesModel() diff --git a/src/app/modules/main/wallet_section/activity/entry.nim b/src/app/modules/main/wallet_section/activity/entry.nim index 9a85a6308b2..a967e446513 100644 --- a/src/app/modules/main/wallet_section/activity/entry.nim +++ b/src/app/modules/main/wallet_section/activity/entry.nim @@ -33,6 +33,9 @@ QtObject: nftName: string nftImageURL: string + # true for entries that were changed/added in the current session + highlight: bool + proc setup(self: ActivityEntry) = self.QObject.setup @@ -54,7 +57,7 @@ QtObject: ) result.setup() - proc newTransactionActivityEntry*(metadata: backend.ActivityEntry, fromAddresses: seq[string], extradata: ExtraData, valueConvertor: AmountToCurrencyConvertor): ActivityEntry = + proc newTransactionActivityEntry*(metadata: backend.ActivityEntry, fromAddresses: seq[string], extradata: ExtraData, valueConvertor: AmountToCurrencyConvertor, highlight: bool): ActivityEntry = new(result, delete) result.valueConvertor = valueConvertor result.metadata = metadata @@ -65,6 +68,7 @@ QtObject: if result.isInTransactionType(): metadata.symbolIn.get("") else: metadata.symbolOut.get(""), ) result.noAmount = newCurrencyAmount() + result.highlight = highlight result.setup() @@ -273,3 +277,17 @@ QtObject: QtProperty[QVariant] amountCurrency: read = getAmountCurrency + + proc highlightChanged*(self: ActivityEntry) {.signal.} + + proc getHighlight*(self: ActivityEntry): bool {.slot.} = + return self.highlight + + proc doneHighlighting*(self: ActivityEntry) {.slot.} = + if self.highlight: + self.highlight = false + self.highlightChanged() + + QtProperty[bool] highlight: + read = getHighlight + notify = highlightChanged \ No newline at end of file diff --git a/src/app/modules/main/wallet_section/activity/events_handler.nim b/src/app/modules/main/wallet_section/activity/events_handler.nim index 9d54f50d9b3..3077e348fb5 100644 --- a/src/app/modules/main/wallet_section/activity/events_handler.nim +++ b/src/app/modules/main/wallet_section/activity/events_handler.nim @@ -21,11 +21,13 @@ QtObject: walletEventHandlers: Table[string, WalletEventCallbackProc] # Ignore events older than this relevantTimestamp + # TODO #12120: remove this after dropping individual events and only using incremental updates events relevantTimestamp: int subscribedAddresses: HashSet[string] subscribedChainIDs: HashSet[int] newDataAvailableFn: proc() + # TODO #12120: Optional[int], getter and setter requestId: int proc setup(self: EventsHandler) = @@ -34,12 +36,17 @@ QtObject: proc delete*(self: EventsHandler) = self.QObject.delete + # TODO #12120: replace with session update event proc onFilteringDone*(self: EventsHandler, handler: EventCallbackProc) = self.eventHandlers[backend_activity.eventActivityFilteringDone] = handler + # TODO #12120: replace with session update event proc onFilteringUpdateDone*(self: EventsHandler, handler: EventCallbackProc) = self.eventHandlers[backend_activity.eventActivityFilteringUpdate] = handler + proc onFilteringSessionUpdated*(self: EventsHandler, handler: EventCallbackProc) = + self.eventHandlers[backend_activity.eventActivitySessionUpdated] = handler + proc onGetRecipientsDone*(self: EventsHandler, handler: EventCallbackProc) = self.eventHandlers[backend_activity.eventActivityGetRecipientsDone] = handler @@ -98,9 +105,10 @@ QtObject: # TODO #12120: Replace these specific events with incremental updates events self.walletEventHandlers[EventNewTransfers] = newDataAvailableCallback - self.walletEventHandlers[EventPendingTransactionUpdate] = newDataAvailableCallback + # self.walletEventHandlers[EventPendingTransactionUpdate] = newDataAvailableCallback self.walletEventHandlers[EventMTTransactionUpdate] = newDataAvailableCallback + # TODO #12120: no need for hardcoded requestId with sessions proc newEventsHandler*(requestId: int, events: EventEmitter): EventsHandler = new(result, delete) @@ -110,7 +118,7 @@ QtObject: result.subscribedAddresses = initHashSet[string]() result.subscribedChainIDs = initHashSet[int]() - result.requestId = requestId + result.requestId = -1 # requestId result.setup() @@ -134,3 +142,6 @@ QtObject: self.subscribedChainIDs.clear() for chainID in chainIDs: self.subscribedChainIDs.incl(chainID) + + proc setSessionId*(self: EventsHandler, sessionId: int) = + self.requestId = sessionId \ No newline at end of file diff --git a/src/app/modules/main/wallet_section/activity/model.nim b/src/app/modules/main/wallet_section/activity/model.nim index 34526d052ab..ddfaf3044e3 100644 --- a/src/app/modules/main/wallet_section/activity/model.nim +++ b/src/app/modules/main/wallet_section/activity/model.nim @@ -101,6 +101,18 @@ QtObject: self.countChanged() self.setHasMore(hasMore) + # TODO #12120: add indexes + proc updateEntries*(self: Model, newEntries: seq[entry.ActivityEntry]) = + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + + self.beginInsertRows(parentModelIndex, 0, newEntries.len - 1) + self.entries = newEntries & self.entries + self.endInsertRows() + + self.countChanged() + # hasMore flag should not be change by updates + proc sameIdentity(e: entry.ActivityEntry, d: backend.Data): bool = let m = e.getMetadata() if m.getPayloadType() != d.payloadType: diff --git a/src/backend/activity.nim b/src/backend/activity.nim index 2819aab87e5..361c1e2676b 100644 --- a/src/backend/activity.nim +++ b/src/backend/activity.nim @@ -23,6 +23,8 @@ const eventActivityGetOldestTimestampDone*: string = "wallet-activity-get-oldest const eventActivityFetchTransactionDetails*: string = "wallet-activity-fetch-transaction-details-result" const eventActivityGetCollectiblesDone*: string = "wallet-activity-get-collectibles" +const eventActivitySessionUpdated*: string = "wallet-activity-session-updated" + type Period* = object startTimestamp*: int @@ -74,6 +76,17 @@ proc fromJson[T](jsonObj: JsonNode, TT: typedesc[Option[T]]): Option[T] = else: return none(T) +proc fromJson[T](jsonObj: JsonNode, TT: typedesc[seq[T]]): seq[T] = + if jsonObj.kind != JArray: + error "Expected array, got: ", jsonObj.kind + return @[] + + result = newSeq[T](jsonObj.len) + for i, elem in jsonObj.getElems(): + result[i] = fromJson(elem, T) + + return result + proc `%`*(at: ActivityType): JsonNode {.inline.} = return newJInt(ord(at)) @@ -241,8 +254,6 @@ proc `$`*(pt: ProtocolType): string {.inline.} = return "Hop" of Uniswap: return "Uniswap" - else: - return "" # Mirrors status-go/services/wallet/activity/activity.go TransferType type @@ -319,6 +330,7 @@ type ErrorCodeTaskCanceled, ErrorCodeFailed + # TODO #12120: Replace it with SessionUpdate # Mirrors services/wallet/activity/service.go FilterResponse FilterResponse* = object activities*: seq[ActivityEntry] @@ -326,6 +338,12 @@ type hasMore*: bool errorCode*: ErrorCode + # Mirrors services/wallet/activity/session.go SessionUpdate + SessionUpdate* = object + newEntries*: seq[ActivityEntry] + removed*: seq[Data] + updated*: seq[Data] + proc getPayloadType*(ae: ActivityEntry): PayloadType = return ae.payloadType @@ -423,8 +441,8 @@ proc fromJson*(e: JsonNode, T: typedesc[ActivityEntry]): ActivityEntry {.inline. activityType: data.activityType.get(), activityStatus: data.activityStatus.get(), timestamp: data.timestamp.get(), - amountOut: data.amountOut.get(), - amountIn: data.amountIn.get(), + amountOut: if data.amountOut.isSome: data.amountOut.get() else: 0.u256, + amountIn: if data.amountIn.isSome: data.amountIn.get() else: 0.u256, tokenOut: data.tokenOut, tokenIn: data.tokenIn, symbolOut: data.symbolOut, @@ -478,6 +496,19 @@ proc fromJson*(e: JsonNode, T: typedesc[FilterResponse]): FilterResponse {.inlin errorCode: ErrorCode(e["errorCode"].getInt()) ) +proc fromJson*(e: JsonNode, T: typedesc[SessionUpdate]): SessionUpdate {.inline.} = + var newEntries: seq[ActivityEntry] + if e.hasKey("newEntries"): + let jsonEntries = e["newEntries"] + if jsonEntries.kind == JNull: + newEntries = @[] + elif jsonEntries.kind == JArray: + newEntries = fromJson(jsonEntries, seq[ActivityEntry]) + + result = T( + newEntries: newEntries + ) + rpc(filterActivityAsync, "wallet"): requestId: int32 addresses: seq[string] @@ -487,6 +518,32 @@ rpc(filterActivityAsync, "wallet"): offset: int limit: int +rpc(startActivityFilterSession, "wallet"): + addresses: seq[string] + allAddresses: bool + chainIds: seq[ChainId] + filter: ActivityFilter + count: int + +rpc(stopActivityFilterSession, "wallet"): + sessionId: int + +# returns (sessionId, success) +proc newActivityFilterSession*( + addresses: seq[string], + allAddresses: bool, + chainIds: seq[ChainId], + filter: ActivityFilter, + count: int, +): (int, bool) {.inline.} = + let res = startActivityFilterSession(addresses, allAddresses, chainIds, filter, count) + if res.error != nil: + error "error starting a new session of activity fitlering: ", res.error + return (-1, false) + var test: json.JsonNode + test = res.result + return (test.getInt(), true) + # see services/wallet/activity/service.go GetRecipientsResponse type GetRecipientsResponse* = object addresses*: seq[string] diff --git a/storybook/pages/TransactionDelegatePage.qml b/storybook/pages/TransactionDelegatePage.qml index 77ba4fd2c94..c95c1577d7d 100644 --- a/storybook/pages/TransactionDelegatePage.qml +++ b/storybook/pages/TransactionDelegatePage.qml @@ -11,6 +11,7 @@ import shared.controls 1.0 SplitView { id: root + // mirrors ActivityEntry defined in src/app/modules/main/wallet_section/activity/entry.nim readonly property QtObject mockupModelData: QtObject { readonly property int timestamp: Date.now() / 1000 readonly property int status: ctrlStatus.currentValue @@ -38,6 +39,12 @@ SplitView { readonly property string chainId: "NETWORKID" readonly property string chainIdIn: "NETWORKID-IN" readonly property string chainIdOut: "NETWORKID-OUT" + + readonly property bool highlight: _highlight + function doneHighlighting() { + _highlight = false + } + property bool _highlight: false } SplitView { @@ -175,6 +182,13 @@ SplitView { id: ctrlMultiTrans text: "Multi transaction" } + + Button { + text: "New transaction" + onClicked: { + mockupModelData._highlight = true + } + } } } } diff --git a/test/status-go/integration/helpers/helpers.go b/test/status-go/integration/helpers/helpers.go index df3cdb1e609..96cd465f000 100644 --- a/test/status-go/integration/helpers/helpers.go +++ b/test/status-go/integration/helpers/helpers.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" statusgo "github.com/status-im/status-go/mobile" "github.com/status-im/status-go/multiaccounts" @@ -107,18 +108,31 @@ func WaitForEvent(eventQueue chan GoEvent, eventName StatusGoEventName, timeout } } -// WaitForWalletEvents returns payloads corresponding to the given eventNames in the order they are received for duplicate events -func WaitForWalletEvents[T any](eventQueue chan GoEvent, eventNamesOrig []walletevent.EventType, timeout time.Duration) (payloads []*T, err error) { - var event *GoEvent +func WaitForWalletEvents(eventQueue chan GoEvent, eventNames []walletevent.EventType, timeout time.Duration, condition func(walletEvent *walletevent.Event) bool) (walletEvents []*walletevent.Event, err error) { + return WaitForWalletEventsWithOptionals(eventQueue, eventNames, timeout, condition, nil) +} + +// WaitForWalletEvents waits for the given events to be received on the eventQueue. +// It returns the wallet events in the order they are received. +func WaitForWalletEventsWithOptionals(eventQueue chan GoEvent, eventNames []walletevent.EventType, timeout time.Duration, condition func(walletEvent *walletevent.Event) bool, optionalEventNames []walletevent.EventType) (walletEvents []*walletevent.Event, err error) { + if len(eventNames) == 0 { + return nil, errors.New("no event names provided") + } - payloads = make([]*T, len(eventNamesOrig)) - processed := make([]bool, len(eventNamesOrig)) - processedCount := 0 + startTime := time.Now() + expected := make([]walletevent.EventType, len(eventNames)) + copy(expected, eventNames) + walletEvents = make([]*walletevent.Event, 0, len(eventNames)) +infiniteLoop: for { - event, err = WaitForEvent(eventQueue, WalletEvent, timeout) + toWait := timeout - time.Since(startTime) + if toWait <= 0 { + return nil, fmt.Errorf("timeout waiting for events %+v", expected) + } + event, err := WaitForEvent(eventQueue, WalletEvent, toWait) if err != nil { - return nil, err + return nil, fmt.Errorf("error waiting for events %+v: %w", expected, err) } walletEvent, ok := event.Payload.(walletevent.Event) @@ -126,40 +140,115 @@ func WaitForWalletEvents[T any](eventQueue chan GoEvent, eventNamesOrig []wallet return nil, errors.New("event payload is not a wallet event") } - var newPayload T - foundIndex := -1 - for i, eventName := range eventNamesOrig { - if walletEvent.Type == eventName && !processed[i] { - foundIndex = i - processed[i] = true - processedCount += 1 - break + for i, event := range expected { + if walletEvent.Type == event && (condition == nil || condition(&walletEvent)) { + walletEvents = append(walletEvents, &walletEvent) + if len(expected) == 1 { + return walletEvents, nil + } + // Remove found event from the list of expected events + expected = append(expected[:i], expected[i+1:]...) + continue infiniteLoop } } - - if foundIndex != -1 { - if walletEvent.Message != "" { - err = json.Unmarshal([]byte(walletEvent.Message), &newPayload) - if err != nil { - return nil, err - } - payloads[foundIndex] = &newPayload - } else { - payloads[foundIndex] = nil + for _, event := range optionalEventNames { + if walletEvent.Type == event && condition != nil { + _ = condition(&walletEvent) } - if processedCount == len(eventNamesOrig) { - return payloads, nil + } + } +} + +type payloadRes struct { + eventName walletevent.EventType + data []byte +} + +// WaitForWalletEventsGetPayloads returns payloads corresponding to the given eventNames in the order they are received for duplicate events +func WaitForWalletEventsGetPayloads(eventQueue chan GoEvent, eventNames []walletevent.EventType, timeoutEach time.Duration) (payloads []payloadRes, err error) { + walletEvents, err := WaitForWalletEvents(eventQueue, eventNames, timeoutEach, nil) + if err != nil { + return nil, err + } + + payloads = make([]payloadRes, len(walletEvents)) + for i, event := range walletEvents { + payloads[i] = payloadRes{ + eventName: event.Type, + } + if event.Message != "" { + payloads[i].data = []byte(event.Message) + } + } + return payloads, nil +} + +type payloadMapRes struct { + EventName walletevent.EventType + JsonData map[string]interface{} +} + +// WaitForWalletEventsGetMap returns parsed JSON payloads; @see WaitForWalletEventsGetPayloads +func WaitForWalletEventsGetMap(eventQueue chan GoEvent, eventNames []walletevent.EventType, timeout time.Duration) (payloads []payloadMapRes, err error) { + bytePayloads, err := WaitForWalletEventsGetPayloads(eventQueue, eventNames, timeout) + if err != nil { + return nil, err + } + payloads = make([]payloadMapRes, len(bytePayloads)) + for i, payload := range bytePayloads { + var mapPayload map[string]interface{} + if payload.data != nil { + mapPayload = make(map[string]interface{}) + err = json.Unmarshal(payload.data, &mapPayload) + if err != nil { + return nil, err } } + payloads[i] = payloadMapRes{ + EventName: payload.eventName, + JsonData: mapPayload, + } } + return payloads, nil } -func WaitForWalletEvent[T any](eventQueue chan GoEvent, eventName walletevent.EventType, timeout time.Duration) (payload *T, err error) { - res, err := WaitForWalletEvents[T](eventQueue, []walletevent.EventType{eventName}, timeout) +func WaitForWalletEventGetPayload[T any](eventQueue chan GoEvent, eventName walletevent.EventType, timeout time.Duration) (payload *T, err error) { + res, err := WaitForWalletEventsGetPayloads(eventQueue, []walletevent.EventType{eventName}, timeout) + if err != nil { + return nil, err + } + if res[0].data == nil { + return nil, nil + } + + newPayload := new(T) + err = json.Unmarshal(res[0].data, newPayload) if err != nil { return nil, err } - return res[0], nil + return newPayload, nil +} + +// WaitForTxDownloaderToFinishForAccountsCondition returns a state-full condition function that records every account that has been seen with the events until the entire list is seen +func WaitForTxDownloaderToFinishForAccountsCondition(t *testing.T, accounts []common.Address) func(walletEvent *walletevent.Event) bool { + accs := make([]common.Address, len(accounts)) + copy(accs, accounts) + + return func(walletEvent *walletevent.Event) bool { + eventAccountsLoop: + for _, acc := range walletEvent.Accounts { + for i, a := range accs { + if acc == a { + if len(accs) == 1 { + return true + } + accs = append(accs[:i], accs[i+1:]...) + continue eventAccountsLoop + } + } + } + return false + } } func loginToAccount(hashedPassword, userFolder, nodeConfigJson string) error { @@ -251,32 +340,49 @@ func CallPrivateMethodWithTimeout(method string, params []interface{}, timeout t } didTimeout := false - done := make(chan bool) - var responseJson string + done := make(chan string) go func() { - responseJson = statusgo.CallPrivateRPC(string(msgJson)) + responseJson := statusgo.CallPrivateRPC(string(msgJson)) if didTimeout { log.Warn("Call to CallPrivateRPC returned after timeout", "payload", string(msgJson)) return } - done <- true + done <- responseJson }() select { - case <-done: + case res := <-done: + return res, nil case <-time.After(timeout): didTimeout = true return "", fmt.Errorf("timeout waiting for response to statusgo.CallPrivateRPC; payload \"%s\"", string(msgJson)) } - - return responseJson, nil } func CallPrivateMethod(method string, params []interface{}) (string, error) { return CallPrivateMethodWithTimeout(method, params, 60*time.Second) } +func CallPrivateMethodAndGetT[T any](method string, params []interface{}) (*T, error) { + resJson, err := CallPrivateMethodWithTimeout(method, params, 60*time.Second) + if err != nil { + return nil, err + } + + var res T + rawJson, err := GetRPCAPIResponseRaw(resJson) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(rawJson, &res); err != nil { + return nil, fmt.Errorf("failed to unmarshal data: %w", err) + } + + return &res, nil +} + type Config struct { HashedPassword string `json:"hashedPassword"` NodeConfigFile string `json:"nodeConfigFile"` @@ -359,29 +465,37 @@ type jsonError struct { } func GetRPCAPIResponse[T any](responseJson string, res T) error { - errApiResponse := jsonrpcErrorResponse{} - err := json.Unmarshal([]byte(responseJson), &errApiResponse) - if err == nil && errApiResponse.Error.Code != 0 { - return fmt.Errorf("API error: %#v", errApiResponse.Error) - } - - apiResponse := jsonrpcSuccessfulResponse{} - err = json.Unmarshal([]byte(responseJson), &apiResponse) - if err != nil { - return fmt.Errorf("failed to unmarshal jsonrpcSuccessfulResponse: %w", err) - } - typeOfT := reflect.TypeOf(res) kindOfT := typeOfT.Kind() - // Check for valid types: pointer, slice, map if kindOfT != reflect.Ptr && kindOfT != reflect.Slice && kindOfT != reflect.Map { return fmt.Errorf("type T must be a pointer, slice, or map") } - if err := json.Unmarshal(apiResponse.Result, &res); err != nil { + rawJson, err := GetRPCAPIResponseRaw(responseJson) + if err != nil { + return err + } + + if err := json.Unmarshal(rawJson, &res); err != nil { return fmt.Errorf("failed to unmarshal data: %w", err) } return nil } + +func GetRPCAPIResponseRaw(responseJson string) (json.RawMessage, error) { + errApiResponse := jsonrpcErrorResponse{} + err := json.Unmarshal([]byte(responseJson), &errApiResponse) + if err == nil && errApiResponse.Error.Code != 0 { + return nil, fmt.Errorf("API error: %#v", errApiResponse.Error) + } + + apiResponse := jsonrpcSuccessfulResponse{} + err = json.Unmarshal([]byte(responseJson), &apiResponse) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal jsonrpcSuccessfulResponse: %w", err) + } + + return apiResponse.Result, nil +} diff --git a/test/status-go/integration/wallet/activityfiltering_test.go b/test/status-go/integration/wallet/activityfiltering_test.go index e4f65534928..5e14c3f25ab 100644 --- a/test/status-go/integration/wallet/activityfiltering_test.go +++ b/test/status-go/integration/wallet/activityfiltering_test.go @@ -4,9 +4,11 @@ package wallet import ( + "encoding/json" "testing" "time" + eth "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" "github.com/status-im/status-desktop/test/status-go/integration/helpers" @@ -14,8 +16,8 @@ import ( "github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/services/wallet/activity" "github.com/status-im/status-go/services/wallet/common" + "github.com/status-im/status-go/services/wallet/transfer" "github.com/status-im/status-go/services/wallet/walletevent" - "github.com/status-im/status-go/transactions" ) // TestActivityIncrementalUpdates_NoFilterNewPendingTransactions tests that a pending transaction is created, then updated and finally deleted. @@ -23,34 +25,82 @@ func TestActivityIncrementalUpdates_NoFilterNewPendingTransactions(t *testing.T) td, close := setupAccountsAndTransactions(t) defer close() - _, err := helpers.CallPrivateMethod("wallet_startActivityFilterSession", []interface{}{[]types.Address{td.sender.Address}, false, []common.ChainID{5}, activity.Filter{}, 3}) + rawSessionID, err := helpers.CallPrivateMethodAndGetT[int32]("wallet_startActivityFilterSession", []interface{}{[]types.Address{td.sender.Address}, false, []common.ChainID{5}, activity.Filter{}, 3}) require.NoError(t, err) + require.NotNil(t, rawSessionID) + sessionID := activity.SessionID(*rawSessionID) // Confirm async filtering results - filterRes, err := helpers.WaitForWalletEvents[activity.FilterResponse]( - td.eventQueue, []walletevent.EventType{activity.EventActivityFilteringDone}, - 5*time.Second, - ) + res, err := helpers.WaitForWalletEventGetPayload[activity.FilterResponse](td.eventQueue, activity.EventActivityFilteringDone, 5*time.Second) require.NoError(t, err) - res := filterRes[0] require.Equal(t, activity.ErrorCodeSuccess, res.ErrorCode) require.Equal(t, 3, len(res.Activities)) // Trigger updating of activity results sendTransaction(t, td) - // Wait for EventActivitySessionUpdated signal triggered by the EventPendingTransactionUpdate - update, err := helpers.WaitForWalletEvent[activity.SessionUpdate](td.eventQueue, activity.EventActivitySessionUpdated, 2*time.Second) + // Wait for EventActivitySessionUpdated signal triggered by the first EventPendingTransactionUpdate + update, err := helpers.WaitForWalletEventGetPayload[activity.SessionUpdate](td.eventQueue, activity.EventActivitySessionUpdated, 60*time.Second) + require.NoError(t, err) + require.NotNil(t, update.HasNewEntries) + require.True(t, *update.HasNewEntries) + + // TODO #12120 check EventActivitySessionUpdated due to transactions.EventPendingTransactionStatusChanged + // statusPayload, err := helpers.WaitForWalletEventGetPayload[transactions.StatusChangedPayload](td.eventQueue, , 60*time.Second) + // require.NoError(t, err) + // require.Equal(t, transactions.Success, statusPayload.Status) + + // Start history download to cleanup pending transactions + _, err = helpers.CallPrivateMethod("wallet_checkRecentHistoryForChainIDs", []interface{}{[]uint64{5}, []types.Address{td.sender.Address, td.recipient.Address}}) + require.NoError(t, err) + + downloadDoneFn := helpers.WaitForTxDownloaderToFinishForAccountsCondition(t, []eth.Address{eth.Address(td.sender.Address), eth.Address(td.recipient.Address)}) + + update = nil + // Wait for EventActivitySessionUpdated signal triggered by the second EventPendingTransactionUpdate. Also wait for download done for accounts + _, err = helpers.WaitForWalletEventsWithOptionals( + td.eventQueue, + []walletevent.EventType{transfer.EventRecentHistoryReady}, + 120*time.Second, + func(e *walletevent.Event) bool { + if e.Type == activity.EventActivitySessionUpdated { + var parsedPayload activity.SessionUpdate + err := json.Unmarshal(([]byte)(e.Message), &parsedPayload) + require.NoError(t, err) + update = &parsedPayload + + // TODO #12120 enable after implementing remove and update + // require.NotNil(t, update.HasNewEntries) + // require.True(t, *update.HasNewEntries) + // require.NotNil(t, update.Removed) + // require.True(t, *update.Removed) + return false + } else if e.Type == transfer.EventFetchingHistoryError { + require.Fail(t, "History download failed") + return false + } else if downloadDoneFn(e) { + return true + } + return false + }, + []walletevent.EventType{activity.EventActivitySessionUpdated, transfer.EventFetchingHistoryError}, + ) require.NoError(t, err) - require.Equal(t, 1, len(update.NewEntries)) + require.NotNil(t, update, "EventActivitySessionUpdated signal WASN'T triggered by the second EventPendingTransactionUpdate during history download") + require.NotNil(t, update.HasNewEntries) + require.True(t, *update.HasNewEntries) - // Step x: Trigger downloading of the new transaction ... - _, err = helpers.CallPrivateMethodWithTimeout("wallet_checkRecentHistoryForChainIDs", []interface{}{[]uint64{5}, []types.Address{td.sender.Address, td.recipient.Address}}, 2*time.Second) + // Start history download to cleanup pending transactions + _, err = helpers.CallPrivateMethodAndGetT[interface{}]("wallet_resetFilterSession", []interface{}{sessionID, 3}) require.NoError(t, err) - // ... and wait for the new transaction download to trigger deletion from pending_transactions - updatePayload, err := helpers.WaitForWalletEvent[transactions.PendingTxUpdatePayload]( - td.eventQueue, transactions.EventPendingTransactionUpdate, 120*time.Second) + updatedRes, err := helpers.WaitForWalletEventsGetMap(td.eventQueue, []walletevent.EventType{activity.EventActivityFilteringDone}, 1*time.Second) require.NoError(t, err) - require.Equal(t, true, updatePayload.Deleted) + require.Equal(t, activity.ErrorCodeSuccess, activity.ErrorCode(updatedRes[0].JsonData["errorCode"].(float64))) + activitiesList := updatedRes[0].JsonData["activities"].([]interface{}) + require.Equal(t, 3, len(activitiesList)) + firstActivity := activitiesList[0].(map[string]interface{}) + isNew, found := firstActivity["isNew"] + require.True(t, found) + require.True(t, isNew.(bool)) } diff --git a/test/status-go/integration/wallet/common.go b/test/status-go/integration/wallet/common.go index e5a659d5e8a..e6a56cfc12c 100644 --- a/test/status-go/integration/wallet/common.go +++ b/test/status-go/integration/wallet/common.go @@ -37,13 +37,13 @@ func setupAccountsAndTransactions(t *testing.T) (td testUserData, close func()) require.Greater(t, len(watchAccounts), 0) return testUserData{ - opAccounts[0], - watchAccounts[0], - conf.HashedPassword, - eventQueue, - }, func() { - helpers.Logout(t) - } + opAccounts[0], + watchAccounts[0], + conf.HashedPassword, + eventQueue, + }, func() { + helpers.Logout(t) + } } // sendTransaction generates multi_transactions and pending entries then it creates and publishes a transaction diff --git a/test/status-go/integration/wallet/pendingtransactions_test.go b/test/status-go/integration/wallet/pendingtransactions_test.go index bae892e4630..1d754be02ea 100644 --- a/test/status-go/integration/wallet/pendingtransactions_test.go +++ b/test/status-go/integration/wallet/pendingtransactions_test.go @@ -7,11 +7,13 @@ import ( "testing" "time" + eth "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" "github.com/status-im/status-desktop/test/status-go/integration/helpers" "github.com/status-im/status-go/eth-node/types" + "github.com/status-im/status-go/services/wallet/transfer" "github.com/status-im/status-go/services/wallet/walletevent" "github.com/status-im/status-go/transactions" ) @@ -23,21 +25,44 @@ func TestPendingTx_NotificationStatus(t *testing.T) { sendTransaction(t, td) - // Start history download ... - _, err := helpers.CallPrivateMethod("wallet_checkRecentHistoryForChainIDs", []interface{}{[]uint64{5}, []types.Address{td.sender.Address, td.recipient.Address}}) - require.NoError(t, err) - - // ... and wait for the new transaction download to trigger deletion from pending_transactions - updatePayloads, err := helpers.WaitForWalletEvents[transactions.PendingTxUpdatePayload]( + // Wait for transaction to be included in block + confirmationPayloads, err := helpers.WaitForWalletEventsGetMap( td.eventQueue, []walletevent.EventType{ transactions.EventPendingTransactionUpdate, - transactions.EventPendingTransactionUpdate, + transactions.EventPendingTransactionStatusChanged, }, 60*time.Second, ) require.NoError(t, err) - // Validate that we received both add and delete event - require.False(t, updatePayloads[0].Deleted) - require.True(t, updatePayloads[1].Deleted) + // Validate that we received update event + for _, payload := range confirmationPayloads { + if payload.EventName == transactions.EventPendingTransactionUpdate { + require.False(t, payload.JsonData["deleted"].(bool)) + } else { + require.Equal(t, transactions.Success, payload.JsonData["status"].(transactions.TxStatus)) + } + } + + // Start history download ... + _, err = helpers.CallPrivateMethod("wallet_checkRecentHistoryForChainIDs", []interface{}{[]uint64{5}, []types.Address{td.sender.Address, td.recipient.Address}}) + require.NoError(t, err) + + downloadDoneFn := helpers.WaitForTxDownloaderToFinishForAccountsCondition(t, []eth.Address{eth.Address(td.sender.Address), eth.Address(td.recipient.Address)}) + + // ... and wait for the new transaction download to trigger deletion from pending_transactions + _, err = helpers.WaitForWalletEventsWithOptionals( + td.eventQueue, + []walletevent.EventType{transfer.EventRecentHistoryReady}, + 60*time.Second, + func(e *walletevent.Event) bool { + if e.Type == transfer.EventFetchingHistoryError { + require.Fail(t, "History download failed") + return false + } + return downloadDoneFn(e) + }, + []walletevent.EventType{transfer.EventFetchingHistoryError}, + ) + require.NoError(t, err) } diff --git a/ui/imports/shared/controls/TransactionDelegate.qml b/ui/imports/shared/controls/TransactionDelegate.qml index c4f157f4a3b..8d052065dcd 100644 --- a/ui/imports/shared/controls/TransactionDelegate.qml +++ b/ui/imports/shared/controls/TransactionDelegate.qml @@ -160,7 +160,7 @@ StatusListItem { bgWidth: width + 2 bgHeight: height + 2 bgRadius: bgWidth / 2 - bgColor: Style.current.name === Constants.lightThemeName && Constants.isDefaultTokenIcon(root.tokenImage) ? + bgColor: d.lightTheme && Constants.isDefaultTokenIcon(root.tokenImage) ? Theme.palette.white : "transparent" color: "transparent" isImage: !loading @@ -176,6 +176,9 @@ StatusListItem { property int titlePixelSize: 15 property int subtitlePixelSize: 13 property bool showRetryButton: false + + readonly property bool isLightTheme: Style.current.name === Constants.lightThemeName + property color animatedBgColor } function getDetailsString(detailsObj) { @@ -527,7 +530,12 @@ StatusListItem { rightPadding: 16 enabled: !loading loading: !isModelDataValid - color: sensor.containsMouse ? Theme.palette.baseColor5 : Style.current.transparent + color: { + if (bgColorAnimation.running) { + return d.animatedBgColor + } + return sensor.containsMouse ? Theme.palette.baseColor5 : Style.current.transparent + } statusListItemIcon.active: (loading || root.asset.name) asset { @@ -861,4 +869,20 @@ StatusListItem { } } ] + + ColorAnimation { + id: bgColorAnimation + + target: d + property: "animatedBgColor" + from: d.isLightTheme ? "#33869eff" : "#1a4360df" + to: "transparent" + duration: 1000 + alwaysRunToEnd: true + running: root.visible && modelData.highlight + + onStopped: { + modelData.doneHighlighting() + } + } } diff --git a/vendor/status-go b/vendor/status-go index 4584de34b09..2fcdac57507 160000 --- a/vendor/status-go +++ b/vendor/status-go @@ -1 +1 @@ -Subproject commit 4584de34b096f93e6f1be760cd1105f956c91962 +Subproject commit 2fcdac57507fdb2a052d75fd3e3d287f0e0f8f3b