[feature] use new block signal to get new transactions

- remove the transaction fetching loop entirely to rely only on subscription
for live transactions and token transfer updates
- fetch token transfers history via etherscan API to lift the 100000 blocks
limit on token transfers history

- inbound token transfers are catched via a filter on ethlogs
- outbound token transfers and other transactions are catched by filtering
transaction in current block that have the wallet address as to or from field
This commit is contained in:
yenda 2019-05-13 13:44:46 +02:00
parent f5c18ae7a9
commit b274ed9fa9
No known key found for this signature in database
GPG Key ID: 0095623C0069DCE6
13 changed files with 474 additions and 605 deletions

View File

@ -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)]

View File

@ -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)))

View File

@ -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,37 @@
[{:keys [db]} id handler]
{:db (assoc-in db [:ethereum/subscriptions id] handler)})
(defn keep-user-transactions
[wallet-address transactions]
(keep (fn [{:keys [to from] :as transaction}]
(when-let [direction (cond
(= wallet-address to) :inbound
(= wallet-address from) :outbound)]
(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))]
(fx/merge cofx
{: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
transactions)}}
(when (or (not current-block)
(not= number (inc current-block)))
;; in case we skipped some blocks or got an uncle, re-fetch history
;; from etherscan
(transactions/initialize))))))
(defn subscribe-signal
[filter params callback]
@ -98,139 +75,33 @@
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
"eth_newFilter"
[{:fromBlock "latest"
: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))))
(re-frame/dispatch [:ethereum.signal/new-block block]))))))
(re-frame/reg-fx
:ethereum.subscriptions/new-block
@ -242,7 +113,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}}))

View File

@ -1,252 +1,200 @@
(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.constants :as constants]
[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
;; --------------------------------------------------------------------------
(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)))))))
(def etherscan-supported? #{:testnet :mainnet :rinkeby})
(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))))))
(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)))))
(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))))))
(def etherscan-api-key "DMSI4UAAKUBVGCDMVP3H2STAMSAUV7BYFI")
(defn add-padding [address]
{:pre [(string? address)]}
(str "0x000000000000000000000000" (subs address 2)))
(defn- get-api-network-subdomain [chain]
(case chain
(:testnet) "api-ropsten"
(:mainnet) "api"
(:rinkeby) "api-rinkeby"))
(defn- remove-padding [topic]
{:pre [(string? topic)]}
(str "0x" (subs topic 26)))
(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")))))
(def default-erc20-token
{:symbol :ERC20
:decimals 18
:name "ERC20"})
(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- 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
default-erc20-token)]
(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- 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 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 logs] :as receipt}]
(let [[event _ _] (:topics (first logs))
transfer (= constants/event-transfer-hash event)]
(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))
;; 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)))
: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))))
(re-frame/reg-fx
::start-sync-transactions
(fn [db] (start-sync! db)))
(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))))])))))))))))))
(fx/defn start-sync [{:keys [db]}]
{::start-sync-transactions
(select-keys db [:network-status :account/account :wallet/all-tokens
:app-state :network :web3])})
;; -----------------------------------------------
;; transactions api
;; -----------------------------------------------
(re-frame/reg-fx
::stop-sync-transactions
#(when @polling-executor
(async-util/async-periodic-stop! @polling-executor)))
(fx/defn new
[{:keys [db]} {:keys [hash] :as transaction}]
{:db (assoc-in db [:wallet :transactions hash] transaction)})
(fx/defn stop-sync [_]
{::stop-sync-transactions nil})
(fx/defn handle-history
[{:keys [db]} transactions]
{:db (update-in db
[:wallet :transactions]
#(merge transactions %))})
(fx/defn handle-token-history
[{:keys [db]} transactions]
{:db (update-in db
[:wallet :transactions]
merge transactions)})
(fx/defn initialize
[cofx]
(transactions.etherscan/fetch-history cofx))

View File

@ -0,0 +1,192 @@
(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-history
[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-history
[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)))
on-error))
(log/info "Etherscan not supported for " chain)))
(re-frame/reg-fx
:ethereum.transactions.etherscan/fetch-history
(fn [{:keys [chain address on-success on-error chaos-mode?]}]
(etherscan-history chain address on-success on-error chaos-mode?)))
(re-frame/reg-fx
:ethereum.transactions.etherscan/fetch-token-history
(fn [{:keys [chain chain-tokens address on-success on-error chaos-mode?]}]
(etherscan-token-history 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.etherscan
{:fetch-history
{:chain chain
:address normalized-address
:on-success
#(re-frame/dispatch
[:ethereum.transactions.callback/fetch-history-success %])
:on-error
#(re-frame/dispatch
[:ethereum.transactions.callback/etherscan-error %])
:chaos-mode? chaos-mode?}
:fetch-token-history
{:chain chain
:chain-tokens chain-tokens
:address normalized-address
:on-success
#(re-frame/dispatch
[:ethereum.transactions.callback/fetch-token-history-success %])
:on-error
#(re-frame/dispatch
[:ethereum.transactions.callback/etherscan-error %])
:chaos-mode? chaos-mode?}}))

View File

@ -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]
@ -2120,10 +2121,26 @@
(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.transactions.callback/fetch-history-success
(fn [cofx [_ transactions]]
(ethereum.transactions/handle-history cofx transactions)))
(handlers/register-handler-fx
:ethereum.signal/new-transactions
:ethereum.transactions.callback/etherscan-error
(fn [cofx [event error]]
(log/info event error)))
(handlers/register-handler-fx
:ethereum.transactions.callback/fetch-token-history-success
(fn [cofx [_ transactions]]
(ethereum.subscriptions/new-transactions cofx transactions)))
(ethereum.transactions/handle-token-history cofx transactions)))
(handlers/register-handler-fx
:ethereum.transactions/new
(fn [cofx [_ transaction]]
(ethereum.transactions/new cofx transaction)))

View File

@ -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]
@ -1133,7 +1135,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
@ -1141,7 +1143,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

View File

@ -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]]

View File

@ -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)

View File

@ -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]]]))

View File

@ -32,7 +32,7 @@
:body body}))))))
(.catch (or on-error
(fn [error]
(utils/show-popup "Error" (str error))))))))
(utils/show-popup "Error" url (str error))))))))
(defn post
"Performs an HTTP POST request"
@ -72,7 +72,7 @@
(.catch (fn [error]
(if on-error
(on-error {:response-body error})
(utils/show-popup "Error" (str error))))))))
(utils/show-popup "Error" url (str error))))))))
(defn raw-get
"Performs an HTTP GET request and returns raw results :status :headers :body."
@ -94,7 +94,7 @@
:body body}))))))
(.catch (or on-error
(fn [error]
(utils/show-popup "Error" (str error))))))))
(utils/show-popup "Error" url (str error))))))))
(defn get
"Performs an HTTP GET request"
@ -129,7 +129,7 @@
:else false)))
(.catch (or on-error
(fn [error]
(utils/show-popup "Error" (str error))))))))
(utils/show-popup "Error" url (str error))))))))
(defn normalize-url [url]
(str (when (and (string? url) (not (re-find #"^[a-zA-Z-_]+:/" url))) "http://") url))

View File

@ -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

View File

@ -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,28 +51,29 @@
: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
mock-etherscan-empty-response)
(deftest etherscan-transactions
(deftest etherscan-history
(let [ky-set #{:block :hash :symbol :gas-price :value :gas-limit :type
:gas-used :from :timestamp :nonce :to :data}]
(with-redefs [http/get (fn [url success-fn error-fn]
(success-fn mock-etherscan-success-response))]
(let [result (atom nil)]
(transactions/etherscan-transactions
(transactions/etherscan-history
: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)))
@ -232,25 +88,25 @@
(with-redefs [http/get (fn [url success-fn error-fn]
(success-fn mock-etherscan-empty-response))]
(let [result (atom nil)]
(transactions/etherscan-transactions
(transactions/etherscan-history
:mainnet
"asdfasdf"
"0xasdfasdf"
#(reset! result %)
(fn [er]))
(fn [er])
false)
(is (= {} @result))))
(with-redefs [http/get (fn [url success-fn error-fn]
(success-fn mock-etherscan-error-response))]
(let [result (atom nil)]
(transactions/etherscan-transactions
(transactions/etherscan-history
: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!\"}")