diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql index 0f46d62..66d4614 100644 --- a/resources/sql/queries.sql +++ b/resources/sql/queries.sql @@ -145,35 +145,11 @@ RETURNING repo_id, issue_id, issue_number, title, commit_sha, contract_address; -- :name update-transaction-hash :! :n -- :doc updates transaction-hash for a given issue UPDATE issues -SET transaction_hash = :transaction_hash +SET transaction_hash = :transaction_hash, + updated = timezone('utc'::text, now()) WHERE issue_id = :issue_id; --- TODO: this is terrible --- :name update-contract-address :NonceTracker (atom {}))) - -(defn get-signed-tx [{:keys [gas-price gas-limit to data internal-tx-id]}] - "Create a sign a raw transaction. 'From' argument is not needed as it's already encoded in credentials. - See https://web3j.readthedocs.io/en/latest/transactions.html#offline-transaction-signing" - (let [nonce (get-nonce nonce-tracker internal-tx-id)] - (log/infof "%s: Signing nonce: %s, gas-price: %s, gas-limit: %s" - internal-tx-id nonce gas-price gas-limit) - (-> (RawTransaction/createTransaction (biginteger nonce) gas-price gas-limit to data) - (TransactionEncoder/signMessage (creds)) - (Numeric/toHexString)))) - (defn eth-gasstation-gas-price "Get max of average and average_calc from gas station and use that as gas price. average_calc is computed from a larger time period than average, @@ -155,7 +70,9 @@ (defn eth-rpc [{:keys [method params internal-tx-id]}] {:pre [(string? method) (some? params)]} - (let [request-id (swap! req-id-tracker inc) + (let [[type-kw issue-id] internal-tx-id + tx-id-str (str type-kw "-" issue-id) + request-id (swap! req-id-tracker inc) body {:jsonrpc "2.0" :method method :params params @@ -165,21 +82,22 @@ response @(post (eth-rpc-url) options) result (safe-read-str (:body response))] (when internal-tx-id - (log/infof "%s: eth-rpc %s" internal-tx-id method)) - (log/debugf "%s: eth-rpc req(%s) body: %s" internal-tx-id request-id body) - (if internal-tx-id - (log/infof "%s: eth-rpc req(%s) result: %s" internal-tx-id request-id result) + (log/infof "%s: eth-rpc %s" tx-id-str method)) + (log/debugf "%s: eth-rpc req(%s) body: %s" tx-id-str request-id body) + (if tx-id-str + (log/infof "%s: eth-rpc req(%s) result: %s" tx-id-str request-id result) (log/debugf "no-tx-id: eth-rpc req(%s) result: %s" request-id result)) (cond ;; Ignore any responses that have mismatching request ID (not= (:id result) request-id) - (log/error "Geth returned an invalid json-rpc request ID, ignoring response") + (throw (ex-info "Geth returned an invalid json-rpc request ID, ignoring response" + {:result result})) ;; If request ID matches but contains error, throw (:error result) (throw (ex-info (format "%s: Error submitting transaction via eth-rpc %s" - (or internal-tx-id "(no-tx-id)") (:error result)) + (or tx-id-str "(no-tx-id)") (:error result)) (:error result))) :else @@ -277,36 +195,70 @@ (eth-rpc {:method "eth_call" :params [{:to contract :data data} "latest"]}))) -(defn execute - [{:keys [from contract method-id gas-limit params internal-tx-id]}] - {:pre [(string? method-id)]} +(defn construct-params + [{:keys [from contract method-id params gas-price]}] (let [data (apply format-call-params method-id params) + value (format "0x%x" 0)] + (cond-> {:data data + :from from + :value value} + gas-price + (merge {:gasPrice (integer->hex gas-price)}) + contract + (merge {:to contract})))) + +(defmulti execute (fn [_] (if (offline-signing?) + :with-tx-signing + :no-tx-signing))) + + +(defmethod execute :with-tx-signing + [{:keys [from contract method-id gas-limit params internal-tx-id] + :as args}] + {:pre [(string? method-id)]} + (let [[type-kw issue-id] internal-tx-id + nonce (tracker/try-reserve-nonce!) gas-price (gas-price) - value (format "0x%x" 0) - params (cond-> {:data data - :from from - :value value} - gas-price - (merge {:gasPrice (integer->hex gas-price)}) - contract - (merge {:to contract})) - gas (or gas-limit (estimate-gas from contract value params)) - params (if (offline-signing?) - (get-signed-tx {:internal-tx-id internal-tx-id - :gas-price (biginteger gas-price) - :gas-limit (hex->big-integer gas) - :to contract - :data data}) - (assoc params :gas gas))] - (if (offline-signing?) - (eth-rpc - {:method "eth_sendRawTransaction" - :params [params] - :internal-tx-id internal-tx-id}) - (eth-rpc - {:method "personal_sendTransaction" - :params [params (eth-password)] - :internal-tx-id internal-tx-id})))) + params (construct-params (assoc args :gas-price gas-price)) + gas (or gas-limit (estimate-gas from contract (:value params) params)) + params (get-signed-tx (biginteger gas-price) + (hex->big-integer gas) + contract + (:data params) + nonce) + tx-hash (try + (eth-rpc + {:method "eth_sendRawTransaction" + :params [params] + :internal-tx-id internal-tx-id}) + (catch Throwable ex + (tracker/drop-nonce! nonce) + (throw ex)))] + {:tx-hash tx-hash + :issue-id issue-id + :type type-kw + :nonce nonce + :timestamp (t/now)})) + +(defmethod execute :no-tx-signing + [{:keys [from contract method-id gas-limit params internal-tx-id] + :as args}] + {:pre [(string? method-id)]} + (let [[type-kw issue-id] internal-tx-id + gas-price (gas-price) + params (construct-params (assoc args :gas-price gas-price)) + gas (or gas-limit (estimate-gas from contract (:value params) params)) + params (assoc params :gas gas) + tx-hash (eth-rpc + {:method "personal_sendTransaction" + :params [params (eth-password)] + :internal-tx-id internal-tx-id})] + {:tx-hash tx-hash + :type type-kw + :timestamp (t/now) + :issue-id issue-id} + )) + (defn hex-ch->num [ch] @@ -362,14 +314,3 @@ (filter true?) (empty?))) true)))) - -(mount/defstate - eth-core - :start - (do - (swap! creds-obj (constantly nil)) - (log/info "eth/core started")) - :stop - (log/info "eth/core stopped")) - - diff --git a/src/clj/commiteth/eth/multisig_wallet.clj b/src/clj/commiteth/eth/multisig_wallet.clj index 874c113..5b425a7 100644 --- a/src/clj/commiteth/eth/multisig_wallet.clj +++ b/src/clj/commiteth/eth/multisig_wallet.clj @@ -2,6 +2,7 @@ (:require [commiteth.eth.core :as eth] [commiteth.config :refer [env]] [clojure.tools.logging :as log] + [commiteth.eth.web3j :refer [web3j-obj creds-obj]] [taoensso.tufte :as tufte :refer (defnp p profiled profile)] [commiteth.eth.token-data :as token-data]) (:import [org.web3j @@ -41,7 +42,7 @@ `internal-tx-id` is used to identify what issue this multisig is deployed for and manage nonces at a later point in time." [{:keys [owner internal-tx-id]}] - {:pre [(string? owner) (string? internal-tx-id)]} + {:pre [(string? owner) (vector? internal-tx-id)]} (eth/execute {:internal-tx-id internal-tx-id :from (eth/eth-account) :contract (factory-contract-addr) @@ -87,7 +88,7 @@ (defn send-all [{:keys [contract payout-address internal-tx-id]}] - {:pre [(string? contract) (string? payout-address) (string? internal-tx-id)]} + {:pre [(string? contract) (string? payout-address) (vector? internal-tx-id)]} (log/debug "multisig/send-all " contract payout-address internal-tx-id) (let [params (eth/format-call-params (:withdraw-everything method-ids) @@ -106,11 +107,11 @@ (:address token-details))) (defn watch-token - [bounty-addr token] + [{:keys [bounty-addr token internal-tx-id]}] (log/debug "multisig/watch-token" bounty-addr token) (let [token-address (get-token-address token)] (assert token-address) - (eth/execute {:internal-tx-id (str "watch-token-" (System/currentTimeMillis) "-" bounty-addr) + (eth/execute {:internal-tx-id internal-tx-id :from (eth/eth-account) :contract bounty-addr :method-id (:watch method-ids) @@ -119,8 +120,8 @@ (defn load-bounty-contract [addr] (MultiSigTokenWallet/load addr - @eth/web3j-obj - (eth/creds) + @web3j-obj + @creds-obj (eth/gas-price) (BigInteger/valueOf 500000))) diff --git a/src/clj/commiteth/eth/token_registry.clj b/src/clj/commiteth/eth/token_registry.clj index c7aeec7..a80a0f0 100644 --- a/src/clj/commiteth/eth/token_registry.clj +++ b/src/clj/commiteth/eth/token_registry.clj @@ -1,6 +1,7 @@ (ns commiteth.eth.token-registry (:require [commiteth.eth.core :as eth] [commiteth.config :refer [env]] + [commiteth.eth.web3j :refer [web3j-obj creds-obj]] [clojure.tools.logging :as log]) (:import [org.web3j abi.datatypes.generated.Uint256 @@ -22,8 +23,8 @@ (defn- load-tokenreg-contract [addr] (TokenReg/load addr - @eth/web3j-obj - (eth/creds) + @web3j-obj + @creds-obj (eth/gas-price) (BigInteger/valueOf 21000))) @@ -58,8 +59,8 @@ (defn deploy-parity-tokenreg "Deploy an instance of parity token-registry to current network" [] - (TokenReg/deploy @eth/web3j-obj - (eth/creds) + (TokenReg/deploy @web3j-obj + @creds-obj (eth/gas-price) (BigInteger/valueOf 4000000) ;; gas limit BigInteger/ZERO)) diff --git a/src/clj/commiteth/eth/tracker.clj b/src/clj/commiteth/eth/tracker.clj new file mode 100644 index 0000000..f46270e --- /dev/null +++ b/src/clj/commiteth/eth/tracker.clj @@ -0,0 +1,99 @@ +(ns commiteth.eth.tracker + (:require [clojure.data.json :as json] + [org.httpkit.client :refer [post]] + [commiteth.db.issues :as issues] + [commiteth.config :refer [env]] + [commiteth.eth.web3j :refer [web3j-obj]] + [clojure.tools.logging :as log] + [clojure.string :as str] + [clj-time.core :as t]) + (:import [org.web3j + protocol.Web3j + protocol.http.HttpService + protocol.core.DefaultBlockParameterName])) + +(defn eth-rpc-url [] (env :eth-rpc-url "http://localhost:8545")) + +(defn get-nonce [] + (.. (.ethGetTransactionCount @web3j-obj + (env :eth-account) + DefaultBlockParameterName/PENDING) + sendAsync + get + getTransactionCount)) + +(defprotocol ITxTracker + (try-reserve-nonce [this] + "Fetch current nonce via eth_getTransactionCount + and either return it if it is not in use yet, + or throw an exception") + (drop-nonce [this nonce] + "If tx execution returned with an error, + release previously reserved nonce") + (track-tx [this tx-info] + "Record tx data after successful submission") + (untrack-tx [this tx-info] + "Mark tx as successfully mined") + (prune-txs [this txs] + "Release nonces related to txs param + and the ones that have expired (haven't been mined after a certain timeout)") + ) + +(defrecord SequentialTxTracker [current-tx] + ITxTracker + (try-reserve-nonce [this] + (let [nonce (get-nonce)] + (if (or (nil? @current-tx) + (> nonce (:nonce @current-tx))) + (:nonce (reset! current-tx {:nonce nonce})) + (throw (Exception. (str "Attempting to re-use old nonce" nonce)))))) + (drop-nonce [this nonce] + (reset! current-tx nil)) + (track-tx [this tx-info] + (reset! current-tx tx-info)) + (untrack-tx [this tx-info] + (when (= (:nonce tx-info) (:nonce @current-tx)) + (reset! current-tx nil))) + (prune-txs [this unmined-txs] + (when (or ((set (map :tx-hash unmined-txs)) (:tx-hash @current-tx)) + (and (:timestamp @current-tx) + (t/before? (:timestamp @current-tx) (t/minus (t/now) (t/minutes 10))))) + (log/errorf "Current nonce unmined for 10 minutes, force reset. Tx hash: %s, type: %s" + (:tx-hash @current-tx) (:type @current-tx)) + (reset! current-tx nil))) + + ) + +(def tx-tracker (SequentialTxTracker. (atom nil))) + +(defn try-reserve-nonce! [] + (try-reserve-nonce tx-tracker)) + +(defn drop-nonce! [nonce] + (drop-nonce tx-tracker nonce)) + +(defn track-tx! + "Store tx data in tx-tracker and DB" + [{:keys [issue-id tx-hash type] + :as tx-info}] + (track-tx tx-tracker tx-info) + (issues/save-tx-info! issue-id tx-hash type)) + +(defn untrack-tx! + "Mark tx data stored in tx-tracker and DB as successfully mined" + [{:keys [issue-id result type] + :as tx-info}] + (untrack-tx tx-tracker tx-info) + (issues/save-tx-result! issue-id result type)) + +(defn prune-txs! [unmined-txs] + "Release nonces related to unmined txs, + and set relevant DB fields to null thereby + marking them as candidates for re-execution" + (doseq [{issue-id :issue_id + tx-hash :tx_hash + type :type} unmined-txs] + (log/infof "issue %s: resetting tx operation: %s for hash: %s" issue-id type tx-hash) + (issues/save-tx-info! issue-id nil type)) + (prune-txs tx-tracker unmined-txs)) + diff --git a/src/clj/commiteth/eth/web3j.clj b/src/clj/commiteth/eth/web3j.clj new file mode 100644 index 0000000..975317c --- /dev/null +++ b/src/clj/commiteth/eth/web3j.clj @@ -0,0 +1,46 @@ +(ns commiteth.eth.web3j + (:require [commiteth.config :refer [env]] + [clojure.tools.logging :as log]) + (:import [org.web3j + protocol.Web3j + protocol.http.HttpService + protocol.core.methods.request.RawTransaction + utils.Numeric + crypto.TransactionEncoder + crypto.WalletUtils])) + +(defn eth-rpc-url [] (env :eth-rpc-url "http://localhost:8545")) + +(def web3j-obj + (delay (Web3j/build (HttpService. (eth-rpc-url))))) + +(defn wallet-file-path [] + (env :eth-wallet-file)) + +(defn wallet-password [] + (env :eth-password)) + +(def creds-obj + (delay + (let [password (wallet-password) + file-path (wallet-file-path)] + (if (and password file-path) + (WalletUtils/loadCredentials + password + file-path) + (throw (ex-info "Make sure you provided proper credentials in appropriate resources/config.edn" + {:password password :file-path file-path})))))) + +(defn get-signed-tx [gas-price gas-limit to data nonce] + "Create a sign a raw transaction. + 'From' argument is not needed as it's already + encoded in credentials. + See https://web3j.readthedocs.io/en/latest/transactions.html#offline-transaction-signing" + (log/infof "Signing TX: nonce: %s, gas-price: %s, gas-limit: %s, data: %s" + nonce gas-price gas-limit data) + (-> (RawTransaction/createTransaction nonce gas-price gas-limit to data) + (TransactionEncoder/signMessage @creds-obj) + (Numeric/toHexString))) + + + diff --git a/src/clj/commiteth/github/core.clj b/src/clj/commiteth/github/core.clj index a121461..ba0c89b 100644 --- a/src/clj/commiteth/github/core.clj +++ b/src/clj/commiteth/github/core.clj @@ -12,6 +12,7 @@ [clj-http.client :as http] [commiteth.config :refer [env]] [digest :refer [sha-256]] + [commiteth.db.issues :as db-issues] [clojure.tools.logging :as log] [cheshire.core :as json] [clojure.string :as str]) @@ -271,13 +272,6 @@ (learn-more-text)) eth-balance-str payee-login)) - -(defn post-deploying-comment - [owner repo issue-number tx-id] - (let [comment (generate-deploying-comment owner repo issue-number tx-id)] - (log/info "Posting comment to" (str owner "/" repo "/" issue-number) ":" comment) - (issues/create-comment owner repo issue-number comment (self-auth-params)))) - (defn make-patch-request [end-point positional query] (let [{:keys [auth oauth-token] :as query} query @@ -296,6 +290,24 @@ :otp))] (assoc req :body (json/generate-string (or raw-query proper-query))))) +(defn post-deploying-comment + [issue-id tx-id] + (let [{owner :owner + repo :repo + issue-number :issue_number + comment-id :comment_id} (db-issues/get-issue-by-id issue-id) + comment (generate-deploying-comment owner repo issue-number tx-id) ] + (log/info "Posting comment to" (str owner "/" repo "/" issue-number) ":" comment) + (if comment-id + (let [req (make-patch-request "repos/%s/%s/issues/comments/%s" + [owner repo comment-id] + (assoc (self-auth-params) :body comment))] + (tentacles/safe-parse (http/request req))) + (let [resp (issues/create-comment owner repo issue-number comment (self-auth-params))] + (db-issues/update-comment-id issue-id (:id resp)) + (log/infof "issue %s: post-deploying-comment response: %s" issue-id resp) + resp)))) + (defn update-comment "Update comment for an open bounty issue" [owner repo comment-id issue-number contract-address eth-balance eth-balance-str tokens] diff --git a/src/clj/commiteth/scheduler.clj b/src/clj/commiteth/scheduler.clj index 57aa61e..4329bd4 100644 --- a/src/clj/commiteth/scheduler.clj +++ b/src/clj/commiteth/scheduler.clj @@ -2,6 +2,7 @@ (:require [commiteth.eth.core :as eth] [commiteth.eth.multisig-wallet :as multisig] [commiteth.eth.token-data :as token-data] + [commiteth.eth.tracker :as tracker] [commiteth.github.core :as github] [commiteth.db.issues :as issues] [taoensso.tufte :as tufte :refer (defnp p profiled profile)] @@ -52,11 +53,14 @@ (when-let [receipt (eth/get-transaction-receipt transaction-hash)] (log/infof "issue %s: update-issue-contract-address: tx receipt: %s" issue-id receipt) (if-let [contract-address (multisig/find-created-multisig-address receipt)] - (let [issue (issues/update-contract-address issue-id contract-address) + (let [_ (tracker/untrack-tx! {:issue-id issue-id + :tx-hash transaction-hash + :result contract-address + :type :deploy}) {owner :owner repo :repo comment-id :comment_id - issue-number :issue_number} issue + issue-number :issue_number} (issues/get-issue-by-id issue-id) balance-eth-str (eth/get-balance-eth contract-address 6) balance-eth (read-string balance-eth-str)] (log/infof "issue %s: Updating comment image" issue-id) @@ -90,13 +94,10 @@ [] (p :deploy-pending-contracts (doseq [{issue-id :issue_id - issue-number :issue_number - owner :owner - owner-address :owner_address - repo :repo} (db-bounties/pending-contracts)] + owner-address :owner_address} (db-bounties/pending-contracts)] (log/infof "issue %s: Trying to re-deploy failed bounty contract deployment" issue-id) (try - (bounties/deploy-contract owner owner-address repo issue-id issue-number) + (bounties/deploy-contract owner-address issue-id) (catch Throwable t (log/errorf t "issue %s: deploy-pending-contracts exception: %s" issue-id (ex-data t))))))) @@ -106,15 +107,15 @@ (log/info "In self-sign-bounty") (p :self-sign-bounty (doseq [{contract-address :contract_address - issue-id :issue_id - payout-address :payout_address - repo :repo - owner :owner - comment-id :comment_id - issue-number :issue_number - balance-eth :balance_eth - tokens :tokens - winner-login :winner_login} (db-bounties/pending-bounties)] + issue-id :issue_id + payout-address :payout_address + repo :repo + owner :owner + comment-id :comment_id + issue-number :issue_number + balance-eth :balance_eth + tokens :tokens + winner-login :winner_login} (db-bounties/pending-bounties)] (try ;; TODO(martin) delete this shortly after org-dashboard deploy ;; as we're now setting `winner_login` when handling a new claims @@ -132,11 +133,11 @@ tokens winner-login true)) - (let [execute-hash (multisig/send-all {:contract contract-address - :payout-address payout-address - :internal-tx-id (str "payout-github-issue-" issue-id)})] - (log/infof "issue %s: Payout self-signed, called sign-all(%s) tx: %s" issue-id contract-address payout-address execute-hash) - (db-bounties/update-execute-hash issue-id execute-hash) + (let [tx-info (multisig/send-all {:contract contract-address + :payout-address payout-address + :internal-tx-id [:execute issue-id]})] + (log/infof "issue %s: Payout self-signed, called sign-all(%s) tx: %s" issue-id contract-address payout-address (:tx-hash tx-info)) + (tracker/track-tx! tx-info) (github/update-merged-issue-comment owner repo comment-id @@ -162,7 +163,10 @@ (log/infof "issue %s: execution receipt for issue " issue-id receipt) (when-let [confirm-hash (multisig/find-confirmation-tx-id receipt)] (log/infof "issue %s: confirm hash:" issue-id confirm-hash) - (db-bounties/update-confirm-hash issue-id confirm-hash))) + (tracker/untrack-tx! {:issue-id issue-id + :tx-hash execute-hash + :result confirm-hash + :type :execute}))) (catch Throwable ex (log/errorf ex "issue %s: update-confirm-hash exception:" issue-id))))) (log/info "Exit update-confirm-hash")) @@ -177,7 +181,10 @@ (log/infof "issue %s: pending watch call %s" issue-id watch-hash) (try (when-let [receipt (eth/get-transaction-receipt watch-hash)] - (db-bounties/update-watch-hash issue-id nil)) + (tracker/untrack-tx! {:issue-id issue-id + :tx-hash watch-hash + :result nil + :type :watch})) (catch Throwable ex (log/errorf ex "issue %s: update-watch-hash exception:" issue-id)))))) @@ -265,8 +272,10 @@ (when (and (nil? watch-hash) (not= balance internal-balance)) (log/infof "bounty %s: balances not in sync, calling watch" bounty-addr) - (let [hash (multisig/watch-token bounty-addr tla)] - (db-bounties/update-watch-hash issue-id hash))))))) + (let [tx-info (multisig/watch-token {:bounty-addr bounty-addr + :token tla + :internal-tx-id [:watch issue-id]})] + (tracker/track-tx! tx-info))))))) (catch Throwable ex (log/error ex "bounty %s: update-bounty-token-balances exception" bounty-addr)))) (log/info "Exit update-bounty-token-balances")) @@ -376,6 +385,13 @@ (log/error ex "issue %s: update-balances exception" issue-id))))) (log/info "Exit update-balances")) +(defn check-tx-receipts + "At all times, there should be no more than one unmined tx hash, + as we are executing txs sequentially" + [] + (log/info "In check-tx-receipts") + (tracker/prune-txs! (issues/unmined-txs)) + (log/info "Exit check-tx-receipts")) (defn wrap-in-try-catch [func] (try @@ -399,6 +415,7 @@ update-confirm-hash update-payout-receipt update-watch-hash + check-tx-receipts self-sign-bounty ]) (log/info "run-1-min-interval-tasks done"))) @@ -416,11 +433,11 @@ (mount/defstate scheduler :start (let [every-minute (rest - (periodic-seq (t/now) - (t/minutes 1))) + (periodic-seq (t/now) + (t/minutes 1))) every-10-minutes (rest - (periodic-seq (t/now) - (t/minutes 10))) + (periodic-seq (t/now) + (t/minutes 10))) error-handler (fn [e] (log/error "Scheduled task failed" e) (throw e))