From 0b24d7a341dadcfdf59c6dbbb93506b6bb20bf66 Mon Sep 17 00:00:00 2001 From: Richard Ramos Date: Wed, 8 Sep 2021 14:05:39 -0400 Subject: [PATCH] Initial import --- README.md | 3 + eventemitter.nim | 52 ++ nim_status_lib.nimble | 9 + status/accounts.nim | 86 ++ status/browser.nim | 24 + status/chat.nim | 763 ++++++++++++++++++ status/chat/chat.nim | 76 ++ status/chat/stickers.nim | 9 + status/chat/utils.nim | 13 + status/constants.nim | 10 + status/contacts.nim | 151 ++++ status/devices.nim | 43 + status/ens.nim | 387 +++++++++ status/fleet.nim | 15 + status/gif.nim | 151 ++++ status/libstatus/accounts.nim | 393 +++++++++ status/libstatus/accounts/constants.nim | 235 ++++++ status/libstatus/accounts/signing_phrases.nim | 623 ++++++++++++++ status/libstatus/browser.nim | 27 + status/libstatus/chat.nim | 592 ++++++++++++++ status/libstatus/chatCommands.nim | 20 + status/libstatus/coder.nim | 77 ++ status/libstatus/contacts.nim | 45 ++ status/libstatus/conversions.nim | 29 + status/libstatus/core.nim | 59 ++ status/libstatus/edn_helpers.nim | 83 ++ status/libstatus/eth/contracts.nim | 319 ++++++++ status/libstatus/eth/eth.nim | 23 + status/libstatus/eth/methods.nim | 64 ++ status/libstatus/eth/transactions.nim | 30 + status/libstatus/gif.nim | 16 + status/libstatus/installations.nim | 29 + status/libstatus/mailservers.nim | 49 ++ status/libstatus/settings.nim | 215 +++++ status/libstatus/stickers.nim | 234 ++++++ status/libstatus/tokens.nim | 172 ++++ status/libstatus/wallet.nim | 147 ++++ status/mailservers.nim | 21 + status/messages.nim | 51 ++ status/network.nim | 54 ++ status/node.nim | 16 + status/notifications/os_notifications.nim | 20 + status/permissions.nim | 105 +++ status/profile.nim | 28 + status/profile/mailserver.nim | 3 + status/profile/profile.nim | 29 + status/provider.nim | 276 +++++++ status/settings.nim | 72 ++ status/signals/base.nim | 15 + status/signals/community.nim | 13 + status/signals/discovery_summary.nim | 13 + status/signals/envelope.nim | 14 + status/signals/expired.nim | 14 + status/signals/mailserver.nim | 29 + status/signals/messages.nim | 95 +++ status/signals/signal_type.nim | 26 + status/signals/stats.nim | 21 + status/signals/wallet.nim | 25 + status/signals/whisper_filter.nim | 33 + status/status.nim | 89 ++ status/stickers.nim | 146 ++++ status/tokens.nim | 43 + status/transactions.nim | 20 + status/types/account.nim | 46 ++ status/types/activity_center_notification.nim | 49 ++ status/types/bookmark.nim | 7 + status/types/chat.nim | 170 ++++ status/types/chat_member.nim | 21 + status/types/chat_membership_event.nim | 28 + status/types/community.nim | 91 +++ status/types/community_category.nim | 6 + status/types/community_membership_request.nim | 21 + status/types/derived_account.nim | 6 + status/types/fleet.nim | 52 ++ status/types/gas_prediction.nim | 9 + status/types/identity_image.nim | 6 + status/types/installation.nim | 14 + status/types/message.nim | 192 +++++ status/types/message_command_parameters.nim | 16 + status/types/message_reaction.nim | 21 + status/types/message_text_item.nim | 25 + status/types/multi_accounts.nim | 12 + status/types/network.nim | 15 + status/types/network_details.nim | 12 + status/types/node_config.nim | 11 + status/types/os_notification.nim | 46 ++ status/types/pending_transaction_type.nim | 8 + status/types/profile.nim | 49 ++ status/types/removed_message.nim | 13 + status/types/rpc_response.nim | 29 + status/types/setting.nim | 31 + status/types/status_update.nim | 24 + status/types/sticker.nim | 32 + status/types/transaction.nim | 30 + status/types/upstream_config.nim | 8 + status/updates.nim | 44 + status/utils.nim | 178 ++++ status/utils/cache.nim | 17 + status/utils/json_utils.nim | 33 + status/wallet.nim | 408 ++++++++++ status/wallet/account.nim | 62 ++ status/wallet/balance_manager.nim | 90 +++ status/wallet/collectibles.nim | 259 ++++++ status/wallet2.nim | 213 +++++ status/wallet2/account.nim | 77 ++ status/wallet2/balance_manager.nim | 90 +++ status/wallet2/collectibles.nim | 259 ++++++ 107 files changed, 9374 insertions(+) create mode 100644 README.md create mode 100644 eventemitter.nim create mode 100644 nim_status_lib.nimble create mode 100644 status/accounts.nim create mode 100644 status/browser.nim create mode 100644 status/chat.nim create mode 100644 status/chat/chat.nim create mode 100644 status/chat/stickers.nim create mode 100644 status/chat/utils.nim create mode 100644 status/constants.nim create mode 100644 status/contacts.nim create mode 100644 status/devices.nim create mode 100644 status/ens.nim create mode 100644 status/fleet.nim create mode 100644 status/gif.nim create mode 100644 status/libstatus/accounts.nim create mode 100644 status/libstatus/accounts/constants.nim create mode 100644 status/libstatus/accounts/signing_phrases.nim create mode 100644 status/libstatus/browser.nim create mode 100644 status/libstatus/chat.nim create mode 100644 status/libstatus/chatCommands.nim create mode 100644 status/libstatus/coder.nim create mode 100644 status/libstatus/contacts.nim create mode 100644 status/libstatus/conversions.nim create mode 100644 status/libstatus/core.nim create mode 100644 status/libstatus/edn_helpers.nim create mode 100644 status/libstatus/eth/contracts.nim create mode 100644 status/libstatus/eth/eth.nim create mode 100644 status/libstatus/eth/methods.nim create mode 100644 status/libstatus/eth/transactions.nim create mode 100644 status/libstatus/gif.nim create mode 100644 status/libstatus/installations.nim create mode 100644 status/libstatus/mailservers.nim create mode 100644 status/libstatus/settings.nim create mode 100644 status/libstatus/stickers.nim create mode 100644 status/libstatus/tokens.nim create mode 100644 status/libstatus/wallet.nim create mode 100644 status/mailservers.nim create mode 100644 status/messages.nim create mode 100644 status/network.nim create mode 100644 status/node.nim create mode 100644 status/notifications/os_notifications.nim create mode 100644 status/permissions.nim create mode 100644 status/profile.nim create mode 100644 status/profile/mailserver.nim create mode 100644 status/profile/profile.nim create mode 100644 status/provider.nim create mode 100644 status/settings.nim create mode 100644 status/signals/base.nim create mode 100644 status/signals/community.nim create mode 100644 status/signals/discovery_summary.nim create mode 100644 status/signals/envelope.nim create mode 100644 status/signals/expired.nim create mode 100644 status/signals/mailserver.nim create mode 100644 status/signals/messages.nim create mode 100644 status/signals/signal_type.nim create mode 100644 status/signals/stats.nim create mode 100644 status/signals/wallet.nim create mode 100644 status/signals/whisper_filter.nim create mode 100644 status/status.nim create mode 100644 status/stickers.nim create mode 100644 status/tokens.nim create mode 100644 status/transactions.nim create mode 100644 status/types/account.nim create mode 100644 status/types/activity_center_notification.nim create mode 100644 status/types/bookmark.nim create mode 100644 status/types/chat.nim create mode 100644 status/types/chat_member.nim create mode 100644 status/types/chat_membership_event.nim create mode 100644 status/types/community.nim create mode 100644 status/types/community_category.nim create mode 100644 status/types/community_membership_request.nim create mode 100644 status/types/derived_account.nim create mode 100644 status/types/fleet.nim create mode 100644 status/types/gas_prediction.nim create mode 100644 status/types/identity_image.nim create mode 100644 status/types/installation.nim create mode 100644 status/types/message.nim create mode 100644 status/types/message_command_parameters.nim create mode 100644 status/types/message_reaction.nim create mode 100644 status/types/message_text_item.nim create mode 100644 status/types/multi_accounts.nim create mode 100644 status/types/network.nim create mode 100644 status/types/network_details.nim create mode 100644 status/types/node_config.nim create mode 100644 status/types/os_notification.nim create mode 100644 status/types/pending_transaction_type.nim create mode 100644 status/types/profile.nim create mode 100644 status/types/removed_message.nim create mode 100644 status/types/rpc_response.nim create mode 100644 status/types/setting.nim create mode 100644 status/types/status_update.nim create mode 100644 status/types/sticker.nim create mode 100644 status/types/transaction.nim create mode 100644 status/types/upstream_config.nim create mode 100644 status/updates.nim create mode 100644 status/utils.nim create mode 100644 status/utils/cache.nim create mode 100644 status/utils/json_utils.nim create mode 100644 status/wallet.nim create mode 100644 status/wallet/account.nim create mode 100644 status/wallet/balance_manager.nim create mode 100644 status/wallet/collectibles.nim create mode 100644 status/wallet2.nim create mode 100644 status/wallet2/account.nim create mode 100644 status/wallet2/balance_manager.nim create mode 100644 status/wallet2/collectibles.nim diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dddfe3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# nim-status-lib + +WIP refactor to extract business logic from status-desktop into a reusable library \ No newline at end of file diff --git a/eventemitter.nim b/eventemitter.nim new file mode 100644 index 0000000..7ea5eb2 --- /dev/null +++ b/eventemitter.nim @@ -0,0 +1,52 @@ +import # system libs + tables + +import # deps + uuids + +type + Args* = ref object of RootObj # ...args + Handler* = proc (args: Args) {.closure.} # callback function type + EventEmitter* = ref object + events: Table[string, Table[UUID, Handler]] + +proc createEventEmitter*(): EventEmitter = + result.new + result.events = initTable[string, Table[UUID, Handler]]() + + +proc on(this: EventEmitter, name: string, handlerId: UUID, handler: Handler): void = + if this.events.hasKey(name): + this.events[name].add handlerId, handler + return + + this.events[name] = [(handlerId, handler)].toTable + +proc on*(this: EventEmitter, name: string, handler: Handler): void = + var uuid: UUID + this.on(name, uuid, handler) + +proc once*(this:EventEmitter, name:string, handler:Handler): void = + var handlerId = genUUID() + this.on(name, handlerId) do(a: Args): + handler(a) + this.events[name].del handlerId + +proc emit*(this:EventEmitter, name:string, args:Args): void = + if this.events.hasKey(name): + for (id, handler) in this.events[name].pairs: + handler(args) + +when isMainModule: + block: + type ReadyArgs = ref object of Args + text: string + var evts = createEventEmitter() + evts.on("ready") do(a: Args): + var args = ReadyArgs(a) + echo args.text, ": from [1st] handler" + evts.once("ready") do(a: Args): + var args = ReadyArgs(a) + echo args.text, ": from [2nd] handler" + evts.emit("ready", ReadyArgs(text:"Hello, World")) + evts.emit("ready", ReadyArgs(text:"Hello, World")) diff --git a/nim_status_lib.nimble b/nim_status_lib.nimble new file mode 100644 index 0000000..3ab5684 --- /dev/null +++ b/nim_status_lib.nimble @@ -0,0 +1,9 @@ +mode = ScriptMode.Verbose + +version = "0.1.0" +author = "Status Research & Development GmbH" +description = "WIP refactor to extract business logic from status-desktop into a reusable library" +license = "MIT" +skipDirs = @["test"] + +requires "nim >= 1.2.0" diff --git a/status/accounts.nim b/status/accounts.nim new file mode 100644 index 0000000..f74ca80 --- /dev/null +++ b/status/accounts.nim @@ -0,0 +1,86 @@ +import options, chronicles, json, json_serialization, sequtils, sugar +import libstatus/accounts as status_accounts +import libstatus/settings as status_settings +import ./types/[account, fleet, sticker, setting] +import utils +import ../eventemitter + +const DEFAULT_NETWORK_NAME* = "mainnet_rpc" + +type + AccountModel* = ref object + generatedAddresses*: seq[GeneratedAccount] + nodeAccounts*: seq[NodeAccount] + events: EventEmitter + +proc newAccountModel*(events: EventEmitter): AccountModel = + result = AccountModel() + result.events = events + +proc generateAddresses*(self: AccountModel): seq[GeneratedAccount] = + var accounts = status_accounts.generateAddresses() + for account in accounts.mitems: + account.name = status_accounts.generateAlias(account.derived.whisper.publicKey) + account.identicon = status_accounts.generateIdenticon(account.derived.whisper.publicKey) + self.generatedAddresses.add(account) + result = self.generatedAddresses + +proc openAccounts*(self: AccountModel): seq[NodeAccount] = + result = status_accounts.openAccounts() + +proc login*(self: AccountModel, selectedAccountIndex: int, password: string): NodeAccount = + let currentNodeAccount = self.nodeAccounts[selectedAccountIndex] + result = status_accounts.login(currentNodeAccount, password) + +proc storeAccountAndLogin*(self: AccountModel, fleetConfig: FleetConfig, selectedAccountIndex: int, password: string): Account = + let generatedAccount: GeneratedAccount = self.generatedAddresses[selectedAccountIndex] + result = status_accounts.setupAccount(fleetConfig, generatedAccount, password) + +proc storeDerivedAndLogin*(self: AccountModel, fleetConfig: FleetConfig, importedAccount: GeneratedAccount, password: string): Account = + result = status_accounts.setupAccount(fleetConfig, importedAccount, password) + +proc importMnemonic*(self: AccountModel, mnemonic: string): GeneratedAccount = + let importedAccount = status_accounts.multiAccountImportMnemonic(mnemonic) + importedAccount.derived = status_accounts.deriveAccounts(importedAccount.id) + importedAccount.name = status_accounts.generateAlias(importedAccount.derived.whisper.publicKey) + importedAccount.identicon = status_accounts.generateIdenticon(importedAccount.derived.whisper.publicKey) + result = importedAccount + +proc reset*(self: AccountModel) = + self.nodeAccounts = @[] + self.generatedAddresses = @[] + +proc generateAlias*(publicKey: string): string = + result = status_accounts.generateAlias(publicKey) + +proc generateIdenticon*(publicKey: string): string = + result = status_accounts.generateIdenticon(publicKey) + +proc generateAlias*(self: AccountModel, publicKey: string): string = + result = generateAlias(publicKey) + +proc generateIdenticon*(self: AccountModel, publicKey: string): string = + result = generateIdenticon(publicKey) + +proc changeNetwork*(self: AccountModel, fleetConfig: FleetConfig, network: string) = + var statusGoResult = status_settings.setNetwork(network) + if statusGoResult.error != "": + error "Error saving updated node config", msg=statusGoResult.error + + # remove all installed sticker packs (pack ids do not match across networks) + statusGoResult = status_settings.saveSetting(Setting.Stickers_PacksInstalled, %* {}) + if statusGoResult.error != "": + error "Error removing all installed sticker packs", msg=statusGoResult.error + + # remove all recent stickers (pack ids do not match across networks) + statusGoResult = status_settings.saveSetting(Setting.Stickers_Recent, %* {}) + if statusGoResult.error != "": + error "Error removing all recent stickers", msg=statusGoResult.error + +proc changePassword*(self: AccountModel, keyUID: string, password: string, newPassword: string): bool = + try: + if not status_accounts.changeDatabasePassword(keyUID, password, newPassword): + return false + except: + return false + return true \ No newline at end of file diff --git a/status/browser.nim b/status/browser.nim new file mode 100644 index 0000000..626bef2 --- /dev/null +++ b/status/browser.nim @@ -0,0 +1,24 @@ +import libstatus/browser as status_browser +import ../eventemitter + +import ./types/[bookmark] + +type + BrowserModel* = ref object + events*: EventEmitter + +proc newBrowserModel*(events: EventEmitter): BrowserModel = + result = BrowserModel() + result.events = events + +proc storeBookmark*(self: BrowserModel, url: string, name: string): Bookmark = + result = status_browser.storeBookmark(url, name) + +proc updateBookmark*(self: BrowserModel, ogUrl: string, url: string, name: string) = + status_browser.updateBookmark(ogUrl, url, name) + +proc getBookmarks*(self: BrowserModel): string = + result = status_browser.getBookmarks() + +proc deleteBookmark*(self: BrowserModel, url: string) = + status_browser.deleteBookmark(url) diff --git a/status/chat.nim b/status/chat.nim new file mode 100644 index 0000000..0f1e055 --- /dev/null +++ b/status/chat.nim @@ -0,0 +1,763 @@ +import json, strutils, sequtils, tables, chronicles, times, sugar +import libstatus/chat as status_chat +import libstatus/chatCommands as status_chat_commands +import types/[message, status_update, activity_center_notification, + sticker, removed_message] +import utils as status_utils +import stickers +import ../eventemitter + +import profile/profile +import contacts +import chat/[chat, utils] +import ens, accounts + +include utils/json_utils + +logScope: + topics = "chat-model" + +const backToFirstChat* = "__goBackToFirstChat" +const ZERO_ADDRESS* = "0x0000000000000000000000000000000000000000" + +type + ChatUpdateArgs* = ref object of Args + chats*: seq[Chat] + messages*: seq[Message] + pinnedMessages*: seq[Message] + contacts*: seq[Profile] + emojiReactions*: seq[Reaction] + communities*: seq[Community] + communityMembershipRequests*: seq[CommunityMembershipRequest] + activityCenterNotifications*: seq[ActivityCenterNotification] + statusUpdates*: seq[StatusUpdate] + deletedMessages*: seq[RemovedMessage] + + ChatIdArg* = ref object of Args + chatId*: string + + ChannelArgs* = ref object of Args + chat*: Chat + + ChatArgs* = ref object of Args + chats*: seq[Chat] + + CommunityActiveChangedArgs* = ref object of Args + active*: bool + + MsgsLoadedArgs* = ref object of Args + chatId*: string + messages*: seq[Message] + statusUpdates*: seq[StatusUpdate] + + ActivityCenterNotificationsArgs* = ref object of Args + activityCenterNotifications*: seq[ActivityCenterNotification] + + ReactionsLoadedArgs* = ref object of Args + reactions*: seq[Reaction] + + MessageArgs* = ref object of Args + id*: string + channel*: string + + MessageSendingSuccess* = ref object of Args + chat*: Chat + message*: Message + + MarkAsReadNotificationProperties* = ref object of Args + communityId*: string + channelId*: string + notificationTypes*: seq[ActivityCenterNotificationType] + +type ChatModel* = ref object + publicKey*: string + events*: EventEmitter + communitiesToFetch*: seq[string] + mailserverReady*: bool + contacts*: Table[string, Profile] + channels*: Table[string, Chat] + msgCursor: Table[string, string] + pinnedMsgCursor: Table[string, string] + activityCenterCursor*: string + emojiCursor: Table[string, string] + lastMessageTimestamps*: Table[string, int64] + +proc newChatModel*(events: EventEmitter): ChatModel = + result = ChatModel() + result.events = events + result.mailserverReady = false + result.communitiesToFetch = @[] + result.contacts = initTable[string, Profile]() + result.channels = initTable[string, Chat]() + result.msgCursor = initTable[string, string]() + result.pinnedMsgCursor = initTable[string, string]() + result.emojiCursor = initTable[string, string]() + result.lastMessageTimestamps = initTable[string, int64]() + +proc update*(self: ChatModel, chats: seq[Chat], messages: seq[Message], emojiReactions: seq[Reaction], communities: seq[Community], communityMembershipRequests: seq[CommunityMembershipRequest], pinnedMessages: seq[Message], activityCenterNotifications: seq[ActivityCenterNotification], statusUpdates: seq[StatusUpdate], deletedMessages: seq[RemovedMessage]) = + for chat in chats: + self.channels[chat.id] = chat + + for message in messages: + let chatId = message.chatId + let ts = times.convert(Milliseconds, Seconds, message.whisperTimestamp.parseInt()) + if not self.lastMessageTimestamps.hasKey(chatId): + self.lastMessageTimestamps[chatId] = ts + else: + if self.lastMessageTimestamps[chatId] > ts: + self.lastMessageTimestamps[chatId] = ts + + self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages,chats: chats, contacts: @[], emojiReactions: emojiReactions, communities: communities, communityMembershipRequests: communityMembershipRequests, pinnedMessages: pinnedMessages, activityCenterNotifications: activityCenterNotifications, statusUpdates: statusUpdates, deletedMessages: deletedMessages)) + +proc parseChatResponse(self: ChatModel, response: string): (seq[Chat], seq[Message]) = + var parsedResponse = parseJson(response) + var chats: seq[Chat] = @[] + var messages: seq[Message] = @[] + if parsedResponse{"result"}{"messages"} != nil: + for jsonMsg in parsedResponse["result"]["messages"]: + messages.add(jsonMsg.toMessage()) + if parsedResponse{"result"}{"chats"} != nil: + for jsonChat in parsedResponse["result"]["chats"]: + let chat = jsonChat.toChat + self.channels[chat.id] = chat + chats.add(chat) + result = (chats, messages) + +proc emitUpdate(self: ChatModel, response: string) = + var (chats, messages) = self.parseChatResponse(response) + self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages, chats: chats, contacts: @[])) + +proc removeFiltersByChatId(self: ChatModel, chatId: string, filters: JsonNode) + +proc removeChatFilters(self: ChatModel, chatId: string) = + # TODO: this code should be handled by status-go / stimbus instead of the client + # Clients should not have to care about filters. For more info about filters: + # https://github.com/status-im/specs/blob/master/docs/stable/3-whisper-usage.md#keys-management + let filters = parseJson(status_chat.loadFilters(@[]))["result"] + + case self.channels[chatId].chatType + of ChatType.Public: + for filter in filters: + if filter["chatId"].getStr == chatId: + status_chat.removeFilters(chatId, filter["filterId"].getStr) + of ChatType.OneToOne, ChatType.Profile: + # Check if user does not belong to any active chat group + var inGroup = false + for channel in self.channels.values: + if channel.isActive and channel.id != chatId and channel.chatType == ChatType.PrivateGroupChat: + inGroup = true + break + if not inGroup: self.removeFiltersByChatId(chatId, filters) + of ChatType.PrivateGroupChat: + for member in self.channels[chatId].members: + # Check that any of the members are not in other active group chats, or that you don’t have a one-to-one open. + var hasConversation = false + for channel in self.channels.values: + if (channel.isActive and channel.chatType == ChatType.OneToOne and channel.id == member.id) or + (channel.isActive and channel.id != chatId and channel.chatType == ChatType.PrivateGroupChat and channel.isMember(member.id)): + hasConversation = true + break + if not hasConversation: self.removeFiltersByChatId(member.id, filters) + else: + error "Unknown chat type removed", chatId + +proc removeFiltersByChatId(self: ChatModel, chatId: string, filters: JsonNode) = + var partitionedTopic = "" + for filter in filters: + # Contact code filter should be removed + if filter["identity"].getStr == chatId and filter["chatId"].getStr.endsWith("-contact-code"): + status_chat.removeFilters(chatId, filter["filterId"].getStr) + + # Remove partitioned topic if no other user in an active group chat or one-to-one is from the + # same partitioned topic + if filter["identity"].getStr == chatId and filter["chatId"].getStr.startsWith("contact-discovery-"): + partitionedTopic = filter["topic"].getStr + var samePartitionedTopic = false + for f in filters.filterIt(it["topic"].getStr == partitionedTopic and it["filterId"].getStr != filter["filterId"].getStr): + let fIdentity = f["identity"].getStr; + if self.channels.hasKey(fIdentity) and self.channels[fIdentity].isActive: + samePartitionedTopic = true + break + if not samePartitionedTopic: + status_chat.removeFilters(chatId, filter["filterId"].getStr) + +proc hasChannel*(self: ChatModel, chatId: string): bool = + self.channels.hasKey(chatId) + +proc getActiveChannel*(self: ChatModel): string = + if (self.channels.len == 0): "" else: toSeq(self.channels.values)[self.channels.len - 1].id + +proc emitTopicAndJoin(self: ChatModel, chat: Chat) = + let filterResult = status_chat.loadFilters(@[status_chat.buildFilter(chat)]) + self.events.emit("channelJoined", ChannelArgs(chat: chat)) + +proc join*(self: ChatModel, chatId: string, chatType: ChatType, ensName: string = "", pubKey: string = "") = + if self.hasChannel(chatId): return + var chat = newChat(chatId, ChatType(chatType)) + self.channels[chat.id] = chat + status_chat.saveChat(chatId, chatType, color=chat.color, ensName=ensName, profile=pubKey) + self.emitTopicAndJoin(chat) + +proc createOneToOneChat*(self: ChatModel, publicKey: string, ensName: string = "") = + if self.hasChannel(publicKey): + self.emitTopicAndJoin(self.channels[publicKey]) + return + + var chat = newChat(publicKey, ChatType.OneToOne) + if ensName != "": + chat.name = ensName + chat.ensName = ensName + self.channels[chat.id] = chat + discard status_chat.createOneToOneChat(publicKey) + self.emitTopicAndJoin(chat) + +proc createPublicChat*(self: ChatModel, chatId: string) = + if self.hasChannel(chatId): return + var chat = newChat(chatId, ChatType.Public) + self.channels[chat.id] = chat + discard status_chat.createPublicChat(chatId) + self.emitTopicAndJoin(chat) + + +proc updateContacts*(self: ChatModel, contacts: seq[Profile]) = + for c in contacts: + self.contacts[c.id] = c + self.events.emit("chatUpdate", ChatUpdateArgs(contacts: contacts)) + +proc requestMissingCommunityInfos*(self: ChatModel) = + if (self.communitiesToFetch.len == 0): + return + for communityId in self.communitiesToFetch: + status_chat.requestCommunityInfo(communityId) + +proc init*(self: ChatModel, pubKey: string) = + self.publicKey = pubKey + + var contacts = getAddedContacts() + var chatList = status_chat.loadChats() + + let profileUpdatesChatIds = chatList.filter(c => c.chatType == ChatType.Profile).map(c => c.id) + + if chatList.filter(c => c.chatType == ChatType.Timeline).len == 0: + var timelineChannel = newChat(status_utils.getTimelineChatId(), ChatType.Timeline) + self.join(timelineChannel.id, timelineChannel.chatType) + chatList.add(timelineChannel) + + let timelineChatId = status_utils.getTimelineChatId(pubKey) + + if not profileUpdatesChatIds.contains(timelineChatId): + var profileUpdateChannel = newChat(timelineChatId, ChatType.Profile) + status_chat.saveChat(profileUpdateChannel.id, profileUpdateChannel.chatType, profile=pubKey) + chatList.add(profileUpdateChannel) + + # For profile updates and timeline, we have to make sure that for + # each added contact, a chat has been saved for the currently logged-in + # user. Users that will use a version of Status with timeline support for the + # first time, won't have any of those otherwise. + if profileUpdatesChatIds.filter(id => id != timelineChatId).len != contacts.len: + for contact in contacts: + if not profileUpdatesChatIds.contains(status_utils.getTimelineChatId(contact.address)): + let profileUpdatesChannel = newChat(status_utils.getTimelineChatId(contact.address), ChatType.Profile) + status_chat.saveChat(profileUpdatesChannel.id, profileUpdatesChannel.chatType, ensName=contact.ensName, profile=contact.address) + chatList.add(profileUpdatesChannel) + + var filters:seq[JsonNode] = @[] + for chat in chatList: + if self.hasChannel(chat.id): + continue + filters.add status_chat.buildFilter(chat) + self.channels[chat.id] = chat + self.events.emit("channelLoaded", ChannelArgs(chat: chat)) + + if filters.len == 0: return + + let filterResult = status_chat.loadFilters(filters) + + self.events.emit("chatsLoaded", ChatArgs(chats: chatList)) + + + self.events.once("mailserverAvailable") do(a: Args): + self.mailserverReady = true + self.requestMissingCommunityInfos() + + self.events.on("contactUpdate") do(a: Args): + var evArgs = ContactUpdateArgs(a) + self.updateContacts(evArgs.contacts) + +proc statusUpdates*(self: ChatModel) = + let statusUpdates = status_chat.statusUpdates() + self.events.emit("messagesLoaded", MsgsLoadedArgs(statusUpdates: statusUpdates)) + +proc leave*(self: ChatModel, chatId: string) = + self.removeChatFilters(chatId) + + if self.channels[chatId].chatType == ChatType.PrivateGroupChat: + let leaveGroupResponse = status_chat.leaveGroupChat(chatId) + self.emitUpdate(leaveGroupResponse) + + discard status_chat.deactivateChat(self.channels[chatId]) + + self.channels.del(chatId) + discard status_chat.clearChatHistory(chatId) + self.events.emit("channelLeft", ChatIdArg(chatId: chatId)) + +proc clearHistory*(self: ChatModel, chatId: string) = + discard status_chat.clearChatHistory(chatId) + let chat = self.channels[chatId] + self.events.emit("chatHistoryCleared", ChannelArgs(chat: chat)) + +proc setActiveChannel*(self: ChatModel, chatId: string) = + self.events.emit("activeChannelChanged", ChatIdArg(chatId: chatId)) + +proc processMessageUpdateAfterSend(self: ChatModel, response: string): (seq[Chat], seq[Message]) = + result = self.parseChatResponse(response) + var (chats, messages) = result + if chats.len == 0 and messages.len == 0: + self.events.emit("messageSendingFailed", Args()) + return + + self.events.emit("messageSendingSuccess", MessageSendingSuccess(message: messages[0], chat: chats[0])) + +proc sendMessage*(self: ChatModel, chatId: string, msg: string, replyTo: string = "", contentType: int = ContentType.Message.int, communityId: string = "") = + var response = status_chat.sendChatMessage(chatId, msg, replyTo, contentType, communityId) + discard self.processMessageUpdateAfterSend(response) + +proc editMessage*(self: ChatModel, messageId: string, msg: string) = + var response = status_chat.editMessage(messageId, msg) + discard self.processMessageUpdateAfterSend(response) + +proc sendImage*(self: ChatModel, chatId: string, image: string) = + var response = status_chat.sendImageMessage(chatId, image) + discard self.processMessageUpdateAfterSend(response) + +proc sendImages*(self: ChatModel, chatId: string, images: var seq[string]) = + var response = status_chat.sendImageMessages(chatId, images) + discard self.processMessageUpdateAfterSend(response) + +proc deleteMessageAndSend*(self: ChatModel, messageId: string) = + var response = status_chat.deleteMessageAndSend(messageId) + discard self.processMessageUpdateAfterSend(response) + +proc sendSticker*(self: ChatModel, chatId: string, replyTo: string, sticker: Sticker) = + var response = status_chat.sendStickerMessage(chatId, replyTo, sticker) + self.events.emit("stickerSent", StickerArgs(sticker: sticker, save: true)) + var (chats, messages) = self.parseChatResponse(response) + self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages, chats: chats, contacts: @[])) + self.events.emit("sendingMessage", MessageArgs(id: messages[0].id, channel: messages[0].chatId)) + +proc addEmojiReaction*(self: ChatModel, chatId: string, messageId: string, emojiId: int) = + let reactions = status_chat.addEmojiReaction(chatId, messageId, emojiId) + self.events.emit("reactionsLoaded", ReactionsLoadedArgs(reactions: reactions)) + +proc removeEmojiReaction*(self: ChatModel, emojiReactionId: string) = + let reactions = status_chat.removeEmojiReaction(emojiReactionId) + self.events.emit("reactionsLoaded", ReactionsLoadedArgs(reactions: reactions)) + +proc onMarkMessagesRead(self: ChatModel, response: string, chatId: string): JsonNode = + result = parseJson(response) + if self.channels.hasKey(chatId): + self.channels[chatId].unviewedMessagesCount = 0 + self.channels[chatId].mentionsCount = 0 + self.events.emit("channelUpdate", ChatUpdateArgs(messages: @[], chats: @[self.channels[chatId]], contacts: @[])) + +proc onAsyncMarkMessagesRead*(self: ChatModel, response: string) = + let parsedResponse = parseJson(response) + discard self.onMarkMessagesRead(parsedResponse{"response"}.getStr, parsedResponse{"chatId"}.getStr) + +proc markAllChannelMessagesRead*(self: ChatModel, chatId: string): JsonNode = + var response = status_chat.markAllRead(chatId) + return self.onMarkMessagesRead(response, chatId) + +proc markMessagesSeen*(self: ChatModel, chatId: string, messageIds: seq[string]): JsonNode = + var response = status_chat.markMessagesSeen(chatId, messageIds) + return self.onMarkMessagesRead(response, chatId) + +proc confirmJoiningGroup*(self: ChatModel, chatId: string) = + var response = status_chat.confirmJoiningGroup(chatId) + self.emitUpdate(response) + +proc renameGroup*(self: ChatModel, chatId: string, newName: string) = + var response = status_chat.renameGroup(chatId, newName) + self.emitUpdate(response) + +proc getUserName*(self: ChatModel, id: string, defaultUserName: string):string = + if(self.contacts.hasKey(id)): + return userNameOrAlias(self.contacts[id]) + else: + return defaultUserName + +proc processGroupChatCreation*(self: ChatModel, result: string) = + var response = parseJson(result) + var (chats, messages) = formatChatUpdate(response) + let chat = chats[0] + self.channels[chat.id] = chat + self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages, chats: chats, contacts: @[])) + self.events.emit("activeChannelChanged", ChatIdArg(chatId: chat.id)) + +proc createGroup*(self: ChatModel, groupName: string, pubKeys: seq[string]) = + var result = status_chat.createGroup(groupName, pubKeys) + self.processGroupChatCreation(result) + +proc createGroupChatFromInvitation*(self: ChatModel, groupName: string, chatID: string, adminPK: string) = + var result = status_chat.createGroupChatFromInvitation(groupName, chatID, adminPK) + self.processGroupChatCreation(result) + +proc addGroupMembers*(self: ChatModel, chatId: string, pubKeys: seq[string]) = + var response = status_chat.addGroupMembers(chatId, pubKeys) + self.emitUpdate(response) + +proc kickGroupMember*(self: ChatModel, chatId: string, pubKey: string) = + var response = status_chat.kickGroupMember(chatId, pubKey) + self.emitUpdate(response) + +proc makeAdmin*(self: ChatModel, chatId: string, pubKey: string) = + var response = status_chat.makeAdmin(chatId, pubKey) + self.emitUpdate(response) + +proc resendMessage*(self: ChatModel, messageId: string) = + discard status_chat.reSendChatMessage(messageId) + +proc muteChat*(self: ChatModel, chat: Chat) = + discard status_chat.muteChat(chat.id) + self.events.emit("chatUpdate", ChatUpdateArgs(messages: @[], chats: @[chat], contacts: @[])) + +proc unmuteChat*(self: ChatModel, chat: Chat) = + discard status_chat.unmuteChat(chat.id) + self.events.emit("chatUpdate", ChatUpdateArgs(messages: @[], chats: @[chat], contacts: @[])) + +proc processUpdateForTransaction*(self: ChatModel, messageId: string, response: string) = + var (chats, messages) = self.processMessageUpdateAfterSend(response) + self.events.emit("messageDeleted", MessageArgs(id: messageId, channel: chats[0].id)) + +proc acceptRequestAddressForTransaction*(self: ChatModel, messageId: string, address: string) = + let response = status_chat_commands.acceptRequestAddressForTransaction(messageId, address) + self.processUpdateForTransaction(messageId, response) + +proc declineRequestAddressForTransaction*(self: ChatModel, messageId: string) = + let response = status_chat_commands.declineRequestAddressForTransaction(messageId) + self.processUpdateForTransaction(messageId, response) + +proc declineRequestTransaction*(self: ChatModel, messageId: string) = + let response = status_chat_commands.declineRequestTransaction(messageId) + self.processUpdateForTransaction(messageId, response) + +proc requestAddressForTransaction*(self: ChatModel, chatId: string, fromAddress: string, amount: string, tokenAddress: string) = + let address = if (tokenAddress == ZERO_ADDRESS): "" else: tokenAddress + let response = status_chat_commands.requestAddressForTransaction(chatId, fromAddress, amount, address) + discard self.processMessageUpdateAfterSend(response) + +proc acceptRequestTransaction*(self: ChatModel, transactionHash: string, messageId: string, signature: string) = + let response = status_chat_commands.acceptRequestTransaction(transactionHash, messageId, signature) + discard self.processMessageUpdateAfterSend(response) + +proc requestTransaction*(self: ChatModel, chatId: string, fromAddress: string, amount: string, tokenAddress: string) = + let address = if (tokenAddress == ZERO_ADDRESS): "" else: tokenAddress + let response = status_chat_commands.requestTransaction(chatId, fromAddress, amount, address) + discard self.processMessageUpdateAfterSend(response) + +proc getAllComunities*(self: ChatModel): seq[Community] = + result = status_chat.getAllComunities() + +proc getJoinedComunities*(self: ChatModel): seq[Community] = + result = status_chat.getJoinedComunities() + +proc createCommunity*(self: ChatModel, name: string, description: string, access: int, ensOnly: bool, color: string, imageUrl: string, aX: int, aY: int, bX: int, bY: int): Community = + result = status_chat.createCommunity(name, description, access, ensOnly, color, imageUrl, aX, aY, bX, bY) + +proc editCommunity*(self: ChatModel, id: string, name: string, description: string, access: int, ensOnly: bool, color: string, imageUrl: string, aX: int, aY: int, bX: int, bY: int): Community = + result = status_chat.editCommunity(id, name, description, access, ensOnly, color, imageUrl, aX, aY, bX, bY) + +proc createCommunityChannel*(self: ChatModel, communityId: string, name: string, description: string): Chat = + result = status_chat.createCommunityChannel(communityId, name, description) + +proc editCommunityChannel*(self: ChatModel, communityId: string, channelId: string, name: string, description: string, categoryId: string): Chat = + result = status_chat.editCommunityChannel(communityId, channelId, name, description, categoryId) + +proc deleteCommunityChat*(self: ChatModel, communityId: string, channelId: string) = + status_chat.deleteCommunityChat(communityId, channelId) + +proc reorderCommunityCategories*(self: ChatModel, communityId: string, categoryId: string, position: int) = + status_chat.reorderCommunityCategories(communityId, categoryId, position) + +proc createCommunityCategory*(self: ChatModel, communityId: string, name: string, channels: seq[string]): CommunityCategory = + result = status_chat.createCommunityCategory(communityId, name, channels) + +proc editCommunityCategory*(self: ChatModel, communityId: string, categoryId: string, name: string, channels: seq[string]) = + status_chat.editCommunityCategory(communityId, categoryId, name, channels) + +proc deleteCommunityCategory*(self: ChatModel, communityId: string, categoryId: string) = + status_chat.deleteCommunityCategory(communityId, categoryId) + +proc reorderCommunityChannel*(self: ChatModel, communityId: string, categoryId: string, chatId: string, position: int) = + status_chat.reorderCommunityChat(communityId, categoryId, chatId, position) + +proc joinCommunity*(self: ChatModel, communityId: string) = + status_chat.joinCommunity(communityId) + +proc requestCommunityInfo*(self: ChatModel, communityId: string) = + if (not self.mailserverReady): + self.communitiesToFetch.add(communityId) + self.communitiesToFetch = self.communitiesToFetch.deduplicate() + return + status_chat.requestCommunityInfo(communityId) + +proc leaveCommunity*(self: ChatModel, communityId: string) = + status_chat.leaveCommunity(communityId) + +proc inviteUserToCommunity*(self: ChatModel, communityId: string, pubKey: string) = + status_chat.inviteUsersToCommunity(communityId, @[pubKey]) + +proc inviteUsersToCommunity*(self: ChatModel, communityId: string, pubKeys: seq[string]) = + status_chat.inviteUsersToCommunity(communityId, pubKeys) + +proc removeUserFromCommunity*(self: ChatModel, communityId: string, pubKey: string) = + status_chat.removeUserFromCommunity(communityId, pubKey) + +proc banUserFromCommunity*(self: ChatModel, pubKey: string, communityId: string): string = + return status_chat.banUserFromCommunity(pubKey, communityId) + +proc exportCommunity*(self: ChatModel, communityId: string): string = + result = status_chat.exportCommunity(communityId) + +proc importCommunity*(self: ChatModel, communityKey: string): string = + result = status_chat.importCommunity(communityKey) + +proc requestToJoinCommunity*(self: ChatModel, communityKey: string, ensName: string): seq[CommunityMembershipRequest] = + status_chat.requestToJoinCommunity(communityKey, ensName) + +proc acceptRequestToJoinCommunity*(self: ChatModel, requestId: string) = + status_chat.acceptRequestToJoinCommunity(requestId) + +proc declineRequestToJoinCommunity*(self: ChatModel, requestId: string) = + status_chat.declineRequestToJoinCommunity(requestId) + +proc pendingRequestsToJoinForCommunity*(self: ChatModel, communityKey: string): seq[CommunityMembershipRequest] = + result = status_chat.pendingRequestsToJoinForCommunity(communityKey) + +proc setCommunityMuted*(self: ChatModel, communityId: string, muted: bool) = + status_chat.setCommunityMuted(communityId, muted) + +proc myPendingRequestsToJoin*(self: ChatModel): seq[CommunityMembershipRequest] = + result = status_chat.myPendingRequestsToJoin() + +proc setPinMessage*(self: ChatModel, messageId: string, chatId: string, pinned: bool) = + status_chat.setPinMessage(messageId, chatId, pinned) + +proc activityCenterNotifications*(self: ChatModel, initialLoad: bool = true) = + # Notifications were already loaded, since cursor will + # be nil/empty if there are no more notifs + if(not initialLoad and self.activityCenterCursor == ""): return + + let activityCenterNotificationsTuple = status_chat.activityCenterNotification(self.activityCenterCursor) + self.activityCenterCursor = activityCenterNotificationsTuple[0]; + + self.events.emit("activityCenterNotificationsLoaded", ActivityCenterNotificationsArgs(activityCenterNotifications: activityCenterNotificationsTuple[1])) + +proc activityCenterNotifications*(self: ChatModel, cursor: string = "", activityCenterNotifications: seq[ActivityCenterNotification]) = + self.activityCenterCursor = cursor + + self.events.emit("activityCenterNotificationsLoaded", ActivityCenterNotificationsArgs(activityCenterNotifications: activityCenterNotifications)) + +proc markAllActivityCenterNotificationsRead*(self: ChatModel): string = + try: + status_chat.markAllActivityCenterNotificationsRead() + except Exception as e: + error "Error marking all as read", msg = e.msg + result = e.msg + + # This proc should accept ActivityCenterNotificationType in order to clear all notifications + # per type, that's why we have this part here. If we add all types to notificationsType that + # means that we need to clear all notifications for all types. + var types : seq[ActivityCenterNotificationType] + for t in ActivityCenterNotificationType: + types.add(t) + + self.events.emit("markNotificationsAsRead", MarkAsReadNotificationProperties(notificationTypes: types)) + +proc markActivityCenterNotificationRead*(self: ChatModel, notificationId: string, +markAsReadProps: MarkAsReadNotificationProperties): string = + try: + status_chat.markActivityCenterNotificationsRead(@[notificationId]) + except Exception as e: + error "Error marking as read", msg = e.msg + result = e.msg + + self.events.emit("markNotificationsAsRead", markAsReadProps) + +proc acceptActivityCenterNotifications*(self: ChatModel, ids: seq[string]): string = + try: + let response = status_chat.acceptActivityCenterNotifications(ids) + + let (chats, messages) = self.parseChatResponse(response) + self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages, chats: chats)) + + except Exception as e: + error "Error marking as accepted", msg = e.msg + result = e.msg + +proc dismissActivityCenterNotifications*(self: ChatModel, ids: seq[string]): string = + try: + discard status_chat.dismissActivityCenterNotifications(ids) + except Exception as e: + error "Error marking as dismissed", msg = e.msg + result = e.msg + +proc unreadActivityCenterNotificationsCount*(self: ChatModel): int = + status_chat.unreadActivityCenterNotificationsCount() + +proc getLinkPreviewData*(link: string, success: var bool): JsonNode = + result = status_chat.getLinkPreviewData(link, success) + +proc getCommunityIdForChat*(self: ChatModel, chatId: string): string = + if (not self.hasChannel(chatId)): + return "" + return self.channels[chatId].communityId + +proc onAsyncSearchMessages*(self: ChatModel, response: string) = + let responseObj = response.parseJson + if (responseObj.kind != JObject): + info "search messages response is not an json object" + return + + var chatId: string + discard responseObj.getProp("chatId", chatId) + + var messagesObj: JsonNode + if (not responseObj.getProp("messages", messagesObj)): + info "search messages response doesn't contain messages property" + return + + var messagesArray: JsonNode + if (not messagesObj.getProp("messages", messagesArray)): + info "search messages response doesn't contain messages array" + return + + var messages: seq[Message] = @[] + if (messagesArray.kind == JArray): + for jsonMsg in messagesArray: + messages.add(jsonMsg.toMessage()) + + self.events.emit("searchMessagesLoaded", MsgsLoadedArgs(chatId: chatId, messages: messages)) + +proc onLoadMoreMessagesForChannel*(self: ChatModel, response: string) = + let responseObj = response.parseJson + if (responseObj.kind != JObject): + info "load more messages response is not an json object" + + # notify view + self.events.emit("messagesLoaded", MsgsLoadedArgs()) + self.events.emit("reactionsLoaded", ReactionsLoadedArgs()) + self.events.emit("pinnedMessagesLoaded", MsgsLoadedArgs()) + return + + var chatId: string + discard responseObj.getProp("chatId", chatId) + + # handling chat messages + var chatMessagesObj: JsonNode + var chatCursor: string + discard responseObj.getProp("messages", chatMessagesObj) + discard responseObj.getProp("messagesCursor", chatCursor) + + self.msgCursor[chatId] = chatCursor + + var messages: seq[Message] = @[] + if (chatMessagesObj.kind == JArray): + for jsonMsg in chatMessagesObj: + messages.add(jsonMsg.toMessage()) + + if messages.len > 0: + let lastMsgIndex = messages.len - 1 + let ts = times.convert(Milliseconds, Seconds, messages[lastMsgIndex].whisperTimestamp.parseInt()) + self.lastMessageTimestamps[chatId] = ts + + # handling reactions + var reactionsObj: JsonNode + var reactionsCursor: string + discard responseObj.getProp("reactions", reactionsObj) + discard responseObj.getProp("reactionsCursor", reactionsCursor) + + self.emojiCursor[chatId] = reactionsCursor; + + var reactions: seq[Reaction] = @[] + if (reactionsObj.kind == JArray): + for jsonMsg in reactionsObj: + reactions.add(jsonMsg.toReaction) + + # handling pinned messages + var pinnedMsgObj: JsonNode + var pinnedMsgCursor: string + discard responseObj.getProp("pinnedMessages", pinnedMsgObj) + discard responseObj.getProp("pinnedMessagesCursor", pinnedMsgCursor) + + self.pinnedMsgCursor[chatId] = pinnedMsgCursor + + var pinnedMessages: seq[Message] = @[] + if (pinnedMsgObj.kind == JArray): + for jsonMsg in pinnedMsgObj: + var msgObj: JsonNode + if(jsonMsg.getProp("message", msgObj)): + var msg: Message + msg = msgObj.toMessage() + discard jsonMsg.getProp("pinnedBy", msg.pinnedBy) + pinnedMessages.add(msg) + + # notify view + self.events.emit("messagesLoaded", MsgsLoadedArgs(chatId: chatId, messages: messages)) + self.events.emit("reactionsLoaded", ReactionsLoadedArgs(reactions: reactions)) + self.events.emit("pinnedMessagesLoaded", MsgsLoadedArgs(chatId: chatId, messages: pinnedMessages)) + +proc userNameOrAlias*(self: ChatModel, pubKey: string, + prettyForm: bool = false): string = + ## Returns ens name or alias, in case if prettyForm is true and ens name + ## ends with ".stateofus.eth" that part will be removed. + var alias: string + if self.contacts.hasKey(pubKey): + alias = ens.userNameOrAlias(self.contacts[pubKey]) + else: + alias = generateAlias(pubKey) + + if (prettyForm and alias.endsWith(".stateofus.eth")): + alias = alias[0 .. ^15] + + return alias + +proc chatName*(self: ChatModel, chatItem: Chat): string = + if (not chatItem.chatType.isOneToOne): + return chatItem.name + + if (self.contacts.hasKey(chatItem.id) and + self.contacts[chatItem.id].hasNickname()): + return self.contacts[chatItem.id].localNickname + + if chatItem.ensName != "": + return "@" & userName(chatItem.ensName).userName(true) + + return self.userNameOrAlias(chatItem.id) + +proc isMessageCursorSet*(self: ChatModel, channelId: string): bool = + self.msgCursor.hasKey(channelId) + +proc getCurrentMessageCursor*(self: ChatModel, channelId: string): string = + if(not self.msgCursor.hasKey(channelId)): + self.msgCursor[channelId] = "" + + return self.msgCursor[channelId] + +proc isEmojiCursorSet*(self: ChatModel, channelId: string): bool = + self.emojiCursor.hasKey(channelId) + +proc getCurrentEmojiCursor*(self: ChatModel, channelId: string): string = + if(not self.emojiCursor.hasKey(channelId)): + self.emojiCursor[channelId] = "" + + return self.emojiCursor[channelId] + +proc isPinnedMessageCursorSet*(self: ChatModel, channelId: string): bool = + self.pinnedMsgCursor.hasKey(channelId) + +proc getCurrentPinnedMessageCursor*(self: ChatModel, channelId: string): string = + if(not self.pinnedMsgCursor.hasKey(channelId)): + self.pinnedMsgCursor[channelId] = "" + + return self.pinnedMsgCursor[channelId] \ No newline at end of file diff --git a/status/chat/chat.nim b/status/chat/chat.nim new file mode 100644 index 0000000..693447d --- /dev/null +++ b/status/chat/chat.nim @@ -0,0 +1,76 @@ +import ../types/[chat, community] + +export chat, community + +proc findIndexById*(self: seq[Chat], id: string): int = + result = -1 + var idx = -1 + for item in self: + inc idx + if(item.id == id): + result = idx + break + +proc findIndexById*(self: seq[Community], id: string): int = + result = -1 + var idx = -1 + for item in self: + inc idx + if(item.id == id): + result = idx + break + +proc findIndexById*(self: seq[CommunityMembershipRequest], id: string): int = + result = -1 + var idx = -1 + for item in self: + inc idx + if(item.id == id): + result = idx + break + +proc findIndexById*(self: seq[CommunityCategory], id: string): int = + result = -1 + var idx = -1 + for item in self: + inc idx + if(item.id == id): + result = idx + break + +proc isMember*(self: Chat, pubKey: string): bool = + for member in self.members: + if member.id == pubKey: + return member.joined + return false + +proc isMemberButNotJoined*(self: Chat, pubKey: string): bool = + for member in self.members: + if member.id == pubKey: + return not member.joined + return false + +proc contains*(self: Chat, pubKey: string): bool = + for member in self.members: + if member.id == pubKey: return true + return false + +proc isAdmin*(self: Chat, pubKey: string): bool = + for member in self.members: + if member.id == pubKey: + return member.joined and member.admin + return false + +proc recalculateUnviewedMessages*(community: var Community) = + var total = 0 + for chat in community.chats: + total += chat.unviewedMessagesCount + + community.unviewedMessagesCount = total + +proc recalculateMentions*(community: var Community) = + var total = 0 + for chat in community.chats: + total += chat.unviewedMentionsCount + + community.unviewedMentionsCount = total diff --git a/status/chat/stickers.nim b/status/chat/stickers.nim new file mode 100644 index 0000000..30e6fe6 --- /dev/null +++ b/status/chat/stickers.nim @@ -0,0 +1,9 @@ +import chronicles +import ../stickers as status_stickers + +logScope: + topics = "sticker-decoding" + +# TODO: this is for testing purposes, the correct function should decode the hash +proc decodeContentHash*(value: string): string = + status_stickers.decodeContentHash(value) diff --git a/status/chat/utils.nim b/status/chat/utils.nim new file mode 100644 index 0000000..e7d941f --- /dev/null +++ b/status/chat/utils.nim @@ -0,0 +1,13 @@ +import json +import ../types/[message, chat] + +proc formatChatUpdate*(response: JsonNode): (seq[Chat], seq[Message]) = + var chats: seq[Chat] = @[] + var messages: seq[Message] = @[] + if response["result"]{"messages"} != nil: + for jsonMsg in response["result"]["messages"]: + messages.add(jsonMsg.toMessage()) + if response["result"]{"chats"} != nil: + for jsonChat in response["result"]["chats"]: + chats.add(jsonChat.toChat) + result = (chats, messages) \ No newline at end of file diff --git a/status/constants.nim b/status/constants.nim new file mode 100644 index 0000000..1a58ccd --- /dev/null +++ b/status/constants.nim @@ -0,0 +1,10 @@ +import libstatus/accounts/constants + +export DATADIR +export STATUSGODIR +export KEYSTOREDIR +export TMPDIR +export LOGDIR + +const APP_UPDATES_ENS* = "desktop.status.eth" +const CHECK_VERSION_TIMEOUT_MS* = 5000 diff --git a/status/contacts.nim b/status/contacts.nim new file mode 100644 index 0000000..ffb3690 --- /dev/null +++ b/status/contacts.nim @@ -0,0 +1,151 @@ +import json, sequtils, sugar, chronicles +import libstatus/contacts as status_contacts +import libstatus/accounts as status_accounts +import libstatus/chat as status_chat +import profile/profile +import ../eventemitter + +const DELETE_CONTACT* = "__deleteThisContact__" + +type + ContactModel* = ref object + events*: EventEmitter + +type + ContactUpdateArgs* = ref object of Args + contacts*: seq[Profile] + +proc newContactModel*(events: EventEmitter): ContactModel = + result = ContactModel() + result.events = events + +proc getContactByID*(self: ContactModel, id: string): Profile = + let response = status_contacts.getContactByID(id) + # TODO: change to options + let responseResult = parseJSON($response)["result"] + if responseResult == nil or responseResult.kind == JNull: + result = nil + else: + result = toProfileModel(parseJSON($response)["result"]) + +proc blockContact*(self: ContactModel, id: string): string = + var contact = self.getContactByID(id) + contact.systemTags.add(contactBlocked) + discard status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, contact.identityImage.thumbnail, contact.systemTags, contact.localNickname) + self.events.emit("contactBlocked", Args()) + +proc unblockContact*(self: ContactModel, id: string): string = + var contact = self.getContactByID(id) + contact.systemTags.delete(contact.systemTags.find(contactBlocked)) + discard status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, contact.identityImage.thumbnail, contact.systemTags, contact.localNickname) + self.events.emit("contactUnblocked", Args()) + +proc getAllContacts*(): seq[Profile] = + result = map(status_contacts.getContacts().getElems(), proc(x: JsonNode): Profile = x.toProfileModel()) + +proc getAddedContacts*(): seq[Profile] = + result = getAllContacts().filter(c => c.systemTags.contains(contactAdded)) + +proc getContacts*(self: ContactModel): seq[Profile] = + result = getAllContacts() + self.events.emit("contactUpdate", ContactUpdateArgs(contacts: result)) + +proc getOrCreateContact*(self: ContactModel, id: string): Profile = + result = self.getContactByID(id) + if result == nil: + let alias = status_accounts.generateAlias(id) + result = Profile( + id: id, + username: alias, + localNickname: "", + identicon: status_accounts.generateIdenticon(id), + alias: alias, + ensName: "", + ensVerified: false, + appearance: 0, + systemTags: @[] + ) + +proc setNickName*(self: ContactModel, id: string, localNickname: string): string = + var contact = self.getOrCreateContact(id) + let nickname = + if (localNickname == ""): + contact.localNickname + elif (localNickname == DELETE_CONTACT): + "" + else: + localNickname + + var thumbnail = "" + if contact.identityImage != nil: + thumbnail = contact.identityImage.thumbnail + result = status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, thumbnail, contact.systemTags, nickname) + self.events.emit("contactAdded", Args()) + discard requestContactUpdate(contact.id) + + +proc addContact*(self: ContactModel, id: string): string = + var contact = self.getOrCreateContact(id) + + let updating = contact.systemTags.contains(contactAdded) + if not updating: + contact.systemTags.add(contactAdded) + discard status_chat.createProfileChat(contact.id) + else: + let index = contact.systemTags.find(contactBlocked) + if (index > -1): + contact.systemTags.delete(index) + + var thumbnail = "" + if contact.identityImage != nil: + thumbnail = contact.identityImage.thumbnail + + result = status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, thumbnail, contact.systemTags, contact.localNickname) + self.events.emit("contactAdded", Args()) + discard requestContactUpdate(contact.id) + + if updating: + let profile = Profile( + id: contact.id, + username: contact.alias, + identicon: contact.identicon, + alias: contact.alias, + ensName: contact.ensName, + ensVerified: contact.ensVerified, + appearance: 0, + systemTags: contact.systemTags, + localNickname: contact.localNickname + ) + self.events.emit("contactUpdate", ContactUpdateArgs(contacts: @[profile])) + +proc removeContact*(self: ContactModel, id: string) = + let contact = self.getContactByID(id) + contact.systemTags.delete(contact.systemTags.find(contactAdded)) + + var thumbnail = "" + if contact.identityImage != nil: + thumbnail = contact.identityImage.thumbnail + + discard status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, thumbnail, contact.systemTags, contact.localNickname) + self.events.emit("contactRemoved", Args()) + +proc isAdded*(self: ContactModel, id: string): bool = + var contact = self.getContactByID(id) + if contact.isNil: return false + contact.systemTags.contains(contactAdded) + +proc contactRequestReceived*(self: ContactModel, id: string): bool = + var contact = self.getContactByID(id) + if contact.isNil: return false + contact.systemTags.contains(contactRequest) + +proc rejectContactRequest*(self: ContactModel, id: string) = + let contact = self.getContactByID(id) + contact.systemTags.delete(contact.systemTags.find(contactRequest)) + + var thumbnail = "" + if contact.identityImage != nil: + thumbnail = contact.identityImage.thumbnail + + discard status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, thumbnail, contact.systemTags, contact.localNickname) + self.events.emit("contactRemoved", Args()) diff --git a/status/devices.nim b/status/devices.nim new file mode 100644 index 0000000..329c611 --- /dev/null +++ b/status/devices.nim @@ -0,0 +1,43 @@ +import system +import libstatus/settings +import types/[setting, installation] +import libstatus/installations +import json + +proc setDeviceName*(name: string) = + discard setInstallationMetadata(getSetting[string](Setting.InstallationId, "", true), name, hostOs) + +proc isDeviceSetup*():bool = + let installationId = getSetting[string](Setting.InstallationId, "", true) + let responseResult = getOurInstallations() + if responseResult.kind == JNull: + return false + for installation in responseResult: + if installation["id"].getStr == installationId: + return installation["metadata"].kind != JNull + result = false + +proc syncAllDevices*() = + let preferredUsername = getSetting[string](Setting.PreferredUsername, "") + discard syncDevices(preferredUsername) + +proc advertise*() = + discard sendPairInstallation() + +proc getAllDevices*():seq[Installation] = + let responseResult = getOurInstallations() + let installationId = getSetting[string](Setting.InstallationId, "", true) + result = @[] + if responseResult.kind != JNull: + for inst in responseResult: + var device = inst.toInstallation + if device.installationId == installationId: + device.isUserDevice = true + result.add(device) + +proc enable*(installationId: string) = + # TODO handle errors + discard enableInstallation(installationId) + +proc disable*(installationId: string) = + discard disableInstallation(installationId) diff --git a/status/ens.nim b/status/ens.nim new file mode 100644 index 0000000..b3c1c43 --- /dev/null +++ b/status/ens.nim @@ -0,0 +1,387 @@ +import sequtils +import strutils +import profile/profile +import nimcrypto +import json +import json_serialization +import tables +import strformat +import libstatus/core +import ./types/[transaction, setting, rpc_response] +import utils +import libstatus/wallet +import stew/byteutils +import unicode +import transactions +import algorithm +import web3/[ethtypes, conversions], stew/byteutils, stint +import libstatus/eth/contracts +import libstatus/eth/transactions as eth_transactions +import chronicles, libp2p/[multihash, multicodec, cid] + +import ./settings as status_settings +import ./wallet as status_wallet + +const domain* = ".stateofus.eth" + +proc userName*(ensName: string, removeSuffix: bool = false): string = + if ensName != "" and ensName.endsWith(domain): + if removeSuffix: + result = ensName.split(".")[0] + else: + result = ensName + else: + if ensName.endsWith(".eth") and removeSuffix: + return ensName.split(".")[0] + result = ensName + +proc addDomain*(username: string): string = + if username.endsWith(".eth"): + return username + else: + return username & domain + +proc hasNickname*(contact: Profile): bool = contact.localNickname != "" + +proc userNameOrAlias*(contact: Profile, removeSuffix: bool = false): string = + if(contact.ensName != "" and contact.ensVerified): + result = "@" & userName(contact.ensName, removeSuffix) + elif(contact.localNickname != ""): + result = contact.localNickname + else: + result = contact.alias + +proc label*(username:string): string = + let name = username.toLower() + var node:array[32, byte] = keccak_256.digest(username).data + result = "0x" & node.toHex() + +proc namehash*(ensName:string): string = + let name = ensName.toLower() + var node:array[32, byte] + + node.fill(0) + var parts = name.split(".") + for i in countdown(parts.len - 1,0): + let elem = keccak_256.digest(parts[i]).data + var concatArrays: array[64, byte] + concatArrays[0..31] = node + concatArrays[32..63] = elem + node = keccak_256.digest(concatArrays).data + + result = "0x" & node.toHex() + +const registry* = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" +const resolver_signature = "0x0178b8bf" +proc resolver*(usernameHash: string): string = + let payload = %* [{ + "to": registry, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{resolver_signature}{userNameHash}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + # TODO: error handling + var resolverAddr = response.parseJson["result"].getStr + resolverAddr.removePrefix("0x000000000000000000000000") + result = "0x" & resolverAddr + +const owner_signature = "0x02571be3" # owner(bytes32 node) +proc owner*(username: string): string = + var userNameHash = namehash(addDomain(username)) + userNameHash.removePrefix("0x") + let payload = %* [{ + "to": registry, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{owner_signature}{userNameHash}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + # TODO: error handling + let ownerAddr = response.parseJson["result"].getStr; + if ownerAddr == "0x0000000000000000000000000000000000000000000000000000000000000000": + return "" + result = "0x" & ownerAddr.substr(26) + +const pubkey_signature = "0xc8690233" # pubkey(bytes32 node) +proc pubkey*(username: string): string = + var userNameHash = namehash(addDomain(username)) + userNameHash.removePrefix("0x") + let ensResolver = resolver(userNameHash) + let payload = %* [{ + "to": ensResolver, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{pubkey_signature}{userNameHash}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + # TODO: error handling + var pubkey = response.parseJson["result"].getStr + if pubkey == "0x" or pubkey == "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000": + result = "" + else: + pubkey.removePrefix("0x") + result = "0x04" & pubkey + +const address_signature = "0x3b3b57de" # addr(bytes32 node) +proc address*(username: string): string = + var userNameHash = namehash(addDomain(username)) + userNameHash.removePrefix("0x") + let ensResolver = resolver(userNameHash) + let payload = %* [{ + "to": ensResolver, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{address_signature}{userNameHash}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + # TODO: error handling + let address = response.parseJson["result"].getStr; + if address == "0x0000000000000000000000000000000000000000000000000000000000000000": + return "" + result = "0x" & address.substr(26) + +const contenthash_signature = "0xbc1c58d1" # contenthash(bytes32) +proc contenthash*(ensAddr: string): string = + var ensHash = namehash(ensAddr) + ensHash.removePrefix("0x") + let ensResolver = resolver(ensHash) + let payload = %* [{ + "to": ensResolver, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{contenthash_signature}{ensHash}" + }, "latest"] + + let response = callPrivateRPC("eth_call", payload) + let bytesResponse = response.parseJson["result"].getStr; + if bytesResponse == "0x": + return "" + + let size = fromHex(Stuint[256], bytesResponse[66..129]).truncate(int) + result = bytesResponse[130..129+size*2] + + +proc getPrice*(): Stuint[256] = + let + contract = contracts.getContract("ens-usernames") + payload = %* [{ + "to": $contract.address, + "data": contract.methods["getPrice"].encodeAbi() + }, "latest"] + + let responseStr = callPrivateRPC("eth_call", payload) + let response = Json.decode(responseStr, RpcResponse) + if not response.error.isNil: + raise newException(RpcException, "Error getting ens username price: " & response.error.message) + if response.result == "0x": + raise newException(RpcException, "Error getting ens username price: 0x") + result = fromHex(Stuint[256], response.result) + +proc releaseEstimateGas*(username: string, address: string, success: var bool): int = + let + label = fromHex(FixedBytes[32], label(username)) + ensUsernamesContract = contracts.getContract("ens-usernames") + release = Release(label: label) + + var tx = transactions.buildTokenTransaction(parseAddress(address), ensUsernamesContract.address, "", "") + try: + let response = ensUsernamesContract.methods["release"].estimateGas(tx, release, success) + if success: + result = fromHex[int](response) + except RpcException as e: + error "Could not estimate gas for ens release", err=e.msg + +proc release*(username: string, address: string, gas, gasPrice, password: string, success: var bool): string = + let + label = fromHex(FixedBytes[32], label(username)) + ensUsernamesContract = contracts.getContract("ens-usernames") + release = Release(label: label) + + var tx = transactions.buildTokenTransaction(parseAddress(address), ensUsernamesContract.address, "", "") + try: + result = ensUsernamesContract.methods["release"].send(tx, release, password, success) + if success: + trackPendingTransaction(result, address, $ensUsernamesContract.address, PendingTransactionType.ReleaseENS, username) + except RpcException as e: + error "Could not estimate gas for ens release", err=e.msg + +proc getExpirationTime*(username: string, success: var bool): int = + let + label = fromHex(FixedBytes[32], label(username)) + expTime = ExpirationTime(label: label) + ensUsernamesContract = contracts.getContract("ens-usernames") + + var tx = transactions.buildTransaction(parseAddress("0x0000000000000000000000000000000000000000"), 0.u256) + tx.to = ensUsernamesContract.address.some + tx.data = ensUsernamesContract.methods["getExpirationTime"].encodeAbi(expTime) + var response = "" + try: + response = eth_transactions.call(tx).result + success = true + except RpcException as e: + success = false + error "Error obtaining expiration time", err=e.msg + + if success: + result = fromHex[int](response) + +proc extractCoordinates*(pubkey: string):tuple[x: string, y:string] = + result = ("0x" & pubkey[4..67], "0x" & pubkey[68..131]) + +proc registerUsernameEstimateGas*(username: string, address: string, pubKey: string, success: var bool): int = + let + label = fromHex(FixedBytes[32], label(username)) + coordinates = extractCoordinates(pubkey) + x = fromHex(FixedBytes[32], coordinates.x) + y = fromHex(FixedBytes[32], coordinates.y) + ensUsernamesContract = contracts.getContract("ens-usernames") + sntContract = contracts.getSntContract() + price = getPrice() + + let + register = Register(label: label, account: parseAddress(address), x: x, y: y) + registerAbiEncoded = ensUsernamesContract.methods["register"].encodeAbi(register) + approveAndCallObj = ApproveAndCall[132](to: ensUsernamesContract.address, value: price, data: DynamicBytes[132].fromHex(registerAbiEncoded)) + approveAndCallAbiEncoded = sntContract.methods["approveAndCall"].encodeAbi(approveAndCallObj) + + var tx = transactions.buildTokenTransaction(parseAddress(address), sntContract.address, "", "") + + let response = sntContract.methods["approveAndCall"].estimateGas(tx, approveAndCallObj, success) + if success: + result = fromHex[int](response) + +proc registerUsername*(username, pubKey, address, gas, gasPrice, password: string, success: var bool): string = + let + label = fromHex(FixedBytes[32], label(username)) + coordinates = extractCoordinates(pubkey) + x = fromHex(FixedBytes[32], coordinates.x) + y = fromHex(FixedBytes[32], coordinates.y) + ensUsernamesContract = contracts.getContract("ens-usernames") + sntContract = contracts.getSntContract() + price = getPrice() + + let + register = Register(label: label, account: parseAddress(address), x: x, y: y) + registerAbiEncoded = ensUsernamesContract.methods["register"].encodeAbi(register) + approveAndCallObj = ApproveAndCall[132](to: ensUsernamesContract.address, value: price, data: DynamicBytes[132].fromHex(registerAbiEncoded)) + + var tx = transactions.buildTokenTransaction(parseAddress(address), sntContract.address, gas, gasPrice) + + result = sntContract.methods["approveAndCall"].send(tx, approveAndCallObj, password, success) + if success: + trackPendingTransaction(result, address, $sntContract.address, PendingTransactionType.RegisterENS, username & domain) + +proc setPubKeyEstimateGas*(username: string, address: string, pubKey: string, success: var bool): int = + var hash = namehash(username) + hash.removePrefix("0x") + + let + label = fromHex(FixedBytes[32], "0x" & hash) + x = fromHex(FixedBytes[32], "0x" & pubkey[4..67]) + y = fromHex(FixedBytes[32], "0x" & pubkey[68..131]) + resolverContract = contracts.getContract("ens-resolver") + setPubkey = SetPubkey(label: label, x: x, y: y) + resolverAddress = resolver(hash) + + var tx = transactions.buildTokenTransaction(parseAddress(address), parseAddress(resolverAddress), "", "") + + try: + let response = resolverContract.methods["setPubkey"].estimateGas(tx, setPubkey, success) + if success: + result = fromHex[int](response) + except RpcException as e: + raise + +proc setPubKey*(username, pubKey, address, gas, gasPrice, password: string, success: var bool): string = + var hash = namehash(username) + hash.removePrefix("0x") + + let + label = fromHex(FixedBytes[32], "0x" & hash) + x = fromHex(FixedBytes[32], "0x" & pubkey[4..67]) + y = fromHex(FixedBytes[32], "0x" & pubkey[68..131]) + resolverContract = contracts.getContract("ens-resolver") + setPubkey = SetPubkey(label: label, x: x, y: y) + resolverAddress = resolver(hash) + + var tx = transactions.buildTokenTransaction(parseAddress(address), parseAddress(resolverAddress), gas, gasPrice) + + try: + result = resolverContract.methods["setPubkey"].send(tx, setPubkey, password, success) + if success: + trackPendingTransaction(result, $address, resolverAddress, PendingTransactionType.SetPubKey, username) + except RpcException as e: + raise + +proc statusRegistrarAddress*():string = + result = $contracts.getContract("ens-usernames").address + + +type + ENSType* {.pure.} = enum + IPFS, + SWARM, + IPNS, + UNKNOWN + +proc decodeENSContentHash*(value: string): tuple[ensType: ENSType, output: string] = + if value == "": + return (ENSType.UNKNOWN, "") + + if value[0..5] == "e40101": + return (ENSType.SWARM, value.split("1b20")[1]) + + if value[0..7] == "e3010170": + try: + let defaultCodec = parseHexInt("70") #dag-pb + var codec = defaultCodec # no codec specified + var codecStartIdx = 2 # idx of where codec would start if it was specified + # handle the case when starts with 0xe30170 instead of 0xe3010170 + if value[2..5] == "0101": + codecStartIdx = 6 + codec = parseHexInt(value[6..7]) + elif value[2..3] == "01" and value[4..5] != "12": + codecStartIdx = 4 + codec = parseHexInt(value[4..5]) + + # strip the info we no longer need + var multiHashStr = value[codecStartIdx + 2.. 0: + result = "already-connected" + else: + let ownerAddr = owner(username) + if ownerAddr == "" and isStatus: + result = "available" + else: + let userPubKey = status_settings.getSetting2[string](Setting.PublicKey, "0x0") + let userWallet = status_wallet.getWalletAccounts()[0].address + let ens_pubkey = pubkey(ens) + if ownerAddr != "": + if ens_pubkey == "" and ownerAddr == userWallet: + result = "owned" # "Continuing will connect this username with your chat key." + elif ens_pubkey == userPubkey: + result = "connected" + elif ownerAddr == userWallet: + result = "connected-different-key" # "Continuing will require a transaction to connect the username with your current chat key.", + else: + result = "taken" + else: + result = "taken" diff --git a/status/fleet.nim b/status/fleet.nim new file mode 100644 index 0000000..3b19ac9 --- /dev/null +++ b/status/fleet.nim @@ -0,0 +1,15 @@ +import ./types/[fleet] + +export fleet + +type + FleetModel* = ref object + config*: FleetConfig + +proc newFleetModel*(fleetConfigJson: string): FleetModel = + result = FleetModel() + result.config = fleetConfigJson.toFleetConfig() + +proc delete*(self: FleetModel) = + discard + diff --git a/status/gif.nim b/status/gif.nim new file mode 100644 index 0000000..794f6f2 --- /dev/null +++ b/status/gif.nim @@ -0,0 +1,151 @@ +import httpclient +import json +import strformat +import os +import sequtils + +from libstatus/gif import getRecentGifs, getFavoriteGifs, setFavoriteGifs, setRecentGifs + + +const MAX_RECENT = 50 +# set via `nim c` param `-d:TENOR_API_KEY:[api_key]`; should be set in CI/release builds +const TENOR_API_KEY {.strdefine.} = "" +let TENOR_API_KEY_ENV = $getEnv("TENOR_API_KEY") + +let TENOR_API_KEY_RESOLVED = + if TENOR_API_KEY_ENV != "": + TENOR_API_KEY_ENV + else: + TENOR_API_KEY + +const baseUrl = "https://g.tenor.com/v1/" +let defaultParams = fmt("&media_filter=minimal&limit=50&key={TENOR_API_KEY_RESOLVED}") + +type + GifItem* = object + id*: string + title*: string + url*: string + tinyUrl*: string + height*: int + +proc tenorToGifItem(jsonMsg: JsonNode): GifItem = + return GifItem( + id: jsonMsg{"id"}.getStr, + title: jsonMsg{"title"}.getStr, + url: jsonMsg{"media"}[0]["gif"]["url"].getStr, + tinyUrl: jsonMsg{"media"}[0]["tinygif"]["url"].getStr, + height: jsonMsg{"media"}[0]["gif"]["dims"][1].getInt + ) + +proc settingToGifItem(jsonMsg: JsonNode): GifItem = + return GifItem( + id: jsonMsg{"id"}.getStr, + title: jsonMsg{"title"}.getStr, + url: jsonMsg{"url"}.getStr, + tinyUrl: jsonMsg{"tinyUrl"}.getStr, + height: jsonMsg{"height"}.getInt + ) + +proc toJsonNode*(self: GifItem): JsonNode = + result = %* { + "id": self.id, + "title": self.title, + "url": self.url, + "tinyUrl": self.tinyUrl, + "height": self.height + } + +proc `$`*(self: GifItem): string = + return fmt"GifItem(id:{self.id}, title:{self.title}, url:{self.url}, tinyUrl:{self.tinyUrl}, height:{self.height})" + +type + GifClient* = ref object + client: HttpClient + favorites: seq[GifItem] + recents: seq[GifItem] + favoritesLoaded: bool + recentsLoaded: bool + +proc newGifClient*(): GifClient = + result = GifClient() + result.client = newHttpClient() + result.favorites = @[] + result.recents = @[] + +proc tenorQuery(self: GifClient, path: string): seq[GifItem] = + try: + let content = self.client.getContent(fmt("{baseUrl}{path}{defaultParams}")) + let doc = content.parseJson() + + var items: seq[GifItem] = @[] + for json in doc["results"]: + items.add(tenorToGifItem(json)) + + return items + except: + echo getCurrentExceptionMsg() + return @[] + +proc search*(self: GifClient, query: string): seq[GifItem] = + return self.tenorQuery(fmt("search?q={query}")) + +proc getTrendings*(self: GifClient): seq[GifItem] = + return self.tenorQuery("trending?") + +proc getFavorites*(self: GifClient): seq[GifItem] = + if not self.favoritesLoaded: + self.favoritesLoaded = true + self.favorites = map(getFavoriteGifs(){"items"}.getElems(), settingToGifItem) + + return self.favorites + +proc getRecents*(self: GifClient): seq[GifItem] = + if not self.recentsLoaded: + self.recentsLoaded = true + self.recents = map(getRecentGifs(){"items"}.getElems(), settingToGifItem) + + return self.recents + +proc isFavorite*(self: GifClient, gifItem: GifItem): bool = + for favorite in self.getFavorites(): + if favorite.id == gifItem.id: + return true + + return false + +proc toggleFavorite*(self: GifClient, gifItem: GifItem) = + var newFavorites: seq[GifItem] = @[] + var found = false + + for favoriteGif in self.getFavorites(): + if favoriteGif.id == gifItem.id: + found = true + continue + + newFavorites.add(favoriteGif) + + if not found: + newFavorites.add(gifItem) + + self.favorites = newFavorites + setFavoriteGifs(%*{"items": map(newFavorites, toJsonNode)}) + +proc addToRecents*(self: GifClient, gifItem: GifItem) = + let recents = self.getRecents() + var newRecents: seq[GifItem] = @[gifItem] + var idx = 0 + + while idx < MAX_RECENT - 1: + if idx >= recents.len: + break + + if recents[idx].id == gifItem.id: + idx += 1 + continue + + newRecents.add(recents[idx]) + idx += 1 + + self.recents = newRecents + setRecentGifs(%*{"items": map(newRecents, toJsonNode)}) \ No newline at end of file diff --git a/status/libstatus/accounts.nim b/status/libstatus/accounts.nim new file mode 100644 index 0000000..41c8b4b --- /dev/null +++ b/status/libstatus/accounts.nim @@ -0,0 +1,393 @@ +import json, os, nimcrypto, uuids, json_serialization, chronicles, strutils + +from status_go import multiAccountGenerateAndDeriveAddresses, generateAlias, identicon, saveAccountAndLogin, login, openAccounts, getNodeConfig +import core +import ../utils as utils +import ../types/[account, fleet, rpc_response] +import ../signals/[base] +import accounts/constants + +proc getNetworkConfig(currentNetwork: string): JsonNode = + result = constants.DEFAULT_NETWORKS.first("id", currentNetwork) + + +proc getDefaultNodeConfig*(fleetConfig: FleetConfig, installationId: string): JsonNode = + let networkConfig = getNetworkConfig(constants.DEFAULT_NETWORK_NAME) + let upstreamUrl = networkConfig["config"]["UpstreamConfig"]["URL"] + let fleet = Fleet.PROD + + var newDataDir = networkConfig["config"]["DataDir"].getStr + newDataDir.removeSuffix("_rpc") + result = constants.NODE_CONFIG.copy() + result["ClusterConfig"]["Fleet"] = newJString($fleet) + result["ClusterConfig"]["BootNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Bootnodes) + result["ClusterConfig"]["TrustedMailServers"] = %* fleetConfig.getNodes(fleet, FleetNodes.Mailservers) + result["ClusterConfig"]["StaticNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Whisper) + result["ClusterConfig"]["RendezvousNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Rendezvous) + result["ClusterConfig"]["WakuNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Waku) + result["ClusterConfig"]["WakuStoreNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Waku) + result["NetworkId"] = networkConfig["config"]["NetworkId"] + result["DataDir"] = newDataDir.newJString() + result["UpstreamConfig"]["Enabled"] = networkConfig["config"]["UpstreamConfig"]["Enabled"] + result["UpstreamConfig"]["URL"] = upstreamUrl + result["ShhextConfig"]["InstallationID"] = newJString(installationId) + + # TODO: commented since it's not necessary (we do the connections thru C bindings). Enable it thru an option once status-nodes are able to be configured in desktop + # result["ListenAddr"] = if existsEnv("STATUS_PORT"): newJString("0.0.0.0:" & $getEnv("STATUS_PORT")) else: newJString("0.0.0.0:30305") + + +proc hashPassword*(password: string): string = + result = "0x" & $keccak_256.digest(password) + +proc getDefaultAccount*(): string = + var response = callPrivateRPC("eth_accounts") + result = parseJson(response)["result"][0].getStr() + +proc generateAddresses*(n = 5): seq[GeneratedAccount] = + let multiAccountConfig = %* { + "n": n, + "mnemonicPhraseLength": 12, + "bip39Passphrase": "", + "paths": [PATH_WALLET_ROOT, PATH_EIP_1581, PATH_WHISPER, PATH_DEFAULT_WALLET] + } + let generatedAccounts = $status_go.multiAccountGenerateAndDeriveAddresses($multiAccountConfig) + result = Json.decode(generatedAccounts, seq[GeneratedAccount]) + +proc generateAlias*(publicKey: string): string = + result = $status_go.generateAlias(publicKey) + +proc generateIdenticon*(publicKey: string): string = + result = $status_go.identicon(publicKey) + +proc initNode*() = + createDir(STATUSGODIR) + createDir(KEYSTOREDIR) + discard $status_go.initKeystore(KEYSTOREDIR) + +proc parseIdentityImage*(images: JsonNode): IdentityImage = + result = IdentityImage() + if (images.kind != JNull): + for image in images: + if (image["type"].getStr == "thumbnail"): + # TODO check if this can be url or if it's always uri + result.thumbnail = image["uri"].getStr + elif (image["type"].getStr == "large"): + result.large = image["uri"].getStr + +proc openAccounts*(): seq[NodeAccount] = + let strNodeAccounts = status_go.openAccounts(STATUSGODIR).parseJson + # FIXME fix serialization + result = @[] + if (strNodeAccounts.kind != JNull): + for account in strNodeAccounts: + let nodeAccount = NodeAccount( + name: account["name"].getStr, + timestamp: account["timestamp"].getInt, + keyUid: account["key-uid"].getStr, + identicon: account["identicon"].getStr, + keycardPairing: account["keycard-pairing"].getStr + ) + if (account{"images"}.kind != JNull): + nodeAccount.identityImage = parseIdentityImage(account["images"]) + + result.add(nodeAccount) + + +proc saveAccountAndLogin*( + account: GeneratedAccount, + accountData: string, + password: string, + configJSON: string, + settingsJSON: string): Account = + let hashedPassword = hashPassword(password) + let subaccountData = %* [ + { + "public-key": account.derived.defaultWallet.publicKey, + "address": account.derived.defaultWallet.address, + "color": "#4360df", + "wallet": true, + "path": constants.PATH_DEFAULT_WALLET, + "name": "Status account" + }, + { + "public-key": account.derived.whisper.publicKey, + "address": account.derived.whisper.address, + "name": account.name, + "identicon": account.identicon, + "path": constants.PATH_WHISPER, + "chat": true + } + ] + + var savedResult = $status_go.saveAccountAndLogin(accountData, hashedPassword, settingsJSON, configJSON, $subaccountData) + let parsedSavedResult = savedResult.parseJson + let error = parsedSavedResult["error"].getStr + + if error == "": + debug "Account saved succesfully" + result = account.toAccount + return + + raise newException(StatusGoException, "Error saving account and logging in: " & error) + +proc storeDerivedAccounts*(account: GeneratedAccount, password: string, paths: seq[string] = @[PATH_WALLET_ROOT, PATH_EIP_1581, PATH_WHISPER, PATH_DEFAULT_WALLET]): MultiAccounts = + let hashedPassword = hashPassword(password) + let multiAccount = %* { + "accountID": account.id, + "paths": paths, + "password": hashedPassword + } + let response = $status_go.multiAccountStoreDerivedAccounts($multiAccount); + + try: + result = Json.decode($response, MultiAccounts) + except: + let err = Json.decode($response, StatusGoError) + raise newException(StatusGoException, "Error storing multiaccount derived accounts: " & err.error) + +proc getAccountData*(account: GeneratedAccount): JsonNode = + result = %* { + "name": account.name, + "address": account.address, + "identicon": account.identicon, + "key-uid": account.keyUid, + "keycard-pairing": nil + } + +proc getAccountSettings*(account: GeneratedAccount, defaultNetworks: JsonNode, installationId: string): JsonNode = + result = %* { + "key-uid": account.keyUid, + "mnemonic": account.mnemonic, + "public-key": account.derived.whisper.publicKey, + "name": account.name, + "address": account.address, + "eip1581-address": account.derived.eip1581.address, + "dapps-address": account.derived.defaultWallet.address, + "wallet-root-address": account.derived.walletRoot.address, + "preview-privacy?": true, + "signing-phrase": generateSigningPhrase(3), + "log-level": "INFO", + "latest-derived-path": 0, + "networks/networks": defaultNetworks, + "currency": "usd", + "identicon": account.identicon, + "waku-enabled": true, + "wallet/visible-tokens": { + "mainnet": ["SNT"] + }, + "appearance": 0, + "networks/current-network": constants.DEFAULT_NETWORK_NAME, + "installation-id": installationId + } + +proc setupAccount*(fleetConfig: FleetConfig, account: GeneratedAccount, password: string): Account = + try: + let storeDerivedResult = storeDerivedAccounts(account, password) + let accountData = getAccountData(account) + let installationId = $genUUID() + var settingsJSON = getAccountSettings(account, constants.DEFAULT_NETWORKS, installationId) + var nodeConfig = getDefaultNodeConfig(fleetConfig, installationId) + result = saveAccountAndLogin(account, $accountData, password, $nodeConfig, $settingsJSON) + + except StatusGoException as e: + raise newException(StatusGoException, "Error setting up account: " & e.msg) + + finally: + # TODO this is needed for now for the retrieving of past messages. We'll either move or remove it later + let peer = "enode://44160e22e8b42bd32a06c1532165fa9e096eebedd7fa6d6e5f8bbef0440bc4a4591fe3651be68193a7ec029021cdb496cfe1d7f9f1dc69eb99226e6f39a7a5d4@35.225.221.245:443" + discard status_go.addPeer(peer) + +proc login*(nodeAccount: NodeAccount, password: string): NodeAccount = + let hashedPassword = hashPassword(password) + let account = nodeAccount.toAccount + let loginResult = $status_go.login($toJson(account), hashedPassword) + let error = parseJson(loginResult)["error"].getStr + + if error == "": + debug "Login requested", user=nodeAccount.name + result = nodeAccount + return + + raise newException(StatusGoException, "Error logging in: " & error) + +proc loadAccount*(address: string, password: string): GeneratedAccount = + let hashedPassword = hashPassword(password) + let inputJson = %* { + "address": address, + "password": hashedPassword + } + let loadResult = $status_go.multiAccountLoadAccount($inputJson) + let parsedLoadResult = loadResult.parseJson + let error = parsedLoadResult{"error"}.getStr + + if error == "": + debug "Account loaded succesfully" + result = Json.decode(loadResult, GeneratedAccount) + return + + raise newException(StatusGoException, "Error loading wallet account: " & error) + +proc verifyAccountPassword*(address: string, password: string): bool = + let hashedPassword = hashPassword(password) + let verifyResult = $status_go.verifyAccountPassword(KEYSTOREDIR, address, hashedPassword) + let error = parseJson(verifyResult)["error"].getStr + + if error == "": + return true + + return false + +proc changeDatabasePassword*(keyUID: string, password: string, newPassword: string): bool = + let hashedPassword = hashPassword(password) + let hashedNewPassword = hashPassword(newPassword) + let changeResult = $status_go.changeDatabasePassword(keyUID, hashedPassword, hashedNewPassword) + let error = parseJson(changeResult)["error"].getStr + return error == "" + +proc multiAccountImportMnemonic*(mnemonic: string): GeneratedAccount = + let mnemonicJson = %* { + "mnemonicPhrase": mnemonic, + "Bip39Passphrase": "" + } + # status_go.multiAccountImportMnemonic never results in an error given ANY input + let importResult = $status_go.multiAccountImportMnemonic($mnemonicJson) + result = Json.decode(importResult, GeneratedAccount) + +proc MultiAccountImportPrivateKey*(privateKey: string): GeneratedAccount = + let privateKeyJson = %* { + "privateKey": privateKey + } + # status_go.MultiAccountImportPrivateKey never results in an error given ANY input + try: + let importResult = $status_go.multiAccountImportPrivateKey($privateKeyJson) + result = Json.decode(importResult, GeneratedAccount) + except Exception as e: + error "Error getting account from private key", msg=e.msg + + +proc storeDerivedWallet*(account: GeneratedAccount, password: string, walletIndex: int, accountType: string): string = + let hashedPassword = hashPassword(password) + let derivationPath = (if accountType == constants.GENERATED: "m/" else: "m/44'/60'/0'/0/") & $walletIndex + let multiAccount = %* { + "accountID": account.id, + "paths": [derivationPath], + "password": hashedPassword + } + let response = parseJson($status_go.multiAccountStoreDerivedAccounts($multiAccount)); + let error = response{"error"}.getStr + if error == "": + debug "Wallet stored succesfully" + return "m/44'/60'/0'/0/" & $walletIndex + raise newException(StatusGoException, error) + +proc storePrivateKeyAccount*(account: GeneratedAccount, password: string) = + let hashedPassword = hashPassword(password) + let response = parseJson($status_go.multiAccountStoreAccount($(%*{"accountID": account.id, "password": hashedPassword}))); + let error = response{"error"}.getStr + if error == "": + debug "Wallet stored succesfully" + return + + raise newException(StatusGoException, error) + +proc saveAccount*(account: GeneratedAccount, password: string, color: string, accountType: string, isADerivedAccount = true, walletIndex: int = 0 ): DerivedAccount = + try: + var derivationPath = "m/44'/60'/0'/0/0" + if (isADerivedAccount): + # Only store derived accounts. Private key accounts are not multiaccounts + derivationPath = storeDerivedWallet(account, password, walletIndex, accountType) + elif accountType == constants.KEY: + storePrivateKeyAccount(account, password) + + var address = account.derived.defaultWallet.address + var publicKey = account.derived.defaultWallet.publicKey + + if (address == ""): + address = account.address + publicKey = account.publicKey + + echo callPrivateRPC("accounts_saveAccounts", %* [ + [{ + "color": color, + "name": account.name, + "address": address, + "public-key": publicKey, + "type": accountType, + "path": derivationPath + }] + ]) + + result = DerivedAccount(address: address, publicKey: publicKey, derivationPath: derivationPath) + except: + error "Error storing the new account. Bad password?" + raise + +proc changeAccount*(name, address, publicKey, walletType, iconColor: string): string = + try: + let response = callPrivateRPC("accounts_saveAccounts", %* [ + [{ + "color": iconColor, + "name": name, + "address": address, + "public-key": publicKey, + "type": walletType, + "path": "m/44'/60'/0'/0/1" # <--- TODO: fix this. Derivation path is not supposed to change + }] + ]) + + utils.handleRPCErrors(response) + return "" + except Exception as e: + error "Error saving the account", msg=e.msg + result = e.msg + +proc deleteAccount*(address: string): string = + try: + let response = callPrivateRPC("accounts_deleteAccount", %* [address]) + + utils.handleRPCErrors(response) + return "" + except Exception as e: + error "Error removing the account", msg=e.msg + result = e.msg + +proc deriveWallet*(accountId: string, walletIndex: int): DerivedAccount = + let path = "m/" & $walletIndex + let deriveJson = %* { + "accountID": accountId, + "paths": [path] + } + let deriveResult = parseJson($status_go.multiAccountDeriveAddresses($deriveJson)) + result = DerivedAccount( + address: deriveResult[path]["address"].getStr, + publicKey: deriveResult[path]["publicKey"].getStr) + +proc deriveAccounts*(accountId: string): MultiAccounts = + let deriveJson = %* { + "accountID": accountId, + "paths": [PATH_WALLET_ROOT, PATH_EIP_1581, PATH_WHISPER, PATH_DEFAULT_WALLET] + } + let deriveResult = $status_go.multiAccountDeriveAddresses($deriveJson) + result = Json.decode(deriveResult, MultiAccounts) + +proc logout*(): StatusGoError = + result = Json.decode($status_go.logout(), StatusGoError) + +proc storeIdentityImage*(keyUID: string, imagePath: string, aX, aY, bX, bY: int): IdentityImage = + let response = callPrivateRPC("multiaccounts_storeIdentityImage", %* [keyUID, imagePath, aX, aY, bX, bY]).parseJson + result = parseIdentityImage(response{"result"}) + +proc getIdentityImage*(keyUID: string): IdentityImage = + try: + let response = callPrivateRPC("multiaccounts_getIdentityImages", %* [keyUID]).parseJson + result = parseIdentityImage(response{"result"}) + except Exception as e: + error "Error getting identity image", msg=e.msg + +proc deleteIdentityImage*(keyUID: string): string = + try: + let response = callPrivateRPC("multiaccounts_deleteIdentityImage", %* [keyUID]).parseJson + result = "" + except Exception as e: + error "Error getting identity image", msg=e.msg + result = e.msg diff --git a/status/libstatus/accounts/constants.nim b/status/libstatus/accounts/constants.nim new file mode 100644 index 0000000..c52a2c6 --- /dev/null +++ b/status/libstatus/accounts/constants.nim @@ -0,0 +1,235 @@ +import # std libs + json, os, sequtils, strutils + +import # vendor libs + confutils + +const GENERATED* = "generated" +const SEED* = "seed" +const KEY* = "key" +const WATCH* = "watch" + +const ZERO_ADDRESS* = "0x0000000000000000000000000000000000000000" + +const PATH_WALLET_ROOT* = "m/44'/60'/0'/0" +# EIP1581 Root Key, the extended key from which any whisper key/encryption key can be derived +const PATH_EIP_1581* = "m/43'/60'/1581'" +# BIP44-0 Wallet key, the default wallet key +const PATH_DEFAULT_WALLET* = PATH_WALLET_ROOT & "/0" +# EIP1581 Chat Key 0, the default whisper key +const PATH_WHISPER* = PATH_EIP_1581 & "/0'/0" + +# set via `nim c` param `-d:INFURA_TOKEN:[token]`; should be set in CI/release builds +const INFURA_TOKEN {.strdefine.} = "" +# allow runtime override via environment variable; core contributors can set a +# release token in this way for local development +let INFURA_TOKEN_ENV = $getEnv("INFURA_TOKEN") + +let INFURA_TOKEN_RESOLVED = + if INFURA_TOKEN_ENV != "": + INFURA_TOKEN_ENV + else: + INFURA_TOKEN + +let DEFAULT_NETWORKS* = %* [ + { + "id": "testnet_rpc", + "etherscan-link": "https://ropsten.etherscan.io/address/", + "name": "Ropsten with upstream RPC", + "config": { + "NetworkId": 3, + "DataDir": "/ethereum/testnet_rpc", + "UpstreamConfig": { + "Enabled": true, + "URL": "https://ropsten.infura.io/v3/" & INFURA_TOKEN_RESOLVED + } + } + }, + { + "id": "rinkeby_rpc", + "etherscan-link": "https://rinkeby.etherscan.io/address/", + "name": "Rinkeby with upstream RPC", + "config": { + "NetworkId": 4, + "DataDir": "/ethereum/rinkeby_rpc", + "UpstreamConfig": { + "Enabled": true, + "URL": "https://rinkeby.infura.io/v3/" & INFURA_TOKEN_RESOLVED + } + } + }, + { + "id": "goerli_rpc", + "etherscan-link": "https://goerli.etherscan.io/address/", + "name": "Goerli with upstream RPC", + "config": { + "NetworkId": 5, + "DataDir": "/ethereum/goerli_rpc", + "UpstreamConfig": { + "Enabled": true, + "URL": "https://goerli.blockscout.com/" + } + } + }, + { + "id": "mainnet_rpc", + "etherscan-link": "https://etherscan.io/address/", + "name": "Mainnet with upstream RPC", + "config": { + "NetworkId": 1, + "DataDir": "/ethereum/mainnet_rpc", + "UpstreamConfig": { + "Enabled": true, + "URL": "https://mainnet.infura.io/v3/" & INFURA_TOKEN_RESOLVED + } + } + }, + { + "id": "xdai_rpc", + "name": "xDai Chain", + "config": { + "NetworkId": 100, + "DataDir": "/ethereum/xdai_rpc", + "UpstreamConfig": { + "Enabled": true, + "URL": "https://dai.poa.network" + } + } + }, + { + "id": "poa_rpc", + "name": "POA Network", + "config": { + "NetworkId": 99, + "DataDir": "/ethereum/poa_rpc", + "UpstreamConfig": { + "Enabled": true, + "URL": "https://core.poa.network" + } + } + } +] + +var NODE_CONFIG* = %* { + "BrowsersConfig": { + "Enabled": true + }, + "ClusterConfig": { + "Enabled": true + }, + "DataDir": "./ethereum/mainnet", + "EnableNTPSync": true, + "KeyStoreDir": "./keystore", + # TODO: commented since it's not necessary (we do the connections thru C bindings). Enable it thru an option once status-nodes are able to be configured in desktop + #"ListenAddr": ":30304", + "LogEnabled": true, + "LogFile": "geth.log", + "LogLevel": "INFO", + "MailserversConfig": { + "Enabled": true + }, + "Name": "StatusDesktop", + "NetworkId": 1, + "NoDiscovery": false, + "PermissionsConfig": { + "Enabled": true + }, + "Rendezvous": true, + "RegisterTopics": @["whispermail"], + "RequireTopics": { + "whisper": { + "Max": 2, + "Min": 2 + } + }, + "ShhextConfig": { + "BackupDisabledDataDir": "./", + "DataSyncEnabled": true, + "InstallationID": "aef27732-8d86-5039-a32e-bdbe094d8791", + "MailServerConfirmations": true, + "MaxMessageDeliveryAttempts": 6, + "PFSEnabled": true, + "VerifyENSContractAddress": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "VerifyENSURL": "https://mainnet.infura.io/v3/" & INFURA_TOKEN_RESOLVED, + "VerifyTransactionChainID": 1, + "VerifyTransactionURL": "https://mainnet.infura.io/v3/" & INFURA_TOKEN_RESOLVED + }, + "StatusAccountsConfig": { + "Enabled": true + }, + "UpstreamConfig": { + "Enabled": true, + "URL": "https://mainnet.infura.io/v3/" & INFURA_TOKEN_RESOLVED + }, + "WakuConfig": { + "BloomFilterMode": true, + "Enabled": true, + "LightClient": true, + "MinimumPoW": 0.001 + }, + "WakuV2Config": { + "Enabled": false, + "Host": "0.0.0.0", + "Port": 0 + }, + "WalletConfig": { + "Enabled": true + } +} + +const DEFAULT_NETWORK_NAME* = "mainnet_rpc" + +const sep = when defined(windows): "\\" else: "/" + +proc defaultDataDir(): string = + let homeDir = getHomeDir() + let parentDir = + if defined(development): + parentDir(getAppDir()) + elif homeDir == "": + getCurrentDir() + elif defined(macosx): + joinPath(homeDir, "Library", "Application Support") + elif defined(windows): + let targetDir = getEnv("LOCALAPPDATA").string + if targetDir == "": + joinPath(homeDir, "AppData", "Local") + else: + targetDir + else: + let targetDir = getEnv("XDG_CONFIG_HOME").string + if targetDir == "": + joinPath(homeDir, ".config") + else: + targetDir + absolutePath(joinPath(parentDir, "Status")) + +type StatusDesktopConfig = object + dataDir* {. + defaultValue: defaultDataDir() + desc: "Status Desktop data directory" + abbr: "d" .}: string + +# On macOS the first time when a user gets the "App downloaded from the +# internet" warning, and clicks the Open button, the OS passes a unique process +# serial number (PSN) as -psn_... command-line argument, which we remove before +# processing the arguments with nim-confutils. +# Credit: https://github.com/bitcoin/bitcoin/blame/b6e34afe9735faf97d6be7a90fafd33ec18c0cbb/src/util/system.cpp#L383-L389 + +var cliParams = commandLineParams() +if defined(macosx): + cliParams.keepIf(proc(p: string): bool = not p.startsWith("-psn_")) + +let desktopConfig = StatusDesktopConfig.load(cliParams) + +let + baseDir = absolutePath(expandTilde(desktopConfig.dataDir)) + DATADIR* = baseDir & sep + STATUSGODIR* = joinPath(baseDir, "data") & sep + KEYSTOREDIR* = joinPath(baseDir, "data", "keystore") & sep + TMPDIR* = joinPath(baseDir, "tmp") & sep + LOGDIR* = joinPath(baseDir, "logs") & sep + +createDir(DATADIR) +createDir(TMPDIR) +createDir(LOGDIR) diff --git a/status/libstatus/accounts/signing_phrases.nim b/status/libstatus/accounts/signing_phrases.nim new file mode 100644 index 0000000..e459823 --- /dev/null +++ b/status/libstatus/accounts/signing_phrases.nim @@ -0,0 +1,623 @@ +const phrases*: seq[string] = @[ + "acid", + "alto", + "apse", + "arch", + "area", + "army", + "atom", + "aunt", + "babe", + "baby", + "back", + "bail", + "bait", + "bake", + "ball", + "band", + "bank", + "barn", + "base", + "bass", + "bath", + "bead", + "beak", + "beam", + "bean", + "bear", + "beat", + "beef", + "beer", + "beet", + "bell", + "belt", + "bend", + "bike", + "bill", + "bird", + "bite", + "blow", + "blue", + "boar", + "boat", + "body", + "bolt", + "bomb", + "bone", + "book", + "boot", + "bore", + "boss", + "bowl", + "brow", + "bulb", + "bull", + "burn", + "bush", + "bust", + "cafe", + "cake", + "calf", + "call", + "calm", + "camp", + "cane", + "cape", + "card", + "care", + "carp", + "cart", + "case", + "cash", + "cast", + "cave", + "cell", + "cent", + "chap", + "chef", + "chin", + "chip", + "chop", + "chub", + "chug", + "city", + "clam", + "clef", + "clip", + "club", + "clue", + "coal", + "coat", + "code", + "coil", + "coin", + "coke", + "cold", + "colt", + "comb", + "cone", + "cook", + "cope", + "copy", + "cord", + "cork", + "corn", + "cost", + "crab", + "craw", + "crew", + "crib", + "crop", + "crow", + "curl", + "cyst", + "dame", + "dare", + "dark", + "dart", + "dash", + "data", + "date", + "dead", + "deal", + "dear", + "debt", + "deck", + "deep", + "deer", + "desk", + "dhow", + "diet", + "dill", + "dime", + "dirt", + "dish", + "disk", + "dock", + "doll", + "door", + "dory", + "drag", + "draw", + "drop", + "drug", + "drum", + "duck", + "dump", + "dust", + "duty", + "ease", + "east", + "eave", + "eddy", + "edge", + "envy", + "epee", + "exam", + "exit", + "face", + "fact", + "fail", + "fall", + "fame", + "fang", + "farm", + "fawn", + "fear", + "feed", + "feel", + "feet", + "file", + "fill", + "film", + "find", + "fine", + "fire", + "fish", + "flag", + "flat", + "flax", + "flow", + "foam", + "fold", + "font", + "food", + "foot", + "fork", + "form", + "fort", + "fowl", + "frog", + "fuel", + "full", + "gain", + "gale", + "galn", + "game", + "garb", + "gate", + "gear", + "gene", + "gift", + "girl", + "give", + "glad", + "glen", + "glue", + "glut", + "goal", + "goat", + "gold", + "golf", + "gong", + "good", + "gown", + "grab", + "gram", + "gray", + "grey", + "grip", + "grit", + "gyro", + "hail", + "hair", + "half", + "hall", + "hand", + "hang", + "harm", + "harp", + "hate", + "hawk", + "head", + "heat", + "heel", + "hell", + "helo", + "help", + "hemp", + "herb", + "hide", + "high", + "hill", + "hire", + "hive", + "hold", + "hole", + "home", + "hood", + "hoof", + "hook", + "hope", + "hops", + "horn", + "hose", + "host", + "hour", + "hunt", + "hurt", + "icon", + "idea", + "inch", + "iris", + "iron", + "item", + "jail", + "jeep", + "jeff", + "joey", + "join", + "joke", + "judo", + "jump", + "junk", + "jury", + "jute", + "kale", + "keep", + "kick", + "kill", + "kilt", + "kind", + "king", + "kiss", + "kite", + "knee", + "knot", + "lace", + "lack", + "lady", + "lake", + "lamb", + "lamp", + "land", + "lark", + "lava", + "lawn", + "lead", + "leaf", + "leek", + "lier", + "life", + "lift", + "lily", + "limo", + "line", + "link", + "lion", + "lisa", + "list", + "load", + "loaf", + "loan", + "lock", + "loft", + "long", + "look", + "loss", + "lout", + "love", + "luck", + "lung", + "lute", + "lynx", + "lyre", + "maid", + "mail", + "main", + "make", + "male", + "mall", + "manx", + "many", + "mare", + "mark", + "mask", + "mass", + "mate", + "math", + "meal", + "meat", + "meet", + "menu", + "mess", + "mice", + "midi", + "mile", + "milk", + "mime", + "mind", + "mine", + "mini", + "mint", + "miss", + "mist", + "moat", + "mode", + "mole", + "mood", + "moon", + "most", + "moth", + "move", + "mule", + "mutt", + "nail", + "name", + "neat", + "neck", + "need", + "neon", + "nest", + "news", + "node", + "nose", + "note", + "oboe", + "okra", + "open", + "oval", + "oven", + "oxen", + "pace", + "pack", + "page", + "pail", + "pain", + "pair", + "palm", + "pard", + "park", + "part", + "pass", + "past", + "path", + "peak", + "pear", + "peen", + "peer", + "pelt", + "perp", + "pest", + "pick", + "pier", + "pike", + "pile", + "pimp", + "pine", + "ping", + "pink", + "pint", + "pipe", + "piss", + "pith", + "plan", + "play", + "plot", + "plow", + "poem", + "poet", + "pole", + "polo", + "pond", + "pony", + "poof", + "pool", + "port", + "post", + "prow", + "pull", + "puma", + "pump", + "pupa", + "push", + "quit", + "race", + "rack", + "raft", + "rage", + "rail", + "rain", + "rake", + "rank", + "rate", + "read", + "rear", + "reef", + "rent", + "rest", + "rice", + "rich", + "ride", + "ring", + "rise", + "risk", + "road", + "robe", + "rock", + "role", + "roll", + "roof", + "room", + "root", + "rope", + "rose", + "ruin", + "rule", + "rush", + "ruth", + "sack", + "safe", + "sage", + "sail", + "sale", + "salt", + "sand", + "sari", + "sash", + "save", + "scow", + "seal", + "seat", + "seed", + "self", + "sell", + "shed", + "shin", + "ship", + "shoe", + "shop", + "shot", + "show", + "sick", + "side", + "sign", + "silk", + "sill", + "silo", + "sing", + "sink", + "site", + "size", + "skin", + "sled", + "slip", + "smog", + "snob", + "snow", + "soap", + "sock", + "soda", + "sofa", + "soft", + "soil", + "song", + "soot", + "sort", + "soup", + "spot", + "spur", + "stag", + "star", + "stay", + "stem", + "step", + "stew", + "stop", + "stud", + "suck", + "suit", + "swan", + "swim", + "tail", + "tale", + "talk", + "tank", + "tard", + "task", + "taxi", + "team", + "tear", + "teen", + "tell", + "temp", + "tent", + "term", + "test", + "text", + "thaw", + "tile", + "till", + "time", + "tire", + "toad", + "toga", + "togs", + "tone", + "tool", + "toot", + "tote", + "tour", + "town", + "tram", + "tray", + "tree", + "trim", + "trip", + "tuba", + "tube", + "tuna", + "tune", + "turn", + "tutu", + "twig", + "type", + "unit", + "user", + "vane", + "vase", + "vast", + "veal", + "veil", + "vein", + "vest", + "vibe", + "view", + "vise", + "wait", + "wake", + "walk", + "wall", + "wash", + "wasp", + "wave", + "wear", + "weed", + "week", + "well", + "west", + "whip", + "wife", + "will", + "wind", + "wine", + "wing", + "wire", + "wish", + "wolf", + "wood", + "wool", + "word", + "work", + "worm", + "wrap", + "wren", + "yard", + "yarn", + "yawl", + "year", + "yoga", + "yoke", + "yurt", + "zinc", + "zone"] \ No newline at end of file diff --git a/status/libstatus/browser.nim b/status/libstatus/browser.nim new file mode 100644 index 0000000..7255a22 --- /dev/null +++ b/status/libstatus/browser.nim @@ -0,0 +1,27 @@ +import core, ../types/[bookmark], json, chronicles + +proc storeBookmark*(url: string, name: string): Bookmark = + let payload = %* [{"url": url, "name": name}] + result = Bookmark(name: name, url: url) + try: + let resp = callPrivateRPC("browsers_storeBookmark", payload).parseJson["result"] + result.imageUrl = resp["imageUrl"].getStr + except Exception as e: + error "Error updating bookmark", msg = e.msg + discard + +proc updateBookmark*(ogUrl: string, url: string, name: string) = + let payload = %* [ogUrl, {"url": url, "name": name}] + try: + discard callPrivateRPC("browsers_updateBookmark", payload) + except Exception as e: + error "Error updating bookmark", msg = e.msg + discard + +proc getBookmarks*(): string = + let payload = %* [] + result = callPrivateRPC("browsers_getBookmarks", payload) + +proc deleteBookmark*(url: string) = + let payload = %* [url] + discard callPrivateRPC("browsers_deleteBookmark", payload) diff --git a/status/libstatus/chat.nim b/status/libstatus/chat.nim new file mode 100644 index 0000000..94739b0 --- /dev/null +++ b/status/libstatus/chat.nim @@ -0,0 +1,592 @@ +import json, times, strutils, sequtils, chronicles, json_serialization, algorithm, strformat, sugar +import core, ../utils +import ../types/[chat, message, community, activity_center_notification, + status_update, rpc_response, setting, sticker] +import ./settings as status_settings + +proc buildFilter*(chat: Chat):JsonNode = + if chat.chatType == ChatType.PrivateGroupChat: + return newJNull() + result = %* { "ChatID": chat.id, "OneToOne": chat.chatType == ChatType.OneToOne } + +proc loadFilters*(filters: seq[JsonNode]): string = + result = callPrivateRPC("loadFilters".prefix, %* [filter(filters, proc(x:JsonNode):bool = x.kind != JNull)]) + +proc removeFilters*(chatId: string, filterId: string) = + discard callPrivateRPC("removeFilters".prefix, %* [ + [{ "ChatID": chatId, "FilterID": filterId }] + ]) + +proc saveChat*(chatId: string, chatType: ChatType, active: bool = true, color: string = "#000000", ensName: string = "", profile: string = "", joined: int64 = 0) = + # TODO: ideally status-go/stimbus should handle some of these fields instead of having the client + # send them: lastMessage, unviewedMEssagesCount, timestamp, lastClockValue, name? + discard callPrivateRPC("saveChat".prefix, %* [ + { + "lastClockValue": 0, # TODO: + "color": color, + "name": (if ensName != "": ensName else: chatId), + "lastMessage": nil, # TODO: + "active": active, + "profile": profile, + "id": chatId, + "unviewedMessagesCount": 0, # TODO: + "chatType": chatType.int, + "timestamp": 1588940692659, # TODO: + "joined": joined + } + ]) + +proc createPublicChat*(chatId: string):string = + callPrivateRPC("createPublicChat".prefix, %* [{"ID": chatId}]) + +proc createOneToOneChat*(chatId: string):string = + callPrivateRPC("createOneToOneChat".prefix, %* [{"ID": chatId}]) + +proc deactivateChat*(chat: Chat):string = + chat.isActive = false + callPrivateRPC("deactivateChat".prefix, %* [{ "ID": chat.id }]) + +proc createProfileChat*(pubKey: string):string = + callPrivateRPC("createProfileChat".prefix, %* [{ "ID": pubKey }]) + +proc sortChats(x, y: Chat): int = + var t1 = x.lastMessage.whisperTimestamp + var t2 = y.lastMessage.whisperTimestamp + + if t1 <= $x.joined: + t1 = $x.joined + if t2 <= $y.joined: + t2 = $y.joined + + if t1 > t2: 1 + elif t1 == t2: 0 + else: -1 + +proc loadChats*(): seq[Chat] = + result = @[] + let jsonResponse = parseJson($callPrivateRPC("chats".prefix)) + if jsonResponse["result"].kind != JNull: + for jsonChat in jsonResponse{"result"}: + let chat = jsonChat.toChat + if chat.isActive and chat.chatType != ChatType.Unknown: + result.add(chat) + result.sort(sortChats) + +proc parseActivityCenterNotifications*(rpcResult: JsonNode): (string, seq[ActivityCenterNotification]) = + var notifs: seq[ActivityCenterNotification] = @[] + var msg: Message + if rpcResult{"notifications"}.kind != JNull: + for jsonMsg in rpcResult["notifications"]: + notifs.add(jsonMsg.toActivityCenterNotification()) + return (rpcResult{"cursor"}.getStr, notifs) + +proc statusUpdates*(): seq[StatusUpdate] = + let rpcResult = callPrivateRPC("statusUpdates".prefix, %* []).parseJson()["result"] + if rpcResult != nil and rpcResult{"statusUpdates"} != nil and rpcResult["statusUpdates"].len != 0: + for jsonStatusUpdate in rpcResult["statusUpdates"]: + result.add(jsonStatusUpdate.toStatusUpdate) + +proc fetchChatMessages*(chatId: string, cursorVal: string, limit: int, success: var bool): string = + success = true + try: + result = callPrivateRPC("chatMessages".prefix, %* [chatId, cursorVal, limit]) + except RpcException as e: + success = false + result = e.msg + +proc editMessage*(messageId: string, msg: string): string = + callPrivateRPC("editMessage".prefix, %* [ + { + "id": messageId, + "text": msg + } + ]) + +proc deleteMessageAndSend*(messageId: string): string = + callPrivateRPC("deleteMessageAndSend".prefix, %* [messageId]) + +proc rpcReactions*(chatId: string, cursorVal: string, limit: int, success: var bool): string = + success = true + try: + result = callPrivateRPC("emojiReactionsByChatID".prefix, %* [chatId, cursorVal, limit]) + except RpcException as e: + success = false + result = e.msg + +proc addEmojiReaction*(chatId: string, messageId: string, emojiId: int): seq[Reaction] = + let rpcResult = parseJson(callPrivateRPC("sendEmojiReaction".prefix, %* [chatId, messageId, emojiId]))["result"] + + var reactions: seq[Reaction] = @[] + if rpcResult != nil and rpcResult["emojiReactions"] != nil and rpcResult["emojiReactions"].len != 0: + for jsonMsg in rpcResult["emojiReactions"]: + reactions.add(jsonMsg.toReaction) + + result = reactions + +proc removeEmojiReaction*(emojiReactionId: string): seq[Reaction] = + let rpcResult = parseJson(callPrivateRPC("sendEmojiReactionRetraction".prefix, %* [emojiReactionId]))["result"] + + var reactions: seq[Reaction] = @[] + if rpcResult != nil and rpcResult["emojiReactions"] != nil and rpcResult["emojiReactions"].len != 0: + for jsonMsg in rpcResult["emojiReactions"]: + reactions.add(jsonMsg.toReaction) + + result = reactions + +# TODO this probably belongs in another file +proc generateSymKeyFromPassword*(): string = + result = ($parseJson(callPrivateRPC("waku_generateSymKeyFromPassword", %* [ + # TODO unhardcode this for non-status mailservers + "status-offline-inbox" + ]))["result"]).strip(chars = {'"'}) + +proc sendChatMessage*(chatId: string, msg: string, replyTo: string, contentType: int, communityId: string = ""): string = + let preferredUsername = getSetting[string](Setting.PreferredUsername, "") + callPrivateRPC("sendChatMessage".prefix, %* [ + { + "chatId": chatId, + "text": msg, + "responseTo": replyTo, + "ensName": preferredUsername, + "sticker": nil, + "contentType": contentType, + "communityId": communityId + } + ]) + +proc sendImageMessage*(chatId: string, image: string): string = + let preferredUsername = getSetting[string](Setting.PreferredUsername, "") + callPrivateRPC("sendChatMessage".prefix, %* [ + { + "chatId": chatId, + "contentType": ContentType.Image.int, + "imagePath": image, + "ensName": preferredUsername, + "text": "Update to latest version to see a nice image here!" + } + ]) + +proc sendImageMessages*(chatId: string, images: var seq[string]): string = + let + preferredUsername = getSetting[string](Setting.PreferredUsername, "") + let imagesJson = %* images.map(image => %* + { + "chatId": chatId, + "contentType": ContentType.Image.int, + "imagePath": image, + "ensName": preferredUsername, + "text": "Update to latest version to see a nice image here!" + } + ) + callPrivateRPC("sendChatMessages".prefix, %* [imagesJson]) + +proc sendStickerMessage*(chatId: string, replyTo: string, sticker: Sticker): string = + let preferredUsername = getSetting[string](Setting.PreferredUsername, "") + callPrivateRPC("sendChatMessage".prefix, %* [ + { + "chatId": chatId, + "text": "Update to latest version to see a nice sticker here!", + "responseTo": replyTo, + "ensName": preferredUsername, + "sticker": { + "hash": sticker.hash, + "pack": sticker.packId + }, + "contentType": ContentType.Sticker.int + } + ]) + +proc markAllRead*(chatId: string): string = + callPrivateRPC("markAllRead".prefix, %* [chatId]) + +proc markMessagesSeen*(chatId: string, messageIds: seq[string]): string = + callPrivateRPC("markMessagesSeen".prefix, %* [chatId, messageIds]) + +proc confirmJoiningGroup*(chatId: string): string = + callPrivateRPC("confirmJoiningGroup".prefix, %* [chatId]) + +proc leaveGroupChat*(chatId: string): string = + callPrivateRPC("leaveGroupChat".prefix, %* [nil, chatId, true]) + +proc clearChatHistory*(chatId: string): string = + callPrivateRPC("deleteMessagesByChatID".prefix, %* [chatId]) + +proc deleteMessage*(messageId: string): string = + callPrivateRPC("deleteMessage".prefix, %* [messageId]) + +proc renameGroup*(chatId: string, newName: string): string = + callPrivateRPC("changeGroupChatName".prefix, %* [nil, chatId, newName]) + +proc createGroup*(groupName: string, pubKeys: seq[string]): string = + callPrivateRPC("createGroupChatWithMembers".prefix, %* [nil, groupName, pubKeys]) + +proc createGroupChatFromInvitation*(groupName: string, chatID: string, adminPK: string): string = + callPrivateRPC("createGroupChatFromInvitation".prefix, %* [groupName, chatID, adminPK]) + +proc addGroupMembers*(chatId: string, pubKeys: seq[string]): string = + callPrivateRPC("addMembersToGroupChat".prefix, %* [nil, chatId, pubKeys]) + +proc kickGroupMember*(chatId: string, pubKey: string): string = + callPrivateRPC("removeMemberFromGroupChat".prefix, %* [nil, chatId, pubKey]) + +proc makeAdmin*(chatId: string, pubKey: string): string = + callPrivateRPC("addAdminsToGroupChat".prefix, %* [nil, chatId, [pubKey]]) + +proc updateOutgoingMessageStatus*(messageId: string, status: string): string = + result = callPrivateRPC("updateMessageOutgoingStatus".prefix, %* [messageId, status]) + # TODO: handle errors + +proc reSendChatMessage*(messageId: string): string = + result = callPrivateRPC("reSendChatMessage".prefix, %*[messageId]) + +proc muteChat*(chatId: string): string = + result = callPrivateRPC("muteChat".prefix, %*[chatId]) + +proc unmuteChat*(chatId: string): string = + result = callPrivateRPC("unmuteChat".prefix, %*[chatId]) + +proc getLinkPreviewData*(link: string, success: var bool): JsonNode = + let + responseStr = callPrivateRPC("getLinkPreviewData".prefix, %*[link]) + response = Json.decode(responseStr, RpcResponseTyped[JsonNode], allowUnknownFields = false) + + if not response.error.isNil: + success = false + return %* { "error": fmt"""Error getting link preview data for '{link}': {response.error.message}""" } + + success = true + response.result + +proc getAllComunities*(): seq[Community] = + var communities: seq[Community] = @[] + let rpcResult = callPrivateRPC("communities".prefix).parseJSON() + if rpcResult{"result"}.kind != JNull: + for jsonCommunity in rpcResult["result"]: + var community = jsonCommunity.toCommunity() + + communities.add(community) + return communities + +proc getJoinedComunities*(): seq[Community] = + var communities: seq[Community] = @[] + let rpcResult = callPrivateRPC("joinedCommunities".prefix).parseJSON() + if rpcResult{"result"}.kind != JNull: + for jsonCommunity in rpcResult["result"]: + var community = jsonCommunity.toCommunity() + + communities.add(community) + return communities + +proc createCommunity*(name: string, description: string, access: int, ensOnly: bool, color: string, imageUrl: string, aX: int, aY: int, bX: int, bY: int): Community = + let rpcResult = callPrivateRPC("createCommunity".prefix, %*[{ + # TODO this will need to be renamed membership (small m) + "Membership": access, + "name": name, + "description": description, + "ensOnly": ensOnly, + "color": color, + "image": imageUrl, + "imageAx": aX, + "imageAy": aY, + "imageBx": bX, + "imageBy": bY + }]).parseJSON() + + if rpcResult{"error"} != nil: + let error = Json.decode($rpcResult{"error"}, RpcError) + raise newException(RpcException, "Error creating community: " & error.message) + + if rpcResult{"result"} != nil and rpcResult{"result"}.kind != JNull: + result = rpcResult["result"]["communities"][0].toCommunity() + +proc editCommunity*(communityId: string, name: string, description: string, access: int, ensOnly: bool, color: string, imageUrl: string, aX: int, aY: int, bX: int, bY: int): Community = + let rpcResult = callPrivateRPC("editCommunity".prefix, %*[{ + # TODO this will need to be renamed membership (small m) + "CommunityID": communityId, + "Membership": access, + "name": name, + "description": description, + "ensOnly": ensOnly, + "color": color, + "image": imageUrl, + "imageAx": aX, + "imageAy": aY, + "imageBx": bX, + "imageBy": bY + }]).parseJSON() + + if rpcResult{"error"} != nil: + let error = Json.decode($rpcResult{"error"}, RpcError) + raise newException(RpcException, "Error editing community: " & error.message) + + if rpcResult{"result"} != nil and rpcResult{"result"}.kind != JNull: + result = rpcResult["result"]["communities"][0].toCommunity() + +proc createCommunityChannel*(communityId: string, name: string, description: string): Chat = + let rpcResult = callPrivateRPC("createCommunityChat".prefix, %*[ + communityId, + { + "permissions": { + "access": 1 # TODO get this from user selected privacy setting + }, + "identity": { + "display_name": name, + "description": description#, + # "color": color#, + # TODO add images once it is supported by Status-Go + # "images": [ + # { + # "payload": image, + # # TODO get that from an enum + # "image_type": 1 # 1 is a raw payload + # } + # ] + } + }]).parseJSON() + + if rpcResult{"error"} != nil: + let error = Json.decode($rpcResult{"error"}, RpcError) + raise newException(RpcException, "Error creating community channel: " & error.message) + + if rpcResult{"result"} != nil and rpcResult{"result"}.kind != JNull: + result = rpcResult["result"]["chats"][0].toChat() + +proc editCommunityChannel*(communityId: string, channelId: string, name: string, description: string, categoryId: string): Chat = + let rpcResult = callPrivateRPC("editCommunityChat".prefix, %*[ + communityId, + channelId.replace(communityId, ""), + { + "permissions": { + "access": 1 # TODO get this from user selected privacy setting + }, + "identity": { + "display_name": name, + "description": description#, + # "color": color#, + # TODO add images once it is supported by Status-Go + # "images": [ + # { + # "payload": image, + # # TODO get that from an enum + # "image_type": 1 # 1 is a raw payload + # } + # ] + }, + "category_id": categoryId + }]).parseJSON() + + if rpcResult{"error"} != nil: + let error = Json.decode($rpcResult{"error"}, RpcError) + raise newException(RpcException, "Error editing community channel: " & error.message) + + if rpcResult{"result"} != nil and rpcResult{"result"}.kind != JNull: + result = rpcResult["result"]["chats"][0].toChat() + +proc deleteCommunityChat*(communityId: string, chatId: string) = + discard callPrivateRPC("deleteCommunityChat".prefix, %*[communityId, chatId]) + +proc createCommunityCategory*(communityId: string, name: string, channels: seq[string]): CommunityCategory = + let rpcResult = callPrivateRPC("createCommunityCategory".prefix, %*[ + { + "communityId": communityId, + "categoryName": name, + "chatIds": channels + }]).parseJSON() + + if rpcResult.contains("error"): + raise newException(StatusGoException, rpcResult["error"]["message"].getStr()) + else: + for k, v in rpcResult["result"]["communityChanges"].getElems()[0]["categoriesAdded"].pairs(): + result.id = v["category_id"].getStr() + result.name = v["name"].getStr() + result.position = v{"position"}.getInt() + + +proc editCommunityCategory*(communityId: string, categoryId: string, name: string, channels: seq[string]) = + let rpcResult = callPrivateRPC("editCommunityCategory".prefix, %*[ + { + "communityId": communityId, + "categoryId": categoryId, + "categoryName": name, + "chatIds": channels + }]).parseJSON() + if rpcResult.contains("error"): + raise newException(StatusGoException, rpcResult["error"]["message"].getStr()) + +proc reorderCommunityChat*(communityId: string, categoryId: string, chatId: string, position: int) = + let rpcResult = callPrivateRPC("reorderCommunityChat".prefix, %*[ + { + "communityId": communityId, + "categoryId": categoryId, + "chatId": chatId, + "position": position + }]).parseJSON() + if rpcResult.contains("error"): + raise newException(StatusGoException, rpcResult["error"]["message"].getStr()) + +proc reorderCommunityCategories*(communityId: string, categoryId: string, position: int) = + let rpcResult = callPrivateRPC("reorderCommunityCategories".prefix, %*[ + { + "communityId": communityId, + "categoryId": categoryId, + "position": position + }]).parseJSON() + if rpcResult.contains("error"): + raise newException(StatusGoException, rpcResult["error"]["message"].getStr()) + + +proc deleteCommunityCategory*(communityId: string, categoryId: string) = + let rpcResult = callPrivateRPC("deleteCommunityCategory".prefix, %*[ + { + "communityId": communityId, + "categoryId": categoryId + }]).parseJSON() + if rpcResult.contains("error"): + raise newException(StatusGoException, rpcResult["error"]["message"].getStr()) + +proc requestCommunityInfo*(communityId: string) = + discard callPrivateRPC("requestCommunityInfoFromMailserver".prefix, %*[communityId]) + +proc joinCommunity*(communityId: string) = + discard callPrivateRPC("joinCommunity".prefix, %*[communityId]) + +proc leaveCommunity*(communityId: string) = + discard callPrivateRPC("leaveCommunity".prefix, %*[communityId]) + +proc inviteUsersToCommunity*(communityId: string, pubKeys: seq[string]) = + discard callPrivateRPC("inviteUsersToCommunity".prefix, %*[{ + "communityId": communityId, + "users": pubKeys + }]) + +proc exportCommunity*(communityId: string):string = + result = callPrivateRPC("exportCommunity".prefix, %*[communityId]).parseJson()["result"].getStr + +proc importCommunity*(communityKey: string): string = + return callPrivateRPC("importCommunity".prefix, %*[communityKey]) + +proc removeUserFromCommunity*(communityId: string, pubKey: string) = + discard callPrivateRPC("removeUserFromCommunity".prefix, %*[communityId, pubKey]) + +proc requestToJoinCommunity*(communityId: string, ensName: string): seq[CommunityMembershipRequest] = + let rpcResult = callPrivateRPC("requestToJoinCommunity".prefix, %*[{ + "communityId": communityId, + "ensName": ensName + }]).parseJSON() + + var communityRequests: seq[CommunityMembershipRequest] = @[] + if rpcResult{"result"}{"requestsToJoinCommunity"} != nil and rpcResult{"result"}{"requestsToJoinCommunity"}.kind != JNull: + for jsonCommunityReqest in rpcResult["result"]["requestsToJoinCommunity"]: + communityRequests.add(jsonCommunityReqest.toCommunityMembershipRequest()) + + return communityRequests + +proc acceptRequestToJoinCommunity*(requestId: string) = + discard callPrivateRPC("acceptRequestToJoinCommunity".prefix, %*[{ + "id": requestId + }]) + +proc declineRequestToJoinCommunity*(requestId: string) = + discard callPrivateRPC("declineRequestToJoinCommunity".prefix, %*[{ + "id": requestId + }]) + +proc pendingRequestsToJoinForCommunity*(communityId: string): seq[CommunityMembershipRequest] = + let rpcResult = callPrivateRPC("pendingRequestsToJoinForCommunity".prefix, %*[communityId]).parseJSON() + + var communityRequests: seq[CommunityMembershipRequest] = @[] + + if rpcResult{"result"}.kind != JNull: + for jsonCommunityReqest in rpcResult["result"]: + communityRequests.add(jsonCommunityReqest.toCommunityMembershipRequest()) + + return communityRequests + +proc myPendingRequestsToJoin*(): seq[CommunityMembershipRequest] = + let rpcResult = callPrivateRPC("myPendingRequestsToJoin".prefix).parseJSON() + var communityRequests: seq[CommunityMembershipRequest] = @[] + + if rpcResult.hasKey("result") and rpcResult{"result"}.kind != JNull: + for jsonCommunityReqest in rpcResult["result"]: + communityRequests.add(jsonCommunityReqest.toCommunityMembershipRequest()) + + return communityRequests + +proc banUserFromCommunity*(pubKey: string, communityId: string): string = + return callPrivateRPC("banUserFromCommunity".prefix, %*[{ + "communityId": communityId, + "user": pubKey + }]) + +proc setCommunityMuted*(communityId: string, muted: bool) = + discard callPrivateRPC("setCommunityMuted".prefix, %*[communityId, muted]) + +proc rpcPinnedChatMessages*(chatId: string, cursorVal: string, limit: int, success: var bool): string = + success = true + try: + result = callPrivateRPC("chatPinnedMessages".prefix, %* [chatId, cursorVal, limit]) + except RpcException as e: + success = false + result = e.msg + +proc setPinMessage*(messageId: string, chatId: string, pinned: bool) = + discard callPrivateRPC("sendPinMessage".prefix, %*[{ + "message_id": messageId, + "pinned": pinned, + "chat_id": chatId + }]) + +proc rpcActivityCenterNotifications*(cursorVal: JsonNode, limit: int, success: var bool): string = + success = true + try: + result = callPrivateRPC("activityCenterNotifications".prefix, %* [cursorVal, limit]) + except RpcException as e: + success = false + result = e.msg + +proc activityCenterNotification*(cursor: string = ""): (string, seq[ActivityCenterNotification]) = + var cursorVal: JsonNode + + if cursor == "": + cursorVal = newJNull() + else: + cursorVal = newJString(cursor) + + var success: bool + let callResult = rpcActivityCenterNotifications(cursorVal, 20, success) + if success: + result = parseActivityCenterNotifications(callResult.parseJson()["result"]) + +proc markAllActivityCenterNotificationsRead*() = + discard callPrivateRPC("markAllActivityCenterNotificationsRead".prefix, %*[]) + +proc markActivityCenterNotificationsRead*(ids: seq[string]) = + discard callPrivateRPC("markActivityCenterNotificationsRead".prefix, %*[ids]) + +proc acceptActivityCenterNotifications*(ids: seq[string]): string = + result = callPrivateRPC("acceptActivityCenterNotifications".prefix, %*[ids]) + +proc dismissActivityCenterNotifications*(ids: seq[string]): string = + result = callPrivateRPC("dismissActivityCenterNotifications".prefix, %*[ids]) + +proc unreadActivityCenterNotificationsCount*(): int = + let rpcResult = callPrivateRPC("unreadActivityCenterNotificationsCount".prefix, %*[]).parseJson + + if rpcResult{"result"}.kind != JNull: + return rpcResult["result"].getInt + +proc asyncSearchMessages*(chatId: string, searchTerm: string, caseSensitive: bool, success: var bool): string = + success = true + try: + result = callPrivateRPC("allMessagesFromChatWhichMatchTerm".prefix, %* [chatId, searchTerm, caseSensitive]) + except RpcException as e: + success = false + result = e.msg + +proc asyncSearchMessages*(communityIds: seq[string], chatIds: seq[string], searchTerm: string, caseSensitive: bool, success: var bool): string = + success = true + try: + result = callPrivateRPC("allMessagesFromChatsAndCommunitiesWhichMatchTerm".prefix, %* [communityIds, chatIds, searchTerm, caseSensitive]) + except RpcException as e: + success = false + result = e.msg diff --git a/status/libstatus/chatCommands.nim b/status/libstatus/chatCommands.nim new file mode 100644 index 0000000..679bd14 --- /dev/null +++ b/status/libstatus/chatCommands.nim @@ -0,0 +1,20 @@ +import json, chronicles +import core, ../utils + +proc acceptRequestAddressForTransaction*(messageId: string, address: string): string = + result = callPrivateRPC("acceptRequestAddressForTransaction".prefix, %* [messageId, address]) + +proc declineRequestAddressForTransaction*(messageId: string): string = + result = callPrivateRPC("declineRequestAddressForTransaction".prefix, %* [messageId]) + +proc declineRequestTransaction*(messageId: string): string = + result = callPrivateRPC("declineRequestTransaction".prefix, %* [messageId]) + +proc requestAddressForTransaction*(chatId: string, fromAddress: string, amount: string, tokenAddress: string): string = + result = callPrivateRPC("requestAddressForTransaction".prefix, %* [chatId, fromAddress, amount, tokenAddress]) + +proc requestTransaction*(chatId: string, fromAddress: string, amount: string, tokenAddress: string): string = + result = callPrivateRPC("requestTransaction".prefix, %* [chatId, amount, tokenAddress, fromAddress]) + +proc acceptRequestTransaction*(transactionHash: string, messageId: string, signature: string): string = + result = callPrivateRPC("acceptRequestTransaction".prefix, %* [transactionHash, messageId, signature]) diff --git a/status/libstatus/coder.nim b/status/libstatus/coder.nim new file mode 100644 index 0000000..c058ee0 --- /dev/null +++ b/status/libstatus/coder.nim @@ -0,0 +1,77 @@ +import macros +import web3/[encoding, ethtypes], stint + +type + GetPackData* = object + packId*: Stuint[256] + + PackData* = object + category*: DynamicBytes[32] # bytes4[] + owner*: Address # address + mintable*: bool # bool + timestamp*: Stuint[256] # uint256 + price*: Stuint[256] # uint256 + contentHash*: DynamicBytes[64] # bytes + + BuyToken* = object + packId*: Stuint[256] + address*: Address + price*: Stuint[256] + + Register* = object + label*: FixedBytes[32] + account*: Address + x*: FixedBytes[32] + y*: FixedBytes[32] + + SetPubkey* = object + label*: FixedBytes[32] + x*: FixedBytes[32] + y*: FixedBytes[32] + + ExpirationTime* = object + label*: FixedBytes[32] + + Release* = object + label*: FixedBytes[32] + + ApproveAndCall*[N: static[int]] = object + to*: Address + value*: Stuint[256] + data*: DynamicBytes[N] + + Transfer* = object + to*: Address + value*: Stuint[256] + + BalanceOf* = object + address*: Address + + TokenOfOwnerByIndex* = object + address*: Address + index*: Stuint[256] + + TokenPackId* = object + tokenId*: Stuint[256] + + TokenUri* = object + tokenId*: Stuint[256] + +# TODO: Figure out a way to parse a bool as a Bool instead of bool, as it is +# done in nim-web3 +func decode*(input: string, offset: int, to: var bool): int {.inline.} = + let val = input[offset..offset+63].parse(Int256) + to = val.truncate(int) == 1 + 64 + +# TODO: This is taken directly from nim-web3 in order to be able to decode +# booleans. I could not get the type Bool, as used in nim-web3, to be decoded +# properly, and instead resorted to a standard bool type. +func decodeHere*(input: string, offset: int, obj: var object): int = + var offset = offset + for field in fields(obj): + offset += decode(input, offset, field) + +func decodeContractResponse*[T](input: string): T = + result = T() + discard decodeHere(input.strip0xPrefix, 0, result) \ No newline at end of file diff --git a/status/libstatus/contacts.nim b/status/libstatus/contacts.nim new file mode 100644 index 0000000..4e54ab8 --- /dev/null +++ b/status/libstatus/contacts.nim @@ -0,0 +1,45 @@ +import json, strmisc, atomics +import core, ../utils + +var + contacts {.threadvar.}: JsonNode + contactsInited {.threadvar.}: bool + dirty: Atomic[bool] + +proc getContactByID*(id: string): string = + result = callPrivateRPC("getContactByID".prefix, %* [id]) + dirty.store(true) + +proc getContacts*(): JsonNode = + let cacheIsDirty = (not contactsInited) or dirty.load + if not cacheIsDirty: + result = contacts + else: + let payload = %* [] + let response = callPrivateRPC("contacts".prefix, payload).parseJson + if response["result"].kind == JNull: + result = %* [] + else: + result = response["result"] + dirty.store(false) + contacts = result + contactsInited = true + +proc saveContact*(id: string, ensVerified: bool, ensName: string, alias: string, identicon: string, thumbnail: string, systemTags: seq[string], localNickname: string): string = + let payload = %* [{ + "id": id, + "name": ensName, + "ensVerified": ensVerified, + "alias": alias, + "identicon": identicon, + "images": {"thumbnail": {"Payload": thumbnail.partition(",")[2]}}, + "systemTags": systemTags, + "localNickname": localNickname + }] + # TODO: StatusGoError handling + result = callPrivateRPC("saveContact".prefix, payload) + dirty.store(true) + +proc requestContactUpdate*(publicKey: string): string = + result = callPrivateRPC("sendContactUpdate".prefix, %* [publicKey, "", ""]) + dirty.store(true) diff --git a/status/libstatus/conversions.nim b/status/libstatus/conversions.nim new file mode 100644 index 0000000..5eee546 --- /dev/null +++ b/status/libstatus/conversions.nim @@ -0,0 +1,29 @@ +import + json, options, strutils + +import + web3/[conversions, ethtypes], stint + +# TODO: make this public in nim-web3 lib +template stripLeadingZeros*(value: string): string = + var cidx = 0 + # ignore the last character so we retain '0' on zero value + while cidx < value.len - 1 and value[cidx] == '0': + cidx.inc + value[cidx .. ^1] + +# TODO: update this in nim-web3 +proc `%`*(x: EthSend): JsonNode = + result = newJobject() + result["from"] = %x.source + if x.to.isSome: + result["to"] = %x.to.unsafeGet + if x.gas.isSome: + result["gas"] = %x.gas.unsafeGet + if x.gasPrice.isSome: + result["gasPrice"] = %("0x" & x.gasPrice.unsafeGet.toHex.stripLeadingZeros) + if x.value.isSome: + result["value"] = %("0x" & x.value.unsafeGet.toHex) + result["data"] = %x.data + if x.nonce.isSome: + result["nonce"] = %x.nonce.unsafeGet \ No newline at end of file diff --git a/status/libstatus/core.nim b/status/libstatus/core.nim new file mode 100644 index 0000000..b817174 --- /dev/null +++ b/status/libstatus/core.nim @@ -0,0 +1,59 @@ +import json, nimcrypto, chronicles +import status_go, ../utils + +logScope: + topics = "rpc" + +proc callRPC*(inputJSON: string): string = + return $status_go.callRPC(inputJSON) + +proc callPrivateRPCRaw*(inputJSON: string): string = + return $status_go.callPrivateRPC(inputJSON) + +proc callPrivateRPC*(methodName: string, payload = %* []): string = + try: + let inputJSON = %* { + "jsonrpc": "2.0", + "method": methodName, + "params": %payload + } + debug "callPrivateRPC", rpc_method=methodName + let response = status_go.callPrivateRPC($inputJSON) + result = $response + if parseJSON(result).hasKey("error"): + writeStackTrace() + error "rpc response error", result, payload, methodName + except Exception as e: + error "error doing rpc request", methodName = methodName, exception=e.msg + +proc sendTransaction*(inputJSON: string, password: string): string = + var hashed_password = "0x" & $keccak_256.digest(password) + return $status_go.sendTransaction(inputJSON, hashed_password) + +proc startMessenger*() = + discard callPrivateRPC("startMessenger".prefix) + +proc addPeer*(peer: string) = + discard callPrivateRPC("admin_addPeer", %* [peer]) + +proc removePeer*(peer: string) = + discard callPrivateRPC("admin_removePeer", %* [peer]) + +proc markTrustedPeer*(peer: string) = + discard callPrivateRPC("markTrustedPeer".prefix(false), %* [peer]) + +proc getBlockByNumber*(blockNumber: string): string = + result = callPrivateRPC("eth_getBlockByNumber", %* [blockNumber, false]) + +proc getTransfersByAddress*(address: string, toBlock: string, limit: string, fetchMore: bool = false): string = + let toBlockParsed = if not fetchMore: newJNull() else: %toBlock + result = callPrivateRPC("wallet_getTransfersByAddress", %* [address, toBlockParsed, limit, fetchMore]) + +proc signMessage*(rpcParams: string): string = + return $status_go.signMessage(rpcParams) + +proc signTypedData*(data: string, address: string, password: string): string = + return $status_go.signTypedData(data, address, password) + +proc getBloomFilter*(): string = + return $callPrivateRPC("bloomFilter".prefix, %* []).parseJSON()["result"].getStr diff --git a/status/libstatus/edn_helpers.nim b/status/libstatus/edn_helpers.nim new file mode 100644 index 0000000..f2873fe --- /dev/null +++ b/status/libstatus/edn_helpers.nim @@ -0,0 +1,83 @@ +import typetraits +import edn, chronicles +import ../types/[sticker] # FIXME: there should be no type deps + +# forward declaration: +proc parseNode[T](node: EdnNode, searchName: string): T +proc parseMap[T](map: HMap, searchName: string,): T + +proc getValueFromNode[T](node: EdnNode): T = + if node.kind == EdnSymbol: + when T is string: + result = node.symbol.name + elif node.kind == EdnKeyword: + when T is string: + result = node.keyword.name + elif node.kind == EdnString: + when T is string: + result = node.str + elif node.kind == EdnCharacter: + when T is string: + result = node.character + elif node.kind == EdnBool: + when T is bool: + result = node.boolVal + elif node.kind == EdnInt: + when T is int: + try: + result = cast[int](node.num) + except: + warn "Returned 0 value for node, when value should have been ", val = $node.num + result = 0 + else: + raise newException(ValueError, "couldn't get '" & T.type.name & "'value from node: " & repr(node)) + +proc parseVector[T: seq[Sticker]](node: EdnNode, searchName: string): seq[Sticker] = + # TODO: make this generic to accept any seq[T]. Issue is that instantiating result + # like `result = T()` is not allowed when T is `seq[Sticker]` + # because seq[Sticker] isn't an object, whereas it works when T is + # an object type (like Sticker). IOW, Sticker is an object type, but seq[Sticker] + # is not + result = newSeq[Sticker]() + + for i in 0.. 0: + for iChild in 0.. contract.name == name and contract.network == network) + result = if found.len > 0: found[0] else: nil + +proc getContract*(name: string): Contract = + let network = settings.getCurrentNetwork() + getContract(network, name) + +proc getErc20ContractBySymbol*(contracts: seq[Erc20Contract], symbol: string): Erc20Contract = + let found = contracts.filter(contract => contract.symbol.toLower == symbol.toLower) + result = if found.len > 0: found[0] else: nil + +proc getErc20ContractByAddress*(contracts: seq[Erc20Contract], address: Address): Erc20Contract = + let found = contracts.filter(contract => contract.address == address) + result = if found.len > 0: found[0] else: nil + +proc getErc20Contract*(symbol: string): Erc20Contract = + let network = settings.getCurrentNetwork() + result = allContracts().filter(contract => contract.network == network and contract of Erc20Contract).map(contract => Erc20Contract(contract)).getErc20ContractBySymbol(symbol) + +proc getErc20Contract*(address: Address): Erc20Contract = + let network = settings.getCurrentNetwork() + result = allContracts().filter(contract => contract.network == network and contract of Erc20Contract).map(contract => Erc20Contract(contract)).getErc20ContractByAddress(address) + +proc getErc20Contracts*(): seq[Erc20Contract] = + let network = settings.getCurrentNetwork() + result = allContracts().filter(contract => contract of Erc20Contract and contract.network == network).map(contract => Erc20Contract(contract)) + +proc getErc721Contract(network: Network, name: string): Erc721Contract = + let found = allContracts().filter(contract => contract of Erc721Contract and Erc721Contract(contract).name.toLower == name.toLower and contract.network == network) + result = if found.len > 0: Erc721Contract(found[0]) else: nil + +proc getErc721Contract*(name: string): Erc721Contract = + let network = settings.getCurrentNetwork() + getErc721Contract(network, name) + +proc getErc721Contracts*(): seq[Erc721Contract] = + let network = settings.getCurrentNetwork() + result = allContracts().filter(contract => contract of Erc721Contract and contract.network == network).map(contract => Erc721Contract(contract)) + +proc getSntContract*(): Erc20Contract = + if settings.getCurrentNetwork() == Network.Mainnet: + result = getErc20Contract("snt") + else: + result = getErc20Contract("stt") + if result == nil: + # TODO: xDai network does not have an SNT contract listed. We will need to handle + # having no SNT contract in other places in the code (ie anywhere that + # getSntContract() is called) + raise newException(ValueError, "A status contract could not be found for the current network") \ No newline at end of file diff --git a/status/libstatus/eth/eth.nim b/status/libstatus/eth/eth.nim new file mode 100644 index 0000000..23e442c --- /dev/null +++ b/status/libstatus/eth/eth.nim @@ -0,0 +1,23 @@ +import + web3/ethtypes + +import + transactions, ../../types/[rpc_response] + +proc sendTransaction*(tx: var EthSend, password: string, success: var bool): string = + success = true + try: + let response = transactions.sendTransaction(tx, password) + result = response.result + except RpcException as e: + success = false + result = e.msg + +proc estimateGas*(tx: var EthSend, success: var bool): string = + success = true + try: + let response = transactions.estimateGas(tx) + result = response.result + except RpcException as e: + success = false + result = e.msg \ No newline at end of file diff --git a/status/libstatus/eth/methods.nim b/status/libstatus/eth/methods.nim new file mode 100644 index 0000000..85653f4 --- /dev/null +++ b/status/libstatus/eth/methods.nim @@ -0,0 +1,64 @@ +import + strutils, options + +import + nimcrypto, web3/[encoding, ethtypes] + +import + ../../types/[rpc_response], ../coder, eth, transactions + +export sendTransaction + +type Method* = object + name*: string + signature*: string + +proc encodeMethod(self: Method): string = + ($nimcrypto.keccak256.digest(self.signature))[0..<8].toLower + +proc encodeAbi*(self: Method, obj: object = RootObj()): string = + result = "0x" & self.encodeMethod() + + # .fields is an iterator, and there's no way to get a count of an iterator + # in nim, so we have to loop and increment a counter + var fieldCount = 0 + for i in obj.fields: + fieldCount += 1 + var + offset = 32*fieldCount + data = "" + + for field in obj.fields: + let encoded = encode(field) + if encoded.dynamic: + result &= offset.toHex(64).toLower + data &= encoded.data + offset += encoded.data.len + else: + result &= encoded.data + result &= data + +proc estimateGas*(self: Method, tx: var EthSend, methodDescriptor: object, success: var bool): string = + success = true + tx.data = self.encodeAbi(methodDescriptor) + try: + let response = transactions.estimateGas(tx) + result = response.result # gas estimate in hex + except RpcException as e: + success = false + result = e.msg + +proc send*(self: Method, tx: var EthSend, methodDescriptor: object, password: string, success: var bool): string = + tx.data = self.encodeAbi(methodDescriptor) + result = eth.sendTransaction(tx, password, success) + +proc call*[T](self: Method, tx: var EthSend, methodDescriptor: object, success: var bool): T = + success = true + tx.data = self.encodeAbi(methodDescriptor) + let response: RpcResponse + try: + response = transactions.call(tx) + except RpcException as e: + success = false + result = e.msg + result = coder.decodeContractResponse[T](response.result) \ No newline at end of file diff --git a/status/libstatus/eth/transactions.nim b/status/libstatus/eth/transactions.nim new file mode 100644 index 0000000..ee647ff --- /dev/null +++ b/status/libstatus/eth/transactions.nim @@ -0,0 +1,30 @@ +import + json + +import + json_serialization, chronicles, web3/ethtypes + +import + ../core, ../../types/[rpc_response], ../conversions + +proc estimateGas*(tx: EthSend): RpcResponse = + let response = core.callPrivateRPC("eth_estimateGas", %*[%tx]) + result = Json.decode(response, RpcResponse) + if not result.error.isNil: + raise newException(RpcException, "Error getting gas estimate: " & result.error.message) + + trace "Gas estimated succesfully", estimate=result.result + +proc sendTransaction*(tx: EthSend, password: string): RpcResponse = + let responseStr = core.sendTransaction($(%tx), password) + result = Json.decode(responseStr, RpcResponse) + if not result.error.isNil: + raise newException(RpcException, "Error sending transaction: " & result.error.message) + + trace "Transaction sent succesfully", hash=result.result + +proc call*(tx: EthSend): RpcResponse = + let responseStr = core.callPrivateRPC("eth_call", %*[%tx, "latest"]) + result = Json.decode(responseStr, RpcResponse) + if not result.error.isNil: + raise newException(RpcException, "Error calling method: " & result.error.message) \ No newline at end of file diff --git a/status/libstatus/gif.nim b/status/libstatus/gif.nim new file mode 100644 index 0000000..a00afc6 --- /dev/null +++ b/status/libstatus/gif.nim @@ -0,0 +1,16 @@ +import json + +import ./settings +import ../types/[setting] + +proc getRecentGifs*(): JsonNode = + return settings.getSetting[JsonNode](Setting.Gifs_Recent, %*{}) + +proc getFavoriteGifs*(): JsonNode = + return settings.getSetting[JsonNode](Setting.Gifs_Favorite, %*{}) + +proc setFavoriteGifs*(items: JsonNode) = + discard settings.saveSetting(Setting.Gifs_Favorite, items) + +proc setRecentGifs*(items: JsonNode) = + discard settings.saveSetting(Setting.Gifs_Recent, items) \ No newline at end of file diff --git a/status/libstatus/installations.nim b/status/libstatus/installations.nim new file mode 100644 index 0000000..5fdf2c9 --- /dev/null +++ b/status/libstatus/installations.nim @@ -0,0 +1,29 @@ +import json, core, ../utils, system + +var installations: JsonNode = %*{} +var dirty: bool = true + +proc setInstallationMetadata*(installationId: string, deviceName: string, deviceType: string): string = + result = callPrivateRPC("setInstallationMetadata".prefix, %* [installationId, {"name": deviceName, "deviceType": deviceType}]) + # TODO: handle errors + +proc getOurInstallations*(useCached: bool = true): JsonNode = + if useCached and not dirty: + return installations + installations = callPrivateRPC("getOurInstallations".prefix, %* []).parseJSON()["result"] + dirty = false + result = installations + +proc syncDevices*(preferredName: string): string = + # TODO change this to identicon when status-go is updated + let photoPath = "" + result = callPrivateRPC("syncDevices".prefix, %* [preferredName, photoPath]) + +proc sendPairInstallation*(): string = + result = callPrivateRPC("sendPairInstallation".prefix) + +proc enableInstallation*(installationId: string): string = + result = callPrivateRPC("enableInstallation".prefix, %* [installationId]) + +proc disableInstallation*(installationId: string): string = + result = callPrivateRPC("disableInstallation".prefix, %* [installationId]) diff --git a/status/libstatus/mailservers.nim b/status/libstatus/mailservers.nim new file mode 100644 index 0000000..fab51cb --- /dev/null +++ b/status/libstatus/mailservers.nim @@ -0,0 +1,49 @@ +import json, times +import core, ../utils + +proc ping*(mailservers: seq[string], timeoutMs: int): string = + var addresses: seq[string] = @[] + for mailserver in mailservers: + addresses.add(mailserver) + result = callPrivateRPC("mailservers_ping", %* [ + { "addresses": addresses, "timeoutMs": timeoutMs } + ]) + +proc update*(peer: string) = + discard callPrivateRPC("updateMailservers".prefix, %* [[peer]]) + +proc setMailserver*(peer: string): string = + return callPrivateRPC("setMailserver".prefix, %* [peer]) + +proc delete*(peer: string) = + discard callPrivateRPC("mailservers_deleteMailserver", %* [peer]) + +proc requestAllHistoricMessages*(): string = + return callPrivateRPC("requestAllHistoricMessages".prefix, %*[]) + +proc requestStoreMessages*(topics: seq[string], symKeyID: string, peer: string, numberOfMessages: int, fromTimestamp: int64 = 0, toTimestamp: int64 = 0, force: bool = false) = + var toValue = times.toUnix(times.getTime()) + var fromValue = toValue - 86400 + if fromTimestamp != 0: + fromValue = fromTimestamp + if toTimestamp != 0: + toValue = toTimestamp + + echo callPrivateRPC("requestMessages".prefix, %* [ + { + "topics": topics, + "mailServerPeer": "16Uiu2HAmVVi6Q4j7MAKVibquW8aA27UNrA4Q8Wkz9EetGViu8ZF1", + "timeout": 30, + "limit": numberOfMessages, + "cursor": nil, + "from": fromValue, + "to": toValue, + "force": force + } + ]) + +proc syncChatFromSyncedFrom*(chatId: string): string = + return callPrivateRPC("syncChatFromSyncedFrom".prefix, %*[chatId]) + +proc fillGaps*(chatId: string, messageIds: seq[string]): string = + return callPrivateRPC("fillGaps".prefix, %*[chatId, messageIds]) diff --git a/status/libstatus/settings.nim b/status/libstatus/settings.nim new file mode 100644 index 0000000..83ba40d --- /dev/null +++ b/status/libstatus/settings.nim @@ -0,0 +1,215 @@ +import + json, tables, sugar, sequtils, strutils, atomics, os + +import + json_serialization, chronicles, uuids + +import + ./core, ./accounts/constants, ../utils + +import ../types/[setting, network, fleet] +import ../signals/[base] + +from status_go import nil + +var + settings {.threadvar.}: JsonNode + settingsInited {.threadvar.}: bool + dirty: Atomic[bool] + +dirty.store(true) +settings = %* {} + +proc saveSetting*(key: Setting, value: string | JsonNode | bool): StatusGoError = + try: + let response = callPrivateRPC("settings_saveSetting", %* [key, value]) + let responseResult = $(response.parseJSON(){"result"}) + if responseResult == "null": + result.error = "" + else: result = Json.decode(response, StatusGoError) + dirty.store(true) + except Exception as e: + error "Error saving setting", key=key, value=value, msg=e.msg + +proc getWeb3ClientVersion*(): string = + parseJson(callPrivateRPC("web3_clientVersion"))["result"].getStr + +proc getSettings*(useCached: bool = true, keepSensitiveData: bool = false): JsonNode = + let cacheIsDirty = (not settingsInited) or dirty.load + if useCached and (not cacheIsDirty) and (not keepSensitiveData): + result = settings + else: + var + allSettings = callPrivateRPC("settings_getSettings").parseJSON()["result"] + var + noSensitiveData = allSettings.deepCopy + noSensitiveData.delete("mnemonic") + if not keepSensitiveData: + result = noSensitiveData + else: + result = allSettings + dirty.store(false) + settings = noSensitiveData # never include sensitive data in cache + settingsInited = true + +proc getSetting*[T](name: Setting, defaultValue: T, useCached: bool = true): T = + let settings: JsonNode = getSettings(useCached, $name == "mnemonic") + if not settings.contains($name) or settings{$name}.isEmpty(): + return defaultValue + let value = $settings{$name} + try: + result = Json.decode(value, T) + except Exception as e: + error "Error decoding setting", name=name, value=value, msg=e.msg + return defaultValue + +proc getSetting*[T](name: Setting, useCached: bool = true): T = + result = getSetting(name, default(type(T)), useCached) + +proc getCurrentNetwork*(): Network = + case getSetting[string](Setting.Networks_CurrentNetwork, constants.DEFAULT_NETWORK_NAME): + of "mainnet_rpc": + result = Network.Mainnet + of "testnet_rpc": + result = Network.Testnet + of "rinkeby_rpc": + result = Network.Rinkeby + of "goerli_rpc": + result = Network.Goerli + of "xdai_rpc": + result = Network.XDai + of "poa_rpc": + result = Network.Poa + else: + result = Network.Other + +proc getCurrentNetworkDetails*(): NetworkDetails = + let currNetwork = getSetting[string](Setting.Networks_CurrentNetwork, constants.DEFAULT_NETWORK_NAME) + let networks = getSetting[seq[NetworkDetails]](Setting.Networks_Networks) + networks.find((network: NetworkDetails) => network.id == currNetwork) + +proc getLinkPreviewWhitelist*(): JsonNode = + result = callPrivateRPC("getLinkPreviewWhitelist".prefix, %* []).parseJSON()["result"] + +proc getFleet*(): Fleet = + let fleet = getSetting[string](Setting.Fleet, $Fleet.PROD) + result = parseEnum[Fleet](fleet) + +proc getPinnedMailserver*(): string = + let pinnedMailservers = getSetting[JsonNode](Setting.PinnedMailservers, %*{}) + let fleet = getSetting[string](Setting.Fleet, $Fleet.PROD) + return pinnedMailservers{fleet}.getStr() + +proc pinMailserver*(enode: string = "") = + let pinnedMailservers = getSetting[JsonNode](Setting.PinnedMailservers, %*{}) + let fleet = getSetting[string](Setting.Fleet, $Fleet.PROD) + + pinnedMailservers[fleet] = newJString(enode) + discard saveSetting(Setting.PinnedMailservers, pinnedMailservers) + +proc saveMailserver*(name, enode: string) = + let fleet = getSetting[string](Setting.Fleet, $Fleet.PROD) + let result = callPrivateRPC("mailservers_addMailserver", %* [ + %*{ + "id": $genUUID(), + "name": name, + "address": enode, + "fleet": $fleet + } + ]).parseJSON()["result"] + +proc getMailservers*():JsonNode = + let fleet = getSetting[string](Setting.Fleet, $Fleet.PROD) + result = callPrivateRPC("mailservers_getMailservers").parseJSON()["result"] + +proc getNodeConfig*():JsonNode = + result = status_go.getNodeConfig().parseJSON() + + # setting correct values in json + let currNetwork = getSetting[string](Setting.Networks_CurrentNetwork, constants.DEFAULT_NETWORK_NAME) + let networks = getSetting[JsonNode](Setting.Networks_Networks) + let networkConfig = networks.getElems().find((n:JsonNode) => n["id"].getStr() == currNetwork) + var newDataDir = networkConfig["config"]["DataDir"].getStr + newDataDir.removeSuffix("_rpc") + result["DataDir"] = newDataDir.newJString() + result["KeyStoreDir"] = newJString("./keystore") + result["LogFile"] = newJString("./geth.log") + result["ShhextConfig"]["BackupDisabledDataDir"] = newJString("./") + +proc getWakuVersion*():int = + let nodeConfig = getNodeConfig() + if nodeConfig["WakuConfig"]["Enabled"].getBool(): + return 1 + if nodeConfig["WakuV2Config"]["Enabled"].getBool(): + return 2 + return 0 + +proc setWakuVersion*(newVersion: int) = + let nodeConfig = getNodeConfig() + nodeConfig["RegisterTopics"] = %* @["whispermail"] + if newVersion == 1: + nodeConfig["WakuConfig"]["Enabled"] = newJBool(true) + nodeConfig["WakuV2Config"]["Enabled"] = newJBool(false) + nodeConfig["NoDiscovery"] = newJBool(false) + nodeConfig["Rendezvous"] = newJBool(true) + else: + nodeConfig["WakuConfig"]["Enabled"] = newJBool(false) + nodeConfig["WakuV2Config"]["Enabled"] = newJBool(true) + nodeConfig["NoDiscovery"] = newJBool(true) + nodeConfig["Rendezvous"] = newJBool(false) + discard saveSetting(Setting.NodeConfig, nodeConfig) + +proc setNetwork*(network: string): StatusGoError = + let statusGoResult = saveSetting(Setting.Networks_CurrentNetwork, network) + if statusGoResult.error != "": + return statusGoResult + + let networks = getSetting[JsonNode](Setting.Networks_Networks) + let networkConfig = networks.getElems().find((n:JsonNode) => n["id"].getStr() == network) + + var nodeConfig = getNodeConfig() + let upstreamUrl = networkConfig["config"]["UpstreamConfig"]["URL"] + var newDataDir = networkConfig["config"]["DataDir"].getStr + newDataDir.removeSuffix("_rpc") + + nodeConfig["NetworkId"] = networkConfig["config"]["NetworkId"] + nodeConfig["DataDir"] = newDataDir.newJString() + nodeConfig["UpstreamConfig"]["Enabled"] = networkConfig["config"]["UpstreamConfig"]["Enabled"] + nodeConfig["UpstreamConfig"]["URL"] = upstreamUrl + + return saveSetting(Setting.NodeConfig, nodeConfig) + +proc setBloomFilterMode*(bloomFilterMode: bool): StatusGoError = + let statusGoResult = saveSetting(Setting.WakuBloomFilterMode, bloomFilterMode) + if statusGoResult.error != "": + return statusGoResult + var nodeConfig = getNodeConfig() + nodeConfig["WakuConfig"]["BloomFilterMode"] = newJBool(bloomFilterMode) + return saveSetting(Setting.NodeConfig, nodeConfig) + +proc setBloomLevel*(bloomFilterMode: bool, fullNode: bool): StatusGoError = + let statusGoResult = saveSetting(Setting.WakuBloomFilterMode, bloomFilterMode) + if statusGoResult.error != "": + return statusGoResult + var nodeConfig = getNodeConfig() + nodeConfig["WakuConfig"]["BloomFilterMode"] = newJBool(bloomFilterMode) + nodeConfig["WakuConfig"]["FullNode"] = newJBool(fullNode) + nodeConfig["WakuConfig"]["LightClient"] = newJBool(not fullNode) + return saveSetting(Setting.NodeConfig, nodeConfig) + +proc setFleet*(fleetConfig: FleetConfig, fleet: Fleet): StatusGoError = + let statusGoResult = saveSetting(Setting.Fleet, $fleet) + if statusGoResult.error != "": + return statusGoResult + + var nodeConfig = getNodeConfig() + nodeConfig["ClusterConfig"]["Fleet"] = newJString($fleet) + nodeConfig["ClusterConfig"]["BootNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Bootnodes) + nodeConfig["ClusterConfig"]["TrustedMailServers"] = %* fleetConfig.getNodes(fleet, FleetNodes.Mailservers) + nodeConfig["ClusterConfig"]["StaticNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Whisper) + nodeConfig["ClusterConfig"]["RendezvousNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Rendezvous) + nodeConfig["ClusterConfig"]["WakuNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Waku) + nodeConfig["ClusterConfig"]["WakuStoreNodes"] = %* fleetConfig.getNodes(fleet, FleetNodes.Waku) + + return saveSetting(Setting.NodeConfig, nodeConfig) + diff --git a/status/libstatus/stickers.nim b/status/libstatus/stickers.nim new file mode 100644 index 0000000..71d22b1 --- /dev/null +++ b/status/libstatus/stickers.nim @@ -0,0 +1,234 @@ +import # std libs + atomics, json, tables, sequtils, httpclient, net +from strutils import parseHexInt, parseInt + +import # vendor libs + json_serialization, chronicles, libp2p/[multihash, multicodec, cid], stint, + web3/[ethtypes, conversions] +from nimcrypto import fromHex + +import # status-desktop libs + ./core as status, ../types/[sticker, setting, rpc_response], + ./eth/contracts, ./settings, ./edn_helpers + +proc decodeContentHash*(value: string): string = + if value == "": + return "" + + # eg encoded sticker multihash cid: + # e30101701220eab9a8ef4eac6c3e5836a3768d8e04935c10c67d9a700436a0e53199e9b64d29 + # e3017012205c531b83da9dd91529a4cf8ecd01cb62c399139e6f767e397d2f038b820c139f (testnet) + # e3011220c04c617170b1f5725070428c01280b4c19ae9083b7e6d71b7a0d2a1b5ae3ce30 (testnet) + # + # The first 4 bytes (in hex) represent: + # e3 = codec identifier "ipfs-ns" for content-hash + # 01 = unused - sometimes this is NOT included (ie ropsten) + # 01 = CID version (effectively unused, as we will decode with CIDv0 regardless) + # 70 = codec identifier "dag-pb" + + # ipfs-ns + if value[0..1] != "e3": + warn "Could not decode sticker. It may still be valid, but requires a different codec to be used", hash=value + return "" + + try: + # dag-pb + let defaultCodec = parseHexInt("70") #dag-pb + var codec = defaultCodec # no codec specified + var codecStartIdx = 2 # idx of where codec would start if it was specified + # handle the case when starts with 0xe30170 instead of 0xe3010170 + if value[2..5] == "0101": + codecStartIdx = 6 + codec = parseHexInt(value[6..7]) + elif value[2..3] == "01" and value[4..5] != "12": + codecStartIdx = 4 + codec = parseHexInt(value[4..5]) + + # strip the info we no longer need + var multiHashStr = value[codecStartIdx + 2.. -1: + visibleTokenList.del(symbolIdx) + else: + visibleTokenList.add symbol + visibleTokens[$currentNetwork] = newJArray() + visibleTokens[$currentNetwork] = %* visibleTokenList + let saved = saveSetting(Setting.VisibleTokens, $visibleTokens) + + convertStringSeqToERC20ContractSeq(visibleTokenList) + +proc hideAsset*(symbol: string) = + let currentNetwork = getCurrentNetwork() + let visibleTokens = visibleTokensSNTDefault() + var visibleTokenList = visibleTokens[$currentNetwork].to(seq[string]) + var symbolIdx = visibleTokenList.find(symbol) + if symbolIdx > -1: + visibleTokenList.del(symbolIdx) + visibleTokens[$currentNetwork] = newJArray() + visibleTokens[$currentNetwork] = %* visibleTokenList + discard saveSetting(Setting.VisibleTokens, $visibleTokens) + +proc getVisibleTokens*(): seq[Erc20Contract] = + let currentNetwork = getCurrentNetwork() + let visibleTokens = visibleTokensSNTDefault() + var visibleTokenList = visibleTokens[$currentNetwork].to(seq[string]) + let customTokens = getCustomTokens() + + result = convertStringSeqToERC20ContractSeq(visibleTokenList) + +proc addCustomToken*(address: string, name: string, symbol: string, decimals: int, color: string) = + let payload = %* [{"address": address, "name": name, "symbol": symbol, "decimals": decimals, "color": color}] + discard callPrivateRPC("wallet_addCustomToken", payload) + dirty.store(true) + +proc removeCustomToken*(address: string) = + let payload = %* [address] + echo callPrivateRPC("wallet_deleteCustomToken", payload) + dirty.store(true) + +proc getTokensBalances*(accounts: openArray[string], tokens: openArray[string]): JsonNode = + let payload = %* [accounts, tokens] + let response = callPrivateRPC("wallet_getTokensBalances", payload).parseJson + if response["result"].kind == JNull: + return %* {} + response["result"] + +proc getToken*(tokenAddress: string): Erc20Contract = + getErc20Contracts().concat(getCustomTokens()).getErc20ContractByAddress(tokenAddress.parseAddress) + +proc getTokenBalance*(tokenAddress: string, account: string): string = + var postfixedAccount: string = account + postfixedAccount.removePrefix("0x") + let payload = %* [{ + "to": tokenAddress, "from": account, "data": fmt"0x70a08231000000000000000000000000{postfixedAccount}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + let balance = response.parseJson["result"].getStr + + var decimals = 18 + let address = parseAddress(tokenAddress) + let t = getErc20Contract(address) + let ct = getCustomTokens().getErc20ContractByAddress(address) + if t != nil: + decimals = t.decimals + elif ct != nil: + decimals = ct.decimals + + result = $hex2Token(balance, decimals) + +proc getSNTAddress*(): string = + let snt = contracts.getSntContract() + result = $snt.address + +proc getSNTBalance*(account: string): string = + let snt = contracts.getSntContract() + result = getTokenBalance($snt.address, account) + +proc getTokenString*(contract: Contract, methodName: string): string = + let payload = %* [{ + "to": $contract.address, + "data": contract.methods[methodName].encodeAbi() + }, "latest"] + + let responseStr = callPrivateRPC("eth_call", payload) + let response = Json.decode(responseStr, RpcResponse) + if not response.error.isNil: + raise newException(RpcException, "Error getting token string - " & methodName & ": " & response.error.message) + if response.result == "0x": + return "" + + let size = fromHex(Stuint[256], response.result[66..129]).truncate(int) + result = response.result[130..129+size*2].parseHexStr + +proc tokenName*(contract: Contract): string = getTokenString(contract, "name") + +proc tokenSymbol*(contract: Contract): string = getTokenString(contract, "symbol") + +proc tokenDecimals*(contract: Contract): int = + let payload = %* [{ + "to": $contract.address, + "data": contract.methods["decimals"].encodeAbi() + }, "latest"] + + let responseStr = callPrivateRPC("eth_call", payload) + let response = Json.decode(responseStr, RpcResponse) + if not response.error.isNil: + raise newException(RpcException, "Error getting token decimals: " & response.error.message) + if response.result == "0x": + return 0 + result = parseHexInt(response.result) diff --git a/status/libstatus/wallet.nim b/status/libstatus/wallet.nim new file mode 100644 index 0000000..de5b37f --- /dev/null +++ b/status/libstatus/wallet.nim @@ -0,0 +1,147 @@ +import json, json, options, json_serialization, stint, chronicles +import core, conversions, ../types/[transaction, rpc_response], ../utils, strutils, strformat +from status_go import validateMnemonic#, startWallet +import ../wallet/account +import web3/ethtypes + +proc getWalletAccounts*(): seq[WalletAccount] = + try: + var response = callPrivateRPC("accounts_getAccounts") + let accounts = parseJson(response)["result"] + + var walletAccounts:seq[WalletAccount] = @[] + for account in accounts: + if (account["chat"].to(bool) == false): # Might need a better condition + walletAccounts.add(WalletAccount( + address: $account["address"].getStr, + path: $account["path"].getStr, + walletType: if (account.hasKey("type")): $account["type"].getStr else: "", + # Watch accoutns don't have a public key + publicKey: if (account.hasKey("public-key")): $account["public-key"].getStr else: "", + name: $account["name"].getStr, + iconColor: $account["color"].getStr, + wallet: account["wallet"].getBool, + chat: account["chat"].getBool, + )) + result = walletAccounts + except: + let msg = getCurrentExceptionMsg() + error "Failed getting wallet accounts", msg + +proc getTransactionReceipt*(transactionHash: string): string = + result = callPrivateRPC("eth_getTransactionReceipt", %* [transactionHash]) + +proc getTransfersByAddress*(address: string, toBlock: Uint256, limit: int, loadMore: bool = false): seq[Transaction] = + try: + let + toBlockParsed = "0x" & stint.toHex(toBlock) + limitParsed = "0x" & limit.toHex.stripLeadingZeros + transactionsResponse = getTransfersByAddress(address, toBlockParsed, limitParsed, loadMore) + transactions = parseJson(transactionsResponse)["result"] + var accountTransactions: seq[Transaction] = @[] + + for transaction in transactions: + accountTransactions.add(Transaction( + id: transaction["id"].getStr, + typeValue: transaction["type"].getStr, + address: transaction["address"].getStr, + contract: transaction["contract"].getStr, + blockNumber: transaction["blockNumber"].getStr, + blockHash: transaction["blockhash"].getStr, + timestamp: $hex2LocalDateTime(transaction["timestamp"].getStr()), + gasPrice: transaction["gasPrice"].getStr, + gasLimit: transaction["gasLimit"].getStr, + gasUsed: transaction["gasUsed"].getStr, + nonce: transaction["nonce"].getStr, + txStatus: transaction["txStatus"].getStr, + value: transaction["value"].getStr, + fromAddress: transaction["from"].getStr, + to: transaction["to"].getStr + )) + return accountTransactions + except: + let msg = getCurrentExceptionMsg() + error "Failed getting wallet account transactions", msg + +proc getBalance*(address: string): string = + let payload = %* [address, "latest"] + let response = parseJson(callPrivateRPC("eth_getBalance", payload)) + if response.hasKey("error"): + raise newException(RpcException, "Error getting balance: " & $response["error"]) + else: + result = response["result"].str + +proc hex2Eth*(input: string): string = + var value = fromHex(Stuint[256], input) + result = utils.wei2Eth(value) + +proc validateMnemonic*(mnemonic: string): string = + result = $status_go.validateMnemonic(mnemonic) + +proc startWallet*(watchNewBlocks: bool) = + # this will be fixed in a later PR + discard + +proc hex2Token*(input: string, decimals: int): string = + var value = fromHex(Stuint[256], input) + + if decimals == 0: + return fmt"{value}" + + var p = u256(10).pow(decimals) + var i = value.div(p) + var r = value.mod(p) + var leading_zeros = "0".repeat(decimals - ($r).len) + var d = fmt"{leading_zeros}{$r}" + result = $i + if(r > 0): result = fmt"{result}.{d}" + +proc trackPendingTransaction*(hash: string, fromAddress: string, toAddress: string, trxType: PendingTransactionType, data: string) = + let payload = %* [{"hash": hash, "from": fromAddress, "to": toAddress, "type": $trxType, "additionalData": data, "data": "", "value": 0, "timestamp": 0, "gasPrice": 0, "gasLimit": 0}] + discard callPrivateRPC("wallet_storePendingTransaction", payload) + +proc getPendingTransactions*(): string = + let payload = %* [] + try: + result = callPrivateRPC("wallet_getPendingTransactions", payload) + except Exception as e: + error "Error getting pending transactions (possible dev Infura key)", msg = e.msg + result = "" + + +proc getPendingOutboundTransactionsByAddress*(address: string): string = + let payload = %* [address] + result = callPrivateRPC("wallet_getPendingOutboundTransactionsByAddress", payload) + +proc deletePendingTransaction*(transactionHash: string) = + let payload = %* [transactionHash] + discard callPrivateRPC("wallet_deletePendingTransaction", payload) + +proc setInitialBlocksRange*(): string = + let payload = %* [] + result = callPrivateRPC("wallet_setInitialBlocksRange", payload) + +proc watchTransaction*(transactionHash: string): string = + let payload = %* [transactionHash] + result = callPrivateRPC("wallet_watchTransaction", payload) + +proc checkRecentHistory*(addresses: seq[string]): string = + let payload = %* [addresses] + result = callPrivateRPC("wallet_checkRecentHistory", payload) + +proc getOpenseaCollections*(address: string): string = + let payload = %* [address] + result = callPrivateRPC("wallet_getOpenseaCollectionsByOwner", payload) + +proc getOpenseaAssets*(address: string, collectionSlug: string, limit: int): string = + let payload = %* [address, collectionSlug, limit] + result = callPrivateRPC("wallet_getOpenseaAssetsByOwnerAndCollection", payload) + +proc fetchCryptoServices*(success: var bool): string = + success = true + try: + result = callPrivateRPC("wallet_getCryptoOnRamps") + except Exception as e: + success = false + error "Error getting crypto services: ", msg = e.msg + result = "" \ No newline at end of file diff --git a/status/mailservers.nim b/status/mailservers.nim new file mode 100644 index 0000000..42fbfb4 --- /dev/null +++ b/status/mailservers.nim @@ -0,0 +1,21 @@ +import json + +import libstatus/mailservers as status_mailservers +import ../eventemitter + +type + MailserversModel* = ref object + events*: EventEmitter + +proc newMailserversModel*(events: EventEmitter): MailserversModel = + result = MailserversModel() + result.events = events + +proc fillGaps*(self: MailserversModel, chatId: string, messageIds: seq[string]): string = + result = status_mailservers.fillGaps(chatId, messageIds) + +proc setMailserver*(self: MailserversModel, peer: string): string = + result = status_mailservers.setMailserver(peer) + +proc requestAllHistoricMessages*(self: MailserversModel): string = + result = status_mailservers.requestAllHistoricMessages() \ No newline at end of file diff --git a/status/messages.nim b/status/messages.nim new file mode 100644 index 0000000..9c7c037 --- /dev/null +++ b/status/messages.nim @@ -0,0 +1,51 @@ +import tables, sets +import libstatus/chat +import ../eventemitter + +type + MessageDetails* = object + status*: string + chatId*: string + + MessagesModel* = ref object + events*: EventEmitter + messages*: Table[string, MessageDetails] + confirmations*: HashSet[string] + + MessageSentArgs* = ref object of Args + messageId*: string + chatId*: string + +proc newMessagesModel*(events: EventEmitter): MessagesModel = + result = MessagesModel() + result.events = events + result.messages = initTable[string, MessageDetails]() + result.confirmations = initHashSet[string]() + +proc delete*(self: MessagesModel) = + discard + +# For each message sent we call trackMessage to register the message id, +# and wait until an EnvelopeSent signals is emitted for that message. However +# due to communication being async, it's possible that the signal arrives +# first, hence why we check if there's a confirmation (an envelope.sent) +# inside trackMessage to emit the "messageSent" event + +proc trackMessage*(self: MessagesModel, messageId: string, chatId: string) = + if self.messages.hasKey(messageId): return + + self.messages[messageId] = MessageDetails(status: "sending", chatId: chatId) + if self.confirmations.contains(messageId): + self.confirmations.excl(messageId) + self.messages[messageId].status = "sent" + discard updateOutgoingMessageStatus(messageId, "sent") + self.events.emit("messageSent", MessageSentArgs(messageId: messageId, chatId: chatId)) + +proc updateStatus*(self: MessagesModel, messageIds: seq[string]) = + for messageId in messageIds: + if self.messages.hasKey(messageId): + self.messages[messageId].status = "sent" + discard updateOutgoingMessageStatus(messageId, "sent") + self.events.emit("messageSent", MessageSentArgs(messageId: messageId, chatId: self.messages[messageId].chatId)) + else: + self.confirmations.incl(messageId) diff --git a/status/network.nim b/status/network.nim new file mode 100644 index 0000000..95376a5 --- /dev/null +++ b/status/network.nim @@ -0,0 +1,54 @@ +import chronicles +import ../eventemitter +import libstatus/settings +import json +import uuids +import json_serialization +import ./types/[setting] + +logScope: + topics = "network-model" + +type + NetworkModel* = ref object + peers*: seq[string] + events*: EventEmitter + connected*: bool + +proc newNetworkModel*(events: EventEmitter): NetworkModel = + result = NetworkModel() + result.events = events + result.peers = @[] + result.connected = false + +proc peerSummaryChange*(self: NetworkModel, peers: seq[string]) = + if peers.len == 0 and self.connected: + self.connected = false + self.events.emit("network:disconnected", Args()) + + if peers.len > 0 and not self.connected: + self.connected = true + self.events.emit("network:connected", Args()) + + self.peers = peers + +proc peerCount*(self: NetworkModel): int = self.peers.len + +proc isConnected*(self: NetworkModel): bool = self.connected + +proc addNetwork*(self: NetworkModel, name: string, endpoint: string, networkId: int, networkType: string) = + var networks = getSetting[JsonNode](Setting.Networks_Networks) + let id = genUUID() + networks.elems.add(%*{ + "id": $genUUID(), + "name": name, + "config": { + "NetworkId": networkId, + "DataDir": "/ethereum/" & networkType, + "UpstreamConfig": { + "Enabled": true, + "URL": endpoint + } + } + }) + discard saveSetting(Setting.Networks_Networks, networks) diff --git a/status/node.nim b/status/node.nim new file mode 100644 index 0000000..23c1b5c --- /dev/null +++ b/status/node.nim @@ -0,0 +1,16 @@ +import libstatus/core as status +import ../eventemitter + +type NodeModel* = ref object + events*: EventEmitter + +proc newNodeModel*(): NodeModel = + result = NodeModel() + result.events = createEventEmitter() + +proc delete*(self: NodeModel) = + discard + +proc sendRPCMessageRaw*(self: NodeModel, msg: string): string = + echo "sending RPC message" + status.callPrivateRPCRaw(msg) diff --git a/status/notifications/os_notifications.nim b/status/notifications/os_notifications.nim new file mode 100644 index 0000000..a379134 --- /dev/null +++ b/status/notifications/os_notifications.nim @@ -0,0 +1,20 @@ +import json + +import ../types/[os_notification] +import ../../eventemitter + +type OsNotifications* = ref object + events: EventEmitter + +proc delete*(self: OsNotifications) = + discard + +proc newOsNotifications*(events: EventEmitter): OsNotifications = + result = OsNotifications() + result.events = events + +proc onNotificationClicked*(self: OsNotifications, identifier: string) = + ## This slot is called once user clicks a notificaiton bubble, "identifier" + ## contains data which uniquely define that notification. + let details = toOsNotificationDetails(parseJson(identifier)) + self.events.emit("osNotificationClicked", OsNotificationsArgs(details: details)) \ No newline at end of file diff --git a/status/permissions.nim b/status/permissions.nim new file mode 100644 index 0000000..3883ac7 --- /dev/null +++ b/status/permissions.nim @@ -0,0 +1,105 @@ +import json +import strutils +import sets +import libstatus/core +import chronicles +import ../eventemitter +import sequtils + +type + Permission* {.pure.} = enum + Web3 = "web3", + ContactCode = "contact-code" + Unknown = "unknown" + +logScope: + topics = "permissions-model" + +type + PermissionsModel* = ref object + events*: EventEmitter + +proc toPermission*(value: string): Permission = + result = Permission.Unknown + try: + result = parseEnum[Permission](value) + except: + warn "Unknown permission requested", value + +proc newPermissionsModel*(events: EventEmitter): PermissionsModel = + result = PermissionsModel() + result.events = events + +proc init*(self: PermissionsModel) = + discard + +type Dapp* = object + name*: string + permissions*: HashSet[Permission] + +proc getDapps*(self: PermissionsModel): seq[Dapp] = + let response = callPrivateRPC("permissions_getDappPermissions") + result = @[] + for dapps in response.parseJson["result"].getElems(): + var dapp = Dapp( + name: dapps["dapp"].getStr(), + permissions: initHashSet[Permission]() + ) + for permission in dapps["permissions"].getElems(): + dapp.permissions.incl(permission.getStr().toPermission()) + result.add(dapp) + +proc getPermissions*(self: PermissionsModel, dapp: string): HashSet[Permission] = + let response = callPrivateRPC("permissions_getDappPermissions") + result = initHashSet[Permission]() + for dappPermission in response.parseJson["result"].getElems(): + if dappPermission["dapp"].getStr() == dapp: + if not dappPermission.hasKey("permissions"): return + for permission in dappPermission["permissions"].getElems(): + result.incl(permission.getStr().toPermission()) + +proc revoke*(self: PermissionsModel, permission: Permission) = + let response = callPrivateRPC("permissions_getDappPermissions") + var permissions = initHashSet[Permission]() + + for dapps in response.parseJson["result"].getElems(): + for currPerm in dapps["permissions"].getElems(): + let p = currPerm.getStr().toPermission() + if p != permission: + permissions.incl(p) + + discard callPrivateRPC("permissions_addDappPermissions", %*[{ + "dapp": dapps["dapp"].getStr(), + "permissions": permissions.toSeq() + }]) + +proc hasPermission*(self: PermissionsModel, dapp: string, permission: Permission): bool = + result = self.getPermissions(dapp).contains(permission) + +proc addPermission*(self: PermissionsModel, dapp: string, permission: Permission) = + var permissions = self.getPermissions(dapp) + permissions.incl(permission) + discard callPrivateRPC("permissions_addDappPermissions", %*[{ + "dapp": dapp, + "permissions": permissions.toSeq() + }]) + +proc revokePermission*(self: PermissionsModel, dapp: string, permission: Permission) = + var permissions = self.getPermissions(dapp) + permissions.excl(permission) + + if permissions.len == 0: + discard callPrivateRPC("permissions_deleteDappPermissions", %*[dapp]) + else: + discard callPrivateRPC("permissions_addDappPermissions", %*[{ + "dapp": dapp, + "permissions": permissions.toSeq() + }]) + +proc clearPermissions*(self: PermissionsModel, dapp: string) = + discard callPrivateRPC("permissions_deleteDappPermissions", %*[dapp]) + +proc clearPermissions*(self: PermissionsModel) = + let response = callPrivateRPC("permissions_getDappPermissions") + for dapps in response.parseJson["result"].getElems(): + discard callPrivateRPC("permissions_deleteDappPermissions", %*[dapps["dapp"].getStr()]) diff --git a/status/profile.nim b/status/profile.nim new file mode 100644 index 0000000..5cb83b9 --- /dev/null +++ b/status/profile.nim @@ -0,0 +1,28 @@ +import json +import ./types/[identity_image] +import profile/profile +import libstatus/core as libstatus_core +import libstatus/accounts as status_accounts +import libstatus/settings as status_settings +import ../eventemitter + +type + ProfileModel* = ref object + +proc newProfileModel*(): ProfileModel = + result = ProfileModel() + +proc logout*(self: ProfileModel) = + discard status_accounts.logout() + +proc getLinkPreviewWhitelist*(self: ProfileModel): JsonNode = + result = status_settings.getLinkPreviewWhitelist() + +proc storeIdentityImage*(self: ProfileModel, keyUID: string, imagePath: string, aX, aY, bX, bY: int): IdentityImage = + result = status_accounts.storeIdentityImage(keyUID, imagePath, aX, aY, bX, bY) + +proc getIdentityImage*(self: ProfileModel, keyUID: string): IdentityImage = + result = status_accounts.getIdentityImage(keyUID) + +proc deleteIdentityImage*(self: ProfileModel, keyUID: string): string = + result = status_accounts.deleteIdentityImage(keyUID) diff --git a/status/profile/mailserver.nim b/status/profile/mailserver.nim new file mode 100644 index 0000000..c2ec074 --- /dev/null +++ b/status/profile/mailserver.nim @@ -0,0 +1,3 @@ +type + MailServer* = ref object + name*, endpoint*: string diff --git a/status/profile/profile.nim b/status/profile/profile.nim new file mode 100644 index 0000000..213b467 --- /dev/null +++ b/status/profile/profile.nim @@ -0,0 +1,29 @@ +import json +import ../types/[profile, account] + +export profile + +const contactAdded* = ":contact/added" +const contactBlocked* = ":contact/blocked" +const contactRequest* = ":contact/request-received" + +proc isContact*(self: Profile): bool = + result = self.systemTags.contains(contactAdded) + +proc isBlocked*(self: Profile): bool = + result = self.systemTags.contains(contactBlocked) + +proc requestReceived*(self: Profile): bool = + result = self.systemTags.contains(contactRequest) + +proc toProfileModel*(account: Account): Profile = + result = Profile( + id: "", + username: account.name, + identicon: account.identicon, + alias: account.name, + ensName: "", + ensVerified: false, + appearance: 0, + systemTags: @[] + ) diff --git a/status/provider.nim b/status/provider.nim new file mode 100644 index 0000000..ae22604 --- /dev/null +++ b/status/provider.nim @@ -0,0 +1,276 @@ +import ens, wallet, permissions, utils +import ../eventemitter +import ./types/[setting] +import utils +import libstatus/accounts +import libstatus/core +import libstatus/settings as status_settings +import json, json_serialization, sets, strutils +import chronicles +import nbaser +import stew/byteutils +from base32 import nil + +const HTTPS_SCHEME* = "https" +const IPFS_GATEWAY* = ".infura.status.im" +const SWARM_GATEWAY* = "swarm-gateways.net" + +const base58* = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +logScope: + topics = "provider-model" + +type + RequestTypes {.pure.} = enum + Web3SendAsyncReadOnly = "web3-send-async-read-only", + HistoryStateChanged = "history-state-changed", + APIRequest = "api-request" + Unknown = "unknown" + + ResponseTypes {.pure.} = enum + Web3SendAsyncCallback = "web3-send-async-callback", + APIResponse = "api-response", + Web3ResponseError = "web3-response-error" + +type + Payload = ref object + id: JsonNode + rpcMethod: string + + Web3SendAsyncReadOnly = ref object + messageId: JsonNode + payload: Payload + request: string + hostname: string + + APIRequest = ref object + isAllowed: bool + messageId: JsonNode + permission: Permission + hostname: string + +const AUTH_METHODS = toHashSet(["eth_accounts", "eth_coinbase", "eth_sendTransaction", "eth_sign", "keycard_signTypedData", "eth_signTypedData", "eth_signTypedData_v3", "personal_sign", "personal_ecRecover"]) +const SIGN_METHODS = toHashSet(["eth_sign", "personal_sign", "eth_signTypedData", "eth_signTypedData_v3"]) +const ACC_METHODS = toHashSet(["eth_accounts", "eth_coinbase"]) + +type ProviderModel* = ref object + events*: EventEmitter + permissions*: PermissionsModel + +proc newProviderModel*(events: EventEmitter, permissions: PermissionsModel): ProviderModel = + result = ProviderModel() + result.events = events + result.permissions = permissions + +proc requestType(message: string): RequestTypes = + let data = message.parseJson + result = RequestTypes.Unknown + try: + result = parseEnum[RequestTypes](data["type"].getStr()) + except: + warn "Unknown request type received", value=data["permission"].getStr() + +proc toWeb3SendAsyncReadOnly(message: string): Web3SendAsyncReadOnly = + let data = message.parseJson + result = Web3SendAsyncReadOnly( + messageId: data["messageId"], + request: $data["payload"], + hostname: data{"hostname"}.getStr(), + payload: Payload( + id: data["payload"]{"id"}, + rpcMethod: data["payload"]["method"].getStr() + ) + ) + +proc toAPIRequest(message: string): APIRequest = + let data = message.parseJson + + result = APIRequest( + messageId: data["messageId"], + isAllowed: data{"isAllowed"}.getBool(), + permission: data["permission"].getStr().toPermission(), + hostname: data{"hostname"}.getStr() + ) + +proc process(self: ProviderModel, data: Web3SendAsyncReadOnly): string = + if AUTH_METHODS.contains(data.payload.rpcMethod) and not self.permissions.hasPermission(data.hostname, Permission.Web3): + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "error": { + "code": 4100 + } + } + + if data.payload.rpcMethod == "eth_sendTransaction": + try: + let request = data.request.parseJson + let fromAddress = request["params"][0]["from"].getStr() + let to = request["params"][0]{"to"}.getStr() + let value = if (request["params"][0]["value"] != nil): + request["params"][0]["value"].getStr() + else: + "0" + let password = request["password"].getStr() + let selectedGasLimit = request["selectedGasLimit"].getStr() + let selectedGasPrice = request["selectedGasPrice"].getStr() + let txData = if (request["params"][0].hasKey("data") and request["params"][0]["data"].kind != JNull): + request["params"][0]["data"].getStr() + else: + "" + + var success: bool + var errorMessage = "" + var response = "" + var validInput: bool = true + + try: + validateTransactionInput(fromAddress, to, "", value, selectedGasLimit, selectedGasPrice, txData, "dummy") + except Exception as e: + validInput = false + success = false + errorMessage = e.msg + + if validInput: + # TODO make this async + response = wallet.sendTransaction(fromAddress, to, value, selectedGasLimit, selectedGasPrice, password, success, txData) + errorMessage = if not success: + if response == "": + "web3-response-error" + else: + response + else: + "" + + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "error": errorMessage, + "result": { + "jsonrpc": "2.0", + "id": data.payload.id, + "result": if (success): response else: "" + } + } + except Exception as e: + error "Error sending the transaction", msg = e.msg + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "error": { + "code": 4100, + "message": e.msg + } + } + + if SIGN_METHODS.contains(data.payload.rpcMethod): + try: + let request = data.request.parseJson + var params = request["params"] + let password = hashPassword(request["password"].getStr()) + let dappAddress = status_settings.getSetting[string](Setting.DappsAddress) + var rpcResult = "{}" + + case data.payload.rpcMethod: + of "eth_signTypedData", "eth_signTypedData_v3": + rpcResult = signTypedData(params[1].getStr(), dappAddress, password) + else: + rpcResult = signMessage($ %* { + "data": params[0].getStr(), + "password": password, + "account": dappAddress + }) + + let jsonRpcResult = rpcResult.parseJson + let success: bool = not jsonRpcResult.hasKey("error") + let errorMessage = if success: "" else: jsonRpcResult["error"]{"message"}.getStr() + let response = if success: jsonRpcResult["result"].getStr() else: "" + + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "error": errorMessage, + "result": { + "jsonrpc": "2.0", + "id": if data.payload.id == nil: newJNull() else: data.payload.id, + "result": if (success): response else: "" + } + } + + except Exception as e: + error "Error signing message", msg = e.msg + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "error": { + "code": 4100, + "message": e.msg + } + } + + + + if ACC_METHODS.contains(data.payload.rpcMethod): + let dappAddress = status_settings.getSetting[string](Setting.DappsAddress) + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "result": { + "jsonrpc": "2.0", + "id": data.payload.id, + "result": if data.payload.rpcMethod == "eth_coinbase": newJString(dappAddress) else: %*[dappAddress] + } + } + + let rpcResult = callRPC(data.request) + + return $ %* { + "type": ResponseTypes.Web3SendAsyncCallback, + "messageId": data.messageId, + "error": (if rpcResult == "": newJString("web3-response-error") else: newJNull()), + "result": rpcResult.parseJson + } + +proc process*(self: ProviderModel, data: APIRequest): string = + var value:JsonNode = case data.permission + of Permission.Web3: %* [status_settings.getSetting[string](Setting.DappsAddress, "0x0000000000000000000000000000000000000000")] + of Permission.ContactCode: %* status_settings.getSetting[string](Setting.PublicKey, "0x0") + of Permission.Unknown: newJNull() + + let isAllowed = data.isAllowed and data.permission != Permission.Unknown + + info "API request received", host=data.hostname, value=data.permission, isAllowed + + if isAllowed: self.permissions.addPermission(data.hostname, data.permission) + + return $ %* { + "type": ResponseTypes.APIResponse, + "isAllowed": isAllowed, + "permission": data.permission, + "messageId": data.messageId, + "data": value + } + +proc postMessage*(self: ProviderModel, message: string): string = + case message.requestType(): + of RequestTypes.Web3SendAsyncReadOnly: self.process(message.toWeb3SendAsyncReadOnly()) + of RequestTypes.HistoryStateChanged: """{"type":"TODO-IMPLEMENT-THIS"}""" ############# TODO: + of RequestTypes.APIRequest: self.process(message.toAPIRequest()) + else: """{"type":"TODO-IMPLEMENT-THIS"}""" ##################### TODO: + +proc ensResourceURL*(self: ProviderModel, ens: string, url: string): (string, string, string, string, bool) = + let contentHash = contenthash(ens) + if contentHash == "": # ENS does not have a content hash + return (url, url, HTTPS_SCHEME, "", false) + + let decodedHash = contentHash.decodeENSContentHash() + case decodedHash[0]: + of ENSType.IPFS: + let base32Hash = base32.encode(string.fromBytes(base58.decode(decodedHash[1]))).toLowerAscii().replace("=", "") + result = (url, base32Hash & IPFS_GATEWAY, HTTPS_SCHEME, "", true) + of ENSType.SWARM: + result = (url, SWARM_GATEWAY, HTTPS_SCHEME, "/bzz:/" & decodedHash[1] & "/", true) + of ENSType.IPNS: + result = (url, decodedHash[1], HTTPS_SCHEME, "", true) + else: + warn "Unknown content for", ens, contentHash diff --git a/status/settings.nim b/status/settings.nim new file mode 100644 index 0000000..cd7ddbc --- /dev/null +++ b/status/settings.nim @@ -0,0 +1,72 @@ +import json, json_serialization + +import + sugar, sequtils, strutils, atomics + +import libstatus/settings as libstatus_settings +import ../eventemitter +import ./types/[fleet, network, setting] +import ./signals/[base] + +type + SettingsModel* = ref object + events*: EventEmitter + +proc newSettingsModel*(events: EventEmitter): SettingsModel = + result = SettingsModel() + result.events = events + +proc saveSetting*(self: SettingsModel, key: Setting, value: string | JsonNode | bool): StatusGoError = + result = libstatus_settings.saveSetting(key, value) + +proc getSetting*[T](self: SettingsModel, name: Setting, defaultValue: T, useCached: bool = true): T = + result = libstatus_settings.getSetting(name, defaultValue, useCached) + +proc getSetting*[T](self: SettingsModel, name: Setting, useCached: bool = true): T = + result = libstatus_settings.getSetting[T](name, useCached) + +# TODO: name with a 2 due to namespace conflicts that need to be addressed in subsquent PRs +proc getSetting2*[T](name: Setting, defaultValue: T, useCached: bool = true): T = + result = libstatus_settings.getSetting(name, defaultValue, useCached) + +proc getSetting2*[T](name: Setting, useCached: bool = true): T = + result = libstatus_settings.getSetting[T](name, useCached) + +proc getCurrentNetworkDetails*(self: SettingsModel): NetworkDetails = + result = libstatus_settings.getCurrentNetworkDetails() + +proc getMailservers*(self: SettingsModel):JsonNode = + result = libstatus_settings.getMailservers() + +proc getPinnedMailserver*(self: SettingsModel): string = + result = libstatus_settings.getPinnedMailserver() + +proc pinMailserver*(self: SettingsModel, enode: string = "") = + libstatus_settings.pinMailserver(enode) + +proc saveMailserver*(self: SettingsModel, name, enode: string) = + libstatus_settings.saveMailserver(name, enode) + +proc getFleet*(self: SettingsModel): Fleet = + result = libstatus_settings.getFleet() + +proc getCurrentNetwork*(): Network = + result = libstatus_settings.getCurrentNetwork() + +proc getCurrentNetwork*(self: SettingsModel): Network = + result = getCurrentNetwork() + +proc setWakuVersion*(self: SettingsModel, newVersion: int) = + libstatus_settings.setWakuVersion(newVersion) + +proc setBloomFilterMode*(self: SettingsModel, bloomFilterMode: bool): StatusGoError = + libstatus_settings.setBloomFilterMode(bloomFilterMode) + +proc setFleet*(self: SettingsModel, fleetConfig: FleetConfig, fleet: Fleet): StatusGoError = + libstatus_settings.setFleet(fleetConfig, fleet) + +proc getNodeConfig*(self: SettingsModel): JsonNode = + libstatus_settings.getNodeConfig() + +proc setBloomLevel*(self: SettingsModel, bloomFilterMode: bool, fullNode: bool): StatusGoError = + libstatus_settings.setBloomLevel(bloomFilterMode, fullNode) \ No newline at end of file diff --git a/status/signals/base.nim b/status/signals/base.nim new file mode 100644 index 0000000..f2a7ec7 --- /dev/null +++ b/status/signals/base.nim @@ -0,0 +1,15 @@ +import json_serialization +import signal_type + +import ../../eventemitter + +export signal_type + +type Signal* = ref object of Args + signalType* {.serializedFieldName("type").}: SignalType + +type StatusGoError* = object + error*: string + +type NodeSignal* = ref object of Signal + event*: StatusGoError \ No newline at end of file diff --git a/status/signals/community.nim b/status/signals/community.nim new file mode 100644 index 0000000..c3357ee --- /dev/null +++ b/status/signals/community.nim @@ -0,0 +1,13 @@ +import json + +import base + +import status/types/community + +type CommunitySignal* = ref object of Signal + community*: Community + +proc fromEvent*(event: JsonNode): Signal = + var signal: CommunitySignal = CommunitySignal() + signal.community = event["event"].toCommunity() + result = signal \ No newline at end of file diff --git a/status/signals/discovery_summary.nim b/status/signals/discovery_summary.nim new file mode 100644 index 0000000..e52187f --- /dev/null +++ b/status/signals/discovery_summary.nim @@ -0,0 +1,13 @@ +import json + +import base + +type DiscoverySummarySignal* = ref object of Signal + enodes*: seq[string] + +proc fromEvent*(jsonSignal: JsonNode): Signal = + var signal:DiscoverySummarySignal = DiscoverySummarySignal() + if jsonSignal["event"].kind != JNull: + for discoveryItem in jsonSignal["event"]: + signal.enodes.add(discoveryItem["enode"].getStr) + result = signal \ No newline at end of file diff --git a/status/signals/envelope.nim b/status/signals/envelope.nim new file mode 100644 index 0000000..0dbfa90 --- /dev/null +++ b/status/signals/envelope.nim @@ -0,0 +1,14 @@ +import json + +import base + +type EnvelopeSentSignal* = ref object of Signal + messageIds*: seq[string] + +proc fromEvent*(jsonSignal: JsonNode): Signal = + var signal:EnvelopeSentSignal = EnvelopeSentSignal() + if jsonSignal["event"].kind != JNull and jsonSignal["event"].hasKey("ids") and jsonSignal["event"]["ids"].kind != JNull: + for messageId in jsonSignal["event"]["ids"]: + signal.messageIds.add(messageId.getStr) + result = signal + \ No newline at end of file diff --git a/status/signals/expired.nim b/status/signals/expired.nim new file mode 100644 index 0000000..e30a5a4 --- /dev/null +++ b/status/signals/expired.nim @@ -0,0 +1,14 @@ +import json + +import base + +type EnvelopeExpiredSignal* = ref object of Signal + messageIds*: seq[string] + +proc fromEvent*(jsonSignal: JsonNode): Signal = + var signal:EnvelopeExpiredSignal = EnvelopeExpiredSignal() + if jsonSignal["event"].kind != JNull and jsonSignal["event"].hasKey("ids") and jsonSignal["event"]["ids"].kind != JNull: + for messageId in jsonSignal["event"]["ids"]: + signal.messageIds.add(messageId.getStr) + result = signal + \ No newline at end of file diff --git a/status/signals/mailserver.nim b/status/signals/mailserver.nim new file mode 100644 index 0000000..e6546fb --- /dev/null +++ b/status/signals/mailserver.nim @@ -0,0 +1,29 @@ +import json + +import base + +type MailserverRequestCompletedSignal* = ref object of Signal + requestID*: string + lastEnvelopeHash*: string + cursor*: string + errorMessage*: string + error*: bool + +type MailserverRequestExpiredSignal* = ref object of Signal + # TODO + +proc fromCompletedEvent*(jsonSignal: JsonNode): Signal = + var signal:MailserverRequestCompletedSignal = MailserverRequestCompletedSignal() + if jsonSignal["event"].kind != JNull: + signal.requestID = jsonSignal["event"]{"requestID"}.getStr() + signal.lastEnvelopeHash = jsonSignal["event"]{"lastEnvelopeHash"}.getStr() + signal.cursor = jsonSignal["event"]{"cursor"}.getStr() + signal.errorMessage = jsonSignal["event"]{"errorMessage"}.getStr() + signal.error = signal.errorMessage != "" + result = signal + +proc fromExpiredEvent*(jsonSignal: JsonNode): Signal = + var signal:MailserverRequestExpiredSignal = MailserverRequestExpiredSignal() + # TODO: parse signal + result = signal + \ No newline at end of file diff --git a/status/signals/messages.nim b/status/signals/messages.nim new file mode 100644 index 0000000..52609ee --- /dev/null +++ b/status/signals/messages.nim @@ -0,0 +1,95 @@ +import json, chronicles + +import base + +import status/types/[message, chat, community, profile, installation, + activity_center_notification, status_update, removed_message] + +type MessageSignal* = ref object of Signal + messages*: seq[Message] + pinnedMessages*: seq[Message] + chats*: seq[Chat] + contacts*: seq[Profile] + installations*: seq[Installation] + emojiReactions*: seq[Reaction] + communities*: seq[Community] + membershipRequests*: seq[CommunityMembershipRequest] + activityCenterNotification*: seq[ActivityCenterNotification] + statusUpdates*: seq[StatusUpdate] + deletedMessages*: seq[RemovedMessage] + +proc fromEvent*(event: JsonNode): Signal = + var signal:MessageSignal = MessageSignal() + signal.messages = @[] + signal.contacts = @[] + + if event["event"]{"contacts"} != nil: + for jsonContact in event["event"]["contacts"]: + signal.contacts.add(jsonContact.toProfileModel()) + + var chatsWithMentions: seq[string] = @[] + + if event["event"]{"messages"} != nil: + for jsonMsg in event["event"]["messages"]: + var message = jsonMsg.toMessage() + if message.hasMention: + chatsWithMentions.add(message.chatId) + signal.messages.add(message) + + if event["event"]{"chats"} != nil: + for jsonChat in event["event"]["chats"]: + var chat = jsonChat.toChat + if chatsWithMentions.contains(chat.id): + chat.mentionsCount.inc + signal.chats.add(chat) + + if event["event"]{"statusUpdates"} != nil: + for jsonStatusUpdate in event["event"]["statusUpdates"]: + var statusUpdate = jsonStatusUpdate.toStatusUpdate + signal.statusUpdates.add(statusUpdate) + + if event["event"]{"installations"} != nil: + for jsonInstallation in event["event"]["installations"]: + signal.installations.add(jsonInstallation.toInstallation) + + if event["event"]{"emojiReactions"} != nil: + for jsonReaction in event["event"]["emojiReactions"]: + signal.emojiReactions.add(jsonReaction.toReaction) + + if event["event"]{"communities"} != nil: + for jsonCommunity in event["event"]["communities"]: + signal.communities.add(jsonCommunity.toCommunity) + + if event["event"]{"requestsToJoinCommunity"} != nil: + for jsonCommunity in event["event"]["requestsToJoinCommunity"]: + signal.membershipRequests.add(jsonCommunity.toCommunityMembershipRequest) + + if event["event"]{"removedMessages"} != nil: + for jsonRemovedMessage in event["event"]["removedMessages"]: + signal.deletedMessages.add(jsonRemovedMessage.toRemovedMessage) + + if event["event"]{"activityCenterNotifications"} != nil: + for jsonNotification in event["event"]["activityCenterNotifications"]: + signal.activityCenterNotification.add(jsonNotification.toActivityCenterNotification()) + + if event["event"]{"pinMessages"} != nil: + for jsonPinnedMessage in event["event"]["pinMessages"]: + var contentType: ContentType + try: + contentType = ContentType(jsonPinnedMessage{"contentType"}.getInt) + except: + warn "Unknown content type received", type = jsonPinnedMessage{"contentType"}.getInt + contentType = ContentType.Message + signal.pinnedMessages.add(Message( + id: jsonPinnedMessage{"message_id"}.getStr, + chatId: jsonPinnedMessage{"chat_id"}.getStr, + localChatId: jsonPinnedMessage{"localChatId"}.getStr, + pinnedBy: jsonPinnedMessage{"from"}.getStr, + identicon: jsonPinnedMessage{"identicon"}.getStr, + alias: jsonPinnedMessage{"alias"}.getStr, + clock: jsonPinnedMessage{"clock"}.getInt, + isPinned: jsonPinnedMessage{"pinned"}.getBool, + contentType: contentType + )) + + result = signal \ No newline at end of file diff --git a/status/signals/signal_type.nim b/status/signals/signal_type.nim new file mode 100644 index 0000000..0a48eac --- /dev/null +++ b/status/signals/signal_type.nim @@ -0,0 +1,26 @@ +{.used.} + +type SignalType* {.pure.} = enum + Message = "messages.new" + Wallet = "wallet" + NodeReady = "node.ready" + NodeCrashed = "node.crashed" + NodeStarted = "node.started" + NodeStopped = "node.stopped" + NodeLogin = "node.login" + EnvelopeSent = "envelope.sent" + EnvelopeExpired = "envelope.expired" + MailserverRequestCompleted = "mailserver.request.completed" + MailserverRequestExpired = "mailserver.request.expired" + DiscoveryStarted = "discovery.started" + DiscoveryStopped = "discovery.stopped" + DiscoverySummary = "discovery.summary" + SubscriptionsData = "subscriptions.data" + SubscriptionsError = "subscriptions.error" + WhisperFilterAdded = "whisper.filter.added" + CommunityFound = "community.found" + Stats = "stats" + Unknown + +proc event*(self:SignalType):string = + result = "signal:" & $self \ No newline at end of file diff --git a/status/signals/stats.nim b/status/signals/stats.nim new file mode 100644 index 0000000..5b88edf --- /dev/null +++ b/status/signals/stats.nim @@ -0,0 +1,21 @@ +import json + +import base + +type Stats* = object + uploadRate*: uint64 + downloadRate*: uint64 + +type StatsSignal* = ref object of Signal + stats*: Stats + +proc toStats(jsonMsg: JsonNode): Stats = + result = Stats( + uploadRate: uint64(jsonMsg{"uploadRate"}.getBiggestInt()), + downloadRate: uint64(jsonMsg{"downloadRate"}.getBiggestInt()) + ) + +proc fromEvent*(event: JsonNode): Signal = + var signal:StatsSignal = StatsSignal() + signal.stats = event["event"].toStats + result = signal \ No newline at end of file diff --git a/status/signals/wallet.nim b/status/signals/wallet.nim new file mode 100644 index 0000000..8d52bb1 --- /dev/null +++ b/status/signals/wallet.nim @@ -0,0 +1,25 @@ +import json + +import base + +type WalletSignal* = ref object of Signal + content*: string + eventType*: string + blockNumber*: int + accounts*: seq[string] + # newTransactions*: ??? + erc20*: bool + +proc fromEvent*(jsonSignal: JsonNode): Signal = + var signal:WalletSignal = WalletSignal() + signal.content = $jsonSignal + if jsonSignal["event"].kind != JNull: + signal.eventType = jsonSignal["event"]["type"].getStr + signal.blockNumber = jsonSignal["event"]{"blockNumber"}.getInt + signal.erc20 = jsonSignal["event"]{"erc20"}.getBool + signal.accounts = @[] + if jsonSignal["event"]["accounts"].kind != JNull: + for account in jsonSignal["event"]["accounts"]: + signal.accounts.add(account.getStr) + result = signal + \ No newline at end of file diff --git a/status/signals/whisper_filter.nim b/status/signals/whisper_filter.nim new file mode 100644 index 0000000..f8f1336 --- /dev/null +++ b/status/signals/whisper_filter.nim @@ -0,0 +1,33 @@ +import json + +import base + +type Filter* = object + chatId*: string + symKeyId*: string + listen*: bool + filterId*: string + identity*: string + topic*: string + +type WhisperFilterSignal* = ref object of Signal + filters*: seq[Filter] + +proc toFilter(jsonMsg: JsonNode): Filter = + result = Filter( + chatId: jsonMsg{"chatId"}.getStr, + symKeyId: jsonMsg{"symKeyId"}.getStr, + listen: jsonMsg{"listen"}.getBool, + filterId: jsonMsg{"filterId"}.getStr, + identity: jsonMsg{"identity"}.getStr, + topic: jsonMsg{"topic"}.getStr, + ) + +proc fromEvent*(event: JsonNode): Signal = + var signal:WhisperFilterSignal = WhisperFilterSignal() + + if event["event"]{"filters"} != nil: + for jsonMsg in event["event"]["filters"]: + signal.filters.add(jsonMsg.toFilter) + + result = signal \ No newline at end of file diff --git a/status/status.nim b/status/status.nim new file mode 100644 index 0000000..cef1dd3 --- /dev/null +++ b/status/status.nim @@ -0,0 +1,89 @@ +import libstatus/accounts as libstatus_accounts +import libstatus/core as libstatus_core +import libstatus/settings as libstatus_settings +import chat, accounts, wallet, wallet2, node, network, messages, contacts, profile, stickers, permissions, fleet, settings, mailservers, browser, tokens, provider +import notifications/os_notifications +import ../eventemitter +import bitops, stew/byteutils, chronicles +import ./types/[setting] + +export chat, accounts, node, messages, contacts, profile, network, permissions, fleet, eventemitter + +type Status* = ref object + events*: EventEmitter + fleet*: FleetModel + chat*: ChatModel + messages*: MessagesModel + accounts*: AccountModel + wallet*: WalletModel + wallet2*: StatusWalletController + node*: NodeModel + profile*: ProfileModel + contacts*: ContactModel + network*: NetworkModel + stickers*: StickersModel + permissions*: PermissionsModel + settings*: SettingsModel + mailservers*: MailserversModel + browser*: BrowserModel + tokens*: TokensModel + provider*: ProviderModel + osnotifications*: OsNotifications + +proc newStatusInstance*(fleetConfig: string): Status = + result = Status() + result.events = createEventEmitter() + result.fleet = fleet.newFleetModel(fleetConfig) + result.chat = chat.newChatModel(result.events) + result.accounts = accounts.newAccountModel(result.events) + result.wallet = wallet.newWalletModel(result.events) + result.wallet.initEvents() + result.wallet2 = wallet2.newStatusWalletController(result.events) + result.node = node.newNodeModel() + result.messages = messages.newMessagesModel(result.events) + result.profile = profile.newProfileModel() + result.contacts = contacts.newContactModel(result.events) + result.network = network.newNetworkModel(result.events) + result.stickers = stickers.newStickersModel(result.events) + result.permissions = permissions.newPermissionsModel(result.events) + result.settings = settings.newSettingsModel(result.events) + result.mailservers = mailservers.newMailserversModel(result.events) + result.browser = browser.newBrowserModel(result.events) + result.tokens = tokens.newTokensModel(result.events) + result.provider = provider.newProviderModel(result.events, result.permissions) + result.osnotifications = newOsNotifications(result.events) + +proc initNode*(self: Status) = + libstatus_accounts.initNode() + +proc startMessenger*(self: Status) = + libstatus_core.startMessenger() + +proc reset*(self: Status) = + # TODO: remove this once accounts are not tracked in the AccountsModel + self.accounts.reset() + + # NOT NEEDED self.chat.reset() + # NOT NEEDED self.wallet.reset() + # NOT NEEDED self.node.reset() + # NOT NEEDED self.mailservers.reset() + # NOT NEEDED self.profile.reset() + + # TODO: add all resets here + +proc getNodeVersion*(self: Status): string = + libstatus_settings.getWeb3ClientVersion() + +# TODO: duplicated?? +proc saveSetting*(self: Status, setting: Setting, value: string | bool) = + discard libstatus_settings.saveSetting(setting, value) + +proc getBloomFilter*(self: Status): string = + result = libstatus_core.getBloomFilter() + +proc getBloomFilterBitsSet*(self: Status): int = + let bloomFilter = libstatus_core.getBloomFilter() + var bitCount = 0; + for b in hexToSeqByte(bloomFilter): + bitCount += countSetBits(b) + return bitCount \ No newline at end of file diff --git a/status/stickers.nim b/status/stickers.nim new file mode 100644 index 0000000..fae2d6d --- /dev/null +++ b/status/stickers.nim @@ -0,0 +1,146 @@ +import # global deps + atomics, sequtils, strutils, tables + +import # project deps + chronicles, web3/[ethtypes, conversions], stint + +import # local deps + libstatus/eth/contracts as status_contracts, + libstatus/stickers as status_stickers, transactions, + libstatus/wallet, ../eventemitter +import ./types/[sticker, transaction, rpc_response] +from utils as libstatus_utils import eth2Wei, gwei2Wei, toUInt64, parseAddress + + +logScope: + topics = "stickers-model" + +type + StickersModel* = ref object + events*: EventEmitter + recentStickers*: seq[Sticker] + availableStickerPacks*: Table[int, StickerPack] + installedStickerPacks*: Table[int, StickerPack] + purchasedStickerPacks*: seq[int] + + StickerArgs* = ref object of Args + sticker*: Sticker + save*: bool + +# forward declaration +proc addStickerToRecent*(self: StickersModel, sticker: Sticker, save: bool = false) + +proc newStickersModel*(events: EventEmitter): StickersModel = + result = StickersModel() + result.events = events + result.recentStickers = @[] + result.availableStickerPacks = initTable[int, StickerPack]() + result.installedStickerPacks = initTable[int, StickerPack]() + result.purchasedStickerPacks = @[] + +proc init*(self: StickersModel) = + self.events.on("stickerSent") do(e: Args): + var evArgs = StickerArgs(e) + self.addStickerToRecent(evArgs.sticker, evArgs.save) + +proc buildTransaction(packId: Uint256, address: Address, price: Uint256, approveAndCall: var ApproveAndCall[100], sntContract: var Erc20Contract, gas = "", gasPrice = ""): EthSend = + sntContract = status_contracts.getSntContract() + let + stickerMktContract = status_contracts.getContract("sticker-market") + buyToken = BuyToken(packId: packId, address: address, price: price) + buyTxAbiEncoded = stickerMktContract.methods["buyToken"].encodeAbi(buyToken) + approveAndCall = ApproveAndCall[100](to: stickerMktContract.address, value: price, data: DynamicBytes[100].fromHex(buyTxAbiEncoded)) + transactions.buildTokenTransaction(address, sntContract.address, gas, gasPrice) + +proc estimateGas*(packId: int, address: string, price: string, success: var bool): int = + var + approveAndCall: ApproveAndCall[100] + sntContract = status_contracts.getSntContract() + tx = buildTransaction( + packId.u256, + parseAddress(address), + eth2Wei(parseFloat(price), sntContract.decimals), + approveAndCall, + sntContract + ) + + let response = sntContract.methods["approveAndCall"].estimateGas(tx, approveAndCall, success) + if success: + result = fromHex[int](response) + +proc buyPack*(self: StickersModel, packId: int, address, price, gas, gasPrice, password: string, success: var bool): string = + var + sntContract: Erc20Contract + approveAndCall: ApproveAndCall[100] + tx = buildTransaction( + packId.u256, + parseAddress(address), + eth2Wei(parseFloat(price), 18), # SNT + approveAndCall, + sntContract, + gas, + gasPrice + ) + + result = sntContract.methods["approveAndCall"].send(tx, approveAndCall, password, success) + if success: + trackPendingTransaction(result, address, $sntContract.address, PendingTransactionType.BuyStickerPack, $packId) + +proc getStickerMarketAddress*(self: StickersModel): Address = + result = status_contracts.getContract("sticker-market").address + +proc getPurchasedStickerPacks*(self: StickersModel, address: Address): seq[int] = + try: + let + balance = status_stickers.getBalance(address) + tokenIds = toSeq[0.. 24: + self.recentStickers = self.recentStickers[0..23] # take top 24 most recent + if save: + status_stickers.saveRecentStickers(self.recentStickers) + +proc decodeContentHash*(value: string): string = + result = status_stickers.decodeContentHash(value) + +proc getPackIdFromTokenId*(tokenId: Stuint[256]): int = + result = status_stickers.getPackIdFromTokenId(tokenId) diff --git a/status/tokens.nim b/status/tokens.nim new file mode 100644 index 0000000..21c5fb1 --- /dev/null +++ b/status/tokens.nim @@ -0,0 +1,43 @@ +import libstatus/tokens as status_tokens +import libstatus/eth/contracts +import ../eventemitter + +type + TokensModel* = ref object + events*: EventEmitter + +proc newTokensModel*(events: EventEmitter): TokensModel = + result = TokensModel() + result.events = events + +proc getSNTAddress*(): string = + result = status_tokens.getSNTAddress() + +proc getCustomTokens*(self: TokensModel, useCached: bool = true): seq[Erc20Contract] = + result = status_tokens.getCustomTokens(useCached) + +proc removeCustomToken*(self: TokensModel, address: string) = + status_tokens.removeCustomToken(address) + +proc getSNTBalance*(account: string): string = + result = status_tokens.getSNTBalance(account) + +proc tokenDecimals*(contract: Contract): int = + result = status_tokens.tokenDecimals(contract) + +proc tokenName*(contract: Contract): string = + result = status_tokens.tokenName(contract) + +proc tokensymbol*(contract: Contract): string = + result = status_tokens.tokensymbol(contract) + +proc getTokenBalance*(tokenAddress: string, account: string): string = + result = status_tokens.getTokenBalance(tokenAddress, account) + +proc getToken*(self: TokensModel, tokenAddress: string): Erc20Contract = + result = status_tokens.getToken(tokenAddress) + +export newErc20Contract +export getErc20Contracts +export Erc20Contract +export getErc20ContractByAddress diff --git a/status/transactions.nim b/status/transactions.nim new file mode 100644 index 0000000..c418010 --- /dev/null +++ b/status/transactions.nim @@ -0,0 +1,20 @@ +import + options, strutils + +import + stint, web3/ethtypes + +from utils as status_utils import toUInt64, gwei2Wei, parseAddress + +proc buildTransaction*(source: Address, value: Uint256, gas = "", gasPrice = "", data = ""): EthSend = + result = EthSend( + source: source, + value: value.some, + gas: (if gas.isEmptyOrWhitespace: Quantity.none else: Quantity(cast[uint64](parseFloat(gas).toUInt64)).some), + gasPrice: (if gasPrice.isEmptyOrWhitespace: int.none else: gwei2Wei(parseFloat(gasPrice)).truncate(int).some), + data: data + ) + +proc buildTokenTransaction*(source, contractAddress: Address, gas = "", gasPrice = ""): EthSend = + result = buildTransaction(source, 0.u256, gas, gasPrice) + result.to = contractAddress.some \ No newline at end of file diff --git a/status/types/account.nim b/status/types/account.nim new file mode 100644 index 0000000..de1eaac --- /dev/null +++ b/status/types/account.nim @@ -0,0 +1,46 @@ +{.used.} + +import json_serialization + +import ../../eventemitter +import identity_image + +include multi_accounts + +export identity_image + +type + Account* = ref object of RootObj + name*: string + keyUid* {.serializedFieldName("key-uid").}: string + identityImage*: IdentityImage + identicon*: string + +type + NodeAccount* = ref object of Account + timestamp*: int + keycardPairing* {.serializedFieldName("keycard-pairing").}: string + +type + GeneratedAccount* = ref object + publicKey*: string + address*: string + id*: string + mnemonic*: string + derived*: MultiAccounts + # FIXME: should inherit from Account but multiAccountGenerateAndDeriveAddresses + # response has a camel-cased properties like "publicKey" and "keyUid", so the + # serializedFieldName pragma would need to be different + name*: string + keyUid*: string + identicon*: string + identityImage*: IdentityImage + +proc toAccount*(account: GeneratedAccount): Account = + result = Account(name: account.name, identityImage: account.identityImage, identicon: account.identicon, keyUid: account.keyUid) + +proc toAccount*(account: NodeAccount): Account = + result = Account(name: account.name, identityImage: account.identityImage, identicon: account.identicon, keyUid: account.keyUid) + +type AccountArgs* = ref object of Args + account*: Account \ No newline at end of file diff --git a/status/types/activity_center_notification.nim b/status/types/activity_center_notification.nim new file mode 100644 index 0000000..5a988ed --- /dev/null +++ b/status/types/activity_center_notification.nim @@ -0,0 +1,49 @@ +{.used.} + +import json, chronicles +import message + +export message + +type ActivityCenterNotificationType* {.pure.}= enum + Unknown = 0, + NewOneToOne = 1, + NewPrivateGroupChat = 2, + Mention = 3 + Reply = 4 + +type ActivityCenterNotification* = ref object of RootObj + id*: string # ID is the id of the chat, for public chats it is the name e.g. status, for one-to-one is the hex encoded public key and for group chats is a random uuid appended with the hex encoded pk of the creator of the chat + chatId*: string + name*: string + author*: string + notificationType*: ActivityCenterNotificationType + message*: Message + timestamp*: int64 + read*: bool + dismissed*: bool + accepted*: bool + +proc toActivityCenterNotification*(jsonNotification: JsonNode): ActivityCenterNotification = + var activityCenterNotificationType: ActivityCenterNotificationType + try: + activityCenterNotificationType = ActivityCenterNotificationType(jsonNotification{"type"}.getInt) + except: + warn "Unknown notification type received", type = jsonNotification{"type"}.getInt + activityCenterNotificationType = ActivityCenterNotificationType.Unknown + result = ActivityCenterNotification( + id: jsonNotification{"id"}.getStr, + chatId: jsonNotification{"chatId"}.getStr, + name: jsonNotification{"name"}.getStr, + author: jsonNotification{"author"}.getStr, + notificationType: activityCenterNotificationType, + timestamp: jsonNotification{"timestamp"}.getInt, + read: jsonNotification{"read"}.getBool, + dismissed: jsonNotification{"dismissed"}.getBool, + accepted: jsonNotification{"accepted"}.getBool + ) + + if jsonNotification.contains("message") and jsonNotification{"message"}.kind != JNull: + result.message = jsonNotification{"message"}.toMessage() + elif activityCenterNotificationType == ActivityCenterNotificationType.NewOneToOne and jsonNotification.contains("lastMessage") and jsonNotification{"lastMessage"}.kind != JNull: + result.message = jsonNotification{"lastMessage"}.toMessage() \ No newline at end of file diff --git a/status/types/bookmark.nim b/status/types/bookmark.nim new file mode 100644 index 0000000..4da7bf9 --- /dev/null +++ b/status/types/bookmark.nim @@ -0,0 +1,7 @@ +{.used.} + +type Bookmark* = ref object + name*: string + url*: string + imageUrl*: string + diff --git a/status/types/chat.nim b/status/types/chat.nim new file mode 100644 index 0000000..b310654 --- /dev/null +++ b/status/types/chat.nim @@ -0,0 +1,170 @@ +{.used.} + +import strutils, random, strformat, json + +import ../libstatus/accounts as status_accounts +import message + +include chat_member +include chat_membership_event + +type ChatType* {.pure.}= enum + Unknown = 0, + OneToOne = 1, + Public = 2, + PrivateGroupChat = 3, + Profile = 4, + Timeline = 5 + CommunityChat = 6 + +proc isOneToOne*(self: ChatType): bool = self == ChatType.OneToOne +proc isTimeline*(self: ChatType): bool = self == ChatType.Timeline + +type Chat* = ref object + id*: string # ID is the id of the chat, for public chats it is the name e.g. status, for one-to-one is the hex encoded public key and for group chats is a random uuid appended with the hex encoded pk of the creator of the chat + communityId*: string + private*: bool + categoryId*: string + name*: string + description*: string + color*: string + identicon*: string + isActive*: bool # indicates whether the chat has been soft deleted + chatType*: ChatType + timestamp*: int64 # indicates the last time this chat has received/sent a message + joined*: int64 # indicates when the user joined the chat last time + lastClockValue*: int64 # indicates the last clock value to be used when sending messages + deletedAtClockValue*: int64 # indicates the clock value at time of deletion, messages with lower clock value of this should be discarded + unviewedMessagesCount*: int + unviewedMentionsCount*: int + lastMessage*: Message + members*: seq[ChatMember] + membershipUpdateEvents*: seq[ChatMembershipEvent] + mentionsCount*: int # Using this is not a good approach, we should instead use unviewedMentionsCount and refer to it always. + muted*: bool + canPost*: bool + ensName*: string + position*: int + +proc `$`*(self: Chat): string = + result = fmt"Chat(id:{self.id}, name:{self.name}, active:{self.isActive}, type:{self.chatType})" + +proc toJsonNode*(self: Chat): JsonNode = + result = %* { + "active": self.isActive, + "chatType": self.chatType.int, + "color": self.color, + "deletedAtClockValue": self.deletedAtClockValue, + "id": self.id, + "lastClockValue": self.lastClockValue, + "lastMessage": nil, + "members": self.members.toJsonNode, + "membershipUpdateEvents": self.membershipUpdateEvents.toJsonNode, + "name": (if self.ensName != "": self.ensName else: self.name), + "timestamp": self.timestamp, + "unviewedMessagesCount": self.unviewedMessagesCount, + "joined": self.joined, + "position": self.position + } + +proc toChatMember*(jsonMember: JsonNode): ChatMember = + let pubkey = jsonMember["id"].getStr + + result = ChatMember( + admin: jsonMember["admin"].getBool, + id: pubkey, + joined: jsonMember["joined"].getBool, + identicon: generateIdenticon(pubkey), + userName: generateAlias(pubkey) + ) + +proc toChatMembershipEvent*(jsonMembership: JsonNode): ChatMembershipEvent = + result = ChatMembershipEvent( + chatId: jsonMembership["chatId"].getStr, + clockValue: jsonMembership["clockValue"].getBiggestInt, + fromKey: jsonMembership["from"].getStr, + rawPayload: jsonMembership["rawPayload"].getStr, + signature: jsonMembership["signature"].getStr, + eventType: jsonMembership["type"].getInt, + name: jsonMembership{"name"}.getStr, + members: @[] + ) + if jsonMembership{"members"} != nil: + for member in jsonMembership["members"]: + result.members.add(member.getStr) + +proc toChat*(jsonChat: JsonNode): Chat = + + let chatTypeInt = jsonChat{"chatType"}.getInt + let chatType: ChatType = if chatTypeInt >= ord(low(ChatType)) or chatTypeInt <= ord(high(ChatType)): ChatType(chatTypeInt) else: ChatType.Unknown + + result = Chat( + id: jsonChat{"id"}.getStr, + communityId: jsonChat{"communityId"}.getStr, + name: jsonChat{"name"}.getStr, + description: jsonChat{"description"}.getStr, + identicon: "", + color: jsonChat{"color"}.getStr, + isActive: jsonChat{"active"}.getBool, + chatType: chatType, + timestamp: jsonChat{"timestamp"}.getBiggestInt, + lastClockValue: jsonChat{"lastClockValue"}.getBiggestInt, + deletedAtClockValue: jsonChat{"deletedAtClockValue"}.getBiggestInt, + unviewedMessagesCount: jsonChat{"unviewedMessagesCount"}.getInt, + unviewedMentionsCount: jsonChat{"unviewedMentionsCount"}.getInt, + mentionsCount: 0, + muted: false, + ensName: "", + joined: 0, + private: jsonChat{"private"}.getBool + ) + + if jsonChat.hasKey("muted") and jsonChat["muted"].kind != JNull: + result.muted = jsonChat["muted"].getBool + + if jsonChat["lastMessage"].kind != JNull: + result.lastMessage = jsonChat{"lastMessage"}.toMessage() + + if jsonChat.hasKey("joined") and jsonChat["joined"].kind != JNull: + result.joined = jsonChat{"joined"}.getInt + + if result.chatType == ChatType.OneToOne: + result.identicon = generateIdenticon(result.id) + if result.name.endsWith(".eth"): + result.ensName = result.name + if result.name == "": + result.name = generateAlias(result.id) + + if jsonChat["members"].kind != JNull: + result.members = @[] + for jsonMember in jsonChat["members"]: + result.members.add(jsonMember.toChatMember) + + if jsonChat["membershipUpdateEvents"].kind != JNull: + result.membershipUpdateEvents = @[] + for jsonMember in jsonChat["membershipUpdateEvents"]: + result.membershipUpdateEvents.add(jsonMember.toChatMembershipEvent) + +const channelColors* = ["#fa6565", "#7cda00", "#887af9", "#51d0f0", "#FE8F59", "#d37ef4"] + +proc newChat*(id: string, chatType: ChatType): Chat = + randomize() + + result = Chat( + id: id, + color: channelColors[rand(channelColors.len - 1)], + isActive: true, + chatType: chatType, + timestamp: 0, + lastClockValue: 0, + deletedAtClockValue: 0, + unviewedMessagesCount: 0, + mentionsCount: 0, + members: @[] + ) + + if chatType == ChatType.OneToOne: + result.identicon = generateIdenticon(id) + result.name = generateAlias(id) + else: + result.name = id \ No newline at end of file diff --git a/status/types/chat_member.nim b/status/types/chat_member.nim new file mode 100644 index 0000000..718cb65 --- /dev/null +++ b/status/types/chat_member.nim @@ -0,0 +1,21 @@ +{.used.} + +import strformat, json, sequtils + +type ChatMember* = object + admin*: bool + id*: string + joined*: bool + identicon*: string + userName*: string + localNickname*: string + +proc toJsonNode*(self: ChatMember): JsonNode = + result = %* { + "id": self.id, + "admin": self.admin, + "joined": self.joined + } + +proc toJsonNode*(self: seq[ChatMember]): seq[JsonNode] = + result = map(self, proc(x: ChatMember): JsonNode = x.toJsonNode) \ No newline at end of file diff --git a/status/types/chat_membership_event.nim b/status/types/chat_membership_event.nim new file mode 100644 index 0000000..d0b048c --- /dev/null +++ b/status/types/chat_membership_event.nim @@ -0,0 +1,28 @@ +{.used.} + +import strformat, json, sequtils + +type ChatMembershipEvent* = object + chatId*: string + clockValue*: int64 + fromKey*: string + name*: string + members*: seq[string] + rawPayload*: string + signature*: string + eventType*: int + +proc toJsonNode*(self: ChatMembershipEvent): JsonNode = + result = %* { + "chatId": self.chatId, + "name": self.name, + "clockValue": self.clockValue, + "from": self.fromKey, + "members": self.members, + "rawPayload": self.rawPayload, + "signature": self.signature, + "type": self.eventType + } + +proc toJsonNode*(self: seq[ChatMembershipEvent]): seq[JsonNode] = + result = map(self, proc(x: ChatMembershipEvent): JsonNode = x.toJsonNode) \ No newline at end of file diff --git a/status/types/community.nim b/status/types/community.nim new file mode 100644 index 0000000..dbec307 --- /dev/null +++ b/status/types/community.nim @@ -0,0 +1,91 @@ +{.used.} + +import json, strformat, tables +import chat, status_update, identity_image + +include community_category +include community_membership_request + +type Community* = object + id*: string + name*: string + lastChannelSeen*: string + description*: string + chats*: seq[Chat] + categories*: seq[CommunityCategory] + members*: seq[string] + access*: int + unviewedMessagesCount*: int + unviewedMentionsCount*: int + admin*: bool + joined*: bool + verified*: bool + ensOnly*: bool + canRequestAccess*: bool + canManageUsers*: bool + canJoin*: bool + isMember*: bool + muted*: bool + communityImage*: IdentityImage + membershipRequests*: seq[CommunityMembershipRequest] + communityColor*: string + memberStatus*: OrderedTable[string, StatusUpdate] + +proc `$`*(self: Community): string = + result = fmt"Community(id:{self.id}, name:{self.name}, description:{self.description}" + +proc toCommunity*(jsonCommunity: JsonNode): Community = + result = Community( + id: jsonCommunity{"id"}.getStr, + name: jsonCommunity{"name"}.getStr, + description: jsonCommunity{"description"}.getStr, + access: jsonCommunity{"permissions"}{"access"}.getInt, + admin: jsonCommunity{"admin"}.getBool, + joined: jsonCommunity{"joined"}.getBool, + verified: jsonCommunity{"verified"}.getBool, + ensOnly: jsonCommunity{"permissions"}{"ens_only"}.getBool, + canRequestAccess: jsonCommunity{"canRequestAccess"}.getBool, + canManageUsers: jsonCommunity{"canManageUsers"}.getBool, + canJoin: jsonCommunity{"canJoin"}.getBool, + isMember: jsonCommunity{"isMember"}.getBool, + muted: jsonCommunity{"muted"}.getBool, + chats: newSeq[Chat](), + members: newSeq[string](), + communityColor: jsonCommunity{"color"}.getStr, + communityImage: IdentityImage() + ) + + result.memberStatus = initOrderedTable[string, StatusUpdate]() + + if jsonCommunity.hasKey("images") and jsonCommunity["images"].kind != JNull: + if jsonCommunity["images"].hasKey("thumbnail"): + result.communityImage.thumbnail = jsonCommunity["images"]["thumbnail"]["uri"].str + if jsonCommunity["images"].hasKey("large"): + result.communityImage.large = jsonCommunity["images"]["large"]["uri"].str + + if jsonCommunity.hasKey("chats") and jsonCommunity["chats"].kind != JNull: + for chatId, chat in jsonCommunity{"chats"}: + result.chats.add(Chat( + id: result.id & chatId, + categoryId: chat{"categoryID"}.getStr(), + communityId: result.id, + name: chat{"name"}.getStr, + description: chat{"description"}.getStr, + canPost: chat{"canPost"}.getBool, + chatType: ChatType.CommunityChat, + private: chat{"permissions"}{"private"}.getBool, + position: chat{"position"}.getInt + )) + + if jsonCommunity.hasKey("categories") and jsonCommunity["categories"].kind != JNull: + for catId, cat in jsonCommunity{"categories"}: + result.categories.add(CommunityCategory( + id: catId, + name: cat{"name"}.getStr, + position: cat{"position"}.getInt + )) + + if jsonCommunity.hasKey("members") and jsonCommunity["members"].kind != JNull: + # memberInfo is empty for now + for memberPubKey, memeberInfo in jsonCommunity{"members"}: + result.members.add(memberPubKey) \ No newline at end of file diff --git a/status/types/community_category.nim b/status/types/community_category.nim new file mode 100644 index 0000000..49cab30 --- /dev/null +++ b/status/types/community_category.nim @@ -0,0 +1,6 @@ +{.used.} + +type CommunityCategory* = object + id*: string + name*: string + position*: int \ No newline at end of file diff --git a/status/types/community_membership_request.nim b/status/types/community_membership_request.nim new file mode 100644 index 0000000..7add50c --- /dev/null +++ b/status/types/community_membership_request.nim @@ -0,0 +1,21 @@ +{.used.} + +import json + +type CommunityMembershipRequest* = object + id*: string + publicKey*: string + chatId*: string + communityId*: string + state*: int + our*: string + +proc toCommunityMembershipRequest*(jsonCommunityMembershipRequest: JsonNode): CommunityMembershipRequest = + result = CommunityMembershipRequest( + id: jsonCommunityMembershipRequest{"id"}.getStr, + publicKey: jsonCommunityMembershipRequest{"publicKey"}.getStr, + chatId: jsonCommunityMembershipRequest{"chatId"}.getStr, + state: jsonCommunityMembershipRequest{"state"}.getInt, + communityId: jsonCommunityMembershipRequest{"communityId"}.getStr, + our: jsonCommunityMembershipRequest{"our"}.getStr, + ) \ No newline at end of file diff --git a/status/types/derived_account.nim b/status/types/derived_account.nim new file mode 100644 index 0000000..fd47d19 --- /dev/null +++ b/status/types/derived_account.nim @@ -0,0 +1,6 @@ +{.used.} + +type DerivedAccount* = object + publicKey*: string + address*: string + derivationPath*: string \ No newline at end of file diff --git a/status/types/fleet.nim b/status/types/fleet.nim new file mode 100644 index 0000000..bd5374b --- /dev/null +++ b/status/types/fleet.nim @@ -0,0 +1,52 @@ +{.used.} + +import json, typetraits, tables, sequtils + +type + Fleet* {.pure.} = enum + Prod = "eth.prod", + Staging = "eth.staging", + Test = "eth.test", + WakuV2Prod = "wakuv2.prod" + WakuV2Test = "wakuv2.test" + + FleetNodes* {.pure.} = enum + Bootnodes = "boot", + Mailservers = "mail", + Rendezvous = "rendezvous", + Whisper = "whisper", + Waku = "waku" + + FleetMeta* = object + hostname*: string + timestamp*: uint64 + + FleetConfig* = object + fleet*: Table[string, Table[string, Table[string, string]]] + meta*: FleetMeta + + +proc toFleetConfig*(jsonString: string): FleetConfig = + let fleetJson = jsonString.parseJSON + result.meta.hostname = fleetJson["meta"]["hostname"].getStr + result.meta.timestamp = fleetJson["meta"]["timestamp"].getBiggestInt.uint64 + result.fleet = initTable[string, Table[string, Table[string, string]]]() + + for fleet in fleetJson["fleets"].keys(): + result.fleet[fleet] = initTable[string, Table[string, string]]() + for nodes in fleetJson["fleets"][fleet].keys(): + result.fleet[fleet][nodes] = initTable[string, string]() + for server in fleetJson["fleets"][fleet][nodes].keys(): + result.fleet[fleet][nodes][server] = fleetJson["fleets"][fleet][nodes][server].getStr + + +proc getNodes*(self: FleetConfig, fleet: Fleet, nodeType: FleetNodes = FleetNodes.Bootnodes): seq[string] = + if not self.fleet[$fleet].hasKey($nodeType): return + result = toSeq(self.fleet[$fleet][$nodeType].values) + +proc getMailservers*(self: FleetConfig, fleet: Fleet): Table[string, string] = + if not self.fleet[$fleet].hasKey($FleetNodes.Mailservers): + result = initTable[string,string]() + return + result = self.fleet[$fleet][$FleetNodes.Mailservers] + diff --git a/status/types/gas_prediction.nim b/status/types/gas_prediction.nim new file mode 100644 index 0000000..9ce3f16 --- /dev/null +++ b/status/types/gas_prediction.nim @@ -0,0 +1,9 @@ +{.used.} + +type GasPricePrediction* = object + safeLow*: float + standard*: float + fast*: float + fastest*: float + currentBaseFee*: float + recommendedBaseFee*: float \ No newline at end of file diff --git a/status/types/identity_image.nim b/status/types/identity_image.nim new file mode 100644 index 0000000..a77913d --- /dev/null +++ b/status/types/identity_image.nim @@ -0,0 +1,6 @@ +{.used.} + +type + IdentityImage* = ref object + thumbnail*: string + large*: string \ No newline at end of file diff --git a/status/types/installation.nim b/status/types/installation.nim new file mode 100644 index 0000000..98aff46 --- /dev/null +++ b/status/types/installation.nim @@ -0,0 +1,14 @@ +import json + +type Installation* = ref object + installationId*: string + name*: string + deviceType*: string + enabled*: bool + isUserDevice*: bool + +proc toInstallation*(jsonInstallation: JsonNode): Installation = + result = Installation(installationid: jsonInstallation{"id"}.getStr, enabled: jsonInstallation{"enabled"}.getBool, name: "", deviceType: "", isUserDevice: false) + if jsonInstallation["metadata"].kind != JNull: + result.name = jsonInstallation["metadata"]["name"].getStr + result.deviceType = jsonInstallation["metadata"]["deviceType"].getStr diff --git a/status/types/message.nim b/status/types/message.nim new file mode 100644 index 0000000..97ed398 --- /dev/null +++ b/status/types/message.nim @@ -0,0 +1,192 @@ +{.used.} + +import json, strutils, sequtils, sugar, chronicles +import json_serialization +import ../utils +import ../wallet/account +import ../libstatus/accounts/constants as constants +import ../libstatus/wallet as status_wallet +import ../libstatus/settings as status_settings +import ../libstatus/tokens as status_tokens +import ../libstatus/eth/contracts as status_contracts +import web3/conversions +from ../utils import parseAddress, wei2Eth +import setting, network + +include message_command_parameters +include message_reaction +include message_text_item + +type ContentType* {.pure.} = enum + FetchMoreMessagesButton = -2 + ChatIdentifier = -1, + Unknown = 0, + Message = 1, + Sticker = 2, + Status = 3, + Emoji = 4, + Transaction = 5, + Group = 6, + Image = 7, + Audio = 8 + Community = 9 + Gap = 10 + Edit = 11 + +type Message* = object + alias*: string + userName*: string + localName*: string + chatId*: string + clock*: int + gapFrom*: int + gapTo*: int + commandParameters*: CommandParameters + contentType*: ContentType + ensName*: string + fromAuthor*: string + id*: string + identicon*: string + lineCount*: int + localChatId*: string + messageType*: string # ??? + parsedText*: seq[TextItem] + # quotedMessage: # ??? + replace*: string + responseTo*: string + rtl*: bool # ??? + seen*: bool # ??? + sticker*: string + stickerPackId*: int + text*: string + timestamp*: string + editedAt*: string + whisperTimestamp*: string + isCurrentUser*: bool + stickerHash*: string + outgoingStatus*: string + linkUrls*: string + image*: string + audio*: string + communityId*: string + audioDurationMs*: int + hasMention*: bool + isPinned*: bool + pinnedBy*: string + deleted*: bool + +proc `$`*(self: Message): string = + result = fmt"Message(id:{self.id}, chatId:{self.chatId}, clock:{self.clock}, from:{self.fromAuthor}, contentType:{self.contentType})" + +proc currentUserWalletContainsAddress(address: string): bool = + if (address.len == 0): + return false + + let accounts = status_wallet.getWalletAccounts() + for acc in accounts: + if (acc.address == address): + return true + + return false + +proc toMessage*(jsonMsg: JsonNode): Message = + let publicChatKey = status_settings.getSetting[string](Setting.PublicKey, "0x0") + + var contentType: ContentType + try: + contentType = ContentType(jsonMsg{"contentType"}.getInt) + except: + warn "Unknown content type received", type = jsonMsg{"contentType"}.getInt + contentType = ContentType.Message + + var message = Message( + alias: jsonMsg{"alias"}.getStr, + userName: "", + localName: "", + chatId: jsonMsg{"localChatId"}.getStr, + clock: jsonMsg{"clock"}.getInt, + contentType: contentType, + ensName: jsonMsg{"ensName"}.getStr, + fromAuthor: jsonMsg{"from"}.getStr, + id: jsonMsg{"id"}.getStr, + identicon: jsonMsg{"identicon"}.getStr, + lineCount: jsonMsg{"lineCount"}.getInt, + localChatId: jsonMsg{"localChatId"}.getStr, + messageType: jsonMsg{"messageType"}.getStr, + replace: jsonMsg{"replace"}.getStr, + editedAt: $jsonMsg{"editedAt"}.getInt, + responseTo: jsonMsg{"responseTo"}.getStr, + rtl: jsonMsg{"rtl"}.getBool, + seen: jsonMsg{"seen"}.getBool, + text: jsonMsg{"text"}.getStr, + timestamp: $jsonMsg{"timestamp"}.getInt, + whisperTimestamp: $jsonMsg{"whisperTimestamp"}.getInt, + outgoingStatus: $jsonMsg{"outgoingStatus"}.getStr, + isCurrentUser: publicChatKey == jsonMsg{"from"}.getStr, + stickerHash: "", + stickerPackId: -1, + parsedText: @[], + linkUrls: "", + image: $jsonMsg{"image"}.getStr, + audio: $jsonMsg{"audio"}.getStr, + communityId: $jsonMsg{"communityId"}.getStr, + audioDurationMs: jsonMsg{"audioDurationMs"}.getInt, + deleted: jsonMsg{"deleted"}.getBool, + hasMention: false + ) + + if contentType == ContentType.Gap: + message.gapFrom = jsonMsg["gapParameters"]["from"].getInt + message.gapTo = jsonMsg["gapParameters"]["to"].getInt + + if jsonMsg.contains("parsedText") and jsonMsg{"parsedText"}.kind != JNull: + for text in jsonMsg{"parsedText"}: + message.parsedText.add(text.toTextItem) + + message.linkUrls = concat(message.parsedText.map(t => t.children.filter(c => c.textType == "link"))) + .filter(t => t.destination.startsWith("http") or t.destination.startsWith("statusim://")) + .map(t => t.destination) + .join(" ") + + if message.contentType == ContentType.Sticker: + message.stickerHash = jsonMsg["sticker"]["hash"].getStr + message.stickerPackId = jsonMsg["sticker"]["pack"].getInt + + if message.contentType == ContentType.Transaction: + let + allContracts = getErc20Contracts().concat(getCustomTokens()) + ethereum = newErc20Contract("Ethereum", Network.Mainnet, parseAddress(constants.ZERO_ADDRESS), "ETH", 18, true) + tokenAddress = jsonMsg["commandParameters"]["contract"].getStr + tokenContract = if tokenAddress == "": ethereum else: allContracts.getErc20ContractByAddress(parseAddress(tokenAddress)) + tokenContractStr = if tokenContract == nil: "{}" else: $(Json.encode(tokenContract)) + var weiStr = if tokenContract == nil: "0" else: wei2Eth(jsonMsg["commandParameters"]["value"].getStr, tokenContract.decimals) + weiStr.trimZeros() + + # TODO find a way to use json_seralization for this. When I try, I get an error + message.commandParameters = CommandParameters( + id: jsonMsg["commandParameters"]["id"].getStr, + fromAddress: jsonMsg["commandParameters"]["from"].getStr, + address: jsonMsg["commandParameters"]["address"].getStr, + contract: tokenContractStr, + value: weiStr, + transactionHash: jsonMsg["commandParameters"]["transactionHash"].getStr, + commandState: jsonMsg["commandParameters"]["commandState"].getInt, + signature: jsonMsg["commandParameters"]["signature"].getStr + ) + + # This is kind of a workaround in case we're processing a transaction message. The reason for + # that is a message where a recipient accepted to share his address with sender. In that message + # a recipient's public key is set as a "from" property of a "Message" object and we cannot + # determine which of two users has initiated transaction actually. + # + # To overcome this we're checking if the "from" address from the "commandParameters" object of + # the "Message" is contained as an address in the wallet of logged in user. If yes, means that + # currently logged in user has initiated a transaction (he is a sender), otherwise currently + # logged in user is a recipient. + message.isCurrentUser = currentUserWalletContainsAddress(message.commandParameters.fromAddress) + + message.hasMention = concat(message.parsedText.map( + t => t.children.filter( + c => c.textType == "mention" and c.literal == publicChatKey))).len > 0 + + result = message \ No newline at end of file diff --git a/status/types/message_command_parameters.nim b/status/types/message_command_parameters.nim new file mode 100644 index 0000000..edb0b3f --- /dev/null +++ b/status/types/message_command_parameters.nim @@ -0,0 +1,16 @@ +{.used.} + +import strformat + +type CommandParameters* = object + id*: string + fromAddress*: string + address*: string + contract*: string + value*: string + transactionHash*: string + commandState*: int + signature*: string + +proc `$`*(self: CommandParameters): string = + result = fmt"CommandParameters(id:{self.id}, fromAddr:{self.fromAddress}, addr:{self.address}, contract:{self.contract}, value:{self.value}, transactionHash:{self.transactionHash}, commandState:{self.commandState}, signature:{self.signature})" \ No newline at end of file diff --git a/status/types/message_reaction.nim b/status/types/message_reaction.nim new file mode 100644 index 0000000..f554aa3 --- /dev/null +++ b/status/types/message_reaction.nim @@ -0,0 +1,21 @@ +{.used.} + +import json + +type Reaction* = object + id*: string + chatId*: string + fromAccount*: string + messageId*: string + emojiId*: int + retracted*: bool + +proc toReaction*(jsonReaction: JsonNode): Reaction = + result = Reaction( + id: jsonReaction{"id"}.getStr, + chatId: jsonReaction{"chatId"}.getStr, + fromAccount: jsonReaction{"from"}.getStr, + messageId: jsonReaction{"messageId"}.getStr, + emojiId: jsonReaction{"emojiId"}.getInt, + retracted: jsonReaction{"retracted"}.getBool + ) \ No newline at end of file diff --git a/status/types/message_text_item.nim b/status/types/message_text_item.nim new file mode 100644 index 0000000..210ea49 --- /dev/null +++ b/status/types/message_text_item.nim @@ -0,0 +1,25 @@ +{.used.} + +import json, strutils + +type TextItem* = object + textType*: string + children*: seq[TextItem] + literal*: string + destination*: string + +proc toTextItem*(jsonText: JsonNode): TextItem = + result = TextItem( + literal: jsonText{"literal"}.getStr, + textType: jsonText{"type"}.getStr, + destination: jsonText{"destination"}.getStr, + children: @[] + ) + if (result.literal.startsWith("statusim://")): + result.textType = "link" + # TODO isolate the link only + result.destination = result.literal + + if jsonText.hasKey("children") and jsonText["children"].kind != JNull: + for child in jsonText["children"]: + result.children.add(child.toTextItem) \ No newline at end of file diff --git a/status/types/multi_accounts.nim b/status/types/multi_accounts.nim new file mode 100644 index 0000000..f118712 --- /dev/null +++ b/status/types/multi_accounts.nim @@ -0,0 +1,12 @@ +{.used.} + +import json_serialization +import ../libstatus/accounts/constants + +include derived_account + +type MultiAccounts* = object + whisper* {.serializedFieldName(PATH_WHISPER).}: DerivedAccount + walletRoot* {.serializedFieldName(PATH_WALLET_ROOT).}: DerivedAccount + defaultWallet* {.serializedFieldName(PATH_DEFAULT_WALLET).}: DerivedAccount + eip1581* {.serializedFieldName(PATH_EIP_1581).}: DerivedAccount \ No newline at end of file diff --git a/status/types/network.nim b/status/types/network.nim new file mode 100644 index 0000000..ddd37b7 --- /dev/null +++ b/status/types/network.nim @@ -0,0 +1,15 @@ +{.used.} + +include node_config +include network_details +include upstream_config + +type + Network* {.pure.} = enum + Mainnet = "mainnet_rpc", + Testnet = "testnet_rpc", + Rinkeby = "rinkeby_rpc", + Goerli = "goerli_rpc", + XDai = "xdai_rpc", + Poa = "poa_rpc", + Other = "other" \ No newline at end of file diff --git a/status/types/network_details.nim b/status/types/network_details.nim new file mode 100644 index 0000000..6e4aeb7 --- /dev/null +++ b/status/types/network_details.nim @@ -0,0 +1,12 @@ +{.used.} + +import json_serialization + +import node_config + +type + NetworkDetails* = ref object + id*: string + name*: string + etherscanLink* {.serializedFieldName("etherscan-link").}: string + config*: NodeConfig diff --git a/status/types/node_config.nim b/status/types/node_config.nim new file mode 100644 index 0000000..c266e32 --- /dev/null +++ b/status/types/node_config.nim @@ -0,0 +1,11 @@ +{.used.} + +import json_serialization + +import upstream_config + +type + NodeConfig* = ref object + networkId* {.serializedFieldName("NetworkId").}: int + dataDir* {.serializedFieldName("DataDir").}: string + upstreamConfig* {.serializedFieldName("UpstreamConfig").}: UpstreamConfig \ No newline at end of file diff --git a/status/types/os_notification.nim b/status/types/os_notification.nim new file mode 100644 index 0000000..e9eda5e --- /dev/null +++ b/status/types/os_notification.nim @@ -0,0 +1,46 @@ +{.used.} + +import json + +import ../../eventemitter + +type + OsNotificationType* {.pure.} = enum + NewContactRequest = 1, + AcceptedContactRequest, + JoinCommunityRequest, + AcceptedIntoCommunity, + RejectedByCommunity, + NewMessage + + OsNotificationDetails* = object + notificationType*: OsNotificationType + communityId*: string + channelId*: string + messageId*: string + +type + OsNotificationsArgs* = ref object of Args + details*: OsNotificationDetails + +proc toOsNotificationDetails*(json: JsonNode): OsNotificationDetails = + if (not (json.contains("notificationType") and + json.contains("communityId") and + json.contains("channelId") and + json.contains("messageId"))): + return OsNotificationDetails() + + return OsNotificationDetails( + notificationType: json{"notificationType"}.getInt.OsNotificationType, + communityId: json{"communityId"}.getStr, + channelId: json{"channelId"}.getStr, + messageId: json{"messageId"}.getStr + ) + +proc toJsonNode*(self: OsNotificationDetails): JsonNode = + result = %* { + "notificationType": self.notificationType.int, + "communityId": self.communityId, + "channelId": self.channelId, + "messageId": self.messageId + } \ No newline at end of file diff --git a/status/types/pending_transaction_type.nim b/status/types/pending_transaction_type.nim new file mode 100644 index 0000000..fa094a5 --- /dev/null +++ b/status/types/pending_transaction_type.nim @@ -0,0 +1,8 @@ +{.used.} + +type PendingTransactionType* {.pure.} = enum + RegisterENS = "RegisterENS", + SetPubKey = "SetPubKey", + ReleaseENS = "ReleaseENS", + BuyStickerPack = "BuyStickerPack" + WalletTransfer = "WalletTransfer" diff --git a/status/types/profile.nim b/status/types/profile.nim new file mode 100644 index 0000000..540bf37 --- /dev/null +++ b/status/types/profile.nim @@ -0,0 +1,49 @@ +{.used.} + +import json, strformat +import identity_image + +export identity_image + +type Profile* = ref object + id*, alias*, username*, identicon*, address*, ensName*, localNickname*: string + ensVerified*: bool + messagesFromContactsOnly*: bool + sendUserStatus*: bool + currentUserStatus*: int + identityImage*: IdentityImage + appearance*: int + systemTags*: seq[string] + +proc `$`*(self: Profile): string = + return fmt"Profile(id:{self.id}, username:{self.username})" + +proc toProfileModel*(profile: JsonNode): Profile = + var systemTags: seq[string] = @[] + if profile["systemTags"].kind != JNull: + systemTags = profile["systemTags"].to(seq[string]) + + result = Profile( + id: profile["id"].str, + username: profile["alias"].str, + identicon: profile["identicon"].str, + identityImage: IdentityImage(), + address: profile["id"].str, + alias: profile["alias"].str, + ensName: "", + ensVerified: profile["ensVerified"].getBool, + appearance: 0, + systemTags: systemTags + ) + + if profile.hasKey("name"): + result.ensName = profile["name"].str + + if profile.hasKey("localNickname"): + result.localNickname = profile["localNickname"].str + + if profile.hasKey("images") and profile["images"].kind != JNull: + if profile["images"].hasKey("thumbnail"): + result.identityImage.thumbnail = profile["images"]["thumbnail"]["uri"].str + if profile["images"].hasKey("large"): + result.identityImage.large = profile["images"]["large"]["uri"].str \ No newline at end of file diff --git a/status/types/removed_message.nim b/status/types/removed_message.nim new file mode 100644 index 0000000..dffab11 --- /dev/null +++ b/status/types/removed_message.nim @@ -0,0 +1,13 @@ +{.used.} + +import json + +type RemovedMessage* = object + chatId*: string + messageId*: string + +proc toRemovedMessage*(jsonRemovedMessage: JsonNode): RemovedMessage = + result = RemovedMessage( + chatId: jsonRemovedMessage{"chatId"}.getStr, + messageId: jsonRemovedMessage{"messageId"}.getStr, + ) \ No newline at end of file diff --git a/status/types/rpc_response.nim b/status/types/rpc_response.nim new file mode 100644 index 0000000..9a6e30b --- /dev/null +++ b/status/types/rpc_response.nim @@ -0,0 +1,29 @@ +{.used.} + +type + RpcError* = ref object + code*: int + message*: string + +type + RpcResponse* = ref object + jsonrpc*: string + result*: string + id*: int + error*: RpcError + + # TODO: replace all RpcResponse and RpcResponseTyped occurances with a generic + # form of RpcReponse. IOW, rename RpceResponseTyped*[T] to RpcResponse*[T] and + # remove RpcResponse. +type + RpcResponseTyped*[T] = object + jsonrpc*: string + result*: T + id*: int + error*: RpcError + +type + StatusGoException* = object of CatchableError + +type + RpcException* = object of CatchableError \ No newline at end of file diff --git a/status/types/setting.nim b/status/types/setting.nim new file mode 100644 index 0000000..123f58b --- /dev/null +++ b/status/types/setting.nim @@ -0,0 +1,31 @@ +{.used.} + +type + Setting* {.pure.} = enum + Appearance = "appearance", + Bookmarks = "bookmarks", + Currency = "currency" + EtherscanLink = "etherscan-link" + InstallationId = "installation-id" + MessagesFromContactsOnly = "messages-from-contacts-only" + Mnemonic = "mnemonic" + Networks_Networks = "networks/networks" + Networks_CurrentNetwork = "networks/current-network" + NodeConfig = "node-config" + PublicKey = "public-key" + DappsAddress = "dapps-address" + Stickers_PacksInstalled = "stickers/packs-installed" + Stickers_Recent = "stickers/recent-stickers" + Gifs_Recent = "gifs/recent-gifs" + Gifs_Favorite = "gifs/favorite-gifs" + WalletRootAddress = "wallet-root-address" + LatestDerivedPath = "latest-derived-path" + PreferredUsername = "preferred-name" + Usernames = "usernames" + SigningPhrase = "signing-phrase" + Fleet = "fleet" + VisibleTokens = "wallet/visible-tokens" + PinnedMailservers = "pinned-mailservers" + WakuBloomFilterMode = "waku-bloom-filter-mode" + SendUserStatus = "send-status-updates?" + CurrentUserStatus = "current-user-status" \ No newline at end of file diff --git a/status/types/status_update.nim b/status/types/status_update.nim new file mode 100644 index 0000000..56c084f --- /dev/null +++ b/status/types/status_update.nim @@ -0,0 +1,24 @@ +{.used.} + +import json + +type StatusUpdateType* {.pure.}= enum + Unknown = 0, + Online = 1, + DoNotDisturb = 2 + +type StatusUpdate* = object + publicKey*: string + statusType*: StatusUpdateType + clock*: uint64 + text*: string + +proc toStatusUpdate*(jsonStatusUpdate: JsonNode): StatusUpdate = + let statusTypeInt = jsonStatusUpdate{"statusType"}.getInt + let statusType: StatusUpdateType = if statusTypeInt >= ord(low(StatusUpdateType)) or statusTypeInt <= ord(high(StatusUpdateType)): StatusUpdateType(statusTypeInt) else: StatusUpdateType.Unknown + result = StatusUpdate( + publicKey: jsonStatusUpdate{"publicKey"}.getStr, + statusType: statusType, + clock: uint64(jsonStatusUpdate{"clock"}.getBiggestInt), + text: jsonStatusUpdate{"text"}.getStr + ) \ No newline at end of file diff --git a/status/types/sticker.nim b/status/types/sticker.nim new file mode 100644 index 0000000..62055c4 --- /dev/null +++ b/status/types/sticker.nim @@ -0,0 +1,32 @@ +{.used.} + +import json, options, typetraits +import web3/ethtypes, json_serialization, stint + +type Sticker* = object + hash*: string + packId*: int + +type StickerPack* = object + author*: string + id*: int + name*: string + price*: Stuint[256] + preview*: string + stickers*: seq[Sticker] + thumbnail*: string + +proc `%`*(stuint256: Stuint[256]): JsonNode = + newJString($stuint256) + +proc readValue*(reader: var JsonReader, value: var Stuint[256]) + {.raises: [IOError, SerializationError, Defect].} = + try: + let strVal = reader.readValue(string) + value = strVal.parse(Stuint[256]) + except: + try: + let intVal = reader.readValue(int) + value = intVal.stuint(256) + except: + raise newException(SerializationError, "Expected string or int representation of Stuint[256]") \ No newline at end of file diff --git a/status/types/transaction.nim b/status/types/transaction.nim new file mode 100644 index 0000000..d86d77d --- /dev/null +++ b/status/types/transaction.nim @@ -0,0 +1,30 @@ +{.used.} + +import strutils + +include pending_transaction_type + +type + Transaction* = ref object + id*: string + typeValue*: string + address*: string + blockNumber*: string + blockHash*: string + contract*: string + timestamp*: string + gasPrice*: string + gasLimit*: string + gasUsed*: string + nonce*: string + txStatus*: string + value*: string + fromAddress*: string + to*: string + +proc cmpTransactions*(x, y: Transaction): int = + # Sort proc to compare transactions from a single account. + # Compares first by block number, then by nonce + result = cmp(x.blockNumber.parseHexInt, y.blockNumber.parseHexInt) + if result == 0: + result = cmp(x.nonce, y.nonce) \ No newline at end of file diff --git a/status/types/upstream_config.nim b/status/types/upstream_config.nim new file mode 100644 index 0000000..81a513f --- /dev/null +++ b/status/types/upstream_config.nim @@ -0,0 +1,8 @@ +{.used.} + +import json_serialization + +type + UpstreamConfig* = ref object + enabled* {.serializedFieldName("Enabled").}: bool + url* {.serializedFieldName("URL").}: string \ No newline at end of file diff --git a/status/updates.nim b/status/updates.nim new file mode 100644 index 0000000..3868f13 --- /dev/null +++ b/status/updates.nim @@ -0,0 +1,44 @@ +import ens, provider +import stew/byteutils +from base32 import nil +import chronicles, httpclient, net +import nbaser, strutils +import semver +import constants + + +type + VersionInfo* = object + version*: string + url*: string + +proc getLatestVersion*(): VersionInfo = + let contentHash = contenthash(APP_UPDATES_ENS) + if contentHash == "": + raise newException(ValueError, "ENS does not have a content hash") + + var url: string = "" + + let decodedHash = contentHash.decodeENSContentHash() + case decodedHash[0]: + of ENSType.IPFS: + let base32Hash = base32.encode(string.fromBytes(base58.decode(decodedHash[1]))).toLowerAscii().replace("=", "") + url = "https://" & base32Hash & IPFS_GATEWAY + of ENSType.SWARM: + url = "https://" & SWARM_GATEWAY & "/bzz:/" & decodedHash[1] + of ENSType.IPNS: + url = "https://" & decodedHash[1] + else: + warn "Unknown content for", contentHash + raise newException(ValueError, "Unknown content for " & contentHash) + + # Read version from folder + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext, timeout = CHECK_VERSION_TIMEOUT_MS) + result.version = client.getContent(url & "/VERSION").strip() + result.url = url + +proc isNewer*(currentVersion, versionToCheck: string): bool = + let lastVersion = parseVersion(versionToCheck) + let currVersion = parseVersion(currentVersion) + result = lastVersion > currVersion diff --git a/status/utils.nim b/status/utils.nim new file mode 100644 index 0000000..d0720a0 --- /dev/null +++ b/status/utils.nim @@ -0,0 +1,178 @@ +import json, random, strutils, strformat, tables, chronicles, unicode, times +from sugar import `=>`, `->` +import stint +from times import getTime, toUnix, nanosecond +import libstatus/accounts/signing_phrases +from web3 import Address, fromHex +import web3/ethhexstrings + +proc getTimelineChatId*(pubKey: string = ""): string = + if pubKey == "": + return "@timeline70bd746ddcc12beb96b2c9d572d0784ab137ffc774f5383e50585a932080b57cca0484b259e61cecbaa33a4c98a300a" + else: + return "@" & pubKey + +proc isWakuEnabled(): bool = + true # TODO: + +proc prefix*(methodName: string, isExt:bool = true): string = + result = if isWakuEnabled(): "waku" else: "shh" + result = result & (if isExt: "ext_" else: "_") + result = result & methodName + +proc isOneToOneChat*(chatId: string): bool = + result = chatId.startsWith("0x") # There is probably a better way to do this + +proc keys*(obj: JsonNode): seq[string] = + result = newSeq[string]() + for k, _ in obj: + result.add k + +proc generateSigningPhrase*(count: int): string = + let now = getTime() + var rng = initRand(now.toUnix * 1000000000 + now.nanosecond) + var phrases: seq[string] = @[] + + for i in 1..count: + phrases.add(rng.sample(signing_phrases.phrases)) + + result = phrases.join(" ") + +proc handleRPCErrors*(response: string) = + let parsedReponse = parseJson(response) + if (parsedReponse.hasKey("error")): + raise newException(ValueError, parsedReponse["error"]["message"].str) + +proc toStUInt*[bits: static[int]](flt: float, T: typedesc[StUint[bits]]): T = + var stringValue = fmt"{flt:<.0f}" + stringValue.removeSuffix('.') + if (flt >= 0): + result = parse($stringValue, StUint[bits]) + else: + result = parse("0", StUint[bits]) + +proc toUInt256*(flt: float): UInt256 = + toStUInt(flt, StUInt[256]) + +proc toUInt64*(flt: float): StUInt[64] = + toStUInt(flt, StUInt[64]) + +proc eth2Wei*(eth: float, decimals: int = 18): UInt256 = + let weiValue = eth * parseFloat(alignLeft("1", decimals + 1, '0')) + weiValue.toUInt256 + +proc gwei2Wei*(gwei: float): UInt256 = + eth2Wei(gwei, 9) + +proc wei2Eth*(input: Stuint[256], decimals: int = 18): string = + var one_eth = u256(10).pow(decimals) # fromHex(Stuint[256], "DE0B6B3A7640000") + + var (eth, remainder) = divmod(input, one_eth) + let leading_zeros = "0".repeat(($one_eth).len - ($remainder).len - 1) + + fmt"{eth}.{leading_zeros}{remainder}" + +proc wei2Eth*(input: string, decimals: int): string = + try: + var input256: Stuint[256] + if input.contains("e+"): # we have a js string BN, ie 1e+21 + let + inputSplit = input.split("e+") + whole = inputSplit[0].u256 + remainder = u256(10).pow(inputSplit[1].parseInt) + input256 = whole * remainder + else: + input256 = input.u256 + result = wei2Eth(input256, decimals) + except Exception as e: + error "Error parsing this wei value", input, msg=e.msg + result = "0" + + +proc first*(jArray: JsonNode, fieldName, id: string): JsonNode = + if jArray == nil: + return nil + if jArray.kind != JArray: + raise newException(ValueError, "Parameter 'jArray' is a " & $jArray.kind & ", but must be a JArray") + for child in jArray.getElems: + if child{fieldName}.getStr.toLower == id.toLower: + return child + +proc any*(jArray: JsonNode, fieldName, id: string): bool = + if jArray == nil: + return false + result = false + for child in jArray.getElems: + if child{fieldName}.getStr.toLower == id.toLower: + return true + +proc isEmpty*(a: JsonNode): bool = + case a.kind: + of JObject: return a.fields.len == 0 + of JArray: return a.elems.len == 0 + of JString: return a.str == "" + of JNull: return true + else: + return false + +proc find*[T](s: seq[T], pred: proc(x: T): bool {.closure.}): T {.inline.} = + let results = s.filter(pred) + if results.len == 0: + return default(type(T)) + result = results[0] + +proc find*[T](s: seq[T], pred: proc(x: T): bool {.closure.}, found: var bool): T {.inline.} = + let results = s.filter(pred) + if results.len == 0: + found = false + return default(type(T)) + result = results[0] + found = true + +proc parseAddress*(strAddress: string): Address = + fromHex(Address, strAddress) + +proc isAddress*(strAddress: string): bool = + try: + discard parseAddress(strAddress) + except: + return false + return true + +proc validateTransactionInput*(from_addr, to_addr, assetAddress, value, gas, gasPrice, data, uuid: string) = + if not isAddress(from_addr): raise newException(ValueError, "from_addr is not a valid ETH address") + if not isAddress(to_addr): raise newException(ValueError, "to_addr is not a valid ETH address") + if parseFloat(value) < 0: raise newException(ValueError, "value should be a number >= 0") + if parseInt(gas) <= 0: raise newException(ValueError, "gas should be a number > 0") + if parseFloat(gasPrice) <= 0: raise newException(ValueError, "gasPrice should be a number > 0") + + if uuid.isEmptyOrWhitespace(): raise newException(ValueError, "uuid is required") + + if assetAddress != "": # If a token is being used + if not isAddress(assetAddress): raise newException(ValueError, "assetAddress is not a valid ETH address") + if assetAddress == "0x0000000000000000000000000000000000000000": raise newException(ValueError, "assetAddress requires a valid token address") + + if data != "": # If data is being used + if not validate(HexDataStr(data)): raise newException(ValueError, "data should contain a valid hex string") + +proc hex2Time*(hex: string): Time = + # represents the time since 1970-01-01T00:00:00Z + fromUnix(fromHex[int64](hex)) + +proc hex2LocalDateTime*(hex: string): DateTime = + # Convert hex time (since 1970-01-01T00:00:00Z) into a DateTime using the + # local timezone. + hex.hex2Time.local + +proc isUnique*[T](key: T, existingKeys: var seq[T]): bool = + # If the key doesn't exist in the existingKeys seq, add it and return true. + # Otherwise, the key already existed, so return false. + # Can be used to deduplicate sequences with `deduplicate[T]`. + if not existingKeys.contains(key): + existingKeys.add key + return true + return false + +proc deduplicate*[T](txs: var seq[T], key: (T) -> string) = + var existingKeys: seq[string] = @[] + txs.keepIf(tx => tx.key().isUnique(existingKeys)) diff --git a/status/utils/cache.nim b/status/utils/cache.nim new file mode 100644 index 0000000..566c6f4 --- /dev/null +++ b/status/utils/cache.nim @@ -0,0 +1,17 @@ +import tables, times + +type ValueTime* = ref object + value*: string + timestamp*: DateTime + +type CachedValues* = Table[string, ValueTime] + +proc newCachedValues*(): CachedValues = initTable[string, ValueTime]() + +proc isCached*(self: CachedValues, cacheKey: string, duration=initDuration(minutes = 5)): bool = + self.hasKey(cacheKey) and ((self[cacheKey].timestamp + duration) >= now()) + +proc cacheValue*(self: var CachedValues, cacheKey: string, value: string) = + self[cacheKey] = ValueTime(value: value, timestamp: now()) + +proc get*(self: var CachedValues, cacheKey: string): string = self[cacheKey].value diff --git a/status/utils/json_utils.nim b/status/utils/json_utils.nim new file mode 100644 index 0000000..6ca96d6 --- /dev/null +++ b/status/utils/json_utils.nim @@ -0,0 +1,33 @@ +import json + +template getProp(obj: JsonNode, prop: string, value: var typedesc[int]): bool = + var success = false + if (obj.kind == JObject and obj.contains(prop)): + value = obj[prop].getInt + success = true + + success + +template getProp(obj: JsonNode, prop: string, value: var typedesc[string]): bool = + var success = false + if (obj.kind == JObject and obj.contains(prop)): + value = obj[prop].getStr + success = true + + success + +template getProp(obj: JsonNode, prop: string, value: var typedesc[float]): bool = + var success = false + if (obj.kind == JObject and obj.contains(prop)): + value = obj[prop].getFloat + success = true + + success + +template getProp(obj: JsonNode, prop: string, value: var typedesc[JsonNode]): bool = + var success = false + if (obj.kind == JObject and obj.contains(prop)): + value = obj[prop] + success = true + + success \ No newline at end of file diff --git a/status/wallet.nim b/status/wallet.nim new file mode 100644 index 0000000..9a62b8c --- /dev/null +++ b/status/wallet.nim @@ -0,0 +1,408 @@ +import json, strformat, strutils, chronicles, sequtils, sugar, httpclient, tables, net +import json_serialization, stint +from web3/ethtypes import Address, EthSend, Quantity +from web3/conversions import `$` +from libstatus/core import getBlockByNumber +import libstatus/accounts as status_accounts +import libstatus/tokens as status_tokens +import libstatus/settings as status_settings +import libstatus/wallet as status_wallet +import libstatus/accounts/constants as constants +import libstatus/eth/[eth, contracts] +from libstatus/core import getBlockByNumber +from utils as libstatus_utils import eth2Wei, gwei2Wei, first, toUInt64, parseAddress +import wallet/[balance_manager, collectibles] +import wallet/account as wallet_account +import transactions +import ../eventemitter +import options +import ./types/[account, transaction, network, setting, gas_prediction, rpc_response] +export wallet_account, collectibles +export Transaction + +logScope: + topics = "wallet-model" + +proc confirmed*(self:PendingTransactionType):string = + result = "transaction:" & $self + +type TransactionMinedArgs* = ref object of Args + data*: string + transactionHash*: string + success*: bool + revertReason*: string # TODO: possible to get revert reason in here? + +type WalletModel* = ref object + events*: EventEmitter + accounts*: seq[WalletAccount] + defaultCurrency*: string + tokens*: seq[Erc20Contract] + totalBalance*: float + +proc getDefaultCurrency*(self: WalletModel): string +proc calculateTotalFiatBalance*(self: WalletModel) + +proc newWalletModel*(events: EventEmitter): WalletModel = + result = WalletModel() + result.accounts = @[] + result.tokens = @[] + result.events = events + result.defaultCurrency = "" + result.totalBalance = 0.0 + +proc initEvents*(self: WalletModel) = + self.events.on("currencyChanged") do(e: Args): + self.defaultCurrency = self.getDefaultCurrency() + for account in self.accounts: + updateBalance(account, self.getDefaultCurrency()) + self.calculateTotalFiatBalance() + self.events.emit("accountsUpdated", Args()) + + self.events.on("newAccountAdded") do(e: Args): + self.calculateTotalFiatBalance() + +proc delete*(self: WalletModel) = + discard + +proc buildTokenTransaction(source, to, assetAddress: Address, value: float, transfer: var Transfer, contract: var Erc20Contract, gas = "", gasPrice = ""): EthSend = + contract = getErc20Contract(assetAddress) + if contract == nil: + raise newException(ValueError, fmt"Could not find ERC-20 contract with address '{assetAddress}' for the current network") + transfer = Transfer(to: to, value: eth2Wei(value, contract.decimals)) + transactions.buildTokenTransaction(source, assetAddress, gas, gasPrice) + +proc getKnownTokenContract*(self: WalletModel, address: Address): Erc20Contract = + getErc20Contracts().concat(getCustomTokens()).getErc20ContractByAddress(address) + +proc estimateGas*(self: WalletModel, source, to, value, data: string, success: var bool): string = + var tx = transactions.buildTransaction( + parseAddress(source), + eth2Wei(parseFloat(value), 18), + data = data + ) + tx.to = parseAddress(to).some + result = eth.estimateGas(tx, success) + +proc getTransactionReceipt*(self: WalletModel, transactionHash: string): JsonNode = + result = status_wallet.getTransactionReceipt(transactionHash).parseJSON()["result"] + +proc confirmTransactionStatus(self: WalletModel, pendingTransactions: JsonNode, blockNumber: int) = + for trx in pendingTransactions.getElems(): + let transactionReceipt = self.getTransactionReceipt(trx["hash"].getStr) + if transactionReceipt.kind != JNull: + status_wallet.deletePendingTransaction(trx["hash"].getStr) + let ev = TransactionMinedArgs( + data: trx["additionalData"].getStr, + transactionHash: trx["hash"].getStr, + success: transactionReceipt{"status"}.getStr == "0x1", + revertReason: "" + ) + self.events.emit(parseEnum[PendingTransactionType](trx["type"].getStr).confirmed, ev) + +proc getLatestBlockNumber*(self: WalletModel): int = + let response = getBlockByNumber("latest").parseJson() + if not response.hasKey("result"): + return -1 + + return parseInt($fromHex(Stuint[256], response["result"]["number"].getStr)) + +proc checkPendingTransactions*(self: WalletModel) = + let latestBlockNumber = self.getLatestBlockNumber() + if latestBlockNumber == -1: + return + + let pendingTransactions = status_wallet.getPendingTransactions() + if (pendingTransactions != ""): + self.confirmTransactionStatus(pendingTransactions.parseJson{"result"}, latestBlockNumber) + +proc checkPendingTransactions*(self: WalletModel, address: string, blockNumber: int) = + self.confirmTransactionStatus(status_wallet.getPendingOutboundTransactionsByAddress(address).parseJson["result"], blockNumber) + +proc estimateTokenGas*(self: WalletModel, source, to, assetAddress, value: string, success: var bool): string = + var + transfer: Transfer + contract: Erc20Contract + tx = buildTokenTransaction( + parseAddress(source), + parseAddress(to), + parseAddress(assetAddress), + parseFloat(value), + transfer, + contract + ) + + result = contract.methods["transfer"].estimateGas(tx, transfer, success) + +proc sendTransaction*(source, to, value, gas, gasPrice, password: string, success: var bool, data = ""): string = + var tx = transactions.buildTransaction( + parseAddress(source), + eth2Wei(parseFloat(value), 18), gas, gasPrice, data + ) + + if to != "": + tx.to = parseAddress(to).some + + result = eth.sendTransaction(tx, password, success) + if success: + trackPendingTransaction(result, $source, $to, PendingTransactionType.WalletTransfer, "") + +proc sendTokenTransaction*(source, to, assetAddress, value, gas, gasPrice, password: string, success: var bool): string = + var + transfer: Transfer + contract: Erc20Contract + tx = buildTokenTransaction( + parseAddress(source), + parseAddress(to), + parseAddress(assetAddress), + parseFloat(value), + transfer, + contract, + gas, + gasPrice + ) + + result = contract.methods["transfer"].send(tx, transfer, password, success) + if success: + trackPendingTransaction(result, $source, $to, PendingTransactionType.WalletTransfer, "") + +proc getDefaultCurrency*(self: WalletModel): string = + # TODO: this should come from a model? It is going to be used too in the + # profile section and ideally we should not call the settings more than once + status_settings.getSetting[string](Setting.Currency, "usd") + +# TODO: This needs to be removed or refactored so that test tokens are shown +# when on testnet https://github.com/status-im/nim-status-client/issues/613. +proc getStatusToken*(self: WalletModel): string = + var + token = Asset() + erc20Contract = getSntContract() + token.name = erc20Contract.name + token.symbol = erc20Contract.symbol + token.address = $erc20Contract.address + result = $(%token) + +proc setDefaultCurrency*(self: WalletModel, currency: string) = + discard status_settings.saveSetting(Setting.Currency, currency) + self.events.emit("currencyChanged", CurrencyArgs(currency: currency)) + +proc generateAccountConfiguredAssets*(self: WalletModel, accountAddress: string): seq[Asset] = + var assets: seq[Asset] = @[] + var asset = Asset(name:"Ethereum", symbol: "ETH", value: "0.0", fiatBalanceDisplay: "0.0", accountAddress: accountAddress) + assets.add(asset) + for token in self.tokens: + var symbol = token.symbol + var existingToken = Asset(name: token.name, symbol: symbol, value: fmt"0.0", fiatBalanceDisplay: "$0.0", accountAddress: accountAddress, address: $token.address) + assets.add(existingToken) + assets + +proc populateAccount*(self: WalletModel, walletAccount: var WalletAccount, balance: string, refreshCache: bool = false) = + var assets: seq[Asset] = self.generateAccountConfiguredAssets(walletAccount.address) + walletAccount.balance = none[string]() + walletAccount.assetList = assets + walletAccount.realFiatBalance = none[float]() + +proc update*(self: WalletModel, address: string, ethBalance: string, tokens: JsonNode) = + for account in self.accounts: + if account.address != address: continue + storeBalances(account, ethBalance, tokens) + updateBalance(account, self.getDefaultCurrency(), false) + +proc getEthBalance*(address: string): string = + var balance = getBalance(address) + result = hex2token(balance, 18) + +proc newAccount*(self: WalletModel, walletType: string, derivationPath: string, name: string, address: string, iconColor: string, balance: string, publicKey: string): WalletAccount = + var assets: seq[Asset] = self.generateAccountConfiguredAssets(address) + var account = WalletAccount(name: name, path: derivationPath, walletType: walletType, address: address, iconColor: iconColor, balance: none[string](), assetList: assets, realFiatBalance: none[float](), publicKey: publicKey) + updateBalance(account, self.getDefaultCurrency()) + account + +proc initAccounts*(self: WalletModel) = + self.tokens = status_tokens.getVisibleTokens() + let accounts = status_wallet.getWalletAccounts() + for account in accounts: + var acc = WalletAccount(account) + self.populateAccount(acc, "") + updateBalance(acc, self.getDefaultCurrency(), true) + self.accounts.add(acc) + +proc updateAccount*(self: WalletModel, address: string) = + for acc in self.accounts.mitems: + if acc.address == address: + self.populateAccount(acc, "", true) + updateBalance(acc, self.getDefaultCurrency(), true) + self.events.emit("accountsUpdated", Args()) + +proc getTotalFiatBalance*(self: WalletModel): string = + self.calculateTotalFiatBalance() + fmt"{self.totalBalance:.2f}" + +proc convertValue*(self: WalletModel, balance: string, fromCurrency: string, toCurrency: string): float = + result = convertValue(balance, fromCurrency, toCurrency) + +proc calculateTotalFiatBalance*(self: WalletModel) = + self.totalBalance = 0.0 + for account in self.accounts: + if account.realFiatBalance.isSome: + self.totalBalance += account.realFiatBalance.get() + +proc addNewGeneratedAccount(self: WalletModel, generatedAccount: GeneratedAccount, password: string, accountName: string, color: string, accountType: string, isADerivedAccount = true, walletIndex: int = 0) = + try: + generatedAccount.name = accountName + var derivedAccount: DerivedAccount = status_accounts.saveAccount(generatedAccount, password, color, accountType, isADerivedAccount, walletIndex) + var account = self.newAccount(accountType, derivedAccount.derivationPath, accountName, derivedAccount.address, color, fmt"0.00 {self.defaultCurrency}", derivedAccount.publicKey) + self.accounts.add(account) + # wallet_checkRecentHistory is required to be called when a new account is + # added before wallet_getTransfersByAddress can be called. This is because + # wallet_checkRecentHistory populates the status-go db that + # wallet_getTransfersByAddress reads from + discard status_wallet.checkRecentHistory(self.accounts.map(account => account.address)) + self.events.emit("newAccountAdded", wallet_account.AccountArgs(account: account)) + except Exception as e: + raise newException(StatusGoException, fmt"Error adding new account: {e.msg}") + +proc generateNewAccount*(self: WalletModel, password: string, accountName: string, color: string) = + let + walletRootAddress = status_settings.getSetting[string](Setting.WalletRootAddress, "") + walletIndex = status_settings.getSetting[int](Setting.LatestDerivedPath) + 1 + loadedAccount = status_accounts.loadAccount(walletRootAddress, password) + derivedAccount = status_accounts.deriveWallet(loadedAccount.id, walletIndex) + generatedAccount = GeneratedAccount( + id: loadedAccount.id, + publicKey: derivedAccount.publicKey, + address: derivedAccount.address + ) + + # if we've gotten here, the password is ok (loadAccount requires a valid password) + # so no need to check for a valid password + self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.GENERATED, true, walletIndex) + + let statusGoResult = status_settings.saveSetting(Setting.LatestDerivedPath, $walletIndex) + if statusGoResult.error != "": + error "Error storing the latest wallet index", msg=statusGoResult.error + +proc addAccountsFromSeed*(self: WalletModel, seed: string, password: string, accountName: string, color: string) = + let mnemonic = replace(seed, ',', ' ') + var generatedAccount = status_accounts.multiAccountImportMnemonic(mnemonic) + generatedAccount.derived = status_accounts.deriveAccounts(generatedAccount.id) + + let + defaultAccount = status_accounts.getDefaultAccount() + isPasswordOk = status_accounts.verifyAccountPassword(defaultAccount, password) + if not isPasswordOk: + raise newException(StatusGoException, "Error generating new account: invalid password") + + self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.SEED) + +proc addAccountsFromPrivateKey*(self: WalletModel, privateKey: string, password: string, accountName: string, color: string) = + let + generatedAccount = status_accounts.MultiAccountImportPrivateKey(privateKey) + defaultAccount = status_accounts.getDefaultAccount() + isPasswordOk = status_accounts.verifyAccountPassword(defaultAccount, password) + + if not isPasswordOk: + raise newException(StatusGoException, "Error generating new account: invalid password") + + self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.KEY, false) + +proc addWatchOnlyAccount*(self: WalletModel, address: string, accountName: string, color: string) = + let account = GeneratedAccount(address: address) + self.addNewGeneratedAccount(account, "", accountName, color, constants.WATCH, false) + +proc hasAsset*(self: WalletModel, symbol: string): bool = + self.tokens.anyIt(it.symbol == symbol) + +proc changeAccountSettings*(self: WalletModel, address: string, accountName: string, color: string): string = + var selectedAccount: WalletAccount + for account in self.accounts: + if (account.address == address): + selectedAccount = account + break + if (isNil(selectedAccount)): + result = "No account found with that address" + error "No account found with that address", address + selectedAccount.name = accountName + selectedAccount.iconColor = color + result = status_accounts.changeAccount(selectedAccount.name, selectedAccount.address, + selectedAccount.publicKey, selectedAccount.walletType, selectedAccount.iconColor) + +proc deleteAccount*(self: WalletModel, address: string): string = + result = status_accounts.deleteAccount(address) + self.accounts = self.accounts.filter(acc => acc.address.toLowerAscii != address.toLowerAscii) + +proc toggleAsset*(self: WalletModel, symbol: string) = + self.tokens = status_tokens.toggleAsset(symbol) + for account in self.accounts: + account.assetList = self.generateAccountConfiguredAssets(account.address) + updateBalance(account, self.getDefaultCurrency()) + self.events.emit("assetChanged", Args()) + +proc hideAsset*(self: WalletModel, symbol: string) = + status_tokens.hideAsset(symbol) + self.tokens = status_tokens.getVisibleTokens() + for account in self.accounts: + account.assetList = self.generateAccountConfiguredAssets(account.address) + updateBalance(account, self.getDefaultCurrency()) + self.events.emit("assetChanged", Args()) + +proc addCustomToken*(self: WalletModel, symbol: string, enable: bool, address: string, name: string, decimals: int, color: string) = + addCustomToken(address, name, symbol, decimals, color) + +proc getTransfersByAddress*(self: WalletModel, address: string, toBlock: Uint256, limit: int, loadMore: bool): seq[Transaction] = + result = status_wallet.getTransfersByAddress(address, toBlock, limit, loadMore) + +proc validateMnemonic*(self: WalletModel, mnemonic: string): string = + result = status_wallet.validateMnemonic(mnemonic).parseJSON()["error"].getStr + +proc getGasPricePredictions*(): GasPricePrediction = + if status_settings.getCurrentNetwork() != Network.Mainnet: + # TODO: what about other chains like xdai? + return GasPricePrediction(safeLow: 1.0, standard: 2.0, fast: 3.0, fastest: 4.0) + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + try: + let url: string = fmt"https://etherchain.org/api/gasPriceOracle" + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + let response = client.request(url) + result = Json.decode(response.body, GasPricePrediction) + except Exception as e: + echo "error getting gas price predictions" + echo e.msg + finally: + client.close() + +proc checkRecentHistory*(self: WalletModel, addresses: seq[string]): string = + result = status_wallet.checkRecentHistory(addresses) + +proc setInitialBlocksRange*(self: WalletModel): string = + result = status_wallet.setInitialBlocksRange() + +proc getWalletAccounts*(self: WalletModel): seq[WalletAccount] = + result = status_wallet.getWalletAccounts() + +proc getWalletAccounts*(): seq[WalletAccount] = + result = status_wallet.getWalletAccounts() + +proc watchTransaction*(self: WalletModel, transactionHash: string): string = + result = status_wallet.watchTransaction(transactionHash) + +proc getPendingTransactions*(self: WalletModel): string = + result = status_wallet.getPendingTransactions() + +# proc getTransfersByAddress*(address: string): seq[types.Transaction] = + # result = status_wallet.getTransfersByAddress(address) + +proc getTransfersByAddress*(address: string, toBlock: Uint256, limit: int, loadMore: bool): seq[Transaction] = + result = status_wallet.getTransfersByAddress(address, toBlock, limit, loadMore) + +proc watchTransaction*(transactionHash: string): string = + result = status_wallet.watchTransaction(transactionHash) + +proc hex2Token*(self: WalletModel, input: string, decimals: int): string = + result = status_wallet.hex2Token(input, decimals) + +proc getOpenseaCollections*(address: string): string = + result = status_wallet.getOpenseaCollections(address) + +proc getOpenseaAssets*(address: string, collectionSlug: string, limit: int): string = + result = status_wallet.getOpenseaAssets(address, collectionSlug, limit) \ No newline at end of file diff --git a/status/wallet/account.nim b/status/wallet/account.nim new file mode 100644 index 0000000..543e42d --- /dev/null +++ b/status/wallet/account.nim @@ -0,0 +1,62 @@ +import options, json, strformat + +from ../../eventemitter import Args +import ../types/[transaction] + +type CollectibleList* = ref object + collectibleType*, collectiblesJSON*, error*: string + loading*: int + +type Collectible* = ref object + name*, image*, id*, collectibleType*, description*, externalUrl*: string + +type OpenseaCollection* = ref object + name*, slug*, imageUrl*: string + ownedAssetCount*: int + +type OpenseaAsset* = ref object + id*: int + name*, description*, permalink*, imageThumbnailUrl*, imageUrl*, address*: string + +type CurrencyArgs* = ref object of Args + currency*: string + +type Asset* = ref object + name*, symbol*, value*, fiatBalanceDisplay*, fiatBalance*, accountAddress*, address*: string + +type WalletAccount* = ref object + name*, address*, iconColor*, path*, walletType*, publicKey*: string + balance*: Option[string] + realFiatBalance*: Option[float] + assetList*: seq[Asset] + wallet*, chat*: bool + collectiblesLists*: seq[CollectibleList] + transactions*: tuple[hasMore: bool, data: seq[Transaction]] + +type AccountArgs* = ref object of Args + account*: WalletAccount + +proc `$`*(self: OpenseaCollection): string = + return fmt"OpenseaCollection(name:{self.name}, slug:{self.slug}, owned asset count:{self.ownedAssetCount})" + +proc `$`*(self: OpenseaAsset): string = + return fmt"OpenseaAsset(id:{self.id}, name:{self.name}, address:{self.address}, imageUrl: {self.imageUrl}, imageThumbnailUrl: {self.imageThumbnailUrl})" + +proc toOpenseaCollection*(jsonCollection: JsonNode): OpenseaCollection = + return OpenseaCollection( + name: jsonCollection{"name"}.getStr, + slug: jsonCollection{"slug"}.getStr, + imageUrl: jsonCollection{"image_url"}.getStr, + ownedAssetCount: jsonCollection{"owned_asset_count"}.getInt + ) + +proc toOpenseaAsset*(jsonAsset: JsonNode): OpenseaAsset = + return OpenseaAsset( + id: jsonAsset{"id"}.getInt, + name: jsonAsset{"name"}.getStr, + description: jsonAsset{"description"}.getStr, + permalink: jsonAsset{"permalink"}.getStr, + imageThumbnailUrl: jsonAsset{"image_thumbnail_url"}.getStr, + imageUrl: jsonAsset{"image_url"}.getStr, + address: jsonAsset{"asset_contract"}{"address"}.getStr + ) \ No newline at end of file diff --git a/status/wallet/balance_manager.nim b/status/wallet/balance_manager.nim new file mode 100644 index 0000000..13823da --- /dev/null +++ b/status/wallet/balance_manager.nim @@ -0,0 +1,90 @@ +import strformat, strutils, stint, httpclient, json, chronicles, net +import ../libstatus/wallet as status_wallet +import ../libstatus/tokens as status_tokens +import ../types/[rpc_response] +import ../utils/cache +import account +import options + +logScope: + topics = "balance-manager" + +type BalanceManager* = ref object + pricePairs: CachedValues + tokenBalances: CachedValues + +proc newBalanceManager*(): BalanceManager = + result = BalanceManager() + result.pricePairs = newCachedValues() + result.tokenBalances = newCachedValues() + +var balanceManager = newBalanceManager() + +proc getPrice(crypto: string, fiat: string): string = + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + try: + let url: string = fmt"https://min-api.cryptocompare.com/data/price?fsym={crypto}&tsyms={fiat}" + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + result = $parseJson(response.body)[fiat.toUpper] + except Exception as e: + error "Error getting price", message = e.msg + result = "0.0" + finally: + client.close() + +proc getEthBalance(address: string): string = + var balance = status_wallet.getBalance(address) + result = status_wallet.hex2token(balance, 18) + +proc getBalance*(symbol: string, accountAddress: string, tokenAddress: string, refreshCache: bool): string = + let cacheKey = fmt"{symbol}-{accountAddress}-{tokenAddress}" + if not refreshCache and balanceManager.tokenBalances.isCached(cacheKey): + return balanceManager.tokenBalances.get(cacheKey) + + if symbol == "ETH": + let ethBalance = getEthBalance(accountAddress) + return ethBalance + + result = $status_tokens.getTokenBalance(tokenAddress, accountAddress) + balanceManager.tokenBalances.cacheValue(cacheKey, result) + +proc convertValue*(balance: string, fromCurrency: string, toCurrency: string): float = + if balance == "0.0": return 0.0 + let cacheKey = fmt"{fromCurrency}-{toCurrency}" + if balanceManager.pricePairs.isCached(cacheKey): + return parseFloat(balance) * parseFloat(balanceManager.pricePairs.get(cacheKey)) + + var fiat_crypto_price = getPrice(fromCurrency, toCurrency) + balanceManager.pricePairs.cacheValue(cacheKey, fiat_crypto_price) + parseFloat(balance) * parseFloat(fiat_crypto_price) + +proc updateBalance*(asset: Asset, currency: string, refreshCache: bool): float = + var token_balance = getBalance(asset.symbol, asset.accountAddress, asset.address, refreshCache) + let fiat_balance = convertValue(token_balance, asset.symbol, currency) + asset.value = token_balance + asset.fiatBalanceDisplay = fmt"{fiat_balance:.2f} {currency}" + asset.fiatBalance = fmt"{fiat_balance:.2f}" + return fiat_balance + +proc updateBalance*(account: WalletAccount, currency: string, refreshCache: bool = false) = + try: + var usd_balance = 0.0 + for asset in account.assetList: + let assetFiatBalance = updateBalance(asset, currency, refreshCache) + usd_balance = usd_balance + assetFiatBalance + + account.realFiatBalance = some(usd_balance) + account.balance = some(fmt"{usd_balance:.2f} {currency}") + except RpcException: + error "Error in updateBalance", message = getCurrentExceptionMsg() + +proc storeBalances*(account: WalletAccount, ethBalance = "0", tokenBalance: JsonNode) = + let ethCacheKey = fmt"ETH-{account.address}-" + balanceManager.tokenBalances.cacheValue(ethCacheKey, ethBalance) + for asset in account.assetList: + if tokenBalance.hasKey(asset.address): + let cacheKey = fmt"{asset.symbol}-{account.address}-{asset.address}" + balanceManager.tokenBalances.cacheValue(cacheKey, tokenBalance{asset.address}.getStr()) diff --git a/status/wallet/collectibles.nim b/status/wallet/collectibles.nim new file mode 100644 index 0000000..768492d --- /dev/null +++ b/status/wallet/collectibles.nim @@ -0,0 +1,259 @@ +import # std libs + atomics, strformat, httpclient, json, chronicles, sequtils, strutils, tables, + sugar, net + +import # vendor libs + stint + +import # status-desktop libs + ../libstatus/core as status, ../libstatus/eth/contracts as contracts, + ../stickers as status_stickers, + web3/[conversions, ethtypes], ../utils, account + +const CRYPTOKITTY* = "cryptokitty" +const KUDO* = "kudo" +const ETHERMON* = "ethermon" +const STICKER* = "stickers" + +const COLLECTIBLE_TYPES* = [CRYPTOKITTY, KUDO, ETHERMON, STICKER] + +const MAX_TOKENS = 200 + +proc getTokenUri(contract: Erc721Contract, tokenId: Stuint[256]): string = + try: + let + tokenUri = TokenUri(tokenId: tokenId) + payload = %* [{ + "to": $contract.address, + "data": contract.methods["tokenURI"].encodeAbi(tokenUri) + }, "latest"] + response = callPrivateRPC("eth_call", payload) + var postfixedResult: string = parseJson($response)["result"].str + postfixedResult.removeSuffix('0') + postfixedResult.removePrefix("0x") + postfixedResult = parseHexStr(postfixedResult) + let index = postfixedResult.find("http") + if (index < -1): + return "" + result = postfixedResult[index .. postfixedResult.high] + except Exception as e: + error "Error getting the token URI", mes = e.msg + result = "" + +proc tokenOfOwnerByIndex(contract: Erc721Contract, address: Address, index: Stuint[256]): int = + let + tokenOfOwnerByIndex = TokenOfOwnerByIndex(address: address, index: index) + payload = %* [{ + "to": $contract.address, + "data": contract.methods["tokenOfOwnerByIndex"].encodeAbi(tokenOfOwnerByIndex) + }, "latest"] + response = callPrivateRPC("eth_call", payload) + jsonResponse = parseJson($response) + if (not jsonResponse.hasKey("result")): + return -1 + let res = jsonResponse["result"].getStr + if (res == "0x"): + return -1 + result = fromHex[int](res) + +proc balanceOf(contract: Erc721Contract, address: Address): int = + let + balanceOf = BalanceOf(address: address) + payload = %* [{ + "to": $contract.address, + "data": contract.methods["balanceOf"].encodeAbi(balanceOf) + }, "latest"] + response = callPrivateRPC("eth_call", payload) + jsonResponse = parseJson($response) + if (not jsonResponse.hasKey("result")): + return 0 + let res = jsonResponse["result"].getStr + if (res == "0x"): + return 0 + result = fromHex[int](res) + +proc tokensOfOwnerByIndex(contract: Erc721Contract, address: Address): seq[int] = + var index = 0 + var token: int + var maxIndex: int = balanceOf(contract, address) + result = @[] + while index < maxIndex and result.len <= MAX_TOKENS: + token = tokenOfOwnerByIndex(contract, address, index.u256) + result.add(token) + index = index + 1 + + return result + +proc getCryptoKittiesBatch*(address: Address, offset: int = 0): seq[Collectible] = + var cryptokitties: seq[Collectible] + cryptokitties = @[] + # TODO handle testnet -- does this API exist in testnet?? + let url: string = fmt"https://api.cryptokitties.co/kitties?limit=20&offset={$offset}&owner_wallet_address={$address}&parents=false" + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + let responseBody = parseJson(response.body) + let kitties = responseBody["kitties"] + for kitty in kitties: + try: + var id = kitty["id"] + var name = kitty["name"] + var finalId = "" + var finalName = "" + if id.kind != JNull: + finalId = $id + if name.kind != JNull: + finalName = $name + cryptokitties.add(Collectible(id: finalId, + name: finalName, + image: kitty["image_url_png"].str, + collectibleType: CRYPTOKITTY, + description: "", + externalUrl: "")) + except Exception as e2: + error "Error with this individual cat", msg = e2.msg, cat = kitty + + let limit = responseBody["limit"].getInt + let total = responseBody["total"].getInt + let currentCount = limit * (offset + 1) + if (currentCount < total and currentCount < MAX_TOKENS): + # Call the API again with offset + 1 + let nextBatch = getCryptoKittiesBatch(address, offset + 1) + return concat(cryptokitties, nextBatch) + return cryptokitties + +proc getCryptoKitties*(address: Address): string = + try: + let cryptokitties = getCryptoKittiesBatch(address, 0) + + return $(%*cryptokitties) + except Exception as e: + error "Error getting Cryptokitties", msg = e.msg + return e.msg + +proc getCryptoKitties*(address: string): string = + let eth_address = parseAddress(address) + result = getCryptoKitties(eth_address) + +proc getEthermons*(address: Address): string = + try: + var ethermons: seq[Collectible] + ethermons = @[] + let contract = getErc721Contract("ethermon") + if contract == nil: return $(%*ethermons) + + let tokens = tokensOfOwnerByIndex(contract, address) + + if (tokens.len == 0): + return $(%*ethermons) + + let tokensJoined = strutils.join(tokens, ",") + let url = fmt"https://www.ethermon.io/api/monster/get_data?monster_ids={tokensJoined}" + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + let monsters = parseJson(response.body)["data"] + var i = 0 + for monsterKey in json.keys(monsters): + let monster = monsters[monsterKey] + ethermons.add(Collectible(id: $tokens[i], + name: monster["class_name"].str, + image: monster["image"].str, + collectibleType: ETHERMON, + description: "", + externalUrl: "")) + i = i + 1 + + return $(%*ethermons) + except Exception as e: + error "Error getting Ethermons", msg = e.msg + result = e.msg + +proc getEthermons*(address: string): string = + let eth_address = parseAddress(address) + result = getEthermons(eth_address) + +proc getKudos*(address: Address): string = + try: + var kudos: seq[Collectible] + kudos = @[] + let contract = getErc721Contract("kudos") + if contract == nil: return $(%*kudos) + + let tokens = tokensOfOwnerByIndex(contract, address) + + if (tokens.len == 0): + return $(%*kudos) + + for token in tokens: + let url = getTokenUri(contract, token.u256) + + if (url == ""): + return $(%*kudos) + + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + let kudo = parseJson(response.body) + + kudos.add(Collectible(id: $token, + name: kudo["name"].str, + image: kudo["image"].str, + collectibleType: KUDO, + description: kudo["description"].str, + externalUrl: kudo["external_url"].str)) + + return $(%*kudos) + except Exception as e: + error "Error getting Kudos", msg = e.msg + result = e.msg + +proc getKudos*(address: string): string = + let eth_address = parseAddress(address) + result = getKudos(eth_address) + +proc getStickers*(address: Address, running: var Atomic[bool]): string = + try: + var stickers: seq[Collectible] + stickers = @[] + let contract = getErc721Contract("sticker-pack") + if contract == nil: return + + let tokensIds = tokensOfOwnerByIndex(contract, address) + + if (tokensIds.len == 0): + return $(%*stickers) + + let purchasedStickerPacks = tokensIds.map(tokenId => status_stickers.getPackIdFromTokenId(tokenId.u256)) + + if (purchasedStickerPacks.len == 0): + return $(%*stickers) + # TODO find a way to keep those in memory so as not to reload it each time + let availableStickerPacks = getAvailableStickerPacks(running) + + var index = 0 + for stickerId in purchasedStickerPacks: + let sticker = availableStickerPacks[stickerId] + stickers.add(Collectible(id: $tokensIds[index], + name: sticker.name, + image: fmt"https://ipfs.infura.io/ipfs/{status_stickers.decodeContentHash(sticker.preview)}", + collectibleType: STICKER, + description: sticker.author, + externalUrl: "") + ) + index = index + 1 + + return $(%*stickers) + except Exception as e: + error "Error getting Stickers", msg = e.msg + result = e.msg + +proc getStickers*(address: string, running: var Atomic[bool]): string = + let eth_address = parseAddress(address) + result = getStickers(eth_address, running) diff --git a/status/wallet2.nim b/status/wallet2.nim new file mode 100644 index 0000000..51590ad --- /dev/null +++ b/status/wallet2.nim @@ -0,0 +1,213 @@ +import json, strformat, options, chronicles, sugar, sequtils, strutils + +import libstatus/accounts as status_accounts +import libstatus/accounts/constants as constants +import libstatus/tokens as status_tokens +import libstatus/wallet as status_wallet +import libstatus/settings as status_settings +import libstatus/eth/[contracts] +import wallet2/[balance_manager, collectibles] +import wallet2/account as wallet_account +import ./types/[account, transaction, network, setting, gas_prediction, rpc_response] +import ../eventemitter +from web3/ethtypes import Address +from web3/conversions import `$` + +export wallet_account, collectibles + +logScope: + topics = "status-wallet2" + +type + CryptoServicesArg* = ref object of Args + services*: JsonNode # an array + +type + StatusWalletController* = ref object + events: EventEmitter + accounts: seq[WalletAccount] + tokens: seq[Erc20Contract] + totalBalance*: float + +# Forward declarations +proc initEvents*(self: StatusWalletController) +proc generateAccountConfiguredAssets*(self: StatusWalletController, + accountAddress: string): seq[Asset] +proc calculateTotalFiatBalance*(self: StatusWalletController) + +proc setup(self: StatusWalletController, events: EventEmitter) = + self.events = events + self.accounts = @[] + self.tokens = @[] + self.totalBalance = 0.0 + self.initEvents() + +proc delete*(self: StatusWalletController) = + discard + +proc newStatusWalletController*(events: EventEmitter): + StatusWalletController = + result = StatusWalletController() + result.setup(events) + +proc initTokens(self: StatusWalletController) = + self.tokens = status_tokens.getVisibleTokens() + +proc initAccounts(self: StatusWalletController) = + let accounts = status_wallet.getWalletAccounts() + for acc in accounts: + var assets: seq[Asset] = self.generateAccountConfiguredAssets(acc.address) + var walletAccount = newWalletAccount(acc.name, acc.address, acc.iconColor, + acc.path, acc.walletType, acc.publicKey, acc.wallet, acc.chat, assets) + self.accounts.add(walletAccount) + +proc init*(self: StatusWalletController) = + self.initTokens() + self.initAccounts() + +proc initEvents*(self: StatusWalletController) = + self.events.on("currencyChanged") do(e: Args): + self.events.emit("accountsUpdated", Args()) + + self.events.on("newAccountAdded") do(e: Args): + self.calculateTotalFiatBalance() + +proc getAccounts*(self: StatusWalletController): seq[WalletAccount] = + self.accounts + +proc getDefaultCurrency*(self: StatusWalletController): string = +# TODO: this should come from a model? It is going to be used too in the +# profile section and ideally we should not call the settings more than once + status_settings.getSetting[string](Setting.Currency, "usd") + +proc generateAccountConfiguredAssets*(self: StatusWalletController, + accountAddress: string): seq[Asset] = + var assets: seq[Asset] = @[] + var asset = Asset(name:"Ethereum", symbol: "ETH", value: "0.0", + fiatBalanceDisplay: "0.0", accountAddress: accountAddress) + assets.add(asset) + for token in self.tokens: + var symbol = token.symbol + var existingToken = Asset(name: token.name, symbol: symbol, + value: fmt"0.0", fiatBalanceDisplay: "$0.0", accountAddress: accountAddress, + address: $token.address) + assets.add(existingToken) + assets + +proc calculateTotalFiatBalance*(self: StatusWalletController) = + self.totalBalance = 0.0 + for account in self.accounts: + if account.realFiatBalance.isSome: + self.totalBalance += account.realFiatBalance.get() + +proc newAccount*(self: StatusWalletController, walletType: string, derivationPath: string, + name: string, address: string, iconColor: string, balance: string, + publicKey: string): WalletAccount = + var assets: seq[Asset] = self.generateAccountConfiguredAssets(address) + var account = WalletAccount(name: name, path: derivationPath, walletType: walletType, + address: address, iconColor: iconColor, balance: none[string](), assetList: assets, + realFiatBalance: none[float](), publicKey: publicKey) + updateBalance(account, self.getDefaultCurrency()) + account + +proc addNewGeneratedAccount(self: StatusWalletController, generatedAccount: GeneratedAccount, + password: string, accountName: string, color: string, accountType: string, + isADerivedAccount = true, walletIndex: int = 0) = + try: + generatedAccount.name = accountName + var derivedAccount: DerivedAccount = status_accounts.saveAccount(generatedAccount, + password, color, accountType, isADerivedAccount, walletIndex) + var account = self.newAccount(accountType, derivedAccount.derivationPath, + accountName, derivedAccount.address, color, fmt"0.00 {self.getDefaultCurrency()}", + derivedAccount.publicKey) + + self.accounts.add(account) + # wallet_checkRecentHistory is required to be called when a new account is + # added before wallet_getTransfersByAddress can be called. This is because + # wallet_checkRecentHistory populates the status-go db that + # wallet_getTransfersByAddress reads from + discard status_wallet.checkRecentHistory(self.accounts.map(account => account.address)) + self.events.emit("newAccountAdded", wallet_account.AccountArgs(account: account)) + except Exception as e: + raise newException(StatusGoException, fmt"Error adding new account: {e.msg}") + +proc generateNewAccount*(self: StatusWalletController, password: string, accountName: string, color: string) = + let + walletRootAddress = status_settings.getSetting[string](Setting.WalletRootAddress, "") + walletIndex = status_settings.getSetting[int](Setting.LatestDerivedPath) + 1 + loadedAccount = status_accounts.loadAccount(walletRootAddress, password) + derivedAccount = status_accounts.deriveWallet(loadedAccount.id, walletIndex) + generatedAccount = GeneratedAccount( + id: loadedAccount.id, + publicKey: derivedAccount.publicKey, + address: derivedAccount.address + ) + + # if we've gotten here, the password is ok (loadAccount requires a valid password) + # so no need to check for a valid password + self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.GENERATED, true, walletIndex) + + let statusGoResult = status_settings.saveSetting(Setting.LatestDerivedPath, $walletIndex) + if statusGoResult.error != "": + error "Error storing the latest wallet index", msg=statusGoResult.error + +proc addAccountsFromSeed*(self: StatusWalletController, seed: string, password: string, accountName: string, color: string) = + let mnemonic = replace(seed, ',', ' ') + var generatedAccount = status_accounts.multiAccountImportMnemonic(mnemonic) + generatedAccount.derived = status_accounts.deriveAccounts(generatedAccount.id) + + let + defaultAccount = status_accounts.getDefaultAccount() + isPasswordOk = status_accounts.verifyAccountPassword(defaultAccount, password) + if not isPasswordOk: + raise newException(StatusGoException, "Error generating new account: invalid password") + + self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.SEED) + +proc addAccountsFromPrivateKey*(self: StatusWalletController, privateKey: string, password: string, accountName: string, color: string) = + let + generatedAccount = status_accounts.MultiAccountImportPrivateKey(privateKey) + defaultAccount = status_accounts.getDefaultAccount() + isPasswordOk = status_accounts.verifyAccountPassword(defaultAccount, password) + + if not isPasswordOk: + raise newException(StatusGoException, "Error generating new account: invalid password") + + self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.KEY, false) + +proc addWatchOnlyAccount*(self: StatusWalletController, address: string, accountName: string, color: string) = + let account = GeneratedAccount(address: address) + self.addNewGeneratedAccount(account, "", accountName, color, constants.WATCH, false) + +proc changeAccountSettings*(self: StatusWalletController, address: string, accountName: string, color: string): string = + var selectedAccount: WalletAccount + for account in self.accounts: + if (account.address == address): + selectedAccount = account + break + if (isNil(selectedAccount)): + result = "No account found with that address" + error "No account found with that address", address + selectedAccount.name = accountName + selectedAccount.iconColor = color + result = status_accounts.changeAccount(selectedAccount.name, selectedAccount.address, + selectedAccount.publicKey, selectedAccount.walletType, selectedAccount.iconColor) + +proc deleteAccount*(self: StatusWalletController, address: string): string = + result = status_accounts.deleteAccount(address) + self.accounts = self.accounts.filter(acc => acc.address.toLowerAscii != address.toLowerAscii) + +proc getOpenseaCollections*(address: string): string = + result = status_wallet.getOpenseaCollections(address) + +proc getOpenseaAssets*(address: string, collectionSlug: string, limit: int): string = + result = status_wallet.getOpenseaAssets(address, collectionSlug, limit) + +proc onAsyncFetchCryptoServices*(self: StatusWalletController, response: string) = + let responseArray = response.parseJson + if (responseArray.kind != JArray): + info "received crypto services is not a json array" + self.events.emit("cryptoServicesFetched", CryptoServicesArg()) + return + + self.events.emit("cryptoServicesFetched", CryptoServicesArg(services: responseArray)) \ No newline at end of file diff --git a/status/wallet2/account.nim b/status/wallet2/account.nim new file mode 100644 index 0000000..deeb86b --- /dev/null +++ b/status/wallet2/account.nim @@ -0,0 +1,77 @@ +import options, json, strformat + +from ../../eventemitter import Args +import ../types/[transaction] + +type CollectibleList* = ref object + collectibleType*, collectiblesJSON*, error*: string + loading*: int + +type Collectible* = ref object + name*, image*, id*, collectibleType*, description*, externalUrl*: string + +type OpenseaCollection* = ref object + name*, slug*, imageUrl*: string + ownedAssetCount*: int + +type OpenseaAsset* = ref object + id*: int + name*, description*, permalink*, imageThumbnailUrl*, imageUrl*, address*: string + +type CurrencyArgs* = ref object of Args + currency*: string + +type Asset* = ref object + name*, symbol*, value*, fiatBalanceDisplay*, fiatBalance*, accountAddress*, address*: string + +type WalletAccount* = ref object + name*, address*, iconColor*, path*, walletType*, publicKey*: string + balance*: Option[string] + realFiatBalance*: Option[float] + assetList*: seq[Asset] + wallet*, chat*: bool + collectiblesLists*: seq[CollectibleList] + transactions*: tuple[hasMore: bool, data: seq[Transaction]] + +proc newWalletAccount*(name, address, iconColor, path, walletType, publicKey: string, + wallet, chat: bool, assets: seq[Asset]): WalletAccount = + result = new WalletAccount + result.name = name + result.address = address + result.iconColor = iconColor + result.path = path + result.walletType = walletType + result.publicKey = publicKey + result.wallet = wallet + result.chat = chat + result.assetList = assets + result.balance = none[string]() + result.realFiatBalance = none[float]() + +type AccountArgs* = ref object of Args + account*: WalletAccount + +proc `$`*(self: OpenseaCollection): string = + return fmt"OpenseaCollection(name:{self.name}, slug:{self.slug}, owned asset count:{self.ownedAssetCount})" + +proc `$`*(self: OpenseaAsset): string = + return fmt"OpenseaAsset(id:{self.id}, name:{self.name}, address:{self.address}, imageUrl: {self.imageUrl}, imageThumbnailUrl: {self.imageThumbnailUrl})" + +proc toOpenseaCollection*(jsonCollection: JsonNode): OpenseaCollection = + return OpenseaCollection( + name: jsonCollection{"name"}.getStr, + slug: jsonCollection{"slug"}.getStr, + imageUrl: jsonCollection{"image_url"}.getStr, + ownedAssetCount: jsonCollection{"owned_asset_count"}.getInt + ) + +proc toOpenseaAsset*(jsonAsset: JsonNode): OpenseaAsset = + return OpenseaAsset( + id: jsonAsset{"id"}.getInt, + name: jsonAsset{"name"}.getStr, + description: jsonAsset{"description"}.getStr, + permalink: jsonAsset{"permalink"}.getStr, + imageThumbnailUrl: jsonAsset{"image_thumbnail_url"}.getStr, + imageUrl: jsonAsset{"image_url"}.getStr, + address: jsonAsset{"asset_contract"}{"address"}.getStr + ) \ No newline at end of file diff --git a/status/wallet2/balance_manager.nim b/status/wallet2/balance_manager.nim new file mode 100644 index 0000000..d51b91f --- /dev/null +++ b/status/wallet2/balance_manager.nim @@ -0,0 +1,90 @@ +import strformat, strutils, stint, httpclient, json, chronicles, net +import ../libstatus/wallet as status_wallet +import ../libstatus/tokens as status_tokens +import ../types/[rpc_response] +import ../utils/cache +import account +import options + +logScope: + topics = "status-wallet2-balance-manager" + +type BalanceManager* = ref object + pricePairs: CachedValues + tokenBalances: CachedValues + +proc newBalanceManager*(): BalanceManager = + result = BalanceManager() + result.pricePairs = newCachedValues() + result.tokenBalances = newCachedValues() + +var balanceManager = newBalanceManager() + +proc getPrice(crypto: string, fiat: string): string = + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + try: + let url: string = fmt"https://min-api.cryptocompare.com/data/price?fsym={crypto}&tsyms={fiat}" + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + result = $parseJson(response.body)[fiat.toUpper] + except Exception as e: + error "Error getting price", message = e.msg + result = "0.0" + finally: + client.close() + +proc getEthBalance(address: string): string = + var balance = status_wallet.getBalance(address) + result = status_wallet.hex2token(balance, 18) + +proc getBalance*(symbol: string, accountAddress: string, tokenAddress: string, refreshCache: bool): string = + let cacheKey = fmt"{symbol}-{accountAddress}-{tokenAddress}" + if not refreshCache and balanceManager.tokenBalances.isCached(cacheKey): + return balanceManager.tokenBalances.get(cacheKey) + + if symbol == "ETH": + let ethBalance = getEthBalance(accountAddress) + return ethBalance + + result = $status_tokens.getTokenBalance(tokenAddress, accountAddress) + balanceManager.tokenBalances.cacheValue(cacheKey, result) + +proc convertValue*(balance: string, fromCurrency: string, toCurrency: string): float = + if balance == "0.0": return 0.0 + let cacheKey = fmt"{fromCurrency}-{toCurrency}" + if balanceManager.pricePairs.isCached(cacheKey): + return parseFloat(balance) * parseFloat(balanceManager.pricePairs.get(cacheKey)) + + var fiat_crypto_price = getPrice(fromCurrency, toCurrency) + balanceManager.pricePairs.cacheValue(cacheKey, fiat_crypto_price) + parseFloat(balance) * parseFloat(fiat_crypto_price) + +proc updateBalance*(asset: Asset, currency: string, refreshCache: bool): float = + var token_balance = getBalance(asset.symbol, asset.accountAddress, asset.address, refreshCache) + let fiat_balance = convertValue(token_balance, asset.symbol, currency) + asset.value = token_balance + asset.fiatBalanceDisplay = fmt"{fiat_balance:.2f} {currency}" + asset.fiatBalance = fmt"{fiat_balance:.2f}" + return fiat_balance + +proc updateBalance*(account: WalletAccount, currency: string, refreshCache: bool = false) = + try: + var usd_balance = 0.0 + for asset in account.assetList: + let assetFiatBalance = updateBalance(asset, currency, refreshCache) + usd_balance = usd_balance + assetFiatBalance + + account.realFiatBalance = some(usd_balance) + account.balance = some(fmt"{usd_balance:.2f} {currency}") + except RpcException: + error "Error in updateBalance", message = getCurrentExceptionMsg() + +proc storeBalances*(account: WalletAccount, ethBalance = "0", tokenBalance: JsonNode) = + let ethCacheKey = fmt"ETH-{account.address}-" + balanceManager.tokenBalances.cacheValue(ethCacheKey, ethBalance) + for asset in account.assetList: + if tokenBalance.hasKey(asset.address): + let cacheKey = fmt"{asset.symbol}-{account.address}-{asset.address}" + balanceManager.tokenBalances.cacheValue(cacheKey, tokenBalance{asset.address}.getStr()) diff --git a/status/wallet2/collectibles.nim b/status/wallet2/collectibles.nim new file mode 100644 index 0000000..768492d --- /dev/null +++ b/status/wallet2/collectibles.nim @@ -0,0 +1,259 @@ +import # std libs + atomics, strformat, httpclient, json, chronicles, sequtils, strutils, tables, + sugar, net + +import # vendor libs + stint + +import # status-desktop libs + ../libstatus/core as status, ../libstatus/eth/contracts as contracts, + ../stickers as status_stickers, + web3/[conversions, ethtypes], ../utils, account + +const CRYPTOKITTY* = "cryptokitty" +const KUDO* = "kudo" +const ETHERMON* = "ethermon" +const STICKER* = "stickers" + +const COLLECTIBLE_TYPES* = [CRYPTOKITTY, KUDO, ETHERMON, STICKER] + +const MAX_TOKENS = 200 + +proc getTokenUri(contract: Erc721Contract, tokenId: Stuint[256]): string = + try: + let + tokenUri = TokenUri(tokenId: tokenId) + payload = %* [{ + "to": $contract.address, + "data": contract.methods["tokenURI"].encodeAbi(tokenUri) + }, "latest"] + response = callPrivateRPC("eth_call", payload) + var postfixedResult: string = parseJson($response)["result"].str + postfixedResult.removeSuffix('0') + postfixedResult.removePrefix("0x") + postfixedResult = parseHexStr(postfixedResult) + let index = postfixedResult.find("http") + if (index < -1): + return "" + result = postfixedResult[index .. postfixedResult.high] + except Exception as e: + error "Error getting the token URI", mes = e.msg + result = "" + +proc tokenOfOwnerByIndex(contract: Erc721Contract, address: Address, index: Stuint[256]): int = + let + tokenOfOwnerByIndex = TokenOfOwnerByIndex(address: address, index: index) + payload = %* [{ + "to": $contract.address, + "data": contract.methods["tokenOfOwnerByIndex"].encodeAbi(tokenOfOwnerByIndex) + }, "latest"] + response = callPrivateRPC("eth_call", payload) + jsonResponse = parseJson($response) + if (not jsonResponse.hasKey("result")): + return -1 + let res = jsonResponse["result"].getStr + if (res == "0x"): + return -1 + result = fromHex[int](res) + +proc balanceOf(contract: Erc721Contract, address: Address): int = + let + balanceOf = BalanceOf(address: address) + payload = %* [{ + "to": $contract.address, + "data": contract.methods["balanceOf"].encodeAbi(balanceOf) + }, "latest"] + response = callPrivateRPC("eth_call", payload) + jsonResponse = parseJson($response) + if (not jsonResponse.hasKey("result")): + return 0 + let res = jsonResponse["result"].getStr + if (res == "0x"): + return 0 + result = fromHex[int](res) + +proc tokensOfOwnerByIndex(contract: Erc721Contract, address: Address): seq[int] = + var index = 0 + var token: int + var maxIndex: int = balanceOf(contract, address) + result = @[] + while index < maxIndex and result.len <= MAX_TOKENS: + token = tokenOfOwnerByIndex(contract, address, index.u256) + result.add(token) + index = index + 1 + + return result + +proc getCryptoKittiesBatch*(address: Address, offset: int = 0): seq[Collectible] = + var cryptokitties: seq[Collectible] + cryptokitties = @[] + # TODO handle testnet -- does this API exist in testnet?? + let url: string = fmt"https://api.cryptokitties.co/kitties?limit=20&offset={$offset}&owner_wallet_address={$address}&parents=false" + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + let responseBody = parseJson(response.body) + let kitties = responseBody["kitties"] + for kitty in kitties: + try: + var id = kitty["id"] + var name = kitty["name"] + var finalId = "" + var finalName = "" + if id.kind != JNull: + finalId = $id + if name.kind != JNull: + finalName = $name + cryptokitties.add(Collectible(id: finalId, + name: finalName, + image: kitty["image_url_png"].str, + collectibleType: CRYPTOKITTY, + description: "", + externalUrl: "")) + except Exception as e2: + error "Error with this individual cat", msg = e2.msg, cat = kitty + + let limit = responseBody["limit"].getInt + let total = responseBody["total"].getInt + let currentCount = limit * (offset + 1) + if (currentCount < total and currentCount < MAX_TOKENS): + # Call the API again with offset + 1 + let nextBatch = getCryptoKittiesBatch(address, offset + 1) + return concat(cryptokitties, nextBatch) + return cryptokitties + +proc getCryptoKitties*(address: Address): string = + try: + let cryptokitties = getCryptoKittiesBatch(address, 0) + + return $(%*cryptokitties) + except Exception as e: + error "Error getting Cryptokitties", msg = e.msg + return e.msg + +proc getCryptoKitties*(address: string): string = + let eth_address = parseAddress(address) + result = getCryptoKitties(eth_address) + +proc getEthermons*(address: Address): string = + try: + var ethermons: seq[Collectible] + ethermons = @[] + let contract = getErc721Contract("ethermon") + if contract == nil: return $(%*ethermons) + + let tokens = tokensOfOwnerByIndex(contract, address) + + if (tokens.len == 0): + return $(%*ethermons) + + let tokensJoined = strutils.join(tokens, ",") + let url = fmt"https://www.ethermon.io/api/monster/get_data?monster_ids={tokensJoined}" + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + let monsters = parseJson(response.body)["data"] + var i = 0 + for monsterKey in json.keys(monsters): + let monster = monsters[monsterKey] + ethermons.add(Collectible(id: $tokens[i], + name: monster["class_name"].str, + image: monster["image"].str, + collectibleType: ETHERMON, + description: "", + externalUrl: "")) + i = i + 1 + + return $(%*ethermons) + except Exception as e: + error "Error getting Ethermons", msg = e.msg + result = e.msg + +proc getEthermons*(address: string): string = + let eth_address = parseAddress(address) + result = getEthermons(eth_address) + +proc getKudos*(address: Address): string = + try: + var kudos: seq[Collectible] + kudos = @[] + let contract = getErc721Contract("kudos") + if contract == nil: return $(%*kudos) + + let tokens = tokensOfOwnerByIndex(contract, address) + + if (tokens.len == 0): + return $(%*kudos) + + for token in tokens: + let url = getTokenUri(contract, token.u256) + + if (url == ""): + return $(%*kudos) + + let secureSSLContext = newContext() + let client = newHttpClient(sslContext = secureSSLContext) + client.headers = newHttpHeaders({ "Content-Type": "application/json" }) + + let response = client.request(url) + let kudo = parseJson(response.body) + + kudos.add(Collectible(id: $token, + name: kudo["name"].str, + image: kudo["image"].str, + collectibleType: KUDO, + description: kudo["description"].str, + externalUrl: kudo["external_url"].str)) + + return $(%*kudos) + except Exception as e: + error "Error getting Kudos", msg = e.msg + result = e.msg + +proc getKudos*(address: string): string = + let eth_address = parseAddress(address) + result = getKudos(eth_address) + +proc getStickers*(address: Address, running: var Atomic[bool]): string = + try: + var stickers: seq[Collectible] + stickers = @[] + let contract = getErc721Contract("sticker-pack") + if contract == nil: return + + let tokensIds = tokensOfOwnerByIndex(contract, address) + + if (tokensIds.len == 0): + return $(%*stickers) + + let purchasedStickerPacks = tokensIds.map(tokenId => status_stickers.getPackIdFromTokenId(tokenId.u256)) + + if (purchasedStickerPacks.len == 0): + return $(%*stickers) + # TODO find a way to keep those in memory so as not to reload it each time + let availableStickerPacks = getAvailableStickerPacks(running) + + var index = 0 + for stickerId in purchasedStickerPacks: + let sticker = availableStickerPacks[stickerId] + stickers.add(Collectible(id: $tokensIds[index], + name: sticker.name, + image: fmt"https://ipfs.infura.io/ipfs/{status_stickers.decodeContentHash(sticker.preview)}", + collectibleType: STICKER, + description: sticker.author, + externalUrl: "") + ) + index = index + 1 + + return $(%*stickers) + except Exception as e: + error "Error getting Stickers", msg = e.msg + result = e.msg + +proc getStickers*(address: string, running: var Atomic[bool]): string = + let eth_address = parseAddress(address) + result = getStickers(eth_address, running)