diff --git a/src/status_im/accounts/login/core.cljs b/src/status_im/accounts/login/core.cljs index e3b8ae5405d..6a62cb1cb33 100644 --- a/src/status_im/accounts/login/core.cljs +++ b/src/status_im/accounts/login/core.cljs @@ -85,9 +85,9 @@ (fx/defn initialize-wallet [cofx] (fx/merge cofx (models.wallet/initialize-tokens) + (transactions/initialize) (ethereum.subscriptions/initialize) - (models.wallet/update-wallet) - (transactions/start-sync))) + (models.wallet/update-wallet))) (fx/defn user-login [{:keys [db] :as cofx} create-database?] (let [{:keys [address password]} (accounts.db/credentials cofx)] diff --git a/src/status_im/accounts/logout/core.cljs b/src/status_im/accounts/logout/core.cljs index e891f311da7..c4e8a815e7f 100644 --- a/src/status_im/accounts/logout/core.cljs +++ b/src/status_im/accounts/logout/core.cljs @@ -1,7 +1,6 @@ (ns status-im.accounts.logout.core (:require [re-frame.core :as re-frame] [status-im.chaos-mode.core :as chaos-mode] - [status-im.ethereum.transactions.core :as transactions] [status-im.i18n :as i18n] [status-im.init.core :as init] [status-im.node.core :as node] @@ -13,7 +12,6 @@ (fx/merge cofx {:keychain/clear-user-password (get-in db [:account/account :address]) :dev-server/stop nil} - (transactions/stop-sync) (transport/stop-whisper #(re-frame/dispatch [:accounts.logout/filters-removed])) (chaos-mode/stop-checking))) diff --git a/src/status_im/ethereum/subscriptions.cljs b/src/status_im/ethereum/subscriptions.cljs index 6b670023884..97666bb0403 100644 --- a/src/status_im/ethereum/subscriptions.cljs +++ b/src/status_im/ethereum/subscriptions.cljs @@ -2,7 +2,7 @@ (:require [clojure.string :as string] [re-frame.core :as re-frame] [status-im.constants :as constants] - [status-im.ethereum.decode :as decode] + [status-im.ethereum.transactions.core :as transactions] [status-im.native-module.core :as status] [status-im.utils.ethereum.core :as ethereum] [status-im.utils.ethereum.tokens :as tokens] @@ -10,57 +10,6 @@ [status-im.utils.types :as types] [taoensso.timbre :as log])) -;; NOTE: this is the safe block range that can be -;; queried from infura rpc gateway without getting timeouts -;; determined experimentally by @goranjovic -(def block-query-limit 100000) - -(defn get-latest-block [callback] - (status/call-private-rpc - (types/json->clj {:jsonrpc "2.0" - :id 1 - :method "eth_blockNumber" - :params []}) - (fn [response] - (if (string/blank? response) - (log/warn :web3-response-error) - (callback (-> (.parse js/JSON response) - (js->clj :keywordize-keys true) - :result - decode/uint)))))) - -(defn get-block-by-hash [block-hash callback] - (status/call-private-rpc - (types/json->clj {:jsonrpc "2.0" - :id 1 - :method "eth_getBlockByHash" - :params [block-hash false]}) - (fn [response] - (if (string/blank? response) - (log/warn :web3-response-error) - (callback (-> (.parse js/JSON response) - (js->clj :keywordize-keys true) - :result - (update :number decode/uint) - (update :timestamp decode/uint))))))) - -(defn- get-token-transfer-logs - [from-block {:keys [chain-tokens direction from to]} callback] - (status/call-private-rpc - (types/json->clj {:jsonrpc "2.0" - :id 2 - :method "eth_getLogs" - :params - [{:address (keys chain-tokens) - :fromBlock from-block - :topics [constants/event-transfer-hash from to]}]}) - (fn [response] - (if (string/blank? response) - (log/warn :web3-response-error) - (callback (-> (.parse js/JSON response) - (js->clj :keywordize-keys true) - :result)))))) - (fx/defn handle-signal [cofx {:keys [subscription_id data] :as event}] (if-let [handler (get-in cofx [:db :ethereum/subscriptions subscription_id])] @@ -75,9 +24,40 @@ [{:keys [db]} id handler] {:db (assoc-in db [:ethereum/subscriptions id] handler)}) +(defn keep-user-transactions + [wallet-address token-contracts-addresses transactions] + (keep (fn [{:keys [to from] :as transaction}] + (when-let [direction (cond + (= wallet-address to) :inbound + (= wallet-address from) :outbound)] + (if (and (= :outbound direction) + (token-contracts-addresses to)) + (assoc transaction :transfer true) + (assoc transaction :direction direction)))) + transactions)) + (fx/defn new-block - [{:keys [db]} block-number] - {:db (assoc-in db [:ethereum/current-block] block-number)}) + [{:keys [db] :as cofx} {:keys [number transactions] :as block}] + (when number + (let [{:keys [:account/account :wallet/all-tokens network + :ethereum/current-block]} db + chain (ethereum/network->chain-keyword (get-in account [:networks network])) + chain-tokens (into {} (map (juxt :address identity) + (tokens/tokens-for all-tokens chain))) + wallet-address (ethereum/normalized-address (:address account)) + token-contracts-addresses (into #{} (keys chain-tokens))] + (if (or (not current-block) + (= number (inc current-block))) + {:db (assoc-in db [:ethereum/current-block] number) + :ethereum.transactions/enrich-transactions-from-new-blocks + {:chain-tokens chain-tokens + :block block + :transactions (keep-user-transactions wallet-address + token-contracts-addresses + transactions)}} + ;; in case we skipped some blocks or got an uncle, re-fetch history + ;; from etherscan + (transactions/initialize cofx))))) (defn subscribe-signal [filter params callback] @@ -98,90 +78,6 @@ result callback]))))))) -(defn- add-padding [address] - {:pre [(string? address)]} - (str "0x000000000000000000000000" (subs address 2))) - -(defn- remove-padding [topic] - {:pre [(string? topic)]} - (str "0x" (subs topic 26))) - -(defn- parse-transaction-entries [timestamp chain-tokens direction transfers] - {:pre [(integer? timestamp) - (map? chain-tokens) - (every? (fn [[k v]] (and (string? k) (map? v))) chain-tokens) - (keyword? direction) - (every? map? transfers)]} - (into {} - (keep identity - (for [transfer transfers] - (when-let [token (->> transfer :address (get chain-tokens))] - (when-not (:nft? token) - [(:transactionHash transfer) - {:block (str (-> transfer :blockNumber ethereum/hex->bignumber)) - :hash (:transactionHash transfer) - :symbol (:symbol token) - :from (some-> transfer :topics second remove-padding) - :to (some-> transfer :topics last remove-padding) - :value (-> transfer :data ethereum/hex->bignumber) - :type direction - :gas-price nil - :nonce nil - :data nil - :gas-limit nil - :timestamp (str (* timestamp 1000)) - :gas-used nil - ;; NOTE(goranjovic) - metadata on the type of token: contains name, symbol, decimas, address. - :token token - ;; NOTE(goranjovic) - if an event has been emitted, we can say there was no error - :error? false - ;; NOTE(goranjovic) - just a flag we need when we merge this entry with the existing entry in - ;; the app, e.g. transaction info with gas details, or a previous transfer entry with old - ;; confirmations count. - :transfer true}])))))) - -(letfn [(combine-entries [transaction token-transfer] - (merge transaction (select-keys token-transfer [:symbol :from :to :value :type :token :transfer]))) - (tx-and-transfer? [tx1 tx2] - (and (not (:transfer tx1)) (:transfer tx2))) - (both-transfer? - [tx1 tx2] - (and (:transfer tx1) (:transfer tx2)))] - (defn- dedupe-transactions [tx1 tx2] - (cond (tx-and-transfer? tx1 tx2) (combine-entries tx1 tx2) - (tx-and-transfer? tx2 tx1) (combine-entries tx2 tx1) - :else tx2))) - -(fx/defn new-transactions - [{:keys [db]} transactions] - {:db (update-in db - [:wallet :transactions] - #(merge-with dedupe-transactions % transactions))}) - -(defn transactions-handler - [{:keys [chain-tokens from to direction]}] - (fn [transfers] - (let [transfers-by-block (group-by :blockHash transfers)] - (doseq [[block-hash block-transfers] transfers-by-block] - (get-block-by-hash - block-hash - (fn [{:keys [timestamp]}] - (let [transactions (parse-transaction-entries timestamp - chain-tokens - direction - block-transfers)] - (when (not-empty transactions) - (re-frame/dispatch [:ethereum.signal/new-transactions - transactions]))))))))) - -;; Here we are querying event logs for Transfer events. -;; -;; The parameters are as follows: -;; - address - token smart contract address -;; - fromBlock - we need to specify it, since default is latest -;; - topics[0] - hash code of the Transfer event signature -;; - topics[1] - address of token sender with leading zeroes padding up to 32 bytes -;; - topics[2] - address of token sender with leading zeroes padding up to 32 bytes (defn new-token-transaction-filter [{:keys [chain-tokens from to] :as args}] (subscribe-signal @@ -190,47 +86,27 @@ :toBlock "latest" :address (keys chain-tokens) :topics [constants/event-transfer-hash from to]}] - (transactions-handler args))) + (transactions/inbound-token-transfer-handler chain-tokens))) + +(re-frame/reg-fx + :ethereum.subscriptions/token-transactions + (fn [{:keys [address] :as args}] + ;; start inbound token transaction subscriptions + ;; outbound token transactions are already caught in new blocks filter + (new-token-transaction-filter (merge args + {:direction :inbound + :to address})))) (defn new-block-filter [] (subscribe-signal "eth_newBlockFilter" [] (fn [[block-hash]] - (get-block-by-hash + (transactions/get-block-by-hash block-hash (fn [block] - (when-let [block-number (:number block)] - (re-frame/dispatch [:ethereum.signal/new-block - block-number]))))))) - -(defn get-from-block - [current-block-number] - (-> current-block-number - (- block-query-limit) - (max 0) - ethereum/int->hex)) - -(re-frame/reg-fx - :ethereum.subscriptions/token-transactions - (fn [{:keys [address] :as args}] - (let [inbound-args (merge args - {:direction :inbound - :to address}) - outbound-args (merge args - {:direction :outbound - :from address})] - ;; fetch 2 weeks of history until transactions are persisted - (get-latest-block - (fn [current-block-number] - (let [from-block (get-from-block current-block-number)] - (get-token-transfer-logs from-block inbound-args - (transactions-handler inbound-args)) - (get-token-transfer-logs from-block outbound-args - (transactions-handler outbound-args))))) - ;; start inbound and outbound token transaction subscriptions - (new-token-transaction-filter inbound-args) - (new-token-transaction-filter outbound-args)))) + (println :block (:number block)) + (re-frame/dispatch [:ethereum.signal/new-block block])))))) (re-frame/reg-fx :ethereum.subscriptions/new-block @@ -242,7 +118,9 @@ chain (ethereum/network->chain-keyword (get-in account [:networks network])) chain-tokens (into {} (map (juxt :address identity) (tokens/tokens-for all-tokens chain))) - padded-address (add-padding (ethereum/normalized-address (:address account)))] + normalized-address (ethereum/normalized-address (:address account)) + padded-address (transactions/add-padding normalized-address)] {:ethereum.subscriptions/new-block nil - :ethereum.subscriptions/token-transactions {:chain-tokens chain-tokens - :address padded-address}})) + :ethereum.subscriptions/token-transactions + {:chain-tokens chain-tokens + :address padded-address}})) diff --git a/src/status_im/ethereum/transactions/core.cljs b/src/status_im/ethereum/transactions/core.cljs index da3b1134220..788f72e313a 100644 --- a/src/status_im/ethereum/transactions/core.cljs +++ b/src/status_im/ethereum/transactions/core.cljs @@ -1,252 +1,186 @@ (ns status-im.ethereum.transactions.core - (:require [clojure.set :as set] - [clojure.string :as string] + (:require [clojure.string :as string] [re-frame.core :as re-frame] - re-frame.db - [status-im.utils.async :as async-util] + [status-im.ethereum.decode :as decode] + [status-im.ethereum.transactions.etherscan :as transactions.etherscan] + [status-im.native-module.core :as status] [status-im.utils.ethereum.core :as ethereum] - [status-im.utils.ethereum.tokens :as tokens] [status-im.utils.fx :as fx] - [status-im.utils.http :as http] [status-im.utils.types :as types] [taoensso.timbre :as log])) -(def sync-interval-ms 15000) -(def sync-timeout-ms 20000) (def confirmations-count-threshold 12) -;; -------------------------------------------------------------------------- -;; etherscan transactions -;; -------------------------------------------------------------------------- - -(def etherscan-supported? #{:testnet :mainnet :rinkeby}) - -(let [network->subdomain {:testnet "ropsten" :rinkeby "rinkeby"}] - (defn get-transaction-details-url [chain hash] - {:pre [(keyword? chain) (string? hash)] - :post [(or (nil? %) (string? %))]} - (when (etherscan-supported? chain) - (let [network-subdomain (when-let [subdomain (network->subdomain chain)] - (str subdomain "."))] - (str "https://" network-subdomain "etherscan.io/tx/" hash))))) - -(def etherscan-api-key "DMSI4UAAKUBVGCDMVP3H2STAMSAUV7BYFI") - -(defn- get-api-network-subdomain [chain] - (case chain - (:testnet) "api-ropsten" - (:mainnet) "api" - (:rinkeby) "api-rinkeby")) - -(defn- get-transaction-url - ([chain account] (get-transaction-url chain account false)) - ([chain account chaos-mode?] - {:pre [(keyword? chain) (string? account)] - :post [(string? %)]} - (let [network-subdomain (get-api-network-subdomain chain)] - (if chaos-mode? - "http://httpstat.us/500" - (str "https://" network-subdomain - ".etherscan.io/api?module=account&action=txlist&address=0x" - account "&startblock=0&endblock=99999999&sort=desc&apikey=" etherscan-api-key "&q=json"))))) - -(defn- format-transaction [account - {:keys [value timeStamp blockNumber hash from to - gas gasPrice gasUsed nonce input isError]}] - (let [inbound? (= (str "0x" account) to) - error? (= "1" isError)] - {:value value - ;; timestamp is in seconds, we convert it in ms - :timestamp (str timeStamp "000") - :symbol :ETH - :type (cond error? :failed - inbound? :inbound - :else :outbound) - :block blockNumber - :hash hash - :from from - :to to - :gas-limit gas - :gas-price gasPrice - :gas-used gasUsed - :nonce nonce - :data input})) - -(defn- format-transactions-response [response account] - (let [{:keys [result]} (types/json->clj response)] - (cond-> {} - (vector? result) - (into (comp - (map (partial format-transaction account)) - (map (juxt :hash identity))) - result)))) - -(defn- etherscan-transactions - ([chain account on-success on-error] - (etherscan-transactions chain account on-success on-error false)) - ([chain account on-success on-error chaos-mode?] - (if (etherscan-supported? chain) - (let [url (get-transaction-url chain account chaos-mode?)] - (log/debug "HTTP GET" url) - (http/get url - #(on-success (format-transactions-response % account)) - on-error)) - (log/info "Etherscan not supported for " chain)))) - -(defn- get-transactions [{:keys [web3 chain chain-tokens account-address - success-fn error-fn chaos-mode?]}] - (log/debug "Syncing transactions data..") - (etherscan-transactions chain - account-address - success-fn - error-fn - chaos-mode?)) - -;; ----------------------------------------------------------------------------- -;; Helpers functions that help determine if a background sync should execute -;; ----------------------------------------------------------------------------- - -(defn- keyed-memoize - "Space bounded memoize. - - Takes a key-function that decides the key in the cache for the - memoized value. Takes a value function that will extract the value - that will invalidate the cache if it changes. And finally the - function to memoize. - - Memoize that doesn't grow bigger than the number of keys." - [key-fn val-fn f] - (let [val-store (atom {}) - res-store (atom {})] - (fn [arg] - (let [k (key-fn arg) - v (val-fn arg)] - (if (not= (get @val-store k) v) - (let [res (f arg)] - #_(prn "storing!!!!" res) - (swap! val-store assoc k v) - (swap! res-store assoc k res) - res) - (get @res-store k)))))) - -;; Map[id, chat] -> Set[transaction-id] -;; chat may or may not have a :messages Map -(let [chat-map-entry->transaction-ids - (keyed-memoize key (comp :messages val) - (fn [[_ chat]] - (some->> (:messages chat) - vals - (filter #(= "command" (:content-type %))) - (keep #(select-keys (get-in % [:content :params]) [:tx-hash :network])))))] - (defn- chat-map->transaction-ids [network chat-map] - {:pre [(string? network) (every? map? (vals chat-map))] - :post [(set? %)]} - (let [network (string/replace network "_rpc" "")] - (->> chat-map - (remove (comp :public? val)) - (mapcat chat-map-entry->transaction-ids) - (filter #(= network (:network %))) - (map :tx-hash) - set)))) - -(letfn [(combine-entries [transaction token-transfer] - (merge transaction (select-keys token-transfer [:symbol :from :to :value :type :token :transfer]))) - (tx-and-transfer? [tx1 tx2] - (and (not (:transfer tx1)) (:transfer tx2))) - (both-transfer? - [tx1 tx2] - (and (:transfer tx1) (:transfer tx2)))] - (defn- dedupe-transactions [tx1 tx2] - (cond (tx-and-transfer? tx1 tx2) (combine-entries tx1 tx2) - (tx-and-transfer? tx2 tx1) (combine-entries tx2 tx1) - :else tx2))) - -;; ---------------------------------------------------------------------------- -;; The following Code represents how fetching transactions is -;; complected with the rest of the application -;; ---------------------------------------------------------------------------- - -(defonce polling-executor (atom nil)) - -(defn transactions-query-helper [web3 all-tokens account-address chain done-fn chaos-mode?] - (get-transactions - {:account-address account-address - :chain chain - :chain-tokens (into {} (map (juxt :address identity) (tokens/tokens-for all-tokens chain))) - :web3 web3 - :success-fn (fn [transactions] - #_(log/debug "Transactions received: " (pr-str (keys transactions))) - (swap! re-frame.db/app-db - (fn [app-db] - (when (= (get-in app-db [:account/account :address]) - account-address) - (update-in app-db - [:wallet :transactions] - #(merge-with dedupe-transactions % transactions))))) - (done-fn)) - :error-fn (fn [http-error] - (log/debug "Unable to get transactions: " http-error) - (done-fn)) - :chaos-mode? chaos-mode?})) - -(defn- sync-now! [{:keys [network-status :account/account :wallet/all-tokens app-state network web3] :as opts}] - (when @polling-executor - (let [chain (ethereum/network->chain-keyword (get-in account [:networks network])) - account-address (:address account) - chaos-mode? (get-in account [:settings :chaos-mode?])] - (when (and (not= network-status :offline) - (= app-state "active") - (not= :custom chain)) - (async-util/async-periodic-run! - @polling-executor - #(transactions-query-helper web3 all-tokens account-address chain % chaos-mode?)))))) - -;; this function handles background syncing of transactions -(defn- background-sync [web3 account-address done-fn] - (let [{:keys [network network-status :account/account app-state wallet chats :wallet/all-tokens]} @re-frame.db/app-db - chain (ethereum/network->chain-keyword (get-in account [:networks network]))] - (assert (and web3 account-address network network-status account app-state wallet chats) - "Must have all necessary data to run background transaction sync") - (if-not (and (not= network-status :offline) - (= app-state "active") - (not= :custom chain)) - (done-fn) - (let [chat-transaction-ids (chat-map->transaction-ids network chats) - transaction-map (:transactions wallet) - transaction-ids (set (keys transaction-map)) - chaos-mode? (get-in account [:settings :chaos-mode?])] - (if-not (not-empty (set/difference chat-transaction-ids transaction-ids)) - (done-fn) - (transactions-query-helper web3 all-tokens account-address chain done-fn chaos-mode?)))))) - -(defn- start-sync! [{:keys [:account/account network web3] :as options}] - (let [account-address (:address account)] - (when @polling-executor - (async-util/async-periodic-stop! @polling-executor)) - (reset! polling-executor - (async-util/async-periodic-exec - (partial #'background-sync web3 account-address) - sync-interval-ms - sync-timeout-ms))) - (sync-now! options)) +(defn get-block-by-hash + [block-hash callback] + (status/call-private-rpc + (types/clj->json {:jsonrpc "2.0" + :id 1 + :method "eth_getBlockByHash" + :params [block-hash true]}) + (fn [response] + (if (string/blank? response) + (log/warn :web3-response-error) + (callback (-> (.parse js/JSON response) + (js->clj :keywordize-keys true) + :result + (update :number decode/uint) + (update :timestamp decode/uint))))))) + +(defn get-transaction-by-hash + [transaction-hash callback] + (status/call-private-rpc + (types/clj->json {:jsonrpc "2.0" + :id 1 + :method "eth_getTransactionByHash" + :params [transaction-hash]}) + (fn [response] + (if (string/blank? response) + (log/warn :web3-response-error) + (callback (-> (.parse js/JSON response) + (js->clj :keywordize-keys true) + :result)))))) + +(defn get-transaction-receipt [transaction-hash callback] + (status/call-private-rpc + (types/clj->json {:jsonrpc "2.0" + :id 1 + :method "eth_getTransactionReceipt" + :params [transaction-hash]}) + (fn [response] + (if (string/blank? response) + (log/warn :web3-response-error) + (callback (-> (.parse js/JSON response) + (js->clj :keywordize-keys true) + :result)))))) + +(defn add-padding [address] + {:pre [(string? address)]} + (str "0x000000000000000000000000" (subs address 2))) + +(defn- remove-padding [topic] + {:pre [(string? topic)]} + (str "0x" (subs topic 26))) + +(defn- parse-token-transfer + [chain-tokens direction transfer] + (let [{:keys [blockHash transactionHash topics data address]} transfer + [_ from to] topics + {:keys [nft? symbol] :as token} (get chain-tokens address)] + (when-not nft? + (cond-> {:hash transactionHash + :symbol symbol + :from (remove-padding from) + :to (remove-padding to) + :value (ethereum/hex->bignumber data) + :type direction + :token token + :error? false + ;; NOTE(goranjovic) - just a flag we need when we merge this entry + ;; with the existing entry in the app, e.g. transaction info with + ;; gas details, or a previous transfer entry with old confirmations + ;; count. + :transfer true} + (= :inbound direction) + (assoc :block-hash blockHash))))) + +(defn enrich-transaction-from-new-block + [chain-tokens + {:keys [number timestamp]} + {:keys [transfer direction hash gasPrice value gas from input nonce to] :as transaction}] + (get-transaction-receipt + hash + (fn [{:keys [gasUsed] :as receipt}] + (re-frame/dispatch + [:ethereum.transactions/new + (merge {:block (str number) + :timestamp (str (* timestamp 1000)) + :gas-used (str (decode/uint gasUsed)) + :gas-price (str (decode/uint gasPrice)) + :gas-limit (str (decode/uint gas)) + :nonce (str (decode/uint nonce)) + :data input} + (if transfer + (parse-token-transfer chain-tokens + :outbound + (first (:logs receipt))) + ;; this is not a ERC20 token transaction + {:hash hash + :symbol :ETH + :from from + :to to + :type direction + :value (str (decode/uint value))}))])))) (re-frame/reg-fx - ::sync-transactions-now - (fn [db] (sync-now! db))) - -(re-frame/reg-fx - ::start-sync-transactions - (fn [db] (start-sync! db))) - -(fx/defn start-sync [{:keys [db]}] - {::start-sync-transactions - (select-keys db [:network-status :account/account :wallet/all-tokens - :app-state :network :web3])}) - -(re-frame/reg-fx - ::stop-sync-transactions - #(when @polling-executor - (async-util/async-periodic-stop! @polling-executor))) - -(fx/defn stop-sync [_] - {::stop-sync-transactions nil}) + :ethereum.transactions/enrich-transactions-from-new-blocks + (fn [{:keys [chain-tokens block transactions]}] + (doseq [transaction transactions] + (enrich-transaction-from-new-block chain-tokens + block + transaction)))) + +(defn inbound-token-transfer-handler + "The handler gets a list of inbound token transfer events and parses each + transfer. Transfers are grouped by block the following chain of callbacks + follows: + - get block by hash is called to get the `timestamp` of each block + - get transaction by hash is called on each transaction to get the `gasPrice` + `gas` used, `input` data and `nonce` of each transaction + - get transaction receipt is used to get the `gasUsed` + - finally everything is merged into one map that is dispatched in a + `ethereum.signal/new-transaction` event for each transfer" + [chain-tokens] + (fn [transfers] + (let [transfers-by-block + (group-by :block-hash + (keep #(parse-token-transfer + chain-tokens + :inbound + %) + transfers))] + ;; TODO: remove this callback chain by implementing a better status-go api + ;; This function takes the map of supported tokens as params and returns a + ;; handler for token transfer events + (doseq [[block-hash block-transfers] transfers-by-block] + (get-block-by-hash + block-hash + (fn [{:keys [timestamp number]}] + (let [timestamp (str (* timestamp 1000))] + (doseq [{:keys [hash] :as transfer} block-transfers] + (get-transaction-by-hash + hash + (fn [{:keys [gasPrice gas input nonce]}] + (get-transaction-receipt + hash + (fn [{:keys [gasUsed]}] + (re-frame/dispatch + [:ethereum.transactions/new + (-> transfer + (dissoc :block-hash) + (assoc :timestamp timestamp + :block (str number) + :gas-used (str (decode/uint gasUsed)) + :gas-price (str (decode/uint gasPrice)) + :gas-limit (str (decode/uint gas)) + :data input + :nonce (str (decode/uint nonce))))]))))))))))))) + +;; ----------------------------------------------- +;; transactions api +;; ----------------------------------------------- + +(fx/defn new + [{:keys [db]} {:keys [hash] :as transaction}] + {:db (assoc-in db [:wallet :transactions hash] transaction)}) + +(fx/defn handle-history + [{:keys [db]} transactions] + {:db (update-in db + [:wallet :transactions] + merge + transactions)}) + +(fx/defn initialize + [cofx] + (transactions.etherscan/fetch-history cofx)) diff --git a/src/status_im/ethereum/transactions/etherscan.cljs b/src/status_im/ethereum/transactions/etherscan.cljs new file mode 100644 index 00000000000..cbd8e4bdc52 --- /dev/null +++ b/src/status_im/ethereum/transactions/etherscan.cljs @@ -0,0 +1,176 @@ +(ns status-im.ethereum.transactions.etherscan + (:require [re-frame.core :as re-frame] + [status-im.utils.ethereum.core :as ethereum] + [status-im.utils.ethereum.tokens :as tokens] + [status-im.utils.fx :as fx] + [status-im.utils.http :as http] + [status-im.utils.types :as types] + [taoensso.timbre :as log])) + +;; -------------------------------------------------------------------------- +;; etherscan transactions +;; -------------------------------------------------------------------------- + +(def etherscan-supported? #{:testnet :mainnet :rinkeby}) + +(let [network->subdomain {:testnet "ropsten" :rinkeby "rinkeby"}] + (defn get-transaction-details-url [chain hash] + {:pre [(keyword? chain) (string? hash)] + :post [(or (nil? %) (string? %))]} + (when (etherscan-supported? chain) + (let [network-subdomain (when-let [subdomain (network->subdomain chain)] + (str subdomain "."))] + (str "https://" network-subdomain "etherscan.io/tx/" hash))))) + +(def etherscan-api-key "DMSI4UAAKUBVGCDMVP3H2STAMSAUV7BYFI") + +(defn- get-api-network-subdomain [chain] + (case chain + (:testnet) "api-ropsten" + (:mainnet) "api" + (:rinkeby) "api-rinkeby")) + +(defn- get-transaction-url + ([chain address] (get-transaction-url chain address false)) + ([chain address chaos-mode?] + {:pre [(keyword? chain) (string? address)] + :post [(string? %)]} + (let [network-subdomain (get-api-network-subdomain chain)] + (if chaos-mode? + "http://httpstat.us/500" + (str "https://" network-subdomain + ".etherscan.io/api?module=account&action=txlist&address=" address + "&startblock=0&endblock=99999999&sort=desc&apikey=" etherscan-api-key + "&q=json"))))) + +(defn- get-token-transaction-url + ([chain address] (get-token-transaction-url chain address false)) + ([chain address chaos-mode?] + {:pre [(keyword? chain) (string? address)] + :post [(string? %)]} + (let [network-subdomain (get-api-network-subdomain chain)] + (if chaos-mode? + "http://httpstat.us/500" + (str "https://" network-subdomain + ".etherscan.io/api?module=account&action=tokentx&address=" address + "&startblock=0&endblock=999999999&sort=asc&apikey=" etherscan-api-key + "&q=json"))))) + +(defn- format-transaction + [address + {:keys [value timeStamp blockNumber hash from to + gas gasPrice gasUsed nonce input isError]}] + (let [inbound? (= address to) + error? (= "1" isError)] + {:value value + ;; timestamp is in seconds, we convert it in ms + :timestamp (str timeStamp "000") + :symbol :ETH + :type (cond error? :failed + inbound? :inbound + :else :outbound) + :block blockNumber + :hash hash + :from from + :to to + :gas-limit gas + :gas-price gasPrice + :gas-used gasUsed + :nonce nonce + :data input})) + +(defn- format-token-transaction + [address + chain-tokens + {:keys [contractAddress blockHash hash tokenDecimal gasPrice value + gas tokenName timeStamp transactionIndex tokenSymbol + confirmations blockNumber from gasUsed input nonce + cumulativeGasUsed to]}] + (let [inbound? (= address to) + token (get chain-tokens contractAddress + {:name tokenName + :symbol tokenSymbol + :decimals tokenDecimal + :address contractAddress})] + {:value value + ;; timestamp is in seconds, we convert it in ms + :timestamp (str timeStamp "000") + :symbol (keyword tokenSymbol) + :type (if inbound? + :inbound + :outbound) + :block blockNumber + :hash hash + :from from + :to to + :gas-limit gas + :gas-price gasPrice + :gas-used gasUsed + :nonce nonce + :data input + :error? false + :transfer true + :token token})) + +(defn- format-transactions-response [response format-fn] + (let [{:keys [result]} (types/json->clj response)] + (cond-> {} + (vector? result) + (into (comp + (map format-fn) + (map (juxt :hash identity))) + result)))) + +(defn- etherscan-transactions + [chain address on-success on-error chaos-mode?] + (if (etherscan-supported? chain) + (let [url (get-transaction-url chain address chaos-mode?)] + (log/debug :etherscan-transactions :url url) + (http/get url + #(on-success (format-transactions-response + % + (partial format-transaction address))) + on-error)) + (log/info "Etherscan not supported for " chain))) + +(defn- etherscan-token-transactions + [chain address chain-tokens on-success on-error chaos-mode?] + (if (etherscan-supported? chain) + (let [token-url (get-token-transaction-url chain address chaos-mode?)] + (log/debug :etherscan-token-transactions :token-url token-url) + (http/get token-url + #(on-success (format-transactions-response + % + (partial format-token-transaction address chain-tokens))))) + (log/info "Etherscan not supported for " chain))) + +(re-frame/reg-fx + :ethereum.transactions/get-etherscan-transactions + (fn [{:keys [chain chain-tokens address on-success on-error chaos-mode?]}] + (etherscan-transactions chain address on-success on-error chaos-mode?) + (etherscan-token-transactions chain address chain-tokens on-success on-error chaos-mode?))) + +;; ----------------------------------------------- +;; chain transactions +;; ----------------------------------------------- + +(fx/defn fetch-history + [{:keys [db] :as cofx}] + (let [{:keys [:account/account :wallet/all-tokens network]} db + chain (ethereum/network->chain-keyword + (get-in account [:networks network])) + chain-tokens (into {} (map (juxt :address identity) + (tokens/tokens-for all-tokens chain))) + chaos-mode? (get-in account [:settings :chaos-mode?]) + normalized-address (ethereum/normalized-address (:address account))] + {:ethereum.transactions/get-etherscan-transactions + {:chain chain + :chain-tokens chain-tokens + :address normalized-address + :on-success + #(re-frame/dispatch + [:ethereum.transactions.callback/get-etherscan-transactions-success %]) + :on-error + #(re-frame/dispatch + [:ethereum.transactions.callback/get-etherscan-transactions-error %]) + :chaos-mode? chaos-mode?}})) diff --git a/src/status_im/events.cljs b/src/status_im/events.cljs index 2a43aaa42de..0cb8498b8a4 100644 --- a/src/status_im/events.cljs +++ b/src/status_im/events.cljs @@ -21,6 +21,7 @@ [status-im.contact.block :as contact.block] [status-im.contact.core :as contact] [status-im.ethereum.subscriptions :as ethereum.subscriptions] + [status-im.ethereum.transactions.core :as ethereum.transactions] [status-im.extensions.core :as extensions] [status-im.extensions.registry :as extensions.registry] [status-im.fleet.core :as fleet] @@ -2055,10 +2056,21 @@ (handlers/register-handler-fx :ethereum.signal/new-block - (fn [cofx [_ block-number]] - (ethereum.subscriptions/new-block cofx block-number))) + (fn [cofx [_ block]] + (ethereum.subscriptions/new-block cofx block))) +;; ethereum transactions events (handlers/register-handler-fx - :ethereum.signal/new-transactions + :ethereum.transactions.callback/get-etherscan-transactions-success (fn [cofx [_ transactions]] - (ethereum.subscriptions/new-transactions cofx transactions))) + (ethereum.transactions/handle-history cofx transactions))) + +(handlers/register-handler-fx + :ethereum.transactions.callback/get-etherscan-transactions-error + (fn [cofx [event error]] + (log/error event error))) + +(handlers/register-handler-fx + :ethereum.transactions/new + (fn [cofx [_ transaction]] + (ethereum.transactions/new cofx transaction))) diff --git a/src/status_im/subs.cljs b/src/status_im/subs.cljs index 47f4908af6d..17a90ab77a4 100644 --- a/src/status_im/subs.cljs +++ b/src/status_im/subs.cljs @@ -13,6 +13,8 @@ [status-im.ethereum.transactions.core :as transactions] [status-im.fleet.core :as fleet] [status-im.i18n :as i18n] + [status-im.ethereum.transactions.core :as transactions] + [status-im.ethereum.transactions.etherscan :as transactions.etherscan] [status-im.models.wallet :as models.wallet] [status-im.ui.components.bottom-bar.styles :as tabs.styles] [status-im.ui.components.toolbar.styles :as toolbar.styles] @@ -1131,7 +1133,7 @@ :hash (i18n/label :not-applicable)} {:cost (when gas-used (money/wei->str :eth (money/fee-value gas-used gas-price) display-unit)) - :url (transactions/get-transaction-details-url chain hash)})))))) + :url (transactions.etherscan/get-transaction-details-url chain hash)})))))) (re-frame/reg-sub :wallet.transactions.details/confirmations @@ -1139,7 +1141,7 @@ :<- [:wallet.transactions/transaction-details] (fn [[current-block {:keys [block]}]] (if (and current-block block) - (- current-block block) + (inc (- current-block block)) 0))) (re-frame/reg-sub diff --git a/src/status_im/ui/screens/wallet/events.cljs b/src/status_im/ui/screens/wallet/events.cljs index fd221da199d..f94fc8d7a1d 100644 --- a/src/status_im/ui/screens/wallet/events.cljs +++ b/src/status_im/ui/screens/wallet/events.cljs @@ -1,6 +1,5 @@ (ns status-im.ui.screens.wallet.events (:require [re-frame.core :as re-frame] - [status-im.ethereum.transactions.core :as transactions] [status-im.i18n :as i18n] [status-im.models.wallet :as models] [status-im.ui.screens.navigation :as navigation] @@ -143,13 +142,6 @@ (navigation/navigate-back) (models/update-wallet)))) -(handlers/register-handler-fx - :update-transactions - (fn [{:keys [db]} _] - {::transactions/sync-transactions-now - (select-keys db [:network-status :account/account :wallet/all-tokens - :app-state :network :web3])})) - (handlers/register-handler-fx :update-balance-success (fn [{:keys [db]} [_ balance]] diff --git a/src/status_im/ui/screens/wallet/navigation.cljs b/src/status_im/ui/screens/wallet/navigation.cljs index 5239b2e2d4b..89f165d11f1 100644 --- a/src/status_im/ui/screens/wallet/navigation.cljs +++ b/src/status_im/ui/screens/wallet/navigation.cljs @@ -1,5 +1,6 @@ (ns status-im.ui.screens.wallet.navigation (:require [re-frame.core :as re-frame] + [status-im.constants :as constants] [status-im.ui.screens.navigation :as navigation] [status-im.utils.ethereum.core :as ethereum] [status-im.constants :as constants] @@ -25,11 +26,6 @@ 500) (assoc-in db [:wallet :current-tab] 0)) -(defmethod navigation/preload-data! :transactions-history - [db _] - (re-frame/dispatch [:update-transactions]) - db) - (def transaction-send-default (let [symbol :ETH] {:gas (ethereum/estimate-gas symbol) diff --git a/src/status_im/ui/screens/wallet/transactions/views.cljs b/src/status_im/ui/screens/wallet/transactions/views.cljs index c668a93c309..f5d86f9e119 100644 --- a/src/status_im/ui/screens/wallet/transactions/views.cljs +++ b/src/status_im/ui/screens/wallet/transactions/views.cljs @@ -1,21 +1,19 @@ (ns status-im.ui.screens.wallet.transactions.views - (:require-macros [status-im.utils.views :refer [defview letsubs]]) (:require [re-frame.core :as re-frame] [status-im.i18n :as i18n] + [status-im.ui.components.colors :as colors] [status-im.ui.components.list.views :as list] [status-im.ui.components.react :as react] [status-im.ui.components.status-bar.view :as status-bar] [status-im.ui.components.styles :as components.styles] - [status-im.ui.components.colors :as colors] [status-im.ui.components.toolbar.actions :as actions] [status-im.ui.components.toolbar.view :as toolbar] - [status-im.ui.components.status-bar.view :as status-bar] [status-im.ui.screens.wallet.transactions.styles :as styles] - [status-im.utils.money :as money] - [status-im.utils.ethereum.tokens :as tokens] - [status-im.utils.ethereum.core :as ethereum] [status-im.ui.screens.wallet.utils :as wallet.utils] - [status-im.utils.utils :as utils])) + [status-im.utils.ethereum.core :as ethereum] + [status-im.utils.ethereum.tokens :as tokens] + [status-im.utils.money :as money]) + (:require-macros [status-im.utils.views :refer [defview letsubs]])) (defn history-action [filter?] (cond-> @@ -111,7 +109,6 @@ :render-fn #(render-transaction % network all-tokens hide-details?) :empty-component [react/i18n-text {:style styles/empty-text :key :transactions-history-empty}] - :on-refresh #(re-frame/dispatch [:update-transactions]) :refreshing false}]])) ;; Filter history @@ -263,4 +260,3 @@ [details-confirmations confirmations confirmations-progress type] [react/view {:style styles/details-separator}] [details-list transaction]]])) - diff --git a/test/cljs/status_im/test/sign_in/flow.cljs b/test/cljs/status_im/test/sign_in/flow.cljs index 29eede285e0..7741d4f0871 100644 --- a/test/cljs/status_im/test/sign_in/flow.cljs +++ b/test/cljs/status_im/test/sign_in/flow.cljs @@ -204,8 +204,7 @@ (is (contains? efx :get-balance)) (is (contains? efx :web3/get-syncing)) (is (contains? efx :get-tokens-balance)) - (is (contains? efx :get-prices)) - (is (contains? efx :status-im.ethereum.transactions.core/start-sync-transactions)))))) + (is (contains? efx :get-prices)))))) (deftest login-failed (testing diff --git a/test/cljs/status_im/test/wallet/transactions.cljs b/test/cljs/status_im/test/wallet/transactions.cljs index f64ef4e5710..0c080d6ba44 100644 --- a/test/cljs/status_im/test/wallet/transactions.cljs +++ b/test/cljs/status_im/test/wallet/transactions.cljs @@ -1,154 +1,9 @@ (ns status-im.test.wallet.transactions (:require [cljs.test :refer-macros [deftest is]] [goog.Uri :as goog-uri] - [status-im.ethereum.transactions.core :as transactions] + [status-im.ethereum.transactions.etherscan :as transactions] [status-im.utils.http :as http])) -(deftest chat-map->transaction-ids - (is (= #{} (transactions/chat-map->transaction-ids "testnet_rpc" {}))) - (is (= #{"a" "b" "c" "d"} - (transactions/chat-map->transaction-ids - "testnet_rpc" - {:a {:messages {1 {:content-type "command" - :content {:params {:tx-hash "a" - :network "testnet"}}}}} - :b {:messages {1 {:content-type "command" - :content {:params {:tx-hash "b" - :network "testnet"}}}}} - :c {:messages {1 {:content-type "command" - :content {:params {:tx-hash "c" - :network "testnet"}}} - 2 {:content-type "command" - :content {:params {:tx-hash "d" - :network "testnet"}}}}}}))) - - (is (= #{"a" "b" "c" "d" "e"} - (transactions/chat-map->transaction-ids - "testnet" - {:aa {:messages {1 {:content-type "command" - :content {:params {:tx-hash "a" - :network "testnet"}}}}} - :bb {:messages {1 {:content-type "command" - :content {:params {:tx-hash "b" - :network "testnet"}}}}} - :cc {:messages {1 {:content-type "command" - :content {:params {:tx-hash "c" - :network "testnet"}}} - 2 {:content-type "command" - :content {:params {:tx-hash "d" - :network "testnet"}}} - 3 {:content-type "command" - :content {:params {:tx-hash "e" - :network "testnet"}}}}}}))) - (is (= #{"b"} - (transactions/chat-map->transaction-ids - "testnet_rpc" - {:aa {:public? true - :messages {1 {:content-type "command" - :content {:params {:tx-hash "a" - :network "testnet"}}}}} - :bb {:messages {1 {:content-type "command" - :content {:params {:tx-hash "b" - :network "testnet"}}}}} - :cc {:messages {1 {:content {:params {:tx-hash "c" - :network "testnet"}}} - 2 {:content-type "command"}}}})))) - -;; The following tests are fantastic for developing the async-periodic-exec -;; but dismal for CI because of their probablistic nature -#_(deftest async-periodic-exec - (testing "work-fn is executed and can be stopeed" - (let [executor (atom nil) - state (atom 0)] - (reset! executor - (transactions/async-periodic-exec - (fn [done-fn] - (swap! state inc) - (done-fn)) - 100 - 500)) - (async test-done - (js/setTimeout - (fn [] - (is (> 6 @state 2)) - (transactions/async-periodic-stop! @executor) - (let [st @state] - (js/setTimeout - #(do - (is (= st @state)) - (is (closed? @executor)) - (test-done)) - 500))) - 500))))) - -#_(deftest async-periodic-exec-error-in-job - (testing "error thrown in job is caught and loop continues" - (let [executor (atom nil) - state (atom 0)] - (reset! executor - (transactions/async-periodic-exec - (fn [done-fn] - (swap! state inc) - (throw (ex-info "Throwing this on purpose in error-in-job test" {}))) - 10 - 100)) - (async test-done - (js/setTimeout - (fn [] - (is (> @state 1)) - (transactions/async-periodic-stop! @executor) - (let [st @state] - (js/setTimeout - #(do - (is (= st @state)) - (is (closed? @executor)) - (test-done)) - 500))) - 1000))))) - -#_(deftest async-periodic-exec-job-takes-longer - (testing "job takes longer than expected, executor timeout but task side-effects are still applied" - (let [executor (atom nil) - state (atom 0)] - (reset! executor - (transactions/async-periodic-exec - (fn [done-fn] (js/setTimeout #(swap! state inc) 100)) - 10 - 1)) - (async test-done - (js/setTimeout - (fn [] - (transactions/async-periodic-stop! @executor) - (js/setTimeout - #(do (is (< 3 @state)) - (test-done)) - 500)) - 500))))) - -#_(deftest async-periodic-exec-stop-early - (testing "stopping early prevents any executions" - (let [executor (atom nil) - state (atom 0)] - (reset! executor - (transactions/async-periodic-exec - (fn [done-fn] - (swap! state inc) - (done-fn)) - 100 - 100)) - (async test-done - (js/setTimeout - (fn [] - (is (zero? @state)) - (transactions/async-periodic-stop! @executor) - (let [st @state] - (js/setTimeout - (fn [] - (is (zero? @state)) - (test-done)) - 500))) - 50))))) - (defn- uri-query-data [uri] (let [uri' (goog-uri/parse uri) accum (atom {})] @@ -183,7 +38,7 @@ :sort "desc", :apikey "DMSI4UAAKUBVGCDMVP3H2STAMSAUV7BYFI", :q "json"}} - (uri-query-data (transactions/get-transaction-url :mainnet "asdfasdf")))) + (uri-query-data (transactions/get-transaction-url :mainnet "0xasdfasdf")))) (is (= {:scheme "https", :domain "api-rinkeby.etherscan.io", :path "/api", @@ -196,12 +51,12 @@ :sort "desc", :apikey "DMSI4UAAKUBVGCDMVP3H2STAMSAUV7BYFI", :q "json"}} - (uri-query-data (transactions/get-transaction-url :rinkeby "asdfasdfg")))) - (let [uri (-> (transactions/get-transaction-url :testnet "asdfasdfgg") + (uri-query-data (transactions/get-transaction-url :rinkeby "0xasdfasdfg")))) + (let [uri (-> (transactions/get-transaction-url :testnet "0xasdfasdfgg") uri-query-data)] (is (= "api-ropsten.etherscan.io" (:domain uri))) (is (= "0xasdfasdfgg" (-> uri :query :address)))) - (is (thrown? js/Error (transactions/get-transaction-url nil "asdfasdfg")))) + (is (thrown? js/Error (transactions/get-transaction-url nil "0xasdfasdfg")))) (declare mock-etherscan-success-response mock-etherscan-error-response @@ -215,9 +70,10 @@ (let [result (atom nil)] (transactions/etherscan-transactions :mainnet - "asdfasdf" + "0xasdfasdf" #(reset! result %) - (fn [er])) + (fn [er]) + false) (doseq [[tx-hash tx-map] @result] (is (string? tx-hash)) (is (= tx-hash (:hash tx-map))) @@ -234,9 +90,10 @@ (let [result (atom nil)] (transactions/etherscan-transactions :mainnet - "asdfasdf" + "0xasdfasdf" #(reset! result %) - (fn [er])) + (fn [er]) + false) (is (= {} @result)))) (with-redefs [http/get (fn [url success-fn error-fn] @@ -244,13 +101,12 @@ (let [result (atom nil)] (transactions/etherscan-transactions :mainnet - "asdfasdf" + "0xasdfasdf" #(reset! result %) - (fn [er])) + (fn [er]) + false) (is (= {} @result))))) -#_(run-tests) - (def mock-etherscan-error-response "{\"status\":\"0\",\"message\":\"NOTOK\",\"result\":\"Error!\"}")